内存层次 — 快的小,大的慢
一句话总结
内存无法同时做到又快、又大、又便宜,因此系统把它从小而快到大而慢逐层排列,并将常用数据提升到上层。
为什么需要它
寄存器能以和 CPU 相同的速度工作,却只有几十个。DRAM 可以容纳数十 GB 数据,但一次访问要耗费数百个周期。这道鸿沟不仅没有缩小,反而随着每一代硬件继续扩大。增加运算单元只需放入更多晶体管,而在芯片内外搬运数据的能力,却受到引脚数量和布线等物理限制。
这种不断累积的差距称为内存墙(memory wall)。层次结构是绕过这堵墙唯一现实的方法。
它如何工作
典型的层次结构及其大致延迟如下。具体数字会随硬件世代变化,但重点在于它们之间的数量级差异。
| 层级 | 容量 | 访问延迟(大致) |
|---|---|---|
| 寄存器 | 数百字节 | 不到 1 个周期 |
| L1 缓存 | 32~64KB | 4~5 个周期 |
| L2 缓存 | 0.5~2MB | 12~20 个周期 |
| L3 缓存 | 数十 MB | 40~70 个周期 |
| DRAM | 数十 GB | 200~300 个周期 |
| NVMe SSD | 数 TB | 数万个周期 |
这种结构之所以真正有效,是因为程序具有局部性(locality)。
- 时间局部性 — 使用过一次的数据很可能很快再次使用,例如循环变量。
- 空间局部性 — 使用一个地址后,很可能很快使用附近的地址,例如遍历数组。
缓存直接利用了这两种特性。它不会逐字节取数据,而是以缓存行为单位,通常一次取出完整的 64 字节。即使只读取一个 4 字节整数,也会连同相邻的 15 个整数一起载入。这就是按顺序遍历数组的代码更快的原因。
把缓存未命中分为三类,才能分别采取合适的对策。
- 强制未命中(compulsory) — 首次访问的数据,只能通过预取缓解。
- 容量未命中(capacity) — 工作集比缓存大,数据被挤出;需要改变算法或数据结构。
- 冲突未命中(conflict) — 数据集中映射到同一组而相互挤出;数组步幅(stride)是缓存容量的倍数时尤其容易出现。
实际工作中的表现
对于按行优先存储的二维数组,如果改为按列优先遍历,即使运算完全相同,也可能慢数倍乃至数十倍。因为每次访问都要载入一条新的缓存行,却只使用其中 4 个字节便将其丢弃。矩阵乘法采用分块(tiling/blocking)的原因也相同:把工作集切成能放进缓存的大小,从而增加复用。
这个问题在 AI 工作负载中表现得更加直观。LLM 推理的解码阶段会读取庞大的权重,与一个很小的输入相乘后便将其丢弃。它的算术强度(每读取一个字节所执行的运算数)极低,因此运算器大部分时间都在闲置,性能由内存带宽决定。人们常抱怨 GPU 利用率达不到 30%,根源往往就在这里。此时购买计算能力更强的芯片只是白花钱;正确做法是减少读取的字节数(量化),或提高数据复用。
接下来的测验将检查什么
检查你能否解释:为什么缓存行每次移动 64 字节;以及为什么同一种算法仅仅改变访问顺序,就会慢上数倍。