3. Cache 基础概念:直接映射、组相联、全相联 Cache 结构与替换策略

好,咱们今天聊聊 Cache 的三种基本结构。说实话,这部分是存储层次设计的基石。我在刚入行时,总觉得 Cache 就是个“大缓存”,后来被 mentor 带着调了一次性能,才发现里面的门道深着呢。

你想想看,CPU 跑得飞快,内存却慢吞吞的。Cache 就是中间的“缓冲带”。但怎么把内存数据放到 Cache 里?放哪里?满了怎么办?这就是映射方式和替换策略要解决的问题。

3.1 直接映射 Cache

直接映射,说白了就是“一个萝卜一个坑”。每个内存地址只能映射到 Cache 中唯一的一个位置。

怎么算这个位置?很简单:

Cache 行号 = (内存块地址) % (Cache 总行数)

举个例子。假设 Cache 有 8 行,内存块地址 0、8、16……都映射到第 0 行。地址 1、9、17……都映射到第 1 行。

核心特点:

  • 硬件实现最简单——只需要一个比较器
  • 查找速度最快——直接算索引,不用遍历
  • 冲突率最高——多个地址抢同一行

我在项目中遇到过一个问题:一个循环访问两个数组,恰好它们的地址映射到同一个 Cache 行。结果每次访问都 miss,性能直接崩了。这就是典型的“Cache 抖动”。

避坑指南:我曾经调试过一个视频编解码模块,帧率死活上不去。最后发现是输入缓冲和输出缓冲在直接映射 Cache 中冲突了。解决办法很简单——把其中一个数组的起始地址偏移一下,避开冲突。

3.2 全相联 Cache

全相联是另一个极端。任何内存块可以放在 Cache 的任何位置。没有“固定坑位”这一说。

听起来很灵活对吧?但代价也大:

  • 每次查找都要比较所有 Cache 行——硬件开销巨大
  • 需要 n 个比较器并行工作——n 是 Cache 行数
  • 功耗高、延迟大——不适合大容量 Cache

实际应用:全相联通常只用在 TLB(快表)这种小容量结构中。我见过最大的全相联 Cache 也就几十行。再大,芯片面积和功耗就扛不住了。

3.3 组相联 Cache

组相联是前两者的折中方案。它把 Cache 分成若干组,每组内有若干行。内存块先映射到某个组,然后在组内可以自由放置。

用公式表示:

组号 = (内存块地址) % (组数)
组内查找:遍历组内所有行

比如 4 路组相联,就是每组有 4 行。内存块先算组号,然后在这 4 行里找。

参数 直接映射 组相联 全相联
冲突率 高 中 低
硬件复杂度 低 中 高
查找延迟 低 中 高
典型路数 1 路 2~16 路 全部
实际应用 早期 CPU 现代 CPU L1/L2 TLB、小容量缓冲

我个人习惯在 L1 Cache 用 4~8 路组相联。为什么?因为 2 路冲突还是有点高,16 路以上延迟增加明显,性价比最好的就是 4 路和 8 路。

3.4 替换策略

Cache 满了怎么办?得把某一行踢出去,让新数据进来。这就是替换策略。

3.4.1 LRU(最近最少使用)

LRU 是业界最常用的策略。它替换掉最长时间没有被访问的那一行。

实现方式:

  • 每行维护一个“年龄”计数器
  • 每次访问,被访问行的计数器清零,其他行加 1
  • 替换时,选计数器最大的行

优点:性能好,能很好地利用时间局部性。

缺点:硬件开销大——每组需要维护 n 个计数器,还要做比较。

我记得有一次做 RISC-V 处理器设计,L2 Cache 用了精确 LRU。综合后一看面积,好家伙,替换逻辑占了 Cache 控制器的 40%。后来改成了近似 LRU。

3.4.2 伪 LRU(近似 LRU)

伪 LRU 用二叉树来近似 LRU 的行为。每组维护一棵二叉树,每个节点指示“左右子树哪边更久没被访问”。

举个例子,4 路组相联的伪 LRU:

  • 树有 3 个节点(4-1=3)
  • 每个节点 1 bit,共 3 bit
  • 替换时沿着树走,选“更久未访问”的那一侧

经验之谈:伪 LRU 的性能和精确 LRU 非常接近,但硬件开销小得多。我做的几个项目都用伪 LRU,没出过问题。

3.4.3 随机替换

随机替换就是随便选一行踢出去。实现最简单——一个随机数生成器就行。

你可能会问:“随机能行吗?”

嗯,在某些场景下还真行。比如一些流式处理应用,数据只访问一次,LRU 和随机效果差不多。但通用场景下,随机比 LRU 差 10%~20%。

3.4.4 FIFO(先进先出)

FIFO 替换最早进入 Cache 的那一行。实现简单,用环形队列就行。

但有个问题:经常访问的“热数据”可能被 FIFO 踢出去,而“冷数据”却留在里面。这种现象叫“颠簸”。

注意:我曾经在一个网络处理器项目中用了 FIFO,结果热点数据频繁被踢,Cache miss 率飙升到 40%。换成 LRU 后,直接降到 5%。从那以后,我再也没在通用处理器中用 FIFO。

3.5 三种结构与替换策略的搭配

不同的 Cache 结构适合不同的替换策略:

Cache 结构 推荐替换策略 原因
直接映射 无(不需要) 每个地址只有唯一位置,没得选
组相联(2~4 路) 伪 LRU 硬件开销小,性能接近 LRU
组相联(8 路以上) LRU 或 伪 LRU 路数多,LRU 开销可接受
全相联 LRU 行数少,精确 LRU 可行

3.6 知识结构图

下面这张图总结了三种映射结构和替换策略的关系:

Cache 结构与替换策略知识图谱 Cache 结构与替换策略 直接映射 1 路,固定位置 组相联 N 路,组内自由 全相联 任意位置 无需替换策略 LRU / 伪 LRU LRU 替换策略对比 LRU:性能最好,硬件开销大 | 伪 LRU:近似 LRU,开销小 | 随机:实现简单,性能一般 | FIFO:易颠簸 设计建议 L1 Cache:4~8 路组相联 + 伪 LRU | L2 Cache:8~16 路组相联 + LRU

3.7 实际设计中的选择

说了这么多,到底怎么选?我总结几条经验:

  1. L1 Cache:用 4~8 路组相联 + 伪 LRU。延迟要低,面积要小。
  2. L2 Cache:用 8~16 路组相联 + LRU。容量大,可以接受稍高的延迟。
  3. TLB:用全相联 + LRU。行数少,精确 LRU 可行。
  4. 专用加速器:如果数据访问模式固定,直接映射 + 无替换策略反而更简单高效。

一个小技巧:在做架构探索时,先用模拟器跑一下不同配置的 miss 率。我习惯用 gem5 做这个事。等找到最优配置,再动手写 RTL。这样能省很多迭代时间。