可以使用哪些方法有效地扩展现代 x86 上的指令长度?

What methods can be used to efficiently extend instruction length on modern x86?

假设您想将一系列 x86 汇编指令对齐到特定边界。例如,您可能希望将循环对齐到 16 或 32 字节的边界,或者打包指令以便将它们有效地放置在 uop 缓存或其他任何地方。

实现此目的的最简单方法是单字节 NOP 指令,紧随其后的是 multi-byte NOPs。虽然后者通常更有效,但这两种方法都不是免费的:NOP 使用前端执行资源,并且还计入现代 x86 上的 4-wide1 重命名限制。

另一种选择是以某种方式延长一些指令以获得您想要的对齐方式。如果在不引入新停顿的情况下完成此操作,它似乎比 NOP 方法更好。如何在最近的 x86 CPU 上有效地延长指令?

在理想世界中,延长技术同时是:

不太可能有一种方法可以同时满足以上所有要求,因此好的答案可能会解决各种权衡问题。


1AMD Ryzen 上的限制是 5 或 6。

我能想到四种方法:

首先: 对指令使用替代编码(Peter Cordes 提到了类似的东西)。例如ADD操作的调用方式有很多种,有的占用字节比较多:

http://www.felixcloutier.com/x86/ADD.html

通常汇编程序会尝试根据速度或长度优化的情况选择 "best" 编码,但您始终可以使用另一种编码并获得相同的结果。

其次:使用其他意思相同但长度不同的指令。我敢肯定,您可以想出无数示例,在这些示例中,您可以将一条指令放入代码中以替换现有指令并获得相同的结果。手动优化代码的人一直在做:

shl 1
add eax, eax
mul 2
etc etc

第三:使用各种可用的 NOP 来填充额外的 space:

nop
and eax, eax
sub eax, 0
etc etc

在理想情况下,您可能必须使用所有这些技巧才能使代码成为您想要的确切字节长度。

第四:使用上述方法更改算法以获得更多选项。

最后一点:显然,由于指令的数量和复杂性,以更现代的处理器为目标会给你带来更好的结果。访问 MMX、XMM、SSE、SSE2、浮点等指令可以使您的工作更轻松。

取决于代码的性质。

浮点重代码

AVX 前缀

大多数 SSE 指令都可以使用较长的 AVX 前缀。 请注意,在英特尔 CPU [1] 上在 SSE 和 AVX 之间切换时会有固定的惩罚。这需要 vzeroupper,它可以被解释为 SSE 代码或 AVX 代码的另一个 NOP,它不需要更高的 128 位。

SSE/AVX NOPS

我能想到的典型 NOP 是:

  • XORPS 相同的寄存器,对这些
  • 的整数使用 SSE/AVX 变体
  • ANDPS 相同的寄存器,对这些
  • 的整数使用 SSE/AVX 变体

我们来看一段具体的代码:

    cmp ebx,123456
    mov al,0xFF
    je .foo

对于此代码,none 的指令可以替换为任何其他内容,因此唯一的选择是冗余前缀和 NOP。

但是,如果你改变指令顺序呢?

您可以将代码转换成这样:

    mov al,0xFF
    cmp ebx,123456
    je .foo

re-ordering指令后; mov al,0xFF 可以替换为 or eax,0x000000FFor ax,0x00FF.

第一种指令排序只有一种可能,第二种指令排序有3种可能;因此,在不使用任何冗余前缀或 NOP 的情况下,总共有 4 种可能的排列可供选择。

对于这 4 个排列中的每一个,您都可以添加具有不同数量冗余前缀的变体,以及单个和 multi-byte NOP,以使其在特定的 alignment/s 处结束。我懒得算了,所以让我们假设它可能扩展到 100 种可能的排列。

如果您给这 100 个排列中的每一个都打分会怎么样(基于诸如执行需要多长时间、它在这一段之后对齐指令的程度、大小或速度是否重要等因素,...)。这可以包括 micro-architectural 目标(例如,对于某些 CPU,原始排列可能会破坏 micro-op 融合并使代码更糟)。

你可以生成所有可能的排列并给它们打分,然后选择得分最高的排列。请注意,这可能不是具有最佳对齐方式的排列(如果对齐不如其他因素重要并且只会使性能变差)。

当然,您可以将大型程序分解为许多小的线性指令组,这些指令由控制流变化分隔;然后对每一小组线性指令执行此操作 "exhaustive search for the permutation with the best score"。

问题是指令顺序和指令选择是co-dependent.

对于上面的示例,在我们 re-ordered 说明之前,您无法替换 mov al,0xFF;在替换(某些)说明之前,很容易发现无法 re-order 说明的情况。这使得很难为 "best" 的任何定义详尽搜索最佳解决方案,即使您只关心对齐而根本不关心性能。

考虑使用温和的代码打高尔夫球来收缩您的代码而不是扩展它,尤其是在循环之前。例如xor eax,eax / cdq 如果你需要两个置零的寄存器,或者 mov eax, 1 / lea ecx, [rax+1] 将寄存器设置为 1 和 2,总共只有 8 个字节而不是 10 个字节。见 for more about that, and Tips for golfing in x86/x64 machine code 以获得更一般的想法。不过,您可能仍想避免错误的依赖关系。

或者用填充额外的space而不是从内存中加载它。 (不过,对于包含您的设置 + 内部循环的较大循环,增加更多的 uop-cache 压力可能会更糟。但它避免了常量的 d-cache 未命中,因此它有一个好处来补偿 运行 更多的 uops .)

如果您尚未使用它们来加载 "compressed" 常量,则 pmovsxbdmovddupvpbroadcastdmovaps 长。 dword / qword 广播负载是免费的(没有 ALU uop,只是一个负载)。

如果您完全担心代码对齐,您可能担心它在 L1I 缓存中的位置或 uop 缓存边界的位置,因此仅计算总 uops 已经不够了,并且在 before 块中添加一些额外的微指令,您关心的可能根本不是问题。

但在某些情况下,您可能真的想要优化解码吞吐量/uop 缓存使用/要对齐的块之前的指令的总 uops。


padding说明,如提问的问题:

Agner Fog 在他的 "Optimizing subroutines in assembly language" guide 中有一整节:“10.6 为了对齐而使指令变长”。 (leapush r/m64、SIB的思路都出自那里,我复制了一两句/短语,否则这个答案就是我自己的作品,要么是不同的思路,要么是在查看Agner的指南之前写的。)

它还没有针对当前的 CPU 进行更新,但是:lea eax, [rbx + dword 0]mov eax, ebx 有更多的缺点,因为你错过了 。如果它不在关键路径上,那就去吧。简单 lea 具有相当好的吞吐量,具有大寻址模式(甚至可能有一些段前缀)的 LEA 比 mov + nop.[= 的解码/执行吞吐量更好。 107=]

使用一般形式而不是 push regmov reg,imm 等指令的简短形式(无 ModR/M)。例如使用 2 字节 push r/m64 作为 push rbx。或者使用更长的等效指令,例如 add dst, 1 而不是 inc dst 所以您已经在使用 inc.

使用 SIB 字节。您可以通过使用单个寄存器作为索引来让 NASM 做到这一点,例如 mov eax, [nosplit rbx*1] (), but that hurts the load-use latency vs. simply encoding mov eax, [rbx] with a SIB byte. Indexed addressing modes have other downsides on SnB-family, like un-lamination and not using port7 for stores.

所以 最好只使用 ModR/M + SIB 编码 base=rbx + disp0/8/32=0,没有索引 reg。 ("no index" 的 SIB 编码是 idx=RSP 的编码)。 [rsp + x] 寻址模式已经需要 SIB(base=RSP 是表示有 SIB 的转义码),并且它一直出现在编译器生成的代码中。因此,有充分的理由期望现在和将来都能完全有效地解码和执行(即使对于 RSP 以外的基址寄存器)。 NASM 语法无法表达这一点,因此您必须手动编码。来自 objdump -d 的 GNU gas Intel 语法对 Agner Fog 的示例 10.20 说 8b 04 23 mov eax,DWORD PTR [rbx+riz*1]。 (riz 是虚构的索引零表示法,表示存在没有索引的 SIB)。我还没有测试 GAS 是否接受它作为输入。

使用 imm32 and/or disp32 形式的指令,只需要 imm8disp0/disp32. Agner Fog 对 Sandybridge 的 uop 缓存 (microarch guide table 9.1) 的测试表明,立即数/位移的实际值才是最重要的,而不是指令编码中使用的字节数。我没有关于 Ryzen 的 uop 缓存的任何信息。

所以 NASM imul eax, [dword 4 + rdi], strict dword 13(10 字节:opcode + modrm + disp32 + imm32)将使用 32small、32small 类别并在 uop 缓存中获取 1 个条目,这与立即数或disp32 实际上有超过 16 个有效位。 (那么它需要2个条目,从uop缓存中加载它需要一个额外的周期。)

根据 Agner 的 table,8/16/32small 对于 SnB 总是等价的。而寄存器的寻址方式无论是完全没有位移还是32small都是一样的,所以mov dword [dword 0 + rdi], 123456取2个条目,就像mov dword [rdi], 123456789一样。我没有意识到 [rdi] + full imm32 占用了 2 个条目,但显然 SnB 就是这种情况。

使用jmp / jcc rel32代替rel8。理想情况下,尝试在您正在扩展的区域之外不需要更长的跳转编码的地方扩展指令。 在较早前向跳跃的跳跃目标之后填充,在稍后向后跳跃的跳跃目标之前填充, 如果他们在其他地方接近需要 rel32。即尽量避免在分支与其目标之间进行填充,除非您希望该分支无论如何都使用 rel32。


您可能想在 64 位代码中将 mov eax, [symbol] 编码为 6 字节 a32 mov eax, [abs symbol],使用地址大小前缀来使用 32 位绝对地址。但是 this does cause a Length-Changing-Prefix stall 在 Intel CPU 上解码时。幸运的是,如果您没有明确指定 32 位地址大小,而是使用 7 字节 mov r32, r/m32,NASM/YASM/gas/clang 的 none 默认会执行此代码大小优化ModR/M+SIB+disp32 绝对寻址模式 mov eax, [abs symbol].

在 64 位位置相关代码中,相对于 RIP 相对 ,绝对寻址是使用 1 个额外字节的廉价方式。但请注意,32 位绝对 + 立即数需要 2 个周期才能从 uop 缓存中获取,这与 RIP-relative + imm8/16/32 不同,它只需要 1 个周期,即使它仍然使用 2 个条目作为指令。 (例如 mov 商店或 cmp)。所以 cmp [abs symbol], 123 从 uop 缓存中获取比 cmp [rel symbol], 123 慢,即使两者各有 2 个条目。没有立即数,

没有额外费用

请注意,PIE executables 甚至对于 executable、and are the default in many Linux distro 也允许 ASLR,因此如果您可以保持代码 PIC 而没有任何性能缺点,那么这是更可取的。


在不需要时使用 REX 前缀,例如db 0x40 / add eax, ecx.

添加当前 CPU 忽略的像 rep 这样的前缀通常是不安全的,因为它们在未来的 ISA 扩展中可能有其他含义。

重复相同的前缀有时是可能的(但 REX 不行)。例如,db 0x66, 0x66 / add ax, bx 为指令提供了 3 个操作数大小的前缀,我认为这始终严格等同于前缀的一个副本。最多 3 个前缀是某些 CPU 上高效解码的限制。但这仅在您首先可以使用前缀时才有效;您通常不使用 16 位操作数大小,并且通常不需要 32 位地址大小(尽管在位置相关代码中访问静态数据是安全的)。

访问内存的指令上的 dsss 前缀是空操作,并且可能不会导致任何速度减慢当前的 CPU。 (@prl 在评论中提出了这个建议)。

事实上,Agner Fog 的微架构指南在 示例 7.1 中的 movq [esi+ecx],mm0 上使用了 ds 前缀。安排 IFETCH 块 以调整 PII/PIII 的循环(无循环缓冲区或 uop 缓存),将其从每个时钟 3 次迭代加速到 2.

当指令有超过 3 个前缀时,某些 CPU(如 AMD)解码速度很慢。在某些 CPU 上,这包括 SSE2 中的强制性前缀,尤其是 SSSE3 / SSE4.1 指令。在 Silvermont,即使是 0F 转义字节也很重要。

AVX 指令可以使用 2 或 3 字节的 VEX 前缀。一些指令需要一个 3 字节的 VEX 前缀(第二个来源是 x/ymm8-15,或者 SSSE3 或更高版本的强制性前缀)。但是本可以使用 2 字节前缀的指令始终可以使用 3 字节 VEX 进行编码。 NASM 或天然气 {vex3} vxorps xmm0,xmm0。如果AVX512可用,你也可以使用4字节的EVEX。


mov 使用 64 位操作数大小,即使您不需要它,例如 mov rax, strict dword 1 强制使用 7 字节符号-extended-imm32 编码 NASM, which would normally optimize it to 5-byte mov eax, 1.

mov    eax, 1                ; 5 bytes to encode (B8 imm32)
mov    rax, strict dword 1   ; 7 bytes: REX mov r/m64, sign-extended-imm32.
mov    rax, strict qword 1   ; 10 bytes to encode (REX B8 imm64).  movabs mnemonic for AT&T.

您甚至可以使用 mov reg, 0 而不是 xor reg,reg

mov r64, imm64 当常量实际上很小时(适合 32 位符号扩展)有效地适合 uop 缓存。 1 uop 缓存条目,并加载-time = 1,与 mov r32, imm32 相同。解码一个巨大的指令意味着在一个 16 字节的解码块中可能没有空间供 3 个其他指令在同一周期中解码,除非它们都是 2 字节的。可能稍微延长多条其他指令可能比一条长指令更好。


解码额外前缀的惩罚:

  • P5:前缀阻止配对,address/operand-size 仅在 PMMX 上除外。
  • PPro 到 PIII:如果一条指令有多个前缀,总会有惩罚。这种惩罚通常是每个额外前缀一个时钟。(Agner 的微架构指南,第 6.3 节结尾)
  • Silvermont:如果您关心它,它可能是对您可以使用哪些前缀的最严格限制。解码停止超过 3 个前缀,计算强制前缀 + 0F 转义字节。 SSSE3 和 SSE4 指令已经有 3 个前缀,因此即使是 REX 也会使它们解码缓慢。
  • 某些 AMD:可能有 3 个前缀限制,包括转义字节,并且可能不包括 SSE 指令的强制性前缀。

... TODO:完成本节。在那之前,请参阅 Agner Fog 的微架构指南。


手工编码后,一定要反汇编二进制文件以确保正确。不幸的是,NASM 和其他汇编程序没有更好地支持在指令区域上选择便宜的填充以达到给定的对齐边界。


汇编语法

NASM 有一些编码覆盖语法{vex3}{evex} 前缀,NOSPLITstrict byte / dword,并强制 disp8/disp32 进入寻址模式。请注意,[rdi + byte 0] 是不允许的,byte 关键字必须放在第一位。 [byte rdi + 0] 是允许的,但我觉得这看起来很奇怪。

列表来自 nasm -l/dev/stdout -felf64 padding.asm

 line  addr    machine-code bytes      source line
 num

 4 00000000 0F57C0                         xorps  xmm0,xmm0    ; SSE1 *ps instructions are 1-byte shorter
 5 00000003 660FEFC0                       pxor   xmm0,xmm0
 6                                  
 7 00000007 C5F058DA                       vaddps xmm3, xmm1,xmm2
 8 0000000B C4E17058DA              {vex3} vaddps xmm3, xmm1,xmm2
 9 00000010 62F1740858DA            {evex} vaddps xmm3, xmm1,xmm2
10                                  
11                                  
12 00000016 FFC0                        inc  eax
13 00000018 83C001                      add  eax, 1
14 0000001B 4883C001                    add  rax, 1
15 0000001F 678D4001                    lea  eax, [eax+1]     ; runs on fewer ports and doesn't set flags
16 00000023 67488D4001                  lea  rax, [eax+1]     ; address-size and REX.W
17 00000028 0501000000                  add  eax, strict dword 1   ; using the EAX-only encoding with no ModR/M 
18 0000002D 81C001000000                db 0x81, 0xC0, 1,0,0,0     ; add    eax,0x1  using the ModR/M imm32 encoding
19 00000033 81C101000000                add  ecx, strict dword 1   ; non-eax must use the ModR/M encoding
20 00000039 4881C101000000              add  rcx, strict qword 1   ; YASM requires strict dword for the immediate, because it's still 32b
21 00000040 67488D8001000000            lea  rax, [dword eax+1]
22                                  
23                                  
24 00000048 8B07                        mov  eax, [rdi]
25 0000004A 8B4700                      mov  eax, [byte 0 + rdi]
26 0000004D 3E8B4700                    mov  eax, [ds: byte 0 + rdi]
26          ******************       warning: ds segment base generated, but will be ignored in 64-bit mode
27 00000051 8B8700000000                mov  eax, [dword 0 + rdi]
28 00000057 8B043D00000000              mov  eax, [NOSPLIT dword 0 + rdi*1]  ; 1c extra latency on SnB-family for non-simple addressing mode

GAS 有 encoding-override pseudo-prefixes {vex3}, {evex}, {disp8}, and {disp32} .

GAS 没有直接大小的覆盖,只有位移。

GAS 允许您添加显式 ds 前缀,ds mov src,dst

gcc -g -c padding.S && objdump -drwC padding.o -S,手动编辑:

  # no CPUs have separate ps vs. pd domains, so there's no penalty for mixing ps and pd loads/shuffles
  0:   0f 28 07                movaps (%rdi),%xmm0
  3:   66 0f 28 07             movapd (%rdi),%xmm0

  7:   0f 58 c8                addps  %xmm0,%xmm1        # not equivalent for SSE/AVX transitions, but sometimes safe to mix with AVX-128

  a:   c5 e8 58 d9             vaddps %xmm1,%xmm2, %xmm3  # default {vex2}
  e:   c4 e1 68 58 d9          {vex3} vaddps %xmm1,%xmm2, %xmm3
 13:   62 f1 6c 08 58 d9       {evex} vaddps %xmm1,%xmm2, %xmm3

 19:   ff c0                   inc    %eax
 1b:   83 c0 01                add    [=12=]x1,%eax
 1e:   48 83 c0 01             add    [=12=]x1,%rax
 22:   67 8d 40 01             lea  1(%eax), %eax     # runs on fewer ports and doesn't set flags
 26:   67 48 8d 40 01          lea  1(%eax), %rax     # address-size and REX
         # no equivalent for  add  eax, strict dword 1   # no-ModR/M

         .byte 0x81, 0xC0; .long 1    # add    eax,0x1  using the ModR/M imm32 encoding
 2b:   81 c0 01 00 00 00       add    [=12=]x1,%eax     # manually encoded
 31:   81 c1 d2 04 00 00       add    [=12=]x4d2,%ecx   # large immediate, can't get GAS to encode this way with  other than doing it manually

 37:   67 8d 80 01 00 00 00      {disp32} lea  1(%eax), %eax
 3e:   67 48 8d 80 01 00 00 00   {disp32} lea  1(%eax), %rax


        mov  0(%rdi), %eax      # the 0 optimizes away
  46:   8b 07                   mov    (%rdi),%eax
{disp8}  mov  (%rdi), %eax      # adds a disp8 even if you omit the 0
  48:   8b 47 00                mov    0x0(%rdi),%eax
{disp8}  ds mov  (%rdi), %eax   # with a DS prefix
  4b:   3e 8b 47 00             mov    %ds:0x0(%rdi),%eax
{disp32} mov  (%rdi), %eax
  4f:   8b 87 00 00 00 00       mov    0x0(%rdi),%eax
{disp32} mov  0(,%rdi,1), %eax    # 1c extra latency on SnB-family for non-simple addressing mode
  55:   8b 04 3d 00 00 00 00    mov    0x0(,%rdi,1),%eax

GAS 在表达比需要更长的编码方面严格来说不如 NASM 强大。