为何编译器坚持在此处使用被调用者保存的寄存器?


10

考虑下面的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

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

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

bar:
        push    rdi
        call    foo
        pop     rax
        ret

用英语,这就是我所看到的:

  • 将已保存被调用者的寄存器的旧值推入堆栈
  • 移动x到该被调用方保存寄存器
  • 呼叫 foo
  • 移动x从被调用者被保存的寄存器到返回值寄存器
  • 弹出堆栈以恢复被调用方保存的寄存器的旧值

为什么要弄乱所有保存在被调用方中的寄存器?为什么不这样做呢?它看起来更短,更简单,并且可能更快:

  • x入栈
  • 呼叫 foo
  • x从堆栈弹出到返回值寄存器

我的大会错了吗?它比以多余的寄存器搞乱效率低吗?如果对这两个问题的回答都是“否”,那么为什么GCC或clang都不这样做呢?

Godbolt链接


编辑:这是一个比较简单的示例,以显示即使变量被有意义地使用也会发生:

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链接


4
有趣的错过优化。
fuz

1
最有可能的假设是将使用传递的参数,因此您要保存一个易失性寄存器,并将传递的参数保留在堆栈中而不是堆栈中的寄存器中,因为从寄存器对该参数的后续访问速度更快。将x传递给foo,您将看到此信息。因此这很可能只是其堆栈框架设置的通用部分。
old_timer

当然,我确实看到没有foo不会使用堆栈,所以是的,这是一个错过的优化,但是有些人需要添加,分析函数,如果未使用该值并且与该寄存器没有冲突(通常存在冲突)是)。
old_timer

手臂后端也在gcc上执行此操作。很可能不是后端
old_timer

lang 10相同的故事(手臂后端)。
old_timer

Answers:


5

TL:DR:

  • 可能没有设置编译器内部组件来轻松查找此优化,并且它可能仅对小函数有用,而在两次调用之间的大函数内部则不可用。
  • 内联创建大型函数通常是更好的解决方案
  • 如果foo碰巧不保存/恢复RBX,则可能存在延迟与吞吐量的折衷。

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

我将其报告为GCC错误69986- 2016年使用push / pop来溢出/重新加载,因此-O可能使用更小的代码;没有GCC开发者的活动或答复。:/

略有相关:GCC错误70408-​​在某些情况下,重用相同的调用保留寄存器会提供较小的代码 -编译器开发人员告诉我,GCC能够进行优化需要大量工作,因为它需要评估的选择顺序foo(int)可以根据使目标asm更简单的方式进行两次调用。


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

编译器通常偏向于延迟而不是吞吐量,例如,使用2 imul reg, reg, 10倍LEA而不是(3周期延迟,1 /时钟吞吐量),因为大多数代码平均在Skylake之类的典型4宽管道上平均低于4微秒/时钟。(但是,更多的指令/指令确实会在ROB中占用更多空间,从而减少了同一乱序窗口可以看到的距离,并且执行实际上是突发性的,因为停顿很可能导致了一些少于4的指令/平均时钟。)

如果foo确实推送/弹出RBX,则延迟不会有太大收获。ret除非在ret错误预测或I缓存未命中会延迟在返回地址处获取代码的情况,否则恢复操作仅发生在之前而不是之后可能并不重要。

大多数非平凡的函数都会保存/恢复RBX,因此通常不是一个很好的假设,即在RBX中保留变量实际上意味着它确实在整个调用中都保留在寄存器中。(尽管随机选择哪个保留呼叫的寄存器功能可能是减轻这种情况的一个好主意。)


因此,在这种情况下,是push rdi/ pop rax会更有效率,可能是针对微小的非叶子函数的优化遗漏,具体取决于foo操作以及额外的存储/重载延迟x与更多保存/恢复调用方指令之间的平衡rbx

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


如果值得教编译器寻找这种优化,请使用IDK

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

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

您也不想在循环中进行额外的保存/恢复推送/弹出操作,只需在循环外保存/恢复RBX并在进行函数调用的循环中使用保留调用的寄存器。即使没有循环,一般情况下大多数函数也会进行多个函数调用。如果您确实不在x第一次调用和最后一次调用之间的任何调用之间使用此优化思想,则可以采用此优化思路,否则,call如果在每次调用之后执行一次弹出操作,则每个调用都保持16字节堆栈对齐的问题电话,再接一个电话。

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

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


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

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

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


不确定是否有理由问为什么某些编译器以这种方式翻译代码,何时可能会更好地使用..,如果翻译没有错误。例如,可能要问为什么clang这么奇怪(未优化)将这个循环进行了翻译,与gcc,icc甚至msvc进行了比较
RbMm

1
@RbMm:我不明白你的意思。这看起来像是clang的完全独立的错过的优化,与这个问题的内容无关。存在错过的优化错误,在大多数情况下应予以修复。来吧,在举报bugs.llvm.org
彼得·柯德斯

是的,我的代码示例绝对与原始问题无关。只是奇怪的(对于我而言)翻译的另一个示例(仅适用于单个clang编译器)。但是无论如何都会得到正确的asm代码。只是不是最好的,而eveen不是本机的比较gcc / icc / msvc
RbMm
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.