[英]How are x86 uops scheduled, exactly?
现代 x86 CPU 将传入的指令流分解为微操作 (uops 1 ),然后在它们的输入准备就绪时乱序调度这些 uops。 虽然基本思想很清楚,但我想知道如何安排就绪指令的具体细节,因为它会影响微优化决策。
例如,以下面的玩具循环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
)是依赖链的一部分,每个循环都popcnt
开始。
最后的dec
和jne
融合在一起。 所以我们总共有 4 个融合域 uops,一个只有循环携带的依赖链,延迟为 1 个周期。 因此,根据该标准,循环似乎可以执行 1 个循环/迭代。
但是,我们也应该查看端口压力:
lea
可以在端口 1 和 5 上执行add
可以在端口 0、1、5 和 6 上执行jnz
在端口 6 上执行因此,要达到 1 个循环/迭代,您几乎需要执行以下操作:
lea
必须在端口 5 上执行(而不是在端口 1 上)add
必须在端口 0 上执行,绝不能在它可以执行的其他三个端口中的任何一个上执行jnz
无论如何只能在端口 6 上执行这是很多条件! 如果指令只是随机安排的,您可能会得到更糟糕的吞吐量。 例如,75% 的add
将进入端口 1、5 或 6,这将使popcnt
、 lea
或jnz
延迟一个周期。 同样,对于可以去 2 个端口的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
它几乎反映了我上面提到的必要的“理想”调度,有一个小偏差:它显示了在 10 个周期中的 1 个周期中add
从lea
窃取端口 5。 它也不会知道,融合的分支要去口6,因为预计拍摄,所以它把大部分的微指令为分支端口0,而大部分的微指令的的add
端口6,而反之亦然。
目前尚不清楚 IACA 报告的超过最优值的额外 0.05 个循环是一些深入、准确分析的结果,还是它使用的算法的洞察力较低的结果,例如,分析固定数量的循环的循环,或者只是一个错误或什么。 对于它认为将进入非理想端口的 uop 的 0.1 部分也是如此。 也不清楚是否有人解释了另一个 - 我认为错误分配端口 1 的 10 次会导致每次迭代的循环计数为 11/10 = 1.1 个循环,但我还没有计算出实际的下游结果 - 也许平均影响较小。 或者它可能只是四舍五入(0.05 == 0.1 到 1 个小数位)。
那么现代 x86 CPU 是如何调度的呢? 特别是:
add
和lea
),它如何决定选择哪个端口?让我们在 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
指令,则会得到以下结果:
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 uop/周期,因为xor
是一个归零习语,并在重命名器中被消除。 让我们尝试使用 4 uop 的最大压力。 我希望上面启动的任何机制也能够完美地安排它。 我们只将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
均匀调度所有内容, p0156
未充分利用p0
(它只执行约 49% 的周期),因此p1
和p6
被过度订阅,因为它们正在执行所需的imul
和dec/jnz
。 我认为这种行为与 hayesti 在他们的回答中指出的基于计数器的压力指示器一致,并且在发布时将 uops分配给端口,而不是在hayesti 和 Peter Cordes 提到的执行时。 行为3使得执行最旧的就绪 uops规则几乎没有那么有效。 如果 uops 没有绑定到有问题的执行端口,而是在执行时绑定,那么这个“最古老的”规则将在一次迭代后解决上面的问题——一旦一个imul
和一个dec/jnz
被阻止进行一次迭代,他们将总是比竞争的xor
更旧并add
指令,所以应该总是首先安排。 不过,我正在学习的一件事是,如果端口是在发布时分配的,则此规则无济于事,因为端口是在发布时预先确定的。 我想它仍然有助于支持作为长依赖链一部分的指令(因为它们往往会落后),但这并不是我认为的万能药。
这也似乎是一个解释上述结果: p0
被分配更多的压力比它确实有因为dec/jnz
组合在理论上可以上执行p06
。 事实上,因为分支被预测采取它只去p6
,但也许该信息不能提供给压力平衡算法,所以计数器往往会看到p016
上的相等压力,这意味着add
和xor
得到传播与最优不同。
也许我们可以通过稍微展开循环来测试这一点,这样jnz
就不是一个因素......
1好的,它写得正确μops ,但这会扼杀搜索能力并实际输入“μ”字符,我通常求助于从网页复制粘贴该字符。
2我最初在循环中使用imul
而不是popcnt
,但令人难以置信的是,_IACA 不支持它_!
3请注意,我并不是在暗示这是一个糟糕的设计或任何东西 - 可能有很好的硬件原因导致调度程序无法在执行时轻松做出所有决定。
您的问题很棘手,原因如下:
不过,我会尽量回答...
当保留站中准备好多个微指令时,它们按什么顺序被调度到端口?
它应该是最古老的 [见下文],但您的里程可能会有所不同。 P6 微体系结构(在 Pentium Pro、2 和 3 中使用)使用带有五个调度程序(每个执行端口一个)的保留站; 调度程序使用优先级指针作为开始扫描准备好要调度的 uops 的位置。 它只是伪 FIFO,因此完全有可能并不总是安排最旧的就绪指令。 在 NetBurst 微体系结构(在 Pentium 4 中使用)中,他们放弃了统一保留站,而是使用两个 uop 队列。 这些是适当的折叠优先级队列,因此可以保证调度程序获得最旧的就绪指令。 核心架构返回到一个保留站,我会冒险猜测他们使用了折叠优先级队列,但我找不到证实这一点的来源。 如果有人有明确的答案,我会全神贯注。
当一个 uop 可以去多个端口时(如上例中的 add 和 lea),它是如何决定选择哪个端口的?
这很难知道。 我能找到的最好的是来自英特尔的专利,描述了这种机制。 本质上,它们为每个具有冗余功能单元的端口保留一个计数器。 当 uops 离开前端到保留站时,它们会被分配一个调度端口。 如果必须在多个冗余执行单元之间做出决定,则使用计数器来平均分配工作。 计数器随着微指令分别进入和离开保留站而递增和递减。
当然,这只是一种启发式方法,并不能保证完美的无冲突时间表,但是,我仍然可以看到它与您的玩具示例一起使用。 只能到达一个端口的指令最终会影响调度程序将“限制较少”的 uops 分派到其他端口。
在任何情况下,专利的存在并不一定意味着该想法被采用(尽管如此说,其中一位作者也是奔腾 4 的技术负责人,所以谁知道呢?)
如果任何答案都涉及在 uops 中选择最旧的概念,那么它是如何定义的? 自交付给 RS 以来的年龄? 准备好后的年龄? 关系是怎么断的? 程序顺序有没有出现过?
由于微指令是按顺序插入保留站的,这里最老的确实是指它进入保留站的时间,即程序顺序最旧。
顺便说一句,我会对那些 IACA 结果持怀疑态度,因为它们可能无法反映真实硬件的细微差别。 在 Haswell 上,有一个名为uops_executed_port的硬件计数器,它可以告诉您线程中有多少个周期是端口 0-7 的uops问题。 也许您可以利用这些来更好地了解您的程序?
这是我在 Skylake 上发现的,从uops 在发布时间(即,当它们被发布到 RS 时)而不是在调度时间(即,在它们被发送执行时)分配给端口的角度来看。 . 在我明白港口决定是在派送时做出的。
我进行了各种测试,试图隔离可以进入p0156
的add
操作序列和仅进入端口 0 的imul
操作。一个典型的测试是这样的:
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
... many more mov instructions
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1
add r9, 1
add r8, 1
add ecx, 1
add edx, 1
add r9, 1
add r8, 1
add ecx, 1
add edx, 1
add r9, 1
add r8, 1
add ecx, 1
add edx, 1
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
... many more mov instructions
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
基本上有很长的mov eax, [edi]
指令引入,它们只在p23
发出,因此不会阻塞指令使用的端口(我也可以使用nop
指令,但测试将是一个有点不同,因为nop
不向 RS 发出问题)。 接下来是“有效载荷”部分,这里由 4 个imul
和 12 个add
,然后是更多虚拟mov
指令的引出部分。
首先,让我们看看上面 hayesti 链接的专利,他描述了其基本思想:每个端口的计数器,用于跟踪分配给端口的 uop 总数,用于对端口分配进行负载平衡。 看看专利描述中包含的这张表:
该表用于为专利中讨论的 3-wide 架构的问题组中的 3-uop 在p0
或p1
之间进行选择。 请注意,行为取决于uop 在 group 中的位置,并且有 4个基于计数的规则1 ,它们以合乎逻辑的方式散布 uop。 特别是,在整个组被分配到未充分使用的端口之前,计数需要为 +/- 2 或更大。
让我们看看我们是否可以在 Sklake 上观察“在问题组中的位置”问题的行为。 我们使用单个add
的有效负载,例如:
add edx, 1 ; position 0
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
...我们将其在 4 指令卡盘内滑动,例如:
mov eax, [edi]
add edx, 1 ; position 1
mov eax, [edi]
mov eax, [edi]
... 以此类推,测试问题组2 中的所有四个位置。 当 RS 已满(包含mov
指令)但没有任何相关端口的端口压力时,这将显示以下内容:
add
指令转到p5
或p6
,选择的端口通常随着指令变慢而交替(即,偶数位置的add
指令转到p5
,奇数位置的add
指令转到p6
)。add
指令也转到p56
- 第一个没有转到的两个中的那个。add
指令开始在p0156
周围平衡, p5
和p6
通常在前面,但总体上相当均匀(即p56
和其他两个端口之间的差距没有增加)。 接下来,我看看如果用imul
操作加载p1
会发生什么,然后首先在一堆add
操作中:
imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1
add r9, 1
add r8, 1
add ecx, 1
add edx, 1
add r9, 1
add r8, 1
add ecx, 1
add edx, 1
add r9, 1
add r8, 1
add ecx, 1
add edx, 1
结果表明调度程序处理得很好——所有imul
被调度到p1
(如预期的那样),然后没有后续的add
指令进入p1
,而是在p056
周围传播。 所以这里的调度运行良好。
当然,当情况相反,并且imul
序列在add
s 之后出现时, p1
在imul
s 命中之前加载了它的add
份额。 这是端口分配在发布时按顺序发生的结果,因为在调度add
时没有“向前看”和查看imul
的机制。
总体而言,调度程序看起来在这些测试用例中做得很好。
它没有解释在更小、更紧密的循环中会发生什么,如下所示:
sub r9, 1
sub r10, 1
imul ebx, edx, 1
dec ecx
jnz top
就像我的问题中的示例 4一样,尽管有两个sub
指令应该能够在每个周期转到p0
,但该循环仅在大约 30% 的周期内填充p0
。 p1
和p6
被超额订阅,每个迭代执行 1.24 uop(1 是理想的)。 我无法对在此答案顶部运行良好的示例与坏循环之间的差异进行三角测量 - 但仍有许多想法可以尝试。
我确实注意到没有指令延迟差异的示例似乎不会受到这个问题的影响。 例如,这是另一个具有“复杂”端口压力的 4-uop 循环:
top:
sub r8, 1
ror r11, 2
bswap eax
dec ecx
jnz top
uop图如下:
instr p0 p1 p5 p6
sub X X X X
ror X X
bswap X X
dec/jnz X
因此sub
必须始终转到p15
,如果要解决问题,则与bswap
共享。 他们是这样:
'./sched-test2' 的性能计数器统计信息(2 次运行):
999,709,142 uops_dispatched_port_port_0 ( +- 0.00% )
999,675,324 uops_dispatched_port_port_1 ( +- 0.00% )
999,772,564 uops_dispatched_port_port_5 ( +- 0.00% )
1,000,991,020 uops_dispatched_port_port_6 ( +- 0.00% )
4,000,238,468 uops_issued_any ( +- 0.00% )
5,000,000,117 instructions:u # 4.99 insns per cycle ( +- 0.00% )
1,001,268,722 cycles:u ( +- 0.00% )
如此看来,该问题可能与指令延迟(当然,也有实例之间的其他差异)。 这是在这个类似问题中提出的。
1该表有 5 个规则,但 0 和 -1 计数的规则是相同的。
2当然,我不能确定问题组从哪里开始和结束,但不管我们在滑下四个指令时测试四个不同的位置(但标签可能是错误的)。 我也不确定问题组的最大大小是 4 - 管道的早期部分更宽 - 但我相信它是,并且一些测试似乎表明它是(具有 4 uop 倍数的循环显示一致的调度行为)。 在任何情况下,结论都适用于不同的调度组大小。
最近英特尔微架构上基本块的准确吞吐量预测[^1] 的第 2.12 节解释了端口的分配方式,尽管它未能解释问题描述中的示例 4。 我也没有弄清楚延迟在端口分配中扮演什么角色。
之前的工作 [19, 25, 26] 已经确定了单个指令的微操作可以使用的端口。 然而,对于可以使用多个端口的微操作,处理器如何选择实际端口是未知的。 我们使用微基准对端口分配算法进行了逆向工程。 下面,我们将描述我们对具有 8 个端口的 CPU 的发现; 此类 CPU 目前使用最为广泛。
当重命名程序向调度程序发出 µop 时,就会分配端口。 在单个周期中,最多可以发出 4 个 µop。 在下文中,我们将循环中 µop 的位置称为发布槽; 例如,一个周期中发出的最旧指令将占用发出槽 0。
µop 分配的端口取决于它的发布槽和分配给尚未执行且在前一个周期发布的 µop 的端口。
在下文中,我们将只考虑可以使用多个端口的微操作。 对于给定的 µop m,让 $P_{min}$ 是 m 可以使用的端口中分配到的最少非执行 µop 的端口。 令 $P_{min'}$ 是目前使用量第二小的端口。 如果使用量最小(或分别为第二小)的端口之间存在联系,则让 $P_{min}$(或 $P_{min'}$)是这些端口中端口号最高的端口(这种选择的原因可能是编号较大的端口连接到较少的功能单元)。 如果 $P_{min}$ 和 $P_{min'}$ 之间的差值大于或等于 3,我们将 $P_{min'}$ 设置为 $P_{min}$。
发布时隙 0 和 2 中的微操作分配给端口 $P_{min}$ 发布时隙 1 和 3 中的微操作分配给端口 $P_{min'}$。
一个特例是可以使用端口 2 和端口 3 的 µops。这些端口由处理内存访问的 µops 使用,并且两个端口都连接到相同类型的功能单元。 对于此类微操作,端口分配算法在端口 2 和端口 3 之间交替。
我试图找出 $P_{min}$ 和 $P_{min'}$ 是否在线程之间共享(超线程),即一个线程是否会影响同一内核中另一个线程的端口分配。
只需将 BeeOnRope 的答案中使用的代码拆分为两个线程即可。
thread1:
.loop:
imul rax, rbx, 5
jmp .loop
thread2:
mov esi,1000000000
.top:
bswap eax
dec esi
jnz .top
jmp thread2
其中指令bswap
可以在端口 1 和 5 上执行, imul r64, R64, i
在端口 1 上执行。如果计数器在线程之间共享,您会看到bswap
在端口 5 上执行,而imul
在端口 1 上执行。
实验记录如下,其中线程1上的端口P0和P5和线程2上的p0应该记录了少量非用户数据,但不妨碍得出结论。 从数据可以看出,线程2的bswap
指令在端口P1和P5之间交替执行,没有放弃P1。
港口 | 线程 1 活动周期 | 线程 2 个活动周期 |
---|---|---|
P0 | 63,088,967 | 68,022,708 |
P1 | 180,219,013,832 | 95,742,764,738 |
P5 | 63,994,200 | 96,291,124,547 |
P6 | 180,330,835,515 | 192,048,880,421 |
全部的 | 180,998,504,099 | 192,774,759,297 |
因此,计数器不在线程之间共享。
这个结论与SMotherSpectre[^2]并不冲突,SMotherSpectre[^2]以时间作为旁道。 (例如,线程 2 在端口 1 上等待更长的时间才能使用端口 1。)
执行占用特定端口的指令并测量它们的时序可以推断在同一端口上执行的其他指令。 我们首先选择两条指令,每条指令都安排在一个单独的、不同的执行端口上。 一个线程运行并计时在端口 a 上调度的一长串单个 µop 指令,同时另一个线程运行在端口 b 上调度的一长串指令。 我们预计,如果 a = b,则发生争用,并且与 a ≠ b 的情况相比,测量的执行时间更长。
[^1]:Abel、Andreas 和 Jan Reineke。 “最新英特尔微架构上基本块的准确吞吐量预测。” arXiv 预印本 arXiv:2107.14210 (2021)。
[^2]:Bhattacharyya、Atri、Alexandra Sandulescu、Matthias Neugschwandtner、Alessandro Sorniotti、Babak Falsafi、Mathias Payer 和 Anil Kurmus。 “SMoTherSpectre:通过端口争用利用投机执行。” 2019 年 ACM SIGSAC 计算机和通信安全会议论文集,2019 年 11 月 6 日,785-800。 https://doi.org/10.1145/3319535.3363194 。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.