Cache:CPU 身边的小抄
Cache:CPU 身边的小抄
期末考试前,你不会把整个图书馆搬回宿舍,但你会把最常用的那几页公式抄在一张小纸条上——考试时看小纸条比跑图书馆快多了。CPU 对内存做的事情一模一样。
内存访问需要约 50 纳秒(150 个 CPU 周期)。CPU 的运算速度是内存速度的 100 多倍。如果每次计算都去内存取数据,CPU 将有 99% 的时间在等待。
缓存就是 CPU 和内存之间的"小抄":把最近或最常用的数据放在 CPU 旁边的小块快速存储里,让 CPU 大多数时候不需要跑去内存。
Level 1:建立直觉
缓存的三个层级
现代 CPU 通常有三级缓存:
CPU 核心
│
├── L1 缓存(最快,最小)
│ ├── L1-I(指令缓存):32-64 KB,速度 4 个时钟周期
│ └── L1-D(数据缓存):32-64 KB,速度 4-5 个时钟周期
│
├── L2 缓存(中等速度)
│ └── 256 KB - 1 MB,速度 12 个时钟周期
│
├── L3 缓存(最慢的缓存,最大)
│ └── 8-64 MB(多核共享),速度 40 个时钟周期
│
└── 主内存(RAM)
└── 8-64 GB,速度 ~150 个时钟周期
以 Apple M4 Pro 为例:
- L1 缓存:192 KB(指令)+ 128 KB(数据),每个性能核
- L2 缓存:16 MB,每簇(多核共享)
- 主内存:24-64 GB 统一内存,273 GB/s
形象一点:
- L1 = 你桌上翻开着的书(触手可及)
- L2 = 书架上的书(几步路的距离)
- L3 = 附近房间的书柜
- 主内存 = 楼里的图书馆
- 磁盘/SSD = 城市里的图书馆(另一章讲)
缓存命中和缓存未命中
当 CPU 需要读取地址 X 的数据时:
- 先查 L1 缓存:如果有,L1 命中(L1 Hit),~4 周期
- 没有,查 L2:如果有,L2 命中,~12 周期
- 没有,查 L3:如果有,L3 命中,~40 周期
- 都没有:缓存未命中(Cache Miss),去主内存取,~150 周期
命中率对性能影响巨大。假设操作本身需要 1 个周期:
| 命中率 | 有效访问时间(平均) |
|---|---|
| 100% | 1 + 4 = 5 周期 |
| 95% | 0.95×5 + 0.05×150 = 12.25 周期 |
| 90% | 0.90×5 + 0.10×150 = 19.5 周期 |
| 80% | 0.80×5 + 0.20×150 = 34 周期 |
命中率从 100% 降到 80%,有效访问时间增加了近 7 倍!
这就是为什么"缓存友好"的代码比"缓存不友好"的代码快 10 倍甚至更多。
为什么缓存有效:局部性原理
缓存之所以有效,是因为程序访问内存有两种规律性(局部性):
时间局部性(Temporal Locality):
刚访问过的数据,很快还会被访问。
举例:循环里的变量 i 每次循环都要用到,sum 也是。把它们放到缓存里,反复使用效率极高。
空间局部性(Spatial Locality):
访问了某个地址,附近的地址也很可能很快被访问。
举例:访问数组 arr[0] 之后,arr[1], arr[2] 通常也会被访问。
缓存利用了这两种局部性:不是只缓存你要的那个字节,而是缓存一整行(Cache Line)——通常 64 字节(x86/ARM 都是)。
访问 arr[0](地址 0x1000)时:
→ 缓存把 0x1000 到 0x103F(64字节)都加载到 L1
→ 下次访问 arr[1](0x1004)到 arr[15](0x103C)都是 L1 命中
Level 2:原理剖析
缓存的组织结构:直接映射 vs 组相联
缓存如何决定"把数据放在缓存的哪个位置"?
直接映射(Direct-Mapped Cache): 内存地址的一部分(中间几位)直接决定放入缓存的哪一行。
内存地址: ┌──────┬──────┬──────┐
│ Tag │ Index│Offset│
└──────┴──────┴──────┘
高位 中间 低位
- Offset: 确定数据在 Cache Line 内的位置(64字节→6位)
- Index: 确定数据放入哪一"组"(缓存行数)
- Tag: 用于校验这行缓存是否是你要的数据
直接映射的问题:内存里相差"缓存大小"倍数的两个地址会映射到同一缓存行,互相驱逐(缓存颠簸):
// 直接映射缓存的颠簸现象(假设缓存大小4KB)
for (int i = 0; i < N; i++) {
sum += a[i] + b[i]; // a 和 b 相差 4KB 时会互相驱逐
}
N 路组相联(N-Way Set-Associative):
每个"组(Set)"有 N 个可用的位置,同一内存地址可以放到组内任意一个位置:
- 2路组相联:每组 2 个位置,映射到同组的数据可以共存
- 4路组相联:每组 4 个位置
- 8路组相联:每组 8 个位置(L1 常见)
- 16路组相联:L2/L3 常见
N 越大,冲突越少,但查找需要比较更多 Tag,电路更复杂。现代 L1 通常是 4-8 路组相联。
替换策略(当缓存满了,新数据来了,驱逐哪个?):
- LRU(最近最少使用):驱逐最长时间没被访问的那行。最常用,最接近最优,但实现需要记录时间戳
- PLRU(伪 LRU):近似 LRU 的低开销实现
- 随机(Random):随机驱逐,实现极简,实际效果不差
写策略:修改缓存后怎么办
CPU 修改了缓存里的数据后,什么时候同步到内存?
写直通(Write-Through): 修改缓存的同时,立即写入内存。
- 优点:内存和缓存始终一致
- 缺点:每次写操作都有内存访问,写密集型代码很慢
写回(Write-Back): 只修改缓存,标记该行为"脏(Dirty)";当这行被驱逐出缓存时,才写回内存。
- 优点:减少内存访问次数,写性能好
- 缺点:缓存和内存可能不一致,需要额外管理
现代 CPU 的 L1/L2 通常用写回策略,L3 可能用写直通。
写缓冲区(Write Buffer): 写回策略下,驱逐脏行时需要写内存,这可能阻塞 CPU。写缓冲区是一个小队列,暂时接收待写的脏行,让 CPU 继续执行,由写缓冲区异步完成内存写入。
缓存一致性:多核的挑战
在多核 CPU 里,每个核心有自己的 L1 和 L2 缓存。如果核心 1 和核心 2 都缓存了地址 X 的值,核心 1 修改了 X,核心 2 的缓存就过期了——这叫缓存不一致。
MESI 协议是最常用的缓存一致性协议,每个缓存行有四种状态:
M(Modified): 我有最新值,内存已过期,其他核心没有
E(Exclusive):我有最新值,内存一致,其他核心没有
S(Shared): 我和其他核心都有,与内存一致
I(Invalid): 我的副本已过期,不能使用
状态转换:
- 核心 0 读取 X → X 进入 E 状态
- 核心 1 也读取 X → 两个核心都变为 S 状态
- 核心 0 写入 X → 发送"使无效"消息给核心 1,核心 0 的 X 变为 M,核心 1 的 X 变为 I
- 核心 1 再次读取 X → 核心 0 的 M 状态行先写回内存(或直接传给核心 1),然后两者都变为 S
这个协议通过核心间的总线通信维护一致性,但通信本身有延迟,这就是多核共享数据的性能代价。
伪共享(False Sharing):MESI 的杀手
有时候,两个核心操作的是完全不相关的变量,但这两个变量恰好在同一个 Cache Line 里:
// 问题:两个线程各自修改自己的变量,但变量在同一缓存行
struct Counter {
long count1; // 8字节
long count2; // 8字节,和 count1 同在一个64字节缓存行
};
Counter c;
// 线程1:c.count1++ (核心1的缓存行变为 M 状态)
// 线程2:c.count2++ (核心2需要这行,但它被标为 I,需要从核心1取最新值)
// 虽然 count1 和 count2 互不干扰,但因为在同一缓存行,导致大量核间通信!
修复方法:让两个变量填充到不同的缓存行:
struct Counter {
long count1;
char pad1[56]; // 填充到 64 字节
long count2;
char pad2[56]; // 填充到 64 字节
};
或使用 C++17 的 alignas(64):
struct Counter {
alignas(64) long count1; // 对齐到 64 字节边界,独占一个缓存行
alignas(64) long count2;
};
在高并发的 CPU 密集型代码里,伪共享可以导致 10 倍以上的性能下降。这是多线程编程中最隐蔽的性能陷阱之一。
Level 3 · 规范怎么定义的(资深)
缓存一致性协议的形式化定义
多核处理器的缓存一致性由 MESI 协议 及其变体形式化定义。MESI 定义了每条缓存行的四种状态:Modified(已修改,仅本核心持有最新值)、Exclusive(独占,与内存一致但仅本核心持有)、Shared(共享,多个核心持有相同副本)、Invalid(无效)。状态转换由总线事务触发,协议保证在任意时刻,如果某条缓存行处于 Modified 状态,其他所有核心对该行的副本必须是 Invalid。
Intel 实际使用的是 MESIF 协议(增加了 Forward 状态,指定一个核心负责响应共享请求,减少总线流量),AMD 使用 MOESI 协议(增加了 Owned 状态,允许一个核心持有"脏"数据的同时与其他核心共享,无需先写回内存)。这些协议的正确性可以用状态机形式化验证——每对(本地状态,远程事务)都有确定的转换结果。
缓存行大小(通常 64 字节)是 CPU 架构的硬件参数,但 POSIX 并未直接暴露此值。C++17 引入了 std::hardware_destructive_interference_size(对应缓存行大小)和 std::hardware_constructive_interference_size,让程序员可以在编译期获取这一参数,用于避免伪共享和优化数据布局。Linux 内核通过 /sys/devices/system/cpu/cpu0/cache/ 目录暴露完整的缓存层级信息。
Level 4 · 边界与陷阱(所有人)
陷阱 1:伪共享(False Sharing)导致多线程性能暴跌
两个线程分别写入不同的变量,但如果这两个变量恰好位于同一条 64 字节的缓存行中,MESI 协议会让两个核心的缓存行不断在 Modified 和 Invalid 之间切换("缓存行乒乓"),每次切换需要约 50-100 个周期。表现为:两个逻辑上完全独立的操作,由于物理上相邻,性能下降 10 倍以上。经典的修复方式是在变量之间插入填充(padding)使它们位于不同的缓存行。Java 8 引入了 @Contended 注解,C++ 程序员则用 alignas(64) 来对齐。Linux 内核中的 ____cacheline_aligned 宏遍布各处,正是为了防止这个问题。
陷阱 2:缓存组冲突(Set Conflict)让数组遍历变慢
典型的 L1 Cache 是 8 路组相联、32KB 大小、64 字节缓存行,共 64 个组(set)。如果你以恰好 32KB 的步长访问内存(即 array[0]、array[8192]、array[16384]...),所有访问会映射到同一个组,而该组只有 8 路——第 9 次访问必然驱逐之前的缓存行。这就是为什么某些特定大小的矩阵(如 512x512 的 float 矩阵,每行恰好 2048 字节 = 2KB 的倍数)在列遍历时性能异常差。解决方案是对矩阵进行"填充"(每行多分配几个元素),打破步长对齐。
陷阱 3:Cacheline 跨越(Split Load)导致双倍延迟
当一个 8 字节的数据跨越两条缓存行边界时(例如一个 uint64_t 存储在地址 0x3C,跨越 0x00-0x3F 和 0x40-0x7F 两条缓存行),CPU 需要加载两条缓存行并拼接——延迟大约翻倍。x86 CPU 虽然不会报错(它支持非对齐访问),但性能惩罚是真实的。在 ARM 上,未对齐的访问可能直接触发异常。编译器通常会确保基本类型自然对齐,但在手动管理内存(如自定义分配器、序列化/反序列化)时,这个陷阱很容易踩中。