[英]What methods can be used to efficiently extend instruction length on modern x86?
想象一下,您希望將一系列x86匯編指令與某些邊界對齊。 例如,您可能希望將循環對齊到16或32字節的邊界,或者將指令打包以使它們有效地放置在uop緩存中或其他任何位置。
實現這一目標的最簡單方法是單字節NOP指令,緊接着是多字節NOP 。 雖然后者通常效率更高,但這兩種方法都不是免費的:NOP使用前端執行資源,並且還計入現代x86上的4寬1重命名限制。
另一個選擇是以某種方式延長一些指令以獲得所需的對齊。 如果這樣做沒有引入新的停頓,它似乎比NOP方法更好。 如何在最近的x86 CPU上有效地延長指令?
在理想的世界中,延長技術同時是:
有一種方法不可能同時滿足所有上述要點,因此很好的答案可能會解決各種權衡問題。
1 AMD Ryzen的限制為5或6。
考慮使用溫和的代碼 - 高爾夫來縮小代碼而不是擴展代碼 ,特別是在循環之前。 例如xor eax,eax
/ cdq
如果需要兩個歸零寄存器,或者移動mov eax, 1
/ lea ecx, [rax+1]
將寄存器設置為1和2,總共只有8個字節而不是10個。請參見設置CPU中的所有位有效注冊1以獲得更多相關信息,以及x86 / x64機器代碼中的高爾夫球技巧提示以獲得更多一般性創意。 不過,可能你仍然希望避免錯誤的依賴。
或者通過動態創建向量常量來填充額外的空間,而不是從內存中加載它。 (但是,對於包含setup +內部循環的較大循環,添加更多uop-cache壓力可能會更糟。但它避免了常量的d-cache錯誤,因此它有一個上升空間來補償運行更多的uops。)
如果您尚未使用它們來加載“壓縮”常量,則pmovsxbd
, movddup
或vpbroadcastd
比movaps
更長。 dword / qword廣播加載是免費的(沒有ALU uop,只是一個加載)。
如果你擔心代碼對齊,你可能會擔心它在L1I緩存中的位置或者uop-cache邊界的位置,所以只計算總uops不再足夠,並且還有一些額外的uops。 在你關心的人之前阻止可能根本不是問題。
但在某些情況下,您可能真的希望在要對齊的塊之前優化指令的解碼吞吐量/ uop-cache使用/總uop。
Agner Fog在他的“匯編語言中的優化子程序”指南中 有一個完整的部分:“10.6為了對齊而制作更長的指令 ” 。 ( lea
, push r/m64
和SIB的想法來自那里,我復制了一個或兩個短語,否則這個答案是我自己的作品,要么是不同的想法,要么是在檢查Agner指南之前寫的。)
雖然它還沒有為當前的CPU更新,但是: lea eax, [rbx + dword 0]
比使用mov eax, ebx
有更多的缺點,因為你錯過了零延遲/沒有執行單元mov
。 如果它不在關鍵路徑上,那就去吧。 簡單的lea
具有相當好的吞吐量,並且具有大尋址模式(甚至可能甚至一些段前綴)的LEA可以比mov
+ nop
更好地用於解碼/執行吞吐量。
使用一般形式而不是簡短形式(無ModR / M)的指令,如push reg
或mov reg,imm
。 例如,對於push rbx
使用2字節push r/m64
。 或者使用更長的等效指令,例如add dst, 1
而不是inc dst
, 如果沒有inc
缺點,那么你已經在使用inc
。
使用SIB字節 。 您可以通過使用單個寄存器作為索引來讓NASM這樣做,例如mov eax, [nosplit rbx*1]
( 另請參閱 ),但這會影響負載使用延遲,而不僅僅是編碼mov eax, [rbx]
一個SIB字節。 索引尋址模式在SnB系列上有其他缺點, 例如取消層壓而不使用port7用於存儲 。
因此,最好只使用沒有索引reg的ModR / M + SIB對base=rbx + disp0/8/32=0
進行編碼 。 (“無索引”的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是否接受它作為輸入。
使用只需要imm8
或disp0/disp32
的指令的imm32
和/或disp32
形式。 Agner Fog對Sandybridge的uop緩存( 微指南指南表9.1 )的測試表明,立即/位移的實際值是重要的,而不是指令編碼中使用的字節數。 我沒有關於Ryzen的uop緩存的任何信息。
所以NASM imul eax, [dword 4 + rdi], strict dword 13
(10字節:操作碼+ modrm + disp32 + imm32)將使用32small,32small類別並在uop緩存中取1個條目,這與immediate或disp32不同實際上有超過16個有效位。 (然后它將需要2個條目,並從uop緩存加載它將需要一個額外的周期。)
根據Agner的表,8/16 / 32small總是相當於SnB。 並且具有寄存器的尋址模式是相同的,無論是否完全沒有位移,或者它是否為32small,因此mov dword [dword 0 + rdi], 123456
需要2個條目,就像mov dword [rdi], 123456789
。 我沒有意識到[rdi]
+完整的imm32占用了2個條目,但顯然在SnB上就是這種情況。
使用jmp / jcc rel32
而不是rel8
。 理想情況下,嘗試在不需要在您正在擴展的區域之外進行更長跳轉編碼的位置擴展指令。 在跳躍目標之后填充較早的前向跳躍,在跳躍目標之前填充以便稍后向后跳躍,如果它們接近需要在其他地方使用rel32。 即嘗試避免分支與其目標之間的填充,除非您希望該分支仍然使用rel32。
你可能想要將mov eax, [symbol]
編碼為6字節a32 mov eax, [abs symbol]
64位代碼中的a32 mov eax, [abs symbol]
,使用地址大小前綴來使用32位絕對地址。 但是,當它在Intel CPU上解碼時, 這確實會導致長度變化前綴失速 。 幸運的是,如果沒有明確指定32位地址大小,而不是使用7字節mov r32, r/m32
和ModR / M,則默認情況下NASM / YASM / gas / clang都不執行此代碼大小優化mov eax, [abs symbol]
+ SIB + disp32絕對尋址模式mov eax, [abs symbol]
。
在64位位置相關代碼中,絕對尋址是使用1個額外字節而不是RIP相對的廉價方式 。 但請注意,32位絕對+立即需要2個周期才能從uop緩存中獲取,這與RIP相對+ imm8 / 16/32不同,后者只需1個周期,即使它仍然使用2個條目作為指令。 (例如,對於mov
-store或cmp
)。 所以cmp [abs symbol], 123
從uop緩存中獲取的速度比cmp [rel symbol], 123
慢,即使每個都需要2個條目。 沒有立即,沒有額外的成本
請注意,PIE可執行文件甚至允許ASLR用於可執行文件, 並且是許多Linux發行版中的默認設置 ,因此如果您可以保持代碼PIC沒有任何缺陷,那么這是更可取的。
當您不需要時,請使用REX前綴,例如db 0x40
/ add eax, ecx
。
添加當前CPU忽略的rep這樣的前綴通常不安全,因為它們可能在未來的ISA擴展中意味着其他東西。
有時可能會重復相同的前綴(但不能使用REX)。 例如, db 0x66, 0x66
/ add ax, bx
給出指令3操作數大小的前綴,我認為它總是嚴格等同於前綴的一個副本。 最多3個前綴是某些CPU上有效解碼的限制。 但這只適用於你有一個前綴,你可以在第一時間使用; 您通常不使用16位操作數大小,並且通常不需要32位地址大小(盡管在位置相關代碼中訪問靜態數據是安全的)。
訪問內存的指令上的ds
或ss
前綴是無操作 ,並且可能不會導致任何當前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或GAS {vex3} vxorps xmm0,xmm0
。 如果AVX512可用,您也可以使用4字節EVEX。
即使你不需要 ,也可以使用64位操作數大小的mov
,例如mov rax, strict dword 1
強制在NASM中使用7字節的sign-extended-imm32編碼, 這通常會將其優化為5字節的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
在常量實際很小時有效地適合mov r64, imm64
緩存(適合32位符號擴展。) 1 uop-cache條目,load-time = 1,與mov r32, imm32
。 對一個巨大的指令進行解碼意味着在一個16字節的解碼塊中可能沒有空間用於其他3個指令在同一個周期內進行解碼,除非它們都是2字節。 稍微延長多個其他指令可能比使用一個長指令更好。
... TODO:完成本節。 在此之前,請咨詢Agner Fog的微型指南。
手工編碼之后,總是反匯編你的二進制文件以確保你做對了 。 令人遺憾的是,NASM和其他裝配工無法更好地支持在指令區域上選擇便宜的填充以達到給定的對齊邊界。
NASM具有一些編碼覆蓋語法 : {vex3}
和{evex}
前綴, NOSPLIT
和strict 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具有編碼覆蓋偽前綴 {vex3}
, {evex}
, {disp8}
和{disp32}
這些替換現在已棄用的.s
, .d8
和.d32
后綴 。
GAS沒有覆蓋直接大小,只有位移。
GAS允許您使用ds mov src,dst
添加顯式ds
前綴
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 $0x1,%eax
1e: 48 83 c0 01 add $0x1,%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 $0x1,%eax # manually encoded
31: 81 c1 d2 04 00 00 add $0x4d2,%ecx # large immediate, can't get GAS to encode this way with $1 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那樣表達超過需要的編碼。
我可以想到四個方面:
第一:使用備用編碼作為指令(Peter Cordes提到類似的東西)。 例如,有很多方法可以調用ADD操作,其中一些方法占用更多字節:
http://www.felixcloutier.com/x86/ADD.html
通常,匯編程序會嘗試為這種情況選擇“最佳”編碼,無論是針對速度還是長度進行優化,但是您總是可以使用另一個編碼並獲得相同的結果。
第二:使用意思相同且長度不同的其他說明。 我相信你可以想到無數的例子,你可以將一條指令放入代碼中來替換現有的指令並得到相同的結果。 手動優化代碼的人會一直這樣做:
shl 1
add eax, eax
mul 2
etc etc
第三:使用各種NOP來填補額外空間:
nop
and eax, eax
sub eax, 0
etc etc
在理想的世界中,您可能必須使用所有這些技巧來使代碼成為您想要的確切字節長度。
第四:使用上述方法更改算法以獲得更多選項。
最后一點:由於指令的數量和復雜性,顯然針對更現代的處理器將為您提供更好的結果。 訪問MMX,XMM,SSE,SSE2,浮點等指令可以使您的工作更輕松。
我們來看一段特定的代碼:
cmp ebx,123456
mov al,0xFF
je .foo
對於此代碼,沒有任何指令可以替換為其他任何指令,因此唯一的選項是冗余前綴和NOP。
但是,如果更改指令順序怎么辦?
您可以將代碼轉換為:
mov al,0xFF
cmp ebx,123456
je .foo
重新訂購說明后; mov al,0xFF
可以用or eax,0x000000FF
or ax,0x00FF
替換。
對於第一個指令排序,只有一種可能性,對於第二個指令排序,有3種可能性; 因此,在不使用任何冗余前綴或NOP的情況下,總共有4種可能的排列可供選擇。
對於這4種排列中的每一種,您可以添加具有不同冗余前綴量的變體,以及單字節和多字節NOP,以使其以特定對齊結束。 我懶得做數學,所以讓我們假設它可能擴展到100種可能的排列。
如果你給這100個排列中的每一個都得分(基於諸如執行需要多長時間,如果大小或速度很重要的話,如果大小或速度很重要,它與指令對齊的程度等等),該怎么辦? 這可以包括微架構定位(例如,對於某些CPU,原始排列會破壞微操作融合並使代碼變得更糟)。
您可以生成所有可能的排列並給它們一個分數,並選擇具有最佳分數的排列。 請注意,這可能不是具有最佳對齊的排列(如果對齊不如其他因素重要,只會使性能變差)。
當然,您可以將大型程序分成許多由控制流變化分隔的小型線性指令組; 然后對每組小線性指令進行“窮舉搜索最佳得分排列”。
問題是指令順序和指令選擇是相互依賴的。
對於上面的示例,在我們重新排序指令之前,您無法替換mov al,0xFF
; 並且很容易找到在您更換(某些)指令之后無法重新訂購指令的情況。 這使得很難對“最佳”的任何定義進行詳盡的搜索,即使您只關心對齊而根本不關心性能。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.