gpt4 book ai didi

performance - 为什么 GCC 和 Clang 不使用 cvtss2sd [内存]?

转载 作者:行者123 更新时间:2023-12-04 03:21:57 26 4
gpt4 key购买 nike

我正在尝试优化一些应该从内存中读取单精度浮点数并以 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.
}

GCC 和 Clang 都执行 *f 的加载和转换为 double 作为两个单独的指令,即使 cvtss2sd指令支持内存作为源参数。根据 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] 的内容? ?

编辑:很好的答案,斯蒂芬佳能!我将 Clang 的汇编语言输出用于我的实际用例,将其作为内联 ASM 粘贴到源文件中,对其进行基准测试,然后进行此处讨论的更改并再次对其进行基准测试。我简直不敢相信 cvtss2sd [memory]实际上更慢。

最佳答案

这实际上是一种优化。来自存储器的 CVTSS2SD 使目标寄存器的高 64 位保持不变。这意味着会发生部分寄存器更新,这在许多情况下会导致严重停顿并大大降低 ILP。另一方面,MOVSS 将寄存器的未使用位清零,这是破坏依赖性的,并避免了停顿的风险。

您可能在转换为 double 时遇到瓶颈,但事实并非如此。

我将详细说明为什么部分寄存器更新会带来性能风险。

我不知道实际执行了什么计算,但让我们假设它看起来像这个非常简单的例子:

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 的负载一半,因为这些微操作几乎不受约束,并且可以或多或少地发生在任何时候):
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.

因为 movss从 xmm0 的内存零位 [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

请注意,在我的玩具示例中,在消除部分寄存器更新停顿后,还有很多工作要做来优化有问题的代码。它可以被向量化,并且可以使用多个累加器(以改变发生的特定舍入为代价)来最小化循环携带的累加到累加延迟的影响。

关于performance - 为什么 GCC 和 Clang 不使用 cvtss2sd [内存]?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/16597587/

26 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com