做模型部署的朋友对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加速精度损失小边缘计算盒/工控机带NPUINT8NPU通常原生只吃int8功耗和内存收益大普通CPUx86/ARMINT8支持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_typesTrue, 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_version13, 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张之间太少统计出的激活范围不稳太多浪费时间。关键是要覆盖足够多样的输入分布比如检测任务里大小目标都要有分类任务里各个类别都要有。然后实现一个CalibrationDataReaderonnxruntime量化时会通过它来获取输入数据并统计激活分布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_nameinput) quantize_static( model_fp32.onnx, model_int8.onnx, calibration_data_readerreader, quant_formatQuantFormat.QDQ, per_channelTrue, activation_typeQuantType.QUInt8, weight_typeQuantType.QInt8, calibrate_methodCalibrationMethod.MinMax, )这里解释一下参数。per_channelTrue是权重侧最关键的开关做了之后量化精度通常会有明显提升。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_readerreader, 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_optionsort.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非常大峰值超过50000fp16下直接溢出。这不是某一个权重的问题而是这类分类模型最后的全连接层把特征映射到了很大的数值空间。最后的解法是让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都是很趁手的工具。
阅读完成 · 觉得有帮助?