第 7 章 · 存储层次与 Cache

「内存墙」靠存储层次弥合——小块高速存储挡住大部分访问。前提(局部性)+ Cache 三件套(映射/替换/写策略);第 8 章推到虚拟内存。

7.1 局部性原理

Cache 有效的前提是程序访问内存不均匀

  • 时间局部性:刚访问过的地址很快会再访问(循环变量、热代码)。
  • 空间局部性:访问过某地址,很快访问它附近(数组顺序扫描、指令顺序执行)。
for (int i = 0; i < n; i++)        // i:时间局部(反复读)
    sum += a[i];                    // a[i]:空间局部(连续读)

注意:局部性是「程序的统计规律」,非硬件保证。链表遍历、哈希表随机访问是反局部性——p = p->next 每次跳随机堆地址,Cache 命中率暴跌。故「数组 vs 链表」遍历性能差一个数量级。

7.2 存储层次与命中

从快到慢、从小到大:

寄存器 < L1 Cache < L2 < L3 < 主存 < 磁盘
  ~0.3ns    ~1ns    ~4ns  ~12ns  ~100ns   ~10ms

衡量指标:

平均访存时间 = 命中时间 + 缺失率 × 缺失代价

缺失代价 = 从下一层取数的延迟,远大于命中时间,故降缺失率是 Cache 第一目标。

7.3 三种映射方式

内存地址切成 标记 Tag | 索引 Index | 块内偏移 Offset。三种映射决定「内存块能放 Cache 哪个位置」:

映射规则查找方式冲突缺失硬件
直接映射每块固定放唯一位置(Index 决定)只查 1 个位置高(同 Index 不同 Tag 互踢)最简单
全相联可放任意位置并行查所有 Tag(内容寻址)无冲突缺失最贵(CAM)
组相联先按 Index 定组,组内任意查一组(如 8 路)的 Tag介于两者折中,主流
直接映射:  块只能进它唯一的那一格
组相联(8路):块进它所属组的 8 格之一
全相联:    块能进任何一格

对比:直接映射便宜但同 Index 不同块互相驱逐;全相联零冲突但需 CAM 并行比较所有 Tag、面积功耗爆炸;组相联(8/16 路)是工程甜点——现代 L1 常用 8 路、L2/L3 用 16 路。

7.4 替换策略

组满时新块要踢掉一个旧块:

策略规则特点
随机随机踢一个简单,命中率不稳定
FIFO踢最早进来的简单,但可能踢掉热点(Belady 异常)
LRU踢最久没用的贴合时间局部性,命中率高,但硬件要记录访问顺序

注意:LRU 理论最优,但硬件代价随路数指数增长(记录 N! 种顺序)。工程用近似 LRU(树形伪 LRU,每路几 bit 记「最近用过」)以低成本逼近。缓存越大、路数越多,越用近似算法。

7.5 写策略:写直达 vs 写回

读缺失好办,才是一致性麻烦——写会同时改 Cache 和主存:

策略写命中时写缺失时特点
写直达(write-through)同时写 Cache 和主存写分配 / 写不分配主存永远新,简单,但每次写都访存
写回(write-back)只写 Cache,标 dirty写分配为主主存延迟到被替换时才写,省带宽
  • 写分配 vs 写不分配:写缺失时是否先把块调入 Cache?「写回」通常配写分配(写局部性);「写直达」可配写不分配。
  • 写缓冲(write buffer):写直达每次写都访存,加缓冲队列让 CPU 写完即继续、后台慢慢写主存——代价是读缺失时可能要等缓冲排空。

对比:写直达简单、一致性好但带宽浪费;写回省带宽、快但要 dirty 位和替换时写回。多核下写回需 MESI 之类协议同步(第 8 章/OS 并发延伸)。CPU 内部 Cache 普遍写回,MMIO 设备(第 9 章)有时强制写直达。

7.6 与虚拟内存 / 语言对照

  • 第 8 章:Cache 与虚拟内存是同一思想两种尺度——Cache 弥合 CPU↔主存速度差,虚拟内存弥合主存↔磁盘容量差。映射/替换/写策略一一对应(组相联 vs 页表、LRU vs 页面置换、写回 vs 写时复制)。
  • C:数组顺序访问吃满空间局部性;struct 小字段紧邻利于 Cache 行。伪共享(false sharing):两线程写同一 Cache 行不同变量,互相失效对方行、性能雪崩。
  • Java/Python:对象在堆上散落,引用数组遍历比连续数组慢,本质是 Cache 局部性差。