第三章 模型格式解析与转换:ONNX、Caffe、TensorFlow模型结构解析、量化参数提取、转换为NPU原生格式
做NPU部署这些年,我有个很深的体会:模型格式转换,往往是整个移植流程里最磨人的环节。你想想看,训练好的模型可能是PyTorch的、TensorFlow的、Caffe的,但NPU只认自己的“方言”。这中间要是翻译错了,后面所有优化都白搭。
今天我们就来聊聊,怎么把这几种主流模型格式“吃透”,再把量化参数准确提取出来,最后转换成NPU能跑的原生格式。
3.1 主流模型格式的“脾气秉性”
先说说我接触最多的三种格式:ONNX、Caffe、TensorFlow。它们各有各的“性格”,处理方式也不一样。
3.1.1 ONNX:中间格式的“和事佬”
ONNX(Open Neural Network Exchange)说白了就是个“通用翻译官”。微软和Meta搞出来的,目的是让不同框架的模型能互相转换。我个人习惯把ONNX当作中间桥梁——不管原始模型是PyTorch还是TensorFlow,先转成ONNX,再转NPU格式,这样兼容性最好。
解析ONNX模型,我一般用onnx库自带的工具:
import onnx
model = onnx.load("model.onnx")
onnx.checker.check_model(model) # 先检查模型是否完整
# 打印网络结构
graph = model.graph
print(f"输入节点数: {len(graph.input)}")
print(f"输出节点数: {len(graph.output)}")
print(f"中间节点数: {len(graph.node)}")
# 遍历每个算子
for node in graph.node:
print(f"算子类型: {node.op_type}, 名称: {node.name}")
for input_name in node.input:
print(f" 输入: {input_name}")
for output_name in node.output:
print(f" 输出: {output_name}")
这里有个坑——ONNX版本兼容性问题。我曾经遇到一个模型,在onnx 1.8下能正常加载,换到1.12就报错。后来发现是某个算子(比如Resize)的接口变了。我的建议是:固定onnx版本,或者用官方推荐的版本转换。
3.1.2 Caffe:老牌框架的“倔强”
Caffe模型由两部分组成:.prototxt(网络结构)和.caffemodel(权重)。解析起来其实更直接,因为prototxt是明文文本。
我常用的解析方式:
import caffe
net = caffe.Net("deploy.prototxt", "model.caffemodel", caffe.TEST)
# 打印每层信息
for layer_name, layer in net.layers.items():
print(f"层名: {layer_name}, 类型: {layer.type}")
if layer.blobs:
for i, blob in enumerate(layer.blobs):
print(f" blob[{i}] shape: {blob.data.shape}")
嗯,这里要注意:Caffe的数据排布是NCHW,而很多NPU原生格式可能要求NHWC。转换时一定要搞清楚数据排布,否则推理结果全是错的。
3.1.3 TensorFlow:生态庞大的“巨无霸”
TensorFlow模型格式比较多:SavedModel、Frozen Graph、Checkpoint等。我建议用Frozen Graph(.pb文件)作为转换输入,因为它把网络结构和权重打包在一起,不容易出错。
解析TF模型,我习惯用tf.compat.v1:
import tensorflow as tf
# 加载冻结图
with tf.io.gfile.GFile("frozen_graph.pb", "rb") as f:
graph_def = tf.compat.v1.GraphDef()
graph_def.ParseFromString(f.read())
# 打印所有节点
for node in graph_def.node:
print(f"节点名: {node.name}, 算子: {node.op}")
for input_name in node.input:
print(f" 输入: {input_name}")
TF模型里有个让我头疼的地方——控制流节点(比如Switch、Merge)。这些节点在训练时有用,推理时完全是多余的。我曾经花了一整天手动清理这些节点,后来发现用tf.graph_util.remove_training_nodes可以自动处理。
3.2 量化参数提取:从模型里“挖”出关键信息
量化参数是NPU部署的“灵魂”。没有准确的量化参数,模型精度会掉得一塌糊涂。
量化参数主要包括:
- 缩放因子(scale):浮点数值到整数的映射比例
- 零点(zero_point):浮点0值对应的整数
- 量化类型:对称/非对称、per-tensor/per-channel
3.2.1 从ONNX提取量化参数
ONNX的量化模型(Q/DQ格式)会把量化参数放在初始值(initializer)里:
import onnx
import numpy as np
model = onnx.load("quantized_model.onnx")
graph = model.graph
# 提取所有量化参数
for init in graph.initializer:
if "scale" in init.name or "zero_point" in init.name:
data = np.frombuffer(init.raw_data, dtype=np.float32 if "scale" in init.name else np.int32)
print(f"{init.name}: {data}")
我个人习惯把量化参数整理成一个字典,方便后续写入NPU格式:
quant_params = {}
for node in graph.node:
if node.op_type == "QuantizeLinear":
scale_name = node.input[1]
zp_name = node.input[2]
# 从initializer中查找对应的值
...
3.2.2 从Caffe提取量化参数
Caffe的量化模型通常用INT8层表示。我遇到过一些Caffe模型,量化参数直接写在prototxt的quantization_param里:
layer {
name: "conv1"
type: "Convolution"
quantization_param {
scale: 0.0234
zero_point: 128
}
}
解析时直接用protobuf读取即可。不过要注意——Caffe的量化参数有时是per-layer的,不是per-channel。如果NPU要求per-channel量化,需要自己重新计算。
3.2.3 从TensorFlow提取量化参数
TF的量化模型(TFLite量化)会把参数存在tensor的min/max值里。我常用的方法:
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path="quantized_model.tflite")
interpreter.allocate_tensors()
# 获取所有tensor的量化参数
for tensor_idx in range(len(interpreter.get_tensor_details())):
tensor_detail = interpreter.get_tensor_details()[tensor_idx]
if tensor_detail['quantization']:
scale, zero_point = tensor_detail['quantization']
print(f"Tensor {tensor_detail['name']}: scale={scale}, zp={zero_point}")
这里有个经验:TF的量化参数默认是uint8的,而很多NPU要求int8。转换时记得把zero_point从128偏移到0。
3.3 转换为NPU原生格式
不同NPU的原生格式不一样。我以瑞芯微RKNN和地平线JET为例,说说通用流程。
3.3.1 通用转换流程
- 解析原始模型:用上面提到的方法,提取网络结构和权重
- 算子映射:把原始算子映射到NPU支持的算子列表
- 量化参数注入:把提取的scale/zero_point写入NPU格式
- 内存布局调整:根据NPU要求调整数据排布(NCHW/NHWC)
- 生成NPU模型:调用NPU厂商的转换工具
3.3.2 以RKNN为例
瑞芯微的RKNN转换工具使用起来比较直观:
from rknn.api import RKNN
rknn = RKNN()
# 加载ONNX模型
ret = rknn.load_onnx(model="model.onnx")
if ret != 0:
print("加载失败")
exit()
# 设置量化参数
rknn.config(quantized_dtype='asymmetric_quantized-8',
quantized_algorithm='normal',
quantized_method='channel')
# 构建RKNN模型
ret = rknn.build(do_quantization=True, dataset="calibration_dataset.txt")
if ret != 0:
print("构建失败")
exit()
# 导出RKNN模型
rknn.export_rknn("model.rknn")
这里要注意:校准数据集(calibration dataset)的质量直接影响量化精度。我建议用100-200张有代表性的图片,覆盖各种场景,不要只用纯色图。
3.3.3 常见问题与避坑
我曾经踩过的坑:
- 算子不支持:某些NPU不支持GELU、LayerNorm等算子。我的做法是手动替换成支持算子(比如用ReLU+乘加组合代替GELU)
- 量化精度损失:如果量化后精度掉得厉害,检查一下激活值的分布。我习惯用
np.histogram看激活值范围,如果分布太宽,考虑用per-channel量化 - 内存对齐:NPU通常要求tensor地址对齐(比如16字节对齐)。转换时记得检查,否则推理会崩溃
3.4 知识体系总览
下面这张图,是我自己总结的模型格式转换流程。你看一眼,心里就有谱了:
核心要点:
- 模型解析是基础,一定要先搞清楚原始格式的细节(算子、排布、量化参数)
- 量化参数提取要逐层检查,不要相信“自动提取”能搞定一切
- 转换后务必做精度对比,用浮点模型输出作为基准
我的个人习惯:
每次转换前,我会先写一个小脚本,把原始模型的每层输出dump下来。转换后再dump NPU模型的每层输出,逐层对比。虽然麻烦,但能精准定位问题出在哪一层。
好了,模型格式解析与转换这块,核心就是这些。说白了就是:先读懂原始模型,再准确提取量化参数,最后按NPU的规矩重新组装。多练几次,你也能成为“格式转换老手”。