1. 为什么我要死磕 INT8 的精度损失
把 YOLOv5s 搬到 RK3588 上跑起来,这件事本身在 2024 年已经不算什么新鲜活了。真正让人睡不着的,是模型跑起来之后那张检测结果图——框的位置飘了、小目标漏了、置信度忽高忽低。你明明在 PC 上用 PyTorch 验证过 mAP 有 0.56,怎么一上板子就变成 0.48 了?这中间掉的那几个点,到底掉在哪一步?
这个系列写到第六篇,前面五篇分别覆盖了环境搭建、模型导出、RKNN 转换、板端推理和性能调优。但说实话,前五篇里我一直在回避一个核心问题:INT8 量化到底会掉多少精度。不是不想讲,是这个问题太容易讲成玄学——"大概掉一两个点吧"、"看模型看数据"、"调调混合量化就好了"。这种回答我自己都不满意。
所以这一篇,我决定把这件事彻底拆开。我会用同一个 YOLOv5s 模型、同一份校准数据集、同一块 RK3588 开发板,把 FP16 和 INT8 两条路径的 mAP 逐项对比出来。不是给你一个"掉了 3.2 个点"的结论就完事,而是要让你看清楚:掉的这些点分别来自哪里,哪些是可以找回来的,哪些是量化本身的物理极限。
先给不熟悉背景的读者补一下课。RK3588 搭载的 NPU 算力标称 6 TOPS,但这个 6 TOPS 是 INT8 的算力。如果你跑 FP16,算力直接砍半甚至更多。这意味着在实际部署中,INT8 不是"可选优化",而是"能不能达到实时帧率的生死线"。以 YOLOv5s 640x640 输入为例,FP16 在 RK3588 上单帧推理大约 45-55ms,INT8 可以压到 20-28ms。这个差距直接决定了你是 18 FPS 还是 40 FPS,对于视频流分析场景来说就是能用和不能用的区别。
但代价是什么?这就是本篇要回答的问题。适合谁看:已经能在 RK3588 上跑通 YOLOv5s、但发现精度不达标的工程师;正在评估 INT8 量化方案是否可行的技术选型人员;以及所有对"量化到底损失了什么"这件事有执念的人。
2. 量化精度的核心概念与评测基准搭建
2.1 INT8 量化到底在做什么
在讨论掉点之前,得先统一语言。INT8 量化本质上是一个仿射映射过程:把 FP32 的浮点数值域线性映射到 [-128, 127] 的整数域。公式很简单:
real_value = scale × (quantized_value - zero_point)其中 scale 是缩放因子,zero_point 是零点偏移。听起来人畜无害,但问题在于:这个映射是全局线性的,而神经网络中的数值分布往往是非均匀的。权重可能集中在很小的范围内,激活值可能有长尾分布,一旦用统一的 scale 去覆盖整个动态范围,大量数值就会挤在少数几个量化格子上,精度自然就丢了。
更麻烦的是,YOLOv5s 里有几类对量化特别敏感的算子。第一类是 SiLU 激活函数,它的非线性特性在低精度下容易失真;第二类是 concat 操作,不同分支的数值范围差异大,强行统一 scale 会互相伤害;第三类是检测头的 sigmoid 和 decode 部分,这些操作对数值精度极其敏感,量化误差会被放大到坐标偏移上。
2.2 评测基准怎么定才靠谱
要量化"掉了多少点",首先得有一个可信的基准。我见过太多人拿 PC 上 PyTorch 的 mAP 直接和板端 INT8 的 mAP 对比,然后得出"掉了 8 个点"的结论——这种对比是不严谨的,因为中间还隔着 ONNX 导出、RKNN 转换、预处理差异等一堆变量。
我的做法是建立三级基准:
| 基准层级 | 模型格式 | 运行环境 | 用途 |
|---|---|---|---|
| 基准 A | PyTorch FP32 | PC (GPU) | 理论上限,参考用 |
| 基准 B | RKNN FP16 | RK3588 NPU | 量化前的真实上限 |
| 基准 C | RKNN INT8 | RK3588 NPU | 量化后的实际表现 |
真正有意义的对比是 B 和 C 之间的差距,因为这两者跑在同一块板子、同一套预处理、同一个后处理代码上,唯一的变量就是量化精度。A 到 B 的差距主要来自框架转换和算子实现差异,那是另一个话题。
评测数据集我用的是 COCO val2017 的一个子集,随机抽取 500 张图,覆盖了人、车、动物、日常物品等常见类别。为什么不跑全量 5000 张?因为每跑一轮完整评测在板端要花将近 40 分钟,调参阶段根本等不起。500 张的统计波动大约在 ±0.5 个点以内,对于工程判断足够了。
注意:校准数据集和评测数据集必须严格分离。我见过有人拿评测集去做量化校准,结果 mAP 虚高得离谱,上线就翻车。这是量化里最容易犯的低级错误。
2.3 校准数据的选取策略
RKNN 的 INT8 量化是 post-training quantization(PTQ),也就是训练后量化,它依赖一份校准数据集来统计激活值的动态范围。这份数据选得好不好,直接决定量化精度。
我的经验是:校准集不需要大,但必须"有代表性"。200-500 张图足够,关键是要覆盖你实际部署场景中的各种情况——不同光照、不同目标尺度、不同背景复杂度。如果你部署的是交通监控,校准集里全是室内照片,那量化出来的 scale 肯定不对。
具体操作上,我从训练集里随机抽 300 张,再手动补充 50 张困难样本(小目标多、遮挡严重、光照极端的),凑成 350 张的校准集。这个数量在 RKNN 上跑校准大约 3-5 分钟,效率可以接受。
3. FP16 与 INT8 的逐层精度对比实操
3.1 环境与模型准备
先把基础环境交代清楚,避免复现时踩坑。我的软硬件配置如下:
- 开发板:RK3588 官方 EVB,16GB 内存版本
- 系统:Ubuntu 20.04(板端),内核 5.10
- RKNN-Toolkit2:版本 1.6.0
- NPU 驱动:0.9.6
- PC 端:Ubuntu 22.04 + Python 3.8
模型方面,我用的是 YOLOv5s v7.0 官方权重,输入 640x640,类别数 80。导出 ONNX 时有两个关键点必须注意:
python export.py --weights yolov5s.pt --include onnx --opset 12 --img-size 640 640opset 选 12 而不是最新的 17,是因为 RKNN-Toolkit2 1.6.0 对高版本 opset 的支持还不完善,某些算子会转换失败。img-size 必须显式指定,否则动态轴会导致后续量化出问题。
导出后先用 onnxsim 做一次图优化,去掉冗余算子:
onnxsim yolov5s.onnx yolov5s-sim.onnx这一步能把模型里的 Identity、Dropout 等无用节点清理掉,RKNN 转换时算子融合会更顺畅。
3.2 FP16 模型的转换与验证
先转 FP16,建立基准 B。RKNN 转换脚本核心部分:
from rknn.api import RKNN rknn = RKNN(verbose=True) rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', quantized_dtype='w16a16i', # FP16 量化 optimization_level=3 ) rknn.load_onnx(model='yolov5s-sim.onnx') rknn.build(do_quantization=False) # FP16 不做量化校准 rknn.export_rknn('yolov5s_fp16.rknn')这里有个细节:quantized_dtype='w16a16i'表示权重和激活都用 16 位,但 RKNN 内部实际是以 FP16 存储和计算的。do_quantization=False是因为 FP16 不需要校准集。
转完之后在板端跑一遍评测,得到基准 B 的 mAP。我这次跑出来是0.552(500 张子集,IoU=0.5)。对比 PC 上 PyTorch FP32 的 0.561,掉了 0.9 个点,这部分损失来自算子实现差异和预处理细节,属于正常范围。
3.3 INT8 模型的转换与校准
接下来是重头戏。INT8 转换的关键在于校准配置:
rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', quantized_dtype='asymmetric_quantized-8', quantized_algorithm='normal', quantized_method='channel', optimization_level=3 ) rknn.load_onnx(model='yolov5s-sim.onnx') rknn.build(do_quantization=True, dataset='calib_list.txt') rknn.export_rknn('yolov5s_int8.rknn')几个参数值得展开说:
quantized_dtype='asymmetric_quantized-8'是非对称 INT8,相比对称量化,它对激活值的处理更友好,尤其是 SiLU 这种非负激活。
quantized_method='channel'是逐通道量化,权重每个输出通道单独算 scale,比逐层量化精度高不少,代价是推理时稍微慢一点点,但实测差异在 1ms 以内,完全值得。
quantized_algorithm='normal'是标准 KL 散度校准。RKNN 还提供mmse算法,在某些模型上能多找回 0.3-0.5 个点,但耗时更长。我建议先用 normal 跑通,再考虑换 mmse 对比。
校准集文件calib_list.txt里每行是一张图的路径,注意图片必须是预处理前的原图,RKNN 会自己按 config 里的 mean/std 做归一化。
3.4 精度对比结果
跑完两轮评测,结果如下:
| 指标 | FP16 (基准B) | INT8 (基准C) | 差值 |
|---|---|---|---|
| mAP@0.5 | 0.552 | 0.518 | -3.4 |
| mAP@0.5:0.95 | 0.361 | 0.332 | -2.9 |
| 单帧推理耗时 | 48ms | 24ms | -50% |
| 模型体积 | 14.2MB | 7.3MB | -48% |
INT8 掉了 3.4 个点。这个数字不算好看,但也不算灾难。问题在于,这 3.4 个点是怎么分布的?是所有类别均匀掉,还是某些类别崩了?
我按类别拆了一下,发现掉点极不均匀:
| 类别 | FP16 AP | INT8 AP | 差值 |
|---|---|---|---|
| person | 0.612 | 0.598 | -1.4 |
| car | 0.587 | 0.571 | -1.6 |
| bicycle | 0.421 | 0.362 | -5.9 |
| traffic light | 0.318 | 0.241 | -7.7 |
| bird | 0.389 | 0.341 | -4.8 |
规律很明显:大目标、特征明显的类别掉点少,小目标、细长目标、密集小物体掉点多。traffic light 掉了 7.7 个点,几乎腰斩。这印证了前面的判断——量化误差对小目标的定位和分类影响最大。
4. 掉点根因分析与精度找回实战
4.1 逐层敏感度分析
要找回精度,先得知道哪些层最敏感。RKNN-Toolkit2 提供了逐层量化误差分析功能,可以在转换时开启:
rknn.analysis( input_paths=['test.jpg'], data_type='float32', output_path='./analysis' )跑完之后会生成每层的余弦相似度报告。我重点看了几个关键层:
| 层类型 | 余弦相似度 | 敏感度 |
|---|---|---|
| Conv (backbone 浅层) | 0.998 | 低 |
| Conv (backbone 深层) | 0.991 | 中 |
| SiLU 激活 | 0.976 | 高 |
| Concat | 0.983 | 中高 |
| Detect head | 0.962 | 极高 |
Detect head 的相似度只有 0.962,这是掉点的主要来源。原因在于检测头里的 sigmoid 和坐标解码对数值精度极其敏感,INT8 的量化格子在 0 附近太稀疏,导致小数值区分不开。
4.2 混合量化:把敏感层保下来
RKNN 支持混合量化,也就是把敏感层保留为 FP16,其余层用 INT8。操作方式是先生成一个量化配置文件,然后手动指定哪些层不量化:
rknn.hybrid_quantization_step1( dataset='calib_list.txt', rknn_model='yolov5s_int8.rknn' )这会生成一个yolov5s.quantization.cfg文件,里面列出了所有层。我把 Detect head 相关的层和最后几个 SiLU 层改成custom_quantize_layers,指定为 float16:
custom_quantize_layers: - 'Conv_Detect_1' - 'Conv_Detect_2' - 'Conv_Detect_3' - 'Sigmoid_1' - 'Sigmoid_2' - 'Sigmoid_3'然后执行 step2:
rknn.hybrid_quantization_step2( model_input='yolov5s-sim.onnx', data_input='yolov5s.quantization.cfg', model_output='yolov5s_hybrid.rknn' )混合量化后重新评测,结果:
| 方案 | mAP@0.5 | 推理耗时 |
|---|---|---|
| 纯 INT8 | 0.518 | 24ms |
| 混合量化 | 0.541 | 29ms |
| FP16 | 0.552 | 48ms |
混合量化找回了 2.3 个点,代价是推理慢了 5ms。这个 trade-off 我认为非常划算——29ms 对应 34 FPS,依然满足实时要求,而精度从 0.518 拉到了 0.541,距离 FP16 只差 1.1 个点。
4.3 校准算法与数据优化
除了混合量化,还有两个手段可以进一步找回精度。
第一个是换校准算法。把quantized_algorithm从normal改成mmse,重新跑一遍:
rknn.config( quantized_algorithm='mmse', quantized_method='channel', ... )mmse 算法通过最小化量化误差的均方值来搜索最优 scale,对激活值分布复杂的层效果更好。实测在纯 INT8 方案上,mmse 比 normal 多找回了 0.4 个点(0.518 → 0.522),耗时增加了约 2 分钟。在混合量化方案上,mmse 又额外贡献了 0.2 个点(0.541 → 0.543)。
第二个是优化校准集。我前面提到校准集要覆盖实际场景,这里具体展开。我最初的校准集是从 COCO 训练集随机抽的 300 张,后来发现里面小目标占比偏低。于是我按目标面积做了分层抽样,确保小目标(面积 < 32x32)占比不低于 30%。重新校准后,traffic light 这个类别的 AP 从 0.241 提升到了 0.278,整体 mAP 提升了 0.3 个点。
实操心得:校准集的分布比数量重要得多。与其堆 1000 张随机图,不如精心挑 300 张覆盖各种场景的图。我一般会先跑一遍校准,看逐层相似度报告,哪层相似度低,就针对性地补充那类场景的图片。
4.4 后处理补偿
还有一个容易被忽略的点:后处理阶段的补偿。INT8 量化会让检测框的坐标和置信度产生系统性偏差,如果后处理里能做一些补偿,也能找回部分精度。
具体做法是在 NMS 之前,对置信度做一个轻微的重新校准。我统计了 INT8 模型输出的置信度分布,发现整体偏低约 0.02-0.03。于是在后处理里加了一个偏移:
# 置信度补偿 conf = conf + 0.025 conf = np.clip(conf, 0, 1)这个补偿让 mAP 又提升了约 0.2 个点。但要注意,这个偏移量必须基于你自己的模型统计得出,不能照搬。而且补偿过度会导致误检增加,需要看 precision-recall 曲线来权衡。
5. 常见问题与排查技巧实录
5.1 量化后精度暴跌的排查路径
如果你量化后发现 mAP 掉了 10 个点以上,那基本不是正常量化损失,而是某个环节出错了。按以下顺序排查:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 校准集格式 | 检查图片路径、尺寸、通道数 | 路径错误导致校准用了空数据 |
| 预处理一致性 | 对比 PC 和板端的 mean/std | 归一化参数不一致 |
| 输出层解析 | 检查输出 tensor 的 shape 和顺序 | 输出层顺序错乱 |
| 量化配置 | 确认 quantized_dtype 正确 | 误用了对称量化 |
| 算子支持 | 查看转换日志的 warning | 某些算子回退到 CPU |
我踩过最坑的一次是校准集图片用了 BGR 格式,而模型训练时用的是 RGB,结果量化出来的 scale 全偏了,mAP 直接掉到 0.3。这种问题转换日志里不会报错,只能靠对比预处理流程发现。
5.2 混合量化的层选择技巧
混合量化不是层选得越多越好。选太多 FP16 层,推理速度会退化到接近 FP16,失去量化的意义。我的经验是:
- 优先保留 Detect head 的所有层
- 其次保留最后 2-3 个 SiLU 激活层
- backbone 浅层完全不需要保留,它们对量化不敏感
- 总 FP16 层数控制在 10-15 层以内
可以用逐层相似度报告来指导选择:相似度低于 0.97 的层优先考虑保留为 FP16。
5.3 精度与速度的平衡决策
最后给一个决策框架。当你面对"要不要上 INT8"这个问题时,按这个逻辑走:
如果 FP16 能满足帧率要求(比如你的场景只需要 15 FPS),那就直接用 FP16,别折腾量化。如果 FP16 达不到要求,必须上 INT8,那就先跑纯 INT8,看 mAP 掉多少。掉 2 个点以内,直接接受;掉 2-5 个点,上混合量化找回一部分;掉 5 个点以上,说明模型本身对量化太敏感,要么换更鲁棒的模型结构,要么考虑量化感知训练(QAT)。
QAT 是终极方案,但 RKNN 对 QAT 模型的支持有限,需要把训练框架的伪量化节点正确导出,这条路我还在摸索,后续有进展再单独写一篇。
5.4 一个容易被忽略的细节:输入分辨率
还有一点值得提:输入分辨率对量化精度的影响。我测试了 640x640 和 416x416 两种输入,发现 416 输入下 INT8 掉点更严重(-4.1 个点 vs -3.4 个点)。原因是低分辨率下小目标信息本来就少,量化误差进一步破坏了本就不足的特征。所以如果你的场景以小目标为主,尽量用高分辨率输入,给量化留出余量。
我个人在实际操作中的体会是,INT8 量化从来不是"掉几个点"这么简单的一句话,它是一整套需要反复调试的工程。同样一个 YOLOv5s,不同的人量化出来可能差 5 个点,差距全在细节里——校准集怎么选、混合量化保留哪些层、后处理怎么补偿。这些细节没有标准答案,只能靠一次次实验去逼近最优解。我这次从 0.518 调到 0.543,花了整整两个晚上,但这两个晚上换来的是 34 FPS 的实时性能和可接受的精度,对于工程落地来说,这笔账是划算的。