gcc 9.1中为什么会出现这个不必要的MOVAPD复制,在一个小函数中

5

请考虑以下代码:

double x(double a,double b) {
    return a*(float)b;
}

它将double转换为float,然后再次转换为double并进行乘法运算。

当我使用gcc 9.1编译,并在x86/64上使用-O3时,我得到:

x(double, double):
        movapd  xmm2, xmm0
        pxor    xmm0, xmm0
        cvtsd2ss        xmm1, xmm1
        cvtss2sd        xmm0, xmm1
        mulsd   xmm0, xmm2
        ret

使用 clang 和较老版本的 gcc,我会得到这个错误:

x(double, double):
        cvtsd2ss        xmm1, xmm1
        cvtss2sd        xmm1, xmm1
        mulsd   xmm0, xmm1
        ret

在这里,我没有将xmm0复制到xmm2,因为我认为这是不必要的。

使用gcc 9.1-Os,我得到:

x(double, double):
        movapd  xmm2, xmm0
        cvtsd2ss        xmm1, xmm1
        cvtss2sd        xmm0, xmm1
        mulsd   xmm0, xmm2
        ret

因此,它仅删除将xmm0设置为零的指令,但不删除moveapd指令。

我认为这三个版本都是正确的,那么gcc 9.1 -O3版本是否有性能上的好处?如果有,为什么?pxor xmm0,xmm0指令有什么好处吗?

这个问题类似于优化C代码中的汇编代码冗余,但我认为它并不相同,因为旧版本的gcc不会生成不必要的副本。


1
作为一个猜测,我对这个东西并不是特别熟悉,我会说 movapd 由于寄存器重命名的缘故实际上是免费的,而那些额外的指令可能会消除一些错误依赖。 - Thomas Jager
1
@ThomasJager:没有什么是免费的。它仍然需要一个前端操作,以及代码大小(L1i缓存占用)。它在后端具有零延迟,并且不需要执行单元,但仅此而已 - x86的MOV指令真的可以“免费”吗?为什么我根本无法复制这个结果?。(对于pxor清零,GCC只使用了因为英特尔短视的坏设计,用于不将源标量指令零扩展到目标的情况。 - Peter Cordes
2
在clang的版本中没有虚假依赖,它正在读写相同的寄存器,因此cvtss2sd输出的虚假依赖已经与具有输入真实依赖关系的同一寄存器。 Clang的版本是最佳的,gcc的版本很愚蠢,是一个明显的优化遗漏。 当GCC的寄存器分配器受到调用约定强制的硬寄存器约束时,在小型函数中经常发生这种情况。 显然,GCC通常不会像这样在较大的函数部分之间表现愚蠢。 - Peter Cordes
@PeterCordes,您能详细说明一下pxor-零寄存器吗?它的目的是什么? 在未使用返回值的部分中,xmm0必须为零吗?但是这样做会使-Os不正确。 - Unlikus
1
@Unlikus:不,x86 / x86-64调用约定允许返回值之外的寄存器部分包含垃圾数据(与某些RISC调用约定不同,在这些约定中,至少需要对整数寄存器进行符号或零扩展以传递/返回窄值)。完整细节请参见x86-64 ABI在将32位偏移量添加到指针时是否需要符号或零扩展?。这没有任何目的;pxor清零仅因为movapd错过了优化。 - Peter Cordes
1
如果您只需要结果的零扩展,“movq xmm0,xmm0”将是最紧凑的(尽管这会使关键路径延迟更长,因为移动消除在其中无法工作)。 - Peter Cordes
1个回答

9
这是GCC在细小的函数中经常出现的一种错误优化; 当调用约定所施加的硬寄存器限制时,其寄存器分配器工作不佳。显然,在大型函数的各个部分之间,GCC通常不会像这样愚蠢。 pxor-清零存在的原因是为了打破cvtss2sd的(虚假)输出依赖关系,这是由于英特尔的短视设计造成的,即单源标量指令将目标向量的上半部分保持不变。他们从SSE1开始就这样做,针对PIII,因为PIII将XMM寄存器作为两个64位的半部分处理,所以只写一个半部分使得像sqrtss这样的指令成为单uop指令。
但不幸的是,他们甚至在SSE2(Pentium 4新推出)中也保留了这种模式。后来,在AVX版的SSE指令中拒绝修复它。因此,编译器陷入了在创建长的循环传递依赖链和使用pxor-zeroing之间选择的困境。GCC在-O3下始终保守地使用pxor,省略掉-Os。(像mulsd这样的2源操作已经依赖于目标作为输入,因此不需要这样做)。
在这种情况下,由于其糟糕的寄存器分配选择,省略pxor-清零意味着将(float)b转换回double直到准备好。因此,如果关键路径是准备就绪(提前准备好了),省略它将使延迟从 ->结果增加5个时钟周期(因为2-uop cvtss2sd仅在就绪后运行,因为输出必须合并到最初保存的寄存器中)。否则,只有mulsd需要等待,而所有涉及的东西都提前完成。 foo same,same是另一种解决输出依赖性的方法; 这就是clang正在做的事情。(以及GCC尝试为popcnt做的事情,这意外地在Sandybridge家族上有一个不需要遵守体系结构规定的依赖关系,不像这些愚蠢的SSE指令。)
顺便说一下,AVX 3操作数指令有时提供了一种解决虚假依赖的方法,使用“冷”寄存器或已经xor清零的寄存器作为要合并到的寄存器。包括标量int->FP,尽管clang有时仅使用movd加上打包转换来实现这一点。

相关: 为什么添加xorps指令会使使用cvtsi2ss和addss的函数快5倍? (我应该只链接那个,我忘记我最近已经在Stack Overflow上详细写了这个)


movapdpxor清零在现代CPU上不需要任何延迟,但没有免费的午餐。它们仍然需要前端uop和代码大小(L1i缓存占用)。movapd在后端没有延迟,并且不需要执行单元,但仅此而已 - x86的MOV真的是“免费”的吗?为什么我完全无法复制这个?


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