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%,帧率直接翻倍。你想想看,同样的硬件,同样的代码,只是把数据放对了地方,效果就这么明显。

嗯,今天就聊到这儿。下一章咱们讲内存带宽优化,那个又是另一番天地了。