快取友善程式碼與記憶體山 (Cache-Friendly Code & the Memory Mountain)

Overview Table

主題 核心結論 書頁
快取參數取捨 (block size / E / write strategy) 每個參數都是 hit time vs. miss rate vs. miss penalty 的權衡 p.669
Block / Line / Set 術語辨析 block 是資料封包、line 是容器(block+tag+valid)、set 是 line 的集合 p.670
快取友善程式碼兩原則 1. 讓 common case 快(專注核心函式的內層迴圈);2. 最小化內層迴圈的 cache miss p.670
stride-k miss 公式 每次迭代平均 min(1, (wordsize × k) / B) 次 miss;stride-1 最佳 p.671
列優先 vs. 行優先掃描 sumarrayrowssumarraycols25 倍(大陣列時後者全 miss) p.671–672
Read throughput(讀取吞吐量) 讀 n bytes 花 s 秒 → n/s MB/s;衡量記憶體系統效能 p.675
Memory Mountain(記憶體山) throughput 是 size(時間局部性)× stride(空間局部性) 的二維函數;高低差超過一個數量級 p.675–679
矩陣乘法六版本 三等價類 AB / AC / BC;miss rate 比總存取次數更能預測效能;最快比最慢快近 40 倍 p.679–682
Blocking(分塊) 提升時間局部性的技巧;Core i7 上因 prefetch 硬體而無效 p.683
撰寫程式的三建議 專注內層迴圈、stride-1 讀取(空間)、資料一旦讀入就盡量重複使用(時間) p.683

快取參數對效能的影響(6.4 收尾,p.669)

快取設計是多維權衡:總容量 C=S×E×B(組數 × 每組行數 × 區塊大小)固定時,調整任一參數都有代價。

參數 加大的好處 加大的代價
Block size (B) 利用空間局部性提高 hit rate line 數變少 → 傷害時間局部性;傳輸時間變長 → miss penalty 上升
Associativity (E) 減少 conflict miss / thrashing tag bits、LRU 狀態位、控制邏輯更多 → hit time 與選 victim 的 miss penalty 上升
Write-back(vs. write-through) 傳輸次數少 → 留更多頻寬給 DMA I/O;越往下層越划算 實作較複雜(write-through 較簡單、可搭配 write buffer,且 read miss 不需觸發記憶體寫入)
術語辨析(p.670 Aside)

  • Block:在快取與下層記憶體間搬動的固定大小資料封包。
  • Line:快取內存放一個 block 的容器,還含 valid bittag bits
  • Set:一個或多個 line 的集合。direct-mapped 時 set = line;associative 快取中兩者不可混用
  • 業界說的「line size」其實是 block size——line 永遠只裝一個 block,故常互換使用。


6.5 撰寫快取友善程式碼 (Writing Cache-Friendly Code, p.669–675)

局部性好的程式 miss rate 低,miss rate 低的程式跑得快。快取友善 (cache friendly) = 具有良好局部性的程式碼。基本方法只有兩條:

  1. 讓 common case 快:程式大部分時間花在少數核心函式的少數迴圈 → 只需專注核心函式的內層迴圈,忽略其他。
  2. 最小化每個內層迴圈的 cache miss 數:在 load/store 總數等其他條件相同下,miss rate 較低的迴圈較快。

stride-k 存取的 miss 公式

若 block 大小為 B bytes,stride-k(以 word 為單位)的參照模式,每次迴圈迭代平均產生

misses per iteration=min(1, wordsize×kB)

兩個關鍵教訓(以 sumvec 為例)

二維陣列:迴圈順序決定生死

C 以 row-major(列優先) 存放陣列。sumarrayrows 內層迴圈沿列掃描(stride-1);sumarraycols 交換迴圈後變成沿行掃描(stride-N)。

row-major 記憶體佈局:  a[0][0] a[0][1] a[0][2] a[0][3] | a[1][0] a[1][1] ...

sumarrayrows(i 外 j 內)存取順序 →→→→ 沿記憶體連續前進(stride-1)
  hit/miss:  m h h h m h h h        miss rate = 1/4

sumarraycols(j 外 i 內)存取順序 ↓↓↓↓ 每步跳 N 個元素(stride-N)
  hit/miss:  m m m m m m m m        陣列大於快取時 → 每次都 miss!
例外:若整個陣列塞得進快取,sumarraycols 也能享有同樣的 1/4 miss rate(p.672)。「行優先掃描必定全 miss」只在工作集 > 快取容量時成立;同理 Practice Problem 6.19 顯示快取加倍(裝得下整個陣列)後 hit rate 從 50% 升到 75%。
範圍內 Practice Problems 素材(6.17–6.20, p.672–674;解答 p.701–702)

  • 6.17(transpose):src/dst 兩陣列的同一列映到相同 cache line 時互相驅逐(thrashing);快取加倍(32 bytes)後兩陣列同時容納,只剩 cold miss。
  • 6.18(分兩趟各讀 x、y):依原書解答,存取呈 m,h,m,h… 模式 → 2,048 reads、1,024 misses,miss rate 50%
  • 6.19(column-wise 同時讀 x,y):快取只裝得下半個陣列,掃第二半驅逐第一半 → hit rate 50%;快取加倍 → 75%
  • 6.20(row-wise 同時讀 x,y):stride-1,只有 cold miss → hit rate 75%;快取再大也不變(cold miss 無法避免)。


6.6 快取對程式效能的整體影響 (p.675–683)

6.6.1 The Memory Mountain(記憶體山, p.675–679)

Read throughput(read bandwidth):程式讀取 n bytes 花 s 秒,則吞吐量為

read throughput=ns(MB/s, 1MB=106bytes,非 220)

量測程式 mountain.c(Figure 6.40):

Read throughput (MB/s)          Core i7 Haswell 2.1 GHz
      ^                          32KB L1d / 256KB L2 / 8MB L3 / 64B block
14000 |  ____
      | /L1  \____                <- ridges of temporal locality(垂直 size 軸)
      ||ridge \ L2 \____             工作集分別塞進 L1 / L2 / L3 / Mem
      ||       \ridge\  L3\____
      ||        \     \ridge\ Mem ridge
  900 ||_________\_____\_____\______
      +--------------------------------> size: 16K ........ 128M
     每條 ridge 沿 stride 方向下坡 = slopes of spatial locality(stride 越大越低)
     stride=1 有一條平坦高稜線(~12 GB/s)= 硬體 prefetch 的功勞

山的地理結構(Core i7 Haswell,size 16 KB–128 MB、stride 1–12 元素、元素為 8-byte long):

兩種切片分析:

切片 固定 觀察 重點
Figure 6.42 stride = 8 時間局部性 / 快取容量 ≤32 KB 由 L1 供應(~12 GB/s);≤256 KB 由 L2;≤8 MB 由 L3;更大主要由主記憶體供應。在 256 KB、8 MB(恰等於 L2/L3 容量)處有凹陷 (dips),推測是與其他 code/data line 的 conflict(確認需 cache simulation)
Figure 6.43 size = 4 MB(L3 稜:塞得進 L3、塞不進 L2) 空間局部性 stride 1→8 throughput 遞減:L2 read miss 由 L3 補一個 block、後續在 L2 命中,stride 越大 L2 miss/hit 比越高;stride ≥ 8 words(8×8 = 64 bytes = block size)後每次讀取都在 L2 miss,throughput 固定為 L3→L2 的 block 傳輸速率
總結:記憶體系統的效能不是單一數字,而是一座時間×空間局部性的山,海拔高低差超過一個數量級。聰明的程式設計師要讓程式跑在峰頂而非谷底:利用時間局部性讓熱門 word 從 L1 取得,利用空間局部性讓單一 L1 cache line 供應越多 word 越好。
Practice Problem 6.21(p.679, 解答 p.702):由山估算 L1 讀取延遲——L1 吞吐 ~12,000 MB/s、時脈 2,100 MHz、每次讀 16 bytes → 2100/12000×162.83 cycles,比名目 4-cycle L1 延遲快約 1.25 倍,因為 4×4 unrolling 讓多個 load 同時在飛行中 (in flight)

6.6.2 重排迴圈以提升空間局部性(矩陣乘法, p.679–683)

C=AB(n×n, double)的三重迴圈依 i, j, k 排列共 6 種版本,計算結果相同(假設加法可結合)、總運算量都是 O(n3)、加法乘法次數相同、A 和 B 的每個元素都被讀 n 次——但內層迴圈的存取次數與局部性天差地遠

「結果相同」的例外:浮點加法可交換但不可結合(見 02-Information-Representation/04-Floating-Point)。若矩陣不混用極大與極小值(如儲存物理量時常成立),結合性假設才合理(p.680 註 1)。

分析假設:sizeof(double) = 8、單一快取 B = 32(一個 block 裝 4 個 double)、n 大到一列塞不進 L1、區域變數放暫存器(不產生 load/store)。

6 版本依內層迴圈實際觸碰的兩個矩陣分成三等價類(Figure 6.45):

版本(類別) 內層迴圈存取模式 Loads Stores A misses B misses C misses 總 misses/iter
ijk & jik(AB) A 沿列 stride-1;B 沿行 stride-n 2 0 0.25 1.00 0.00 1.25
jki & kji(AC) A、C 都沿行 stride-n 2 1 1.00 0.00 1.00 2.00
kij & ikj(BC) B、C 都沿列 stride-1 2 1 0.00 0.25 0.25 0.50
class AB (ijk):  sum += A[i][k]*B[k][j]      class BC (kij):  C[i][j] += r*B[k][j]
   A: ─k→ 沿列 stride-1 (0.25 miss)             r = A[i][k] 放暫存器 (0 miss)
   B: ↓k  沿行 stride-n (每次 miss)              B: ─j→ stride-1 (0.25)
                                                 C: ─j→ stride-1 (0.25)  ← 最佳
class AC (jki):  C[i][j] += A[i][k]*r
   A: ↓i 沿行 stride-n (1.0)    C: ↓i 沿行 stride-n (1.0)   ← 最糟

Core i7 實測(Figure 6.46, cycles per inner-loop iteration vs. n)四個要點:

Web Aside MEM:BLOCKING(p.683):blocking(分塊) 把資料結構組織成應用層的大塊(此處 block ≠ cache block),一次載入一塊到 L1、對它做完所有讀寫再丟棄、換下一塊,以提升時間局部性。缺點是程式碼變得難讀難懂,較適合最佳化編譯器或高頻函式庫。在 Core i7 上因精密的 prefetch 硬體,blocking 對矩陣乘法沒有幫助;但在不做 prefetch 的系統上可帶來大幅增益。

6.6.3 在程式中利用局部性(p.683)

記憶體階層使程式存取記憶體的有效速率不是單一數字,而是隨程式局部性劇烈變動的函數(即記憶體山),差距可達數個數量級。不論底層記憶體系統的具體組織為何,建議三招:

  1. 專注內層迴圈——大部分計算與記憶體存取都發生在那裡。
  2. 最大化空間局部性——按資料在記憶體中的存放順序、以 stride-1 循序讀取。
  3. 最大化時間局部性——資料物件一旦從記憶體讀入,就盡量多次使用

6.7 本章總結 (Summary, p.684)


Exam/Test Patterns

情境 / 關鍵字 答案
stride-k 每次迭代平均 miss 數? min(1, (wordsize × k)/B);k=1 最佳
word 4B、block 16B、cold cache、stride-1 掃陣列的 miss rate? 1/4(模式 m h h h m h h h),cold cache 下已是最佳
為何 sumarrayrows 遠快於 sumarraycols? C 是 row-major;行優先掃描為 stride-N,陣列大於快取時每次存取都 miss(實測差 25 倍)
陣列塞得進快取時,行優先掃描的 miss rate? 與列優先相同(1/4)——「全 miss」只在工作集 > 快取容量時成立
read throughput 定義與單位? n bytes / s 秒 = n/s;MB/s 以 10^6 bytes 計(非 2^20)
memory mountain 的兩個軸? size(工作集大小 → 時間局部性)與 stride(→ 空間局部性)
垂直 size 軸的稜線代表? ridges of temporal locality:工作集分別完全塞進 L1 / L2 / L3 / Mem 的四個區域
沿 stride 軸的下坡代表? slopes of spatial locality:stride 越大 miss 比例越高、throughput 越低
stride-1 上的平坦高稜線成因? 硬體 prefetching 自動辨識循序 stride-1 模式並預先抓取 block;對小 stride 最有效
stride 到達幾個 word 後 throughput 落底(64B block、8B word)? 8 words = block size;之後每次讀取都在 L2 miss,速率由 L3→L2 block 傳輸率決定
固定 stride 切片在 size = 256 KB / 8 MB 處的凹陷? 工作集恰等於 L2/L3 容量,疑似與其他 code/data line 的 conflict(需 cache simulation 確認)
矩陣乘法哪一類最快?為何? class BC(kij / ikj):B、C 都 stride-1,僅 0.5 misses/iter;最慢 AC 類為 2.0
「總記憶體存取次數」vs.「miss rate」哪個更能預測效能? miss rate(BC 類有 3 個 memory ops 仍勝只有 2 個的 AB 類)
大 n 時 kij/ikj 效能不隨 n 惡化的原因? prefetch 硬體認得 stride-1 且跟得上內層迴圈,即使工作集遠超所有 SRAM 快取
blocking 在 Core i7 上對矩陣乘法無效的原因? 精密的 prefetching 硬體已涵蓋其效益;在無 prefetch 的系統上才有大增益
由山估 L1 讀取 cycle 數(6.21 型) cycles ≈ (時脈 MHz ÷ 吞吐 MB/s) × 每次讀取 bytes;例:2100/12000 × 16 ≈ 2.8 ≈ 3 cycles
兩陣列同列映到相同 cache line、交錯讀寫(6.17 型) 互相驅逐 → thrashing;快取加倍能同時容納兩陣列 → 只剩 cold miss
stride-1 掃描下快取加倍 hit rate 不變(6.20 型) 原本就只有 cold miss;cold miss 無法靠加大容量消除