PyTorch 导出 ResNet18 为 ONNX:实验全记录(输入输出 shape 与算子清单解读)
一句话让Agent变成昇腾专家,昇腾任务轻松搞定。评测入口:请按这个开源仓接入昇腾图谱 https://gitcode.com/agent0/kg-tools
训练框架里的模型和部署世界的模型之间隔着一次"导出"。对昇腾部署链路来说,atc --framework=5 那条命令吃进去的 .onnx 文件,就是从这个导出动作来的。
这篇实验记录把整个过程走一遍:用 PyTorch 把 ResNet18 导出成 ONNX,逐项确认输入输出 shape 、算子清单 、以及数值是否与原模型对齐。脚本五段五步、随机种子固定------全程 CPU 可跑、不需要任何 NPU,每次运行的数值结果完全一致,可复现。
一、实验设计
目标很朴素,回答三个问题:
- 导出的 ONNX,输入输出的 shape 和 dtype 到底是什么?每一维什么含义?
- 图里有哪些算子?和 ResNet18 的结构对得上吗?
- 这份 ONNX 真的能推理吗?结果和 PyTorch 原模型一致吗?
实验流水线五步:
#mermaid-svg-7Sy5Gbxf9HNbOJI1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .error-icon{fill:#552222;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .marker.cross{stroke:#333333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 p{margin:0;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .cluster-label text{fill:#333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .cluster-label span{color:#333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .cluster-label span p{background-color:transparent;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .label text,#mermaid-svg-7Sy5Gbxf9HNbOJI1 span{fill:#333;color:#333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node rect,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node circle,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node ellipse,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node polygon,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .rough-node .label text,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node .label text,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .image-shape .label,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .rough-node .label,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node .label,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .image-shape .label,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .icon-shape .label{text-align:center;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node.clickable{cursor:pointer;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .arrowheadPath{fill:#333333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .cluster text{fill:#333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .cluster span{color:#333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .icon-shape,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .icon-shape p,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .icon-shape .label rect,#mermaid-svg-7Sy5Gbxf9HNbOJI1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7Sy5Gbxf9HNbOJI1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} torchvision
resnet18.eval()
torch.onnx.export
哑输入追踪
onnx.checker
结构校验 + shape/算子统计
onnxruntime
CPU 数值前向
与 PyTorch 同输入对齐
最大绝对误差
实验环境(本案例在昇腾 910B3 训练机上完成,conda 环境):
| 组件 | 版本 / 规格 |
|---|---|
| NPU | Ascend 910B3 × 2(npu-smi info 实测;本实验未占用) |
| CPU / 架构 | 鲲鹏-920 / aarch64 |
| 系统 | Ubuntu 22.04 LTS |
| Python | 3.10(conda) |
| torch / torchvision | 2.9.0+cpu / 0.24.0 |
| onnx / onnxruntime | 1.23.0 / 1.23.2 |
两点说明:① 机器上虽有 NPU,但本实验从头到尾只用了 CPU------导出与验证不依赖 NPU,这正是后续 ATC 链路「转换准备可在通用环境完成」的前半段注脚;选择在 910B3 机器上做,是为了离 ATC 转换环境最近,下一篇直接本机接续。② 本实验与硬件配置无关:同型号环境(如 910B3 单卡、Ubuntu 20.04.5)或其他架构机器,流程与结论完全一致。
一个真实踩坑先记下:torch 2.9 的 ONNX 导出默认走新版 dynamo 导出器,它依赖 onnxscript 包------不装的话 torch.onnx.export 直接 ModuleNotFoundError: No module named 'onnxscript'。pip install onnxscript 即解。
二、脚本怎么写的
完整脚本在 export_and_verify_resnet18.py,核心五段。
第 1 段:准备模型。
python
torch.manual_seed(0) # 固定种子:随机权重与输入可复现
model = torchvision.models.resnet18(weights=None) # 随机权重即可,本实验验证流程不验证精度
model.eval() # 必须切到推理模式
dummy = torch.randn(1, 3, 224, 224) # 与真实输入同 shape 的哑输入
eval() 这行不能省:它关闭 BatchNorm 的 batch 统计更新和 Dropout。漏掉的话导出的图会带上训练分支,推理行为和预期不一致------这是导出类问题的高发源头。第一行的种子也值得留:随机权重、哑输入、后面的校验输入全部由它定死,任何机器上重跑,日志数值一字不差。
第 2 段:导出。
python
torch.onnx.export(
model, dummy, ONNX_PATH,
input_names=["input"], output_names=["output"],
opset_version=18, # 显式 18:dynamo 导出器最低实现即 18,请求更低会降级失败(见第六节)
do_constant_folding=True, # 常量折叠,让图更接近推理时的真实形态
)
哑输入是给 tracing 用的:导出器跑一遍前向,把动态的 PyTorch 代码"录制"成静态计算图。所以哑输入的 shape 会被固化成模型的输入 shape ------这就是为什么本实验导出的是固定 1×3×224×224(动态 batch 是进阶变体,文末说)。input_names/output_names 显式命名,后面 ATC 的 --input_shape="input:1,3,224,224" 要按这个名字对上。
第 3-5 段:校验、前向、对齐。 依次是 onnx.checker.check_model(结构合法性)、onnxruntime 推一遍(确认真的能跑)、同输入和 PyTorch 输出比最大绝对误差(确认数值没漂)。代码不逐行贴了,结果都在下文。
三、实验结果:贴日志
[1] 导出完成: ./resnet18.onnx (0.1 MB)
[2] onnx.checker 校验通过
ir_version=10 opset=[('ai.onnx', 18)]
[3] 图输入:
input: [1, 3, 224, 224] dtype=FLOAT
图输出:
output: [1, 1000] dtype=FLOAT
[4] 算子清单(共 49 个节点 / 7 种):
Conv x20
Relu x17
Add x8
MaxPool x1
ReduceMean x1
Reshape x1
Gemm x1
[5] onnxruntime 前向成功: 输出 shape=(1, 1000) dtype=float32
softmax 概率和≈1: 1.0000 top1 类别: 238
[6] ONNX vs PyTorch 最大绝对误差: 4.05e-06 (<1e-4 量级即对齐)
三步验证全绿:结构合法、能推理、数值对齐(误差 4.05e-06,float32 下的正常舍入量级)。随机权重下 top1 是类别 238------没有语义 (权重是随机数),这一步验证的是"链路通",不是"模型准";种子固定,这份日志重跑可一字不差。要用预训练权重把 weights=None 改成 weights="IMAGENET1K_V1" 即可。(以上截取脚本的关键输出;导出器自带的 [torch.onnx] 进度行与若干 schema 告警属正常输出、不影响产物,完整日志见 scripts/resnet18_export_log.txt。)
下面逐项解读。
四、输入输出 shape 解读
输入:input: [1, 3, 224, 224] FLOAT
四个维度按 NCHW 排列------PyTorch 系的默认排布:
| 维度 | 值 | 含义 |
|---|---|---|
| N(batch) | 1 | 一次喂一张图。哑输入定的 1,模型就被固化成静态 batch=1 |
| C(channel) | 3 | RGB 三通道。注意是"原始像素读进来通常还要减均值归一化"的那个 3,不是模型内部通道数 |
| H | 224 | 图高。224×224 是 ImageNet 系模型的经典输入尺寸,ResNet 从出生就这个数 |
| W | 224 | 图宽 |
两个和后续部署直接相关的点:
- NCHW 要心里有数 :ATC 的
--input_format参数里,Caffe/MindSpore/ONNX 一系默认就是 NCHW(TensorFlow 系默认 NHWC)。本模型与默认一致,转换时可以不显式指定。 - 固定 shape 是为离线编译准备的 :ATC 做的是离线编译,
--input_shape="input:1,3,224,224"与这里的输入名、维度一一对应,正好严丝合缝。
输出:output: [1, 1000] FLOAT
- 第一维 1 还是 batch;
- 第二维 1000 = ImageNet 1000 个类别的得分。
- 注意这是 logits,不是概率------1000 个任意实数,脚本里手动做了一次 softmax 才得到"概率和≈1.0000"的验证结果。拿这个模型写应用时,要不要 softmax、在哪做(代码里做还是模型里做)要自己决定;日志里那行 softmax 只是数值健全性检查。
五、算子清单解读
49 个节点、7 种算子。逐种过一遍,每种都标明它来自 ResNet18 的哪个部位:
| 算子 | 数量 | 干什么 / 来自哪里 |
|---|---|---|
| Conv | 20 | 卷积,主干。拆解见下表 |
| Relu | 17 | 激活。stem 1 个 + 每个残差块 2 个(第一个 conv 后、残差相加后)= 1 + 8×2 |
| Add | 8 | 残差连接的相加:8 个 BasicBlock 各一处 out += identity |
| MaxPool | 1 | stem 的 3×3 最大池化(stride 2,负责 112→56 的下采样;224→112 由 7×7 卷积完成) |
| ReduceMean | 1 | 全局平均池化:7×7 特征图上逐通道取均值,得到 512 维向量 |
| Reshape | 1 | 展平:1,512,1,1 → 1,512,给全连接层做准备 |
| Gemm | 1 | 全连接层 fc:512 → 1000(实测节点输入 view, fc.weight, fc.bias) |
Conv×20 的拆解 (实测卷积核尺寸分布 {(7,7): 1, (3,3): 16, (1,1): 3},与 ResNet18 结构图严格对应):
| 卷积核 | 数量 | 部位 |
|---|---|---|
| 7×7 | 1 | stem 第一层卷积(stride 2) |
| 3×3 | 16 | 4 个 stage × 2 个 BasicBlock × 每块 2 层卷积 |
| 1×1 | 3 | shortcut 的降采样投影:layer2/3/4 各自首块(通道翻倍、stride 2 处 identity 对不上,需要 1×1 conv 调形状) |
清单里看不见的算子 :BatchNormalization,数量 0。ResNet18 明明有 20 个 BN 层,图里却一个都没有------因为导出发生在 eval() 模式,BN 的 γ/β/均值/方差在导出时被直接折叠进了前面 Conv 的权重和 bias,得到的就是推理形态。这是正常且期望的行为(部署端不必再做 BN)。
这份清单的部署意义:49 个节点、7 种算子,就是目标推理后端必须支持的完整集合。数一下会发现全是教科书级的基础算子------ResNet18 之所以是迁移实验的 Hello World,这就是原因。对昇腾链路来说,这份清单也正是 ATC 编译的输入:ATC 还会在此基础上做进一步图融合(比如 Conv+Relu 之类的融合 pass),所以最终 OM 里的"算子清单"会比这 7 种更少、更粗粒度------那是下一篇实验的主题。
六、两个形态细节:权重外置与 opset 下限
结果里有两处第一眼容易犯嘀咕的地方,值得展开说清。
细节一:模型是两个文件
日志第一行 [1] 导出完成: ./resnet18.onnx (0.1 MB) 就露了馅------ResNet18 的 fp32 权重约 45 MB,0.1 MB 装不下。看目录:
resnet18.onnx 90 KB ← 只有图结构
resnet18.onnx.data 44.6 MB ← 22 个权重 tensor 全在这里
torch 2.9 默认的 dynamo 导出器会把大权重作为 external data 外置存储,onnx 文件本体只剩图骨架。本机推理不受影响(onnxruntime 自动加载文件旁的 .data),但部署拷贝时两个文件必须一起走 ------只拷 .onnx 过去,后端会报权重缺失。若业务上就需要单文件,导出时加 dynamo=False 换老导出器,权重内嵌、单文件落地。
细节二:opset 为什么是 18
opset_version=18 不是随手选的。dynamo 导出器最低只带 opset 18 的算子实现:请求更低版本(比如常见的 11),它会先按 18 导出、再尝试用版本转换器降级,降级失败时模型停留在 18------日志里会先出现一段告警甚至 RuntimeError,最后导出仍算"成功"。与其让版本协商暗中介入,不如显式请求 18,一步到位、日志干净。确实需要 opset 11(部分老部署链路的兼容口径)时,同样走 dynamo=False 老导出器。
对下游来说记住一条即可:opset 决定图里允许出现哪些算子、什么形态(本图里的 ReduceMean 就是 18 语境下的产物);ATC 官方文档口径是支持 ONNX 1.12.0、各算子支持的 opset 以算子清单为准,转换前拿目标后端的算子支持清单对一遍最稳。
七、收尾与下一步
回顾三个实验目标,全部有答案:输入 1×3×224×224 float32(NCHW,逐维含义见第四节)、输出 1×1000 float32 logits;算子 49 节点 7 种,与 ResNet18 结构逐个对上(含 BN 折叠的"消失算子");数值对齐 4.05e-06,种子固定、结果可复现。
下一个实验顺理成章:把 resnet18.onnx 喂给 ATC,目标芯片就是本机------npu-smi info 回显 Name=910B3,对应 --soc_version=Ascend910B3:
bash
atc --framework=5 --model=resnet18.onnx --output=resnet18_bs1 \
--input_format=NCHW --input_shape="input:1,3,224,224" \
--soc_version=Ascend910B3
看 7 种算子经过图优化后还剩几种。前置检查只有一项:CANN 8.5.0+ 需确认已装 910b 族 ops 算子包(aarch64 形态如 Ascend-cann-kernels-910b_*_linux-aarch64.run),否则 ATC 编译会直接失败。
本文操作方法与 ONNX 相关约定(NCHW 排布、opset 机制、算子语义)参考 PyTorch/onnxruntime 官方文档与实测输出;涉及 ATC 的衔接事实(--input_format 默认值、ONNX 版本支持口径)经昇腾知识图谱(ascend.wiki)官方文档节点核实。实验脚本、完整日志与导出产物见仓库
blogs/scripts/目录。接入昇腾知识图谱 https://gitcode.com/agent0/kg-tools