【问题标题】:Why don't GCC and Clang use cvtss2sd [memory]?为什么 GCC 和 Clang 不使用 cvtss2sd [memory]?
【发布时间】:2013-05-11 22:41:42
【问题描述】:

我正在尝试优化一些应该从内存中读取单精度浮点数并以双精度对它们执行算术运算的代码。这正成为一个重要的性能瓶颈,因为以单精度在内存中存储数据的代码比以双精度在内存中存储数据的等效代码要。下面是一个玩具 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 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] 的东西?

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

【问题讨论】:

    标签: performance assembly x86-64 sse


    【解决方案1】:

    这实际上是一种优化。来自内存的 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/(addsdlatency) 的速度运行,即在当前“典型”x86 内核上每次循环迭代 3 个周期(有关详细信息,请参阅 Agner Fog 的表格或英特尔的优化手册)。

    但是,如果我们实际查看这些指令的操作,我们会看到 xmm0 的高 64 位,尽管它们对我们感兴趣的结果没有影响,但形成了第二个循环携带的依赖链。在前面循环迭代的mulsd 的结果可用之前,每条cvtss2sd 指令都不能开始;这将循环的实际速度限制为 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
    

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

    【讨论】:

    • 很有趣,但会引出两个问题: 1. 为什么高位不归零?据推测,如果您使用此指令,您的意图是编写非矢量化代码。 2. GCC 和 Clang 似乎仍然这样做,即使 xmm 寄存器的高位未使用,即随后仅使用非向量化指令时。这是为什么呢?
    • 1.英特尔选择这样做。为什么不是很重要。它偶尔有用,但可能带来的麻烦多于其价值。 2. 即使 XMM 寄存器的高位部分从未使用过,也会存在部分寄存器更新风险。这就是它如此阴险的原因。
    • 您的编辑中更详细的解释太棒了!我唯一的问题是,为什么 CPU 的逻辑不独立于对高四字的依赖来跟踪对低四字的依赖关系,并理解 xxxsd 指令只能读取/写入寄存器的低四字?
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-06-16
    • 2019-06-14
    • 1970-01-01
    • 2019-09-22
    • 2019-10-05
    • 1970-01-01
    相关资源
    最近更新 更多