现代的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。循环的前三个指令(leaimuladd)是依赖关系链的一部分,该依赖关系链从每个循环重新开始。

最终的decjne融合在一起。因此,我们总共有4个融合域uops,并且只有一个循环携带的依赖链,其延迟为1个周期。因此,基于该标准,似乎循环可以1个周期/迭代执行。

但是,我们也应该查看端口压力:
  • lea可以在端口1和5上执行
  • popcnt可以在端口1上执行
  • add可以在端口0、1、5和6上执行
  • 预测的jnz在端口6上执行

  • 因此,要达到1个周期/迭代,您几乎需要发生以下情况:
  • popcnt必须在端口1上执行(它可以在其上执行的唯一端口)
  • lea必须在端口5上执行(并且永远不要在端口1上执行)
  • add必须在端口0上执行,并且决不能在它可以在
  • 上执行的任何其他三个端口上执行
  • 仍然只能在端口6上执行jnz
    有很多条件!如果只是随机安排指令,那么吞吐量可能会差很多。例如,add的75%将进入端口1、5或6,这会将popcntleajnz延迟一个周期。同样,对于可以进入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实际如何调度?特别是:
  • 当预留站中准备好多个uop时,它们按什么顺序调度到端口?
  • 当一个uop可以进入多个端口(如上例中的addlea)时,如何确定选择哪个端口?
  • 如果任何答案涉及到在uops中选择的最古老的概念,那么它是如何定义的?自交付给RS以来的年龄?自准备就绪以来的年龄?纽带如何断裂?程序顺序会进入吗?

  • 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% )
    

    不出所料,p1p6分别充分利用了imuldec/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大致均匀地分布在p0p1p5之间-因此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%的周期),因此p1p6被超额订阅,因为它们正在执行imuldec/jnz的必需操作。我认为此行为与hayesti在其答案中指出的基于计数器的压力指示器相一致,并且与 uops在发布时(而不是在执行时)一起分配给端口
    hayesti和Peter Cordes提到过。该行为3使执行最旧的就绪uops规则的效果几乎不那么有效。如果uops并非在执行时绑定(bind)到执行端口,而是在执行时绑定(bind),那么此“最早的”规则将在一次迭代后解决上述问题-一旦将imul和一个dec/jnz保留了一次迭代,它们将始终是比竞争的xoradd指令更早,因此应始终优先安排。我正在学习的一件事是,如果在发布时分配了端口,则此规则无济于事,因为端口是在发布时预先确定的。我想这仍然有利于支持长期依赖链的一部分的指令(因为这些指令往往会落在后面),但这并不是我所认为的所有治愈方法。

    这似乎也可以解释上面的结果:p0被分配了比实际更多的压力,因为dec/jnz组合在理论上可以在p06上执行。实际上,因为预测分支是仅通过p6进行的,但可能信息无法馈入压力平衡算法,因此计数器倾向于对p016施加相同的压力,这意味着addxor会散布开来不同于最佳。

    也许我们可以通过展开循环来测试这一点,以便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问题有多少个周期。也许您可以利用这些来更好地了解您的程序?

    09-11 05:40