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

时间:2016-06-12 11:39:40

标签: c++ assembly x86 x86-64 abi

在下面的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

3 个答案:

答案 0 :(得分:18)

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

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

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

推动无关紧要的价值效果良好,并且CPUs with a stack engine上的sub rsp, 8 效率更高。 (见评论)。

答案 1 :(得分:8)

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

  

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

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

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

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

正如@CodyGray指出的那样,如果你使用优化级别为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 ),那么生成的代码似乎更合理。来自TAIL CALL GCC 6.1输出是:

"query": { 
      "wildcard": { 
        "Field1": { 
              "value": "*apple*" 
            } 
        }
     }

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

答案 2 :(得分:3)

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

使用push在代码大小方面具有效率上升(push仅为1个字节,而sub rsp, 8为4个字节),以及英特尔CPU上的uops。 (不需要堆栈同步uop,如果你直接访问rsp就可以获得,因为将我们带到当前函数顶部的call构成了堆栈引擎&#34脏#34;。)

这个漫长而漫无边际的回答讨论了使用push rax / pop rcx来对齐堆栈以及rax和{{1}是否存在最糟糕的性能风险是注册的好选择。(很抱歉这么久。)

(TL:DR:看起来很好,可能的缺点通常很小,而且在常见情况下的优势使得它值得。如果rcx或{{{}}或{{},部分注册停顿可能是Core2 / Nehalem的问题但是,&#34;脏&#34;,没有其他64位能力的CPU有大问题(因为他们不重命名部分注册表,或有效合并),32位代码需要超过1个额外的al将堆栈与另一个ax对齐16,除非它已经保存/恢复了一些保留/恢复保留的regs供自己使用。)

使用push代替call会导致依赖push rax 的旧值,因此您认为如果放慢速度,可能会降低速度sub rsp, 8的值是长延迟依赖链(和/或缓存未命中)的结果。

e.g。调用者可能在与rax无关的函数args做了一些事情,例如rax

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。 (它由堆栈引擎处理,或者在非常旧的CPU rsp上解码为多个uop,其中一个uup独立于存储rax的uop而更新push。 -fusion存储地址和存储数据uops让rsp成为单个融合域uop,即使存储总是占用2个未融合域uops。)

只要没有任何内容取决于输出rax / push,它就不会成为无序执行的问题。如果push rax必须等待,因为pop rcx尚未准备就绪,则无法导致 ROB(ReOrder缓冲区)填满并最终阻止执行后来的独立教学。即使没有push rax,ROB也会填满,因为生成rax的指令很慢,而且呼叫前的任何指令消耗push甚至更老,在rax准备就绪之前,我们不能退休。在异常/中断的情况下,退休必须按顺序进行。

(我不认为缓存失败加载可以在加载完成之前退出,只留下一个加载缓冲区条目。但即使可以,生成结果也没有意义。在发出rax之前,没有用另一条指令读取一个调用被破坏的寄存器。消耗rax的调用者的指令肯定无法执行/退出,直到我们的{{ 1}}也可以这样做。

call准备就绪时,rax可以在几个周期内执行和退出,允许后来的指令(已经无序执行)也退出。存储地址uop已经执行,我假设存储数据uop可以在分配到存储端口后的一个或两个周期内完成。一旦将数据写入存储缓冲区,存储就会退出。承诺L1D在退休后发生,当时知道商店是非投机性的。

因此,即使在最坏的情况下,产生push的指令太慢,导致ROB填充了大部分已经执行并准备退出的独立指令,必须执行{{1只有在退出之后的独立指令之前,才会导致一些额外的延迟周期。 (并且一些来电者的指示将首先退出,甚至在我们rax退休之前在ROB中留出一点空间。)

必须等待的push将占用其他一些微架构资源,只留下一个用于查找其他后续指令之间并行性的条目。 (可以执行的rax只会消耗一个ROB条目,而不会消耗其他内容。)

它将在无序调度程序(也称为Reservation Station / RS)中用尽一个条目。商店地址uop可以在一个空闲周期后立即执行,因此只剩下商店数据uop。 push rax uop的加载地址已准备就绪,因此应分派到加载端口并执行。 (当push加载执行时,它发现其地址与存储缓冲区(也称为内存顺序缓冲区)中的不完整push rax存储相匹配,因此它设置了存储转发,这将在存储之后发生-data uop执行。这可能会占用一个加载缓冲区条目。)

即使是像Nehalem has a 36 entry RS, vs. 54 in Sandybridge这样的旧CPU,也可能是Skylake中的97。在极少数情况下保持1次入场时间比平常更长,无需担心。执行两个uops(stack-sync + add rsp,8)的替代方案更糟糕。

关闭主题
ROB大于RS,128(Nehalem),168(Sandybridge),224(Skylake)。 (它保持融合域uop从发行到退休,而RS保持未融合域uops从发行到执行)。每个时钟最大前端吞吐量为4 uop,这是Skylake上超过50个延迟隐藏周期。 (较早的搜索不太可能每个时钟维持4次uops ...)

ROB大小确定​​隐藏慢速独立操作的无序窗口。 (Unless register-file size limits are a smaller limit)。 RS大小确定用于在两个单独的依赖关系链之间找到并行性的无序窗口。 (例如,考虑一个200 uop循环体,其中每次迭代都是独立的,但在每次迭代中它都是一个没有太多指令级并行性的长依赖链(例如pop rcx).Skylake的ROB可以容纳超过1次迭代,但是我们不能从下一次迭代中获取u到我们在当前结束时的97 uop之内。如果dep链不是那么大比RS大小,2次迭代的uops大部分时间都可以飞行。)

有些情况pop可能更危险

此函数的调用者知道push是call-clobbered,因此不会读取该值。但在我们返回后,它可能会依赖于sub,例如a[i] = complex_function(b[i]) / push rax / pop rcxrcx / rcxRecent Intel CPUs don't rename low8 partial registers anymore, so setcc cl has a false dep on rcx。如果源为0,bsf rcx, rax实际上保持其目标未修改,即使英特尔将其记录为未定义的值。 AMD记录了未经修改的行为。

false依赖可能会创建一个循环携带的dep链。另一方面,如果我们的函数使用依赖于其输入的指令编写jnz,则假依赖可以做到这一点。

使用test eax,eax / setz cl来保存/恢复我们不会使用的调用保留寄存器会更糟糕。在我们返回之后,调用者可能读取它,并且我们已经将存储转发延迟引入到该寄存器的调用者依赖链中。 (另外,bsf可能更有可能在rcx之前写入,因为调用者希望在调用中保留的任何内容都将被移动到调用保留的寄存器,如{{ 1}}和push rbx。)

在具有部分寄存器停顿的CPU(Intel pre-Sandybridge)上,使用pop rbx读取rbx可能会导致Core2 / Nehalem停顿或2-3个周期调用者在call之前做过rbx之类的事情。插入合并的uop时,Sandybridge不会失速,Haswell and later don't rename low8 registers separately from rax at all.

rbp寄存器不太可能使用它的低8会很好。如果编译器出于代码大小原因试图避免使用REX前缀,那么他们会避免使用raxpush,因此setcc alcall不太可能有部分注册问题。但不幸的是,gcc和clang似乎不赞成使用pushdil作为8位临时寄存器,使用silrdi即使在没有任何功能的微小函数中也是如此其他人正在使用rsidl。 (虽然在某些CPU中缺少low8重命名意味着cl对旧dil具有错误依赖性,因此如果标志设置依赖于sil中的函数arg,则rdx更安全{1}}。)

rcx最后&#34;清理&#34; setcc cl任何部分注册的东西。由于rcx用于移位计数,因此函数有时只写setcc dil,即使它们可能已写入rdi。 (IIRC我已经看到clang这样做了.gcc更强烈支持32位和64位操作数,以避免部分寄存器问题。)

在许多情况下,

pop rcx可能是一个不错的选择,因为函数的其余部分也会读取rcx,因此引入另一个依赖于它的指令不会受到伤害。如果在cl之前cl准备就绪,它会阻止无序执行,导致ecx无法使用。

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