当 base+offset 与 base 位于不同的页面时是否会有惩罚?
Is there a penalty when base+offset is in a different page than the base?
这三个片段的执行时间:
pageboundary: dq (pageboundary + 8)
...
mov rdx, [rel pageboundary]
.loop:
mov rdx, [rdx - 8]
sub ecx, 1
jnz .loop
还有这个:
pageboundary: dq (pageboundary - 8)
...
mov rdx, [rel pageboundary]
.loop:
mov rdx, [rdx + 8]
sub ecx, 1
jnz .loop
还有这个:
pageboundary: dq (pageboundary - 4096)
...
mov rdx, [rel pageboundary]
.loop:
mov rdx, [rdx + 4096]
sub ecx, 1
jnz .loop
在 4770K 上,第一个片段每次迭代大约 5 个周期,第二个片段每次迭代大约 9 个周期,然后第三个片段每次迭代 5 个周期。它们都访问完全相同的地址,该地址是 4K 对齐的。在第二个片段中,只有地址 calculation 越过页面边界: rdx
和 rdx + 8
不属于同一页面,负载仍然对齐。有了很大的偏移量,它又回到了 5 个周期。
这种效果一般如何发挥作用?
通过这样的 ALU 指令路由加载结果:
.loop:
mov rdx, [rdx + 8]
or rdx, 0
sub ecx, 1
jnz .loop
使每次迭代需要 6 个周期,这相当于 5+1。 Reg+8 应该是一个特殊的快速加载并且 AFAIK 需要 4 个周期,所以即使在这种情况下似乎也有一些惩罚,但只有 1 个周期。
为了回应一些评论,使用了这样的测试:
.loop:
lfence
; or rdx, 0
mov rdx, [rdx + 8]
; or rdx, 0
; uncomment one of the ORs
lfence
sub ecx, 1
jnz .loop
将 or
放在 mov
之前使循环比没有任何 or
更快,将 or
放在 mov
之后使其成为一个循环较慢。
优化规则:在链表/树等指针连接的数据结构中,将next
或left
/right
指针放在对象的前16个字节中。 malloc
通常 returns 16 字节对齐块 (alignof(maxalign_t)
),因此这将确保链接指针与对象的开头位于同一页中。
确保重要的结构成员与对象的开头位于同一页中的任何其他方法也都有效。
Sandybridge-family 通常有 5 个周期的 L1d 加载使用延迟,但有一个特殊情况,用于使用 base+disp 寻址模式的小 positive 位移的指针追逐。
Sandybridge 系列在 [reg + 0..2047]
寻址模式下有 4 个周期的加载使用延迟,当基本寄存器是 mov
加载的结果时,而不是 ALU 指令。或者,如果 reg+disp
与 reg
在不同的页面中,则会受到惩罚。
根据 Haswell 和 Skylake(可能还有原始 SnB 但我们不知道)的这些测试结果,看来以下所有条件都必须为真:
base reg 来自另一个负载。 (指针追踪的粗略启发式,通常意味着加载延迟可能是 dep 链的一部分)。如果对象的分配通常不跨越页面边界,那么这是一个很好的启发式方法。 (硬件显然可以检测到输入是从哪个执行单元转发的。)
寻址方式为[reg]
或[reg+disp8/disp32]
。 (Or an indexed load with an xor-zeroed index register! 通常没有实际用处,但可能会提供对 issue/rename 阶段转换负载 uops 的一些见解。)
位移<2048。即,第 11 位以上的所有位均为零(硬件可以在没有完整整数的情况下检查的条件 adder/comparator。)
(Skylake 但不是 Haswell/Broadwell):上次加载不是重试快速路径。 (所以 base = 4 或 5 个循环负载的结果,它将尝试快速路径。但是 base = 10 个循环重试负载的结果,它不会。SKL 的惩罚似乎是 10,而 HSW 是 9 ).
我不知道是否是在该加载端口上尝试的最后一次加载才是重要的,或者它是否实际上是产生该输入的负载发生的事情。或许并行追踪两个 dep 链的实验可以提供一些启示;我只尝试了一个指针追逐 dep 链,混合了页面更改和非页面更改位移。
如果所有这些都是真的,加载端口推测最终有效地址将与基址寄存器在同一页中。 这在实际情况下是一个有用的优化,当加载使用延迟形成一个循环携带的 dep 链时,比如链表或二叉树。
微架构解释(我对解释结果的最佳猜测,并非英特尔发布的任何内容):
似乎索引 L1dTLB 是 L1d 加载延迟的关键路径。提前开始那个 1 个周期(不等待加法器的输出来计算最终地址)将使用地址的低 12 位索引 L1d 的整个过程缩短一个周期,然后将该组中的 8 个标签与高位进行比较TLB 产生的物理地址的位。 (Intel 的 L1d 是 VIPT 8-way 32kiB,所以它没有别名问题,因为索引位全部来自地址的低 12 位:页面内的偏移量,在虚拟地址和物理地址中都是相同的。即低 12 位免费从 virt 转换为 phys。)
由于我们没有发现跨越 64 字节边界的效果,我们知道加载端口在索引缓存之前添加位移。
正如 Hadi 所建议的,如果第 11 位有进位,加载端口可能会让错误的 TLB 加载完成,然后使用正常路径重做。 (在 HSW 上,总加载延迟 = 9。在 SKL 上,总加载延迟可以是 7.5 或 10)。
立即中止并在下一个周期重试(使其成为 5 或 6 个周期而不是 9 个)在理论上是可能的,但请记住,加载端口以每个时钟吞吐量 1 个流水线传输。调度程序期望能够在下一个周期向加载端口发送另一个 uop,而 Sandybridge 系列将延迟标准化为 5 个周期或更短。 (没有 2 周期指令)。
我没有测试 2M 大页面是否有帮助,但可能没有。我认为 TLB 硬件非常简单,它无法识别高出 1 页的索引仍会选择相同的条目。因此,只要位移超过 4k 边界,它就可能会进行慢速重试,即使那是在同一个大页面中。 (页面拆分加载以这种方式工作:如果数据实际上跨越了 4k 边界(例如,从页面 4 加载 8 字节),则无论大页面如何,您都将支付页面拆分惩罚而不仅仅是缓存行拆分惩罚)
Intel's optimization manual 在 2.4.5.2 L1 DCache 部分(在 Sandybridge 部分)记录了这种特殊情况,但没有提及任何不同的页面限制,或者事实上,它仅用于指针追逐,当 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)
关于这个 table 的文本也没有提到 Haswell/Skylake 上存在的限制,并且可能也存在于 SnB 上(我不知道)。
也许 Sandybridge 没有这些限制并且英特尔没有记录 Haswell 回归,或者英特尔一开始就没有记录这些限制。 table 非常确定寻址模式始终是 4c 延迟,偏移量 = 0..2047.
@Harold 将 ALU 指令作为 load/use 指针追逐依赖链的一部分的实验 证实正是这种影响导致了速度下降:一个 ALU insn减少了总延迟,在这种特定的页面交叉情况下,当添加到 mov rdx, [rdx-8]
dep 链时,有效地给出了像 and rdx, rdx
负增量延迟这样的指令。
此答案中的先前猜测包括以下建议:在 ALU 中使用负载 result 与另一个负载是决定延迟的因素。那将是非常奇怪的,需要展望未来。这是我对将 ALU 指令添加到循环中的影响的错误解释。 (之前不知道跨页的9-cycle效应,还以为HW机制是load port内部结果的转发快道,说得通。)
我们可以证明重要的是基本 reg 输入的来源,而不是加载结果的目的地:在前后两个不同的位置存储相同的地址页边界。创建一个 ALU => 加载 => 加载的 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
同步。
off = 8
,推测是正确的,我们得到总延迟 = 10 个周期 = 1 + 5 + 4。(每次迭代 10 个周期)。
off = 16
,[rdi+off]
load 很慢,我们得到 16 cycles / iter = 1 + 5 + 10。(惩罚似乎在 SKL 上比高铁)
加载顺序相反(首先执行[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 确实尝试了快速路径,即使最后一个快速路径失败了,而且快速路径失败惩罚真的在 HSW 上只有 9 而在 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 个周期,根据索引的存在或偏移量的大小没有区别.
我已经在 Haswell 上进行了足够多的实验,以便在完全计算有效地址之前准确地确定何时推测性地发出内存加载。这些结果也证实了Peter的猜测。
我改变了以下参数:
- 与
pageboundary
的偏移量。 pageboundary
和加载指令的定义中使用的偏移量相同。
- 偏移量的符号是+或-。定义中使用的符号始终与加载指令中使用的符号相反。
- 可执行二进制文件中
pageboundary
的对齐方式。
在以下所有图表中,Y 轴代表内核周期中的加载延迟。 X轴表示NS1S2形式的配置,其中N是偏移量,S1是定义中使用的偏移量的符号,S2是加载指令中使用的符号。
下图显示只有当偏移量为正或零时,加载才会在计算有效地址之前发出。请注意,对于0-15之间的所有偏移量,加载指令中使用的基地址和有效地址都在同一4K页内。
下图显示了此模式发生变化的点。变化发生在偏移量 213 处,这是加载指令中使用的基地址和有效地址都在不同 4K 页面内的最小偏移量。
从前两个图中可以得出的另一个重要观察结果是,即使基地址指向与有效地址不同的缓存集,也不会产生任何惩罚。所以好像是计算出有效地址后才打开cache set。这说明L1 DTLB hit latency是2个cycle(即L1D接收tag需要2个cycle),但是打开cache的数据数组集和cache的tag数组集只需要1个cycle(发生并行)。
下图显示了 pageboundary
在 4K 页面边界上对齐时发生的情况。在这种情况下,任何不为零的偏移量都会使基地址和有效地址驻留在不同的页面中。比如pageboundary
的基地址是4096,那么load指令中使用的pageboundary
的基地址就是4096-offset,对于任何非零偏移显然是在不同的4K页.
下图显示模式从偏移量 2048 开始再次发生变化。此时,在计算有效地址之前不会发出负载。
这个分析可以通过测量分派到负载端口2和3的微指令数量来确认。退役负载微指令总数为10亿(等于迭代次数)。但是,当测得的负载延迟为9个周期时,分配给两个端口的负载微指令数为10亿。同样,当负载延迟为 5 或 4 个周期时,分配到两个端口中的每一个的负载微指令数为 5 亿。所以会发生这样的事情:
- 加载单元检查偏移量是否为非负且小于2048。如果是这样,它将使用基地址发出数据加载请求。它还将开始计算有效地址。
- 下一个周期,有效地址计算完成。如果发现加载到不同的 4K 页面,加载单元会等待发出的加载完成,然后丢弃结果并重放加载。无论哪种方式,它都会为数据缓存提供设置的索引和行偏移量。
- 在下一个循环中,进行标签比较并将数据转发到加载缓冲区。 (我不确定地址推测加载是否会在 L1D 或 DTLB 未命中的情况下中止。)
- 在下一个周期,加载缓冲区从缓存中接收数据。如果它应该丢弃数据,它就会被丢弃,并告诉调度程序在禁用地址推测的情况下重放负载。否则,数据被写回。如果后续指令需要数据用于其地址计算,它将在下一个周期接收数据(因此如果所有其他操作数都准备就绪,它将在下一个周期调度)。
这些步骤解释了观察到的 4、5 和 9 周期延迟。
目标页面可能是大页面。加载单元在使用大页面时知道基地址和有效地址是否指向同一页的唯一方法是让 TLB 向加载单元提供被访问页面的大小。然后加载单元必须检查有效地址是否在该页内。在现代处理器中,在 TLB 未命中时,使用 dedicated page-walk hardware。在这种情况下,我认为加载单元不会向数据缓存提供缓存集索引和缓存行偏移,而是使用实际有效地址访问TLB。这需要使页面遍历硬件能够区分具有推测地址的负载和其他负载。只有当其他访问错过了 TLB 时,页面遍历才会发生。现在,如果目标页面原来是一个大页面并且它在 TLB 中命中,则可能会通知加载单元该页面的大小大于 4K 或者甚至可能是页面的确切大小。然后加载单元可以就是否应该重播加载做出更好的决定。但是,此逻辑所花的时间不应超过(可能错误的)数据到达为加载分配的加载缓冲区的时间。我觉得这次只有一个周期。
这三个片段的执行时间:
pageboundary: dq (pageboundary + 8)
...
mov rdx, [rel pageboundary]
.loop:
mov rdx, [rdx - 8]
sub ecx, 1
jnz .loop
还有这个:
pageboundary: dq (pageboundary - 8)
...
mov rdx, [rel pageboundary]
.loop:
mov rdx, [rdx + 8]
sub ecx, 1
jnz .loop
还有这个:
pageboundary: dq (pageboundary - 4096)
...
mov rdx, [rel pageboundary]
.loop:
mov rdx, [rdx + 4096]
sub ecx, 1
jnz .loop
在 4770K 上,第一个片段每次迭代大约 5 个周期,第二个片段每次迭代大约 9 个周期,然后第三个片段每次迭代 5 个周期。它们都访问完全相同的地址,该地址是 4K 对齐的。在第二个片段中,只有地址 calculation 越过页面边界: rdx
和 rdx + 8
不属于同一页面,负载仍然对齐。有了很大的偏移量,它又回到了 5 个周期。
这种效果一般如何发挥作用?
通过这样的 ALU 指令路由加载结果:
.loop:
mov rdx, [rdx + 8]
or rdx, 0
sub ecx, 1
jnz .loop
使每次迭代需要 6 个周期,这相当于 5+1。 Reg+8 应该是一个特殊的快速加载并且 AFAIK 需要 4 个周期,所以即使在这种情况下似乎也有一些惩罚,但只有 1 个周期。
为了回应一些评论,使用了这样的测试:
.loop:
lfence
; or rdx, 0
mov rdx, [rdx + 8]
; or rdx, 0
; uncomment one of the ORs
lfence
sub ecx, 1
jnz .loop
将 or
放在 mov
之前使循环比没有任何 or
更快,将 or
放在 mov
之后使其成为一个循环较慢。
优化规则:在链表/树等指针连接的数据结构中,将next
或left
/right
指针放在对象的前16个字节中。 malloc
通常 returns 16 字节对齐块 (alignof(maxalign_t)
),因此这将确保链接指针与对象的开头位于同一页中。
确保重要的结构成员与对象的开头位于同一页中的任何其他方法也都有效。
Sandybridge-family 通常有 5 个周期的 L1d 加载使用延迟,但有一个特殊情况,用于使用 base+disp 寻址模式的小 positive 位移的指针追逐。
Sandybridge 系列在 [reg + 0..2047]
寻址模式下有 4 个周期的加载使用延迟,当基本寄存器是 mov
加载的结果时,而不是 ALU 指令。或者,如果 reg+disp
与 reg
在不同的页面中,则会受到惩罚。
根据 Haswell 和 Skylake(可能还有原始 SnB 但我们不知道)的这些测试结果,看来以下所有条件都必须为真:
base reg 来自另一个负载。 (指针追踪的粗略启发式,通常意味着加载延迟可能是 dep 链的一部分)。如果对象的分配通常不跨越页面边界,那么这是一个很好的启发式方法。 (硬件显然可以检测到输入是从哪个执行单元转发的。)
寻址方式为
[reg]
或[reg+disp8/disp32]
。 (Or an indexed load with an xor-zeroed index register! 通常没有实际用处,但可能会提供对 issue/rename 阶段转换负载 uops 的一些见解。)位移<2048。即,第 11 位以上的所有位均为零(硬件可以在没有完整整数的情况下检查的条件 adder/comparator。)
(Skylake 但不是 Haswell/Broadwell):上次加载不是重试快速路径。 (所以 base = 4 或 5 个循环负载的结果,它将尝试快速路径。但是 base = 10 个循环重试负载的结果,它不会。SKL 的惩罚似乎是 10,而 HSW 是 9 ).
我不知道是否是在该加载端口上尝试的最后一次加载才是重要的,或者它是否实际上是产生该输入的负载发生的事情。或许并行追踪两个 dep 链的实验可以提供一些启示;我只尝试了一个指针追逐 dep 链,混合了页面更改和非页面更改位移。
如果所有这些都是真的,加载端口推测最终有效地址将与基址寄存器在同一页中。 这在实际情况下是一个有用的优化,当加载使用延迟形成一个循环携带的 dep 链时,比如链表或二叉树。
微架构解释(我对解释结果的最佳猜测,并非英特尔发布的任何内容):
似乎索引 L1dTLB 是 L1d 加载延迟的关键路径。提前开始那个 1 个周期(不等待加法器的输出来计算最终地址)将使用地址的低 12 位索引 L1d 的整个过程缩短一个周期,然后将该组中的 8 个标签与高位进行比较TLB 产生的物理地址的位。 (Intel 的 L1d 是 VIPT 8-way 32kiB,所以它没有别名问题,因为索引位全部来自地址的低 12 位:页面内的偏移量,在虚拟地址和物理地址中都是相同的。即低 12 位免费从 virt 转换为 phys。)
由于我们没有发现跨越 64 字节边界的效果,我们知道加载端口在索引缓存之前添加位移。
正如 Hadi 所建议的,如果第 11 位有进位,加载端口可能会让错误的 TLB 加载完成,然后使用正常路径重做。 (在 HSW 上,总加载延迟 = 9。在 SKL 上,总加载延迟可以是 7.5 或 10)。
立即中止并在下一个周期重试(使其成为 5 或 6 个周期而不是 9 个)在理论上是可能的,但请记住,加载端口以每个时钟吞吐量 1 个流水线传输。调度程序期望能够在下一个周期向加载端口发送另一个 uop,而 Sandybridge 系列将延迟标准化为 5 个周期或更短。 (没有 2 周期指令)。
我没有测试 2M 大页面是否有帮助,但可能没有。我认为 TLB 硬件非常简单,它无法识别高出 1 页的索引仍会选择相同的条目。因此,只要位移超过 4k 边界,它就可能会进行慢速重试,即使那是在同一个大页面中。 (页面拆分加载以这种方式工作:如果数据实际上跨越了 4k 边界(例如,从页面 4 加载 8 字节),则无论大页面如何,您都将支付页面拆分惩罚而不仅仅是缓存行拆分惩罚)
Intel's optimization manual 在 2.4.5.2 L1 DCache 部分(在 Sandybridge 部分)记录了这种特殊情况,但没有提及任何不同的页面限制,或者事实上,它仅用于指针追逐,当 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)
关于这个 table 的文本也没有提到 Haswell/Skylake 上存在的限制,并且可能也存在于 SnB 上(我不知道)。
也许 Sandybridge 没有这些限制并且英特尔没有记录 Haswell 回归,或者英特尔一开始就没有记录这些限制。 table 非常确定寻址模式始终是 4c 延迟,偏移量 = 0..2047.
@Harold 将 ALU 指令作为 load/use 指针追逐依赖链的一部分的实验 证实正是这种影响导致了速度下降:一个 ALU insn减少了总延迟,在这种特定的页面交叉情况下,当添加到 mov rdx, [rdx-8]
dep 链时,有效地给出了像 and rdx, rdx
负增量延迟这样的指令。
此答案中的先前猜测包括以下建议:在 ALU 中使用负载 result 与另一个负载是决定延迟的因素。那将是非常奇怪的,需要展望未来。这是我对将 ALU 指令添加到循环中的影响的错误解释。 (之前不知道跨页的9-cycle效应,还以为HW机制是load port内部结果的转发快道,说得通。)
我们可以证明重要的是基本 reg 输入的来源,而不是加载结果的目的地:在前后两个不同的位置存储相同的地址页边界。创建一个 ALU => 加载 => 加载的 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
同步。
off = 8
,推测是正确的,我们得到总延迟 = 10 个周期 = 1 + 5 + 4。(每次迭代 10 个周期)。off = 16
,[rdi+off]
load 很慢,我们得到 16 cycles / iter = 1 + 5 + 10。(惩罚似乎在 SKL 上比高铁)
加载顺序相反(首先执行[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 确实尝试了快速路径,即使最后一个快速路径失败了,而且快速路径失败惩罚真的在 HSW 上只有 9 而在 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 个周期,根据索引的存在或偏移量的大小没有区别.
我已经在 Haswell 上进行了足够多的实验,以便在完全计算有效地址之前准确地确定何时推测性地发出内存加载。这些结果也证实了Peter的猜测。
我改变了以下参数:
- 与
pageboundary
的偏移量。pageboundary
和加载指令的定义中使用的偏移量相同。 - 偏移量的符号是+或-。定义中使用的符号始终与加载指令中使用的符号相反。
- 可执行二进制文件中
pageboundary
的对齐方式。
在以下所有图表中,Y 轴代表内核周期中的加载延迟。 X轴表示NS1S2形式的配置,其中N是偏移量,S1是定义中使用的偏移量的符号,S2是加载指令中使用的符号。
下图显示只有当偏移量为正或零时,加载才会在计算有效地址之前发出。请注意,对于0-15之间的所有偏移量,加载指令中使用的基地址和有效地址都在同一4K页内。
下图显示了此模式发生变化的点。变化发生在偏移量 213 处,这是加载指令中使用的基地址和有效地址都在不同 4K 页面内的最小偏移量。
从前两个图中可以得出的另一个重要观察结果是,即使基地址指向与有效地址不同的缓存集,也不会产生任何惩罚。所以好像是计算出有效地址后才打开cache set。这说明L1 DTLB hit latency是2个cycle(即L1D接收tag需要2个cycle),但是打开cache的数据数组集和cache的tag数组集只需要1个cycle(发生并行)。
下图显示了 pageboundary
在 4K 页面边界上对齐时发生的情况。在这种情况下,任何不为零的偏移量都会使基地址和有效地址驻留在不同的页面中。比如pageboundary
的基地址是4096,那么load指令中使用的pageboundary
的基地址就是4096-offset,对于任何非零偏移显然是在不同的4K页.
下图显示模式从偏移量 2048 开始再次发生变化。此时,在计算有效地址之前不会发出负载。
这个分析可以通过测量分派到负载端口2和3的微指令数量来确认。退役负载微指令总数为10亿(等于迭代次数)。但是,当测得的负载延迟为9个周期时,分配给两个端口的负载微指令数为10亿。同样,当负载延迟为 5 或 4 个周期时,分配到两个端口中的每一个的负载微指令数为 5 亿。所以会发生这样的事情:
- 加载单元检查偏移量是否为非负且小于2048。如果是这样,它将使用基地址发出数据加载请求。它还将开始计算有效地址。
- 下一个周期,有效地址计算完成。如果发现加载到不同的 4K 页面,加载单元会等待发出的加载完成,然后丢弃结果并重放加载。无论哪种方式,它都会为数据缓存提供设置的索引和行偏移量。
- 在下一个循环中,进行标签比较并将数据转发到加载缓冲区。 (我不确定地址推测加载是否会在 L1D 或 DTLB 未命中的情况下中止。)
- 在下一个周期,加载缓冲区从缓存中接收数据。如果它应该丢弃数据,它就会被丢弃,并告诉调度程序在禁用地址推测的情况下重放负载。否则,数据被写回。如果后续指令需要数据用于其地址计算,它将在下一个周期接收数据(因此如果所有其他操作数都准备就绪,它将在下一个周期调度)。
这些步骤解释了观察到的 4、5 和 9 周期延迟。
目标页面可能是大页面。加载单元在使用大页面时知道基地址和有效地址是否指向同一页的唯一方法是让 TLB 向加载单元提供被访问页面的大小。然后加载单元必须检查有效地址是否在该页内。在现代处理器中,在 TLB 未命中时,使用 dedicated page-walk hardware。在这种情况下,我认为加载单元不会向数据缓存提供缓存集索引和缓存行偏移,而是使用实际有效地址访问TLB。这需要使页面遍历硬件能够区分具有推测地址的负载和其他负载。只有当其他访问错过了 TLB 时,页面遍历才会发生。现在,如果目标页面原来是一个大页面并且它在 TLB 中命中,则可能会通知加载单元该页面的大小大于 4K 或者甚至可能是页面的确切大小。然后加载单元可以就是否应该重播加载做出更好的决定。但是,此逻辑所花的时间不应超过(可能错误的)数据到达为加载分配的加载缓冲区的时间。我觉得这次只有一个周期。