前兩篇探討演算法與編譯器優化,本文聚焦硬體層面的關鍵因素——CPU 快取與記憶體局部性。同樣的邏輯,不同的迴圈結構可能導致數倍效能差距,根源在於能否善用 L1/L2 快取。
// 寫法 A:融合迴圈
for (int i = 0; i < 100; i++) {
DoSomethingA();
DoSomethingB();
}
// 寫法 B:分離迴圈
for (int i = 0; i < 100; i++) DoSomethingA();
for (int i = 0; i < 100; i++) DoSomethingB();
直覺認為寫法 A 減少迴圈開銷(計數器遞增、條件跳轉)應更快。但實測往往寫法 B 顯著更快。原因在於快取局部性。
關鍵機制:CPU 以 Cache Line(通常 64 bytes) 為單位從較慢層級載入資料。讀取某位址後,其鄰近 64 bytes 同步載入 L1。
| 類型 | 定義 | 優化方向 |
|---|---|---|
| 空間局部性 | 訪問某位址後,短時間內訪問鄰近位址 | 連續記憶體佈局、陣列優於鏈表、結構體陣列 (AoS vs SoA) |
| 時間局部性 | 短時間內重複訪問同一位址 | 熱資料重用、迴圈融合/分離權衡、避免大物件在迴圈間流失 |
寫法 A 交替執行 A/B,導致 A 的熱資料在 B 執行期間從 L1 退出(快取污染),下輪迴圈需重新載入。寫法 B 密集執行 100 次 A,資料長駐 L1,再密集執行 B,**最大化時間局部性**。
// AoS:同一座標 x/y 相鄰,適合逐點處理
struct Point { public int X, Y; }
Point[] points = new Point[N];
// SoA:x 陣列、y 陣列分離,適合向量化運算
int[] xs = new int[N];
int[] ys = new int[N];
若迴圈只處理 X,SoA 避免載入無用 Y,節省快取頻寬;若需同時處理 X/Y,AoS 更佳。
大矩陣運算時,將迴圈拆分為適合 L1/L2 大小的塊,確保工作集駐留快取。
多執行緒寫入同一 Cache Line 不同變數會導致核心間快取同步風暴。對齊 64 bytes 或使用 ThreadLocal<T>。
靜態代碼分析無法準確預測快取行為。必須使用:
HardwareCounters 屬性結論:演算法與編譯器優化是基礎,但記憶體存取模式決定效能上限。理解快取機制,設計符合局部性原理的資料結構與迴圈,是高效能程式設計的核心技能。