为什么当我的循环包含在一个缓存行中时它会快得多?
Why is my loop much faster when it is contained in one cache line?
当我 运行 我的 Ryzen 9 3900X 上的这个小汇编程序时:
_start: xor rax, rax
xor rcx, rcx
loop0: add rax, 1
mov rdx, rax
and rdx, 1
add rcx, rdx
cmp rcx, 1000000000
jne loop0
如果 loop0 到 jne 之间的所有指令都完全包含在一个缓存行中,则它会在 450 毫秒内完成。也就是说,如果:
round((loop0 的地址)/64) == round((jne 指令结束的地址)/64)
但是,如果上述条件不成立,则循环需要 900 毫秒。
我用代码 https://github.com/avl/strange_performance_repro 做了一个回购。
为什么在某些特定情况下内循环要慢得多?
编辑:从测试错误中删除了带有结论的声明。
您的问题在于 jne
指令的可变成本。
首先,要了解效果的影响,我们需要分析整个循环本身。 Ryzen 9 3900X的架构是Zen2。我们可以在 AMD website or also WikiChip 上检索有关此的信息。
该架构有 4 个 ALU 和 3 个 AGU。这大致意味着它每个周期最多可以执行 4 条指令,例如 add/and/cmp。
这是循环中每条指令的成本(基于 Zen1 的 Agner Fog instruction table):
# Reciprocal throughput
loop0: add rax, 1 # 0.25
mov rdx, rax # 0.2
and rdx, 1 # 0.25
add rcx, rdx # 0.25
cmp rcx, 1000000000 # 0.25 | Fused 0.5-2 (2 if jumping)
jne loop0 # 0.5-2 |
可以看到,循环的前4条计算指令可以在~1个周期内执行完。您的处理器可以将最后 2 条指令合并为更快的指令。
您的主要问题是,与循环的其余部分相比,最后一条 jne
指令可能非常慢。所以你很可能只测量这条指令的开销。从这里开始,事情开始变得复杂了。
过去几十年,工程师和研究人员努力工作,以(几乎)不惜任何代价降低此类指令的成本。如今,处理器(如 Ryzen 9 3900X)使用 乱序指令调度 来执行 [=11= 所需的 依赖指令 ] 尽快指示。大多数处理器还可以 预测 在 jne
和 获取 新指令之后执行的下一条指令的地址(例如,下一个循环迭代),而当前循环迭代的其他指令正在执行。
尽快执行提取对于防止处理器执行 管道 中的任何停顿很重要(尤其是在您的情况下)。
从AMD文档"Software Optimization Guide for AMD Family 17h Models 30h and Greater Processors"中,我们可以读到:
2.8.3 循环对齐:
For the processor loop alignment is not usually a significant issue. However, for hot loops, some further knowledge of trade-offs can be helpful. Since the processor can read an aligned 64-byte fetch block every cycle, aligning the end of the loop to the last byte of a 64-byte cache line is the best thing to do, if possible.
2.8.1.1 下一个地址逻辑
The next-address logic determines addresses for instruction fetch. [...]. When branches are identified, the next-address logic is redirected by the branch target and branch direction prediction hardware to generate a non-sequential fetch block address. The processor facilities that are designed to predict the next instruction to be executed following a branch are detailed in the following sections.
因此,对位于另一个缓存行中的指令执行条件分支会引入额外的延迟开销,这是由于 Op Cache 的提取(比 L1 更快的指令缓存)如果整个循环适合 1 个缓存行,则不需要。事实上,如果一个循环跨越缓存行,则需要 2 次行缓存提取,这需要不少于 2 个周期。如果整个循环适合缓存行,则只需要一次 1 行缓存提取,这只需要 1 个周期。结果,由于您的循环迭代非常快,因此额外支付 1 个循环会导致显着的减速。但是多少钱?
你说这个程序大约需要 450 毫秒。
由于 Ryzen 9 3900X turbo 频率为 4.6 GHz 并且您的循环执行了 2e9 次,因此每次循环迭代的周期数为 1.035。请注意,这比我们之前预期的要好(我猜这个处理器能够重命名寄存器,忽略 mov
指令,仅在 1 个周期内并行执行 jne
指令,而其他指令循环是完美的流水线;这是惊人的)。这也表明支付 一个额外的 1 个周期的获取开销将使执行每个循环迭代所需的周期数加倍,因此整体执行时间 .
如果您不想支付此开销,请考虑展开循环 以显着减少条件分支和非顺序提取的数量。
此问题可能发生在其他架构上,例如 Intel Skylake。事实上,i5-9600KF 上的相同循环在循环对齐时需要 0.70 秒,在没有循环对齐时需要 0.90 秒(也是由于额外的 1 个周期获取延迟)。展开 8 倍后,结果为 0.53 秒(无论对齐方式如何)。
当我 运行 我的 Ryzen 9 3900X 上的这个小汇编程序时:
_start: xor rax, rax
xor rcx, rcx
loop0: add rax, 1
mov rdx, rax
and rdx, 1
add rcx, rdx
cmp rcx, 1000000000
jne loop0
如果 loop0 到 jne 之间的所有指令都完全包含在一个缓存行中,则它会在 450 毫秒内完成。也就是说,如果:
round((loop0 的地址)/64) == round((jne 指令结束的地址)/64)
但是,如果上述条件不成立,则循环需要 900 毫秒。
我用代码 https://github.com/avl/strange_performance_repro 做了一个回购。
为什么在某些特定情况下内循环要慢得多?
编辑:从测试错误中删除了带有结论的声明。
您的问题在于 jne
指令的可变成本。
首先,要了解效果的影响,我们需要分析整个循环本身。 Ryzen 9 3900X的架构是Zen2。我们可以在 AMD website or also WikiChip 上检索有关此的信息。 该架构有 4 个 ALU 和 3 个 AGU。这大致意味着它每个周期最多可以执行 4 条指令,例如 add/and/cmp。
这是循环中每条指令的成本(基于 Zen1 的 Agner Fog instruction table):
# Reciprocal throughput
loop0: add rax, 1 # 0.25
mov rdx, rax # 0.2
and rdx, 1 # 0.25
add rcx, rdx # 0.25
cmp rcx, 1000000000 # 0.25 | Fused 0.5-2 (2 if jumping)
jne loop0 # 0.5-2 |
可以看到,循环的前4条计算指令可以在~1个周期内执行完。您的处理器可以将最后 2 条指令合并为更快的指令。
您的主要问题是,与循环的其余部分相比,最后一条 jne
指令可能非常慢。所以你很可能只测量这条指令的开销。从这里开始,事情开始变得复杂了。
过去几十年,工程师和研究人员努力工作,以(几乎)不惜任何代价降低此类指令的成本。如今,处理器(如 Ryzen 9 3900X)使用 乱序指令调度 来执行 [=11= 所需的 依赖指令 ] 尽快指示。大多数处理器还可以 预测 在 jne
和 获取 新指令之后执行的下一条指令的地址(例如,下一个循环迭代),而当前循环迭代的其他指令正在执行。
尽快执行提取对于防止处理器执行 管道 中的任何停顿很重要(尤其是在您的情况下)。
从AMD文档"Software Optimization Guide for AMD Family 17h Models 30h and Greater Processors"中,我们可以读到:
2.8.3 循环对齐:
For the processor loop alignment is not usually a significant issue. However, for hot loops, some further knowledge of trade-offs can be helpful. Since the processor can read an aligned 64-byte fetch block every cycle, aligning the end of the loop to the last byte of a 64-byte cache line is the best thing to do, if possible.
2.8.1.1 下一个地址逻辑
The next-address logic determines addresses for instruction fetch. [...]. When branches are identified, the next-address logic is redirected by the branch target and branch direction prediction hardware to generate a non-sequential fetch block address. The processor facilities that are designed to predict the next instruction to be executed following a branch are detailed in the following sections.
因此,对位于另一个缓存行中的指令执行条件分支会引入额外的延迟开销,这是由于 Op Cache 的提取(比 L1 更快的指令缓存)如果整个循环适合 1 个缓存行,则不需要。事实上,如果一个循环跨越缓存行,则需要 2 次行缓存提取,这需要不少于 2 个周期。如果整个循环适合缓存行,则只需要一次 1 行缓存提取,这只需要 1 个周期。结果,由于您的循环迭代非常快,因此额外支付 1 个循环会导致显着的减速。但是多少钱?
你说这个程序大约需要 450 毫秒。
由于 Ryzen 9 3900X turbo 频率为 4.6 GHz 并且您的循环执行了 2e9 次,因此每次循环迭代的周期数为 1.035。请注意,这比我们之前预期的要好(我猜这个处理器能够重命名寄存器,忽略 mov
指令,仅在 1 个周期内并行执行 jne
指令,而其他指令循环是完美的流水线;这是惊人的)。这也表明支付 一个额外的 1 个周期的获取开销将使执行每个循环迭代所需的周期数加倍,因此整体执行时间 .
如果您不想支付此开销,请考虑展开循环 以显着减少条件分支和非顺序提取的数量。
此问题可能发生在其他架构上,例如 Intel Skylake。事实上,i5-9600KF 上的相同循环在循环对齐时需要 0.70 秒,在没有循环对齐时需要 0.90 秒(也是由于额外的 1 个周期获取延迟)。展开 8 倍后,结果为 0.53 秒(无论对齐方式如何)。