4. 数据类型兼容性:Tensor数据类型映射、float16/bfloat16/int8支持情况、类型提升规则、精度损失分析

好,咱们进入第四讲。数据类型兼容性,这话题我估计不少同学在部署时都踩过坑。说白了,ONNX 作为一个中间格式,它要连接训练框架和推理引擎,数据类型这块儿要是没对齐,模型跑起来要么报错,要么精度崩了。我个人习惯是,拿到一个模型,第一件事就是看它的输入输出是什么类型,心里先有个底。

4.1 Tensor数据类型映射:从PyTorch/TF到ONNX

不同框架有自己的数据类型命名。PyTorch 叫 torch.float32,TensorFlow 叫 tf.float32,到了 ONNX 里,统一叫 tensor(float)。映射关系其实挺直观的,但有几个容易翻车的地方。

我列个表,大家对照着看:

PyTorch 类型 TensorFlow 类型 ONNX 类型 备注
torch.float32 tf.float32 tensor(float) 最常用,无坑
torch.float64 tf.float64 tensor(double) 部分算子不支持,慎用
torch.int64 tf.int64 tensor(int64) 索引类算子常用
torch.int32 tf.int32 tensor(int32) 大部分引擎优化较好
torch.bool tf.bool tensor(bool) 条件分支常用
注意: 我在项目中遇到过,PyTorch 导出时默认用 torch.int64 做索引,但某些 ONNX Runtime 的优化版本对 int64 支持不完整,会回退到 CPU 执行。我建议导出前手动转成 torch.int32,性能能提升不少。

4.2 float16 / bfloat16 / int8 支持情况

低精度推理是部署的香饽饽,但 ONNX 对它们的支持程度不一样。咱们一个一个说。

4.2.1 float16 (半精度)

ONNX 原生支持 tensor(float16)。大部分 GPU 推理引擎(比如 TensorRT、ONNX Runtime CUDA)都能直接跑。但 CPU 端就比较尴尬了,很多 CPU 没有原生 float16 指令,跑起来反而更慢。

我记得有一次,客户非要在 CPU 上跑 float16 模型,结果推理时间翻了三倍。后来我查了日志,发现 ONNX Runtime 在 CPU 上把 float16 转成了 float32 再算,白折腾一场。

4.2.2 bfloat16 (脑浮点)

bfloat16 是 Google 搞的,主要用在 TPU 和部分新 GPU(比如 A100、H100)上。ONNX 在 opset 14 之后才正式支持 tensor(bfloat16)。如果你用的 ONNX 版本比较老,导出时 bfloat16 会被自动转成 float32。

小技巧: 如果你要在 ONNX 里用 bfloat16,导出前先检查 opset 版本:onnx.__version__。低于 1.10 的版本,我建议你升级一下。

4.2.3 int8 (量化)

int8 量化是 ONNX 里最复杂的部分。ONNX 本身定义了 tensor(int8)tensor(uint8),但量化模型通常需要额外的 Q/DQ(Quantize/Dequantize)节点。这些节点在 ONNX opset 13 之后才标准化。

我踩过一个坑:用 PyTorch 的量化模型导出 ONNX,结果 Q/DQ 节点没对齐,推理引擎直接报错。后来我手动修改了 ONNX 图,把量化参数重新绑定了一下才跑通。

4.3 类型提升规则

类型提升,说白了就是当两个不同类型的 Tensor 做运算时,结果是什么类型。ONNX 的类型提升规则基本遵循「向高精度看齐」的原则。

举个例子:

# 假设有两个输入
tensor_A: float16
tensor_B: float32

# 在 ONNX 中做 Add 操作
# 结果类型:float32
# 因为 float32 精度更高,float16 会被自动提升

规则其实很简单:

  • 整数类型:int8 < int16 < int32 < int64,低精度向高精度提升
  • 浮点类型:float16 < float32 < float64,低精度向高精度提升
  • 混合类型:整数和浮点混合时,整数先转成浮点,再按浮点规则提升
重点: 类型提升是在算子内部自动完成的,不会改变原始 Tensor 的数据类型。你想想看,如果每次运算都改原始数据,那模型图就乱套了。

4.4 精度损失分析

精度损失是低精度推理的核心问题。我一般从三个维度来分析:

4.4.1 量化误差

从 float32 到 int8,精度损失是不可避免的。损失大小取决于量化方法:

  • 对称量化:适合权重分布对称的模型,损失较小
  • 非对称量化:适合 ReLU 后的激活值,能保留更多信息
  • 逐通道量化:比逐张量量化更精细,损失更小

我曾经在 YOLOv5 上做过实验,对称量化后 mAP 掉了 0.5%,换成非对称量化只掉了 0.2%。嗯,这里要注意,不同模型对量化方法的敏感度不一样,最好都试一下。

4.4.2 浮点截断误差

float16 的精度只有约 3.3 位有效数字,而 float32 有约 7.2 位。当数值范围很大时(比如 loss 从 100 降到 0.001),float16 的梯度可能会直接变成 0。

我记得有一次训练一个语音模型,用 float16 混合精度训练,结果 loss 死活降不下去。后来发现是梯度下溢了,开了 loss scaling 才解决。

4.4.3 累积误差

深度模型层数多,每一层的精度损失会累积。我建议在部署前做逐层精度对比:

# 伪代码:逐层对比 float32 和 float16 的输出
for layer in model.layers:
    output_fp32 = layer.forward(input_fp32)
    output_fp16 = layer.forward(input_fp16)
    diff = abs(output_fp32 - output_fp16).max()
    if diff > threshold:
        print(f"Layer {layer.name} 精度损失过大: {diff}")
警告: 如果某层的精度损失超过 1e-2,我建议你保留该层为 float32。不要为了省那点显存,把整个模型的精度都搭进去。

4.5 实战建议

最后,我总结几条实战经验:

  1. 先跑通,再优化:先用 float32 导出 ONNX,确保模型能跑通。然后再尝试低精度。
  2. 逐层验证:不要只看最终精度,要逐层对比中间结果。我习惯写个脚本,自动对比每一层的输出。
  3. 保留回退机制:在推理引擎中配置 fallback,让精度敏感的层自动回退到 float32。
  4. 多测几个样本:精度损失可能只在某些输入上出现。我建议至少用 1000 个样本做验证。

好了,数据类型兼容性这块儿就聊到这儿。下一章咱们讲算子版本兼容性,那个坑更多,到时候我给大家分享几个我踩过的「经典案例」。