ONNX 算子与图结构:版本、自定义与拓扑
这一章咱们要啃的,是 ONNX 模型最核心的骨架——算子和图结构。说白了,模型就是个有向无环图,节点是算子,边是张量。你只有搞懂了算子集怎么管理版本、怎么塞进自定义算子,以及图结构怎么遍历、怎么切片,才能真正掌控模型的优化流程。
算子集版本:版本号背后的学问
ONNX 算子集有个版本号,比如 opset 11、opset 15。很多人觉得它就是个数字,其实不是。它直接决定了你的模型能不能被推理引擎正确加载。
我记得有一次项目组从 PyTorch 导出模型,用的 opset 17,结果对方那个 TensorRT 5 的环境最高只支持 opset 11。模型死活加载不了。后来只能回退到 opset 11 重新导出,才解决问题。
查看算子集版本很简单,看模型元数据就行:
import onnx
model = onnx.load("model.onnx")
print(model.opset_import[0].version) # 输出算子集版本号
版本演进背后是算子的新增和修改。比如 opset 11 加入了 ScatterND,opset 13 里 Softmax 的 axis 参数变成必选。这些细节不注意,转换时就会报错。
核心原则:尽量使用高版本的算子集导出,但同时要考虑目标推理引擎支持的上限。如果兼容性不确定,就选择 opset 11 或 13,这两个版本兼容性较好。
算子集版本演进对照表
| 算子集版本 | 关键变化 | 推荐场景 |
|---|---|---|
| opset 7 | 基础算子定型,支持 RNN/LSTM | 老旧模型兼容 |
| opset 9 | 加入 ConstantOfShape、Upsample |
图像类模型常见 |
| opset 11 | 加入 ScatterND、ScatterElements;Resize 改参数 |
通用兼容性最优 |
| opset 13 | Softmax 修改;加入 GridSample |
Transformer、分割模型 |
| opset 15 | Resize 参数调整,加入 BitShift |
前沿模型导出 |
自定义算子:补齐 ONNX 的短板
标准算子库覆盖了 90% 的常见操作,但总有那么几个自定义 op 你绕不过去——比如你写了个特殊的激活函数,或者用到了一个第三方库里的高效算子。
这时候就需要自定义算子。说白了就是先在 ONNX 里注册一个你的新算子,然后在推理引擎里实现它的计算逻辑。
在 ONNX 里注册自定义算子,需要两步:定义符号函数,注册适配器。
import torch
import torch.onnx.symbolic_helper as sym_help
from torch.onnx import register_custom_op_symbolic
# 第一步:定义符号函数
def my_custom_relu(g, input, alpha):
# 映射到 ONNX 的自定义算子
return g.op("my::CustomRelu", input, alpha_i=alpha)
# 第二步:注册适配器
register_custom_op_symbolic("::my_custom_relu", my_custom_relu, 1)
# 在模型中使用
class MyModel(torch.nn.Module):
def forward(self, x):
return torch.ops.my_custom_relu(x, alpha=0.1)
小技巧:命名空间要用域名前缀比如 my::,避免和标准算子冲突。反向传播的梯度也得自己实现,不然训练时过不去。
在 TensorRT 里加载自定义算子,就得写插件(Plugin)。我曾经写过一个 FFT 算子插件,调了整整两天才跑通。关键点是 input 和 output 的维度推导一定要测全,不然推理时会崩在奇怪的地方。
图遍历与拓扑排序:搞懂计算顺序
ONNX 模型是一个有向无环图(DAG)。图中每个节点代表一个算子,边代表数据流向。你要做优化,第一步就是遍历并理解这个图的结构。
图遍历分两种:广度优先(BFS)和深度优先(DFS)。我记得做模型剪枝时,就是用 BFS 从输出节点往回找,逐步剥离冗余的层。
拓扑排序是 DAG 优化的基础。它保证了:如果算子 A 的输入依赖算子 B 的输出,那么 B 一定在 A 之前执行。这是所有推理引擎的底层基础。
import onnx
from collections import deque
model = onnx.load("model.onnx")
graph = model.graph
# 拓扑排序:Kahn 算法
in_degree = {node.name: 0 for node in graph.node}
# 建立邻接表和入度表
# ...(省略构建过程,假设已经构建好)
queue = deque([n for n, d in in_degree.items() if d == 0])
sorted_nodes = []
while queue:
node = queue.popleft()
sorted_nodes.append(node)
for neighbor in adj_list[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# sorted_nodes 就是拓扑排序后的算子列表
注意:如果图中有环,拓扑排序会失败。ONNX 标准要求模型不能有环。但实际项目中,我曾经遇到过因为控制流写错导致的死循环。排查方法是用 onnx.checker.check_model 检查模型合法性。
子图与函数:封装与复用
子图,就是计算图的一部分。你用它对模型做剪枝、量化或者融合优化。TensorRT 的 layer fusion 本质上就是在子图上做模式匹配,把多个小算子合并成一个更大的算子。
函数(Function)是 ONNX 1.6 之后引入的新特性。它让你定义可复用的算子组合。比如你有个“conv + bn + relu”的组合,就可以定义成一个函数,在多处复用。
我做过一个项目,把 68 个重复的卷积激活块定义成函数,模型体积直接减少了 30%,导出速度也快了。
# 定义一个函数:ConvBNReLU
func_proto = onnx.helper.make_function(
domain="my.domain",
fname="ConvBNReLU",
inputs=["X", "W", "B", "gamma", "beta", "mean", "var"],
outputs=["Y"],
nodes=[
# Conv 节点
onnx.helper.make_node("Conv", ["X", "W", "B"], ["conv_out"],
kernel_shape=[3,3], pads=[1,1,1,1]),
# BN 节点
onnx.helper.make_node("BatchNormalization",
["conv_out", "gamma", "beta", "mean", "var"],
["bn_out"], epsilon=1e-5),
# ReLU 节点
onnx.helper.make_node("Relu", ["bn_out"], ["Y"])
],
opset_imports=[onnx.helper.make_opsetid("", 13)]
)
# 在图中使用函数
graph.node.append(
onnx.helper.make_node("my.domain.ConvBNReLU",
["X", "W", "B", "gamma", "beta", "mean", "var"],
["Y"], domain="my.domain")
)
实践建议:自定义函数要注意处理输入输出的可变长度。如果你的函数要处理不同大小的 batch,记得把 shape inference 实现清楚,否则推理引擎不知道你的输出是什么形状。
一张图概括本章知识体系
避坑指南与个人经验
关于算子版本,我常用的做法是定义一个字典,把目标推理引擎支持的 opset 范围记下来。导出模型前先查这个字典,能省不少排查时间。
自定义算子最容易出坑的地方是 shape inference。如果你不实现它,TensorRT 或者 ONNXRuntime 会认为输出形状未知,然后就可能报错或者走很慢的 fallback 路径。
图拓扑排序在我做算子融合时帮了大忙。那时候需要把相邻的 Conv 和 BN 合并,用拓扑序就能从左到右一次扫描,不用来回看图。
最后说个实用技巧:用 onnx.shape_inference.infer_shapes(model) 可以在 Python 里帮你补全所有中间张量的形状信息。这个操作在导出后做一次,很多问题都能提前暴露。