ONNX 模型转换中的中间特征与数值对齐
一段推理代码换成 ONNX 后,输出形状完全一致,下游结果却发生了变化。问题可能出在算子计算,也可能更早:导出时取错了一层特征,验证时重新抽了一份随机噪声,或者模型外的一次缩放没有接回来。
在音频编码、图像潜变量与关键点模型的转换中,这些问题经常混在同一个误差数字里。有效的对齐需要先确定两边实际执行了什么,再用固定输入逐段比较。否则,即使误差很小,也未必验证了真正要替换的那条路径。
一、先明确要替换的是哪一段计算
应用调用的对象通常比神经网络本身多做几步:读取配置、处理输入、选择中间层、缩放特征,再把结果包装成下游需要的形式。直接导出模型类的默认输出,可能遗漏调用方依赖的行为。
例如,一个编码器的最终输出是
检查导出路径时,应沿着真正使用这些特征的代码往回看:
| 对象 | 需要核对的内容 |
|---|---|
| 输入 | 归一化、布局、有效长度和 padding 的位置 |
| 中间特征 | 取自哪些层,是否包含输入特征,是否经过最终归一化 |
| 组合方式 | 层顺序、时间顺序,以及 stack 和 concat 使用的轴 |
| 输出 | 缩放、转置、截断与下游索引方式 |
曾有一份编码器包装实现完整加载了最终归一化层的权重,但实际 forward 在各块输出处直接收集特征,没有调用这一层。这本身不能判定为错误:如果原调用方使用的就是归一化前特征,这样导出才是正确的。它说明,参数加载检查与计算路径检查是两件事。
比较时可以暂时把关键位置都暴露为输出,按层报告误差。若第一层已经不一致,继续调后端融合往往没有意义;若前面各层一致、只有拼接后出错,应先检查轴和顺序。定位完成后再移除调试输出,重新验证最终导出图,因为增加输出可能影响图优化。
二、随机采样需要使用同一份随机量
潜变量编码器可能输出一个分布,再从中采样:
如果原框架和 ONNX 各自生成一份
更容易定位的办法是将编码器拆成确定部分和采样部分。先对照
下面是采样与缩放部分的验证表达,不包含编码器本身:
import numpy as np
def latent_from_noise(mean, logvar, noise, scale):
# 三项使用相同布局;noise 由外部生成一次,两边复用。
return scale * (mean + np.exp(0.5 * logvar) * noise)这样可以分别回答:编码器算出的分布是否一致、相同噪声下重参数化是否一致,以及潜变量进入解码器后的结果是否一致。固定噪声通过后,还需要检查实际随机路径的分布和生成效果;它不能证明不同运行时会产生逐项相同的随机序列。
只设相同 seed 不足以替代共享噪声。不同设备或运行时可能使用不同的随机数实现,随机调用次数也可能变化。PyTorch 的可复现性说明同样没有保证跨平台、跨版本及 CPU/GPU 的完全一致结果。
把 sample() 改成 mode() 可以得到确定输出,但对于通常的对角高斯实现,后者返回均值,已经改变了采样行为。它可以帮助检查编码器的确定部分,不能直接当作保留原有生成行为的修复方案。
三、缩放与坐标变换也属于需要对齐的计算
3.1 固定缩放值要在图内外对应
图像潜变量常在编码后乘以缩放系数,解码前再除回来。导出编码器和解码器时,要先明确各自接口接收的是缩放前还是缩放后的张量。
若两边都以为对方已经完成缩放,会漏做一次;若包装类和调用方各做一次,则会重复缩放。这类错误不会改变 shape,模型也可能正常执行,最后表现为亮度、对比度或纹理异常。
固定值可以由模块的 buffer 持有,也可以在导出图中成为常量或 initializer。关键是检查实际图中的乘除操作,以及调用方是否仍在执行相同步骤。曾使用不可训练 Parameter 保存缩放系数,不意味着 Python 常量必然无法导出,也不能据此认定原图漏掉了计算。
验证时先比较缩放前的 latent,再比较缩放后、解码前和解码后的张量。最终转成 uint8 图像之前也保留一次浮点对照:取整和裁剪会掩盖部分误差,超出范围的值还可能被统一压到同一边界。
3.2 原始输出接近,最终坐标仍可能偏移
一类关键点模型分别输出横轴和纵轴上的响应分布。采用基础 argmax 解码时,若坐标分类的细分比例为
遗漏除以
随后还要将坐标从模型输入映射回原图。对于没有旋转、按中心
这里的
因此,应先比较横纵轴分布,再比较解码后的输入坐标,最后比较原图坐标。若只看最终像素误差,很难分清模型计算和坐标映射各自造成了多少偏差。裁剪到图像范围内的操作也应放在定位之后检查,避免把错误坐标压回边缘,制造结果合理的假象。
四、把导出误差与后端精度变化分开
原框架在加速设备运行、ONNX 在 CPU 运行时,比较中同时包含图表达、设备实现和精度路径差异。把两边输出都转成 FP32,只是统一了误差计算的类型,不会恢复此前丢失的精度。
可以先采用以下对照顺序,再逐步接入真实执行路径:
| 对照 | 固定条件 | 主要用于定位 |
|---|---|---|
| 原框架与 ONNX 参考执行 | 相同输入、随机量,优先使用同设备 FP32 | 导出是否改变计算与输出定义 |
| 同一后端的不同精度 | 相同输入和输出位置 | 低精度的影响 |
| ONNX 与目标编译模型 | 明确各自精度、相同前后处理 | 编译和目标后端引入的变化 |
| 单段与完整链路 | 固定各段交接张量 | 接线、数据转换和误差传播 |
相同输入是指已经完成前处理的同一组数值,而不只是同一个文件名。两边独立读取图片、构造 padding 或增加位置编码,仍可能给模型送入不同数据。
统计误差前先检查输出数量、shape、dtype 和 NaN/Inf,避免 NumPy 广播把形状错误变成一个看似有效的 MAE。随后按输出分支、层或时间段报告平均误差、较高分位误差和最大误差,并回查异常位置。
相对误差在参考值接近零时会变得很大,因此需要结合绝对误差与数值尺度看;余弦相似度则可能掩盖整体幅值缩放。对于关键点、分割或生成模型,还应继续检查坐标、掩码或图像效果。一个全局阈值不适合替代这些不同层次的判断。
目标后端有明显偏差时,可以继续采用 TensorRT FP16 数值异常排查中的 QKV 布局对照方法,缩小首次发生偏差的图范围。
五、运行时必须按模型接口解释内存
在一次昇腾推理适配中,输入字节数是预期缓冲区大小的两倍,问题最终落在 FP32 数组与 FP16 接口的对应关系上。对连续的普通稠密张量,样本数相同而元素字节数翻倍,就会产生这种现象;但遇到两倍差异时仍应同时核对 batch 和 shape,不能仅凭倍率确定原因。
正确顺序是先读取模型的输入描述,再核对数组类型与布局,最后检查需要传输的字节数。扩大缓冲区只能改变容量,不能让模型把四字节浮点自动解释成两字节浮点。
输出同样如此。np.frombuffer 的 dtype 决定怎样解释原始字节,它不是数值转换。先用错误类型解释,再 astype,不会得到正确数据。输出尺寸和有效 batch 也应来自实际执行的形状,而不是根据数组长度猜测。
动态 batch 则要分别检查导出图、编译档位与运行时设置。ONNX 声明 batch 轴为动态,不代表编译模型接受任意 batch;为最大档位分配了足够内存,也不代表本次已经选择正确档位。在采用动态 batch 控制接口的 OM 路径中,还需按对应版本完成执行前的档位设置。切出前几项输出不能替代这一步。
模型较大时,结构文件与外部权重也要一起保留。启用外部数据保存时,all_tensors_to_one_file=True 可以把外部张量集中保存,并不等于权重已经嵌入单个 ONNX 文件。ONNX 官方文档对大于 2 GB 的模型要求以文件路径调用 checker,并保留正确的外部数据关系。加载和结构校验通过后,仍要继续做数值验证。
六、完整链路应逐段使用已对齐的输入
当多个模型串联时,后一段输入不同,最终输出误差就无法直接归因。较清楚的做法是先保存参考路径的中间张量,把它分别送入两种实现的下一段;每段独立通过后,再接回转换路径自己的输出,检查误差如何累积。
如果各段单独对齐,连接后却出现明显偏差,应优先检查缩放是否重复、层与通道顺序是否改变,以及缓存是否仍对应同一条输入。若偏差确实来自前段的微小数值变化,再判断后段是否对这些变化敏感,而不是反复放宽最终容差。
定位到首次出现偏差的位置后,应保留对应输入与最小计算图,再检查具体算子或后端实现。中间特征、随机量和前后处理都已对应时,后面的数值差异才有清楚的比较基线。
完整链路中的等待与播放问题,见 实时音视频生成中的同步与批处理延迟。