4、内存访问模式:顺序访问、随机访问、步长访问、空间局部性与时间局部性
聊内存带宽,绕不开一个核心问题:你的CPU到底怎么去拿数据的?
我见过太多性能优化案例,最后都卡在这一步。代码逻辑没问题,算法复杂度也低,但跑起来就是慢。为什么?说白了,内存访问模式不对,CPU在等数据。
这一节,我们就把四种访问模式掰开揉碎讲清楚。顺便把空间局部性和时间局部性这两个老朋友也请出来。
4.1 顺序访问:最友好的模式
顺序访问,就是按内存地址从小到大,一个一个读过去。像翻书一样,从第一页翻到最后一页。
为什么它快?因为硬件预取器最喜欢这种模式。CPU发现你在顺序读,它会提前把后面的数据拉到缓存里。等你读到那里,数据已经在L1缓存等着了。
我在项目中遇到过一个大数组求和的问题。原始代码是嵌套循环,内层按列访问。改成按行顺序访问后,性能提升了将近4倍。嗯,就改了一行代码。
// 顺序访问示例:按行遍历二维数组
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,两者隔了十万八千里。缓存行白拿了,预取器也懵了。
为什么会这样?很多时候是数据结构设计的问题。比如链表、哈希表、或者指针乱飞的代码。
我曾经优化过一个图算法。原始实现用邻接链表,节点在内存里散落得到处都是。每次遍历邻居都要随机跳。改成邻接矩阵后,虽然内存占用大了点,但访问变成顺序的了,整体吞吐量翻了一倍。
// 随机访问示例:链表遍历
Node* p = head;
while (p) {
process(p->data); // 每个节点地址不连续
p = p->next;
}
4.3 步长访问:介于两者之间
步长访问,也叫跨步访问。每次跳过固定大小的间隔去读数据。比如每隔4个元素取一个。
这种模式对缓存很不友好。你想想看,一个缓存行64字节,如果步长是64字节,那每次读到的数据刚好占满一个缓存行,但下一个数据又在另一个缓存行里。预取器很难判断你到底要什么。
我个人的习惯是,遇到步长访问,先问问自己:能不能改成顺序的?如果不行,能不能把步长缩小到缓存行大小以内?
// 步长访问示例:每隔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是浪费的。
// 空间局部性差: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倍多。
// 时间局部性好:循环内反复使用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 知识体系总览
下面这张图,把四种访问模式和两个局部性原理串起来了。你可以看到它们之间的关联,以及优化的大方向。
这张图你看懂了吗?从上往下看,先识别你的访问模式,再判断局部性好坏,最后选择对应的优化手段。我每次做性能分析,都是按这个框架走的。
好了,这一节的内容就到这里。四种模式你记住了吗?下次写代码前,先想想你的数据是怎么被访问的。这个习惯,能帮你省下很多优化时间。