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 图上明明写的是 Conv,但实际属性里 dilations 是空的,导致推理结果全错。建议导完后用 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 的矩阵乘”,或者某个框架独有的算子。这时候就要写自定义算子。

但自定义算子的坑,比我啃掉的鸡腿还多。我来列几个关键点:

  1. 注册必须对应 IR 版本。你写了个 MyCustomOp,如果注册到 opset 10 的域,在 opset 13 的模型里可能找不到。我当初在 ONNX Runtime 里注册了一个“FFN”,结果模型 opset 是 15,运行报错“unsupported operator”。
  2. 形状推断不可省略。TensorRT 和 ONNX Runtime 都会在编译期推导张量形状。如果你没写 shape inference 函数,推理引擎只能靠猜,性能大降甚至出错。
  3. 属性序列化用 protobuf 的 Any/Map。ONNX 的算子属性类型有限(int, float, string, tensor 等)。复杂结构需要自己编码成 bytes。我之前用 JSON 字符串塞进 string 属性里,结果不同引擎解析不一致。
注意:自定义算子的实现,在 ONNX 里叫“函数(Function)”或“自定义域(Custom Domain)”。如果只在本机跑,可以简单注册;如果要跨平台部署,必须把实现编译成动态库(.so/.dll),并且引擎能加载它。我曾因为没链接 CUDA 库,导致自定义 Conv 在 GPU 上直接 segfault。

下面是一个最简单的自定义算子注册示例(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 核心关系 ONNX 模型 (.onnx) IR 版本 (v3 ~ v9) 算子集 (opset N) 常用算子 (Conv等) 自定义算子 (域) 推理引擎 (ORT / TRT) 注意 IR 版本决定 opset 范围 自定义算子需注册域 引擎加载时才解析

从图里能看到,ONNX 模型挂载了 IR 版本和算子集,而常用算子和自定义算子都在同一个 opset 下。推理引擎根据 IR 版本号决定如何反序列化。自定义算子通过“域”区分,引擎需要实现对应的 kernel 才能运行。

为什么自定义算子要格外小心?

因为一旦用了自定义算子,你的模型就不是“标准 ONNX”了。很多工具链(比如 ONNX Simplifier、onnx2trt)遇到陌生算子直接跳过或报错。我习惯的做法是:先用内置算子拼出功能,实在不行再写自定义。写之前先查一下目标引擎的扩展文档,别等编译报错再回过头改。

核心记忆点:一个标准 ONNX 模型 = protobuf 协议 + IR 版本 + opset 版本 + 算子节点。自定义算子是“额外域”,不是核心,慎用。

好了,这一章的内容差不多就是这些。记住,算子选型要匹配 opset,自定义算子是最后的手段。下次你遇到模型转换失败,先查算子版本对不对,再查自定义域有没有注册——这两个问题占了 80% 的坑。