当前位置: 首页 > 知识库问答 >
问题:

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

戎永福
2023-03-14

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

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

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

因此,我的直觉是,这段代码应该是吞吐量受限的,并且考虑到F0上的最大压力,每次迭代运行8个周期(除非遇到解码瓶颈或缓存未命中)。考虑到流访问模式以及阵列完全适合L1缓存的事实,后一种情况不太可能出现。对于前者,考虑到优化手册第4.1节中列出的约束,我预计循环体仅在8个循环中可解码。

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

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

尽管有这些改进,但性能仍然与我仅从资源压力来看所期望的每次迭代的 8 个周期相去甚远。即使 CPU 进行了错误的调度调用并向 F0 发出 uzp2,修改资源压力表也会指示每次迭代 9 个周期,但仍与实际测量值相去甚远。那么,是什么原因导致此代码的运行速度比预期的要慢得多呢?我在分析中遗漏了哪些效果?

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

共有3个答案

臧梓
2023-03-14

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

我使用C和Intrinsic实现了一个6阶段的软件管道:

负载mul/smull/smull2 smlal/smlal2uzp2存储

使用clang进行编译,并在主循环中使用< code > # pragma clang loop unroll _ count(6),我能够实现每次迭代大约8.9个循环。尝试使用< code > LDP /< code > STP 和< code > ld1 /< code > st1 可能会产生更好的结果。

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

穆承运
2023-03-14

从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性能挠头之后偶然发现这个问题的人的一些注意事项:

> < li>

调度程序(保留站)是每个端口的,而不是全局的(请参见本文和此框图)。除非你的代码在加载、存储、标量、Neon、分支等方面有一个非常平衡的指令组合。,那么OoO窗口将比你预期的要小,有时非常小。这段代码对于每个端口的调度程序来说是一个病态的例子。因为70%的指令是Neon,50%的指令是乘法(只在F0端口上运行)。对于这些乘法,OoO窗口是一个非常贫血的8条指令,所以不要指望CPU在执行当前迭代时会查看下一个循环迭代的指令。

试图进一步将展开因子减半会导致速度大幅降低(23%)。我猜测原因是浅OoO窗口,这是由于每个端口的调度程序和绑定到端口F0的指令的高流行性,如上面第1点所解释的。如果不能预见下一次迭代,那么要提取的并行性就会减少,因此代码会受到延迟而不是吞吐量的限制。因此,交错循环的多次迭代似乎是该内核需要考虑的重要优化策略。

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

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

有趣的是,在循环的一次执行期间,将写入寄存器文件的位数加起来,结果是4992位(我不知道写入PC,特别是通过<code>b.ne

从理论上讲,通过将存储的寻址模式切换到立即偏移量,然后包括一条额外的指令来显式递增pDst,可以减少1个周期。对于原始代码,4个存储区中的每一个都将向pDst写入64位,总计256位,而如果pDst,则只写入一次64位。因此,此更改将导致节省192位,即相当于1个周期的寄存器文件写入。我尝试了这个更改,试图在代码的许多不同点上调度<code>pSrc1/<code>pSrc2/<code>pDst

范安歌
2023-03-14

首先,您可以通过将第一个 mul 替换为 uzp1,并反过来执行以下 smullsmlal 来进一步将理论周期减少到 6:mulmulsmullsmlal =

您不需要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,那么芯片或文档一定有问题。< br >否则,A72的OoO引擎可能会像Peter指出的那样缺乏光泽。

PS:或者加载/存储端口可能没有在A72上并行发出。考虑到你的展开实验,这是有意义的。

 类似资料:
  • 我已经创建了一个集,只是与循环计数和斜坡周期混淆。我有一个具有以下参数的测试集。 根据Quora上的页面。

  • 假设我有一些账单,上面有开始日期和结束日期。 我要检查的商业规则是 例如,3月10日到4月9日大约相隔一个月,所以我用它来检查任何两个连续的账单开始日期(4月10日和3月10日)是否相隔一个月。 现在我的问题是求周期的长度。例如,假设我有以下数据集 我正在使用JodaTime库,所以我说类似这样的话 它返回0,这是正确的,但没有用处。 结果是1,尽管相隔一天。 有什么更好的方法来做到这一点?我可以

  • 执行周期性任务也是一样简单,您只需要编写一行代码: RecurringJob.AddOrUpdate(() => Console.Write("Easy!"), Cron.Daily); 此行在持久存储中创建一个新实体。 Hangfire Server中的一个特殊组件(请参阅 处理后台任务) 以分钟为间隔检查周期性任务,然后在队列中将其视作 fire-and-forget 任务。这样就可以照常跟踪

  • 下午好!我使用以下堆栈进行实现自动化测试:Java8,Maven,Jenkins用于测试的自动化执行。有时(不是每次,大约占所有执行的3-5%)我会在并行执行过程中遇到测试问题。并行执行由Jenkins和Jenkins文件提供。jenkins文件的示例结构: null 我很绝望,不知道还能做什么。也许您在jenkins中的并行执行过程中也遇到过同样的问题(单个测试执行总是成功完成)。谢谢你,祝你有

  • 可以通过设置任务详情页中的周期,实现建立周期性任务。比如“周例会”

  • 为了澄清所使用的输入是100,用于投资金额,利率为5%(在该程序中,取5/100,然后/12=0.00417),在这种情况下,投资的持续时间为6个月。因此,在任何利息累积之前的第0个月,没有利息,所以这只是投资的投入金额(100)。然后,在第一个月,它现在运行正常((100*i)*(i interest());或在或情况下((100*1)*(10.00417))=100.417 但当我到了第二个月