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

What C/C++ compiler can use push pop instructions for creating local variables, instead of just increasing esp once?

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

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

这是关于这个问题和类似问题的 forum topic

总之,我想了解哪种代码更好。代码如下:

sub esp, c
mov [esp+8],eax
mov [esp+4],ecx
mov [esp],edx
...
add esp, c

或这样的代码:

push eax
push ecx
push edx
...
add esp, c

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

你说得对,push 是所有 4 个主要 x86 编译器 的次要 missed-optimization。有一些 code-size,因此可以间接获得性能。或者在某些情况下 直接 少量性能,例如保存 sub rsp 指令。

但是如果你不小心,你可以通过混合使用 push[rsp+x] 寻址模式来使用额外的 stack-sync 微指令来减慢速度。 pop听起来没什么用,就是push。正如 the forum thread you linked 所建议的那样,您仅将其用于本地人的 initial 商店;稍后的重新加载和存储应该使用像 [rsp+8] 这样的正常寻址模式。我们 不是 谈论试图完全避免 mov loads/stores,我们仍然希望随机访问我们从寄存器溢出局部变量的堆栈槽!

Modern code generators avoid using PUSH. It is inefficient on today's processors because it modifies the stack pointer, that gums-up a super-scalar core.

这在 15 年前是正确的,但编译器在优化速度时再次使用 push,而不仅仅是 code-size。 编译器已经将 push/pop 用于 saving/restoring call-preserved 他们想使用的寄存器 ,例如 rbx,以及压入堆栈参数(主要在 32 位模式下;在 64 位模式下,大多数参数适合寄存器)。这两件事都可以用 mov 完成,但编译器使用 push 因为它比 sub rsp,8 / mov [rsp], rbx 更有效。 gcc 调整选项以避免 push/pop 这些情况,为 -mtune=pentium3-mtune=pentium 启用,和类似的旧 CPU,但不适用于现代 CPU。

that tracks the changes to RSP with zero latency and no ALU uops, for PUSH/POP/CALL/RET. Lots of real code was still using push/pop, so CPU designers added hardware to make it efficient. Now we can use them (carefully!) when tuning for performance. See Agner Fog's microarchitecture guide and instruction tables, and his asm optimization manual. They're excellent. (And other links in the x86 tag wiki.)

它并不完美;直接读取 RSP(当 out-of-order 核心中的值的偏移量不为零时)确实会导致在 Intel CPU 上插入 stack-sync uop。例如push rax / mov [rsp-8], rdi 总共 3 个 fused-domain uops:2 个商店和一个 stack-sync。

在函数入口处,"stack engine" 已经处于 non-zero-offset 状态(来自父级中的 call),因此在第一个之前使用一些 push 指令直接引用 RSP 根本不需要额外的 uops。 (除非我们是从另一个带有 jmp 的函数尾调用的,并且那个函数在 jmp 之前没有 pop 任何东西。)

有点好笑 现在有一段时间了,因为它是如此便宜和紧凑(如果你只做一次,而不是分配 10 次80 字节),但没有利用它来存储有用的数据。堆栈在高速缓存中几乎总是热的,现代 CPU 具有非常出色的 L1d 存储/加载带宽。


int extfunc(int *,int *);

void foo() {
    int a=1, b=2;
    extfunc(&a, &b);
}

使用 clang6.0 -O3 -march=haswell on the Godbolt compiler explorer 进行编译 link 其余所有代码,以及许多不同的 missed-optimization 和愚蠢的 code-gen(请参阅我在 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

与 gcc、ICC 和 MSVC 的代码非常相似,有时指令顺序不同,或者 gcc 无故保留额外的 16B 堆栈 space。 (MSVC 保留更多 space,因为它针对 Windows x64 调用约定,它保留影子 space 而不是 red-zone)。

clang 通过使用存储地址的 LEA 结果而不是重复 RSP-relative 地址 (SIB+disp8) 来节省 code-size。 ICC 和 clang 将变量放在它保留的 space 的底部,因此其中一种寻址模式避免了 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

push是一个8字节的store,我们重叠了一半。这不是问题,CPU 可以 store-forward 未修改的低半部分,即使在存储高半部分之后也是如此。重叠存储通常不是问题,事实上 glibc's well-commented memcpy implementation 使用两个(可能)重叠加载 + 存储小副本(至少达到 2x xmm 寄存器的大小),加载所有内容然后存储所有内容而不关心关于是否有重叠。

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

在这种情况下,我们必须显式引用 RSP 以获取指向局部变量的指针以传递给另一个函数,因此无法绕过 Intel CPU 上的额外 stack-sync uop。在其他情况下,您可能只需要溢出一些函数参数以便在 call 之后使用。 (尽管编译器通常会 push rbxmov rbx,rdi 将 arg 保存在 call-preserved 寄存器中,而不是 spilling/reloading arg 本身,以缩短关键路径。)

我选择了 2x 4 字节 args 这样我们就可以达到 1 push 的 16 字节对齐边界,所以我们可以优化掉 sub rsp, ##(或虚拟 push)完全.

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

如果你需要为超过一个 8 字节的块预留 space,并且没有任何有用的东西可以存储在那里,一定要使用 sub 而不是在最后一个有用的 PUSH 之后的多个虚拟 PUSH。但如果你有有用的东西要存储,推 imm8 或推 imm32,或推 reg 都很好。

我们可以看到编译器使用带有 ICC 输出的 "canned" 序列的更多证据:它在调用的 arg 设置中使用 lea rdi, [rsp]。似乎他们没有想到去寻找一个局部地址被寄存器直接指向的特殊情况,没有偏移量,允许 mov 而不是 lea。 (.)


一个不使局部变量连续的有趣示例是上面的一个版本,其中包含 3 个参数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

这比 compiler-generated 代码小得多 code-size,因为 mov [rsp+16], 2 必须使用 mov r/m32, imm32 编码,使用 4 字节立即数,因为没有 [= mov.

的 277=] 形式

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

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

但是,假设还有其他局部变量稍后会用到,我们可以将它们分配到我们提前溢出的任何局部变量之间的间隙中。所以 space 不必浪费,我们可以稍后再使用 mov [rsp+12], eax 来存储我们推送的两个 32 位值之间。


一个 long 的小数组,内容 non-constant

int ext_longarr(long *);
void longarr_arg(long a, long b, long c) {
    long arr[] = {a,b,c};
    ext_longarr(arr);
}

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

但它可以像这样存储一个参数数组:

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

有了更多的参数,我们开始获得更明显的好处,尤其是在 code-size 中,当更多的总函数用于存储到堆栈时。这是一个非常综合的例子,几乎什么都不做。我本可以使用 volatile int a = 1;,但一些编译器认为 extra-specially.


逐渐构建栈帧的原因

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

但这不对,因为 alloca 和 C99 variable-length 数组会违反这一点。可能存在编译器本身之外的某种工具链原因导致不寻求这种优化。

This gcc mailing list post about disabling -maccumulate-outgoing-args for tune=default (in 2014) was interesting. It pointed out that more push/pop led to larger unwind info (.eh_frame section), but that's metadata that's normally never read (if no exceptions), so larger total binary but smaller / faster code. Related: this shows what -maccumulate-outgoing-args 适用于 gcc code-gen.

显然,我选择的示例很简单,我们 push 未修改输入参数。更有趣的是,当我们从 args(以及它们指向的数据和全局变量等)计算寄存器中的一些东西时,在我们想要溢出的值之前。

如果您必须 spill/reload 函数入口和之后的 pushes 之间的任何东西,您将在 Intel 上创建额外的 stack-sync 微指令。在 AMD 上,push rbx/blah blah/mov [rsp-32], eax(溢出到红色区域)/blah blah/push rcx/imul ecx, [rsp-24], 12345(重新加载较早的溢出仍然是 red-zone,具有不同的偏移量)

混合 push[rsp] 寻址模式是 lss efficient(在 Intel CPU 上因为 stack-sync uops),所以编译器必须仔细权衡权衡以确保它们不会让事情变得更慢。 sub / mov 是 well-known 在所有 CPU 上都能很好地工作,即使它在 code-size 中可能代价高昂,尤其是对于小常量。

"It's hard to keep track of the offsets" 是一个完全虚假的论点。这是一台电脑; re-calculating 从变化的引用偏移是它在使用 push 将函数参数放入堆栈时无论如何都必须做的事情。我认为编译器可能 运行 遇到问题(即需要更多 special-case 检查和代码,使它们编译得更慢)如果他们有超过 128B 的局部变量,所以你不能总是 mov 存储在使用未来的 push 指令将 RSP 向下移动之前,低于 RSP(仍然是 red-zone)。

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

有一个 mostly-fixed 序言和访问当地人的秘诀当然更简单。

This requires disabling stack frames as well though.

实际上并没有。简单的堆栈帧初始化可以使用 enterpush ebp \ mov ebp, esp \ sub esp, x (或者可以使用 lea esp, [ebp - x] 代替 sub)。除了这些之外,还可以将值压入堆栈以初始化变量,或者只是压入任何随机寄存器以移动堆栈指针而不初始化为任何特定值。

这是我的一个项目中的示例(针对 16 位 8086 real/V 86 模式):https://bitbucket.org/ecm/symsnip/src/ce8591f72993fa6040296f168c15f3ad42193c14/binsrch.asm#lines-1465

save_slice_farpointer:
[...]
.main:
[...]
    lframe near
    lpar word,  segment
    lpar word,  offset
    lpar word,  index
    lenter
    lvar word,  orig_cx
     push cx
    mov cx, SYMMAIN_index_size
    lvar word,  index_size
     push cx
    lvar dword, start_pointer
     push word [sym_storage.main.start + 2]
     push word [sym_storage.main.start]

lenter 宏设置(在本例中)仅 push bp \ mov bp, sp 然后 lvar 设置堆栈帧中变量的偏移量(从 bp)的数字定义。我没有从 sp 中减去,而是通过推入变量各自的堆栈槽来初始化变量(这也保留了所需的堆栈 space)。