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

Weird performance effects from nearby dependent stores in a pointer-chasing loop on IvyBridge. Adding an extra load speeds it up?

首先我在 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:

我插入到payload位置:

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

perf 显示循环是 5.4c/iter。有点好理解,因为L1d延迟是4个周期。

案例 2:

我把这两条指令的顺序颠倒过来:

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

结果突然变成了9c/iter。我不明白为什么。因为下一次迭代的第一条指令不依赖于当前迭代的第二条指令,所以这个设置应该与情况 1 没有什么不同。

我也用IACA工具对这两种情况进行了静态分析,但是这个工具并不可靠,因为它预测两种情况的结果都是5.71c/iter,这与实验相矛盾。

案例 3:

然后我插入一个不相关的mov指令到案例2:

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的时候却发生了意外:case 1变成了5.3c/iter,case 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

案例 1 [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%)

案例 2 [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%)

案例 3 [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%)

案例 2 [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%)

Tl;DR: 对于这三种情况,同时执行加载和存储时会产生几个周期的惩罚。加载延迟在所有三种情况下都位于关键路径上,但不同情况下的惩罚不同。由于额外的负载,案例 3 大约比案例 1 高一个周期。


分析方法一:利用失速性能事件

我能够在 IvB 和 SnB 的所有三个案例中重现您的结果。我得到的数字在你的数字的 2% 以内。案例1、案例2、案例4执行单次迭代所需的周期数分别为5.4、8.9、6.6。

让我们从前端开始。 LSD.CYCLES_4_UOPSLSD.CYCLES_3_UOPS性能事件表明,基本上所有的微指令都是从LSD发出的。此外,这些事件与 LSD.CYCLES_ACTIVE 一起表明,在 LSD 未停止的每个周期中,在情况 1 和 2 中发出 3 微指令,在情况 3 中发出 4 微指令。换句话说,正如预期的那样,每次迭代的微指令在一个周期内在同一组中一起发出。

以下所有关系中,“=~”符号表示相差在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个未融合的domain uops:1个load uop,1个STA,1个STD和1个dec/jne。负载和 STA 微指令取决于先前的负载微指令。每当LSD发出一组微指令,STD和跳转微指令都可以在下一个周期调度,所以下一个周期不会造成执行停顿事件。然而,负载和 STA 微指令可以被调度的最早点是在负载结果被写回的同一周期中。 CYCLES_NO_EXECUTESTALLS_LDM_PENDING 之间的相关性表明没有 uops 准备执行的原因是因为 RS 中的所有 uops 都在等待 L1 服务挂起的加载请求。具体来说,RS中一半的uops是load uops,另一半是STA,都在等待各自上一次迭代的load完成。 LSD.CYCLES_3_UOPS表示LSD等到RS中至少有4个空闲条目,才发出一组构成完整迭代的微指令。在下一个周期中,将发送其中两个微指令,从而释放 2 个 RS 条目2。另一个将不得不等待他们所依赖的负载完成。很可能加载按程序顺序完成。因此,LSD 等待直到 STA 和尚未执行的最旧迭代的加载微指令离开 RS。因此,UOPS_ISSUED.STALL_CYCLES + 1 =~ 平均负载延迟3。我们可以得出结论,案例 1 的平均加载延迟为 5.4c。大多数情况适用于情况 2,只有一处不同,我将在稍后解释。

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

cycles =~平均加载延迟。

因此:

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

在情况 1 中,平均加载延迟为 5.4c。我们知道L1缓存的best-case延迟是4c,所以有1.4c的加载延迟惩罚。但是为什么有效加载延迟不是4c?

调度程序将预测 uops 所依赖的负载将在某个恒定延迟内完成,因此它将安排它们进行相应的调度。如果加载由于任何原因(例如 L1 未命中)花费的时间比加载时间长,则将调度 uops 但加载结果尚未到达。在这种情况下,将重放 uops,派发的 uops 数量将大于已发出的 uops 总数。

负载和STA uops只能派发到端口2或3。事件UOPS_EXECUTED_PORT.PORT_2UOPS_EXECUTED_PORT.PORT_3可以分别统计派发到端口2和3的uops数量.

案例 1:UOPS_EXECUTED_PORT.PORT_2 + UOPS_EXECUTED_PORT.PORT_3 =~ 2uops/iter
情况 2:UOPS_EXECUTED_PORT.PORT_2 + UOPS_EXECUTED_PORT.PORT_3 =~ 6uops/iter
情况 3:UOPS_EXECUTED_PORT.PORT_2 + UOPS_EXECUTED_PORT.PORT_3 =~ 4.2uops/iter

情况1,总派发的AGU微码数正好等于退役的AGU微码数;没有重播。所以调度器永远不会预测错误。在情况 2 中,每个 AGU uop 平均有 2 次重播,这意味着调度程序 mispr每个 AGU uop 平均听写两次。为什么案例2会出现错误预测而案例1却不会?

由于以下任何原因,调度程序将根据负载重放 uops:

  • L1 缓存未命中。
  • 内存消歧错误预测。
  • 内存一致性违规。
  • L1 缓存命中,但存在 L1-L2 流量。
  • 虚拟页码预测错误。
  • 其他一些(未记录的)原因。

使用相应的性能事件可以明确排除前 5 个原因。 Patrick Fay(英特尔)says 以下:

Lastly yes, there are 'a few' idle cycles when switching between a load and a store. I'm told not to be more specific than 'a few'.
...
SNB can read and write different banks at the same cycle.

我发现这些陈述,也许是故意的,有点模棱两可。第一个陈述表明对 L1 的加载和存储永远不会完全重叠。第二个建议只有在不同的银行中才能在同一周期内执行加载和存储。虽然去不同的银行可能既不是必要条件也不是充分条件。但有一点是肯定的,如果有并发的加载和存储请求,加载(和存储)可以延迟一个或多个周期。这解释了情况 1 中加载延迟平均 1.4c 的损失。

case 1和case 2有区别,case 1是STA和依赖同一个load uop的load uop在同一个周期一起下发。另一方面,在情况2中,依赖于同一个负载微指令的STA和负载微指令属于两个不同的问题组。每次迭代的问题停顿时间基本上等于顺序执行一个加载并退出一个存储所需的时间。可以使用 CYCLE_ACTIVITY.STALLS_LDM_PENDING 来估计每个操作的贡献。执行 STA uop 需要一个周期,因此商店可以在调度 STA 的周期后的周期中退出。

平均加载延迟为CYCLE_ACTIVITY.STALLS_LDM_PENDING + 1 周期(调度负载的周期)+ 1 周期(调度跳转uop 的周期)。我们需要向 CYCLE_ACTIVITY.STALLS_LDM_PENDING 添加 2 个周期,因为这些周期中没有执行停顿,但它们构成了总加载延迟的一小部分。这等于 6.8 + 2 = 8.8 个周期 =~ cycles.

在前十几次迭代的执行过程中,每个循环都会在RS中分配一个跳转和STD uops。这些将始终在发布周期之后的周期中被调度执行。在某个时候,RS 将变满并且所有尚未分派的条目将是 STA 和加载 uops,它们正在等待各自先前迭代的加载 uops 完成(写回它们的结果)。因此,分配器将停止,直到有足够的空闲 RS 条目来发出整个迭代。假设最老的加载 uop 在周期 T + 0 处写回了它的结果。我将把该加载 uop 所属的迭代称为当前迭代。将发生以下事件序列:

在周期T+0:调度本次迭代的STA微指令和下一次迭代的负载微指令。由于没有足够的 RS 条目,此循环中没有分配。该周期被计为分配停顿周期而不是执行停顿周期。

在周期T+1:STA uop完成执行,store退出。分配下一次迭代的微指令。该周期被计为执行停滞周期而不是分配停滞周期。

在周期 T + 2 处:刚刚分配的跳转和 STD 微指令被调度。该周期被计为分配停顿周期而不是执行停顿周期。

在周期 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。

根据案例1的推理,cycles应该等于UOPS_ISSUED.STALL_CYCLES+1=7.9+1=~实测cycles。同时执行加载和存储时产生的惩罚比情况 1 高 3.6c。就好像加载正在等待存储被提交。我想这也解释了为什么案例2有重播而案例1没有。

案例3,有1个STD,1个STA,2个负载,1个跳跃。单次迭代的 uops 都可以在一个周期内分配,因为 IDQ-RS 带宽是每个周期 4 个融合的 uops。微指令在进入 RS 时解开。 1 个 STD 需要 1 个周期才能发送。跳跃也需要1个周期。有 3 个 AGU 微处理器,但只有 2 个 AGU 端口。所以它需要 2 个周期(与情况 1 和 2 中的 1 个周期相比)来分派 AGU 微指令。调度的AGU uops组将是以下之一:

  • 第二次加载uop和同一次迭代的STA uop。这些取决于第一次加载相同迭代的 uop。两个 AGU 端口都被使用。
  • 下一个迭代的第一个load uop可以在下一个周期调度。这取决于前一次迭代的负载。仅使用两个 AGU 端口之一。

由于释放足够的 RS 条目以容纳整个问题组需要多一个周期,UOPS_ISSUED.STALL_CYCLES + 1 - 1 = UOPS_ISSUED.STALL_CYCLES =~ 平均加载延迟 =~ 5.6c,这和case 1很接近,penalty是1.6c左右。这就解释了为什么在案例 3 中与案例 1 和案例 2 相比,每个 AGU uop 平均被调度 1.4 次。

同样,因为释放足够的 RS 条目以容纳整个问题组需要更多的周期:

cycles =~ 平均加载延迟 + 1 = 6.6c/iter,实际上与我系统上测量的 cycles 完全匹配。

案例3也可以做类似案例2的详细分析。在情况 3 中,STA 的执行与第二个负载的延迟重叠。两个负载的延迟也大部分重叠。

不知道为什么不同的案件处罚不一样。我们需要知道 L1D 缓存是如何设计的。无论如何,我有足够的信心 post 这个答案的加载延迟(和存储延迟)有 "a few idle cycles" 的惩罚。


脚注

(1) 另外80%的时间花在了负载矩阵上的停顿上。手册中几乎没有提到这种结构。它用于指定 uops 和加载 uops 之间的依赖关系。在SnB和IvB上有32个条目是estimated。没有记录的性能事件可以专门计算 LM 上的失速。所有记录的资源停顿事件均为零。在情况 3 中,每次迭代有 3 个 5 微指令取决于先前的负载,因此很可能 LM 将在任何其他结构之前被填充。 IvB 和 SnB 的 "effective" RS 条目数估计分别约为 51 和 48。

(2) 我可能在这里做了一个无害的简化。参见 Is it possible for the RESOURCE_STALLS.RS event to occur even when the RS is not completely full?

(3) 创建通过管道的 uop 流的可视化以查看它们如何组合在一起可能会有所帮助。您可以使用简单的负载链作为参考。这对于情况1来说很容易,但由于重放,对于情况2来说很难。


分析方法二:使用加载延迟性能监控工具

我想出了另一种分析代码的方法。这种方法更容易,但不太准确。然而,它确实使我们得出了相同的结论。

替代方法基于 MEM_TRANS_RETIRED.LOAD_LATENCY_* 性能事件。这些事件的特殊之处在于它们只能在 p 精确级别进行计数(参见:)。

例如,MEM_TRANS_RETIRED.LOAD_LATENCY_GT_4 计算所有已执行负载中 "randomly" 选定样本的延迟大于 4 个核心周期的负载数。延迟测量如下。第一次调度负载的周期是第一个被视为负载延迟的一部分的周期。写回加载结果的周期是被视为延迟一部分的最后一个周期。因此,重播被考虑在内。此外,从 SnB 开始(至少),根据此定义,所有负载的延迟都大于 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 亿。因此,绝对值本身意义不大。真正重要的是跨越不同阈值的模式。

在案例 1 中,延迟时间大于特定阈值的负载数量显着下降了 3 次。我们可以推断,延迟等于或小于 6 个周期的负载是最常见的,延迟等于或小于 7 个周期但大于 6 个周期的负载是第二常见的,而大多数其他负载的延迟介于8-11 个周期。

我们已经知道最小延迟是 4 个周期。鉴于这些数字,估计平均加载延迟在 4 到 6 个周期之间是合理的,但更接近 6 而不是 4。我们从方法 1 知道平均加载延迟实际上是 5.4c。所以我们可以使用这些数字做出相当好的估计。

在情况2中,我们可以推断出大多数负载的延迟小于或等于11个周期。平均负载延迟可能也远大于 4,因为所测量的负载数量具有一致性广泛的延迟阈值。所以它在 4 和 11 之间,但比 4 更接近 11。我们从方法 1 中知道平均加载延迟实际上是 8.8c,这接近于基于这些数字的任何合理估计。

情况 3 与情况 1 类似,实际上,对于这两种情况,使用方法 1 确定的实际平均负载延迟几乎相同。

使用 MEM_TRANS_RETIRED.LOAD_LATENCY_* 进行测量很容易,对微体系结构知之甚少的人也可以完成此类分析。