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) |
条件分支常用 |
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.__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,低精度向高精度提升
- 混合类型:整数和浮点混合时,整数先转成浮点,再按浮点规则提升
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}")
4.5 实战建议
最后,我总结几条实战经验:
- 先跑通,再优化:先用 float32 导出 ONNX,确保模型能跑通。然后再尝试低精度。
- 逐层验证:不要只看最终精度,要逐层对比中间结果。我习惯写个脚本,自动对比每一层的输出。
- 保留回退机制:在推理引擎中配置 fallback,让精度敏感的层自动回退到 float32。
- 多测几个样本:精度损失可能只在某些输入上出现。我建议至少用 1000 个样本做验证。
好了,数据类型兼容性这块儿就聊到这儿。下一章咱们讲算子版本兼容性,那个坑更多,到时候我给大家分享几个我踩过的「经典案例」。