4、内存访问模式:顺序访问、随机访问、步长访问、空间局部性与时间局部性

聊内存带宽,绕不开一个核心问题:你的CPU到底怎么去拿数据的?

我见过太多性能优化案例,最后都卡在这一步。代码逻辑没问题,算法复杂度也低,但跑起来就是慢。为什么?说白了,内存访问模式不对,CPU在等数据。

这一节,我们就把四种访问模式掰开揉碎讲清楚。顺便把空间局部性和时间局部性这两个老朋友也请出来。

4.1 顺序访问:最友好的模式

顺序访问,就是按内存地址从小到大,一个一个读过去。像翻书一样,从第一页翻到最后一页。

为什么它快?因为硬件预取器最喜欢这种模式。CPU发现你在顺序读,它会提前把后面的数据拉到缓存里。等你读到那里,数据已经在L1缓存等着了。

我在项目中遇到过一个大数组求和的问题。原始代码是嵌套循环,内层按列访问。改成按行顺序访问后,性能提升了将近4倍。嗯,就改了一行代码。

关键点:顺序访问能充分利用硬件预取和缓存行。一个缓存行64字节,顺序读一次拿一整行,几乎零浪费。
// 顺序访问示例:按行遍历二维数组
int sum = 0;
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        sum += matrix[i][j];  // 内存地址连续
    }
}

4.2 随机访问:性能杀手

随机访问,就是跳着读。地址不连续,毫无规律。你想想看,CPU刚把地址A的数据取到缓存,下一个指令要读地址B,两者隔了十万八千里。缓存行白拿了,预取器也懵了。

为什么会这样?很多时候是数据结构设计的问题。比如链表、哈希表、或者指针乱飞的代码。

我曾经优化过一个图算法。原始实现用邻接链表,节点在内存里散落得到处都是。每次遍历邻居都要随机跳。改成邻接矩阵后,虽然内存占用大了点,但访问变成顺序的了,整体吞吐量翻了一倍。

避坑指南:我曾经在哈希表遍历上栽过跟头。以为O(1)的查找就万事大吉,结果遍历所有元素时,因为桶分布散乱,缓存命中率低得可怜。后来改成开放寻址法+紧凑存储,才把性能救回来。
// 随机访问示例:链表遍历
Node* p = head;
while (p) {
    process(p->data);  // 每个节点地址不连续
    p = p->next;
}

4.3 步长访问:介于两者之间

步长访问,也叫跨步访问。每次跳过固定大小的间隔去读数据。比如每隔4个元素取一个。

这种模式对缓存很不友好。你想想看,一个缓存行64字节,如果步长是64字节,那每次读到的数据刚好占满一个缓存行,但下一个数据又在另一个缓存行里。预取器很难判断你到底要什么。

我个人的习惯是,遇到步长访问,先问问自己:能不能改成顺序的?如果不行,能不能把步长缩小到缓存行大小以内?

小技巧:如果步长是2的幂次,并且大于缓存行大小,性能会急剧下降。这时候可以考虑数据重排,或者用结构体数组(SoA)代替数组结构体(AoS)。
// 步长访问示例:每隔4个元素取一个
for (int i = 0; i < N; i += 4) {
    process(data[i]);  // 步长为4
}

4.4 空间局部性:把数据放近一点

空间局部性,简单说就是:你刚用了一个地址,它附近的地址很快也会被用到。

为什么重要?因为缓存是按行加载的。你读一个int,CPU把整个缓存行(16个int)都拉进来了。如果你接下来用到了旁边的数据,那就是缓存命中,快得很。

我见过最典型的反面教材:用数组存结构体,但每次只访问结构体里的一个字段。比如一个Person数组,有name、age、score三个字段。你只遍历age。结果每个缓存行里只有1/3的数据是你要的,剩下2/3是浪费的。

优化思路:把热点字段单独抽出来,做成并行数组。或者用结构体数组(SoA)代替数组结构体(AoS)。这样遍历时,缓存行里全是你要的数据。
// 空间局部性差:AoS
struct Person { char name[32]; int age; float score; };
Person people[1000];
for (int i = 0; i < 1000; i++) {
    process(people[i].age);  // 缓存行浪费
}

// 空间局部性好:SoA
int ages[1000];
float scores[1000];
for (int i = 0; i < 1000; i++) {
    process(ages[i]);  // 缓存行全是age
}

4.5 时间局部性:反复用同一份数据

时间局部性,就是同一份数据,短时间内被多次访问。

这个好理解。比如循环里的累加变量,或者热点数据。只要数据还在缓存里,第二次访问就快得多。

但有个坑:如果数据量太大,缓存装不下,时间局部性就失效了。你刚用完,它就被踢出缓存了。下次再用,又得从内存读。

我记得有一次优化矩阵乘法,原始实现是三重循环,内层循环每次都要重新加载数据。后来做了分块(tiling),让每个小块的数据尽量留在缓存里。性能提升了3倍多。

个人经验:判断时间局部性好不好的一个简单方法:看代码里有没有反复访问同一个变量或数组元素。如果有,而且访问间隔很短,那就是好的时间局部性。如果每次访问都隔了十万八千里,那基本就是缓存miss。
// 时间局部性好:循环内反复使用sum
int sum = 0;
for (int i = 0; i < N; i++) {
    sum += data[i];  // sum一直在寄存器/缓存里
}

// 时间局部性差:每次访问不同的大块数据
for (int i = 0; i < N; i++) {
    process(big_array[i]);  // big_array太大,缓存装不下
}

4.6 四种模式对比

我把这四种模式放在一起对比一下,方便你快速参考。

访问模式 缓存友好度 预取效果 典型场景 优化方向
顺序访问 ★★★★★ 极好 数组遍历、矩阵按行访问 保持连续,利用缓存行
随机访问 ★☆☆☆☆ 极差 链表、哈希表、指针追逐 改用数组、预计算、数据重排
步长访问 ★★☆☆☆ 一般 矩阵转置、图像处理 缩小步长、SoA、分块
时间局部性 ★★★★☆ 依赖场景 循环累加、热点数据 分块、数据复用、减少数据量

4.7 知识体系总览

下面这张图,把四种访问模式和两个局部性原理串起来了。你可以看到它们之间的关联,以及优化的大方向。

内存访问模式与局部性原理 内存访问模式 顺序访问 随机访问 步长访问 时间局部性 空间局部性 优化方向:数据重排 → 分块 → SoA → 预取 四种模式 + 两个局部性 = 内存带宽优化的核心框架

这张图你看懂了吗?从上往下看,先识别你的访问模式,再判断局部性好坏,最后选择对应的优化手段。我每次做性能分析,都是按这个框架走的。

一句话总结:顺序访问 + 空间局部性 + 时间局部性 = 内存带宽的黄金组合。随机访问和大步长是敌人,能避则避,不能避就重排数据。

好了,这一节的内容就到这里。四种模式你记住了吗?下次写代码前,先想想你的数据是怎么被访问的。这个习惯,能帮你省下很多优化时间。

可可精品资料库