TL;DR :第一个循环在Haswell CPU上运行速度快〜18% . 为什么?循环来自使用 ptr++
vs ++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
的循环 .
为了运行我的基准测试,我只收集了不同大小的循环的运行时间,并将它们嵌套在其他循环中以经常重复操作 .
ASM分析
查看ASM代码,第二个循环包含较少的指令,我们有3个movl和2个addl,而在第一个循环中我们有4个movl,一个addl和一个leal,所以我们有一个movl和 one leal instead of addl
用于计算正确地址的 LEA
操作比 ADD
(4)方法快得多是否正确?这是性能差异的原因吗?
据我所知,一旦在内存被引用之前计算了一个新地址,必须经过一些时钟周期,所以addl $ 4之后的第二个循环,-12(%ebp)需要等待一小段才能继续,而在第一个循环我们可以立即引用内存,同时LEAL将计算下一个地址(这里有一些更好的流水线性能) .
这里有一些重新排序吗?我不确定我对这些循环的性能差异的解释,我可以有你的意见吗?
1 回答
首先,对
-O0
编译器输出的性能分析通常不是很有趣或有用 .不,
add
可以在任何x86 CPU上的每个ALU执行端口上运行 .lea
通常具有简单寻址模式的低延迟,但吞吐量不高 . 在Atom上,它在正常ALU指令的管道的不同阶段运行,因为它实际上符合其名称并在有序微体系结构上使用AGU .请参阅x86标记wiki,了解在不同的微体系结构上使代码变慢或变快的原因,尤其是Agner Fog's microarchitecture pdf and instruction tables .
add is only worse because it lets gcc -O0 make even worse code by using it with a memory destination and then loading from that.
用
-O0
进行编译't even try to use the best instructions for the job. e.g. you' ll得到mov $0, %eax
而不是xor %eax,%eax
你总是得到优化的代码 . 您不应该从查看未优化的编译器输出中推断出什么是好的 .-O0
代码总是充满瓶颈,通常是在加载/存储或存储转发时 . 不幸的是,IACA并没有意识到这些循环实际上是瓶颈是的,
-12(%ebp)
的mov
负载在add
的读取 - 修改 - 写入的负载之后将在大约6个周期内没有准备好 .是
没有 .
您的分析很接近,但您错过了下一次迭代仍然需要将我们存储的值加载到
-12(%ebp)
的事实 . 所以循环携带的依赖链是相同的长度,并且下一次迭代的lea
实际上不能比使用add
的循环更快地启动延迟问题可能不是循环吞吐量瓶颈:
需要考虑uop /执行端口吞吐量 . 在这种情况下,OP的测试显示它实际上是相关的 . (或资源冲突造成的延迟 . )
当gcc
-O0
实现ptr++
时,它会将旧值保存在寄存器中,就像你说的那样 . 因此,存储地址可以提前知道,并且需要AGU的负载uop少一个 .假设一个Intel SnB系列CPU:
所以第二个循环的指针增量部分还有一个加载uop . 可能是AGU吞吐量的代码瓶颈(地址生成单位) . IACA表示arch = SNB就是这种情况,但HSW瓶颈存储数据吞吐量(而不是AGU) .
然而,在没有考虑存储转发延迟的情况下,IACA表示第一个循环可以每3.5个循环运行一次,而第二个循环每4个循环运行一次 . 这比
addl $1, -48(%ebp)
循环计数器的6循环循环携带依赖性更快,这表明循环因延迟而低于最大AGU吞吐量而受到瓶颈 . (资源冲突可能意味着它实际上比每6c的一次迭代运行得慢,见下文) .我们可以测试这个理论:
在关键路径上添加一个额外的负载uop到
lea
版本将需要更多的吞吐量,但不会延迟链 . 例如%edx
即将被mov
覆盖,因此不会对此负载的结果产生依赖性 . (mov
的目标是只写的,因此它会破坏依赖链,这要归功于寄存器重命名 . ) .So this extra load would bring the lea loop up to the same number and flavour of uops as the add loop, but with different latency . 如果额外负载对速度没有影响,我们知道第一个循环在加载/存储吞吐量上没有瓶颈 .
更新:OP的测试证实,额外的未使用负载会将
lea
循环降低到与add
循环大致相同的速度 .当我们没有遇到执行端口吞吐量瓶颈时,为什么额外的uops很重要
uops are scheduled in oldest-first order (在其操作数准备就绪的uops中),而不是以关键路径优先顺序 . 稍后可能在备用周期中完成的额外微操作将实际上延迟关键路径上的微操作(例如,循环携带依赖性的一部分) . 这称为 resource conflict ,可以增加关键路径的延迟 .
也就是说,不是等待关键路径延迟使加载端口无事可做的循环,未使用的加载将在其最早的加载及其加载地址就绪时运行 . 这将延迟其他负载 .
类似地,在额外负载是关键路径的一部分的
add
循环中,额外负载会导致更多资源冲突,从而延迟关键路径上的操作 .其他猜测:
因此,可能更快就准备好商店地址就是这样做,因此内存操作可以更好地进行流水线操作 . (例如,当接近页面边界时,TLB-miss页面遍历可以更快地开始 . 即使正常的硬件预取也不会跨越页面边界,即使它们在TLB中很热 . 循环接触4MiB的内存,这对于这种类型的重要的是.L3延迟足够高,可能会产生管道泡沫 . 或者如果你的L3很小,那么主内存肯定是 .
或者,额外的延迟可能会使无序执行更难以完成 .