优化规则:在链表/树等指针连接的数据结构中,将next或left/right指针放在对象的前16个字节。 malloc 通常返回 16 字节对齐的块 (alignof(maxalign_t)),因此这将确保链接指针与对象的开头位于同一页中。
确保重要结构成员与对象开头位于同一页面中的任何其他方式也可以工作。
Sandybridge 系列通常具有 5 个周期的 L1d 负载使用延迟,但对于使用 base+disp 寻址模式进行小正位移的指针追逐存在特殊情况。
当基本 reg 是 mov 加载的结果而不是 ALU 指令时,Sandybridge 系列对于 [reg + 0..2047] 寻址模式有 4 个周期的加载使用延迟。或者如果reg+disp 与reg 在不同的页面中,则会受到惩罚。
根据在 Haswell 和 Skylake 上的这些测试结果(可能还有原始的 SnB,但我们不知道),看来以下所有条件都必须为真:
-
base reg 来自另一个负载。 (指针追踪的粗略启发式,通常意味着加载延迟可能是 dep 链的一部分)。如果对象的分配通常不跨越页面边界,那么这是一个很好的启发式方法。 (硬件显然可以检测到输入是从哪个执行单元转发的。)
-
寻址模式为[reg] 或[reg+disp8/disp32]。 (Or an indexed load with an xor-zeroed index register! 通常没有实际用处,但可能会提供一些关于转换负载 uops 的问题/重命名阶段的见解。)
-
位移。即第 11 位以上的所有位都为零(HW 可以在没有全整数加法器/比较器的情况下检查的条件。)
-
(Skylake 但不是 Haswell/Broadwell):最后一次加载不是重试快速路径。 (所以 base = 4 或 5 个循环加载的结果,它将尝试快速路径。但 base = 10 个循环重试加载的结果,它不会。SKL 的惩罚似乎是 10,而 HSW 的惩罚是 9 )。
我不知道是否是在该加载端口上尝试的最后一次加载很重要,或者是否实际上是产生该输入的负载发生了什么。也许平行追逐两条深度链的实验可以提供一些启示;我只尝试了一个指针追逐 dep 链,其中混合了页面更改和非页面更改位移。
如果所有这些都是真的,加载端口推测最终的有效地址将与基址寄存器在同一页中。这是一个有用的优化负载使用延迟形成循环承载的 dep 链的真实案例,例如链表或二叉树。
微架构解释(我对解释结果的最佳猜测,而不是英特尔发布的任何内容):
索引 L1dTLB 似乎是 L1d 加载延迟的关键路径。提前开始 1 个周期(无需等待加法器的输出来计算最终地址)将使用地址的低 12 位索引 L1d 的整个过程减少一个周期,然后将该组中的 8 个标签与高位进行比较TLB 产生的物理地址位。 (Intel 的 L1d 是 VIPT 8-way 32kiB,所以它没有混叠问题,因为索引位都来自地址的低 12 位:在虚拟地址和物理地址中相同的页面内的偏移量。即低 12 位可免费从 virt 转换为 phy。)
由于我们没有发现跨越 64 字节边界的影响,我们知道加载端口在索引缓存之前添加了位移。
正如 Hadi 建议的那样,如果第 11 位有进位输出,加载端口可能会让错误的 TLB 加载完成,然后使用正常路径重做。 (在 HSW 上,总加载延迟 = 9。在 SKL 上,总加载延迟可以是 7.5 或 10)。
理论上,立即中止并在下一个周期重试(使其变为 5 或 6 个周期而不是 9 个)是可能的,但请记住,加载端口是流水线的,每个时钟吞吐量为 1 个。调度程序希望能够在下一个周期向加载端口发送另一个微指令,并且 Sandybridge 系列将延迟标准化为 5 个周期或更短的所有内容。 (没有 2 周期指令)。
我没有测试 2M 大页面是否有帮助,但可能没有。我认为 TLB 硬件足够简单,以至于它无法识别高 1 页的索引仍然会选择相同的条目。因此,只要位移越过 4k 边界,即使在同一个大页面中,它也可能会缓慢重试。 (页面拆分加载以这种方式工作:如果数据实际上跨越了 4k 边界(例如,从第 4 页加载 8 字节),您将支付页面拆分惩罚,而不仅仅是缓存行拆分惩罚,无论大页面如何)
Intel's optimization manual 在 2.4.5.2 L1 DCache 部分(在 Sandybridge 部分)中记录了这种特殊情况,但没有提及任何不同页面的限制,或者它仅用于指针的事实-chasing,当 dep 链中有 ALU 指令时不会发生。
(Sandybridge)
Table 2-21. Effect of Addressing Modes on Load Latency
-----------------------------------------------------------------------
Data Type | Base + Offset > 2048 | Base + Offset < 2048
| Base + Index [+ Offset] |
----------------------+--------------------------+----------------------
Integer | 5 | 4
MMX, SSE, 128-bit AVX | 6 | 5
X87 | 7 | 6
256-bit AVX | 7 | 7
(remember, 256-bit loads on SnB take 2 cycles in the load port, unlike on HSW/SKL)
此表周围的文字也没有提及 Haswell/Skylake 上存在的限制,SnB 上也可能存在(我不知道)。
也许 Sandybridge 没有这些限制并且英特尔没有记录 Haswell 回归,或者英特尔只是没有记录这些限制。该表非常明确地表明寻址模式始终为 4c 延迟,偏移量 = 0..2047。
@Harold 将 ALU 指令作为加载/使用指针跟踪依赖链的一部分的实验证实,正是这种影响导致了速度变慢:ALU insn 减少了总延迟,有效地给出了在这种特定的页面交叉情况下,当添加到 mov rdx, [rdx-8] dep 链时,类似 and rdx, rdx 负增量延迟的指令。
此答案中的先前猜测包括在 ALU 中使用负载 result 与另一个负载的建议是决定延迟的原因。那将是非常奇怪的,需要展望未来。这是对我将 ALU 指令添加到循环中的效果的错误解释。 (我不知道 9 周期对页面交叉的影响,并且认为 HW 机制是加载端口内结果的转发快速路径。这是有道理的。)
我们可以证明重要的是基本 reg 输入的来源,而不是加载结果的目的地:在页面边界之前和之后的 2 个不同位置存储相同的地址。创建一个 ALU => load => load 的 dep 链,并检查它是否是容易受到这种减速影响的第二个负载/能够通过简单的寻址模式从加速中受益。
%define off 16
lea rdi, [buf+4096 - 16]
mov [rdi], rdi
mov [rdi+off], rdi
mov ebp, 100000000
.loop:
and rdi, rdi
mov rdi, [rdi] ; base comes from AND
mov rdi, [rdi+off] ; base comes from a load
dec ebp
jnz .loop
... sys_exit_group(0)
section .bss
align 4096
buf: resb 4096*2
在 SKL i7-6700k 上使用 Linux perf 计时。
颠倒加载顺序(首先执行 [rdi+off] 加载),无论 off=8 或 off=16,它始终为 10c,因此我们已经证明 mov rdi, [rdi+off] 不会尝试推测性快速路径,如果它的输入来自 ALU 指令。
没有and 和off=8,我们得到预期的每迭代8c:两者都使用快速路径。 (@harold 确认 HSW 在这里也有 8 个)。
没有and 和off=16,我们每迭代得到15c:5+10。 mov rdi, [rdi+16] 尝试快速路径并失败,占用 10c。然后mov rdi, [rdi] 不会尝试快速路径,因为它的输入失败。 (@harold 的 HSW 在这里取 13:4 + 9。因此即使最后一个快速路径失败,HSW 也会尝试快速路径,并且快速路径失败惩罚实际上只有 9 HSW 与 SKL 10)
很遗憾,SKL 没有意识到没有位移的[base] 总是可以安全地使用快速路径。
在 SKL 上,只有 mov rdi, [rdi+16] 在循环中,平均延迟为 7.5 个周期。根据对其他混合的测试,我认为它在 5c 和 10c 之间交替:在没有尝试快速路径的 5c 负载之后,下一个尝试并失败,占用 10c。这使得下一次加载使用安全的 5c 路径。
在我们知道快速路径总是会失败的情况下,添加一个归零的索引寄存器实际上会加快速度。或者不使用基址寄存器,如[nosplit off + rdi*1],NASM 将其组装成48 8b 3c 3d 10 00 00 00 mov rdi,QWORD PTR [rdi*1+0x10]。请注意,这需要 disp32,因此对代码大小不利。
还要注意,微融合内存操作数的索引寻址模式在某些情况下是非分层的,而 base+disp 模式则不是。但是,如果您使用的是纯负载(如mov 或vbroadcastss),那么索引寻址模式本身并没有什么问题。不过,使用额外的归零寄存器并不是很好。
在 Ice Lake 上,这种用于指针追踪加载的特殊 4 周期快速路径已不复存在:在 L1 中命中的 GP 寄存器加载现在通常需要 5 个周期,不会因索引的存在或偏移量的大小而有所不同。