4. ONNX模型优化:常量折叠、节点融合、图优化、使用onnx-simplifier简化模型

模型从训练框架导出成ONNX,这步其实不难。真正让我头疼的,是导出来的ONNX模型又大又慢,跑在端侧根本没法用。我刚开始做部署时,就吃过这个亏——模型在PC上推理好好的,一放到开发板上,延迟直接翻了三倍。

后来我才明白,训练框架导出的ONNX,里面藏着大量冗余计算。说白了,就是训练框架为了灵活性,加了很多“中间人”节点。这些节点在训练时有用,推理时就是累赘。所以,ONNX模型优化,就是把这些累赘去掉。

这一章,我带你走一遍ONNX优化的核心流程。包括常量折叠、节点融合、图优化,还有我最常用的工具——onnx-simplifier。

4.1 为什么要做ONNX优化?

先看一个真实案例。我之前优化过一个BEV感知模型,原始ONNX有1200多个节点。用onnx-simplifier跑一遍,直接砍到400多个节点。推理速度提升了40%。

你想想看,端侧设备的算力本来就有限。多一个节点,就多一次内存读写,多一次调度开销。优化ONNX,本质上就是给模型“减负”。

核心目标:

  • 减少节点数——降低推理引擎的调度开销
  • 消除冗余计算——减少不必要的算子执行
  • 合并连续操作——减少内存读写次数
  • 简化图结构——提高后续量化/编译的稳定性

4.2 常量折叠(Constant Folding)

常量折叠,说白了就是把那些输入全是常量的计算,提前算好。比如一个Add节点,两个输入都是固定数值,那推理时每次都要算一遍,纯属浪费。

我举个例子。模型里有个Shape节点,后面跟着Gather取某个维度。如果输入张量的形状在导出时就已经固定了,那这个Shape+Gather的结果就是个常量。常量折叠会把这两个节点替换成一个Constant节点。

# 常量折叠前后的对比
# 优化前:Shape -> Gather -> 后续节点
# 优化后:Constant -> 后续节点

ONNX Runtime自带的优化器就支持常量折叠。但要注意,不是所有常量都能被折叠。如果某个常量计算依赖动态输入,那就没法折叠。嗯,这里要区分清楚。

我的经验:常量折叠对BEV模型的GridSample和Resize操作特别有效。因为这些操作经常用到固定的坐标网格,折叠后能省掉一大半计算量。

4.3 节点融合(Node Fusion)

节点融合,就是把多个连续的算子合并成一个。这样做的好处很明显:减少内存读写,减少调度开销。

最常见的融合模式是Conv + BatchNorm + Relu。训练时这三个是分开的,推理时完全可以合并成一个Conv节点。BN的参数可以吸收到Conv的权重和偏置里。

融合模式 原始节点序列 融合后节点 收益
Conv-BN-ReLU Conv → BatchNorm → Relu Conv 减少2次内存读写
MatMul-Add MatMul → Add Gemm 减少1次内存读写
Reshape-Transpose-Reshape Reshape → Transpose → Reshape Transpose(优化后) 减少2次reshape开销
Clip-Sigmoid Clip → Sigmoid Sigmoid(输入已裁剪) 减少1次clip计算

我遇到过最夸张的一次,一个BEV模型的backbone部分,通过Conv-BN-ReLU融合,节点数从600降到了200。推理速度直接翻倍。

注意:节点融合不是万能的。有些融合后的算子,端侧推理引擎可能不支持。比如某些NPU不支持融合后的Conv(带BN参数)。这时候就得权衡:是保留原始结构,还是换一种融合方式。

4.4 图优化(Graph Optimization)

图优化,范围比常量折叠和节点融合更广。它是对整个计算图做结构性调整。常见的图优化包括:

  • 死节点消除——删除那些输出没有被使用的节点
  • 公共子表达式消除——如果两个节点计算相同,合并成一个
  • 恒等操作消除——比如Identity节点,直接删掉
  • Cast消除——如果数据类型转换是多余的,直接去掉
  • Shape推理优化——提前推理出中间张量的形状,减少动态shape判断

我个人习惯用ONNX Runtime的GraphOptimizationLevel来控制优化强度。有三个级别:

# ONNX Runtime的图优化级别
# ORT_ENABLE_BASIC      (1) - 基础优化,常量折叠 + 死节点消除
# ORT_ENABLE_EXTENDED   (2) - 扩展优化,包含节点融合
# ORT_ENABLE_ALL        (99) - 全部优化,包含所有已知优化

我建议在端侧部署时,先用ORT_ENABLE_ALL跑一遍。如果推理引擎报错说某个算子不支持,再降级到ORT_ENABLE_EXTENDED。

4.5 使用onnx-simplifier简化模型

onnx-simplifier是我最常用的工具,没有之一。它把常量折叠、节点融合、图优化打包在一起,一行命令就能搞定。

它的核心思路是:用ONNX Runtime跑一次推理,记录下所有中间结果。然后对比原始图和推理结果,把那些“计算前后结果不变”的节点删掉。

# 安装onnx-simplifier
pip install onnx-simplifier

# 简化模型
python -m onnxsim input_model.onnx output_model.onnx

# 带输入shape的简化(推荐)
python -m onnxsim input_model.onnx output_model.onnx \
    --input-shape "input:1,3,224,224"

我曾经用onnx-simplifier优化过一个BEV模型的ViewTransformer部分。原始模型里有大量Reshape和Transpose操作,简化后直接减少了30%的节点数。

使用技巧:

  • 一定要指定--input-shape,否则简化器不知道输入的具体形状,很多优化做不了
  • 简化后,用onnx.checker.check_model验证一下模型是否合法
  • 如果简化后的模型推理结果不对,试试加--dynamic-input-shape参数

4.6 优化流程实战

下面是我在实际项目中总结的ONNX优化流程。你照着做,基本不会踩坑。

  1. 导出ONNX——从PyTorch/TensorFlow导出原始ONNX模型
  2. 运行onnx-simplifier——指定输入shape,做第一轮简化
  3. ONNX Runtime优化——用ORT_ENABLE_ALL做第二轮优化
  4. 验证精度——对比优化前后的推理结果,误差应在1e-5以内
  5. 端侧测试——在目标设备上跑一遍,确认算子都支持
  6. 迭代调整——如果某个算子不支持,回退到上一版,手动调整

我曾经在一个项目中,因为跳过了第4步,直接拿优化后的模型去部署。结果在端侧推理结果全是NaN。排查了半天,才发现是某个融合后的算子精度不达标。从那以后,我再也不敢跳过精度验证了。

4.7 优化效果对比

拿一个典型的BEV感知模型(基于ResNet-50的backbone + Transformer的view transformer)做测试:

优化阶段 节点数 模型大小 推理延迟(RK3588)
原始ONNX 1247 187 MB 142 ms
onnx-simplifier 683 152 MB 98 ms
+ ORT优化 412 148 MB 85 ms
+ 手动融合 387 146 MB 79 ms

你看,从1247个节点降到387个,延迟从142ms降到79ms。优化效果非常明显。

4.8 ONNX优化流程图

下面这张图,展示了ONNX优化的完整流程。我建议你把它打印出来,贴在工位上。

ONNX模型优化流程图 ONNX模型优化流程图 原始ONNX模型 onnx-simplifier 简化 ONNX Runtime 图优化 精度验证(误差 < 1e-5) 通过? 端侧部署 手动调整 回退调整 提示:优化后务必验证精度,避免融合算子引入数值误差

4.9 常见问题与避坑

最后,我总结几个常见问题。这些都是我踩过的坑,你遇到了可以直接拿来用。

问题1:简化后的模型推理结果不对

原因:onnx-simplifier在简化时,如果输入shape是动态的,可能会误删某些节点。

解决方案:加--dynamic-input-shape参数,或者手动指定所有输入的具体shape。

问题2:端侧推理引擎报错“Unsupported Operator”

原因:节点融合后产生了端侧不支持的算子。

解决方案:回退到未融合的版本,手动实现一个等效的算子序列。

问题3:优化后模型变小了,但推理反而变慢了

原因:某些融合后的算子,在端侧没有硬件加速,只能用CPU跑。

解决方案:对比不同融合策略的性能,选择最优方案。

我曾经在一个项目中,优化后的模型在PC上推理快了30%,但到了RK3588上反而慢了20%。排查后发现,融合后的Conv算子(带BN参数)在NPU上不支持,只能回退到CPU。最后我手动把BN参数拆出来,用CPU跑BN,NPU跑Conv,性能才恢复正常。

嗯,ONNX优化这块,说白了就是“实践出真知”。多试几次,你就能找到最适合自己模型的优化方案。