首页 文章

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

提问于
浏览
9

我'm trying to optimize some code that'应该从内存中读取单精度浮点数并以双精度对它们进行算术运算 . 这正成为一个重要的性能瓶颈,因为将数据作为单一精度存储在内存中的代码基本上比将内存中的数据存储为双精度的等效代码 slower . 下面是一个玩具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.
}

即使 cvtss2sd 指令支持内存作为源参数,GCC和Clang都会执行 *f 的加载并将转换为双精度作为两个单独的指令 . 根据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] 这样的东西?

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

1 回答

  • 13

    这实际上是一种优化 . 来自存储器的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 延迟)的速度运行,这是当前"typical" x86核心上每循环迭代3个循环(参见Agner Fog 's tables or Intel' s优化手册了解更多详情) .

    但是,如果我们实际查看这些指令的操作,我们会看到xmm0的高64位 even though they have no effect on the result we are interested in 构成了第二个循环携带的依赖链 . 在前一个循环迭代 mulsd 的结果可用之前,每个 cvtss2sd 指令都不能开始;这将循环的实际速度限制为1 /( cvtss2sd 延迟 mulsd 延迟),或典型x86核心上每循环迭代7个循环(好消息是您只需支付reg-reg转换延迟,因为转换操作已破解到两个μop,负载μ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                    .              .
    

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

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

相关问题