4、图优化技术:算子融合、常量折叠、死代码消除、内存优化
图优化,说白了就是让计算图变得更聪明。我经常跟团队里的小朋友说,你写出来的模型就像一锅乱炖,图优化就是那个帮你把食材重新摆盘、去掉糊锅部分的大厨。今天咱们就聊聊这四大招:算子融合、常量折叠、死代码消除、内存优化。
4.1 算子融合:把零碎操作打包成整块
算子融合是我个人最喜欢的一招。你想想看,GPU干活最怕什么?最怕频繁启动小算子。每次启动都有开销,就像你开车去超市,如果每买一瓶酱油就回家一趟,那油钱都够买十瓶了。
核心思想:将多个连续的小算子合并成一个大的算子,减少内核启动次数和中间数据读写。
我在项目中遇到过最典型的例子就是 Conv-BN-ReLU 融合。早期很多框架都是分开写的,我一看那性能曲线就头疼。后来我们做了融合,推理速度直接提升了30%以上。
// 融合前:三个独立算子
conv_output = conv2d(input, weight, bias)
bn_output = batch_norm(conv_output, mean, var)
relu_output = relu(bn_output)
// 融合后:一个融合算子
output = fused_conv_bn_relu(input, weight, bias, mean, var)
嗯,这里要注意:不是所有算子都能随便融合。我踩过坑——把两个形状不匹配的算子硬融在一起,结果精度掉了两个点。后来我总结了一条经验:融合的前提是数据流图是线性的,且中间结果不需要被其他分支复用。
4.2 常量折叠:把能算的先算好
常量折叠,说白了就是「偷懒」。既然有些计算在编译时就能确定结果,为什么还要等到运行时再算?
我记得有一次优化一个BERT模型,发现里面有个子图全是常量操作。比如 a = 3.14 * 2.0,这种计算每次推理都重复做,纯属浪费电。
// 折叠前
const_a = 3.14 * 2.0 // 每次推理都算一遍
const_b = const_a + 1.0
output = input * const_b
// 折叠后
output = input * 7.28 // 直接替换成常量
小技巧:我习惯在模型导出阶段就做一次完整的常量折叠。这样导出的模型更小,推理时也少了很多无用功。尤其是那些包含大量 LayerNorm 权重计算的 Transformer 模型,折叠后能省下不少。
我曾经遇到一个坑:常量折叠把 float32 和 int32 混在一起算,结果精度对不上。后来我加了一条规则——不同数据类型的常量不要折叠,除非你能保证精度无损。
4.3 死代码消除:砍掉没用的分支
死代码消除,名字听着吓人,其实就是「断舍离」。模型里经常有一些永远不会被执行的节点,或者输出永远不会被使用的节点。留着它们干嘛?占内存、耗时间。
我见过最夸张的一个案例:某个检测模型里有个分支,作者写的时候是为了做实验,后来忘了删。那个分支占了整个计算图的15%,但从来没人用过。我们做死代码消除时把它砍掉,模型体积直接小了10%。
避坑指南:我曾经把一些看似「死」但实际上被动态条件控制的节点给删了。比如 if (condition) { ... } 里的分支,如果 condition 在运行时才确定,那这些节点就不能算死代码。所以做消除时一定要搞清楚控制流依赖。
死代码消除的典型场景包括:
- 未使用的输出:某个算子的输出没有被任何后续节点引用
- 恒为假的条件分支:比如
if (False)里的子图 - 重复的 Identity 节点:很多框架自动插入的冗余节点
4.4 内存优化:让显存用在刀刃上
内存优化这块,我踩过的坑最多。你想想看,GPU显存就那么点,模型越来越大,不优化根本跑不动。
核心思路就两个:复用和重算。
| 优化策略 | 说明 | 适用场景 |
|---|---|---|
| 内存复用 | 两个生命周期不重叠的 tensor 共用同一块显存 | 大多数推理场景 |
| 重算 | 不保存中间结果,需要时重新计算 | 显存极度紧张时 |
| in-place 操作 | 直接在输入 tensor 上修改,不分配新内存 | ReLU、Dropout 等 |
我个人习惯在模型编译阶段做一次完整的内存规划。先分析每个 tensor 的生命周期,然后像拼图一样把不冲突的 tensor 安排到同一块地址上。这个过程有点像酒店前台安排房间——客人入住时间不重叠,就可以住同一间。
一个真实案例:我们优化一个 YOLOv5 模型时,通过内存复用把峰值显存从 2.1GB 降到了 1.3GB。怎么做到的?就是把那些只在某个阶段使用的中间结果(比如特征金字塔的某些层)及时释放,让后面的层复用这块空间。
嗯,这里要提醒一句:in-place 操作虽然省内存,但用起来要小心。我曾经在反向传播时用了 in-place ReLU,结果梯度计算全乱了。推理场景还好,训练时千万别乱用。
4.5 图优化的整体流程
说了这么多,咱们来看看这些技术是怎么配合的。我画了一张图,帮你理清思路。
你看,这个流程是有顺序的。我习惯先做常量折叠,把能算的都算好;然后做死代码消除,把没用的砍掉;接着做算子融合,把零散的打包;最后做内存优化,精打细算每一块显存。
当然,实际工程中这些步骤可能会反复迭代。比如做完算子融合后,可能又产生了新的死代码,那就再跑一遍消除。我见过有些引擎会循环做3-5轮优化,直到图不再变化为止。
个人经验:图优化不是越猛越好。我见过有人把整个模型融成一个超级算子,结果 GPU 因为寄存器不够用,性能反而下降了。优化的度,得根据硬件特性来把握。说白了,图优化是给硬件量身定做的衣服,不是越大越好,是越合身越好。
好了,图优化这块就聊到这儿。这些技术看着简单,但真正用好需要大量的工程实践。我建议你从一个小模型开始,一步步做优化,看看每一步能带来多少提升。踩过坑,才能真正理解。