为什么这对于性能的影响很大(在未优化的ptr ++与++ ptr循环中)?

时间:2016-05-16 09:07:16

标签: c++ performance loops assembly x86

TL; DR :第一个循环在Haswell CPU上运行速度快〜18%。为什么?循环来自使用gcc -O0 vs ptr++的{​​{1}}(未优化)循环,但问题是为什么生成的asm执行的方式不同,而不是如何编写更好的C。

假设我们有两个循环:

++ptr

和第二个:

    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

这些循环完全相同,但以不同的方式,请参阅评论以获取详细信息。

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

    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

现在,第一个循环比第二个循环快约18%,无论执行循环的顺序如何, //FIRST LOOP: for(;index<size;index++){ *(ptr++) = index; } //SECOND LOOP: ptr = data - 1; for(index = 0;index<size;index++){ *(++ptr) = index; } 的循环都快于ptr++的循环。

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

ASM分析

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

用于计算正确地址的++ptr操作比LEA(+4)方法快得多,这是否正确?这是性能差异的原因吗?

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

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

1 个答案:

答案 0 :(得分:13)

首先,对-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)需要稍等一会儿再继续,

是的,mov的{​​{1}}负载在-12(%ebp)读取修改的一部分加载后,已经准备好大约6个周期写。

  

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

  

同时LEAL将计算下一个地址

没有

您的分析很接近,但您错过了下一次迭代仍然需要将我们存储的值加载到add的事实。因此,循环携带的依赖链是相同的长度,并且下一次迭代的-12(%ebp)实际上可以比使用lea

的循环更快地启动

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

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

当gcc add实现-O0时,它会将旧值保存在寄存器中,就像你说的那样。因此,商店地址可以提前知道,并且需要AGU的负载uop少一个。

假设Intel SnB系列CPU:

ptr++

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

然而,在不考虑存储转发延迟的情况下,IACA表示第一个循环可以每3.5个循环运行一次,而第二个循环每4个循环运行一次。这比## 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 循环计数器的6循环循环携带依赖性更快,这表明循环因等待时间瓶颈而小于最大AGU吞吐量。 (资源冲突可能意味着它实际上比每6c的一次迭代运行得慢,见下文)。

我们可以测试这个理论:

addl $1, -48(%ebp)版本中添加额外的加载uop,关闭关键路径会增加吞吐量,但不会成为循环延迟的一部分链。 e.g。

lea

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循环相同的数量和风格,但具有不同的延迟。如果额外负载对速度没有影响,我们知道第一个循环在加载/存储吞吐量方面没有瓶颈。

更新:OP的测试确认额外的未使用负载会使add循环速度降低到与lea循环速度大致相同的速度。

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

uops按最早的第一顺序排列(在其操作数准备就绪的uops中),而不是以关键路径优先顺序排列。稍后可能在备用周期中完成的额外微操作将实际上延迟关键路径上的微操作(例如,循环携带依赖性的一部分)。这称为资源冲突,可能会增加关键路径的延迟。

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

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

其他猜测:

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

或许额外的延迟可能会使无序执行更难以做好工作。