在IvyBridge上,指针追踪循环中的附近依赖存储会产生奇怪的性能影响。添加额外的负载可以加速吗?

7

首先,我在IvyBridge上有以下设置,在注释位置插入测量负载代码。 buf 的前8个字节存储了buf本身的地址,我使用这个来创建循环依赖:

section .bss
align   64
buf:    resb    64

section .text
global _start
_start:
    mov rcx,         1000000000
    mov qword [buf], buf
    mov rax,         buf
loop:
    ; I will insert payload here
    ; as is described below 

    dec rcx
    jne loop

    xor rdi,    rdi
    mov rax,    60
    syscall

案例1:

我将数据插入到负载位置:

mov qword [rax+8],  8
mov rax,            [rax]

perf 显示循环每次需要 5.4c。这个数据比较容易理解,因为 L1d 的延迟为 4 个时钟周期。

案例二:

我将这两条指令的顺序颠倒了:

mov rax,            [rax]
mov qword [rax+8],  8

结果突然变成了9c/iter。我不明白为什么。因为下一次迭代的第一个指令不依赖于当前迭代的第二个指令,所以这种设置在案例1中应该是相同的。
我还使用了IACA工具对这两种情况进行了静态分析,但该工具不可靠,因为它预测这两种情况的结果都是5.71c/iter,这与实验结果相矛盾。
案例3:
然后我在案例2中插入了一个无关的mov指令:
mov rax,            [rax]
mov qword [rax+8],  8
mov rbx,            [rax+16] 

现在结果变为6.8c/iter。但是一个无关的mov怎么会将速度从9c/iter提高到6.8c/iter呢?
与之前情况一样,IACA工具预测的结果是错误的,显示为5.24c/iter。
我现在完全困惑了,如何理解上述结果?
附加信息编辑:
在情况1和2中,有一个地址rax+8。如果将rax+8更改为rax+16rax+24,情况1和2将保持相同的结果。但当它被更改为rax+32时,令人惊讶的事情发生了:情况1变成了5.3c/iter,而情况2突然变成了4.2c/iter。
更多perf事件信息编辑:
$ perf stat -ecycles,ld_blocks_partial.address_alias,int_misc.recovery_cycles,machine_clears.count,uops_executed.stall_cycles,resource_stalls.any ./a.out

[rax+8]的第一个例子:

 5,429,070,287      cycles                                                        (66.53%)
         6,941      ld_blocks_partial.address_alias                                     (66.75%)
       426,528      int_misc.recovery_cycles                                      (66.83%)
        17,117      machine_clears.count                                          (66.84%)
 2,182,476,446      uops_executed.stall_cycles                                     (66.63%)
 4,386,210,668      resource_stalls.any                                           (66.41%)

对于[rax+8]的第二个情况:

 9,018,343,290      cycles                                                        (66.59%)
         8,266      ld_blocks_partial.address_alias                                     (66.73%)
       377,824      int_misc.recovery_cycles                                      (66.76%)
        10,159      machine_clears.count                                          (66.76%)
 7,010,861,225      uops_executed.stall_cycles                                     (66.65%)
 7,993,995,420      resource_stalls.any                                           (66.51%)

对于[rax+8],第三种情况:

 6,810,946,768      cycles                                                        (66.69%)
         1,641      ld_blocks_partial.address_alias                                     (66.73%)
       223,062      int_misc.recovery_cycles                                      (66.73%)
         7,349      machine_clears.count                                          (66.74%)
 3,618,236,557      uops_executed.stall_cycles                                     (66.58%)
 5,777,653,144      resource_stalls.any                                           (66.53%)

[rax+32]的第二个情况:

 4,202,233,246      cycles                                                        (66.68%)
         2,969      ld_blocks_partial.address_alias                                     (66.68%)
       149,308      int_misc.recovery_cycles                                      (66.68%)
         4,522      machine_clears.count                                          (66.68%)
 1,202,497,606      uops_executed.stall_cycles                                     (66.64%)
 3,179,044,737      resource_stalls.any                                           (66.64%)

1
@PeterCordes 我也在IvyBridge上测试了[rel buf+8],所有情况都变成了4c/iter,所以这与相关存储有关。出于好奇,这种微架构异常在实践中发生的频率有多高?这段代码看起来很简单,对于更复杂的实际代码,我想知道是否真的可能预测关键部分的周期数。 - user10865622
1
纯ALU循环具有交错依赖关系的不完美调度或其他问题,但通常从循环传递的链中分叉出短的独立链并不会影响吞吐量。存储和加载使事情变得复杂。内存消歧义是难以实现的,x86必须给人强烈的内存排序语义的假象,同时实际上要执行积极的乱序操作,因此有很多内存硬件。在循环遍历一个或两个数组的实际代码中预测吞吐量通常非常准确,即使从一个数组加载并存储到另一个数组也是如此。 - Peter Cordes
1
我的Haswell的结果与IvB和SK不同,但同样有趣。Case1 = case2 = 8.4c/iter,Case3 = 8.9c/iter。STALLS_LDM_PENDING等于CYCLES_NO_EXECUTE= 6c,表明负载延迟对性能的有效影响为8.4c中的6c,且负载延迟至少为6c。UOPS_EXECUTED_PORT.PORT_X事件显示PORT_2+PORT_3+PORT_7 = 5B uops,但预期值为2B。然而,PORT_4正如预期的是1B。由于某种原因,load uop正在被重放。 - Hadi Brais
1
@PeterCordes 我认为在HSW上,不能同时执行对同一缓存行的加载和存储操作。如果有一个非重叠的加载和一个(要提交的)存储到同一行,内存单元将选择其中一个并发出它,另一个将不得不等待直到它完成。例如,除非存储缓冲区已满或其他原因,否则它可能会优先处理加载而不是存储。我的结果表明 STALLS_LDM_PENDING 可能捕捉到了这种效应。OP的结果表明,这个问题可能存在于IvB上,但具有不同的性能影响... - Hadi Brais
1
@PeterCordes - 是的,内存消歧义使用了预测器。我在Skylake上详细介绍了它的工作原理这里,但我认为早期架构也是类似的。 - BeeOnRope
显示剩余13条评论
1个回答

2
对于这三种情况,在同时执行加载和存储时会产生一些周期的惩罚。在所有三种情况中,加载延迟都处于关键路径上,但不同情况下的惩罚不同。由于额外的加载,第3种情况比第1种情况多一个周期。
分析方法1:使用停顿性能事件 我能够在IvB和SnB上重现您在所有三种情况下的结果。我得到的数字与您的数字相差不到2%。执行单个迭代所需的周期数分别为5.4、8.9和6.6。
让我们从前端开始。LSD.CYCLES_4_UOPS和LSD.CYCLES_3_UOPS性能事件表明基本上所有uop都是从LSD发出的。此外,这些事件以及LSD.CYCLES_ACTIVE显示,在LSD未被阻塞的每个周期中,情况1和2中发出3个uop,情况3中发出4个uop。换句话说,如预期的那样,每次迭代的uop都在同一组中在单个周期内发出。
在以下所有关系中,"=~"符号表示差异在2%以内。我将从以下经验观察开始:
UOPS_ISSUED.STALL_CYCLES + LSD.CYCLES_ACTIVE =~ cycles
请注意,在SnB上,LSD事件计数需要按照此处讨论的进行调整。
我们还有以下关系:
情况1:UOPS_ISSUED.STALL_CYCLES =~ RESOURCE_STALLS.ANY =~ 4.4c/iter 情况2:UOPS_ISSUED.STALL_CYCLES =~ RESOURCE_STALLS.ANY =~ 7.9c/iter 情况3:UOPS_ISSUED.STALL_CYCLES =~ RESOURCE_STALLS.ANY =~ 5.6c/iter 这意味着问题停顿的原因是由于后端中一个或多个所需资源不可用。因此,我们可以自信地将整个前端从考虑范围内排除。在情况1和2中,该资源是RS。在情况3中,由于RS而导致的停顿约占所有资源停顿的20%1
让我们现在关注于情况1。总共有4个未合并的域uops:1个load uop,1个STA,1个STD和1个dec/jne。load和STA uops依赖于先前的load uop。每当LSD发出一组uops时,STD和jump uops可以在下一个周期中分派,因此下一个周期不会导致执行停顿事件。然而,load和STA uops可以分派的最早时间是在写回load结果的同一周期内。CYCLES_NO_EXECUTE和STALLS_LDM_PENDING之间的相关性表明,没有准备好执行的uops的原因是因为RS中所有的uops都在等待L1服务挂起的load请求。具体来说,RS中一半的uops是load uops,另一半是STAs,它们都在等待相应上一次迭代的load完成。LSD.CYCLES_3_UOPS显示LSD会等到RS中至少有4个空闲条目,然后发出一组uops,这些uops构成一个完整的迭代。在下一个周期中,其中两个uops将被分派,从而释放2个RS条目2。其他的将不得不等待它们所依赖的load完成。很可能load按程序顺序完成。因此,LSD会等到尚未执行的最老迭代的STA和load uops离开RS。因此,UOPS_ISSUED.STALL_CYCLES + 1 =~ 平均load延迟3。我们可以得出结论,在情况1下,平均load延迟为5.4c。大部分内容适用于情况2,除了一个差异,我马上会解释。

由于每个迭代中的uops形成一个依赖链,我们还有:

cycles =~ 平均load延迟。

因此:

cycles =~ UOPS_ISSUED.STALL_CYCLES + 1 =~ 平均load延迟。

在情况1下,平均load延迟为5.4c。我们知道L1缓存的最佳情况延迟为4c,因此存在1.4c的load延迟惩罚。但是为什么有效的load延迟不是4c?

调度程序将预测uops所依赖的load将在某些恒定延迟内完成,因此它将安排它们相应地分派。如果由于任何原因(例如L1未命中),load需要更长的时间,则uops将被分派,但load结果尚未到达。在这种情况下,uops将被重新播放,并且分派的uops数量将大于发出的总uops数量。

Load和STA uops只能分派到端口2或3。事件“UOPS_EXECUTED_PORT.PORT_2”和“UOPS_EXECUTED_PORT.PORT_3”可用于计算分派到端口2和3的uops数量。
情况1:UOPS_EXECUTED_PORT.PORT_2 + UOPS_EXECUTED_PORT.PORT_3 =约2个uops/迭代 情况2:UOPS_EXECUTED_PORT.PORT_2 + UOPS_EXECUTED_PORT.PORT_3 =约6个uops/迭代 情况3:UOPS_EXECUTED_PORT.PORT_2 + UOPS_EXECUTED_PORT.PORT_3 =约4.2个uops/迭代
在情况1中,AGU uops的总数恰好等于退役的AGU uops的数量;没有重播。因此,调度程序从未出现错误预测。在情况2中,平均每个AGU uop会有2次重播,这意味着调度程序平均每个AGU uop会出现两次错误预测。为什么情况2会出现错误预测,而情况1不会呢?
调度程序将根据负载重放uops,原因如下:
L1缓存未命中。 内存消歧误判。 内存一致性违规。 L1缓存命中,但存在L1-L2流量。 虚拟页号错误预测。 某些其他(未记录的)原因。
可以使用相应的性能事件明确排除前5个原因。Patrick Fay(Intel)在此处说:
最后,是的,在切换负载和存储之间有“一些”空闲周期。我被告知不要更具体地说明。 ... SNB可以在同一周期读取和写入不同的银行。
我发现这些陈述可能故意含糊。第一句话暗示了负载和存储到L1永远无法完全重叠。第二个暗示只有在有两个不同的银行时才能在同一周期内执行负载和存储。虽然具有不同的银行可能既不是必要条件也不是充分条件。但是,有一件事是确定的,如果存在并发的加载和存储请求,则负载(和存储)可能会延迟一个或多个周期。这解释了情况1中平均负载延迟1.4c的惩罚。
在情况1和情况2之间存在差异。在情况1中,依赖于相同加载uop的STA和load uop会在同一周期内一起发出。另一方面,在情况2中,依赖于相同加载uop的STA和load uop属于两个不同的发布组。每次迭代的问题停滞时间基本上等于按顺序执行一个load并退役一个store所需的时间。可以使用CYCLE_ACTIVITY.STALLS_LDM_PENDING估计每个操作的贡献。执行STA uop需要一个周期,因此存储器可以在STA被派遣的下一个周期中退役。
平均加载延迟为CYCLE_ACTIVITY.STALLS_LDM_PENDING + 1个周期(加载被派遣的周期)+ 1个周期(跳转uop被派遣的周期)。我们需要将2个周期添加到CYCLE_ACTIVITY.STALLS_LDM_PENDING,因为这些周期中没有执行停顿,但它们构成了总加载延迟的一部分。这等于6.8 + 2 = 8.8个周期 =~ cycles。
在前十几个迭代的执行过程中,每个周期都会在RS中分配一个跳转和STD uop。这些将始终在发布周期后的周期中分派执行。在某个时刻,RS将变满,并且所有尚未分派的条目都是STA和load uop,它们正在等待各自前一迭代的load uop写回其结果。因此,分配器将停顿,直到有足够的空闲RS条目来发出整个迭代。假设最旧的load uop在周期T + 0中写回其结果。我将把该load uop所属的迭代称为当前迭代。将发生以下事件序列:
在周期T + 0:派遣当前迭代的STA uop和下一迭代的load uop。在此周期中没有分配,因为没有足够的RS条目。这个周期被计算为一个分配停顿周期,但不是执行停顿周期。
在周期T + 1:STA uop完成执行并且store退役。将分配下一批要分配的uops。这个周期被计算为一个执行停顿周期,但不是分配停顿周期。
在周期T + 2:刚刚分配的跳转和STD uop被派遣。这个周期被计算为一个分配停顿周期,但不是执行停顿周期。
在 cycles T + 3 到 T + 3 + CYCLE_ACTIVITY.STALLS_LDM_PENDING - 2 中的所有周期都被计算为执行和分配停顿周期。注意,这里有 CYCLE_ACTIVITY.STALLS_LDM_PENDING - 1 个周期。
因此,UOPS_ISSUED.STALL_CYCLES 应该等于 1 + 0 + 1 + CYCLE_ACTIVITY.STALLS_LDM_PENDING - 1。检查一下:7.9 = 1+0+1+6.8-1。
按照 case 1 的推理,cycles 应该等于 UOPS_ISSUED.STALL_CYCLES + 1 = 7.9 + 1 =~ 实际测量的 cycles。在同时执行 load 和 store 时产生的惩罚比 case 1 高 3.6c。就好像 load 在等待 store 被提交一样。我认为这也解释了为什么 case 2 中会出现重放,而 case 1 中不会出现。
在 case 3 中,有 1 个 STD、1 个 STA、2 个 loads 和 1 个 jump。单次迭代的 uops 可以在一个周期内全部分配,因为 IDQ-RS 带宽为每个周期 4 个融合 uops。uops 进入 RS 后会变成非融合状态。1 个 STD 需要 1 个周期被分派。跳转操作也需要 1 个周期。有 3 个 AGU uop,但只有 2 个 AGU 端口。因此,需要 2 个周期(与 case 1 和 2 中的 1 个周期相比)来分派 AGU uop。已分派的 AGU uop 组合将是以下之一:
- 同一次迭代的第二个 load uop 和 STA uop。它们依赖于同一次迭代的第一个 load uop。两个 AGU 端口都被使用。 - 下一次迭代的第一个 load uop 可以在下一个周期中分派。这取决于前一次迭代的 load。只有两个 AGU 端口之一被使用。
由于需要多一个周期来释放足够的 RS 条目以容纳整个发行组,所以 UOPS_ISSUED.STALL_CYCLES + 1 - 1 = UOPS_ISSUED.STALL_CYCLES =~ 平均加载延迟 =~ 5.6c,非常接近 case 1 的值。惩罚约为 1.6c。这就解释了为什么在 case 3 中,相对于 case 1 和 2,每个 AGU uop 平均被分派了 1.4 次。
同样,由于需要多一个周期来释放足够的 RS 条目以容纳整个发行组:

cycles =~ 平均负载延迟 + 1 = 6.6c/iter,这与我系统上测量得到的 cycles 完全一致。

类似于第二种情况的详细分析也可以在第三种情况下进行。在第三种情况下,STA 的执行与第二次加载的延迟重叠。两个加载的延迟时间也大多重叠。

我不知道为什么不同情况下惩罚是不同的。我们需要知道 L1D 缓存的确切设计方式。无论如何,我足够自信地说加载延迟(和存储延迟)会有“几个空闲周期”的惩罚,以发布此答案。


脚注

(1) 其余80%的时间用于在加载矩阵上停顿。手册中几乎没有提到这个结构。它用于指定 uops 和加载 uops 之间的依赖关系。据估计,SnB 和 IvB 上有32个条目。没有记录性能事件可以专门计算 LM 上的停顿时间。所有记录的资源停顿事件都是零。在第3种情况下,每次迭代中有5个uops中的3个依赖于前一个加载,因此在任何其他结构之前,LM 很可能会被填满。估计“有效”的RS条目数分别为IvB和SnB约为51和48。

(2) 我可能在这里做了一个无害的简化。请参见 RESOURCE_STALLS.RS 事件即使 RS 没有完全填满是否可能发生?

(3) 可以创建一个 uop 流通过管道的可视化图以查看所有这些内容如何组合在一起。您可以使用简单的加载链作为参考。对于第1种情况来说很容易,但由于重放而对于第2种情况来说则很困难。


分析方法2:使用加载延迟性能监测设备

我提出了另一种分析代码的方法。这种方法更简单,但不太准确。然而,它实质上导致我们得出相同的结论。

替代方法基于 MEM_TRANS_RETIRED.LOAD_LATENCY_* 性能事件。这些事件是特殊的,因为它们只能在精确级别上进行计数(参见:PERF STAT 不计算内存加载但计算内存存储)。

例如,MEM_TRANS_RETIRED.LOAD_LATENCY_GT_4 统计了所有执行的加载操作中,延迟大于 4 个核心周期的加载操作的数量。延迟的测量方法如下:第一次调度加载操作的周期是被视为加载操作延迟的第一个周期。写回加载结果的周期是被视为加载操作延迟的最后一个周期。因此,重新播放也会被计入延迟时间。从Sandy Bridge (至少)开始,根据这个定义,所有的加载操作都有大于 4 个周期的延迟时间。当前支持的最小延迟阈值是 3 个周期。
Case 1
Lat Threshold  | Sample Count
 3             | 1426934
 4             | 1505684
 5             | 1439650
 6             | 1032657      << Drop 1
 7             |   47543      << Drop 2
 8             |   57681
 9             |   60803
10             |   76655
11             |     <10      << Drop 3

Case 2
Lat Threshold  | Sample Count
 3             | 1532028
 4             | 1536547
 5             | 1550828
 6             | 1541661
 7             | 1536371
 8             | 1537337
 9             | 1538440
10             | 1531577
11             |     <10      << Drop

Case 3
Lat Threshold  | Sample Count
 3             | 2936547
 4             | 2890162
 5             | 2921158
 6             | 2468704      << Drop 1
 7             | 1242425      << Drop 2
 8             | 1238254
 9             | 1249995
10             | 1240548
11             |     <10      << Drop 3

这些数字代表随机选择的所有负载样本的负载数。例如,如果所有负载样本的总大小为1000万,仅有100万的延迟大于指定阈值,则测量值为100万。然而,实际执行的负载总数可能达到10亿。因此,这些绝对值本身并没有太多意义,真正重要的是在不同阈值之间的模式。
在第一种情况下,有三个显著的下降点,表示延迟大于特定阈值的负载数。我们可以推断出延迟等于或小于6个周期的负载最常见,延迟等于或小于7个周期但大于6个周期的负载次之,大多数其他负载的延迟在8-11个周期之间。
我们已经知道最小延迟为4个周期。基于这些数字,合理估计平均负载延迟介于4和6个周期之间,但更接近6个周期。从方法1中,我们知道平均负载延迟实际上是5.4个周期。因此,使用这些数字可以做出相当好的估计。
在第二种情况下,我们可以推断出大多数负载的延迟小于或等于11个周期。考虑到在广泛的延迟阈值范围内测量的负载数量的一致性,平均负载延迟可能也比4大得多。因此,它介于4和11之间,但更接近11个周期。从方法1中,我们知道平均负载延迟实际上是8.8个周期,这与基于这些数字进行任何合理估计都很接近。
第3种情况类似于第1种情况,实际上使用方法1确定的平均负载延迟在这两种情况下几乎相同。
使用“MEM_TRANS_RETIRED.LOAD_LATENCY_*”进行测量很容易,即使对于微体系结构了解不多的人也可以进行此类分析。

我们是否知道STD uops实际上是否依赖于STA uops,或者它们可以以任何顺序/并行运行?如果STA必须为STD在存储缓冲区中保留空间,这将是有意义的,这可能使设计更简单。(这比依赖关系反过来更有意义,因为您希望在存储数据准备好之前检测重叠/存储转发。)但是我们确定吗?调度程序或分配器可以选择一个存储缓冲区条目供两者使用,而无需要求从STA到STD转发结果。 - Peter Cordes
1
@PeterCordes 是的,我们确信 STD 和 STA 可以以任何顺序分派。分配已经在分配阶段完成。这在多个(旧的)英特尔文档中提到,并且可以通过实验证明。 - Hadi Brais
啊,我试图快速阅读这个密集的答案中我认为自己已经理解的部分,并错过了你解释这一点的方式。所以就像我预期的那样,STD和跳转uops可以在发出后立即调度,只有STA和load uops才涉及到“当前迭代”。我原以为你是说STD uops正在等待同一迭代的STA uops,但仔细阅读后发现你并没有这么说。可能添加更多诸如“最旧的STA”和“刚刚分配的STD/jump”之类的词语会帮助未来的读者跟上进展。 - Peter Cordes
我同意你的观点,负载延迟确实更长了,但原始问题已经表明:基准测试实际上是负载延迟的_测量_。因此,说指针追踪循环需要X个周期,因为在这种情况下负载延迟为X,这并没有说太多,对吧?真正的问题是为什么延迟会增加 - 我们知道可能的第一层答案是:发生了重播。因此,第二个问题是“为什么会发生重播”?分析的其余部分虽然有趣,但主要只是表明除了重播之外没有什么超级奇怪的事情:你看到的就是你得到的... - BeeOnRope
@BeeOnRope,不知道你怎么看待这个问题,但当我第一次阅读时,我对三种不同情况下循环吞吐量为5.4c、9c和6.8的结果感到惊讶。这些数字并不明显(至少对我来说),不清楚每种情况下的有效负载延迟是多少,以及它如何影响整体性能。对于这样的循环来说,这些数字很奇怪。我的分析表明,所有情况实际上都受到负载延迟的限制,只是延迟比预期的要大(4c)且有所变化。至于“为什么”,我们可以... - Hadi Brais
显示剩余11条评论

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