4、内存访问模式优化:连续访问 vs 随机访问、步长访问的影响、预取指令的使用

内存带宽不够用,很多时候不是硬件不行,而是你的访问模式太「野」了。

我见过不少团队,代码逻辑写得没毛病,但性能就是上不去。一查内存带宽利用率,惨不忍睹——只有理论峰值的 5% 到 10%。问题出在哪?访问模式。

这一节,我们就来聊聊怎么通过优化内存访问模式,把带宽真正用起来。

4.1 连续访问 vs 随机访问:天壤之别

先问一个问题:CPU 从内存读一个字节,和读连续的一百个字节,哪个更快?

答案是:读一百个连续字节,可能只比读一个字节慢一点点。

为什么会这样?因为内存的读取机制是按「行」来的。DRAM 芯片内部有个行缓冲区,一次激活就能读出整行数据(通常是 1KB 或 2KB)。你访问第一个地址时,整行数据都被拉到了缓冲区。接下来如果访问同一行的相邻地址,直接从缓冲区拿,延迟极低。

这就是连续访问的优势——空间局部性。

反过来,随机访问是什么情况?每次访问都命中不同的行,每次都要重新激活、预充电、再激活。行切换的开销,比连续读高出几十倍。

核心结论: 连续访问的带宽利用率可达 80%-90%,随机访问往往只有 5%-15%。

我在项目中遇到过这样一个案例:一个图像处理算法,需要遍历像素邻域。原始代码用嵌套循环按列访问,结果内存带宽利用率不到 10%。改成按行访问后,性能直接翻了 6 倍。改动就几行代码,效果天差地别。

所以,写代码时养成一个习惯:优先保证内层循环的访问是连续的。比如遍历二维数组,把行索引放在内层循环。

// ❌ 随机访问(按列遍历)
for (int j = 0; j < COLS; j++) {
    for (int i = 0; i < ROWS; i++) {
        sum += matrix[i][j];  // 每次跳一行,不连续
    }
}

// ✅ 连续访问(按行遍历)
for (int i = 0; i < ROWS; i++) {
    for (int j = 0; j < COLS; j++) {
        sum += matrix[i][j];  // 连续地址访问
    }
}

嗯,这里要注意:编译器有时候会帮你做循环交换优化,但不是所有情况都靠谱。手动写对,最保险。

4.2 步长访问的影响:别跳着走

连续访问是最理想的。但现实中,我们经常需要「跳着」访问数据,比如每隔几个元素取一个。这就是步长访问。

步长对性能的影响有多大?我直接给数据:

步长 带宽利用率(典型值) 说明
1(连续) 85% - 95% 理想情况,硬件预取也能完美工作
2 50% - 60% 浪费一半的缓存行数据
4 25% - 35% 大部分数据被浪费
8 或更大 < 15% 基本等于随机访问

为什么步长大了带宽就掉?因为每次读取一个缓存行(通常 64 字节),你只用到了其中的一个元素,剩下的都浪费了。步长越大,浪费越严重。

我曾经优化过一个矩阵转置算法。原始实现就是步长访问——读一行写一列,结果带宽利用率只有 20%。后来改用分块转置,把数据切成小块,保证块内连续访问,带宽利用率提到了 70% 以上。

避坑指南: 如果你必须用步长访问,尽量让步长等于缓存行大小(64 字节)的整数倍。这样至少能保证每次读取的缓存行被完全利用。但说实话,最好的办法还是重新设计数据结构,避免步长访问。

4.3 预取指令的使用:让数据提前到位

连续访问虽然好,但有些场景下,数据就是没法连续。比如遍历链表、处理稀疏矩阵。这时候怎么办?

答案是用预取指令。

预取指令的作用是告诉 CPU:「我马上要访问这个地址,你先帮我把数据拉到缓存里。」这样当真正访问时,数据已经在缓存中了,不会产生缓存缺失的等待。

我个人习惯在两种场景下使用预取:

  • 链表遍历:提前预取下一个节点的数据
  • 多路数据合并:同时处理多个数据流时,预取下一个流的数据

看一个链表遍历的例子:

// 普通链表遍历
while (node) {
    process(node->data);
    node = node->next;  // 每次都要等内存
}

// 带预取的链表遍历
while (node) {
    // 预取下一个节点的数据
    __builtin_prefetch(node->next, 0, 3);
    process(node->data);
    node = node->next;
}

这里用了 GCC 的 __builtin_prefetch。三个参数分别是:地址、读写模式(0 表示读,1 表示写)、时间局部性(0-3,3 表示希望数据尽量留在缓存)。

我曾经在一个网络包处理项目中用过这个技巧。原始代码处理每个包都要等内存,吞吐量上不去。加上预取后,延迟被隐藏了,吞吐量提升了 40%。

注意: 预取不是万能的。预取本身也有开销,而且预取太早会导致数据被踢出缓存,预取太晚又来不及。一般来说,预取距离控制在 5-10 次循环迭代比较合适。具体数值需要根据你的硬件和访问模式来调。

另外,不同平台有不同的预取指令。x86 上有 _mm_prefetch,ARM 上有 __pld。但思路是一样的——提前告诉硬件你要什么。

4.4 本章知识体系

下面这张图总结了内存访问模式优化的核心逻辑:

内存访问模式优化核心逻辑 内存访问模式优化 连续 vs 随机访问 空间局部性原理 行缓冲区命中率 内层循环连续化 步长访问的影响 缓存行利用率 步长越大带宽越低 分块转置优化 预取指令的使用 隐藏内存延迟 链表预取优化 预取距离调优 目标:最大化缓存行利用率,隐藏内存访问延迟

说白了,内存访问模式优化的核心就两件事:让数据在缓存中待得更久,让数据提前到达缓存。连续访问和步长优化解决的是前者,预取解决的是后者。

你想想看,如果你的代码能把这两件事都做好,内存带宽利用率轻松翻倍。我见过太多项目,花大价钱买高端硬件,结果代码写得稀烂,带宽利用率不到 20%。优化访问模式,零成本,收益巨大。

嗯,这一节就到这里。记住:写代码时多想想你的数据是怎么在内存里「走」的,别让它瞎跑。