4、Cache优化技术:Cache命中率提升、预取策略、Cache Line对齐、伪共享问题解决
各位好,咱们接着聊Cache。说实话,做海思芯片性能优化,Cache这块要是没玩明白,那基本等于白干。我见过太多项目,CPU算力明明够,内存带宽也不差,但跑起来就是慢。一查,Cache miss率高得吓人。今天我就把这几年的实战经验掰开揉碎,跟你讲讲怎么把Cache伺候好。
4.1 为什么Cache命中率这么重要?
先问个问题:CPU从L1 Cache读数据要几个周期?大概2-4个。从L2呢?10-20个。从DDR呢?200-300个。你想想看,一次Cache miss,性能直接掉两个数量级。我调过一块海思3559A的板子,有个算法模块原本跑30fps,优化完Cache命中率后直接飙到55fps。说白了,Cache优化就是捡钱,而且是捡大钱。
核心原则:让CPU尽量在Cache里找到它要的数据,别让它去DDR里翻箱倒柜。
4.2 提升Cache命中率的实战技巧
我个人习惯把命中率优化分成三个层面:数据布局、访问模式、编译控制。一个一个说。
4.2.1 数据布局:把热数据凑一起
我在项目中遇到过一个问题:一个结构体里既有频繁访问的成员,也有几乎不用的成员。结果每次访问热数据,Cache Line里还捎带了冷数据,白白浪费空间。解决办法很简单——把热数据单独拎出来。
// 不推荐:冷热数据混在一起
struct bad_design {
int hot_counter; // 频繁访问
char debug_info[64]; // 几乎不用
int hot_flag; // 频繁访问
};
// 推荐:热数据集中存放
struct good_design {
int hot_counter;
int hot_flag;
char debug_info[64]; // 冷数据放后面
};
嗯,这里要注意:结构体成员按访问频率从高到低排列。这样Cache Line里装的全是热数据,命中率自然就上去了。
4.2.2 访问模式:顺序访问优于随机访问
硬件预取器最喜欢顺序访问模式。你写个for循环从头到尾遍历数组,硬件会自动把后面的数据提前拉到Cache里。但你要是跳着访问,预取器就懵了。
我曾经调过一个图像处理算法,原本用双循环按列访问像素数据。改成按行访问后,Cache miss率从35%降到了8%。为什么?因为C语言数组在内存里是按行存储的,按行访问就是顺序访问。
小技巧:如果必须随机访问,试试把数据重新排列成访问顺序。或者用软件预取指令提前把数据拉进来。
4.3 预取策略:让数据提前到位
硬件预取虽然好用,但有时候它猜不准。这时候就需要我们手动干预了。海思芯片支持PLD(Preload Data)指令,可以在代码里显式告诉Cache:嘿,我马上要用这个地址的数据,你先帮我拉进来。
// 软件预取示例
void process_data(int *data, int size) {
for (int i = 0; i < size; i++) {
// 预取后面第4个元素
__builtin_prefetch(&data[i + 4], 0, 3);
// 处理当前元素
data[i] = data[i] * 2;
}
}
__builtin_prefetch的三个参数:第一个是地址,第二个是读写提示(0表示读,1表示写),第三个是时效性(3表示高时效,数据用完很快会再用)。我个人习惯在循环里预取4-8个元素之后的数据,这个距离要根据Cache Line大小和延迟来调。
注意:预取不是越多越好。预取太多会挤占Cache空间,把有用的数据踢出去。我曾经在一个项目里过度预取,结果性能反而下降了5%。
4.4 Cache Line对齐:别让数据跨行
Cache Line一般是64字节。如果一个数据对象跨了两个Cache Line,访问它就要读两次内存。这就像你去图书馆借书,一本书分放在两个书架上,你得跑两趟。
解决办法:把关键数据结构按Cache Line对齐。
// 按64字节对齐
struct __attribute__((aligned(64))) aligned_data {
int data[16]; // 正好64字节
};
// 或者用宏
#define CACHE_LINE_SIZE 64
#define CACHE_ALIGNED __attribute__((aligned(CACHE_LINE_SIZE)))
int buffer[CACHE_ALIGNED 1024];
我记得有一次调一个网络包处理程序,每个包结构体刚好65字节。改对齐到64字节后,虽然浪费了点空间,但吞吐量提升了12%。值不值?太值了。
4.5 伪共享问题:多核时代的隐形杀手
伪共享(False Sharing)是个坑,很多新手甚至老手都会栽在这里。说白了就是:两个CPU核心各自修改不同的变量,但这两个变量恰好在同一个Cache Line里。结果每次修改都导致Cache Line失效,两个核心互相拖后腿。
我调过一个多线程统计程序,8个线程各统计一部分数据,最后合并。原本跑完要200ms,加了锁反而更慢。一查,每个线程的统计变量紧挨着,全在同一个Cache Line里。这就是典型的伪共享。
解决办法:给每个线程的变量填充到不同Cache Line。
// 伪共享版本
int counter[8]; // 8个线程各用一个,但可能在同一个Cache Line
// 修复版本
struct __attribute__((aligned(64))) padded_counter {
int value;
char padding[60]; // 填充到64字节
};
struct padded_counter counter[8];
经验之谈:伪共享的排查方法很简单——用perf stat看cache-misses事件。如果多核场景下cache-misses异常高,十有八九是伪共享。
4.6 实战总结:我的Cache优化检查清单
做项目这么多年,我总结了一套Cache优化的检查清单,分享给你:
| 检查项 | 优化方法 | 预期收益 |
|---|---|---|
| 数据布局 | 热数据集中、冷热分离 | 命中率提升10-30% |
| 访问模式 | 顺序访问、避免跳跃 | 命中率提升15-40% |
| 软件预取 | PLD指令、合理距离 | 延迟降低20-50% |
| Cache Line对齐 | 64字节对齐、避免跨行 | 性能提升5-15% |
| 伪共享修复 | 变量填充、线程隔离 | 多核性能提升2-10倍 |
最后说一句:Cache优化不是玄学,是科学。每次改动都要用perf stat、cachegrind这些工具去验证。我曾经靠着一把perf工具,把一个视频编码器的Cache miss率从40%降到了12%,帧率直接翻倍。你想想看,同样的硬件,同样的代码,只是把数据放对了地方,效果就这么明显。
嗯,今天就聊到这儿。下一章咱们讲内存带宽优化,那个又是另一番天地了。