【问题标题】:Why does this difference in asm matter for performance (in an un-optimized ptr++ vs. ++ptr loop)?为什么 asm 的这种差异对性能很重要(在未优化的 ptr++ 与 ++ptr 循环中)?
【发布时间】:2016-09-12 00:41:22
【问题描述】:

TL;DR:第一个循环在 Haswell CPU 上运行速度提高了约 18%。为什么?这些循环来自使用ptr++ 和++ptr 的gcc -O0(未优化)循环,但问题是为什么生成的asm 执行不同,而不是关于如何编写更好的C。


假设我们有这两个循环:

    movl    $0, -48(%ebp)     //Loop counter set to 0
    movl    $_data, -12(%ebp) //Pointer to the data array
    movl    %eax, -96(%ebp)
    movl    %edx, -92(%ebp)
    jmp L21
L22:
    // ptr++
    movl    -12(%ebp), %eax   //Get the current address
    leal    4(%eax), %edx     //Calculate the next address
    movl    %edx, -12(%ebp)   //Store the new (next) address
    // rest of the loop is the same as the other
    movl    -48(%ebp), %edx   //Get the loop counter to edx
    movl    %edx, (%eax)      //Move the loop counter value to the CURRENT address, note -12(%ebp) contains already the next one
    addl    $1, -48(%ebp)     //Increase the counter
L21:
    cmpl    $999999, -48(%ebp)
    jle     L22

第二个:

    movl    %eax, -104(%ebp)
    movl    %edx, -100(%ebp)
    movl    $_data-4, -12(%ebp) //Address of the data - 1 element (4 byte)
    movl    $0, -48(%ebp)       //Set the loop counter to 0
    jmp L23
L24:
    // ++ptr
    addl    $4, -12(%ebp)       //Calculate the CURRENT address by adding one sizeof(int)==4 bytes
    movl    -12(%ebp), %eax     //Store in eax the address
    // rest of the loop is the same as the other
    movl    -48(%ebp), %edx     //Store in edx the current loop counter
    movl    %edx, (%eax)        //Move the loop counter value to the current stored address location
    addl    $1, -48(%ebp)       //Increase the loop counter
L23:
    cmpl    $999999, -48(%ebp)
    jle L24

这些循环的作用完全相同,但方式略有不同,详情请参阅评论。

此 asm 代码由以下两个 C++ 循环生成:

    //FIRST LOOP:
    for(;index<size;index++){
        *(ptr++) = index;
    }
    //SECOND LOOP:
    ptr = data - 1;
    for(index = 0;index<size;index++){
        *(++ptr) = index;
    }

现在,第一个循环比第二个循环快约 18%,无论循环执行的顺序如何,ptr++ 的循环都比 ++ptr 的循环快。

为了运行我的基准测试,我只是收集了这些循环在不同 size 下的运行时间,并将它们都嵌套在其他循环中以频繁重复操作。


ASM 分析

查看 ASM 代码,第二个循环包含较少的指令,我们有 3 个 movl 和 2 个 addl,而在第一个循环中,我们有 4 个 movl 一个 addl 和一个 leal,所以我们有一个 movl 和 一个 leal而不是添加

LEA 计算正确地址的操作比ADD (+4) 方法快得多,这对吗?这是造成性能差异的原因吗?

据我所知,一旦在内存可以引用之前计算出新地址,就必须经过一些时钟周期,因此 addl $4,-12(%ebp) 之后的第二个循环需要稍等片刻才能继续,而在第一个循环中,我们可以立即引用内存,同时 LEAL 将计算下一个地址(这里是某种更好的流水线性能)。

这里有一些重新排序吗?我不确定我对这些循环的性能差异的解释,我能听听你的意见吗?

【问题讨论】:

  • 请注意,由于data - 1 下溢,第二个循环可能是未定义的行为。
  • @EOF:你能详细说明一下吗?对我来说,它看起来像普通的指针数学......所以 ptr-1+1 = ptr+0,对我来说一切都很好。
  • @Ped7g:C++ 标准草案 N3337:5.7 加法运算符 [...] 如果指针操作数和结果都指向同一个数组对象的元素,或者最后一个元素之后对数组对象,求值不会产生溢出;否则,行为是未定义的。 虽然指向数组的最后一个元素过去存在特殊情况,但指向之前第一个元素。
  • @EOF:使用强制转换创建未对齐的指针也是未定义的行为,即使您不取消引用。但是,我们一直使用__m128i 类型来执行此操作。在大多数 C 实现(包括针对 x86 的 gcc)中,语义已经很好地定义了。有趣的是,OP 的习语在技术上是未定义的行为。如果有同样好的替代品,值得避免。
  • @Yakk:当然没有人关心一般未优化代码的速度。我应该试一试。将问题标题编辑回关于 asm,而不是 C,因为问题实际上是关于 gcc -O0 恰好生成的 asm。 你不会学到任何关于如何编写更好/更高效的 C 的知识,但你可以学到一些关于现代 x86 微架构的管道。 事实证明,答案可能是资源冲突。

标签: c++ performance loops assembly x86


【解决方案1】:

首先,对-O0 编译器输出的性能分析通常不是很有趣或有用。


计算正确地址的 LEAL 操作比 ADDL (+4) 方法快得多,这对吗?这是造成性能差异的原因吗?

不,add 可以在任何 x86 CPU 上的每个 ALU 执行端口上运行。 lea 通常与简单寻址模式一样低延迟,但吞吐量不高。在 Atom 上,它运行在与普通 ALU 指令不同的流水线阶段,因为它实际上名副其实,并在该有序微架构上使用 AGU。

请参阅 标签 wiki,了解是什么让代码在不同的微架构上变慢或变快,尤其是。 Agner Fog's microarchitecture pdf and instruction tables.

add 更糟糕,因为它让 gcc -O0 通过将其与内存目标一起使用然后从中加载来制作更糟糕的代码。


使用-O0 编译甚至不会尝试使用最佳指令来完成这项工作。例如你会得到mov $0, %eax 而不是xor %eax,%eax 你总是得到优化的代码。您不应该通过查看未优化的编译器输出来推断任何事情。

-O0 代码总是充满瓶颈,通常在加载/存储或存储转发时。不幸的是,IACA 没有考虑存储转发延迟,所以它没有意识到这些循环实际上是瓶颈


据我所知,一旦在内存可以引用之前计算出新地址,就必须经过一些时钟周期,因此 addl $4,-12(%ebp) 之后的第二个循环需要稍等片刻才能继续,

是的,-12(%ebp) 的 mov 负载在作为 add 的读-修改-写一部分的负载之后的大约 6 个周期内不会准备好。

而在第一个循环中,我们可以立即引用内存

是的

同时 LEAL 将计算下一个地址

没有。

您的分析已经接近尾声,但您错过了下一次迭代仍然需要将我们存储的值加载到 -12(%ebp) 中的事实。所以循环携带的依赖链长度相同,下一次迭代的lea实际上不能比使用add的循环更早开始


延迟问题可能不是循环吞吐量瓶颈:

需要考虑uop/执行端口的吞吐量。在这种情况下,OP 的测试表明它实际上是相关的。 (或资源冲突造成的延迟。)

当 gcc -O0 实现 ptr++ 时,它会将旧值保存在寄存器中,就像你说的那样。因此,存储地址可以提前知道,并且需要 AGU 的加载微指令更少。

假设 Intel SnB 系列 CPU:

## ptr++: 1st loop
movl    -12(%ebp), %eax   //1 uop (load)
leal    4(%eax), %edx     //1 uop (ALU only)
movl    %edx, -12(%ebp)   //1 store-address, 1 store-data
//   no load from -12(%ebp) into %eax
... rest the same.


 ## ++ptr:  2nd loop
addl    $4, -12(%ebp)       // read-modify-write: 2 fused-domain uops.  4 unfused: 1 load + 1 store-address + 1 store-data
movl    -12(%ebp), %eax     // load: 1 uop.   ~6 cycle latency for %eax to be ready
... rest the same

所以第二个循环的指针增量部分多了一个加载微指令。可能是 AGU 吞吐量(地址生成单元)的代码瓶颈。 IACA 表示 arch=SNB 就是这种情况,但 HSW 会成为存储数据吞吐量(而不是 AGU)的瓶颈。

但是,IACA 表示,在不考虑存储转发延迟的情况下,第一个循环可以每 3.5 个循环运行一次迭代,而第二个循环每 4 个循环运行一次。这比 addl $1, -48(%ebp) 循环计数器的 6 个循环循环携带依赖项要快,这表明循环受到延迟的瓶颈,低于最大 AGU 吞吐量。 (资源冲突可能意味着它实际上运行的速度比每 6c 一次迭代要慢,见下文)。

我们可以检验这个理论:

向lea 版本添加额外的负载uop,离开关键路径,将需要更多的吞吐量,但不会成为循环延迟链的一部分。例如

movl    -12(%ebp), %eax   //Get the current address
leal    4(%eax), %edx     //Calculate the next address
movl    %edx, -12(%ebp)   //Store the new (next) address

mov     -12(%ebp), %edx 

%edx 即将被mov 覆盖,因此不依赖此加载的结果。 (mov 的目的地是只写的,因此它破坏了依赖链,这要归功于寄存器重命名。)。

因此,这个额外的负载将使lea 循环达到与add 循环相同的微指令数量和风格,但延迟不同。如果额外的负载对速度没有影响,我们知道第一个循环不会成为加载/存储吞吐量的瓶颈。


更新:OP 的测试证实,额外未使用的负载会使lea 循环减慢到与add 循环大致相同的速度。

当我们没有遇到执行端口吞吐量瓶颈时,为什么额外的微指令很重要

微指令按最旧优先顺序安排(在已准备好操作数的微指令中),而不是按关键路径优先顺序。稍后可能在备用周期中完成的额外微指令实际上会延迟关键路径上的微指令(例如,循环承载的依赖项的一部分)。这称为资源冲突,会增加关键路径的延迟。

即而不是等待关键路径延迟使加载端口无事可做的循环,未使用的加载将在其加载地址就绪的最旧加载时运行。这会延迟其他加载。

同样,在额外负载是关键路径的一部分的add循环中,额外负载会导致更多的资源冲突,从而延迟关键路径上的操作。


其他猜测:

因此,也许更快地准备好存储地址就是这样做的,因此内存操作可以更好地流水线化。 (例如,当接近页面边界时,TLB-miss 页面遍历可以更快开始。即使是正常的硬件预取也不会跨越页面边界,即使它们在 TLB 中很热。循环触及 4MiB 的内存,这对于这种重要的事情。L3 延迟足够高,可能会产生管道泡沫。或者如果您的 L3 很小,那么主内存肯定是。

或者,额外的延迟可能只会让乱序执行更难做好。

【讨论】:

  • 我用一个可能的实验更新了我的答案,以测试 uop 吞吐量瓶颈理论。如果有人想发布所有三个版本的循环的结果(OP 的原始版本和lea + 未使用的负载),那可能会很有趣,只要我们知道结果的 CPU 微架构。
  • 我做了实验,和你说的完全一样!在 edx 被 -48(%ebp) 覆盖之前添加一个额外的 movl (movl -12(%ebp), %edx) 会导致性能差异缩小到可以忽略不计的值!现在性能差异小于 ~2% .我在 Haswell 处理器 (i5-4300u) 上运行测试。
  • @fjanisze:感谢您的测试,该结果让我知道发生了什么事情:再次更新了我的答案。尽管 load-uop 吞吐量不是瓶颈,但更多的吞吐量会导致关键路径上的负载发生资源冲突。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-06-25
  • 2012-03-24
  • 2010-12-22
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多