程式效能優化探討(4)——直接對映快取記憶體命中率問題的模擬

coreyspomu發表於2014-12-17

        前一節初步介紹了快取記憶體的結構和地址劃分策略,以及快取記憶體“讀”處理規則,這一節從討論“寫”開始。


一、快取記憶體寫的處理

        快取處理讀的過程是,根據編號查詢相應的值,如果不命中,就從下一集快取調入新的資料,再根據替換策略(不細數),將新資料替換快取中的舊資料。而對於快取處理寫的情況,稍複雜些。

        假如CPU要對快取中某個存在的塊進行寫操作,什麼時候才去更新記憶體裡的與之對應的該欄位呢?如果記憶體和快取同時更新,這稱為直寫,那寫操作對於快取設計又有什麼優勢呢?所以更常見的方法是,你這個塊先委託快取保管,你寫你的,修改你的。什麼時候等該塊要被驅逐出快取時,再更新到記憶體,這稱為寫回。從快取的思想上看,這是非常美妙的策略,增加了時間區域性性優勢,減少低層儲存裝置的讀取時間。但是,實現寫回意味著更復雜的邏輯,比如每個快取行都要維護一個修改位用於識別塊是否被修改。  

        還有個問題,如果寫不命中呢?CPU修改快取裡的某個欄位時,發現這個欄位不在快取內,是從記憶體把欄位讀進快取再修改快取裡的塊,還是直接修改記憶體呢?前者利用空間區域性性,但增加步驟,稱為寫分配,可以理解為分配快取進行寫;同理,後者就成為非寫分配。很明顯,直寫一般就對應非寫分配,而寫回就對應寫分配。

        一般來說,寫回+寫分配的策略是主流,隨著邏輯電路密度的提高,高複雜性的實現越來越容易。採用直寫更容易實現,但是增加了匯流排事物,儲存層次越往下走,資料傳送的耗時越長,因此越可能採用寫回而不是直寫策略。

二、快取記憶體分配策略

        我們要討論快取對程式優化的影響,就要考慮快取的不命中率(miss rate),還要考慮各級儲存的不命中率處罰。一般L1不命中,需要5~10個週期從L2獲得資料;L2和L3類似,而快取不命中從記憶體獲得資料則需要耗費25~100個週期。

        快取記憶體的總大小與效能的關係可以做定性討論。一方面,快取記憶體越大,命中率肯定更高;但另一方面,快取記憶體更大,命中時間就越長,因此不同的快取可能採用不同的策略。比如L1,要求不命中處罰時間只能幾個週期,那麼L1就不能採用大快取。

        快取記憶體的塊大小和行數量(相聯度)與效能的關係也可以做定性討論。一方面,有較大的塊,每行存的資料就更大,而快取中這些不同的塊可能對應完全不相干的低層儲存區域,相鄰行之間的塊資料可能毫不相干,因此本行的資料越大,則代表某個區域的資料集體被快取的就越多,這是利用了空間區域性性;但另一方面,快取記憶體總大小一旦確定,塊越大就意味著行越少,行越少,就意味著快取可能對映更少的低層儲存區域,這就破壞了時間區域性性。所謂優化時間區域性性即當快取某塊資料後,在短時間內儘可能多重複使用這些資料,而CPU呼叫的資料可能涉及到儲存區很多不同區域,如果你的行太少,覆蓋的各儲存區域就不夠廣泛,自然也就很難保證更多區域的資料能夠及時被快取並且被重複使用,即便你利用空間區域性性在快取某幾個區域資料時讓他們被快取得更完整。當應對不同資料處理順序導致某些情況造成不命中的區域增多時,程式實際執行效率引數就可能出現明顯的抖動現象。事實上,時間區域性性比空間區域性性有更好的命中率,如果從命中率來考慮,那麼時間區域性性的優先順序更高。

        然而我們不能無限制的提高行數(提高相聯度引數E),如果行數太多,需要更多的標記位,實現起來更昂貴並且訪問速度很難提高,實現複雜度的增加會使得其命中時間加大,而且不命中處罰也很大,因為選擇犧牲行(快取慢時需要清除舊行移入不命中新行)的複雜性也增加了。

        以上的分配策略討論,也是CPU生產商經過權衡依據,他們在設計L1、L2甚至L3時,會反映出他們的折中結果,而且這個結果除了依據理論推導外,還會引入大量的程式執行實踐。


三、直接對映快取記憶體命中率問題的模擬

        這裡詳細套用教材上兩個很有意思的練習題。

        1、轉置矩陣行列互換的實現:

        typedef int array [2][2];

        void transpose(array dst, array src)

        {

                int  i, j;

                for(i = 0; i < 2; i++){

                        for(j = 0; j < 2; j++){

                                dst[j][i] = src[i][j];

                        }

                }

        }

        假設程式碼執行環境:

        (1)sizeof(int)= 4。

        (2)src陣列從地址0開始,dst陣列從地址16開始。

        (3)只有一個L1快取,是直接對映的、直寫、寫分配,塊大小為8位元組。

        (4)快取記憶體總大小16位元組,一開始空的。

        (5)對src和dst陣列的訪問分別是讀和寫不命中的唯一原因。

        

        關於這個例子,很明顯能看到,快取塊大小8位元組,快取總大小16位元組,說明只有兩組,也就是說兩行,而兩個陣列dst和src在儲存上是平權的,這兩個二維陣列,一共就兩行,每行兩列,那麼每行剛好就是8位元組,二維陣列總大小16位元組。也就是說,整個快取只能存下一個二維陣列,而現在有dst和src兩個二維陣列,為了實現快取對兩個二維陣列的快取,只能把快取平均分配給兩個二維陣列。比如,快取的第一行8位元組用來快取dst[0][0]、dst[0][1]或者src[0][0]、src[0][1];而快取的第二行8個位元組用來快取dst[1][0]、dst[1][1]或者src[1][0]、src[1][1]。也就是說,快取有可能出現以下四種滿的情況:


src[0][0] src[0][1]
src[1][0] src[1][1]

dst[0][0] dst[0][1]
dst[1][0] dst[1][1]

src[0][0] src[0][1]
dst[1][0] dst[1][1]

dst[0][0] dst[0][1]
src[1][0] src[1][1]

        從上可看出,快取的行,剛好對應了二維陣列的行,至於是哪個二維陣列不重要,反正都可以共享快取的行,有了這樣的分配方式,我們就能很容易分析出命中關係了。

由於C語言是從右到左的計算順序,先讀取的是src[0][0],src[0]這一行進快取:

src[0][0] src[0][1]
   

接下來是寫入dst[0][0],由於都是第一行,因此dst[0]就會佔用快取第一行,而移出src[0]:

dst[0][0] dst[0][1]
 

然後又會讀取src[0][1],我去,剛才src[0][1]還在快取呆著,不幸被dst[0]取代了,現在又要讀取src[0],所以果斷移出dst[0]:

src[0][0] src[0][1]
   

然後又是寫入dst[1][0],快取第二行終於第一次被用上:

src[0][0] src[0][1]
dst[1][0] dst[1][1]

接下來是讀取src[1][0],哦豁,dst[1]只能滾蛋:

src[0][0] src[0][1]
src[1][0] src[1][1]

然後就是寫入dst[0][1],以牙還牙,趕出src[0]:

dst[0][0] dst[0][1]
src[1][0] src[1][1]

接下來是讀取src[1][1],老天有眼,快取裡終於有個現成的了,讀取命中!快取不變:

dst[0][0] dst[0][1]
src[1][0] src[1][1]

然後就是寫入dst[1][1],又把src[1]取代:

dst[0][0] dst[0][1]
dst[1][0] dst[1][1]

        至此迴圈結束,我們發現,無論讀寫幾乎全部不命中,唯一的例外就是讀取src[1][1]時快取命中。如果我們增加快取的行,讓快取大小增加到32位元組呢?那麼dst和src兩個二維陣列的兩個行就可以對映到不同的快取空間中:

src[0][0] src[0][1]
src[1][0] src[1][1]
dst[0][0] dst[0][1]
dst[1][0] dst[1][1]
        這樣一來會不會全部命中呢?No!要知道還有冷不命中的概念,快取為空時,第一次讀取肯定是不命中的,有興趣的讀者可以使用四行快取重複上面的步驟,最後你將會得出,dst[0][0]、src[0][0]、dst[1][0]、src[1][0]四個讀或者寫時產生冷不命中,而其餘四個讀寫都會是命中!


        有了上面的例子做幫助,再來研究下面的例子就會容易許多了:

        2、緊密迴圈實現的命中

        (1)直接對映快取記憶體,塊大小16位元組,總大小1024位元組;

        (2)sizeof(int),grid從儲存器地址0開始

        (3)快取記憶體開始時是空的,唯一記憶體訪問是對陣列grid的元素訪問,其餘臨時變數都存放在暫存器中。


struct algae_position {

        int x;
        int y;
};

struct algae_position grid[16][16];
int total_x = 0, total_y = 0;
int i, j;

 for (i = 0; i < 16; i++) {
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
     }
 }

 for (i = 0; i < 16; i++) {
     for (j = 0; j < 16; j++) {
         total_y += grid[i][j].y;
     }
}

        總讀數是多少?快取不命中的讀總數是多少?不命中率是多少?如果快取記憶體的行有兩倍大,不命中率會是多少呢?

        我們看看,快取記憶體塊大小2^4位元組,總大小2^10位元組,說明有2^6也就是64個快取行(也是快取組),快取結構(S,B,E,m)= (64,16,64,8)。

        grid內每個元素是8個位元組,那麼一個快取塊就能容納兩個元素,總共256個元素就需要128個塊,而快取總大小隻有64個塊(64個行)。

        從程式碼的呼叫能看出是順序訪問,不會重複訪問相同的grid行,當讀取grid[0][0]時,grid[0][1]也會被讀入快取,當讀到grid[7][14]時,grid[7][15]也會被讀入快取,此時快取剛好滿(每陣列行要佔據8個快取行,因此8個陣列行就剛好填滿64個快取行),當此時我們繼續讀取grid[8][0]時,它會取代grid[0][0]、grid[0][1]所佔據的第一個快取塊——反正也不需要的,因此,冷不命中和命中將交替出現,x和y的訪問都是完全相同的操作:

g[0][0] g[0][1]
g[0][2] g[0][3]
…… ……
g[7][14] g[7][15]

grid[8][0]進來時,會驅趕grid[0][0]這行:

g[8][0] g[8][1]
g[0][2] g[0][3]
…… ……
g[7][14] g[7][15]

x或者y全部讀取完時,快取內的情況就如下:

g[8][0] g[8][1]
g[8][2] g[8][3]
…… ……
g[15][14] g[15][15]


        有了上面的鋪墊,就很容易回答問題了。總讀數是512,快取不命中的讀總數是256,不命中率是50%,如果快取記憶體的行數有兩倍多,不命中率會是多少呢?快取行再大,有區別麼?沒有,你肯定是存在冷不命中的,所以不命中率永遠是50%。


        如果把例題程式碼改成如下情形,會有怎樣的結果呢?

 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[j][i].x;
         total_y += grid[j][i].y;
     }
 }

        很明顯,訪問順序變了!這段程式碼的外層迴圈是按列進行遍歷,裡層迴圈是遍歷行,這樣的遍歷方式和二維陣列的儲存順序相悖,用上面的基礎,我們再來詳細分析一下:

        如果還按上面迴圈1的順序,是類似grid[0][0]~grid[0][15],grid[1][0]~grid[1][15]的遍歷方式,就像之前那樣,快取結果會這樣:

g[0][0] g[0][1]
g[0][2] g[0][3]
…… ……
g[0][14] g[0][15]
g[1][0] g[1][1]
g[1][2] g[1][3]
…… ……
g[1][14] g[1][15]

        而現在讀取方式是grid[0][0]~grid[1][0],什麼不會變呢?我想應該是快取與二維陣列的資料空間對應關係不變,當讀取grid[0][0],grid[1][0]時,快取應該是這樣:

g[0][0] g[0][1]
空…… 空……
空…… 空……
空……
g[1][0] g[1][1]
空…… 空……
空…… 空……
空…… 空……

        在快取g[1][0]時,CPU給g[0][3]~g[0][15]預留了對應的快取空間,因此g[1][0]和g[1][1]剛好快取在整個g[0]行之後的位置,往下g[2]和g[3]都會給前一行預留足夠的空間,即便這時程式並沒有讀上一行的其他列。
        以此類推,當讀到g[8][0]時,快取雖然還有很多空閒,但卻沒有對應的位置去儲存g[8][0],要儲存只能取代g[0][0]:

g[8][0] g[8][1]
空…… 空……
空…… 空……
空…… 空……
g[1][0] g[1][1]
空…… 空……
空…… 空……
空…… 空……
g[2][0] g[2][1]
空…… 空……
空…… 空……
g[3][0] g[3][1]
空…… 空……
空…… 空……
g[7][0] g[7][1]
空…… 空……

        我們可以預見到,當程式很久以後要讀g[0][1]時,由於不命中又會把g[0][0]、g[0][1]移入快取,這時候肯定是驅逐g[8][0]、g[8][1],等到遙遠的未來程式再讀到g[8][1]時,又出現不命中,也就是說,所有g[j][i].x的呼叫都將是冷不命中或者不命中,只有讀取g[j][i].y時,能沾沾光x的光,實現命中,因此,總讀數是512,快取不命中的讀總數是256,不命中率是50%。答案不變!

         如果快取行數擴大到兩倍,是什麼結果?可以想象一下,那將是128行,每行16位元組,就是2^(4+7)=2048 = 256*8,也就是說整個grid二維陣列都能裝入快取,再也不會出現什麼g[8]驅逐g[0]、g[9]驅逐g[1]的情形。那麼此時只可能發生冷不命中,也就是第一次讀取某grid行時載入進快取的動作。舉個例子,當讀取g[2][0]時,g[2][1]會一起進快取並且一直儲存到程式終結,那麼等到程式外層列迴圈到1時,g[2][1]也會命中,那麼除了g[2][0].x引用不命中外,其餘的g[2][0].y、g[2][1].x、g[2][1].y引用都會命中,不命中率就會是25%!


        接下來分析下面的迴圈3程式碼:
 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
         total_y += grid[i][j].y;
     }
 }

        很明顯,這個是具有良好時間和空間區域性性的迴圈,如果快取沒有擴大,還是1024位元組的話,用心算就能得出(j是偶數):g[i][j].x不命中,g[i][j].y、g[i][j+1].x、g[i][j+1].y都會命中,不命中率是25%!

        如果快取行數又擴大到兩倍多呢?隨便你快取有多大都沒用,而即便快取只有一行,也就是說快取總共只有16位元組,不命中率仍然是25%,因為這個迴圈的訪問順序是按照二維陣列的儲存順序走的,g[i][j]、g[i][j+1]進快取後也只使用一次,所以驅逐不驅逐對他們的結果沒有影響。

        我們發現一個現象,無論怎麼改程式序和快取行大小,不命中率始終無法小於25%,其實關於不命中率的計算,存在一個很有意思的公式:如果一個快取記憶體塊大小為B位元組,那麼一個步長為k的應用模式(這個k是以字(wordsize)為單位,而1字又等於4位元組,你可以把字理解成int),平均每次迴圈會有min(1,(wordsize*k)/B)次不命中。取最小值,也就說最慘的情況是每次都不命中,最好的情況,是(wordsize*k)/B次不命中。


        先看第一個迴圈:
 for (i = 0; i < 16; i++) {
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
     }
 }
        
        很明顯,按步長為8位元組訪問,說明k是2,最小不命中率應該是:(4*2)/16 = 50%。
        再來看第二個迴圈:

 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[j][i].x;
         total_y += grid[j][i].y;
     }
 }

        很明顯,這個是按步長4位元組訪問,說明k是1,最小不命中率應該是:(4*1)/16 = 25%。

        再來看第三個迴圈:

 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
         total_y += grid[i][j].y;
     }
 }

       很明顯,這個是按步長4位元組訪問,說明k是1,最小不命中率應該是:(4*1)/16 = 25%。


       你可能會質疑迴圈2的計算方式,步長怎麼是4位元組?不是訪問完兩列就跳行麼?呵呵,題目設計最巧的地方也是這裡。如果快取的行夠多,能夠存下所有的元素,先訪問和後訪問又有啥區別呢?想想是不是這個道理吧!從這裡我們能得出結論,最小不命中數,有可能跟快取的大小有關,比如迴圈2;也可能跟快取大小無關,比如迴圈3——哪怕你只有一行都沒事,只要有一個塊就夠用,因此該公式只能算出最小不命中率,也就是最優情況,但如何實現最優,條件是什麼?那就具體問題具體分析了。


        這也許是網上對該習題最詳細深入的解讀了。實話說,我在開寫這一節內容時,越來越發覺自己對例題程式迴圈2的快取機制理解有誤。之前我一直以為,當訪問g[0][0]時,會把g[0]的整行資料:g[0][0]~g[0][15]全部載入進快取的相應位置,以為這樣才能解釋為什麼習題答案中說g[8][0]沒地方存,要驅逐g[0][0]了。但後來發現不對勁!這個假設與迴圈1和迴圈3的實現相悖!(轉置矩陣例題倒是沒看出與之矛盾~囧)憑啥迴圈1和迴圈3在讀取g[0][0]的時候就沒有把g[0][0]~g[0][15]打包載入進快取呢?!而且也不符合本節第二部分得出的結論:快取塊越大,空間區域性性越好,但時間區域性性相應就越差;明明你塊大小不夠了(一次載入只夠存g[0][0]、g[0][1]),居然還能用多餘的快取行接著載入g[0][2]、g[0][3]……,這樣一來,快取的塊大或者行大又有啥區別呢??思考糾結了好幾日,今天終於茅塞頓開!CPU對快取的利用一定有自己的分配策略,事先將二維陣列的各行各列對映到快取的相應區域,如果對映不完,就讓不同的二維陣列“元素對”共享同一快取塊(行),這與轉置矩陣的分配策略非常類似!這才是對教材前後內容統一的最合理解釋。如果你剛好也在這裡被迷惑的話,能看到這部分文字是不是感到很幸運?嘿嘿,這道題我也是搜遍網路也找不到其他解答,還好自己悟出來。通過梳理部落格到現在,已經有很多地方溫故而知新了O(∩_∩)O~



相關文章