4. 图优化技术:算子融合、常量折叠、死代码消除、表达式化简
图优化,说白了就是让计算图变得更「聪明」。
我刚开始做编译器的时候,总觉得这步可有可无——反正硬件能跑就行。直到有一次,一个模型在推理时死活达不到目标帧率,我翻来覆去调了三天,最后发现是图里一堆冗余节点在拖后腿。嗯,从那以后,我再也不敢小看图优化了。
4.1 算子融合(Operator Fusion)
算子融合是我个人最喜欢的一个优化手段。它的核心思想很简单:把多个连续的小算子合并成一个大的算子。
为什么要这么做?你想想看,每个算子在执行时,都要经历「启动内核→搬运数据→计算→写回结果」这个过程。如果算子切得太碎,大部分时间都花在了启动开销和数据搬运上,真正的计算反而没占多少。
核心收益:
- 减少内核启动次数(GPU上尤其明显)
- 减少中间结果的显存读写
- 给后端优化提供更大的「视野」
我在项目中遇到过最典型的场景:Conv + BatchNorm + ReLU 这三个算子,如果不融合,中间要写两次显存。融合之后,数据直接从卷积输出流到BN再流到ReLU,全程在寄存器里搞定。
常见的融合模式有这些:
| 融合模式 | 说明 | 收益 |
|---|---|---|
| Conv + BN + ReLU | 卷积后接批归一化和激活 | 减少2次中间显存读写 |
| MatMul + Add + ReLU | 全连接层后接偏置和激活 | 减少1次显存读写 |
| Element-wise 链式融合 | 多个逐元素操作合并 | 减少内核启动次数 |
| Reshape + Transpose + Reshape | 连续的形状变换合并 | 消除冗余数据搬运 |
实现上,我一般用「模式匹配」的方式来做。先定义好要匹配的子图模式,然后在计算图上做一次遍历,找到匹配的子图就替换成一个融合算子。
// 伪代码:Conv+BN+ReLU 融合模式匹配
for node in graph.nodes:
if node.op == 'Conv2D':
next_node = node.outputs[0].consumer
if next_node.op == 'BatchNorm':
bn_node = next_node
next_next = bn_node.outputs[0].consumer
if next_next.op == 'ReLU':
// 匹配成功,替换为融合算子
fused_node = graph.create_fused_op('ConvBNReLU',
inputs=[node.inputs[0], node.weights, bn_node.gamma,
bn_node.beta, bn_node.mean, bn_node.var])
graph.replace_subgraph(node, bn_node, next_next, fused_node)
我的小技巧:融合不是越「大」越好。融合后的算子如果太大,寄存器压力会飙升,反而导致性能下降。我一般会设一个阈值,比如融合后的算子内部操作不超过8个,超过就拆开。
4.2 常量折叠(Constant Folding)
常量折叠,说白了就是「提前算好」。如果某个子图的输入全是常量,那何必等到运行时再算?编译期直接算好,把结果写进图里就行了。
举个例子:
// 原始图
a = Constant(3.0)
b = Constant(4.0)
c = Add(a, b) // 结果永远是7.0
d = Mul(c, x) // 等价于 7.0 * x
// 常量折叠后
c_folded = Constant(7.0)
d = Mul(c_folded, x)
看起来很简单对吧?但实际做的时候有几个坑要注意。
我曾经踩过的坑:常量折叠不能乱做。有一次我把 Shape 算子的输出也当常量折叠了——结果那个 Shape 依赖于动态输入,折叠后整个图的形状都错了。所以一定要确认:只有「所有输入都是编译期可知的常量」时,才能做折叠。
我建议的折叠策略:
- 纯常量子图:直接折叠,没商量
- 部分常量子图:能折多少折多少,比如 a + 0 可以简化为 a
- Shape 相关操作:谨慎处理,最好保留到运行时
- 控制流相关:如果条件分支的判定条件是常量,可以直接剪掉死分支
4.3 死代码消除(Dead Code Elimination, DCE)
DCE 这个名字听起来挺吓人,其实意思就是「没用的代码删掉」。在计算图里,如果一个节点的输出没有被任何其他节点使用,那它就是死代码。
为什么会生成死代码?我总结了几种常见情况:
- 调试遗留:开发时加的打印节点、统计节点,上线前没删干净
- 自动微分产物:训练图转推理图时,梯度计算节点没清理
- 条件分支剪枝:某个分支永远不会执行,但分支内的节点还在图里
- 多次编译的残留:迭代优化过程中产生的中间节点
实现 DCE 其实很简单,核心就是「反向可达性分析」:
// DCE 算法伪代码
1. 标记所有输出节点(模型的最终输出)为「存活」
2. 从输出节点开始,反向遍历,把所有能到达的节点都标记为「存活」
3. 遍历所有节点,删除未被标记的节点
注意:有些节点虽然输出没被用,但它可能有副作用(比如打印日志、更新全局计数器)。这种节点不能删。我一般会在节点上挂一个 has_side_effect 标记,DCE 时跳过这些节点。
4.4 表达式化简(Expression Simplification)
表达式化简,说白了就是「用数学规则偷懒」。编译器里有一堆代数恒等式,可以用来简化计算。
常见的化简规则:
| 原始表达式 | 化简后 | 说明 |
|---|---|---|
| a + 0 | a | 加法单位元 |
| a * 1 | a | 乘法单位元 |
| a * 0 | 0 | 零乘任何数都得零 |
| a / 1 | a | 除以1不变 |
| a - a | 0 | 自身相减为零 |
| max(a, a) | a | 相同值取最大 |
| min(a, a) | a | 相同值取最小 |
| not(not(a)) | a | 双重否定 |
这些规则看起来简单,但组合起来效果惊人。我记得有一次,一个模型里有大量 a * 1 和 a + 0 的模式——都是自动微分工具生成的冗余代码。一轮化简下来,图里少了将近 15% 的节点。
我的实现建议:不要手写 if-else 来匹配每种模式。用「重写规则引擎」来做——定义好模式模板和替换模板,让引擎自动匹配。这样加新规则只需要加一行配置,不用改代码。
4.5 四种优化的执行顺序
这四种优化不是孤立做的,它们之间有依赖关系。我一般按这个顺序来:
- 先做常量折叠——把能算的提前算好,为后续优化提供更多常量信息
- 再做表达式化简——利用常量折叠的结果,进一步简化表达式
- 然后做死代码消除——前两步可能会产生新的死代码,正好清理掉
- 最后做算子融合——在干净的图上做融合,避免融合了死代码
当然,实际项目中我会跑多轮。因为融合之后可能又产生新的常量折叠机会,DCE 之后也可能让融合模式更清晰。一般跑 2-3 轮就能收敛。
嗯,以上就是图优化的四个核心手段。我个人觉得,这四种技术里,算子融合的收益最明显,但常量折叠和表达式化简是「润物细无声」——它们不会让单个算子变快,但能让整个图更干净,为后续优化铺好路。
一句话总结:图优化不直接改算子实现,而是改算子之间的「关系」。好的图优化,能让后端少干活、干巧活。
公众号:蓝海资料掘金营,微信deep3321