1. 程式人生 > 其它 >CSAPP-Lab05 Cache Lab 深入解析

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中分別獲取我們需要的stagaddress結構如下:

這就用到了第二章以及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行就會重複。AB的地址由於取餘關係,每個元素對應的地址是相同的。各個元素對應快取行如下:

對於A,每8個int就會佔滿快取的一組,所以每一行會有 32/8 = 4 次不命中;而對於B,考慮最壞情況,每一列都有 32 次不命中,由此,算出總不命中次數為 4 × 32 + 32 × 32 = 1152。拿程式跑一下:

結果為 1183 比預估多了一點,這是對角線部分兩者衝突造成的,後面會講到。

回過頭來,思考暴力做法:

在寫入B的前 8 行後,BD區域就全部進入了快取,此時如果能對D進行操作,那麼就能利用上快取的內容,不會miss;但是,暴力解法接下來操作的是C,每一個元素的寫都要驅逐之前的快取區,當來到第 2 列繼續寫D時,它對應的快取行很可能已經被驅逐了,於是又要miss,也就是說,暴力解法的問題在於沒有充分利用上已經進入快取的元素。

分塊解決的就是同一個矩陣內部快取塊相互替換的問題。

由上述分析,顯然應考慮 8 × 8 分塊,這樣在塊的內部不會衝突,接下來判斷AB之間會不會衝突

A中標紅的塊佔用的是快取的第 0,4,8,12,16,20,24,28組,而B中標紅的塊佔用的是快取的第2,6,10,14,18,16,30組,剛好不會衝突。事實上,除了對角線AB中對應的塊都不會衝突。所以,我們的想法是可行的,寫出程式碼:

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,與我們計算的結果差距非常大,沒有得到滿分,這是為什麼呢?這就要考慮到對角線上的塊了。AB對角線上的塊在快取中對應的位置是相同的,而它們在轉置過程中位置不變,所以複製過程中會發生相互衝突。

A的一個對角線塊pBp相應的對角線塊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。這樣,就避免了第一個元素複製時,BA的緩衝行驅逐,導致沒有利用上A的緩衝。

結果如下:

miss次數為 287,滿分!

64 × 64

每 4 行就會佔滿一個快取,先考慮 4 × 4 分塊,結果如下:

結果還不錯,雖然沒有得到滿分。

還是考慮 8 × 8 分塊,由於存在著每 4 行就會佔滿一個快取的問題,在分塊內部處理時就需要技巧了,我們把分塊內部分成 4 個 4 × 4 的小分塊分別處理:

  • 第一步,將A的左上和右上一次性複製給B
  • 第二步,用本地變數把B的右上角儲存下來
  • 第三步,將A的左下複製給B的右上
  • 第四步,利用上述儲存B的右上角的本地變數,把A的右上覆制給B的左下
  • 第五步,把A的右下複製給B的右下

畫出圖解如下:

這裡的AB均表示兩個矩陣中的 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 小時