CPU 和内存之间的速度差距超过 300 倍——如果每次数据访问都要等内存,CPU 99% 的时间都在空转。缓存(Cache)是解决这一问题的核心机制:把频繁访问的数据放在离 CPU 更近、更快但更小的存储中。
缓存层次是现代 CPU 性能的基石。理解缓存,就理解了为什么同样的算法、不同的数据布局可以产生 10 倍的性能差异。
一、缓存为什么有效?
1.1 局部性原理
缓存的有效性基于两个局部性原理:
- 时间局部性(Temporal Locality):最近访问的数据很可能很快再次被访问。循环变量、栈帧、热点数据。
- 空间局部性(Spatial Locality):最近访问的数据附近的数据很可能很快被访问。数组遍历、结构体字段。
局部性不是 CPU 的”猜测”,而是程序的统计特性。Drepper 在他的经典论文中统计了典型工作负载的访问模式,发现 90%+ 的内存访问都表现出显著的局部性。缓存利用了这一统计规律。
1.2 缓存层次的演进
二、缓存行:缓存的基本单位
2.1 什么是缓存行?
缓存不是按字节管理的,而是按**缓存行(Cache Line)**管理的。缓存行是缓存和主存之间数据传输的最小单位,通常为 64 字节。
内存地址空间:┌──────────┬──────────┬──────────┬──────────┐│ 行 0 │ 行 1 │ 行 2 │ 行 3 ││ 0-63 │ 64-127 │ 128-191 │ 192-255 ││ 字节 │ 字节 │ 字节 │ 字节 │└──────────┴──────────┴──────────┴──────────┘每次加载/传输一整行(64 字节)2.2 缓存行的结构
每个缓存行包含:
| 字段 | 大小 | 含义 |
|---|---|---|
| 有效位(Valid) | 1 bit | 该行是否包含有效数据 |
| 脏位(Dirty) | 1 bit | 数据是否被修改(仅 Write-Back 模式) |
| 标签(Tag) | 地址高位 | 标识该行对应内存的哪个位置 |
| 数据(Data) | 64 字节 | 实际缓存的数据 |
2.3 缓存行对软件的影响
struct Data { int a; // 偏移 0 int b; // 偏移 4 int c; // 偏移 8 int d; // 偏移 12 // ... 共 16 字节};
struct Data arr[4];// arr[0]: 地址 0-15 → 缓存行 0 (0-63)// arr[1]: 地址 16-31 → 缓存行 0 (0-63)// arr[2]: 地址 32-47 → 缓存行 0 (0-63)// arr[3]: 地址 48-63 → 缓存行 0 (0-63)// 4 个结构体共享 1 个缓存行!访问任意字段都会加载整行三、缓存的映射方式
3.1 直接映射(Direct-Mapped)
每个内存地址只能映射到缓存中的一个固定位置。
地址分解:[Tag][Index][Offset] ↓映射到缓存中的第 Index 行
示例:缓存 64 行,缓存行 64 字节地址 0x0000 → Index 0地址 0x1000 → Index 0 ← 冲突!地址 0x0040 → Index 1优点:简单、快速。缺点:冲突严重——地址 0x0000 和 0x1000 映射到同一行,交替访问会导致缓存抖动(Thrashing)。
3.2 全相联(Fully Associative)
任意内存地址可以放在缓存的任意位置。
优点:无冲突。缺点:查找需要比较所有行的 Tag,功耗和延迟高。只适用于小容量缓存(如 TLB)。
3.3 组相联(Set-Associative)
折中方案:缓存分为多个”组”,每个内存地址映射到固定组,但组内可以放在任意路(Way)。
4 路组相联缓存:┌─────────────────────────────────────┐│ 组 0: │ Way 0 │ Way 1 │ Way 2 │ Way 3 ││ 组 1: │ Way 0 │ Way 1 │ Way 2 │ Way 3 ││ 组 2: │ Way 0 │ Way 1 │ Way 2 │ Way 3 ││ ... │ │ │ │ │└─────────────────────────────────────┘地址映射到固定组,组内 4 路任选3.4 现代CPU的缓存配置
| CPU | L1 D-Cache | L2 Cache | L3 Cache |
|---|---|---|---|
| Intel Skylake | 32 KB, 8-way | 256 KB, 4-way | 共享, 12-way |
| AMD Zen 4 | 32 KB, 8-way | 1 MB, 8-way | 共享, 16-way |
| Apple M1 | 64 KB, 8-way | 128-192 KB | 共享, ~12-way |
3.5 相联度对性能的影响
// 缓存抖动示例#define STRIDE 4096 // = 64 行 × 64 字节/行int *arr = malloc(STRIDE * 8 * sizeof(int)); // 8 个元素间隔 4096 字节
// 如果 L1 是 8-way 组相联,这 8 个元素映射到同一组// 交替访问会不断驱逐for (int i = 0; i < 8; i++) { arr[i * STRIDE / 4]++; // 每次访问驱逐前一个}四、替换策略
当缓存组满时,需要选择一行驱逐。常见策略:
| 策略 | 原理 | 优势 | 劣势 |
|---|---|---|---|
| LRU(Least Recently Used) | 驱逐最久未访问的行 | 局部性好 | 实现复杂 |
| PLRU(Pseudo-LRU) | 近似 LRU | 实现简单 | 精度稍低 |
| Random | 随机驱逐 | 最简单 | 无局部性保证 |
现代 CPU 普遍使用 PLRU,在精度和实现复杂度之间取得平衡。
4.1 LRU vs PLRU 的详细对比
LRU 需要为每个缓存行维护一个精确的访问时间戳或链表。对于 N 路组相联缓存,精确 LRU 需要 N! 个状态——8 路就是 40320 个状态,硬件代价极高。
PLRU(也叫 tree-PLRU)用一棵二叉树来近似 LRU:每个内部节点只有 1 bit,指向”最近访问较少”的那一侧。
8 路 PLRU 树(3 bit 即可管理 8 路):
bit0 / \ bit1 bit2 / \ / \ W0 W1 W2 W3 ← 4 个 Way /\ /\ /\ /\ W4 W5 W6 W7 ← 另外 4 个 Way(简化展示)
bit0=0 → 左半边优先驱逐bit0=1 → 右半边优先驱逐每次访问某一路时,沿途 bit 指向另一侧| 对比维度 | LRU | PLRU | Random |
|---|---|---|---|
| 状态位数 | N×log₂N | N-1 | 0 |
| 8 路所需位数 | ~24 bit | 7 bit | 0 |
| 命中率(典型工作负载) | 基准 | -1~3% | -5~15% |
| 硬件复杂度 | 高 | 低 | 最低 |
| 扫描抵抗 | 差 | 差 | 好 |
LRU 和 PLRU 都有一个共同的弱点:扫描抵抗性差。如果程序顺序扫描一个比缓存大的数组,新数据会不断驱逐可能还有用的旧数据。某些 CPU 在 L3 缓存使用 LRU 的变体(如 LRU-Insertion-Policy),新插入的行放在”最近访问”位置而非”最久未访问”位置,给旧数据更多生存机会。
4.2 缓存污染(Cache Pollution)
缓存污染是指不需要的数据占用了缓存空间,把有用的数据驱逐出去。常见的污染来源:
实战案例:大数组拷贝污染缓存
// 普通拷贝:污染 L1/L2 缓存void copy_normal(float *dst, float *src, int n) { for (int i = 0; i < n; i++) { dst[i] = src[i]; // 数据进入 L1/L2,驱逐热点 }}
// 非临时存储:绕过缓存void copy_nontemporal(float *dst, float *src, int n) { int i = 0; for (; i + 16 <= n; i += 16) { __m512 data = _mm512_load_ps(src + i); // 加载(不可避免) _mm512_stream_ps(dst + i, data); // 非临时存储!绕过缓存 } for (; i < n; i++) dst[i] = src[i]; _mm_sfence(); // 确保所有流式存储完成}// 非临时存储直接写内存,不加载到缓存// 热点数据留在缓存中,不受拷贝影响| 拷贝方式 | L1 缓存影响 | 热点数据 | 适用场景 |
|---|---|---|---|
| 普通拷贝 | 严重污染 | 被驱逐 | 小数据拷贝 |
| 非临时存储 | 不污染 | 保留 | 大块数据搬移 |
| memcpy | 取决于实现 | 部分保留 | 通用(glibc 大块用 NT) |
非临时存储只对写方向有效——读方向仍然会加载到缓存。如果数据只用一次,考虑使用 PREFETCHNTA 提示 CPU 不要将预取数据放入 L1。
4.3 预取策略与缓存层次的配合
硬件预取器通常在 L2 缓存层面工作,预取数据到 L2 而非 L1。这样做的好处:
- L1 容量小(32KB),预取数据容易驱逐热点
- L2 容量适中(256KB-1MB),预取数据影响较小
- 从 L2 到 L1 只需 ~4 周期,远小于 DRAM 到 L1 的 ~200 周期
| 预取目标 | 延迟(从预取完成到核心访问) | 缓存污染 | 适用场景 |
|---|---|---|---|
| L1 | ~1 周期 | 高 | 即将使用的数据 |
| L2 | ~4 周期 | 中 | 短期会用的数据 |
| L3 | ~12 周期 | 低 | 中期会用的数据 |
软件预取可以通过 __builtin_prefetch 的 locality 参数控制预取目标:
// locality=3: 预取到 L1(高局部性)__builtin_prefetch(&arr[i + 8], 0, 3);
// locality=1: 预取到 L2(中局部性)__builtin_prefetch(&arr[i + 16], 0, 1);
// locality=0: 预取到 L2/L3(低局部性,可被快速驱逐)__builtin_prefetch(&arr[i + 32], 0, 0);注意:locality 参数只是提示,CPU 不一定严格遵守。不同微架构的实现可能不同。
五、写策略
5.1 Write-Through vs Write-Back
| 策略 | 写操作 | 优势 | 劣势 |
|---|---|---|---|
| Write-Through | 同时写缓存和下级存储 | 一致性简单 | 写带宽大 |
| Write-Back | 只写缓存,驱逐时写下级 | 写带宽小 | 一致性复杂 |
现代 CPU 的 L1/L2/L3 缓存普遍使用 Write-Back 策略,通过缓存一致性协议保证多核间的数据一致。
5.2 Write Allocate vs No-Write Allocate
| 策略 | 写未命中时 | 适用场景 |
|---|---|---|
| Write Allocate | 先加载到缓存,再写入 | Write-Back 缓存 |
| No-Write Allocate | 直接写下级存储 | Write-Through 缓存 |
六、缓存友好的代码
6.1 原则一:利用空间局部性
// 不好:跳跃访问for (int i = 0; i < N; i += 16) { sum += arr[i]; // 每次跳 64 字节,缓存行利用率 1/16}
// 好:顺序访问for (int i = 0; i < N; i++) { sum += arr[i]; // 连续访问,缓存行利用率 100%}6.2 原则二:减少工作集大小
// 不好:大结构体数组struct BigStruct { int key; // 4 字节 char data[1020]; // 1020 字节}; // 总大小 1024 字节
// 遍历 key 时,每个缓存行只有 1 个 key// 缓存行利用率 = 4/64 = 6.25%
// 好:分离热/冷数据struct HotData { int key; // 4 字节}; // 16 个 key/缓存行
struct ColdData { char data[1020];};
// 先遍历 HotData 数组(缓存行利用率 100%)// 只对需要的 key 访问 ColdData6.3 原则三:避免缓存抖动
// 不好:2 的幂步长#define SIZE 1024 // 2 的幂int matrix[SIZE][SIZE];for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { sum += matrix[j][i]; // 列优先访问 }}// 列优先访问 + 2 的幂行宽 = 同一组反复驱逐
// 好:填充避免 2 的幂#define SIZE 1024#define PADDED_SIZE 1025 // 不是 2 的幂int matrix[PADDED_SIZE][SIZE];// 或者改为行优先访问for (int i = 0; i < SIZE; i++) { for (int j = 0; j < SIZE; j++) { sum += matrix[i][j]; // 行优先访问 }}6.4 原则四:分块(Blocking/Tiling)
矩阵乘法的经典优化:
// 朴素版本:O(N³) 访问,缓存未命中率高void matmul_naive(double *C, double *A, double *B, int N) { for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) for (int k = 0; k < N; k++) C[i*N+j] += A[i*N+k] * B[k*N+j]; // B 的访问模式是列优先 → 缓存不友好}
// 分块版本:利用缓存局部性#define BLOCK 64 // 适配 L1 缓存大小void matmul_blocked(double *C, double *A, double *B, int N) { for (int ii = 0; ii < N; ii += BLOCK) for (int jj = 0; jj < N; jj += BLOCK) for (int kk = 0; kk < N; kk += BLOCK) for (int i = ii; i < ii + BLOCK && i < N; i++) for (int j = jj; j < jj + BLOCK && j < N; j++) for (int k = kk; k < kk + BLOCK && k < N; k++) C[i*N+j] += A[i*N+k] * B[k*N+j]; // 每个工作块适配 L1 缓存,命中率大幅提升}七、缓存性能的量化
7.1 缓存未命中的分类:3C 模型
| 类型 | 含义 | 减少方法 |
|---|---|---|
| Compulsory(强制未命中) | 首次访问数据 | 预取(Ch12) |
| Capacity(容量未命中) | 工作集超过缓存容量 | 减小工作集、分块 |
| Conflict(冲突未命中) | 多个地址映射到同一组 | 增加相联度、填充 |
7.2 用 perf 测量缓存性能
# L1 数据缓存perf stat -e L1-dcache-loads,L1-dcache-load-misses ./your_program
# L3 缓存(最后一级缓存)perf stat -e LLC-loads,LLC-load-misses ./your_program
# 典型输出:# 1,234,567,890 L1-dcache-loads# 12,345,678 L1-dcache-load-misses # 1.00% 未命中率# 1,234,567 LLC-loads# 123,456 LLC-load-misses # 10.0% 未命中率7.3 缓存未命中的代价计算
假设:- L1 命中:3 周期- L2 命中:12 周期- L3 命中:36 周期- DRAM:200 周期- L1 未命中率:5%- L2 未命中率(在 L1 未命中中):20%- L3 未命中率(在 L2 未命中中):30%
平均访问时间(AMAT)= 3 + 0.05 × (12 + 0.20 × (36 + 0.30 × 200))= 3 + 0.05 × (12 + 0.20 × 96)= 3 + 0.05 × 31.2= 3 + 1.56= 4.56 周期八、缓存与预取
8.1 硬件预取器
现代 CPU 内置了硬件预取器,能自动检测访问模式并提前加载数据:
| 预取器类型 | 检测模式 | 适用场景 |
|---|---|---|
| Stream 预取器 | 顺序访问 | 数组遍历 |
| Stride 预取器 | 固定步长 | 结构体字段访问 |
| Spatial 预取器 | 相邻缓存行 | 顺序访问的相邻行 |
预取器对顺序访问非常有效,但对随机访问几乎无能为力。详见第 12 章:预取。
8.2 软件预取
// GCC 内建预取指令for (int i = 0; i < N; i++) { __builtin_prefetch(&arr[i + 16], 0, 1); // 预取 16 个元素后的数据 sum += arr[i];}九、动手实验
9.1 实验 1:测量缓存行大小
#include <stdio.h>#include <time.h>
#define SIZE (64 * 1024 * 1024)
int main() { int *arr = calloc(SIZE, sizeof(int));
printf("步长 每元素延迟(ns)\n"); for (int stride = 1; stride <= 128; stride *= 2) { clock_t start = clock(); long long sum = 0; for (int i = 0; i < SIZE; i += stride) { sum += arr[i]; } clock_t end = clock(); double ns = (double)(end - start) / CLOCKS_PER_SEC * 1e9 / (SIZE / stride); printf("%4d %.1f\n", stride, ns); } free(arr); return 0;}// 当步长从 1→16 时延迟急剧增加(16 个 int = 64 字节 = 1 个缓存行)// 步长 > 16 后延迟趋于平稳9.2 实验 2:测量缓存容量
#include <stdio.h>#include <time.h>#include <stdlib.h>
int main() { printf("工作集(KB) 每次访问延迟(ns)\n"); for (int size_kb = 4; size_kb <= 16384; size_kb *= 2) { int size = size_kb * 1024 / sizeof(int); int *arr = malloc(size * sizeof(int)); for (int i = 0; i < size; i++) arr[i] = (i + 16) % size;
clock_t start = clock(); int idx = 0; for (int i = 0; i < 10000000; i++) idx = arr[idx]; clock_t end = clock();
double ns = (double)(end - start) / CLOCKS_PER_SEC * 1e9 / 10000000; printf("%8d %.1f\n", size_kb, ns); free(arr); } return 0;}// 延迟在 32KB(L1)、256KB(L2)、几MB(L3) 处阶梯式增加9.3 实验 3:矩阵遍历方向
#include <stdio.h>#include <time.h>#include <stdlib.h>
#define N 2048
int main() { int (*matrix)[N] = malloc(N * N * sizeof(int)); for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) matrix[i][j] = i + j;
// 行优先遍历 clock_t start = clock(); long long sum = 0; for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) sum += matrix[i][j]; clock_t end = clock(); printf("行优先: %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
// 列优先遍历 start = clock(); sum = 0; for (int j = 0; j < N; j++) for (int i = 0; i < N; i++) sum += matrix[i][j]; end = clock(); printf("列优先: %.3f 秒\n", (double)(end - start) / CLOCKS_PER_SEC);
free(matrix); return 0;}// 行优先通常快 3-10 倍(利用了缓存行预取)十、小结
上一章深入探讨了乱序执行与推测执行。
| 概念 | 要点 | 对软件的影响 |
|---|---|---|
| 缓存行(64B) | 缓存传输的最小单位 | 数据布局影响缓存利用率 |
| 组相联 | 平衡冲突和复杂度 | 2 的幂步长可能导致冲突 |
| Write-Back | 延迟写入下级存储 | 多核一致性需要 MESI 协议 |
| 3C 模型 | 强制/容量/冲突未命中 | 不同原因需要不同优化 |
| 分块/tiling | 将工作集适配缓存 | 矩阵运算的核心优化 |
| 热冷分离 | 减小有效工作集 | 结构体布局优化 |
缓存友好的代码不是”优化”,而是”设计”。在数据结构设计阶段就考虑缓存行为,比事后优化有效得多。详见第 14 章:数据导向设计。
下一步:缓存一致性——多核 CPU 如何保证缓存数据一致?伪共享如何毁掉多线程性能?MESI 协议的精妙设计。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






