我正在尝试优化一些应该从内存中读取单精度浮点数并对其执行 double 算术的代码。这正成为一个重要的性能瓶颈,因为将数据以单精度存储在内存中的代码的基本上比将数据以 double 存储在内存中的等效代码的慢。下面是一个玩具C++程序,它捕获了我的问题的本质:#include <cstdio>
// noinline to force main() to actually read the value from memory.
__attributes__ ((noinline)) float* GetFloat() {
float* f = new float;
*f = 3.14;
return f;
}
int main() {
float* f = GetFloat();
double d = *f;
printf("%f\n", d); // Use the value so it isn't optimized out of existence.
}
即使*f
指令支持将内存作为源参数,GCC和Clang都将cvtss2sd
的加载和转换为 double 作为两个单独的指令。根据Agner Fog,cvtss2sd r, m
在大多数体系结构上的执行速度与movss r, m
一样快,并且避免了需要执行cvtss2sd r, r
后缀。尽管如此,Clang仍为main()
生成了以下代码:main PROC
push rbp ;
mov rbp, rsp ;
call _Z8GetFloatv ;
movss xmm0, dword ptr [rax] ;
cvtss2sd xmm0, xmm0 ;
mov edi, offset ?_001 ;
mov al, 1 ;
call printf ;
xor eax, eax ;
pop rbp ;
ret ;
main ENDP
GCC生成类似的低效率代码。为什么这些编译器都不能简单地生成cvtss2sd xmm0, dword ptr [rax]
之类的东西?
编辑:很好的答案,斯蒂芬·佳能(Stephen Canon)!我将Clang的汇编语言输出用于我的实际用例,将其粘贴到作为内联ASM的源文件中,对其进行了基准测试,然后进行了此处讨论的更改并再次对其进行了基准测试。我简直不敢相信cvtss2sd [memory]
实际上更慢。
最佳答案
这实际上是一种优化。来自存储器的CVTSS2SD保留目标寄存器的高64位不变。这意味着会发生部分寄存器更新,这可能会导致严重的停顿并在许多情况下大大降低ILP。另一方面,MOVSS将寄存器的未使用位清零(这会破坏依赖关系),并避免发生停顿的风险。
您可能会遇到转换为两倍的瓶颈,但这不是。
我将详细解释为什么部分寄存器更新会对性能造成危害。
我不知道实际上正在执行什么计算,但是让我们假设它看起来像一个非常简单的示例:
double accumulator, x;
float y[n];
for (size_t i=0; i<n; ++i) {
accumulator += x*(double)y[i];
}
循环的“显而易见的”代码生成看起来像这样:
loop_begin:
cvtss2sd xmm0, [y + 4*i]
mulsd xmm0, x
addsd accumulator, xmm0
// some loop arithmetic that I'll ignore; it isn't important.
天真的,唯一的循环依赖项是累加器更新,因此渐近循环应该以1/(
addsd
延迟)的速度运行,在当前的“典型” x86内核上,每个循环迭代需要3个周期(请参阅Agner Fog的表)或了解更多信息,请参阅《英特尔优化手册》。但是,如果我们实际上看一下这些指令的操作,则会看到xmm0的高64位,即使它们对我们感兴趣的的结果没有影响,也形成了第二个循环承载的依赖链。在上一个循环迭代的
cvtss2sd
的结果可用之前,每个mulsd
指令都无法开始;这会将循环的实际速度限制为1/(cvtss2sd
延迟+ mulsd
延迟),或者在典型的x86内核上每个循环迭代有7个周期(好消息是您只需支付reg-reg转换延迟,因为转换操作是分为两个µop,并且负载µop与xmm0
不相关,因此可以将其吊起。我们可以如下写出该循环的操作,以使其更加清晰(我忽略了
cvtss2sd
的负载一半,因为这些µop几乎不受限制,并且随时可能会或多或少地发生):cycle iteration 1 iteration 2 iteration 3
------------------------------------------------
0 cvtss2sd
1 .
2 mulsd
3 .
4 .
5 .
6 . --- xmm0[64:127]-->
7 addsd cvtss2sd(*)
8 . .
9 .-- accum -+ mulsd
10 | .
11 | .
12 | .
13 | . --- xmm0[64:127]-->
14 +-> addsd cvtss2sd
15 . .
(*)我实际上是在简化一些事情;我们不仅要考虑延迟,还要考虑端口利用率,以确保准确无误。但是,仅考虑等待时间就足以说明问题所在,因此,我将其保持简单。假设我们在具有无限ILP资源的计算机上运行。
现在假设我们这样编写循环:
loop_begin:
movss xmm0, [y + 4*i]
cvtss2sd xmm0, xmm0
mulsd xmm0, x
addsd accumulator, xmm0
// some loop arithmetic that I'll ignore; it isn't important.
因为来自xmm0的内存中的
movss
归零位[32:127],所以对xmm0不再存在循环承载的依赖关系,因此,正如预期的那样,我们受累加延迟的约束;稳定状态下的执行看起来像这样:cycle iteration i iteration i+1 iteration i+2
------------------------------------------------
0 cvtss2sd .
1 . .
2 mulsd . movss
3 . cvtss2sd .
4 . . .
5 . mulsd .
6 . . cvtss2sd
7 addsd . .
8 . . mulsd
9 . . .
10 . -- accum --> addsd .
11 . .
12 . .
13 . -- accum --> addsd
请注意,在我的玩具示例中,在消除了部分寄存器更新停顿之后,还有很多工作要做以优化所讨论的代码。可以对其进行矢量化处理,并可以使用多个累加器(以更改发生的特定舍入为代价),以最大程度地减少循环进行的累加到累加延迟的影响。