高级 #hardware#memory#cache

缓存(Cache Memory)

缓存(Cache)是CPU内部的高速存储器,填补了CPU和内存之间的速度鸿沟——利用局部性原理,把频繁使用的数据放在CPU"触手可及"的地方

CPU 在等内存

现代 CPU 每秒钟可以执行几十亿条指令,但内存(RAM)的速度跟不上——CPU 执行一条指令需要约 0.25 纳秒,而从内存读数据需要约 50-100 纳秒。这意味着 CPU 大部分时间在等内存

缓存(Cache Memory) 就是解决这个问题的方法——把最常用的数据放在离 CPU 更近、更快的存储器里。

类比:书桌 vs 书架 vs 图书馆

  • 图书馆(内存):什么书都有,但每次借书要走 10 分钟
  • 书架(L2 缓存):常用的书放书架上,1 分钟就能拿到
  • 书桌(L1 缓存):正在读的书放桌上,伸手就拿
  • 口袋(寄存器):手里正在看的这一页——零延迟

局部性原理(Principle of Locality)

缓存能工作的理论基础是局部性——程序访问内存时,不是随机访问的,而是有规律可循的。

时间局部性(Temporal Locality)

如果程序访问了某个地址,不久后很可能再次访问它

for (int i = 0; i < 1000000; i++) {
    sum += x;  // x 被反复访问——时间局部性好
}

空间局部性(Spatial Locality)

如果程序访问了某个地址,附近的地址可能很快也会被访问

for (int i = 0; i < N; i++) {
    sum += arr[i];  // arr[0], arr[1], arr[2]... 连续访问——空间局部性好
}

反例:局部性差的代码

// ❌ 局部性极差:按列遍历行优先存储的二维数组
for (int j = 0; j < COLS; j++) {
    for (int i = 0; i < ROWS; i++) {
        matrix[i][j] = 0;  // 跳着访问,空间局部性差
    }
}

// ✅ 局部性好:按行遍历
for (int i = 0; i < ROWS; i++) {
    for (int j = 0; j < COLS; j++) {
        matrix[i][j] = 0;  // 连续访问
    }
}

💡 按行遍历比按列遍历快 10-50 倍——这不是算法复杂度的问题,纯粹是缓存局部性的差异。

缓存层次结构

现代 CPU 有三级缓存:

CPU 核心

  ├── L1 缓存(32 KB ~ 64 KB)
  │   ├── L1 指令缓存(L1I):存指令
  │   └── L1 数据缓存(L1D):存数据
  │   延迟:约 1 纳秒(4 个 CPU 周期)

  ├── L2 缓存(256 KB ~ 512 KB)
  │   延迟:约 3-5 纳秒(~12 个周期)

  ├── L3 缓存(8 MB ~ 32 MB,多核共享)
  │   延迟:约 10-15 纳秒(~40 个周期)

  └── 主存(RAM,8 GB ~ 64 GB)
      延迟:约 50-100 纳秒(~200 个周期)
大小与速度的关系(金字塔结构):

        速度越快                容量越小
        ╱─────────╲
       ╱  寄存器   ╲     ≈ 1 周期,    ~1 KB
      ╰─────────────╯
     ╱    L1 缓存    ╲   ≈ 4 周期,    ~32 KB
    ╰──────────────────╯
   ╱      L2 缓存      ╲  ≈ 12 周期,  ~256 KB
  ╰──────────────────────╯
 ╱       L3 缓存         ╲ ≈ 40 周期,  ~16 MB
╰──────────────────────────╯
╱         主存(RAM)       ╲ ≈ 200 周期, ~16 GB
╰────────────────────────────╯
        容量越大                速度越慢

🔑 关键数字:L1 比主存快 50 倍。如果一个程序的数据都在 L1 缓存里,理论上可以比数据全在主存里的程序快 50 倍。

缓存的基本工作原理

缓存行(Cache Line)

缓存和内存之间的数据传输单位不是字节,而是缓存行(Cache Line)——通常 64 字节。

// 当你从内存读 arr[0] 时,CPU 实际上把 arr[0]~arr[15](假设 int 4 字节)
// 共 64 字节整条缓存行都加载到缓存了
int sum = arr[0];  // 缓存未命中 → 加载 64 字节
// 接下来访问 arr[1]~arr[15] 都是缓存命中!
sum += arr[1];     // ✅ 缓存命中
sum += arr[2];     // ✅ 缓存命中

这也是为什么空间局部性有效——一次缓存缺失加载了 64 字节,后面的 15 个访问可能都是命中的。

缓存命中(Cache Hit) vs 缓存缺失(Cache Miss)

缓存命中 — 数据在缓存中:
CPU → 请求地址 → 查缓存 → 找到了!→ 立即返回(快)

缓存缺失 — 数据不在缓存中:
CPU → 请求地址 → 查缓存 → 没找到 → 查内存(慢)

                                  加载整条缓存行

                                  写入缓存(可能淘汰旧数据)

                                  CPU 继续执行

缓存映射方式

直接映射(Direct-Mapped)

每个内存地址只能缓存在缓存中的一个固定位置:

类比:图书馆的还书车——每个书号固定放某一格,放满了就把旧的丢掉

内存地址 → 取模运算 → 唯一对应的缓存行
直接映射计算:
缓存行号 = (内存地址 / 64) % 缓存行总数

例:64 KB 缓存,64 字节/行 → 1024 行
地址 0x1000 → 缓存行 (0x1000/64) % 1024 = 第 64 行
地址 0x2000 → 缓存行 (0x2000/64) % 1024 = 第 128 行

优点:简单、快速(硬件实现简单) 缺点:容易冲突(两个常用地址映射到同一位置,互相”踢”)

组相联(Set-Associative)

每个内存地址可以映射到一组缓存行中的任意一个:

类比:停车场——你不需要停到指定车位,可以停在该区域的任意车位

内存地址 → 取模 → 对应的一组(N 个缓存行)→ 选一个空的
方式每组行数灵活度硬件复杂度
直接映射1 行最低最简单
2 路组相联2 行中等中等
4 路组相联4 行较复杂
8 路组相联8 行很高复杂
全相联(TLB 用)所有行最高最复杂

现代 CPU 的 L1 缓存通常是 8 路组相联,L2/L3 是 16-24 路。

全相联(Fully-Associative)

任何内存地址可以缓存在任何位置——最灵活,但查找最慢(需要比较所有行),所以只在行数很少的场景使用(如 TLB)。

替换策略

当缓存满了,要加载新数据——该淘汰谁?

策略做法特点
LRU(最近最少使用)淘汰最久没用过的效果好,但硬件开销大
FIFO(先进先出)淘汰最先来的实现简单,效果一般
随机随便淘汰一个最简单,意外地效果不错
伪 LRU近似 LRU,用少量位跟踪性价比最高的折中

💡 现代 CPU 的 L1/L2 缓存多用伪 LRU——效果接近 LRU,但硬件实现成本低得多。

写策略

当 CPU 修改了缓存中的数据,什么时候把修改写回内存?

写直达(Write-Through)

写到缓存的同时也写到内存

CPU → 写缓存(快)→ 写内存(慢)
                   ↖ 每次写入都要等内存

优点:缓存和内存总是一致的 缺点:每次写操作都很慢

写回(Write-Back)

只写到缓存,标记为脏,等缓存行被替换时才写回内存:

CPU → 写缓存(快),标记"脏"
      ...
      (需要腾出缓存行时)→ 写回内存

优点:写操作快(多次写同一地址只需一次内存写) 缺点:实现复杂(需要”脏”标志位,多核间有一致性问题)

现代 CPU 的 L1/L2 缓存几乎都使用**写回(Write-Back)**策略。

缓存一致性(多核场景)

多核 CPU 中,每个核心有自己的 L1/L2 缓存——同一个内存地址可能被多个核心缓存。如果核心 A 修改了共享数据,核心 B 的缓存中还是旧值——不一致

MESI 协议——每个缓存行标记为 4 种状态之一:

状态含义本核其他核
M(Modified,已修改)已修改,与内存不一致有最新值没有此数据
E(Exclusive,独占)与内存一致有,且仅此核有没有此数据
S(Shared,共享)与内存一致可能有
I(Invalid,无效)数据过时没有有效值
核心 A 写地址 X:             核心 B 读地址 X:
  缓存行 → M(已修改)           发现核心 A 有 M 版本

                              核心 A 写回内存

                              核心 B 从内存加载

                              两核都变成 S(共享)

💡 MESI 的硬件实现通过总线嗅探(Bus Snooping)——每个缓存”监听”总线上的内存访问请求,判断自己的缓存行是否需要失效。

缓存对性能的影响:实测

// 对比不同访问模式的性能
#define N 10000000
int arr[N];

// 方案 1:顺序访问(空间局部性好)
for (int i = 0; i < N; i++) arr[i] = i;
// 时间:~30 ms  ✅

// 方案 2:跳跃访问(空间局部性差)
for (int i = 0; i < N; i++) arr[(i * 64) % N] = i;
// 时间:~300 ms  ❌(慢了 10 倍)

// 方案 3:随机访问(局部性极差)
for (int i = 0; i < N; i++) arr[rand() % N] = i;
// 时间:~3000 ms  ❌❌(慢了 100 倍)

缓存友好的编程指南

// 1. 连续访问(线性遍历)
for (int i = 0; i < N; i++) sum += a[i];  // ✅

// 2. 避免步长过大
for (int i = 0; i < N; i += 64) sum += a[i];  // ❌ 每 64 个才访问一次

// 3. 数据紧凑(struct 打包)
struct Bad { int id; char padding[60]; };      // ❌ 浪费缓存行
struct Good { int id; int data; };              // ✅ 一个缓存行塞更多

// 4. 分块处理(loop tiling)
// 对大矩阵,一次处理一小块让小数据在缓存中反复利用
for (int ii = 0; ii < N; ii += BLOCK) {
    for (int jj = 0; jj < N; jj += BLOCK) {
        for (int i = ii; i < ii + BLOCK; i++) {
            for (int j = jj; j < jj + BLOCK; j++) {
                c[i][j] = a[i][j] + b[i][j];
            }
        }
    }
}

小结

缓存是计算机体系结构中最重要的性能优化机制之一:

概念要点
局部性原理时间和空间局部性是缓存能工作的理论基础
缓存层次L1(最快最小)→ L2 → L3(最慢最大)→ 主存
缓存行传输单位 64 字节,一次缺失带回来一堆
映射方式直接映射(简单易冲突)→ 组相联(折中)→ 全相联(灵活但慢)
写策略写直达(简单但慢)vs 写回(复杂但快)
MESI 协议多核场景下的缓存一致性保证

为什么这很重要? 缓存是”看不见的内存层级”——程序员不需要显式管理它,但它的行为深刻影响程序性能。理解了缓存,你就能写出比不懂缓存的人快 10 倍的代码。

接下来,你将看到缓存如何融入更大的 CPU 架构:数据到底是怎么在 CPU 内部流动的?——CPU 数据通路