3. 模型转换与图优化验证

模型转换是部署链路的第一个高风险环节。训练框架里的模型经过导出、图优化、量化校准之后,最终交给推理引擎执行,中间任何一步都可能引入数值偏差、算子不兼容或形状约束丢失。本节围绕 ONNX 导出、算子融合、精度校准、动态形状支持和一致性检查五个关键点,说明每一步实际发生了什么、应该验证什么、以及如何判断转换是否可信。

ONNX 导出:从框架图到中间表示

ONNX 导出本质上是把训练框架的计算图翻译成一套与框架无关的算子图。以 PyTorch 为例,导出时模型会先被 trace 或 script,记录张量在算子间的流动,再映射到 ONNX 算子集。导出命令通常如下:

torch.onnx.export(
    model,                      # 已 eval() 的模型
    dummy_input,                # 与真实输入同形状、同 dtype
    "model.onnx",
    opset_version=17,           # 与目标推理引擎匹配
    input_names=["input"],
    output_names=["logits"],
    dynamic_axes={"input": {0: "batch", 2: "height", 3: "width"}},
    do_constant_folding=True
)

这里有几个容易出错的点。第一,模型必须处于 eval() 状态,否则 BatchNorm 和 Dropout 会以训练模式导出,导致推理结果随机。第二,dummy_input 的 dtype 必须与真实推理一致,例如视觉模型常用 float32,而量化前的校准输入可能是 uint8。第三,opset_version 不是越高越好,需要与目标推理引擎(TensorRT、ONNX Runtime、OpenVINO 等)支持的算子集对齐,否则会出现算子无法解析。

导出完成后,应先用 onnx.checker.check_model 做结构校验,再用 onnx.shape_inference.infer_shapes 补全中间张量形状。如果导出日志里出现 TracerWarning,说明存在数据依赖的控制流被固化成了常量,这类模型在动态输入下极易出错,必须重点排查。

算子融合:图优化改变了什么

算子融合是图优化中最常见的一类变换,典型如 Conv+BN+ReLU 融合为单个 Conv、MatMul+Add 融合为 Gemm、LayerNorm 拆解为若干基础算子。融合的目的是减少 kernel 启动次数和中间张量的显存读写,但它会改变图中节点的数量和名称。

验证融合是否按预期发生,可以对比优化前后的节点统计。例如用 ONNX Runtime 的 optimized_model_filepath 导出优化后模型,再统计算子类型分布:

import onnx
from collections import Counter

def op_stats(path):
    m = onnx.load(path)
    return Counter(n.op_type for n in m.graph.node)

before = op_stats("model.onnx")
after  = op_stats("model_optimized.onnx")
print("BN before:", before.get("BatchNormalization", 0))
print("BN after :", after.get("BatchNormalization", 0))

如果优化后 BatchNormalization 数量降为 0,而 Conv 数量不变,说明 Conv+BN 融合生效。需要警惕的是,融合有时会改变数值行为:BN 的 epsilon 被折叠进卷积权重后,若权重精度不足,可能引入微小偏差。因此融合后的模型必须重新做一致性比对,而不能默认“优化等于等价”。

精度校准:量化模型的误差来源

当部署目标需要 INT8 推理时,必须用校准数据集统计激活值的动态范围,生成量化参数(scale 和 zero_point)。校准不是训练,它只前向跑一批代表性样本,记录每层激活的 min/max 或直方图分布。校准集的选择直接决定量化误差:如果校准集只覆盖简单场景,遇到复杂输入时激活值会溢出量化范围,导致精度骤降。

校准后应逐层检查量化误差。常用做法是对比 FP32 与 INT8 模型在同一批校准样本上的输出,计算余弦相似度和最大绝对误差。经验上,分类模型 top-1 精度下降应控制在 1% 以内,检测模型 mAP 下降应控制在 1~2 个百分点以内;若某层激活的量化误差显著高于相邻层,说明该层对量化敏感,可考虑保留为 FP16 或 FP32。

校准集不能使用训练集的随机子集敷衍了事,应覆盖真实部署中出现的各类输入分布,包括边界样本和长尾样本。校准集规模通常 100~500 张即可,但分布代表性比数量更重要。

动态形状支持:从固定 batch 到可变输入

很多部署场景要求 batch、序列长度或图像分辨率可变。导出时通过 dynamic_axes 把对应维度标记为符号维度,ONNX 图中该维度会变成字符串名称而非固定整数。但标记为动态不等于推理引擎真的支持动态,还需要在目标引擎中验证。

验证动态形状时,应至少测试三组不同形状的输入,例如 batch=1、batch=4、batch=8,或分辨率 640×640、1280×720、1920×1080。对每组输入分别运行推理,检查输出形状是否与输入匹配、数值是否稳定。如果某组形状报错,常见原因是图中存在被固化的常量形状(如 reshape 的目标形状写死),需要回到导出阶段修正。

转换前后一致性检查

一致性检查是转换验证的收口环节,核心是回答一个问题:转换后的模型在相同输入下,输出是否与原始模型足够接近。推荐的做法是固定一组测试输入,分别跑原始框架模型和转换后模型,逐输出比对。

import numpy as np

def compare(a, b, atol=1e-4, rtol=1e-3):
    a, b = np.asarray(a), np.asarray(b)
    assert a.shape == b.shape, f"shape mismatch {a.shape} vs {b.shape}"
    diff = np.abs(a - b)
    rel  = diff / (np.abs(a) + 1e-8)
    cos  = np.dot(a.ravel(), b.ravel()) / (
        np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
    return diff.max(), rel.max(), cos

max_abs, max_rel, cos = compare(ref_out, onnx_out)
print(f"max_abs={max_abs:.2e} max_rel={max_rel:.2e} cos={cos:.6f}")

判断标准需要分阶段设定:FP32 导出阶段,最大绝对误差通常应在 1e-4 量级,余弦相似度高于 0.9999;INT8 量化阶段,误差会明显放大,余弦相似度高于 0.99 通常可接受,但最终仍要以任务指标(精度、mAP、BLEU 等)为准。如果一致性检查不通过,排查顺序建议是:先确认输入预处理是否完全一致,再检查 opset 与引擎版本,最后定位到具体算子逐层比对。

模型转换验证链路与一致性检查 原始框架模型 PyTorch / TensorFlow eval() 模式 ONNX 导出 opset / dynamic_axes checker + shape_infer 图优化 / 算子融合 Conv+BN+ReLU 节点统计对比 精度校准 校准集统计 min/max 生成 scale / zero_point 目标推理引擎 TensorRT / ONNX Runtime / OpenVINO 转换前后一致性检查 输入:固定测试样本(覆盖多形状) 比对:max_abs / max_rel / 余弦相似度 判据:FP32 cos > 0.9999;INT8 cos > 0.99 不通过 → 查预处理 → 查 opset → 逐层定位算子 动态形状验证 batch=1 / 4 / 8,多分辨率 不通过则回退修正

把上述五个环节串起来看,转换验证的实质是建立一条可追溯的证据链:导出阶段确认图结构正确,融合阶段确认优化符合预期,校准阶段确认量化误差可控,动态形状阶段确认输入约束满足,一致性检查阶段确认端到端数值可信。任何一环缺失,都可能让一个在训练环境表现良好的模型在部署后出现难以定位的精度问题。实际工程中,建议把这套检查固化成脚本,每次模型更新后自动执行,并将误差指标记录到版本对比表中,这样才能在模型迭代过程中持续守住部署质量。