为什么编译器在这里坚持使用被调用者保存寄存器?

22

考虑以下 C 代码:

void foo(void);

long bar(long x) {
    foo();
    return x;
}

当我使用 -O3-Os 在 GCC 9.3 上进行编译时,我会得到以下这个结果:

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret

从clang的输出来看,除了选择rbx作为被调用者保存的寄存器之外,它与原代码完全一致。

然而,我希望/期望看到的汇编代码更像这样:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

既然您必须将某些东西推送到堆栈中,那么只需将值推送到堆栈中,而不是将某个任意的被调用者保存的寄存器的值推送到堆栈中,然后再将您的值存储在该寄存器中,这似乎更短、更简单,而且可能更快。同样,在执行call foo之后将要放回的内容时也是如此。

我的汇编有问题吗?它是否比使用额外寄存器更有效?如果对这两个问题的答案都是“否”,那么为什么GCC或clang没有这样做呢?

Godbolt链接


编辑:以下是一个更少见的例子,以展示即使变量被有意义地使用,也会发生这种情况:

long foo(long);

long bar(long x) {
    return foo(x * x) - x;
}

我得到了这个:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret

我更喜欢这个:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret
这次只有一条指令与两条指令的差别,但核心概念是相同的。

Godbolt链接


7
有趣的优化被错过了。 - fuz
1
最有可能的假设是传递的参数将被使用,因此您想保存易失性寄存器并将传递的参数保留在寄存器中,而不是堆栈上,因为对该参数的后续访问从寄存器更快。将x传递给foo,您将看到这一点。因此,这很可能只是他们的堆栈帧设置的通用部分。 - old_timer
1
好的,我假设你知道我的意思,编译器为函数入口和出口提供了存栈代码,建立代码有一些简单的规则要遵循。如果有一个帧指针并且有,则构建堆栈帧,如果有嵌套函数,则根据体系结构处理返回地址。如果有传递参数并且有嵌套函数(对于此架构或调用约定使用寄存器传递参数),则通过将值从传递的寄存器中移出来进行设置,但不是在堆栈上。这些都是你可以为任何大小的函数所做的事情。 - old_timer
3
通常情况下,如果项目足够大,很容易超越编译器,因为有许多被忽略的优化(包括这些微小的函数)。相对于手写汇编代码,编译器所能实现的是一致性和效率。最好使用高级语言并在必要时修复输出结果,而不是试图完全用汇编来编写整个程序以使某些代码可能更快(但总体上很难在没有额外努力和技能的情况下击败编译器)。我们中的一些人当然可以做到这一点,但这是否值得呢? - old_timer
2
如果效率/性能是关键,那么您希望编译器执行的操作是内联此函数,并不需要在foo调用周围添加额外指令。或者您根本不会在高级语言中创建这样的函数,因为即使使用push/pop,它也不够高效。 - old_timer
显示剩余8条评论
2个回答

16

TL:DR:

  • 编译器内部可能没有很好地设置这种优化,而且它可能只在小函数内部有用,而不是在大函数内部和调用之间。
  • 创建大型函数的内联通常是更好的解决方案
  • 如果 foo 恰好未保存/恢复 RBX,则可能存在延迟与吞吐量的权衡。

编译器是复杂的机器。它们不像人类一样“聪明”,而在寻找每种可能的优化时花费昂贵的算法通常不值得在额外的编译时间上付出代价。

我在2016年报告了 GCC bug 69986 - smaller code possible with -Os by using push/pop to spill/reload;GCC 开发人员没有任何活动或回复。 :/

稍相关:GCC bug 70408 - reusing the same call-preserved register would give smaller code in some cases - 编译器开发人员告诉我,为了能够进行这种优化,需要根据使目标汇编更简单的原则选择两个 foo(int) 调用的评估顺序,这需要耗费巨大的工作量。


如果foo本身不保存/恢复rbx,则在吞吐量(指令数量)与在x-> retval依赖链上额外存储/重新加载延迟之间存在权衡。
编译器通常偏向于延迟而非吞吐量,例如使用2x LEA而不是imul reg,reg,10(3个周期的延迟,1/clock吞吐量),因为大多数代码在典型的4宽流水线(如Skylake)上平均显着少于4个uops / clock。(更多指令/uops确实占用了ROB中的更多空间,降低了相同乱序窗口能够看到多远,尽管执行实际上是突发性的,停顿可能占其中一些少于4个uops/clock的平均值。)
如果foo推入/弹出RBX,则延迟没有太多收益。除非有ret误判或I-cache错失导致延迟获取返回地址处的代码,否则在ret之前恢复可能并不重要。
大多数非平凡函数都会保存/恢复RBX,因此让变量保留在RBX中并不意味着它真正跨调用保留寄存器。(尽管有时随机选择函数选择哪些调用保留寄存器可能是一个好主意来减轻这种情况。)
所以在这种情况下,push rdi/pop rax更有效率,这可能是对于小型非叶函数的一个被忽视的优化,具体取决于foo的作用以及额外存储/重新加载x与保存/恢复调用者的rbx之间的平衡。

在这里,堆栈展开元数据可以表示RSP的更改,就像如果它使用sub rsp, 8x溢出/重新加载到堆栈插槽中一样。(但编译器也不知道使用push来保留空间和初始化变量的这种优化。哪个C/C++编译器可以使用push pop指令创建本地变量,而不仅仅是增加esp一次?如果对多个本地变量都这样做,会导致更大的.eh_frame堆栈展开元数据,因为每次推送时都要分别移动堆栈指针。但这并不妨碍编译器使用push/pop来保存/恢复调用保留寄存器。)


我不确定是否值得教编译器寻找这种优化

也许在整个函数范围内是一个好主意,而不是在函数内部的一个调用中。就像我说的,它基于一种悲观的假设,即foo无论如何都会保存/恢复RBX。(或者在您知道从x到返回值的延迟不重要时优化吞吐量。但编译器不知道这一点,通常会优化延迟)。

如果您在大量代码中开始做出这种悲观的假设(例如在函数内部的单个函数调用周围),则会出现更多情况,其中未保存/恢复RBX,并且可以利用该情况。

您还不希望在循环中进行额外的保存/恢复push/pop操作,只需在循环外保存/恢复RBX并在进行函数调用的循环中使用调用保留寄存器。即使没有循环,在一般情况下,大多数函数进行多次函数调用。如果您确实不在任何调用之间使用x,仅在第一个调用之前和最后一个调用之后使用,则此优化想法可能适用,否则您将面临每个call维护16字节堆栈对齐的问题,如果在调用之后进行一次pop操作,再进行另一个调用。

编译器通常不擅长处理微小函数。但它也不适合CPU。最好情况下,非内联函数调用会对优化产生影响,除非编译器可以看到被调用者的内部并做出比通常更多的假设。非内联函数调用是一个隐式的内存屏障:调用者必须假设函数可能读取或写入任何全局可访问数据,因此所有这些变量都必须与C抽象机器同步。(逃逸分析允许在地址未逃逸函数中跨调用保留本地寄存器。)此外,编译器必须假定调用破坏的寄存器都已被破坏。这对于x86-64 System V中没有调用保留XMM寄存器的浮点运算来说是很糟糕的。

bar()这样的微小函数最好内联到其调用者中。使用-flto编译,以便在大多数情况下甚至可以跨文件边界进行内联。(函数指针和共享库边界可能会破坏此功能。)


我认为编译器没有尝试做这些优化的一个原因是需要在编译器内部编写一堆不同的代码,与普通的堆栈与寄存器分配代码不同,它知道如何保存调用保留寄存器并使用它们。

换句话说,实现这个功能需要大量的工作和代码维护。如果过度热衷于此,可能会产生更差的代码。

还有就是(希望)这不是很重要;如果它很重要,你应该将bar内联到其调用者中,或将foo内联到bar中。除非有很多不同的类似bar的函数且foo很大,并且由于某种原因它们无法内联到它们的调用者中,否则这是可以接受的。


2
@RbMm:我不明白你的观点。那似乎是clang中完全独立的一个被忽视的优化问题,与这个问题无关。存在被忽视的优化错误,大多数情况下应该得到修复。请在https://bugs.llvm.org/上报告它。 - Peter Cordes
1
@BasileStarynkevitch:我知道,谢谢。 我尝试通过指出最终汇编可能的改进来帮助,但我已经考虑过深入内部并看看是否可以让它自己发出更好的汇编。 仍然从开发人员的评论中可以听出,找到这些优化需要对某些基础设施进行重大重新设计,或者可能会非常丑陋/不专业。 像可能没有好的方法来实现优化传递以找到其中一些,并且一个超级丑陋和低效的补丁可能不会被接受到主流GCC中。 - Peter Cordes
1
这种优化会使异常展开变得更加复杂。大多数ABIs要求函数堆栈符合特定的布局。该函数的堆栈布局不符合“先保存寄存器,然后保留本地变量,最后在结尾处不改变堆栈指针”的标准模式,因此它属于“非标准堆栈布局”类别,需要更复杂的展开过程。 - Raymond Chen
1
@RaymondChen:是的,SO在你完成输入并发布评论之前不会刷新已删除的评论。我看到后进行了编辑。无论如何,关于异常展开的观点很有趣。但我认为这里实际上没有问题。从展开的角度来看,这个bar是一个具有8字节本地变量和没有保存的非易失性寄存器的函数。它使用push而不是sub $8, %rsp / mov初始化其本地变量空间的事实是无关紧要的,只是一个窥视孔。展开不应该将该值恢复到RDI或RAX。就像int bar(int x){int t=x; foo(x); return t;}一样。 - Peter Cordes
1
@RaymondChen:确切地说,它只是一个易失性寄存器,在展开之后其值无关紧要。clang已经使用了虚拟弹出而不是add rsp,8,通常是到RCX中。我们使用RAX是因为期望的非异常行为,但如果您正在展开,则RAX的最终值并不重要。没有理由使用不同的CFI元数据,比如如果它是以add rsp,8或者pop rcx结束的空函数,那么只是在调用前使用虚拟推入/弹出来对齐RSP。 - Peter Cordes
显示剩余6条评论

-1
为什么编译器坚持在这里使用被调用者保存的寄存器?
因为大多数编译器会为给定的函数生成几乎相同的代码,并遵循由您的编译器针对的ABI定义的全局调用约定
您可以定义自己不同的调用约定(例如,在处理器寄存器中传递更多的函数参数,或者相反地通过位运算将两个short参数“打包”到单个处理器寄存器中等等),并实现遵循它们的编译器。您可能需要重新编写一些C标准库(例如,在Linux上修补GNU libc的较低部分,然后重新编译它)。
我记得,对于相同的CPU,Windows、FreeBSD和Linux上的某些调用约定是不同的。
请注意,使用最新的GCC(例如2021年初的GCC 10),您可以使用gcc -O3 -flto -fwhole-program编译和链接,并且在某些情况下可以获得内联扩展。您还可以从源代码构建GCC作为交叉编译器,并且由于GCC是自由软件,因此您可以改进它以遵循您的私有新调用约定。请确保首先记录您的调用约定。
如果性能对你来说非常重要,那么你可以考虑编写自己的GCC插件来进行更多的优化。甚至你的编译器插件可以实现其他的调用约定(例如使用asmjit)。
同时,你也可以考虑改进TinyCC或者Clang或者NWCC以满足你的需求。

我的观点是,在许多情况下,花费数月的时间仅为了提高几个纳秒的性能并不值得。但你的雇主/经理/客户可能会有不同的意见。考虑将软件的重要部分编译(或重构)到硅中,例如通过VHDL,或使用专用硬件,例如带有OpenCLCUDAGPGPU


我认为你只是在说GCC10是当前的版本。但LTO已经存在了几年,它不是那么新的功能。(而且是的,跨文件内联真的非常好,特别是对于试图通过不在头文件中定义甚至小函数来减少编辑/重建时间的代码库(非LTO /调试)构建。是的,内联微小的函数比仅仅使它们稍微便宜要好得多。) - Peter Cordes
1
然而,你的第一段不是一个答案。Joseph手写的汇编版本完全符合ABI,他们只是选择将其推到堆栈中,而不是保存并使用调用保存寄存器。编译器(至少gcc / clang)永远不会选择这样做,因为它只在仅进行一次函数调用的小函数中很好,并且不在循环中。因为它可能不值得寻找这种优化。 - Peter Cordes
1
你的编辑表明你仍然认为push rdi / call foo / pop rax与x86-64 System V ABI不兼容,并且这种优化需要一个新的调用约定。实际上,它是完全兼容/符合标准的(包括在异常时的堆栈展开),这一点在我的回答下的评论中讨论过。如果这不是你的意思,而只是像你回答中的大部分内容一样的一个大的离题,那么请说出来。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接