例如,考虑以下玩具循环2:
top:
lea eax, [ecx + 5]
popcnt eax, eax
add edi, eax
dec ecx
jnz top
这基本上实现了循环(以下是对应关系:
eax -> total,c -> ecx
):do {
total += popcnt(c + 5);
} while (--c > 0);
我熟悉通过查看uop分解、依赖链延迟等来优化任何小循环的过程。在上面的循环中,我们只有一个传递的依赖链:
dec ecx
。循环的前三个指令(lea
、popcnt
、add
)是一个依赖链的一部分,每个循环都会重新开始。最后的
dec
和jne
被合并了。因此,我们总共有4个融合域uop,只有一个循环传递的依赖链,延迟为1个周期。因此,基于这个标准,循环似乎可以以1个周期/迭代的速度执行。然而,我们还应该看一下端口压力:
lea
可以在端口1和5上执行popcnt
可以在端口1上执行add
可以在端口0、1、5和6上执行- 预测取反的
jnz
在端口6上执行
- popcnt指令必须在端口1上执行(唯一可执行的端口)
lea
指令必须在端口5上执行(不得在端口1上执行)add
指令必须在端口0上执行,不得在其它三个可执行的端口上执行jnz
指令只能在端口6上执行
这是很多条件!如果指令随机调度,吞吐量会大大降低。例如,add
指令有75%的概率会被分配到端口1、5或6中的一个,这将导致popcnt
、lea
或jnz
指令延迟一个周期。同样地,lea
指令可以在两个端口中的一个执行,其中一个与popcnt
指令共享。
另一方面,IACA报告的结果非常接近最优,每次迭代需要1.05个周期:
Intel(R) Architecture Code Analyzer Version - 2.1
Analyzed File - l.o
Binary Format - 64Bit
Architecture - HSW
Analysis Type - Throughput
Throughput Analysis Report
--------------------------
Block Throughput: 1.05 Cycles Throughput Bottleneck: FrontEnd, Port0, Port1, Port5
Port Binding In Cycles Per Iteration:
---------------------------------------------------------------------------------------
| Port | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 6 | 7 |
---------------------------------------------------------------------------------------
| Cycles | 1.0 0.0 | 1.0 | 0.0 0.0 | 0.0 0.0 | 0.0 | 1.0 | 0.9 | 0.0 |
---------------------------------------------------------------------------------------
N - port number or number of cycles resource conflict caused delay, DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3), CP - on a critical path
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion happened
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256 instruction, dozens of cycles penalty is expected
! - instruction not supported, was not accounted in Analysis
| Num Of | Ports pressure in cycles | |
| Uops | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 6 | 7 | |
---------------------------------------------------------------------------------
| 1 | | | | | | 1.0 | | | CP | lea eax, ptr [ecx+0x5]
| 1 | | 1.0 | | | | | | | CP | popcnt eax, eax
| 1 | 0.1 | | | | | 0.1 | 0.9 | | CP | add edi, eax
| 1 | 0.9 | | | | | | 0.1 | | CP | dec ecx
| 0F | | | | | | | | | | jnz 0xfffffffffffffff4
它基本反映了我之前提到的必要的“理想”调度,但有一个小偏差:它显示“add”从“lea”中窃取端口5的情况在10个周期中发生一次。它也不知道融合分支将转到端口6,因为预测会被采取,所以它将大部分分支的uops放在端口0上,将大部分“add”的uops放在端口6上,而不是相反。
不清楚IACA报告的比最优解多0.05个周期是否是一些深入准确的分析结果,还是其使用的算法的不太有洞察力的后果,例如分析固定数量的周期内的循环,或者只是一个错误或其他什么原因。对于它认为0.1个uop将进入非理想端口的情况也不清楚。也不清楚一个是否能解释另一个 - 我认为每10次中有1次错误分配端口将导致每个迭代的循环计数为11/10 = 1.1个周期,但我没有计算实际的下游结果 - 也许平均影响较小。或者这可能只是舍入(0.05 == 0.1保留1位小数)。
- 当多个uop在保留站中处于ready状态时,它们按什么顺序被调度到端口?
- 当一个uop可以进入多个端口(例如上面示例中的
add
和lea
),如何决定选择哪个端口? - 如果任何答案涉及像oldest这样的概念以在uops之间进行选择,那么它是如何定义的?自从交付到RS以来的年龄?自从变得ready以来的年龄?如何打破平局?程序顺序是否会参与其中?
Skylake的结果
让我们测量一些Skylake上的实际结果,以检查哪些答案可以解释实验证据,这里是我Skylake机器上的一些真实世界测量结果(来自perf
)。令人困惑的是,我将切换到使用imul
作为我的“只在一个端口上执行”的指令,因为它有许多变体,包括允许您为源和目标使用不同寄存器的3个参数版本。当试图构建依赖链时,这非常方便。它还避免了popcnt
所具有的“对目标的错误依赖”。
独立指令
让我们首先看看简单的(?)情况,即指令相对独立 - 没有除循环计数器之外的任何依赖链。这是一个4 uop循环(只有3个执行uop),并带有轻微的压力。所有指令都是独立的(不共享任何源或目的地)。
add
原则上可以窃取imul
所需的p1
或dec
所需的p6
:instr p0 p1 p5 p6
xor (elim)
imul X
add X X X X
dec X
top:
xor r9, r9
add r8, rdx
imul rax, rbx, 5
dec esi
jnz top
The results is that this executes with perfect scheduling at 1.00 cycles / iteration:
560,709,974 uops_dispatched_port_port_0 ( +- 0.38% )
1,000,026,608 uops_dispatched_port_port_1 ( +- 0.00% )
439,324,609 uops_dispatched_port_port_5 ( +- 0.49% )
1,000,041,224 uops_dispatched_port_port_6 ( +- 0.00% )
5,000,000,110 instructions:u # 5.00 insns per cycle ( +- 0.00% )
1,000,281,902 cycles:u
( +- 0.00% )
作为预期,
p1
和p6
被imul
和dec/jnz
完全利用,然后add
在剩余可用端口之间大约平均分配。请注意大约 - 实际比例为56%和44%,这个比例在运行中非常稳定(注意+-0.49%
的变化)。如果我调整循环对齐方式,则拆分会发生变化(32B对齐为53/46,32B+4对齐更像是57/42)。现在,我们只改变imul
在循环中的位置:top:
imul rax, rbx, 5
xor r9, r9
add r8, rdx
dec esi
jnz top
突然间,p0
/p5
的分割比例恰好为50%/50%,变化率为0.00%:
500,025,758 uops_dispatched_port_port_0 ( +- 0.00% )
1,000,044,901 uops_dispatched_port_port_1 ( +- 0.00% )
500,038,070 uops_dispatched_port_port_5 ( +- 0.00% )
1,000,066,733 uops_dispatched_port_port_6 ( +- 0.00% )
5,000,000,439 instructions:u # 5.00 insns per cycle ( +- 0.00% )
1,000,439,396 cycles:u ( +- 0.01% )
所以这已经很有趣了,但很难说发生了什么。也许确切的行为取决于循环进入时的初始条件,并且对循环内的顺序敏感(例如,因为使用了计数器)。此示例表明,正在发生比“随机”或“愚蠢”的调度更多的事情。特别是,如果您只从循环中消除imul
指令,则会得到以下结果:
示例3
330,214,329 uops_dispatched_port_port_0 ( +- 0.40% )
314,012,342 uops_dispatched_port_port_1 ( +- 1.77% )
355,817,739 uops_dispatched_port_port_5 ( +- 1.21% )
1,000,034,653 uops_dispatched_port_port_6 ( +- 0.00% )
4,000,000,160 instructions:u # 4.00 insns per cycle ( +- 0.00% )
1,000,235,522 cycles:u ( +- 0.00% )
这里,
add
现在大致均匀分布在 p0
、p1
和 p5
中 - 因此 imul
的存在确实影响了 add
的调度: 这不仅仅是某种“避免使用端口 1”的规则导致的结果。请注意,总端口压力仅为每周期 3 个 uops,因为
xor
是一个清零惯用语,并且在重命名器中被消除。让我们尝试最大压力为每周期 4 个 uops。我预计以上启用的任何机制也能够完美地进行调度。我们只需将 xor r9, r9
更改为 xor r9, r10
,因此它不再是一个清零惯用语。我们得到以下结果:top:
xor r9, r10
add r8, rdx
imul rax, rbx, 5
dec esi
jnz top
488,245,238 uops_dispatched_port_port_0 ( +- 0.50% )
1,241,118,197 uops_dispatched_port_port_1 ( +- 0.03% )
1,027,345,180 uops_dispatched_port_port_5 ( +- 0.28% )
1,243,743,312 uops_dispatched_port_port_6 ( +- 0.04% )
5,000,000,711 instructions:u # 2.66 insns per cycle ( +- 0.00% )
1,880,606,080 cycles:u ( +- 0.08% )
糟糕!调度程序没有均匀地分配所有任务到 p0156
,导致 p0
的利用率不足(仅执行了约 49% 的周期),因此 p1
和 p6
被过度使用,因为它们都在执行其所需的操作 imul
和 dec/jnz
。我认为这种行为与 hayesti 在他们的答案中提到的基于计数器的压力指标以及 hayesti 和 Peter Cordes 提到的 uops 在发出时而非执行时被分配到端口 是一致的。这种行为3使得执行最旧的就绪 uops规则不再那么有效。如果 uops 不是在发出时绑定到执行端口,而是在执行时绑定,那么这个“最旧”的规则将在一次迭代后解决上述问题——一旦一个 imul
和一个 dec/jnz
被推迟了一个周期,它们将始终比竞争的 xor
和 add
指令更老,因此应该首先被调度。然而,我正在学习的一件事是,如果端口是在发出时分配的,这个规则就没有帮助,因为端口在发出时是预先确定的。我想它仍然有点帮助,有利于长依赖链的指令(因为这些指令往往会落后),但它并不是我想象中的万能药。
p0
被分配了比实际更多的压力,因为 dec/jnz
组合在理论上可以在 p06
上执行。实际上,由于分支被预测为采取,它只会进入 p6
,但也许这些信息不能输入到压力平衡算法中,所以计数器倾向于在 p016
上看到相等的压力,这意味着 add
和 xor
的分布不同于最优情况。
可能我们可以通过展开循环来测试这个假设,这样 jnz
就不是那么重要了...
1好的,它的正确写法是μops,但这会降低搜索能力,为了实际输入“μ”字符,我通常会从网页复制粘贴该字符。
2最初在循环中使用了imul
而不是popcnt
,但令人难以置信的是,_IACA 不支持它_!
3请注意,我并不是在暗示这是一种糟糕的设计或其他什么 - 可能有非常好的硬件原因,使得调度程序不能在执行时轻松地做出所有决策。
perf
添加了一些结果。它们明确表明,在某些情况下,IACA过于乐观了。即使在相对简单的调度情况下(没有依赖链),也存在重要的错误调度,这几乎会使运行时间增加一倍。 - BeeOnRopep6
,而不是p0
。对于call
也是一样。p0
只能处理(预测)未被采取的条件跳转。我刚刚在uarch-bench中添加了一个测试来说明这一点。使用--timer=libpfc --test-name=misc/*tight* --extra-events=UOPS_DISPATCHED.PORT_0,UOPS_DISPATCHED.PORT_1,UOPS_DISPATCHED.PORT_5,UOPS_DISPATCHED.PORT_6
运行... - BeeOnRope