为什么这个不必要的 MOVAPD 复制在 gcc 9.1 中,在一个小函数中
Why this unnecessary MOVAPD copy in gcc 9.1, in a tiny function
考虑以下代码:
double x(double a,double b) {
return a*(float)b;
}
它执行从 double
到 float
的转换,然后再次转换为 double
并相乘。
当我在 x86/64
上使用 gcc 9.1
和 -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
指令有什么好处吗?
这个问题与 Assembly code redundancy in optimized C code 类似,但我认为它不一样,因为 gcc
的旧版本不会生成不必要的副本。
这是 GCC 遗漏的优化;不幸的是,对于 GCC 在微型函数 中这种情况并不少见,因为它的寄存器分配器在调用约定强加的硬寄存器约束下表现不佳;显然 GCC 在较大函数的部分之间通常不会像这样愚蠢。
pxor
-归零是为了打破 cvtss2sd
的(假)输出依赖性,这是因为英特尔的短视设计,单源标量指令离开上部未修改的目标向量。他们从 PIII 的 SSE1 开始,它提供了短期收益,因为 PIII 将 XMM regs 处理为两个 64 位的一半,所以只写一半让像 sqrtss
这样的指令成为单 uop。
但不幸的是,即使对于 SSE2(Pentium 4 的新功能),他们也保留了这种模式。并且后来拒绝用 SSE 指令的 AVX 版本修复它。因此,编译器只能在通过错误依赖创建长循环携带依赖链或使用 pxor-zeroing 的风险之间做出选择。 GCC 保守地总是在 -O3
处使用 pxor,在 -Os
处省略它。 (像 mulsd
这样的 2 源操作已经依赖于作为输入的目的地,所以这是不必要的)。
在这种情况下,由于其寄存器分配选择不当,遗漏 pxor
-归零将意味着将 (float)b
转换回 double
直到 [=19] 才能开始=] 准备好了。因此,如果关键路径是 a
准备就绪(b
提前准备好),则忽略它会增加 a
-> Skylake 上结果的延迟 5 个周期(对于 2-uop cvtss2sd
到 运行 只有在 a
准备好之后,因为输出必须合并到最初保存 a
的寄存器中。)否则只有 mulsd
等待 a
,所有涉及 b
的事情都提前完成。
foo same,same
是解决输出依赖的另一种方法;这就是 clang 正在做的事情。 (以及 GCC 试图为 popcnt
做的事情,它出人意料地在 Sandybridge 系列上有一个架构上不需要的,不像这些愚蠢的 SSE 那样。)
顺便说一句,AVX 3 操作数指令有时确实提供了一种解决错误依赖关系的方法,使用“冷”寄存器或异或归零的寄存器作为要合并到的寄存器。包括标量 int->FP,尽管 clang 有时只使用 movd
加上打包转换。
相关: (我应该把它链接起来,我忘了我最近已经在 Stack Overflow 上写了这么多细节。)
movapd
和 pxor
归零在现代 CPU 上不会造成任何延迟,但没有什么是免费的。它们仍然需要前端 uop 和代码大小(L1i 缓存占用空间)。 movapd
后端零延迟,不需要执行单元,仅此而已 -
考虑以下代码:
double x(double a,double b) {
return a*(float)b;
}
它执行从 double
到 float
的转换,然后再次转换为 double
并相乘。
当我在 x86/64
上使用 gcc 9.1
和 -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
指令有什么好处吗?
这个问题与 Assembly code redundancy in optimized C code 类似,但我认为它不一样,因为 gcc
的旧版本不会生成不必要的副本。
这是 GCC 遗漏的优化;不幸的是,对于 GCC 在微型函数 中这种情况并不少见,因为它的寄存器分配器在调用约定强加的硬寄存器约束下表现不佳;显然 GCC 在较大函数的部分之间通常不会像这样愚蠢。
pxor
-归零是为了打破 cvtss2sd
的(假)输出依赖性,这是因为英特尔的短视设计,单源标量指令离开上部未修改的目标向量。他们从 PIII 的 SSE1 开始,它提供了短期收益,因为 PIII 将 XMM regs 处理为两个 64 位的一半,所以只写一半让像 sqrtss
这样的指令成为单 uop。
但不幸的是,即使对于 SSE2(Pentium 4 的新功能),他们也保留了这种模式。并且后来拒绝用 SSE 指令的 AVX 版本修复它。因此,编译器只能在通过错误依赖创建长循环携带依赖链或使用 pxor-zeroing 的风险之间做出选择。 GCC 保守地总是在 -O3
处使用 pxor,在 -Os
处省略它。 (像 mulsd
这样的 2 源操作已经依赖于作为输入的目的地,所以这是不必要的)。
在这种情况下,由于其寄存器分配选择不当,遗漏 pxor
-归零将意味着将 (float)b
转换回 double
直到 [=19] 才能开始=] 准备好了。因此,如果关键路径是 a
准备就绪(b
提前准备好),则忽略它会增加 a
-> Skylake 上结果的延迟 5 个周期(对于 2-uop cvtss2sd
到 运行 只有在 a
准备好之后,因为输出必须合并到最初保存 a
的寄存器中。)否则只有 mulsd
等待 a
,所有涉及 b
的事情都提前完成。
foo same,same
是解决输出依赖的另一种方法;这就是 clang 正在做的事情。 (以及 GCC 试图为 popcnt
做的事情,它出人意料地在 Sandybridge 系列上有一个架构上不需要的,不像这些愚蠢的 SSE 那样。)
顺便说一句,AVX 3 操作数指令有时确实提供了一种解决错误依赖关系的方法,使用“冷”寄存器或异或归零的寄存器作为要合并到的寄存器。包括标量 int->FP,尽管 clang 有时只使用 movd
加上打包转换。
相关:
movapd
和 pxor
归零在现代 CPU 上不会造成任何延迟,但没有什么是免费的。它们仍然需要前端 uop 和代码大小(L1i 缓存占用空间)。 movapd
后端零延迟,不需要执行单元,仅此而已 -