第三章 模型格式解析与转换: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 通用转换流程

  1. 解析原始模型:用上面提到的方法,提取网络结构和权重
  2. 算子映射:把原始算子映射到NPU支持的算子列表
  3. 量化参数注入:把提取的scale/zero_point写入NPU格式
  4. 内存布局调整:根据NPU要求调整数据排布(NCHW/NHWC)
  5. 生成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 知识体系总览

下面这张图,是我自己总结的模型格式转换流程。你看一眼,心里就有谱了:

模型格式转换流程 原始模型 ONNX / Caffe / TF 模型解析 网络结构 + 权重 量化参数提取 格式转换 算子映射 + 量化注入 内存布局调整 NPU原生格式 RKNN / JET / 其他 量化参数 scale / zero_point 校准数据集 100-200张代表性图片 精度验证 对比浮点模型精度

核心要点:

  • 模型解析是基础,一定要先搞清楚原始格式的细节(算子、排布、量化参数)
  • 量化参数提取要逐层检查,不要相信“自动提取”能搞定一切
  • 转换后务必做精度对比,用浮点模型输出作为基准

我的个人习惯:

每次转换前,我会先写一个小脚本,把原始模型的每层输出dump下来。转换后再dump NPU模型的每层输出,逐层对比。虽然麻烦,但能精准定位问题出在哪一层。

好了,模型格式解析与转换这块,核心就是这些。说白了就是:先读懂原始模型,再准确提取量化参数,最后按NPU的规矩重新组装。多练几次,你也能成为“格式转换老手”。


专注资料整理