3. 模型转换与量化基础:从PyTorch/TensorFlow到ONNX、INT8量化原理、校准数据集准备

好,咱们进入第三章。这一章我打算聊聊模型转换和量化。说实话,这两个东西是嵌入式部署的「入场券」。你模型训练得再漂亮,落不了地,那就是空中楼阁。我自己刚入行那会儿,就吃过这个亏——模型在GPU上跑得飞起,一上板子直接崩了。后来才发现,是转换环节出了岔子。

3.1 为什么需要模型转换?

你想想看,PyTorch和TensorFlow是给训练设计的。它们灵活、动态,方便你调参。但嵌入式设备要的是「死」的、固定的计算图。说白了,就是要把训练框架的「花架子」拆掉,变成一套纯粹的数学运算指令。

ONNX(Open Neural Network Exchange)就是干这个的。它像一个中间语言。你用PyTorch写了个模型,导出成ONNX。再用TensorFlow写一个,也能导出成ONNX。然后,你的嵌入式推理引擎(比如ONNX Runtime、TensorRT、NCNN)就可以直接吃ONNX了。

我个人习惯,所有模型落地前,先过一遍ONNX。为什么?因为ONNX的计算图是标准化的,很多工具都能解析它。一旦卡住,排查问题也方便。

3.2 从PyTorch到ONNX

PyTorch导出ONNX,核心就一个函数:torch.onnx.export()。但这里面的坑,不少。

我给你看个典型例子:

import torch
import torchvision.models as models

# 加载一个预训练的ResNet18
model = models.resnet18(pretrained=True)
model.eval()

# 创建一个虚拟输入
dummy_input = torch.randn(1, 3, 224, 224)

# 导出ONNX
torch.onnx.export(
    model,               # 模型
    dummy_input,         # 输入张量
    "resnet18.onnx",     # 输出文件名
    export_params=True,  # 导出参数
    opset_version=11,    # ONNX算子集版本
    do_constant_folding=True,  # 常量折叠优化
    input_names=['input'],
    output_names=['output'],
    dynamic_axes={
        'input': {0: 'batch_size'},
        'output': {0: 'batch_size'}
    }
)

这里我重点说几个参数:

  • opset_version:这个很关键。版本太低,有些新算子不支持。版本太高,你的推理引擎可能又不认识。我一般用11或13,兼容性比较好。
  • dynamic_axes:允许动态batch。你想想看,如果每次推理的batch size都固定为1,那还好。但如果你要支持不同大小的输入,就必须打开这个选项。我在项目中遇到过,没设dynamic_axes,结果换了一张分辨率不同的图片,直接报shape不匹配。
  • do_constant_folding:建议打开。它会把一些固定的计算提前算好,比如BN层的参数融合。能省一点是一点。

注意: 导出前一定要调用 model.eval()。否则Dropout和BatchNorm的行为会不一样,导出的ONNX在推理时可能出错。我曾经因为这个,排查了整整一个下午。

3.3 从TensorFlow到ONNX

TensorFlow这边,稍微麻烦一点。因为TensorFlow有1.x和2.x两个版本,导出方式不同。

对于TensorFlow 2.x,推荐用tf2onnx这个工具。安装很简单:

pip install tf2onnx

然后命令行转换:

python -m tf2onnx.convert \
    --saved-model ./saved_model \
    --output model.onnx \
    --opset 13

或者用Python API:

import tf2onnx
import tensorflow as tf

# 加载SavedModel
model = tf.saved_model.load('./saved_model')

# 指定输入签名
spec = (tf.TensorSpec((None, 224, 224, 3), tf.float32, name="input"),)

# 转换
output_path = "./model.onnx"
model_proto, _ = tf2onnx.convert.from_function(
    model.signatures['serving_default'],
    input_signature=spec,
    opset=13,
    output_path=output_path
)

嗯,这里要注意。TensorFlow的图里经常有一些自定义算子,比如一些图像预处理操作。这些算子ONNX不一定认识。我建议,把预处理逻辑单独拿出来,在CPU上做,只把纯推理部分丢给ONNX。这样能省去很多麻烦。

3.4 INT8量化原理

模型转换完了,接下来就是量化。为什么需要量化?说白了,就是为了快和省。

你想想看,FP32的模型,一个参数占4个字节。INT8的模型,一个参数只占1个字节。模型体积直接缩小4倍。而且,INT8的矩阵乘法,在硬件上比FP32快得多。很多嵌入式芯片,比如高通、联发科的DSP,都对INT8做了专门优化。

量化的核心思想,就是用更少的bit来表示一个数值范围。FP32能表示的范围是 [-3.4e38, 3.4e38],精度很高。INT8只能表示 [-128, 127] 这256个整数。所以,我们需要做一个映射。

这个映射,通常用「缩放因子」和「零点」来描述:

real_value = scale * (quantized_value - zero_point)

其中,scale 是缩放因子,zero_point 是零点偏移。这两个参数,就是量化要确定的。

常见的量化方式有两种:

  • 对称量化:零点固定为0。适合权重这种正负对称的分布。
  • 非对称量化:零点可以偏移。适合激活值这种非负的分布。

我个人更倾向于非对称量化,因为它能更好地利用INT8的表示范围。比如ReLU的输出全是非负数,用对称量化会浪费一半的表示能力。

3.5 校准数据集准备

量化不是随便找个范围就行的。你需要知道,你的模型在真实数据上,每一层的激活值分布是什么样的。这个「摸底」的过程,就叫校准(Calibration)。

校准需要准备一个数据集。这个数据集不需要很大,但要有代表性。我一般从训练集中抽100-500张图片,覆盖各种场景。

校准的过程,说白了就是跑一遍推理,记录每一层激活值的最大值和最小值。然后根据这些统计值,计算出最佳的scale和zero_point。

常见的校准方法有:

方法 原理 特点
MinMax 直接取激活值的全局最小/最大值 简单,但对异常值敏感
KL散度 寻找一个阈值,使得量化前后的分布差异最小 效果较好,NVIDIA TensorRT默认使用
百分比 去掉一定比例的极端值(如99.9%分位) 折中方案,鲁棒性较好

我建议,如果时间允许,三种方法都试一下,看哪个在验证集上精度最高。我在项目中遇到过,MinMax方法因为一张过曝的图片,导致量化后精度暴跌。换成KL散度就好了。

小技巧: 校准数据集不要用训练集。因为训练集模型已经见过了,统计出来的分布可能过于乐观。最好用验证集或者专门采集的数据。

3.6 量化实战:PyTorch静态量化

来,我们看一个完整的PyTorch静态量化例子。静态量化,就是先校准,再量化。推理时不再调整参数。

import torch
import torch.quantization as quant

# 1. 准备模型
model = models.resnet18(pretrained=True)
model.eval()

# 2. 融合BN和Conv层(减少量化误差)
model = quant.fuse_modules(model, [['conv1', 'bn1', 'relu']])

# 3. 设置量化配置
model.qconfig = quant.get_default_qconfig('fbgemm')

# 4. 准备校准
model = quant.prepare(model, inplace=True)

# 5. 校准:用校准数据集跑几遍
def calibrate(model, data_loader, num_batches=10):
    model.eval()
    with torch.no_grad():
        for i, (inputs, _) in enumerate(data_loader):
            if i >= num_batches:
                break
            model(inputs)

calibrate(model, calibration_loader)

# 6. 转换到量化模型
quantized_model = quant.convert(model, inplace=False)

# 7. 保存
torch.save(quantized_model.state_dict(), 'resnet18_quantized.pth')

这里有几个关键点:

  • fuse_modules:把Conv+BN+ReLU融合成一个算子。这样量化时误差更小。我建议能融合的都融合掉。
  • qconfig:量化配置。fbgemm是Facebook针对x86优化的后端。如果你在ARM上跑,可以用qnnpack。
  • 校准的batch数:我一般用10-20个batch就够了。太多也没意义,反而浪费时间。

核心总结: 模型转换是「翻译」,量化是「压缩」。翻译要准确,压缩要保真。校准数据集就是那个「保真」的标尺。没有好的校准数据,量化就是盲人摸象。

好了,这一章就到这里。下一章,我们会深入推理引擎的底层,看看ONNX Runtime和TensorRT到底是怎么把模型跑起来的。到时候,我会分享一些我自己调优时踩过的坑,保证让你少走弯路。