为什么这个循环每次迭代需要1.32个时钟周期

21

考虑这个计算一个数组的前缀和的简单C++函数:

void prefix_sum(const uint32_t* input, uint32_t* output, size_t size) {
    uint32_t total = 0;
    for (size_t i = 0; i < size; i++) {
        total += input[i];
        output[i] = total;
    }
}

在gcc 5.5上,该循环编译为以下汇编代码:

.L5:
        add     ecx, DWORD PTR [rdi+rax*4]
        mov     DWORD PTR [rsi+rax*4], ecx
        add     rax, 1
        cmp     rdx, rax
        jne     .L5

我没有看到任何会阻止这个程序每个迭代运行1个周期的问题,但是在我的Skylake i7-6700HQ上对8 KiB输入/输出数组运行时,我一直测量到1.32(+/- 0.01)个周期/迭代。

该循环从uop缓存中提供服务,并且不跨越任何uop缓存边界,性能计数器也没有指示任何前端瓶颈。

它是4个融合的uops1,而此CPU可以维持每个周期的4个融合操作。

通过ecxrax有1个周期的依赖链传递,但是这些add uops可以发送到4个ALU端口中的任何一个,因此似乎不太可能发生冲突。融合cmp需要发送到p6,这更令人担忧,但是我测量到只有1.1个uops/迭代发送到p6。这可以解释每个迭代使用1.1个周期,但不能解释1.4个周期。如果我将循环展开2倍,则端口压力要低得多:所有p0156的uops都小于0.7,但性能仍然意外地慢,每个迭代的周期为1.3个。

每次迭代有一个存储器操作,但我们可以每个周期进行一次存储操作。

每次迭代有一个加载操作,但我们可以每个周期执行两个这样的操作。

每个周期有两个复杂的AGU,但我们可以每个周期执行两个这样的操作。

问题出在哪里?

有趣的是,我尝试了Ithermal性能预测器,它几乎完全正确:估计1.314个周期,而我测量到1.32个周期。

1 我通过uops_issued.any计数器确认了宏观和微观融合,该计数器在融合域中计算,并且对于此循环每次迭代读取4.0个融合uops。


你有检查4k抗锯齿吗?如果你有一个方便的MCVE调用者,我会在我的桌面上进行测试运行。 - Peter Cordes
@PeterCordes 我检查了 ld_blocks_partial.address_alias ,它报告了一个低数字并且不随问题大小而增加。 两个数组都对齐到2 MiB。 是的,我应该提供MCVE,但这需要一些工作,因为当前的基准测试分布在十几个文件中,但我会在某个时候完成它。 - BeeOnRope
1
@HadiBrais:我得到了CYCLE_ACTIVITY.STALLS_MEM_ANY:u的250万计数,而总周期数为27亿。虽然不高,但不为零。(如果不仅限于用户空间,大约为420万)。但是 resource_stalls.sb:u 大约为70k到90k,并且嘈杂,低了约30倍。所以存储瓶颈可能只是噪声。 - Peter Cordes
1
我想知道是否存在某种寄存器读取限制。例如,https://www.agner.org/optimize/blog/read.php?i=415#857 还表明,读取更多的寄存器(或使用复杂的寻址模式?)会减慢 Skylake 的速度。因此,我的更改所带来的加速可能是通过从循环条件中消除一个寄存器实现的。 - Peter Cordes
1
我注意到每次迭代中p4计数比1高,并且接近于循环/迭代,即可以解释大部分性能差异。例如,原始版本的展开运行在1.26个循环/迭代,并显示每次迭代1.25个uops到p4。这可能表明存储正在被重放,因为它们的操作数尚未准备好?虽然更可能是症状而不是原因。 - BeeOnRope
显示剩余7条评论
1个回答

4
我刚刚使用Ithermal性能预测器的指令进行了测试,可能已经找到了问题。 正在尝试。
add     ecx, DWORD PTR [rdi]
mov     DWORD PTR [rsi], ecx
add     rax, 1
cmp     rdx, rax

每次迭代耗时惊人的1.131个周期。通过在每次迭代中添加0进行交叉检查(再次得到1.3个周期),消除了存储/加载瓶颈的可能性。这最终表明存在寻址模式问题。

(编辑注:这是有趣的实验数据,与我在Agner Fog博客上发布的线程中所发表的内容相匹配,下面的猜测误解了这一点。简单的寻址模式可以加速它,即使没有解缩。)


(编辑注:这部分是错误的:我们知道从问题中每次迭代uops_issued.any=4。)

我认为你的CPU会在索引寻址的情况下取消操作/移动的解缩。对于几种体系结构(SnB、SKL、HWL),这种行为都有良好的文档记录,有人在stackoverflow上做了很好的工作,描述了整个过程:https://dev59.com/jl8e5IYBdhLWcg3wJXrm#31027695 简而言之:如果涉及太多寄存器和标志,则融合操作(DSB)将被取消解缩(IDQ),因此有效地再次未合并。

其他资源:


BeeOnRope在问题中表示,他使用性能计数器确认了循环为4个融合域uop,因此排除了非分离。这也不是我在Agner Fog的博客主题帖中发表的内容,它是关于未融合域uop 吞吐量限制和/或寄存器读取吞吐量限制的。在HSW和SKL上,我发现减少输入寄存器的数量是有帮助的,这表明存在一些其他未知的微架构限制,就像您通过读取更少的寄存器所证明的那样。 - Peter Cordes
是的,复杂的寻址模式可能会带来问题,但这可能只是因为每个uop需要额外的输入。可能还与对最近递增的RAX的依赖有关,但这种情况不太可能发生。无论如何,我们知道HSW和SKL可以保持那些add+load和mov-store uops微融合,并且指令外的上下文不会影响它们。 - Peter Cordes
DSB 之后会发生去层化。你确定 uops_issued.any 也包括在其中吗? - Zacharias
1
@PeterCordes - 我对于寄存器读取限制(如你在Agner的博客中所描述的)是否涉及此处存在疑虑。首先,似乎没有足够的寄存器被读取,而且即使你将循环展开2倍,效果仍然存在(但更小)。使用2倍展开,肯定没有多少寄存器被读取,所需IPC大约为3而不是4,这也有助于消除“太多uops”的理论(例如unlamination理论)。总的来说,展开操作会不断减少与预期1.0 cycles/iter的差距,即使在4倍展开时,它仍然保持在1.07 iters/cycle左右。 - BeeOnRope
1
我想提醒未来的读者,赏金是自动分配的,因为它是(唯一)得到最多赞的答案,但它并没有回答问题。赏金分配不是认可。 - BeeOnRope
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接