CSAPP:優化程式效能(四)
瞭解一些限制程式效能的因素
一. 暫存器溢位
如果我們的並行度P超過了可用暫存器的數量,那麼編譯器就會通知溢位,將某些臨時值存放在記憶體中,通常是執行時堆疊上分配空間,聚個例子,當把combine6的多累積變數模式擴充套件到k=10或者k=20的時候,我們會發現這種迴圈展開程度沒有改善CPE,有些甚至變差了,現代x86-64處理器有16個暫存器,並可以使用16個ymm暫存器儲存浮點數,一點迴圈變數的數量超過了可用暫存器數量,程式就必須在棧上分配一些變數,從而是操作步驟中增加了從記憶體讀取資料的操作,適得其反。
二. 分值預測錯誤的懲罰
在一些使用投機執行的處理器中,處理器會預測分支目標處的指令,它會避免修改任何實際的暫存器或者記憶體位置,知道確定了實際的結果。如果預測正確,那麼處理器就會提交投機執行的指令結果,把結果儲存到暫存器或者記憶體,如果預測錯誤,處理器必須丟掉所有投機執行的結果,在正確的位置,重新開始取值過程,這樣做會引起預測錯誤懲罰,因為在產生有用的結果之前,必須重新填充指令流水線。
那麼如何保證分值預測處罰不會阻礙程式效率呢?下面是一些通用的原則:
1. 不要過分關心可預測的分支
我們看到錯誤的分支預測對程式的效能影響很大,但這並不意味著每一個分支都會拖慢程式的效能,實際上,現代處理器的分支預測邏輯非常善於辨別不同的分支指令的有規律的模式和長期的趨勢,例如,在合併函式中,結束迴圈的分支通常會被預測為選擇分支,一次只有在最後一次會導致預測錯誤的懲罰。
前面的文章中講到從combine2 變化到 combine3的時候,我們把函式get_vec_element從函式的內迴圈中拿出來,CPE基本上沒有改變,因為對這個函式來說,這些檢測總是預測索引是在界內的,所以是高度可預測的。
而且執行邊界檢查的預測可以與合併操作並行執行,處理器能夠預測這些分支的結果,所以不會對程式執行中關鍵路徑的指令的取指和處理產生太大影響。
2. 書寫適合用條件傳送實現的程式碼
分支預測只對有規律的模式可行,程式中的許多測試時不可預測的,對於本質上無法預測的情況,如果編譯器能夠產生使用條件資料傳送而不是使用條件控制轉移的程式碼,可以極大的提高程式的效能,這不是程式設計師可以直接控制的,但是有些表達條件行為的方法能夠更直接地被翻譯成條件傳送,而不是其他操作。
GCC能夠為 以一種“”功能性“”的風格書寫的程式碼產生條件傳送,這種風格對立與一種“”命令式“的風格,我們用一個例子來感受一下
給定兩個整數陣列a和b,對於每個位置i,設定a[i]為a[i]和b[i]中較小的那個,設定b[i]為較大的那個,首先用命令式的程式碼風格實現
void minmax1(long a[], long b[], long n)
{
long i;
for( i=0; i<n; i++){
if(a[i] > b[i]){
long t = a[i];
a[i] = b[i];
b[i] = t;
}
}
}
隨機資料測試該函式,CPE大約為13.50,而對於可預測資料,CPE為2.5~3.5,預測錯誤懲罰週期約為20個時鐘週期
用功能式的風格實現這個函式
void minmax2(long a[], long b[], long n)
{
long i;
for(i=0; i<n; i++){
long min = a[i] < b[i] ? a[i] : b[i];
long max = a[i] < b[i] ?b[i] : a[i];
a[i] = min;
b[i] = max;
}
}
無論資料是任意的還是可預測的,CPE大約為4.0(檢查彙編程式碼確實使用了條件傳送)理解記憶體效能
載入的效能
一個包含載入操作的程式的效能既依賴於流水線的能力,也依賴於載入單元的延遲。到目前為止我們還沒在示例中看到過載入操作的延遲產生的影響,載入操作地址只依賴於索引i,所以載入操作不會成為限制性能的關鍵路徑的一部分。
要確定一臺機器上載入操作的延遲,我們可以建立一系列操作組成一個計算,一條載入操作的結果決定下一條操作的地址
typedef struct ELE{
struct ELE *next;
long data;
}list_ele, *list_ptr;
long list_len(list_ptr ls){
long len = 0;
while(ls){
len++;
ls = ls->next;
}
return len;
}
連結串列函式,其效能受限於載入操作的延遲
其彙編程式碼為
// ls in %rdi , len in %rax
.L3:
addq $1, %rax
movq (%rdi), %rdi
testq %rdi,%rdi
jne .L3
第三行的movq指令是這個迴圈中的關鍵瓶頸
儲存的效能
儲存操作並不影響任何暫存器,一系列儲存操作都不會產生資料相關,下面所示函式說明了載入和儲存操作之間可能相互影響
void weite_read(long *src, long *dst, long n)
{
long cnt = n;
long val = 0;
while(cnt){
*dst = val;
val = (*src) + 1;
cnt--;
}
}
示例A:write_read(&a[0], &a[1], 3)
示例A從src讀出的結果不受對dst寫的影響,在較大次數的迭代上測試這個示例得到CPE等於1.3
示例B:write_read(&a[0], &a[0], 3)
這種情況下,*src的每次載入都需要用到 *dst的前次執行的儲存值,我們稱之為寫/讀相關——一個記憶體讀的結果依賴於一個最近的寫,示例B的CPE為7.3,寫/讀相關導致處理速度下降了6個時鐘週期。
為了研究為什麼一種情況比另一種情況慢,我們仔細看看載入和儲存執行單元
儲存單元包含一個儲存緩衝區,它包含已經發射到儲存單元而又還沒有完成的儲存操作的地址和資料,這裡的完成包括更新資料快取記憶體。提供這樣的緩衝區使得一系列儲存操作不必等待每個操作都更新快取記憶體就能執行,當一個載入操作發生時,他必須檢查儲存緩衝區的條目,看看有沒有地址相匹配,如果有地址相匹配,它就取出相應的資料條目作為載入操作的結果。