在 ARM Cortex-A72 CPU 中,循环需要比预期更多的周期来执行

Loop takes more cycles to execute than expected in an ARM Cortex-A72 CPU

考虑以下代码,运行在 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 个周期。如果不存在其他合理的解释,我可能会编辑问题,包括有关我如何对这段代码进行基准测试的更多细节,但我相当肯定这种差异不能仅归因于基准测试工件。此外,我尝试增加迭代次数以查看性能是否由于 startup/cool-down 效应而朝着渐近极限提高,但对于上面显示的 128 次迭代的选定值,它似乎已经这样做了。

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

尽管有所改进,但性能与我仅考虑资源压力时预期的每次迭代 8 个周期相差甚远。即使 CPU 进行了错误的调度调用并向 F0 发出 uzp2,修改资源压力 table 将表明每次迭代有 9 个周期,与实际测量值仍然相去甚远。那么,是什么导致此代码 运行 比预期慢得多?我在分析中遗漏了哪些影响?

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

首先,您可以通过将第一个 mul 替换为 uzp1 并以另一种方式执行以下 smullsmlal 来进一步将理论循环减少到 6周围:mulmulsmullsmlal => smulluzp1mulsmlal 这也大大降低了寄存器压力,以便我们可以进行更深入的展开(每次迭代最多 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:或者 load/store 端口可能未在 A72 上并行发布。考虑到您展开的实验,这是有道理的。

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

    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

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

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

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

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

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

  3. 一定要注意加载使用的具体寻址方式。将原始代码中使用的立即 post-index 寻址模式替换为立即偏移,然后在其他地方手动执行递增指针,导致性能提升,但仅针对加载(存储不受影响)。在优化手册的第 4.5 节(“Load/Store 吞吐量”)中,这在内存复制例程的上下文中有所暗示,但没有给出基本原理。但是,我相信这可以在下面的第 4 点中得到解释。

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

有趣的是,将一次循环执行期间写入寄存器文件的位数相加得到 4992 位(我不知道是否写入 PC,特别是 b.ne 指令,应该是是否包含在统计中;我随意选择不包含)。鉴于 192-bit/cycle 的限制,这至少需要 26 个周期才能通过循环将所有这些结果写入寄存器文件。因此,上面的代码似乎不能仅通过重新安排指令来提高速度。

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

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

我使用 C 和内部函数实现了 6 级软件管道:

loadmul/smull/smull2mulsmlal/smlal2uzp2store

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

因此,软件流水线可能是在 Cortex-A72 中探索的有利可图的途径,以克服由于每个端口调度队列导致的短 OOO window,如果大多数指令在有限数量上执行端口(甚至一个)。