ONNX Operator 与 IR 规范
ONNX 的算子(Operator)和中间表示(IR)是整个生态的基石。我最早接触时,以为只要把模型转成 .onnx 就万事大吉了。结果在 TensorRT 上推理时,一个 Conv 算子的参数对齐错误,直接跑飞。嗯,这块坑不少,咱们一个个来拆。
常用算子:Conv、Relu、Gemm
算子就是 ONNX 图中的“指令节点”。每个算子有固定的输入、输出和属性。我个人习惯把最常用的三个记牢:
- Conv:卷积算子。属性包括 dilations、group、kernel_shape、pads、strides。输入是 X(数据)和 W(权重),可选 B(偏置)。我记得有一次在转 MobileNet 时,pads 默认是 'auto_pad',但 TensorRT 7 不支持自动填充,必须显式写 pads 值。
- Relu:激活算子。只有一个输入,一个输出,无属性。简单到容易被忽略?但它的 inplace 优化很关键。我在 ONNX Runtime 里遇到过 Relu 后面紧跟另一个算子时,内存复用没生效,反复拷数据拖慢了速度。
- Gemm:全连接层。属性 transA、transB、alpha、beta。输入 A、B、C(偏置)。Gemmin 说白了就是
Y = alpha * A * B + beta * C。很多框架导出的 Gemm 会把 transB=1 打开,而 TensorRT 的 FullyConnected 层只支持固定维度,我踩过“维度不匹配”的坑。
onnx.checker 验证一遍。
IR 版本演进
ONNX IR 版本从 1 到现在的 9(截至 2025 年),每个版本都会新增或修改算子集(opset)。版本号决定了你的模型能用哪些算子。
| IR 版本 | 对应 opset 范围 | 主要变化 |
|---|---|---|
| v3 | opset 1-6 | 初始版本,基础算子,支持 protobuf 3 |
| v4 | opset 7-9 | 引入量化相关算子(QLinearConv 等),支持自动求导信息 |
| v6 | opset 10-12 | 新增 OneHot、Hardmax,修改 Split 的行为 |
| v7 | opset 13-15 | 支持 float16 类型扩展,新增 Shape 动态性 |
| v9 | opset 18+ | 支持函数(Function),可组合定义新算子;添加了更多自定义扩展能力 |
为什么会这样频繁演进?因为深度学习框架迭代太快。ONNX 作为“翻译官”,必须跟着加新算子。我建议你在部署时,先把模型 opset 版本锁定到至少 opset 11,这样大部分推理引擎都支持。如果用了 opset 18 的新特性(比如动态量化),记得检查目标引擎的兼容性。
自定义算子注意事项
现实项目中,内置算子不够用是家常便饭。比如你想实现一个“带 mask 的矩阵乘”,或者某个框架独有的算子。这时候就要写自定义算子。
但自定义算子的坑,比我啃掉的鸡腿还多。我来列几个关键点:
- 注册必须对应 IR 版本。你写了个 MyCustomOp,如果注册到 opset 10 的域,在 opset 13 的模型里可能找不到。我当初在 ONNX Runtime 里注册了一个“FFN”,结果模型 opset 是 15,运行报错“unsupported operator”。
- 形状推断不可省略。TensorRT 和 ONNX Runtime 都会在编译期推导张量形状。如果你没写 shape inference 函数,推理引擎只能靠猜,性能大降甚至出错。
- 属性序列化用 protobuf 的 Any/Map。ONNX 的算子属性类型有限(int, float, string, tensor 等)。复杂结构需要自己编码成 bytes。我之前用 JSON 字符串塞进 string 属性里,结果不同引擎解析不一致。
下面是一个最简单的自定义算子注册示例(Python + ONNX Runtime)。看清楚,我只注册了一个类型,没做 shape inference,仅演示流程:
import onnx
from onnx import helper, TensorProto
from onnxruntime import InferenceSession, SessionOptions
# 创建自定义算子节点(假设已在另一个域注册了实现)
custom_node = helper.make_node(
'MyCustomRelu',
inputs=['input'],
outputs=['output'],
domain='my.domain',
name='my_relu'
)
graph = helper.make_graph(
[custom_node],
'test_graph',
[helper.make_tensor_value_info('input', TensorProto.FLOAT, [1,3,224,224])],
[helper.make_tensor_value_info('output', TensorProto.FLOAT, [1,3,224,224])]
)
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 15), helper.make_opsetid("my.domain", 1)])
# 用 onnxruntime 加载(需要注册自定义库)
sess_options = SessionOptions()
# sess_options.register_custom_ops_library('libmycustom.so') # 编译好的算子库
session = InferenceSession(model.SerializeToString(), sess_options)
代码里有两处容易踩雷:一是 opset_imports 必须包含自定义域及其版本号;二是注册的库必须和 ONNX Runtime 版本匹配。我在项目里因为用了高版本 ORT 但链接了旧版库,符号找不到,折腾了一下午。
IR 与算子的关系图
说了这么多,可能有点绕。我画了一张图帮你理清 ONNX 模型里 IR 版本、算子集合、自定义域到底怎么配合。
从图里能看到,ONNX 模型挂载了 IR 版本和算子集,而常用算子和自定义算子都在同一个 opset 下。推理引擎根据 IR 版本号决定如何反序列化。自定义算子通过“域”区分,引擎需要实现对应的 kernel 才能运行。
为什么自定义算子要格外小心?
因为一旦用了自定义算子,你的模型就不是“标准 ONNX”了。很多工具链(比如 ONNX Simplifier、onnx2trt)遇到陌生算子直接跳过或报错。我习惯的做法是:先用内置算子拼出功能,实在不行再写自定义。写之前先查一下目标引擎的扩展文档,别等编译报错再回过头改。
核心记忆点:一个标准 ONNX 模型 = protobuf 协议 + IR 版本 + opset 版本 + 算子节点。自定义算子是“额外域”,不是核心,慎用。
好了,这一章的内容差不多就是这些。记住,算子选型要匹配 opset,自定义算子是最后的手段。下次你遇到模型转换失败,先查算子版本对不对,再查自定义域有没有注册——这两个问题占了 80% 的坑。