什么 C/C++ 编译器可以使用 push pop 指令来创建局部变量,而不是仅仅增加一次 esp?

Ale*_*Sss 1 c++ x86 assembly micro-optimization compiler-optimization

我相信 push/pop 指令会产生更紧凑的代码,甚至可能会运行得稍微快一点。不过,这也需要禁用堆栈帧。

为了检查这一点,我需要手动重写一个足够大的汇编程序(比较它们),或者安装和研究一些其他编译器(看看他们是否有这个选项,并比较结果) .

这是有关此问题和类似问题的论坛主题

简而言之,我想了解哪个代码更好。像这样的代码:

sub esp, c
mov [esp+8],eax
mov [esp+4],ecx
mov [esp],edx
...
add esp, c
Run Code Online (Sandbox Code Playgroud)

或这样的代码:

push eax
push ecx
push edx
...
add esp, c
Run Code Online (Sandbox Code Playgroud)

什么编译器可以生成第二种代码?他们通常会产生第一个的一些变体。

Pet*_*des 7

你是对的,push是对所有 4 个主要 x86 编译器的一个小的遗漏优化。有一些代码大小,因此间接性能。或者在某些情况下可能更直接的少量性能,例如保存sub rsp指令。

但是,如果你不小心,你可以通过混合使事情有额外的堆栈同步微指令慢push[rsp+x]寻址模式。 pop听起来没什么用,只是push。正如您链接的论坛帖子所暗示的那样,您仅将其用于本地人的初始商店;以后重新加载和存储应该使用正常的寻址模式,如[rsp+8]. 我们不是在谈论试图mov完全避免加载/存储,我们仍然希望随机访问我们从寄存器溢出局部变量的堆栈槽!

现代代码生成器避免使用 PUSH。它在当今的处理器上效率低下,因为它修改了堆栈指针,从而使超标量内核变得混乱。 (汉斯·帕桑特)

15 年前确实如此,但编译器push在优化速度时再次使用,而不仅仅是代码大小。 编译器已经使用push/pop来保存/恢复他们想要使用的调用保留寄存器,例如rbx, 以及用于推送堆栈参数(主要在 32 位模式下;在 64 位模式下,大多数 args 适合寄存器)。这两件事都可以用 来完成mov,但编译器使用push它是因为它比sub rsp,8/更有效mov [rsp], rbxgcc调整选项来避免push/pop针对这些情况,为-mtune=pentium3-mtune=pentium和类似的旧 CPU启用,但不适用于现代 CPU。

自 Pentium-M 以来的 Intel 和自 Bulldozer(?) 以来的 AMD 都有一个“堆栈引擎”,可以在 PUSH/POP/CALL/RET 的情况下以零延迟和无 ALU uops 跟踪对 RSP 的更改。许多实际代码仍在使用 push/pop,因此 CPU 设计人员添加了硬件以使其高效。现在我们可以在调整性能时使用它们(小心!)。请参阅Agner Fog 的微体系结构指南和指令表,以及他的 asm 优化手册。他们很优秀。(以及x86 标签 wiki中的其他链接。)

它并不完美;直接读取 RSP(当与乱序内核中的值的偏移量非零时)确实会导致在 Intel CPU 上插入堆栈同步 uop。例如push rax/mov [rsp-8], rdi总共有 3 个融合域 uops:2 个存储和一个堆栈同步。

在函数入口,“堆栈引擎”已经处于非零偏移状态(从call父级中的 ),因此push在第一次直接引用 RSP 之前使用一些指令根本不需要额外的 uops。(除非我们从另一个带有 的函数进行尾调用jmp,并且该函数之前没有pop任何内容jmp。)

一段时间以来编译器一直使用虚拟的 push/pop 指令只是为了将堆栈调整 8 个字节,这有点有趣,因为它既便宜又紧凑(如果你只做一次,而不是 10 次来分配 80 个字节) ,但没有利用它来存储有用的数据。堆栈在缓存中几乎总是很热,现代 CPU 具有非常出色的 L1d 存储/加载带宽。


int extfunc(int *,int *);

void foo() {
    int a=1, b=2;
    extfunc(&a, &b);
}
Run Code Online (Sandbox Code Playgroud)

与编译clang6.0 -O3 -march=haswell 的Godbolt编译探险 请参阅链接代码的所有的休息,和许多不同错过的优化和愚蠢的代码生成(见我在C源代码注释指出其中的一些):

 # compiled for the x86-64 System V calling convention: 
 # integer args in rdi, rsi  (,rdx, rcx, r8, r9)
    push    rax               # clang / ICC ALREADY use push instead of sub rsp,8
    lea     rdi, [rsp + 4]
    mov     dword ptr [rdi], 1      # 6 bytes: opcode + modrm + imm32
    mov     rsi, rsp                # special case for lea rsi, [rsp + 0]
    mov     dword ptr [rsi], 2
    call    extfunc(int*, int*)
    pop     rax                     # and POP instead of add rsp,8
    ret
Run Code Online (Sandbox Code Playgroud)

与 gcc、ICC 和 MSVC 非常相似的代码,有时指令的顺序不同,或者 gcc 无缘无故地保留了额外的 16B 堆栈空间。(MSVC 保留了更多空间,因为它针对的是 Windows x64 调用约定,该约定保留了阴影空间而不是红色区域)。

clang 通过使用存储地址的 LEA 结果而不是重复 RSP 相关地址 (SIB+disp8) 来节省代码大小。ICC 和 clang 将变量放在它保留的空间的底部,因此其中一种寻址模式避免了disp8. (对于 3 个变量,需要保留 24 个字节而不是 8 个字节,并且 clang 那时没有利用。)gcc 和 MSVC 错过了这个优化。

但无论如何,更理想的是

    push    2                       # only 2 bytes
    lea     rdi, [rsp + 4]
    mov     dword ptr [rdi], 1
    mov     rsi, rsp                # special case for lea rsi, [rsp + 0]
    call    extfunc(int*, int*)
      # ... later accesses would use [rsp] and [rsp+] if needed, not pop
    pop     rax                     # alternative to add rsp,8
    ret
Run Code Online (Sandbox Code Playgroud)

push是一个 8 字节的存储,我们重叠了它的一半。这不是问题,即使在存储了高半部分之后,CPU 也可以有效地存储转发未修改的低半部分。重叠存储通常不是问题,实际上glibc 的良好注释memcpy实现使用两个(潜在)重叠加载 + 存储用于小副本(至少达到 2x xmm 寄存器的大小),加载所有内容然后存储所有内容而无需关心关于是否有重叠。

请注意,在 64 位模式下,32 位push不可用。所以我们还是要rsp直接引用qword的上半部分。但是如果我们的变量是 uint64_t,或者我们不关心让它们连续,我们可以使用push.

在这种情况下,我们必须显式引用 RSP 以获取指向本地变量的指针以传递给另一个函数,因此无法绕过 Intel CPU 上的额外堆栈同步 uop。在其他情况下,您可能只需要在call. (虽然通常的编译器将push rbxmov rbx,rdi保存在呼叫保存寄存器中的精氨酸,代替溢出/重装ARG本身,以缩短关键路径。)

我选择了 2x 4 字节 args,因此我们可以使用 1 达到 16 字节对齐边界push,因此我们可以完全优化掉sub rsp, ##(或虚拟push)。

我本可以使用mov rax, 0x0000000200000001/ push rax,但是 10 字节mov r64, imm64需要 uop 缓存中的 2 个条目,以及很多代码大小。
gcc7 确实知道如何合并两个相邻的商店,但mov在这种情况下选择不这样做。如果这两个常量都需要 32 位立即数,那就有意义了。但是,如果这些值实际上根本不是常量,而是来自寄存器,则这将不起作用而push/mov [rsp+4]会。(将寄存器中的值与 SHL + SHLD 或任何其他将 2 个存储变为 1 个的指令合并是不值得的。)

如果您需要为超过一个 8 字节的块保留空间,并且还没有任何有用的东西可以存储在那里,那么一定sub要在最后一个有用的 PUSH 之后使用多个虚拟 PUSH 来代替。但是如果你有有用的东西要存储,push imm8 或 push imm32,或者 push reg 都不错。

我们可以看到编译器使用带有 ICC 输出的“固定”序列的更多证据:它lea rdi, [rsp]在调用的 arg 设置中使用。似乎他们没有想到寻找由寄存器直接指向的本地地址的特殊情况,没有偏移,允许mov代替lea. (mov绝对不会更糟,并且在某些 CPU 上更好。)


不使局部变量连续的一个有趣示例是上述版本的 3 个 args , int a=1, b=2, c=3;。为了保持 16B 对齐,我们现在需要偏移8 + 16*1 = 24字节,所以我们可以这样做

bar3:
    push   3
    push   2               # don't interleave mov in here; extra stack-sync uops
    push   1
    mov    rdi, rsp
    lea    rsi, [rsp+8]
    lea    rdx, [rdi+16]         # relative to RDI to save a byte with probably no extra latency even if MOV isn't zero latency, at least not on the critical path
    call   extfunc3(int*,int*,int*)
    add    rsp, 24
    ret
Run Code Online (Sandbox Code Playgroud)

这比编译器生成的代码小得多,因为mov [rsp+16], 2必须使用mov r/m32, imm32编码,使用 4 字节立即数,因为没有 sign_extended_imm8 形式的mov.

push imm8非常紧凑,2个字节。 mov dword ptr [rsp+8], 1是 8 个字节:操作码 + modrm + SIB + disp8 + imm32。(作为基址寄存器的 RSP 总是需要一个 SIB 字节;带有 base=RSP 的 ModRM 编码是现有 SIB 字节的转义码。使用 RBP 作为帧指针允许更紧凑的局部寻址(每个 insn 1 个字节),但是需要 3 个额外的指令来设置/拆除,并绑定一个寄存器。但它避免了进一步访问 RSP,避免了堆栈同步 uops。有时它实际上可能是一个胜利。)

在本地人之间留下差距的一个缺点是它可能会在以后打败加载或存储合并机会。如果您(编译器)需要在某处复制 2 个本地人,如果它们相邻,您可以使用单个 qword 加载/存储来完成。 据我所知,编译器在决定如何在堆栈上排列局部变量时不会考虑函数的所有未来权衡。我们希望编译器能够快速运行,这意味着并不总是回溯以考虑重新排列局部变量或其他各种事物的所有可能性。如果寻找优化需要二次时间,或者将其他步骤所需的时间乘以一个重要的常数,那么它最好是一个重要的优化。(IDK 实施搜索使用机会可能有多么困难push,特别是如果您保持简单并且不花时间为其优化堆栈布局。)

但是,假设还有其他局部变量将在以后使用,我们可以将它们分配到任何我们早期溢出的间隙中。因此不必浪费空间,我们可以稍后简单地使用它 mov [rsp+12], eax来存储我们推送的两个 32 位值。


一个很小的数组long,具有非常量的内容

int ext_longarr(long *);
void longarr_arg(long a, long b, long c) {
    long arr[] = {a,b,c};
    ext_longarr(arr);
}
Run Code Online (Sandbox Code Playgroud)

gcc/clang/ICC/MSVC 遵循它们的正常模式,并使用mov商店:

longarr_arg(long, long, long):                     # @longarr_arg(long, long, long)
    sub     rsp, 24
    mov     rax, rsp                 # this is clang being silly
    mov     qword ptr [rax], rdi     # it could have used [rsp] for the first store at least,
    mov     qword ptr [rax + 8], rsi   # so it didn't need 2 reg,reg MOVs to avoid clobbering RDI before storing it.
    mov     qword ptr [rax + 16], rdx
    mov     rdi, rax
    call    ext_longarr(long*)
    add     rsp, 24
    ret
Run Code Online (Sandbox Code Playgroud)

但它可以存储这样的参数数组:

longarr_arg_handtuned:
    push    rdx
    push    rsi
    push    rdi                 # leave stack 16B-aligned
    mov     rsp, rdi
    call    ext_longarr(long*)
    add     rsp, 24
    ret
Run Code Online (Sandbox Code Playgroud)

随着更多的参数,我们开始获得更明显的好处,尤其是在代码大小方面,当更多的总函数用于存储到堆栈时。这是一个非常综合的例子,几乎什么都不做。我本可以使用volatile int a = 1;,但有些编译器特别特别地对待它。


逐步构建堆栈框架的原因

(可能错误)堆栈展开异常和调试格式,我认为不支持随意使用堆栈指针。因此,至少在发出任何call指令之前,函数应该具有与此函数中所有未来函数调用相同的偏移量 RSP。

但这不可能是正确的,因为allocaC99 可变长度数组会违反这一点。编译器本身之外可能有某种工具链原因不寻找这种优化。

这篇关于禁用-maccumulate-outgoing-argstune=default(2014 年)的gcc 邮件列表帖子很有趣。它指出更多的推送/弹出导致更大的展开信息(.eh_frame部分),但这是通常永远不会读取的元数据(如果没有例外),所以更大的总二进制但更小/更快的代码。相关:这显示了-maccumulate-outgoing-argsgcc 代码生成的作用。

显然,我选择的示例是微不足道的,我们push未修改输入参数。更有趣的是,当我们在获得想要溢出的值之前,根据 args(以及它们指向的数据和全局变量等)计算寄存器中的某些内容。

如果您必须在函数入口和后续pushes之间溢出/重新加载任何内容,那么您将在 Intel 上创建额外的堆栈同步 uops。在 AMD 上,这样做push rbx/等等/ mov [rsp-32], eax(溢出到红色区域)/等等push rcx// imul ecx, [rsp-24], 12345(从仍然是红色区域的地方重新加载较早的溢出,使用不同的偏移量)仍然可能是一个胜利

混合push[rsp]寻址模式效率较低(在 Intel CPU 上,由于堆栈同步 uops),因此编译器必须仔细权衡权衡以确保它们不会使事情变慢。 众所周知,sub/mov可以在所有 CPU 上运行良好,尽管它的代码大小可能会很昂贵,尤其是对于小常量。

“很难跟踪偏移量”是一个完全虚假的论点。这是一台电脑;在使用push将函数 args 放在堆栈上时,无论如何都必须根据不断变化的引用重新计算偏移量。我认为编译器可能会遇到问题(即需要更多的特殊情况检查和代码,使它们编译更慢),如果他们有超过 128B 的本地人,所以你不能总是mov存储在 RSP 以下(仍然是红色区域)在根据未来的push指示降低 RSP 之前。

编译器已经考虑了多种权衡,但目前逐渐增加堆栈框架并不是他们考虑的事情之一。 push在 Pentium-M 引入堆栈引擎之前效率并不高,因此push即使可用也是最近的一个变化,因为重新设计编译器如何考虑堆栈布局选择。

拥有一个基本固定的序言和访问当地人的食谱当然更简单。