閱讀經典——《深入理解計算機系統》07
本文將介紹非常實用的程序性能優化手段,并用一個案例來詳細說明。
- 為什么要優化程序性能?
- 衡量性能的指標
- 未優化版本
- 提取重復操作
- 減少函數調用
- 避免內存讀寫
- 還能進一步優化嗎?
- 循環展開
- 提高并行性
- 重結合變換
- 總結
為什么要優化程序性能?
對于c代碼而言,從源代碼到匯編代碼再到機器指令,這中間是有一個編譯器在起作用的。編譯器發展到現在,它的功能已經不僅僅是將源碼編譯為機器碼,更重要的是它的優化能力。編譯器需要根據指令集的特點將代碼盡可能地優化,以得到更快的執行速度。
那么,既然有了編譯器自動做優化,我們程序員還要手動優化程序性能嗎?
答案是肯定的。在很多情況下,編譯器無法像程序員一樣掌握足夠的信息以判斷是否可以執行某種優化,更多的情況下,編譯器會很謹慎地做少量的優化,以確保程序的正確性。而我們程序員則可以手動采用更深入的優化策略,以獲得更高的性能。具體的案例將在下文敘述。
衡量性能的指標
通常性能瓶頸出現在循環處,對于循環遍歷的元素數是固定的情況下,所用的時間正比于每個元素消耗的時間。因此我們用CPE(cycles per element)來衡量程序性能。CPE是指對于一個循環操作,平均每個元素所用的周期數。
這樣說似乎不是很直觀,接下來讓案例登場吧。
未優化版本
要想有循環,先得有個數組。定義如下結構體:
typedef int data_t;
typedef struct {
long int len;
data_t *data;
} vec_rec, *vec_ptr;
這是一個數組結構體,包含兩個成員:len
表示數組長度,data
保存第一個數據的地址。為了通用,將data
設為data_t *
類型,這個類型可以任意定義為int
、float
、double
。
將數組所有元素乘/加的第一種實現,也就是未優化版本如下:
#define IDENT 0
#define OP +
/* Implementation with maximum use of data abstraction */
void combine1(vec_ptr v, data_t *dest)
{
long int i;
*dest = IDENT;
for (i = 0; i < vec_length(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
為了通用乘法和加法,我們用IDENT
、OP
的不同組合來區分兩種運算。上面代碼定義用于處理加法,如果把IDENT
定義為1
,并把OP
定義為*
就可以用來處理乘法了。
這種寫法也許是我們最常用的寫法,雖然現在看不出有什么問題,但接下來我們將分析它的性能瓶頸。
提取重復操作
這個版本最容易被指出的問題就是循環條件調用了一個函數vec_length
,不管這個函數具體是如何實現的,對于長度固定的數組來說,這樣做都是一種冗余。因為其實我們可以在循環開始前定義一個局部變量length
保存數組的長度值,這樣就只需要調用一次vec_length
,一定會降低程序的運行時間。新的程序版本如下:
/* Move call to vec_length out of loop */
void combine2(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
用實際測得的CPE來表示兩個版本間的性能差異比較有公信力,請看下表。
表中,每種實現都給出了五類數據類型的測試結果,包括整數加法、整數乘法、浮點數加法、單精度浮點數乘法和雙精度浮點數乘法。由于每類運算本身執行一次需要的時間就不一樣,因此需要分開比較。
注意,combine1已經采用了-O1級別的編譯器優化,但combine2的用時仍然比前者短了數秒。
編譯器為什么不自動做這個優化呢?因為它沒那么聰明唄。它不知道vec_length
這個函數的返回值是不變的,因此只能謹慎起見,不優化。
減少函數調用
如何做進一步的優化就不那么容易想到了。不過想想之前《函數調用棧》中講的函數調用過程是多么的繁瑣,提示我們應該盡量減少函數調用。新的版本如下:
/* Direct access to vector data */
void combine3(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
data_t *data = get_vec_start(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
*dest = *dest OP data[i];
}
}
現在,循環里不再有get_vec_element
方法了,取而代之的是直接用下標索引取數。說實話,這是一個非常魯莽的做法,因為這樣寫的代碼不具有擴展性,而且大大破壞了原來程序的抽象和封裝,不是面向對象程序應有的作風。因此,現在我們講的是提高程序性能的手段,而不是說所有程序都應該這樣寫。當性能不是程序首要考慮的因素時,我們根本不需要做任何優化。優化之后,代碼會變得難以理解,通常要求附帶文檔解釋說明。
CPE測試結果如下。
雖然性能提高不多,但關鍵時候一點點的性能提升也很重要。
避免內存讀寫
現在,讓我們關注循環中唯一的一句代碼,如何提高這句代碼的性能?
如果查看匯編代碼,就很容易發現其中的問題(匯編碼就不再貼出來了):每個循環都要讀寫一次內存,這豈不是很耽誤時間。我們都知道,CPU訪問內存的速度比訪問寄存器要慢得多,因此如果把訪問內存的操作轉變成訪問寄存器的操作就可以節約大量時間了。看這個版本的實現:
/* Accumulate result in local variable */
void combine4(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
循環中只用局部變量acc
保存計算的中間值,循環結束后再賦值給*dest
。由于現代處理器都優先使用寄存器保存局部變量的值,因此可以大大提高程序性能。
看看現在的CPE吧,簡直是飛一般的快啊。
再回頭想一下,為什么編譯器不做這樣的優化?肯定還是因為有不確定的因素。假如說dest
和數組v
的地址有交叉,那么原始版本的程序在循環中可能會改變數組元素的值,而當前版本的循環就不會改變數組元素的值,造成不一致的結果。因此編譯器在不確定的情況下不敢擅自做優化。
還能進一步優化嗎?
我們需要知道處理器的性能極限是多少,以此判斷我們的程序還能否進一步優化。
上一篇文章《從零開始制作自己的指令集架構》中詳細講述了指令集架構的內部結構。但是,不得不指出,現代處理器架構完全不同于Y86,雖然在某些具體的實現方面沿用了Y86的技術,但實際的邏輯組成卻是這樣的:
整個處理器分為兩大部分,指令控制部分和執行部分。前者負責取指以及寫回寄存器,后者又包括多個功能單元,比如FP add、load、store等等,分別負責各自獨立的計算和存取內存操作。
上一篇文章提到了現代處理器用的亂序執行技術,這個技術就依賴于這些功能單元。每個單元都是完全流水線化的,意味著FP add單元可以每周期完成一次加法操作。而各個單元間又是完全并行執行的,而且不必照顧指令的執行順序。如果一條指令需要多個單元執行,那么把每個單元需要執行的任務排隊進入流水線就可以了。
這里需要提出兩個名詞:
- Latency:時延。一條指令從開始到完成所用的時間。
- Issue:發射時間。兩條指令連續發射的時間間隔。完全流水線情況下應該為1。
時延限制了順序執行的運算的性能,而發射時間限制了流水線級別的并行運算的性能。理論上在兩種限制下所能達到的最低CPE見下表(吞吐量和發射時間是同一個含義)。
可見,不同類型數據和不同操作對應的時延不同,但它們在完全流水線下的CPE都可以達到1。
下一步,就要研究怎樣才能突破時延對CPE的限制,最終達到吞吐量對CPE的限制。之所以combine4只接近了時延對CPE的限制,是因為代碼中每兩次運算間是順序執行的。之所以是順序執行的,是因為下一次的運算用到了上一次運算的結果,產生了數據依賴。所以,接下來我們應該考慮如何消除數據依賴。
循環展開
將本來需要n次的循環變成n/2次,每次循環內部做兩個元素的操作,這種技術就稱為循環展開。看代碼:
/* Unroll loop by 2 */
void combine5(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
long int limit = length - 1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i += 2) {
acc = (acc OP data[i]) OP data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
代碼中每次循環處理兩個元素,循環展開因數為2。
現在,CPE為
可以發現,整數運算性能提升了,而且展開3次的情況下整數加法和整數乘法都達到了吞吐量界限,但是浮點數運算性能卻毫無改善。這是因為雖然循環展開了,但是兩次運算間仍然存在直接的數據依賴,導致流水線的并行能力仍然沒有發揮出來。不過整數乘法卻越過了延遲界限,原因涉及到重結合變換(reassociation transformation),我們將在后面詳細解釋。
提高并行性
現在,必須真正地消除數據依賴了。為了讓下次運算不再需要上次運算的結果,我們可以將整個運算分為兩個并行分支,用兩個局部變量分別累加奇數項和偶數項,最后再合并到一起。代碼如下:
/* Unroll loop by 2, 2-way parallelism */
void combine6(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
long int limit = length - 1;
data_t *data = get_vec_start(v);
data_t acc0 = IDENT;
data_t acc1 = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i += 2) {
acc0 = acc0 OP data[i];
acc1 = acc1 OP data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}
看看效果怎么樣。
果然,浮點數運算性能也提高了不少。經過測試,如果提高到3路、4路甚至5路并行,浮點數運算也會下降到吞吐量界限。
重結合變換
另一種提高并行的方式是采用重結合變換。依據加法和乘法的結合律,在循環展開的基礎上,重新結合三個數的運算順序,就可以實現性能提高。代碼如下:
/* Change associativity of combining operation */
void combine7(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
long int limit = length - 1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i += 2) {
acc = acc OP (data[i] OP data[i+1]);
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
原理何在呢?其實很簡單,仍然是數據依賴的問題。先計算的兩個數data[i]
和data[i+1]
是沒有任何數據依賴的,因此這次計算可以和下一次與acc
的運算完全并行化,這就比之前先計算acc
要好得多了。
實際效果也很明顯。
總結
最后給出一個性能優化的完美結果。
可以看到,當采用了展開5次,5路并行的優化后,無論哪一種運算都達到了吞吐量界限,表明我們的優化非常成功。
最后,有兩件事情需要提醒大家:
本文所講的這些優化方法,在大部分編譯器中都已經實現了。但是它們有可能不會實行這些優化,或需要我們手動設置更高級別的優化選項才行。所以,作為一個程序員,我們應該做的是盡量引導編譯器執行這些優化,或者說排除阻礙編譯器優化的障礙。這樣可以使我們的代碼在保持簡潔的情況下獲得更高的性能。迫不得已時,我們才去手動做這些優化。
循環展開、多路并行并不是越多越好。因為寄存器的個數是有限的,x86-64最多只能有12個寄存器用于累加,如果局部變量的個數多于12個,就會被放進存儲器,反倒嚴重拉低程序性能。
想要親自測試的同學請移步我的GitHub倉庫optimization_demo。
關注作者或文集《深入理解計算機系統》,第一時間獲取最新發布文章。