2016-11-26 62 views
1

我正在寫一個x86的彙編程序是需要作爲參數:在地址位移或其外部乘以更高效?

  1. [ESP+4]:32位整數後面的數字。
  2. [ESP+8]開始:要加起來的32位整數列表。

它返回從[ESP+8]開始傳遞的所有整數的總和。所以,基本上,該函數的C語言是:

int addN(int numberofitems, ...);

我有兩種方式寫這x86彙編程序的選項:

第一種方式(乘以項目的大小內地址位移):

addN PROC 
    mov ecx, dword ptr [esp+4] ; Dec ecx per argument processed 
    mov edx, 2 ; Offset into variable length argument list 
    xor eax, eax ; Accumulator 
    AdderLoop: 
     add eax, dword ptr [esp+edx*4] 
     inc edx 
     dec ecx 
     jnz AdderLoop 
    ret 
addN ENDP 

方式二(項目的大小被添加到edx本身):

addN PROC 
    mov ecx, dword ptr [esp+4] ; Dec ecx per argument processed 
    mov edx, 8 ; Offset into variable length argument list 
    xor eax, eax ; Accumulator 
    AdderLoop: 
     add eax, dword ptr [esp+edx] 
     add edx, 4 
     dec ecx 
     jnz AdderLoop 
    ret 
addN ENDP 

有什麼優勢,表現明智或其他方式,比較喜歡一種方式?

+1

恕我直言,性能差異可以忽略這個簡單的循環。在更復雜(算法)的情況下,其他標準將會出現差異。 – zx485

回答

3

隨着現代的CPUs,它是非常棘手的理論,關於特定源的性能。但無論如何我會燒傷自己。

特別是在過去的十年中,我沒有去學習任何有關ASM性能編碼的知識,所以我的大多數評論都是基於對事物的微小瞥見,而沒有任何透徹的知識和經驗。

步驟零:弄清楚,你將如何分析你的代碼。如果沒有真實世界的分析,你將無處可去,因爲接下來我要描述的所有東西都可以使結果更快更慢,顯然在不同的目標CPU上,但即使在同一臺目標機器上 - 也取決於剩下的可執行文件如何着陸,所以對其他函數如何進行對齊以及緩存如何覆蓋其他函數代碼。

第一件事:在循環開始時使用align指令。 (或以這種方式啓動程序,循環的第一條指令將對齊)。多少?看起來像16通常會加速大多數當前的CPU。這可能會對性能產生實際影響,但不僅僅是積極的,建議僅使用頻繁分支到的地址。

細微之處:

讓我們來測試幾個變種,它們是如何編譯成機器代碼:

0: 8b 04 94    mov eax,DWORD PTR [esp+edx*4] 
3: 8b 04 14    mov eax,DWORD PTR [esp+edx*1] 
6: 8b 04 24    mov eax,DWORD PTR [esp] 
9: 8b 44 95 00    mov eax,DWORD PTR [ebp+edx*4+0x0] 
d: 8b 44 15 00    mov eax,DWORD PTR [ebp+edx*1+0x0] 
11: 8b 45 00    mov eax,DWORD PTR [ebp+0x0] 

正如你所看到的,*4 VS *1變種具有相同的長度,而且性能將等於,所以你不必擔心在尋址*4

所以使用無論哪種模式使代碼的其餘部分更短/更快。inc edx是1B長操作碼,add edx,4是3B長,所以我會選擇第一個,因爲在複雜的可執行文件中,更短的機器代碼將更好地適應緩存,並且在現代x86之間不應該有任何性能差異incadd - 與其餘代碼隔離考慮時。當被認爲不是孤立的時候,inc was evil on the Intel Pentium 4 CPUs a few years back,但是最近幾代人再次確定,所以它應該像add一樣快。

(現在我注意到您使用add eax,...,所以解決的一個更多的時間不同的變種,一個):

0: 42      inc edx 
1: 83 c2 04    add edx,0x4 
4: 03 04 94    add eax,DWORD PTR [esp+edx*4] 
7: 03 44 95 00    add eax,DWORD PTR [ebp+edx*4+0x0] 
b: 03 04 14    add eax,DWORD PTR [esp+edx*1] 
e: 03 44 15 00    add eax,DWORD PTR [ebp+edx*1+0x0] 
12: 03 45 00    add eax,DWORD PTR [ebp+0x0] 

現在,我想我看到了一些關於通過esp解決具有附加前綴字節,但我不在這裏看不到,所以它可能在16b?這就是爲什麼我也測試ebp變種,以擺脫esp。但是由於esp的機器碼較短(ebp強制位移+0x0字節的使用),所以我會保留它就像現在使用它。


在一些舊的CPU將是可能更快交錯的依賴說明:

AdderLoop: 
    add eax, dword ptr [esp+edx*4] 
    dec ecx 
    lea edx, [edx+1] 
    jnz AdderLoop 

但latests架構使用一種叫指令"macro-fusion"dec + jnz雙現在應該放在一起。


如果你知道的參數的數量將大部分的時間相當大的(不太可能,因爲這將溢出的結果值),當然你也可以展開的幾次迭代循環(4,8或16,由於大碼緩存污染而不會更高)。


然後再次,如果參數的數量會相當高,您可能會結束等待內存加載大部分循環的值。

然後上面的任何代碼變體都會以相同的性能結束,因爲內存緩存未命中數量爲數十到數百個指令,而不是尋址模式下的單指令細微差別。

我有沒有提醒過你,這很棘手?我在第一句話中做了。


結論:

不要理會這個,你是在浪費你的時間。

簡單地寫出最簡單的最可讀的來源是正確的(在您的具體情況下,我更喜歡*4變體與「索引」類似的來源)。

完成應用程序後,對其進行配置。

修復真正的瓶頸。

+1

* esp有額外的前綴字節*:不完全:ESP作爲基址寄存器總是需要SIB字節,即使沒有索引寄存器。所以'[ESP + 4]'不幸的是需要一個額外的字節與'[EBP-4]'。他們沒有選擇'[ECX]'或者其他的東西作爲表示SIB字節存在的轉義碼,所以對於'-fomit-frame-pointer'不會有這樣的代碼大小的懲罰'。無論如何,如果您實際使用索引寄存器,那麼使用ESP作爲基址寄存器不會有任何損失。另外,'[ebp + 0x0]'總是需要一個disp8/disp32。長時間模式下的R13也一樣。 –

+1

在現代處理器上將分支目標對準到16字節,或者英特爾的官方建議。我發現它通常是合適的。你可能不想打擾排列不經常分支的目標;代碼大小的增加可能不值得最小或不存在的性能增加。 –

3

在二進制機器碼中,比例因子被編碼爲2位移位計數(這就是爲什麼只支持從0到3的2的冪,而不是任意的乘數)。所以機器碼中的[esp+edx]實際編碼爲[esp+edx*1]:仍有一個移位量,但它被設置爲0。

移位計數= 0(即,標度係數= 1)不是特殊情況爲硬件,因爲移位是很容易爲硬件做。所以真的,就硬件的內部行爲而言,你的循環都使用相同的尋址模式。

所以@ Ped7g是正確的:你的環路之間的差異,通過使用inc,而不是add只是歸結爲節省代碼大小。


實際的加速

標籤維基性能環節,尤其是Agner Fog's guides

顯然總結的陣列將去SSE2或AVX2向量快得多。使用PADDD。 (因爲你一次需要去16B,所以你不能使用INC和一個比例因子,你可以加4,並使用比例因子4)。

它可以更有效地避免使用一個索引尋址模式。 Intel Sandybridge-family CPUs before Skylake can't micro-fuse indexed addressing modes。在除DEC/JNZ多個CPU

addN PROC 
    mov ecx, dword ptr [esp+4] ; length 
    lea edx, [esp+8]   ; start of args 
    lea ecx, [edx + ecx*4]  ; end pointer 
    xor eax, eax ; Accumulator 
    AdderLoop:      ; do{ 
     add eax, dword ptr [edx] 
     add edx, 4 
     cmp edx, ecx 
     jb  AdderLoop   ; } while(p < endp) 
    ret 
addN ENDP 

add eax, dword ptr [edx]罐微熔絲連上的SandyBridge,和CMP/JB可以宏觀保險絲。 (AMD和Intel Core2/Nehalem只能融合CMP/JB)。請注意,這會讓我們在循環之外花費額外的指令。


甚至可以減少在循環內的指令的數量,通過向零向上計數,並使用該計數器以指數從陣列的端部。或者,因爲你只是總結的陣列,順序並不重要,你可以循環向下:

addN PROC 
    mov ecx, dword ptr [esp+4] ; length 
    xor eax, eax ; Accumulator 
    AdderLoop:      ; do{ 
     add eax, dword ptr [esp+8 + ecx*4-4] ; the +8 and -4 reduce down to just +4, but written this way for clarity. 
     dec ecx 
     jnz AdderLoop   ; } while(idx != 0) 
    ret 
addN ENDP 

因爲現代的x86 CPU可以做到每個時鐘兩個負載,我們只得到了一半吞吐量而不展開。這項技術適用於所有索引方法。

(這其實不是最優的。這表明了計數,向上,向零技術,我前面提到的,我沒有時間來改寫這個認識到循環向下將是最好的了。)

;; untested: unroll by two with a clever way to handle the odd element 
addN PROC 
    mov ecx, dword ptr [esp+4] ; length 
    lea edx, [esp+8 + ecx*4] ; one-past-the-end 

    xor eax, eax  ; sum1 
    push esi 
    xor esi, esi  ; sum2 

    ;; Unrolling means extra work to handle the case where the length is odd 
    shr ecx, 1   ; ecx /= 2, shifting the low bit into CF 
    cmovc eax, [esp+8] ; sum1 = first element if count was odd 

    neg ecx     ; edx + ecx*8 == 1st or 2nd element 

    AdderLoop:     ; do{ 
     add eax, dword ptr [edx + ecx*8] 
     add esi, dword ptr [edx + ecx*8 + 4] 
     inc ecx 
     jl AdderLoop   ; } while(idx < 0) 

    add eax, esi 
    pop esi 
    ret 
addN ENDP 

在某些CPU上,它的運行速度要快一倍(如果L1緩存中的數據很熱)。使用多個累加器(在這種情況下爲EAX和ESI)對於更高延遲的操作(如FP添加)非常有用。我們在這裏只需要兩個,因爲整數ADD在每個x86微架構上有1個週期延遲。

在Intel之前的Skylake上,使用非索引尋址模式(和add edx, 8)會更好,因爲每個循環有兩個存儲器尋址操作,但仍然只有一個分支(需要CMP/JB代替通過增加索引來設置測試標誌)。

展開時,它更常見的只使用一個不展開循環來處理第一個或最後一個遺留的迭代。我能夠使用shift和CMOV初始化其中一個累加器,因爲我們只展開2,並且索引尋址模式的比例因子爲8.(我也可以使用and ecx, ~1來屏蔽ecx以清除低位而不是將其移位,然後用更高的比例因子進行補償。)