我相信推/弹出指令将导致更紧凑的代码,甚至可能运行得更快。但是,这也需要禁用堆栈帧。
为了检查这一点,我将需要手工在汇编中重写一个足够大的程序(以进行比较),或者安装并研究其他一些编译器(以查看它们是否对此具有选项,并比较结果)。 。
这是关于此问题和类似问题的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编译器的次要错失优化。有一些代码大小,因此需要间接的性能。或者在某些情况下可能更直接地表现出少量性能,例如保存sub rsp
指令。
但是,如果您不小心,可以通过将push
与[rsp+x]
寻址模式混合使用额外的堆栈同步指令,从而降低处理速度。 pop
听起来没什么用,只是push
。就像the forum thread you linked所建议的那样,您仅将其用于本地人的初始存储。以后的重新加载和存储应使用[rsp+8]
这样的常规寻址模式。我们并不是在谈论尝试完全避免mov
的加载/存储,我们仍然希望随机访问我们从寄存器中溢出局部变量的堆栈插槽!
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. (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没有启用。 Intel since Pentium-M and AMD since Bulldozer(?) have a "stack engine",用于PUSH / POP / CALL / RET,以零延迟和无ALU指令跟踪对RSP的更改。许多实际代码仍在使用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个融合域对象的总和:2个存储和一个堆栈同步。在函数输入时,“堆栈引擎”已经处于非零偏移状态(来自父级中的
call
),因此在第一次直接引用RSP之前使用一些push
指令根本不需要额外的投入。 (除非我们被另一个带有jmp
的函数调用,并且该函数在pop
之前没有对jmp
进行任何调用)。compilers have been using dummy push/pop instructions just to adjust the stack by 8 bytes 有一阵子是很有趣的,因为它是如此的便宜和紧凑(如果您这样做的话,分配80个字节要花10次),但是却没有利用它来存储有用的数据。堆栈在高速缓存中几乎总是很热,现代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。因此,对于qword的上半部分,我们仍然必须直接引用rsp
。但是,如果我们的变量是uint64_t,或者我们不关心使它们连续,那么我们可以使用push
。在这种情况下,我们必须显式引用RSP才能获得指向本地变量的指针以传递给另一个函数,因此在Intel CPU上没有多余的堆栈同步uop。在其他情况下,您可能只需要在
call
之后添加一些函数args即可使用。 (尽管通常编译器会使用push rbx
和mov rbx,rdi
将arg保存在保留调用的寄存器中,而不是溢出/重新加载arg本身,以缩短关键路径。)我选择了2个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个存储转换为1的指令合并在一起是不值得的。)如果您需要为一个以上的8字节块保留空间,并且还没有任何有用的存储空间,请在最后一个有用的PUSH之后使用
sub
而不是多个虚拟PUSH。但是,如果您要存储有用的东西,则推送imm8或push imm32或push reg都很好。我们可以看到编译器使用带有ICC输出的“固定”序列的更多证据:它在arg设置中使用
lea rdi, [rsp]
进行调用。似乎他们没有想到要寻找由寄存器直接指向的本地地址(无偏移)的特殊情况,允许使用mov
而不是lea
。 ( mov
is definitely not worse, and better on some CPUs。)一个不使本地人连续的有趣示例是上述版本的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
这比编译器生成的代码小得多,因为
mov [rsp+16], 2
必须使用mov r/m32, imm32
编码,并且使用4字节立即数,因为 mov
没有sign_extended_imm8形式。push imm8
非常紧凑,只有2个字节。 mov dword ptr [rsp+8], 1
是8个字节:操作码+ modrm + SIB + disp8 + imm32。 (RSP作为基址寄存器始终需要一个SIB字节; base = RSP的ModRM编码是存在的SIB字节的转义代码。使用RBP作为帧指针可以对本地进行更紧凑的寻址(每insn减少1个字节),但是花费了3条额外的指令来设置/拆除,并绑定(bind)了一个寄存器。但是,它避免了对RSP的进一步访问,避免了堆栈同步的操作。有时实际上可能是一个胜利。)在您的本地人之间留下空白的一个缺点是,它可能会在以后击败负载或存储合并机会。如果您(编译器)需要在某个地方复制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
随着更多的参数,我们开始获得更多明显的好处,特别是在代码大小上,当更多的全部功能都花在了存储到堆栈上时。这是一个非常综合的示例,几乎无所作为。我本可以使用
volatile int a = 1;
,但是某些编译器对此进行了特殊处理。不逐步构建堆栈框架的原因
(可能是错误的)异常处理和调试格式的堆栈展开,我认为不支持任意使用堆栈指针进行播放。因此,至少在执行任何
call
指令之前,一个函数应该具有与该函数中所有将来函数调用相同的偏移RSP。但这是不对的,因为
alloca
和C99可变长度数组将违反此规定。编译器外部可能存在某种工具链原因,原因是它们不寻求这种优化。This gcc mailing list post about disabling
-maccumulate-outgoing-args
for tune=default (in 2014) was interesting。它指出,更多的推入/弹出操作会导致更大的展开信息(.eh_frame
部分),但这通常是从不读取的元数据(如果没有异常(exception)),因此二进制文件总数较大,而代码则较小/速度更快。相关:this shows what -maccumulate-outgoing-args
用于gcc代码生成。显然,我选择的示例很简单,我们使用
push
修改了未修改的输入参数。更有趣的是,当我们在拥有要溢出的值之前,根据args(它们指向的数据以及全局变量等)在寄存器中计算某些东西时。如果您必须在函数入口和以后的
push
之间溢出/重新加载任何内容,则可以在Intel上创建额外的堆栈同步uops。在AMD上,做push rbx
/ blah blah / mov [rsp-32], eax
(溢出到红色区域)/ blah blah / push rcx
/ imul ecx, [rsp-24], 12345
(仍然从红色区域重新加载较早的溢出,具有不同的偏移量)仍然可能是一个胜利。混合
push
和[rsp]
寻址模式会降低的效率(在Intel CPU上由于堆栈同步uops),因此编译器必须谨慎权衡这些折衷方案,以确保它们不会使事情变慢。众所周知,sub
/ mov
在所有CPU上都能很好地工作,即使代码大小可能会非常昂贵,尤其是对于较小的常量。完全是虚假的论点,“很难跟踪偏移量”。这是一台电脑;当使用
push
将函数args放在堆栈上时,无论如何都要从变化的引用中重新计算偏移量。我认为如果编译器具有超过128B的本地变量,则编译器可能会遇到问题(即,需要更多特殊情况下的检查和代码,从而使它们的编译速度变慢),因此,您始终无法始终在RSP下存储mov
(进入仍然是红色区域的区域) ),然后使用将来的push
指令将RSP向下移动。编译器已经考虑了多个折衷方案,但是当前逐渐增加堆栈框架并不是他们考虑的事情之一。在Pentium-M引入堆栈引擎之前
push
效率不高,因此就重新设计编译器如何考虑堆栈布局选择而言,甚至可以使用有效的push
也是最近的更改。对于序言和访问本地人来说,基本上采用固定的食谱无疑会更简单。
关于c++ - 哪些C/C++编译器可以使用推式弹出指令创建局部变量,而不仅仅是增加esp一次?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/49485395/