【问题标题】:Why does this function push RAX to the stack as the first operation?为什么这个函数将 RAX 推入堆栈作为第一个操作?
【发布时间】:2018-01-28 06:44:40
【问题描述】:

在下面的 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&lt;void()&gt; 包装器的尾调用以进行比较:

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

琐碎:

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

【问题讨论】:

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


    【解决方案1】:

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

    call 将一个 8 字节的返回地址压入堆栈,这会破坏对齐,因此编译器需要在下一个 call 之前再次将堆栈对齐到 16 的倍数。

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

    推送不关心值效果很好,并且比CPUs with a stack engine 上的sub rsp, 8有效。 (见 cmets)。

    【讨论】:

    • 啊 - 我想错了,并没有真正相信 RAX 是垃圾值这一事实 :) 谜团解决了!
    • 其实恰恰相反。在调用之前堆栈必须对齐,所以在调用之后它是未对齐的并且必须重新对齐。
    • 我必须同意@Dani。当通过调用函数f 控制传输时,RSP 已经错位8,因为返回地址被放置在堆栈上。在控制权转移到f 之前,它与 16 字节边界对齐。可能您的意思是在 push rax 之后,堆栈再次与 16 字节边界对齐。当分支没有在 JMP 之前被采用时,代码实际上将 8 添加回 RSP。完成这项工作的代码效率很低。
    • @Gene:sub rsp, 8 需要额外的 uop 让堆栈引擎将其 rsp 的偏移值与无序核心中的值同步。所以在现代 Intel CPU(但不是 AMD)上,实际上做一个 push 的垃圾比手动修改 rsp 8 更有效。(堆栈引擎使 push / @987654336 成为可能@ 是单微指令,而不是需要额外的微指令来修改rsp)。有关详细信息,请参阅Agner Fog's microarch.pdf。堆栈引擎在 Pentium-M 中是新的,但 AMD 也有它,这要归功于他们的专利共享协议。
    • @PeterCordes :在 GCC 上似乎在版本之间发生了变化。 GCC 5.3(和一些早期版本)似乎在生成的代码中使用push rax(对于这种情况),然后在 6.1 中使用sub。我仍然不太明白为什么 CLANG 只是在 call std::__throw_bad_function_call() 之前不进行堆栈对齐,而不是在更有可能执行的路径中进行推送/弹出。
    【解决方案2】:

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

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

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

    在比较失败的情况下,一旦执行add rsp, 8 指令,堆栈将像在函数入口处一样出现。 CALLER 函数f 的返回地址现在将回到堆栈顶部,堆栈将再次错位8。这就是我们想要的,因为正在使用jmp qword ptr [rdi + 24] 生成TAIL CALL,以将控制权转移到函数a。这将 JMP 到函数而不是 CALL 它。当函数a 执行RET 时,它将直接返回到调用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 更好地处理此代码生成。

    【讨论】:

    • 确实,您在最后一段中描述的是exactly what GCC does,位于-O2 或-O3。 Clang 和 ICC 都在函数顶部对齐堆栈。这是 GCC 的优化器似乎比 Clang 的更有效的少数情况之一。
    • @CodyGray 现在我已经喝过咖啡了,我确实把它扔到了 Godbolt 上,你是对的,GCC 看起来在这种情况下会生成更好的代码。我修改了我的答案以反映这一发现。这也证实了我对如何对其进行优化的评论。
    • @daniel:您对此问题的答案的其他编辑已被拒绝,因为您正在通过编辑大幅更改答案。如果您有自己的答案要提供,请随意提供,但除非答案是社区 wiki 类型的答案,否则您应避免进行如此剧烈的更改。
    【解决方案3】:

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

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

    这个冗长而漫不经心的答案讨论了使用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
    

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

    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 / jnztest eax,eax / setz clRecent 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 之前,因为调用者想要在调用中保留的任何内容都将被移动到调用保留寄存器,如rbxrbp。)


    在具有部分寄存器停顿的 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 前缀,他们会避免使用 dilsil,因此 rdirsi 不太可能出现部分寄存器问题。但不幸的是,gcc 和 clang 似乎不赞成使用 dlcl 作为 8 位暂存寄存器,使用 dilsil,即使在没有其他任何东西使用 rdxrcx . (虽然在某些 CPU 中缺少 low8 重命名意味着 setcc cl 对旧的 rcx 有错误的依赖关系,所以如果标志设置依赖于 rdi 中的函数 arg,setcc dil 会更安全。)

    pop rcx 最后“清理”rcx 任何部分注册的东西。由于cl 用于移位计数,并且函数有时只写cl,即使它们本可以写ecx。 (IIRC 我见过 clang 这样做。gcc 更倾向于 32 位和 64 位操作数大小以避免部分寄存器问题。)


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


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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多