为什么这个函数会把RAX压栈作为第一个操作呢?

Why does this function push RAX to the stack as the first operation?

在下面的 C++ 源代码的汇编中。为什么 RAX 会被压入栈?

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

代码:-

#include <functional> 

void f(std::function<void()> a) 
{
  a(); 
}

输出,来自 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()

我确信原因很明显,但我正在努力弄清楚。

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

void g(void(*a)())
{
  a(); 
}

琐碎的:

g(void (*)()):             # @g(void (*)())
        jmp     rdi        # TAILCALL

64-bit ABI 要求堆栈在 call 指令之前对齐到 16 字节。

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

(要求在 call 之前而不是之后对齐的 ABI 设计选择具有较小的优势,即如果在堆栈上传递了任何 args,此选择会使第一个 arg 16B 对齐。)

推送无关值效果很好,并且比 上的 sub rsp, 8 效率 更高 。 (见评论)

push rax 的原因是为了在采用 je .LBB0_1 分支的情况下将堆栈重新对齐到 16 字节边界以符合 64-bit System V ABI。放在堆栈上的值不相关。另一种方法是用 sub rsp, 8RSP 中减去 8。 ABI 以这种方式声明对齐方式:

The end of the input argument area shall be aligned on a 16 (32, if __m256 is passed on stack) byte boundary. In other words, the value (%rsp + 8) is always a multiple of 16 (32) when control is transferred to the function entry point. The stack pointer, %rsp, always points to the end of the latest allocated stack frame.

在调用函数 f 之前,堆栈按照调用约定按 16 字节对齐。在通过 CALL 将控制权转移到 f 之后,return 地址被放置在堆栈上,使堆栈错位 8。push rax 是一种简单的方法从 RSP 中减去 8 并再次重新对齐。如果分支被带到 call std::__throw_bad_function_call(),堆栈将正确对齐以使该调用正常工作。

在比较失败的情况下,执行add rsp, 8指令后,堆栈将出现在函数入口处。 CALLER 函数 f 的 return 地址现在将回到堆栈顶部,堆栈将再次错位 8。这就是我们想要的,因为正在用 jmp qword ptr [rdi + 24] 创建 TAIL CALL 以将控制权转移到函数 a。这将 JMP 到函数而不是 CALL 它。当函数 a 执行 RET 时,它将 return 直接返回到调用 f.

的函数

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


正如@CodyGray 指出的那样,如果您使用优化级别为 -O2 或更高的 GCC(不是 CLANG) ,产生的代码确实看起来更合理。 GCC 来自 Godbolt 的 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()        #

这段代码更符合我的预期。在这种情况下,GCC 的优化器可能比 CLANG.

更好地处理此代码生成

在其他情况下,clang 通常会在 returning with a pop rcx 之前修复堆栈。

使用 push 提高代码大小的效率(push 仅 1 个字节,而 sub rsp, 8 为 4 个字节),在 Intel CPU秒。 (不需要堆栈同步 uop,如果你直接访问 rsp 你会得到它,因为 call 把我们带到当前函数的顶部使得堆栈引擎 "dirty" ).

这个长篇大论的答案讨论了使用 push rax / pop rcx 对齐堆栈的最坏情况下的性能风险,以及 raxrcx 是寄存器的不错选择。(很抱歉让这么长。)

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


使用 push rax 而不是 sub rsp, 8 会引入对 rax 旧值的依赖,因此您认为它可能会变慢如果 rax 的值是长延迟依赖链的结果(and/or 高速缓存未命中),则事情会发生变化。

例如调用者可能使用 rax 做了一些与函数参数无关的缓慢操作,比如 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

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

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

只要不依赖于输出push rax / pop rcx,乱序执行是没有问题的。如果 push rax 因为 rax 没有准备好而必须等待,它不会 导致 ROB(重新排序缓冲区)填满并最终阻止执行后来独立教学。即使没有 push,ROB 也会填满,因为生成速度较慢的指令 rax,并且调用者在调用之前消耗 rax 的任何指令甚至更旧,并且无法退出直到 rax 准备就绪。在出现异常/中断的情况下,必须按顺序退出。

(我不认为缓存缺失加载可以在加载完成之前退出,只留下一个加载缓冲区条目。但即使可以,在调用中产生结果也没有意义- 在创建 call 之前没有用另一条指令读取它就破坏了寄存器。调用者的指令消耗 rax 绝对不能 execute/retire 直到我们的 push 可以做同样的事情。)

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

所以即使在最坏的情况下,产生 rax 的指令非常慢,以至于导致 ROB 填满了大部分已经执行并准备退出的独立指令,不得不执行 push rax 只会在独立指令退出后导致几个额外的延迟周期。 (并且一些调用者的指令将首先退出,甚至在我们的 push 退出之前在 ROB 中腾出一些空间。)


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

它将用完无序调度程序(又名保留站/RS)中的一个条目。一旦有空闲周期,存储地址微指令就可以执行,因此只剩下存储数据微指令。 pop rcx uop 的加载地址已准备就绪,因此它应该分派到加载端口并执行。 (当 pop 加载执行时,它发现它的地址与存储缓冲区(又名内存顺序缓冲区)中不完整的 push 存储匹配,因此它设置存储转发将发生在存储之后-data uop 执行。这可能会消耗一个加载缓冲区条目。)

甚至像 Nehalem has a 36 entry RS, vs. 54 in Sandybridge 这样的旧 CPU,或 Skylake 中的 97。在极少数情况下,保持 1 个条目占用的时间比平常更长,无需担心。执行两个 uops (stack-sync + sub) 的替代方案更糟糕。

(题外话)
ROB比RS大,128(Nehalem),168(Sandybridge),224(Skylake)。 (它持有从发行到退役的融合域微指令,而 RS 持有从发行到执行的非融合域微指令)。在每时钟 4 微指令的最大前端吞吐量下,这在 Skylake 上超过 50 个延迟隐藏周期。 (较旧的 uarches 不太可能维持每个时钟 4 微指令这么久...)

ROB 大小决定了乱序 window 用于隐藏缓慢的独立操作。 (Unless register-file size limits are a smaller limit)。 RS 大小决定了乱序 window,用于在两个独立的依赖链之间寻找并行性。 (例如,考虑一个 200 uop 的循环体,其中每次迭代都是独立的,但在每次迭代中它是一个长依赖链,没有太多指令级并行性(例如 a[i] = complex_function(b[i]))。Skylake 的 ROB 可以容纳超过 1 次迭代,但我们可以直到我们在当前迭代末尾的 97 微指令以内时,才从下一次迭代中获取微指令。如果 dep 链不比 RS 大小大很多,则来自 2 次迭代的微指令可能大部分时间都在运行时间。)


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

此函数的调用者知道 rcx 被调用破坏,因此不会读取该值。但是它可能在我们 return 之后对 rcx 有错误的依赖,比如 bsf rcx, rax / jnztest eax,eax / setz cl。如果源为 0,bsf 实际上会保留其目标未修改,即使英特尔将其记录为未定义的值。 AMD 记录了未修改行为。

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

使用 push rbx/pop rbx 到 save/restore 我们不打算使用的调用保留寄存器会更糟。调用者可能 在我们 return 之后读取它,并且我们会在该寄存器的调用者依赖链中引入存储转发延迟。 (此外,rbx 更有可能写在 call 之前,因为调用者想要在整个调用过程中保留的任何内容都将移动到调用保留寄存器,如 rbxrbp.)


On CPUs 部分寄存器停顿(Intel pre-Sandybridge),用 push 读取 rax 可能导致停顿或者如果调用者在 call 之前做了类似 setcc al 的事情,则在 Core2 / Nehalem 上有 2-3 个周期。 Sandybridge 在插入合并 uop 时不会停止,并且

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

pop rcx 在任何部分寄存器内容的末尾 "cleans" rcx。由于 cl 用于轮班计数,函数有时只写 cl 即使他们可以写 ecx 。 (IIRC 我见过 clang 这样做。gcc 更倾向于 32 位和 64 位操作数大小以避免部分寄存器问题。)


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


另一个潜在的缺点是在 load/store 端口上使用循环。但它们不太可能饱和,替代方案是 ALU 端口的 uops。使用英特尔 CPUs 上的额外堆栈同步微指令,您将从 sub rsp, 8 获得,这将是函数顶部的 2 个 ALU 微指令。