RK3588上YOLOv5s INT8量化掉点压测与调优实战

发布时间:2026/9/29 14:20:39
RK3588上YOLOv5s INT8量化掉点压测与调优实战
我先把话搁这儿INT8量化掉点这件事从来不是一句会掉1-2个点就能概括的。我在RK3588上把YOLOv5s从FP16切到INT8第一版直接量化结果掉了接近9个点当时看着测试集里满屏乱飞的小框差点就把整条部署方案推倒重来。后来连着折腾了将近两周一点点抠校准集、抠量化粒度、抠检测头保留层才把掉点压回1-2个点的可接受范围。这篇文章是RK3588 上从 0 部署 YOLOv5s系列实践记录的第六篇前几篇我们搞定了环境、转换、上板推理这一篇就来正面回答那个所有做边缘部署的人都会问的问题**INT8到底掉多少点为什么掉怎么把掉点压下去**这篇文章不是给你一个通用的量化会掉点所以大家要小心的结论而是把我完整走一遍的量化流程、实测数据、调优手段、踩坑复盘全部摊开。如果你正在RK3588上做YOLO系列模型的INT8部署或者刚把ONNX导出来准备转RKNN这篇文章应该能帮你省下大量试错时间。1. 为什么FP16跑得好好的我还要折腾INT81.1 标称6TOPS背后的真相RK3588的NPU对FP16并不友好先说一个很多新手忽略的事实。RK3588的NPU标称6TOPS算力但这个数字基本是在INT8条件下测出来的。这颗NPU对INT8/INT16的支持最到位而对FP16说实话更多是兼容模式而不是原生模式。我的实测数据很能说明问题YOLOv5s输入640×640FP16模型单帧推理大约15-18毫秒换成INT8之后直接干到7-9毫秒速度几乎翻倍同时内存占用差不多降了一半。打个不严谨但很直观的比方INT8相当于给这个NPU喂它最顺手的食物FP16则是让它用不太擅长的姿势干活。所以只要业务对精度没有苛刻到一个点都不能掉那么INT8几乎是必选项——尤其是当你打算在RK3588上同时跑多路视频分析的时候40%以上的算力节省和内存节省是实打实的收益。1.2 量化原理速补把照片从32位色深压到8位很多朋友一听到量化就头大总觉得这是个黑箱。其实底层直觉特别简单原来的卷积权重和激活值都是FP32浮点数现在要映射到[-128, 127]这256个整数档位上核心就两个参数——缩放系数scale和零点zero_point。实际计算时浮点值乘以scale再加上zero_point取整后变成INT8NPU里的乘累加运算用INT8做累加器用INT32接收结果然后再反量化回浮点做下一层。这个过程就像是把一张32位色深的照片压成8位色深——肉眼看着还行但如果原图的动态范围极广或者色彩过渡极其细腻压缩后就会出现可见的色阶断层。模型量化同理只不过断层表现为特征图激活值的信息损失最终体现在检测框的置信度和位置上。1.3 掉点的三个背锅侠权重误差、激活误差、检测头归一化我一开始天真地以为量化掉点主要是权重精度损失后来逐层排查才发现太想当然了。真正在YOLOv5s上捅娄子的大概率是这三件事权重量化误差。卷积核里的权重分布如果比较均匀、动态范围不大INT8量化带来的损失就很小。但YOLOv5s某些层尤其是浅层卷积的权重分布其实很尖锐两头有少量极大极小值中间大量集中在零附近这种情况下量化步长被迫覆盖整个范围中间密集区反而精度下降。激活值量化误差。输入图片经过前几层卷积后激活值的分布往往偏离高斯分布有的层激活值大量集中在某个区间少量值却蹿得很远。如果按全局max值来确定scalenormal模式常这么干那么大量小值的有效区间只占用了很少的量化档位信息被挤成一团误差自然就大了。检测头的sigmoid归一化。YOLOv5s的输出头要对原始logits做sigmoid把数值压到[0,1]区间才计算obj和class的分数。问题来了sigmoid的输出在0附近和1附近特别拥挤稍微一点量化误差映射到置信度上就可能从0.3跳到0.5或者反过来。这一块是最容易产生诡异检测行为的重灾区——我的第一版INT8模型白天大目标基本没大问题夜间小目标直接变得神神叨叨框偏移、置信度乱飞就是检测头量化误差在作祟。2. 量化全流程实操从ONNX到INT8 RKNN每个环节的硬约束2.1 环境与版本Python 3.10 rknn-toolkit2 1.6.0先把我用的环境固定下来省的后面哪一步报错了你回头怪配置。宿主机x86_64的Linux系统我用的Ubuntu 20.04Python版本3.10这个很重要rknn-toolkit2对Python版本有严格要求3.8-3.10我试过都能跑3.11我没验证过不推荐冒险rknn-toolkit2版本1.6.0模型来源YOLOv5s训练好的PyTorch权重导出为ONNX安装步骤也没啥稀奇的conda create -n rknn python3.10 conda activate rknn pip install rknn-toolkit21.6.0 onnx onnxruntime1.16.0 numpy1.24.0这里有个小坑onnxruntime版本别乱上最新版1.16.0和rknn-toolkit2配合最稳。我试过装1.17直接把rknn_load_onnx干崩了报什么operator not supported其实根本不是算子问题就是版本兼容性翻车。装完之后先跑一下toolkit自带的示例确认环境没问题再开始正经干活。这一步花不了十分钟但能帮你排除掉是环境问题还是模型问题这个最烦人的分叉。2.2 导出ONNX时的两个约束opset和预处理移植我要导出的YOLOv5s来自ultralytics训练出来的权重。导出ONNX这一步两个约束必须盯紧第一个约束是opset版本。rknn-toolkit2推荐opset12我实测opset13/14也能转但某些算子会被展开成更多子图量化时容易多出几个敏感节点。所以老老实实——torch.onnx.export里务必显式指定opset_version12。第二个约束是预处理的一致性。强烈建议导出模型时把归一化操作剥离掉不要在模型内部做除以255这类操作。原因是rknn.config里有独立的mean_values/std_values参数来做预处理如果你模型里又除一次255板端推理时等于归一化了两次这种错误极其隐蔽后面我专门用案例一说。导出命令大致长这样python export.py --weights yolov5s.pt --include onnx --opset 12导出完先用onnxruntime在PC上推理一张图确认输出精度和PyTorch原版对得上再往下走。2.3 rknn.config参数怎么写三个参数决定量化风格接下来是整篇文章最核心的代码段——rknn.config的配置。我把一轮典型配置贴出来from rknn.api import RKNN rknn RKNN() ret rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588, quantized_dtypeint8, quantized_algorithmlayer_wise, quantized_methodchannel, optimization_level3, )逐行解释因为这几个参数直接决定你的量化风格和最终掉点程度quantized_dtypeint8不需要多说就是本次要的量化类型。想试混合精度的时候后续会让某些层保留fp16这里先不展开。quantized_algorithmlayer_wise这个参数是掉点分水岭之一。默认值其实是normalnormal模式下的量化统计是整个网络共享一套动态范围统计对某些层来说误差比较大。layer_wise模式下每一层都会根据自身的权重激活分布独立确定scale和zero_point相当于每层量体裁衣。代价是模型体积略大、某些老版本runtime可能兼容性差一点但在RK3588上1.6.0版本跑layer_wise一点问题没有效果立竿见影。quantized_methodchannel量化粒度细化到通道级。normal方法整层共用一个scalechannel方法每个输出通道一个scale对权重的拟合精度高很多。我实测channel方法比normal方法在YOLOv5s上能救回大约1-2个点的mAP。optimization_level3这个参数控制图优化和算子融合强度。level3是最高优化空间换时间量化后模型尺寸可能会稍微大一点但推理速度最乐观。如果模型转换后某些算子被融合出了问题比如自定义后处理算子可以降回level2排查。2.4 量化校准集不等于训练集更不等于随便抽的图量化校准集可能是整个流程里最被低估的一步。rknn.build(do_quantizationTrue)的时候工具会根据你提供的图片集去统计每一层激活值的分布范围然后决定量化参数。这个图片集不需要标注但必须像真实推理时你会遇到的输入。我最初的量化集就是从COCO val2017里随机抽了500张结果转出来在业务视频上一塌糊涂——因为我的实际场景是夜间低照度户外远距离而COCO图片大多明亮且物体偏大激活值分布对不上量化纯粹在瞎忙。正确做法我放到第四章细讲这里先记住结论量化校准集的数量不一定要多300张精选的、覆盖各种光照/尺度/类别的图比盲抽1000张强得多。2.5 构建、模拟推理、导出板端模型的一气呵成config写完接着就是标准三连ret rknn.load_onnx(modelyolov5s.onnx, input_size_list[[1, 3, 640, 640]]) if ret ! 0: print(load onnx failed) exit(1) ret rknn.build(do_quantizationTrue, datasetquant_dataset.txt) if ret ! 0: print(build failed) exit(1) # PC上模拟推理先看量化后模型输出是否还正常 rknn.init_runtime() outputs rknn.inference(inputs[img]) print(len(outputs), [o.shape for o in outputs]) ret rknn.export_rknn(yolov5s_int8.rknn)dataset文件就是每行一个图片路径的txt这个不用标注纯图片路径就行。构建完成后rknn.init_runtime在PC上会走模拟器推理此时拿一张代表性图片过一遍先用肉眼看看检测结果还靠不靠谱。这一步很关键如果PC模拟阶段就已经崩了上板肯定也崩问题出在量化策略上如果PC模拟没问题但上板后崩了才需要去查板端runtime版本和内存问题。3. 实测INT8到底掉了多少点以及这些点掉在哪3.1 评估口径为什么我不用COCO的mAP来交差必须说明一点评估量化掉点最忌讳拿一个不贴近业务的测试集。很多朋友说INT8掉1个点其实是拿COCO val2017做的统计那个数字对你的真实业务没多大参考价值。我做的是户外视频结构化场景类别是行人、车辆、两轮车这几类所以我专门从真实摄像头录像里抽了300帧人工标注成测试集并且确保这300帧里有白天的、夜间的、逆光的、小雨的尽量覆盖真实世界的分布。这样的好处是我最后给出的掉点数字是业务真的会遇到的掉点而不是实验室里的理想值。评估时我跑两个指标mAP0.5和mAP0.5:0.95。很多做工程的朋友只看mAP0.5但mAP0.5:0.95对框质量更敏感INT8量化最容易影响的恰恰是框定位的精细度只看0.5阈值会掩盖问题。3.2 第一版INT8成绩单掉7-9个点的残酷现实这是我最初始、最朴素的量化结果量化算法用normal校准集用COCO随机500张量化方法用默认模型版本单帧延迟(ms)内存占用(MB)mAP0.5mAP0.5:0.95FP1616.8约12000.7420.461INT8直接量化8.2约6400.6350.372INT8掉点快约2倍省近一半-0.107-0.089这张成绩单很残酷mAP0.5掉了将近11个点mAP0.5:0.95掉了将近9个点。当时我盯着这个数字看了半天心里只有一个念头这量化了个寂寞但别急着下结论。掉点不是均匀分布的接下来我们要做的是拆解它——搞清楚到底是哪些样本在拖后腿再针对性地处理。3.3 按尺寸和场景拆解掉点不是平均分的我把测试集按目标尺寸和光照场景打了两个分组结果差异极其明显先按目标尺寸分我以大目标高大于128像素、中目标32-128像素、小目标小于32像素三档统计分组FP16 mAP0.5INT8直接量化 mAP0.5掉点大目标0.8530.821-0.032中目标0.7210.663-0.058小目标0.4080.312-0.096小目标掉点几乎是大目标的3倍。原因不难理解小目标在特征图上本身只占极少像素激活值整体偏小且信噪比低量化误差一旦注入特征被噪声淹没的概率就激增。再按光照场景分白天正常光照和夜间低照度完全是两个世界场景FP16 mAP0.5INT8直接量化 mAP0.5掉点白天0.7840.718-0.066夜间0.6630.547-0.116逆光0.7020.621-0.081夜间场景掉点接近白天的两倍。这里面有个连锁反应夜间图像整体亮度低输入经过mean/std归一化后网络的激活值分布和白天差异巨大量化校准集如果没覆盖这种分布那么夜间特征图的量化步长就会错得离谱。看到这些分组数据至少有了方向掉点不是均匀的主要集中在小目标低照度场景那调优就有的放矢。4. 实战调优把掉点从7-9个点压到1-2个点的四个手段4.1 校准集只选代表性数据而不是多一点数据这一步是整个调优里收益最大、成本最低的改进我甚至愿意把它排到所有手段的第一位。我的做法是从真实视频流里做自动化的困难样本挖掘而不是随机抽帧。具体操作分三步第一步把目标场景的录像按每10秒抽一帧粗筛出约2000帧候选图。 第二步对每帧算亮度直方图和边缘密度——亮度直方图用来卡光照分布边缘密度用来卡有没有明显目标。 第三步按分位数挑选300帧保证这300帧覆盖暗光、正常、过曝三档亮度且每档里都保证有相当比例的小目标样本。# 伪代码示意 for frame in video_frames: gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) mean_brightness gray.mean() edge_density cv2.Laplacian(gray, cv2.CV_64F).var() samples.append((frame, mean_brightness, edge_density)) selected stratified_sample(samples, buckets[dark, normal, bright], quota_per_bucket100)就这么一个改动INT8直接量化的mAP0.5从0.635提升到了0.662凭空救回将近3个点。原理也很直白量化校准的本质是摸清网络每一层激活值的真实分布喂给它的校准集分布越贴近实际推理分布量化参数就越准确。随机抽图经常抽到一堆明亮、大目标、无遮挡的简单样本对实际推理帮助有限。4.2 把normal量化换成layer_wise channel每一层都给自己定制一套量化参数前面我配置rknn.config的时候已经埋了伏笔——quantized_algorithmlayer_wise和quantized_methodchannel这两个参数在基于业务校准集的基础上进一步挽回了大约1-2个点的损失。说下原理。normal算法在整个网络的激活值上做一个全局的统计相当于给所有人发同一件均码衣服。但YOLOv5s不同层的特征图尺度天差地别浅层特征图数值波动剧烈深层检测头的特征图数值普遍很小全局一套参数必然顾此失彼。layer_wise则是每层算一套scalechannel更是细化到每个输出通道一个scale相当于给每层每个人量体裁衣。我把对比数据摆出来量化策略mAP0.5mAP0.5:0.95业务校准集 normal0.6620.398业务校准集 layer_wise channel0.6810.419这组对比是在校准集优化之后做的两项指标分别再提升约2个点。代价是导出的rknn文件体积从7.8MB涨到9.1MB但在RK3588上这点体积差异完全不敏感。4.3 混合量化把sigmoid和输出层的好日子还给它到了这一步INT8的成绩已经比最开始好看了不少但还差最后一口气夜间小目标的置信度依然飘忽不定误检和漏检在肉眼检查时仍然显眼。接下来要动的是检测头。前面说过YOLOv5s的检测头对sigmoid之后的[0,1]区间映射极其敏感。普通量化后这个区间的映射容易出现档位过粗的问题——就好比温度计只有5度一个刻度那26度和29度看起来都是25度置信度区分度直接崩了。解决方案是混合量化让少数几个敏感层继续以FP16精度运行其余大部分层保持INT8。在rknn.config中可以通过custom_oplist指定保留FP16的算子类型或层级。我保留的是三个输出头前面的最后几个卷积层和sigmoid层其余全INT8。具体配置大概长这样不同版本字段名略有差异看你的toolkit文档ret rknn.config( ..., quantized_dtypeint8, custom_oplist[ # 根据实际onnx算子名指定输出头附近的层保留fp16 {op_type: Sigmoid, quantized_dtype: fp16}, {op_name: /model.24/m.2/Conv, quantized_dtype: fp16}, ], do_quantizationTrue, )这步操作后掉点进一步收窄模型版本mAP0.5mAP0.5:0.95全INT8 (layer_wisechannel)0.6810.419混合量化(关键层fp16)0.7190.447相比FP16基准0.742/0.461混合量化后的INT8掉点已经压到2-3个点以内。达到了业务可接受范围——说实在的这种差异在真实视频监控场景里肉眼已经很难分辨了而推理速度依然保持在9ms左右。4.4 mean/std、分辨率、NMS阈值这些外围细节才是隐形杀手当量化策略调到位后剩下的掉点其实很多是外围细节问题。这里点名三个我实测中影响极大的坑第一个是mean/std的一致性。rknn.config里的mean_values和std_values必须和你训练时完全一致。YOLOv5s官方权重默认的归一化是直接把像素除以255也就是mean0、std255。但如果你用的是自定义训练的权重训练时用了mean(0.485, 0.456, 0.406)这种ImageNet归一化那就必须在rknn.config里同步设置并且注意数值范围是[0,255]还是[0,1]。这个不一致会让所有层的激活值分布整体平移量化误差雪上加霜。第二个是输入分辨率。量化时校准集的分辨率和上板推理的分辨率必须一致。我一开始校准集图是720p的rknn内部会resize到640×640后来测试时改成832×832推理校准分布跟实际推理分布直接错位掉点又回来了0.5个点。所以整个链路里要保持输入尺寸的统一。第三个是NMS阈值的再校准。INT8模型的置信度分布和FP16模型有差异直接套用FP16时的conf_thres和nms_iou会人为造成漏检/误检。我的习惯是量化部署后重新在测试集上扫一遍conf_thres从0.15到0.45每隔0.05测一次找到当前模型的最优阈值。这一步通常能稳定挽回0.5-1个点的表现但很多教程根本不会提。5. 三个真实踩坑案例模型是怎么被搞崩的又怎么救回来5.1 案例一预处理做了两次归一化检测结果大面积漏检这是一个让我印象深刻的翻车案例。当时我从一个同事手里接了一个导出好的ONNX模型内部在输入节点后挂了一个除以255的节点而我在rknn.config里又把std_values设成了[255, 255, 255]。结果就是输入图像先被模型内部除以255又被rknn的预处理层缩放等于做了两次归一化所有激活值一进入网络就偏离了训练时的分布。第一版模型跑出来的效果惨不忍睹大目标勉强能框中等目标大量漏检小目标一个都看不见置信度全面偏低。排查这个问题的思路也很折磨人——因为PC上onnxruntime推理明明正常一转成rknn就崩。后来我做了个交叉验证把两个归一化去掉一个逐个测试。先让rknn.config里的std_values设成[1,1,1]不做预处理模型内部自带的除以255生效——正常了。再反过来把模型内部的除以255节点去掉用rknn.config做预处理——也正常了。这就实锤了双归一化问题。经验教训拿到别人导出的ONNX第一件事就是看输入节点后面挂的是什么。上板部署绝对不能留两套归一化逻辑。5.2 案例二重参数化结构折叠后直接量化mAP掉到0.12第二个案例更隐蔽。我尝试把一套RepVGG风格训练的backbone套进YOLOv5s结构训练完成后在导出ONNX时进行了结构重参数化折叠。折叠后的模型推理速度很理想测试集FP16精度也正常但一量化INT8mAP直接从0.74崩到0.12几乎等于废了。当时第一反应是校准集出了问题换了好几版校准集都无济于事。后来我写了个脚本逐层打印权重在量化和反量化前后的差异发现折叠后某些卷积层的权重分布极其不均匀——因为重参数化把多个分支的统计量折叠到了单一卷积核里导致权重中存在少量绝对值很大的离群点。这些离群点拉高了全局scale使得大量正常权重在量化后只落在几个离散档位上精度损失呈指数级放大。normal和layer_wise都救不回来因为问题出在权重自身分布上。最后解决办法是暂时放弃了这条路——重新用普通结构训练而不是用重参数化结构硬上INT8。这也让我记住了一个原则结构和量化是一体的设计模型结构时就要考虑它适不适合INT8。如果你想走重参数化INT8务必在训练时就引入QAT量化感知训练让网络在训练阶段就适应量化的噪声否则纯训练后量化大概率翻车。5.3 案例三并发推理时INT8峰值内存反而比FP16高最后一个案例不是精度问题而是工程资源问题但同样能坑死人。我的业务上要在RK3588上同时跑两路YOLOv5s推理当时想的是INT8比FP16内存省一半两个INT8实例同时跑应该很轻松。结果一压测发现双实例INT8的峰值内存偶尔飙得比双实例FP16还高导致频繁触发OOM。排查后定位到原因rknn runtime从文件加载模型时会对权重做页对齐和缓冲区预分配INT8模型如果使用了channel-wise量化每个通道的scale和zero_point也要常驻内存。双实例并行时这部分额外开销叠加起来反而超过了单纯fp16模型节省下来的权重内存。解决办法是改成单实例多线程推理复用同一个rknn context而不是开两个进程/两个context。模型共享权重推理并发用线程池排队内存峰值立刻降了下来。这个案例提醒我模型文件小不等于运行内存小runtime的缓冲机制和上下文开销必须实测。上板前做压测别只看单实例数据想当然。6. 最后一次量化实验后我留下的一些判断标准跑完这整轮INT8量化我总结出几条自己的判断标准不一定适合所有人但至少能帮你少踩几个坑判断量化是否成功先看业务场景的目标尺寸分布和光照分布。如果你的业务里小目标占比高、夜间低照度画面多那掉点注定会比通用数据集严重。做好心理预期别拿COCO的上限来要求自己压力就不会那么大。调优顺序有优先级先校准集再量化粒度最后才考虑混合量化。我见过有人一上来就搞混合量化保留一堆层结果掉点还是压不住——多半是校准集没救回来先去查地基。不要盲目追求0掉点。INT8部署的本质是拿少量精度换一倍以上的算力和内存收益。对我来说mAP0.5掉点控制在2个点以内速度翻倍就是完全值得的买卖。有时候为了那最后0.5个点去保留一堆FP16层会让模型体积膨胀、推理速度打折得不偿失。最后再分享一个我现在的固定动作每次量化完不管看起来多好我都固定跑同一份业务测试集把FP16和INT8的检测框叠加可视化逐个视频片段用肉眼过一遍。指标可以撒谎但可视化不会。把那些数值达标但视觉上明显有问题的硬骨头啃掉部署才算真正完成。