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

Jos*_*ica 15 c assembly gcc x86-64 register-allocation

考虑这个 C 代码:

void foo(void);

long bar(long x) {
    foo();
    return x;
}
Run Code Online (Sandbox Code Playgroud)

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

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret
Run Code Online (Sandbox Code Playgroud)

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

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

bar:
        push    rdi
        call    foo
        pop     rax
        ret
Run Code Online (Sandbox Code Playgroud)

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

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

Godbolt 链接


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

long foo(long);

long bar(long x) {
    return foo(x * x) - x;
}
Run Code Online (Sandbox Code Playgroud)

我明白了:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret
Run Code Online (Sandbox Code Playgroud)

我宁愿有这个:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret
Run Code Online (Sandbox Code Playgroud)

这一次,它只是一条指令对两条指令,但核心概念是相同的。

Godbolt 链接

Pet*_*des 12

电话:DR:

  • 编译器内部可能没有设置为轻松查找此优化,并且它可能仅在小函数周围有用,而不是在调用之间的大函数内部。
  • 大多数情况下,内联创建大型函数是更好的解决方案
  • 如果foo碰巧没有保存/恢复 RBX,则可能存在延迟与吞吐量的权衡。

编译器是复杂的机器。它们不像人类那样“聪明”,并且寻找所有可能优化的昂贵算法通常不值得花费额外的编译时间。

我将此报告为GCC 错误 69986 - 通过在 2016 年使用 push/pop 溢出/重新加载,-Os 可以实现更小的代码;GCC 开发人员没有任何活动或回复。:/

稍微相关:GCC 错误 70408 - 在某些情况下重用相同的调用保留寄存器会产生更小的代码- 编译器开发人员告诉我 GCC 需要大量的工作才能进行优化,因为它需要选择评估顺序foo(int)基于使目标 asm 更简单的两个调用。


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

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

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

大多数非平凡的函数都会保存/恢复 RBX,因此将变量留在 RBX 中实际上意味着它在整个调用过程中真正留在寄存器中,这通常不是一个好的假设。(虽然随机选择哪些调用保留的寄存器函数可能是一个好主意,有时可以缓解这种情况。)


所以是的push rdi/pop rax这种情况下会更有效,可能是对微小的非叶函数的一个错过的优化,这取决于什么foo以及额外的存储/重新加载延迟x与更多指令之间的平衡来保存/恢复调用者的rbx.

堆栈展开元数据可以在这里表示对 RSP 的更改,就像它曾经sub rsp, 8溢出/重新加载x到堆栈槽中一样。(但编译器也不知道这种优化,push用于保留空间和初始化变量。 什么 C/C++ 编译器可以使用 push pop 指令来创建局部变量,而不是仅仅增加一次 esp?。并且这样做不止一次一个本地 var 会导致更大的.eh_frame堆栈展开元数据,因为您在每次推送时都单独移动堆栈指针。不过,这并不能阻止编译器使用推送/弹出来保存/恢复调用保留的 regs。)


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

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

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

您也不希望在循环中进行这种额外的保存/恢复推送/弹出,只需在循环外保存/恢复 RBX 并在进行函数调用的循环中使用调用保留寄存器。即使没有循环,在一般情况下,大多数函数都会调用多个函数。如果您真的不在x任何调用之间使用,就在第一个调用之前和最后一个调用之后,则可以应用此优化思想,否则call如果您在 a呼叫,在另一个呼叫之前。

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

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


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

也就是说,这将需要大量的工作来实现,并且需要维护大量的代码,如果它过于热衷于这样做,它可能会使代码变得更糟

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

  • @RbMm:我不明白你的意思。这看起来像是对 clang 的完全独立的错过优化,与这个问题的内容无关。存在错过优化的错误,并且在大多数情况下应该得到修复。继续报告 https://bugs.llvm.org/ (2认同)