本文參考《深入理解計算機系統》中的第五章,本文中有不詳細的地方請查看原書。
本文會出現部分彙編代碼,以下爲可能出現的優化。
1. 兩個指針指向同一個位置
void twiddle1(int *xp, int *yp) //*xp進行加兩次*yp
{
*xp += *yp;
*xp += *yp;
}
void twiddle2(int *xp, int *yp)
{
*xp += 2* *yp;
}
這兩個程序似乎有相同的行爲,他們將yp位置的值兩次添加到xp位置。此時twiddle2效率更高,進行了三次存儲器引用(讀 *xp,讀 *yp, 寫 *xp),而twiddle1執行了兩次twiddle2的操作,編譯器會優化嗎?
在gcc 下並沒有進行優化(gcc -S 生成彙編,cat讀取彙編代碼)下面爲彙編代碼
twiddle1彙編
movl 8(%ebp), %eax ;取x
movl (%eax), %edx
movl 12(%ebp), %eax ; 取y
movl (%eax), %eax
addl %eax, %edx ; x+y
movl 8(%ebp), %eax
movl %edx, (%eax) ;放進x
movl 8(%ebp), %eax ; 再次進行上面的操作
movl (%eax), %edx
movl 12(%ebp), %eax
movl (%eax), %eax
addl %eax, %edx
movl 8(%ebp), %eax
movl %edx, (%eax)
movl 8(%ebp), %eax ;twiddle2彙編
movl (%eax), %edx
movl 12(%ebp), %eax
movl (%eax), %eax
addl %eax, %eax
addl %eax, %edx
movl 8(%ebp), %eax
movl %edx, (%eax)
上面的彙編代碼省略了棧幀結構,可以看出twiddle1進行了多次存取,可見編譯器並不會對其優化,因爲編譯器無法判斷x和y的地址是否相同,如果是相同,那就有意思了,twddle1中*xp會翻四倍。因爲編譯器出於對程序員的不信任,優化總是小心而安全的。
- 程序示例
下面通過實例說明一個抽象的程序如何被轉化成有效代碼的下面爲構造實例。
typedef int data_t;
#define IDENT 1
#define OP *
typedef struct
{
long int len;
data_t *data;
}vec_rec,*vec_ptr;
vec_ptr new_vec(long int len)
{
vec_ptr result = (vec_ptr)malloc(sizeof(vec_rec));
if (!result)
return NULL;
result->len = len;
if (len > 0)
{
data_t *data = (data_t *)calloc(len, sizeof(data_t));
if(!data)
{
free((void *) result);
return NULL;
}
result->data = data;
}
else
result->data = NULL;
return result;
}
int get_vec_element(vec_ptr v, long int index, data_t *dest)
{
if (index < 0 || index >= v->len)
return 0;
*dest = v->data[index];
return 1;
}
long int vec_length(vec_ptr v)
{
return v->len;
}
void combine1(vec_ptr v, data_t *dest)
{
long int i;
*dest = IDENT;
for (i = 0; i<vec_length(v); i++)
{
data_t val = 0;
get_vec_element(v, i, &val);
*dest = *dest OP val;
}
}
此時combine1的彙編代碼爲
combine1:
pushl %ebp
movl %esp, %ebp
subl $28, %esp
movl 12(%ebp), %eax
movl $1, (%eax)
movl $0, -4(%ebp)
jmp .L16
.L17:
leal -8(%ebp), %eax
movl %eax, 8(%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
movl 8(%ebp), %eax
movl %eax, (%esp)
call get_vec_element ;外調用
movl 12(%ebp), %eax
movl (%eax), %edx
movl -8(%ebp), %eax
imull %eax, %edx
movl 12(%ebp), %eax
movl %edx, (%eax)
addl $1, -4(%ebp)
.L16:
movl 8(%ebp), %eax
movl %eax, (%esp)
call vec_length ;外調用
cmpl -4(%ebp), %eax
jg .L17
leave
ret
這段代碼未經優化,編譯器會爲其做從C語言到機器代碼的直接翻譯,通常有明顯的低效率,我們可以通過“-O1”命令進行優化(不放彙編了),接下來用更高級別的優化測試上面的代碼。
combine1它每次循環都會對length求值,但是長度不變,所以可以進行以下優化:
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;
}
}
此時彙編減少了一個跳轉過程
.L16:
movl -8(%ebp), %eax
cmpl -4(%ebp), %eax
jl .L17
leave
ret
過程調用會帶來相當大的開銷,而且妨礙大多數形式的程序優化,從combine2代碼看出,每次都會調用get_vec_element來獲取下一個元素,我們可以做一個get_vec_start函數返回數組的起始地址,得到combine3。
data_t *get_vec_start(vec_ptr v)
{
return v->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];
}
}
此時的部分彙編代碼
.L17:
movl 12(%ebp), %eax
movl (%eax), %edx
movl -20(%ebp), %eax
sall $2, %eax
addl -12(%ebp), %eax
movl (%eax), %eax
imull %eax, %edx
movl 12(%ebp), %eax
movl %edx, (%eax)
addl $1, -20(%ebp)
對比combine1可以看出又減少了一層循環調用,現在再次看combine3發現指針dest可能存在反覆寫入,浪費時間,(書上存在的,我並沒有找到,有可能編譯器對其進行了優化),將*dest放到外面用acc變量進行維護即可。
循環展開改善代碼,提高並行性展開代碼。
void combine5(vec_ptr v, data_t *dest)
{
long int i;
long int length = vec_length(v);
data_t *data = get_vec_start(v);
long int limit = length - 1;
data_t acc = IDENT;
for(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;
}
循環展開:它是一種程序變換,通過增加每次迭代計算的元素數量減少循環的次數,不過他也犧牲了一部分空間。
代碼並行性:程序是受單元延遲限制的,但是加法和乘法完全是是流水化的,它們可以在每個週期開始一個新的操作。所以我們可以添加兩個acc臨時變量(沒貼代碼),這樣可以使得運行速度增快。並且加法和乘法滿足結合律,我們可以讓本前兩個結合轉化爲後兩個結合,重新結合變換能夠減少計算中關鍵操作的數量,通過更好的利用功能單元的流水線能力得到更好的性能。