---
# System prepended metadata

title: 第 6-2 講 Memory Hierachy 2
tags: [計算機結構]

---

# Associativity Structures

如同 Hash map 一樣，我們希望某個 cache index 對到的空間可以放兩個或多個 block 的資料，這種技巧叫做 Associativity Structures。

<iframe src="https://drive.google.com/file/d/1BLpOmtVQMWgFU1-_CUItnjOWahuMANVj/preview"  height="580"></iframe>

- 1-Way Assoc
    - 就是之前的 Direct Mapped，一個 Cache 地址只能放一個 block
- 2-Way Assoc
    - 如同其名，一個 Cache 地址可以放兩個 block
    - 所以可以自行延伸出 N-Way Assoc，一個 Cache 地址可以放 N 個 block
- Fully Assoc
    - 整個 cache 就只有一個 set，這樣就不用算 Cache index 了
    - 因此全部的資料都放在同一個 set 裡面
    - 另一個別名叫做 Full Mapped

:::warning
要注意一件事情，通常進行不同 Assoc 進行比較的時候，**是要固定 Cache 大小的**。
因此如果從 1-Way 變成 2-Way，要注意 set 數會只剩一半。
:::

## 比較

這裡來比較 1-Way Assoc、2-Way Assoc 跟 Fully Assoc 的差異。

假設現在 Cache **只有 4 個 Block 的容量**，現在要從記憶體中讀取資料，並且用以下的「Block Address」序列來依序讀取資料：

- 0 8 0 6 8

> 別忘記我們是將記憶體中的「Block Address」進行模除運算後才找到對應的 Cache index

- 1-Way Assoc：要模除 4，因為一個 set 只能放 1 個 block
    - 模除後的 Index：0 0 0 2 0
    - 全部的操作都是 Miss，包含讀取新資料也是 Miss
    - 例如第 3 個 0，他是要讀取 Block Address 0 的資料，可是在那之前就被 Block Address 8 給覆蓋掉了
- 2-Way Assoc：要模除 2，因為一個 set 可以放 2 個 block 所以 index 砍半
    - 模除後的 Index：0 0 0 0 0
    - 只有一個 1 Hit：第三個 0 讀取的時候沒有 miss
    - 因為這裡採用了 2-Way，所以就不會被前一個 Block Address 8 的資料給覆蓋掉
    - 可是後來因為讀取了 Block Address 6 的資料，**所以會把最先讀進來的 Block Address 8 的資料給覆蓋掉**
    - 因此導致最後一次的讀取是 Miss
- Fully Assoc，要模除 1，因為一個 set 可以放 4 個 block，Index 剩 1 個
    - 模除後的 Index：0 0 0 0 0 (其實也不用模除就是了...)
    - 因為一個 set 可以放 4 個 Block 的資料，除了第一次放入是 miss，後續讀取都是 hit
    - 所以總共有兩個 Hit

:::warning
要注意 2-Way 中的**取代原則**，這裡是採用 **LRU / Least Recently Used**，因為 Block Address 0 的有被使用一次，而 Block Address 8 則沒有，因此他會比較優先被替換掉。

LRU 的話會有特別的硬體負責相關的紀錄；還有其他的原則，像是 **FIFO** 或是 **Random**，這部分也是一門學問。
:::

## 電路結構

下面以 4-Way 的電路作介紹：

<iframe src="https://drive.google.com/file/d/1CAiTdoza84F72HXHj_RpUO6s0uDeeV5u/preview"  height="580"></iframe>

- 這是個 256 Sets 的 Cache，所以 Cache index 是 8 個 bit
- 每個 Block 是 4 Bytes 寬，所以 Block Offset 是 2 個 bit

可以看到跟上周一樣的電路結構，不過現在一個 Set 裡面有 4 個 Block 的資料。

順序上是先將該 Set 的資料全部讀出來，然後用 Cache tag 進行檢驗的結果作為 MUX 的 Selection bit，來決定要輸出誰。

# Tag Size

情境：

- Cache size 為 4K 個 Blocks 的大小，一個 block 有 4 個 word(16 bytes)，32 bit 的 Address：
- 所以 Bytes offset 是 4 bit。
- 可以知道 tag + index 必定有 32 - 4 = 28 個 bits

接著我們來探討不同 Assoc 對 Tag size 的影響。

- 1-Way Assoc
    - Cache size 有 4K 個 Blocks，所以總共有 4K 個 Sets，所以 index 需要 12 個 bits
    - 因此 tag 有 16 個 bits
    - 所以 tag 總共佔了 16 × 4K = 64K bits
- 2-Way Assoc
    - Cache size 有 4K 個 Blocks，所以總共有 2K 個 Sets，所以 index 需要 11 個 bits
    - 因此 tag 有 17 個 bits
    - 所以 tag 總共佔了 17 × 2 × 2K = 68K bits
- Fully Assoc
    - Cache size 有 4K 個 Blocks，總共只有 1 個 Set，所以 index 要 0 個 bit
    - 因此 tag 有 28 個 bits
    - 所以 tag 總共佔了 28 × 4K × 1 = 112K bits

# Cache Control

講了這麼多跟 Cache 有關的內容，現在要講如何處理 Cache 的控制電路。

下面是三個層級之間的概念架構圖：

<iframe src="https://drive.google.com/file/d/1uBhwZIKy2EVRB8UA869FUrfChy_A7BIn/preview"  height="580"></iframe>

>老師說 low end CPU 通常會共用 write 跟 read 的 bus

這裡的情境是：

- Direct-mapped, write-back, write allocate
    - write-back 就是只寫到 Cache，並且要設置 Dirty bit
    - write allocate 就是發生 write miss 時會把資料拿進 Cache 裡面
    - 可以複習上一周的內容
- Block size：4 words (16 bytes)
- Cache size：16 KB (1024 blocks)
- 32-bit address
- Valid bit and dirty bit per block
- Blocking cache
    - CPU 會等到存取完成才做下一步


設計 Cache Control 的方法就是先畫出 FSM，也就是狀態圖：

<iframe src="https://drive.google.com/file/d/11Iw6LiXUc7EEO05QHMKKt9EIG-bbw4DS/preview"  height="580"></iframe>

- Idle → Compare Tag
    - 當 CPU 發送一個 Valid request 時
- Idle ← Compare Tag
    - 有兩種 request 情形
        - 如果是 read ，並且資料是 Valid 並且是 Hit
            - 設置 Valid Bit 跟 Cache Tag
        - 如果是 write，沒有發生 write miss
            - 設置 Dirty bit
    - 滿足條件的話就標示 Cache ready 以及 Cache Hit
- Compare Tag → Write-Back
    - 如果 Request 是 read，但是發生了 read miss，並且資料是 dirty 的
    - 需要先將資料寫回記憶體
    - 並且要等到資料寫好，所以會有個 Memory not ready 的指向自己箭頭
    - Memory Ready 的話就可以前往 Allocate 狀態了
- Compare Tag → Allocate
    - 如果 Request 是 read，但是發生了 read miss，不過資料是 clean 的
    - 那麼就可以直接去拿資料了
    - 並且也會有個 Memory not ready 的指向自己箭頭
- 別忘記由於這裡是 Write-Allocate，所以 write miss 跟 read miss 一樣，遇到 dirty 要先寫回

上面狀態圖中的 Cache Ready 跟 Memory Ready 就是概念架構圖中的 Ready。

有了 FSM 後就可以轉成電路圖了，例如下圖：

<iframe src="https://drive.google.com/file/d/1MQITf5pKNbrhRTJ5V806Nnyq5BemvS3o/preview"  height="580"></iframe>

通常這個轉換的工作都有軟體可以幫忙；而最後電路可以透過 PLA 或是 ROMs 的方式做出來。

總之概念就是只要畫出 FSM 後就可以弄出電路來。


# L2 Cache

現在的電腦就我們所知，都會有 2 到 3 層的 Cache，尤其近年來 L3 快取挺普遍的。

會需要 L2 快取的原因是根據下面的公式：


$$
\begin{align}
\text{Total CPI} &= \frac{\text{CPU execution clock cycles + Memory stall clock cycles}}{\text{Total Instructions}}\\
&= \text{CPI} + \frac{\text{Memory stall clock cycles}}{\text{Total Instructions}}\\
&= \text{CPI} + \frac{\text{# Memory accesses × Miss rate × Miss penalty}}{\text{Total Instructions}}\\
&= \text{CPI} + \text{Miss rate × Miss penalty}\\
\end{align}
$$

:::info
這裡是混合式的 Cache，所以每個 Instructions 都是一筆 Memory Access。
:::

可知如果只有 L1 快取，一旦 Miss 就會動到 Memory 的 Penalty，那可是很貴的。
聰明的架構師就想到，既然一層不夠，那就兩層。


## 情境模擬

- Base CPI = 1.0
- L1 Cache miss rate = 2%
- Clock Rate = 5GHz = 0.2 ns per cycle
- Memory Access time = 100 ns，包含 miss handling
- L1 Miss Penalty **to main memory** = 100 / 0.2 = 500 cycle

如果今天只有 L1 快取，那麼 Total CPI 就會是 1 + 2% × 500 = 11.0。

:::warning
CPU cycle 又稱 **hit time**
:::

但是當我們引進 L2 快取：

- 5 ns access time
- L1 Miss Penalty **to L2 Cache**= 5 / 0.2 = 25 cycle
- ==global miss rate== = 0.5%

那麼 Total CPI 就會是 1 + 2% × 25 + ==0.5%== × 500 = 4.0。

:::warning
上面的公式其實可以改寫成：
1 + 2% × 25 + 0.5% × 500 = 1 + 2%(25 + 25% × 500)
- 25% 叫做 **L1 Local miss rate in L2**
- 乘開來後的 0.5% 叫做 **CPU global miss rate in L2**
- 所以 **CPU global miss rate in L1** 就是一開始給的 2%
:::

## 設計目的

如果要避免 Miss，那為何不將 L1 快取弄得很大？原因是因為 L1 快取負責跟 CPU 接洽，所以他的 **Hit Time** 也是很重要的。

如果為了降低 Miss 而將 L1 弄得很大，結果反而讓 ==Hit time== 變很長，那麼也不是我們想要的，所以才有了 L2 快取的產生。

而 L2 快取的設計目的跟 L1 相比之下， ==Hit time== 就沒有那麼重要，會著重處理 Miss，所以可以：

- 有更大的 Cache 大小
- 更大的 associativity
- 更大的 Block Size

這裡跟上周提到的 [Combined 跟 Split Cache](https://hackmd.io/xnZnH9ZXT1yJfCaXqwflHw?view#Split-cache-vs-Combined-cache) 一樣：

- L1 追求的快還要更快：==低 Hit Time==。
- L2 則是追求低 Miss Rate：==高 Hit Time==。

# Cache Friendly

老師說 Programmer 會分為以下等級：

- 會寫程式：基礎
- 進行時間優化的：進階
- 會寫 Cache Friendly 的：專家

所謂的 Cache Friendly，就是寫程式的時候要**想到 Locality，以及善用 Locality**。

例如下面的 Radix Sort 跟 Quick Sort，雖然 Radix Sort 在數量大的時候有著較低的 Instruction 量(左圖)，但是實際跑出來的結果卻有較多的 Clock Cycle(右圖)。

<iframe src="https://drive.google.com/file/d/1tiELh0axcLuvrNi8Os7ilx4cODeLAtR-/preview"  height="380"></iframe>

而原因就是在於 Radix Sort 的 Cache Misses 比 Quick Sort 大：

<iframe src="https://drive.google.com/file/d/1gy5x52uPq_NSJZIH1rssKkV-5t55zz0X/preview"  height="480"></iframe>

也就是說在該實驗中的 Radix Sort 比較不具有 Cache Friendly 的特性。

# Software Optimization via Blocking / DGEMM

在計算矩陣乘法時可以使用所謂的 Blocking 技巧，善用 Locality 做出 Cache Friendly 的 Code。

下面是在計算兩個方陣 A 跟 B 的矩陣乘法

```c=
// 假設 C 矩陣都先初始化為 0
for (int i = 0; i < n; ++i){
    for (int j = 0; j < n; ++j){
        double cij = C[i+j*n]; /* cij = C[i][j] */
        for( int k = 0; k < n; k++ )
            cij += A[i+k*n] * B[k+j*n]; /* cij += A[i][k]*B[k][j] */
        C[i+j*n] = cij; /* C[i][j] = cij */
    }
}
```

在這樣的算法中，Cache 存取的次數是 $2N^3+N^2$，也就是內層的 `cij += A[i+k*n] * B[k+j*n]` 部分跟 `cij = C[i+j*n]` 的部分。

在最糟的情形，每次的存取都是 Miss，所以 Miss 次數就是 $2N^3+N^2$。

- 首先 `cij = C[i+j*n]` 的部分確定是拿取寫入只會發生一次，所以 Miss 次數確定有 $N^2$ 次
- 至於 `cij += A[i+k*n] * B[k+j*n]` 的部分
    - 假設 `A[i+k*n]` 大小很大，雖然第一次讀取時拿進 Cache 裡面，但是它佔滿了 Cache
    - 並且 Cache 又剛好只放的下他
    - 因此換到下一個 `A[i+k*n]` 就會被替換掉。
    
但其實最糟的情形不一定會發生，因為通常記憶體中一個 Block 可能可以放入多個 `A[i+k*n]`，而且不同的 `A[i+k*n]` 也不一定會在同個 Block，藉由 Block Address 算出的 Cache index 可能也會不同。

總之為了避免發生被替換掉，聰明的設計師們想到說，那就確保計算某個區域內的資料量大小剛好是 Cache 的大小，這樣就不會發生替換這件事了。


```c=
void do_block(int n, int si, int sj, int sk,double *A, double *B, double *C){
for (int i = si; i < si + BLOCKSIZE; i ++) {
    for (int j = sj; j < sj + BLOCKSIZE; j++) {
        double cij = C[i+j*n]; /* cij = C[i][j] */
        for (int k = sk; k < sk + BLOCKSIZE; k++) {
            cij += A[i+k*n] * B[k+j*n]; /* cij += A[i][k]*B[k][j] */
        }
        //C[i+j*n] = cij; /* C[i][j] = cij */
        C[i+j*n] += cij; // 應該是 += 不是 =
    }
}
}
void dgemm(size_t n, double *A, double *B, double *C){
for (int sj = 0; sj < n; sj += BLOCKSIZE) {
    for (int si = 0; si < n; si += BLOCKSIZE) {
        for (int sk = 0; sk < n; sk += BLOCKSIZE) {
            do_block(n, si, sj, sk, A, B, C);
        }
    }
}
}
```

這份程式碼可以看到的特點是，我們一次只取 A 跟 B 當中的 `BLOCKSIZE × BLOCKSIZE` 的區域進行計算，也就是說可以確保要經過每一個 `BLOCKSIZE` 才會發生 Miss。

因此 Miss 次數就可以從 $2N^3+N^2$ 降到 $\frac{2N^3}{BLOCKSIZE}+N^2$ 次。

> 對 A 跟 B 兩個方陣來說，原本是 $N^3$ 次存取都會發生 Miss，現在則是要經過一個 Block 大小的次數後才會發生 Miss，因此次數從 $N^3$ 變成 $\frac{N^3}{BLOCKSIZE}$，而因為有 A 跟 B 兩個方陣，所以次數要乘以 2

---

## 回顧

- Associativity
- L2 Cache & formula