《深入瞭解計算機系統》筆記——優化程序性能

程序性能優化

編寫高性能程序需要滿足:
1.選擇適當的算法和數據結構
2.必須編寫出變異其能夠有效優化以轉化成高效可執行代碼的源代碼

程序優化

程序優化的第一步就是消除不必要的工作:例如對同一個內存地址的反覆讀寫我們要儘可能的減少,消除不必要的函數調用、條件測試和內存引用。這些都不依賴目標機器的任何具體屬性而屬於程序員可控範疇內的代碼的改動。
爲了使性能最大化,程序員和編譯器都需要一個目標機器的模型,知名如何處理指令,以及哥哥操作的時序特性。

研究程序的彙編代碼表示是理解編譯器以及產生的代碼會如何運行是進行程序優化的最有效手段之一。通過用彙編語言寫代碼,這種間接的方法具有的優點是:雖然性能並非最好的,但是能保證代碼能夠在其他機器上運行。

優化編譯器的能力和侷限性

現代編譯器運用複雜精細的算法來確定一個程序中計算的是什麼值,以及他們是如何使用的。編譯器必須很小心地對程序只使用安全的優化,在C語言標準提供的保證下,優化後的得到的程序和未優化的版本有一樣的行爲,限制編譯器只進行安全的優化,消除了造成不希望的運行時行爲的一些可能的原因。
爲了理解決定一種程序轉換是否安全的難度,我們來看以下兩個程序:

void twiddle1(long *xp,long *yp)
{
	*xp += *yp;
	*xp += *yp;
}

void twiddle2(long *xp,long *yp)
{
	*xp += 2* *yp;
}

這兩個函數有着看似相似的行爲,他們都是將存儲在指針yp位置的值兩次相加到指針xp位置的值上。
一方面,函數twiddle2()效率更高,因爲他只要求3次內存的引用(讀xp,讀yp,寫*xp),相應的twiddle1()需要6次。
另一方面,當如果xp和yp指向同一位置的時候。

//指針xp和yp相同(指向同一地址)
void twiddle1(long *xp,long *yp)
{
	*xp += *xp;
	*xp += *xp;
}

twiddle1()的xp會變爲原來的4倍。

*xp += 2* *xp;

twiddle2()的xp會變味原來的3倍。
因此,我們不能吧twiddle2()作爲twiddle1()的優化版本。

這種兩個指針指向相同內存的情況稱之爲內存別名使用。在執行優化過程中,編譯器必須假設不同的指針可能會指向同一內存同一個位置的情況。
例如對於以下,一個使用指針變量q和p的程序:

x=1000, y=3000;
*q = y;		//3000
*p = x;     //1000
t1 = *q;   

t1的值的情況是根據p和q的指向決定的。
當p,q指向同一內存位置,t1就等於1000;相反則爲3000。
這造成了一個主要的妨礙優化的因素這也可能是嚴重限制編譯器產生優化代碼機會的程序的一個方面:如果不能確定指向,就必須假設所有情況,這就限制了優化策略。

表示程序性能

度量標準——每元素的週期數(Cycles Per Element, CPE)
CPE作爲知道我們改進代碼的方法,幫助我們在更細節層次上理解迭代程序的循環性能。

處理器的活動順序是用時鐘控制的,時鐘提供了某個頻率的規律信號,通常用千兆赫茲(GHz),即十億週期/秒來表示。
例如:4GHz——表示處理器時鐘運行頻率爲4 X 10^9個週期。

許多過程含有在一組元素迭代的循環。
舉一個例子:函數psum1()和psum2()計算都是一個長度爲n的向量的前置和

void psum1(float a[],float p[],long n)
{
	long i;
	p[0] = a[0];
	for(int i=1 ;i < n;i++)
		p[i] = p[i-1] + a[i];
}

void psum1(float a[],float p[],long n)
{
	long i;
	p[0] = a[0];
	for(int i=1; i<n-1;i+=2)
	{
		float mid_val = p[i-1]+a[i];
		p[i] = mid_val;
		p[i+1] = mid_val+ a[i+1];
	}
	if(i<n)
		p[i]= a[i] + p[i-1];  //當i並未到n位置時候執行,將最後一個向量前置和求出
}

函數psum1()每次迭代計算一個元素。
函數psum2()使用了循環展開技術,每次迭代計算兩個元素。
很明顯高psum2運行時間明顯小於psum1(這個時間優勢差距會在元素越多的情況下越拉越大);使用最小二乘擬合也得出一樣結論:
psum1的運行時間(時鐘週期爲單位),近似等於368+9.0n
psum2的運行時間(時鐘週期爲單位),近似等於368+6.0n
對於較大的n值,運行時間就會由線性因子決定。

9,0和6.0稱爲線性因子

根據這種度量標準,psum2的CPE爲6.0,優於psum1的CPE爲psum1。

程序示例

爲了說明一個抽象的程序是如何被系統專換爲更有效的代碼,我們使用基於下面的所示的向量的數據結構來做例子:
此向量數據結構將由兩個內存塊表示:頭部和數據數組。
以下爲頭部結構

typedef struct{
	long len;
	data_t *data;
}vec_rec, *vec_ptr;

接下來這是我們對向量元素數據數組的操作:

int get_vec_element(vec_ptr v,long index, data_t *dest){
	if(index < 0 || index>= v->len)
		return 0;
		*dest = v->data[index]return 1;
}		//得到向量數據數組v在位置index上的數據並賦值給*dest

long vec_length(vec_ptr v)
{
	return v->len;
}		//返回向量數據數組v的長度

另外我們在接下來的程序中使用聲明:

#define IDEN 1
#define OP *

表示的是對向量的元素進行乘積。
或者:

#define IDEN 0
#define OP +

表示的是對向量的元素進行求和。

首先是第一次編寫的函數combin1():

void combine1(vec_ptr v,data_t *dest){
	long i;
	*dest =IDENT;
	for(int i=0; i<vec_length(v);i++){
		data_t val;
		get_vec_element(v,i,&val);
		*dest = *dest OP val;
	}
}

《深入瞭解計算機系統》書上測試機器是一臺具有Intel Core i7 Haswell處理器的機器上測試的,我們稱其爲參考機

我們來將combine1()作爲我們進行程序優化的起點。
詳細的CPE數據在書上P349最下面。

我們在書上看到,如果使用GCC的命令行選項“-O1”,會進行一些基本的優化,在這個程序員不需要做任何事情的情況下,在這個程序上優化顯著的提升了兩個數量級,這也是優化的一個方法——使用-Og優化級別。

消除循環的低效率

可以觀察到,combine1中在for循環中調用了vec_lengeth函數來取得數組長度。
這意味着每一次循環迭代,程序都要調用此函數。
但是數組長度在本函數是不會改變的。
這樣我們有了一個優化的思路,用一個length的數據來保存vec_length返回的數組長度,而不是每一次都去調用它。

void combine2(vec_ptr v,data_t *dest){
	long i;
	*dest =IDENT;
	int length = vec_length(v);   //保存vec_length返回的數組長度
	for(int i=0; i<length; i++){
		data_t val;
		get_vec_element(v,i,&val);
		*dest = *dest OP val;
	}
}

這個優化方法十分的常見,稱之爲代碼移動。這類優化包括將多次識別的值(前提是此值不會改變)存放起來,就如上面,我們將vec_length的調用移動到循環外。
優化編譯器會試着進行這樣的代碼移動,但是他並不能可靠的知道這樣做是否會有副作用(如果值變化了那就有很大的影響了)。
因此,程序員通常要幫編譯器顯示地完成代碼的移動。

舉一個更加極端的例子:lower函數——對字符串中所有大寫字母轉化爲小寫字母。

void lower1(char *s)
{
	long i;
	for(int i=0; i<strlen(s); i++)
		if(s[i]>='A' && s[i]<='Z')
			s[i] -= ('A'-'a');
}

void lower2(char *s)
{
	long i;
	long len=strlen(s);
	for(int i=0; i<len; i++)
		if(s[i]>='A' && s[i]<='Z')
			s[i] -= ('A'-'a');
}

其中,strlen函數是這樣的:

size_t strlen(const char *s)
{
	long length =0;
	while(*s != '\0'){
		s++;
		length++;
	}
	return length;
}

在C語言中,字符串的皆爲必須是以NULL結尾的字符序列,strlen()必須一步步地檢查當前位置的字符,直至遇到NULL。
回到編寫的lower()函數:
基於strlen()的情況,對於lower1(),它的整體運行時間相當於O(n²)。
每一次運行時間對於lower1來說都是數組長度n的二次冪(在n越大的情況下,運行時間將會更加的長)。
例如:在n=1048576情況下,lower2比lower1快樂500 000多倍。

對於這種代碼移動的優化,需要有非常成熟的完善的分析,這樣的分析遠超出了編譯器的能力,需要程序員來進行這樣的變換。

減少過程調用

過程調用也會帶來很大的開銷。
例如:combine2函數,get_vec_element的調用來獲取下一個向量元素存放在val中。

int get_vec_element(vec_ptr v,long index, data_t *dest){
	if(index < 0 || index>= v->len)   //向量索引i與循環向量作比較
		//........
}		

對每個向量引用,這個函數要把向量索引i與循環向量作比較,會造成低效率,這種邊界檢查很必要,但是我們在分析後知道:對於combine2而言,所有的引用都是合法的。(因爲我們在combine2函數內的for循環設置了(i<數組長度length)的邊界)。

我們將對此進行優化:
假設爲我們的抽象數據類型增加一個函數get_vec_start,此函數返回數組起始地址。

data* get_vec_start(vec_ptr v)
{
	return v->data;   //返回數組的“頭部”
}

對此我們可以寫出combine3()。

void combine3(vec_ptr v,data_t *dest){
	long i;
	int length = vec_length(v);   
	data_t *data = get_vec_start(v);
	*dest = IDENT;
	for(int i=0; i<length; i++){
		*dest = *dest OP data[i];
	}
}

在做完這一系列後,我們卻發現性能並無更大提升,事實上整體求和性能甚至反降。
顯然是內部循環中的其他操作限制了瓶頸,這個限制甚至於超過多次調用get_vec_element。

我們對數據類型爲double(8),合併運算OP爲乘法的x86-64代碼進行分析:

.L17:
	vmovsd (%rbx) , %xmm0			//存放dest指向的地址
	vmulsd  (%rdx) , %xmm0 , %xmm0	//存放data[i]元素的指針
	vmovsd   %xmm0 ,(%rbx) 			//在dest存放數據
	addq     $8 ,%rdx				//data+i 
	cmpq    %rax, %rdx				//和data+length作對比
	jne       .L17					// if !=,goto loop

在comine3中,我們看到,dest指針的的地址存放在寄存器 %rbx中;
他還改變了代碼,將第i個元素的指針存放到寄存器%rdx中,並且每次迭代,這個指針都+8。
循環的終止操作來根據%rdx的指針和%rax中的數值來判斷。
從上面的分析可以看出,每次迭代,累積的變量的數值都要從內存讀出再寫入,因爲每次迭代開始時從dest讀出的值就是上次迭代寫入的最後的值。

combine4的目的就是爲了消除這種不必要的內存讀寫:引入臨時變量acc來保存循環中累積計算出來的值。

void combine4(vec_ptr v,data_t *dest){
	long i;
	int length = vec_length(v);   
	data_t *data = get_vec_start(v);
	data_t acc = IDENT;
	for(int i=0; i<length; i++){
		acc = acc OP data[i];
	}
	*dest = acc;
}

在這種情況下,我們再來看x86-64代碼:

.L25
	vmulsd(%rdx),%xmm0,%xmm0	//rdx存放data[i]地址的指針
	addq   $8 ,%rdx				//data+i
	cmpq   %rax, %rdx			
	jne    .L25

combine4減少了對%rdx存儲位置的內存的重複讀寫。

在combine4中,相較於combine3,程序性能有了更加明顯的提高。

對於combine3和combine4,,這也引發了一個問題,回到之前的內存別名使用問題上,兩個函數會有不同的行爲。

combine3(v,get_vec_start(v)+2);
combine4(v,get_vec_start(v)+2);

因爲combine3是直接對內存上的數據進行多次的改動,combine4是用額外的acc來保存數據在最後纔對%rdx內存位置上的數據進行更替,我們可以粗略的理解爲:combine3的改變是實時的,而combin4不是。

函數 初始 循環前 i=0 i=1 i=2 最後結果
combine3 [2,3,5] [2,3,1] [2,3,2] [2,3,6] [2,3,36] [2,3,36]
combine4 [2,3,5] [2,3,5] [2,3,5] [2,3,5] [2,3,5] [2,3,5]

這種巧合的例子是我們人爲設計出來的,但實際中,編譯器不能判斷函數會在什麼情況下調用,以及程序員的本意是什麼。取而代之,編譯combine3時,保守的方法就是讓程序不斷地讀寫內存,即使這樣做效率不高。

循環展開

上面提及到的循環展開是一種程序變換,通過增加每次迭代的計算的元素數量,減少循環迭代次數(上面的psum2函數例子)。
我們根據循環展開,可以對combine使用“2X1”循環展開版本:combine5每次循環處理數組兩個元素,也就是每次迭代,索引i+2。(並且在當n爲2的倍數時,在最後執行將剩餘的元素進行處理)。

void combine5(vec_ptr v,data_t *dest){
	long i;
	int length = vec_length(v);   
	long limit = length-1;
	data_t *data = get_vec_start(v);
	data_t acc = IDENT;
	for(int i=0; i<limit; i+=2){
		acc = (acc OP data[i]) OP data[i+1];
	}
	for(; i<length;i++){
		acc = acc OP data[i];  //處理n不爲2的倍數時的剩餘元素
	}
	*dest = acc;
}

在書P367表中對combine4進行循環展開的CPE可以看出,對於OP爲整數加法運算,CPE得到一定的提升,這得益於combine5減少了循環次數;但其他情況並沒有提升。

這讓我們再次去觀察combine5的內循環機器代碼。類型data_t爲double,操作爲乘法。

.L35 
	vmulsd (%rax,%rdx,8) , %xmm0, %xmm0
	vmulsd 8(%rax,%rdx,8), %xmm0, %xmm0
	add    $2 , %rdx
	cmpq   %rdx, %rbp
	jg 	   .L35

與之前一樣:%xmm0存放累積值acc,%rdx存放索引i,%rax存放data地址。
循環展開導致產生兩條vmulsd指令:將data[i]乘以acc;將data[i+1]乘以acc。
每條vmulsd被翻譯成兩個操作:1.從內存中加載一個數組元素 2.將這個乘以已有累積值acc。

詳細的數據流圖在書P369

提高並行性

程序的性能是受運算單元的延遲限制的,但他們的執行加法和乘法的功能單元完全是流水線化的:這意味着他們可以每個是中週期開始一個操作,並且有些操作可以被多個功能單元執行。

多個累積變量

我們可以對combine5做出這樣的改動:

void combine6(vec_ptr v,data_t *dest){
	long i;
	int length = vec_length(v);   
	long limit = length-1;
	data_t *data = get_vec_start(v);
	data_t acc0 = IDENT;
	data_t acc1 = IDENT;
	for(int i=0; i<limit; i+=2){
		acc0 = acc OP data[i];
		acc1 = acc OP data[i+1];
	}
	for(; i<length;i++){
		acc0 = acc OP data[i];  
	}
	*dest = acc0 OP acc1;
}

使用了兩次循環展開,也使用了兩路並行,我們將combine6稱爲“2x2循環展開”。
並且這種改進方法,所有情況都有了提升。

數據流圖在書P372,可以看到與combine5相比,comebine6會有兩條關鍵路徑來對data[n]數據數組進行訪問,每天路徑包含n/2個操作。

重新結合變換

這是一種打破順序相關,從而使性能提高到延遲界限之外的方法。
combine5沒有改變合併向量元素形成和或者乘積中執行的操作,不過我們可以這樣改動,也可極大地提高性能:

void combine7(vec_ptr v,data_t *dest){
	long i;
	int length = vec_length(v);   
	long limit = length-1;
	data_t *data = get_vec_start(v);
	data_t acc = IDENT;
	for(int i=0; i<limit; i+=2){
		acc = acc OP (data[i] OP data[i+1]);   //!!改變了這裏,請注意對比
	}
	for(; i<length;i++){
		acc = acc OP data[i];  
	}
	*dest = acc;
}

combine5元素合併:

acc = (acc OP data[i]) OP data[i+1];

combine7元素合併:

 acc = acc OP (data[i] OP data[i+1]);

因爲括號改變了向量元素與累積值acc的合併順序,產生了稱之爲“2x1a”的循環展開形式。

圖P374

對於combine4和combine7,有兩個load和兩個mul操作(load讀取data[i]位置的數據,mul將數據相乘),但是combine7只有一個操作形成了循環寄存器間的數據相關鏈。我們可以在書P375的數據流圖看到關鍵路徑上只有n/2個操作,每次迭代內的第一個乘法都不需要等待前一次迭代的累積值就可執行(與combine5對比)。

結果小結 (性能提高技術)

高級設計。

爲遇到的問題選擇適當的算法和數據結構,避免使用會漸進地產生糟糕性能的算法或編碼技術。

基本編碼原則

避免限制優化的因素。

  • 消除連續的函數使用,將計算移到循環外(如上面用len存儲長度,而非在循環內調用函數)
  • 消除不必要的內存引用,引入臨時變量來保存中間結果(如combine函數中的acc),在最後纔將結果存放到數組或全局變量中

低級優化

結構化代碼以利用硬件功能

  • 展開循環,降低開銷,使進一步優化成爲可能
  • 通過使用例如多個累積變量(如上combine7的acc0和acc1存儲臨時數據)和重新結合等技術 ,找到方法提高指令集並行
  • 用功能性的風格重寫條件操作使得便已採用條件數據傳送

確認和消除性能瓶頸

書P388開始的5.14小節講述的是如何使用代碼剖析程序(code profiler)的方法和介紹了程序性能分析工具,還展示了一個系統優化的通用原則。本人用得少,只做瞭解不作展開。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章