Length-Changing 前缀 (LCP) 是否会导致简单的 x86_64 指令停顿?

Does a Length-Changing Prefix (LCP) incur a stall on a simple x86_64 instruction?

考虑像这样的简单指令

mov RCX, RDI          # 48 89 f9

48 是 x86_64 的 REX 前缀。它不是 LCP。但考虑添加一个 LCP(用于对齐目的):

.byte 0x67
mov RCX, RDI          # 67 48 89 f9

67 是地址大小前缀,在这种情况下是针对没有地址的指令。该指令也没有立即数,并且它不使用 F7 操作码(假 LCP 停顿;F7 将是 TEST、NOT、NEG、MUL、IMUL、DIV + IDIV)。假设它也不跨越 16 字节边界。这些是 Intel 的 优化参考手册.

中提到的 LCP 停顿情况

此指令会导致 LCP 停顿(在 Skylake、Haswell 等上)吗?两个 LCP 怎么样?

我的日常 driver 是一台 MacBook。所以我无法访问 VTune,也无法查看 ILD_STALL 事件。还有其他方法可以知道吗?

TL:DR: 67h 在所有 CPUs 上都是安全的。在64位模式下1,67h只能LCP-stall和累加器的addr32 movabsload/store(AL/AX/EAX/RAX) from/to 一个 moffs 32 位绝对地址(相对于该特殊操作码的普通 64 位绝对地址)。

67h 不是 length-changing 使用 ModRM 的普通指令,即使在寻址模式下使用 disp32 组件也是如此,因为 32 位和 64 位 address-size 使用相同的 ModRM 格式。 67h-LCP-stallable 形式的 mov 很特殊,不使用 modrm 寻址模式。

(它几乎肯定不会在未来的 CPU 中有其他含义,比如作为更长的操作码的一部分 rep3.)

长度更改前缀是指操作码 (+modrm) 暗示指令机器代码 non-prefixes 部分的字节长度不同,如果您忽略了前缀。 即它改变了指令的 rest 的长度。(并行 length-finding 很难,并且与完整解码分开完成:后面的 insns 在 16 字节中块甚至没有已知的起点。所以这个最小(16 字节,6 条指令)阶段需要查看前缀后尽可能少的位,以便正常快速情况下工作。这是 LCP 停止的阶段可能会发生。)

通常只有一个实际的 imm16 / imm32 操作码,例如66hadd cx, 1234 中的 length-changing,但不是 add cx, 12:在前缀之后或在适当的模式下,add r/m16, imm8add r/m32, imm8 都是操作码 + modrm + imm8,3 个字节,(https://www.felixcloutier.com/x86/add)。 Pre-decode 硬件可以通过跳过前缀找到正确的长度,而不是根据它看到的内容修改后面的操作码+modrm 的解释,这与 66h 意味着操作码暗示 2 个直接字节而不是 4 个字节不同。汇编程序将尽可能选择 imm8 编码,因为它更短(或者 no-modrm add ax, imm16 特殊情况的长度相等)。

(请注意,对于 mov r64, imm64mov r32, imm32,REX.W=1 是 length-changing,但是所有硬件都能有效地处理相对常见的指令,因此只有 66h67h 实际上可以 LCP-stall.)

SnB-family 没有任何 false2 LCP 停顿前缀可以是 length-changing 对于这个操作码但不是这个特定的指令,因为66h 或 67h。所以 F7 在 SnB 上是 non-issue,不像 Core2 和 Nehalem。 (早期的 P6 系列 Intel CPUs 不支持 64 位模式。)Atom/Silvermont 根本没有 LCP 惩罚,AMD 或 Via CPUs 也没有。


Agner Fog's microarch guide很好地涵盖了这一点,并且解释得很清楚。搜索“length-changing 前缀”。 (这个答案试图将这些部分与一些关于 x86 指令编码如何工作等的提醒放在一起)

脚注 1:67h 在非 64 位模式下增加 length-finding 难度:

在64位模式下,67h从64位地址大小变为32位地址大小,两者都使用disp0 / 8 / 32(0、1或4字节的立即数位移作为指令),并且使用与 ModRM“转义码”相同的 ModRM + optional SIB encoding for normal addressing modes. RIP+rel32 repurposes the shorter (no SIB) encoding of 32-bit mode's two redundant ways to encode [disp32], so length decoding is unaffected. Note that REX was already designed not to be length-changing (except for mov r64, imm64), by 分别表示没有基本寄存器或存在 SIB 字节。

在 16 位和 32 位模式下,67h 切换到 32 位或 16 位地址大小。不仅 [x + disp32][x + disp16]the ModRM byte (just like immediates for the operand-size prefix), but also 16-bit address-size can't signal a SIB byte. Why don't x86 16-bit addressing modes have a scale factor, while the 32-bit version has it? 之后的长度不同,所以模式和 /rm 字段中的相同位可能意味着不同的长度。

脚注 2:“假”LCP 停顿

这种需要(见脚注 1)有时需要以不同的方式看待 ModRM,甚至是为了找到长度,这大概是为什么英特尔 CPUs 在 Sandybridge 之前在 67h 在任何带有 ModRM 的指令上加上前缀,即使它们不是 length-changing(例如寄存器寻址模式)。不是乐观地 length-finding 并以某种方式检查,如果他们看到 addr32 + 大多数操作码,如果他们不在 64 位模式,Core2/Nehalem 只是平底船。

幸运的是,在 32 位代码中使用它的理由基本上为零,因此这主要只适用于使用 32 位寄存器而不切换到保护模式的 16 位代码。或者像您一样使用 67h 进行填充的代码,但在 32 位模式下除外。 .byte 0x67 / mov ecx, edi 会成为 Core 2 / Nehalem 的问题。 (我没有检查之前的 32-bit-only P6 系列 CPUs。它们比 Nehalem 更过时。)

67h 的错误 LCP 停顿在 64 位模式下永远不会发生;如上所述,这是最简单的情况,长度 pre-decoders 已经知道它们处于什么模式,所以幸运的是,使用它进行填充没有任何缺点。与 rep(可能成为未来操作码的一部分)不同,67h 极有可能被安全地忽略,因为它可以应用于 some 形式的指令相同的操作码,即使实际上没有内存操作数。

Sandybridge-family 从来没有任何错误的 LCP 停顿,删除了 16/32 位模式 address-size (67h) 和 all-modes 66 F7 情况(需要查看 ModRM 来消除指令歧义,例如neg dimul di 来自 test di, imm16。)

SnB-family 也删除了一些 66h true-LCP 档,例如来自 mov- 像 mov word ptr [rdi], 0 这样实际上很有用。

脚注 3:使用 67h 进行填充的向前兼容

67h 一般应用于操作码时(即它可以使用内存操作数),对于具有恰好编码 reg 的 modrm 的相同操作码,它不太可能意味着其他东西,注册操作数。所以这对 .

是安全的

事实上,GNU binutils 通过用 67h 填充 call rel32 来将 6 字节的 call [RIP+rel32] “放宽”为 5 字节的 call rel32 address-size 前缀,尽管这对 E8 call rel32 毫无意义。 (当链接使用 -fno-plt 编译的代码时会发生这种情况,它使用 call [RIP + foo@gotpcrel] 来表示当前编译单元中未找到且不具有“隐藏”可见性的任何 foo。)

但这不是一个好的先例:在这一点上,CPU 供应商想要打破 that 特定前缀+操作码组合(例如 What does `rep ret` mean?),但是你程序中的一些自制的东西,比如 67h cdq 不会从供应商那里得到同样的待遇。


规则,为Sandybridge-family CPUs

edited/condensed 来自 Agner 的微架构 PDF,这些情况可以 LCP-stall,在 pre-decode 中额外花费 2 到 3 个周期(如果它们在 uop 缓存中丢失)。

  • 任何带有 imm16 的 ALU 运算在没有 66h 的情况下将是 imm32。 (mov-immediate 除外)。
    • 请记住,movtest 没有更宽 operand-size 的 imm8 形式,因此更喜欢 test al, 1,或者 imm32 如果必要的。或者有时甚至 test ah, imm8 如果你想测试 AX 上半部分的位,尽管要注意 HSW 和更高版本的 。 GCC 使用了这个技巧,但也许应该开始小心使用它,也许有时在提供 setcccmovcc 时使用 bt reg, imm8 (不能像 JCC 那样使用 macro-fuse 进行测试).
  • 67h 与 movabs moffs(A0/A1/A2/A3 操作码在 64 位模式下,也可能在 16 或 32 位模式下)。当 LLVM 决定优化 mov al, [0x123456] 以使用 67 A0 4-byte-address 或普通操作码 + modrm + sib + disp32(以获得绝对而不是 rip-relative)。那指的是 Agner 指南的旧版本;我把我的测试结果发给他后他很快就更新了。
  • 如果指令NEG, NOT, DIV, IDIV, MUL 和IMUL 中的一个是一个操作数 有一个 16 位操作数,并且操作码字节和操作码字节之间有一个 16 字节的边界 mod-reg-rm 字节。这些指令有一个伪造的 length-changing 前缀 因为这些指令与带有 16- 的 TEST 指令具有相同的操作码 位立即操作数 [...]
    SnB-family 对于 div cx 或其他任何东西,无论对齐方式如何,都不会受到惩罚。
  • 地址大小前缀 (67H) 总是会导致任何 16 位和 32 位模式的延迟 具有 mod/reg/rm 字节的指令,即使它不改变指令的长度。
    SnB-family 删除了这个惩罚,如果你小心的话,address-size 前缀可以用作填充。

或者用另一种方式总结:

  • SnB-family 没有错误的 LCP 停顿。

  • SnB-family 在每个 66h67h 真正的 LCP 上都有 LCP 停顿,除了:

    • mov r/m16, imm16mov r16, imm16 no-modrm 版本。
    • 67h 与 ModRM 交互的地址大小(在 16/32 位模式下)。
      (这不包括 AL/AX/EAX/RAX 形式的 no-modrm 绝对地址 load/store——它们仍然可以 LCP-stall,大概甚至在 32 位模式下,就像在 64 位模式下一样。)
  • Length-changing REX 不会停止(在任何 CPU 上)。


一些例子

(这部分忽略了某些 CPU 在某些非 length-changing 情况下出现的虚假 LCP 停顿,这在这里并不重要,但也许这就是您担心 67h for mov reg,reg.)

在你的例子中,其余的指令字节,从 67 之后开始,解码为 3 字节指令,无论当前 address-size 是 32 还是 64。即使使用寻址模式也是如此像 mov eax, [e/rsi + 1024] (reg+disp32) 或 addr32 mov edx, [RIP + rel32].

在 16 位和 32 位模式下,67h 在 16 位和 32 位地址大小之间切换。 [x + disp32][x + disp16]the ModRM byte 之后的不同长度,但非 16 位 address-size 也可以根据 R/M 字段发出 SIB 字节信号。但是在64位模式下,32位和64位address-size都使用[x + disp32],同样是ModRM->SIB或者不编码。

只有一种情况 67h address-size 前缀在 64 位模式下是 length-changing movabs load/store 8 字节与 4 字节绝对地址,是的 LCP-stall Intel CPUs.(我 posted 测试结果 https://bugs.llvm.org/show_bug.cgi?id=34733#c3)

例如,addr32 movabs [0x123456], al

.intel_syntax noprefix
  addr32 mov    [0x123456], cl   # non-AL to make movabs impossible
         mov    [0x123456], al   # GAS picks normal absolute [disp32]
  addr32 mov    [0x123456], al   # GAS picks A2 movabs since addr32 makes that the shortest choice, same as NASM does.
         movabs [0x123456], al   # 64-bit absolute address

请注意,GAS(幸运的是)不会选择自己使用 addr32 前缀,即使使用 as -Os (gcc -Wa,-Os)。

$ gcc -c foo.s
$ objdump -drwC -Mintel foo.o
...
   0:   67 88 0c 25 56 34 12 00         mov    BYTE PTR ds:0x123456,cl
   8:   88 04 25 56 34 12 00    mov    BYTE PTR ds:0x123456,al   # same encoding after the 67
   f:   67 a2 56 34 12 00       addr32 mov ds:0x123456,al
  15:   a2 56 34 12 00 00 00 00 00      movabs ds:0x123456,al    # different length for same opcode

尽你所能ee 来自最后 2 条指令,使用 a2 mov moffs, al 操作码,带有 67 指令的其余部分对于相同的操作码是不同的长度。

在 Skylake 上 LCP-stall,所以它只有在从 uop 缓存中 运行ning 时才快。


当然,更常见的 LCP 停顿来源是 66 前缀和 imm16(而不是 imm32)。就像 add ax, 1234 一样,在这个随机测试中,我想看看跳过 LCP-stalling 指令是否可以避免问题:。但不是 add ax, 12 这样的情况,它将使用 add r/m16, imm866 前缀后的长度与 add r/m32, imm8 相同)。

此外,据报道,Sandybridge-family 使用 16 位立即数避免了 mov-立即数的 LCP 停顿。

相关:

  • 另一个解决 add r/m16, imm16 的例子:add 1 byte immediate value to a 2 bytes memory location

  • - 选择 add r/m16, imm8 而不是 also-3-byte add ax, imm16 形式。

  • - address-size 如何与 movabsmoffs 形式交互。 (可以的那种LCP-stall)

  • - 你正在做的事情的一般情况。


调整建议和 uarch 详细信息:

通常不要尝试用 addr32 mov [0x123456], al 保存 space,除非在保存 1 个字节或使用 15 个字节的填充(包括循环内的实际 NOP)之间做出选择。 (下面有更多调整建议)

一个 LCP 停顿通常不会成为 uop 缓存的灾难,特别是如果 length-decoding 可能不是这里的 front-end 瓶颈(尽管如果 front-end 是一个瓶颈)。但是 micro-benchmarking 很难在一个函数中测试单个实例;只有真正的 full-app 基准测试才能准确反映代码何时可以 运行 来自 uop 缓存(英特尔性能计数器称为 DSB),绕过遗留解码 (MITE)。

现代 CPU 阶段之间存在队列,至少可以部分吸收摊位 https://www.realworldtech.com/haswell-cpu/2/(比 PPro/PIII 中更多),并且 SnB-family 更短 LCP-stalls 比 Core2/Nehalem。 (但是 pre-decode 缓慢的其他原因已经降低了它们的容量,并且在 I-cache 未命中之后它们可能都是空的。)

当前缀不是 length-changing 时,查找指令边界的 pre-decode 流水线阶段(在将字节块引导至实际 complex/simple 解码器或进行实际解码之前)将找到正确 instruction-length / 通过跳过所有前缀然后只查看操作码(和 modrm,如果适用)结束。

这个 pre-decode length-finding 是 LCP 停顿发生的地方,所以 有趣的事实:即使是 Core 2 的 pre-decode 循环缓冲区也可以隐藏 LCP 停顿在随后的迭代中,因为它使用解码队列(pre-decode 输出)作为缓冲区,在 找到指令边界后锁定多达 64 字节/18 insns 的 x86 机器代码 。 =147=]

在稍后的 CPUs 中,LSD 和 uop 缓存被 post 解码,所以除非有什么东西破坏了 uop 缓存(比如讨厌的 JCC-erratum mitigation 或者只是有太多的 uops 32 字节对齐的 x86 机器代码块中的 uop 缓存),循环仅在第一次迭代时支付 LCP-stall 成本,如果它们还不是热的话。

我想说的是,如果你能以低廉的成本做到这一点,通常可以解决 LCP 停顿问题,尤其是对于通常 运行 是“冷”的代码。或者,如果您可以只使用 32 位 operand-size 并避免 partial-register 恶作剧,通常只花费一个字节 code-size 并且没有额外的指令或微指令。或者,如果您连续有多个 LCP 档位,例如天真地使用 16 位立即数,缓冲区会隐藏太多气泡,所以你会遇到一个真正的问题,值得花费额外的指令。 (例如 mov eax, imm32 / add [mem], ax,或 movzx 加载/添加 r32、imm32 / 存储,或其他。)


在指令边界处填充以结束 16 字节提取块:不需要

(这与在分支目标处对齐获取块的 start 是分开的,考虑到 uop 缓存,这有时也是不必要的。)

Wikichip 关于 Skylake pre-decode 的部分错误地暗示留在块末尾的部分指令必须单独 pre-decode,而不是与包含的下一个 16 字节组一起指令结束。它似乎是从 Agner Fog 的文本中转述的,有一些更改和添加使其错误:

[from wikichip...] As with previous microarchitectures, the pre-decoder has a throughput of 6 macro-ops per cycle or until all 16 bytes are consumed, whichever happens first. Note that the predecoder will not load a new 16-byte block until the previous block has been fully exhausted. For example, suppose a new chunk was loaded, resulting in 7 instructions. In the first cycle, 6 instructions will be processed and a whole second cycle will be wasted for that last instruction. This will produce the much lower throughput of 3.5 instructions per cycle which is considerably less than optimal.
[this part is paraphrased from Agner Fog's Core2/Nehalem section, with the word "fully" having been added"]

Likewise, if the 16-byte block resulted in just 4 instructions with 1 byte of the 5th instruction received, the first 4 instructions will be processed in the first cycle and a second cycle will be required for the last instruction. This will produce an average throughput of 2.5 instructions per cycle. [nothing like this appears in the current version of Agner's guide, IDK where this misinformation came from. Perhaps made up based on a misunderstanding of what Agner said, but without testing.]

幸运的是没有。指令的其余部分是 下一个提取块中,因此现实更有意义:剩余字节被添加到下一个 16 字节块中。

(从这条指令开始一个新的 16 字节 pre-decode 块也似乎是合理的,但我的测试排除了这一点:2.82 IPC 重复 5、6、6 字节 = 17 字节模式。如果它只查看 16 个字节并将部分 5 或 6 字节指令留作下一个块的开始,那将给我们 2 个 IPC。)

3x 5 字节指令的重复模式 展开多次(aNASM %rep 2500 或 GAS .rept 2500 块,所以在 ~36kiB 中有 7.5k 条指令) 运行s 在 3.19 IPC, pre-decoding 并以每个周期约 16 字节的速度解码。 (16 bytes/cycle) / (5 bytes/insn) = 理论上每个周期 3.2 条指令.

(如果 wikichip 是正确的,它会以 3-1 的模式预测接近 2 个 IPC,这当然低得不合理,并且在 运行 的很长一段时间内都不是英特尔可接受的设计long or medium-length when 运行ning from legacy decode.2 IPC 比 4-wide pipeline 窄很多,即使是 legacy decode 也不行。英特尔从 P4 了解到 运行从遗留解码至少体面地好是重要的,即使你的 CPU 缓存解码 uops。这就是为什么 SnB 的 uop 缓存可以这么小,只有 ~1.5k uops。比 P4 的跟踪缓存小很多,但 P4 的问题是试图用跟踪缓存 替换 L1i,并且解码器较弱。(事实上它是 trace 缓存,所以它缓存了多次使用相同的代码。))

这些性能差异足够大,您可以在 Mac 上使用 plenty-large 重复计数来验证它,因此您不需要性能计数器来验证 uop-cache 未命中。 (请记住,L1i 包含 uop 缓存,因此不适合 L1i 的循环也会从 uop 缓存中逐出自己。)无论如何,只需测量总时间并知道您将命中的近似 max-turbo 就足够了进行这样的完整性检查。

比 wikichip 预测的 theoretical-max 更好,即使在启动开销和保守的频率估计之后,也将完全排除这种行为,即使在没有性能计数器的机器上也是如此.

$ nasm -felf64 && ld       # 3x 5 bytes, repeated 2.5k times

$ taskset -c 3 perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_retired.retire_slots,uops_executed.thread,idq.dsb_uops -r2 ./testloop

 Performance counter stats for './testloop' (2 runs):

            604.16 msec task-clock                #    1.000 CPUs utilized            ( +-  0.02% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
                 1      page-faults               #    0.002 K/sec                  
     2,354,699,144      cycles                    #    3.897 GHz                      ( +-  0.02% )
     7,502,000,195      instructions              #    3.19  insn per cycle           ( +-  0.00% )
     7,506,746,328      uops_issued.any           # 12425.167 M/sec                   ( +-  0.00% )
     7,506,686,463      uops_retired.retire_slots # 12425.068 M/sec                   ( +-  0.00% )
     7,506,726,076      uops_executed.thread      # 12425.134 M/sec                   ( +-  0.00% )
                 0      idq.dsb_uops              #    0.000 K/sec                  

         0.6044392 +- 0.0000998 seconds time elapsed  ( +-  0.02% )

(and from another run):
      7,501,076,096      idq.mite_uops             # 12402.209 M/sec                   ( +-  0.00% )

不知道为什么 idq.mite_uops:u 不等于 issued 或 retired。 un-laminate 没有任何意义,也不需要 stack-sync 微指令,所以 IDK 可能来自额外发布+退役的微指令。超出部分在 运行 秒内是一致的,并且与我认为的 %rep 计数成正比。

使用其他模式,如 5-5-6(16 字节)和 5-6-6(17 字节),我得到类似的结果。

我有时会测量 16 字节组是否相对于绝对 16 字节边界未对齐(在循环顶部放置 nop)时的细微差别。但这似乎只发生在较大的重复次数上。 %rep 2500 对于 39kiB 的总大小,我仍然得到 2.99 IPC(每个周期不到一个 16 字节组),0 DSB uops,无论对齐还是未对齐。

我仍然在 %rep 5000 处获得 2.99IPC,但我在 %rep 10000 处看到差异:2.95 IPC 未对齐与 2.99 IPC 对齐。最大的 %rep 计数是 ~156kiB 并且仍然适合 256k L2 缓存所以 IDK 为什么任何东西都会不同于那个大小的一半。 (它们比 32k Li1 大得多)。我想早些时候我在 5k 时看到了不同,但我现在无法重现。也许那是 17 字节的组。


_start 下的静态可执行文件中实际循环 运行s 1000000 次,原始 syscall 到 _exit,因此性能计数器(和时间)为整个过程基本上就是一个循环。 (特别是 perf --all-user 只计算 user-space。)

; complete Linux program
default rel
%use smartalign
alignmode p6, 64

global _start
_start:
    mov     ebp, 1000000

align 64
.loop:
%ifdef MISALIGN
    nop
%endif
 %rep 2500
    mov eax, 12345        ; 5 bytes.
    mov ecx, 123456       ; 5 bytes.  Use r8d for 6 bytes
    mov edx, 1234567      ; 5 bytes.  Use r9d for 6 bytes
 %endrep
    dec ebp
    jnz .loop
.end:

   xor edi,edi
    mov eax,231   ; __NR_exit_group  from /usr/include/asm/unistd_64.h
    syscall       ; sys_exit_group(0)