大多數情況下,編寫程序都不會使用匯編語言而是使用高級語言,原因大致有以下幾點:
- 花費更多時間。高級語言的一行相當於匯編語言的幾行、幾十行甚至更多。
- 不夠安全。比如說在進行函數調用時PUSH與POP必須成對出現,高級語言中的函數調用會自動為你執行PUSH與POP的操作,但是匯編語言中就必須由程序員自己保證PUSH與POP一致,否則會導致棧錯亂,使得程序出現不可預知的錯誤。
- 難以調試。相對於高級語言來說,匯編語言除了語法之外,還要對指令有一定的了解,比如說有些指令的操作數只能是常數,有些指令只能有一個操作數為寄存器等。如果因不了解指令的這些限制而出錯,單單從匯編器給出的錯誤信息是比較難發現出錯在什么地方的。
- 難以維護。就可讀性來說,匯編語言不像高級語言那樣直觀,缺少注釋的話,比較難看出一段指令的具體意圖。
- 可移植性差。不同平台(如x86、arm)采用的是不同指令集,如果采用匯編語言開發的話則需要分別為各個平台編寫程序。
- intrinsic function。intrinsic function以類似內聯函數(inline function)的方式封裝了匯編指令,使得用戶只需了解函數的功能,而不用關注具體的實現。這種方式更多地應用在SIMD上,程序員只需調用SIMD的intrinsic function即可達到SIMD的優化效果(見x86、arm)。
- 編譯器優化。編譯器經過這么多年的發展,已經有了相當不錯的的優化效果,而且也有很多可選的優化特性,如inline function、intrinsic function等。
盡管匯編語言不是開發的常用語言,不過它也有很多的應用場景,如系統最底層的開發、程序的反匯編調試等。不過本篇文章主要目的是用匯編語言對程序的運行速度進行優化。文中所描述的處理器為intel系列處理器,用到的匯編語言為IA-32(NASM)。
優化要有針對性
首先要說明的是,匯編優化不是軟件優化的唯一手段。軟件的運行速度會受到很多方面的影響,比如說軟件常常會在加載中耗費大量時間:加載模塊、加載資源文件、加載數據庫、加載底層框架(如C# framework),又比如說軟件可能會由於網絡狀況不好而出現長時間的等待,這都是可以優化的方向。要對軟件進行優化,首先需要找出軟件的瓶頸,針對這個瓶頸采用相對應的優化方法。
這里討論的匯編優化,是當瓶頸出現在CPU運算時采用的優化措施。在實際應用中,匯編優化經常會被用在音頻與圖像處理、加密、排序、數據壓縮以及復雜的數學運算當中。上述的這些程序有一個特點,就是程序中的絕大部分都是在利用CPU進行運算,這種程序稱為CPU-Intensive Program。
一般來說,CPU-Intensive Program處理的數據都是一連串連續的序列,也就是說會循環對數據進行處理。對這種程序來說,循環的內部是程序的主體,在循環內部耗費的時間會占到總運行時間的99%以上。因此為了避免在沒必要優化的代碼上浪費時間,我們進行優化之前需要對程序有個總體的了解,知道哪部分的代碼是最常跑的、急需優化的(critical),哪部分的代碼是次要的(less critical)。如果確實很難分辨程序的主次部分,可以在不同的代碼加上計數器來區分。
指令周期 Instruction cycle
在討論指令優化之前,我們首先需要了解一條指令在CPU中是如何被執行的。
一條指令的執行從開始到結束,我們稱之為指令周期(Instruction cycle),又可以稱為fetch-decode-execute cycle。指令周期按照處理流程可以粗略地為以下幾個部分:
- 指令獲取 Instruction fetch。
- 指令解碼 Instruction decoding。
- 指令執行 Instruction execution。
- 指令休止 Instruction retirement。(Out-of-order)
我們這節的目的就是對指令周期的各部分有個大致的了解,並在此基礎上討論各部分對指令運行效率的影響。
指令獲取 Instruction fetch
指令獲取,顧名思義,目的就是把指令從內存傳輸進CPU以便后續執行。
一般來說,大多數CPU會在一個時鍾周期內獲取16字節的指令,並且獲取方式是16字節對齊,因此對於某些重要的、規模較小的循環代碼,還是有必要盡量把代碼的大小壓縮在16字節內並保證這部分代碼16字節對齊。這涉及到指令的大小優化以及對齊,以后有機會再討論。
在很多處理器中,跳轉指令(jumps)會導致指令的獲取延遲,因此在重要的代碼區有必要減少跳轉指令的數目。不過如果是條件跳轉指令,但是的跳轉的條件不滿足,走非跳轉分支的話,是不會導致指令的獲取延遲的。因此,對於條件跳轉指令,盡量使得指令多走不命中的分支(非跳轉分支)。
指令解碼 Instruction decoding
指令是二進制串,其中包括前綴、操作碼、操作數等各種信息,為了弄清一段二進制串的具體意圖,需要對其進行解碼。解碼過程我們關注的有兩部分:一是復雜指令轉換成多條簡單指令(μops),二是指令的前綴碼。
在復雜指令轉換成μops時,各CPU都有各自的最優的解碼模式,如:PM處理器的最優解碼模式為4-1-1,Core2的最優解碼模式為4-1-1-1。以PM處理器為例,其中第一個數字4代表復雜指令,即可以被分解成4、3或者2個μops的指令,后面的-1-1代表兩個復雜指令之間至少要有兩個簡單指令(μops)。而AMD處理器應該盡量避免使用復雜指令。
有些CPU會對前綴碼的個數有要求,一旦前綴的個數超過某個值,指令的解碼速度將會變慢。有些Intel 32位處理器為1,P4E為2,AMD的處理器為3,Core2則沒有限制。因此應盡量使用少前綴碼的指令。如在32位處理器中,mov ax, 2 比 mov eax, 2 多一個前綴,因此我們傾向於用后者。關於前綴的更多信息請查看Agner Optimize 2. Optimizing subroutines in assembly language: An optimization guide for x86 platforms中的3.4/3.5節。
指令執行 Instruction Execution
這步指的是執行具體的指令,實現指令的目的。這部分優化涉及的篇幅較多,我們會在以后的文章中展開討論。
指令休止 Instruction retirement
只有采用散序處理的CPU才會出現這個步驟,散序處理在下一節有描述(最好先看一下)。我們后面在討論散序處理的時候會說到在執行指令的最后會把指令運行的結果,按照指令原本的順序寫回到用戶可見的寄存器/內存。
在P4處理器上,一個時鍾周期內可以執行3次μop的Instruction retirement,現在的處理器可能可以執行更多的次數。雖然這已經非常高效,不過並不意味着Instruction retirement就不會稱為指令執行的瓶頸,原因如下:
- 雖然程序是散序執行,不過用戶所見的是Instruction retirement的執行結果,而Instruction retirement是順序執行的。
- 如后面散序處理介紹時所述,指令在處理完成后會進入重排序隊列ROB(re-order buffer),不過在P4處理器上ROB大小為40μops,因此如果是執行某些延時很長的指令,如div,那么在div期間,div之后的指令得不到移除,因此ROB有可能會滿隊列,影響到后面指令的執行。
- 條件JUMP指令跳轉必須占用時鍾周期的第一個μop,因此,如果有連續兩個條件跳轉的話,則會浪費時鍾周期的第2、3個μop。
散序處理 Out of Order Execution
散序處理(Out of Order Execution)是現代CPU非常重要特性,x86、arm的新架構基本都支持這種特性,要進行指令優化必須要對散序處理有個基本的了解。
與散序處理相對應的就是順序處理(In Order Execution),兩者在指令的處理步驟上存在明顯區別:
順序處理 In Order Execution :
- 獲取指令。
- 如果操作數已就位(操作數位於寄存器內),則可以把指令分配到相應的功能單元。如果有一個或多個操作數未就位,則需要花費數個時鍾周期來獲取該操作數,在這段時間內處理器會處於停止狀態。
- 所有操作數就位后,指令會被相應的功能單元執行。
- 功能單元把執行結果寫回寄存器。
散序處理 Out of Order Execution :
- 獲取指令。
- 指令進入指令隊列。
- 在操作數就位前,指令會在隊列內進行等待。一旦操作數就位,指令就會處於就緒狀態,處於就緒狀態的指令可以先於前面的未就緒指令出隊列。
- 指令在出隊列后會被分配到相應的功能單元執行。
- 指令進入重排序隊列。
- 重排序隊列需要指令按照原本的順序把執行結果寫回到用戶可見的寄存器/內存,寫回后從隊列中刪除指令。
散序處理的第6步,也就是最后一個步驟,被稱為Instruction retirement,具體實現的部分被稱為Retirement unit。程序指令原來的執行順序叫做program order,不過在散序處理的CPU中,指令的處理順序是基於數據的,當指令在其所依賴的數據就緒后即可開始執行,這種執行順序叫做data order。不過對於用戶來說,為了便於調試與維護,還是有必要在用戶可見的范圍內維持程序原有的順序,retirement unit做的就是這個工作。Retirement unit把指令的執行結果按照program order寫回用戶可見的寄存器,使得用戶以為程序是順序執行的,實際上在CPU內部,指令是散序執行的。
散序處理總體上可以按照上面的描述進行理解,細分開來則涉及到較多的CPU特性。
指令的依賴性
有如下的例子:
; Example 9.1a, Out-of-order execution mov eax, [mem1] imul eax, 6 mov [mem2], eax mov ebx, [mem3] add ebx, 2 mov [mem4], ebx
上面的代碼分別做了兩項完全不相干的工作:
- [mem2] = [mem1] * 6
- [mem4] = [mem3] + 2
CPU散序處理的邏輯如下:
- 如果CPU在執行第一條指令 mov eax, [mem1] 時發現[mem1]不在cache內,則會到內存去獲取[mem1],這會花費數個時鍾周期。在這段時間內,CPU會去尋找下一條可以被處理處理的指令。
- 第二條指令 imul eax, 6 依賴第一條指令,而此時第一條指令還沒執行完成,因此無法執行。
- 第三條指令 mov [mem2], eax 依賴第二條指令,也無法執行。
- 第四條指令 mov ebx, [mem3] 是獨立於上述指令的因此可以執行。
- 如果[mem3]在cache內,第四條指令會比第一條之類早執行完成,此時可以開始執行第五條指令 add ebx, 2 。
CPU的散序處理的目的就是CPU會盡量使得自己忙起來,這需要CPU具有判斷指令間是否具有依賴性的能力。
寄存器重命名
支撐CPU散序處理的另一個重要特性就是寄存器重命名(register renaming)。
匯編代碼中的寄存器都是邏輯寄存器,在CPU的實際處理時會轉換成物理寄存器,這就叫做寄存器重命名。如下面的例子:
; Example 9.1b, Out-of-order execution with register renaming mov eax, [mem1] imul eax, 6 mov [mem2], eax mov eax, [mem3] add eax, 2 mov [mem4], eax
這段代碼由前一小節的代碼演變過來,只是把寄存器ebx改成了eax。這兩段代碼實現的功能並沒有改變,運行時間也是完全一樣。因為每次對邏輯寄存器eax進行寫入的時候,都會為其分配一個新的物理寄存器。這也意味着上面這段代碼中,邏輯寄存器eax共用到了4個物理寄存器,分別為:從[mem1]讀取數據、存放乘法運算結果、從[mem2]讀取數據、存放加法運算結果。
對同一個邏輯寄存器采用多個物理寄存器的做法使得上述這段指令的前三句與后三句相互獨立,能更有效地進行散序處理。這種機制要求CPU有大量的物理寄存器,不過這不是我們關心的問題,一般來說物理寄存器都會多到足以使得這種機制能有效運作。
寄存器假依賴
我們知道寄存器可以8位、16位、32位、64位進行訪問,如:ah/al、ax、eax、rax。對於32位寄存器來說,小於等於16位的寄存器被稱為partial register。由於是同一個邏輯寄存器,所以在對寄存器進行操作時需要多加注意,以防出現假依賴(false dependence)的情況。
如下是一個假依賴的例子:
; Example 9.1c, False dependence of partial register mov eax, [mem1] ; 32 bit memory operand imul eax, 6 mov [mem2], eax mov ax, [mem3] ; 16 bit memory operand add ax, 2 mov [mem4], ax
代碼原本的目的是做兩項完全不相關的工作,但是第四條指令 mov ax, [mem3] 只改變了寄存器eax的低16位,高16位仍然是前面乘法保留下來的結果。對於Intel、AMD等公司的CPU來說,它們不會對partial register進行重命名(register renaming),也就是說第四條指令與前面的指令用的是同一個物理寄存器,這使得 mov ax, [mem3] 依賴於指令 imul eax, 6。
另外有些CPU會對partial register進行重命名,使得 mov ax, [mem3] 不依賴於指令 imul eax, 6,但是最后還是要把 imul eax, 6 中eax的高16位與 mov ax, [mem3] 中的ax進行組合,這又會耗費一段時間。
總之,寄存器假依賴會降低指令的執行效率。假依賴可以通過以下方法來避免:把 mov ax, [mem3] 替換成 movzx eax, [mem3] 。movzx會對寄存器的高位進行補零,如此一來就消除了依賴關系。而64位寄存器與32位寄存器之間就不用擔心依賴關系,因為在對32位寄存器進行寫入的時候,其對應的64為寄存器的高位是自動補零的,即 movzx eax, [mem3] 與movzx rax, [mem3] 是完全相同的效果。
另外,有些指令會對標志寄存器(flag register)進行修改,這也可能會導致出現假依賴。如:INC與DEC這兩個指令只修改了標志寄存器的zero flag與sign flag,不會修改carry flag。
微操作 Micro-operations
Micro-operations(縮寫為μops或uops)就是CPU最基本的一些操作,分為四類:
- 數據傳輸,最常見的就是mov了。
- 算術運算,如add,mul等。
- 邏輯運算,如and,or等。
- 移位,如shl等。
某些復雜的指令在處理時可以分成多個μops。如下面的例子:
; Example 9.2, Splitting instructions into uops push eax call SomeFunction
其中 push eax 會把棧頂下移,然后把eax移入棧內,在分解成μops會把這兩步分開,得到如 sub esp, 4 與 mov [esp], eax 的μops集。call指令依賴於棧頂esp。假設這兩行指令的前面需要進行大量計算才能得到eax,那么如果push指令不分解成μops的話,那么call指令就跟push指令形成依賴關系,必須先得到eax的計算結果再執行push,最后才能執行call。在分解成μops后,call指令依賴的只有 sub esp 4 ,因此可以在得到eax結果之前就開始執行。
多處理單元 Execution unitis
現代CPU一般都會有多個處理單元使得Out of Order Execution可以更高效的運行。如大多數CPU都有兩個以上的ALU(Arithmetic Logic Unit),因此在一個時鍾周期內可以同時進行兩項或者更多的整數運算;CPU通常會有一個浮點加法與一個浮點乘法處理單元,因此浮點數的乘法與浮點數的加法可以同時進行;CPU可能也會分別有一個內存讀單元與內存寫單元,因此內存的讀與寫可以同時進行;CPU也可以同時分別執行整數運算、浮點運算、讀寫內存等。各個處理單元相互獨立,使得CPU可以在一個時鍾周期之內同時處理多條指令。
流水線指令 Pipelined instructions
浮點數的運算相比整數運算需要更長的執行時間,一般都超過一個時鍾周期,不過浮點數運算可以細分成更小的處理單位,各個單位組成流水線。例如:不用等前一條浮點加法指令執行完畢就可以開始執行下一條浮點加法指令。當然,不止有浮點數指令,還有其它的指令也可以進行流水線處理,不過不同的芯片、不同的指令的執行周期不同,指令的相關資料可以去芯片商的官網查找或者Agner Optimize的The microarchitecture of Intel, AMD and VIA CPUs以及Instruction tables。
指令的延時與吞吐量 Instruction latency and throughput
- 延時(latency)是指一條指令從開始執行到執行結果就緒(可以被另一條指令使用)所花費的時間,以時鍾周期為單位。執行一條依賴鏈(dependency chain)所花的時間是該依賴鏈內所有指令的延時的總和。
- 吞吐量(throughput)是指在一個時鍾周期之內,同一類指令所能執行的次數。由於CPU在指令的處理上采用了pipeline等各種優化方式,而pipeline的特點就是就算是多條相同的指令也可以同時執行,因此通常有latency > 1/throughput而非相等。
以Core2處理器為例,其浮點加法的latency為3個時鍾周期,throughput為1。這意味着在一條依賴鏈內,處理器需要用3個時鍾周期來執行浮點加法,然后才能去執行該依賴鏈內的下一條指令;對於不在這條依賴鏈內的指令,如果同樣是是浮點加法指令,只需在1個時鍾周期之后即可開始執行。
如下是一些指令的典型的延時與吞吐量表格,為了更好地對比,列出的是1/throughput,指的是一條指令在開始執行之后,間隔多久(平均值)才能開始執行另一條同類型並且不在同一依賴鏈的指令。
Instruction | latency | 1/throughput |
Interger move | 1 | 0.33-0.5 |
Interger addition | 1 | 0.33-0.5 |
Interger boolean | 1 | 0.33-1 |
Interger shift | 1 | 0.33-1 |
Interger multiplication | 3-10 | 1-2 |
Interger division | 20-80 | 20-40 |
Floating point addition | 3-6 | 1 |
Floating point multiplication | 4-8 | 1-2 |
Floating point division | 20-45 | 20-45 |
Interger vector addition (XMM) | 1-2 | 0.5-2 |
Interger vector multiplication (XMM) | 3-7 | 1-2 |
Floating point vector addition (XMM) | 3-5 | 1-2 |
Floating point vector multiplication (XMM) | 4-7 | 1-4 |
Floating point vector division (XMM) | 20-60 | 20-60 |
Memory read (cache) | 3-4 | 0.5-1 |
Memory write (cache) | 3-4 | 1 |
Jump or call | 0 | 1-2 |
各CPU更具體的latency與throughput可以去查看Agner Optimize的Manual 4: "Instruction tables"。