【问题标题】:ARM-GCC not optimizing array access in structs?ARM-GCC 没有优化结构中的数组访问?
【发布时间】:2021-04-21 20:49:24
【问题描述】:

我有以下最小示例代码:

typedef struct {
    unsigned long a[16];
    unsigned long b[16];
} myStruct_t;

myStruct_t myStruct;

unsigned long getA(unsigned int index) 
{    
    return myStruct.a[index];
}

unsigned long getB(unsigned int index) 
{    
    return myStruct.b[index];
}

编译器资源管理器:https://godbolt.org/z/d6WavW

这是使用 ARM-GCC 8.2 和 -O2 编译的(O1 和 O3 产生相同的 asm 代码,其他 GCC 版本似乎也产生类似的结果)

有趣的是,GCC 在运行时计算 b 的偏移量。由于 b 的地址是恒定的,我希望它会被预先计算,从而导致函数 getA 和 getB 的代码大小相同

getA:
        ldr     r3, .L3
        ldr     r0, [r3, r0, lsl #2]
        bx      lr
.L3:
        .word   myStruct
getB:
        ldr     r3, .L6
        add     r0, r0, #16
        ldr     r0, [r3, r0, lsl #2]
        bx      lr
.L6:
        .word   myStruct

为什么 GCC 忽略了这个优化?

最佳编码实践是什么?当速度很重要时,不要将数组放在结构中?

【问题讨论】:

  • 这个不限ARM也不限cortex-m
  • 如果速度很重要,不要把它放在初学者的函数中,然后再讨论代码生成。同样,当您可以拥有两个单独的全局数组时,为什么要将它们放在一个结构中。当然,它真的会影响你的表现吗?
  • clang 做同样的事情

标签: c gcc arm


【解决方案1】:

这只是一个错过的优化,可能与编译器的非目标特定区域的优化有关。这不仅限于 cortex-m(存在于全尺寸 ARM 和 risc-v 中,毫无疑问还有其他(固定长度的指令集))

00000000 <getA>:
   0:   00251793            slli    x15,x10,0x2
   4:   00000537            lui x10,0x0
   8:   00050513            addi    x10,x10,0 # 0 <getA>
   c:   953e                    c.add   x10,x15
   e:   4108                    c.lw    x10,0(x10)
  10:   8082                    c.jr    x1

00000012 <getB>:
  12:   0541                    c.addi  x10,16
  14:   00251793            slli    x15,x10,0x2
  18:   00000537            lui x10,0x0
  1c:   00050513            addi    x10,x10,0 # 0 <getA>
  20:   953e                    c.add   x10,x15
  22:   4108                    c.lw    x10,0(x10)
  24:   8082                    c.jr    x1

是的 gcc 和 clang 做同样的事情(我在这里使用 gcc 10.x)

getA:
    ldr r3, .L3
    ldr r0, [r3, r0, lsl #2]
    bx  lr
.L4:
    .align  2
.L3:
    .word   .LANCHOR0
    .size   getA, .-getA

getB:
    ldr r3, .L6
    add r0, r0, #16
    ldr r0, [r3, r0, lsl #2]
    bx  lr
.L7:
    .align  2
.L6:
    .word   .LANCHOR0

所以这里有两件事很突出。现在假定这些功能是由它们自己构建和优化的。因此,如果结构的基地址是链接器正在填写的内容,并且在这种情况下,两者都使用相同的地址,并且它们足够小,以至于两个函数都可以访问相同的池数据,但这对于工具来说是有风险的由于传递次数的原因进行这样的优化,而且gcc至少会生成汇编语言并且不知道最终函数会有多大,所以不能简单地假设两者都可以达到。

对于 x86,它是可变指令长度,因此它可以将调整后的偏移量嵌入到指令中:

00000000004000f0 <getA>:
  4000f0:   89 ff                   mov    %edi,%edi
  4000f2:   48 8b 04 fd 60 01 60    mov    0x600160(,%rdi,8),%rax
  4000f9:   00 
  4000fa:   c3                      retq   
  4000fb:   0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

0000000000400100 <getB>:
  400100:   89 ff                   mov    %edi,%edi
  400102:   48 8b 04 fd e0 01 60    mov    0x6001e0(,%rdi,8),%rax
  400109:   00 
  40010a:   c3 

(根据定义,long 的大小允许从一个编译器/目标到另一个编译器/目标有所不同)

回到手臂和气,我们可以试试这个:

getB:
    ldr r3, .L6
    ldr r0, [r3, r0, lsl #2]
    bx  lr
.L7:
    .align  2
.L6:
    .word   .LANCHOR0+16

给予

00008000 <getA>:
    8000:   e59f3004    ldr r3, [pc, #4]    ; 800c <getA+0xc>
    8004:   e7930100    ldr r0, [r3, r0, lsl #2]
    8008:   e12fff1e    bx  lr
    800c:   00001000    .word   0x00001000

00008010 <getB>:
    8010:   e59f3004    ldr r3, [pc, #4]    ; 801c <getB+0xc>
    8014:   e7930100    ldr r0, [r3, r0, lsl #2]
    8018:   e12fff1e    bx  lr
    801c:   00001010    .word   0x00001010

所以该指令可以被删除是的。

这只是一个错过的优化。原因可能比您想象的要深,可能是(编译器)代码通常编写为基于结构的基地址执行所有操作。如果您想生成与位置无关的代码,这很有用。

getA:
    ldr r3, .L3
    ldr r2, .L3+4
.LPIC0:
    add r3, pc, r3
    ldr r3, [r3, r2]
    ldr r0, [r3, r0, lsl #2]
    bx  lr
.L4:
    .align  2
.L3:
    .word   _GLOBAL_OFFSET_TABLE_-(.LPIC0+8)
    .word   myStruct(GOT)

getB:
    ldr r3, .L6
    ldr r2, .L6+4
.LPIC1:
    add r3, pc, r3
    ldr r3, [r3, r2]
    add r0, r0, #16
    ldr r0, [r3, r0, lsl #2]
    bx  lr
.L7:
    .align  2
.L6:
    .word   _GLOBAL_OFFSET_TABLE_-(.LPIC1+8)
    .word   myStruct(GOT)

所以现在你不仅会得到二进制文件中每个函数的池值,而且你还需要为结构中每个可能的偏移量提供 GOT 值 代码已生成。

00008000 <getA>:
    8000:   e59f3010    ldr r3, [pc, #16]   ; 8018 <getA+0x18>
    8004:   e59f2010    ldr r2, [pc, #16]   ; 801c <getA+0x1c>
    8008:   e08f3003    add r3, pc, r3
    800c:   e7933002    ldr r3, [r3, r2]
    8010:   e7930100    ldr r0, [r3, r0, lsl #2]
    8014:   e12fff1e    bx  lr
    8018:   00010034    .word   0x00010034
    801c:   0000000c    .word   0x0000000c

00008020 <getB>:
    8020:   e59f3014    ldr r3, [pc, #20]   ; 803c <getB+0x1c>
    8024:   e59f2014    ldr r2, [pc, #20]   ; 8040 <getB+0x20>
    8028:   e08f3003    add r3, pc, r3
    802c:   e7933002    ldr r3, [r3, r2]
    8030:   e2800010    add r0, r0, #16
    8034:   e7930100    ldr r0, [r3, r0, lsl #2]
    8038:   e12fff1e    bx  lr
    803c:   00010014    .word   0x00010014
    8040:   0000000c    .word   0x0000000c

所以你没有一个干净的地方可以在编译时添加 16,你必须在 GOT 中添加另一个条目,这是一个已经存在的条目加上一个偏移量。就二进制大小而言,这很快就会变得非常糟糕。

用 x86 看 PIC

0000000000400120 <getA>:
  400120:   48 8d 05 d9 0e c0 ff    lea    -0x3ff127(%rip),%rax        # 1000 <myStruct>
  400127:   89 ff                   mov    %edi,%edi
  400129:   48 8b 04 f8             mov    (%rax,%rdi,8),%rax
  40012d:   c3                      retq   
  40012e:   66 90                   xchg   %ax,%ax

0000000000400130 <getB>:
  400130:   48 8d 05 c9 0e c0 ff    lea    -0x3ff137(%rip),%rax        # 1000 <myStruct>
  400137:   89 ff                   mov    %edi,%edi
  400139:   48 8b 84 f8 80 00 00    mov    0x80(%rax,%rdi,8),%rax
  400140:   00 
  400141:   c3                      retq 

使用这样的可变长度指令集,您可以看到它们不需要获取 GOT 的基地址,然后获取偏移量并添加它。

还要记住,这些编译器是由人类编写的,并且所有提到的编译器都被很多人使用。还要记住,其中一些工具具有比其他工具更流行或某些指令集,这些指令集现在或过去可能占主导地位(并推动了该工具的大部分工作)。以 x86 为例,它具有可变长度特性,并且能够像伪代码一样采用通用 icode

get struct base address
add 16
read from that address

并将其整合到一条指令中,而不是三条或四条指令。所以有多少人在抱怨,特别是总体上。考虑一下添加这些优化与增加的价值相比会有多复杂以及风险有多大。例如,大部分代码可能是由 x86 人员生成的,而 ARM 人员只是简单地使后端功能化。

要考虑的另一件事是,很容易在编译的代码中找到似乎遗漏了优化的东西。有些人幻想编译器比人类更好,并且您永远不需要学习汇编语言(阅读或编写)。虽然很少需要编写它,或者您可能想采用已编译的代码并进行手动优化的情况很少,但您还需要考虑它是否真的,真的,影响我的性能?或者我现在是否在我用 C 编写的项目中添加了一些外国编程语言,例如只是为了优化一条指令,这会增加项目的可读性和维护成本?在您采取优化路径之前,您可能会问一个更大的问题,即为什么要创建这些函数并消耗更多的指令和性能,而不是手动内联代码,或者做一些事情来鼓励编译器内联。

如果您进行一个正常大小的项目,您可能会发现一些“遗漏的优化”。如果你和我自己和其他人一样花时间盯着这些东西,你也会看到像 gcc 这样的事情在这方面变得更糟,而不是随着时间的推移变得更好,最后的少数主要版本往往会根据某种定义制作更大/更差的代码更糟糕的是(对于手臂,不一定对于其他目标)。除了这样的事情之外,通常在编译器输出中经常发生这种情况。这是意料之中的事,很少会出人意料。

您还可能会发现,在 ARM 后端为 gcc 和 clang 工作的人可能与在 ARM 或 Kiel 在他们的工具等方面工作的人相同,因此您可能会看到相同的习惯或限制。

我认为在这种情况下,是的,这是一个错过的优化,这里不涉及链接器,编译器可以删除该指令并将其添加到池值中。同时,如果您考虑编写编译器需要什么,然后添加优化,并保留一定程度的可靠性,特别是编译器作者的调试能力,编译器可能是最好的根据结构的基地址进行尽可能多的操作。我永远不会知道,但有人很可能已经看到并尝试过,然后 PIC 案例爆炸了二进制文件的大小(或功能严重损坏),他们就放弃了......

很好,请留意更多,因为这些事情在编译输出中经常发生。

编辑

另外请注意,如果您在整个函数中对结构进行多次访问,那么您的测试函数有点过于简单,那么在每个项目的池地址和一条指令之间需要进行权衡。如果是拇指,那么该指令对于某些偏移量可能会更便宜,或者相同。

在我使用全尺寸的 arm 指令时,我们遇到了结构中第 16 个长字的问题,在这种情况下,长字为 4 个字节,因此作为参数传入的偏移量乘以 4,所以尝试了这个:

typedef struct {
    unsigned char a[16];
    unsigned char b[16];
} myStruct_t;

myStruct_t myStruct;

unsigned char getA(unsigned int index) 
{    
    return myStruct.a[index];
}

unsigned char getB(unsigned int index) 
{    
    return myStruct.b[index];
}

00000000 <getA>:
   0:   e59f3004    ldr r3, [pc, #4]    ; c <getA+0xc>
   4:   e7d30000    ldrb    r0, [r3, r0]
   8:   e12fff1e    bx  lr
   c:   00000000    .word   0x00000000

00000010 <getB>:
  10:   e59f3008    ldr r3, [pc, #8]    ; 20 <getB+0x10>
  14:   e0833000    add r3, r3, r0
  18:   e5d30010    ldrb    r0, [r3, #16]
  1c:   e12fff1e    bx  lr
  20:   00000000    .word   0x00000000

所以这里再次在所选指令集中没有可用的指令来执行所有这些操作。 +16 移到负载上,但要到达那里,需要添加基础加偏移量。但是

unsigned char getB(unsigned int index) 
{    
    return (myStruct.b[index]+myStruct.b[3]);
}


00000010 <getB>:
  10:   e59f3014    ldr r3, [pc, #20]   ; 2c <getB+0x1c>
  14:   e5d32013    ldrb    r2, [r3, #19]
  18:   e0833000    add r3, r3, r0
  1c:   e5d30010    ldrb    r0, [r3, #16]
  20:   e0800002    add r0, r0, r2
  24:   e20000ff    and r0, r0, #255    ; 0xff
  28:   e12fff1e    bx  lr
  2c:   00000000    .word   0x00000000

在这种情况下没有额外的指令,在这种情况下

unsigned char getB(unsigned int index) 
{    
    return (myStruct.b[index]+myStruct.b[index+1]);
}

00000010 <getB>:
  10:   e59f3014    ldr r3, [pc, #20]   ; 2c <getB+0x1c>
  14:   e0830000    add r0, r3, r0
  18:   e5d03011    ldrb    r3, [r0, #17]
  1c:   e5d00010    ldrb    r0, [r0, #16]
  20:   e0830000    add r0, r3, r0
  24:   e20000ff    and r0, r0, #255    ; 0xff
  28:   e12fff1e    bx  lr
  2c:   00000000    .word   0x00000000

如果添加了 16 位,我想这仍然可以工作,但这种情况:

unsigned long getB(unsigned int index) 
{    
    return (myStruct.b[index]+myStruct.b[index+1]);
}

00000010 <getB>:
  10:   e59f3014    ldr r3, [pc, #20]   ; 2c <getB+0x1c>
  14:   e2802011    add r2, r0, #17
  18:   e2800010    add r0, r0, #16
  1c:   e7932102    ldr r2, [r3, r2, lsl #2]
  20:   e7930100    ldr r0, [r3, r0, lsl #2]
  24:   e0820000    add r0, r2, r0
  28:   e12fff1e    bx  lr
  2c:   00000000    .word   0x00000000

这演示了对结构和池数据的多次访问,这将保存一条指令,但不会保存两条……您需要两个池项,一个是 16,一个是添加 16

ldr r3, [pc, #20]
add r2,r0,#1
ldr r2, [r3, r2, lsl #2]
ldr r0, [r3, r0, lsl #2]

或类似的东西......

这种错过的优化虽然是真实的,但实际上是一个更小的极端情况,人们不得不质疑是否值得为编译器添加大量代码和风险?或者让通用机制做它的事情,当你有很多访问权限或图片或两者兼而有之时,通用解决方案就可以工作。一些指令集具有更“丰富”的指令,允许使用更少但更大的指令。

使用结构而不是两个独立的数组开始,使用这些访问函数,如果单个指令很重要,那么这两个决定首先对性能来说是不明智的。同样,这种差异在最终应用程序中实际上是可以测量的,还是只是看起来令人反感。当您遇到严重到需要以这种方式解决的实际性能问题时,最好从更高效的高级代码开始,然后获取编译器的输出(为什么要做所有的工作),然后手动用汇编语言调整它。并继续评估差异是否实际上是可测量的和相关的,以及是否值得进行这种手动调整代码的工作和风险。大多数时候性能是可以忍受的。首先使用高级语言意味着您会接受性能损失,因此需要管理您的敏感度和容忍度。

所以新的底线并没有太大的不同:

是的,这是一个错过的优化,但这是一个极端情况,不值得编译器和用户 IMO 付出努力或冒险。这个标签上的一个/一些常客和其他人会指出这一点(小功能并不总是能很好地优化,也不预期)

【讨论】:

  • 缺少类似于 mov rax, QWORD PTR [rdi+128+rsi*8] 的指令 - 然后代码生成器发出 2 条指令。
【解决方案2】:

不是错过优化。问题是 CORTEX-M 不能在一条指令中加载立即 32 位地址。所以地址必须存储在内存中。在我们的例子中,我们的结构的地址只被存储。必须在运行时计算成员的偏移量。

否则,编译器将不得不将程序中任何成员的地址存储在FLASH内存中。

但另一方面,结构的地址被存储多次。这是因为链接器实际上会将数据放置到内存中,而编译器不知道另一个定义与代码的距离有多远,并且偏移量的范围非常有限。

当然,通过更好的链接器/编译器合作可以改进很多事情。

【讨论】:

  • btw 同样的事情发生在 x64 上,除了偏移量计算是 mov 指令的一部分:godbolt.org/z/hK8sxx
  • @bolov 但它是由编译器生成的
  • 有道理。但是为什么 struct 的地址被存储了两次,而不是 struct+0 和 struct +16 呢?
  • 这可能是因为它们对所有平台使用相同的优化逻辑。
  • 不限于cortex-m(拇指)。 (与FLASH无关)
猜你喜欢
  • 2019-05-18
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-12-10
  • 2010-11-28
  • 2010-09-12
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多