深入理解計算機系統:寄存器溢出問題的原理、對性能的影響(register spilling)

《深入理解計算機系統》第5章的5.11.1介紹了寄存器溢出(register spilling)問題,請結合教材給出的簡單實例,闡釋爲什麼會出現寄存器溢出問題?寄存器溢出問題爲什麼會影響性能?但實際上我們在進行高級語言編程的時候根本無需考慮這個問題,爲什麼?試簡單闡釋系統內部誰、如何解決寄存器溢出問題。

一、 寄存器溢出的原理

首先我們回顧一下什麼是寄存器。寄存器是CPU內部的元件,包括通用寄存器、專用寄存器和控制寄存器。寄存器擁有非常高的讀寫速度,所以在寄存器之間的數據傳送非常快。
我們已經知道,通過引入臨時的累積變量,消除不必要的存儲器引用,可以提高程序性能。“消除不必要的存儲器引用”可以提高程序性能,其原因即在於寄存器的讀寫速度遠優於存儲器。
在上者的基礎上,通過將一組合並運算分割成多個部分,最後合併結果,即引入多個累計變量提高程序並行性,可以進一步提高程序性能。比如:

通常我們這麼寫代碼:

void sumAnswer(long int *sum) {
	int i=0;
	for(i=0; i<100000; i++)
		*sum+=num[i];
}

但我們可以這樣改進:

void sumAnswer(long int *sum) {
        int i;
        long int acc0=0;
        long int acc1=0;
        long int acc2=0;
        for(i=0 ;i<10000; i+=3) {
            acc0=acc0+num[i];
            acc1=acc0+num[i];
            acc2=acc0+num[i];
        }
        *sum=(acc0+acc1+acc2);
}

我們可以看到,區別於通常的for(i=0; i<10000; i++),這裏每次i加三,在循環內部通過三個臨時的累積變量存儲結果。這三個累計變量直接存儲到三個寄存器中,而不是每次循環都要讀寫存儲器,因此執行效率大大提升。這一點我們之後會進行驗證。

在前面分析的基礎上,我們又知道,IA32指令集只有少量寄存器可用於存放累積的值。會不會出現累計變量過多、寄存器“不夠用”的情況呢?因此,我們通過如下程序驗證:

#include <stdio.h>
int num[10006];//注意這裏爲了防止越界,我多定義了一些空間
void sumAnswer(long int *sum) {
        int i;
        long int acc0=0;
        long int acc1=0;
        long int acc2=0;
        long int acc3=0;
        long int acc4=0;
        long int acc5=0;
        for(i=0 ;i<10000; i+=6) {//循環展開六次,使用六個累積變量
            acc0=acc0+num[i];
            acc1=acc0+num[i];
            acc2=acc0+num[i];
            acc3=acc0+num[i];
            acc4=acc0+num[i];
            acc5=acc0+num[i];
        }
        *sum=(acc0+acc1+acc2+acc3+acc4+acc5);
}

int main() {
        int i;
        srand((unsigned int)time(NULL));
        for(i=0; i<10010; i++)
                num[i]=rand()%1000;//這裏我隨機生成一個數組
        long int sum;
        sumAnswer(&sum);//通過指針,直接改變值
        return 0;
}

在命令行輸入如下指令:

gcc -g spilling.c -o spilling

gdb ./spilling

list

disass sumAnswer

查看sumAnswer函數的反彙編代碼,觀察到結果爲:

我們可以看到,每次相加後的結果分別保存到-0x8(%ebp)、-0xc(%ebp)、-0x10(%ebp)、-0x14(%ebp)、-0x1c(%ebp),這是棧幀中的連續存儲空間,而不是寄存器,表明編譯器並沒有允許寄存器存儲臨時變量!

在64位系統下查看反彙編代碼(上圖)、將加法更改爲乘法、將臨時變量的定義不放在一起依舊如此。

那麼,在什麼時候編譯器允許寄存器存儲臨時變量呢?

答案是:需要開啓O2優化選項!

gcc -g -O2 spilling.c -o spilling

編譯上面的代碼,得到反彙編代碼爲:

可以清楚看到,每次相加的結果存儲到了寄存器edx中!

我們得到這樣一個結論:默認的編譯選項不提供“消除不必要的存儲器引用”的機制,所有局部變量按定義順序存儲在棧中,每次讀寫時需要從存儲器中訪問並寫回存儲器。

接下來,我們來分析寄存器溢出與溢出的情況。我用三個累積變量:

得到反彙編代碼:

分析得到:

此時沒有出現寄存器溢出問題。我們改爲使用6個累積變量:

得到反彙編代碼:

分析得到:

因此,我們得到結論:IA32指令集提供4個寄存器存放累積的值,它們分別是:%ecx  %edx  %edi  %esi。當我們的並行度超過了可用的寄存器數量時,就會發生寄存器溢出。

二、 寄存器溢出對性能的影響

原理上:發生寄存器溢出時,編譯器會將結果溢出到棧中。程序需要在內存中讀寫這些變量,會抵消使用多個值並行累積所獲得的好處,並行積累的優勢就可能消失。

編程驗證:測試並行度爲自08的程序運行時間。

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
long int num[100020];

void sum0(long int *r) {
	int i=0;
	for(i=0; i<100000; i++)
		*r+=num[i];
}
void sum1(long int *r) {
	int i=0;
	long int acc;
	for(i=0; i<100000; i++)
		acc+=num[i];
	*r=acc;
}
void sum2(long int *r) {
	int i=0;
	long int acc0,acc1;
	for(i=0; i<100000; i+=2) {
		acc0+=num[i];
		acc1+=num[i+1];
	}
	*r=acc0+acc1;
}
void sum3(long int *r) {
	int i=0;
	long int acc0,acc1,acc2;
	for(i=0; i<100000; i+=3) {
		acc0+=num[i];
		acc1+=num[i+1];
		acc2+=num[i+2];
	}
	*r=acc0+acc1+acc2;
}
void sum4(long int *r) {
	int i=0;
	long int acc0,acc1,acc2,acc3;
	for(i=0; i<100000; i+=4) {
		acc0+=num[i];
		acc1+=num[i+1];
		acc2+=num[i+2];
		acc3+=num[i+3];
	}
	*r=acc0+acc1+acc2+acc3;
}
void sum5(long int *r) {
	int i=0;
	long int acc0,acc1,acc2,acc3,acc4;
	for(i=0; i<100000; i+=5) {
		acc0+=num[i];
		acc1+=num[i+1];
		acc2+=num[i+2];
		acc3+=num[i+3];
		acc4+=num[i+4];
	}
	*r=acc0+acc1+acc2+acc3+acc4;
}
void sum6(long int *r) {
	int i=0;
	long int acc0,acc1,acc2,acc3,acc4,acc5;
	for(i=0; i<100000; i+=6) {
		acc0+=num[i];
		acc1+=num[i+1];
		acc2+=num[i+2];
		acc3+=num[i+3];
		acc4+=num[i+4];
		acc5+=num[i+5];
	}
	*r=acc0+acc1+acc2+acc3+acc4+acc5;
}
void sum7(long int *r) {
	int i=0;
	long int acc0,acc1,acc2,acc3,acc4,acc5,acc6;
	for(i=0; i<100000; i+=6) {
		acc0+=num[i];
		acc1+=num[i+1];
		acc2+=num[i+2];
		acc3+=num[i+3];
		acc4+=num[i+4];
		acc5+=num[i+5];
		acc6+=num[i+6];
	}
	*r=acc0+acc1+acc2+acc3+acc4+acc5+acc6;
}
void sum8(long int *r) {
	int i=0;
	long int acc0,acc1,acc2,acc3,acc4,acc5,acc6,acc7;
	for(i=0; i<100000; i+=6) {
		acc0+=num[i];
		acc1+=num[i+1];
		acc2+=num[i+2];
		acc3+=num[i+3];
		acc4+=num[i+4];
		acc5+=num[i+5];
		acc6+=num[i+6];
		acc7+=num[i+7];
	}
	*r=acc0+acc1+acc2+acc3+acc4+acc5+acc6,acc7;
}
int main() {
	clock_t start,finish,during;
	srand((unsigned int)time(NULL));
	long int result;
	for(int i=0; i<100020; i++)
		num[i]=(long int)rand()%10000;
	start=clock();
	sum0(&result);
	finish=clock();
	during=finish-start;
	start=clock();
	sum1(&result);
	finish=clock();
	printf("k=0:1\n");
	printf("k=1:%f\n",(float)(during/(finish-start)));
	start=clock();
	sum2(&result);
	finish=clock();
	printf("k=2:%f\n",(float)(during/(finish-start)));
	start=clock();
	sum3(&result);
	finish=clock();
	printf("k=3:%f\n",(float)(during/(finish-start)));
	start=clock();
	sum4(&result);
	finish=clock();
	printf("k=4:%f\n",(float)(during/(finish-start)));
	start=clock();
	sum5(&result);
	finish=clock();
	printf("k=5:%f\n",(float)(during/(finish-start)));
	start=clock();
	sum6(&result);
	finish=clock();
	printf("k=6:%f\n",(float)(during/(finish-start)));
	start=clock();
	sum7(&result);
	finish=clock();
	printf("k=7:%f\n",(float)(during/(finish-start)));
	start=clock();
	sum8(&result);
	finish=clock();
	printf("k=8:%f\n",(float)(during/(finish-start)));
}

運行結果爲:

在一定的誤差範圍內,程序給出了優化效率隨並行度的變化趨勢,與理論分析是吻合的。下圖爲教材中給出的結果:

三、 寄存器溢出問題的處理

1. 開啓不同的優化選項,結果不同,如gcc與dev C++的默認優化選項不支持該優化方案,因此不存在寄存器溢出問題。

2. 開發者本身無需考慮寄存器的分配問題。在開啓了相應優化選項的前提下,編譯器會自動爲相應臨時變量分配寄存器,若寄存器溢出,相應數據存儲到棧中,並通過register location技術確保其地址不會丟失,以保證該數據在需要時可被再次讀寫。

比如說,我在寫如上代碼時,並沒有考慮寄存器有無溢出,這是編譯器考慮的問題。寄存器溢出只會影響性能,但不會導致錯誤結果。

3. 開發者應適當利用此原理,編寫高效代碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章