2、缓存行与局部性原理:缓存行大小、空间局部性与时间局部性、缓存命中与缺失

2.1 缓存行:CPU缓存的最小工作单元

聊缓存,第一个绕不开的概念就是缓存行(Cache Line)。

我刚开始做性能优化时,有个误区——以为CPU从内存读数据是按字节来的。后来被一个性能问题狠狠教育了一顿。当时一个循环处理数组,怎么优化都跑不满预期,最后用perf一看,缓存缺失率高得吓人。查了资料才明白:CPU从来不会只读一个字节,它一次就读一整条缓存行。

缓存行的大小,在x86架构下通常是64字节。ARM架构也类似,但有些嵌入式核可能是32字节。说白了,这就是CPU和内存之间数据交换的“基本单位”。你读一个int,4个字节,但CPU会连它附近的60个字节一起拉进缓存。

核心要点:缓存行是缓存一致性协议(如MESI)跟踪的最小粒度。两个核修改同一个缓存行里的不同变量,也会引发一致性流量——这就是所谓的“伪共享”(False Sharing)。

2.2 空间局部性:邻居的数据更可能被用到

空间局部性,说白了就是“如果你用了一个地址,它附近的地址很快也会被用到”。

为什么会这样?因为程序的数据在内存里往往是连续存放的。数组遍历就是最典型的例子。你访问a[0],很快就要访问a[1]、a[2]……

我举个例子你就明白了:

// 空间局部性好 —— 按行遍历
for (int i = 0; i < N; i++) {
    for (int j = 0; j < N; j++) {
        sum += matrix[i][j];
    }
}

// 空间局部性差 —— 按列遍历
for (int j = 0; j < N; j++) {
    for (int i = 0; i < N; i++) {
        sum += matrix[i][j];
    }
}

第一个版本,每次读入一个缓存行(64字节),就能连续处理16个int(假设int是4字节)。第二个版本呢?每次跳着访问,缓存行里只用到1个int,剩下的15个全浪费了。性能差距轻松达到10倍以上。

实战建议:我在优化一个图像处理算法时,就是把按列处理改成按块处理,让每次缓存行加载的数据都能被充分利用。改完之后,处理时间从120ms降到了35ms。

2.3 时间局部性:刚用过的数据很快会再用

时间局部性更直观——你刚访问过的数据,短时间内很可能再次被访问。

想想看,循环里的变量、频繁调用的函数代码、栈上的局部变量……这些都是时间局部性的典型例子。CPU的L1缓存之所以能做到1-2个时钟周期的访问延迟,就是靠这个原理。

我遇到过一个问题:一个服务端的热点函数,每次调用都要从内存加载一堆配置数据。明明这些配置在短时间内不会变,但代码就是没做缓存。后来我在函数入口加了个静态局部变量做缓存,QPS直接翻了一倍。

关键认知:时间局部性告诉我们——重复利用已经加载到缓存的数据,比重新从内存加载要快两个数量级。L1缓存命中延迟约1ns,而内存访问延迟约100ns。

2.4 缓存命中与缺失:性能的分水岭

缓存命中(Cache Hit)和缓存缺失(Cache Miss),是衡量程序内存访问效率的核心指标。

命中,就是你要的数据已经在缓存里了。缺失,就是没在,得去下一级缓存甚至内存里拿。

缓存缺失主要有三种类型:

缺失类型 原因 典型场景
强制缺失(Compulsory Miss) 第一次访问数据,缓存里肯定没有 程序启动、首次加载文件
容量缺失(Capacity Miss) 工作集太大,缓存装不下 处理超大数组、数据库全表扫描
冲突缺失(Conflict Miss) 多个地址映射到同一个缓存行位置 步长为2的幂次的大数组访问

嗯,这里要注意——冲突缺失是最容易被忽视的。我曾经调试过一个网络包处理程序,性能忽高忽低。后来发现是两个热数据结构恰好映射到了同一个缓存组,互相踢来踢去。解决办法就是在结构体之间填充一些字节,让它们错开。

避坑指南:我曾经在优化一个多线程程序时,把两个独立的计数器放在同一个结构体里。结果两个线程各写各的计数器,却因为共享同一个缓存行,触发了大量的MESI协议消息。性能比单线程还差。这就是典型的伪共享问题。

2.5 如何利用局部性原理写出缓存友好的代码

说了这么多理论,来点实际的。我个人习惯在写代码时遵循几条原则:

  1. 数据尽量连续存放——用数组代替链表,用结构体数组代替数组结构体。
  2. 按访问顺序布局内存——热点字段放在结构体开头,冷热数据分离。
  3. 循环内避免跳跃访问——步长尽量小,最好就是1。
  4. 分块处理大数据集——让每个数据块能完整塞进L1/L2缓存。
  5. 对齐缓存行边界——关键数据结构按64字节对齐,避免跨行访问。

举个例子,一个网络包处理的结构体,我通常会这样设计:

// 缓存友好的结构体布局
struct __attribute__((aligned(64))) packet {
    // 热点字段:每次处理都会访问
    uint32_t len;
    uint32_t flags;
    uint64_t timestamp;
    
    // 填充到缓存行边界
    char padding[48];
    
    // 冷数据:只在异常时访问
    char error_msg[64];
};

这样设计,热点数据都在同一个缓存行里,处理时一次加载就够了。冷数据被隔离到另一个缓存行,不会污染热点。

小技巧:用Linux的perf工具可以轻松看到程序的缓存命中率。命令是perf stat -e cache-references,cache-misses ./your_program。如果缓存缺失率超过5%,就该考虑优化了。

最后说一句——理解缓存行和局部性原理,是写出高性能代码的基础。你想想看,现代CPU的主频已经很难再大幅提升了,性能增长主要靠缓存和多核。如果你写的代码总是触发缓存缺失,那再快的CPU也救不了你。

下一章,我们会深入MESI协议,看看多核之间到底是怎么维护缓存一致性的。到时候你会发现,伪共享只是冰山一角。