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

请注意,在我的玩具示例中,在消除了部分寄存器更新停顿之后,还有很多工作要做以优化所讨论的代码。可以对其进行矢量化处理,并可以使用多个累加器(以更改发生的特定舍入为代价),以最大程度地减少循环进行的累加到累加延迟的影响。

10-08 08:44