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满了怎么办?得踢一个出去。踢谁?这就是替换策略的事。
我画了一张图,帮你理清思路:
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的核心思想:写操作前先获得独占权。
举个例子:
- 核A读变量x,Cache里没有,从内存加载,状态变成E(独占)
- 核B也读x,总线嗅探发现核A有,核A把状态改成S(共享),核B也变成S
- 核A要写x,发"读独占"请求,核B收到后把它的x置为I(无效)
- 核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