ONNX模型导出实战:PyTorch 导出 ResNet18 模型

PyTorch 导出 ResNet18 为 ONNX:实验全记录(输入输出 shape 与算子清单解读)

一句话让Agent变成昇腾专家,昇腾任务轻松搞定。评测入口:请按这个开源仓接入昇腾图谱 https://gitcode.com/agent0/kg-tools

训练框架里的模型和部署世界的模型之间隔着一次"导出"。对昇腾部署链路来说,atc --framework=5 那条命令吃进去的 .onnx 文件,就是从这个导出动作来的。

这篇实验记录把整个过程走一遍:用 PyTorch 把 ResNet18 导出成 ONNX,逐项确认输入输出 shape 、算子清单 、以及数值是否与原模型对齐。脚本五段五步、随机种子固定------全程 CPU 可跑、不需要任何 NPU,每次运行的数值结果完全一致,可复现。

一、实验设计

目标很朴素,回答三个问题:

  1. 导出的 ONNX,输入输出的 shape 和 dtype 到底是什么?每一维什么含义?
  2. 图里有哪些算子?和 ResNet18 的结构对得上吗?
  3. 这份 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

相关推荐
Andreapiki1 小时前
数字化审计校招技术备考指南:SQL、Python、BI、RPA学习路径
python·sql·rpa
cu1431 小时前
细谈GM8775C的具体功能和应用
c语言·c++·人工智能·嵌入式硬件
haliu1 小时前
【FHE 同态加密】我们如何实现同态加密推理(十四):为什么 `RESULT=PASS` 不是判据(纯 C11 · 零依赖)
人工智能·嵌入式·c·fhe·推理引擎·c11·边缘推理·同态加密推理
Rebecca_aLi1 小时前
码匠教育:零基础学 Python,标识符到循环全套基础语法解析
开发语言·python·正则表达式
wuyk5551 小时前
Python实战项目05:JSON数据解析与数据可视化小案例|全套实战闭环
python·信息可视化·json
pjj198541 小时前
NLP-情感分析项目(四):训练评估 + 主函数
人工智能·深度学习·机器学习
belldeep1 小时前
python:wps2md
python·wps2md
微三云生态系统架构师-彭丹1 小时前
微团AI红包风控与反作弊引擎:设备指纹与红包池熔断架构
人工智能·架构
IvanCodes1 小时前
Python 正则表达式(十四):文本匹配、查找与替换
开发语言·python·正则表达式