編寫高效的C代碼
原文標題:Writing Efficient C and C Code Optimization
原文地址:http://www.codeproject.com/cpp/C___Code_Optimization.asp
原文作者:Koushik Ghosh
譯文作者:zhigang
前言
前段時間,我開發了一個輕量級的JPEG庫,用來在某種移動設備上不失真地顯示圖像。我注意到一些技巧可以使程序運行的更快。在這篇文章里,我收集和整理了所有的這些方法,用來在運行速度和內存占用方面對C代碼進行優化。
盡管編寫C代碼,已經有了許多指導原則可以參考,但這并不表示你已經徹底的了解你正在使用的編譯器和CPU。
經常的,為了使代碼運行的更快,會導致代碼長度的增加,所謂以空間換時間,但是代碼長度的增加有一種負面作用就是使程序的復雜度增加、可讀性減弱。尤其是當你的程序最終運行環境是手機、PDA之類的對內存要求比較苛刻的設備的時候,這將會是不可接受的。所以,我們優化的宗旨就是使程序在內存占用和運行速度兩個方面都要得到優化。
聲明
實際上,在我的那個項目中,我使用了ARM公司的一篇文章中提到的技巧,因為我的程序是基于ARM平臺的,你可以從這里http://www.arm.com/pdfs/DAI0034A_efficient_c.pdf取得這篇文章,我也從Internet上收集了許多其它的文章。但并不是每篇文章中提到的所有技巧都有用,所以,在這里,我只收集了有用且有效的,還有,我修改了其中的一些技巧使它幾乎適用于所有情況,而不僅僅是ARM。
我所作的工作只是將各個站點的技巧收集到一起,尤其是我在上面提到的那個PDF文件,我從來沒有說過這些技巧使我自己的發明。在本文末尾的參考文獻部分,我羅列了所有技巧的來源。
哪里需要優化
沒有這一點,所有的討論都無從談起。首先也是最重要的是要找出哪里需要優化,程序的那一部分或者那個模塊運行速度慢或者使用大量內存。程序的每個部分都被分別優化,自然而然的整個程序又會變得運行的比較快了。
優化應該主要針對程序中最經常運行的部分,尤其是被內部循環反復調用的函數。
一個經驗豐富的程序員會很容易找出程序中需要著重優化的部分,另外,還有許多工具可以用來確定這些部分。我曾經用Visual C++ 的IDE內建的Profiler,另外一個我用過的工具是Intel Vtune,這是一個非常好用的Profiler,都可以用來尋找程序中最費時的地方。以我的經驗來看,導致程序運行速度慢得罪魁禍首可能就是某個內部或嵌套循環,也可能是對第三方庫函數的調用。
整型數 / Integers
如果一個變量的取值范圍是非負的,我們應該使用unsigned int,而不是int。一些處理器在處理無符號整型數要比處理符號整型數快得多,這也是一個好的習慣,有利于代碼的自我文檔化(self-documenting)。
那么,在一個小循環(tight loop)中,定義一個整型變量,最好類似這樣:
register unsigned int variable_name;
然而,我們不能保證編譯器會注意到那個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
在一些情況下,除法運算和取余運算都需要用到,在這種情況下,編譯器會將除法運算和取余運算合并,因為除法運算總是同時返回商和余數。如果兩個運算都要用到,我們可以將他們寫到一起(譯者:不要細究下面的代碼的含義,只是闡述寫到“一起”)
int func_div_and_mod (int a, int b) {
return (a / b) + (a % b);
}
除數是2的冪的除法和取余 / Division and remainder by powers of two
如果除法運算中的除數是2的冪,我們對這個除法運算還可以進一步優化,編譯器會使用移位運算來進行這種除法運算。所以,我們要盡可能縮放比例為2的冪(比方說要用64而不用66)。如果是無符號數,它要比有符號的快得多。
typedef unsigned int uint;
uint div32u (uint a) {
return a / 32;
}
int div32s (int a){
return a / 32;
}
這兩個除法都會避免調用除法函數,另外,無符號的除法要比有符號的除法使用更少的指令。有符號的除法要耗費更多的時間,因為除法是使最終結果趨向于零的,而移位則是趨向于負無窮。
取模運算的變通 / An alternative for modulo arithmetic
我們一般使用取余運算進行取模,不過,有時候使用if語句也是可行的。考慮下面的兩個例子:
uint modulo_func1 (uint count)
{
return (++count % 60);
}
uint modulo_func2 (uint count)
{
if (++count >= 60)
count = 0;
return (count);
}
第二個例子要比第一個更可取,因為由它產生的代碼會更快,注意:這只是在count取值范圍在0 – 59之間的時候才行。
使用數組索引 / Using array indices
假設你要依據某個變量的值,設置另一個變量的取值為特定的字符,你可能會這樣做:
switch ( queue ) {
case 0 : letter = 'W';
break;
case 1 : letter = 'S';
break;
case 2 : letter = 'U';
break;
}
或者這樣:
if ( queue == 0 )
letter = 'W';
else if ( queue == 1 )
letter = 'S';
else
letter = 'U';
有一個簡潔且快速的方式是簡單的將變量的取值做成一個字符串索引,例如:
static char *classes="WSU";
letter = classes[queue];
全局變量 / Global variables
全局變量不會被分配在寄存器上,修改全局變量需要通過指針或者調用函數的方式間接進行。所以編譯器不會將全局變量存儲在寄存器中,那樣會帶來額外的、不必要的負擔和存儲空間。所以在比較關鍵的循環中,我們要不使用全局變量。
如果一個函數要頻繁的使用全局變量,我們可以使用局部變量,作為全局變量的拷貝,這樣就可以使用寄存器了。條件是本函數調用的任何子函數不使用這些全局變量。
舉個例子:
int f(void);
int g(void);
int errs;
void test1(void)
{
errs += f();
errs += g();
}
void test2(void)
{
int localerrs = errs;
localerrs += f();
localerrs += g();
errs = localerrs;
}
可以看到test1()中每次加法都需要讀取和存儲全局變量errs,而在test2()中,localerrs分配在寄存器上,只需要一條指令。
使用別名 / Using Aliases
考慮下面的例子:
void func1( int *data )
{
int i;
for(i=0; i<10; i++)
{
anyfunc( *data, i);
}
}
即使*data從來沒有變化,編譯器卻不知道anyfunc()沒有修改它,于是程序每次用到它的時候,都要把它從內存中讀出來,可能它只是某些變量的別名,這些變量在程序的其他部分被修改。如果能夠確定它不會被改變,我們可以這樣寫:
void func1( int *data )
{
int i;
int localdata;
localdata = *data;
for(i=0; i<10; i++)
{
anyfunc ( localdata, i);
}
}
這樣會給編譯器優化工作更多的選擇余地。
活的變量和泄漏 / 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位來處理仍是有意義的。
我們來考慮下面的三個例子函數:
int wordinc (int a)
{
return a + 1;
}
short shortinc (short a)
{
return a + 1;
}
char charinc (char a)
{
return a + 1;
}
他們的運算結果是相同的,但是第一個代碼片斷要比其他片斷運行的要快。
指針 / Pointers
如果可能,我們應該使用結構體的引用作為參數,也就是結構體的指針,否則,整個結構體就會被壓入堆棧,然后傳遞,這會降低速度。程序適用值傳遞可能需要幾K字節,而一個簡單的指針也可以達到同樣的目的,只需要幾個字節就可以了。
如果在函數內部不會改變結構體的內容,那么就應該將參數聲明為const型的指針。舉個例子:
void print_data_of_a_structure ( const Thestruct *data_pointer)
{
...printf contents of the structure...
}
這個例子代碼告知編譯器在函數內部不會改變外部結構體的內容,訪問他們的時候,不需要重讀。還可以確保編譯器捕捉任何修改這個只讀結構體的代碼,給結構體以額外的保護。
指針鏈 / Pointer chains
指針鏈經常被用來訪問結構體的信息,比如,下面的這段常見的代碼:
typedef struct { int x, y, z; } Point3;
typedef struct { Point3 *pos, *direction; } Object;
void InitPos1(Object *p)
{
p->pos->x = 0;
p->pos->y = 0;
p->pos->z = 0;
}
代碼中,處理器在每次賦值操作的時候都要重新裝載p->pos,因為編譯器不知道p->pos->x不是p->pos的別名。更好的辦法是將p->pos緩存成一個局部變量,如下:
void InitPos2(Object *p)
{
Point3 *pos = p->pos;
pos->x = 0;
pos->y = 0;
pos->z = 0;
}
另一個可能的方法是將Point3結構體包含在Object結構體中,完全避免指針的使用。
有條件的執行 / Conditional Execution
條件執行主要用在if語句中,同時也會用到由關系運算(<,==,>等)或bool運算(&&,!等)組成的復雜的表達式。
盡可能的保持if和else語句的簡單是有好處的,這樣才能很好的條件化。關系表達式應該被分成包含相似條件的若干塊。
下面的例子演示了編譯器如何使用條件執行:
int g(int a, int b, int c, int d)
{
if (a > 0 && b > 0 && c < 0 && d < 0)
//分組化的條件被捆綁在一起
return a + b + c + d;
return -1;
}
條件被分組,便以其能夠條件化他們。
Boolean表達式和范圍檢查 / Boolean Expressions & Range checking
有一種常見的boolean表達式被用來檢查是否一個變量取值在某個特定的范圍內,比方說,檢查一個點是否在一個窗口內。
bool PointInRectangelArea (Point p, Rectangle *r)
{
return (p.x >= r->xmin && p.x < r->xmax &&
p.y >= r->ymin && p.y < r->ymax);
}
這里還有一個更快的方法:把(x >= min && x < max) 轉換成 (unsigned)(x-min) < (max-min). 尤其是min為0時,更為有效。下面是優化后的代碼:
bool PointInRectangelArea (Point p, Rectangle *r)
{
return ((unsigned) (p.x - r->xmin) < r->xmax &&
(unsigned) (p.y - r->ymin) < r->ymax);
}
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語言中,每用到一個關系運算符,編譯器就會產生一個比較指令。如果關系運算符是上面的其中一個,在數據處理指令緊跟比較指令的情況下,編譯器就會將比較指令優化掉。比如:
int aFunction(int x, int y)
{
if (x + y < 0)
return 1;
else
return 0;
}
這樣做,會在關鍵循環中節省比較指令,使代碼長度減少,效率增加。C語言中沒有借位(carry)標志位和溢出(overflow)標志位的概念,所以如果不使用內嵌匯編語言,要訪問C和V標志位是不可能的。盡管如此,編譯器支持借位標志位(無符號數溢出),比方說:
int sum(int x, int y)
{
int res;
res = x + y;
if ((unsigned) res < (unsigned) x) // carry set? //
res++;
return res;
}
Lazy Evaluation Exploitation
In a if(a>10 && b=4) type of thing, make sure that the first part of the AND expression is the most likely to give a false answer (or the easiest/quickest to calculate), therefore the second part will be less likely to be executed.
用switch() 代替if...else...
在條件選擇比較多的情況下,可以用if…else…else…,像這樣:
if( val == 1)
dostuff1();
else if (val == 2)
dostuff2();
else if (val == 3)
dostuff3();
使用switch可以更快:
switch( val )
{
case 1: dostuff1(); break;
case 2: dostuff2(); break;
case 3: dostuff3(); break;
}
在if語句中,即使是最后一個條件成立,也要先判斷所有前面的條件是否成立。Switch語句能夠去除這些額外的工作。如果你不得不使用if…else,那就把最可能的成立的條件放在前面。
二進制分解 / Binary Breakdown
把判斷條件做成二進制的風格,比如,不要使用下面的列表:
if(a==1) {
} else if(a==2) {
} else if(a==3) {
} else if(a==4) {
} else if(a==5) {
} else if(a==6) {
} else if(a==7) {
} else if(a==8)
{
}
而采用:
if(a<=4) {
if(a==1) {
} else if(a==2) {
} else if(a==3) {
} else if(a==4) {
}
}
else
{
if(a==5) {
} else if(a==6) {
} else if(a==7) {
} else if(a==8) {
}
}
甚至:
if(a<=4)
{
if(a<=2)
{
if(a==1)
{
/* a is 1 */
}
else
{
/* a must be 2 */
}
}
else
{
if(a==3)
{
/* a is 3 */
}
else
{
/* a must be 4 */
}
}
}
else
{
if(a<=6)
{
if(a==5)
{
/* a is 5 */
}
else
{
/* a must be 6 */
}
}
else
{
if(a==7)
{
/* a is 7 */
}
else
{
/* a must be 8 */
}
}
}
慢速、低效 快速、高效
c=getch();
switch(c){
case 'A':
{
do something;
break;
}
case 'H':
{
do something;
break;
}
case 'Z':
{
do something;
break;
}
} c=getch();
switch(c){
case 0:
{
do something;
break;
}
case 1:
{
do something;
break;
}
case 2:
{
do something;
break;
}
}
以上是兩個case語句之間的比較
switch語句和查找表 / Switch statement vs. lookup tables
switch語句通常用于以下情況:
? 調用幾個函數中的一個
? 設置一個變量或返回值
? 執行幾個代碼片斷中的一個
如果case表示是密集的(譯者:連續的?),在使用switch語句的前兩種情況中,可以使用效率更高的查找表。比如下面的兩個實現匯編代碼轉換成字符串的例程:
char * Condition_String1(int condition) {
switch(condition) {
case 0: return "EQ";
case 1: return "NE";
case 2: return "CS";
case 3: return "CC";
case 4: return "MI";
case 5: return "PL";
case 6: return "VS";
case 7: return "VC";
case 8: return "HI";
case 9: return "LS";
case 10: return "GE";
case 11: return "LT";
case 12: return "GT";
case 13: return "LE";
case 14: return "";
default: return 0;
}
}
char * Condition_String2(int condition) {
if ((unsigned) condition >= 15) return 0;
return
"EQ\0NE\0CS\0CC\0MI\0PL\0VS\0VC\0HI\0LS\0GE\0LT\0GT\0LE\0\0" +
3 * condition;
}
第一個例程需要240個字節,第二個只需要72個。
循環 / Loops
在大多數程序中,循環是一種常見的結構,相當數量的執行時間被消耗在循環上,因此在時間苛刻的循環上付出注意是值得的。
循環終止 / Loop termination
如果編寫循環終止條件是不加留意,就可能會給程序帶來明顯的負擔。我們應該盡量使用“倒數到零”的循環,使用簡單的循環終止條件。循環終止條件相對簡單,程序在執行的時候也會消耗相對少的時間。拿下面兩個計算n!的例子來說,第一個例子使用遞增循環,第二個使用遞減循環。
int fact1_func (int n)
{
int i, fact = 1;
for (i = 1; i <= n; i++)
fact *= i;
return (fact);
}
int fact2_func(int n)
{
int i, fact = 1;
for (i = n; i != 0; i--)
fact *= i;
return (fact);
}
結果是,第二個例子要比第一個快得多。
更快的for()循環 / Faster for() loops
這是一個簡單而有效的概念,通常情況下,我們習慣把for循環寫成這樣:
for( i=0; i<10; i++){ ... }
i值依次為:0,1,2,3,4,5,6,7,8,9
在不在乎循環計數器順序的情況下,我們可以這樣:
for( i=10; i--; ) { ... }
i值依次為: 9,8,7,6,5,4,3,2,1,0,而且循環要更快
這種方法是可行的,因為它是用更快的i--作為測試條件的,也就是說“i是否為非零數,如果是減一,然后繼續”。相對于原先的代碼,處理器不得不“把i減去10,結果是否為非零數,如果是,增加i,然后繼續”,在緊密循環(tight loop)中,這會產生顯著的區別。
這種語法看起來有一點陌生,卻完全合法。循環中的第三條語句是可選的(無限循環可以寫成這樣for(;;)),下面的寫法也可以取得同樣的效果:
for(i=10; i; i--){}
或者:
for(i=10; i!=0; i--){}
我們唯一要小心的地方是要記住循環需要停止在0(如果循環是從50-80,這樣做就不行了),而且循環的計數器為倒計數方式。
另外,我們還可以把計數器分配到寄存器上,可以產生更為有效的代碼。這種將循環計數器初始化成循環次數,然后遞減到零的方法,同樣適用于while和do語句。
混合循環/ Loop jamming
在可以使用一個循環的場合,決不要使用兩個。但是如果你要在循環中進行大量的工作,超過處理器的指令緩沖區,在這種情況下,使用兩個分開的循環可能會更快,因為有可能這兩個循環都被完整的保存在指令緩沖區里了。
//原先的代碼
for(i=0; i<100; i++){
stuff();
}
for(i=0; i<100; i++){
morestuff();
}
//更好的做法
for(i=0; i<100; i++){
stuff();
morestuff();
}
函數循環 / Function Looping
調用函數的時候,在性能上就會付出一定的代價。不光要改變程序指針,還要將那些正在使用的變量壓入堆棧,分配新的變量空間。為了提高程序的效率,在程序的函數結構上,有很多工作可以做。保證程序的可讀性的同時,還要盡量控制程序的大小。
如果一個函數在一個循環中被頻繁調用,就可以考慮將這個循環放在函數的里面,這樣可以免去重復調用函數的負擔,比如:
for(i=0 ; i<100 ; i++)
{
func(t,i);
}
void func(int w,d)
{
lots of stuff.
}
可以寫成…
func(t);
void func(w)
{
for(i=0 ; i<100 ; i++)
{
//lots of stuff.
}
}
循環的拆解 / Loop unrolling
為了提高效率,可以將小的循環解開,不過這樣會增加代碼的尺寸。循環被拆開后,會降低循環計數器更新的次數,減少所執行的循環的分支數目。如果循環只重復幾次,那它完全可以被拆解開,這樣,由循環所帶來的額外開銷就會消失。
比如:
for(i=0; i<3; i++){
something(i);
}
//更高效的方式
something(0);
something(1);
something(2);
因為在每次的循環中,i的值都會增加,然后檢查是否有效。編譯器經常會把這種簡單的循環解開,前提是這些循環的次數是固定的。對于這樣的循環:
for(i=0;i< limit;i++) { ... }
就不可能被拆解,因為我們不知道它循環的次數到底是多少。不過,將這種類型的循環拆解開并不是不可能的。
與簡單循環相比,下面的代碼的長度要長很多,然而具有高得多的效率。選擇8作為分塊大小,只是用來演示,任何合適的長度都是可行的。例子中,循環的成立條件每八次才被檢驗一次,而不是每次都要檢驗。如果需要處理的數組的大小是確定的,我們就可以使用數組的大小作為分塊的大小(或者是能夠整除數組長度的數值)。不過,分塊的大小跟系統的緩存大小有關。
//例1
#include<STDIO.H>
#define BLOCKSIZE (8)
void main(void)
{
int i = 0;
int limit = 33; /* could be anything */
int blocklimit;
/* The limit may not be divisible by BLOCKSIZE,
* go as near as we can first, then tidy up.
*/
blocklimit = (limit / BLOCKSIZE) * BLOCKSIZE;
/* unroll the loop in blocks of 8 */
while( i < blocklimit )
{
printf("process(%d)\n", i);
printf("process(%d)\n", i+1);
printf("process(%d)\n", i+2);
printf("process(%d)\n", i+3);
printf("process(%d)\n", i+4);
printf("process(%d)\n", i+5);
printf("process(%d)\n", i+6);
printf("process(%d)\n", i+7);
/* update the counter */
i += 8;
}
/*
* There may be some left to do.
* This could be done as a simple for() loop,
* but a switch is faster (and more interesting)
*/
if( i < limit )
{
/* Jump into the case at the place that will allow
* us to finish off the appropriate number of items.
*/
switch( limit - i )
{
case 7 : printf("process(%d)\n", i); i++;
case 6 : printf("process(%d)\n", i); i++;
case 5 : printf("process(%d)\n", i); i++;
case 4 : printf("process(%d)\n", i); i++;
case 3 : printf("process(%d)\n", i); i++;
case 2 : printf("process(%d)\n", i); i++;
case 1 : printf("process(%d)\n", i);
}
}
}
人口統計-計算非零位的個數 / Population count - counting the number of bits set
例1測試單個的最低位,計數,然后移位。例2則是先除4,然后計算被4處的每個部分。循環拆解經常會給程序優化帶來新的機會。
//Example - 1
int countbit1(uint n)
{
int bits = 0;
while (n != 0)
{
if (n & 1) bits++;
n >>= 1;
}
return bits;
}
//Example - 2
int countbit2(uint n)
{
int bits = 0;
while (n != 0)
{
if (n & 1) bits++;
if (n & 2) bits++;
if (n & 4) bits++;
if (n & 8) bits++;
n >>= 4;
}
return bits;
}
及早的退出循環 / Early loop breaking
通常沒有必要遍歷整個循環。舉例來說,在數組中搜索一個特定的值,我們可以在找到我們需要值之后立刻退出循環。下面的例子在10000個數字中搜索-99。
found = FALSE;
for(i=0;i<10000;i++)
{
if( list[i] == -99 )
{
found = TRUE;
}
}
if( found ) printf("Yes, there is a -99. Hooray!\n");
這樣做是可行的,但是不管這個被搜索到的項目出現在什么位置,都會搜索整個數組。跟好的方法是,再找到我們需要的數字以后,立刻退出循環。
found = FALSE;
for(i=0; i<10000; i++)
{
if( list[i] == -99 )
{
found = TRUE;
break;
}
}
if( found ) printf("Yes, there is a -99. Hooray!\n");
如果數字出現在位置23上,循環就會終止,忽略剩下的9977個。
函數設計 / Function Design
保持函數短小精悍,是對的。這可以使編譯器能夠跟高效地進行其他的優化,比如寄存器分配。
調用函數的開銷 / Function call overhead
對處理器而言,調用函數的開銷是很小的,通常,在被調用函數所進行的工作中,所占的比例也很小。能夠使用寄存器傳遞的函數參數個數是有限制的。這些參數可以是整型兼容的(char,short,int以及float都占用一個字),或者是4個字以內的結構體(包括2個字的double和long long)。假如參數的限制是4,那么第5個及后面的字都會被保存到堆棧中。這會增加在調用函數是存儲這些參數的,以及在被調用函數中恢復這些參數的代價。
int f1(int a, int b, int c, int d) {
return a + b + c + d;
}
int g1(void) {
return f1(1, 2, 3, 4);
}
int f2(int a, int b, int c, int d, int e, int f) {
return a + b + c + d + e + f;
}
ing g2(void) {
return f2(1, 2, 3, 4, 5, 6);
}
g2函數中,第5、6個參數被保存在堆棧中,在f2中被恢復,每個參數帶來2次內存訪問。
最小化參數傳遞開銷 / Minimizing parameter passing overhead
為了將傳遞參數給函數的代價降至最低,我們可以:
盡可能確保函數的形參不多于四個,甚至更少,這樣就不會使用堆棧來傳遞參數。
如果一個函數形參多于四個,那就確保在這個函數能夠做大量的工作,這樣就可以抵消由傳遞堆棧參數所付出的代價。
用指向結構體的指針作形參,而不是結構體本身。
把相關的參數放到一個結構里里面,然后把它的指針傳給函數,可以減少參數的個數,增加程序的可讀性。
將long類型的參數的個數降到最小,因為它使用兩個參數的空間。對于double也同樣適用。
避免出現參數的一部分使用寄存器傳輸,另一部分使用堆棧傳輸的情況。這種情況下參數將被全部壓到堆棧里。
避免出現函數的參數個數不定的情況。這種情況下,所有參數都使用堆棧。
葉子函數 / Leaf functions
如果一個函數不再調用其他函數,這樣的函數被稱為葉子函數。在許多應用程序中,大約一半的函數調用是對葉子函數的調用。葉子函數在所有平臺上都可以得到非常高效的編譯,因為他們不需要進行參數的保存和恢復。在入口壓棧和在出口退棧的代價,跟一個足夠復雜的需要4個或者5個參數的葉子函數所完成的工作相比,是非常小的。如果可能的話,我們就要盡量安排經常被調用的函數成為葉子函數。函數被調用的次數可以通過模型工具(profiling facility)來確定。這里有幾種方法可以確保函數被編譯成葉子函數:
不調用其他函數:包括那些被轉換成調用C語言庫函數的運算,比如除法、浮點運算。
使用關鍵字__inline修飾小的函數。
內嵌函數 / Inline functions
對于所有調試選項,內嵌函數是被禁止的。使用inline關鍵字修飾函數后,跟普通的函數調用不同,代碼中對該函數的調用將會被函數體本身代替。這會是代碼更快,另一方面它會影響代碼的長度,尤其是內嵌函數比較大而且經常被調用的情況下。
__inline int square(int x) {
return x * x;
}
#include <MATH.H>
double length(int x, int y){
return sqrt(square(x) + square(y));
}
使用內嵌函數有幾個優點:
沒有調用函數的開銷。因為函數被直接代替,沒有任何額外的開銷,比如存儲和恢復寄存器。
更低的參數賦值開銷。參數傳遞的開銷通常會更低,因為它不需要復制變量。如果其中一些參數是常量,編譯器還可以作進一步的優化。
內嵌函數的缺點是如果函數在許多地方被調用,將會增加代碼的長度。長度差別的大小顯著依賴于內嵌函數的大小和調用的次數。
僅將少數關鍵函數設置成內嵌函數是明智的。如果設置得當,內嵌函數可以減少代碼的長度,一次函數調用需要一定數量的指令,但是,使用優化過的內嵌函數可以編譯成更少的指令。
使用查找表 / 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
譯者聲明:作者對我的這片譯文并不知情,我在完全不知會作者的情況下翻譯了其發表于www.codeproject.com的文章,但我保留了原文的作者信息。請您僅將此文用于學術研究目的。如果您要將此文用于其它目的,請與原作者聯系。如果您認為文章翻譯的有問題,譯者將非常高興看到您提出的寶貴意見。