mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2339 字
6 分钟
缓存层次:L1/L2/L3
2026-02-18

CPU 和内存之间的速度差距超过 300 倍——如果每次数据访问都要等内存,CPU 99% 的时间都在空转。缓存(Cache)是解决这一问题的核心机制:把频繁访问的数据放在离 CPU 更近、更快但更小的存储中。

缓存层次是现代 CPU 性能的基石。理解缓存,就理解了为什么同样的算法、不同的数据布局可以产生 10 倍的性能差异。

一、缓存为什么有效?#

1.1 局部性原理#

缓存的有效性基于两个局部性原理:

  • 时间局部性(Temporal Locality):最近访问的数据很可能很快再次被访问。循环变量、栈帧、热点数据。
  • 空间局部性(Spatial Locality):最近访问的数据附近的数据很可能很快被访问。数组遍历、结构体字段。
Note

局部性不是 CPU 的”猜测”,而是程序的统计特性。Drepper 在他的经典论文中统计了典型工作负载的访问模式,发现 90%+ 的内存访问都表现出显著的局部性。缓存利用了这一统计规律。

1.2 缓存层次的演进#

graph TB subgraph 早期["1990s: 单级缓存"] CPU1["CPU"] --> L1_1["L1 Cache<br/>8-16 KB"] L1_1 --> MEM1["主存"] end subgraph 中期["2000s: 两级缓存"] CPU2["CPU"] --> L1_2["L1<br/>32-64 KB"] L1_2 --> L2_2["L2<br/>256 KB-1 MB"] L2_2 --> MEM2["主存"] end subgraph 现代["2010s+: 三级缓存"] CPU3["CPU"] --> L1_3["L1<br/>32-48 KB"] L1_3 --> L2_3["L2<br/>256 KB-2 MB"] L2_3 --> L3_3["L3<br/>4-64 MB<br/>多核共享"] L3_3 --> MEM3["主存"] end style 早期 fill:#e8eaf6,stroke:#283593 style 中期 fill:#e8f5e9,stroke:#2e7d32 style 现代 fill:#fff3e0,stroke:#e65100

二、缓存行:缓存的基本单位#

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的缓存配置#

CPUL1 D-CacheL2 CacheL3 Cache
Intel Skylake32 KB, 8-way256 KB, 4-way共享, 12-way
AMD Zen 432 KB, 8-way1 MB, 8-way共享, 16-way
Apple M164 KB, 8-way128-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 指向另一侧
对比维度LRUPLRURandom
状态位数N×log₂NN-10
8 路所需位数~24 bit7 bit0
命中率(典型工作负载)基准-1~3%-5~15%
硬件复杂度最低
扫描抵抗
Note

LRU 和 PLRU 都有一个共同的弱点:扫描抵抗性差。如果程序顺序扫描一个比缓存大的数组,新数据会不断驱逐可能还有用的旧数据。某些 CPU 在 L3 缓存使用 LRU 的变体(如 LRU-Insertion-Policy),新插入的行放在”最近访问”位置而非”最久未访问”位置,给旧数据更多生存机会。

4.2 缓存污染(Cache Pollution)#

缓存污染是指不需要的数据占用了缓存空间,把有用的数据驱逐出去。常见的污染来源:

flowchart TD POLLUTION["缓存污染来源"] --> HW["硬件预取<br/>预取了不会用的数据"] POLLUTION --> SCAN["大数组扫描<br/>驱逐了热点数据"] POLLUTION --> DMA["DMA / I/O<br/>设备数据占用缓存"] POLLUTION --> OS["OS 调度<br/>上下文切换冲刷缓存"] HW --> FIX1["PREFETCHNTA<br/>非临时预取"] SCAN --> FIX2["分块/tiling<br/>限制工作集"] DMA --> FIX3["非临时存储<br/>_mm_stream_si128"] OS --> FIX4["CPU 亲和性<br/>减少迁移"] style POLLUTION fill:#ffcdd2,stroke:#c62828 style FIX1 fill:#e8f5e9,stroke:#2e7d32 style FIX2 fill:#e8f5e9,stroke:#2e7d32 style FIX3 fill:#e8f5e9,stroke:#2e7d32 style FIX4 fill:#e8f5e9,stroke:#2e7d32

实战案例:大数组拷贝污染缓存

// 普通拷贝:污染 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)
Warning

非临时存储只对方向有效——读方向仍然会加载到缓存。如果数据只用一次,考虑使用 PREFETCHNTA 提示 CPU 不要将预取数据放入 L1。

4.3 预取策略与缓存层次的配合#

硬件预取器通常在 L2 缓存层面工作,预取数据到 L2 而非 L1。这样做的好处:

  1. L1 容量小(32KB),预取数据容易驱逐热点
  2. L2 容量适中(256KB-1MB),预取数据影响较小
  3. 从 L2 到 L1 只需 ~4 周期,远小于 DRAM 到 L1 的 ~200 周期
flowchart LR CORE["CPU 核心"] -->|"请求"| L1["L1<br/>32KB"] L1 -->|"未命中"| L2["L2<br/>1MB"] L2 -->|"未命中"| L3["L3<br/>共享"] L3 -->|"未命中"| DRAM["DRAM"] PF["硬件预取器<br/>Stream/Stride"] -->|"预取到 L2"| L2 PF -.->|"也可预取到 L3"| L3 style PF fill:#fff9c4,stroke:#f9a825
预取目标延迟(从预取完成到核心访问)缓存污染适用场景
L1~1 周期即将使用的数据
L2~4 周期短期会用的数据
L3~12 周期中期会用的数据

软件预取可以通过 __builtin_prefetchlocality 参数控制预取目标:

// 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 访问 ColdData

6.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 倍(利用了缓存行预取)
graph TB CPU["CPU Core"] --> L1I["L1 I-Cache<br/>64KB / 4周期"] CPU --> L1D["L1 D-Cache<br/>64KB / 4周期"] L1I --> L2["L2 Cache<br/>512KB / 12周期"] L1D --> L2 L2 --> L3["L3 Cache<br/>8MB / 40周期"] L3 --> DRAM["主存 DRAM<br/>200+ 周期"] style L1I fill:#ffcdd2,stroke:#c62828 style L1D fill:#ffcdd2,stroke:#c62828 style L2 fill:#fff9c4,stroke:#f9a825 style L3 fill:#c8e6c9,stroke:#2e7d32

十、小结#

上一章深入探讨了乱序执行与推测执行。

概念要点对软件的影响
缓存行(64B)缓存传输的最小单位数据布局影响缓存利用率
组相联平衡冲突和复杂度2 的幂步长可能导致冲突
Write-Back延迟写入下级存储多核一致性需要 MESI 协议
3C 模型强制/容量/冲突未命中不同原因需要不同优化
分块/tiling将工作集适配缓存矩阵运算的核心优化
热冷分离减小有效工作集结构体布局优化
Warning

缓存友好的代码不是”优化”,而是”设计”。在数据结构设计阶段就考虑缓存行为,比事后优化有效得多。详见第 14 章:数据导向设计


下一步缓存一致性——多核 CPU 如何保证缓存数据一致?伪共享如何毁掉多线程性能?MESI 协议的精妙设计。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

缓存层次:L1/L2/L3
https://blog.souloss.com/posts/cpu-architecture/cache-hierarchy/
作者
Souloss
发布于
2026-02-18
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时