CSAPP-Lab05 Cache Lab 深入解析
本文首發於我的知乎專欄:https://zhuanlan.zhihu.com/p/484657229
實驗概覽
Cache Lab 分為兩部分,編寫一個快取記憶體模擬器以及要求優化矩陣轉置的核心函式,以最小化對模擬的快取記憶體的不命中次數。本實驗對我這種程式碼能力較差的人來說還是很有難度的。
在開始實驗前,強烈建議先閱讀以下學習資料:
實驗說明文件:Writeup
CMU 關於 Cache Lab 的 PPT:Cache Lab Implementation and Blocking
CMU 關於分塊優化的講解: Using Blocking to Increase Temporal Locality
本人踩的坑:我的 lab 環境是 Windows11 + wsl2。由於 wsl2 跨 OS 磁碟訪問非常慢,而我是將檔案放在 Windows 下進行的實驗,Part B 部分的測試結果甚至無法跑出來!所以,建議用虛擬機器進行實驗,如果你也是 wsl2 使用者,請將實驗檔案放在 wsl2 自己的目錄下!
Part A: Writing a Cache Simulator
Part A 要求在csim.c
下編寫一個快取記憶體模擬器來對記憶體讀寫操作進行正確的反饋。這個模擬器有 6 個引數:
Usage: ./csim-ref [-hv] -s <s> -E <E> -b <b> -t <tracefile> • -h: Optional help flag that prints usage info • -v: Optional verbose flag that displays trace info • -s <s>: Number of set index bits (S = 2s is the number of sets) • -E <E>: Associativity (number of lines per set) • -b <b>: Number of block bits (B = 2b is the block size) • -t <tracefile>: Name of the valgrind trace to replay
其中,輸入的 trace 的格式為:[space]operation address, size
,operation 有 4 種:
-
I
表示載入指令 -
L
載入資料 -
S
儲存資料 -
M
修改資料
模擬器不需要考慮載入指令,而M
指令就相當於先進行L
再進行S
,因此,要考慮的情況其實並不多。模擬器要做出的反饋有 3 種:
-
hit
:命中,表示要操作的資料在對應組的其中一行 -
miss
:不命中,表示要操作的資料不在對應組的任何一行 -
eviction
:驅趕,表示要操作的資料的對應組已滿,進行了替換操作
回顧:Cache 結構
Cache 類似於一個二維陣列,它有\(S=2^s\)組,每組有 E 行,每行儲存的位元組也是固定的。其中,每行都有一個有效位,和一個標記位。想要查詢到對應的位元組,我們的地址需要三部分組成:
- s,索引位,找到對應的組序號
- tag,標記位,在組中的每一行進行匹配,判斷能否命中
- b,塊偏移,表明在找到的行中的具體位置。本實驗不考慮塊便宜,完全可以忽略。
那麼,Cache 中的有效位是幹什麼的呢?判斷該行是否為空。這裡有一個概念:冷不命中,表示該快取塊為空造成的不命中。而一旦確定不命中不是冷不命中,那麼就需要考慮行替換的問題了。我認為,行替換關乎著 Cache 的效率,是 Cache 設計的核心。
回顧:替換策略
當 CPU 要處理的字不在組中任何一行,且組中沒有一個空行,那就必須從裡面選取一個非空行進行替換。選取哪個空行進行替換呢?書上給了我們兩種策略:
- LFU,最不常使用策略。替換在過去某個視窗時間內引用次數最少的那一行
- LRU,最近最少使用策略。替換最後一次訪問時間最久遠的哪一行
本實驗要求採取的策略為 LRU。
那麼程式碼如何實現呢?我的第一反應是實現 S 個雙向連結串列,每個連結串列有 E 個結點,對應於組中的每一行,每當訪問了其中的一行,就把這個結點移動到連結串列的頭部,後續替換的時候只需要選擇鏈尾的結點就好了。但是,為了簡單,我還是選擇了 PPT 中提示的相對簡單的設定時間戳的辦法,雙向連結串列以後有時間再寫吧。
下面就可以正式開始 Part A 了!我對我寫的模擬器的核心部分進行講解。
資料結構
定義了Cache
結構體
typedef struct cache_
{
int S;
int E;
int B;
Cache_line **line;
} Cache;
用Cache
表示一個快取,它包括 S, B, E 等特徵,以及前面說過的,每一個快取類似於一個二位陣列,陣列的每一個元素就是快取中的行所以用一個line
來表示這一資訊:
typedef struct cache_line
{
int valid; //有效位
int tag; //標記位
int time_tamp; //時間戳
} Cache_line;
valid
以及tag
不再贅述,這裡的time_tamp
表示時間戳,是 LRU 策略需要用到的特徵。Cache 初始值設定如下:
void Init_Cache(int s, int E, int b)
{
int S = 1 << s;
int B = 1 << b;
cache = (Cache *)malloc(sizeof(Cache));
cache->S = S;
cache->E = E;
cache->B = B;
cache->line = (Cache_line **)malloc(sizeof(Cache_line *) * S);
for (int i = 0; i < S; i++)
{
cache->line[i] = (Cache_line *)malloc(sizeof(Cache_line) * E);
for (int j = 0; j < E; j++)
{
cache->line[i][j].valid = 0; //初始時,快取記憶體是空的
cache->line[i][j].tag = -1;
cache->line[i][j].time_tamp = 0;
}
}
}
注意,時間戳初始設定為0。
LRU 時間戳實現
我的邏輯是時間戳越大則表示該行最後訪問的時間越久遠。先看 LRU 更新的程式碼:
void update(int i, int op_s, int op_tag){
cache->line[op_s][i].valid=1;
cache->line[op_s][i].tag = op_tag;
for(int k = 0; k < cache->E; k++)
if(cache->line[op_s][k].valid==1)
cache->line[op_s][k].time_tamp++;
cache->line[op_s][i].time_tamp = 0;
}
這段程式碼在找到要進行的操作行後呼叫(無論是不命中還是命中,還是驅逐後)。前兩行是對有效位和標誌位的設定,與時間戳無關,主要關注後幾行:
- 遍歷組中每一行,並將它們的值加1,也就是說每一行在進行一次操作後時間戳都會變大,表示它離最後操作的時間變久
- 將本次操作的行時間戳設定為最小,也就是0
由此,每次只需要找到時間戳最大的行進行替換就可以了:
int find_LRU(int op_s)
{
int max_index = 0;
int max_stamp = 0;
for(int i = 0; i < cache->E; i++){
if(cache->line[op_s][i].time_tamp > max_stamp){
max_stamp = cache->line[op_s][i].time_tamp;
max_index = i;
}
}
return max_index;
}
快取搜尋及更新
先解決比較核心的問題,在得知要操作的組op_s
以及標誌位op_tag
後,判斷是miss
還是hit
還是應該eviction
呼叫find_LRU
。
先判斷是miss
還是hit
:
int get_index(int op_s, int op_tag)
{
for (int i = 0; i < cache->E; i++)
{
if (cache->line[op_s][i].valid && cache->line[op_s][i].tag == op_tag)
return i;
}
return -1;
}
遍歷所有行,如果某一行有效,且標誌位相同,則hit
,返回該索引。否則,miss
,返回 -1。當接收到-1後,有兩種情況:
- 冷不命中。組中有空行,只不過還未操作過,有效位為0,找到這個空行即可
- 所有行都滿了。那麼就要用到上面得 LRU 進行選擇驅逐
所以,設計一個判滿的函式:
int is_full(int op_s)
{
for (int i = 0; i < cache->E; i++)
{
if (cache->line[op_s][i].valid == 0)
return i;
}
return -1;
}
掃描完成後,得到對應行的索引值,就可以呼叫 LRU 更新函式進行更新了。整體呼叫如下:
void update_info(int op_tag, int op_s)
{
int index = get_index(op_s, op_tag);
if (index == -1)
{
miss_count++;
if (verbose)
printf("miss ");
int i = is_full(op_s);
if(i==-1){
eviction_count++;
if(verbose) printf("eviction");
i = find_LRU(op_s);
}
update(i,op_s,op_tag);
}
else{
hit_count++;
if(verbose)
printf("hit");
update(index,op_s,op_tag);
}
}
至此,Part A 的核心部分函式就編寫完了,下面的內容屬於是技巧性的部分,與架構無關。
指令解析
設計的資料結構解決了對 Cache 的操作問題,LRU 時間戳的實現解決了核心的驅逐問題,快取掃描解決了對塊中哪一列進行操作的問題,而應該對哪一塊進行操作呢?接下來要解決的就是指令的解析問題了。
輸入資料為[space]operation address, size
的形式,operation
很容易獲取,重要的是從address
中分別獲取我們需要的s
和tag
,address
結構如下:
這就用到了第二章以及Data Lab的知識。tag 很容易得到,右移 (b + s) 位即可:
int op_tag = address >> (s + b);
獲取 s,考慮先右移 b 位,再用無符號 0xFF... 右移後進行與操作將 tag 抹去。為什麼要用無符號 0xFF... 右移呢?因為C語言中的右移為算術右移,有符號數右移補位的數為符號位。
int op_s = (address >> b) & ((unsigned)(-1) >> (8 * sizeof(unsigned) - s));
由於資料讀寫對於本模擬器而言是沒有區別的,因此不同的指令對應的只是 Cache 更新次數的問題:
void get_trace(int s, int E, int b)
{
FILE *pFile;
pFile = fopen(t, "r");
if (pFile == NULL)
{
exit(-1);
}
char identifier;
unsigned address;
int size;
// Reading lines like " M 20,1" or "L 19,3"
while (fscanf(pFile, " %c %x,%d", &identifier, &address, &size) > 0) // I讀不進來,忽略---size沒啥用
{
//想辦法先得到標記位和組序號
int op_tag = address >> (s + b);
int op_s = (address >> b) & ((unsigned)(-1) >> (8 * sizeof(unsigned) - s));
switch (identifier)
{
case 'M': //一次儲存一次載入
update_info(op_tag, op_s);
update_info(op_tag, op_s);
break;
case 'L':
update_info(op_tag, op_s);
break;
case 'S':
update_info(op_tag, op_s);
break;
}
}
fclose(pFile);
}
update_info
就是對 Cache 進行更新的函式,前面已經講解。如果指令是M
則一次儲存一次載入,總共更新兩次,其他指令只用更新一次,而I
無需考慮。
命令列引數獲取
通過閱讀Cache Lab Implementation and Blocking的提示,我們使用getopt()
函式來獲取命令列引數的字串形式,然後用atoi()
轉換為要用的引數,最後用switch
語句跳轉到對應功能塊。
程式碼如下:
int main(int argc, char *argv[])
{
char opt;
int s, E, b;
/*
* s:S=2^s是組的個數
* E:每組中有多少行
* b:B=2^b每個緩衝塊的位元組數
*/
while (-1 != (opt = getopt(argc, argv, "hvs:E:b:t:")))
{
switch (opt)
{
case 'h':
print_help();
exit(0);
case 'v':
verbose = 1;
break;
case 's':
s = atoi(optarg);
break;
case 'E':
E = atoi(optarg);
break;
case 'b':
b = atoi(optarg);
break;
case 't':
strcpy(t, optarg);
break;
default:
print_help();
exit(-1);
}
}
Init_Cache(s, E, b); //初始化一個cache
get_trace(s, E, b);
free_Cache();
// printSummary(hit_count, miss_count, eviction_count)
printSummary(hit_count, miss_count, eviction_count);
return 0;
}
完整程式碼太長,可訪問我的Github
倉庫檢視:
https://github.com/Deconx/CSAPP-Lab
Part B: Optimizing Matrix Transpose
Part B 是在trans.c
中編寫矩陣轉置的函式,在一個 s = 5, E = 1, b = 5 的快取中進行讀寫,使得 miss 的次數最少。測試矩陣的引數以及 miss 次數對應的分數如下:
要求最多隻能宣告12個本地變數。
根據課本以及 PPT 的提示,這裡肯定要使用矩陣分塊進行優化
32 × 32
開始之前,我們先了解一下何為分塊?為什麼分塊?
s = 5, E = 1, b = 5 的快取有32組,每組一行,每行存 8 個int
,如圖:
就以這個快取為例,考慮暴力轉置的做法:
void trans_submit(int M, int N, int A[N][M], int B[M][N]) {
for (int i = 0; i < N; i++) {
for (int j = 0; j < M; j++) {
int tmp = A[i][j];
B[j][i] = tmp;
}
}
}
這裡我們會按行優先讀取 A
矩陣,然後一列一列地寫入 B
矩陣。
以第1行為例,在從記憶體讀 A[0][0]
的時候,除了 A[0][0]
被載入到快取中,它之後的 A[0][1]---A[0][7]
也會被載入進快取。
但是內容寫入 B
矩陣的時候是一列一列地寫入,在列上相鄰的元素不在一個記憶體塊上,這樣每次寫入都不命中快取。並且一列寫完之後再返回,原來的快取可能被覆蓋了,這樣就又會不命中。我們來定量分析。
快取只夠儲存一個矩陣的四分之一,A
中的元素對應的快取行每隔8行就會重複。A
和B
的地址由於取餘關係,每個元素對應的地址是相同的。各個元素對應快取行如下:
對於A
,每8個int
就會佔滿快取的一組,所以每一行會有 32/8 = 4 次不命中;而對於B
,考慮最壞情況,每一列都有 32 次不命中,由此,算出總不命中次數為 4 × 32 + 32 × 32 = 1152。拿程式跑一下:
結果為 1183 比預估多了一點,這是對角線部分兩者衝突造成的,後面會講到。
回過頭來,思考暴力做法:
在寫入B
的前 8 行後,B
的D
區域就全部進入了快取,此時如果能對D
進行操作,那麼就能利用上快取的內容,不會miss
;但是,暴力解法接下來操作的是C
,每一個元素的寫都要驅逐之前的快取區,當來到第 2 列繼續寫D
時,它對應的快取行很可能已經被驅逐了,於是又要miss
,也就是說,暴力解法的問題在於沒有充分利用上已經進入快取的元素。
分塊解決的就是同一個矩陣內部快取塊相互替換的問題。
由上述分析,顯然應考慮 8 × 8 分塊,這樣在塊的內部不會衝突,接下來判斷A
與B
之間會不會衝突
A
中標紅的塊佔用的是快取的第 0,4,8,12,16,20,24,28組,而B
中標紅的塊佔用的是快取的第2,6,10,14,18,16,30組,剛好不會衝突。事實上,除了對角線,A
與B
中對應的塊都不會衝突。所以,我們的想法是可行的,寫出程式碼:
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
for (int i = 0; i < N; i += 8)
for (int j = 0; j < M; j += 8)
for (int k = 0; k < 8; k++)
for (int s = 0; s < 8; s++)
B[j + s][i + k] = A[i + k][j + s];
}
對於A
中每一個操作塊,只有每一行的第一個元素會不命中,所以為8次不命中;對於B
中每一個操作塊,只有每一列的第一個元素會不命中,所以也為 8 次不命中。總共miss
次數為:8 × 16 × 2 = 256
跑出結果:
miss
次數為343,與我們計算的結果差距非常大,沒有得到滿分,這是為什麼呢?這就要考慮到對角線上的塊了。A
與B
對角線上的塊在快取中對應的位置是相同的,而它們在轉置過程中位置不變,所以複製過程中會發生相互衝突。
以A
的一個對角線塊p
,B
與p
相應的對角線塊q
為例,複製前, p
在快取中。 複製時,q
會驅逐p
。 下一個開始複製 p
又被重新載入進入快取驅逐 q
,這樣就會多產生兩次miss
。
如何解決這種問題呢?題目給了我們提示:
You are allowed to define at most 12 local variables of type int per transpose function
考慮使用 8 個本地變數一次性存下 A
的一行後,再複製給 B
。程式碼如下:
void transpose_submit(int M, int N, int A[N][M], int B[M][N])
{
for(int i = 0; i < 32; i += 8)
for(int j = 0; j < 32; j += 8)
for (int k = i; k < i + 8; k++)
{
int a_0 = A[k][j];
int a_1 = A[k][j+1];
int a_2 = A[k][j+2];
int a_3 = A[k][j+3];
int a_4 = A[k][j+4];
int a_5 = A[k][j+5];
int a_6 = A[k][j+6];
int a_7 = A[k][j+7];
B[j][k] = a_0;
B[j+1][k] = a_1;
B[j+2][k] = a_2;
B[j+3][k] = a_3;
B[j+4][k] = a_4;
B[j+5][k] = a_5;
B[j+6][k] = a_6;
B[j+7][k] = a_7;
}
}
對於非對角線上的塊,本身就沒有額外的衝突;對於對角線上的塊,寫入A
每一行的第一個元素後,這一行的元素都進入了快取,我們就立即用本地變數存下這 8 個元素,隨後再複製給B
。這樣,就避免了第一個元素複製時,B
把A
的緩衝行驅逐,導致沒有利用上A
的緩衝。
結果如下:
miss
次數為 287,滿分!
64 × 64
每 4 行就會佔滿一個快取,先考慮 4 × 4 分塊,結果如下:
結果還不錯,雖然沒有得到滿分。
還是考慮 8 × 8 分塊,由於存在著每 4 行就會佔滿一個快取的問題,在分塊內部處理時就需要技巧了,我們把分塊內部分成 4 個 4 × 4 的小分塊分別處理:
- 第一步,將
A
的左上和右上一次性複製給B
- 第二步,用本地變數把
B
的右上角儲存下來 - 第三步,將
A
的左下複製給B
的右上 - 第四步,利用上述儲存
B
的右上角的本地變數,把A
的右上覆制給B
的左下 - 第五步,把
A
的右下複製給B
的右下
畫出圖解如下:
這裡的A
和B
均表示兩個矩陣中的 8 × 8 塊
第 1 步:
此時B
的前 4 行就在快取中了,接下來考慮利用這個快取 。可以看到,為了利用A
的快取,第 2 塊放置的位置實際上是錯的,接下來就用本地變數儲存B
中 2 塊的內容
第 2 步:
用本地變數把B
的 2 塊儲存下來
for (int k = j; k < j + 4; k++){
a_0 = B[k][i + 4];
a_1 = B[k][i + 5];
a_2 = B[k][i + 6];
a_3 = B[k][i + 7];
}
第 3 步:
現在快取中還是存著B
中上兩塊的內容,所以將A
的 3 塊內容複製給它
第 4/5 步:
現在快取已經利用到極致了,可以開闢B
的下面兩塊了
這樣就實現了轉置,且消除了同一行中的衝突,具體程式碼如下:
void transpose_64x64(int M, int N, int A[N][M], int B[M][N])
{
int a_0, a_1, a_2, a_3, a_4, a_5, a_6, a_7;
for (int i = 0; i < 64; i += 8){
for (int j = 0; j < 64; j += 8){
for (int k = i; k < i + 4; k++){
// 得到A的第1,2塊
a_0 = A[k][j + 0];
a_1 = A[k][j + 1];
a_2 = A[k][j + 2];
a_3 = A[k][j + 3];
a_4 = A[k][j + 4];
a_5 = A[k][j + 5];
a_6 = A[k][j + 6];
a_7 = A[k][j + 7];
// 複製給B的第1,2塊
B[j + 0][k] = a_0;
B[j + 1][k] = a_1;
B[j + 2][k] = a_2;
B[j + 3][k] = a_3;
B[j + 0][k + 4] = a_4;
B[j + 1][k + 4] = a_5;
B[j + 2][k + 4] = a_6;
B[j + 3][k + 4] = a_7;
}
for (int k = j; k < j + 4; k++){
// 得到B的第2塊
a_0 = B[k][i + 4];
a_1 = B[k][i + 5];
a_2 = B[k][i + 6];
a_3 = B[k][i + 7];
// 得到A的第3塊
a_4 = A[i + 4][k];
a_5 = A[i + 5][k];
a_6 = A[i + 6][k];
a_7 = A[i + 7][k];
// 複製給B的第2塊
B[k][i + 4] = a_4;
B[k][i + 5] = a_5;
B[k][i + 6] = a_6;
B[k][i + 7] = a_7;
// B原來的第2塊移動到第3塊
B[k + 4][i + 0] = a_0;
B[k + 4][i + 1] = a_1;
B[k + 4][i + 2] = a_2;
B[k + 4][i + 3] = a_3;
}
for (int k = i + 4; k < i + 8; k++)
{
// 處理第4塊
a_4 = A[k][j + 4];
a_5 = A[k][j + 5];
a_6 = A[k][j + 6];
a_7 = A[k][j + 7];
B[j + 4][k] = a_4;
B[j + 5][k] = a_5;
B[j + 6][k] = a_6;
B[j + 7][k] = a_7;
}
}
}
}
執行結果:
miss
為 1227,通過!
61 × 67
這個矩陣的轉置要求很鬆,miss
為 2000 以下就可以了。我也無心進行更深入的優化,直接 16 × 16 的分塊就能通過。
miss
為 1992,擦線滿分!
總結
先附上滿分完結圖:
- Cache Lab 是我在做前 5 個實驗中感覺最痛苦的一個,主要原因在於我的程式碼能力較弱,邏輯思維能力較差,以後應該加強這方面的訓練
- 這個實驗的 Part A 讓我對快取的設計有了更深入的理解,其中替換策略也值得以後繼續研究;Part B 為我展示了計算機之美,一個簡簡單單的轉置函式,無論怎麼寫,時間複雜度都是\(O(n^2)\),然而因為緩衝區的問題,不同程式碼的效能竟然有著天壤之別。編寫函式過程中,對
miss
的估量與計算很燒腦,但也很有趣 - 本實驗耗時 2 天,約 20 小時