存储器层次与 Cache
上一篇我们让多条指令在流水线上重叠执行,把吞吐翻了几倍。但流水线有一个天敌:取指、访存要等内存。如果 CPU 每取一条指令、读一个变量都要干等内存几百个周期,流水线再深也救不回来。
这一篇往上抬一级视角,回答一个根本问题——为什么今天的计算机既能"内存很大"又能"访问很快"? 答案是用层次结构(memory hierarchy)把"快而贵"和"慢而便宜"组合成一套用户看来又大又快的存储系统,而真正的魔法器件就是 Cache。
一句话定位:局部性是因,Cache 是果;命中是常态,缺失是代价。
1. 存储器层次:用金字塔换"又快又大"
CPU 寄存器最快(亚纳秒、在芯片内),但容量只有几百字节;DRAM 主存便宜、能上 GB,但慢几十到上百倍;磁盘更便宜、能上 TB,但慢百万倍。如果只用一个层级,要么快得装不下,要么大得慢死。
核心思想:把最近用过的、以及它"邻居"的数据,都顺手搬到更靠近 CPU 的层。这样绝大多数访问命中上层、少量才落到下层。代价只是"偶尔没命中要去下层取"——只要命中率够高,平均访问时间就逼近最快那层。
2. 局部性原理:Cache 为什么成立
Cache 能work,靠的是程序的两类局部性(locality):
- 时间局部性(temporal):刚访问过的地址,很可能马上再被访问。例:循环变量
i、函数栈帧。 - 空间局部性(spatial):访问某个地址后,附近地址很可能也被访问。例:数组遍历、顺序取指令(下一条指令就在旁边)。
1 | // 典型双局部性:i 反复用(时间),arr[i]、arr[i+1] 相邻用(空间) |
反例:链表随机跳、哈希表稀疏访问——空间局部性差,Cache 命中率会明显下滑。这也是"数组比链表缓存友好"的底层原因。
3. Cache 基本术语
- 块 / 行(block / line):Cache 与主存之间搬运的最小单位,典型 32~64 字节。一次缺失会把一整块搬进来(顺手利用空间局部性)。
- 命中(hit):要的数据已在 Cache。
- 缺失(miss):不在 Cache,得去下一层取,取到后填回 Cache。
- 命中率(hit rate) = 命中次数 / 总访问次数;缺失率 = 1 − 命中率。
- 平均访问时间(AMAT) =
命中时间 + 缺失率 × 缺失代价。
4. 地址怎么拆:tag / index / offset
Cache 怎么知道"主存地址 A 在不在我这儿、在哪"?把地址切成三段:
- offset:块内偏移,由块大小决定。块 16 字节 → 占 4 位。
- index:组索引,"这个块该住在哪一组"。4 组 → 占 2 位。
- tag:剩下的高位,用来确认"这一组里躺着的到底是不是我要的那个地址"。
5. 三种映射方式
把主存块放进 Cache,放哪有讲究,对应三种策略:
冲突缺失(conflict miss) 是直接映射的软肋:两块映射到同一组时,会互相把对方挤掉。例:块 16 字节、4 组时,地址 0 与 64 的 index 都是 (x>>4)&3 = 0,交替访问就反复缺失(所谓"抖动")。组相联用多个空位直接消掉这类冲突。
6. 跑一个直接映射 Cache 仿真
下面这段 C 程序实现 4 组、块 16 字节的直接映射 Cache,并跑一条体现局部性的访问序列。注意 index = (addr>>4)&3、tag = addr>>6 和上面地址拆分完全对应。
1 |
|
输出(序列里 0,4,8,12 同属一块、16~28 同属一块……空间局部性让第二次起全命中):
1 | addr= 0 set=0 tag= 0 -> MISS |
20 次访问只 3 次冷缺失(每组第一次),其余全靠空间+时间局部性命中——这正是 Cache 日常工作的缩影。
7. 替换策略:组内满了怎么办
组相联 / 全相联里,组内位置用完又来了新块,必须挑一个踢出去:
- LRU(最近最少使用):踢"最久没被访问"的,最符合局部性,命中率通常最好,但要维护访问顺序。
- 随机(Random):硬件简单,大容量下和 LRU 差距不大。
- FIFO / RR:按进入顺序踢,实现简单但可能踢掉正热的块(Belady 异常相关)。
硬件里 LRU 常用"伪 LRU(tree-PLRU)"近似,用几位比特模拟顺序,省面积。
8. 写策略:写命中时怎么处理
读缺失只需"取回来",写缺失还涉及"什么时候写回下层":
- 写直达(write-through):每次写都同时写 Cache 和下层。简单、下层数据总一致;但每次写都访问下层,带宽压力大。
- 写回(write-back):只写 Cache,并且给块打"脏位(dirty)",只有被替换且脏时才写回下层。写带宽小得多,是 L1/L2 主流;代价是要维护脏位、一致性更复杂。
9. 三类缺失:知道自己为什么慢
- 强制缺失(compulsory / cold):第一次碰到这块,必然缺失,无法避免(除非预取)。
- 容量缺失(capacity):Cache 太小,装不下工作集,有用的块被挤出又取回。
- 冲突缺失(conflict):直接映射下多块抢同一组,即使总容量够也缺失;改组相联可消除。
诊断时把缺失拆成这三类,才能对症下药:容量不够就加 Cache,冲突多就加相联度,强制缺失靠预取(prefetch)。
10. 小结:AMAT 才是真正的指标
回到开头那句话——Cache 不是"命中就赢",而是把平均访存时间 AMAT = 命中时间 + 缺失率 × 缺失代价 压到接近最快那层。局部性好 → 缺失率低 → AMAT 低 → 流水线不用干等 → 整机就快。它和上一篇的流水线、下一篇的虚拟内存是同一套"用一层骗过另一层"的思想:CPU 以为自己拥有"又大又快"的单一内存,其实是 Cache 与 MMU 在底下齐心搬砖。
| 维度 | 直接映射 | 组相联 | 全相联 |
|---|---|---|---|
| 组内位置数 | 1 | E(如 8) | 全部 |
| 查找速度 | 最快 | 中 | 最慢 |
| 冲突缺失 | 最多 | 少 | 最少 |
| 典型用途 | L1 常客 | L2/L3 | TLB 小表 |
🐾 一句话收尾:Cache 不是魔法,是"局部性 + 层次 + 搬砖"三件套;命中率是它的命门,AMAT 是它的体温计。

