【问题标题】:Is there a penalty when base+offset is in a different page than the base?当 base+offset 与 base 位于不同的页面时是否会受到惩罚?
【发布时间】:2019-02-20 09:55:33
【问题描述】:

这三个sn-ps的执行时间:

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 上,第一个 sn-p 每次迭代大约 5 个周期,第二个 sn-p 每次迭代大约 9 个周期,然后第三个 sn-p 大约 5 个周期。它们都访问完全相同的地址,即 4K 对齐的。在第二个sn-p中,只有地址计算跨越了页面边界:rdxrdx + 8不属于同一个页面,加载还是对齐的。如果偏移量很大,它会再次回到 5 个周期。

这种效果一般是如何起作用的?


通过这样的 ALU 指令路由加载的结果:

.loop:
    mov rdx, [rdx + 8]
    or rdx, 0
    sub ecx, 1
    jnz .loop

使得每次迭代需要 6 个周期,这对于 5+1 是有意义的。 Reg+8 应该是一个特殊的快速加载,AFAIK 需要 4 个周期,所以即使在这种情况下,似乎也有一些惩罚,但只有 1 个周期。


这样的测试用于响应一些 cmets:

.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 之后会使循环变慢。

【问题讨论】:

  • 这很奇怪。我认为英特尔的文档没有提到 SnB 系列的 [base + 0..2047] 特殊情况 4 周期负载使用延迟的失败,但它基于使用基本 reg 在添加之前启动 TLB 检查是合理的,并且速度较慢如果事实证明它们在不同的页面中。 (顺便说一句,这种特殊情况仅在转发到另一种寻址模式时,而不是在 ALU 指令时。)
  • 是的,将 ALU 指令插入到 dep 链中会降低总延迟,这很有趣(就像负延迟指令一样)
  • 提供 ALU 指令总是禁用 4 周期指针追踪快速路径。即使没有任何跨页恶作剧,包括mov rdx, [rdx] / and rdx,rdx,您也会从该循环中获得 6 个周期。
  • 这是一个非常好的发现。我已将此效果添加到Intel Performance Quirks page,并带有问题的链接和@PeterCordes 的答案。
  • 我在 Ryzen 上对此进行了测试,并没有看到任何类似的效果:循环仍然以 4 个周期执行,负载在不同的页面上。 Ryzen 也没有加载地址需要来自加载的限制:添加 1 个周期 ALU 后,总延迟上升到 5 个周期 (4 + 1),而 Intel 为 6 个周期(因为加载需要在这种情况下 5 个周期)。

标签: performance assembly x86 micro-optimization


【解决方案1】:

优化规则:在链表/树等指针连接的数据结构中,将nextleft/right指针放在对象的前16个字节。 malloc 通常返回 16 字节对齐的块 (alignof(maxalign_t)),因此这将确保链接指针与对象的开头位于同一页中。

确保重要结构成员与对象开头位于同一页面中的任何其他方式也可以工作。


Sandybridge 系列通常具有 5 个周期的 L1d 负载使用延迟,但对于使用 base+disp 寻址模式进行小位移的指针追逐存在特殊情况。

当基本 reg 是 mov 加载的结果而不是 ALU 指令时,Sandybridge 系列对于 [reg + 0..2047] 寻址模式有 4 个周期的加载使用延迟。或者如果reg+dispreg 在不同的页面中,则会受到惩罚。

根据在 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 manual2.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 计时。

  • off = 8,推测是正确的,我们得到总延迟 = 10 个周期 = 1 + 5 + 4。(每次迭代 10 个周期)。

  • off = 16[rdi+off] 加载很慢,我们得到 16 个周期/iter = 1 + 5 + 10。(SKL 的惩罚似乎比 HSW 更高)

颠倒加载顺序(首先执行 [rdi+off] 加载),无论 off=8 或 off=16,它始终为 10c,因此我们已经证明 mov rdi, [rdi+off] 不会尝试推测性快速路径,如果它的输入来自 ALU 指令。

没有andoff=8,我们得到预期的每迭代8c:两者都使用快速路径。 (@harold 确认 HSW 在这里也有 8 个)。

没有andoff=16,我们每迭代得到15c:5+10mov 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 模式则不是。但是,如果您使用的是纯负载(如movvbroadcastss),那么索引寻址模式本身并没有什么问题。不过,使用额外的归零寄存器并不是很好。


在 Ice Lake 上,这种用于指针追踪加载的特殊 4 周期快速路径已不复存在:在 L1 中命中的 GP 寄存器加载现在通常需要 5 个周期,不会因索引的存在或偏移量的大小而有所不同。

【讨论】:

  • Sandy Bridge 实际上有一个性能事件,AGU_BYPASS_CANCEL.COUNT,其名称和描述几乎可以解释效果:此事件计算具有以下所有特征的已执行加载操作:1. 格式寻址[base+offset],2.偏移量在1到2047之间,3.基址寄存器中指定的地址在一页中,地址[base+offset]在an中。(是的,它结束突然像那样)。 “1 之间”部分似乎是错误的,因为正如您指出的那样,即使是零偏移量也会发生这种情况。
  • @Noah - 对于其中一些结果来说,结果“太好了”:ICL 上的最小加载延迟为 5 个周期,即使是简单的寻址,除非“内存重命名”。可能正在发生的事情是内存重命名正在启动,并且至少部分测试通过从寄存器文件加载值而不是实际执行加载来运行。我会尝试调整它以阻止内存重命名。
  • this change 内存重命名失败后,结果看起来像much more sane on Ice Lake。 @诺亚
  • 所以我应该补充一点,在 Ice Lake 上,4-cycle opt 消失了:大多数 GPs regs 负载(除非跨缓存行、段前缀等)需要 5 个周期。因此,测试结果不再显示添加偏移量后落在另一个页面中的负载的任何惩罚。
  • @Noah - 是的,当然。我的意思是它是相同的内存,因此根据定义可以发生实际的混叠:向量加载必须看到重叠的 GP 存储,反之亦然,以确保正确性。或者您是在询问是否发生转发?我相信它确实(有效地)用于 GP 负载命中矢量存储。另一种方法是停顿,因为向量加载比 GP 存储更宽,所以你会得到部分加载停顿。
【解决方案2】:

我在 Haswell 上进行了足够多的实验,以确定在完全计算有效地址之前推测性地发出内存负载的确切时间。这些结果也证实了彼得的猜测。

我改变了以下参数:

  • pageboundary 的偏移量。使用的偏移量在pageboundary的定义和加载指令中是一样的。
  • 偏移的符号是 + 或 -。定义中使用的符号始终与加载指令中使用的符号相反。
  • pageboundary 在可执行二进制文件中的对齐方式。

在以下所有图表中,Y 轴代表核心周期中的负载延迟。 X轴表示NS1S2形式的配置,其中N是偏移量,S1是定义中使用的偏移量的符号,S2是加载指令中使用的符号。

下图显示,只有当偏移量为正或零时,才会在计算有效地址之前发出加载。请注意,对于 0-15 之间的所有偏移量,加载指令中使用的基地址和有效地址都在同一个 4K 页面内。

下一张图显示了这种模式发生变化的点。变化发生在偏移量 213,这是加载指令中使用的基地址和有效地址都在不同 4K 页内的最小偏移量。

从前两个图中可以得出的另一个重要观察结果是,即使基地址指向与有效地址不同的缓存集,也不会产生任何惩罚。所以看起来缓存集是在计算出有效地址后打开的。这表明L1 DTLB命中延迟为2个周期(即L1D接收标签需要2个周期),但打开缓存的数据数组集和缓存的标签数组集只需要1个周期(发生这种情况)并行)。

下图显示了当pageboundary 在 4K 页面边界上对齐时会发生什么。在这种情况下,任何不为零的偏移量都会使基地址和有效地址驻留在不同的页面中。例如,如果pageboundary的基地址是4096,那么在加载指令中使用的pageboundary的基地址就是4096-偏移量,对于任何非零偏移量,这显然是在不同的4K页面中。

下图显示模式从偏移量 2048 开始再次发生变化。此时,在计算有效地址之前永远不会发出加载。

可以通过测量调度到负载端口 2 和 3 的微指令数来确认此分析。退役负载微指令的总数为 10 亿(等于迭代次数)。但是,当测量的负载延迟为 9 个周期时,分派到两个端口中的每个端口的负载微指令数为 10 亿。此外,当负载延迟为 5 或 4 个周期时,分派到两个端口中的每个端口的负载微指令数为 5 亿。所以会发生这样的事情:

  • 加载单元检查偏移量是否为非负且小于2048。在这种情况下,它将使用基地址发出数据加载请求。它还将开始计算有效地址。
  • 在下一个周期,有效地址计算完成。如果结果是加载到不同的 4K 页面,加载单元会等待直到发出的加载完成,然后丢弃结果并重放加载。无论哪种方式,它都会为数据缓存提供设置的索引和行偏移。
  • 在下一个循环中,执行标签比较并将数据转发到加载缓冲区。 (我不确定在 L1D 或 DTLB 未命中的情况下是否会中止地址推测加载。)
  • 在下一个周期,加载缓冲区从缓存中接收数据。如果它应该丢弃数据,它就会被丢弃,并告诉调度程序在禁用地址推测的情况下重放负载。否则,数据被写回。如果后面的指令需要数据进行地址计算,它将在下一个周期接收数据(因此如果所有其他操作数都准备好,它将在下一个周期调度)。

这些步骤解释了观察到的 4、5 和 9 个周期的延迟。

目标页面可能是大页面。在使用大页面时,加载单元知道基地址和有效地址是否指向同一页面的唯一方法是让 TLB 为加载单元提供正在访问的页面的大小。然后加载单元必须检查有效地址是否在该页面内。在现代处理器中,在 TLB 未命中时,使用 dedicated page-walk hardware。在这种情况下,我认为加载单元不会将缓存集索引和缓存行偏移量提供给数据缓存,而是使用实际有效地址来访问 TLB。这需要启用 page-walk 硬件来区分具有推测地址的负载和其他负载。只有当其他访问错过了 TLB 时,才会发生页面遍历。现在,如果目标页面是一个巨大的页面并且它在 TLB 中很成功,那么可能会通知加载单元页面的大小大于 4K 甚至可能是页面的确切大小。然后负载单元可以就是否应该重放负载做出更好的决定。但是,此逻辑所花费的时间不应超过(可能是错误的)数据到达为加载分配的加载缓冲区的时间。我想这次只是一个周期。

【讨论】:

  • 英特尔手册中“可以”之后的下一句是“但是,由于堆栈绕过,总体延迟会因目标寄存器数据类型而异”。这给人的印象是他们只说 can 因为它只适用于 GP 整数。该表确实明确指出使用该寻址模式的 GP 整数加载是 4 个周期,而不是 4 或 9 个周期。我不认为英特尔的狡猾的话足以使他们的手册对 HSW 没有错误。我很好奇我们是否仍然对第一代 SnB 产生相同的影响,这就是手册的那部分记录的内容。
  • HW page walk 不是微编码的;有专门的 page-walk 硬件可以独立于加载端口进行自己的缓存加载。 What happens after a L2 TLB miss?。有趣的事实:在 P5 和更早的版本中,页面遍历硬件绕过了缓存(因此捕获到软件页面遍历实际上更快),但 P6 系列的页面遍历器执行缓存加载。 Are page table walks cached?
  • 顺便说一句,如果它们不是交替的正/负,你的图表会更容易理解。我们从之前的实验和英特尔的手册中知道,[base - constant] 从来没有什么奇怪的地方,所以这些锯齿是出乎意料的/难以理解的。您必须仔细阅读图例以区分 +- 和 -+,如果我还不知道只有正位移(您的术语中的负相对偏移)可以永远是 4 或 9。特别是因为标题只是说 0..n,所以它的数量级是出乎意料的。
  • 在您新的最后一段中,我不确定您对 TLB 未命中和页面跳转有何看法。我认为您在这里有多个要点。 1. 在 TLB 未命中时,我们必须向 page walker 发送正确的地址,而不是投机的地址。但是,在第一次 TLB 检查甚至完成之前就可以检测到错误推测,正如您在单个周期中所说的那样(检查从添加到页码中的进位,无论如何它都必须这样做)。哦,我认为您的意思是,在错误推测时,它可能会避免为那组 VIPT L1d 缓存获取数据+标签?有道理,电源优化不错。
  • 还有 2. 你的意思是,如果 TLB 检查包括页面大小,它可能会避免在大页面内跨越 4k 边界时重放,但我没有遵循最后一句话.
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-05-16
  • 2013-07-16
  • 2018-06-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-11-22
相关资源
最近更新 更多