編寫高效的C程式與C程式碼優化

發表於2014-12-24

雖然對於優化C程式碼有很多有效的指導方針,但是對於徹底地瞭解編譯器和你工作的機器依然無法取代,通常,加快程式的速度也會加大程式碼量。這些增加的程式碼也會影響一個程式的複雜度和可讀性,這是不可接受的,比如你在一些小型的裝置上程式設計,例如:移動裝置、PDA……,這些有著嚴格的記憶體限制,於是,在優化的座右銘是:寫程式碼在記憶體和速度都應該優化。

整型數 / Integers

在我們知道使用的數不可能是負數的時候,應該使用unsigned int取代int,一些處理器處理整數算數運算的時候unsigned int比int快,於是,在一個緊緻的迴圈裡面定義一個整型變數,最好這樣寫程式碼:

然而,我們不能保證編譯器會注意到那個register關鍵字,也有可能,對某種處理器來說,有沒有unsigned是一樣的。這兩個關鍵字並不是可以在所有的編譯器中應用。記住,整形數運算要比浮點數運算快得多,因為處理器可以直接進行整型數運算,浮點數運算需要依賴於外部的浮點數處理器或者浮點數數學庫。我們處理小數的時候要精確點些(比如我們在做一個簡單的統計程式時),要限制結果不能超過100,要儘可能晚的把它轉化成浮點數。

除法和餘數 / Division and Remainder

在標準的處理器中,根據分子和分母的不同,一個32位的除法需要20-140個時鐘週期來執行完成,等於一個固定的時間加上每個位被除的時間。

Time (分子/ 分母) = C0 + C1* log2 (分子/分母)

= C0 + C1 * (log2 (分子) – log2 (分母)).
現在的ARM處理器需要消耗20+4.3N個時鐘週期,這是一個非常費時的操作,要儘可能的避免。在有些情況下,除法表示式可以用乘法表達是來重寫。比方說,(a/b)>c可以寫成a>(c*b),條件是我們已經知道b為非負數而且b*c不會超過整型數的取值範圍。如果我們能夠確定其中的一個運算元為unsigned,那麼使用無符號除法將會更好,因為它要比有符號除法快得多。

合併除法運算和取餘運算 / Combining division and remainder

在一些情況下,除法運算和取餘運算都需要用到,在這種情況下,編譯器會將除法運算和取餘運算合併,因為除法運算總是同時返回商和餘數。如果兩個運算都要用到,我們可以將他們寫到一起。

這兩種除法都會避免呼叫除法函式,另外,無符號的除法要比有符號的除法使用更少的指令。有符號的除法要耗費更多的時間,因為這種除法是使最終結果趨向於零的,而移位則是趨向於負無窮。

取模運算的替換 / An alternative for modulo arithmetic

我們一般使用取餘運算進行取模,不過,有時候使用 if 語句來重寫也是可行的。考慮下面的兩個例子:

第二個例子要比第一個更可取,因為由它產生的程式碼會更快,注意:這只是在count取值範圍在0 – 59之間的時候才行。

但是我們可以使用如下的程式碼(筆者補充)實現等價的功能:

使用陣列索引 / Using array indices

假設你要依據某個變數的值,設定另一個變數的取值為特定的字元,你可能會這樣做:

或者這樣:

有一個簡潔且快速的方式是簡單的將變數的取值做成一個字串索引,例如:

 全域性變數 / Global variables

全域性變數不會被分配在暫存器上,修改全域性變數需要通過指標或者呼叫函式的方式間接進行。所以編譯器不會將全域性變數儲存在暫存器中,那樣會帶來額外的、不必要的負擔和儲存空間。所以在比較關鍵的迴圈中,我們要不使用全域性變數。
如果一個函式要頻繁的使用全域性變數,我們可以使用區域性變數,作為全域性變數的拷貝,這樣就可以使用暫存器了。條件是本函式呼叫的任何子函式不使用這些全域性變數。

舉個例子:

可以看到test1()中每次加法都需要讀取和儲存全域性變數errs,而在test2()中,localerrs分配在暫存器上,只需要一條指令。

使用別名 / Using Aliases

考慮下面的例子:

即使*data從來沒有變化,編譯器卻不知道anyfunc()沒有修改它,於是程式每次用到它的時候,都要把它從記憶體中讀出來,可能它只是某些變數的別名,這些變數在程式的其他部分被修改。如果能夠確定它不會被改變,我們可以這樣寫:

這樣會給編譯器優化工作更多的選擇餘地。

活躍變數和洩漏 / Live variables and spilling

暫存器的數量在每個處理器當中都是固定的,所以在程式的某個特定的位置,可以儲存在暫存器中的變數的數量是有限制的。有些編譯器支援“生命週期分割”(live-range splitting),也就是說在函式的不同部分,變數可以被分配到不同的暫存器或者記憶體中。變數的生存範圍被定義成:起點是對該變數的一次空間分配,終點是在下次空間分配之前的最後一次使用之間。在這個範圍內,變數的值是合法的,是活的。在生存範圍之外,變數不再被使用,是死的,它的暫存器可以供其他變數使用,這樣,編譯器就可以安排更多的變數到暫存器當中。
可分配到暫存器的變數需要的暫存器數量等於經過生命範圍重疊的變數的數目,如果這個數目超過可用的暫存器的數量,有些變數就必須被暫時的儲存到記憶體中。這種處理叫做“洩漏(spilling)”。
編譯器優先釋放最不頻繁使用的變數,將釋放的代價降到最低。可以通過以下方式避免變數的“釋放”:

  • 限制活躍變數的最大數目:通常可以使用簡單小巧的表示式,在函式內部不使用太多的變數。把大的函式分割成更加簡單的、更加小巧的多個函式,也可能會有所幫助。
  • 使用關鍵字register修飾最經常使用的變數:告訴編譯器這個變數將會被經常用到,要求編譯器使用非常高的優先順序將此變數分配到暫存器中。儘管如此,在某些情況下,變數還是可能被洩漏。

變數型別 / Variable Types

C編譯器支援基本的變數型別:char、short、int、long(signed、unsigned)、float、double。為變數定義最恰當的型別,非常重要,因為這樣可以減少程式碼和資料的長度,可以非常顯著的提高效率。

區域性變數 / Local variables

如果可能,區域性變數要避免使用char和short。對於char和short型別,編譯器在每次分配空間以後,都要將這種區域性變數的尺寸減少到8位或16位。這對於符號變數來說稱為符號擴充套件,對無符號變數稱為無符號擴充套件。這種操作是通過將暫存器左移24或16位,然後再有符號(或無符號的)右移同樣的位數來實現的,需要兩條指令(無符號位元組變數的無符號擴充套件需要一條指令)。
這些移位操作可以通過使用int和unsigned int的區域性變數來避免。這對於那些首先將資料調到區域性變數然後利用區域性變數進行運算的情況尤其重要。即使資料以8位或16位的形式輸入或輸出,把他們當作32位來處理仍是有意義的。
我們來考慮下面的三個例子函式:

他們的運算結果是相同的,但是第一個程式碼片斷要比其他片斷執行的要快。

指標 / Pointers

如果可能,我們應該使用結構體的引用作為引數,也就是結構體的指標,否則,整個結構體就會被壓入堆疊,然後傳遞,這會降低速度。程式適用值傳遞可能需要幾K位元組,而一個簡單的指標也可以達到同樣的目的,只需要幾個位元組就可以了。
如果在函式內部不會改變結構體的內容,那麼就應該將引數宣告為const型的指標。舉個例子:

這個例子程式碼告知編譯器在函式內部不會改變外部結構體的內容,訪問他們的時候,不需要重讀。還可以確保編譯器捕捉任何修改這個只讀結構體的程式碼,給結構體以額外的保護。

指標鏈 / Pointer chains

指標鏈經常被用來訪問結構體的資訊,比如,下面的這段常見的程式碼:

程式碼中,處理器在每次賦值操作的時候都要重新裝載p->pos,因為編譯器不知道p->pos->x不是p->pos的別名。更好的辦法是將p->pos快取成一個區域性變數,如下:

另一個可能的方法是將Point3結構體包含在Object結構體中,完全避免指標的使用。

條件的執行 / Conditional Execution

條件執行主要用在if語句中,同時也會用到由關係運算(<,==,>等)或bool運算(&&, !等)組成的複雜的表示式。儘可能的保持if和else語句的簡單是有好處的,這樣才能很好的條件化。關係表示式應該被分成包含相似條件的若干塊。
下面的例子演示了編譯器如何使用條件執行:

條件被分組,便以其能夠條件化他們。

Boolean表示式和範圍檢查 / Boolean Expressions & Range checking

有一種常見的boolean表示式被用來檢查是否一個變數取值在某個特定的範圍內,比方說,檢查一個點是否在一個視窗內。

這裡還有一個更快的方法:把(x >= min && x < max) 轉換成 (unsigned)(x-min) < (max-min). 尤其是min為0時,更為有效。下面是優化後的程式碼:

Boolean表示式&與零的比較 / Boolean Expressions & Compares with zero

在比較(CMP)指令後,相應的處理器標誌位就會被設定。這些標誌位也可以被其他的指令設定,諸如MOV, ADD, AND, MUL, 也就是基本的數學和邏輯運算指令(資料處理指令)。假如一條資料處理指令要設定這些標誌位,那麼N和Z標誌位的設定方法跟把數字和零比較的設定方法是一樣的。N標誌位表示結果是不是負數,Z標誌位表示結果是不是零。
在C語言中,處理器中的N和Z標誌位對應的有符號數的關係運算子是x < 0, x >= 0, x == 0, x != 0,無符號數對應的是x == 0, x != 0 (or x > 0)。
C語言中,每用到一個關係運算子,編譯器就會產生一個比較指令。如果關係運算子是上面的其中一個,在資料處理指令緊跟比較指令的情況下,編譯器就會將比較指令優化掉。比如:

這樣做,會在關鍵迴圈中節省比較指令,使程式碼長度減少,效率增加。C語言中沒有借位(carry)標誌位和溢位(overflow)標誌位的概念,所以如果不使用內嵌組合語言,要訪問C和V標誌位是不可能的。儘管如此,編譯器支援借位標誌位(無符號數溢位),比方說:

惰性評估計算 / Lazy Evaluation Exploitation

在類似與這樣的 if(a>10 && b=4) 語句中, 確保AND表示式的第一部分最有可能為false, 結果第二部分極有可能不被執行.

用switch() 代替if…else…,在條件選擇比較多的情況下,可以用if…else…else…,像這樣:

使用switch可以更快:

在if語句中,即使是最後一個條件成立,也要先判斷所有前面的條件是否成立。Switch語句能夠去除這些額外的工作。如果你不得不使用if…else,那就把最可能的成立的條件放在前面。

二分分解 / Binary Breakdown

把判斷條件做成二進位制的風格,比如,不要使用下面的列表:

而採用:

甚至:

慢速、低效:

快速、高效:

以上是兩個case語句之間的比較

switch語句和查詢表 / Switch statement vs. lookup tables

switch語句通常用於以下情況:

  • 呼叫幾個函式中的一個
  • 設定一個變數或返回值
  • 執行幾個程式碼片斷中的一個

如果case表示是密集的,在使用switch語句的前兩種情況中,可以使用效率更高的查詢表。比如下面的兩個實現彙編程式碼轉換成字串的例程:

第一個例程需要240個位元組,第二個只需要72個。

迴圈終止 / Loop termination

如果不加留意地編寫迴圈終止條件,就可能會給程式帶來明顯的負擔。我們應該儘量使用“倒數到零”的迴圈,使用簡單的迴圈終止條件。迴圈終止條件相對簡單,程式在執行的時候也會消耗相對少的時間。拿下面兩個計算n!的例子來說,第一個例子使用遞增迴圈,第二個使用遞減迴圈。

結果是,第二個例子要比第一個快得多。

更快的for()迴圈 / Faster for() loops

這是一個簡單而有效的概念,通常情況下,我們習慣把for迴圈寫成這樣:

i 值依次為:0,1,2,3,4,5,6,7,8,9

在不在乎迴圈計數器順序的情況下,我們可以這樣:

i 值依次為: 9,8,7,6,5,4,3,2,1,0,而且迴圈要更快

這種方法是可行的,因為它是用更快的i–作為測試條件的,也就是說“i是否為非零數,如果是減一,然後繼續”。相對於原先的程式碼,處理器不得不“把i減去10,結果是否為非零數,如果是,增加i,然後繼續”,在緊密迴圈(tight loop)中,這會產生顯著的區別。
這種語法看起來有一點陌生,卻完全合法。迴圈中的第三條語句是可選的(無限迴圈可以寫成這樣for(;;)),下面的寫法也可以取得同樣的效果:

或者:

我們唯一要小心的地方是要記住迴圈需要停止在0(如果迴圈是從50-80,這樣做就不行了),而且迴圈的計數器為倒計數方式。

另外,我們還可以把計數器分配到暫存器上,可以產生更為有效的程式碼。這種將迴圈計數器初始化成迴圈次數,然後遞減到零的方法,同樣適用於while和do語句。

混合迴圈/ Loop jamming

在可以使用一個迴圈的場合,決不要使用兩個。但是如果你要在迴圈中進行大量的工作,超過處理器的指令緩衝區,在這種情況下,使用兩個分開的迴圈可能會更快,因為有可能這兩個迴圈都被完整的儲存在指令緩衝區裡了。

函式迴圈 / Function Looping

呼叫函式的時候,在效能上就會付出一定的代價。不光要改變程式指標,還要將那些正在使用的變數壓入堆疊,分配新的變數空間。為了提高程式的效率,在程式的函式結構上,有很多工作可以做。保證程式的可讀性的同時,還要儘量控制程式的大小。
如果一個函式在一個迴圈中被頻繁呼叫,就可以考慮將這個迴圈放在函式的裡面,這樣可以免去重複呼叫函式的負擔,比如:

可以寫成:

展開迴圈 / Loop unrolling

為了提高效率,可以將小的迴圈解開,不過這樣會增加程式碼的尺寸。迴圈被拆開後,會降低迴圈計數器更新的次數,減少所執行的迴圈的分支數目。如果迴圈只重複幾次,那它完全可以被拆解開,這樣,由迴圈所帶來的額外開銷就會消失。

比如:

因為在每次的迴圈中,i 的值都會增加,然後檢查是否有效。編譯器經常會把這種簡單的迴圈解開,前提是這些迴圈的次數是固定的。對於這樣的迴圈:

就不可能被拆解,因為我們不知道它迴圈的次數到底是多少。不過,將這種型別的迴圈拆解開並不是不可能的。

與簡單迴圈相比,下面的程式碼的長度要長很多,然而具有高得多的效率。選擇8作為分塊大小,只是用來演示,任何合適的長度都是可行的。例子中,迴圈的成立條件每八次才被檢驗一次,而不是每次都要檢驗。如果需要處理的陣列的大小是確定的,我們就可以使用陣列的大小作為分塊的大小(或者是能夠整除陣列長度的數值)。不過,分塊的大小跟系統的快取大小有關。

 

計算非零位的個數 / counting the number of bits set

例1:測試單個的最低位,計數,然後移位。

例2:先除4,然後計算被4處的每個部分。迴圈拆解經常會給程式優化帶來新的機會。

儘早地退出迴圈 / Early loop breaking

通常沒有必要遍歷整個迴圈。舉例來說,在陣列中搜尋一個特定的值,我們可以在找到我們需要值之後立刻退出迴圈。下面的例子在10000個數字中搜尋-99。

這樣做是可行的,但是不管這個被搜尋到的專案出現在什麼位置,都會搜尋整個陣列。跟好的方法是,再找到我們需要的數字以後,立刻退出迴圈。

如果數字出現在位置23上,迴圈就會終止,忽略剩下的9977個。

函式設計 / Function Design

保持函式短小精悍,是對的。這可以使編譯器能夠跟高效地進行其他的優化,比如暫存器分配。

呼叫函式的開銷 / Function call overhead

對處理器而言,呼叫函式的開銷是很小的,通常,在被呼叫函式所進行的工作中,所佔的比例也很小。能夠使用暫存器傳遞的函式引數個數是有限制的。這些引數可以是整型相容的(char,short,int以及float都佔用一個字),或者是4個字以內的結構體(包括2個字的double和long long)。假如引數的限制是4,那麼第5個及後面的字都會被儲存到堆疊中。這會增加在呼叫函式是儲存這些引數的,以及在被呼叫函式中恢復這些引數的代價。

g2函式中,第5、6個引數被儲存在堆疊中,在f2中被恢復,每個引數帶來2次記憶體訪問。

最小化引數傳遞的開銷 / Minimizing parameter passing overhead

為了將傳遞引數給函式的代價降至最低,我們可以:
儘可能確保函式的形參不多於四個,甚至更少,這樣就不會使用堆疊來傳遞引數。
如果一個函式形參多於四個,那就確保在這個函式能夠做大量的工作,這樣就可以抵消由傳遞堆疊引數所付出的代價。
用指向結構體的指標作形參,而不是結構體本身。
把相關的引數放到一個結構裡裡面,然後把它的指標傳給函式,可以減少引數的個數,增加程式的可讀性。
將long型別的引數的個數降到最小,因為它使用兩個引數的空間。對於double也同樣適用。
避免出現引數的一部分使用暫存器傳輸,另一部分使用堆疊傳輸的情況。這種情況下引數將被全部壓到堆疊裡。
避免出現函式的引數個數不定的情況。這種情況下,所有引數都使用堆疊。

葉子函式 / Leaf functions

如果一個函式不再呼叫其他函式,這樣的函式被稱為葉子函式。在許多應用程式中,大約一半的函式呼叫是對葉子函式的呼叫。葉子函式在所有平臺上都可以得到非常高效的編譯,因為他們不需要進行引數的儲存和恢復。在入口壓棧和在出口退棧的代價,跟一個足夠複雜的需要4個或者5個引數的葉子函式所完成的工作相比,是非常小的。如果可能的話,我們就要儘量安排經常被呼叫的函式成為葉子函式。函式被呼叫的次數可以通過模型工具(profiling facility)來確定。這裡有幾種方法可以確保函式被編譯成葉子函式:

  • 不呼叫其他函式:包括那些被轉換成呼叫C語言庫函式的運算,比如除法、浮點運算。
  • 使用關鍵字__inline修飾小的函式。

行內函數 / Inline functions

對於所有除錯選項,內嵌函式是被禁止的。使用inline關鍵字修飾函式後,跟普通的函式呼叫不同,程式碼中對該函式的呼叫將會被函式體本身代替。這會使程式碼更快,另一方面它會影響程式碼的長度,尤其是內嵌函式比較大而且經常被呼叫的情況下。

使用內嵌函式有幾個優點:

  • 沒有呼叫函式的開銷。

因為函式被直接代替,沒有任何額外的開銷,比如儲存和恢復暫存器。

  • 更低的引數賦值開銷。

引數傳遞的開銷通常會更低,因為它不需要複製變數。如果其中一些引數是常量,編譯器還可以作進一步的優化。

內嵌函式的缺點是如果函式在許多地方被呼叫,將會增加程式碼的長度。長度差別的大小非常依賴於內嵌函式的大小和呼叫的次數。

僅將少數關鍵函式設定成內嵌函式是明智的。如果設定得當,內嵌函式可以減少程式碼的長度,一次函式呼叫需要一定數量的指令,但是,使用優化過的內嵌函式可以編譯成更少的指令。

使用查詢表 / Using Lookup Tables

有些函式可以近似成查詢表,這樣可以顯著的提高效率。查詢表的精度一般比計算公式的精度低,不過在大多數程式中,這種精度就足夠了。
許多訊號處理軟體(比如MODEM調製軟體)會大量的使用sin和cos函式,這些函式會帶來大量的數學運算。對於實時系統來說,精度不是很重要,sin/cos查詢表顯得更加實用。使用查詢表的時候,儘量將相近的運算合併成一個查詢表,這樣要比使用多個查詢表要更快和使用更少的空間。

浮點運算 / Floating-Point Arithmetic

儘管浮點運算對於任何處理器來講都是很費時間的,有的時候,我們還是不得不用到浮點運算,比方說實現訊號處理。儘管如此,編寫浮點運算程式碼的時候,我們要牢記:

  • 浮點除法是慢的

除法要比加法或者乘法慢兩倍,我們可以把被一個常數除的運算寫成被這個數的倒數乘(比如,x=x/3.0寫成x=x*(1.0/3.0))。倒數的計算在編譯階段就被完成。

  • 使用float代替double

Float型變數消耗更少的記憶體和暫存器,而且因為它的低精度所以具有更高的效率。在精度足夠的情況下,就要使用float。

  • 不要使用先驗函式(transcendental functions),

先驗函式(比如sin,cos,log)是通過使用一系列的乘法和加法實現的,所以這些運算會比普通的乘法慢10倍以上。

  • 簡化浮點表示式

編譯器在整型跟浮點型混合的運算中不會進行太多的優化。比如3 * (x / 3) 不會被優化成x,因為浮點運算通常會導致精度的降低,甚至表示式的順序都是重要的: (a + b)     + c 不等於 a + (b + c)。因此,進行手動的優化是有好處的。

不過,在特定的場合下,浮點運算的效率達不到指定的水平,這種情況下,最好的辦法可能是放棄浮點運算,轉而使用定點運算。當變數的變化範圍足夠的小,定點運算要比浮點運算精度更高、速度更快。

其他的技巧 / Misc tips

一般情況下,可以用儲存空間換取時間。你可以快取那些經常用到的資料,而不是每次都重新計算、或者重新裝載。比如sin/cos表,或者偽隨機數的表(如果你不是真的需要隨機數,你可以在開始的時候計算1000個,在隨後的程式碼中重複利用就是了)
儘量少的使用全域性變數。
將一個檔案內部的變數宣告成靜態的,除非它有必要成為全域性的。
不要使用遞迴。遞迴可以使程式碼非常整齊和美觀,但會產生大量的函式呼叫和開銷。
訪問單維陣列要比多維陣列快
使用#defined巨集代替經常用到的小函式。

 

引用/References

其他網路資源 / Other URLs

http://www.xs4all.nl/~ekonijn/loopy.html
http://www.public.asu.edu/~sshetty/Optimizing_Code_Manual.doc
http://www.abarnett.demon.co.uk/tutorial.html

 

 

 

 

 

 

 

 

相關文章