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

dsi*_*cha 10 performance assembly sse x86-64

我正在尝试优化一些应该从内存中读取单精度浮点数的代码,并以双精度对它们进行算术运算.这正成为一个重要的性能瓶颈,因为将数据作为单一精度存储在内存中的代码比将内存中的数据存储为双精度的等效代码要慢得多.下面是一个玩具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.
}
Run Code Online (Sandbox Code Playgroud)

GCC和Clang都执行加载*f和转换为双精度作为两个单独的指令,即使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
Run Code Online (Sandbox Code Playgroud)

GCC生成类似低效的代码.为什么这些编译器中的任何一个都不会产生类似的东西cvtss2sd xmm0, dword ptr [rax]

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

Ste*_*non 15

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

转换为double可能会遇到瓶颈,但事实并非如此.


我将详细介绍部分寄存器更新为何会对性能造成危害.

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

double accumulator, x;
float y[n];
for (size_t i=0; i<n; ++i) {
    accumulator += x*(double)y[i];
}
Run Code Online (Sandbox Code Playgroud)

循环的"明显"代码如下所示:

loop_begin:
  cvtss2sd xmm0, [y + 4*i]
  mulsd    xmm0,  x
  addsd    accumulator, xmm0
  // some loop arithmetic that I'll ignore; it isn't important.
Run Code Online (Sandbox Code Playgroud)

天真地,唯一的循环搬运依存是在累加器中更新,所以渐近该循环应该以1 /(的速度运行addsd的等待时间),这是对当前的"典型的" x86核心每循环迭代3个循环(参见昂纳雾的表或英特尔优化手册了解更多详情).

但是,如果我们实际查看这些指令的操作,我们会看到xmm0的高64位,即使它们对我们感兴趣的结果没有影响,也会形成第二个循环携带的依赖链.cvtss2sd在前一循环迭代的结果mulsd可用之前,每条指令都不能开始; 这将循环的实际速度限制为1 /(cvtss2sd延迟+ mulsd延迟),或典型x86核心上每循环迭代7个循环(好消息是您只需支付reg-reg转换延迟,因为转换操作已被破解为2个μops,负载μop并没有对依赖xmm0,因此它可以被悬挂).

我们可以写出操作这个循环如下,以使它有点更清晰(我忽略了负荷的一半cvtss2sd,因为这些μops几乎不受约束,可以发生更多或更少的时候):

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                    .              .
Run Code Online (Sandbox Code Playgroud)

(*)我实际上是在简化一些事情; 我们不仅要考虑延迟,还要考虑端口利用率,以使其准确.然而,只考虑延迟就足以说明有问题的失速,所以我保持简单.假装我们在具有无限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.
Run Code Online (Sandbox Code Playgroud)

因为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
Run Code Online (Sandbox Code Playgroud)

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