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

时间:2018-03-26 06:42:03

标签: c++ assembly x86 compiler-optimization micro-optimization

我相信推/弹指令会产生更紧凑的代码,甚至可能会稍快一点。这也需要禁用堆栈帧。

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

以下是有关此问题和类似问题的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

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

2 个答案:

答案 0 :(得分:2)

  

这也需要禁用堆栈帧。

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

这是我其中一个项目的示例(适用于16位8086实/ 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中减去,而是通过将变量压入它们各自的堆栈插槽(也保留了所需的堆栈空间)来初始化变量。

答案 1 :(得分:1)

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

但如果您不小心,可以通过将push[rsp+x]寻址模式混合,使用额外的堆栈同步uops来减慢速度。 pop听起来并不有用,只有push 。正如the forum thread you linked建议的那样,您只能将其用于本地人的初始商店;以后重新加载和存储应使用[rsp+8]等常规寻址模式。我们谈论试图完全避免mov加载/存储,我们仍然希望随机访问堆栈插槽,我们从寄存器中溢出局部变量!

  

现代代码生成器避免使用PUSH。它在今天的处理器上是低效的,因为它修改了堆栈指针,这是一个超级标量核心。 (Hans Passant)

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

对于PUSH / POP / CALL / RET,

Intel since Pentium-M and AMD since Bulldozer(?) have a "stack engine"跟踪RSP的变化,延迟为零,没有ALU uops。许多真正的代码仍在使用push / pop,因此CPU设计人员添加了硬件以使其高效。现在我们可以在调整性能时使用它们(小心!)。请参阅Agner Fog's microarchitecture guide and instruction tables和他的asm优化手册。他们非常出色。 (以及x86 tag wiki中的其他链接。)

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

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

compilers have been using dummy push/pop instructions just to adjust the stack by 8 bytes 暂时有点好笑,因为它非常便宜和紧凑(如果你曾经做过一次,不是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查看所有其余代码的链接,以及许多不同的遗漏优化和愚蠢的代码(请参阅我的评论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堆栈空间。 (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

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

请注意,在64位模式下,32-bit push is not available。因此,我们仍然需要直接引用rsp来获取qword的上半部分。但如果我们的变量是uint64_t,或者我们并不关心使它们连续,我们可以使用push

在这种情况下,我们必须明确地引用RSP来获取指向本地的指针以传递给另一个函数,因此在英特尔CPU上没有额外的堆栈同步uop。在其他情况下,您可能只需要在call之后溢出一些函数args。 (尽管通常编译器会push rbxmov rbx,rdi将arg保存在调用保留的寄存器中,而不是溢出/重新加载arg本身,以缩短关键路径。)

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

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

如果您需要为多个8字节块保留空间,并且还没有任何有用的东西存储在那里,请务必使用sub 而不是多个虚拟在最后一次有用的PUSH之后推动。但是如果你有很多有用的东西需要存储,推送imm8或推送imm32,或推送reg是好的。

我们可以看到更多的编译器使用" canned"具有ICC输出的序列:它在调用的arg设置中使用lea rdi, [rsp]。看起来他们并没有想到要查找寄存器直接指向的本地地址的特殊情况,没有偏移,允许mov而不是lea。 (mov is definitely not worse, and better on some CPUs。)

不使本地人连续的一个有趣例子是以上版本的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

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

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);
}

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

但它可能存储了一个像这样的args数组:

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

随着更多的args,我们开始获得更明显的好处,特别是在代码大小时,当更多的总函数用于存储到堆栈时。这是一个非常合成的例子,几乎没有其他任何东西。我本可以使用volatile int a = 1;,但有些编译器特别对待它。

逐渐构建堆栈帧的原因

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

但那可能是对的,因为alloca和C99可变长度数组会违反这一点。编译器本身之外可能存在某种工具链原因,因为它没有寻找这种优化。

mov。它指出更多的推/弹导致更大的展开信息(.eh_frame部分),但那些通常永远不会读取的元数据(如果没有例外),所以更大的总二进制但更小/更快的代码。相关:This gcc mailing list post about disabling -maccumulate-outgoing-args for tune=default (in 2014) was interesting适用于gcc code-gen。

显然,我选择的例子很简单,我们不会修改输入参数push。更有意思的是,在我们想要泄漏的值之前,我们从args(以及它们指向的数据和全局变量等)计算寄存器中的一些东西。

如果您必须在功能输入和后来的push es之间泄漏/重新加载任何内容,则需要在Intel上创建额外的堆栈同步uops。在AMD上,做push rbx / blah blah / mov [rsp-32], eax(溢出到红色区域)/ blah blah / push rcx / imul ecx, [rsp-24], 12345仍然是一个胜利(重新加载早些时候溢出的仍然是红区,有不同的偏移量)

混合push[rsp]寻址模式的效率较低(由于堆栈同步uops,在Intel CPU上),因此编译器必须仔细权衡权衡确定他们不会让事情变慢。众所周知,sub / mov可以在所有CPU上运行良好,即使它在代码大小方面成本很高,特别是对于小常量。

"很难跟踪偏移量"是一个完全虚假的论点。它是一台电脑;当使用push将函数args放在堆栈上时,重新计算来自变化引用的偏移是它必须要做的事情。我认为编译器可能遇到问题(即需要更多的特殊情况检查和代码,使它们编译得更慢),如果他们有超过128B的本地人,那么你不能总是mov存储在RSP之下(进入在使用未来push指令移动RSP之前,还有什么红色区域。

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

对于序言和访问当地人而言,拥有大部分固定的食谱当然更简单。