4、图优化技术:算子融合(Fusion)、常量折叠(Constant Folding)、死代码消除(DCE)、公共子表达式消除(CSE)

好,咱们今天聊聊图优化。这是编译器后端里最见功力的一块。

你想想看,前端解析完模型,生成了一张计算图。但这张图是“原生态”的,充满了冗余、低效、甚至无用的操作。图优化要做的,就是把这堆“毛坯房”装修成“精装房”。

我个人习惯把图优化分成四类:算子融合、常量折叠、死代码消除、公共子表达式消除。咱们一个一个来拆解。

4.1 算子融合(Fusion)

说白了,就是把多个连续的小算子合并成一个大的算子。为什么要这么做?

因为每次调用一个算子,都有开销。启动kernel、读写数据、同步……这些开销在小算子面前,占比非常大。我见过一个项目,模型里全是Conv + Bias + ReLU这种小组合,如果不融合,性能直接腰斩。

核心思想:减少kernel launch次数,减少中间数据的内存读写。

举个例子,最常见的融合模式:

// 融合前
y1 = Conv2D(x, w)
y2 = Add(y1, bias)
y3 = ReLU(y2)

// 融合后
y = FusedConvBiasReLU(x, w, bias)

嗯,这里要注意。不是所有算子都能随便融合。你得满足几个条件:

  • 数据依赖关系:必须是顺序执行的,不能有分支。
  • 算子语义等价:融合后的结果,必须和融合前完全一致。
  • 硬件支持:你的NPU有没有对应的融合指令?如果没有,强行融合反而更慢。

我在项目中遇到过一个问题:把BatchNormReLU融合后,精度掉了0.3%。查了半天,发现是BatchNorm的推理模式参数没处理好。所以啊,融合后一定要做精度对齐验证。

避坑指南:我曾经把ReshapeTranspose也尝试融合,结果发现NPU的硬件调度器对这类形状变换操作有特殊优化,融合后反而破坏了硬件流水线。所以,融合前先看看硬件手册。

4.2 常量折叠(Constant Folding)

这个就比较好理解了。编译时能算出来的东西,就别拖到运行时。

你想想看,如果图里有一个Add(3, 5),难道每次推理都要算一遍8吗?当然不是。编译器在编译阶段就把这个节点替换成常量8了。

// 折叠前
c1 = Constant(3)
c2 = Constant(5)
y = Add(c1, c2)

// 折叠后
y = Constant(8)

听起来简单?但实际场景要复杂得多。比如:

  • 部分常量折叠Add(Conv(x, w), Constant(bias)),这里bias是常量,但Conv的输入x不是。那我们可以把bias折叠进Conv的bias参数里。
  • 形状推导中的常量折叠Reshape(x, [32, -1]),这里的-1需要根据x的总元素数推导。如果x的形状在编译时已知,那-1就可以被折叠成具体数值。

我个人习惯在常量折叠之后,紧接着做一轮死代码消除。因为折叠后会产生一些无用的节点,比如上面例子里的c1c2,它们已经没有消费者了。

注意:常量折叠不能改变数值精度。比如Float16(3.1415926)折叠成3.14,这就不对了。一定要保持类型和精度完全一致。

4.3 死代码消除(DCE)

DCE,全称Dead Code Elimination。说白了,就是删掉那些“没用”的节点。

什么叫没用?一个节点,如果它的输出没有被任何其他节点使用,那它就是死代码。比如:

// 原始图
a = Conv(x, w1)
b = ReLU(a)      // b没有被任何节点使用
c = Conv(x, w2)  // c是最终输出

// DCE后
a = Conv(x, w1)  // 如果a也没有被使用,也会被删掉
c = Conv(x, w2)

你可能会问:谁会写出这种代码?其实不是人写的,是编译器自己产生的。比如常量折叠后,原来的常量节点就变成了死代码。再比如,某些调试用的打印节点,在推理时根本不需要。

我见过一个极端案例:某个模型经过多轮优化后,DCE竟然删掉了整个子图的80%节点。原因是前面的优化把计算都折叠到后面了,前面的节点全成了“孤儿”。

实现要点:DCE通常采用“标记-清扫”算法。先标记所有从输出节点可达的节点,然后清扫掉所有未被标记的节点。复杂度是O(V+E),非常高效。

嗯,这里要提醒一下。DCE不能乱删。如果一个节点有副作用,比如写文件、打印日志、更新全局状态,那即使它的输出没人用,也不能删。好在NPU的计算图里,这类节点很少见。

4.4 公共子表达式消除(CSE)

CSE,Common Subexpression Elimination。这个优化在传统编译器里很常见,在NPU编译器里同样重要。

说白了,就是如果同一个表达式被计算了多次,那就只算一次,结果复用。

// CSE前
a = Add(x, y)
b = Mul(a, z)
c = Add(x, y)   // 和a的计算完全一样
d = Mul(c, w)

// CSE后
a = Add(x, y)
b = Mul(a, z)
d = Mul(a, w)   // 直接复用a的结果

你想想看,如果Add(x, y)是一个很大的张量操作,重复计算两次,浪费了多少算力和带宽?

但CSE有个坑:等价性判断。两个表达式“看起来”一样,但真的等价吗?

  • 数值等价Add(x, y)Add(x, y),显然等价。
  • 代数等价Mul(x, 2)Add(x, x),在数学上等价,但在浮点运算中可能不等价(因为舍入误差)。
  • 形状等价:两个Reshape(x, shape),如果shape不同,那就不等价。

我在项目中遇到过一个问题:两个Conv2D的输入张量相同,但一个用了NHWC布局,另一个用了NCHW布局。虽然数学上等价,但内存布局不同,不能直接复用。所以CSE时,一定要把数据布局也纳入等价性判断。

我的经验:CSE通常和代数化简配合使用。比如Add(x, 0)可以化简为xMul(x, 1)可以化简为x。化简后再做CSE,效果更好。

4.5 四种优化的执行顺序

你可能会问:这四种优化,先做哪个?

我个人习惯的顺序是:

  1. 常量折叠:先做,因为它会产生很多死代码。
  2. 死代码消除:紧接着做,清理掉常量折叠产生的垃圾。
  3. 公共子表达式消除:在干净的图上做,避免重复计算。
  4. 算子融合:最后做,因为融合会改变图结构,可能破坏前面优化的结果。

当然,这不是绝对的。实际项目中,我通常会把这四步放在一个优化循环里,反复迭代,直到图不再变化为止。

总结一下:

优化名称 核心目标 典型收益 注意事项
算子融合 减少kernel启动和内存访问 20%-50%性能提升 需硬件支持,精度对齐
常量折叠 编译时计算,减少运行时开销 5%-10%性能提升 保持精度不变
死代码消除 删除无用节点,减小图规模 图大小减少10%-30% 注意副作用节点
公共子表达式消除 避免重复计算 5%-15%性能提升 等价性判断要严谨

好了,图优化这块就聊到这儿。下一章咱们会讲内存分配与调度,那才是真正考验架构师功底的地方。到时候见。