CSAPP 之 CacheLab 詳解

之一Yo發表於2022-05-18

前言

本篇部落格將會介紹 CSAPP 之 CacheLab 的解題過程,分為 Part A 和 Part B 兩個部分,其中 Part A 要求使用程式碼模擬一個快取記憶體儲存器,Part B 要求優化矩陣的轉置運算。

解題過程

Part A

題目要求

Part A 給出了一些字尾名為 trace 的檔案,檔案中的內容如下圖所示,其中每一行代表一次對快取的操作,格式為 [空格] 操作 地址,資料大小,其中操作的型別有以下幾種:

  • I:取指令操作
  • L:讀資料操作
  • S:寫資料操作
  • M:修改資料操作,比如先讀一次資料再寫一次資料

只有 I 操作沒有帶前置空格,其他操作都有一個前置空格。地址為 64 位,資料大小以位元組為單位。

trace 檔案內容

Part A 要求實現的快取儲存器的行為和 csim-ref 一致,使用 LRU 演算法進行替換操作。CSAPP 中指出快取記憶體儲存器可以用四元組 \((S, E, B,m)\) 來描述,其中 \(S=2^s\) 為組數,\(E\) 為行數,\(B=2^b\) 為塊的大小,\(m\) 為地址的位數,具體結構如下圖所示:

快取記憶體儲存器的結構

對於模擬的快取記憶體,至少需要接受 4 個引數:

  • -s:組索引的位數
  • -E:行數
  • -b:塊大小 \(B=2^b\) 中的 \(b\)
  • -ttrace 檔案的路徑

根據給定的 trace 檔案,模擬的快取記憶體 csim 需要給出命中次數、未命中次數和替換次數,只有和 csim-ref 的次數一樣才能拿到分數。

程式碼

我們首先定義一個結構,用來代表快取記憶體中的行,由於題目沒要求儲存資料,所以結構中並沒有包含代表快取塊的陣列,同時題目要求使用 LRU 替換演算法,所以包含一個 time 代表與上次訪問相隔多久:

typedef struct {
    int valid;
    int tag;
    int time;
} CacheLine, *CacheSet, **Cache;

接著完成入口函式,進行命令列引數解析和模擬工作:

#include <assert.h>
#include <getopt.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cachelab.h"

int hit, miss, evict;

int s, S, E, b;
char filePath[100];

Cache cache;

int main(int argc, char* argv[]) {
    int opt;
    while ((opt = getopt(argc, argv, "s:E:b:t:")) != -1) {
        switch (opt) {
            case 's':
                s = atoi(optarg);
                S = 1 << s;
                break;
            case 'E':
                E = atoi(optarg);
                break;
            case 'b':
                b = atoi(optarg);
                break;
            case 't':
                strcpy(filePath, optarg);
                break;
        }
    }

    mallocCache();
    simulate();
    freeCache();

    printSummary(hit, miss, evict);
    return 0;
}

由於 \(s\)\(E\)\(b\) 會變,所以需要使用 malloc 函式來在堆上分配空間,使用結束之後還得將這部分空間釋放掉:

/* 動態分配快取空間 */
void mallocCache() {
    cache = (Cache)malloc(S * sizeof(CacheSet));
    assert(cache);

    for (int i = 0; i < S; ++i) {
        cache[i] = (CacheSet)malloc(E * sizeof(CacheLine));
        assert(cache[i]);
    }
}

/* 釋放快取空間 */
void freeCache() {
    for (int i = 0; i < S; ++i) {
        free(cache[i]);
    }
    free(cache);
}

根據 trace 檔案進行模擬的函式如下所示,其中 IS 只需訪問快取一次,而 M 需要兩次,且每進行一次操作,就得更新一次時間戳:

/* 模擬快取讀寫操作*/
void simulate() {
    FILE* file = fopen(filePath, "r");
    assert(file);

    char op;
    uint64_t address;
    int size;
    while (fscanf(file, " %c %lx,%d", &op, &address, &size) > 0) {
        switch (op) {
            case 'M':
                accessCache(address);
            case 'L':
            case 'S':
                accessCache(address);
                break;
        }
        lruUpdate();
    }

    fclose(file);
}

/* 更新訪問時間 */
void lruUpdate() {
    for (int i = 0; i < S; ++i) {
        for (int j = 0; j < E; ++j) {
            if (cache[i][j].valid) {
                cache[i][j].time++;
            }
        }
    }
}

訪問快取的程式碼如下所示,首先根據組索引選出組,接著行匹配,只有有效位為 1 且 tag 與地址中的 \(t\) 位標記相同才說明緩衝擊中,不然就是未擊中。在未擊中的情況下,需要將資料寫入空行中,如果沒有空行就要執行 LRU 演算法進行替換。

/* 訪問快取 */
void accessCache(uint64_t address) {
    int tag = address >> (b + s);
    uint64_t mask = ((1ULL << 63) - 1) >> (63 - s);
    CacheSet cacheSet = cache[(address >> b) & mask];

    // 快取擊中
    for (int i = 0; i < E; ++i) {
        if (cacheSet[i].valid && cacheSet[i].tag == tag) {
            hit++;
            cacheSet[i].time = 0;
            return;
        }
    }

    miss++;

    // 有空位,直接寫入
    for (int i = 0; i < E; ++i) {
        if (!cacheSet[i].valid) {
            cacheSet[i].valid = 1;
            cacheSet[i].tag = tag;
            cacheSet[i].time = 0;
            return;
        }
    }

    // 沒有空位,只能使用 LRU 演算法進行替換
    evict++;
    int evictIndex = 0;
    int maxTime = 0;
    for (int i = 0; i < E; ++i) {
        if (cacheSet[i].time > maxTime) {
            maxTime = cacheSet[i].time;
            evictIndex = i;
        }
    }

    cacheSet[evictIndex].tag = tag;
    cacheSet[evictIndex].time = 0;
}

最終執行結果如下,發現模擬結果和參考答案一致:

Part A 完成

Part B

Part B 給出了最原始的轉置操作程式碼:

void trans(int M, int N, int A[N][M], int B[M][N]) {
    int i, j, tmp;

    for (i = 0; i < N; i++) {
        for (j = 0; j < M; j++) {
            tmp = A[i][j];
            B[j][i] = tmp;
        }
    }
}

題目要求針對 \(32\times 32\)\(64\times 64\)\(61\times 67\) 這三種維度的矩陣進行優化,同時給出了以下兩點友情提示:

  • 使用分塊技術進行優化
  • 對角線上的元素會引發衝突未擊中

由於快取記憶體的 \(S=2^s=32\)\(E=1\)\(B=2^b=32\),且矩陣中的元素為 int 型別,快取的每行可以裝入 8 個整數,所以對於 \(32\times 32\) 的矩陣,分塊大小取為 8,程式碼如下所示:

for (int i = 0; i < N; i += 8)
    for (int j = 0; j < M; j += 8)
        for (int ii = i; ii < i + 8; ++ii)
            for (int jj=j; jj < j + 8; ++jj)
                B[jj][ii] = A[ii][jj];

測試效果如下圖所示,發現未命中次數為 343 次,而滿分要求未命中小於 300 次:

32×32 沒滿分

根據友情提示,我們應該避免對角線上元素原地轉置引發的衝突未命中問題,所以使用迴圈展開直接訪問行中的 8 個元素並賦值給 \(B\),將程式碼修改如下:

int a, b, c, d, e, f, g, h;
for (int i = 0; i < N; i += 8) {
    for (int j = 0; j < M; j += 8) {
        for (int ii = i; ii < i + 8; ++ii) {
            a = A[ii][j];
            b = A[ii][j + 1];
            c = A[ii][j + 2];
            d = A[ii][j + 3];
            e = A[ii][j + 4];
            f = A[ii][j + 5];
            g = A[ii][j + 6];
            h = A[ii][j + 7];

            B[j][ii] = a;
            B[j + 1][ii] = b;
            B[j + 2][ii] = c;
            B[j + 3][ii] = d;
            B[j + 4][ii] = e;
            B[j + 5][ii] = f;
            B[j + 6][ii] = g;
            B[j + 7][ii] = h;
        }
    }
}

再次測試,未命中次數為 287 次:

32×32 滿分

對於 \(64\times 64\) 大小的矩陣,如果同樣使用 \(8\times 8\) 的分塊,會發現命中次數和未分塊情況下一模一樣,為 4723 次左右。所以這裡把分塊換成 \(4\times 4\) 的,程式碼如下所示:

int a, b, c, d;
for (int i = 0; i < N; i += 4) {
    for (int j = 0; j < M; j += 4) {
        for (int ii = i; ii < i + 4; ++ii) {
            a = A[ii][j];
            b = A[ii][j + 1];
            c = A[ii][j + 2];
            d = A[ii][j + 3];

            B[j][ii] = a;
            B[j + 1][ii] = b;
            B[j + 2][ii] = c;
            B[j + 3][ii] = d;
        }
    }
}

測試結果如下圖所示,未命中次數為 1699 次,雖然沒有達到低於 1300 次的滿分要求(但是至少拿了一點分數):

64×64 矩陣

最後是 \(61\times 67\) 維度的矩陣,因為這個維度不能被 8 整除,所以先使用分塊處理一部分元素,對剩下的元素再單獨處理:

int a, b, c, d, e, f, g, h;
int n = 8 * (N / 8);
int m = 8 * (M / 8);
for (int i = 0; i < n; i += 8) {
    for (int j = 0; j < m; j += 8) {
        for (int ii = i; ii < i + 8; ++ii) {
            a = A[ii][j];
            b = A[ii][j + 1];
            c = A[ii][j + 2];
            d = A[ii][j + 3];
            e = A[ii][j + 4];
            f = A[ii][j + 5];
            g = A[ii][j + 6];
            h = A[ii][j + 7];

            B[j][ii] = a;
            B[j + 1][ii] = b;
            B[j + 2][ii] = c;
            B[j + 3][ii] = d;
            B[j + 4][ii] = e;
            B[j + 5][ii] = f;
            B[j + 6][ii] = g;
            B[j + 7][ii] = h;
        }
    }
}

// 處理剩餘部分
for (int i = 0; i < n; i++) {
    for (int j = m; j < M; j++) {
        B[j][i] = A[i][j];
    }
}

for (int i = n; i < N; i++) {
    for (int j = 0; j < M; j++) {
        B[j][i] = A[i][j];
    }
}

測試結果如下圖所示,未命中次數為 2093,接近滿分 2000:

61×67 矩陣

總結

通過這次實驗,可以加深對儲存器層次結構和快取記憶體工作原理的理解,為後續學習打下鋪墊(經典實驗報告總結)。以上~~

相關文章