ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ONNX模型部署:FP16转换与INT8量化实战指南

ONNX模型部署:FP16转换与INT8量化实战指南

做模型部署的朋友,对ONNX这个中间格式应该都不陌生。但真正到了上线那一步,问题才冒出来:手里这个fp32的ONNX模型动辄几十上百MB,跑在GPU上显存吃紧,跑到CPU或者边缘设备上延迟又压不下去。这时候fp16转换和int8量化就成了两个绕不开的优化手段。这篇内容我会从原理讲到踩坑,把这两个操作里最关键的细节,以及为什么你照着教程转完模型反而变慢了、变不准了,都掰开揉碎讲清楚。

这篇东西适合手头有ONNX模型、正准备往GPU/NPU/嵌入式设备上部署的人,也适合那些已经转完fp16或者量化完int8但精度掉了、推理崩了、不知道从哪开始排查的算法工程师。文章中用到的方法和工具都是业界最常用那套,不需要自研啥特殊框架,装好onnxruntime和转换库就能复现。

1. 先把部署场景和精度档位理清楚

1.1 三个精度档位的本质区别

在动手之前,必须搞清楚fp32、fp16、int8这三者到底在“牺牲”什么。很多同学把转换理解成“压缩文件”,以为是像zip那样无脑打包,这是大错特错。这三种数值格式本质上是三种不同“信息密度”的表示方式。

fp32是单精度浮点,1位符号、8位指数、23位尾数,数值范围大约是±3.4e38,尾数精度大概对应7位十进制有效数字。fp16是半精度浮点,指数位只有5位,尾数位只有10位,有效数字大概3位,最大有限数值是65504,超过这个值就会溢出成Inf。int8则完全不同,它没有指数,只有符号位和8位整数,取值范围就是-128到127,本质上是把浮点数值映射到一个固定网格上的整数刻度。

从信息取舍角度看,fp16是在“动态范围”和“有效精度”两个维度上同时缩水,但整体还是浮点,数值密度分布仍然不均匀;int8则直接把整个数值空间线性切分到256个格子里,属于更激进的重塑。理解了这一点,你就能明白为什么有些模型转fp16没事,转int8却掉点严重——因为两者丢的东西不一样。

1.2 部署场景如何选型

不同硬件对精度的支持差异非常大。我见过不少人拿着支持fp16的GPU去跑一个转成int8的模型,结果速度反而没提升,还白白损失了精度;也见过有人把int8模型扔到不支持int8加速的CPU上跑,结果引擎默默把反量化逻辑跑了一遍,速度比fp32还慢。

这里给一个我长期用的选型表,基本能覆盖90%的场景:

部署目标推荐精度原因
GPU服务器(数据中心)FP16显存减半,Tensor Core加速,精度损失小
边缘计算盒/工控机(带NPU)INT8NPU通常原生只吃int8,功耗和内存收益大
普通CPU(x86/ARM)INT8支持VNNI/类似指令集的CPU可以数倍加速
老平台/兼容性优先FP32避免各类精度兼容问题,稳定第一

这个表只是起点。实际项目里经常出现的情况是:同一个模型,在GPU推理上用fp16,在边缘盒子上用int8,两边各维护一个onnx产物。没必要死磕“只用一个精度走天下”,硬件决定了你能吃什么精度。

1.3 精度损失的来源

不管是fp16还是int8,精度损失都主要来自三个地方。

第一是动态范围溢出。fp16上限65504看着挺大,但卷积特征图里一旦出现比较大的响应值、残差加和、或者带有大数值偏置的归一化层输出,就很容易顶到Inf。int8更是如此,激活值稍微分布不均匀,阈值外的数据直接被截断。

第二是舍入误差累积。模型推理是一个多层计算链,每一层的小误差都会被后续层放大。对小数值区域,fp16的尾数精度不足,容易把本来有区分度的微小差异“抹平”。int8则是量化网格太粗,权重里的小细节可能直接落到同一个格点。

第三是关键算子对数值变化过于敏感。Softmax之前的logits、LayerNorm里的mean/var、检测头里的坐标解码,这些地方一旦精度变了,输出结果可能发生肉眼可见的偏移。后面所有实操策略,本质上都是在跟这三类问题博弈。

2. FP16转换:体积直接减半的实战路径

2.1 为什么一半模型转完就NaN?

这是fp16转换里最经典的翻车现场:模型转完一切正常,结果一跑推理,输出全是NaN或者Inf。

根源就是前面说的动态范围上限。实际模型里很多层的中间值非常大,尤其是分类模型的最后一层logits、目标检测里anchor解码时的坐标倍数、以及带ResNet结构反复加和之后的激活值。这些数值本身是越界风险极高的区域。我踩过最典型的一个坑:一个分类模型,softmax之前的logits峰值接近30000,直接转fp16后logits被截断到65504边缘,再做softmax时因为分母溢出,整个输出概率变成NaN。

另外一类容易出问题的是LayerNorm、Softmax、ReduceMean这类对数值分布敏感的算子。这些算子在fp32下计算稳定,但在fp16下做均值、方差时,累加过程中的舍入误差会明显放大。BN在转换成推理模式时虽然已经变成了“缩放加平移”,但如果参数范围大,一样有风险。

所以fp16转换不是无脑一条命令跑完,你得先把模型里哪些层是“高敏感区”摸出来,再决定哪些保留fp32。

2.2 转换工具与具体操作

主流的转换工具是onnxconverter-common,它提供的convert_float_to_float16函数可以一键把整个ONNX模型从fp32转成fp16。不过我用下来的体会是:一键转换往往只是起点,真正的工夫在调参数。

基础用法很简单:

import onnx from onnxconverter_common import float16 model = onnx.load("model_fp32.onnx") model_fp16 = float16.convert_float_to_float16( model, keep_io_types=True, op_block_list=["LayerNormalization", "Softmax", "ReduceMean"] ) onnx.save(model_fp16, "model_fp16.onnx")

如果你用的是PyTorch,也可以在导出ONNX时直接输出fp16的权重:

import torch model.half().eval() dummy_input = torch.randn(1, 3, 224, 224).half() torch.onnx.export( model, dummy_input, "model_fp16.onnx", opset_version=13, input_names=["input"], output_names=["output"] )

这里有个前提:导出的opset版本最好在13及以上,太低的话部分算子在ONNX标准里不支持fp16类型,导出或者转换时容易报错。

2.3 关键参数决定模型的“生死”

convert_float_to_float16里几个参数,值得逐个讲透。先说keep_io_types。这个参数控制模型的输入输出是否保持fp32。我建议默认设成True,尤其是输出。因为很多后处理代码是按fp32输出写的,一旦模型输出变成fp16,后处理和下游接口都要跟着改,而且最终输出的精度收益不大。内部保持fp16计算,IO用fp32,这在绝大多数推理引擎里都能无缝衔接,精度损失也只发生在中间层。

然后是op_block_list,它是整个转换里调整空间最大的参数。你可以把任何算子名放进去,这些算子就会被保留在fp32计算。经验上,LayerNorm、Softmax、ReduceMean、LSTM/GRU这类对精度敏感的算子,我基本都会放进block list里。

还有一个容易被忽略的node_block_list,它按节点名屏蔽指定单个节点。比如某个模型里只有一个特定位置的Sigmoid输出极不均匀、导致量化误差大,你可以单独把那个节点拎出来留在fp32,而不是把整个算子类都屏蔽。

关键原则是:尽量只屏蔽真正出问题的算子,不要大面积回退,否则fp16的收益会大打折扣。我之前做过一个BERT风格的文本模型,只把LayerNorm和Softmax留在fp32,模型体积仍然减半,精度指标几乎没有变化,效果很好。

2.4 精度验证与性能确认

转完fp16之后,强烈建议先做一次快速输出比照,而不是直接丢到评测集上跑。拿一批真实输入(不用太大,32到100张足够),分别跑fp32和fp16模型,对比每一层或者最终输出的最大绝对误差和余弦相似度。

我习惯用的阈值是:输出向量的余弦相似度不低于0.999,最大绝对误差不超过1e-2,在这个范围内再去做完整评测。如果相似度低到0.99以下,说明某些层确实出了大问题,要回头调整op_block_list。

性能确认也很关键。fp16在支持FP16计算的GPU上加速明显,显存占用也直接下降。但如果在只支持fp32的CPU上跑fp16模型,很多时候推理引擎并不会真正用fp16计算,反而可能因为要处理类型转换而更慢。所以性能测试一定要在目标硬件上测,并且要对比转换前后的端到端延迟。

3. INT8量化:必须校准数据才做得好的压缩术

3.1 量化是如何工作的

int8和fp16的难度不在一个量级。fp16至少还是浮点,int8是把连续浮点数值映射到有限整数集合上,这是一个由连续到离散的“有损投影”。

量化需要两个核心参数:scale(缩放因子)和zero_point(零点)。反量化公式是r = (q - zero_point) * scale,量化公式则是q = round(r / scale) + zero_point。对称量化里zero_point为0,权重量化一般用对称;激活值量化更多用非对称,因为ReLU后的激活几乎都是非负数,非对称量化可以留更多整数刻度给有效区间。

ONNX里现代量化模型通常以QDQ格式呈现,也就是在原来的浮点算子里插入QuantizeLinear和DequantizeLinear节点。权重先被量化成int8、存下来的同时写进scale和zero_point,推理时DequantizeLinear把它还原成浮点再喂给算子;激活则是先量化再反量化。这个形态的好处是算子的数学结构没有改变,对推理引擎的改动小,TensorRT、OpenVINO这类引擎都能较容易地识别并优化。

另一个重要概念是per-channel和per-tensor。权重推荐用per-channel量化,也就是每个输出通道单独算scale和zero_point,因为不同通道的权重分布差异很大;激活一般只用per-tensor,因为同一层激活在通道间的分布相对均匀,强行按通道算会让量化节点数量爆炸。

3.2 动态量化 vs 静态量化:先分清再动手

很多人一上来就想做静态量化,结果被校准数据、预处理对齐这些事劝退。其实onnxruntime还提供了另一种更轻量的方式:动态量化。

动态量化只把权重量化成int8,激活值在推理时动态计算scale并量化。它不需要校准数据集,几行代码就能跑通,但加速效果有限,激活量化本身的开销还留在推理链路里。这类量化对Transformer结构比较友好,因为那些模型的激活分布相对稳定;但对CNN来说收益一般,CNN最吃香的恰好是激活也能提前静态量化。

静态量化需要先用校准数据统计激活的分布范围,在转换阶段就把激活量化参数固定下来。相比之下,静态量化收益更大,部署到NPU这类设备时基本绕不开。选型建议如下:

对比维度动态量化静态量化
是否需要校准数据否是
实现复杂度低中
推理加速收益有限明显
适合模型NLP/TransformerCNN/大模型
是否适合NPU部署一般不推荐推荐

真正常用的还是静态量化,下面的实操完全围绕它展开。

3.3 静态量化完整实操

先准备校准数据。校准数据要从真实业务场景里抽,而不是随便拿几张测试图。数量不用多,我通常在500到1000张之间,太少统计出的激活范围不稳,太多浪费时间。关键是要覆盖足够多样的输入分布,比如检测任务里大小目标都要有,分类任务里各个类别都要有。

然后实现一个CalibrationDataReader,onnxruntime量化时会通过它来获取输入数据并统计激活分布:

import numpy as np from onnxruntime.quantization.calibrate import CalibrationDataReader class CalibReader(CalibrationDataReader): def __init__(self, dataset, input_name): self.dataset = dataset self.input_name = input_name self.iter = iter(dataset) def get_next(self): try: batch = next(self.iter) # 注意:key必须是onnx模型的输入名 return {self.input_name: batch.astype(np.float32)} except StopIteration: return None

完成校准数据准备后即可开始静态量化:

from onnxruntime.quantization import ( quantize_static, QuantFormat, QuantType, CalibrationMethod, ) reader = CalibReader(calib_dataset, input_name="input") quantize_static( "model_fp32.onnx", "model_int8.onnx", calibration_data_reader=reader, quant_format=QuantFormat.QDQ, per_channel=True, activation_type=QuantType.QUInt8, weight_type=QuantType.QInt8, calibrate_method=CalibrationMethod.MinMax, )

这里解释一下参数。per_channel=True是权重侧最关键的开关,做了之后量化精度通常会有明显提升。activation_type和weight_type默认都是uint8,不过有些推理引擎对int8的支持更顺,可以都试一遍。calibrate_method建议按任务类型去选:MinMax实现简单但对异常值敏感;Entropy基于信息熵保留更多信息,分类模型比较适合;Percentile能扛一点离群值,检测类任务可以优先试它。

量化完成后,务必跑一遍和fp32模型的对比评测。量化模型不掉点或者掉点可接受,才算通过。

3.4 掉点严重时怎么排查

量化后掉点这件事,大多数人第一次遇到都会慌,但按照顺序排查,基本都能找到原因。

第一个排查方向是校准数据出问题。最常见的就是校准数据没有按部署前处理流程做resize和归一化。这是最容易踩的坑,我见过不止一次:训练时代码里预处理是ImageNet的mean/std,量化时喂进去的是0到1的原始像素,激活分布差了一大截,最后量化模型预测直接崩掉。校准数据的预处理必须和线上推理链路完全一致。

第二个方向是敏感算子被量化了。检测模型特别容易踩这个坑,模型前一半是backbone,后一半是bbox解码和NMS相关算子,这类解码逻辑本身运算简单但数值敏感,被量化后坐标输出明显偏移。这时候可以用nodes_to_exclude参数把特定节点排除在量化范围外:

quantize_static( "model_fp32.onnx", "model_int8.onnx", calibration_data_reader=reader, nodes_to_exclude=["decoder_node_1", "decoder_node_2"], )

第三个方向是per-channel没开或者校准方法不合适。权重量化只要模型不是特别小,per-channel基本必开;激活校准方法可以从MinMax换成Percentile,有时候掉点能立刻回一半。第四个方向是模型本身太小,比如只有几百KB的轻量模型,参数信息密度高,每一bit都金贵,量化风险天然大。这种模型的正确姿势是只在最终部署阶段才量化,并且要准备接受一定程度掉点。

4. 组合使用与硬件适配的部署方案

4.1 先转fp16再量化?多数时候没必要

经常有人问:能不能先转fp16再量化int8,双倍压缩。坦白说,多数情况下没必要,而且可能引入额外误差。int8量化本身就有校准环节,直接针对原始fp32模型做校准,统计出的激活范围是最准的。先转fp16等于在“真实分布”和“半精度近似分布”之间先插了一道误差,再拿fp16的统计结果去定scale,反而容易让量化阈值偏掉。

唯一例外是目标推理后端明确要求int8模型从fp16输入转换过来,比如某些NPU工具链只接受fp16版模型再转int8。遇到这种情况再走组合链路,否则老老实实从fp32直接量化。

4.2 结合推理引擎的优化

模型转换只是优化链条里的一环。ONNX Runtime和很多推理引擎都有自己的图优化能力,能自动做算子融合、常量折叠、维度简化。我建议转换完之后至少开一次onnxruntime的graph optimization,很多时候速度能再提升一截。

import onnxruntime as ort session = ort.InferenceSession( "model_int8.onnx", providers=["CPUExecutionProvider"], sess_options=ort.SessionOptions() ) session.set_optimization_level(ort.GraphOptimizationLevel.ORT_ENABLE_ALL)

在GPU侧,如果推理引擎支持TensorRT或者类似能力,可以直接让它消费QDQ格式的量化模型,后续的算子融合和kernel自动选择交给引擎自己处理。

4.3 部署前必做的检查清单

总结我反复犯过的低级错误,整理成一套部署前的检查清单,照着走一遍能省很多线上排障时间:

  • onnx.checker能通过,模型结构完整
  • 用脚本对fp32和fp16/int8模型分别跑相同输入,输出形状一致
  • 校准数据集的预处理与线上前处理代码完全一致
  • 模型的输入输出名称在转换后没有变化
  • int8量化模型的nodes_to_exclude清单里,排除了数值敏感的检测解码和特殊算子
  • 在目标硬件上实测延迟和显存/内存占用,不要只信转出来的文件体积
  • 至少跑一遍端到端评测,确认掉点幅度符合预期

这些都是最基础但最容易被忽视的点,尤其是预处理一致性,几乎每个项目都要踩一遍。

5. 真实项目中的三个典型翻车现场

5.1 分类模型fp16转完输出全为0

有一回我处理某个图像分类模型,结构很简单,backbone加一个全连接输出。转成fp16后跑测试集,预测结果全乱套,概率输出几乎全是0或NaN。

排查过程把onnx中间层逐个dump出来,发现logits非常大,峰值超过50000,fp16下直接溢出。这不是某一个权重的问题,而是这类分类模型最后的全连接层把特征映射到了很大的数值空间。最后的解法是让softmax前的那一个全连接层和softmax本身都留在fp32。只是这一处回退,模型的fp16收益依然保住,精度恢复。

这件事给我最大的教训是:模型转fp16之前,先看一眼logits和softmax的数值范围。这个步骤30秒就能完成,但能救回半天排查时间。

5.2 OCR检测模型int8量化后recall掉了8个点

有个OCR检测模型,主干是类似标准检测框架的结构,训练时mAP很不错。量化成int8之后,recall直接从原来的水平掉了8个点,框的位置明显偏移。

一开始怀疑是校准数据不够,扩大到2000张后依然没有起色。后来用onnx的节点可视化逐个检查,发现量化工具把后处理里的bbox解码和一部分坐标变换也量化了。这些算子本质是浮点坐标计算,量化后坐标像素级的偏差被放大成框位偏移。用nodes_to_exclude把整个后处理段的敏感节点排除后,掉点从8个点缩到了2个点以内,完全可接受。

检测、分割这类“模型输出需要精确定量坐标”的任务,量化时一定要额外关注后处理节点。这类节点通常算力开销不大,留在fp32对整体加速没影响,但精准度保护作用巨大。

5.3 校准数据没做预处理,量化模型直接报废

还有一次是给一个车牌识别模型做量化,校准数据直接用了一个现成的公开图片文件夹,没有做和线上一致的resize和归一化。量化完模型跑起来,识别准确率直接掉到一个没法用的状态。

当时花了大半天去排查是不是某些敏感算子被量化,最后才意识到校准数据根本没经过deploy前处理。校准数据的作用是“统计激活分布”,而线上推理的激活分布是脱胎于线上前处理的。校准环节喂进来的图片尺寸、归一化参数和线上不一样,统计出的scale自然不对。

从那以后,我写量化脚本的第一件事就是复用线上的预处理函数,从数据读取到tensor格式,严格保持一致。

最后分享一个我沿用至今的小技巧:转换之后永远不要只看最终指标,先做输出张量级的对比。用同一批输入分别跑fp32和优化后的模型,计算输出矩阵的余弦相似度、绝对误差,以及中间某几层特征的差异。这一步能帮你把问题定位在“哪个阶段”而不是“整体效果”,排查速度快得多。模型部署优化这条路没有银弹,但掌握了精度损失的位置和原因,fp16和int8都是很趁手的工具。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进