4、内存访问模式优化:连续访问 vs 随机访问、步长访问的影响、预取指令的使用
内存带宽不够用,很多时候不是硬件不行,而是你的访问模式太「野」了。
我见过不少团队,代码逻辑写得没毛病,但性能就是上不去。一查内存带宽利用率,惨不忍睹——只有理论峰值的 5% 到 10%。问题出在哪?访问模式。
这一节,我们就来聊聊怎么通过优化内存访问模式,把带宽真正用起来。
4.1 连续访问 vs 随机访问:天壤之别
先问一个问题:CPU 从内存读一个字节,和读连续的一百个字节,哪个更快?
答案是:读一百个连续字节,可能只比读一个字节慢一点点。
为什么会这样?因为内存的读取机制是按「行」来的。DRAM 芯片内部有个行缓冲区,一次激活就能读出整行数据(通常是 1KB 或 2KB)。你访问第一个地址时,整行数据都被拉到了缓冲区。接下来如果访问同一行的相邻地址,直接从缓冲区拿,延迟极低。
这就是连续访问的优势——空间局部性。
反过来,随机访问是什么情况?每次访问都命中不同的行,每次都要重新激活、预充电、再激活。行切换的开销,比连续读高出几十倍。
我在项目中遇到过这样一个案例:一个图像处理算法,需要遍历像素邻域。原始代码用嵌套循环按列访问,结果内存带宽利用率不到 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% 以上。
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%。
另外,不同平台有不同的预取指令。x86 上有 _mm_prefetch,ARM 上有 __pld。但思路是一样的——提前告诉硬件你要什么。
4.4 本章知识体系
下面这张图总结了内存访问模式优化的核心逻辑:
说白了,内存访问模式优化的核心就两件事:让数据在缓存中待得更久,让数据提前到达缓存。连续访问和步长优化解决的是前者,预取解决的是后者。
你想想看,如果你的代码能把这两件事都做好,内存带宽利用率轻松翻倍。我见过太多项目,花大价钱买高端硬件,结果代码写得稀烂,带宽利用率不到 20%。优化访问模式,零成本,收益巨大。
嗯,这一节就到这里。记住:写代码时多想想你的数据是怎么在内存里「走」的,别让它瞎跑。