Cortex M3 的 Keil ARMCC int64 比较

Keil ARMCC int64 comparison for Cortex M3

我注意到 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 这样做呢?我看不到任何真正的好处;两个版本都需要相同数量的命令(每个命令或宽或短,因此没有真正的利润)。

我能看到的唯一微小的好处是更少的分支,这对闪存预取缓冲区有点有益。但是因为没有缓存或者分支预测,我真的不买账。

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

P.S。 Armcc 在每个优化级别上都这样做,所以我认为它是某种 'hard-coded' 片段

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

此外,如果显式比较低位和高位单词并且整数不相等,则分支会更快。

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

生成代码的效率不计入机器码指令数。您还需要了解目标机器的内部结构(不仅是 clock/instruction),还需要了解 fetch/decode/execute 进程的工作原理。

Cortex M3 设备中的每个分支指令都会刷新管道。管道必须再次进料。如果您 运行 从闪存(它很慢)等待状态也会显着减慢这个过程。编译器尽量避免分支。

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

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 做得很好。

正如所指出的,区别在于分支与不分支。如果你能避免分支,你就想避免分支。

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

根据核心(来自 ARM 的任何东西),提取大小和结果对齐会有所不同,因此基准可以 skewed/manipulated 基于循环样式测试的对齐以及需要多少次提取(用许多 cortex-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 不是高性能,而是为了让低效的核心 运行 更顺畅,它周围有很多东西试图保持核心的供电,类似于 运行 宁一辆半卡车 vs一辆跑车,一旦在高速公路上加速行驶,卡车就可以高效,但在城市驾驶中,即使保持限速,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 我想知道这是否是一种通用优化,因为固定长度指令集上的立即数很复杂,或者在某些架构上立即数可能更少 space。谁知道呢

 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 对齐和未对齐,这并没有改变性能。还有两个时钟。