【AI编译器入门③】编译前端:ONNX、TorchScript与动态shape处理
【AI编译器入门③】编译前端:ONNX、TorchScript与动态shape处理
本文是「AI编译器」系列的第三篇。前端是AI编译器的入口——把各种格式的模型变成统一的IR。听起来像"格式转换"这么简单?才不是!前端还要处理算子映射、动态shape、自定义算子注册……稍有不慎,整个编译链路就会失败。本文把前端的三大核心问题逐一拆解。
一、写在前面:前端不只是格式转换
很多人觉得前端就是"把ONNX转成Relay"——就像把Word转成PDF一样,格式转换而已。
大错特错!
前端要处理的事情多着呢:
- 图结构提取:节点、边、输入输出关系
- 算子映射:ONNX的
Conv对应TVM的什么算子?语义完全一致吗? - 权重处理:initializer怎么转成TVM的常量?
- 动态shape:哪些维度是固定的?哪些是符号变量?
- 自定义算子:TVM不支持的算子怎么办?
- 类型推导:每个算子的输出shape和dtype是什么?
格式转换只是最表层的工作。前端的质量,直接决定了后面中端优化和后端代码生成能不能顺利进行。
先上一张前端Pipeline图:
二、前端Pipeline总览
ONNX / TorchScript / TensorFlow 模型 → 前端解析 ├── 图结构提取:遍历节点,建立输入输出拓扑 ├── 算子映射:查映射表,把框架算子转成TVM算子 ├── 权重处理:initializer/parameter → TVM Constant ├── 动态shape处理:确定哪些维度是符号变量 └── 自定义算子注册:处理不支持的算子 → 输出:Relay / Relax IRModule简单说,前端就是个"翻译官"——把各种方言(ONNX、TorchScript、TF)翻译成统一的普通话(TVM IR)。
三、ONNX图解析
ONNX是模型交换的事实标准——几乎所有框架都能导出ONNX,几乎所有部署工具都支持ONNX。
3.1 解析流程
onnx.load("model.onnx") → 提取GraphProto(ONNX的图结构) → 遍历所有node,建立拓扑关系 → 对每个node,查convert_map表,找到对应的TVM算子 → 处理initializer(权重常量) → 处理输入shape/dtype,创建Relay/Relax Var → 构建Function和IRModule → InferType(推导所有张量的类型信息)3.2 代码示例
importonnxfromtvmimportrelay# 加载ONNX模型onnx_model=onnx.load("resnet18.onnx")# 解析为Relaymod,params=relay.frontend.from_onnx(onnx_model,shape={"input":[1,3,224,224]},dtype={"input":"float32"},)# 补全类型信息mod=relay.transform.InferType()(mod)print(mod)3.3 opset版本适配
ONNX的算子语义会随opset版本变化——比如opset 11和opset 13的Resize语义就不一样。
TVM前端需要针对不同opset版本做适配。比如opset 13的bias_add和opset 11的实现可能不同。
💡常见坑:ONNX模型的opset版本太低或太高,都可能导致前端解析失败。太低的话缺少某些算子,太高的话TVM还没适配。
四、TorchScript图解析
TorchScript是PyTorch的静态图表示。如果你用的是PyTorch模型,可以直接通过TorchScript解析,不用绕ONNX。
4.1 解析流程
torch.jit.trace(model, example_input) # 用示例输入trace一次 → 得到Scripted Model(TorchScript) → 遍历graph nodes → 对每个ATen算子,查映射表 → 处理parameters为Constant → 构建Relay/Relax Function4.2 代码示例
importtorchfromtvmimportrelay# 准备PyTorch模型和示例输入model=torch.hub.load("pytorch/vision","resnet18",pretrained=True)model.eval()example_input=torch.randn(1,3,224,224)# 导出TorchScriptscripted=torch.jit.trace(model,example_input)# 解析为Relaymod,params=relay.frontend.from_pytorch(scripted,shape_list=[("input",example_input.shape)],)4.3 ONNX vs TorchScript:怎么选?
| 特性 | ONNX | TorchScript |
|---|---|---|
| 通用性 | 跨框架,所有框架都支持导出 | 主要面向PyTorch |
| 动态控制流 | 较弱 | 原生支持if/loop(用script而不是trace) |
| 自定义算子 | 需要自定义op | 可以直接导出ATen算子 |
| 生态工具 | 可视化工具多(Netron等) | 需要看ATen图 |
一般建议:
- 如果模型是PyTorch的,而且结构比较常规 → 走ONNX(工具多、生态成熟)
- 如果模型有复杂控制流或自定义算子 → 走TorchScript(信息更完整)
五、动态shape处理:前端的硬骨头
动态shape是大模型推理的刚需——batch size、sequence length随时在变。
但动态shape也是前端的难点——shape不确定,很多优化就没法做。
5.1 Relay的动态shape策略(老办法)
Relay本质上是静态图IR,对动态shape支持较弱。常用的workaround:
方案一:固定shape编译
为每个常见的batch size单独编译一个产物。比如batch=1、batch=4、batch=8各编一个。
优点:性能最好(完全静态优化)
缺点:产物多,占空间;不常见的batch size要重新编译
方案二:bucket编译
预编译几个"桶"的尺寸,运行时选最近的那个桶。比如输入batch=5,就用bucket=8的产物(多余的padding掉)。
优点:产物数量可控
缺点:有padding浪费,性能略有损失
方案三:VM模式
用Relay VM(虚拟机)执行,支持动态shape。
优点:灵活,真正的动态
缺点:性能比全静态编译差不少
5.2 Relax的原生动态shape(新办法)
Relax从设计之初就支持动态shape——用符号变量表示未知维度。
fromtvmimportrelaxfromtvm.relax.frontendimportnnclassDecodeStep(nn.Module):defforward(self,x:nn.Tensor):# x的shape是 [batch, seq_len, hidden]# seq_len是动态符号变量returnnn.softmax(x,axis=-1)model=DecodeStep()mod,params=model.export_tvm(spec={"forward":{"x":nn.spec.Tensor([1,"seq_len",768],"float32")}})看到了吗?"seq_len"是个字符串,代表符号变量——编译期不知道具体是多少,运行时才知道。
5.3 动态shape的难点
动态shape听起来美好,但实现起来有很多挑战:
| 挑战 | 说明 |
|---|---|
| 内存规划难 | 编译期不知道具体尺寸,峰值显存没法精确估算 |
| 算子融合受限 | 动态维度可能破坏某些融合模式 |
| 后端支持不一 | 某些后端(如TensorRT)对动态shape支持有限,需要特殊处理 |
| 调度更复杂 | TIR层的循环分块、线程绑定都要支持动态维度 |
这也是为什么Relax虽然好,但生态还在完善中——动态shape的全链路支持是个大工程。
六、自定义算子注册:遇到不支持的算子怎么办?
TVM不可能支持所有算子。遇到不支持的算子怎么办?自己注册一个。
6.1 算子映射表
前端维护了一个映射表,把框架算子名映射到TVM算子:
ONNX算子名 TVM算子 Conv → nn.conv2d MatMul → nn.matmul / nn.dense BatchNorm → nn.batch_norm Softmax → nn.softmax ...如果ONNX模型里有一个TVM不认识的算子,前端解析就会报错。
6.2 怎么注册自定义算子(Relax版)
Relax中注册自定义算子,通常通过call_pure_packed或call_tir:
fromtvmimportrelax# 注册一个自定义算子@relax.op.register_op("relax.custom_op")defcustom_op(x):returnrelax.call_pure_packed("tvm.contrib.custom_op",# 底层的函数名x,sinfo_args=relax.TensorStructInfo(x.shape,x.dtype),# 输出结构信息)# 在前端中注册映射relax.frontend.torch.op_mapping["aten::custom_op"]=custom_op要点:
- 上层(Relax)定义算子的接口和语义
- 底层(PackedFunc或TIR)实现具体的计算
- 前端把框架算子映射到Relax算子
6.3 注册算子的四层完整链路
一个自定义算子,要在四层都注册才能跑通:
1. 前端层:把框架算子名映射到TVM算子 2. IR层:定义算子的语义、属性、类型推导 3. Compute/Schedule层:定义算子的计算和调度 4. 后端层:生成对应的硬件代码只写kernel不注册映射?前端解析时就失败了,根本走不到后端。
七、常见误区
误区一:“前端只是格式转换”
错!前端还要处理shape推断、dtype统一、权重常量转换、动态维度符号化、算子语义对齐。格式转换只是最表层的工作。
误区二:“动态shape直接交给Runtime就行”
完全动态shape在编译期无法做内存规划和算子融合,会牺牲性能。Relax的做法是保留部分编译期知识(符号关系),而不是完全Runtime化。
误区三:“自定义算子只需要写kernel”
错!自定义算子需要在前端、IR、compute/schedule、后端四层都注册。只写kernel,前端解析不过,根本用不了。
八、总结一下
今天这篇文章,我们讲了AI编译器的前端:
- 前端不只是格式转换——还要处理算子映射、动态shape、自定义算子等
- 两种主流输入:ONNX(通用、生态好)和TorchScript(PyTorch原生、控制流好)
- 动态shape处理:Relay用bucket/VM workaround,Relax原生支持符号变量
- 自定义算子注册:从前端到后端四层都要注册,缺一不可
下一篇预告:我们深入编译中端——图优化Pass。DCE、常量折叠、算子融合、布局转换……这些Pass都是干什么的?能带来多大收益?
📚本系列导航:
- 第1篇:AI编译器基础:三段式Pipeline与全链路协同
- 第2篇:TVM架构拆解:Relay、Relax、TIR与Runtime
- 第3篇:编译前端:ONNX、TorchScript与动态shape处理(本文)
- 第4篇:编译中端:图优化Pass全解析
- 第5篇:编译后端:CUDA Codegen、CUTLASS与TensorRT
- 更多内容持续更新中……