3、ONNX进阶:ONNX算子详解、自定义算子注册、ONNX模型优化、ONNX模型量化
好,咱们继续往前走。上一章我们把ONNX的基础摸透了,能导出、能可视化、能跑推理。但说实话,那只是入门。真正到了工业级部署,你会发现模型里全是坑——算子不支持、推理速度慢、模型体积大。这一章,我就带你把这些硬骨头啃下来。
3.1 ONNX算子详解:你到底支持什么?
ONNX的算子集,说白了就是一套标准化的“积木”。PyTorch、TensorFlow这些框架,最终都得把自己的操作翻译成这些积木。我刚开始接触时,以为所有算子都支持,结果一跑就报错。嗯,这里要注意:ONNX的算子集是分版本的。
每个ONNX版本(比如opset 11、13、15)都会新增或废弃一些算子。你导出的模型用的是哪个版本,推理引擎就得支持哪个版本。我个人习惯是,尽量用较新的opset(比如15或17),但也要确认目标部署平台是否支持。
常见的算子类型大致分这几类:
- 张量操作:Reshape、Transpose、Concat、Split、Slice。这些是基础,几乎每个模型都有。
- 数学运算:Add、Mul、MatMul、Softmax、Relu。神经网络的核心计算。
- 归一化:BatchNormalization、InstanceNormalization。训练时用的,推理时通常会被融合。
- 池化与卷积:Conv、MaxPool、AveragePool。CV模型的标配。
- 控制流:If、Loop。动态模型会用到,但很多推理引擎支持得不好。
我在项目中遇到过最头疼的,就是 Reshape 和 Transpose 的组合。PyTorch里一个简单的 view 操作,导出后可能变成 Reshape + Transpose + Reshape 三连击,推理效率直接打折扣。
Reshape 的形状参数在导出时被固定成了静态值,而实际输入尺寸变了。所以,导出时尽量让形状是动态的,或者用 symbolic_shape 做标记。
3.2 自定义算子注册:当标准算子不够用时
现实很骨感。你总会遇到一些ONNX标准算子集里没有的操作。比如一些自定义的激活函数、特殊的池化方式、或者某些硬件加速指令。这时候怎么办?自己动手,注册自定义算子。
注册自定义算子,说白了就是告诉ONNX:“嘿,这个操作我认识,它叫 MyCustomOp,输入输出长这样,计算逻辑我来写。” 我建议你分三步走:
- 定义算子符号:在PyTorch中,用
torch.onnx.register_custom_op_symbolic把自定义函数映射到ONNX算子。 - 实现计算逻辑:在推理引擎(比如ONNX Runtime)中,用C++或Python实现这个算子的前向计算。
- 注册到引擎:把实现好的算子注册到ONNX Runtime的算子库中。
举个例子,假设你写了一个 my_activation 函数:
import torch
class MyActivation(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
return x * torch.sigmoid(x) # 一个简单的自定义激活
@staticmethod
def symbolic(g, x):
return g.op("MyCustom::MyActivation", x)
torch.onnx.register_custom_op_symbolic("MyActivation", MyActivation.symbolic, 1)
导出时,ONNX就会把这个操作识别为 MyCustom::MyActivation。然后在推理端,你需要实现这个算子的计算逻辑并注册。嗯,这里要注意:自定义算子的命名空间(比如 MyCustom)要唯一,避免和官方算子冲突。
3.3 ONNX模型优化:让模型跑得更快
模型导出后,直接拿去推理,往往不是最优的。ONNX模型优化,就是通过一系列图变换,让计算图更高效。我常用的优化手段有这几招:
- 常量折叠:把计算图中能提前算好的常量表达式,直接替换成结果。比如
Add(2, 3)直接变成5。 - 算子融合:把连续的几个小算子合并成一个大算子。最经典的就是
Conv + BatchNormalization + Relu融合成一个ConvRelu。 - 冗余消除:去掉没用的节点,比如
Identity、Cast(如果类型一致)、或者重复的Transpose。 - 拓扑排序优化:调整节点的执行顺序,让内存访问更连续,减少缓存缺失。
ONNX官方提供了 onnxoptimizer 工具,可以直接用:
import onnx
from onnxoptimizer import optimize
model = onnx.load("model.onnx")
optimized_model = optimize(model, passes=["fuse_consecutive_transposes", "eliminate_identity"])
onnx.save(optimized_model, "model_optimized.onnx")
我个人习惯是,先跑一遍所有可用的pass,然后逐个检查效果。有些pass可能会改变模型的数值精度,比如 fuse_bn_into_conv 如果实现有bug,会导致精度下降。所以,优化后一定要做精度验证。
3.4 ONNX模型量化:让模型变得更小
模型量化,说白了就是把模型里的浮点数(FP32)变成整数(INT8)。这样做的好处有两个:模型体积缩小到原来的1/4,推理速度提升2-4倍。代价是精度可能会掉一点点。
ONNX支持的量化方式主要有两种:
| 量化方式 | 原理 | 适用场景 | 精度损失 |
|---|---|---|---|
| 动态量化 | 推理时动态计算量化参数 | NLP模型、LSTM | 较小 |
| 静态量化 | 用校准数据集提前计算量化参数 | CV模型、CNN | 更小 |
静态量化是工业界的主流做法。你需要准备一个校准数据集(几百张图片或几千条文本就行),然后让ONNX Runtime跑一遍校准过程,收集每层激活值的分布,计算出最佳的量化参数。
具体操作可以用 onnxruntime.quantization 工具:
import onnx
from onnxruntime.quantization import quantize_static, QuantType, CalibrationMethod
model = onnx.load("model.onnx")
quantized_model = quantize_static(
model,
calibration_data_reader=my_calibration_reader, # 你的校准数据
quant_format=QuantType.QInt8,
per_channel=True,
calibration_method=CalibrationMethod.MinMax
)
onnx.save(quantized_model, "model_quantized.onnx")
我曾经在一个目标检测项目里,把模型从FP32量化到INT8,模型体积从200MB降到了50MB,推理速度从30ms降到了8ms,精度只掉了0.3%。这个收益,你想想看,对于边缘设备部署来说,简直是质的飞跃。
Softmax 和 Sigmoid。我建议你先用 onnxruntime.quantization.shape_inference 做形状推断,确保量化后的图结构正确。另外,校准数据集一定要覆盖真实场景的数据分布,否则量化后的模型在特定场景下会崩。
好了,这一章的内容就到这里。ONNX进阶的核心,说白了就是三件事:搞清楚算子支持情况、学会自定义扩展、掌握优化和量化技巧。下一章,我们会进入OpenVINO的世界,看看Intel的这套工具链到底有多强。