performance - x86 uops 究竟是如何调度的?

标签 performance optimization x86 intel cpu-architecture

现代 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 。循环的前三个指令( leapopcntadd )是依赖链的一部分,每个循环都从新开始。
最后的 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 上执行,而不能在其他三个端口中的任何一个上执行
  • jnz 无论如何只能在端口 6 上执行

  • 这是很多条件!如果指令只是随机调度,您可能会得到更糟糕的吞吐量。例如,75% 的 add 将进入端口 1、5 或 6,这将使 popcntlea 或 0x251812231341 延迟一个周期。类似的 jnz 可以去 2 个端口,一个与 lea 共享。
    另一方面,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)了我上面提到的必要的“理想”调度,有一个小偏差:它显示 popcnt 在 10 个周期中的 1 个周期中从 add 窃取端口 5。它也不知道融合的分支会去端口 6,因为它被预测采用,所以它将分支的大部分 uops 放在端口 0 上,而 0x2518122231343141 的大部分 uops 放在端口 6 上,而不是而不是反过来。
    目前尚不清楚 IACA 报告的超过最优值的额外 0.05 个循环是一些深入、准确分析的结果,还是它使用的算法的洞察力较低的结果,例如,分析固定数量的循环的循环,或者只是一个错误或什么。对于它认为将进入非理想端口的 uop 的 0.1 部分也是如此。也不清楚是否有人解释了另一个 - 我认为错误分配端口 1 的 10 次会导致每次迭代的循环计数为 11/10 = 1.1 个循环,但我还没有计算出实际的下游结果 - 也许平均影响较小。或者它可能只是四舍五入(0.05 == 0.1 到 1 个小数位)。
    那么现代 x86 CPU 是如何调度的呢?特别是:
  • 当保留站中有多个uop准备好时,它们按什么顺序调度到端口?
  • 当一个uop可以去多个端口时(如上例中的leaadd),如何决定选择哪个端口?
  • 如果任何答案涉及在 uops 中选择最旧的概念,它是如何定义的?自交付给 RS 以来的年龄?准备好后的年龄?关系是怎么断的?是否有程序顺序?

  • Skylake 上的结果
    让我们在 Skylake 上测量一些实际结果,以检查哪些答案解释了实验证据,因此这里是我的 Skylake 盒子上的一些真实世界测量结果(来自 add)。令人困惑的是,我将转而使用 lea 作为我的“仅在一个端口上执行”指令,因为它有许多变体,包括 3 参数版本,允许您为源和目标使用不同的寄存器。这在尝试构建依赖链时非常方便。它还避免了 perf 所具有的整个“对目的地的不正确依赖”。
    独立指令
    让我们先看看指令相对独立的简单 (?) 情况——除了像循环计数器这样的琐碎链之外,没有任何依赖链。
    这是一个带有轻微压力的 4 uop 循环(仅执行 3 个 uop)。所有指令都是独立的(不共享任何来源或目的地)。 imul 原则上可以窃取 popcnt 所需的 addp1 所需的 12 进制:
    示例 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% )
    
    正如预期的那样,imulp6 分别被 p1p6 充分利用,然后 0x2513 和 0x2513 之间大约有一半的可用端口可用。粗略地注意 - 实际比率是 56% 和 44%,并且这个比率在运行中非常稳定(注意 imul 变化)。如果我调整循环对齐,拆分会发生变化(32B 对齐为 53/46,32B+4 对齐更像是 57/42)。现在,除了循环中 dec/jnz 的位置外,我们什么都不改变:
    示例 2
    top:
        imul rax, rbx, 5
        xor  r9, r9
        add  r8, rdx
        dec esi
        jnz top
    
    然后突然 add/+- 0.49% 拆分正好是 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% )
    
    在这里,p0现在大致平均分配p5imuladd分布 - 这样的p0的存在确实影响了p1调度:它不只是一个有些“避免端口1”规则的后果。
    请注意,总端口压力仅为 3 uop/周期,因为 p5 是归零惯用语,在重命名器中被消除。让我们尝试使用 4 uop 的最大压力。我希望上面启动的任何机制也能够完美地安排它。我们只将 imul 更改为 add ,因此它不再是归零习语。我们得到以下结果:
    示例 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% )
    
    哎呀!而不是在xor均匀地安排一切,调度一直未得到充分利用xor r9, r9(它只是执行一些循环〜49%),因此xor r9, r10p0156,因为他们正在执行的p0p1他们都需要OPS的oversubcribed。我认为这种行为与 hayesti 在他们的回答中指出的基于计数器的压力指示器一致,并且在发布时将 uops 分配给端口,而不是在执行时 作为两者
    hayesti 和 Peter Cordes 提到过。该行为 3 使得执行最旧的就绪 uops 规则几乎没有那么有效。如果 uops 没有绑定(bind)到有问题的执行端口,而是在执行时,那么这个“最古老的”规则将在一次迭代后解决上面的问题——一旦一个 p6 和一个 imul 被阻止进行一次迭代,它们将永远是比竞争的 dec/jnzimul 指令更旧,所以应该总是先安排。我正在学习的一件事是,如果端口是在发布时分配的,则此规则无济于事,因为端口是在发布时预先确定的。我想它仍然有助于支持作为长依赖链一部分的指令(因为它们往往会落后),但这并不是我认为的万能药。
    这似乎也解释了上面的结果: dec/jnz 分配的压力比实际压力更大,因为 xor 组合理论上可以在 add 上执行。事实上,因为分支被预测采用,它只会到达 p0 ,但也许该信息无法提供给压力平衡算法,因此计数器往往会在 dec/jnz 上看到相等的压力,这意味着 0x2518122313432141 和 0x2518122313433241 周围的分布与最优不同。
    也许我们可以通过稍微展开循环来测试这一点,因此 p06 不是一个因素......

    1 好的,μops 写得很好,但这会降低搜索能力并实际输入“μ”字符,我通常会求助于从网页中复制粘贴该字符。
    2 我最初在循环中使用 p6 而不是 p016,但令人难以置信的是,_IACA 没有 support it_ !
    3 请注意,我并不是在暗示这是一个糟糕的设计或任何东西 - 可能有很好的硬件原因导致调度程序无法在执行时轻松做出所有决定。

    最佳答案

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

  • 答案很大程度上取决于处理器的微架构
    这可能会因世代而显着不同。
  • 这些是英特尔通常不会向公众发布的细粒度细节。

  • 不过,我会尽量回答...

    当保留站中准备好多个微指令时,它们按什么顺序调度到端口?

    它应该是最古老的 [见下文],但您的里程可能会有所不同。 P6 微体系结构(在 Pentium Pro、2 和 3 中使用)使用带有五个调度程序(每个执行端口一个)的保留站;调度程序使用优先级指针作为开始扫描准备好要调度的 uops 的位置。它只是伪 FIFO,因此完全有可能并不总是安排最旧的就绪指令。在 NetBurst 微体系结构(在 Pentium 4 中使用)中,他们放弃了统一保留站,而是使用两个 uop 队列。这些是适当的折叠优先级队列,因此可以保证调度程序获得最旧的就绪指令。核心架构返回到一个保留站,我会冒险猜测他们使用了折叠优先级队列,但我找不到证实这一点的来源。如果有人有明确的答案,我会全神贯注。

    当一个uop可以去多个端口时(如上例中的add和lea),如何决定选择哪个端口?

    这很难知道。我能找到的最好的是来自英特尔的 a patent 描述了这种机制。本质上,它们为每个具有冗余功能单元的端口保留一个计数器。当 uops 离开前端到保留站时,它们会被分配一个调度端口。如果必须在多个冗余执行单元之间做出决定,则使用计数器来平均分配工作。计数器随着微指令分别进入和离开保留站而递增和递减。

    当然,这只是一种启发式方法,并不能保证完美的无冲突时间表,但是,我仍然可以看到它与您的玩具示例一起使用。只能到达一个端口的指令最终会影响调度程序将“限制较少”的 uops 分派(dispatch)到其他端口。

    在任何情况下,专利的存在并不一定意味着该想法被采用(尽管如此说,其中一位作者也是奔腾 4 的技术负责人,所以谁知道呢?)

    如果任何答案涉及在 uops 中选择最旧的概念,它是如何定义的?自交付给 RS 以来的年龄?准备好后的年龄?关系是怎么断的?是否有程序顺序?

    由于 uop 是按顺序插入保留站的,所以这里的最旧确实是指它进入保留站的时间,即程序顺序中最旧的。

    顺便说一句,我会对那些 IACA 结果持怀疑态度,因为它们可能无法反射(reflect)真实硬件的细微差别。在 Haswell 上,有一个名为 uops_executed_port 的硬件计数器,它可以告诉您线程中有多少个周期是端口 0-7 的 uops 问题。也许您可以利用这些来更好地了解您的程序?

    关于performance - x86 uops 究竟是如何调度的?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/40681331/

    相关文章:

    汇编语言循环

    arrays - Ruby 中哪个更快, `arr += [x]` 或 `arr << x`

    javascript - 使用jquery更快还是不使用jquery?

    android - SQL 查询与搜索算法

    documentation - 学习 Linux x86-64 汇编和文档的建议

    x86 - 将 _mm_shuffle_epi32 转换为 C 表达式以进行排列?

    javascript - 用零初始化javascript数组

    c# - 比 Dictionary<Type, X> 更快的选择?

    mysql - 将多个重复的 MySQL 查询变成一个?

    python - Pypy vs Python 中的计数算法性能优化(Numpy vs List)