4、数据复用原理:时间局部性与空间局部性,如何通过复用减少显存访问次数
说到显存优化,很多人第一反应就是「把显存省着点用」。但说实话,省着用只是治标。真正的高手,想的是怎么少去访问显存。
为什么?因为访问显存这件事,本身就很贵。一次显存读取,能耗是寄存器访问的几百倍。你想想看,如果能把数据留在片上,反复用,那不就省下了大把带宽和功耗?
这就是我们今天要聊的核心——数据复用。它的理论基础,就是计算机体系结构里那两个经典概念:时间局部性和空间局部性。
4.1 时间局部性:用过的数据,别急着扔
时间局部性,说白了就是「刚用过的数据,很可能马上还要再用」。这个道理在推理场景里特别明显。
举个例子,Transformer 里的 Softmax 计算。你需要对一整行 logits 做归一化,每个元素都要减去最大值、算指数、求和、再除。这一套操作下来,同一行数据要被反复读取好几遍。
我刚开始做推理优化时,就踩过这个坑。当时 naive 地按公式一步步写,结果发现显存带宽被吃满了。后来一分析,发现同一行数据从显存里读了四五遍。你说冤不冤?
怎么利用时间局部性?核心就一招:把数据搬到片上缓存,反复用。
关键思路: 对于会被多次访问的数据,尽量只从显存加载一次,后续操作都在片上完成。
具体到实现,比如在做 LayerNorm 时,你可以先把整行数据加载到寄存器或共享内存里,然后依次计算均值、方差、归一化。这样,显存访问次数就从 O(N×操作数) 降到了 O(N)。
// 不好的做法:每次计算都读显存
for each operation:
load row from HBM // 读了好几次!
compute...
// 好的做法:一次加载,反复使用
load row from HBM to SRAM // 只读一次
for each operation:
compute on SRAM data // 片上操作,零显存开销
嗯,这里要注意:片上缓存是有限的。你不能把所有数据都塞进去。所以时间局部性的应用,需要你仔细分析数据的「复用距离」——两次访问之间隔了多少条指令。隔得太远,数据就被挤出去了。
4.2 空间局部性:邻居的数据,顺带捎上
空间局部性讲的是:如果你访问了某个地址,那么它附近的地址,很可能很快也会被访问到。
这个特性在矩阵乘法里体现得淋漓尽致。你想想看,计算 C[i][j] 时,你需要 A 的第 i 行和 B 的第 j 列。而计算 C[i][j+1] 时,A 的第 i 行还在,B 的第 j+1 列就在 B 的第 j 列旁边。
所以,当你从显存里加载 B 的一列数据时,完全可以把它相邻的几个元素也一起捎上。这就是所谓的「空间复用」。
我在优化一个卷积算子时,就利用了这个特性。卷积核在特征图上滑动,每次只移动一个像素。如果你只加载当前窗口的数据,那下一个窗口又要重新加载。但如果一次加载一个 tile(比如 4×4 的块),那相邻窗口的数据就自动复用了。
我的习惯: 设计算子时,先画一个「数据访问模式图」。看看哪些数据是连续访问的,然后按 cache line 对齐去加载。这样能最大化利用硬件的 burst 传输特性。
4.3 两种局部性的协同:tiling 策略
时间局部性和空间局部性不是孤立的。在实际工程中,它们经常一起出现。最典型的例子就是 tiling(分块)。
Tiling 的核心思想是:把大矩阵切成小块,让每个小块在片上缓存里完成所有计算,再写回显存。这样,每个小块的数据只被加载一次,但被反复使用了多次。
我画了一张图,帮你理解这个过程:
你看,左边没有 tiling 时,每次计算都要从显存加载数据。右边用了 tiling 后,每个 tile 只加载一次,然后在片上反复计算。这就是时间局部性和空间局部性的完美协同。
4.4 实际案例:矩阵乘法的 tiling 优化
拿矩阵乘法 C = A × B 来说。假设 A 是 M×K,B 是 K×N,C 是 M×N。
如果不做 tiling,计算 C[i][j] 时,你需要从显存里读 A 的第 i 行和 B 的第 j 列。计算 C[i][j+1] 时,A 的第 i 行还能复用(时间局部性),但 B 的第 j+1 列又要重新加载。
如果做了 tiling,比如把 A 按行切成大小为 T 的块,把 B 按列切成大小为 T 的块。那么计算一个 tile 时,只需要加载 A 的一个行块和 B 的一个列块到片上。这个 tile 内部的所有计算,都在片上完成。
复用率计算: 假设 tile 大小为 T,那么每个数据被复用的次数大约是 T 次。T 越大,复用率越高,但需要的片上缓存也越大。这是一个 trade-off。
我曾经优化过一个 4096×4096 的矩阵乘法。把 tile 大小从 32 调到 128,显存访问量直接降了 4 倍,吞吐提升了将近 3 倍。嗯,当然不是线性关系,因为还有别的瓶颈,但效果确实显著。
4.5 避坑指南:复用不是万能的
说了这么多复用的好处,我也得泼点冷水。复用不是银弹,用不好反而会翻车。
我曾经犯过的错: 在一个卷积算子里,我为了追求极致复用,把 tile 设得特别大。结果片上缓存装不下,发生了 cache thrashing。数据刚加载进来就被挤出去,性能反而比不做 tiling 还差。
所以,应用复用策略时,有几点你要特别注意:
- 缓存容量是硬约束:tile 大小不能超过片上缓存。留点余量给其他数据。
- 复用距离要短:两次访问之间不要隔太多指令,否则数据会被 evict。
- 考虑带宽利用率:有时候小 tile 反而更好,因为可以更充分地利用内存带宽。
- 不要过度优化:如果计算本身已经是瓶颈,复用带来的收益有限。
说白了,复用策略的核心就是一句话:用片上空间换显存带宽。空间是有限的,带宽是宝贵的。你要做的,就是在有限的片上空间里,最大化数据的复用次数。
我个人习惯在做算子设计时,先画一个「数据流图」,标出每个数据被访问的次数和位置。然后根据片上缓存大小,反推最优的 tile 大小。这样既不会浪费带宽,也不会把缓存撑爆。
好了,数据复用的原理就聊到这里。记住两个局部性,用好 tiling 策略,你的算子吞吐就能上一个台阶。