4、内存访问模式:数据搬运的代价

好,咱们来聊聊内存访问。这个话题,我敢说,是决定算子库性能的“隐形杀手”。

很多人觉得,只要计算够快,芯片性能就上去了。其实不然。你想想看,数据从内存搬到计算单元,这个“搬运”过程,往往比计算本身还耗时。我见过太多项目,计算单元闲着等数据,白白浪费了算力。

连续内存访问 vs 随机访问

先看两个基本概念:连续访问和随机访问。

连续内存访问,就是按顺序读取数据。比如遍历一个数组,从地址0读到地址N。这种模式对硬件非常友好。为什么?因为现代内存系统有“预取”机制。你读地址0,硬件会猜你接下来要读地址1、2、3,提前把它们搬到缓存里。等你真要用的时候,数据已经在门口等着了。

随机访问,就是跳着读。比如你有一个链表,每个节点指向下一个,地址完全不连续。每次读取,硬件都猜不到下一个地址在哪,只能老老实实去内存里取。这一来一回,延迟就上去了。

我举个例子,你感受一下差距:

// 连续访问 - 快
for (int i = 0; i < N; i++) {
    sum += array[i];
}

// 随机访问 - 慢
for (int i = 0; i < N; i++) {
    int idx = random_indices[i];
    sum += array[idx];
}

同样的数据量,连续访问可能只要几毫秒,随机访问可能要几百毫秒。差了两个数量级,一点都不夸张。

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

为什么随机访问这么慢?

这里涉及一个关键概念:缓存行(Cache Line)

CPU从内存读数据,不是一次读一个字节,而是一次读一整块。这块的大小通常是64字节,也就是一个缓存行。你读一个int(4字节),硬件会把相邻的64字节都搬过来。

连续访问时,这64字节里大部分数据你都会用到,利用率很高。随机访问时,你可能只用到了其中的4字节,剩下的60字节就浪费了。更糟的是,下次随机访问又得重新搬一个缓存行,之前的缓存行被踢出去,白白浪费了带宽。

我记得有一次优化一个矩阵运算的算子,发现性能始终上不去。一分析,原来是数据在内存里是按列存储的,但算法是按行访问的。每次读一个元素,都要跳过一整行数据,缓存行利用率极低。改成按行存储后,性能直接提升了5倍。

小技巧:设计数据结构时,尽量把要一起访问的数据放在相邻的内存地址上。这就是所谓的“空间局部性”。

算子库如何优化访存?

好的算子库,说白了就是在“搬运数据”这件事上做足了功夫。我总结了几种常见手段:

1. 数据重排(Data Layout Transformation)

把不连续的数据重新排列成连续的。比如矩阵乘法,如果输入矩阵是按列存储的,算子库会在内部先把它转成按行存储,然后再计算。虽然多了一次拷贝,但后续的计算速度会快很多。

// 原始数据:按列存储
// col0: a[0], a[3], a[6]
// col1: a[1], a[4], a[7]
// col2: a[2], a[5], a[8]

// 重排后:按行存储
// row0: a[0], a[1], a[2]
// row1: a[3], a[4], a[5]
// row2: a[6], a[7], a[8]

2. 分块(Tiling)

把大矩阵切成小块,让每一块都能塞进缓存里。这样,计算一块的时候,数据都在缓存里,不用反复去内存里取。我做过一个图像滤波的算子,原始实现是整张图一起处理,缓存命中率很低。改成16x16的分块后,性能提升了3倍。

注意:分块大小不是越大越好。块太大,缓存装不下,反而会触发缓存替换,性能下降。块太小,循环开销增加,也得不偿失。需要根据目标芯片的缓存大小来调优。

3. 预取(Prefetching)

在计算当前数据的同时,提前把下一批数据搬到缓存里。这就像流水线作业,让计算和访存重叠起来。很多现代芯片都有硬件预取器,但软件预取可以做得更精准。

// 软件预取示例
for (int i = 0; i < N; i++) {
    __builtin_prefetch(&data[i + 8]);  // 提前预取8个元素之后的数据
    process(data[i]);
}

4. 合并访问(Coalesced Access)

在GPU上特别重要。多个线程同时访问内存时,如果它们的地址是连续的,硬件可以把这些访问合并成一次大的内存事务。否则,每个线程都要单独发一次请求,效率极低。

访问模式 带宽利用率 典型场景
连续访问 80%-90% 数组遍历、矩阵按行访问
步长访问 30%-60% 矩阵按列访问、图像下采样
随机访问 10%-20% 链表遍历、哈希表查找

避坑指南

我曾经踩过一个坑:优化一个稀疏矩阵乘法算子。一开始我盯着计算逻辑优化,折腾了两周,性能只提升了10%。后来一分析,发现90%的时间都花在访存上。稀疏矩阵的非零元素分布很随机,每次访问都要跳转,缓存命中率极低。

后来怎么解决的?我把稀疏矩阵的存储格式从COO(坐标格式)改成了CSR(压缩行格式)。CSR把每行的非零元素连续存储,访问时至少在一行内是连续的。就这么一改,性能提升了4倍。

所以,我的建议是:优化访存,永远比优化计算更值得投入精力。计算再快,数据到不了,也是白搭。

总结一下:

  • 连续访问是王道,随机访问是魔鬼
  • 缓存行是理解访存性能的关键
  • 好的算子库会通过数据重排、分块、预取等手段优化访存
  • 优化访存往往比优化计算收益更大
内存访问模式优化核心逻辑 随机访问 缓存行利用率低 算子库优化 数据重排 分块 预取 连续访问 带宽利用率80%+ 核心目标:将随机访问转化为连续访问 数据重排:改变存储布局 分块:提高缓存命中率 预取:隐藏访存延迟 最终效果:计算与访存重叠,性能最大化

嗯,关于内存访问模式,今天就聊到这儿。记住一句话:数据怎么搬,决定了你的芯片能跑多快。下次写算子的时候,先想想你的数据在内存里是怎么放的,再动手写代码。

专注资料整理