【问题标题】:Loop takes more cycles to execute than expected in an ARM Cortex-A72 CPU循环在 ARM Cortex-A72 CPU 中执行的周期比预期的要多
【发布时间】:2021-12-19 15:38:55
【问题描述】:

考虑以下在 ARM Cortex-A72 处理器上运行的代码(优化指南 here)。我已经包含了我期望的每个执行端口的资源压力:

Instruction B I0 I1 M L S F0 F1
.LBB0_1:
ldr q3, [x1], #16 0.5 0.5 1
ldr q4, [x2], #16 0.5 0.5 1
add x8, x8, #4 0.5 0.5
cmp x8, #508 0.5 0.5
mul v5.4s, v3.4s, v4.4s 2
mul v5.4s, v5.4s, v0.4s 2
smull v6.2d, v5.2s, v1.2s 1
smull2 v5.2d, v5.4s, v2.4s 1
smlal v6.2d, v3.2s, v4.2s 1
smlal2 v5.2d, v3.4s, v4.4s 1
uzp2 v3.4s, v6.4s, v5.4s 1
str q3, [x0], #16 0.5 0.5 1
b.lo .LBB0_1 1
Total port pressure 1 2.5 2.5 0 2 1 8 1

虽然uzp2 可以在F0 或F1 端口上运行,但我选择将其完全归因于F1,因为F0 上的压力很高,而F1 上的压力为零,除了这条指令。

循环迭代之间没有依赖关系,除了循环计数器和数组指针;与循环体的其余部分所花费的时间相比,这些问题应该很快得到解决。

因此,我的直觉是这段代码应该受到吞吐量限制,并且考虑到最严重的压力是在 F0 上,每次迭代运行 8 个周期(除非它遇到解码瓶颈或缓存未命中)。考虑到流式访问模式以及阵列很适合 L1 缓存的事实,后者不太可能发生。至于前者,考虑到优化手册第 4.1 节列出的约束,我预测循环体只需 8 个周期即可解码。

然而,微基准测试表明循环体的每次迭代平均需要 12.5 个周期。如果不存在其他合理的解释,我可能会编辑该问题,包括有关我如何对该代码进行基准测试的更多详细信息,但我相当确定这种差异不能仅归因于基准测试工件。此外,我尝试增加迭代次数,以查看性能是否由于启动/冷却效应而提高到渐近极限,但对于上面显示的 128 次迭代的选定值,它似乎已经这样做了。

手动展开循环以在每次迭代中包含两个计算,从而将性能降低到 13 个循环;但是,请注意,这也会重复加载和存储指令的数量。有趣的是,如果将双倍的加载和存储替换为单个 LD1/ST1 指令(双寄存器格式)(例如 ld1 { v3.4s, v4.4s }, [x1], #32),那么每次迭代的性能提高到 11.75 个周期。将循环进一步展开为每次迭代 4 次计算,同时使用 LD1/ST1 的四寄存器格式,将性能提高到每次迭代 11.25 个周期。

尽管进行了改进,但性能仍与我仅从资源压力方面预期的每次迭代 8 个周期相去甚远。即使 CPU 进行了错误的调度调用并向 F0 发出了uzp2,修改资源压力表也将表明每次迭代需要 9 个周期,与实际测量结果相距甚远。那么,是什么导致这段代码运行得比预期慢得多?我在分析中遗漏了哪些影响?

编辑:正如所承诺的,更多的基准测试细节。我为预热运行循环 3 次,说 n = 512 运行 10 次,然后为 n = 256 运行 10 次。我取 n = 512 运行的最小循环计数并从最小值中减去 n = 256。差异应该给我运行 n = 256 需要多少个周期,同时取消固定设置成本(代码未显示)。此外,这应确保所有数据都在 L1 I 和 D 缓存中。通过直接读取循环计数器 (pmccntr_el0) 进行测量。上述测量策略应抵消任何开销。

【问题讨论】:

  • ARM 性能数据不能包含芯片(非 arm 制造)或系统(非 arm 制造)问题。而且它是流水线的,因此性能预计无法预测。您是否考虑了所有这些因素?
  • 您是否尝试过代码相对于内存空间的不同对齐方式来解释获取行? (缓存与否)
  • @Jake'Alquimista'LEE:ROB 大小与隐藏一条慢速指令(如缓存未命中)有关。要重叠长的 dep 链,您还需要一个足够大的 scheduler (RS) 来处理尚未执行的指令。 (见Understanding the impact of lfence on a loop with two long dependency chains, for increasing lengths)。大型调度程序比大型 ROB 消耗更多功率,因为​​它没有按顺序分配/回收,并且必须在每个周期扫描最旧的就绪指令。一些 CPU 使用统一的调度程序,其他 CPU 使用每个端口的调度队列。
  • 所以我想说我们不能仅仅基于 ROB 大小就排除 OoO 执行限制。调度器可能足够大,但也可能不够。 (此外,物理寄存器文件的大小可能是另一个限制(例如x86 experiments,但它通常接近 ROB 大小,并且此循环混合了整数和向量 regs,因此可能将其 PRF 需求分布在两个寄存器文件上,假设 Cortex-A72 与大多数设计一样,具有独立的 int 与 FP/SIMD 寄存器文件。)
  • @PeterCordes 根据this article,Cortex-A72 中的保留站是按端口而不是全局的。猜猜我将不得不拆除并重建我为这个核心编写代码的心智模型,因为它应该只能看起来像F0 端口中最多前 8 条指令。

标签: performance assembly optimization arm neon


【解决方案1】:

首先,您可以通过将第一个mul 替换为uzp1 并执行以下smullsmlal 反过来进一步将理论周期减少到6:mulmulsmull, smlal => smull, uzp1, mul, smlal 这也大大降低了注册压力,以便我们可以进行更深入的展开(每次迭代最多 32 个)

而且您不需要v2 系数,但您可以将它们打包到v1 的较高部分

让我们通过深入展开并在汇编中编写它来排除一切:

    .arch armv8-a
    .global foo
    .text


.balign 64
.func

// void foo(int32_t *pDst, int32_t *pSrc1, int32_t *pSrc2, intptr_t count);
pDst    .req    x0
pSrc1   .req    x1
pSrc2   .req    x2
count   .req    x3

foo:

// initialize coefficients v0 ~ v1

    stp     d8, d9, [sp, #-16]!

.balign 64
1:
    ldp     q16, q18, [pSrc1], #32
    ldp     q17, q19, [pSrc2], #32
    ldp     q20, q22, [pSrc1], #32
    ldp     q21, q23, [pSrc2], #32
    ldp     q24, q26, [pSrc1], #32
    ldp     q25, q27, [pSrc2], #32
    ldp     q28, q30, [pSrc1], #32
    ldp     q29, q31, [pSrc2], #32

    smull   v2.2d, v17.2s, v16.2s
    smull2  v3.2d, v17.4s, v16.4s
    smull   v4.2d, v19.2s, v18.2s
    smull2  v5.2d, v19.4s, v18.4s
    smull   v6.2d, v21.2s, v20.2s
    smull2  v7.2d, v21.4s, v20.4s
    smull   v8.2d, v23.2s, v22.2s
    smull2  v9.2d, v23.4s, v22.4s
    smull   v16.2d, v25.2s, v24.2s
    smull2  v17.2d, v25.4s, v24.4s
    smull   v18.2d, v27.2s, v26.2s
    smull2  v19.2d, v27.4s, v26.4s
    smull   v20.2d, v29.2s, v28.2s
    smull2  v21.2d, v29.4s, v28.4s
    smull   v22.2d, v31.2s, v20.2s
    smull2  v23.2d, v31.4s, v30.4s

    uzp1    v24.4s, v2.4s, v3.4s
    uzp1    v25.4s, v4.4s, v5.4s
    uzp1    v26.4s, v6.4s, v7.4s
    uzp1    v27.4s, v8.4s, v9.4s
    uzp1    v28.4s, v16.4s, v17.4s
    uzp1    v29.4s, v18.4s, v19.4s
    uzp1    v30.4s, v20.4s, v21.4s
    uzp1    v31.4s, v22.4s, v23.4s

    mul     v24.4s, v24.4s, v0.4s
    mul     v25.4s, v25.4s, v0.4s
    mul     v26.4s, v26.4s, v0.4s
    mul     v27.4s, v27.4s, v0.4s
    mul     v28.4s, v28.4s, v0.4s
    mul     v29.4s, v29.4s, v0.4s
    mul     v30.4s, v30.4s, v0.4s
    mul     v31.4s, v31.4s, v0.4s

    smlal   v2.2d, v24.2s, v1.2s
    smlal2  v3.2d, v24.4s, v1.4s
    smlal   v4.2d, v25.2s, v1.2s
    smlal2  v5.2d, v25.4s, v1.4s
    smlal   v6.2d, v26.2s, v1.2s
    smlal2  v7.2d, v26.4s, v1.4s
    smlal   v8.2d, v27.2s, v1.2s
    smlal2  v9.2d, v27.4s, v1.4s
    smlal   v16.2d, v28.2s, v1.2s
    smlal2  v17.2d, v28.4s, v1.4s
    smlal   v18.2d, v29.2s, v1.2s
    smlal2  v19.2d, v29.4s, v1.4s
    smlal   v20.2d, v30.2s, v1.2s
    smlal2  v21.2d, v30.4s, v1.4s
    smlal   v22.2d, v31.2s, v1.2s
    smlal2  v23.2d, v31.4s, v1.4s

    uzp2    v24.4s, v2.4s, v3.4s
    uzp2    v25.4s, v4.4s, v5.4s
    uzp2    v26.4s, v6.4s, v7.4s
    uzp2    v27.4s, v8.4s, v9.4s
    uzp2    v28.4s, v16.4s, v17.4s
    uzp2    v29.4s, v18.4s, v19.4s
    uzp2    v30.4s, v20.4s, v21.4s
    uzp2    v31.4s, v22.4s, v23.4s

    subs    count, count, #32

    stp     q24, q25, [pDst], #32
    stp     q26, q27, [pDst], #32
    stp     q28, q29, [pDst], #32
    stp     q30, q31, [pDst], #32

    b.gt    1b
.balign 16
    ldp     d8, d9, [sp], #16
    ret

.endfunc
.end

上面的代码即使是有序的也有零延迟。唯一可能影响性能的是缓存未命中惩罚。

你可以测量周期,如果每次迭代远远超过48,那肯定是芯片或文档有问题。
否则,A72 的 OoO 引擎可能会像 Peter 指出的那样乏善可陈。

PS:或者加载/存储端口可能不会在 A72 上并行发布。考虑到您正在展开的实验,这是有道理的。

【讨论】:

  • 在对我的代码进行考古挖掘后,我确认我之前曾尝试过类似的方法,但因为据说性能更差而将其丢弃。事实证明,clang 引入了一个额外的 smull/smull2 对,这在我的内在代码中根本不存在):
  • 我能够强制 gcc 的主干版本生成与您的答案相似的代码,尽管在指令调度方面存在一些差异(我相信 OoO 引擎应该可以解决)。这导致了 8.75 个周期/迭代的改进,但仍然远非 6 个周期/迭代):我想我会尝试一些微基准测试,看看我是否确切了解实际核心行为与优化手册中描述的不同之处。跨度>
  • @swineone:可能值得对这个精确的手写 asm 进行基准测试,看看它是否达到了 Jake 没有预料到的一些限制。如果它也是 8.75 c/iter,那么这不是 GCC 的错。但如果 GCC 版本较慢,则可能是指令调度或指令顺序的其他影响。
  • @swineone 你确定 L/S 端口与其他端口并行问题吗?如果他们不这样做,那将是完全合理的。
  • @Jake'Alquimista'LEE 你可能正在做某事。我运行了一些微基准测试:单独的 128 位 LDP 具有 2 个周期/指令的倒数吞吐量,128 位 SIMD MUL 也是如此。但是,交错 LDP 和 MUL 会导致大约 2.4 个周期/指令的倒数吞吐量,而我预计为 2。原则上this 不应该成为问题,因为它建议限制每 2 个周期写入 384 位,这正是 LDP/MUL 对写入的数量。
【解决方案2】:

从 Jake 的代码开始,将展开因子减少一半,更改了一些寄存器分配,并尝试了许多不同的加载/存储指令变体(以及不同的寻址模式)和指令调度,最终得出以下结论解决方案:

    ld1     {v16.4s, v17.4s, v18.4s, v19.4s}, [pSrc1], #64
    ld1     {v20.4s, v21.4s, v22.4s, v23.4s}, [pSrc2], #64

    add     count, pDst, count, lsl #2

    // initialize v0/v1

loop:
    smull   v24.2d, v20.2s, v16.2s
    smull2  v25.2d, v20.4s, v16.4s
    uzp1    v2.4s, v24.4s, v25.4s

    smull   v26.2d, v21.2s, v17.2s
    smull2  v27.2d, v21.4s, v17.4s
    uzp1    v3.4s, v26.4s, v27.4s

    smull   v28.2d, v22.2s, v18.2s
    smull2  v29.2d, v22.4s, v18.4s
    uzp1    v4.4s, v28.4s, v29.4s

    smull   v30.2d, v23.2s, v19.2s
    smull2  v31.2d, v23.4s, v19.4s
    uzp1    v5.4s, v30.4s, v31.4s

    mul     v2.4s, v2.4s, v0.4s
    ldp     q16, q17, [pSrc1]
    mul     v3.4s, v3.4s, v0.4s
    ldp     q18, q19, [pSrc1, #32]
    add     pSrc1, pSrc1, #64

    mul     v4.4s, v4.4s, v0.4s
    ldp     q20, q21, [pSrc2]
    mul     v5.4s, v5.4s, v0.4s
    ldp     q22, q23, [pSrc2, #32]
    add     pSrc2, pSrc2, #64

    smlal   v24.2d, v2.2s, v1.2s
    smlal2  v25.2d, v2.4s, v1.4s
    uzp2    v2.4s, v24.4s, v25.4s
    str     q24, [pDst], #16

    smlal   v26.2d, v3.2s, v1.2s
    smlal2  v27.2d, v3.4s, v1.4s
    uzp2    v3.4s, v26.4s, v27.4s
    str     q25, [pDst], #16

    smlal   v28.2d, v4.2s, v1.2s
    smlal2  v29.2d, v4.4s, v1.4s
    uzp2    v4.4s, v28.4s, v29.4s
    str     q26, [pDst], #16

    smlal   v30.2d, v5.2s, v1.2s
    smlal2  v31.2d, v5.4s, v1.4s
    uzp2    v5.4s, v30.4s, v31.4s
    str     q27, [pDst], #16

    cmp     count, pDst
    b.ne    loop

请注意,虽然我已经仔细查看了代码,但我还没有测试它是否真的有效,因此可能缺少一些会影响性能的东西。需要循环的最后一次迭代,删除加载指令,以防止越界内存访问;我省略了这个以节省一些空间。

对原始问题进行类似的分析,假设代码完全受吞吐量限制,则表明此循环需要 24 个周期。归一化为与其他地方使用的相同度量(即每个 4 元素迭代的周期),这将计算为 6 个周期/迭代。对代码进行基准测试,每次循环执行 26 个周期,或者在标准化指标中,6.5 个周期/迭代。虽然不是据称可以实现的最低限度,但它非常接近这一点。

在对 Cortex-A72 的性能摸不着头脑后,对于偶然发现这个问题的其他人的一些注意事项:

  1. 调度程序(预留站)是按端口而不是全局的(参见this 文章和this 框图)。除非您的代码在负载、存储、标量、Neon、分支等之间具有非常平衡的指令组合,否则 OoO 窗口将比您预期的要小,有时甚至非常小。这段代码尤其是每个端口调度程序的病态案例。因为 70% 的指令是 Neon,而 50% 的指令是乘法(仅在 F0 端口上运行)。对于这些乘法,OoO 窗口是非常贫乏的 8 条指令,因此不要指望 CPU 在执行当前迭代时会查看下一个循环迭代的指令。

  2. 尝试将展开因子进一步降低一半会导致大幅 (23%) 减速。我对原因的猜测是 OoO 窗口较浅,这是由于每个端口的调度程序和绑定到端口 F0 的指令的普遍性,如上文第 1 点所述。如果无法查看下一次迭代,则要提取的并行性较少,因此代码会受到延迟而非吞吐量的限制。因此,交叉循环的多次迭代似乎是该内核需要考虑的重要优化策略。

  3. 必须注意负载使用的特定寻址模式。用立即偏移量替换原始代码中使用的立即后索引寻址模式,然后在其他地方手动执行递增指针,导致性能提升,但仅限于加载(存储不受影响)。在优化手册的第 4.5 节(“加载/存储吞吐量”)中,这在内存复制例程的上下文中有所暗示,但没有给出理由。但是,我相信这可以通过下面的第 4 点来解释。

  4. 显然这段代码的主要瓶颈是写入寄存器文件:根据this对另一个SO问题的回答,寄存器文件仅支持每个周期写入192位。这可以解释为什么负载应该避免使用带写回(前和后索引)的寻址模式,因为这会消耗额外的 64 位将结果写回寄存器文件。在使用 Neon 指令和向量加载(使用LDPLD1 的 2/3/4 寄存器版本时更是如此)很容易超过这个限制,而不会增加写回递增地址的压力。知道了这一点,我还决定将 Jake 代码中的原始 subs 替换为与 pDst 的比较,因为比较不会写入寄存器文件——这实际上将性能提高了 1/4 个周期。

有趣的是,在一次循环执行期间将写入寄存器文件的位数加起来为 4992 位(我不知道写入 PC,特别是通过b.ne 指令,是否应该包含在计数中与否;我随意选择不这样做)。鉴于 192 位/周期的限制,这至少需要 26 个周期才能将所有这些结果通过循环写入寄存器文件。所以看来上面的代码不能仅仅通过重新调度指令来加快速度。

理论上,可以通过将存储的寻址模式切换为立即偏移量来减少 1 个周期,然后包含一条额外的指令来显式递增 pDst。对于原始代码,4 个存储中的每一个都将 64 位写入 pDst,总共 256 位,相比之下,如果 pDst 显式递增一次,则单个 64 位写入。因此,此更改将节省 192 位,即 1 个周期的寄存器文件写入。我尝试了这种更改,试图在代码的许多不同点上安排pSrc1/pSrc2/pDst 的增量,但不幸的是我只能放慢而不是加快代码的速度。也许我遇到了另一个瓶颈,例如指令解码。

【讨论】:

  • 程序计数器是特殊的,不会重命名到寄存器文件中的物理寄存器上。如果 OoO exec 需要回滚到那个点,或者类似的东西,ROB 中的每条指令都有一个 PC 值(以某种方式)。与数据寄存器值不同,在分支预测和获取/解码期间的前端很早就知道。 CPU 甚至没有理由将 PC 与数据寄存器一起保存在寄存器文件中。 (32 位 ARM 将 PC 作为 16 个 r0..r15 寄存器之一是不寻常的,但我猜现代实现只是每个 r15 引用的特殊情况。)
  • 您的 192 位限制链接适用于 Cortex A57。有理由认为 A72 没有改进吗?
  • @NateEldredge 你是对的,我正在使用 A57 和 A72,而这个细节却让我忽略了。但是,与我之前执行的基准相比,我倾向于相信没有任何变化。例如,我刚刚对包含 16 个add v0.4s, v1.4s, v2.4s 序列的循环进行了基准测试。鉴于 F0 和 F1 都可以执行向量加法,循环的每次迭代应该运行 8 个周期,但实际上它运行 11 个周期。 192 位的限制表明它应该花费 10.67 个周期——加上循环执行的 64 位减法的 1/3 个周期,正好是 11 个周期。
【解决方案3】:

我最近重新审视了这个问题,虽然 Jake 用 uzp1 替换 mul 的方法是一个明显的改进,但我仍然很好奇我是否可以使用原始方法更接近预期的 8 个周期/迭代。

我使用 C 和内在函数实现了一个 6 阶段的软件管道:

loadmul/smull/smull2mulsmlal/smlal2uzp2store

使用 clang 编译并使用 #pragma clang loop unroll_count(6) 作为主循环,我能够实现每次迭代约 8.9 个循环。尝试ldp/stpld1/st1 可能会产生更好的结果。

因此,如果大多数指令在有限数量的端口(甚至一个一个)。

【讨论】:

    猜你喜欢
    • 2021-12-11
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-07-28
    • 2014-07-26
    • 2012-07-16
    • 2019-04-11
    相关资源
    最近更新 更多