为什么添加 xorps 指令使这个函数使用 cvtsi2ss 并添加 ~5x 快?

LRF*_*LEW 3 sse x86-64 cpu-architecture clang microbenchmark

我正在使用 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
Run Code Online (Sandbox Code Playgroud)

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

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

Pet*_*des 8

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

异或归零打破了 dep 链,允许乱序 exec 发挥其魔力。因此,您会遇到addss吞吐量(0.5 个周期)而不是延迟(4 个周期)的瓶颈。

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


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

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

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


请参阅了解 lfence 对具有两个长依赖链的循环的影响,以获取有关 OoO exec 如何与短到中长独立dep 链重叠的更多信息。还相关:为什么 mulss 在 Haswell 上只需要 3 个周期,与 Agner 的指令表不同?(展开具有多个累加器的 FP 循环)是关于展开具有多个累加器的点积以隐藏 FMA 延迟。

https://www.uops.info/html-instr/CVTSI2SS_XMM_R32.html提供了该指令在各种 uarches 中的性能详细信息。


如果您可以使用 AVX,则可以避免这种情况,withvcvtsi2ss %edi, %xmm7, %xmm0(其中 xmm7 是您最近未写入的任何寄存器,或者在导致 EDI 当前值的 dep 链中的较早位置)。

正如我在为什么 sqrtsd 指令的延迟会根据输入而变化中提到的那样?英特尔处理器

这个 ISA 设计问题要归功于 Intel 在 Pentium III 上使用 SSE1 进行短期优化。P3 在内部将 128 位寄存器处理为两个 64 位半。保持上半部分不变,让标量指令解码为单个 uop。(但这仍然给 PIIIsqrtss一个错误的依赖)。AVX 最终让我们vsqrtsd %src,%src, %dst至少对于寄存器源(如果不是内存)避免这种情况,vcvtsi2sd %eax, %cold_reg, %dst对于类似的近视设计的标量 int->fp 转换指令也是如此。
(GCC 未命中优化报告:805868907180571。)

如果cvtsi2ss/sd已经将寄存器的高位元素归零,我们就不会遇到这个愚蠢的问题 / 不需要到处散布异或归零指令;感谢英特尔。(另一种策略是使用SSE2movd %eax, %xmm0确实零扩展,然后装内部- >其操作对整个128位向量,这可以甚至破裂浮法FP转换,其中内部- > FP标量转换为2个微指令,并且矢量策略是 1+1。但在 int->fp 打包转换成本为 shuffle + FP uop 的情况下,不会加倍。)

这正是 AMD64 通过将写入 32 位整数寄存器隐式零扩展到完整的 64 位寄存器而不是保持不变(也称为合并)来避免的问题。 为什么 32 位寄存器上的 x86-64 指令将整个 64 位寄存器的上半部分归零?(写入 8 位和 16 位寄存器确实会导致对 AMD CPU 和自 Haswell 以来的 Intel 的错误依赖)。


归档时间:

查看次数:

167 次

最近记录:

5 年,5 月 前