1. 程式人生 > >CSAPP:優化程式效能(二)

CSAPP:優化程式效能(二)

程式示例

為了說明一個抽象程式是如何被系統地轉換成更有效的程式碼的,我們使用基於如下所示的向量資料結構的執行示例


向量由兩個記憶體塊表示,頭部和資料陣列,頭部宣告結構如下, data_t代表基本資料型別:

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

生成向量,訪問向量元素,確定向量長度的基本過程
vec_ptr new_vec(long len)
{
    vec_ptr result = (vec_ptr)malloc(sizeof(vec_rec));
    data_t *data = NULL;
    if(!result)
        return NULL;
    result->len = len;
    if(len > 0){
        data = (data_t *)calloc(len, sizeof(data_t));
        if(!data){
        free((void *)result);
        return NULL;
        }
    }
    result->data = data;
    return result;
}
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;
}
long vec_length(vec_ptr v)
{
    return v->len;
}

作為優化示例考慮下面程式碼,將一個向量中所有元素合併成一個值

通過不同的巨集定義來執行不同的運算

#define IDENT 0
#define OP +

#define IDENT 0
#define OP *

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

combine1 的CPE度量值

combine1呼叫函式vec_length作為for迴圈的測試條件,每次迴圈迭代都必須對測試條件求值,另一方面,向量的長度並不會隨著迴圈的進行而改變,我們對程式進行修改

改進迴圈測試效率,通過吧vec_length()移出迴圈,我們不需要每次迭代都執行這個函式

void combine2(vec_ptr v, data_t *dest)
{
    long i;
    long 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;
    }
}
combine2的CPE度量值

這個變換明顯影響了程式的效能,這個優化是一個常見的優化例子,叫做程式碼移動,識別多次執行但是計算結果不會改變的計算。

對於這種改變在哪裡呼叫函式,呼叫函式多少次的變換,編譯器會非常的小心謹慎,程式設計師必須幫助編譯器顯示地完成程式碼移動。

減少過程呼叫

過程呼叫會帶來開銷,妨礙大多數形式的程式優化

從combine2的程式碼可以看出,每次迴圈迭代都呼叫get_vec_element來獲取下一個向量元素,對每個向量都進行邊界檢查,很明顯會造成低效率,在處理任意陣列訪問時,邊界檢查很有用,但是對combine2的程式碼簡單分析表明,所有的引用都是合法的。

為我們的資料型別增加函式get_vec_start來獲取陣列起始地址來獲取元素

data_t *get_vec_start(vec_ptr v)
{
    return v->data;
}
void combine3(vec_ptr v, data_t *dest)
{
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);
    
    *dest = IDENT;
    for(i = 0; i< length; i++){
        *dest = *dest OP data[i];
    }
}
檢視combine3的CPE度量圖

驚奇的是效能沒有提升,整數求和還略有下降,顯然內迴圈中的其他操作形成了瓶頸,限制性能超過get_vec_element,後文我們還會再回到這個函式,看看為什麼combine2中反覆的邊界檢查不會讓效能更差。

消除不必要的記憶體引用

再次我們給出資料型別為double(8位元組)

檢查編譯內迴圈產生的程式碼

//dest in %rbx, data+i in %rdx, data+length in %rax
.L17:
    vmovsd (%rbx), %xmm0
    vmulsd (%rdx), %xmm0, %xmm0
    vmovsd %xmm0, (%rbx)
    addq $8, %rdx
    compq %rax, %rdx
    jne .L17
每次迭代,累計變數的值都要從記憶體讀出再寫回記憶體,這樣的讀寫很浪費,因為每次迭代開始時從dest讀出的值就是上次迭代最後寫入的值

所以我們隊程式再次進行改進,引入臨時變數acc(累積器accumulator),再迴圈中用來累計計算出的值,只有迴圈迭代結束才存放入dest,將combine3每次迭代的2次讀,1次寫減少到只需要1次讀。

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

x86-64彙編程式碼
// acc in %xmm0, data+i in %rdx, data+length in %rax
.L25:
    vmulsd (%rdx), %xmm0, %xmmo
    addq $8, %rdx
    cmpq %rax, %rdx
    jne .L25

檢視combine4的CPE度量值


可能有人會認為,編譯器會自動將combine3中的程式碼轉換為combine4中的程式碼所做的那樣,但是實際上由於記憶體的別名使用(兩個指標指向同一塊記憶體的情況),兩個函式會有不同的行為。