本文首發於我的知乎專欄: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 小時