第 11 章

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 为例:

形象一点:

缓存命中和缓存未命中

当 CPU 需要读取地址 X 的数据时:

  1. 先查 L1 缓存:如果有,L1 命中(L1 Hit),~4 周期
  2. 没有,查 L2:如果有,L2 命中,~12 周期
  3. 没有,查 L3:如果有,L3 命中,~40 周期
  4. 都没有:缓存未命中(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 个可用的位置,同一内存地址可以放到组内任意一个位置:

N 越大,冲突越少,但查找需要比较更多 Tag,电路更复杂。现代 L1 通常是 4-8 路组相联。

替换策略(当缓存满了,新数据来了,驱逐哪个?)

写策略:修改缓存后怎么办

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):  我的副本已过期,不能使用

状态转换:

这个协议通过核心间的总线通信维护一致性,但通信本身有延迟,这就是多核共享数据的性能代价

伪共享(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 上,未对齐的访问可能直接触发异常。编译器通常会确保基本类型自然对齐,但在手动管理内存(如自定义分配器、序列化/反序列化)时,这个陷阱很容易踩中。

本章评分
4.8  / 5  (30 评分)

💬 留言讨论