☰
RK3588上YOLOv5s INT8量化掉点压测与调优实战
2026/9/29 14:20:35 网站建设 项目流程

我先把话搁这儿:INT8量化掉点这件事,从来不是一句"会掉1-2个点"就能概括的。我在RK3588上把YOLOv5s从FP16切到INT8,第一版直接量化结果掉了接近9个点,当时看着测试集里满屏乱飞的小框,差点就把整条部署方案推倒重来。后来连着折腾了将近两周,一点点抠校准集、抠量化粒度、抠检测头保留层,才把掉点压回1-2个点的可接受范围。

这篇文章是"RK3588 上从 0 部署 YOLOv5s"系列实践记录的第六篇,前几篇我们搞定了环境、转换、上板推理,这一篇就来正面回答那个所有做边缘部署的人都会问的问题:**INT8到底掉多少点?为什么掉?怎么把掉点压下去?**这篇文章不是给你一个通用的"量化会掉点所以大家要小心"的结论,而是把我完整走一遍的量化流程、实测数据、调优手段、踩坑复盘全部摊开。如果你正在RK3588上做YOLO系列模型的INT8部署,或者刚把ONNX导出来准备转RKNN,这篇文章应该能帮你省下大量试错时间。

1. 为什么FP16跑得好好的,我还要折腾INT8

1.1 "标称6TOPS"背后的真相:RK3588的NPU对FP16并不友好

先说一个很多新手忽略的事实。RK3588的NPU标称6TOPS算力,但这个数字基本是在INT8条件下测出来的。这颗NPU对INT8/INT16的支持最到位,而对FP16,说实话更多是"兼容模式"而不是"原生模式"。我的实测数据很能说明问题:YOLOv5s输入640×640,FP16模型单帧推理大约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,取整后变成INT8;NPU里的乘累加运算用INT8做,累加器用INT32接收结果,然后再反量化回浮点做下一层。这个过程就像是把一张32位色深的照片压成8位色深——肉眼看着还行,但如果原图的动态范围极广,或者色彩过渡极其细腻,压缩后就会出现可见的色阶断层。模型量化同理,只不过"断层"表现为特征图激活值的信息损失,最终体现在检测框的置信度和位置上。

1.3 掉点的三个背锅侠:权重误差、激活误差、检测头归一化

我一开始天真地以为量化掉点主要是权重精度损失,后来逐层排查才发现太想当然了。真正在YOLOv5s上捅娄子的,大概率是这三件事:

权重量化误差。卷积核里的权重分布如果比较均匀、动态范围不大,INT8量化带来的损失就很小。但YOLOv5s某些层(尤其是浅层卷积)的权重分布其实很尖锐,两头有少量极大极小值,中间大量集中在零附近,这种情况下量化步长被迫覆盖整个范围,中间密集区反而精度下降。

激活值量化误差。输入图片经过前几层卷积后,激活值的分布往往偏离高斯分布,有的层激活值大量集中在某个区间,少量值却蹿得很远。如果按全局max值来确定scale(normal模式常这么干),那么大量小值的有效区间只占用了很少的量化档位,信息被挤成一团,误差自然就大了。

检测头的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.04)
  • Python版本:3.10(这个很重要,rknn-toolkit2对Python版本有严格要求,3.8-3.10我试过都能跑,3.11我没验证过,不推荐冒险)
  • rknn-toolkit2版本:1.6.0
  • 模型来源:YOLOv5s训练好的PyTorch权重,导出为ONNX

安装步骤也没啥稀奇的:

conda create -n rknn python=3.10 conda activate rknn pip install rknn-toolkit2==1.6.0 onnx onnxruntime==1.16.0 numpy==1.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推荐opset=12,我实测opset=13/14也能转,但某些算子会被展开成更多子图,量化时容易多出几个敏感节点。所以老老实实——torch.onnx.export里务必显式指定opset_version=12。

第二个约束是预处理的一致性。强烈建议导出模型时把归一化操作剥离掉,不要在模型内部做除以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_platform='rk3588', quantized_dtype='int8', quantized_algorithm='layer_wise', quantized_method='channel', optimization_level=3, )

逐行解释,因为这几个参数直接决定你的量化风格和最终掉点程度:

quantized_dtype='int8',不需要多说,就是本次要的量化类型。想试混合精度的时候后续会让某些层保留fp16,这里先不展开。

quantized_algorithm='layer_wise',这个参数是掉点分水岭之一。默认值其实是normal,normal模式下的量化统计是整个网络共享一套动态范围统计,对某些层来说误差比较大。layer_wise模式下,每一层都会根据自身的权重激活分布独立确定scale和zero_point,相当于每层量体裁衣。代价是模型体积略大、某些老版本runtime可能兼容性差一点,但在RK3588上1.6.0版本跑layer_wise一点问题没有,效果立竿见影。

quantized_method='channel',量化粒度细化到通道级。normal方法整层共用一个scale,channel方法每个输出通道一个scale,对权重的拟合精度高很多。我实测channel方法比normal方法在YOLOv5s上能救回大约1-2个点的mAP。

optimization_level=3,这个参数控制图优化和算子融合强度。level=3是最高优化,空间换时间,量化后模型尺寸可能会稍微大一点,但推理速度最乐观。如果模型转换后某些算子被融合出了问题(比如自定义后处理算子),可以降回level=2排查。

2.4 量化校准集不等于训练集,更不等于随便抽的图

量化校准集可能是整个流程里最被低估的一步。rknn.build(do_quantization=True)的时候,工具会根据你提供的图片集去统计每一层激活值的分布范围,然后决定量化参数。

这个图片集不需要标注,但必须像真实推理时你会遇到的输入。我最初的量化集就是从COCO val2017里随机抽了500张,结果转出来在业务视频上一塌糊涂——因为我的实际场景是夜间低照度+户外远距离,而COCO图片大多明亮且物体偏大,激活值分布对不上,量化纯粹在瞎忙。

正确做法我放到第四章细讲,这里先记住结论:量化校准集的数量不一定要多,300张精选的、覆盖各种光照/尺度/类别的图,比盲抽1000张强得多。

2.5 构建、模拟推理、导出板端模型的一气呵成

config写完,接着就是标准三连:

ret = rknn.load_onnx(model='yolov5s.onnx', input_size_list=[[1, 3, 640, 640]]) if ret != 0: print('load onnx failed') exit(1) ret = rknn.build(do_quantization=True, dataset='quant_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帧里有白天的、夜间的、逆光的、小雨的,尽量覆盖真实世界的分布。

这样的好处是:我最后给出的掉点数字,是"业务真的会遇到的掉点",而不是实验室里的理想值。

评估时我跑两个指标:mAP@0.5和mAP@0.5:0.95。很多做工程的朋友只看mAP@0.5,但mAP@0.5:0.95对框质量更敏感,INT8量化最容易影响的恰恰是框定位的精细度,只看0.5阈值会掩盖问题。

3.2 第一版INT8成绩单:掉7-9个点的残酷现实

这是我最初始、最"朴素"的量化结果,量化算法用normal,校准集用COCO随机500张,量化方法用默认:

模型版本单帧延迟(ms)内存占用(MB)mAP@0.5mAP@0.5:0.95
FP1616.8约12000.7420.461
INT8直接量化8.2约6400.6350.372
INT8掉点快约2倍省近一半-0.107-0.089

这张成绩单很残酷:mAP@0.5掉了将近11个点,mAP@0.5:0.95掉了将近9个点。当时我盯着这个数字看了半天,心里只有一个念头:"这量化了个寂寞?"

但别急着下结论。掉点不是均匀分布的,接下来我们要做的是拆解它——搞清楚到底是哪些样本在拖后腿,再针对性地处理。

3.3 按尺寸和场景拆解:掉点不是平均分的

我把测试集按目标尺寸和光照场景打了两个分组,结果差异极其明显:

先按目标尺寸分,我以大目标(高大于128像素)、中目标(32-128像素)、小目标(小于32像素)三档统计:

分组FP16 mAP@0.5INT8直接量化 mAP@0.5掉点
大目标0.8530.821-0.032
中目标0.7210.663-0.058
小目标0.4080.312-0.096

小目标掉点几乎是大目标的3倍。原因不难理解:小目标在特征图上本身只占极少像素,激活值整体偏小且信噪比低,量化误差一旦注入,特征被噪声淹没的概率就激增。

再按光照场景分,白天正常光照和夜间低照度完全是两个世界:

场景FP16 mAP@0.5INT8直接量化 mAP@0.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_bucket=100)

就这么一个改动,INT8直接量化的mAP@0.5从0.635提升到了0.662,凭空救回将近3个点。原理也很直白:量化校准的本质是摸清网络每一层激活值的真实分布,喂给它的校准集分布越贴近实际推理分布,量化参数就越准确。随机抽图经常抽到一堆明亮、大目标、无遮挡的"简单样本",对实际推理帮助有限。

4.2 把normal量化换成layer_wise + channel,每一层都给自己定制一套量化参数

前面我配置rknn.config的时候已经埋了伏笔——quantized_algorithm='layer_wise'和quantized_method='channel'这两个参数,在基于业务校准集的基础上进一步挽回了大约1-2个点的损失。

说下原理。normal算法在整个网络的激活值上做一个全局的统计,相当于给所有人发同一件均码衣服。但YOLOv5s不同层的特征图尺度天差地别:浅层特征图数值波动剧烈,深层检测头的特征图数值普遍很小,全局一套参数必然顾此失彼。layer_wise则是每层算一套scale,channel更是细化到每个输出通道一个scale,相当于给每层每个人量体裁衣。

我把对比数据摆出来:

量化策略mAP@0.5mAP@0.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_dtype='int8', custom_oplist=[ # 根据实际onnx算子名,指定输出头附近的层保留fp16 {'op_type': 'Sigmoid', 'quantized_dtype': 'fp16'}, {'op_name': '/model.24/m.2/Conv', 'quantized_dtype': 'fp16'}, ], do_quantization=True, )

这步操作后,掉点进一步收窄:

模型版本mAP@0.5mAP@0.5:0.95
全INT8 (layer_wise+channel)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,也就是mean=0、std=255。但如果你用的是自定义训练的权重,训练时用了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精度也正常,但一量化INT8,mAP直接从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部署的本质是拿少量精度换一倍以上的算力和内存收益。对我来说,mAP@0.5掉点控制在2个点以内,速度翻倍,就是完全值得的买卖。有时候为了那最后0.5个点去保留一堆FP16层,会让模型体积膨胀、推理速度打折,得不偿失。

最后再分享一个我现在的固定动作:每次量化完,不管看起来多好,我都固定跑同一份业务测试集,把FP16和INT8的检测框叠加可视化,逐个视频片段用肉眼过一遍。指标可以撒谎,但可视化不会。把那些"数值达标但视觉上明显有问题"的硬骨头啃掉,部署才算真正完成。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询