从FP32到INT8:ONNX模型量化实战与端侧部署指南
1. 从浮点到INT8先搞清楚浮点做端侧AI这几年我最大的感受是很多人一上来就急着调量化工具却连模型里跑的到底是哪种数据类型都没弄清楚。FP32、FP16、INT8这些词天天挂在嘴边可真要问到“它们到底差在哪”能说清楚的没几个。所以这篇笔记我先花点篇幅把浮点这个基础讲透因为量化这件事本质就是一场“如何用更小的数表达原本的浮点数”的改造工程。先看浮点家族里的几位主角FP64、FP32、FP16、BF16。FP64也就是双精度浮点64位里用1位符号位、11位指数位、52位尾数位来表示一个数。FP32单精度浮点1位符号、8位指数、23位尾数。FP16半精度浮点1位符号、5位指数、10位尾数。BF16则是Google为深度学习搞出来的变体同样是16位但指数位和FP32一样都是8位尾数只留7位。这几者的差异用通俗的话说就是指数位决定了一个数能表示的范围有多大尾数位决定了精度有多细。FP64能表示的范围极大精度极高适合科学计算里那些动辄相差几十个数量级的数值。FP32则是深度学习中长期以来的默认精度训练和推理都用它。FP16的指数位只有5位表示范围窄了很多但深度学习里的权重和激活值大多集中在-1到1这个区间范围窄一点问题不大关键是10位尾数能不能扛住精度损失。BF16牺牲尾数来保指数所以它的精度比FP16更差但动态范围大主要用在训练场景。算力需求这块我贴个实测数据供参考。在英伟达的消费级显卡上比如RTX 3090或4090FP16的吞吐量通常是FP32的两倍。到了RTX 5080这种新卡还多出FP8和NVFP4这些低精度格式理论算力随精度降低而大幅提升。INT8就更不用说了很多端侧NPU的INT8算力是FP16的好几倍。一句话总结数的位宽越小能塞进一个时钟周期的计算就越多吞吐量自然就上去了。注意算力翻倍只是理论峰值。实际跑起来能不能吃到这个红利还要看算子的内存访问模式、有没有专门优化的内核、数据搬运是不是瓶颈。我在不少端侧设备上实测过INT8的感人加速往往伴随大量算子不支持而回退到FP16最后整体只快了30%-50%远没有理论值那么夸张。端侧设备为什么必须走量化这条道三个字资源紧。手机芯片的功耗墙摆在那功耗墙内的算力、带宽都极其有限。你拿一个上百MB的FP32模型往手机里塞先不说能不能跑动光是加载后占用的内存就够让系统杀后台了。我刚入行时接手过一个视觉检测模型FP32版本180MB在手机上调优了半个月帧率死活上不了两位数。后来做了INT8量化模型压到45MB帧率直接翻了快4倍——那一刻我才真正理解“量化改变的是端侧部署的生态位”。2. 量化到底改变了什么量化说白了就是用定点整数去近似表示浮点数。最常见的做法是把权重和激活从FP32范围映射到INT8的[-128, 127]区间。这里面有个核心公式做量化的人必须刻在脑子里$$r S(q - Z)$$其中r是原始的浮点实数值q是量化后的整数值S是缩放系数scaleZ是零点zero point。反过来说给一个浮点值r量化成整数q就是$$q round(r / S Z)$$这个公式不复杂但请务必理解透彻——后面所有的量化策略、校准方法、精度补偿都是围绕这个公式打转的。S是个浮点数它的含义是“一个量化步长代表多少浮点范围”。Z是个整数用来处理浮点零和整数零的偏移关系。为什么要有Z因为浮点数的0在量化后得能精确对应否则ReLU这类算子在推理时会出现偏差。对称量化把Z固定为0简单粗暴非对称量化允许Z不为0精度上限高一些。量化改变了什么我列个清单都是实际部署中能感受到的第一内存占用直接降成四分之一。FP32是4字节INT8是1字节。模型权重、中间激活、缓存全都在享受这个压缩红利。同样的内存预算原来只能跑一个模型现在塞四个还不喘气。第二访存带宽压力骤减。端侧芯片的算力往往是够用的真正卡脖子的是内存带宽。比如一个NPU每秒能算4TOPS但内存带宽可能只有25.6GB/s。跑FP32模型时每读一个权重要搬4字节换成INT8同样带宽能搬4倍的权重计算单元才不至于空等。很多端侧加速器最怕的就是“等数据”量化之后数据搬运压力小了FPGA、DSP这类设备尤其吃这一套。第三算力吞吐量实打实提升。现在各家端侧芯片厂商在主推的NPUINT8吞吐普遍能做到FP16的一倍以上。对于卷积、全连接这类计算密集型算子收益尤其明显。你可以在跑算子的工具链上对比一下比如用Qualcomm的AIMET或联发科的工具链做层级数据分析同样的层FP16算子和INT8算子的耗时差异一目了然。第四功耗下降。单位算力消耗的电能变少了电池发热都轻了。做过手机端单目深度估计的人应该深有体会模型一热就开始降频降频就掉帧掉帧就卡。量化之后功耗低了降频的临界点到得更晚体验的稳定性完全不同。但“改变”从来不是单向的。量化也带来了精度损失、算子兼容性、标定流程等一堆新问题。很多人一量化精度就崩往往不是工具不好而是没搞明白量化切的是哪块蛋糕。3. 实操环节ONNX模型INT8量化全流程写到这里进入这篇笔记的二分之一重头戏——实操。这里我用一个典型的ONNX模型来做演示。假设我们已经拿到了一个训练好的视觉模型导出成了FP32的ONNX格式接下来要完成INT8量化。我会按工具链选型、校准数据集准备、模型量化执行、精度验证的顺序走一遍。3.1 工具链选型onnxruntime vs openvino vs tensorrt做ONNX模型的INT8量化目前最常见的是三选一onnxruntime的量化工具、OpenVINO的NNCF/Pot、以及TensorRT的PTQ对于能转TensorRT的场景。我的建议是如果你最终是跑在端侧CPU或ARM上优先onnxruntime的量化工具链毕竟它配套的推理引擎覆盖最广如果目标是Intel的CPU或集显OpenVINO自带一套校准和编译省事又高效如果目标平台是NVIDIA的GPU比如Jetson系列TensorRT的PTQ效果最稳。提示不要小看工具链选型这步。我踩过最大的坑就是ONNX量化在Linux x86上验证通过部署到ARM Android上后部分算子回退到FP32整体性能直接打折。跨平台部署前建议先用目标板子的推理引擎跑一遍量化模型的精度和延迟再决定要不要用PTQ之外的手段比如QAT。3.2 校准数据集量化成败的第一道关卡量化过程需要一组校准数据calibration dataset它的作用是“观察”模型中各层激活值的分布范围从而确定每层的S和Z。校准数据不是训练数据但应当来自训练数据分布并且覆盖有代表性的场景。比如做目标检测校准集里要包含不同尺寸、不同光照、不同类别的样本Overfitting到某一种情况下会让量化模型在真实场景精度崩得很惨。数据量一般200~1000张就够用。太少统计出来的激活分布范围失准太多校准耗时线性增长收益不明显。我在用onnxruntime做校准的时候常见做法是从训练集里均匀采样300张打乱顺序用XInput的CIFAR或者你自己的dataloader灌进去。3.3 对称量化和非对称量化的现场抉择ONNX Runtime的静态量化API提供了是否使用对称量化的选项。这里有个实用经验对于权重建议使用对称量化因为权重分布通常近似于以0为中心的高斯/拉普拉斯分布对称量化不浪费量化区间对于激活值ReLU类激活函数输出恒为非负非对称量化能用完整[-128, 127]区间表示0到正值精度上限更高。所以很多工具链默认就是混合策略权重对称激活非对称。拿ONNX Runtime的quantize_static接口来说核心参数有这么几个weight_type、activation_type一般直接设成QuantType.QInt8和QuantType.QUInt8per_channel控制权重是否按通道量化建议开启尤其对于卷积层不同输出通道的权重范围差异巨大不按通道量化会损失明显精度还有extra_options里的CalibMax/Min是否要启用ActivationSymmetric。在onnxruntime.quantization里核心调用类似这样from onnxruntime.quantization import quantize_static, CalibrationDataReader, QuantType from onnxruntime.quantization.shape_inference import quant_pre_process # 先做shape inference避免量化时某些算子形状未知 quant_pre_process(input_model_path, preprocessed_model_path) # 自定义校准数据读取器 class CustomCalibDataReader(CalibrationDataReader): def __init__(self, calib_data_path): import numpy as np import onnxruntime as ort self.data np.load(calib_data_path) self.iterator iter(self.data[data]) self.input_name input self.enum_data None def get_next(self): if self.enum_data is None: self.enum_data iter(self.data[data]) return {self.input_name: next(self.enum_data, None)} def rewind(self): self.enum_data iter(self.data[data]) calib_reader CustomCalibDataReader(calib_data.npy) quantize_static( preprocessed_model_path, output_model_path, calib_reader, quant_formatQuantFormat.QDQ, per_channelTrue, weight_typeQuantType.QInt8, activation_typeQuantType.QUInt8, extra_options{ActivationSymmetric: False} )注意quant_formatQuantFormat.QDQ。QDQ全称是QuantizeDeQuantize即在图上保留“量化-反量化”节点运行时再融合。这样的好处是不同推理后端兼容性更好方便调试和算子回退。3.4 实际校准的步骤细节校准的过程其实不复杂把校准图片跑一遍FP32的模型前向推理在每一层的输出处统计激活值的min/max或histogram分布。onnxruntime的校准器默认用MinMax或者Entropy方法对于对应的工具来选定阈值。以我常用的MinMax为例它会记录每个激活张量在FP32推理时的最小值和最大值然后算出S和Z。但这里有个讲究直接拿min/max做阈值容易被离群点带偏。比如激活值里有一个异常的极大值整个量化区间就被撑大普通值的量化步长变大精度下降。业界对策是用Percentile或KLD散度方法选择裁剪掉两端的少量概率质量换取更集中的量化区间。OpenVINO的Pot工具就支持Percentile方案比如99.99%实际效果往往比MinMax好。用onnxruntime时如果发现精度异常可以试试extra_options里的CalibQuantType换为Percentile并调整percentile值。按通道量化值得多说一句。默认的per-tensor量化整个卷积层的权重共用一个S碰上不同输出通道的权重分布差异明显时小范围的通道会被压缩成大颗粒精度掉得让人肉疼。per-channel量化给每个输出通道独立算S几乎不增加推理时间因为反量化操作可以融合进计算内核但精度提升可观。我做过一次对比不开启per-channel模型mAP掉3.5个点开起来后只掉0.8。这个参数在白皮书参数写“建议开启”时请务必信任它。跑完量化得到的是QDQ格式的INT8模型。下一步别急着部署先在CPU上对比一下FP32和INT8的输出差异看是不是所有层的输出都在可接受范围内。哪个层乱了就回退哪个层到FP32——好在QDQ格式天然支持层级别回退onnxruntime也提供quantize_model时的层排除选项大大降低排查难度。4. 量化之后踩过的坑与排查方法这个部分我汇总了自己前段时间做端侧部署时踩过、也帮朋友排查过的典型问题整理成速查表按出现频率排序。这些问题网上零散也能搜到但很少有人把它们串起来讲。4.1 常见问题速查表问题现象可能原因排查与解决建议量化后精度掉得离谱校准数据与实际数据类型分布不一致或激活值存在离群点重新采样校准集换Percentile校准方法对激活用非对称量化模型尺寸没有缩小到1/4仍有部分算子回退到FP32比如LayerNorm、Softmax查看量化报告确认哪些层未量化必要时用QDQ格式保留回退弹性端侧推理速度没有翻倍访存瓶颈或算子回退INT8红利没有完全吃到用profiler定位耗时算子检查NPU驱动和运行时版本回退层改到CPU/NPU的合适异构配置输出出现NaN或极端值某些层溢出了INT8表示范围尤其Deep卷积之后检查校准集的数值范围考虑对该层回退FP32或用更高位量化INT16量化后不同设备精度不一致推理后端在算子融合方式上存在差异QDQ的融合策略不同在目标设备上用同一推理引擎做精度验证规避自定义算子尽量用ONNX标准算子4.2 排查技巧实录第一个技巧量化后用余弦相似度对比每一层的输出。做法很简单拿同一张校准图片分别跑FP32模型和INT8模型导出每个中间张量的值算余弦相似度。相似度低于0.99的层基本就是精度劣化的元凶。这个手段在部署前排查时效率极高比直接看最终mAP要直观得多。第二个技巧留意BatchNorm的折叠。很多推理引擎在跑FP32模型时会把BatchNorm融合进卷积的权重里。如果你量化前没有做模型优化BatchNorm还是独立节点量化的误差会被放大。建议在量化前先用onnxruntime的optimize_model做一轮图优化把BatchNorm折叠掉再进量化流程。实测能改善0.5-1个点的精度。第三个技巧是异构回退。不是所有层都非要INT8不可。像Softmax、LayerNorm这类算子数值范围大、计算简单用FP16或FP32跑反而更划算。端侧芯片的NPU通常也懒得为这些搞INT8内核。我最后发布的版本里就有大概10%的层回退到FP16整体精度几乎无损速度还更快。别被“全量化”三个字绑架灵活配置才是端侧部署的正道。第四个技巧监控模型预热和内存峰值。量化模型虽然存储变小但推理时NPU往往会dump整模型到SRAM或DSP内存里内存峰值可能比理论值高。部署前跑个内存压力测试设置合理的runtime内存池大小免得在低内存设备上崩溃。第五个技巧量化后再做小批量微调QAT。纯PTQ做不动的时候就要考虑QAT——在量化感知训练模式下用少量数据继续训练几百步让模型权重适应量化噪声。这个操作听着吓人其实在onnxruntime里也有回调路径或者在训练框架里PyTorch用torch.ao.quantization的QAT流程做一遍再导出ONNX。我的一次实战里PTQ掉点3.2个QAT调整之后只掉0.4效果非常明显。5. 量化档位选择与端侧部署的落地思考走到量化这一步你手里其实有多个精度档位FP32、FP16、BF16、INT8、INT4、甚至FP8。怎么选取决于你的硬件支持矩阵和精度要求。FP16是目前性价比最稳的一个档位精度几乎无损性能提升可感知所有主流的端侧CPU、GPU、NPU基本都支持。如果目标芯片对FP16的支持很好我先建议做FP16而不是直接上INT8理由很简单——少一个维度的问题要处理稳定压倒一切。INT8则适合那些实在扛不住模型体积或计算量的场景比如摄像头端实时跑检测、音频端跑语音唤醒。量化的收益远远大于付出的调优成本。INT4是这几年新冒出来的档位但老实说端侧消费级硬件对INT4的支持参差不齐。有些NPU宣称支持INT4跑起来精度损失大还得配合复杂的混合精度策略。除非是特定场景比如超大语言模型塞进手机内存时否则不建议一上来就挑战INT4。FP8更多是新一代GPU的玩法对端侧不太适用开个眼界即可。端侧部署的落地思考我最后想分享一个观点量化不是孤立的步骤它是整个部署管线的一部分。模型优化、编译、推理引擎版本、算子库、调度策略都会影响最终的INT8性能表现。你不能把“量化”理解成一个按钮而是把它理解成一套系统性的工程方法。做好数据校准安排好层级别的策略验证好每一层的输出才能在端侧跑出又快又准的效果。6. 一点个人感受从浮点到INT8模型量化改变的远不只是数据类型那么简单。它改变了一个模型能不能在手机、摄像头、耳机里跑起来改变了用户体验的流畅度和续航改变了整个端侧AI的产品设计逻辑。做端侧AI这几年我最大的体会是真正难的并不是跑通一个demo而是在资源受限的条件下把模型压到极致还能保持可用精度——这件事没有捷径只有对每一步的细节都认真对待。这篇笔记算是我这个系列里比较硬核的一篇写到这我自己也重新过了一遍量化路上的关键节点。如果你正准备开始做模型量化我的建议很简单先用最小的模型跑通全流程再去碰复杂的模型先学会看每一层的输出再谈优化整体精度。踩过一遍坑之后你会比我更清楚那个“什么改变了”的答案。