x86 uops的调度是如何进行的?

51
现代x86 CPU将传入的指令流分解为微操作(uops1),并在其输入就绪时对这些uops进行乱序调度out-of-order。虽然基本思想很清楚,但我想了解就绪指令是如何具体调度的,因为它会影响微优化决策。
例如,考虑以下玩具循环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。循环的前三个指令(leapopcntadd)是一个依赖链的一部分,每个循环都会重新开始。
最后的decjne被合并了。因此,我们总共有4个融合域uop,只有一个循环传递的依赖链,延迟为1个周期。因此,基于这个标准,循环似乎可以以1个周期/迭代的速度执行。
然而,我们还应该看一下端口压力:
  • lea可以在端口1和5上执行
  • popcnt可以在端口1上执行
  • add可以在端口0、1、5和6上执行
  • 预测取反的jnz在端口6上执行
因此,要实现1个周期/迭代,你几乎需要以下情况发生:
  • popcnt指令必须在端口1上执行(唯一可执行的端口)
  • lea指令必须在端口5上执行(不得在端口1上执行)
  • add指令必须在端口0上执行,不得在其它三个可执行的端口上执行
  • jnz指令只能在端口6上执行

这是很多条件!如果指令随机调度,吞吐量会大大降低。例如,add指令有75%的概率会被分配到端口1、5或6中的一个,这将导致popcntleajnz指令延迟一个周期。同样地,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位小数)。

我该如何理解现代x86 CPU的调度方式?特别是:
  1. 当多个uop在保留站中处于ready状态时,它们按什么顺序被调度到端口?
  2. 当一个uop可以进入多个端口(例如上面示例中的addlea),如何决定选择哪个端口?
  3. 如果任何答案涉及像oldest这样的概念以在uops之间进行选择,那么它是如何定义的?自从交付到RS以来的年龄?自从变得ready以来的年龄?如何打破平局?程序顺序是否会参与其中?

Skylake的结果

让我们测量一些Skylake上的实际结果,以检查哪些答案可以解释实验证据,这里是我Skylake机器上的一些真实世界测量结果(来自perf)。令人困惑的是,我将切换到使用imul作为我的“只在一个端口上执行”的指令,因为它有许多变体,包括允许您为源和目标使用不同寄存器的3个参数版本。当试图构建依赖链时,这非常方便。它还避免了popcnt所具有的“对目标的错误依赖”。

独立指令

让我们首先看看简单的(?)情况,即指令相对独立 - 没有除循环计数器之外的任何依赖链。
这是一个4 uop循环(只有3个执行uop),并带有轻微的压力。所有指令都是独立的(不共享任何源或目的地)。add原则上可以窃取imul所需的p1dec所需的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% )

作为预期,p1p6imuldec/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 现在大致均匀分布在 p0p1p5 中 - 因此 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% 的周期),因此 p1p6 被过度使用,因为它们都在执行其所需的操作 imuldec/jnz。我认为这种行为与 hayesti 在他们的答案中提到的基于计数器的压力指标以及 hayesti 和 Peter Cordes 提到的 uops 在发出时而非执行时被分配到端口 是一致的。这种行为3使得执行最旧的就绪 uops规则不再那么有效。如果 uops 不是在发出时绑定到执行端口,而是在执行时绑定,那么这个“最旧”的规则将在一次迭代后解决上述问题——一旦一个 imul 和一个 dec/jnz 被推迟了一个周期,它们将始终比竞争的 xoradd 指令更老,因此应该首先被调度。然而,我正在学习的一件事是,如果端口是在发出时分配的,这个规则就没有帮助,因为端口在发出时是预先确定的。我想它仍然有点帮助,有利于长依赖链的指令(因为这些指令往往会落后),但它并不是我想象中的万能药。

这也似乎是解释上面结果的原因: p0 被分配了比实际更多的压力,因为 dec/jnz 组合在理论上可以在 p06 上执行。实际上,由于分支被预测为采取,它只会进入 p6,但也许这些信息不能输入到压力平衡算法中,所以计数器倾向于在 p016 上看到相等的压力,这意味着 addxor 的分布不同于最优情况。

可能我们可以通过展开循环来测试这个假设,这样 jnz 就不是那么重要了...


1好的,它的正确写法是μops,但这会降低搜索能力,为了实际输入“μ”字符,我通常会从网页复制粘贴该字符。

2最初在循环中使用了imul而不是popcnt,但令人难以置信的是,_IACA 不支持它_

3请注意,我并不是在暗示这是一种糟糕的设计或其他什么 - 可能有非常好的硬件原因,使得调度程序不能在执行时轻松地做出所有决策。


1
运行此代码时,您获得什么IPC?这应该可以帮助您确定IACA报告是否准确。 - Gabriel Southern
9
好的,我承认。我***喜欢你的x86问题并且会给大多数问题点赞,因为这些问题恰好是我不敢问的那种东西。 - Iwillnotexist Idonotexist
3
我终于花点时间用perf添加了一些结果。它们明确表明,在某些情况下,IACA过于乐观了。即使在相对简单的调度情况下(没有依赖链),也存在重要的错误调度,这几乎会使运行时间增加一倍。 - BeeOnRope
我认为很明显,在硬件上实现最优调度还有很长的路要走(特别是当你面临最后一刻的旁路和唤醒需要包括在你的调度仲裁中时)。你能期望的最好情况是一些静态启发式方法,希望它们大部分时间都能起作用。顺便说一句,这是一个很棒的问题。 - Leeor
1
@HadiBrais 错别字已经修正,谢谢。是的,根据Agner的表格,预测的转移分支(以及可能是无条件跳转)只到p6,而不是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
显示剩余10条评论
3个回答

35

由于以下几个原因,您的问题比较棘手:

  1. 答案很大程度上取决于处理器的微架构,而不同代的处理器之间可能存在很大差异。
  2. 这些是细节问题,英特尔通常不公开发布。

尽管如此,我会尝试回答......

当多个uop已准备好在保留站中等待调度时,它们按什么顺序被调度到端口?

理论上应该是最老的[见下面的解释],但实际情况可能有所不同。 P6微架构(用于Pentium Pro、2和3)使用了一个具有五个调度程序(每个执行端口一个)的保留站;调度程序使用优先级指针作为扫描准备好的uop以调度的起始位置。它只是伪FIFO,因此最古老的准备好的指令并非总是被调度。在NetBurst微架构(用于Pentium 4)中,他们放弃了统一的保留站,并使用了两个uop队列。这些是真正的折叠优先队列,因此调度程序保证了会选择最古老的准备好的指令。 Core架构重新采用了保留站,我会有一个基于经验的猜测,他们使用了折叠优先队列,但我找不到可以确认这一点的来源。如果有人有确定的答案,我全听着。

当一个uop可以被发送到多个端口时(例如上面例子中的add和lea指令),如何决定选择哪个端口?

这很难知道。我能找到的最好资料是英特尔的专利描述了这样的机制。基本上,他们为每个具有冗余功能单元的端口保留一个计数器。当uops离开前端进入保留站时,它们被分配一个调度端口。如果必须在多个冗余执行单元之间进行选择,则使用计数器均匀分配工作。随着uops进入和离开保留站,计数器分别增加和减少。

当然,这只是一种启发式方法,并不能保证完全无冲突的调度,不过我认为它在你的玩具样例中仍然可行。那些只能传输到一个端口的指令会最终影响调度器将“受限制较少”的微操作分派到其他端口。

无论如何,专利的存在并不一定意味着这个想法被采纳了(尽管可以说其中一位作者也是 Pentium 4 的技术主管,所以谁知道呢?)

如果任何答案涉及最老的uops选择等概念,它是如何定义的?是自进入RS时算起还是自变得可用时算起?如何处理平局?程序顺序是否参与其中?

由于 uops 是按照顺序插入保留站的,因此这里的“最老”确实是指进入保留站的时间,即按程序顺序排列的最老。

顺便说一句,我会对那些 IACA 结果持保留态度,因为它们可能不能反映真实硬件的细微差别。在 Haswell 上,有一个称为 uops_executed_port 的硬件计数器,可以告诉你你的线程中有多少周期是将 uops 发送到端口 0-7。也许你可以利用这些来更好地理解你的程序?


1
那个特定问题对于优化非常重要,因为它区分了你只关心相对顺序以影响调度的情况和你需要遵循规则的情况,例如“一个uop X需要在另一个uop Y之前至少出现4个uops,以确保它更旧”。 - BeeOnRope
2
从我的Haswell计数器使用libpfc,我得到的是uops以4-4-4-0-4-4-4-0的模式发出...周期计数比最小可能值高33%。加上循环中唯一具有延迟>1的指令是popcnt(lat 3),我倾向于相信popcnt总是被同一周期误发到p1addlea所阻塞了1个周期,但总是在下一个周期优先访问p1,因为那是唯一可以接受popcnt的端口。 - Iwillnotexist Idonotexist
2
@BeeOnRope 所有的微操作都是从x86指令中以确定性顺序生成的,而所有的x86指令也都是相互排序的。无论你的流水线有多宽,进入保留站的一批微操作中总会有一个“最老”的微操作,这是由程序顺序决定的。 - hayesti
1
@BeeOnRope uops_issued.any<1, >=1, >=2, >=3, >=4 给您一个累积直方图,显示在任何 uops 被发出的几乎所有周期中都会以 4 个为一组发出 uops。 - Iwillnotexist Idonotexist
2
@BeeOnRope:我从至少有点可靠的来源(Agner Fog或Intel手册,我忘记哪个是哪个了)读到的其他事实:1)uop在发出时分配到端口。2)调度尝试避免涉及具有不同延迟时间的uop时的写回冲突。 (这就是为什么SnB系列将uop延迟时间标准化为1、3和5个周期,并按端口分组的原因。除了SKL有一些4个周期的uop外,仍然没有2个周期的uop)。 - Peter Cordes
显示剩余13条评论

18
以下是关于Skylake的一些发现,重点在于操作码在指令发出时(即被发放到RS时)分配到端口,而不是在调度时(即它们被发送以执行的那一刻)分配。在此之前,我曾经认为端口决策是在调度时做出的。
我进行了各种测试,试图隔离一系列add操作,使其可以进入p0156端口,以及只能进入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]

Basically,这里有一长串的mov eax, [edi]指令,只在p23上发出,因此不会堵塞其他指令使用的端口(我也可以使用nop指令,但测试结果会略有不同,因为nop不会分配到RS)。接下来是“payload”部分,由4个imul和12个add组成,然后是更多虚假的mov指令。
首先,让我们看一下专利,这是hayesti提供的链接,他描述了基本的想法:每个端口都有一个计数器,用于跟踪分配给该端口的uop总数,这些计数器用于负载均衡端口分配。请看专利说明书中包含的这张表格:

enter image description here

这个表格用于在讨论专利中的3宽架构中为3个uops的问题组选择或p1。请注意,行为取决于,并且有4个基于计数的规则1,这些规则以逻辑方式分配uops。特别是,在整个组被分配未使用端口之前,计数需要在+/-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指令发送到p5p6,通常随着指令的减速而交替选择端口(即,偶数位置上的add指令发送到p5,奇数位置上的add指令发送到p6)。
  • 第二个add指令也发送到p56,即第一个指令未发送的那个端口。
  • 此后,进一步的add指令开始在p0156之间平衡分配,其中p5p6通常领先,但整体上情况相对平均(即,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。因此,在这里,调度工作得很好。
当然,当情况反过来时,一系列的imuladd之后出现时,p1会在imul出现之前先加载其所占份额的add。这是由于端口分配按顺序在发出时进行,因为没有机制可以“向前看”并在调度add时查看imul
总体而言,调度程序在这些测试用例中表现良好。
它并没有解释以下更小、更紧凑的循环中会发生什么:
sub r9, 1
sub r10, 1
imul ebx, edx, 1
dec ecx
jnz top

就像我问题中的示例4一样,这个循环只在大约30%的周期内填充p0,尽管有两个sub指令应该能够在每个周期都到达p0p1p6过度订阅,每次迭代执行1.24个uops(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个uops的循环显示出一致的调度行为)。无论如何,结论在不同的调度组大小下都成立。


1
@PeterCordes - 没有特定的原因。它们是虚拟movs。它也可以是[rdi]。指向的位置在.rodata中,并在低32位中加载。 - BeeOnRope
2
我在结尾添加了另一个小测试,似乎表明如果所有指令具有相同的延迟(1),则通常不会出现此问题。 - BeeOnRope
1
@Noah - 它们在核心的乱序部分中作为独立的uops,就像你有单独的加载和ALU指令一样。只有在重命名(以及退役)时它们才会作为一个整体行动。 - BeeOnRope
1
@Noah - 是的,至少对于“选择的任何端口上调度”的特定定义而言是这样。我不会真正这样说,因为似乎它正在使用该端口或类似的东西。但这并不是错误的:任何被调度(进入调度程序)并且依赖于同时被调度的负载的操作都必须在调度程序中等待至少4或5个周期,因为在那之前没有一种方式可以让其所有的运算数准备就绪。当然,这不会干扰其他想要在此期间使用相同端口的操作,除非您达到了调度程序的容量。 - BeeOnRope
1
在某些情况下,您可以认为操作(与所有其他等待操作协调)可能会阻塞一些本来可以准备运行的其他操作。 - BeeOnRope
显示剩余7条评论

2

《最近英特尔微架构基本块准确吞吐量预测》第2.12节[^1]解释了端口如何分配,但未能解释问题描述中的示例4。我也无法弄清楚延迟在端口分配中扮演的角色。

以前的工作[19,25,26]已经确定了单个指令的µop可以使用的端口。然而,对于可以使用多个端口的µop,处理器实际选择的端口是先前未知的。我们使用微基准测试来反向工程化端口分配算法。接下来,我们将描述在具有八个端口的CPU上发现的结果;这样的CPU目前被广泛使用。

当重命名器将µops发布到调度程序时,会分配端口。在单个周期内,最多可以发布四个µops。以下,我们将一个µop在一个周期内的位置称为发布槽;例如,一个周期中发布的最老的指令将占用发布槽0。

µop分配的端口取决于其发布槽和尚未执行并在上一个周期中发布的µops分配的端口。

以下,我们只考虑可以使用多个端口的µops。对于给定的µop m,请让$P_{min}$是从m可以使用的端口中分配最少的未执行µop的端口。让$P_{min'}$是迄今为止使用第二小的端口。如果在具有最小(或次小)使用率的端口之间存在平局,则让$P_{min}$(或$P_{min'}$)是这些端口中最高端口编号的端口(选择此选项的原因可能是高端口号的端口连接到较少的功能单元)。如果$P_{min}$和$P_{min'}$之间的差异大于或等于3,则将$P_{min'}$设置为$P_{min}$。

发布槽0和2中的µops分配给端口$P_{min}$,发布槽1和3中的µops分配给端口$P_{min'}$。

一个特殊情况是可以使用端口2和端口3的µops。这些端口由处理内存访问的µ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

在端口1和5上可以执行指令bswap,并且在端口1上可以执行imul r64, R64, i。如果计数器在线程之间共享,则会看到bswap在端口5上执行,imul在端口1上执行。
实验记录如下,其中线程1的端口P0和P5以及线程2的p0应该记录了少量非用户数据,但不会影响结论。从数据中可以看出,线程2的bswap指令在P1和P5端口之间交替执行,但不放弃P1。
因此,计数器不在线程之间共享。
这个结论与SMotherSpectre[^2]不冲突,后者使用时间作为侧信道。(例如,线程2在端口1上等待更长时间以使用端口1。)
执行占用特定端口的指令并测量它们的时间,可以推断在同一端口上执行的其他指令。我们首先选择两个指令,每个指令都安排在单独的执行端口上。一个线程运行并计时在端口a上安排的一系列单个µop指令,同时另一个线程运行在端口b上安排的一长串指令。我们期望,如果a = b,则会发生争用,并且与a ≠ b的情况相比,测量的执行时间更长。
[^1]:Abel, Andreas, and Jan Reineke. "Accurate Throughput Prediction of Basic Blocks on Recent Intel Microarchitectures." arXiv preprint arXiv:2107.14210 (2021).

[^2]: Bhattacharyya, Atri, Alexandra Sandulescu, Matthias Neugschwandtner, Alessandro Sorniotti, Babak Falsafi, Mathias Payer, and Anil Kurmus. “SMoTherSpectre: Exploiting Speculative Execution through Port Contention.” 2019年11月6日,第2019届ACM SIGSAC计算机与通信安全会议论文集,785-800页。 https://doi.org/10.1145/3319535.3363194.


你在哪个微架构上进行测试?我认为Intel SnB系列竞争地共享RS,所以我有点惊讶调度没有考虑其他逻辑核的uops。但是我猜从公平性/饥饿的角度来看,您不希望一个线程独占一个端口?但是,如果一个uop需要一个端口,它将被安排在那里。也许有其他原因为每个线程计数器,例如作为让lfence仅从RS / ROB中排出此线程的uops的机制的一部分? - Peter Cordes
无论如何,这是一个有趣的结果,是个好实验。但我想知道展开一些会不会有影响:你一半的uops都是被取的分支,只能在端口6上执行,所以瓶颈不在端口1,而在于此。 - Peter Cordes
@PeterCordes 我正在使用i7-10700,谷歌说它是彗星湖。每当我查看agner的指令表时,我都在想应该使用哪一部分 - skylake,coffee lake还是cannon lake?我认为两种选项(共享或不共享)在安全方面都有其优缺点。此外,我重新运行了单个循环中重复39次的imulbswap测试。结论没有改变。 - moep0
1
Comet Lake和Coffee Lake中的实际核心都是相同的Skylake微架构,只是在不同的制造工艺上。 (https://en.wikichip.org/wiki/intel/microarchitectures/comet_lake) (可能有一些更改来部分修复或缓解像Meltdown或Spectre这样的问题)。内存控制器和iGPU更好,并且顶级型号中有更多的核心,但在核心内部,据我所知,没有实质性的变化。我不知道为什么Agner要单独为Coffee Lake进行另一个测试,而不是Skylake。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接