1. 程式人生 > >優化程式效能

優化程式效能

一般來說,程式優化主要是以下三個步驟:

  1. 高階設計 —— 演算法和資料結構選擇

  2. 基本編碼原則 —— 編碼優化

  3. 低階優化 —— 程式碼結構化

高階設計

演算法的選擇是必須首要考慮的,也是最重要的一步。一般我們需要分析演算法的時間複雜度,即處理時間與輸入資料規模的一個量級關係,一個優秀的演算法可以將演算法複雜度降低若干量級,那麼同樣的實現,其平均耗時一般會比其他複雜度高的演算法少(這裡不代表任意輸入都更快)。

比如說排序演算法,快速排序的時間複雜度為O(nlogn),而插入排序的時間複雜度為O(n^2),那麼在統計意義下,快速排序會比插入排序快,而且隨著輸入序列長度n的增加,兩者耗時相差會越來越大。但是,假如輸入資料本身就已經是升序(或降序),那麼實際執行下來,快速排序會更慢。

而往往演算法的選擇必然和資料結構聯絡在一起,即時間和空間的選擇。同樣是排序演算法,快速排序和堆排序的時間複雜都是O(nlogn),但是快速排序使用線性表,而堆排序使用小根堆(或大根堆),很明顯大根堆的空間複雜度要大得多,維持這樣一個數據結構代價也更大。

基本編碼原則

選擇了合適的演算法和資料結構之後,在對原始碼的修改中,首先要去除一些不必要的工作,如不必要的函式呼叫和記憶體引用,讓程式碼儘可能有效地執行所期望的任務。

消除連續的函式呼叫

函式的呼叫會引起引數的入棧出棧,返回地址的入棧出棧,對呼叫函式的資料的儲存,這些都是一次函式呼叫的開銷。複雜引數的拷貝可能造成更大代價。

定義一個結構data:

struct data {
    int length;
    int* array;
};

//獲取資料的函式
int get_length(data* a) const { return a->length; }
int* get_array(data* a) const { return a; }
int get_element(data* a, int index, int length) { 
    if(index < 0 || index >= length)
        retun -1;
    return a->array[i]; 
}

考慮以下程式:將線性表的元素的累積在dst記憶體處。

void multiply_1(int* a int length, int* dst) {  
    for(int i = 0; i < get_length(a); ++i) {
        *dst = *dst * get_element(a, i, get_length(a));
    }
}

很明顯multiply_1的程式碼中,有兩處的呼叫太礙眼了的:迴圈中的get_length函式呼叫和get_element函式呼叫。中對邊界的檢查也是多餘的,所以可以消除這種連續的函式呼叫。

void multiply_2(data* a, int* dst) {
    int length = get_length(a);
    int *array = get_array(a);

    for(int i = 0; i < length; ++i) {
        *dst = *dst * array[i];
    }
}

消除不必要的記憶體引用

觀察multiply_2的程式碼,每次迴圈要先從dst記憶體中讀取值,再將和array[i]的積寫到dst記憶體處。因此,每次迴圈都包括一次讀和一次寫記憶體,而記憶體的讀寫也是有開銷的。因此,我們可以消除這種不必要的記憶體引用。

void multiply_3(data* a, int* dst) {
    int length = get_length(a);
    int *array = get_array(a);
    int product = 1;

    for(int i = 0; i < length; ++i) {
        product = product * array[i];
    }
    *dst = product;
}

程式設計師必須自己消除這種影響,因為編譯器只能對程式小心地進行安全的優化。記憶體別名引用函式呼叫一樣,妨礙著編譯器的優化。

比如,dst和array指向同一個記憶體地址,即使記憶體名字不一樣,也會造成完全不同的結果。

低階優化

要理解低階優化,必須要熟悉處理器的流水線體系結構。處理器執行指令並不是一條一條完成,而是將每一條指令劃分成若干個階段(以簡單的處理器為例):

PC:確定指令的地址,包括順序語句的下一條,跳轉指令,return指令等,有可能錯誤,但後續能修正。

取指:讀取指令內容。

譯碼:翻譯指令碼確定要做的行動。

執行:執行行動,如算術運算。

訪存:從記憶體中讀取資料,或者將資料寫入記憶體。

寫回:將資料寫到暫存器檔案。

每一個階段都有不同的硬體單元來完成,而這些單元之間有著複雜的互動和反饋機制。所以,一條指令沒有完成,下一條指令已經開始,這樣處理器同一個週期可以處理多個指令,從而提高了處理器的效率。

關於流水線結構和順序結構執行迴圈的區別如圖所示,ii值是每個迴圈指令的執行時間。

這裡寫圖片描述

關於這個知識點的內容可參考CSAPP第四章·處理器體系結構。

迴圈展開

迴圈展開有兩個好處:

1、減少不直接有助於程式結果的操作的數量,例如迴圈索引的計算和條件分支;

2、它提供了通過指令並行進一步優化程式碼的方法,減少關鍵路徑上的運算元量。

void multiply_4(data* a, int* dst) {
    int length = get_length(a);
    int *array = get_array(a);
    int product = 1;
    int limit = length - 1;

    int i;
    for(i = 0; i < limit; i += 2) {
        product = (product * array[i]) * array[i + 1];
    }

    for(; i < length; ++i) {
        product = product * array[i];
    }
    *dst = product;
}

平行計算

觀察程式multiply_3,每一次計算product,必須要等到前一次計算完成,處理器的流水線勢必要暫停等待,這就造成效能的損失。迴圈展開中提到的關鍵路徑指的就是迴圈計算product,因為product存在著依存的關係,便形成了一條路徑,而這條路徑是限制程式效能的關鍵,故稱為關鍵路徑。

累積變數

對於一個可交換和結合的合併運算來說,可以通過將一組合並分解成多組進行,這樣形成多條關鍵路徑,處理器可以同時處理這些關鍵路徑,從而提高程式的效能。

下面的程式是對迴圈進行兩路平行計算:

void multiply_5(data* a, int* dst) {
    int length = get_length(a);
    int *array = get_array(a);
    int product0 = 1;
    int product1 = 1;
    int limit = length - 1;

    int i;
    for(int i = 0; i < limit; i += 2) {
        product0 = product0 * array[i];
        product1 = product1 * array[i + 1];
    }

    for(; i < length; ++i) {
        product0 = product0 * array[i];
    }
    *dst = product0 * pruduct1;
}

可以增大並行路數,使得所有功能單元的流水線都是滿的,這樣能得到最好的程式效能。但是有一點需要指出的是,對於不能結合或者交換的資料型別或運算,比如浮點數的乘法和加法,由於四捨五入和溢位的問題,使得如上程式碼的優化不一定能得到相同的結果,需要格外小心。大部分編譯器不會對浮點數程式碼自動進行這種優化。

重新結合

和累積變數的思想一樣,都是通過指令級並行,提高程式效能。

void multiply_6(data* a, int* dst) {
    int length = get_length(a);
    int *array = get_array(a);
    int product = 1;
    int limit = length - 1;

    int i;
    for(i = 0; i < limit; ++i) {
        product = product * (array[i] * array[i + 1]);
    }

    for(; i < length; ++i) {
        product = product * array[i];
    }
    *dst = product;
}

和multiply_4類似,通過迴圈展開,但是區別是結合不一樣,使得整個迴圈中依賴前一個計算結果的次數減半,從而程式效能得到提高。同樣,可以將迴圈展開幅度變大,總的依賴次數也將進一步減少,程式效能進一步提高,最後當滿流水線執行時達到最大值。

使用向量指令

Inter在1999年引入了SSE指令(Streaming SIMD Extensions,流SIMD拓展),即單指令多資料的拓展指令集。SSE經過幾代的發展,最新的版本為高階向量拓展(advanced vector extension),通過向量暫存器對向量資料進行操作。目前的AVX向量暫存器長為32個位元組,可以並行處理8組或4組數值。程式程式碼可以被編譯成AVX的向量指令,對效能的提升可以達到4倍或8倍的提升。

條件資料傳送

對於條件分支,處理器採用分之預測的辦法預測下一條指令的位置,通常通過良好地設計處理器分之預測邏輯,可以使得預測成功率達到50%以上,但是如果錯誤,將招致嚴重的懲罰:程式效能大大降低。因為,處理器流水線技術會通過插入氣泡的形式糾正這種錯誤,但是對於週期的浪費也是不可避免的。

作為替代方法,最近的X86處理器有條件傳送指令可以替換條件控制,對於表示式簡單的邏輯或者算術運算,可以通過條件傳送的來實現條件分支,來提高程式的效能。