为什么需要融合:冯·诺依曼瓶颈与内存墙问题

好,咱们直接切入正题。上一节我们聊了算子融合是什么,这一节我们来聊聊——为什么非得融合不可?

说白了,答案就一句话:访存开销远大于计算开销。

你可能觉得我在说废话。但我在实际项目中见过太多人,写出来的模型跑得慢,第一反应是“计算量太大了”,然后拼命去优化算子本身。结果呢?优化了半天,性能提升不到10%。

嗯,这里要注意:很多时候,瓶颈根本不在计算,而在数据搬运。

冯·诺依曼瓶颈:CPU/GPU 在等数据

先回顾一下经典架构。冯·诺依曼结构里,计算单元和存储单元是分开的。CPU/GPU 要算一个数,得先从内存里把数据取出来,算完再存回去。

这个过程,就像你去图书馆借书——

  • 你走到书架(内存)取书,花 10 分钟
  • 你坐下来看书(计算),花 1 分钟
  • 你走回去还书,再花 10 分钟

你看,大部分时间都花在“走路”上了。这就是冯·诺依曼瓶颈。

我刚开始做 AI 推理优化时,总觉得 GPU 算力那么强,怎么可能慢?后来用 profiler 一跑,发现很多 kernel 的 计算利用率不到 5%。剩下的 95% 时间,都在等数据从显存搬过来。

核心矛盾:计算单元越来越快,但内存带宽的增长速度远远跟不上。这就是所谓的“内存墙”。

内存墙:算力与带宽的剪刀差

我们来看一组数据,你就明白了:

年代 计算峰值(FLOPS) 内存带宽(GB/s) 计算/带宽比
2010 1 TFLOPS 100 10:1
2020 100 TFLOPS 1000 100:1
2024 500 TFLOPS 2000 250:1

看到了吗?计算能力每 2 年翻一番,但内存带宽每 5 年才翻一番。这个差距越来越大。

换句话说,你买的算力,大部分都浪费在“等数据”上了。

我曾经优化过一个 Transformer 模型,原始实现里每个算子都是独立的 kernel launch。跑一次推理,光 kernel launch 的开销就占了 30%。更别提每次都要从显存读写中间结果——那才是真正的大头。

访存开销到底有多大?

我们来算一笔账。假设你要做两个向量的加法:

// 向量加法:C = A + B
for (int i = 0; i < N; i++) {
    C[i] = A[i] + B[i];
}

这个操作的计算量:N 次加法。

访存量:读 A(N 个元素)+ 读 B(N 个元素)+ 写 C(N 个元素)= 3N 次访存。

在 GPU 上,一次浮点加法大概 0.1 ns,但一次全局内存访问大概 100 ns。差了 1000 倍。

我的经验:在优化算子时,我习惯先看“计算访存比”(Compute-to-Memory Ratio)。如果这个比值小于 1,那基本可以断定:瓶颈在访存,不在计算。

融合如何打破瓶颈?

好,现在问题清楚了:数据搬来搬去太费时间。

那融合的思路就很直接了:把多个操作合并成一个,减少中间结果的读写。

举个例子,一个典型的 Conv + BatchNorm + ReLU 流水线:

// 不融合的做法
float* conv_out = conv(input, weight);      // 写 conv_out 到显存
float* bn_out = batch_norm(conv_out);       // 读 conv_out,写 bn_out 到显存
float* relu_out = relu(bn_out);             // 读 bn_out,写 relu_out 到显存

这里,conv_out 和 bn_out 都是中间结果。每次都要写一次、读一次。如果融合成一个 kernel:

// 融合的做法
float* relu_out = fused_conv_bn_relu(input, weight);  // 中间结果留在寄存器里

中间结果根本不用写回显存,直接在寄存器或共享内存里传递。访存量直接砍掉 2/3。

注意:融合不是万能的。如果算子之间依赖复杂,或者中间结果太大放不进寄存器,强行融合反而会降低并行度。我曾经踩过这个坑——融合后寄存器压力过大,导致 occupancy 下降,性能反而变差了。

一张图看懂:融合前后的访存对比

下面这张 SVG 图,展示了融合前后数据流动的区别:

融合前后访存路径对比 不融合:多次访存 全局内存(显存) 读 input Conv 写 conv_out 全局内存 读 conv_out BatchNorm 写 bn_out 融合后:一次访存 全局内存(显存) 读 input Fused Conv+BN+ReLU 中间结果在寄存器中传递 写 relu_out 全局内存

左边是不融合的情况:Conv 算完写回显存,BN 再读出来,算完再写回去,ReLU 再读一遍。来回折腾了 3 次访存。

右边是融合后的情况:只从显存读一次 input,中间结果全部留在寄存器里,最后只写一次最终结果。访存次数从 3 次降到 1 次。

实际收益有多大?

我拿一个真实案例来说。之前优化一个 ResNet-50 的推理,原始实现里 Conv+BN+ReLU 是分开的 kernel。融合之后:

  • 访存量减少:约 60%
  • 端到端延迟:从 12ms 降到 7ms
  • 吞吐量:提升了 70%

你看,没改任何数学逻辑,只是把数据搬运的次数减少了,性能就上去了。

一句话总结:融合的本质,就是用计算换访存。把多次小规模的数据搬运,合并成一次大规模的计算。在内存墙面前,这是最有效的优化手段之一。

好了,这一节我们搞清楚了“为什么需要融合”。下一节,我们来聊聊融合的具体分类——哪些算子适合融合,哪些不适合。到时候我会分享一些我踩过的坑,保证让你少走弯路。

可可精品资料库