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

2024-01-25

首先,我在 IvyBridge 上进行了以下设置,我将在注释位置插入测量有效负载代码。前 8 个字节buf存储地址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

case 1:

我插入有效负载位置:

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

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

case 2:

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

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

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

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

case 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。对于情况 1 和 2,如果rax+8更改为rax+16 or rax+24。但当它改为rax+32:情况 1 变为 5.3c/iter,情况 2 突然变为 4.2c/iter。

编辑了解更多perf events:

$ 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_UOPS and LSD.CYCLES_3_UOPS性能事件表明基本上所有的uop都是由LSD发出的。此外,这些活动还与LSD.CYCLES_ACTIVE显示在 LSD 未停止的每个周期中,在情况 1 和 2 中发出 3 个微指令,在情况 3 中发出 4 个微指令。换句话说,正如预期的那样,每次迭代的微指令都在同一组中一起发出在一个周期内。

以下所有关系中,“=~”符号表示差异在2%以内。我将从以下实证观察开始:

UOPS_ISSUED.STALL_CYCLES + LSD.CYCLES_ACTIVE =~ cycles

请注意,SnB 上的 LSD 事件计数需要调整,如here https://stackoverflow.com/questions/54156499/why-jnz-requires-2-cycles-to-complete-in-an-inner-loop.

我们还有以下关系:

case 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

This means that the reason for the issue stalls is because one or more required resources in the backend are not available. Therefore, we can confidently eliminate the whole frontend from consideration. In cases 1 and 2, that resource is the RS. In case 3, stalls due to the RS constitute about 20% of all resource stalls1.

Let's focus now on case 1. There are a total of 4 unfused domain uops: 1 load uop, 1 STA, 1 STD, and 1 dec/jne. The load and STA uops depend on the previous load uop. Whenever the LSD issues a group of uops, the STD and jump uops can be dispatched in the next cycle, so the next cycle will not cause an execution stall event. However, the earliest point where the load and STA uops can be dispatched is in the same cycle in the which the load result is written back. The correlation between CYCLES_NO_EXECUTE and STALLS_LDM_PENDING indicates that the reason why there would be no uops ready for execution is because all of the uops that are in the RS are waiting for the L1 to service pending load requests. Specifically, half of the uops in the RS are load uops and the other half are STAs and they are all waiting for the load of the respective previous iteration to complete. LSD.CYCLES_3_UOPS shows that the LSD waits until there are at least 4 free entries in the RS, only then it issues a group of uops that constitute a full iteration. In the next cycle, two of these uops will be dispatched, thereby freeing 2 RS entries2. The other will have to wait for the load they depend on to complete. Most probably the loads complete in program order. Therefore, the LSD waits until the STA and load uops of the oldest iteration that is yet to be executed leave the RS. Thus, UOPS_ISSUED.STALL_CYCLES + 1 =~ the average load latency3. We can conclude that the average load latency in case 1 is 5.4c. Most of this applies to case 2, except for one difference, as I'll explain shortly.

由于每次迭代中的微指令形成依赖链,因此我们还有:

cycles=~平均加载延迟。

Hence:

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

在情况 1 中,平均加载延迟为 5.4c。我们知道,L1 缓存的最佳情况延迟为 4c,因此存在 1.4c 的加载延迟损失。但为什么有效加载延迟不是 4c?

调度程序将预测微指令所依赖的负载将在某个恒定的延迟内完成,因此它将相应地调度它们。如果由于任何原因(例如 L1 未命中)加载花费的时间超过了加载时间,则会调度微指令,但加载结果尚未到达。在这种情况下,uop将被重放,并且发送的uop数量将大于发送的uop总数。

负载和 STA uop 只能分派到端口 2 或 3。事件UOPS_EXECUTED_PORT.PORT_2 and UOPS_EXECUTED_PORT.PORT_3可用于分别计算发送到端口 2 和 3 的 uop 数量。

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

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

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

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

前5个原因可以通过相应的性能事件明确排除。帕特里克·费伊(英特尔)says https://software.intel.com/en-us/forums/software-tuning-performance-optimization-platform-monitoring/topic/280663下列:

最后是的,在之间切换时有“一些”空闲周期 加载和存储。我被告知不要比“一些”更具体。
...
SNB可以在同一周期读写不同的bank。

我发现这些陈述可能是故意的,有点含糊不清。第一个陈述表明 L1 的加载和存储永远不能完全重叠。第二个建议仅当存在不同的存储体时才可以在同一周期中执行加载和存储。尽管身处不同的银行可能既不是必要条件也不是充分条件。但有一点是肯定的,如果存在并发的加载和存储请求,则加载(和存储)可能会延迟一个或多个周期。这解释了案例 1 中加载延迟平均为 1.4c 的原因。

情况1和情况2之间存在差异。情况1中,依赖于相同负载uop的STA和负载uop在同一周期中一起发出。另一方面,在情况2中,依赖于相同负载uop的STA和负载uop属于两个不同的发布组。每次迭代的问题停顿时间基本上等于顺序执行一次加载并退出一个存储所需的时间。每个操作的贡献可以使用以下方式估计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 并加载微指令,等待相应先前迭代的加载微指令完成(写回其结果)。因此分配器将停止,直到有足够的空闲 RS 条目来发出整个迭代。假设最旧的加载 uop 已在周期写回其结果T+ 0。我将该加载 uop 所属的迭代称为当前迭代。将发生以下事件序列:

循环时T+ 0:调度当前迭代的STA uop和下一次迭代的load uop。由于没有足够的 RS 条目,因此此周期中没有分配。该周期被计为分配停顿周期,但不计为执行停顿周期。

循环时T+ 1:STA uop 完成执行,存储退出。分配下一次要分配的迭代的微指令。该周期被计为执行停顿周期,但不计为分配停顿周期。

循环时T+ 2:刚刚分配的跳转和 STD uop 被调度。该周期被计为分配停顿周期,但不计为执行停顿周期。

在周期T + 3 to 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 个跳跃。单次迭代的微指令可以在一个周期内全部分配,因为IDQ-RS带宽是每个周期4个融合微指令。 uop 在进入 RS 时未熔断。 1 个 STD 需要 1 个周期才能调度。跳转也需要1个周期。有 3 个 AGU uop,但只有 2 个 AGU 端口。因此,需要 2 个周期(与情况 1 和 2 中的 1 个周期相比)来调度 AGU uop。调度的 AGU uop 组将是以下之一:

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

由于需要再一个周期来释放足够的 RS 条目来容纳整个问题组,UOPS_ISSUED.STALL_CYCLES+ 1 - 1 =UOPS_ISSUED.STALL_CYCLES=~ 平均负载延迟 =~ 5.6c,非常接近情况 1。损失约为 1.6c。这解释了为什么与情况 1 和 2 相比,情况 3 中每个 AGU uop 平均调度 1.4 次。

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

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

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

我不知道为什么不同情况下处罚不同。我们需要知道 L1D 缓存是如何设计的。不管怎样,我有足够的信心,在发布这个答案时,加载延迟(和存储延迟)会受到“一些空闲周期”的惩罚。


脚注

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

(2) 我可能在这里做了无害的简化。看即使 RS 未完全满,是否也可能发生 RESOURCE_STALLS.RS 事件? https://stackoverflow.com/questions/52656695/is-it-possible-for-the-resource-stalls-rs-event-to-occur-even-when-the-rs-is-not.

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


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

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

替代方法基于MEM_TRANS_RETIRED.LOAD_LATENCY_*表演活动。这些事件的特殊之处在于它们只能在p精确水平(参见:PERF STAT 不计算内存负载,但计算内存存储 https://stackoverflow.com/questions/44466697/perf-stat-does-not-count-memory-loads-but-counts-memory-stores).

例如,MEM_TRANS_RETIRED.LOAD_LATENCY_GT_4计算延迟大于所有已执行负载的“随机”选择样本的 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_*很容易,这样的分析可以由对微体系结构知之甚少的人完成。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

IvyBridge 上指针追逐循环中附近的依赖存储对性能产生奇怪的影响。添加额外的负载会加快速度吗? 的相关文章

随机推荐