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

标签 c assembly gcc x86-64 register-allocation

考虑这个 C 代码:

void foo(void);

long bar(long x) {
    foo();
    return x;
}
当我使用 -O3 在 GCC 9.3 上编译它时或 -Os ,我明白了:
bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret
除了选择 rbx 之外,clang 的输出是相同的。而不是 r12作为被调用者保存的寄存器。
但是,我希望/期望看到看起来更像这样的程序集:
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 .

最佳答案

电话:DR:

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

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

    我将此报告为 GCC bug 69986 - smaller code possible with -Os by using push/pop to spill/reload回到2016 ; GCC 开发人员没有任何事件或回复。 :/

    略相关:GCC bug 70408 - reusing the same call-preserved register would give smaller code in some cases - 编译器开发人员告诉我 GCC 需要大量的工作才能进行优化,因为它需要选择两个计算的顺序 foo(int)基于使目标 asm 更简单的调用。

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

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

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

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

    所以是的 push rdi/pop rax在这种情况下会更有效,这可能是对微小的非叶函数的遗漏优化,具体取决于 foox 的额外存储/重新加载延迟之间的平衡与更多保存/恢复调用者的指令 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 堆栈展开元数据,因为您“在每次推送时分别重新移动堆栈指针。不过,这并不能阻止编译器使用推送/弹出来保存/恢复调用保留的 regs。)

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

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

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

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

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

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

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

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

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

    关于c - 为什么编译器坚持在这里使用被调用者保存的寄存器?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/61375336/

    相关文章:

    c - C语言编程如何将输入的字符串输出到屏幕上?

    assembly - 带有跳转(和链接)指令的奇怪的MIPS汇编器行为

    assembly - 电脑开机时 : How does it know from which instruction it needs to start executing?

    c++ - Qt:Centos 6.7 session 管理错误和符号查找错误

    在 C 中创建您自己的控制台图形库

    c - 用C写入文件

    c - 结构体静态数组字段的动态内存重新分配

    c++从无文件系统执行代码

    c++ - 生成由单独的测试类调用的 C++ 静态库的测试覆盖率

    c - 多个 #ifndef 语句 - 应用哪一个