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

时间:2018-01-01 02:21:48

标签: performance assembly optimization x86 micro-optimization

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

实现这一目标的最简单方法是单字节NOP指令,紧接着是multi-byte NOPs。虽然后者通常效率更高,但这两种方法都不是免费的:NOP使用前端执行资源,并且还会计算现代x86上的4宽 1 重命名限制。

另一种选择是以某种方式延长一些指令以获得所需的对齐方式。如果这样做没有引入新的停顿,它似乎比NOP方法更好。如何在最近的x86 CPU上有效地延长指令?

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

  • 适用于大多数说明
  • 能够以可变金额延长指令
  • 不停顿或以其他方式减慢解码器的速度
  • 在uop缓存中高效表示

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

1 AMD Ryzen的限制是5或6。

4 个答案:

答案 0 :(得分:3)

考虑使用温和的代码 - 高尔夫来缩小代码而不是扩展代码,尤其是在循环之前。例如xor eax,eax / cdq如果您需要两个归零寄存器,或mov eax, 1 / lea ecx, [rax+1]将寄存器设置为1和2,总共只有8个字节而不是10个。请参阅{{3有关更多信息,请参阅Set all bits in CPU register to 1 efficiently了解更多一般性提示。但是,您可能仍希望避免错误依赖。

或者按Tips for golfing in x86/x64 machine code 填充额外空格,而不是从内存加载。 (但是,对于包含setup +内部循环的较大循环,添加更多uop-cache压力可能会更糟。但它避免了常量的d-cache错误,因此它有一个好处来补偿运行更多uops。)

如果您还没有使用它们加载"压缩"常量,pmovsxbdmovddupvpbroadcastd的长度超过movaps。 dword / qword广播加载是免费的(没有ALU uop,只是一个加载)。

如果你一直担心代码对齐,你可能会担心它在L1I缓存中的位置或者uop-cache边界的位置,所以只计算总uops不再足够, 之前的块中的一些额外的uops 你关心的那个可能根本不是问题。

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

填充说明,如要求的问题:

Agner Fog对此有一个完整的部分:" 10.6为了对齐而更长时间地制作指令" 在他的creating a vector constant on the fly中。 (leapush r/m64和SIB的想法来自那里,我复制了一两句话/句子,否则这个答案是我自己的作品,要么是不同的想法,要么是在检查Agner之前写的。指南。)

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

使用常规表单代替简短表单(无ModR / M),如push regmov reg,imm。例如对push r/m64使用2字节push rbx。或者使用更长的等效指令,例如add dst, 1而不是inc dstzero-latency / no execution unit mov,因此您已使用inc

使用SIB字节。您可以通过使用单个寄存器作为索引来使NASM执行此操作,例如mov eax, [nosplit rbx*1]in cases where there are no perf downsides to inc),但这会损害负载使用延迟,而不是简单地使用SIB编码mov eax, [rbx]字节。索引寻址模式在SnB系列上有其他缺点,see also

所以最好只使用没有索引注册的ModR / M + SIB编码base=rbx + disp0/8/32=0 。 (&#34的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接受它作为输入,我还没有测试过。

使用仅需imm32disp32的{​​{1}}和/或imm8形式的指令。 Agner Fog' s测试Sandybridge的uop缓存(like un-lamination and not using port7 for stores)表明立即/位移的实际值是重要的,而不是指令编码中使用的字节数。我没有关于Ryzen的uop缓存的任何信息。

所以NASM disp0/disp32(10个字节:操作码+ modrm + disp32 + imm32)将使用32small,32small类别并在uop缓存中取1个条目,这与immediate或disp32实际上超过16个不同重要的一点。 (然后它将需要2个条目,并从uop缓存加载它将需要一个额外的周期。)

根据Agner的表格,8/16 / 32s对于SnB来说总是相同的。并且具有寄存器的寻址模式是相同的,无论是否完全没有位移,或者它是否为32,所以imul eax, [dword 4 + rdi], strict dword 13需要2个条目,就像mov dword [dword 0 + rdi], 123456一样。我没有意识到mov dword [rdi], 123456789 +完整的imm32参加了2次参赛作品,但显然是{&1;就是SnB的情况。

使用[rdi]代替jmp / jcc rel32 。理想情况下,尝试在您不需要在您正在扩展的区域之外进行更长跳转编码的地方扩展说明。 在跳跃目标之前填充以获得较早的向前跳跃,在跳跃目标之前填充以便稍后向后跳跃,如果它们接近需要在其他地方使用rel32。即尽量避免分支与其目标之间的填充,除非您希望该分支仍然使用rel32。

您可能想要将rel8编码为64位代码中的6字节mov eax, [symbol],使用地址大小前缀来使用32位绝对地址。但microarch guide table 9.1在Intel CPU上进行解码时。幸运的是,如果您没有明确指定32位地址大小,而不是使用带有ModR /的7字节a32 mov eax, [abs symbol],那么默认情况下NASM / YASM / gas / clang都不会执行此代码大小优化{+ 1}}的M + SIB + disp32绝对寻址模式。

在64位位置相关代码中,绝对寻址是一种使用1个额外字节而不是RIP相对的便宜方法。但请注意,32位绝对+立即需要2个周期才能从uop缓存中获取,这与RIP相对+ imm8 / 16/32不同,后者只需1个周期,即使它仍然使用2个条目作为指令。 (例如,对于mov r32, r/m32 - 商店或mov eax, [abs symbol])。因此,mov从uop缓存中获取的速度比cmp慢,即使每个都需要2个条目。没有立即,

没有额外费用

请注意,即使对于可执行文件this does cause a Length-Changing-Prefix stall,PIE可执行文件也允许ASLR,因此如果您可以保持代码PIC没有任何缺陷,那么这是可取的。

当您不需要时,请使用REX前缀,例如: cmp [abs symbol], 123 / cmp [rel symbol], 123

添加当前CPU忽略的rep这样的前缀通常是不安全的,因为它们可能在将来的ISA扩展中意味着其他东西。

有时可能会重复相同的前缀(但不能使用REX)。例如,db 0x40 / add eax, ecx给出指令3个操作数大小的前缀,我认为它总是严格等同于前缀的一个副本。最多3个前缀是某些CPU上有效解码的限制。但这只适用于你有一个前缀,你可以在第一时间使用;您通常不使用16位操作数大小,并且通常不需要32位地址大小(尽管在位置相关代码中访问静态数据是安全的。)

访问内存的指令上的db 0x66, 0x66add ax, bx前缀是无操作,并且可能不会导致任何当前CPU的任何减速。 (@prl在评论中建议这样做。)

事实上, Agner Fog的微型指南在示例7.1中的ds上使用ss前缀。安排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或GAS ds。如果AVX512可用,您也可以使用4字节EVEX。

即使您不需要,也可以movq [esi+ecx],mm0使用64位操作数大小,例如{vex3} vxorps xmm0,xmm0强制7字节符号扩展 - NASM中的imm32编码,and are the default in many Linux distro

mov

您甚至可以使用mov rax, strict dword 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.

当常量实际很小(适合32位符号扩展)时,

mov reg, 0可以高效地适应uop缓存。 1 uop-cache条目,加载时间= 1,与xor reg,reg相同。解码一个巨大的指令意味着在一个16字节的解码块中可能没有空间用于在同一周期内解码的其他3个指令,除非它们全部是2字节。稍微延长多个其他指令可能比一条长指令更好。

解码额外前缀的处罚:

  • P5:前缀阻止配对,但PMMX上的地址/操作数大小除外。
  • PPro到PIII:如果一条指令有多个前缀,总会有一个惩罚。这个惩罚通常是每个额外前缀一个时钟。(Agner的微型指南,6.3节结束)
  • Silvermont:如果您关心它,它可能是您可以使用哪些前缀的最严格约束。解码超过3个前缀,计算强制前缀+ 0F转义字节。 SSSE3和SSE4指令已经有3个前缀,因此即使是REX也会使解码速度变慢。
  • 某些AMD:可能是3前缀限制,包括转义字节,也可能不包括SSE指令的强制性前缀。

... TODO:完成本节。在此之前,请咨询Agner Fog的微型指南。

手动编码后,请务必反汇编二进制文件以确保正确。不幸的是,NASM和其他装配工不能更好地支持在指令区域选择便宜的填充以达到给定的对齐边界。

汇编语法

NASM有一些编码覆盖语法mov r64, imm64mov r32, imm32前缀,{vex3}{evex},并强制使用disp8 / disp32内部寻址模式。请注意,NOSPLIT不允许strict byte / dword关键字必须先出现。允许[rdi + byte 0],但我认为这看起来很奇怪。

列出byte

[byte rdi + 0]

GAS有which would normally optimize it to 5-byte mov eax, 1 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 {vex3}{evex} encoding-override pseudo-prefixes

GAS并没有覆盖直接大小,只有替换。

GAS允许您添加明确的{disp8}前缀,{disp32}

ds,手工编辑:

ds mov src,dst

GAS在表达长于需要的编码方面严重不如NASM强大。

答案 1 :(得分:1)

我可以想到四个方面:

首先:使用备用编码作为说明(Peter Cordes提到类似的内容)。例如,有很多方法可以调用ADD操作,其中一些方法占用更多的字节:

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

通常,汇编程序会尝试选择" best"编码是否优化速度或长度,但你总是可以使用另一个并获得相同的结果。

第二:使用其他含义相同但长度不同的说明。我确信您可以想到无数的示例,您可以将一条指令放入代码中以替换现有的指令并获得相同的结果。手动优化代码的人会一直这样做:

shl 1
add eax, eax
mul 2
etc etc

第三:使用各种NOP来填补额外空间:

nop
and eax, eax
sub eax, 0
etc etc

在一个理想的世界中,你可能不得不使用所有这些技巧来使代码成为你想要的确切字节长度。

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

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

答案 2 :(得分:1)

让我们看一段特定的代码:

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

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

但是,如果更改指令顺序怎么办?

您可以将代码转换为:

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

重新订购说明后; mov al,0xFF可以替换为or eax,0x000000FFor ax,0x00FF

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

对于这4种排列中的每一种,您可以添加具有不同冗余前缀量的变体,以及单字节和多字节NOP,以使其以特定对齐结束。我懒得做数学,所以让我们假设它可能扩展到100种可能的排列。

如果你给这100个排列中的每一个都得分(基于诸如执行需要多长时间,如果大小或速度重要的话,它在这篇文章之后如何协调指令,那么......)。这可能包括微架构定位(例如,对于某些CPU来说,原始排列会破坏微操作融合并使代码变得更糟)。

您可以生成所有可能的排列并为其提供分数,并选择具有最佳分数的排列。请注意,这可能不是具有最佳对齐的排列(如果对齐不如其他因素重要,只会使性能变差)。

当然,您可以将大型程序分成许多由控制流变化分隔的小型线性指令组;然后这样做"彻底搜索排名最高的排列"对于每组小线性指令。

问题在于指令顺序和指令选择是相互依赖的。

对于上面的示例,在我们重新订购说明之前,您无法替换mov al,0xFF;并且很容易找到在您更换(某些)指令之后不能重新订购指令的情况。这使得很难对最佳解决方案进行详尽的搜索,对于#34; best"的任何定义,即使您只关心对齐并且根本不关心性能。

答案 3 :(得分:0)

取决于代码的性质。

浮点重码

AVX前缀

对于大多数SSE指令,可以使用较长的AVX前缀。 请注意,在intel CPU [1] [2]上切换SSE和AVX时会有固定的损失。这需要vzeroupper,可以解释为SSE代码的另一个NOP或不需要更高128位的AVX代码。

SSE / AVX NOPS

我能想到的典型NOP是:

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