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;
};

我当年重构过一个中间件,加了几行填充后吞吐量直接翻倍。你想想看,仅仅是因为两个变量挤在了同一行。

核心原则:将频繁被不同线程修改的变量,按缓存行大小(64字节)对齐或填充,避免伪共享。

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。

我见过很多开发者滥用预取。明明访问模式很规则,硬件已经做得很好了,再手动预取反而打乱流水线。记住:只在硬件预取失效时用软件预取。

预取类型适用场景注意事项
硬件预取顺序访问、步长固定的数组自动,开销几乎为零
软件预取不规则访问、链表、树过度使用会浪费指令缓存
踩坑提醒:我曾经在一个数据库查询引擎里对所有指针都加了预取,结果性能反而下降。原因是L1 cache被预取请求撑爆了,关键数据被挤出去。后来我只在内存带宽瓶颈明显的地方用,才看到正收益。
CPU 缓存层级与优化要点 CPU 核 L1i L1d L1d L2 Cache (私有) L3 Cache (共享) 主存 (DRAM) 缓存行对齐 将变量按64B填充 避免伪共享 数据局部性 行优先遍历 结构体热字段集中 预取 硬件预取+软件预取 只在不规则访问时使用

3.4 综合实践建议

说了这么多,总结几条我个人常用的检查清单:

  1. 先测量再优化。用perf stat -e cache-misses看看缓存缺失率。
  2. 结构体布局。按访问频率排字段,确保热字段在同一个缓存行内。
  3. 线程数据隔离。每个线程私有的数据尽量分布在不同缓存行,避免伪共享。
  4. 预取只救急。先确认硬件预取真的失效了,再考虑手动插入。
一个小工具:Linux 下的 getconf LEVEL1_DCACHE_LINESIZE 可以直接读缓存行大小。我之前写对齐代码时,习惯在构建脚本里自动检测这个值,避免硬编码。

缓存优化不是魔法,本质上就是让数据离CPU更近、更整齐。你写对缓存行,用好局部性,适当预取,大部分性能瓶颈都能解决。剩下的,就是堆硬件了(笑)。