【问题标题】:Why does adding an xorps instruction make this function using cvtsi2ss and addss ~5x faster?为什么添加 xorps 指令会使此函数使用 cvtsi2ss 并增加约 5 倍的速度?
【发布时间】:2024-01-05 14:11:01
【问题描述】:

我在使用 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

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

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

【问题讨论】:

    标签: clang x86-64 cpu-architecture sse microbenchmark


    【解决方案1】:

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

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

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


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

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

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


    请参阅Understanding the impact of lfence on a loop with two long dependency chains, for increasing lengths,了解有关 OoO exec 如何重叠短到中等长度的独立 dep 链的更多信息。同样相关:Why does mulss take only 3 cycles on Haswell, different from Agner's instruction tables? (Unrolling FP loops with multiple accumulators) 是关于展开具有多个累加器的点积以隐藏 FMA 延迟。

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


    如果您可以使用 AVX 和 vcvtsi2ss %edi, %xmm7, %xmm0(其中 xmm7 是您最近未编写的任何寄存器,或者在导致当前EDI 值)。

    正如我在Why does the latency of the sqrtsd instruction change based on the input? Intel processors中提到的那样

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

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

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

    【讨论】:

    • 这确实很有意义。我最初的测试是使用 AVX 完成的(我使用 -march=native 来启用一些扩展,包括我原始测试中的 FMA),因此使用 AVX 避免这种情况的建议似乎很有趣。查看 Clang 的输出,似乎这是针对循环中的内联函数 (godbolt.org/z/cascT9) 完成的,但与 GCC (godbolt.org/z/5rTmaU) 不同,非内联函数没有保护。 LLVM 似乎是一个错误(或故意选择)。
    • “这正是 AMD64 通过对 32 位整数寄存器的写入隐式零扩展到完整的 64 位寄存器而不是保持不变(也称为合并)来避免的问题。”那时候我想过那个。我听说它提高了性能,但不是为什么。
    • @LRFLEW:是的,LLVM 不顾一切地保存指令,GCC 很小心。更新了我的答案以解决这部分问题。
    最近更新 更多