3. CPU缓存优化:缓存行对齐、数据局部性、预取
聊到CPU性能,缓存是绕不开的话题。很多人觉得缓存无非就是快一点的内存,其实没那么简单。我做过几个高并发服务,明明算法复杂度没问题,性能就是上不去。后来一查,全栽在缓存行冲突上了。这节我们就把它彻底讲透。
3.1 缓存行对齐:伪共享的根源
CPU缓存的最小单位不是字节,而是缓存行(Cache Line)。绝大多数CPU的缓存行大小是64字节。也就是说,你改一个字节,CPU会把包含它的64字节整行拉进缓存。
这带来一个问题——伪共享(False Sharing)。举个例子:
// 两个线程频繁修改不同的计数器
struct Counter {
long a; // 线程1写
long b; // 线程2写
};
如果 a 和 b 在内存中挨着,它们就会落在同一个缓存行里。线程1改 a,导致线程2所在的核必须失效自己的缓存行,重新拉取。即便线程2只关心 b,也得跟着折腾。说白了,硬件级的无谓竞争。
解决办法就是填充,让 a 和 b 隔离到不同的缓存行:
struct Counter {
long a;
char padding[56]; // 64 - 8 = 56
long b;
};
我当年重构过一个中间件,加了几行填充后吞吐量直接翻倍。你想想看,仅仅是因为两个变量挤在了同一行。
3.2 数据局部性:空间和时间两手抓
数据局部性分两种:空间局部性和时间局部性。我习惯把空间局部性比作“邻居效应”——你大概率会用到刚访问过的东西旁边的数据。时间局部性则是“热数据保鲜”——刚用过的数据很快又会再用。
看个经典例子:
| 写法 | 访问顺序 | 缓存命中率 |
|---|---|---|
| 行优先(高效) | matrix[0][0], matrix[0][1], … | 极高 |
| 列优先(低效) | matrix[0][0], matrix[1][0], … | 极低(每次跳行) |
一个按行遍历,一个按列遍历。按行时内存地址连续,CPU预取器能提前拉进后续缓存行;按列时每次跨过整行,缓存行刚拉进来就失效了。我曾经在一个图像处理项目里,把列优先改成行优先,速度快了5倍都不止。
时间局部性也一样——循环内重复使用的变量尽量保持在小范围内。比如内层循环的临时变量,让它在寄存器或L1里反复用,别冲出去污染缓存。
3.3 预取:让数据提前就位
CPU有硬件预取器,能自动识别规律的内存访问模式。但硬件不是万能的。遇到不规则访问(比如链表遍历、间接寻址),硬件就跟不上了。这时候需要软件预取。
常用的指令是 __builtin_prefetch(GCC/Clang)或 _mm_prefetch(MSVC)。用法:
for (int i = 0; i < n; ++i) {
__builtin_prefetch(&arr[i + 8], 0, 1); // 提前预取8个元素之后的数据
// 处理 arr[i]
}
参数说明:第二个参数0表示读,1表示写;第三个参数是时延等级(0~3),1表示预取到L1。
我见过很多开发者滥用预取。明明访问模式很规则,硬件已经做得很好了,再手动预取反而打乱流水线。记住:只在硬件预取失效时用软件预取。
| 预取类型 | 适用场景 | 注意事项 |
|---|---|---|
| 硬件预取 | 顺序访问、步长固定的数组 | 自动,开销几乎为零 |
| 软件预取 | 不规则访问、链表、树 | 过度使用会浪费指令缓存 |
3.4 综合实践建议
说了这么多,总结几条我个人常用的检查清单:
- 先测量再优化。用
perf stat -e cache-misses看看缓存缺失率。 - 结构体布局。按访问频率排字段,确保热字段在同一个缓存行内。
- 线程数据隔离。每个线程私有的数据尽量分布在不同缓存行,避免伪共享。
- 预取只救急。先确认硬件预取真的失效了,再考虑手动插入。
getconf LEVEL1_DCACHE_LINESIZE 可以直接读缓存行大小。我之前写对齐代码时,习惯在构建脚本里自动检测这个值,避免硬编码。
缓存优化不是魔法,本质上就是让数据离CPU更近、更整齐。你写对缓存行,用好局部性,适当预取,大部分性能瓶颈都能解决。剩下的,就是堆硬件了(笑)。