将整个函数(或函数的热门部分,即通过它的快速路径)保留在较少的缓存行中可以减少 I-cache 占用空间。因此,它可以减少缓存未命中的次数,包括在启动时大部分缓存未命中的情况。在缓存行结束之前循环结束可以给硬件预取时间来获取下一个缓存行。
访问 L1i 缓存中存在的任何行都需要花费相同的时间。 (除非你的缓存使用路径预测:这引入了“缓慢打击”的可能性。看这些幻灯片 https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-823-computer-system-architecture-fall-2005/lecture-notes/l08_caches_2.pdf提及并简要描述这个想法。显然MIPS r10k的L2缓存使用了它,所以也是如此阿尔法21264 https://www.csee.umbc.edu/portal/help/architecture/alpha21264a.pdf的L1指令缓存在其 2 路关联 64kiB L1i 中使用“分支目标”与“顺序”方式。或者查看谷歌搜索时出现的任何学术论文cache way prediction
就像我一样。)
除此之外,其影响并不在于缓存行边界,而在于超标量 CPU 中对齐的取指令块。你是对的,影响不是来自你正在考虑的事情。
See 现代微处理器
90 分钟指南! http://www.lighterra.com/papers/modernmicroprocessors/超标量(和乱序)执行的介绍。
Many superscalar CPUs do their first stage of instruction fetch using aligned accesses to their I-cache. Lets simplify by considering a RISC ISA with 4-byte instruction width1 and 4-wide fetch/decode/exec. (e.g. MIPS r10k, although IDK if some of the other stuff I'm going to make up reflects that microarch exactly).
...
.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 找到并利用更多指令级并行性purely取指令遇到瓶颈。
一般来说,调整分支目标(包括函数入口点)乘以 16 可能会获胜(代价是较低的代码密度带来更大的 I-cache 压力)。如果您在 1 或 2 条指令之内,则一个有用的权衡可以是填充到下一个 16 的倍数。例如因此在最坏的情况下,一个读取块至少包含 2 或 3 条有用的指令,而不仅仅是 1 条。
这就是 GNU 汇编器支持的原因.p2align 4,,8 https://sourceware.org/binutils/docs/as/P2align.html#P2align:如果距离 8 个字节或更近,则填充到下一个 2^4 边界。事实上,GCC 确实针对某些目标/架构使用该指令,具体取决于调整选项/默认值。
在非循环分支的一般情况下,您也不希望跳转到缓存行末尾附近。那么您可能会立即遇到另一个 I-cache 未命中。
脚注1:
该原理也适用于具有可变宽度指令的现代 x86,至少当它们具有解码的 uop 缓存未命中迫使它们实际从 L1I 缓存获取 x86 机器代码时。并且适用于较旧的超标量 x86,如 Pentium III 或 K8,没有 uop 缓存或环回缓冲区(无论对齐如何,都可以使循环高效)。
但 x86 解码非常困难,需要多个管道阶段,例如对一些简单的find指令边界,然后将指令组馈送到解码器。如果预解码可以赶上,则仅初始提取块对齐,并且阶段之间的缓冲区可以隐藏解码器中的气泡。
https://www.realworldtech.com/merom/4/ https://www.realworldtech.com/merom/4/显示了 Core2 前端的详细信息:16 字节读取块,与 PPro/PII/PIII 相同,提供预解码阶段,可扫描最多 32 字节并查找最多 6 个指令 IIRC 之间的边界。然后,将另一个缓冲区提供给完整的解码阶段,该阶段可以将最多 4 条指令(5 条带有 test 或 cmp + jcc 的宏融合)解码为最多 7 个 uops...
Agner Fog 的微架构指南 https://agner.org/optimize/有一些关于优化 x86 asm 以解决 Pentium Pro/II 与 Core2 / Nehalem 与 Sandybridge 系列以及 AMD K8/K10 与 Bulldozer 与 Ryzen 上的读取/解码瓶颈的详细信息。
现代 x86 并不总是受益于对齐。代码对齐会产生一些影响,但它们通常并不简单,而且并不总是有益的。事物的相对对齐可能很重要,但通常对于诸如哪些分支在分支预测器条目中彼此别名,或者微指令如何打包到微指令缓存之类的事情而言。