首页 > 解决方案 > 为什么编译器在这里坚持使用被调用者保存的寄存器?

问题描述

考虑这个 C 代码:

void foo(void);

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

当我在 GCC 9.3 上使用-O3or编译它时-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 不这样做呢?

神螺栓链接


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

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

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

神螺栓链接

标签: cassemblygccx86-64register-allocation

解决方案


TL:博士:

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

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

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

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


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

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

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

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


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

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


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

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

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

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

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

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


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

也就是说,要实现很多工作,维护很多代码,如果它对这样做过于热情,它可能会使代码变得更糟

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


推荐阅读