我正在使用GCC为ARM开发C++。我遇到了一个问题,我没有启用优化,我无法为我的代码创建二进制(ELF),因为它不适合可用空间。然而,如果我只是为调试启用优化(-Og),这是我所知的最低优化,代码很容易适应。
在这两种情况下,-ffunction节、-fodata节-fno异常和-Wl、--gc节 这是二进制大小的巨大差异,即使进行了最小的优化。 我查看了3.11控制优化的选项,了解使用-Og标志执行哪些优化的详细信息,看看这是否能让我有所了解。 哪些优化标志对二进制大小的影响最大?有什么东西可以解释这种巨大的差异吗?
哪些GCC优化标志对二进制大小的影响最大?
它会根据程序本身有所不同。了解每个标志如何影响程序的最准确方法是尝试并将结果与基本水平进行比较。
尺寸优化的基本水平的一个好选择是使用-Os,它可以实现-O2的所有优化,除了那些预计会显著增加二进制尺寸的优化,它们(目前)是:
-falign-functions
-falign-jumps
-falign-labels
-falign-loops
-fprefetch-loop-arrays
-freorder-blocks-algorithm=stc
未优化构建的大部分额外代码大小是,默认的-O0
也意味着调试构建,即使使用GDBj
命令跳转到同一函数中的不同源行,也不会在语句之间的寄存器中保留任何内容以进行一致调试-O0
意味着与最轻级别的优化相比,需要大量的存储/重新加载,尤其是对于不能使用内存源操作数的非CISC ISA上的代码大小来说,这是灾难性的。为什么clang使用-O0(对于这个简单的浮点和)产生低效的asm?同样适用于GCC。
特别是对于现代C++来说,调试构建是灾难性的,因为在简单的情况下(或者可能是一条指令),通常内联和优化的简单模板包装函数,却编译为必须设置参数并运行调用指令的实际函数调用。例如,对于std::vector
,假设编译器在寄存器中具有.data()
指针,则operator[]
成员函数通常可以内联到单个ldr
指令。但如果没有内联,每个调用站点都接受多条指令1
对实际.text
部分中的代码大小影响最大的选项1:通常对分支目标进行对齐,或者只是循环,会花费一些代码大小。除此之外:
-
-ftree-vectorize
-使SIMD版本循环,如果编译器无法证明迭代次数将是矢量宽度的倍数,则也需要标量清理。(或者,如果不使用restrict
,则指向的数组是不重叠的;这可能还需要标量回退)。在GCC11及更早版本的-O3
启用。在GCC12及更高版本的-O2
上启用,如clang。 -
-funroll-loops
/-funroll-all-loops
-即使在现代GCC中的-O3
,默认情况下也不会启用。通过配置文件引导优化(-fprofile-use
)启用,当它有来自-fprofile-generate
构建的分析数据时,可以知道哪些循环实际上是热的,值得花费代码大小。与循环展开相关的是启发式(调整旋钮),用于控制循环剥离(完全展开)和展开量。设置这些值的正常方式是使用
-march=native
,这意味着-mtune=
也可以。与-mtune=sandybridge
或-mtune=haswell
相比,-mtune=znver3
可能倾向于大的展开因子(至少clang是这样)。但是有一些GCC选项可以手动调整单个内容,正如在关于GCC的评论中所讨论的:为简单循环生成的奇怪asm,以及在如何要求GCC完全展开这个循环(即剥离这个循环)
也有一些选项可以覆盖其他决策启发式方法(如内联)的权重和阈值,但除非您正在细化默认值,或为新CPU找到好的默认值,否则很少需要进行这么多微调。 -
-Os
-针对大小和速度进行优化,尽量不要牺牲太快。如果您的代码有很多I缓存未命中,那么这是一个很好的折衷,否则-O3
通常会更快,或者至少这是GCC的设计目标。值得尝试不同的选项,看看-O2
或-Os
是否使您的代码在某些您关心的CPU中比-O3
更快;有时遗漏的优化或某些微体系结构的怪癖会产生影响,比如"如果我优化的是大小而不是速度,为什么GCC生成的代码会快15-20%?"?它在相当多的不同x86和ARM CPU上,针对测试程序中的特定小循环,具有从GCC4.6到4.8(当时是当前的)的实际基准,无论是否使用-march=native
,都可以为它们进行实际调整没有任何理由期望它能代表其他代码,因此您需要测试自己的代码库。(对于任何给定的循环,小的代码更改都可以在任何给定的CPU上使不同的编译选项变得更好。)很明显,如果您需要更小的静态代码大小来适应某个大小限制,
-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
而不是乘性逆来除以常数,这会消耗大量的速度,但不会节省太多(如果有的话)。(https://godbolt.org/z/x9h4vx1YG对于x86-64)。对于ARM,如果不使用暗示udiv
可用的-mcpu=
,GCC-Os
仍然使用反转,否则使用udiv
:https://godbolt.org/z/f4sa9Wqcj。
Clang的-Os
仍然使用与umull
的乘法逆,仅使用udiv
与-Oz
。(或调用不带任何-mcpu
选项的__aeabi_uidiv
辅助函数)。因此,在这方面,clang -Os
比GCC做了更好的权衡,仍然需要花费一点代码大小来避免缓慢的整数除法。
脚注1:std::vector
是否内联
#include <vector>
int foo(std::vector<int> &v) {
return v[0] + v[1];
}
Godbolt使用默认-O0
的gcc
,而使用-mcpu=cortex-m7
的-Os
只是为了随机挑选一些东西。IDK,如果在实际的微控制器上使用像std::vector
这样的动态容器是正常的;可能不会。
# -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
与。调试构建(为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
...
正如您所看到的,未优化的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
在asm输出中省略了.cfi
指令,从而省略了进入.eh_frame
部分的元数据。此外,-fno-exceptions -fno-rtti
与C++配合使用可以减少元数据。(反射的运行时类型信息占用空间。)
控制节/ELF段对齐的链接器选项也可能占用额外的空间,与微小的可执行文件相关,但基本上是一个恒定的空间量,不会随着程序的大小而缩放。另请参阅链接后的最小可执行文件大小现在是2年前的10倍,适用于小程序?
快并不意味着小。事实上,速度优化的很大一部分都围绕着循环展开,这大大增加了代码生成。
如果要优化大小,请使用-Os
,它等效于-O2
,除了所有增加大小的优化(同样,如循环展开)。
Try-s-z nosepate代码(几个月前在stackoverflow上的某个地方发现,当时想知道为什么组装中的简单hello世界是几千字节而不是几个字节)
如果我记得正确的话,-s将删除未使用的符号,-z将代码从elf头中删除不需要的条目。。。(也可用于Gentoo:)