为什么添加一个xorps指令会让使用cvtsi2ss和addss的这个函数变快大约5倍?

9

我在使用Google基准测试对函数进行优化时进行了尝试,但在某些情况下我的代码出现了意外的减速。我开始进行实验,并查看编译后的汇编代码,最终创建了一个展示该问题的最小化测试案例。以下是我创建的展示这种减速情况的汇编代码:

    .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.54纳秒(CPU),而带有xorps指令的函数只需要1.48纳秒。我已经在多台计算机上进行了测试,包括各种操作系统、处理器、处理器代数和不同的处理器制造商(英特尔和AMD),它们都呈现出类似的性能差异。重复执行addss指令会使减速更加明显(到一定程度),并且即使在这里使用其他指令(例如mulss)或甚至混合指令,只要它们都以某种方式依赖于%xmm0中的值,这种减速仍然会发生。值得指出的是,仅在每个函数调用中调用xorps会导致性能改善。在循环中对性能进行抽样(如Google Benchmark所示)并将xorps调用放在循环外部仍然显示较慢的性能。

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

1个回答

15

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

xor-zeroing打破了dep chain,允许乱序执行发挥其魔力。 因此,您会在addss吞吐量(0.5个周期)上遇到瓶颈,而不是延迟(4个周期)。

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


GCC对于错误的依赖关系要非常“小心谨慎”,即使该寄存器实际上已经及时准备好了,它仍然会花费额外的指令(前端带宽)来打破这些依赖关系。在那些瓶颈在前端的代码(或者总代码大小/ uop-cache占用空间是一个因素)中,如果该寄存器实际上已经及时准备好了,这将导致性能下降。

Clang / LLVM则相对不太在意这一点,通常不费力地避免当前函数未写入的寄存器上的错误依赖关系。(即假设/假装函数进入时寄存器是“冷”的)。正如您在注释中所示,clang通过在同一函数内循环时使用xor-zeroing来避免创建循环依赖链,而不是通过对同一函数多次调用实现。

Clang甚至在某些情况下无缘无故地使用8位GP整数部分寄存器,尽管与32位寄存器相比没有节省任何代码大小或指令。通常情况下这可能没事,但如果调用者(或兄弟函数调用)在我们被调用时仍然存在缓存未命中的负载到该寄存器中,则有可能出现长时间依赖链或创建循环依赖链的风险。


请参阅了解lfence对具有两个长依赖链的循环的影响,以增加长度,了解关于OoO exec如何重叠短到中等长度的独立 dep链的更多信息。此外,相关内容:为什么Haswell上mulss只需要3个周期,与Agner的指令表不同?(使用多个累加器展开FP循环)是关于展开具有多个累加器的点积以隐藏FMA延迟的内容。 https://www.uops.info/html-instr/CVTSI2SS_XMM_R32.html提供了各种uarches下该指令的性能详情。

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

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

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

如果cvtsi2ss/sd将寄存器的上部元素清零,我们就不会有这个愚蠢的问题/不需要散布xor清零指令;谢谢英特尔。 (另一种策略是使用SSE2 movd %eax,%xmm0,它确实进行零扩展,然后打包的int-> fp转换操作整个128位向量。即使对于标量int-> fp转换为2个uop,矢量策略也可能会中断,而不是成本为1 + 1。但是不是double,其中int-> fp打包转换成本为洗牌+ FP uop。)

这正是AMD64避免的问题,它使得对32位整数寄存器的写操作隐式地将其扩展为完整的64位寄存器,而不是保持不变(也称为合并)。为什么x86-64指令在32位寄存器上会将完整的64位寄存器上半部分清零? (在AMD CPU和自Haswell以来的英特尔处理器上,写入8位和16位寄存器确实会导致错误依赖)。

1
这确实很有道理。我的初始测试是使用AVX进行的(我使用“-march=native”启用了一些扩展,包括FMA在我的原始测试中),因此避免使用AVX的建议似乎很有趣。查看Clang的输出,似乎这是针对循环中的内联函数完成的(https://godbolt.org/z/cascT9),但与GCC不同,非内联函数没有保护(https://godbolt.org/z/5rTmaU)。这似乎是LLVM的一个错误(或故意选择)。 - LRFLEW
这正是AMD64通过使对32位整数寄存器的写入隐式地零扩展到完整的64位寄存器而避免的问题,而不是保持其未修改(即合并)的问题。 我曾经想知道这个问题。我听说它可以提高性能,但不知道原因。 - LRFLEW
@LRFLEW:是的,LLVM为了节省指令而有些鲁莽,GCC则更加谨慎。我更新了我的答案来回答这个问题的那一部分。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接