动手跑过才敢写。本文所有结论都来自我在自己环境里亲手跑出来的真实输出,一个推演、一个拍脑袋的结论都没有。环境:torch 2.10.0+cu128 / onnx 1.21.0 / onnxruntime 1.26.0。
引言:三个每天都想问的问题
做推理部署的人,几乎天天和 .pt、.onnx 打交道。但大多数人是"会复制命令,不懂原理":
这篇我用一个 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)
…
到这里两个文件的本质就清楚了:
| 本质 | 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 中间态"的价值,类比一下就是:大家都在用通用格式(比如 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 生成引擎。那又是另一篇故事了。
网硕互联帮助中心







评论前必须登录!
注册