理解 PyTorch、ONNX、RKNN 三种格式的本质差异——从 pickle 到 protobuf 到 NPU 指令。
1. 写在前面
本文是神经网络各层详解的姊妹篇——那篇文章解释了 Conv、Pool、FC、BN 等各层做了什么,本文聚焦这些层的权重和结构以什么格式存储、如何跨框架流转、最终怎么在 NPU 上执行。
我们沿用同一个例子:
输入: 28×28 灰度图
结构: Conv(1→8通道, 3×3核) → ReLU → MaxPool(2×2) → FC(→10类)
输出: 数字 0-9 的识别结果
2. 三种格式一句话概括
| 格式 | 本质 | 类比 |
|---|---|---|
.pt / .pth |
Python pickle + 浮点二进制 | "字典 + 一堆 float 数组,结构在代码里" |
.onnx |
protobuf 计算图 + 权重 | "一张蓝图:算子、连接、超参全写着" |
.rknn |
NPU 指令 + 量化权重 | "编译好的二进制,只能跑不能读" |
3. .pt 文件:Python 的序列化
3.1 文件结构(新版 PyTorch,zip 容器)
model.pt (ZIP)
├── archive/
│ ├── data.pkl ← pickle: 对象结构描述
│ ├── data/0 ← conv.weight: 72 × 4 = 288 字节
│ ├── data/1 ← conv.bias: 8 × 4 = 32 字节
│ ├── data/2 ← fc.weight: 13520 × 4 = 54080 字节
│ └── data/3 ← fc.bias: 10 × 4 = 40 字节
└── version
3.2 Pickle 是什么
Pickle 是一个栈式虚拟机字节码,不是文本格式:
指令 栈(底→顶)
─────────────────────────────────
EMPTY_DICT [ {} ]
MARK [ {}, (mark) ]
'conv.weight' [ {}, (mark), 'conv.weight' ]
<Tensor ref→0> [ ..., Tensor→data/0 ]
'conv.bias' [ ..., 'conv.bias', Tensor→data/1 ]
SETITEMS [ {'conv.weight':Tensor, 'conv.bias':Tensor} ]
STOP → 返回 dict
3.3 .pt 的能力边界
✅ 权重数值、张量形状
❌ stride / padding —— 不在文件里
❌ 后面是 ReLU 还是 Sigmoid —— 不在文件里
❌ 网络结构 —— 隐式在 Python 类代码里
本质:.pt 是一组数字 + 隐式公式。 没有 Python 源码,无法重建网络。
4. .onnx 文件:显式的计算蓝图
4.1 文件结构(protobuf 二进制)
ModelProto {ir_version: 8graph: GraphProto {node[0] { op_type: "Conv"input: ["input", "conv.weight", "conv.bias"]output: ["conv_out"]attribute: { kernel_shape: [3,3] strides: [1,1] pads: [0,0,0,0] } }node[1] { op_type: "Relu" input:"conv_out" output:"relu_out" }node[2] { op_type: "MaxPool" input:"relu_out" output:"pool_out"kernel_shape:[2,2] strides:[2,2] }node[3] { op_type: "Gemm" input:"pool_out_flat","fc.weight","fc.bias"output:"output" }initializer { name: "conv.weight" dims: [8,1,3,3] raw_data: <288 字节> }initializer { name: "fc.weight" dims: [10,1352] raw_data: <54080 字节> }}
}
4.2 .pt vs .onnx
.pt |
.onnx |
|
|---|---|---|
| 格式 | pickle + zip | protobuf |
| 包含权重 | ✅ | ✅ |
| 包含结构 | ❌(在代码里) | ✅(显式计算图) |
| stride/padding | ❌ | ✅ attribute |
| 激活函数 | ❌ | ✅ 独立节点 |
| 跨框架 | ❌ | ✅ |
本质:.onnx 是一组数字 + 显式公式。 任何 ONNX 运行时都能执行。
5. .rknn 文件:编译后的 NPU 二进制
5.1 ONNX → RKNN
ONNX 图解析├── 算子融合: Conv+BN+ReLU → 一个操作├── 量化: float32 → int8, 每个 tensor 附 scale + zero_point├── 内存规划: 输入 784B → conv 5408B → pool 1352B → fc 10B└── 生成 NPU 指令序列 → .rknn (不可读微码 + int8 权重)
5.2 NPU vs GPU
| GPU (OpenCL/CUDA) | NPU (RK3576) | |
|---|---|---|
| 算力 | ~50-100 GFLOPS | 6 TOPS (int8) |
| 功耗 | 几瓦 | < 1W |
| 计算方式 | im2col + GEMM | 专用 MAC 阵列 |
| 精度 | FP32/FP16 | int8/int16 |
| 适用 | 图形渲染 | 推理专用 |
本质:.rknn 是编译产物,类似 C 编译出的 ELF。
6. 全流程总结
┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐│ .pt │ ───→ │ .onnx │ ───→ │ .rknn │ ───→ │ RK3576 NPU ││ pickle │ │ protobuf │ │ NPU 指令 │ │ 6 TOPS │└─────────┘ └──────────┘ └─────────┘ └──────────────┘torch.save() torch. RKNN- rknn_run()onnx.export() Toolkit2.build()
7. 实操流程
# PC 端 (x86, conda rknn_toolkit2)
python -c "
import torch
from rknn.api import RKNNmodel = YourModel()
torch.onnx.export(model, dummy_input, 'model.onnx', opset_version=12)rknn = RKNN()
rknn.config(target_platform='rk3576', quantized_dtype='asymmetric_quantized-u8')
rknn.load_onnx('model.onnx')
rknn.build(do_quantization=True)
rknn.export_rknn('model.rknn')
"# RK3576 板端
python -c "
from rknnlite.api import RKNNLiterknn = RKNNLite()
rknn.load_rknn('model.rknn')
rknn.init_runtime(core_mask=0b11)while True:obs = get_observation()action = rknn.inference([obs])send_command(action)
"
附录:训练是怎么学的
A. 什么是"参数"
参数 = 训练中被修改的、存在 .pt 里的数值。
| 层 | 是参数 | 不是参数 |
|---|---|---|
| Conv | weight, bias |
stride, padding(超参,固定) |
| BN | γ, β |
μ, σ²(训练累积的统计量) |
| ReLU | — | 输出值(每次前向现场算) |
| FC | weight, bias |
— |
B. CrossEntropy:衡量"猜得有多错"
网络输出: [0.1, 0.0, 0.1, 7.8, 0.2, 0.3, ...] → 第 3 位最高,猜 "3"
正确答案: "5"① Softmax 把分数转概率
② 取正确答案(5)的概率: 0.0001
③ L = -log(0.0001) = 9.2概率越小 → loss 越大;概率=1 → loss=0
C. lr(学习率)
控制参数每步更新幅度的小正数(典型 0.001)。
W_new = W - lr × ∂L/∂Wlr 太大: 参数跳过头,震荡发散
lr 太小: 学得太慢
D. 链式法则:如何计算参数对错误的贡献
反向传播 = 从 L 出发,逐层往回算梯度。
前向: x ─→ Conv ─→ BN ─→ ReLU ─→ FC ─→ L反向: ∂L/∂x ← ∂L/∂Conv ← ∂L/∂BN ← ∂L/∂ReLU ← ∂L/∂FC ← ∂L/∂L=1
FC 层(前向 z = a·W + b):
∂L/∂W = ∂L/∂z × a ← z 对 W 求导 = a
∂L/∂b = ∂L/∂z × 1
∂L/∂a = ∂L/∂z × W ← 往前传CrossEntropy + Softmax 组合:∂L/∂z[i] = softmax(z)[i] - (i 是正确答案 ? 1 : 0)
ReLU:前向正 → 梯度原样传;前向负 → 截断为 0。
BN:
∂L/∂γ = Σ ∂L/∂y × x̂
∂L/∂β = Σ ∂L/∂y × 1
Conv:每个滑动窗口对核权重贡献一份,多个窗口覆盖的输入像素收到多份梯度求和。
数值例子
前向 (FC 层):a=[2.0, 3.0], W₅=[0.4, 0.6], z₅=2.9softmax(z)₅ = 0.15, L = 1.90反向:∂L/∂z₅ = 0.15 - 1 = -0.85∂L/∂W₅[0] = -0.85 × 2.0 = -1.70∂L/∂W₅[1] = -0.85 × 3.0 = -2.55∂L/∂b₅ = -0.85∂L/∂a[0] = -0.85 × 0.4 = -0.34 ← 往前传
全部机械化——每个算子有对偶的反向公式,L.backward() 全自动执行。
E. γ 和 β 如何"学习"
γ 和 β 跟 Conv 的 72 个权重没有本质区别——都通过反向传播更新:
前向: y = γ × x̂ + β
反向: ∂L/∂γ = Σ ∂L/∂y × x̂ ∂L/∂β = Σ ∂L/∂y × 1
更新: γ_new = γ - lr × ∂L/∂γ β_new = β - lr × ∂L/∂β
直观:每个通道自己学到"这个特征应该多大、偏多少才方便下一层用"。γ 大的通道说明该特征对任务更重要。