现代的x86 CPU将输入的指令流分解为微操作(uops1),然后在输入准备就绪时调度这些uops out-of-order。尽管基本思路很明确,但我想知道如何安排好准备好的指令的详细信息,因为它会影响微优化决策。
例如对于以下玩具loop2:
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
,imul
,add
)是依赖关系链的一部分,该依赖关系链从每个循环重新开始。最终的
dec
和jne
融合在一起。因此,我们总共有4个融合域uops,并且只有一个循环携带的依赖链,其延迟为1个周期。因此,基于该标准,似乎循环可以1个周期/迭代执行。但是,我们也应该查看端口压力:
lea
可以在端口1和5上执行add
可以在端口0、1、5和6上执行jnz
在端口6上执行因此,要达到1个周期/迭代,您几乎需要发生以下情况:
lea
必须在端口5上执行(并且永远不要在端口1上执行)add
必须在端口0上执行,并且决不能在它可以在jnz
有很多条件!如果只是随机安排指令,那么吞吐量可能会差很多。例如,
add
的75%将进入端口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
它几乎反射(reflect)了我上面提到的必要的“理想”调度,但有很小的偏差:它显示
add
在10个周期中的1个周期内从lea
窃取了端口5。它也不知道融合分支将进入端口6,因为它被预测为已使用,因此它将分支的大部分uops放在端口0上,并将大部分uot放在add
的端口6上,而不是而不是相反。尚不清楚IACA报告的超出最佳状态的额外0.05个循环是某些深入,准确的分析的结果,还是其所用算法的见解较弱的结果,例如,分析固定数目的循环,或者仅仅是一个错误或其他。它认为将到达非理想端口的uop的0.1分数也是如此。还不清楚是否有人解释了另一种-我认为错误地分配端口1十次会导致每次迭代的周期计数为11/10 = 1.1个周期,但是我还没有计算出实际的下游结果-平均影响可能较小。或者也可以四舍五入(0.05 == 0.1到1小数位)。
那么现代x86 CPU实际如何调度?特别是:
add
和lea
)时,如何确定选择哪个端口? Skylake上的结果
让我们在Skylake上测量一些实际结果,以检查哪些答案可以解释实验证据,因此这是在Skylake盒子上的一些实际测量结果(来自
perf
)。令人困惑的是,我将切换到对我的“仅在一个端口上执行”指令使用imul
,因为它具有多种变体,包括3参数版本,可让您对源和目标使用不同的寄存器。在尝试构造依赖关系链时,这非常方便。它还避免了popcnt
具有的整个“对目标的不正确依赖性”。独立指示
让我们从简单的(?)情况开始,即指令是相对独立的-除了琐碎的依赖链(如循环计数器)以外,没有任何依赖链。
这是一个压力适中的4 uop循环(仅执行3个uops)。所有说明都是独立的(请勿共享任何来源或目的地)。
add
原则上可以窃取p1
所需的imul
或dec所需的p6
:例子1
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
在循环中的位置即可:例子2
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”规则的结果。请注意,由于
xor
是归零习惯,并且在重命名器中已消除,因此总端口压力仅为3 uops /周期。让我们尝试最大压力为4微秒。我希望上面引入的任何机制也能够完美地安排它。我们只将xor r9, r9
更改为xor r9, r10
,所以它不再是清零习惯。我们得到以下结果:例子4
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在其答案中指出的基于计数器的压力指示器相一致,并且与 uops在发布时(而不是在执行时)一起分配给端口hayesti和Peter Cordes提到过。该行为3使执行最旧的就绪uops规则的效果几乎不那么有效。如果uops并非在执行时绑定(bind)到执行端口,而是在执行时绑定(bind),那么此“最早的”规则将在一次迭代后解决上述问题-一旦将
imul
和一个dec/jnz
保留了一次迭代,它们将始终是比竞争的xor
和add
指令更早,因此应始终优先安排。我正在学习的一件事是,如果在发布时分配了端口,则此规则无济于事,因为端口是在发布时预先确定的。我想这仍然有利于支持长期依赖链的一部分的指令(因为这些指令往往会落在后面),但这并不是我所认为的所有治愈方法。这似乎也可以解释上面的结果:
p0
被分配了比实际更多的压力,因为dec/jnz
组合在理论上可以在p06
上执行。实际上,因为预测分支是仅通过p6
进行的,但可能信息无法馈入压力平衡算法,因此计数器倾向于对p016
施加相同的压力,这意味着add
和xor
会散布开来不同于最佳。也许我们可以通过展开循环来测试这一点,以便
jnz
成为一个较小的因素...1好吧,它正确地编写为μops,但是这会削弱搜索能力,并且实际上是键入“μ”字符的原因,我通常是想从网页中复制粘贴该字符。
2我最初在循环中使用
imul
而不是popcnt
,但是,令人难以置信的是,IACA并不是support it!3请注意,我并不是在暗示这不是一个糟糕的设计或任何东西-可能有很好的硬件原因,使得调度程序无法在执行时轻松地做出所有决定。
最佳答案
您的问题很难回答有两个原因:
世代之间差异很大。
不过,我会尽力回答...
当预留站中准备好多个uops时,它们按什么顺序调度到端口?
它应该是最古老的(见下文),但是您的行驶里程可能会有所不同。 P6微体系结构(在Pentium Pro,2和3中使用)使用了具有五个调度程序(每个执行端口一个)的预留站。调度程序将优先级指针用作开始扫描准备分发的uops的位置。它只是伪FIFO,因此完全有可能不总是调度最早的就绪指令。在NetBurst微体系结构(用于Pentium 4)中,他们放弃了统一预留站,而是使用了两个uop队列。这些是适当的折叠优先级队列,因此可以确保调度程序获得最早的就绪指令。核心体系结构返回到保留站,我会冒昧地以为他们使用了崩溃的优先级队列,但是我找不到来源来证实这一点。如果有人有明确的答案,我会非常高兴。
当一个uop可以进入多个端口(如上例中的add和lea)时,如何确定选择哪个端口?
要知道这很棘手。我能找到的最好的方法是Intel的a patent描述这种机制。本质上,它们为具有冗余功能单元的每个端口保留一个计数器。当微件离开前端到预留站时,它们被分配了一个调度端口。如果必须在多个冗余执行单元之间进行决策,则可以使用计数器来平均分配工作。随着uo分别进入和离开保留站,计数器会递增和递减。
自然,这只是一种试探法,并不能保证有一个完美的无冲突时间表,但是,我仍然可以看到它与您的玩具示例一起使用。只能进入一个端口的指令最终会影响调度程序将“受较少限制的”指令发送到其他端口。
无论如何,专利的存在并不一定意味着采用了该想法(尽管如此,其中一位作者还是奔腾4的技术主管,所以谁知道呢?)
如果任何答案涉及到在uops中选择的最古老的概念,它是如何定义的?自交付给RS以来的年龄?自准备就绪以来的年龄?纽带如何断裂?程序顺序会进入吗?
由于将uops按顺序插入到保留站中,因此这里的最旧确实是指它进入保留站的时间,即程序顺序中最古老的时间。
顺便说一句,我会把那些IACA的结果看得一头雾水,因为它们可能无法反射(reflect)实际硬件的细微差别。在Haswell上,有一个称为uops_executed_port的硬件计数器,它可以告诉您在线程中向端口0-7发出uops问题有多少个周期。也许您可以利用这些来更好地了解您的程序?