了解 lfence 对具有两个长依赖链的循环的影响,以增加长度
Understanding the impact of lfence on a loop with two long dependency chains, for increasing lengths
我在玩this answer中的代码,稍微修改一下:
BITS 64
GLOBAL _start
SECTION .text
_start:
mov ecx, 1000000
.loop:
;T is a symbol defined with the CLI (-DT=...)
TIMES T imul eax, eax
lfence
TIMES T imul edx, edx
dec ecx
jnz .loop
mov eax, 60 ;sys_exit
xor edi, edi
syscall
没有 lfence
I,我得到的结果与该答案中的静态分析一致。
当我引入 single lfence
时,我希望 CPU 执行 [=47= 的 imul edx, edx
序列]k-th 迭代与下一个 (k+1-th) 迭代的 imul eax, eax
序列并行。
像这样的东西(调用 A imul eax, eax
序列和 D imul edx, edx
序列):
|
| A
| D A
| D A
| D A
| ...
| D A
| D
|
V time
使用或多或少相同的周期数,但用于一个不成对的并行执行。
当我测量循环次数时,对于原始版本和修改版本,taskset -c 2 ocperf.py stat -r 5 -e cycles:u '-x ' ./main-$T
对于 T
在下面的范围内,我得到
T Cycles:u Cycles:u Delta
lfence no lfence
10 42047564 30039060 12008504
15 58561018 45058832 13502186
20 75096403 60078056 15018347
25 91397069 75116661 16280408
30 108032041 90103844 17928197
35 124663013 105155678 19507335
40 140145764 120146110 19999654
45 156721111 135158434 21562677
50 172001996 150181473 21820523
55 191229173 165196260 26032913
60 221881438 180170249 41711189
65 250983063 195306576 55676487
70 281102683 210255704 70846979
75 312319626 225314892 87004734
80 339836648 240320162 99516486
85 372344426 255358484 116985942
90 401630332 270320076 131310256
95 431465386 285955731 145509655
100 460786274 305050719 155735555
如何解释Cycles:u lfence
的值?
我本来希望它们与 Cycles:u no lfence
的那些相似,因为单个 lfence
应该只阻止第一次迭代对两个块并行执行。
我不认为这是由于 lfence
开销造成的,因为我认为这对于所有 T
应该是不变的。
我想修复我的 forma mentis 在处理代码静态分析时的问题。
我认为你测量准确,解释是微架构的,不是任何类型的测量错误。
我认为你的中低 T 结果支持这样的结论,即 lfence
阻止 front-end 甚至发出超过 lfence
直到所有早期指令退出,而不是让两条链的所有微指令都已经发出,只是等待 lfence
拨动开关,让每条链的乘数开始交替循环调度。
(如果 lfence
没有阻止 front-end,并且开销不会随 T 缩放。)
当只有来自第一个链的微指令在调度程序中时,您正在失去 imul
吞吐量,因为 front-end 尚未通过 imul edx,edx
和循环分支。并在 window 结束时进行相同数量的循环,此时管道大部分被耗尽并且只剩下来自第二条链的微指令。
在大约 T=60 时,开销增量看起来是线性的。我没有 运行 数字,但到那里的斜率看起来合理 T * 0.25
时钟发出第一个链与 3c 延迟执行瓶颈。即 delta 增长速度可能是总 no-lfence 周期的 1/12.
因此(考虑到我在下面测量的 lfence
开销),T<60:
no_lfence cycles/iter ~= 3T # OoO exec finds all the parallelism
lfence cycles/iter ~= 3T + T/4 + 9.3 # lfence constant + front-end delay
delta ~= T/4 + 9.3
@Margaret 报告说 T/4
比 2*T / 4
更合适,但我预计开始和结束时都为 T/4,总共 2T/4 三角洲的斜率。
在大约 T=60 之后,delta 增长得更快(但仍然是线性的),斜率大约等于总 no-lfence 周期,因此每 T 大约 3c。 我认为在这一点上,调度程序(保留站)的大小限制了 out-of-order window。您可能在 Haswell 或 Sandybridge/IvyBridge、(which have a 60-entry or 54-entry scheduler respectively 上进行了测试。Skylake 是 97 个条目(但未完全统一;IIRC BeeOnRope 的测试表明并非所有条目都可用于任何类型的 uop。有些是特定于加载 and/or 存储,例如。)
RS 跟踪 un-executed 微指令。每个 RS 条目包含 1 unfused-domain uop,等待其输入准备就绪及其执行端口,然后它可以分派和离开 RS1.
在 lfence
之后,front-end 每时钟执行 4 次,而 back-end 每 3 个时钟执行 1 次,在 ~15 个周期内发出 60 微指令,在此期间仅来自 edx
链的 5 imul
条指令已执行。 (这里没有加载或存储 micro-fusion,所以来自 front-end 的每个 fused-domain uop 在 RS2[=260 中仍然只有 1 unfused-domain uop =].)
对于大T,RS会很快填满,此时front-end只能以back-end的速度前进。 (对于小 T,我们在此之前达到下一次迭代的 lfence
,这就是 front-end 停滞的原因)。 当 T > RS_size 时,back-end 无法看到来自 eax
imul 链的任何微指令,直到足够的 back-end 进度通过 edx
链已经在 RS 中腾出了空间。到那时,每个链中的一个 imul
可以每 3 个周期调度一次,而不仅仅是第一条或第二条链。
从第一部分中记住,在 lfence
仅执行第一个链之后花费的时间 = 在 lfence
仅执行第二个链之前花费的时间。这也适用于此。
即使没有 lfence
,对于 T > RS_size,我们也会得到一些这种效果,但是在长的两边都有重叠的机会链。 ROB 至少是 RS 大小的两倍,因此 out-of-order window 在没有被 lfence
拖延时应该能够保持两条链不断飞行,即使 T 比调度器容量。 (请记住,uops 在执行后立即离开 RS。我不确定这是否意味着他们必须 finish 执行并转发他们的结果,或者只是开始执行,但那是对于简短的 ALU 指令,此处存在细微差别。一旦它们完成,只有 ROB 会按程序顺序保留它们直到它们退出。)
ROB 和 register-file 不应限制 out-of-order window 大小 (http://blog.stuffedcow.net/2013/05/measuring-rob-capacity/) 在这种假设情况下,或在您的真实情况下。它们应该都很大。
阻止 front-end 是英特尔 uarches 上 lfence
的一个实现细节。说明书上只说后面的指令不能执行。该措辞将允许 front-end 到 issue/rename 它们全部进入调度程序(保留站)和 ROB 而 lfence
仍在等待,只要 none 被调度到执行单位.
所以一个较弱的 lfence
可能会在 T=RS_size 之前有平坦的开销,然后与你现在看到的相同斜率 T>60.(开销的常量部分可能会更低。)
请注意,conditional/indirect 分支在 lfence
之后的推测执行适用于 执行,而不是(据我所知)code-fetch。仅触发 code-fetch 对 Spectre 或 Meltdown 攻击没有用(据我所知)。可能 side-channel 检测它如何解码的时间可以告诉你一些关于获取代码的信息......
我认为 AMD 的 LFENCE 在启用相关 MSR 时至少在实际 AMD CPU 上同样强大。 (Is LFENCE serializing on AMD processors?).
额外 lfence
开销:
您的结果很有趣,但 lfence
本身(对于小 T)以及随 T 扩展的组件存在显着的持续开销,这一点也不让我感到惊讶。
请记住,lfence
不允许后面的指令开始,直到前面的指令 失效 。这可能比它们的结果准备好 bypass-fowarding 到其他执行单元(即正常延迟)至少晚几个周期/pipeline-stages。
所以对于小 T,通过要求结果不仅准备就绪,而且还写回寄存器文件,在链中增加额外的延迟绝对是重要的。
lfence
可能需要一个额外的周期左右才能允许 issue/rename 阶段在检测到它之前的最后一条指令退出后再次开始运行。 issue/rename 过程需要多个阶段(周期),并且可能 lfence 在 start 处阻塞,而不是在将 uops 添加到 OoO 部分之前的最后一步核心。
根据 Agner Fog 的测试,甚至 back-to-back lfence
本身在 SnB-family 上也有 4 个周期的吞吐量。 Agner Fog reports 2 fused-domain uops(没有未融合),但在 Skylake 上我测量它为 6 fused-domain(仍然没有未融合),如果我只有 1 lfence
。但是 lfence
back-to-back 越多,uop 就越少!每个 lfence
下降到 ~2 微指令,许多 back-to-back,这就是 Agner 的测量方式。
lfence
/dec
/jnz
(没有工作的紧密循环)运行s 在 SKL 上每 ~10 个周期进行 1 次迭代,所以这可能会给即使没有 front-end 和 RS-full 瓶颈,lfence
也会给 dep 链增加真正的额外延迟。
仅使用 one dep chain 测量 lfence
开销,OoO exec 无关紧要:
.loop:
;mfence ; mfence here: ~62.3c (with no lfence)
lfence ; lfence here: ~39.3c
times 10 imul eax,eax ; with no lfence: 30.0c
; lfence ; lfence here: ~39.6c
dec ecx
jnz .loop
没有 lfence
,运行s 在预期的每迭代 30.0c。使用 lfence
、运行s,每迭代 ~39.3c,因此 lfence
有效地向关键路径 dep 链添加了 ~9.3c 的“额外延迟”。 (还有 6 个额外的 fused-domain 微指令)。
lfence
在 imul 链之后,就在 loop-branch 之前,速度稍慢。但不会慢一个完整的周期,所以这表明 front-end 正在发出 loop-branch + 和 imul 在 lfence
允许执行恢复后的单个 issue-group 中。既然如此,IDK 为什么它变慢了。这不是来自分支未命中。
获得您期望的行为:
按程序顺序交错链,就像@BeeOnRope 在评论中建议的那样,不需要 out-of-order 执行来利用 ILP,所以它非常简单:
.loop:
lfence ; at the top of the loop is the lowest-overhead place.
%rep T
imul eax,eax
imul edx,edx
%endrep
dec ecx
jnz .loop
你可以把成对的 times 8 imul
短链放在 %rep
里面,让 OoO exec 有一个轻松的时间。
脚注 1:front-end/RS/ROB 如何交互
我的心智模型是 front-end 中的 issue/rename/allocate 阶段同时向 RS 和 ROB 添加新的微指令.
Uops执行后离开RS,但留在ROB中直到in-order退休。 ROB 可能很大,因为它从未扫描 out-of-order 来查找 first-ready uop,仅扫描 in-order 以检查最旧的 uop(s) 是否已完成执行并因此准备好退出。
(我假设 ROB 在物理上是一个带有 start/end 索引的循环缓冲区,而不是一个实际上每个周期都向右复制 uops 的队列。但只需将其视为具有固定最大值的队列/列表大小,其中 front-end 在前面添加 uops,而退役逻辑 retires/commits 从最后添加 uops 只要它们被完全执行,最多一些 per-cycle per-hyperthread退休限制通常不是瓶颈。Skylake 确实增加了它以获得更好的超线程,可能每个逻辑线程每个时钟 8 个。也许退休也意味着释放有助于 HT 的物理寄存器,因为当两个线程都处于活动状态时 ROB 本身是静态分区的。这就是退休限制是每个逻辑线程的原因。)
添加了 nop
、xor eax,eax
或 lfence
等 Uop,它们在 front-end 中处理(不需要任何端口上的任何执行单元) 仅到ROB,处于already-executed状态。 (一个 ROB 条目大概有一点标记它准备好退休与仍在等待执行完成。这就是我正在谈论的状态。对于 did 需要一个执行端口,我假设 ROB 位是通过来自执行单元的 completion port 设置的。并且相同的 completion-port 信号释放了它的 RS 条目。)
Uops 从发布到 退休.
留在 ROB 中
Uops 从发布到 执行 留在 RS 中。 S 可以在少数情况下重放 uops,例如, or if it was dispatched in anticipation of load data arriving, but in fact it didn't. (Cache miss or other conflicts like ) Or when a load port speculates that it can bypass the AGU before starting a TLB lookup to shorten pointer-chasing latency with small offsets -
因此我们知道 RS 无法在发送时立即删除 uop,因为它可能需要重播。 (甚至可能发生在消耗负载数据的 non-load 微指令上。)但是任何需要重放的推测都是 short-range,而不是通过微指令链,所以一旦结果从执行单元的另一端出来, 可以从 RS 中删除 uop。可能这是完成端口所做的一部分,以及将结果放在旁路转发网络上。
脚注 2:一个 micro-fused uop 需要多少个 RS 条目?
TL:DR: P6 系列:RS 融合,SnB-family:RS 未融合。
一个micro-fused uop被发给了Sandybridge-family中两个独立的RS条目,但只有1个ROB条目。 (假设不是un-laminated issue之前,见Intel优化手册的2.3.5 for HSW或section 2.4.2.4 for SnB,还有Micro fusion and addressing modes。Sandybridge-family更紧凑的uop格式不能在所有情况下代表 ROB 中的索引寻址模式。)
负载可以在 ALU 微指令的另一个操作数准备就绪之前独立分派。 (或者对于 micro-fused 存储,store-address 或 store-data uops 中的任何一个都可以在其输入准备就绪时分派,而无需等待两者。)
我使用问题中的 two-dep-chain 方法在 Skylake(RS 大小 = 97) 上进行了实验测试,其中 micro-fused or edi, [rdi]
对比 mov
+or
,以及 rsi
中的另一个 dep 链。 (Full test code, NASM syntax on Godbolt)
; loop body
%rep T
%if FUSE
or edi, [rdi] ; static buffers are in the low 32 bits of address space, in non-PIE
%else
mov eax, [rdi]
or edi, eax
%endif
%endrep
%rep T
%if FUSE
or esi, [rsi]
%else
mov eax, [rsi]
or esi, eax
%endif
%endrep
查看每个周期(或 perf
为我们计算的每秒)uops_executed.thread
(unfused-domain),我们可以看到不依赖于单独 vs 的吞吐量数字. 折叠负载。
使用小 T (T=30),所有的 ILP 都可以被利用,无论有无 micro-fusion,我们每个时钟都能得到 ~0.67 微指令。 (我忽略了 dec/jnz 每次循环迭代 1 个额外 uop 的小偏差。与我们在 micro-fused uops 仅使用 1 个 RS 条目时看到的效果相比,它可以忽略不计)
记住 load+or
是 2 微指令,我们有 2 个运行中的 dep 链,所以这是 4/6,因为 or edi, [rdi]
有 6 个周期延迟。 (不是 5,这是令人惊讶的,见下文。)
在 T=60 时,对于 FUSE=0,我们仍然有大约每个时钟执行 0.66 个未融合的 uops,对于 FUSE=1,我们仍然有 0.64 个未融合的微指令。我们仍然可以找到基本上所有的 ILP,但它刚刚开始下降,因为两个 dep 链的长度为 120 微指令(相对于 97 的 RS 大小)。
在 T=120,对于 FUSE=0,我们每个时钟有 0.45 个未融合的微指令,对于 FUSE=1,我们有 0.44 个微指令。我们肯定已经过了膝盖,但仍然找到了 一些 的 ILP。
如果一个 micro-fused uop 只占用了 1 个 RS 条目,FUSE=1 T=120 应该与 FUSE=0 T=60 的速度大致相同,但事实并非如此。相反,FUSE=0 或 1 在任何 T 几乎没有区别。(包括较大的 T=200:FUSE=0:0.395 uops/clock,FUSE=1:0.391 uops/clock)。我们必须先到 非常 大 T,然后才能开始 1 [=377=] 在飞行中的时间,以完全控制 2 在飞行中的时间,然后下降到 0.33微指令/时钟(2/6)。
奇怪:融合与未融合的吞吐量差异很小但仍然可以测量,单独的 mov
加载速度更快。
其他奇怪之处:对于 FUSE=0,在任何给定的 T 下,总 uops_executed.thread
略微 较低。例如 2,418,826,591 与 T=60 的 2,419,020,155。这种差异可重复到 2.4G 中的 +- 60k,足够精确。 FUSE=1 在总时钟周期上较慢,但大部分差异来自每个时钟较低的微指令,而不是更多微指令。
像[rdi]
这样的简单寻址模式应该只有4个周期延迟,所以load + ALU应该只有5个周期。但是我测量了 or rdi, [rdi]
的 load-use 延迟的 6 个周期延迟,或者使用单独的 MOV-load,或者使用任何其他 ALU 指令,我永远无法将加载部分是 4c.
当 dep 链中有 ALU 指令时,像 [rdi + rbx + 2064]
这样的复杂寻址模式具有相同的延迟,因此看来 Intel 的简单寻址模式的 4c 延迟仅 当一个负载转发到另一个负载的基址寄存器时适用(最多 +0..2047 位移且没有索引)。
Pointer-chasing 很常见,这是一个有用的优化,但我们需要将其视为一种特殊的 load-load 转发 fast-path,而不是将其视为更快准备好的通用数据通过 ALU 指令使用。
P6 系列不同:一个 RS 条目包含一个 fused-domain uop。
@Hadi 发现 an Intel patent from 2002,其中图 12 显示了融合域中的 RS。
在 Conroe(第一代 Core2Duo,E6600)上的实验测试表明,对于 T=50,FUSE=0 和 FUSE=1 之间存在很大差异。 (The RS size is 32 entries).
T=50 FUSE=1:2.346G周期的总时间(0.44IPC)
T=50 FUSE=0:总时间为 3.272G 周期(0.62IPC = 0.31 负载+或每个时钟)。 (perf
/ ocperf.py
在 Nehalem 左右之前的 uarches 上没有 uops_executed
的事件,并且没有在那台机器上安装 oprofile
。)
T=24 FUSE=0 和 FUSE=1 之间的差异可以忽略不计,大约 0.47 IPC 与 0.9 IPC(每个时钟约 0.45 负载+或)。
T=24 仍然是循环中超过 96 字节的代码,对于 Core 2 的 64 字节 (pre-decode) 循环缓冲区来说太大了,所以它不会因为适合循环缓冲区而更快。没有 uop-cache,我们不得不担心 front-end,但我认为我们很好,因为我只使用 2 字节 single-uop 指令,应该很容易解码为 4 fused-domain 每个时钟微指令。
我将对两种代码(有和没有 lfence
)的 T = 1 的情况进行分析。然后,您可以将其扩展为其他 T 值。您可以参考英特尔优化手册的图 2.4 进行可视化。
因为只有一个容易预测的分支,所以前端只会在后端停止时停止。在 Haswell 中,前端是 4-wide,这意味着最多可以从 IDQ(指令解码队列,它只是一个按顺序保存融合域 uops 的队列,也称为 uop 队列)发出 4 个融合 uops 到调度程序的保留站 (RS)。每个 imul
都被解码为一个无法融合的 uop。指令 dec ecx
和 jnz .loop
在前端被宏融合为一个 uop。 microfusion 和 macrofusion 之间的区别之一是,当调度程序将 macrofused uop(不是 microfused)分派到它分配给的执行单元时,它会作为单个 uop 分派。相比之下,微融合 uop 需要拆分成其组成的 uops,每个 uops 都必须单独分派给执行单元。 (但是,分裂微融合微指令发生在 RS 的入口处,而不是在调度时,请参阅@Peter 的回答中的脚注 2)。 lfence
被解码为 6 微指令。识别微融合只在后端很重要,在这种情况下,循环中没有微融合。
由于循环分支很容易预测,而且迭代次数相对较多,我们可以在不影响准确性的情况下假设分配器始终能够在每个循环中分配 4 微指令。换句话说,调度程序每个周期将接收 4 微指令。由于没有微融合,每个 uop 将作为一个 uop 被调度。
imul
只能由Slow Int执行单元执行(见图2.4)。这意味着执行 imul
微指令的唯一选择是将它们分派到端口 1。在 Haswell 中,Slow Int 是很好的流水线,因此每个周期可以分派一个 imul
。但是乘法的结果需要三个周期才能用于任何需要的指令(写回阶段是流水线调度阶段的第三个周期)。所以对于每个依赖链,每3个周期最多可以调度一个imul
。
因为dec/jnz
被预测占用,唯一可以执行的执行单元是6号端口的Primary Branch。
所以在任何给定的周期,只要RS有space,它就会收到4微码。但是什么样的 uops?让我们检查一下没有 lfence 的循环:
imul eax, eax
imul edx, edx
dec ecx/jnz .loop (macrofused)
有两种可能:
- 来自同一迭代的两个
imul
,来自相邻迭代的一个 imul
,以及来自这两个迭代之一的 dec/jnz
。
- 一个
dec/jnz
来自一次迭代,两个 imul
来自下一次迭代,还有一个 dec/jnz
来自同一迭代。
所以在任何一个周期的开始,RS 都会从每个链中接收到至少一个 dec/jnz
和至少一个 imul
。同时,在同一个周期中,从 RS 中已经存在的那些微指令,调度程序将执行以下两个操作之一:
- 将最旧的
dec/jnz
分派到端口 6,并将准备就绪的最旧的 imul
分派到端口 1。总共 2 微码。
- 因为Slow Int有3个周期的延迟但是只有两条链,对于每个3个周期的周期,RS中没有
imul
准备好执行。但是,RS 中总是至少有一个 dec/jnz
。所以调度器可以调度它。总共是 1 uop。
现在我们可以计算 RS 中的预期微指令数 XN,在任何给定周期结束时 N:
XN = XN-1 + (第N周期开始RS中要分配的uop数) - (期望在第N个周期开始时派发的微指令数)
= XN-1 + 4 - ((0+1)*1/3 + (1+1)*2/3)
= XN-1 + 12/3 - 5/3
= XN-1 + 7/3 对于所有 N > 0
递推的初始条件为X0 = 4,这是一个简单的递推,展开XN-1[=169即可求解=].
XN = 4 + 2.3 * N 对于所有 N >= 0
Haswell 中的 RS 有 60 个条目。我们可以确定 RS 预期变满的第一个周期:
60 = 4 + 7/3 * N
N = 56/2.3 = 24.3
所以在24.3周期结束时,RS预计会满。这意味着在周期 25.3 开始时,RS 将无法接收任何新的微指令。现在考虑的迭代次数 I 决定了您应该如何进行分析。由于依赖链将需要至少 3*I 个周期来执行,因此需要大约 8.1 次迭代才能达到周期 24.3。所以如果迭代次数大于8.1,也就是这里的情况,需要分析24.3次循环之后会发生什么。
调度程序在每个周期按以下速率分派指令(如上所述):
1
2
2
1
2
2
1
2
.
.
但是分配器不会在RS中分配任何微指令,除非至少有4个可用条目。否则,它不会浪费功率以次优吞吐量发出微指令。然而,只有在每第 4 个周期开始时,RS 中才会有至少 4 个空闲条目。所以从周期 24.3 开始,分配器预计每 4 个周期中有 3 个停滞。
对所分析的代码的另一个重要观察是,从来没有发生超过 4 个微指令可以被调度的情况,这意味着每个周期离开其执行单元的微指令的平均数量不大于 4 . 最多 4 微指令可以从重新排序缓冲区 (ROB) 中退出。这意味着 ROB 永远不会在关键路径上。换句话说,性能由调度吞吐量决定。
我们现在可以很容易地计算 IPC(每周期指令数)。 ROB 条目看起来像这样:
imul eax, eax - N
imul edx, edx - N + 1
dec ecx/jnz .loop - M
imul eax, eax - N + 3
imul edx, edx - N + 4
dec ecx/jnz .loop - M + 1
右边的列显示指令可以退出的周期。退休按顺序发生,并受关键路径延迟的限制。这里每个依赖链具有相同的路径长度,因此都构成两个长度为 3 个周期的相等关键路径。所以每3个周期,可以退出4条指令。所以 IPC 是 4/3 = 1.3,CPI 是 3/4 = 0.75。这比理论最优 IPC 4 小得多(即使不考虑微观和宏观融合)。因为退役是按顺序发生的,所以退役行为将是相同的。
我们可以使用 perf
和 IACA 检查我们的分析。我会讨论 perf
。我有一个 Haswell CPU.
perf stat -r 10 -e cycles:u,instructions:u,cpu/event=0xA2,umask=0x10,name=RESOURCE_STALLS.ROB/u,cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u,cpu/event=0xA2,umask=0x4,name=RESOURCE_STALLS.RS/u ./main-1-nolfence
Performance counter stats for './main-1-nolfence' (10 runs):
30,01,556 cycles:u ( +- 0.00% )
40,00,005 instructions:u # 1.33 insns per cycle ( +- 0.00% )
0 RESOURCE_STALLS.ROB
23,42,246 UOPS_ISSUED.ANY ( +- 0.26% )
22,49,892 RESOURCE_STALLS.RS ( +- 0.00% )
0.001061681 seconds time elapsed ( +- 0.48% )
有100万次迭代,每次大约需要3个周期。每次迭代包含 4 条指令,IPC 为 1.33。RESOURCE_STALLS.ROB
显示分配器因完整 ROB 而停止的周期数。这当然永远不会发生。 UOPS_ISSUED.ANY
可用于统计发给 RS 的 uops 数和分配器停滞的周期数(无具体原因)。第一个很简单(未显示在 perf
输出中); 100万*3=300万+小噪音。后者更有趣。它显示大约 73% 的时间分配器由于完整的 RS 而停止,这与我们的分析相符。 RESOURCE_STALLS.RS
计算分配器因 RS 满而停止的周期数。这接近于 UOPS_ISSUED.ANY
因为分配器不会因为任何其他原因而停止(尽管由于某种原因差异可能与迭代次数成正比,但我必须看到 T>1 的结果)。
可以扩展对没有 lfence
的代码的分析,以确定如果在两个 imul
之间添加 lfence
会发生什么情况。我们先看看perf
的结果(遗憾的是IACA不支持lfence
):
perf stat -r 10 -e cycles:u,instructions:u,cpu/event=0xA2,umask=0x10,name=RESOURCE_STALLS.ROB/u,cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u,cpu/event=0xA2,umask=0x4,name=RESOURCE_STALLS.RS/u ./main-1-lfence
Performance counter stats for './main-1-lfence' (10 runs):
1,32,55,451 cycles:u ( +- 0.01% )
50,00,007 instructions:u # 0.38 insns per cycle ( +- 0.00% )
0 RESOURCE_STALLS.ROB
1,03,84,640 UOPS_ISSUED.ANY ( +- 0.04% )
0 RESOURCE_STALLS.RS
0.004163500 seconds time elapsed ( +- 0.41% )
观察循环次数增加了大约1000万次,即每次迭代10次循环。周期数并不能告诉我们太多信息。退休指令数量增加了一百万,这是预期的。我们已经知道 lfence
不会使指令完成得更快,所以 RESOURCE_STALLS.ROB
不应该改变。 UOPS_ISSUED.ANY
和 RESOURCE_STALLS.RS
特别有趣。在此输出中,UOPS_ISSUED.ANY
计算周期,而不是微指令。 uops的数量也可以统计(使用cpu/event=0x0E,umask=0x1,name=UOPS_ISSUED.ANY/u
而不是cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u
)并且每次迭代增加了6 uops(没有融合)。这意味着位于两个 imul
之间的 lfence
被解码为 6 微指令。一百万美元的问题现在是这些 uops 做什么以及它们如何在管道中移动。
RESOURCE_STALLS.RS
为零。那是什么意思?这表明分配器,当它在 IDQ 中看到 lfence
时,它会停止分配,直到 ROB 中的所有当前微指令都退出。换句话说,分配器不会分配 RS 中超过 lfence
的条目,直到 lfence
退出。由于循环体仅包含 3 个其他微指令,因此 60 项 RS 永远不会满。事实上,它几乎总是空的。
现实中的IDQ并不是一个单一的简单队列。它由多个可以并行运行的硬件结构组成。 lfence
需要的微指令数取决于 IDQ 的具体设计。分配器也由许多不同的硬件结构组成,当它看到 IDQ 的任何结构前面有一个 lfence
微指令时,它会暂停从该结构分配,直到 ROB 为空。所以不同的微指令用于不同的硬件结构。
UOPS_ISSUED.ANY
表明分配器在每次迭代大约 9-10 个周期内没有发出任何微指令。这里发生了什么?好吧,lfence
的一个用途是它可以告诉我们退出一条指令并分配下一条指令需要多少时间。以下汇编代码可用于执行此操作:
TIMES T lfence
对于 T
的较小值,性能事件计数器将无法正常工作。对于足够大的 T,并通过测量 UOPS_ISSUED.ANY
,我们可以确定每个 lfence
大约需要 4 个周期才能退出。那是因为 UOPS_ISSUED.ANY
将每 5 个周期递增大约 4 次。因此,每 4 个周期后,分配器发出另一个 lfence
(它不会停止),然后等待另外 4 个周期,依此类推。也就是说,产生结果的指令可能需要 1 个或几个周期才能退出,具体取决于指令。 IACA 总是假定一条指令退出需要 5 个周期。
我们的循环看起来像这样:
imul eax, eax
lfence
imul edx, edx
dec ecx
jnz .loop
在 lfence
边界的任何周期,ROB 将包含以下指令,从 ROB 的顶部开始(最旧的指令):
imul edx, edx - N
dec ecx/jnz .loop - N
imul eax, eax - N+1
其中N表示调度相应指令的周期数。将要完成(到达回写阶段)的最后一条指令是 imul eax, eax
。这发生在周期 N+4。分配器停顿周期计数将在周期 N+1、N+2、N+3 和 N+4 期间递增。然而,在 imul eax, eax
退休之前,它还会有大约 5 个周期。另外,allocator在退休后需要清理掉IDQ中的lfence
uops,分配下一组指令,才能在下一个周期派发。 perf
输出告诉我们每次迭代大约需要 13 个周期,并且分配器在这 13 个周期中有 10 个停止(因为 lfence
)。
问题中的图表仅显示最多 T=100 的循环数。但是,此时还有另一个(最后一个)膝盖。因此最好绘制最多 T=120 的周期以查看完整模式。
我在玩this answer中的代码,稍微修改一下:
BITS 64
GLOBAL _start
SECTION .text
_start:
mov ecx, 1000000
.loop:
;T is a symbol defined with the CLI (-DT=...)
TIMES T imul eax, eax
lfence
TIMES T imul edx, edx
dec ecx
jnz .loop
mov eax, 60 ;sys_exit
xor edi, edi
syscall
没有 lfence
I,我得到的结果与该答案中的静态分析一致。
当我引入 single lfence
时,我希望 CPU 执行 [=47= 的 imul edx, edx
序列]k-th 迭代与下一个 (k+1-th) 迭代的 imul eax, eax
序列并行。
像这样的东西(调用 A imul eax, eax
序列和 D imul edx, edx
序列):
|
| A
| D A
| D A
| D A
| ...
| D A
| D
|
V time
使用或多或少相同的周期数,但用于一个不成对的并行执行。
当我测量循环次数时,对于原始版本和修改版本,taskset -c 2 ocperf.py stat -r 5 -e cycles:u '-x ' ./main-$T
对于 T
在下面的范围内,我得到
T Cycles:u Cycles:u Delta
lfence no lfence
10 42047564 30039060 12008504
15 58561018 45058832 13502186
20 75096403 60078056 15018347
25 91397069 75116661 16280408
30 108032041 90103844 17928197
35 124663013 105155678 19507335
40 140145764 120146110 19999654
45 156721111 135158434 21562677
50 172001996 150181473 21820523
55 191229173 165196260 26032913
60 221881438 180170249 41711189
65 250983063 195306576 55676487
70 281102683 210255704 70846979
75 312319626 225314892 87004734
80 339836648 240320162 99516486
85 372344426 255358484 116985942
90 401630332 270320076 131310256
95 431465386 285955731 145509655
100 460786274 305050719 155735555
如何解释Cycles:u lfence
的值?
我本来希望它们与 Cycles:u no lfence
的那些相似,因为单个 lfence
应该只阻止第一次迭代对两个块并行执行。
我不认为这是由于 lfence
开销造成的,因为我认为这对于所有 T
应该是不变的。
我想修复我的 forma mentis 在处理代码静态分析时的问题。
我认为你测量准确,解释是微架构的,不是任何类型的测量错误。
我认为你的中低 T 结果支持这样的结论,即 lfence
阻止 front-end 甚至发出超过 lfence
直到所有早期指令退出,而不是让两条链的所有微指令都已经发出,只是等待 lfence
拨动开关,让每条链的乘数开始交替循环调度。
(如果 lfence
没有阻止 front-end,并且开销不会随 T 缩放。)
当只有来自第一个链的微指令在调度程序中时,您正在失去 imul
吞吐量,因为 front-end 尚未通过 imul edx,edx
和循环分支。并在 window 结束时进行相同数量的循环,此时管道大部分被耗尽并且只剩下来自第二条链的微指令。
在大约 T=60 时,开销增量看起来是线性的。我没有 运行 数字,但到那里的斜率看起来合理 T * 0.25
时钟发出第一个链与 3c 延迟执行瓶颈。即 delta 增长速度可能是总 no-lfence 周期的 1/12.
因此(考虑到我在下面测量的 lfence
开销),T<60:
no_lfence cycles/iter ~= 3T # OoO exec finds all the parallelism
lfence cycles/iter ~= 3T + T/4 + 9.3 # lfence constant + front-end delay
delta ~= T/4 + 9.3
@Margaret 报告说 T/4
比 2*T / 4
更合适,但我预计开始和结束时都为 T/4,总共 2T/4 三角洲的斜率。
在大约 T=60 之后,delta 增长得更快(但仍然是线性的),斜率大约等于总 no-lfence 周期,因此每 T 大约 3c。 我认为在这一点上,调度程序(保留站)的大小限制了 out-of-order window。您可能在 Haswell 或 Sandybridge/IvyBridge、(which have a 60-entry or 54-entry scheduler respectively 上进行了测试。Skylake 是 97 个条目(但未完全统一;IIRC BeeOnRope 的测试表明并非所有条目都可用于任何类型的 uop。有些是特定于加载 and/or 存储,例如。)
RS 跟踪 un-executed 微指令。每个 RS 条目包含 1 unfused-domain uop,等待其输入准备就绪及其执行端口,然后它可以分派和离开 RS1.
在 lfence
之后,front-end 每时钟执行 4 次,而 back-end 每 3 个时钟执行 1 次,在 ~15 个周期内发出 60 微指令,在此期间仅来自 edx
链的 5 imul
条指令已执行。 (这里没有加载或存储 micro-fusion,所以来自 front-end 的每个 fused-domain uop 在 RS2[=260 中仍然只有 1 unfused-domain uop =].)
对于大T,RS会很快填满,此时front-end只能以back-end的速度前进。 (对于小 T,我们在此之前达到下一次迭代的 lfence
,这就是 front-end 停滞的原因)。 当 T > RS_size 时,back-end 无法看到来自 eax
imul 链的任何微指令,直到足够的 back-end 进度通过 edx
链已经在 RS 中腾出了空间。到那时,每个链中的一个 imul
可以每 3 个周期调度一次,而不仅仅是第一条或第二条链。
从第一部分中记住,在 lfence
仅执行第一个链之后花费的时间 = 在 lfence
仅执行第二个链之前花费的时间。这也适用于此。
即使没有 lfence
,对于 T > RS_size,我们也会得到一些这种效果,但是在长的两边都有重叠的机会链。 ROB 至少是 RS 大小的两倍,因此 out-of-order window 在没有被 lfence
拖延时应该能够保持两条链不断飞行,即使 T 比调度器容量。 (请记住,uops 在执行后立即离开 RS。我不确定这是否意味着他们必须 finish 执行并转发他们的结果,或者只是开始执行,但那是对于简短的 ALU 指令,此处存在细微差别。一旦它们完成,只有 ROB 会按程序顺序保留它们直到它们退出。)
ROB 和 register-file 不应限制 out-of-order window 大小 (http://blog.stuffedcow.net/2013/05/measuring-rob-capacity/) 在这种假设情况下,或在您的真实情况下。它们应该都很大。
阻止 front-end 是英特尔 uarches 上 lfence
的一个实现细节。说明书上只说后面的指令不能执行。该措辞将允许 front-end 到 issue/rename 它们全部进入调度程序(保留站)和 ROB 而 lfence
仍在等待,只要 none 被调度到执行单位.
所以一个较弱的 lfence
可能会在 T=RS_size 之前有平坦的开销,然后与你现在看到的相同斜率 T>60.(开销的常量部分可能会更低。)
请注意,conditional/indirect 分支在 lfence
之后的推测执行适用于 执行,而不是(据我所知)code-fetch。仅触发 code-fetch 对 Spectre 或 Meltdown 攻击没有用(据我所知)。可能 side-channel 检测它如何解码的时间可以告诉你一些关于获取代码的信息......
我认为 AMD 的 LFENCE 在启用相关 MSR 时至少在实际 AMD CPU 上同样强大。 (Is LFENCE serializing on AMD processors?).
额外 lfence
开销:
您的结果很有趣,但 lfence
本身(对于小 T)以及随 T 扩展的组件存在显着的持续开销,这一点也不让我感到惊讶。
请记住,lfence
不允许后面的指令开始,直到前面的指令 失效 。这可能比它们的结果准备好 bypass-fowarding 到其他执行单元(即正常延迟)至少晚几个周期/pipeline-stages。
所以对于小 T,通过要求结果不仅准备就绪,而且还写回寄存器文件,在链中增加额外的延迟绝对是重要的。
lfence
可能需要一个额外的周期左右才能允许 issue/rename 阶段在检测到它之前的最后一条指令退出后再次开始运行。 issue/rename 过程需要多个阶段(周期),并且可能 lfence 在 start 处阻塞,而不是在将 uops 添加到 OoO 部分之前的最后一步核心。
根据 Agner Fog 的测试,甚至 back-to-back lfence
本身在 SnB-family 上也有 4 个周期的吞吐量。 Agner Fog reports 2 fused-domain uops(没有未融合),但在 Skylake 上我测量它为 6 fused-domain(仍然没有未融合),如果我只有 1 lfence
。但是 lfence
back-to-back 越多,uop 就越少!每个 lfence
下降到 ~2 微指令,许多 back-to-back,这就是 Agner 的测量方式。
lfence
/dec
/jnz
(没有工作的紧密循环)运行s 在 SKL 上每 ~10 个周期进行 1 次迭代,所以这可能会给即使没有 front-end 和 RS-full 瓶颈,lfence
也会给 dep 链增加真正的额外延迟。
仅使用 one dep chain 测量 lfence
开销,OoO exec 无关紧要:
.loop:
;mfence ; mfence here: ~62.3c (with no lfence)
lfence ; lfence here: ~39.3c
times 10 imul eax,eax ; with no lfence: 30.0c
; lfence ; lfence here: ~39.6c
dec ecx
jnz .loop
没有 lfence
,运行s 在预期的每迭代 30.0c。使用 lfence
、运行s,每迭代 ~39.3c,因此 lfence
有效地向关键路径 dep 链添加了 ~9.3c 的“额外延迟”。 (还有 6 个额外的 fused-domain 微指令)。
lfence
在 imul 链之后,就在 loop-branch 之前,速度稍慢。但不会慢一个完整的周期,所以这表明 front-end 正在发出 loop-branch + 和 imul 在 lfence
允许执行恢复后的单个 issue-group 中。既然如此,IDK 为什么它变慢了。这不是来自分支未命中。
获得您期望的行为:
按程序顺序交错链,就像@BeeOnRope 在评论中建议的那样,不需要 out-of-order 执行来利用 ILP,所以它非常简单:
.loop:
lfence ; at the top of the loop is the lowest-overhead place.
%rep T
imul eax,eax
imul edx,edx
%endrep
dec ecx
jnz .loop
你可以把成对的 times 8 imul
短链放在 %rep
里面,让 OoO exec 有一个轻松的时间。
脚注 1:front-end/RS/ROB 如何交互
我的心智模型是 front-end 中的 issue/rename/allocate 阶段同时向 RS 和 ROB 添加新的微指令.
Uops执行后离开RS,但留在ROB中直到in-order退休。 ROB 可能很大,因为它从未扫描 out-of-order 来查找 first-ready uop,仅扫描 in-order 以检查最旧的 uop(s) 是否已完成执行并因此准备好退出。
(我假设 ROB 在物理上是一个带有 start/end 索引的循环缓冲区,而不是一个实际上每个周期都向右复制 uops 的队列。但只需将其视为具有固定最大值的队列/列表大小,其中 front-end 在前面添加 uops,而退役逻辑 retires/commits 从最后添加 uops 只要它们被完全执行,最多一些 per-cycle per-hyperthread退休限制通常不是瓶颈。Skylake 确实增加了它以获得更好的超线程,可能每个逻辑线程每个时钟 8 个。也许退休也意味着释放有助于 HT 的物理寄存器,因为当两个线程都处于活动状态时 ROB 本身是静态分区的。这就是退休限制是每个逻辑线程的原因。)
添加了 nop
、xor eax,eax
或 lfence
等 Uop,它们在 front-end 中处理(不需要任何端口上的任何执行单元) 仅到ROB,处于already-executed状态。 (一个 ROB 条目大概有一点标记它准备好退休与仍在等待执行完成。这就是我正在谈论的状态。对于 did 需要一个执行端口,我假设 ROB 位是通过来自执行单元的 completion port 设置的。并且相同的 completion-port 信号释放了它的 RS 条目。)
Uops 从发布到 退休.
留在 ROB 中Uops 从发布到 执行 留在 RS 中。 S 可以在少数情况下重放 uops,例如
因此我们知道 RS 无法在发送时立即删除 uop,因为它可能需要重播。 (甚至可能发生在消耗负载数据的 non-load 微指令上。)但是任何需要重放的推测都是 short-range,而不是通过微指令链,所以一旦结果从执行单元的另一端出来, 可以从 RS 中删除 uop。可能这是完成端口所做的一部分,以及将结果放在旁路转发网络上。
脚注 2:一个 micro-fused uop 需要多少个 RS 条目?
TL:DR: P6 系列:RS 融合,SnB-family:RS 未融合。
一个micro-fused uop被发给了Sandybridge-family中两个独立的RS条目,但只有1个ROB条目。 (假设不是un-laminated issue之前,见Intel优化手册的2.3.5 for HSW或section 2.4.2.4 for SnB,还有Micro fusion and addressing modes。Sandybridge-family更紧凑的uop格式不能在所有情况下代表 ROB 中的索引寻址模式。)
负载可以在 ALU 微指令的另一个操作数准备就绪之前独立分派。 (或者对于 micro-fused 存储,store-address 或 store-data uops 中的任何一个都可以在其输入准备就绪时分派,而无需等待两者。)
我使用问题中的 two-dep-chain 方法在 Skylake(RS 大小 = 97) 上进行了实验测试,其中 micro-fused or edi, [rdi]
对比 mov
+or
,以及 rsi
中的另一个 dep 链。 (Full test code, NASM syntax on Godbolt)
; loop body
%rep T
%if FUSE
or edi, [rdi] ; static buffers are in the low 32 bits of address space, in non-PIE
%else
mov eax, [rdi]
or edi, eax
%endif
%endrep
%rep T
%if FUSE
or esi, [rsi]
%else
mov eax, [rsi]
or esi, eax
%endif
%endrep
查看每个周期(或 perf
为我们计算的每秒)uops_executed.thread
(unfused-domain),我们可以看到不依赖于单独 vs 的吞吐量数字. 折叠负载。
使用小 T (T=30),所有的 ILP 都可以被利用,无论有无 micro-fusion,我们每个时钟都能得到 ~0.67 微指令。 (我忽略了 dec/jnz 每次循环迭代 1 个额外 uop 的小偏差。与我们在 micro-fused uops 仅使用 1 个 RS 条目时看到的效果相比,它可以忽略不计)
记住 load+or
是 2 微指令,我们有 2 个运行中的 dep 链,所以这是 4/6,因为 or edi, [rdi]
有 6 个周期延迟。 (不是 5,这是令人惊讶的,见下文。)
在 T=60 时,对于 FUSE=0,我们仍然有大约每个时钟执行 0.66 个未融合的 uops,对于 FUSE=1,我们仍然有 0.64 个未融合的微指令。我们仍然可以找到基本上所有的 ILP,但它刚刚开始下降,因为两个 dep 链的长度为 120 微指令(相对于 97 的 RS 大小)。
在 T=120,对于 FUSE=0,我们每个时钟有 0.45 个未融合的微指令,对于 FUSE=1,我们有 0.44 个微指令。我们肯定已经过了膝盖,但仍然找到了 一些 的 ILP。
如果一个 micro-fused uop 只占用了 1 个 RS 条目,FUSE=1 T=120 应该与 FUSE=0 T=60 的速度大致相同,但事实并非如此。相反,FUSE=0 或 1 在任何 T 几乎没有区别。(包括较大的 T=200:FUSE=0:0.395 uops/clock,FUSE=1:0.391 uops/clock)。我们必须先到 非常 大 T,然后才能开始 1 [=377=] 在飞行中的时间,以完全控制 2 在飞行中的时间,然后下降到 0.33微指令/时钟(2/6)。
奇怪:融合与未融合的吞吐量差异很小但仍然可以测量,单独的 mov
加载速度更快。
其他奇怪之处:对于 FUSE=0,在任何给定的 T 下,总 uops_executed.thread
略微 较低。例如 2,418,826,591 与 T=60 的 2,419,020,155。这种差异可重复到 2.4G 中的 +- 60k,足够精确。 FUSE=1 在总时钟周期上较慢,但大部分差异来自每个时钟较低的微指令,而不是更多微指令。
像[rdi]
这样的简单寻址模式应该只有4个周期延迟,所以load + ALU应该只有5个周期。但是我测量了 or rdi, [rdi]
的 load-use 延迟的 6 个周期延迟,或者使用单独的 MOV-load,或者使用任何其他 ALU 指令,我永远无法将加载部分是 4c.
当 dep 链中有 ALU 指令时,像 [rdi + rbx + 2064]
这样的复杂寻址模式具有相同的延迟,因此看来 Intel 的简单寻址模式的 4c 延迟仅 当一个负载转发到另一个负载的基址寄存器时适用(最多 +0..2047 位移且没有索引)。
Pointer-chasing 很常见,这是一个有用的优化,但我们需要将其视为一种特殊的 load-load 转发 fast-path,而不是将其视为更快准备好的通用数据通过 ALU 指令使用。
P6 系列不同:一个 RS 条目包含一个 fused-domain uop。
@Hadi 发现 an Intel patent from 2002,其中图 12 显示了融合域中的 RS。
在 Conroe(第一代 Core2Duo,E6600)上的实验测试表明,对于 T=50,FUSE=0 和 FUSE=1 之间存在很大差异。 (The RS size is 32 entries).
T=50 FUSE=1:2.346G周期的总时间(0.44IPC)
T=50 FUSE=0:总时间为 3.272G 周期(0.62IPC = 0.31 负载+或每个时钟)。 (
perf
/ocperf.py
在 Nehalem 左右之前的 uarches 上没有uops_executed
的事件,并且没有在那台机器上安装oprofile
。)T=24 FUSE=0 和 FUSE=1 之间的差异可以忽略不计,大约 0.47 IPC 与 0.9 IPC(每个时钟约 0.45 负载+或)。
T=24 仍然是循环中超过 96 字节的代码,对于 Core 2 的 64 字节 (pre-decode) 循环缓冲区来说太大了,所以它不会因为适合循环缓冲区而更快。没有 uop-cache,我们不得不担心 front-end,但我认为我们很好,因为我只使用 2 字节 single-uop 指令,应该很容易解码为 4 fused-domain 每个时钟微指令。
我将对两种代码(有和没有 lfence
)的 T = 1 的情况进行分析。然后,您可以将其扩展为其他 T 值。您可以参考英特尔优化手册的图 2.4 进行可视化。
因为只有一个容易预测的分支,所以前端只会在后端停止时停止。在 Haswell 中,前端是 4-wide,这意味着最多可以从 IDQ(指令解码队列,它只是一个按顺序保存融合域 uops 的队列,也称为 uop 队列)发出 4 个融合 uops 到调度程序的保留站 (RS)。每个 imul
都被解码为一个无法融合的 uop。指令 dec ecx
和 jnz .loop
在前端被宏融合为一个 uop。 microfusion 和 macrofusion 之间的区别之一是,当调度程序将 macrofused uop(不是 microfused)分派到它分配给的执行单元时,它会作为单个 uop 分派。相比之下,微融合 uop 需要拆分成其组成的 uops,每个 uops 都必须单独分派给执行单元。 (但是,分裂微融合微指令发生在 RS 的入口处,而不是在调度时,请参阅@Peter 的回答中的脚注 2)。 lfence
被解码为 6 微指令。识别微融合只在后端很重要,在这种情况下,循环中没有微融合。
由于循环分支很容易预测,而且迭代次数相对较多,我们可以在不影响准确性的情况下假设分配器始终能够在每个循环中分配 4 微指令。换句话说,调度程序每个周期将接收 4 微指令。由于没有微融合,每个 uop 将作为一个 uop 被调度。
imul
只能由Slow Int执行单元执行(见图2.4)。这意味着执行 imul
微指令的唯一选择是将它们分派到端口 1。在 Haswell 中,Slow Int 是很好的流水线,因此每个周期可以分派一个 imul
。但是乘法的结果需要三个周期才能用于任何需要的指令(写回阶段是流水线调度阶段的第三个周期)。所以对于每个依赖链,每3个周期最多可以调度一个imul
。
因为dec/jnz
被预测占用,唯一可以执行的执行单元是6号端口的Primary Branch。
所以在任何给定的周期,只要RS有space,它就会收到4微码。但是什么样的 uops?让我们检查一下没有 lfence 的循环:
imul eax, eax
imul edx, edx
dec ecx/jnz .loop (macrofused)
有两种可能:
- 来自同一迭代的两个
imul
,来自相邻迭代的一个imul
,以及来自这两个迭代之一的dec/jnz
。 - 一个
dec/jnz
来自一次迭代,两个imul
来自下一次迭代,还有一个dec/jnz
来自同一迭代。
所以在任何一个周期的开始,RS 都会从每个链中接收到至少一个 dec/jnz
和至少一个 imul
。同时,在同一个周期中,从 RS 中已经存在的那些微指令,调度程序将执行以下两个操作之一:
- 将最旧的
dec/jnz
分派到端口 6,并将准备就绪的最旧的imul
分派到端口 1。总共 2 微码。 - 因为Slow Int有3个周期的延迟但是只有两条链,对于每个3个周期的周期,RS中没有
imul
准备好执行。但是,RS 中总是至少有一个dec/jnz
。所以调度器可以调度它。总共是 1 uop。
现在我们可以计算 RS 中的预期微指令数 XN,在任何给定周期结束时 N:
XN = XN-1 + (第N周期开始RS中要分配的uop数) - (期望在第N个周期开始时派发的微指令数)
= XN-1 + 4 - ((0+1)*1/3 + (1+1)*2/3)
= XN-1 + 12/3 - 5/3
= XN-1 + 7/3 对于所有 N > 0
递推的初始条件为X0 = 4,这是一个简单的递推,展开XN-1[=169即可求解=].
XN = 4 + 2.3 * N 对于所有 N >= 0
Haswell 中的 RS 有 60 个条目。我们可以确定 RS 预期变满的第一个周期:
60 = 4 + 7/3 * N
N = 56/2.3 = 24.3
所以在24.3周期结束时,RS预计会满。这意味着在周期 25.3 开始时,RS 将无法接收任何新的微指令。现在考虑的迭代次数 I 决定了您应该如何进行分析。由于依赖链将需要至少 3*I 个周期来执行,因此需要大约 8.1 次迭代才能达到周期 24.3。所以如果迭代次数大于8.1,也就是这里的情况,需要分析24.3次循环之后会发生什么。
调度程序在每个周期按以下速率分派指令(如上所述):
1
2
2
1
2
2
1
2
.
.
但是分配器不会在RS中分配任何微指令,除非至少有4个可用条目。否则,它不会浪费功率以次优吞吐量发出微指令。然而,只有在每第 4 个周期开始时,RS 中才会有至少 4 个空闲条目。所以从周期 24.3 开始,分配器预计每 4 个周期中有 3 个停滞。
对所分析的代码的另一个重要观察是,从来没有发生超过 4 个微指令可以被调度的情况,这意味着每个周期离开其执行单元的微指令的平均数量不大于 4 . 最多 4 微指令可以从重新排序缓冲区 (ROB) 中退出。这意味着 ROB 永远不会在关键路径上。换句话说,性能由调度吞吐量决定。
我们现在可以很容易地计算 IPC(每周期指令数)。 ROB 条目看起来像这样:
imul eax, eax - N
imul edx, edx - N + 1
dec ecx/jnz .loop - M
imul eax, eax - N + 3
imul edx, edx - N + 4
dec ecx/jnz .loop - M + 1
右边的列显示指令可以退出的周期。退休按顺序发生,并受关键路径延迟的限制。这里每个依赖链具有相同的路径长度,因此都构成两个长度为 3 个周期的相等关键路径。所以每3个周期,可以退出4条指令。所以 IPC 是 4/3 = 1.3,CPI 是 3/4 = 0.75。这比理论最优 IPC 4 小得多(即使不考虑微观和宏观融合)。因为退役是按顺序发生的,所以退役行为将是相同的。
我们可以使用 perf
和 IACA 检查我们的分析。我会讨论 perf
。我有一个 Haswell CPU.
perf stat -r 10 -e cycles:u,instructions:u,cpu/event=0xA2,umask=0x10,name=RESOURCE_STALLS.ROB/u,cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u,cpu/event=0xA2,umask=0x4,name=RESOURCE_STALLS.RS/u ./main-1-nolfence
Performance counter stats for './main-1-nolfence' (10 runs):
30,01,556 cycles:u ( +- 0.00% )
40,00,005 instructions:u # 1.33 insns per cycle ( +- 0.00% )
0 RESOURCE_STALLS.ROB
23,42,246 UOPS_ISSUED.ANY ( +- 0.26% )
22,49,892 RESOURCE_STALLS.RS ( +- 0.00% )
0.001061681 seconds time elapsed ( +- 0.48% )
有100万次迭代,每次大约需要3个周期。每次迭代包含 4 条指令,IPC 为 1.33。RESOURCE_STALLS.ROB
显示分配器因完整 ROB 而停止的周期数。这当然永远不会发生。 UOPS_ISSUED.ANY
可用于统计发给 RS 的 uops 数和分配器停滞的周期数(无具体原因)。第一个很简单(未显示在 perf
输出中); 100万*3=300万+小噪音。后者更有趣。它显示大约 73% 的时间分配器由于完整的 RS 而停止,这与我们的分析相符。 RESOURCE_STALLS.RS
计算分配器因 RS 满而停止的周期数。这接近于 UOPS_ISSUED.ANY
因为分配器不会因为任何其他原因而停止(尽管由于某种原因差异可能与迭代次数成正比,但我必须看到 T>1 的结果)。
可以扩展对没有 lfence
的代码的分析,以确定如果在两个 imul
之间添加 lfence
会发生什么情况。我们先看看perf
的结果(遗憾的是IACA不支持lfence
):
perf stat -r 10 -e cycles:u,instructions:u,cpu/event=0xA2,umask=0x10,name=RESOURCE_STALLS.ROB/u,cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u,cpu/event=0xA2,umask=0x4,name=RESOURCE_STALLS.RS/u ./main-1-lfence
Performance counter stats for './main-1-lfence' (10 runs):
1,32,55,451 cycles:u ( +- 0.01% )
50,00,007 instructions:u # 0.38 insns per cycle ( +- 0.00% )
0 RESOURCE_STALLS.ROB
1,03,84,640 UOPS_ISSUED.ANY ( +- 0.04% )
0 RESOURCE_STALLS.RS
0.004163500 seconds time elapsed ( +- 0.41% )
观察循环次数增加了大约1000万次,即每次迭代10次循环。周期数并不能告诉我们太多信息。退休指令数量增加了一百万,这是预期的。我们已经知道 lfence
不会使指令完成得更快,所以 RESOURCE_STALLS.ROB
不应该改变。 UOPS_ISSUED.ANY
和 RESOURCE_STALLS.RS
特别有趣。在此输出中,UOPS_ISSUED.ANY
计算周期,而不是微指令。 uops的数量也可以统计(使用cpu/event=0x0E,umask=0x1,name=UOPS_ISSUED.ANY/u
而不是cpu/event=0x0E,umask=0x1,cmask=1,inv=1,name=UOPS_ISSUED.ANY/u
)并且每次迭代增加了6 uops(没有融合)。这意味着位于两个 imul
之间的 lfence
被解码为 6 微指令。一百万美元的问题现在是这些 uops 做什么以及它们如何在管道中移动。
RESOURCE_STALLS.RS
为零。那是什么意思?这表明分配器,当它在 IDQ 中看到 lfence
时,它会停止分配,直到 ROB 中的所有当前微指令都退出。换句话说,分配器不会分配 RS 中超过 lfence
的条目,直到 lfence
退出。由于循环体仅包含 3 个其他微指令,因此 60 项 RS 永远不会满。事实上,它几乎总是空的。
现实中的IDQ并不是一个单一的简单队列。它由多个可以并行运行的硬件结构组成。 lfence
需要的微指令数取决于 IDQ 的具体设计。分配器也由许多不同的硬件结构组成,当它看到 IDQ 的任何结构前面有一个 lfence
微指令时,它会暂停从该结构分配,直到 ROB 为空。所以不同的微指令用于不同的硬件结构。
UOPS_ISSUED.ANY
表明分配器在每次迭代大约 9-10 个周期内没有发出任何微指令。这里发生了什么?好吧,lfence
的一个用途是它可以告诉我们退出一条指令并分配下一条指令需要多少时间。以下汇编代码可用于执行此操作:
TIMES T lfence
对于 T
的较小值,性能事件计数器将无法正常工作。对于足够大的 T,并通过测量 UOPS_ISSUED.ANY
,我们可以确定每个 lfence
大约需要 4 个周期才能退出。那是因为 UOPS_ISSUED.ANY
将每 5 个周期递增大约 4 次。因此,每 4 个周期后,分配器发出另一个 lfence
(它不会停止),然后等待另外 4 个周期,依此类推。也就是说,产生结果的指令可能需要 1 个或几个周期才能退出,具体取决于指令。 IACA 总是假定一条指令退出需要 5 个周期。
我们的循环看起来像这样:
imul eax, eax
lfence
imul edx, edx
dec ecx
jnz .loop
在 lfence
边界的任何周期,ROB 将包含以下指令,从 ROB 的顶部开始(最旧的指令):
imul edx, edx - N
dec ecx/jnz .loop - N
imul eax, eax - N+1
其中N表示调度相应指令的周期数。将要完成(到达回写阶段)的最后一条指令是 imul eax, eax
。这发生在周期 N+4。分配器停顿周期计数将在周期 N+1、N+2、N+3 和 N+4 期间递增。然而,在 imul eax, eax
退休之前,它还会有大约 5 个周期。另外,allocator在退休后需要清理掉IDQ中的lfence
uops,分配下一组指令,才能在下一个周期派发。 perf
输出告诉我们每次迭代大约需要 13 个周期,并且分配器在这 13 个周期中有 10 个停止(因为 lfence
)。
问题中的图表仅显示最多 T=100 的循环数。但是,此时还有另一个(最后一个)膝盖。因此最好绘制最多 T=120 的周期以查看完整模式。