11

我正在尝试优化一些应该从内存中读取单精度浮点数并以双精度对它们执行算术的代码。这正在成为一个重要的性能瓶颈,因为将数据作为单精度存储在内存中的代码比将数据作为双精度存储在内存中的等效代码要慢得多。下面是一个玩具 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作为两个单独的指令执行双精度的加载和转换,即使该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]实际上更慢。

4

1 回答 1

18

这实际上是一种优化。来自内存的 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 的 memory zeros bits [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

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

于 2013-05-16T21:20:13.070 回答