为什么这个函数将RAX作为第一个操作推送到堆栈?

JCx*_*JCx 22 c++ x86 assembly x86-64 abi

在下面的C++源代码的汇编中.为什么RAX被推入堆栈?

正如我从ABI理解的那样,RAX可以包含来自调用函数的任何内容.但是我们将它保存在这里,然后将堆栈移回8个字节.所以堆栈上的RAX,我认为只与std::__throw_bad_function_call()操作相关......?

代码:-

#include <functional> 

void f(std::function<void()> a) 
{
  a(); 
}
Run Code Online (Sandbox Code Playgroud)

gcc.godbolt.org使用Clang 3.7.1 -O3 输出:

f(std::function<void ()>):                  # @f(std::function<void ()>)
        push    rax
        cmp     qword ptr [rdi + 16], 0
        je      .LBB0_1
        add     rsp, 8
        jmp     qword ptr [rdi + 24]    # TAILCALL
.LBB0_1:
        call    std::__throw_bad_function_call()
Run Code Online (Sandbox Code Playgroud)

我确定原因很明显,但我很难弄清楚.

这是一个没有std::function<void()>包装器的尾部调用,用于比较:

void g(void(*a)())
{
  a(); 
}
Run Code Online (Sandbox Code Playgroud)

琐碎的:

g(void (*)()):             # @g(void (*)())
        jmp     rdi        # TAILCALL
Run Code Online (Sandbox Code Playgroud)

Ben*_*ela 19

64位ABI要求堆栈是一个前对准的16个字节call的指令.

call在堆栈上推送一个8字节的返回地址,这会破坏对齐,因此编译器需要做一些事情以在下一次之前将堆栈再次对齐到16的倍数call.

(ABI设计选择在需要对齐之前call而不是之后具有次要优势,即如果在堆栈上传递任何args,则此选择使第一个arg 16B对齐.)

推不关心值效果很好,并且可以效率比sub rsp, 8用栈引擎的CPU.(见评论).

  • 实际上它是另一种方式.在调用之前,堆栈必须对齐,因此在调用之后它是未对齐的并且必须重新对齐. (3认同)
  • @Gene:`sub rsp,8`需要额外的uop,以便堆栈引擎将其rsp的偏移值与无序内核中的值同步.因此,在现代英特尔CPU(但不是AMD)上,实际上更有效的做一次"推送"垃圾而不是手动修改`rsp`只有8个.(堆栈引擎使`push` /`pop成为可能`是单uop指令,而不需要额外的uop来修改`rsp`).有关详细信息,请参阅[Agner Fog的microarch.pdf](http://agner.org/optimize/).堆栈引擎是Pentium-M的新产品,但AMD也有它的专利共享协议. (2认同)

Mic*_*tch 9

原因push rax是在采用分支的情况下,将堆栈对齐回16字节边界以符合64位System V ABIje .LBB0_1.堆栈上的值无关紧要.另一种方法是从减去8 RSPsub rsp, 8.ABI以这种方式陈述对齐:

输入参数区域的末尾应在16(32,如果在堆栈上传递__m256)字节边界上对齐.换句话说,当控制转移到函数入口点时,值(%rsp + 8)始终是16(32)的倍数.堆栈指针%rsp始终指向最新分配的堆栈帧的末尾.

在调用函数之前,f根据调用约定,堆栈是16字节对齐的.控制经由转印后CALLf返回地址被放置在堆由8错开在堆栈上push rax是从减去8的简单方式RSP并再次重新调整它.如果分支被带到call std::__throw_bad_function_call()堆栈将正确对齐以使该调用工作.

在比较结束的情况下,一旦add rsp, 8执行指令,堆栈将像在函数入口处一样出现.在返回地址CALLER到功能f现在将回到堆栈的顶部和堆栈将被再次8错位.这就是我们想要的,因为正在进行TAIL CALLjmp qword ptr [rdi + 24]以将控制转移到该功能a.这将JMP的功能没有CALL它.当函数a执行RET时,它将直接返回到调用的函数f.

在更高的优化级别,我会期望编译器应该足够聪明以进行比较,并让它直接落入JMP..LBB0_1然后标签处的内容可以将堆栈对齐到16字节边界,以便call std::__throw_bad_function_call()正常工作.


正如@CodyGray指出的那样,如果你使用优化级别或更高级别的GCC(不是CLANG)-O2,那么生成的代码似乎更合理.Godbolt的GCC 6.1输出是:

f(std::function<void ()>):
        cmp     QWORD PTR [rdi+16], 0     # MEM[(bool (*<T5fc5>) (union _Any_data &, const union _Any_data &, _Manager_operation) *)a_2(D) + 16B],
        je      .L7 #,
        jmp     [QWORD PTR [rdi+24]]      # MEM[(const struct function *)a_2(D)]._M_invoker
.L7:
        sub     rsp, 8    #,
        call    std::__throw_bad_function_call()        #
Run Code Online (Sandbox Code Playgroud)

这段代码更符合我的预期.在这种情况下,似乎GCC的优化器可以比CLANG更好地处理这种代码生成.


Pet*_*des 6

在其他情况下,clang 通常会在返回一个pop rcx.

使用push对代码大小的效率有好处(push只有 1 个字节,而对于 4 个字节sub rsp, 8),并且在 Intel CPU 上也有 uops。(不需要堆栈同步 uop,如果您rsp直接访问,您将获得它,因为call将我们带到当前函数顶部的 uop 使堆栈引擎“脏”)。

这个冗长而杂乱的答案讨论了使用push rax/pop rcx对齐堆栈的最坏情况下的性能风险,以及是否raxrcx是寄存器的好选择。 (对不起,让这个说得太长了。)

(TL:DR:看起来不错,可能的缺点通常很小,并且在常见情况下的优点是值得的。不过,如果alax“脏”,部分寄存器停顿可能是 Core2/Nehalem 上的问题。没有其他 64 - 位功能的 CPU 有很大的问题(因为它们不会重命名部分 reg,或有效地合并),并且 32 位代码需要 1 个以上的额外代码push才能将堆栈对齐 16 个,call除非它已经在保存/恢复一些调用-保留 regs 供自己使用。)


使用push rax而不是sub rsp, 8引入了对 的旧值的依赖rax,因此如果 的值rax是长延迟依赖链(和/或缓存未命中)的结果,您会认为它可能会减慢速度。

例如,调用者可能做了一些与rax函数 args 无关的缓慢的事情,比如var = table[ x % y ]; var2 = foo(x);

# example caller that leaves RAX not-ready for a long time

mov   rdi, rax              ; prepare function arg

div   rbx                   ; very high latency
mov   rax, [table + rdx]    ; rax = table[ value % something ], may miss in cache
mov   [rsp + 24], rax       ; spill the result.

call  foo                   ; foo uses push rax to align the stack
Run Code Online (Sandbox Code Playgroud)

幸运的是,乱序执行在这里会做得很好。

push不会使价值rsp依赖rax。(它要么由堆栈引擎处理,要么在非常旧的 CPU 上push解码为多个 uops,其中一个更新rsp独立于存储 的 uops rax。存储地址和存储数据 uops 的微push融合成为单个融合域uop,即使商店总是采用 2 个未融合域的 uop。)

只要不依赖于输出push rax/ pop rcx,乱序执行就不是问题。如果push rax因为rax没有准备好而不得不等待,它不会导致ROB(ReOrder Buffer)填满并最终阻塞后面独立指令的执行。即使没有,ROB 也会填满,push因为产生的指令很慢rax,并且调用者中的任何指令在调用rax之前消耗的指令甚至更旧,并且rax在准备好之前也不能退出。在异常/中断的情况下,退休必须按顺序发生。

(我不认为缓存未命中加载可以在加载完成之前退出,只留下一个加载缓冲区条目。但即使可以,在没有读取的情况下在调用破坏的寄存器中产生结果也是没有意义的它与另一条指令,然后再进行callrax我们push可以做同样的事情之前,调用者的指令绝对不能执行/退出

rax确实准备好时,push可以在几个周期内执行和退出,允许后面的指令(已经被乱序执行)也退出。store-address uop 将已经执行,我假设 store-data uop 可以在被分派到 store 端口后在一两个周期内完成。一旦数据写入存储缓冲区,存储就可以退出。对 L1D 的承诺发生在退休后,当时已知该商店是非投机性的。

因此,即使在最坏的情况下,产生的指令rax非常慢,导致 ROB 填满大部分已经执行并准备退出的push rax独立指令,在独立指令之前只需要执行几个额外的延迟周期可以退休后。(并且呼叫者的一些指令将首先退出,甚至在我们push退休之前在 ROB 中留出一些空间。)


push rax必须等待的A将占用一些其他微体系结构资源,从而减少一个条目,用于查找其他后续指令之间的并行性。(一个add rsp,8可以执行的只会消耗一个 ROB 条目,而不是其他的。)

它将用完无序调度程序(又名预订站/RS)中的一项。store-address uop 可以在有空闲周期时立即执行,因此只剩下 store-data uop。该pop rcxUOP的加载地址是准备好了,所以应该派遣到负载端口和执行。(当pop加载执行时,它发现它的地址与push存储缓冲区(又名内存顺序缓冲区)中的不完整存储匹配,因此它设置了存储转发,这将在存储数据 uop 执行后发生。这可能会消耗一个负载缓冲区条目。)

即使是像Nehalem这样的旧 CPU也有 36 个入口 RS,而 Sandybridge 为 54,Skylake 为 97。在极少数情况下,让 1 个条目占用的时间比平时更长,这没什么好担心的。执行两个 uops (stack-sync + sub)的替代方案更糟。

题外话
ROB 比 RS 大,128(Nehalem),168(Sandybridge),224(Skylake)。(它持有从发布到退役的融合域 uops,而 RS 持有从发布到执行的未融合域 uops)。以每时钟 4 uops 的最大前端吞吐量计算,Skylake 上的延迟隐藏周期超过 50 个。(较旧的 uarches 不太可能长时间维持每个时钟 4 uop ......)

ROB 大小决定了用于隐藏慢速独立操作的无序窗口。(除非寄存器文件大小限制是一个较小的限制)。RS 大小决定了在两个独立的依赖链之间寻找并行性的无序窗口。(例如,考虑一个 200 uop 循环体,其中每次迭代都是独立的,但在每次迭代中,它是一个没有太多指令级并行性的长依赖链(例如a[i] = complex_function(b[i]))。Skylake 的 ROB 可以容纳超过 1 次迭代,但我们无法从中获得 uops下一次迭代到 RS,直到我们在当前迭代结束的 97 uop 内。如果 dep 链没有比 RS 大小大太多,则 2 次迭代的 uops 大部分时间都在飞行中。)


有些情况push rax / pop rcx可能更危险

这个函数的调用者知道它rcx被调用破坏了,所以不会读取这个值。但是它可能rcx在我们返回之后有一个错误的依赖,比如bsf rcx, rax/jnztest eax,eax/ setz cl最近的 Intel CPU 不再重命名 low8 部分寄存器,因此setcc clrcx. bsf如果源为 0,实际上它的目的地保持不变,即使英特尔将其记录为未定义的值。AMD 记录了未经修改的行为。

错误的依赖可能会创建一个循环携带的 dep 链。另一方面,如果我们的函数rcx使用依赖于其输入的指令编写,则错误的依赖项无论如何都可以做到这一点。

使用push rbx/pop rbx来保存/恢复我们不打算使用的调用保留寄存器会更糟。调用者可能在我们返回后读取它,并且我们已经在调用者对该寄存器的依赖链中引入了存储转发延迟。(另外,它可能更有可能rbx写在 之前call,因为调用者想要在整个调用中保留的任何内容都将被移动到调用保留的寄存器中,例如rbxrbp。)


在CPU上有局部寄存器摊位(英特尔前的Sandy Bridge) ,阅读raxpush可能造成的酷睿2 / Nehalem的一个摊位或2-3个周期,如果主叫方做了一些像setcc alcall。Sandybridge 在插入合并 uop 时不会停顿,而且Haswell 和后来根本不会单独重命名 low8 寄存器rax

对于push不太可能使用low8的寄存器会很好。如果编译器试图避免REX前缀码量的原因,他们会避免dilsil,所以rdirsi就不太可能有局部寄存器的问题。但不幸的是,gcc 和 clang 似乎不喜欢使用dlcl作为 8 位暂存寄存器,使用dilsil什至在没有其他任何东西使用的小函数中使用rdxor rcx。(尽管在某些 CPU 中缺少 low8 重命名意味着setcc cl对 old 有错误的依赖性rcx,因此setcc dil如果标志设置依赖于 arg 中的函数,则更安全rdi。)

pop rcx最后“清除”rcx任何部分寄存器的东西。因为cl用于移位计数,并且函数有时会写入,cl即使它们本来可以写入ecx。(IIRC 我见过 clang 这样做。gcc 更强烈地支持 32 位和 64 位操作数大小以避免部分寄存器问题。)


push rdi在很多情况下可能是一个不错的选择,因为函数的其余部分也读取rdi,因此引入另一个依赖于它的指令不会有什么坏处。但是,push如果rax在之前准备好rdi,它确实会阻止乱序执行。


另一个潜在的缺点是在加载/存储端口上使用循环。但它们不太可能饱和,替代方案是 ALU 端口的 uops。使用您从 Intel CPU 上获得的额外堆栈同步 uop sub rsp, 8,这将是函数顶部的 2 个 ALU uop。