1. 算子融合概述
大家好,我是你们的老朋友。今天咱们聊聊算子融合——这个在深度学习编译器中绕不开的话题。
说实话,我第一次接触算子融合时,心里想的是:「不就是把几个算子拼在一起吗?有啥难的?」后来被现实狠狠教育了一顿。嗯,咱们慢慢聊。
1.1 什么是算子融合
算子融合,说白了就是把多个连续的计算操作合并成一个。
举个例子。你写了一段推理代码:
// 原始计算图:三个独立的算子
y = conv2d(x, weight) // 卷积
z = batch_norm(y) // 批归一化
w = relu(z) // 激活函数
这三个算子,在传统框架里是逐个执行的。卷积算完,把中间结果写回显存;批归一化读出来,算完再写回去;ReLU 再读再写。你想想看,这中间有多少次显存读写是浪费的?
算子融合后,就变成了一个「超级算子」:
// 融合后的算子:Conv+BatchNorm+ReLU
w = fused_conv_bn_relu(x, weight, bn_params)
中间结果留在寄存器或共享内存里,不写回显存。速度能快多少?我在项目中遇到过,2~5倍的加速比比皆是。
核心思想:减少中间结果的显存读写,把多个小算子「捏」成一个大算子。
1.2 为什么需要算子融合
你可能要问:现在的 GPU 算力这么强,显存带宽也够大,为啥还要费劲搞融合?
原因有三点,我一个个说。
- 访存瓶颈:深度学习算子的计算密度通常不高。很多算子是访存受限的,计算单元在等数据。融合能减少数据搬运,直接缓解这个瓶颈。
- Kernel Launch 开销:每个算子对应一个 GPU Kernel。启动 Kernel 是有开销的——设置参数、分配线程块、同步等等。融合后 Kernel 数量减少,这部分开销就省下来了。
- 优化空间更大:融合后的算子,编译器可以做更多全局优化。比如寄存器分配、指令重排、共享内存复用。单个小算子反而不好优化。
避坑指南:我曾经在一个项目中,把 12 个小算子强行融合成一个。结果显存占用暴增,反而变慢了。为什么?因为融合后的算子需要更多寄存器,导致寄存器溢出。所以融合不是越多越好,要适可而止。
1.3 融合的分类
算子融合主要分两类:横向融合和纵向融合。我习惯用「兄弟融合」和「父子融合」来记,更形象。
1.3.1 横向融合(兄弟融合)
横向融合,指的是同一层级的、没有数据依赖的算子合并在一起。
比如下面这个场景:
// 横向融合前:两个独立的卷积
conv_a = conv2d(input, weight_a)
conv_b = conv2d(input, weight_b)
// 横向融合后:一个卷积搞定
conv_ab = fused_conv2d_dual(input, weight_a, weight_b)
这种融合的好处是:共享输入数据的读取。原来要读两次 input,现在只读一次。我在做 MobileNet 优化时,就用横向融合把 depthwise 卷积和 pointwise 卷积合并,推理速度提升了 30%。
| 融合类型 | 数据依赖 | 典型场景 | 加速效果 |
|---|---|---|---|
| 横向融合 | 无依赖(并行) | 多分支卷积、多尺度特征 | 中等(共享访存) |
| 纵向融合 | 有依赖(串行) | Conv+BN+ReLU、MatMul+Add | 较高(消除中间结果) |
1.3.2 纵向融合(父子融合)
纵向融合,指的是有数据依赖的、前后串联的算子合并在一起。
这是最常见的融合方式。比如:
// 纵向融合前
h = matmul(x, w1) // 矩阵乘法
y = add(h, bias) // 加偏置
z = relu(y) // 激活
// 纵向融合后
z = fused_matmul_add_relu(x, w1, bias)
纵向融合的核心收益是消除中间结果的显存读写。h 和 y 这两个中间张量,原本都要写回显存。融合后,它们只存在于寄存器中。
注意:纵向融合对计算精度有影响。比如 Conv+BN 融合时,BN 的均值和方差需要提前「吸收」到卷积权重里。如果 BN 是在训练过程中动态更新的,融合后的权重需要重新计算。我曾经因为忘记重新计算权重,导致模型精度掉了 2 个点,排查了一整天。
1.4 知识体系总览
下面这张图,是我自己整理的算子融合知识体系。你可以把它当作本章的「地图」:
这张图把本章的核心内容串起来了。从上到下,先理解「是什么」,再明白「为什么」,最后掌握「怎么分」。后面的章节,我们会围绕这张图展开。
1.5 小结
算子融合,说白了就是用空间换时间——用更多的寄存器/共享内存,换取更少的显存读写。横向融合适合并行分支,纵向融合适合串行流水。
我个人习惯是:先做纵向融合,再做横向融合。因为纵向融合的收益更直接,而且不容易出错。横向融合要小心寄存器压力,搞不好会适得其反。
一个小技巧:如果你不确定某个融合是否有效,可以先用 nvprof 或 nsys 看看算子的访存带宽利用率。如果利用率低于 50%,融合大概率有收益。如果已经接近 80% 了,融合的意义就不大了。
好了,这一章就到这里。下一章我们聊聊手工算子融合——那些年我们手写的融合 Kernel,踩过的坑和收获的经验。