在其他情况下,clang 通常会在返回 with a pop rcx 之前修复堆栈。
使用push 对代码大小的效率有好处(push 只有 1 个字节,而sub rsp, 8 是 4 个字节),在 Intel CPU 上的微指令上也有好处。 (不需要堆栈同步 uop,如果您直接访问 rsp,就会得到它,因为将我们带到当前函数顶部的 call 会使堆栈引擎“脏”)。
这个冗长而漫不经心的答案讨论了使用push rax / pop rcx 对齐堆栈的最坏情况下的性能风险,以及rax 和rcx 是否是不错的寄存器选择。(抱歉拖了这么久。)
(TL:DR: 看起来不错,可能的缺点通常很小,而在常见情况下的优点是值得的。如果 al 或 ax 是 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
幸运的是,乱序执行在这里会做得很好。
push 不会使rsp 的值依赖于rax。 (它要么由堆栈引擎处理,要么在非常旧的 CPU 上 push 解码为多个微指令,其中一个更新 rsp 独立于存储 rax 的微指令。存储地址和存储地址的微融合data uop 让 push 成为单个融合域 uop,即使存储总是采用 2 个未融合域 uop。)
只要不依赖于输出push rax/pop rcx,乱序执行就不是问题。如果push rax 必须等待,因为rax 没有准备好,它不会导致 ROB(重新排序缓冲区)填满并最终阻止后续独立指令的执行。即使没有push,ROB 也会填满,因为生成rax 的指令很慢,并且调用者中的任何指令在调用更早之前消耗rax,并且在rax 之前也不能退休准备好。如果出现异常/中断,必须按顺序退休。
(我不认为缓存未命中加载可以在加载完成之前退出,只留下一个加载缓冲区条目。但即使可以,在调用破坏中产生结果也是没有意义的在创建call 之前,在不读取另一条指令的情况下注册。消耗rax 的调用者指令肯定不能执行/退出,直到我们的push 可以执行相同操作。)
当rax 准备就绪时,push 可以在几个周期内执行和退出,从而允许后面的指令(已经乱序执行)也退出。存储地址 uop 已经执行,我假设存储数据 uop 可以在被分派到存储端口后的一两个周期内完成。一旦数据写入存储缓冲区,存储就可以退出。 L1D 承诺发生在退休后,此时商店被认为是非投机性的。
因此,即使在最坏的情况下,产生rax 的指令非常慢,导致 ROB 充满独立指令,这些指令大部分已经执行并准备退休,必须执行 push rax 只会导致在独立指令可以退休之后,在独立指令之前有几个额外的延迟周期。 (并且调用者的一些指令将首先退出,甚至在我们的push 退出之前在 ROB 中腾出一点空间。)
必须等待的push rax 会占用一些其他微架构资源,从而减少用于查找其他后续指令之间的并行性的条目。 (可以执行的 add rsp,8 只会消耗一个 ROB 条目,而不会消耗太多其他内容。)
它将用完乱序调度程序(又名预订站/RS)中的一个条目。一旦有空闲周期,存储地址 uop 就可以执行,因此只剩下存储数据 uop。 pop rcx uop 的加载地址已准备好,因此它应该分派到加载端口并执行。 (当pop加载执行时,它发现它的地址与存储缓冲区(又名内存顺序缓冲区)中不完整的push存储匹配,因此它设置了存储转发,这将在存储数据uop执行后发生. 这可能会消耗一个加载缓冲区条目。)
即使是像 Nehalem has a 36 entry RS, vs. 54 in Sandybridge 或 Skylake 中的 97 这样的旧 CPU。在极少数情况下,让 1 个条目占用比平时更长的时间是无需担心的。执行两个微指令(stack-sync + sub)的替代方案更糟糕。
(题外话)
ROB 比 RS、128 (Nehalem)、168 (Sandybridge)、224 (Skylake) 大。 (它拥有从发布到退役的融合域微指令,而 RS 拥有从发布到执行的非融合域微指令)。在每时钟 4 微秒的最大前端吞吐量下,这是 Skylake 上超过 50 个延迟隐藏周期。 (较旧的 uarch 不太可能维持每个时钟 4 微指令的时间……)
ROB 大小决定了隐藏慢速独立操作的无序窗口。 (Unless register-file size limits are a smaller limit)。 RS 大小决定了在两个独立的依赖链之间寻找并行性的无序窗口。 (例如,考虑一个 200 uop 循环体,其中每次迭代都是独立的,但在每次迭代中,它是一个没有太多指令级并行性的长依赖链(例如 a[i] = complex_function(b[i]))。Skylake 的 ROB 可以容纳超过 1 次迭代,但我们不能从下一次迭代中获取 uops 到 RS 直到我们在当前迭代结束的 97 uops 内。如果 dep 链没有比 RS 大小大太多,那么来自 2 次迭代的 uops 可能大部分时间都在飞行.)
在某些情况下push rax / pop rcx 可能更危险:
该函数的调用者知道rcx 被调用破坏,因此不会读取该值。但是在我们返回后它可能对rcx 有错误的依赖,比如bsf rcx, rax / jnz 或test eax,eax / setz cl。 Recent Intel CPUs don't rename low8 partial registers anymore, so setcc cl has a false dep on rcx。如果源为 0,bsf 实际上保持其目标未修改,即使英特尔将其记录为未定义值。 AMD 记录了未修改的行为。
错误的依赖可能会创建一个循环携带的 dep 链。另一方面,如果我们的函数使用依赖于其输入的指令写入 rcx,则错误的依赖项无论如何都可以做到这一点。
使用push rbx/pop rbx 来保存/恢复我们不会使用的调用保留寄存器会更糟糕。调用者可能会在我们返回后读取它,并且我们会在该寄存器的调用者依赖链中引入存储转发延迟。 (另外,rbx 更有可能写在call 之前,因为调用者想要在调用中保留的任何内容都将被移动到调用保留寄存器,如rbx 和rbp。)
在具有部分寄存器停顿的 CPU 上(Intel pre-Sandybridge),如果调用者已经完成,使用 push 读取 rax 可能会导致 Core2 / Nehalem 停顿或 2-3 个周期call 之前的 setcc al 之类的东西。 Sandybridge 在插入合并微指令时不会停止,Haswell and later don't rename low8 registers separately from rax at all.
最好push 一个不太可能使用其low8 的寄存器。如果编译器出于代码大小的原因试图避免使用 REX 前缀,他们会避免使用 dil 和 sil,因此 rdi 和 rsi 不太可能出现部分寄存器问题。但不幸的是,gcc 和 clang 似乎不赞成使用 dl 或 cl 作为 8 位暂存寄存器,使用 dil 或 sil,即使在没有其他任何东西使用 rdx 或 rcx . (虽然在某些 CPU 中缺少 low8 重命名意味着 setcc cl 对旧的 rcx 有错误的依赖关系,所以如果标志设置依赖于 rdi 中的函数 arg,setcc dil 会更安全。)
pop rcx 最后“清理”rcx 任何部分注册的东西。由于cl 用于移位计数,并且函数有时只写cl,即使它们本可以写ecx。 (IIRC 我见过 clang 这样做。gcc 更倾向于 32 位和 64 位操作数大小以避免部分寄存器问题。)
push rdi 在很多情况下可能是一个不错的选择,因为函数的其余部分也读取rdi,因此引入另一条依赖于它的指令不会有什么坏处。不过,如果rax 在rdi 之前准备好,它确实会阻止乱序执行,以免妨碍push。
另一个潜在的缺点是在加载/存储端口上使用循环。但它们不太可能饱和,替代方案是用于 ALU 端口的 uops。使用您从sub rsp, 8 获得的 Intel CPU 上的额外堆栈同步微指令,这将是函数顶部的 2 个 ALU 微指令。