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

Why do compilers insist on using a callee-saved register here?

考虑这个 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

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

但是,我 want/expect 看到看起来更像这样的程序集:

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.

TL:DR:

  • 编译器内部可能没有设置为轻松寻找这种优化,它可能只对小函数有用,而不是在调用之间的大函数中。
  • 在大多数情况下,内联创建大型函数是更好的解决方案
  • 如果 foo 不发生在 save/restore RBX 上,则可能存在延迟与吞吐量的权衡。

编译器是复杂的机器。他们 "smart" 不像人类,寻找所有可能的优化的昂贵算法通常不值得花费额外的编译时间。

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

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


如果 foo本身不是save/restore rbx,则吞吐量(指令数)之间存在权衡与 x -> retval 依赖链上的额外 store/reload 延迟相比。

编译器通常更喜欢延迟而不是吞吐量,例如使用 2x LEA 而不是 imul reg, reg, 10(3 周期延迟,1/时钟吞吐量),因为大多数代码在典型的 4 宽管道(如 Skylake)上平均明显低于 4 微指令/时钟。 (更多 instructions/uops 确实在 ROB 中占用更多 space,减少了相同乱序 window 可以看到的距离,但是,执行实际上是突发的,停顿可能是原因一些低于 4 uops/clock 的平均值。)

如果 foo 执行 push/pop RBX,那么延迟不会有太大好处。让恢复发生在 ret 之前而不是之后可能是不相关的,除非有 ret 错误预测或 I-cache 未命中导致在 return 地址处延迟获取代码。

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


所以是的 push rdi / pop rax 这种 情况下会更有效,这可能是对微小的非叶函数的优化遗漏,取决于 foo 的作用以及 x 的额外 store/reload 延迟与调用者 rbx.

的 save/restore 的更多指令之间的平衡

堆栈展开元数据可以在这里表示对 RSP 的更改,就像它在堆栈槽中使用 sub rsp, 8 到 spill/reload x 一样。 (但编译器也不知道这种优化,使用 push 保留 space 并初始化变量。。对多个局部变量执行此操作会导致更大的 .eh_frame stack unwind metadata 因为你在每次推送时分别移动堆栈指针。但这不会阻止编译器使用 push/pop 到 save/restore 调用保留的 regs。)


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

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

如果您开始在大量代码中做出这种悲观假设(例如围绕函数内部的单个函数调用),您将开始遇到更多 RBX 不是 saved/restored 的情况,您可以利用它。

您也不希望在循环中使用这个额外的 save/restore push/pop,只需 save/restore RBX 在循环外并在进行函数调用的循环中使用调用保留寄存器。即使没有循环,在一般情况下大多数函数也会进行多次函数调用。如果你真的不在任何调用之间使用 x,就在第一次调用之前和最后一次调用之后,这个优化想法可以应用,否则你有维护 16 字节堆栈对齐的问题每个 call 如果你在一个电话之后做一个流行音乐,在另一个电话之前。

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

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


我认为编译器不愿意尝试进行这些优化的一个原因是它需要编译器内部的一大堆不同的代码,与正常情况不同知道如何保存调用保留寄存器并使用它们的堆栈与寄存器分配代码。

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

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

Why do compilers insist on using a callee-saved register here?

因为大多数编译器会为给定函数生成几乎相同的代码,并且遵循您的编译器所针对的全局 calling conventions defined by the ABI

您可以定义自己的不同调用约定(例如,在 processor registers, or on the contrary "packing" by bitwise operations two short arguments in a single processor register, etc...), and implement your compiler following them. You probably would need to recode some of the C standard library (e.g. patch lower parts of GNU libc 中传递更多函数参数,然后重新编译它,如果在 Linux 上)。

IIRC,某些调用约定在 Windows 和 FreeBSD 上以及在 Linux 上对于相同的 CPU.

是不同的

请注意,使用最近的 GCC(例如 GCC 10 in start of 2021) you could compile and link with gcc -O3 -flto -fwhole-program and in some cases get some inline expansion. You can also build GCC from its source code as a cross-compiler, and since GCC is free software,您可以改进它以遵循您的私人新调用约定。请务必先记录您的调用约定。

如果性能对您很重要,您可以考虑编写自己的 GCC plugin doing even more optimizations. Your compiler plugin could even implement other calling conventions (e.g. using asmjit)。

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

我的观点是,在许多情况下,花费数月的努力来将性能提高几纳秒是不值得的。但是您的 employer/manager/client 可能不同意。还要考虑将软件的重要部分编译(或重构)到硅片中,例如通过 VHDL, or using specialized hardware e.g. GPGPU with OpenCL or CUDA.