哪些 GCC 优化标志对二进制大小影响最大?

Pat*_*ght 29 c++ gcc arm code-size compiler-optimization

我正在使用 GCC 为 ARM 开发 C++。我遇到了一个问题,我没有启用优化,我无法为我的代码创建二进制文件(ELF),因为它不适合可用空间。但是,如果我只是启用调试优化(-Og)(据我所知这是可用的最低优化),代码就很容易适应。

在这两种情况下,都会启用-ffunction-sections-fdata-sections-fno-exceptions-Wl,--gc-sections 。

  • 闪存大小:512 kB
  • 没有优化:.text 溢出约 200 kB
  • 使用-Og优化:.text 约为 290 kB

即使进行了最小的优化,二进制大小也存在巨大差异。

我查看了3.11 控制优化的选项,详细了解使用 -Og 标志执行哪些优化,看看这是否会给我任何见解。

哪些优化标志对二进制大小影响最大?我应该寻找什么来解释这种巨大的差异吗?

eer*_*ika 26

哪些 GCC 优化标志对二进制大小影响最大?

根据程序本身的不同,它会有所不同。要了解每个标志如何影响您的程序,最准确的方法是尝试并将结果与​​基本级别进行比较。

大小优化的基本级别的一个不错选择是使用 -Os,它可以实现 -O2 的所有优化,除了那些预计可能显着增加二进制大小的优化(目前):

-falign-functions
-falign-jumps
-falign-labels
-falign-loops
-fprefetch-loop-arrays
-freorder-blocks-algorithm=stc
Run Code Online (Sandbox Code Playgroud)

  • @JakobStark:“-Oz”仅由 clang 支持。它“非常”激进,例如在 x86 上使用“push 1”;`pop rax`(总共 3 个字节)而不是 `mov eax, 1`(5 个字节)。GCC 和 clang 上的 `-Os` 仍然关心速度,尽管对于 GCC 来说,不幸的是它选择使用 `div` 而不是乘法逆元来除以常数,这会消耗大量速度,但不会节省太多(如果有的话)。https://godbolt.org/z/x9h4vx1YG。对于 ARM,如果您不使用意味着“udiv”甚至可用的“-mcpu=”,则使用 GCC“-Os”仍然使用逆,否则“udiv”:https://godbolt.org/z/f4sa9Wqcj (13认同)
  • 还有-Oz,根据手册,它对尺寸而不是速度进行了更积极的优化。 (8认同)
  • @JakobStark哦,酷。这在开发版本中似乎是新的,因为它不在 GCC 11(目前最新版本)中。 (2认同)

Pet*_*des 18

未优化构建的大部分额外代码大小是这样一个事实:默认值也意味着调试构建,即使您使用 GDB命令跳转到不同的源代码-O0行,也不会跨语句在寄存器中保留任何内容以实现一致的调试。j相同的功能。 -O0与最轻的优化相比,这意味着大量的存储/重新加载,对于无法使用内存源操作数的非 CISC ISA 上的代码大小来说尤其是灾难性的。 为什么 clang 使用 -O0 生成低效的 asm(对于这个简单的浮点和)?同样适用于 GCC。

特别是对于现代 C++,调试构建是灾难性的,因为简单的模板包装函数通常在简单情况下(或者可能是一条指令)内联并优化为无任何内容,而是编译为必须设置参数并运行调用指令的实际函数调用。例如,对于 a std::vectoroperator[]成员函数通常可以内联到单个ldr指令,假设编译器将.data()指针存储在寄存器中。但如果没有内联,每个调用站点都会接受多个指令1


对实际第1.text节中的代码大小影响最大的选项:一般分支目标的对齐,或只是循环,会花费一些代码大小。除此之外:

  • -ftree-vectorize- 使 SIMD 版本循环,如果编译器无法证明迭代计数将是向量宽度的倍数,则还需要进行标量清理。(或者,如果您不使用 ,则指向的数组是不重叠的restrict;这可能还需要标量回退)。在 GCC11 及更早版本中启用-O3。在 GCC12 及更高版本中启用-O2,如 clang。

  • -funroll-loops/-funroll-all-loops - 即使在现代 GCC 中也默认不启用-O3。通过配置文件引导优化 ( -fprofile-use) 启用,当它具有来自构建的分析数据时,-fprofile-generate可以了解哪些循环实际上很热门并且值得花费代码大小。(而且它们是冷的,因此应该针对大小进行优化,以便在它们运行时减少 I-cache 未命中,并减少其他代码的驱逐。) PGO 也会影响矢量化决策。

    与循环展开相关的是控制循环剥离(完全展开)以及展开程度的启发式(调节旋钮)设置这些的正常方法是 with -march=native表示-mtune=“whatever” 。 与或-mtune=znver3相比,可能有利于大的展开因素(至少 clang 如此)。但是有 GCC 选项可以手动调整个别内容,如gcc 的评论中所讨论的:为简单循环生成的奇怪的 asm如何要求 GCC 完全展开此循环(即,剥离此循环)? 也有一些选项可以覆盖其他决策启发式的权重和阈值,例如内联,但您很少需要微调那么多,除非您正在努力完善默认值,或者为新 CPU 找到良好的默认值。-mtune=sandybridge-mtune=haswell

  • -Os- 优化大小和速度,尽量不要牺牲太多速度。如果您的代码有很多 I-cache 未命中,这是一个很好的权衡,否则-O3通常会更快,或者至少这是 GCC 的设计目标。值得尝试不同的选项,看看您的代码是否比-O2您关心的某些 CPU 更快;有时,某些微架构的错过优化或怪癖会产生影响,例如如果我针对大小而不是速度进行优化,为什么 GCC 会生成速度快 15-20% 的代码?它具有从 GCC4.6 到 4.8(当时当前)的实际基准测试,针对测试程序中的特定小循环,在相当多不同的 x86 和 ARM CPU 上,无论是否进行实际调整。 但是,没有理由期望它能够代表其他代码,因此您需要针对自己的代码库进行测试。(对于任何给定的循环,小的代码更改可以使不同的编译选项在任何给定的 CPU 上更好。)-Os-O3-march=native

    -Os如果您需要较小的静态代码大小以适应某些大小限制,显然这是非常有用的。

  • -Oz仅针对大小进行优化,即使在速度上付出很大代价。GCC 最近才将其添加到当前主干中,因此预计会在 GCC12 或 13 中出现。大概我在下面写的关于 clang 的-Oz相当激进的实现也适用于 GCC,但我还没有测试它。

Clang 有类似的选项,包括-Os. 它还可以选择clang -Oz仅针对大小进行优化,而不关心速度。它非常激进,例如在 x86 上使用代码高尔夫技巧,例如 push 1; pop rax(总共 3 字节)而不是mov eax, 1(5 字节)。

不幸的是, GCC-Os选择使用div乘法逆元来除以常数,这会降低速度,但不会节省太多大小(如果有的话)。(对于 x86-64 ,https://godbolt.org/z/x9h4vx1YG)。-Os对于 ARM,如果您不使用-mcpu=暗示udiv甚至可用的a, GCC仍然使用逆,否则它使用udiv: https: //godbolt.org/z/f4sa9Wqcj

Clang-Os仍然使用乘法逆元 with umull,仅使用udivwith -Oz。(或者__aeabi_uidiv不带任何-mcpu选项调用辅助函数)。因此,在这方面,clang -Os它比 GCC 做出了更好的权衡,仍然花费一点代码大小来避免缓慢的整数除法。


脚注1:内联或不内联std::vector

#include <vector>
int foo(std::vector<int> &v) {
    return v[0] + v[1];
}
Run Code Online (Sandbox Code Playgroud)

Godboltgcc默认值-O0vs.-Os只是-mcpu=cortex-m7随机选择一些东西。std::vectorIDK 如果像在实际的微控制器上一样使用动态容器是正常的可能不会。

# -Os (same as -Og for this case, actually, omitting the frame pointer for this leaf function)
foo(std::vector<int, std::allocator<int> >&):
        ldr     r3, [r0]                @ load the _M_start member of the reference arg
        ldrd    r0, r3, [r3]            @ load a pair of words (v[0..1]) from there into r0 and r3
        add     r0, r0, r3              @ add them into the return-value register
        bx      lr
Run Code Online (Sandbox Code Playgroud)

与调试构建(为 asm 启用名称解析)

# GCC -O0 -mcpu=cortex-m7 -mthumb
foo(std::vector<int, std::allocator<int> >&):
        push    {r4, r7, lr}             @ non-leaf function requires saving LR (the return address) as well as some call-preserved registers
        sub     sp, sp, #12
        add     r7, sp, #0              @ Use r7 as a frame pointer.  -O0 defaults to -fno-omit-frame-pointer
        str     r0, [r7, #4]            @ spill the incoming register arg to the stack


        movs    r1, #0                  @ 2nd arg for operator[]
        ldr     r0, [r7, #4]            @ reload the pointer to the control block as the first arg
        bl      std::vector<int, std::allocator<int> >::operator[](unsigned int)
        mov     r3, r0                  @ useless copy, but hey we told GCC not to spend any time optimizing.
        ldr     r4, [r3]                @ deref the reference (pointer) it returned, into a call-preserved register that will survive across the next call


        movs    r1, #1                  @ arg for the v[1]  operator[]
        ldr     r0, [r7, #4]
        bl      std::vector<int, std::allocator<int> >::operator[](unsigned int)
        mov     r3, r0
        ldr     r3, [r3]                @ deref the returned reference

        add     r3, r3, r4              @ v[1] + v[0]
        mov     r0, r3                  @ and copy into the return value reg because GCC didn't bother to add into it directly

        adds    r7, r7, #12             @ tear down the stack frame
        mov     sp, r7
        pop     {r4, r7, pc}            @ and return by popping saved-LR into PC

@ and there's an actual implementation of the operator[] function
@ it's 15 instructions long.  
@ But only one instance of this is needed for each type your program uses (vector<int>, vector<char*>, vector<my_foo>, etc.)
@ so it doesn't add up as much as each call-site
std::vector<int, std::allocator<int> >::operator[](unsigned int):
        push    {r7}
        sub     sp, sp, #12
  ...
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,未经优化的 GCC 更关心快速编译时间,甚至比最简单的事情(例如避免mov reg,reg在计算一个表达式的代码中无用的指令)更关心。


脚注 1:元数据

如果您可以使用元数据编写整个 ELF 可执行文件,而不仅仅是需要刻录到闪存的​​ .text + .rodata + .data,那么-g调试信息当然对于文件大小非常重要,但基本上无关紧要,因为它不是与运行时所需的部分混合在一起,因此它只是位于磁盘上。

符号名称和调试信息可以用gcc -s或删除strip

堆栈展开信息是代码大小和元数据之间的有趣权衡。 -fno-omit-frame-pointer浪费额外的指令和寄存器作为帧指针,导致机器代码大小更大,但.eh_frame堆栈展开元数据更小。(strip默认情况下不考虑“调试”信息,即使对于非 C++ 的 C 程序,在非调试上下文中异常处理可能需要它。)

如何从 GCC/clang 汇编输出中消除“噪音”?提到如何让编译器省略其中的一些内容:-fno-asynchronous-unwind-tables省略.cfiasm 输出中的指令,从而省略进入该.eh_frame部分的元数据。另外-fno-exceptions -fno-rtti用C++可以减少元数据。(反射的运行时类型信息占用空间。)

控制节/ ELF 段对齐的链接器选项也可能占用额外的空间,与微小的可执行文件相关,但基本上是恒定的空间量,不随程序的大小而缩放。另请参阅对于小型程序,链接后最小可执行文件大小现在比 2 年前大 10 倍?