【问题标题】:Keil ARMCC int64 comparison for Cortex M3Cortex M3 的 Keil ARMCC int64 比较
【发布时间】:2021-03-07 20:35:07
【问题描述】:

我注意到 armcc 会生成这种代码来比较两个 int64 值:

0x080001B0 EA840006 EOR  r0,r4,r6
0x080001B4 EA850107 EOR  r1,r5,r7
0x080001B8 4308     ORRS r0,r0,r1
0x080001BA D101     BNE  0x080001C0

大致可以翻译为:

r0 = lower_word_1 ^ lower_word_2
r1 = higher_word_1 ^ higher_word_2
r0 = r1 | r0
jump if r0 is not zero

类似这样的东西,当比较 int64 (int r0,r1) 和积分常数(即 int,在 r3 中)时

0x08000674 4058  EORS  r0,r0,r3
0x08000676 4308  ORRS  r0,r0,r1
0x08000678 D116  BNE   0x080006A8

同样的想法,只是完全跳过比较更高的词,因为它只需要为零。

但我很感兴趣——为什么这么复杂?

这两种情况都可以通过比较较低和较高的词并在两者之后进行 BNE 来非常简单地完成:

对于两个int64,假设寄存器相同

CMP lower words
BNE
CMP higher words
BNE

对于整数常量的 int64:

CMP lower words
BNE
CBNZ if higher word is non-zero

这将采用相同数量的指令,每条指令的长度可能(也可能不是,取决于所使用的寄存器)为 2 个字节。

arm-none-eabi-gcc does something different 但也没有玩 EORS

那么为什么 armcc 会这样做呢?我看不到任何真正的好处;两个版本都需要相同数量的命令(每个命令都是宽的或短的,所以没有真正的利润)。

我能看到的唯一一点好处是更少的分支,这对闪存预取缓冲区有点有益。但由于没有缓存或分支预测,我并没有真正购买它。

所以我的理由是,这种模式只是遗留下来的,来自不存在 CBZ/CBNZ 的 ARM7 架构,混合 ARM 和 Thumb 指令并不是很容易。 我错过了什么吗?

附: Armcc 在每个优化级别都这样做,所以我认为它是某种“硬编码”部分

UPD:当然,有一个执行管道会随着每个分支的执行而被刷新,但是每个解决方案都需要至少一个条件分支来执行或不执行(取决于整数)比较),因此管道将以相同的概率无论如何被刷新。
所以我真的看不出最小化条件分支的意义。

此外,如果低位和高位词被显式比较并且整数不相等,则将更快地进行分支。

使用 IT-block 可以完全避免分支指令,但在 Cortex-M3 上它最多只能有 4 条指令,所以我将忽略这一点。

【问题讨论】:

  • 我无法立即找到 Cortex M3 的指令时序,但在世界上绝大多数 CPU 上,ALU 指令都比条件分支更快,无论是否有缓存或分支预测。而没有分支预测的处理器往往会发现采用一个分支非常昂贵。
  • 这是一个合法的原因,但是根据这个 - developer.arm.com/documentation/ddi0337/h/programmers-model/… - “如果分支没有被采用,条件分支将在一个周期内完成。”,所以不,不是那么简单:) 请注意在任何情况下,至少有一个条件分支可能会或可能不会被采用。
  • cmp / it / cmpeq / bne 应该可以工作,并使用 16 位指令(谓词 cmp 需要 IT)。它也不需要任何 tmp regs,只需要标志。很惊讶你的编译器不使用它。更新:我检查了 GCC 所做的:godbolt.org/z/4a8e1M 所以 Keil 的代码生成可能是一个错过的优化。除非有人能想出为什么更大的代码会更快的原因。
  • @PeterCordes 您可能会发现接受的答案很有趣;但是我可以从你的推理中看出一点。

标签: c assembly arm compiler-optimization keil


【解决方案1】:

生成代码的效率不计入机器码指令的数量。您还需要了解目标机器的内部结构(不仅是时钟/指令),还需要了解获取/解码/执行过程的工作原理。

Cortex M3 设备中的每条分支指令都会刷新流水线。必须再次馈送管道。如果你从 FLASH 内存运行(它很慢),等待状态也会显着减慢这个过程。编译器会尽量避免分支。

可以使用其他说明按照您的方式完成:

int foo(int64_t x, int64_t y)
{
    return x == y;
}

        cmp     r1, r3
        itte    eq
        cmpeq   r0, r2
        moveq   r0, #1
        movne   r0, #0
        bx      lr

相信你的编译器。写它们的人知道他们的行业:)。在进一步了解 ARM Cortex 之前,您不能像现在这样简单地判断编译器。

您示例中的代码非常优化且简单。 Keil 做得很好。

【讨论】:

  • 对,我忘了指令管道。然而,它真的对执行速度有那么大的影响吗?如果我没记错的话,它只是 3 条指令还是 smthn 关闭?无论如何-如果有比较-会有一个分支。如果它没有被占用,它不会刷新管道 - 如果它被占用,它会。如果它是两者中的第二个或唯一一个,有什么区别?
  • 好代码中没有分支。这是经验法则 - 尽量不要分支。
  • 我知道这一点。但我不是在寻找手动优化特定的东西,在可能的情况下,我对编译器在if( a == b ) {....} 这样的一般情况下进行推理感兴趣
  • @Amomum 原因是:让它高效而简单。两个 XOR 和一个 OR 非常简单且非常有效。你还要什么理由?现在你的问题是:为什么编译器不以未优化的方式对其进行优化?
  • 如果您的闪存有 7 个等待状态,则流水线重新填充 3 个周期可以操作到 28 个时钟
【解决方案2】:

正如所指出的,区别在于分支与不分支。如果您可以避免分支,则希望避免分支。

虽然 ARM 文档可能很有趣,但对于 x86 和全尺寸 ARM 以及系统在此处发挥作用的许多其他地方,可能会很有趣。 ARM 等高性能内核对系统实现很敏感。这些 cortex-m 内核用于对成本非常敏感的微控制器,因此虽然它们将 PIC 或 AVR 或 msp430 的 mips 提高到 mhz 和每美元 mips,但它们仍然对成本敏感。使用更新的技术或更高的成本,您开始看到在整个范围内以处理器速度运行的闪存(不必在有效时钟速度范围内的各个位置添加等待状态),但持续时间很长您以最慢的核心速度以核心速度的一半看到闪存的时间。然后随着您选择更高的核心速度而变得更糟。但是sram经常匹配核心。无论哪种方式,闪存都是部件成本的主要部分,它的数量和速度在一定程度上推动了部件价格。

取决于内核(来自 ARM 的任何东西),提取大小和结果对齐方式会有所不同,因此可以根据循环样式测试的对齐方式和需要多少次提取来扭曲/操纵基准测试(用许多皮质-ms)。 cortex-ms 通常是半字或全字提取,有些是芯片供应商的编译时间选项(因此您可能有两个具有相同内核的芯片,但性能会有所不同)。这也可以演示......只是不在这里......除非被推动,否则我现在在这个站点上做了太多次这个演示。但我们可以在本次测试中做到这一点。

我手边没有 cortex-m3,如果需要,我必须挖出一个并将其连接起来,但不需要手边的 cortex-m4,它也是 armv7-m。一个 NUCLEO-F411RE

测试夹具

.thumb_func
.globl HOP
HOP:
    bx r2

.balign 0x20

.thumb_func
.globl TEST0
TEST0:
    push {r4,r5}

    mov r4,#0
    mov r5,#0

    ldr r2,[r0]
t0:
    cmp r4,r5
    beq skip
skip:   
    subs r1,r1,#1
    bne t0
    
    ldr r3,[r0]
    subs r0,r2,r3

    pop {r4,r5}
    bx lr

systick 计时器通常适用于这些类型的测试,无需与调试器计时器混淆,它通常只是显示相同的内容,但需要更多的工作。这里绰绰有余。

这样调用,结果以十六进制打印出来

hexstring(TEST0(STK_CVR,0x10000));
hexstring(TEST0(STK_CVR,0x10000));

将 flash 代码复制到 ram 并在那里执行

hexstring(HOP(STK_CVR,0x10000,0x20000001));
hexstring(HOP(STK_CVR,0x10000,0x20000001));

现在 stm32 的闪存前面有这个缓存,这会影响像这些基于循环的基准测试以及针对这些部分的其他基准测试,有时您无法超越,最终会得到一个虚假的基准测试。但在这种情况下不是。

为了演示提取效果,您需要系统延迟提取,如果提取速度太快,您可能看不到提取效果。

0800002c <t0>:
 800002c:   42ac        cmp r4, r5
 800002e:   d1ff        bne.n   8000030 <skip>

08000030 <skip>:

00050001 <-- flash time
00050001 <-- flash time
00060004 <-- sram time
00060004 <-- sram time

0800002c <t0>:
 800002c:   42ac        cmp r4, r5
 800002e:   d0ff        beq.n   8000030 <skip>

08000030 <skip>:

00060001
00060001
00080000
00080000

0800002c <t0>:
 800002c:   42ac        cmp r4, r5
 800002e:   bf00        nop

08000030 <skip>:

00050001
00050001
00060000
00060000

所以我们可以看到,如果分支没有被采用,它与 nop 相同。就这个基于循环的测试而言。所以也许有一个分支预测器(通常是一个小缓存,可以记住最后 N 个分支及其目的地,并且可以提前一两个时钟开始预取)。我还没有深入研究它,实际上并不需要,因为我们已经看到由于必须采用分支而导致性能成本(尽管指令数量相同,但您建议的代码不相等,这是指令数量相同但性能不同)。

因此,删除循环并避免 stm32 缓存的最快方法是在 ram 中执行类似的操作

push {r4,r5}

mov r4,#0
mov r5,#0
cmp r4,r5

ldr r2,[r0]

instruction under test repeated many times

ldr r3,[r0]
subs r0,r2,r3

pop {r4,r5}
bx lr

被测试的指令是一个 bne 到下一个,一个 beq 到下一个或一个 nop

// 800002e: d1ff        bne.n   8000030 <skip>
00002001
// 800002e: d0ff        beq.n   8000030 <skip>
00004000
// 800002e: bf00        nop
00001001

我没有空间容纳 0x10000 指令,所以我使用了 0x1000,我们可以看到这两种分支类型都受到了影响,其中执行分支的成本更高。

请注意,基于循环的基准测试没有显示出这种差异,必须小心进行基准测试或判断结果。甚至我在这里展示的那些。

我可以花更多的时间来调整核心设置或系统设置,但根据经验,我认为这已经表明了不要让 cmp、bne、cbnz 替换 eor、orr、bne 的愿望。现在公平地说,你的另一个是 eor.w(thumb2 扩展),它比 thumb2 指令消耗更多的时钟,所以还有另一件事需要考虑(我也测量过)。

请记住,对于这些高性能内核,您需要对获取和获取对齐非常敏感,很容易做出糟糕的基准测试。并不是说 x86 的性能不高,而是为了让低效的核心运行更顺畅,它周围有很多东西来试图保持核心的供给,类似于运行半卡车和跑车,卡车可以高效一旦在高速公路上加速但在城市驾驶,即使保持速度限制,Yugo 也会比半卡车更快地穿过城镇(如果它没有抛锚的话)。在 x86 中很难看到获取效果、未对齐的传输等,但在 ARM 中有些容易,因此要获得最佳性能,您要避免容易吃循环。

编辑

请注意,我对 GCC 产生的结果过早下结论。必须更多地尝试制作等效的比较。我开始了

unsigned long long fun2 ( unsigned long long a)
{
    if(a==0) return(1);
    return(0);
}
unsigned long long fun3 ( unsigned long long a)
{
    if(a!=0) return(1);
    return(0);
}
00000028 <fun2>:
  28:   460b        mov r3, r1
  2a:   2100        movs    r1, #0
  2c:   4303        orrs    r3, r0
  2e:   bf0c        ite eq
  30:   2001        moveq   r0, #1
  32:   4608        movne   r0, r1
  34:   4770        bx  lr
  36:   bf00        nop

00000038 <fun3>:
  38:   460b        mov r3, r1
  3a:   2100        movs    r1, #0
  3c:   4303        orrs    r3, r0
  3e:   bf14        ite ne
  40:   2001        movne   r0, #1
  42:   4608        moveq   r0, r1
  44:   4770        bx  lr
  46:   bf00        nop

其中使用了 it 指令,这是一个自然的解决方案,因为 if-then-else 案例可以是单个指令。有趣的是,他们选择使用 r1 而不是立即数 #0 我想知道这是否是一种通用优化,因为在固定长度指令集上立即数很复杂,或者可能立即数在某些架构上占用的空间更少。谁知道呢。

 800002e:   bf0c        ite eq
 8000030:   bf00        nopeq
 8000032:   bf00        nopne
00003002 
00003002 

 800002e:   bf14        ite ne
 8000030:   bf00        nopne
 8000032:   bf00        nopeq
00003002 
00003002 

线性使用 sram 0x1000 组三个指令,所以 0x3002 表示平均每条指令 1 个时钟。

在 it 块中放置一个 mov 不会改变性能

ite eq
moveq   r0, #1
movne   r0, r1

仍然是一个时钟。

void more_fun ( unsigned int );
unsigned long long fun4 ( unsigned long long a)
{
    for(;a!=0;a--)
    {
        more_fun(5);
    }
    return(0);
}
  48:   b538        push    {r3, r4, r5, lr}
  4a:   ea50 0301   orrs.w  r3, r0, r1
  4e:   d00a        beq.n   66 <fun4+0x1e>
  50:   4604        mov r4, r0
  52:   460d        mov r5, r1
  54:   2005        movs    r0, #5
  56:   f7ff fffe   bl  0 <more_fun>
  5a:   3c01        subs    r4, #1
  5c:   f165 0500   sbc.w   r5, r5, #0
  60:   ea54 0305   orrs.w  r3, r4, r5
  64:   d1f6        bne.n   54 <fun4+0xc>
  66:   2000        movs    r0, #0
  68:   2100        movs    r1, #0
  6a:   bd38        pop {r3, r4, r5, pc}

这基本上是与零的比较

  60:   ea54 0305   orrs.w  r3, r4, r5
  64:   d1f6        bne.n   54 <fun4+0xc>

反对另一个

void more_fun ( unsigned int );
unsigned long long fun4 ( unsigned long long a, unsigned long long b)
{
    for(;a!=b;a--)
    {
        more_fun(5);
    }
    return(0);
}

00000048 <fun4>:
  48:   4299        cmp r1, r3
  4a:   bf08        it  eq
  4c:   4290        cmpeq   r0, r2
  4e:   d011        beq.n   74 <fun4+0x2c>
  50:   b5f8        push    {r3, r4, r5, r6, r7, lr}
  52:   4604        mov r4, r0
  54:   460d        mov r5, r1
  56:   4617        mov r7, r2
  58:   461e        mov r6, r3
  5a:   2005        movs    r0, #5
  5c:   f7ff fffe   bl  0 <more_fun>
  60:   3c01        subs    r4, #1
  62:   f165 0500   sbc.w   r5, r5, #0
  66:   42ae        cmp r6, r5
  68:   bf08        it  eq
  6a:   42a7        cmpeq   r7, r4
  6c:   d1f5        bne.n   5a <fun4+0x12>
  6e:   2000        movs    r0, #0
  70:   2100        movs    r1, #0
  72:   bdf8        pop {r3, r4, r5, r6, r7, pc}
  74:   2000        movs    r0, #0
  76:   2100        movs    r1, #0
  78:   4770        bx  lr
  7a:   bf00        nop

他们选择在这里使用 it 块。

  66:   42ae        cmp r6, r5
  68:   bf08        it  eq
  6a:   42a7        cmpeq   r7, r4
  6c:   d1f5        bne.n   5a <fun4+0x12>

指令数量与此相当。

0x080001B0 EA840006 EOR  r0,r4,r6
0x080001B4 EA850107 EOR  r1,r5,r7
0x080001B8 4308     ORRS r0,r0,r1
0x080001BA D101     BNE  0x080001C0

但是那些 thumb2 指令会执行更长的时间。所以总的来说,我认为 GCC 似乎已经做了一个更好的序列,但当然你想检查一个苹果到一个苹果是否从相同的 C 代码开始,看看每个产生了什么。 gcc 比 eor/orr 更容易阅读,可以少考虑它在做什么。

 8000040:   406c        eors    r4, r5
00001002
 8000042:   ea94 0305   eors.w  r3, r4, r5
00002001

0x1000 指令一个是两个半字 (thumb2) 一个是一个半字 (thumb)。需要两个时钟并不感到惊讶。

0x080001B0 EA840006 EOR  r0,r4,r6
0x080001B4 EA850107 EOR  r1,r5,r7
0x080001B8 4308     ORRS r0,r0,r1
0x080001BA D101     BNE  0x080001C0

在添加任何其他惩罚之前,我看到了六个时钟,而不是四个(在这个 cortex-m4 上)。

请注意,我使 eors.w 对齐和未对齐,它并没有改变性能。还有两个时钟。

【讨论】:

  • 所以基本上你是说不管文档怎么说,不采用分支比 nop 指令(或像 xor/or 这样的一点操作)花费更多的周期,因此避免分支仍然是有意义的(请纠正我如果我错了)?
  • 您的基准测试技巧也给我留下了深刻的印象,非常感谢您!
  • 很公平。实际上,Keil 使用了一些有关架构的内部知识,而 gcc 没有,这实际上是有道理的。我自己的解释是“历史性的”,但您的数据更适合:) 再次感谢!
  • 这是我如何制作 C 代码并将其留在第一个实验中的一个案例,进一步的实验生成了与 Keils 编译器竞争的解决方案,我认为这是一个更好的解决方案,但确实需要成为苹果苹果从相同的 C 代码开始,我没有 Keil 工具,也无法访问 ARM 的工具,所以我所能做的就是比较 clang 和 gcc(除非 Godbolt 有它们)。
  • 这不是 Keil 现在被 arm 拥有的情况,也许他们的旧工具被替换了对内核的了解更多,这是一个碰巧有人坐下来寻找这种优化的情况并实施。我已经看到 arm 工具被其他非 arm(非 gnu)工具击败。在所有情况下,重要的是它对“您的”代码(而不是基准,而是您的应用程序)的表现有多好,这是衡量这些事情的真正标尺。
猜你喜欢
  • 2015-07-05
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-05-28
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多