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有没有对应的融合指令?如果没有,强行融合反而更慢。
我在项目中遇到过一个问题:把BatchNorm和ReLU融合后,精度掉了0.3%。查了半天,发现是BatchNorm的推理模式参数没处理好。所以啊,融合后一定要做精度对齐验证。
避坑指南:我曾经把Reshape和Transpose也尝试融合,结果发现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就可以被折叠成具体数值。
我个人习惯在常量折叠之后,紧接着做一轮死代码消除。因为折叠后会产生一些无用的节点,比如上面例子里的c1和c2,它们已经没有消费者了。
注意:常量折叠不能改变数值精度。比如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)可以化简为x,Mul(x, 1)可以化简为x。化简后再做CSE,效果更好。
4.5 四种优化的执行顺序
你可能会问:这四种优化,先做哪个?
我个人习惯的顺序是:
- 常量折叠:先做,因为它会产生很多死代码。
- 死代码消除:紧接着做,清理掉常量折叠产生的垃圾。
- 公共子表达式消除:在干净的图上做,避免重复计算。
- 算子融合:最后做,因为融合会改变图结构,可能破坏前面优化的结果。
当然,这不是绝对的。实际项目中,我通常会把这四步放在一个优化循环里,反复迭代,直到图不再变化为止。
总结一下:
| 优化名称 | 核心目标 | 典型收益 | 注意事项 |
|---|---|---|---|
| 算子融合 | 减少kernel启动和内存访问 | 20%-50%性能提升 | 需硬件支持,精度对齐 |
| 常量折叠 | 编译时计算,减少运行时开销 | 5%-10%性能提升 | 保持精度不变 |
| 死代码消除 | 删除无用节点,减小图规模 | 图大小减少10%-30% | 注意副作用节点 |
| 公共子表达式消除 | 避免重复计算 | 5%-15%性能提升 | 等价性判断要严谨 |
好了,图优化这块就聊到这儿。下一章咱们会讲内存分配与调度,那才是真正考验架构师功底的地方。到时候见。