C++ 層次程式碼最佳化 (轉)

amyz發表於2007-10-17
C++ 層次程式碼最佳化 (轉)[@more@]第二次修訂 2002.4.16 第一次修訂 2002.3.12 初次發表 2002.1.17

談到,很多人都會直接想到。難道最佳化只能在彙編層次嗎?當然不是,C++層次一樣可以作程式碼最佳化,其中有些常常是意想不到的。在C++層次進行最佳化,比在彙編層次最佳化具有更好的移植性,應該是最佳化中的首選做法。

確定浮點型變數和是 float 型

為了讓產生更好的程式碼(比如說產生3DNow! 或SSE指令的程式碼),必須確定浮點型變數和表示式是 float 型的。要特別注意的是,以 "F" 或 "f" 為字尾(比如:3.14f)的浮點常量才是 float 型,否則預設是 double 型。為了避免 float 型引數自動轉化為 double,請在宣告時使用 float。

使用32位的資料型別

編譯器有很多種,但它們都包含的典型的32位型別是:int,signed,signed int,unsigned,unsigned int,long,signed long,long int,signed long int,unsigned long,unsigned long int。儘量使用32位的資料型別,因為它們比16位的資料甚至8位的資料更有。

明智使用有符號整型變數

在很多情況下,你需要考慮整型變數是有符號還是無符號型別的。比如,儲存一個人的體重資料時不可能出現負數,所以不需要使用有符號型別。但是,如果是要儲存溫度資料,就必須使用到有符號的變數。

在許多地方,考慮是否使用有符號的變數是必要的。在一些情況下,有符號的運算比較快;但在一些情況下卻相反。

比如:整型到浮點轉化時,使用大於16位的有符號整型比較快。因為x86構架中提供了從有符號整型轉化到浮點型的指令,但沒有提供從無符號整型轉化到浮點的指令。看看編譯器產生的彙編程式碼:

不好的程式碼:

編譯前編譯後 double x; mov [foo + 4], 0 unsigned int i; mov eax, i x = i; mov [foo], eax flid q ptr [foo] fstp qword ptr [x]上面的程式碼比較慢。不僅因為指令數目比較多,而且由於指令不能配對造成的FLID指令被延遲。最好用以下程式碼代替:

推薦的程式碼:

編譯前編譯後 double x; fild dword ptr [i] int i; fstp qword ptr [x] x = i;

在整數運算中計算商和餘數時,使用無符號型別比較快。以下這段典型的程式碼是編譯器產生的32位整型數除以4的程式碼:

不好的程式碼推薦的程式碼 編譯前編譯後 int i; mov eax, i i = i / 4; cdq and edx, 3 add eax, edx sar eax, 2 mov i, eax 編譯前編譯後 unsigned int i; shr i, 2 i = i / 4;

總結:

無符號型別用於:
除法和餘數
迴圈計數
陣列下標
有符號型別用於:
整型到浮點的轉化

while VS. for

在中,我們常常需要用到無限迴圈,常用的兩種方法是while (1) 和 for (;;)。這兩種方法效果完全一樣,但那一種更好呢?然我們看看它們編譯後的程式碼:

編譯前編譯後 while (1); mov eax,1 test eax,eax je foo+23h jmp foo+18h 編譯前編譯後 for (;;); jmp foo+23h

一目瞭然,for (;;)指令少,不佔用暫存器,而且沒有判斷跳轉,比while (1)好。

使用陣列型代替指標型

使用指標會使編譯器很難最佳化它。因為缺乏有效的指標程式碼最佳化的方法,編譯器總是假設指標可以訪問的任意地方,包括分配給其他變數的儲存空間。所以為了編譯器產生最佳化得更好的程式碼,要避免在不必要的地方使用指標。一個典型的例子是訪問存放在陣列中的資料。C++ 允許使用運算子 [] 或指標來訪問陣列,使用陣列型程式碼會讓最佳化器減少產生不程式碼的可能性。比如,x[0] 和x[2] 不可能是同一個記憶體地址,但 *p 和 *q 可能。強烈建議使用陣列型,因為這樣可能會有意料之外的提升。

不好的程式碼推薦的程式碼

typedef struct { float x,y,z,w; } VERTEX; typedef struct { float m[4][4]; } MATRIX; void XForm(float* res, const float* v, const float* m, int nNumVerts) { float dp; int i; const VERTEX* vv = (VERTEX *)v; for (i = 0; i < nNumVerts; i++) { dp = vv->x * *m ++; dp += vv->y * *m ++; dp += vv->z * *m ++; dp += vv->w * *m ++; *res ++ = dp; // 寫入轉換了的 x dp = vv->x * *m ++; dp += vv->y * *m ++; dp += vv->z * *m ++; dp += vv->w * *m ++; *res ++ = dp; // 寫入轉換了的 y dp = vv->x * *m ++; dp += vv->y * *m ++; dp += vv->z * *m ++; dp += vv->w * *m ++; *res ++ = dp; // 寫入轉換了的 z dp = vv->x * *m ++; dp += vv->y * *m ++; dp += vv->z * *m ++; dp += vv->w * *m ++; *res ++ = dp; // 寫入轉換了的 w vv ++; // 下一個向量 m -= 16; } }


typedef struct { float x,y,z,w; } VERTEX; typedef struct { float m[4][4]; } MATRIX; void XFo(float* res, const float* v, const float* m, int nNumVerts) { int i; const VERTEX* vv = (VERTEX*)v; const MATRIX* mm = (MATRIX*)m; VERTEX* rr = (VERTEX*)res; for (i = 0; i < nNumVerts; i++) { rr->x = vv->x * mm->m[0][0] + vv->y * mm->m[0][1] + vv->z * mm->m[0][2] + vv->w * mm->m[0][3]; rr->y = vv->x * mm->m[1][0] + vv->y * mm->m[1][1] + vv->z * mm->m[1][2] + vv->w * mm->m[1][3]; rr->z = vv->x * mm->m[2][0] + vv->y * mm->m[2][1] + vv->z * mm->m[2][2] + vv->w * mm->m[2][3]; rr->w = vv->x * mm->m[3][0] + vv->y * mm->m[3][1] + vv->z * mm->m[3][2] + vv->w * mm->m[3][3]; } }

注意: 的轉化是與編譯器的程式碼發生器相結合的。從原始碼層次很難控制產生的機器碼。依靠編譯器和特殊的原始碼,有可能指標型程式碼編譯成的機器碼比同等條件下的陣列型程式碼執行速度更快。明智的做法是在原始碼轉化後檢查效能是否真正提高了,再選擇使用指標型還是陣列型。

充分分解小的迴圈

要充分利用的指令快取,就要充分分解小的迴圈。特別是當迴圈體本身很小的時候,分解迴圈可以提高效能。BTW:很多編譯器並不能自動分解迴圈。

不好的程式碼推薦的程式碼

// 3D轉化:把向量 V 和 4x4 矩陣 M 相乘 for (i = 0; i < 4; i ++) { r[i] = 0; for (j = 0; j < 4; j ++) { r[i] += M[j][i]*V[j]; } }


r[0] = M[0][0]*V[0] + M[1][0]*V[1] + M[2][0]*V[2] + M[3][0]*V[3]; r[1] = M[0][1]*V[0] + M[1][1]*V[1] + M[2][1]*V[2] + M[3][1]*V[3]; r[2] = M[0][2]*V[0] + M[1][2]*V[1] + M[2][2]*V[2] + M[3][2]*V[3]; r[3] = M[0][3]*V[0] + M[1][3]*V[1] + M[2][3]*V[2] + M[3][3]*v[3];


避免沒有必要的讀寫依賴

當資料儲存到記憶體時存在讀寫依賴,即資料必須在正確寫入後才能再次讀取。雖然 Athlon等CPU有加速讀寫依賴延遲的,允許在要儲存的資料被寫入記憶體前讀取出來,但是,如果避免了讀寫依賴並把資料儲存在內部暫存器中,速度會更快。在一段很長的又互相依賴的程式碼鏈中,避免讀寫依賴顯得尤其重要。如果讀寫依賴發生在運算元組時,許多編譯器不能自動最佳化程式碼以避免讀寫依賴。所以推薦員手動去消除讀寫依賴,舉例來說,引進一個可以儲存在暫存器中的臨時變數。這樣可以有很大的效能提升。下面一段程式碼是一個例子:

不好的程式碼推薦的程式碼

float x[VECLEN], y[VECLEN], z[VECLEN]; ....... for (unsigned int k = 1; k < VECLEN; k ++) { x[k] = x[k-1] + y[k]; } for (k = 1; k < VECLEN; k++) { x[k] = z[k] * (y[k] - x[k-1]); }


float x[VECLEN], y[VECLEN], z[VECLEN]; ....... float t(x[0]); for (unsigned int k = 1; k < VECLEN; k ++) { t = t + y[k]; x[k] = t; } t = x[0]; for (k = 1; k < VECLEN; k ++) { t = z[k] * (y[k] - t); x[k] = t; }


Switch 的用法

Switch 可能轉化成多種不同演算法的程式碼。其中最常見的是跳轉表和比較鏈/樹。推薦對case的值依照發生的可能性進行排序,把最有可能的放在第一個,當switch用比較鏈的方式轉化時,這樣可以提高效能。此外,在case中推薦使用小的連續的整數,因為在這種情況下,所有的編譯器都可以把switch 轉化成跳轉表。

不好的程式碼推薦的程式碼

int days_in_month, short_months, normal_months, long_months; ....... switch (days_in_month) { case 28: case 29: short_months ++; break; case 30: normal_months ++; break; case 31: long_months ++; break; default: cout << "month has fewer than 28 or more than 31 days" << endl; break; }


int days_in_month, short_months, normal_months, long_months; ....... switch (days_in_month) { case 31: long_months ++; break; case 30: normal_months ++; break; case 28: case 29: short_months ++; break; default: cout << "month has fewer than 28 or more than 31 days" << endl; break; }


所有函式都應該有原型定義

一般來說,所有函式都應該有原型定義。原型定義可以傳達給編譯器更多的可能用於最佳化的資訊。

儘可能使用常量(const)

儘可能使用常量(const)。C++ 標準規定,如果一個const宣告的的地址不被獲取,允許編譯器不對它分配儲存空間。這樣可以使程式碼更有效率,而且可以生成更好的程式碼。

提升迴圈的效能

要提升迴圈的效能,減少多餘的常量計算非常有用(比如,不隨迴圈變化的計算)。

不好的程式碼(在for()中包含不變的if()) 推薦的程式碼

for( i ... ) { if( CONSTANT0 ) { DoWork0( i ); // 假設這裡不改變CONSTANT0的值 } else { DoWork1( i ); // 假設這裡不改變CONSTANT0的值 } }


if( CONSTANT0 ) { for( i ... ) { DoWork0( i ); } } else { for( i ... ) { DoWork1( i ); } }


如果已經知道if()的值,這樣可以避免重複計算。雖然不好的程式碼中的分支可以簡單地預測,但是由於推薦的程式碼在進入迴圈前分支已經確定,就可以減少對分支預測的依賴。

把本地函式宣告為靜態的(static)

如果一個函式在實現它的外未被使用的話,把它宣告為靜態的(static)以強制使用內部連線。否則,預設的情況下會把函式定義為外部連線。這樣可能會影響某些編譯器的最佳化——比如,自動內聯。

考慮動態記憶體分配

動態記憶體分配(C++中的"new")可能總是為長的基本型別(四字對齊)返回一個已經對齊的指標。但是如果不能保證對齊,使用以下程式碼來實現四字對齊。這段程式碼假設指標可以對映到 long 型。

例子

double* p = (double*)new BYTE[sizeof(double) * number_of_doubles+7L]; double* np = (double*)((long(p) + 7L) & –8L);


現在,你可以使用 np 代替 p 來訪問資料。注意:釋放儲存空間時仍然應該用delete p。

使用顯式的並行程式碼

儘可能把長的有依賴的程式碼鏈分解成幾個可以在流水線執行單元中並行執行的沒有依賴的程式碼鏈。因為浮點操作有很長的潛伏期,所以不管它被對映成 x87 或 3DNow! 指令,這都很重要。很多高階語言,包括C++,並不對產生的浮點表示式重新排序,因為那是一個相當複雜的過程。需要注意的是,重排序的程式碼和原來的程式碼在代數上一致並不等價於計算結果一致,因為浮點操作缺乏精確度。在一些情況下,這些最佳化可能導致意料之外的結果。幸運的是,在大部分情況下,最後結果可能只有最不重要的位(即最低位)是錯誤的。 不好的程式碼推薦的程式碼

double a[100], sum; int i; sum = 0.0f; for (i=0; i<100; i++) sum += a[i];


double a[100], sum1, sum2, sum3, sum4, sum; int i; sum1 = sum2 = sum3 = sum4 = 0.0; for (i = 0; i < 100; i += 4) { sum1 += a[i]; sum2 += a[i+1]; sum3 += a[i+2]; sum4 += a[i+3]; } sum = (sum4+sum3)+(sum1+sum2);


要注意的是:使用4 路分解是因為這樣使用了4階段流水線浮點加法,浮點加法的每一個階段佔用一個時鐘週期,保證了最大的資源利用率。

提出公共子表示式

在某些情況下,C++編譯器不能從浮點表示式中提出公共的子表示式,因為這意味著相當於對錶達式重新排序。需要特別指出的是,編譯器在提取公共子表示式前不能按照代數的等價關係重新安排表示式。這時,程式設計師要手動地提出公共的子表示式(在VC裡有一項“全域性最佳化”選項可以完成此工作,但效果就不得而知了)。

不好的程式碼推薦的程式碼

float a, b, c, d, e, f; .... e = b * c / d; f = b / d * a;


float a, b, c, d, e, f; .... const float t(b / d); e = c * t; f = a * t;

不好的程式碼推薦的程式碼

float a, b, c, e, f; .... e = a / c; f = b / c;


float a, b, c, e, f; .... const float t(1.0f / c); e = a * t; f = b * t;


結構體成員的佈局

很多編譯器有“使結構體字,雙字或四字對齊”的選項。但是,還是需要改善結構體成員的對齊,有些編譯器可能分配給結構體成員空間的順序與他們宣告的不同。但是,有些編譯器並不提供這些功能,或者效果不好。所以,要在付出最少代價的情況下實現最好的結構體和結構體成員對齊,建議採取這些方法:

按型別長度排序

把結構體的成員按照它們的型別長度排序,宣告成員時把長的型別放在短的前面。

把結構體填充成最長型別長度的整倍數

把結構體填充成最長型別長度的整倍數。照這樣,如果結構體的第一個成員對齊了,所有整個結構體自然也就對齊了。下面的例子演示瞭如何對結構體成員進行重新排序:

不好的程式碼,普通順序推薦的程式碼,新的順序並手動填充了幾個位元組

struct { char a[5]; long k; double x; } baz;


struct { double x; long k; char a[5]; char pad[7]; } baz;


這個規則同樣適用於類的成員的佈局。

按資料型別的長度排序本地變數

當編譯器分配給本地變數空間時,它們的順序和它們在原始碼中宣告的順序一樣,和上一條規則一樣,應該把長的變數放在短的變數前面。如果第一個變數對齊了,其它變數就會連續的存放,而且不用填充位元組自然就會對齊。有些編譯器在分配變數時不會自動改變變數順序,有些編譯器不能產生4位元組對齊的棧,所以4位元組可能不對齊。下面這個例子演示了本地變數宣告的重新排序:

不好的程式碼,普通順序推薦的程式碼,改進的順序

short ga, gu, gi; long foo, bar; double x, y, z[3]; char a, b; float baz;


double z[3]; double x, y; long foo, bar; float baz; short ga, gu, gi;


避免不必要的整數除法

整數除法是整數運算中最慢的,所以應該儘可能避免。一種可能減少整數除法的地方是連除,這裡除法可以由乘法代替。這個替換的副作用是有可能在算乘積時會,所以只能在一定範圍的除法中使用。

不好的程式碼推薦的程式碼

int i, j, k, m; m = i / j / k;


int i, j, k, m; m = i / (j * k);


把頻繁使用的指標型引數複製到本地變數

避免在函式中頻繁使用指標型引數指向的值。因為編譯器不知道指標之間是否存在衝突,所以指標型引數往往不能被編譯器最佳化。這樣是資料不能被存放在暫存器中,而且明顯地佔用了記憶體頻寬。注意,很多編譯器有“假設不衝突”最佳化開關(在VC裡必須手動新增編譯器命令列/Oa或/Ow),這允許編譯器假設兩個不同的指標總是有不同的內容,這樣就不用把指標型引數儲存到本地變數。否則,請在函式一開始把指標指向的資料儲存到本地變數。如果需要的話,在函式結束前複製回去。

不好的程式碼推薦的程式碼

// 假設 q != r void isqrt(unsigned long a, unsigned long* q, unsigned long* r) { *q = a; if (a > 0) { while (*q > (*r = a / *q)) { *q = (*q + *r) >> 1; } } *r = a - *q * *q; }


// 假設 q != r void isqrt(unsigned long a, unsigned long* q, unsigned long* r) { unsigned long qq, rr; qq = a; if (a > 0) { while (qq > (rr = a / qq)) { qq = (qq + rr) >> 1; } } rr = a - qq * qq; *q = qq; *r = rr; }


賦值與初始化

先看看以下程式碼:

class CInt { int m_i; public: CInt(int a = 0):m_i(a) { cout << "CInt" << endl; } ~CInt() { cout << "~CInt" << endl; } CInt operator + (const CInt& a) { return CInt(m_i + a.GetInt()); } void SetInt(const int i) { m_i = i; } int GetInt() const { return m_i; } };

不好的程式碼推薦的程式碼

void main() { CInt a, b, c; a.SetInt(1); b.SetInt(2); c = a + b; }


void main() { CInt a(1), b(2); CInt c(a + b); }


這兩段程式碼所作的事都一樣,但那一個更好呢?看看輸出結果就會發現,不好的程式碼輸出了四個"CInt"和四個"~CInt",而推薦的程式碼只輸出三個。也就是說,第二個例子比第一個例子少生成一次臨時物件。Why? 請注意,第一個中的c用的是先宣告再賦值的方法,第二個用的是初始化的方法,它們有本質的區別。第一個例子的"c = a + b"先生成一個臨時物件用來儲存a + b的值,再把該臨時物件用位複製的方法給c賦值,然後臨時物件被銷燬。這個臨時物件就是那個多出來的物件。第二個例子直接用複製建構函式的方法對c初始化,不產生臨時物件。所以,儘量在需要使用一個物件時才宣告,並用初始化的方法賦初值。

儘量使用成員初始化列表

在初始化類的成員時,儘量使用成員初始化列表而不是傳統的賦值方式。

不好的程式碼推薦的程式碼

class CMyClass { string strName; public: CMyClass(const string& str); }; CMyClass::CMyClass(const string& str) { strName = str; }


class CMyClass { string strName; int i; public: CMyClass(const string& str); }; CMyClass::CMyClass(const string& str) : strName(str) { }


不好的例子用的是賦值的方式。這樣,strName會先被建立(了string的預設建構函式),再由引數str賦值。而推薦的例子用的是成員初始化列表,strName直接構造為str,少呼叫一次預設建構函式,還少了一些安全隱患。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752019/viewspace-977289/,如需轉載,請註明出處,否則將追究法律責任。

相關文章