【问题标题】:Why do compilers insist on using a callee-saved register here?为什么编译器在这里坚持使用被调用者保存的寄存器?
【发布时间】:2020-08-06 01:50:21
【问题描述】:

考虑这个 C 代码:

void foo(void);

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

当我在 GCC 9.3 上使用 -O3 或 -Os 编译它时,我得到了这个:

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

除了选择 rbx 而不是 r12 作为被调用者保存的寄存器之外,clang 的输出是相同的。

但是,我希望/期望看到看起来更像这样的程序集:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

由于无论如何您都必须将某些内容推送到堆栈,因此将您的值推送到那里似乎更短、更简单并且可能更快,而不是在那里推送一些任意被调用者保存的寄存器的值,然后将您的值存储在该寄存器中。当你把东西放回去时,call foo 之后的倒数也是如此。

我的组装错了吗?它是否比弄乱额外的寄存器效率低?如果这两个问题的答案都是“否”,那么为什么 GCC 或 clang 不这样做呢?

Godbolt link.


编辑:这是一个不那么简单的例子,表明即使变量被有意义地使用,它也会发生:

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 link.

【问题讨论】:

  • 有趣的错过优化。
  • 很可能假设将使用传递的参数,因此您希望保存一个易失性寄存器并将传递的参数保存在一个寄存器中,而不是在堆栈上,因为从寄存器对该参数的后续访问速度更快.将 x 传递给 foo ,您将看到这一点。所以它可能只是他们堆栈框架设置的一个通用部分。
  • 很公平,我想你知道我的意思,编译器有用于函数进入和退出的代码,一些代码构建的简单规则可以遵循,如果有一个帧指针,然后构建堆栈帧,如果有嵌套函数,则根据架构需要处理返回地址。如果有传递的参数并且有一个嵌套函数(并且参数的寄存器传递用于此架构或调用约定),则通过将值移出传递的寄存器而不是堆栈来进行设置。您可以为任何规模的任何功能做的事情。
  • 通常很容易超越编译器,给定足够大的项目规模,有许多错过的优化(以及像这样的小函数)。编译器在手工 asm 编码器上可以做的是一致性和效率,最好使用高级语言并在需要的地方修复输出,而不是尝试自己在 asm 中编写整个东西只是为了让一些代码更快(但总体上可能没有额外的努力和技巧就无法击败编译器)。我们中的一些人当然可以做到这一点,但值得吗?
  • 如果效率/性能是关键,那么您希望编译器做的是内联此函数,而不用担心 foo 调用周围的额外指令。或者,您一开始就永远不会在高级语言中创建这样的函数,因为即使使用 push/pop 也没有效率。

标签: c assembly gcc x86-64 register-allocation


【解决方案1】:

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

因为大多数编译器会为给定函数生成几乎相同的代码,并且遵循由您的编译器定位的 ABI 定义的全局 calling conventions。

您可以定义自己的不同调用约定(例如,在processor registers 中传递更多的函数参数,或者相反,通过按位操作将两个short 参数“打包”到单个处理器寄存器中,等等...),并按照它们实现你的编译器。您可能需要重新编码一些 C 标准库(例如,修补 GNU libc 的下部,然后重新编译它,如果在 Linux 上)。

IIRC,一些调用约定在 Windows、FreeBSD 和 Linux 上对于相同的 CPU 是不同的。

请注意,使用最近的 GCC(例如 2021 年初的 GCC 10),您可以编译并链接 with gcc -O3 -flto -fwhole-program 和在某些情况下 inline expansion。您还可以将 GCC 的源代码构建为 cross-compiler,并且由于 GCC 是 free software,您可以对其进行改进以遵循您的私有新调用约定。请务必先记录您的调用约定。

如果性能对您很重要,您可以考虑编写自己的 GCC plugin 进行更多优化。您的编译器插件甚至可以实现其他调用约定(例如使用asmjit)。

考虑同时改进 TinyCC 或 Clang 或 NWCC 以满足您的需求。

我的观点是,在许多情况下,花费数月的时间来将性能提高几纳秒是不值得的。但是您的雇主/经理/客户可能不同意。还考虑将软件的重要部分编译(或重构)到硅,例如通过VHDL,或使用专用硬件,例如GPGPU 与 OpenCL 或 CUDA。

【讨论】:

  • 我想你只是说 GCC10 是现在的流行。但是 LTO 已经存在好几年了,它并不是最近才有的功能。 (是的,跨文件内联真的非常好,特别是对于那些试图通过不在头文件中定义甚至小的函数来减少(非 LTO / 调试)构建的编辑/重建时间的代码库。是的,内联微小的函数很多比仅仅让它们便宜一点要好。)
  • 但是,您的第一段不是答案。 Joseph 的手写 asm 版本完全符合 ABI,他们只是选择推送到堆栈而不是保存和使用保留调用的寄存器。编译器(至少 gcc/clang)永远不会选择这样做,因为它只适用于只进行一次函数调用且不在循环中的小函数。而且因为它可能不值得寻找这种优化。
  • 您的编辑表明您仍然认为push rdi / call foo / pop rax 在某种程度上与 x86-64 System V ABI 不兼容,并且这种优化需要新的调用约定。在我的回答下,它完全兼容/兼容(包括异常堆栈展开)的事实是discussed in comments。如果这不是您的意思,并且与您的大多数其他答案一样,这只是一个很大的切线,请说出来。
【解决方案2】:

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 - 编译器开发人员告诉我,GCC 需要做大量工作才能进行优化,因为它需要根据会产生什么来选择两个 foo(int) 调用的评估顺序目标汇编更简单。


如果 foo 本身不保存/恢复rbx,则需要在吞吐量(指令数)与x 上的额外存储/重新加载延迟之间进行权衡-> retval 依赖链。

编译器通常更喜欢延迟而不是吞吐量,例如使用 2x LEA 而不是 imul reg, reg, 10(3 周期延迟,1/时钟吞吐量),因为在 Skylake 等典型的 4 宽管道上,大多数代码平均显着低于 4 uops/时钟。 (更多指令/微指令确实在 ROB 中占用更多空间,但减少了同一个无序窗口可以看到的前方多远,并且执行实际上是突发性的,停顿可能是少于 4 微指令/时钟平均值。)

如果foo 确实推送/弹出RBX,那么延迟不会有太多好处。恢复发生在 ret 之前而不是之后可能无关紧要,除非有 ret 错误预测或 I-cache 未命中延迟在返回地址获取代码。

大多数重要的函数都会保存/恢复 RBX,因此在 RBX 中保留变量实际上意味着它在调用期间真正保留在寄存器中通常不是一个好的假设。 (尽管有时随机选择哪些调用保留寄存器函数可能是缓解这种情况的好主意。)


所以是的,push rdi / pop rax 在 this 情况下会更有效,这可能是对微小非叶函数的优化遗漏,具体取决于 foo 的作用和在x 的额外存储/重新加载延迟与保存/恢复调用者rbx 的更多指令之间取得平衡。

堆栈展开元数据可以在此处表示对 RSP 的更改,就像它使用 sub rsp, 8 将 x 溢出/重新加载到堆栈槽中一样。 (但编译器也不知道这种优化,即使用push 来保留空间并初始化变量。What C/C++ compiler can use push pop instructions for creating local variables, instead of just increasing esp once?。对多个本地变量执行此操作会导致更大的.eh_frame 堆栈展开元数据,因为你'每次推送时分别重新移动堆栈指针。但这并不会阻止编译器使用推送/弹出来保存/恢复调用保留的注册表。)


IDK 是否值得教编译器寻找这种优化

围绕整个函数可能是个好主意,而不是函数内部的一次调用。正如我所说,这是基于foo 无论如何都会保存/恢复RBX 的悲观假设。 (或者如果您知道从 x 到返回值的延迟并不重要,则优化吞吐量。但编译器不知道这一点,通常会针对延迟进行优化)。

如果您开始在大量代码中做出这种悲观假设(例如围绕函数内部的单个函数调用),您将开始遇到更多未保存/恢复 RBX 的情况,而您本可以利用这一点。

您也不希望在循环中进行这种额外的保存/恢复推送/弹出,只需在循环外保存/恢复 RBX 并在进行函数调用的循环中使用调用保留寄存器。即使没有循环,在一般情况下,大多数函数都会进行多个函数调用。如果您真的不在任何调用之间使用x,则可以应用此优化思想,就在第一个之前和最后一个之后,否则您会遇到为每个call 维护 16 字节堆栈对齐的问题如果您在通话后进行一次弹出,则在另一次通话之前。

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

像bar() 这样的小函数最好内联到它们的调用者中。 使用-flto 编译,因此在大多数情况下,即使跨文件边界也可能发生这种情况。 (函数指针和共享库边界可以解决这个问题。)


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

即这将需要大量的工作来实现,并且需要维护大量的代码,如果它对这样做过于热情,它可能会使代码变得更糟。

而且它(希望)并不重要;如果重要,您应该将bar 内联到其调用者,或将foo 内联到bar。这很好,除非有很多不同的类似bar 的函数并且foo 很大,并且由于某种原因它们不能内联到它们的调用者中。

【讨论】:

  • @RbMm:我不明白你的意思。这看起来像是一个完全独立的 clang 优化,与这个问题无关。存在遗漏的优化错误,在大多数情况下应该得到修复。继续并在bugs.llvm.org 上报告它
  • @BasileStarynkevitch:我知道,谢谢。我试图通过指出对最终 asm 的可能改进来提供帮助,但我已经考虑深入研究内部结构,看看我是否可以让它自己发出更好的 asm。尽管如此,从 dev cmets 看来,找到其中一些优化似乎需要对某些基础设施进行重大重新设计,或者可能是超级丑陋/hacky。就像可能没有一个好的方法来实现优化传递来找到其中的一些,并且一个超级丑陋和低效的补丁可能不会被主线 GCC 接受。
  • (抱歉,删除了我之前的评论,因为我意识到这是错误的,但在你回复之前没有。)这种优化会使异常展开复杂化。大多数 ABI 要求函数堆栈符合特定的布局。该函数的堆栈布局不符合“先保存寄存器,后保留局部变量,直到结尾才更改堆栈指针”的标准模式,因此它属于“非标准堆栈布局”类别,需要更复杂的展开.
  • @RaymondChen:是的,所以在您完成输入并发布之前不会刷新已删除的 cmets。我看到后编辑。无论如何,关于异常展开的有趣点。但我认为这里实际上没有问题。从展开的 PoV 来看,这个 bar 是一个函数,在堆栈上有 8 个字节的局部变量,并且没有保存非易失性寄存器。它使用 push 而不是 sub $8, %rsp / mov 初始化其本地 var 空间这一事实无关紧要,只是一个窥视孔。展开应该不将该值恢复到 RDI 或 RAX。就像int bar(int x){int t=x; foo(x); return t;}
  • @RaymondChen:没错,它只是一个易失性寄存器,展开后其值无关紧要。 clang 已经使用虚拟弹出而不是add rsp,8,通常用于 RCX。我们使用 RAX 是因为需要非异常行为,但如果您正在展开,那么 RAX 的最终值是无关紧要的。没有理由使用不同的 CFI 元数据,而不是使用以 add rsp,8 或 pop rcx 结尾的 void 函数,这只是在调用之前使用虚拟推送/弹出来对齐 RSP。
猜你喜欢
  • 1970-01-01
  • 2012-03-05
  • 2020-11-19
  • 1970-01-01
  • 1970-01-01
  • 2021-04-28
  • 2019-08-18
  • 1970-01-01
  • 2021-02-15
相关资源
最近更新 更多