云计算百科
云计算领域专业知识百科平台

为什么 .pt 能转 onnx?onnx 到底是什么?为什么部署都要先过一道 ONNX?—— 亲手跑一遍,快 5 倍的原因全在这

动手跑过才敢写。本文所有结论都来自我在自己环境里亲手跑出来的真实输出,一个推演、一个拍脑袋的结论都没有。环境:torch 2.10.0+cu128 / onnx 1.21.0 / onnxruntime 1.26.0。

引言:三个每天都想问的问题

做推理部署的人,几乎天天和 .pt、.onnx 打交道。但大多数人是"会复制命令,不懂原理":

  • 为什么 torch.onnx.export 一下,.pt 就能变成 .onnx?它不是两种完全不同的文件吗?
  • ONNX 到底是什么东西?一个文件格式?
  • 为什么市面上的模型都要先转成 ONNX 这个"中间态",再转成 TensorRT / OpenVINO / RKNN?
  • 为什么都说 ONNX 推理比 .pt 快?它哪来的底气?
  • 这篇我用一个 8.7 万参数的小 CNN 当实验对象,把 .pt 和 .onnx 都拆开给你看,再实测推理速度,用数据回答这四个问题。
    在这里插入图片描述


    第一章、撕开外壳:.pt 和 .onnx 分别是什么格式?

    和上一篇拆 .pt 一样,先看文件头(魔数)。保存一个 state_dict 再用二进制读前 20 字节:

    with open('pt_onnx_blog/tinycnn.pt', 'rb') as f:
    print(repr(f.read(20)))

    我环境里真实输出:

    b'PK\\x03\\x04\\x00\\x00\\x08\\x08\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00'

    PK\\x03\\x04 是 ZIP 的魔数。所以 .pt 本质是个 ZIP 压缩包。用 zipfile 看里面装了什么:

    tinycnn/data.pkl
    tinycnn/.format_version
    tinycnn/.storage_alignment
    tinycnn/byteorder
    tinycnn/data/0
    tinycnn/data/1
    tinycnn/data/2
    tinycnn/data/3

    data.pkl 是 Pickle 序列化的对象结构,data/0、data/1… 是每个张量的二进制数据。也就是说:.pt = ZIP 外壳 + Pickle 内核 + 独立存储的张量。它存的是一堆带名字的 numpy 张量,外加"怎么把它们装回类"的说明。

    那 .onnx 呢?同样读文件头:

    b'\\x08\\n\\x12\\x07pyto…'

    这是 Protobuf(Protocol Buffers) 的编码。\\x08\\n 表示 ir_version=10,\\x12\\x07pyto… 是 producer 字段,内容是 pytorch_export…。也就是说 ONNX 是一个用 Protobuf 序列化的、描述"计算图"的标准文件。

    用 onnx 库把它解析成人类能看懂的样子:

    %input[FLOAT, 1x3x112x112] # 输入
    initializers( # 权重(initializer 直接挂在图里)
    %conv1.weight[FLOAT, 16x3x3x3]
    %fc.weight[FLOAT, 10×25088]

    )
    %getitem = Conv(…)(%input, %conv1.weight, %conv1.bias)
    %relu = Relu(%getitem)
    %max_pool2d = MaxPool(%relu)

    到这里两个文件的本质就清楚了:

    .pt.onnx
    本质 ZIP + Pickle Protobuf 序列化的计算图
    存什么 张量 + “如何装回类” 算子节点 + 权重 + 数据流(有向图)
    依赖什么 必须有对应的 Python 类定义 不依赖任何框架,纯数据描述
    计量单位 张量 图的节点(算子)

    一个存的是"参数和类",一个存的是"一张算子组成的数据流图 + 参数"。这就是两者最根本的区别,也是后面所有问题的钥匙。


    第二章、为什么 .pt 能转成 onnx?

    因为任何一个 PyTorch 模型,本质上都"长着一张计算图",只是平时被藏起来了。

    训模型时你写的是 forward(),里面是一串算子:Conv、ReLU、BatchNorm、MaxPool、Linear……PyTorch 底层(ATen)本来就把它们编排成了一张 动态计算图。转 ONNX 要做的,就是把这团"藏着图"的 Python 对象,用一批真实输入喂它跑一遍,把这条路径上的算子、形状、权重全部固化下来,再翻译成 ONNX 的图格式。

    一句话:torch.onnx.export 做的事 = 用真实输入跑一遍前向(trace)+ 把算子翻译成 ONNX 标准算子 + 把权重固化成 initializer。

    我导出的图上,torch.onnx 在翻译时顺手做了一件事:把 BatchNorm 直接熔进了 Conv。看算子分布就露馅了:

    算子节点总数: 8
    算子类型分布: {'Conv': 2, 'Relu': 2, 'MaxPool': 2, 'Reshape': 1, 'Gemm': 1}

    我的模型明明有 2 个 BatchNorm 层,但导出的 ONNX 图里一个 BatchNorm 节点都没有。因为 BN 在推理时只是一组 scale/bias/shift,可以折叠进前面的 Conv 权重里。这就是图优化(算子融合)的第一步,也是 ONNX 能变快的第一个原因,后面还会展开。

    所以"为什么能转":同一个模型,PyTorch 眼里是"类 + 参数",ONNX 眼里是"算子图 + 参数"。导出只是做了个翻译,把前者翻译成后者,信息不丢,还顺带优化了一下。

    完整导出代码(可复现)

    import torch, torch.nn as nn

    class TinyCNN(nn.Module):
    def __init__(self):
    super().__init__()
    self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
    self.bn1 = nn.BatchNorm2d(16)
    self.relu = nn.ReLU()
    self.pool = nn.MaxPool2d(2)
    self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
    self.bn2 = nn.BatchNorm2d(32)
    self.fc = nn.Linear(32 * 28 * 28, 10)
    def forward(self, x):
    x = self.pool(self.relu(self.bn1(self.conv1(x))))
    x = self.pool(self.relu(self.bn2(self.conv2(x))))
    x = x.view(x.size(0), 1)
    return self.fc(x)

    model = TinyCNN().eval()
    x = torch.randn(1, 3, 112, 112)

    torch.save(model.state_dict(), "tinycnn.pt") # 存权重
    torch.onnx.export(
    model, x, "tinycnn.onnx",
    input_names=["input"], output_names=["output"],
    opset_version=17,
    )
    print("参数量:", sum(p.numel() for p in model.parameters())) # 87114

    注意:torch.onnx.export 需要你传入一个真实输入 x,这正是"trace"的要求——它得跑一遍才知道图长什么样。


    第三章、为什么市面上的模型都要一个 ONNX 中间态?

    因为 ONNX 不是"某种推理引擎的私有格式",而是深度学习模型的"通用语言 / 中间表示(IR)"。

    现实里,训完一个模型,你想部署到:

    • NVIDIA 显卡 → 用 TensorRT(TRT 引擎)
    • Intel CPU / 集显 → 用 OpenVINO(IR)
    • 瑞芯微 Rockchip 板子 → 用 RKNN
    • 手机/树莓派 → 用 TFLite / NCNN / MNN……

    你不可能每个硬件都让 PyTorch 原生支持。于是 ONNX 就成了"万能中转站":

    PyTorch(.pt) ──导出──▶ ONNX ──转换──▶ TensorRT / OpenVINO / RKNN / NCNN …
    TensorFlow(.pb) ──▶ ONNX ─────────▶ 同一个 ONNX,喂给任意引擎

    为什么需要一个"中间态"而不是"每个引擎各自读 .pt"?

  • 跨框架:ONNX 是开放标准,PyTorch、TensorFlow、Paddle 都能导出 ONNX,各家的模型都能汇到同一条部署流水线上。
  • 跨硬件:每个硬件厂商只要实现"读 ONNX → 转成自己的格式",就一次性支持了所有框架的模型,不用为每个框架各写一遍转换器。
  • 解耦训练与部署:ONNX 不依赖任何 Python 类。训练端升级框架,只要导出的 ONNX 标准不变,部署端完全无感。
  • 所以"ONNX 中间态"的价值,类比一下就是:大家都在用通用格式(比如 PDF),而不是每家浏览器各出个私有格式。ONNX 就是模型界的 PDF。

    注意一个容易混淆的点:ONNX 本身不是"最终部署格式",而是"分发/交换格式"。真正上线跑得飞快的,是各引擎把 ONNX 再转出来的原生引擎文件(TRT 引擎、RKNN 模型等)。ONNX 站在"源头和终点之间"。


    第四章、为什么 ONNX 推理比 .pt 快?—— 实测 5.11 倍

    先说破一个误区:快的不是"ONNX 这个文件",而是运行它的推理引擎 ONNX Runtime(ORT)。.onnx 文件本身只是一张图,是一段描述,不是编译好的二进制。速度来自"用谁去执行这张图"。

    我拿同一个模型,同一张 1×3×112×112 输入,在 CPU 上跑 200 次取平均(先 warmup):

    PyTorch eager 平均: 0.877 ms
    ONNX Runtime 平均: 0.172 ms
    加速比: 5.11x

    输出一致性也验证了(浮点误差可忽略):

    最大绝对误差: 1.45e-07

    一个 eager 模式,一个 ORT 引擎,为什么差 5 倍?三个原因,其中第二点是核心。

    原因一:没有 Python 解释器开销

    PyTorch eager 模式,每执行一个算子都要走一遍 Python 解释器 → 派发到 C++/CUDA kernel 的链路,一层层函数调用、对象创建、GIL、动态形状检查。ORT 是纯 C++ 运行时,把整张图 load 进去后,一次调用 sess.run() 就能把整条图跑完,算子之间的数据在内存里直接传递,不需要来回越过 Python 边界。

    对小算子、小模型,Python 的调度开销占比尤其大,所以加速比越明显。模型超大、算子超大时,Python 调度占比下降,加速比会缩水——但"省掉 Python 开销"这点始终成立。

    原因二:图级优化(算子融合 + 常量折叠)—— 实测 17.9 倍

    这是最硬核的一点。ORT 在加载 ONNX 时会做一系列图变换:

    • 算子融合:把 Conv + BatchNorm + ReLU 熔成一个 ConvRelu、Conv+Add+Relu 熔成一个 FusedConv 等,减少内存读写次数和 kernel 启动次数。
    • 常量折叠:图里能提前算的常量(如某些 shape 计算)在加载时就算完,推理时不再算。
    • 死代码消除 / 冗余消除:删掉不影响输出的节点。

    我用同一个 ONNX 文件,一只开图优化、一只关掉,实测:

    图优化开启 (默认 ALL): 0.111 ms
    图优化关闭 (DISABLE) : 1.993 ms
    优化带来的加速 : 17.91x

    光"图优化"一项就带来 18 倍差距。 这充分说明:ONNX/ORT 的加速大头,是把"一张朴素算子图"和"融合后的高效算子图"的区别,而不是文件格式本身有多玄。

    原因三:静态图 + 固定形状 → 内存规划与 kernel 选择

    ONNX 图结构固定、形状固定(导出时定了 1×3×112×112),ORT 可以在会话初始化阶段就完成内存池规划、kernel 选择、多线程调度编排,推理时零动态决策。而 PyTorch eager 每次前向都要现场决定形状、分配内存。

    小结:为什么 onnx 快

    加速来源原理实测影响
    无 Python 解释器开销 一次 sess.run() 跑完整图 明显
    图级优化(算子融合等) Conv+BN+ReLU 融合、常量折叠、死码消除 单独 17.9×
    静态图 + 固定形状 提前规划内存与 kernel 明显

    公平起见补充一句:PyTorch 也有一套 torch.compile / TorchScript 静态化方案来缩小差距,但 ONNX 生态的跨硬件通用性 + 成熟的图优化,让它成为部署事实标准。


    第五章、完整可复现脚本

    conda activate ysj310-gpu
    pip install onnx onnxruntime onnxscript

    python exp1_export.py # 导出 .pt 和 .onnx
    python exp2_inspect_speed.py # 解剖图 + 推理速度对比
    python exp3_opt.py # 图优化开/关对比
    python exp4_magic.py # 看两个文件的魔数

    exp2_inspect_speed.py 代码:

    import onnx
    import time
    import torch
    import torch.nn as nn
    import numpy as np
    import onnxruntime as ort

    # ———- 1. 解剖 ONNX 图 ———-
    m = onnx.load("pt_onnx_blog/tinycnn.onnx")
    print("=== ONNX 图结构 ===")
    print("输入:", [(i.name, [d.dim_value for d in i.type.tensor_type.shape.dim]) for i in m.graph.input])
    print("输出:", [(o.name, [d.dim_value for d in o.type.tensor_type.shape.dim]) for o in m.graph.output])
    print("算子节点总数:", len(m.graph.node))
    from collections import Counter
    ops = Counter(n.op_type for n in m.graph.node)
    print("算子类型分布:", dict(ops))
    # 打印前 8 个节点展示计算图
    print("前8个节点:")
    for n in m.graph.node[:8]:
    print(" ", n.op_type, "in:", list(n.input), "->", list(n.output))

    # ———- 2. 重建 torch 模型,对比推理速度 ———-
    class TinyCNN(nn.Module):
    def __init__(self):
    super().__init__()
    self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
    self.bn1 = nn.BatchNorm2d(16)
    self.relu = nn.ReLU()
    self.pool = nn.MaxPool2d(2)
    self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
    self.bn2 = nn.BatchNorm2d(32)
    self.fc = nn.Linear(32 * 28 * 28, 10)
    def forward(self, x):
    x = self.pool(self.relu(self.bn1(self.conv1(x))))
    x = self.pool(self.relu(self.bn2(self.conv2(x))))
    x = x.view(x.size(0), 1)
    return self.fc(x)

    torch.manual_seed(0)
    model = TinyCNN().eval()
    # 为了公平,给 torch 模型也做 torch.compile 之外的基础优化:关掉梯度
    model = model.float()
    x = torch.randn(1, 3, 112, 112)

    # warmup
    with torch.no_grad():
    for _ in range(10):
    model(x)
    torch.cuda.synchronize() if torch.cuda.is_available() else None

    def bench(fn, n=200):
    t0 = time.perf_counter()
    for _ in range(n):
    fn()
    if torch.cuda.is_available():
    torch.cuda.synchronize()
    return (time.perf_counter() t0) / n * 1000 # ms

    with torch.no_grad():
    torch_ms = bench(lambda: model(x), 200)

    # ———- onnxruntime CPU ———-
    sess = ort.InferenceSession("pt_onnx_blog/tinycnn.onnx", providers=["CPUExecutionProvider"])
    inputs = {sess.get_inputs()[0].name: x.numpy()}
    for _ in range(10):
    sess.run(None, inputs)
    ort_ms = bench(lambda: sess.run(None, inputs), 200)

    print(f"\\n=== 推理速度对比 (单张 112×112, CPU) ===")
    print(f"PyTorch eager 平均: {torch_ms:.3f} ms")
    print(f"ONNX Runtime 平均: {ort_ms:.3f} ms")
    print(f"加速比: {torch_ms/ort_ms:.2f}x")

    # ———- 数字一致性 ———-
    with torch.no_grad():
    torch_out = model(x)
    ort_out = sess.run(None, inputs)[0]
    print(f"\\n=== 输出一致性 ===")
    print("torch 输出 shape:", torch_out.shape, "| ort 输出 shape:", ort_out.shape)
    diff = np.abs(torch_out.numpy() ort_out)
    print(f"最大绝对误差: {diff.max():.2e}")


    总结:一张表读懂

    问题答案验证方式
    .pt 是什么 ZIP 外壳 + Pickle 内核 + 独立张量文件 读魔数 PK\\x03\\x04,zipfile 看 data.pkl
    .onnx 是什么 Protobuf 序列化的计算图(算子 + 权重) 读文件头 \\x08\\n,onnx 库打印图
    为什么能转 模型本来就有计算图,导出=trace 一遍 + 翻译算子 + 固化权重 torch.onnx.export,图里 BN 被熔进 Conv
    为什么需要中间态 ONNX 是跨框架、跨硬件的"通用 IR",各引擎再转私有格式 画出 PT→ONNX→TRT/RKNN 链路
    为什么 ONNX 更快 ① 无 Python 开销 ② 图级算子融合 ③ 静态图内存规划 实测 5.11×;单图优化 17.9×

    最后一句大白话:.pt 存的是"参数和类",.onnx 存的是"一张静态算子图 + 参数"。能转是因为模型本质就是一张图;需要中间态是因为 ONNX 是各种硬件引擎都认的"通用语言";变快不是因为文件换了个格式,而是交给了 ONNX Runtime 这种纯 C++ 的、带图优化的静态推理引擎去执行。

    引申彩蛋:如果你想让 ONNX 再快,通常会再用 onnxsim(onnx-simplifier)把图再撸一遍(拓扑排序、常量折叠、消除冗余 reshape 等),然后交给 TensorRT 生成引擎。那又是另一篇故事了。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 为什么 .pt 能转 onnx?onnx 到底是什么?为什么部署都要先过一道 ONNX?—— 亲手跑一遍,快 5 倍的原因全在这
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!