缓存基础:Cache Line、Cache Hit/Miss、局部性原理
大家好,我是你们的CPU架构讲师。今天咱们聊聊缓存,这玩意儿可以说是CPU性能的命根子。我做了这么多年性能优化,见过太多代码跑得慢,最后查下来都是缓存没用好。说白了,CPU主频再高,内存跟不上也是白搭。
一、为什么需要缓存?
先问个问题:CPU和内存谁更快?答案是CPU快得多。现代CPU一个时钟周期大概0.3纳秒,而访问一次DDR内存需要几十甚至上百纳秒。这差距有多大?你想想看,CPU等一次内存访问,够它执行几百条指令了。
所以缓存就诞生了。它是一块小而快的内存,放在CPU和主存之间。我习惯把缓存比作「工作台」——你常用的工具放台面上,不常用的放仓库。仓库(内存)大但远,工作台(缓存)小但近。
二、Cache Line——缓存的最小单元
缓存不是按字节存的,而是按「行」存的。这个行就是Cache Line。主流CPU的Cache Line大小是64字节。什么意思呢?你读一个int(4字节),CPU会把包含这个int的连续64字节都加载进缓存。
我在项目中遇到过一个问题:两个线程各自修改一个int,这两个int恰好落在同一个Cache Line里。结果就是,明明改的是不同变量,却互相拖慢性能。这就是著名的「伪共享」问题。
// 伪共享示例
struct Data {
int a; // 线程1频繁修改
int b; // 线程2频繁修改
// 如果a和b在同一个Cache Line,性能会暴跌
};
三、Cache Hit 与 Cache Miss
CPU访问数据时,先看缓存里有没有。有就叫Cache Hit,没有就叫Cache Miss。命中率直接决定性能。
| 类型 | 含义 | 代价 |
|---|---|---|
| Cache Hit | 数据在缓存中 | 几个时钟周期 |
| Cache Miss | 数据不在缓存中 | 几十到几百个时钟周期 |
Cache Miss又分三种:
- 强制未命中:第一次访问,缓存里肯定没有。没办法,硬扛。
- 容量未命中:缓存太小,装不下所有数据。换进换出导致的。
- 冲突未命中:多个数据映射到同一个缓存位置,互相踢来踢去。
嗯,这里要注意:强制未命中是不可避免的,但容量和冲突未命中可以通过优化代码来减少。
四、局部性原理
局部性原理是缓存能工作的理论基础。说白了就是:程序倾向于访问最近用过的数据,以及它附近的数据。
时间局部性
一个数据被访问后,很可能很快再次被访问。比如循环里的变量:
for (int i = 0; i < 1000000; i++) {
sum += array[i]; // sum被反复访问
}
这里的sum就有很好的时间局部性。每次循环都访问它,缓存命中率极高。
空间局部性
访问了一个地址,很可能访问它附近的地址。比如遍历数组:
// 空间局部性好:连续访问
for (int i = 0; i < N; i++) {
sum += array[i];
}
// 空间局部性差:跳着访问
for (int i = 0; i < N; i++) {
sum += array[step * i];
}
第一个循环,CPU预取器能轻松预测下一个要访问的地址,提前加载到缓存。第二个循环,每次跳step个元素,预取器基本废了。
五、知识体系图
下面这张图总结了缓存的核心概念和它们之间的关系:
六、实战建议
说了这么多理论,来点实际的。我个人习惯在写性能敏感代码时,遵循这几条原则:
- 数据紧凑排列:把一起用的数据放在一起。比如结构体里,热点字段放前面。
- 按顺序访问:尽量用连续内存,别跳来跳去。数组比链表快,原因就在这里。
- 注意对齐:多线程下,把热点变量按Cache Line对齐,避免伪共享。
- 利用预取:编译器一般会自动优化,但如果你写的是底层代码,可以手动加预取指令。
perf stat可以看程序的缓存命中率。如果Cache Miss率超过10%,就该优化了。
好了,缓存基础就讲到这里。记住一句话:性能优化的本质,就是让数据尽可能待在缓存里。下次写代码时,多想想你的数据是怎么在缓存里流动的,很多性能问题自然就迎刃而解了。
公众号:蓝海资料掘金营,微信deep3321