天天看點

C++ 層次代碼優化

C++ 層次代碼優化

作者:系統  日期:2003-04-22  閱讀:89

第二次修訂 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 qword 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 XForm (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];
	}
}      
注意: 源代碼的轉化是與編譯器的代碼發生器相結合的。從源代碼層次很難控制産生的機器碼。依靠編譯器和特殊的源代碼,有可能指針型代碼編譯成的機器碼比同等條件下的數組型代碼運作速度更快。明智的做法是在源代碼轉化後檢查性能是否真正提高了,再選擇使用指針型還是數組型。

充分分解小的循環

要充分利用CPU的指令緩存,就要充分分解小的循環。特别是當循環體本身很小的時候,分解循環可以提高性能。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];
      

避免沒有必要的讀寫依賴

當資料儲存到記憶體時存在讀寫依賴,即資料必須在正确寫入後才能再次讀取。雖然AMD 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.net裡有一項“全局優化”選項可以完成此工作,但效果就不得而知了)。
不好的代碼 推薦的代碼
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,少調用一次預設構造函數,還少了一些安全隐患。

v