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 四种优化的执行顺序

这四种优化不是孤立做的,它们之间有依赖关系。我一般按这个顺序来:

  1. 先做常量折叠——把能算的提前算好,为后续优化提供更多常量信息
  2. 再做表达式化简——利用常量折叠的结果,进一步简化表达式
  3. 然后做死代码消除——前两步可能会产生新的死代码,正好清理掉
  4. 最后做算子融合——在干净的图上做融合,避免融合了死代码

当然,实际项目中我会跑多轮。因为融合之后可能又产生新的常量折叠机会,DCE 之后也可能让融合模式更清晰。一般跑 2-3 轮就能收敛。

图优化执行流程 常量折叠 表达式化简 死代码消除 算子融合 多轮迭代(2-3轮) 输入:原始计算图 输出:优化后的计算图 图优化是编译器的「大脑」,让计算图更高效、更简洁

嗯,以上就是图优化的四个核心手段。我个人觉得,这四种技术里,算子融合的收益最明显,但常量折叠和表达式化简是「润物细无声」——它们不会让单个算子变快,但能让整个图更干净,为后续优化铺好路。

一句话总结:图优化不直接改算子实现,而是改算子之间的「关系」。好的图优化,能让后端少干活、干巧活。


公众号:蓝海资料掘金营,微信deep3321