为什么添加 xorps 指令会使使用 cvtsi2ss 和 addss 的函数快 ~5 倍?

Why does adding an xorps instruction make this function using cvtsi2ss and addss ~5x faster?

我正在使用 Google Benchmark 优化一个函数,运行 导致我的代码在某些情况下意外变慢。我开始试验它,查看编译后的程序集,并最终想出了一个展示问题的最小测试用例。这是我提出的显示这种减速的程序集:

    .text
test:
    #xorps  %xmm0, %xmm0
    cvtsi2ss    %edi, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    retq
    .global test

此函数遵循 GCC/Clang 函数声明的 x86-64 调用约定 extern "C" float test(int); 请注意注释掉的 xorps 指令。取消注释该指令会显着提高函数的性能。使用我的机器和 i7-8700K 对其进行测试,Google 基准显示函数 没有 xorps 指令需要 8.54ns(CPU),而函数 with xorps 指令需要 1.48ns。我已经在具有各种 OS、处理器、处理器代数和不同处理器制造商(Intel 和 AMD)的多台计算机上对此进行了测试,它们都表现出相似的性能差异。重复 addss 指令会使减速更加明显(在一定程度上),并且使用此处的其他指令(例如 mulss)甚至混合指令仍然会发生这种减速,只要它们都依赖于%xmm0 中的值以某种方式。值得指出的是,只有调用 xorps each 函数调用才能提高性能。使用循环(如 Google Benchmark 所做的那样)和循环外的 xorps 调用对性能进行采样仍然显示较慢的性能。

因为这是 专门添加 指令提高性能的情况,这似乎是由 CPU 中非常低级的东西引起的。由于它出现在各种各样的 CPU 中,所以这似乎一定是故意的。但是,我找不到任何文档来解释为什么会发生这种情况。有人对这里发生的事情有解释吗?这个问题似乎取决于复杂的因素,因为我在原始代码中看到的减速只发生在特定的优化级别(-O2,有时是 -O1,但不是 -Os),没有内联,并且使用特定编译器(Clang,但不是 GCC)。

cvtsi2ss %edi, %xmm0 将浮点数合并到 XMM0 的低元素中,因此它对旧值具有错误的依赖性。(重复调用同一函数,创建一个长循环携带的依赖链。)

xor-zeroing 破坏了 dep 链,允许无序执行发挥它的魔力。所以你的瓶颈是 addss 吞吐量(0.5 个周期)而不是延迟(4 个周期)。

您的 CPU 是 Skylake 衍生产品,所以这些是数字;较早的英特尔使用专用的 FP-add 执行单元而不是 FMA 单元上的 运行 具有 3 个周期延迟和 1 个周期吞吐量。 https://agner.org/optimize/。可能函数 call/ret 开销阻止您看到流水线 FMA 单元中 8 个飞行中 addss 微指令的延迟 * 带宽乘积的完整 8 倍预期加速;如果您从单个函数内的循环中删除 xorps dep-breaking,您应该会得到加速。


GCC 往往非常 "careful" 关于错误的依赖关系 ,花费额外的指令(前端带宽)来打破它们以防万一。在前端瓶颈的代码中(或者总代码大小/uop 缓存占用空间是一个因素)如果寄存器实际上及时准备好,这会降低性能。

Clang/LLVM 对此鲁莽而漫不经心,通常不会费心去避免对未写入当前函数的寄存器的错误依赖。 (即假设/假装寄存器在函数入口处是 "cold")。正如您在评论中所展示的那样,当在一个函数内循环时,clang 确实避免了通过异或归零创建循环携带的 dep 链,而不是通过对同一函数的多次调用。

Clang 甚至在某些情况下无缘无故地使用 8 位 GP 整数部分寄存器,与 32 位寄存器相比,这不会节省任何代码大小或指令。通常它可能没问题,但是如果调用者(或同级函数调用)仍然有缓存未命中加载到该 reg 时,则存在耦合到长 dep 链或创建循环携带的依赖链的风险例如,调用。


参见 for more about how OoO exec can overlap short to medium length independent dep chains. Also related: 是关于展开具有多个累加器的点积以隐藏 FMA 延迟。

https://www.uops.info/html-instr/CVTSI2SS_XMM_R32.html 具有此指令在各种 uarche 中的性能详细信息。


如果你可以使用 AVX,你可以避免这种情况,vcvtsi2ss %edi, %xmm7, %xmm0(其中 xmm7 是你最近没有写过的任何寄存器,或者它在导致 EDI 当前值的 dep 链)。

正如我在

中提到的

This ISA design wart is thanks to Intel optimizing for the short term with SSE1 on Pentium III. P3 handled 128-bit registers internally as two 64-bit halves. Leaving the upper half unmodified let scalar instructions decode to a single uop. (But that still gives PIII sqrtss a false dependency). AVX finally lets us avoid this with vsqrtsd %src,%src, %dst at least for register sources if not memory, and similarly vcvtsi2sd %eax, %cold_reg, %dst for the similarly near-sightedly designed scalar int->fp conversion instructions.
(GCC missed-optimization reports: 80586, 89071, 80571.)

如果 cvtsi2ss/sd 将寄存器的高位元素归零,我们就不会遇到这个愚蠢的问题/不需要散布异或归零指令;感谢英特尔。 (另一种策略是使用 SSE2 movd %eax, %xmm0,其中 零扩展,然后打包 int->fp 转换,对整个 128 位向量进行操作。这可以收支平衡float,其中 int->fp 标量转换为 2 微指令,向量策略为 1+1。但不是 double,其中 int->fp 打包转换花费 shuffle + FP 微指令。)

这正是 AMD64 通过对 32 位整数寄存器的写入隐式零扩展到完整的 64 位寄存器而不是保持不变(也称为合并)来避免的问题。 Why do x86-64 instructions on 32-bit registers zero the upper part of the full 64-bit register?(写入 8 位和 16 位寄存器 do 导致对 AMD CPUs 和自 Haswell 以来的 Intel 的错误依赖。