3. Cache架构设计:映射方式、替换策略与一致性协议

大家好,我是老张。今天咱们聊聊Cache架构设计里最核心的三个问题:数据怎么放满了怎么办多核怎么同步

这三个问题,说白了就是Cache的映射方式替换策略一致性协议。我在无线SoC项目里吃过不少亏,今天把这些坑都摊开来讲。

3.1 Cache映射方式:数据该放哪儿?

先问个问题:CPU要访问地址0x1234,Cache怎么知道这个数据在不在?

这就涉及到映射方式了。常见的有三种:直接映射组相联全相联

3.1.1 直接映射

直接映射最简单。每个内存地址只能进Cache里唯一的一个位置。

举个例子:假设Cache有8行,地址0x1234算出来只能放第2行。没得选。

优点:硬件简单,查得快,功耗低。

缺点:冲突率高。两个常用地址如果映射到同一行,就会互相踢来踢去。

我在一个低功耗蓝牙芯片项目里用过直接映射。当时觉得省面积嘛,挺好。结果跑音频流的时候,两个关键缓冲区正好冲突,Cache命中率掉到40%以下。嗯,后来我学乖了——直接映射适合确定性强的场景,比如指令Cache。

3.1.2 组相联

组相联是折中方案。Cache分成若干组,每组里有N个位置(叫N路组相联)。

地址先算属于哪一组,然后在这组的N个位置里随便放。

比如4路组相联:每组有4个坑位。地址0x1234算出来在第3组,那它可以在第3组的4个位置里任选一个。

路数 冲突率 硬件复杂度 典型场景
2路 中等 小容量Cache
4路 较低 中等 无线SoC L1 Cache
8路 较高 L2 Cache
16路 很低 高性能处理器

我个人习惯:无线SoC的L1数据Cache用4路组相联。面积和性能平衡得最好。

3.1.3 全相联

全相联最灵活。一个地址可以放在Cache的任意位置

你想想看,这几乎不会冲突。但代价是什么?每次查Cache都要比较所有行,硬件开销巨大。

注意:全相联一般只用于TLB(页表缓存)这种小容量场景。大容量Cache用全相联,面积和功耗都扛不住。

3.2 Cache替换策略:满了踢谁?

Cache满了怎么办?得踢一个出去。踢谁?这就是替换策略的事。

我画了一张图,帮你理清思路:

Cache替换策略决策树 Cache替换策略 LRU(最近最少使用) 伪LRU(近似LRU) 随机替换 命中率最高 硬件开销大 需要记录访问顺序 命中率接近LRU 硬件简单 常用树形PLRU 实现最简单 命中率不稳定 避免恶意攻击 推荐:小容量、高性能 推荐:无线SoC主流选择 推荐:大容量、防攻击

3.2.1 LRU(最近最少使用)

LRU的思路很直观:踢掉最长时间没被用过的那个

为什么?因为程序访问有局部性。刚用过的数据,很可能马上再用。很久没用的,大概率以后也不用。

LRU的命中率确实高。但代价也大——需要记录每个Cache行的访问顺序。对于N路组相联,需要维护N!种状态。4路还行,8路以上就有点吃不消了。

我的经验:在4路组相联的L1 Cache里,LRU效果很好。但到了L2 Cache(8路或16路),我建议换伪LRU。

3.2.2 伪LRU(近似LRU)

伪LRU是LRU的简化版。它不精确记录访问顺序,而是用一棵二叉树来近似。

举个例子:4路组相联,用3个bit的二叉树。每次访问时,更新树上的路径标记。替换时,沿着树找到"最可能没被用过的"那一路。

// 伪LRU的二叉树实现(4路示例)
// 每个组有3个bit: b0, b1, b2
// b0: 决定淘汰左半(0)还是右半(1)
// b1: 左半内部的选择
// b2: 右半内部的选择

// 访问路x时更新:
if (x < 2) {
    b0 = 0;  // 最近访问了左半
    b1 = (x == 0) ? 0 : 1;  // 具体哪一路
} else {
    b0 = 1;  // 最近访问了右半
    b2 = (x == 2) ? 0 : 1;
}

// 替换时:
if (b0 == 0) {
    // 淘汰右半
    replace_way = 2 + b2;
} else {
    // 淘汰左半
    replace_way = b1;
}

伪LRU的命中率只比真LRU低1-2%,但硬件开销小得多。我在多个无线SoC项目里都用它,效果很稳。

3.2.3 随机替换

随机替换最简单——随机选一个踢掉

你可能会想:这也太随意了吧?但有意思的是,随机替换在某些场景下表现并不差。

我曾经在一个多线程基带处理器里用过随机替换。当时发现LRU在某些访问模式下反而有"抖动"问题——频繁访问的数据把有用的旧数据全踢了。换成随机替换后,虽然平均命中率略低,但最差情况下的性能反而更稳定

注意:随机替换还有一个好处——防侧信道攻击。LRU的访问模式会被攻击者利用,随机替换没有固定模式,安全性更好。

3.3 Cache一致性协议:多核怎么同步?

多核处理器里,每个核都有自己的Cache。问题来了:核A改了变量x,核B的Cache里还存着旧的x。怎么办?

这就需要Cache一致性协议

3.3.1 MESI协议

MESI是经典的协议。每个Cache行有四种状态:

状态 全称 含义
M Modified 已修改,数据只在本Cache,与内存不一致
E Exclusive 独占,数据只在本Cache,与内存一致
S Shared 共享,多个Cache都有,与内存一致
I Invalid 无效,数据不可用

MESI的核心思想:写操作前先获得独占权

举个例子:

  1. 核A读变量x,Cache里没有,从内存加载,状态变成E(独占)
  2. 核B也读x,总线嗅探发现核A有,核A把状态改成S(共享),核B也变成S
  3. 核A要写x,发"读独占"请求,核B收到后把它的x置为I(无效)
  4. 核A写x,状态变成M(已修改)

我在项目中遇到过一个问题:MESI协议在写操作时,如果数据在多个核里共享,需要发广播让其他核无效化。这个广播操作很耗时,尤其是在总线带宽紧张的时候。

3.3.2 MOESI协议

MOESI在MESI基础上加了O状态(Owned,拥有)。

O状态的意思是:数据被修改了,但其他核可以共享读

为什么要加O状态?你想想看:MESI里,如果核A是M状态,核B要读,核A必须把数据写回内存,然后两个核都变成S。这多了一步写内存的操作。

MOESI的O状态允许核A保持修改后的数据,同时让核B也读到这份数据。核A是O状态,核B是S状态。数据不用写回内存,省了一次总线传输。

关键区别

  • MESI:写回内存才能共享 → 延迟高
  • MOESI:直接共享修改后的数据 → 延迟低

我个人习惯:无线SoC里优先用MOESI。因为无线基带处理经常有多个核共享数据(比如信道估计结果),MOESI能减少不必要的内存访问,省电又省带宽。

3.4 避坑指南

讲了这么多,最后分享几个我踩过的坑:

坑1:映射方式选错

我曾经在一个DMA控制器里用了直接映射,结果DMA和CPU访问的地址正好冲突,Cache命中率惨不忍睹。后来改成4路组相联,问题解决。

坑2:替换策略不考虑功耗

LRU的硬件实现需要大量比较器,功耗不低。在电池供电的无线SoC里,我建议用伪LRU。省下来的功耗可以留给射频前端用。

坑3:一致性协议选型不考虑总线

MOESI虽然好,但需要总线支持"数据转发"(Data Forwarding)。如果总线不支持,MOESI的优势就发挥不出来。选协议前,先看看总线的能力。

好了,Cache架构设计的三个核心问题就聊到这儿。映射方式决定了数据怎么放,替换策略决定了满了踢谁,一致性协议保证了多核不乱套。这三个选好了,你的Cache性能就稳了八成。

下次咱们聊聊Cache的写策略和预取技术,到时候见。


公众号:蓝海资料掘金营,微信deep3321