《深入瞭解計算機系統》筆記——優化程式效能
程式效能優化
編寫高效能程式需要滿足:
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)的方法和介紹了程式效能分析工具,還展示了一個系統優化的通用原則。本人用得少,只做瞭解不作展開。