为什么指令高速缓存对齐可以提高集合关联高速缓存实现的性能?
Why does instruction cache alignment improve performance in set associative cache implementations?
我有一个关于指令缓存对齐的问题。我听说对于微优化,对齐循环以便它们适合缓存行可以略微提高性能。我不明白为什么那会做任何事情。
我了解缓存命中的概念及其对计算速度的重要性。
但似乎在set associative caches中,相邻的代码块不会映射到同一个cache set。因此,如果循环跨越代码块,CPU 仍应获得缓存命中,因为相邻块尚未被前一个块的执行驱逐。两个块都可能在循环期间保持缓存状态。
所以我能想到的是,对齐可以提供帮助的说法是否属实,那一定是来自某种其他效果。
切换缓存行是否有成本?
缓存命中是否存在差异,一种是命中,另一种是命中当前正在读取的同一缓存行?
将整个函数(或函数的热部分,即通过它的快速路径)保留在较少的缓存行中可减少 I-cache 占用空间。因此它可以减少缓存未命中的次数,包括在启动时大部分缓存都处于冷状态时。在缓存行结束之前有一个循环结束可以给硬件预取时间来获取下一个。
访问 L1i 缓存中存在的任何行都需要相同的时间。 (除非您的缓存使用 way-prediction:这引入了 "slow hit" 的可能性。参见 these slides for a mention and brief description of the idea. Apparently MIPS r10k's L2 cache used it, and so did Alpha 21264 的 L1 指令缓存 和 "branch target" vs. "sequential" 方式在其 2-way associative 64kiB L1i 中。或者看看当你像我一样 google cache way prediction
时出现的任何学术论文。)
除此之外,缓存行边界的影响并不大,而是超标量 CPUs 中的 对齐指令获取块。你是对的,影响不是来自你正在考虑的事情。
参见现代微处理器
90 分钟指南! 介绍超标量(和乱序)执行。
许多超标量 CPU 使用对齐访问其 I-cache 来执行指令获取的第一阶段。让我们通过考虑具有 4 字节指令宽度 1 和 4 字节宽度 fetch/decode/exec 的 RISC ISA 来简化。 (例如 MIPS r10k,尽管 IDK 如果我要弥补的其他一些东西准确地反映了那个微架构)。
...
.top_of_loop:
insn1 ; at address 16*n + 12
; 16-byte boundary here
insn2 ; at address 16*n + 0
insn3 ; at address 16*n + 4
b .top_of_loop ; at address 16*n + 8
... after loop ; at address 16*n + 12
... after loop ; at address 16*n + 0
没有任何类型的循环缓冲区,获取阶段必须在每次执行时从 I-cache 中获取循环指令。但这每次迭代至少需要 2 个周期,因为循环跨越两个 16 字节对齐的提取块。它无法在一次未对齐的提取中提取 16 字节的指令。
但是如果我们对齐循环的顶部,它可以在一个循环中获取,如果循环体没有其他瓶颈,允许循环在 1 个循环/迭代中 运行。
...
nop ; at address 16*n + 12 ; NOP padding for alignment
.top_of_loop: ; 16-byte boundary here
insn1 ; at address 16*n + 0
insn2 ; at address 16*n + 4
insn3 ; at address 16*n + 8
b .top_of_loop ; at address 16*n + 12
... after loop ; at address 16*n + 0
... after loop ; at address 16*n + 4
对于不是 4 条指令的倍数的更大循环,仍然会在某处进行部分浪费的提取。不过,通常最好不要在循环的顶部。尽早将更多指令引入流水线有助于 CPU 找到并利用更多的指令级并行性,因为代码并非 纯粹 在指令获取上存在瓶颈。
一般来说,将分支目标(包括函数入口点)对齐 16 可能是一个胜利(代价是较低的代码密度带来更大的 I-cache 压力)。如果您在 1 或 2 条指令之内,可以进行有用的权衡,填充到 16 的下一个倍数。例如所以在最坏的情况下,一个获取块至少包含 2 或 3 条有用的指令,而不仅仅是 1 条。
这就是 GNU 汇编程序支持 .p2align 4,,8
的原因:填充到下一个 2^4 边界(如果距离或更近 8 个字节)。 GCC 实际上会根据调整选项/默认值对某些目标/体系结构使用该指令。
在非循环分支的一般情况下,您也不希望在缓存行末尾附近跳转。那么你可能马上就会有另一个 I-cache 未命中。
脚注 1:
该原则也适用于具有可变宽度指令的现代 x86,至少当它们有解码的 uop 缓存未命中迫使它们实际从 L1I 缓存中获取 x86 机器代码时。并适用于较旧的超标量 x86,如 Pentium III 或 K8,没有 uop 缓存或环回缓冲区(无论对齐如何,都可以使循环高效)。
但是 x86 解码非常困难,需要多个流水线阶段,例如到一些简单的 find 指令边界,然后将指令组提供给解码器。如果预解码可以赶上,只有初始的提取块是对齐的,阶段之间的缓冲区可以隐藏解码器中的气泡。
https://www.realworldtech.com/merom/4/ 显示了 Core2 前端的详细信息:16 字节的提取块,与 PPro/PII/PIII 相同,提供了一个预解码阶段,该阶段最多可以扫描 32 个字节并找到它们之间的边界最多 6 条指令 IIRC。然后将另一个缓冲区提供给完整的解码阶段,该阶段可以将最多 4 条指令(5 条指令与测试或 cmp + jcc 的宏融合)解码为最多 7 uops...
Agner Fog's microarch guide 有一些关于针对 Pentium Pro/II vs. Core2 / Nehalem vs. Sandybridge-family 和 AMD K8/K10 上的 fetch/decode 瓶颈优化 x86 asm 的一些详细信息vs. Bulldozer vs. Ryzen.
现代 x86 并不总是受益于对齐。代码对齐会产生一些影响,但它们通常并不简单,而且并不总是有益的。事物的相对对齐可能很重要,但通常对于分支预测器条目中哪些分支相互别名,或者 uops 如何打包到 uop 缓存中。
我有一个关于指令缓存对齐的问题。我听说对于微优化,对齐循环以便它们适合缓存行可以略微提高性能。我不明白为什么那会做任何事情。
我了解缓存命中的概念及其对计算速度的重要性。
但似乎在set associative caches中,相邻的代码块不会映射到同一个cache set。因此,如果循环跨越代码块,CPU 仍应获得缓存命中,因为相邻块尚未被前一个块的执行驱逐。两个块都可能在循环期间保持缓存状态。
所以我能想到的是,对齐可以提供帮助的说法是否属实,那一定是来自某种其他效果。
切换缓存行是否有成本?
缓存命中是否存在差异,一种是命中,另一种是命中当前正在读取的同一缓存行?
将整个函数(或函数的热部分,即通过它的快速路径)保留在较少的缓存行中可减少 I-cache 占用空间。因此它可以减少缓存未命中的次数,包括在启动时大部分缓存都处于冷状态时。在缓存行结束之前有一个循环结束可以给硬件预取时间来获取下一个。
访问 L1i 缓存中存在的任何行都需要相同的时间。 (除非您的缓存使用 way-prediction:这引入了 "slow hit" 的可能性。参见 these slides for a mention and brief description of the idea. Apparently MIPS r10k's L2 cache used it, and so did Alpha 21264 的 L1 指令缓存 和 "branch target" vs. "sequential" 方式在其 2-way associative 64kiB L1i 中。或者看看当你像我一样 google cache way prediction
时出现的任何学术论文。)
除此之外,缓存行边界的影响并不大,而是超标量 CPUs 中的 对齐指令获取块。你是对的,影响不是来自你正在考虑的事情。
参见现代微处理器 90 分钟指南! 介绍超标量(和乱序)执行。
许多超标量 CPU 使用对齐访问其 I-cache 来执行指令获取的第一阶段。让我们通过考虑具有 4 字节指令宽度 1 和 4 字节宽度 fetch/decode/exec 的 RISC ISA 来简化。 (例如 MIPS r10k,尽管 IDK 如果我要弥补的其他一些东西准确地反映了那个微架构)。
...
.top_of_loop:
insn1 ; at address 16*n + 12
; 16-byte boundary here
insn2 ; at address 16*n + 0
insn3 ; at address 16*n + 4
b .top_of_loop ; at address 16*n + 8
... after loop ; at address 16*n + 12
... after loop ; at address 16*n + 0
没有任何类型的循环缓冲区,获取阶段必须在每次执行时从 I-cache 中获取循环指令。但这每次迭代至少需要 2 个周期,因为循环跨越两个 16 字节对齐的提取块。它无法在一次未对齐的提取中提取 16 字节的指令。
但是如果我们对齐循环的顶部,它可以在一个循环中获取,如果循环体没有其他瓶颈,允许循环在 1 个循环/迭代中 运行。
...
nop ; at address 16*n + 12 ; NOP padding for alignment
.top_of_loop: ; 16-byte boundary here
insn1 ; at address 16*n + 0
insn2 ; at address 16*n + 4
insn3 ; at address 16*n + 8
b .top_of_loop ; at address 16*n + 12
... after loop ; at address 16*n + 0
... after loop ; at address 16*n + 4
对于不是 4 条指令的倍数的更大循环,仍然会在某处进行部分浪费的提取。不过,通常最好不要在循环的顶部。尽早将更多指令引入流水线有助于 CPU 找到并利用更多的指令级并行性,因为代码并非 纯粹 在指令获取上存在瓶颈。
一般来说,将分支目标(包括函数入口点)对齐 16 可能是一个胜利(代价是较低的代码密度带来更大的 I-cache 压力)。如果您在 1 或 2 条指令之内,可以进行有用的权衡,填充到 16 的下一个倍数。例如所以在最坏的情况下,一个获取块至少包含 2 或 3 条有用的指令,而不仅仅是 1 条。
这就是 GNU 汇编程序支持 .p2align 4,,8
的原因:填充到下一个 2^4 边界(如果距离或更近 8 个字节)。 GCC 实际上会根据调整选项/默认值对某些目标/体系结构使用该指令。
在非循环分支的一般情况下,您也不希望在缓存行末尾附近跳转。那么你可能马上就会有另一个 I-cache 未命中。
脚注 1:
该原则也适用于具有可变宽度指令的现代 x86,至少当它们有解码的 uop 缓存未命中迫使它们实际从 L1I 缓存中获取 x86 机器代码时。并适用于较旧的超标量 x86,如 Pentium III 或 K8,没有 uop 缓存或环回缓冲区(无论对齐如何,都可以使循环高效)。
但是 x86 解码非常困难,需要多个流水线阶段,例如到一些简单的 find 指令边界,然后将指令组提供给解码器。如果预解码可以赶上,只有初始的提取块是对齐的,阶段之间的缓冲区可以隐藏解码器中的气泡。
https://www.realworldtech.com/merom/4/ 显示了 Core2 前端的详细信息:16 字节的提取块,与 PPro/PII/PIII 相同,提供了一个预解码阶段,该阶段最多可以扫描 32 个字节并找到它们之间的边界最多 6 条指令 IIRC。然后将另一个缓冲区提供给完整的解码阶段,该阶段可以将最多 4 条指令(5 条指令与测试或 cmp + jcc 的宏融合)解码为最多 7 uops...
Agner Fog's microarch guide 有一些关于针对 Pentium Pro/II vs. Core2 / Nehalem vs. Sandybridge-family 和 AMD K8/K10 上的 fetch/decode 瓶颈优化 x86 asm 的一些详细信息vs. Bulldozer vs. Ryzen.
现代 x86 并不总是受益于对齐。代码对齐会产生一些影响,但它们通常并不简单,而且并不总是有益的。事物的相对对齐可能很重要,但通常对于分支预测器条目中哪些分支相互别名,或者 uops 如何打包到 uop 缓存中。