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

swi*_*one 7 optimization performance assembly arm neon

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

操作说明 Ⅰ0 Ⅰ1 中号 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
端口总压力 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 个周期。进一步将循环展开为每次迭代四次计算,同时使用四寄存器格式LD1/ ST1,将性能提高到每次迭代 11.25 个周期。

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

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

Jak*_*LEE 5

首先,您可以通过将第一个替换muluzp1并执行以下操作smullsmlal相反的操作,进一步将理论周期减少到 6 个:mul, mul, smull, 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
Run Code Online (Sandbox Code Playgroud)

上面的代码即使按顺序也具有零延迟。唯一可能影响性能的是缓存未命中损失。

你可以测量一下循环次数,如果每次迭代远远超过48次,那么芯片或者文档肯定有问题。
否则,A72 的 OoO 发动机可能会像 Peter 指出的那样黯淡无光。

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


swi*_*one 3

从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
Run Code Online (Sandbox Code Playgroud)

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

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

对于其他在对 Cortex-A72 性能摸不着头脑后偶然发现这个问题的人,请注意以下几点:

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

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

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

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

有趣的是,在一次循环执行期间将写入寄存器文件的位数相加,结果为 4992 位(我不知道写入 PC,特别是通过指令b.ne,是否应包含在计数中;我任意选择不)。考虑到 192 位/周期限制,至少需要 26 个周期才能跨循环将所有这些结果写入寄存器文件。因此,似乎仅通过重新安排指令无法使上面的代码变得更快。

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

  • @NateEldredge,你是对的,我正在使用 A57 和 A72,但这个细节我没有注意到。然而,我倾向于相信,与我之前执行的基准相比,没有任何变化。例如,我刚刚对一个包含 16 个“add v0.4s、v1.4s、v2.4s”序列的循环进行了基准测试。鉴于 F0 和 F1 都可以执行向量加法,循环的每次迭代应该运行 8 个周期,但实际上它运行 11 个周期。192 位限制表明它应该需要 10.67 个周期 - 加上循环执行的 64 位减法的 1/3 周期,正好是 11 个周期。 (3认同)