4、Cache原理:Cache层次结构、命中率与缺失率、直接映射/组相联/全相联
各位同学,咱们今天聊聊Cache。说实话,Cache这玩意儿是ARM处理器性能调优里最绕不开的一个话题。我见过太多工程师,代码写得飞起,一上板子就跑不动,最后查来查去,问题全出在Cache上。你想想看,CPU跑得再快,如果数据拿不到,那也是白搭。
4.1 Cache层次结构:为什么要有L1、L2、L3?
先问个问题:为什么处理器不直接用一块又大又快的内存?答案很简单——造不出来。或者说,造出来你也买不起。SRAM速度快,但面积大、功耗高、成本贵。DRAM便宜,但速度慢了一个数量级。
所以,工程师们想了个折中方案:搞层次化存储。离CPU越近的,越小越快越贵;离CPU越远的,越大越慢越便宜。这就是Cache层次结构的核心思想。
典型的ARM Cortex-A系列Cache层次:
- L1 Cache:分指令Cache和数据Cache,通常各32KB或64KB。1-2个时钟周期访问。每个核心独享。
- L2 Cache:统一Cache(指令+数据),通常256KB-2MB。10-20个时钟周期。可以核心独享,也可以多核共享。
- L3 Cache:部分高性能处理器才有,比如Cortex-A76/A78。几MB到十几MB。20-50个时钟周期。所有核心共享。
我在项目中遇到过一件事:有个同事把一个大数组放在全局区,程序跑起来慢得离谱。我一看,数组大小刚好比L2 Cache大一点点,每次循环都在L2和主存之间来回倒腾。这就是典型的Cache thrashing。后来我把数组拆成小块处理,性能直接翻倍。
我的习惯:写代码前先查一下目标芯片的Cache大小。Cortex-M系列通常只有L1,Cortex-A系列才有L2/L3。别指望M4能像A72那样扛得住大数据量。
4.2 命中率与缺失率:性能的晴雨表
Cache命中率,说白了就是CPU要的数据在Cache里找到的概率。命中率越高,性能越好。缺失率就是没找到的概率。
公式很简单:
命中率 = Cache命中次数 / 总访问次数
缺失率 = 1 - 命中率
平均访问时间 = 命中时间 + 缺失率 × 缺失代价
举个例子:假设L1命中率95%,命中时间1ns,缺失代价(从主存拿数据)100ns。那么平均访问时间 = 1 + 0.05 × 100 = 6ns。你看,缺失率才5%,但平均访问时间变成了6倍。这就是为什么我们要死磕Cache命中率。
我曾经踩过的坑:有个项目做视频处理,我用了链表结构来管理帧数据。结果每次访问都要跳来跳去,Cache命中率惨不忍睹。后来改成数组+索引,命中率从60%飙到95%以上。记住:空间局部性比你想的更重要。
影响命中率的因素有哪些?我总结了几点:
- 程序的空间局部性:访问连续地址的数据,命中率高。比如数组遍历。
- 程序的时间局部性:反复访问同一块数据,命中率高。比如循环内的变量。
- Cache大小:越大越容易命中,但延迟也越大。
- Cache行大小:通常64字节。行越大,空间局部性收益越高,但也会浪费带宽。
- 替换策略:LRU(最近最少使用)通常比随机替换好。
4.3 直接映射Cache:简单粗暴
直接映射Cache,就是把主存地址映射到Cache中唯一的位置。怎么映射?用地址的某些位做索引。
假设Cache有64行,每行64字节。那么地址的bit[5:0]是行内偏移,bit[11:6]是行索引,剩下的高位是标签。CPU访问时,先算索引,找到对应的Cache行,然后比较标签。匹配且有效位为1,就是命中。
// 直接映射Cache访问伪代码
uint32_t cache_index = (address >> 6) & 0x3F; // 取bit[11:6]
uint32_t tag = address >> 12; // 取高位
if (cache[cache_index].tag == tag && cache[cache_index].valid) {
// 命中
return cache[cache_index].data[address & 0x3F];
} else {
// 缺失,从主存加载
load_from_memory(address);
}
直接映射的优点是简单、硬件开销小、访问速度快。但缺点也很明显:如果两个经常访问的地址映射到同一行,就会互相踢来踢去,造成频繁缺失。这种现象叫冲突缺失。
我建议:在写实时性要求高的代码时,尽量避免让两个热点数据落在同一个Cache索引上。你可以用地址对齐或者填充数据来错开。嗯,这招我在做音频处理时用过,效果不错。
4.4 组相联Cache:折中的艺术
组相联Cache是直接映射和全相联的折中方案。它把Cache分成若干组,每组有N行(N路)。一个主存地址可以映射到某一组内的任意一行。
比如4路组相联Cache,每组有4行。地址的索引位决定去哪个组,然后在组内的4行里找匹配的标签。如果都没匹配上,就替换其中一行(通常用LRU策略)。
| 相联度 | 冲突缺失 | 硬件复杂度 | 访问延迟 | 典型应用 |
|---|---|---|---|---|
| 直接映射(1路) | 高 | 低 | 低 | 低端MCU |
| 2路组相联 | 中 | 中 | 中 | 部分Cortex-M |
| 4路组相联 | 低 | 较高 | 较高 | Cortex-A系列L1 |
| 8路/16路组相联 | 很低 | 高 | 高 | L2/L3 Cache |
为什么L1通常用4路,L2用8路或16路?因为L1要快,相联度太高会拖慢访问速度。L2可以稍微慢一点,但希望命中率更高,所以用更高的相联度。
4.5 全相联Cache:灵活但昂贵
全相联Cache,就是任何主存地址可以放在Cache的任何一行。没有索引的概念,只有标签比较。每次访问都要和所有行比较标签。
全相联的优点是冲突缺失几乎为零,空间利用率最高。但缺点也很致命:硬件实现复杂,比较器太多,访问延迟大,功耗高。所以全相联Cache通常只用在TLB(页表缓存)这种行数很少的场景,或者一些特殊处理器中。
实际项目中:你几乎不会在ARM处理器的L1/L2 Cache里看到全相联。但TLB经常用全相联或高度组相联。我记得有一次调试MMU相关的性能问题,就是TLB缺失太频繁,后来通过调整页表大小和布局解决了。
4.6 实战建议:如何利用Cache优化性能
说了这么多理论,来点实际的。我在ARM平台上做性能优化时,通常会遵循这几条原则:
- 数据对齐:关键数据结构按Cache行对齐(64字节)。避免一个数据跨两个Cache行,否则一次访问要加载两行。
- 循环展开:适当展开循环,减少分支,提高指令Cache命中率。但别展开太多,否则指令Cache会爆。
- 数据预取:ARM有PLD指令,可以提前告诉Cache“我马上要用这个地址了”。在遍历大数组时特别有用。
- 避免伪共享:多核场景下,不同核心修改同一Cache行的不同变量,会导致Cache一致性协议频繁同步。解决办法是把变量分散到不同Cache行。
- 使用Cache锁定:部分ARM处理器支持锁定Cache行,把关键代码或数据锁在Cache里,防止被踢出去。实时系统里常用。
// 示例:使用PLD指令预取
void process_data(uint32_t *data, int len) {
for (int i = 0; i < len; i += 4) {
// 预取后面第64字节的数据
__asm__ volatile("PLD [%0, #64]" : : "r"(&data[i]));
// 处理当前数据
data[i] = data[i] * 2;
data[i+1] = data[i+1] * 2;
data[i+2] = data[i+2] * 2;
data[i+3] = data[i+3] * 2;
}
}
注意:预取不是万能的。预取太早,数据可能被踢出去;预取太晚,来不及加载。我一般会在循环的前几次迭代里手动预取,后面靠硬件预取器自动处理。具体调优还是要靠性能计数器来量。
最后说一句:Cache调优没有银弹。每个芯片的Cache参数都不一样,每个应用场景的访问模式也不同。我的做法是:先用性能计数器(比如PMU)测出Cache缺失率,找到热点,再针对性地优化。别一上来就瞎改代码,那样效率太低。
好了,Cache原理就讲到这里。下一章咱们聊聊MMU和页表,那又是另一番天地了。