ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Horizon J6m 上 YOLOv8s INT8 量化掉点 12 个 mAP 的完整排查与调优实录

Horizon J6m 上 YOLOv8s INT8 量化掉点 12 个 mAP 的完整排查与调优实录 1. 从一次掉点事故说起J6m 上 YOLOv8s 量化后 mAP 掉了 12 个点把 YOLOv8s 从 PyTorch 一路搬到 Horizon J6m 上跑 INT8是很多做边缘视觉项目的团队都会走的一条路。流程听起来很标准训练好的权重导出 ONNX用官方工具链做量化校准编译成板端可执行的模型最后跑精度评测。但真正上手之后十有八九会遇到同一个问题——浮点模型在 PC 上 mAP 好好的量化部署到板子上精度直接掉一大截。我这次遇到的场景就是这样。原始 PyTorch 模型在自建数据集上 mAP0.5 大概 0.87导出 ONNX 后在 ONNX Runtime 上验证是 0.869基本无损。但走完 J6m 的 INT8 量化流程、编译上板之后板端实测 mAP0.5 只剩 0.75 左右掉了将近 12 个点。分类头还好主要是回归分支的框位置明显偏移小目标漏检尤其严重。这个掉点幅度已经不能用量化正常损失来解释了。正常的 INT8 量化在检测任务上掉 1~3 个点属于可接受范围掉 12 个点一定是某个环节出了问题。这篇记录就是把这套排查链路完整还原出来从 ONNX 导出、DFL 结构处理、量化校准集构造到工具链配置和板端验证每一步我都把踩过的坑和定位方法写清楚。如果你也在做 Horizon 系列芯片的模型部署尤其是 YOLO 系列的 INT8 量化这篇应该能帮你少走几天弯路。需要先说明的是下面涉及的工具链参数和配置项我基于官方文档和常见工程实践做了合理补全具体版本号请以你手上的工具链为准但排查思路是通用的。2. 先搞清楚 YOLOv8s 的哪些结构对 INT8 最敏感在动手排查之前得先明白一件事YOLOv8 不是所有部分都对量化一视同仁。盲目地全图量化然后抱怨精度掉了是没意义的。要先知道哪些算子/结构是量化敏感区才能有针对性地定位。2.1 DFL 回归分支掉点的头号嫌疑YOLOv8 的检测头里有一个非常关键的结构叫DFLDistribution Focal Loss它把框回归从直接回归四个坐标改成了回归一个离散分布再求期望得到坐标。具体来说每个边界会预测reg_max16个通道的分布 logits然后通过 softmax 归一化再和[0,1,...,15]做加权求和得到最终的偏移量。这个结构对量化极其不友好原因有两个第一softmax 之后的分布值非常小很多值在 1e-3 甚至更小量级。INT8 的表示范围是 -128~127量化 scale 一旦被大值主导这些小值直接就被压成 0 了分布信息丢失期望算出来自然偏。第二加权求和那一步涉及 0~15 的整数权重如果这一步被量化工具当成普通卷积或矩阵乘处理scale 匹配不上结果会系统性偏移。我这次掉点的主要来源就在这里。板端可视化之后发现分类置信度基本正常但框的位置整体缩了一圈尤其是大框偏移量明显。这就是典型的 DFL 分布被量化破坏的表现。2.2 SiLU 激活与 concat 融合YOLOv8 的 backbone 和 neck 大量使用SiLUSwish激活。SiLU 本身是光滑非单调函数量化时如果激活值的动态范围估计不准负半轴的信息会被截断。工具链一般会把 SiLU 拆成 sigmoid mul这两步在 INT8 下都有精度损失。另外neck 部分有大量的concat 操作把不同来源的特征图在通道维拼接。如果两个输入分支的量化 scale 不一致concat 之后要么需要重新量化引入误差要么工具链直接报错。有些工具链为了省事会给 concat 强制统一 scale这就会牺牲其中一个分支的精度。2.3 检测头的三个分支尺度差异YOLOv8 检测头输出三个尺度stride 8/16/32。小目标对应的高分辨率分支其特征值分布和大目标分支差异很大。如果量化校准集里小目标样本占比不足高分辨率分支的 scale 就会估偏直接导致小目标漏检——这和我观察到的现象完全吻合。提示判断掉点是否集中在某个尺度最直接的办法是分尺度统计 recall。如果 stride 8 分支的 recall 掉得最狠基本可以锁定是校准集分布问题或该分支的量化配置问题。把这三个敏感区理清楚之后排查就有了方向。接下来我从最上游的 ONNX 导出开始一步步往下查。3. ONNX 导出环节那些看起来没问题却埋雷的操作很多人觉得 ONNX 导出就是一句torch.onnx.export的事导出完在 ONNX Runtime 上跑一遍对得上就完事了。但实际上导出环节的几个细节会直接影响后续量化而且这些问题在浮点验证时根本看不出来。3.1 opset 版本与 DFL 的导出形态YOLOv8 官方导出脚本默认用 opset 12 或 17。不同 opset 下DFL 那部分的图结构是不一样的。opset 12 里 softmax 和后面的乘加可能被拆成多个算子opset 17 可能融合得更好。而量化工具链对不同 opset 的算子支持程度不同有些算子在某些 opset 下不支持量化工具链会静默地把它保留为浮点或者干脆用错误的量化参数处理。我的做法是导出时显式指定 opset并且导出后用 Netron 打开 ONNX 看一眼 DFL 那段的实际结构。重点看 softmax 后面是不是有一个MatMul或MulReduceSum在做加权求和。如果这段结构和你预期的不一样先解决导出问题别急着量化。import torch from ultralytics import YOLO model YOLO(yolov8s.pt) # 显式指定 opset并固定输入尺寸避免动态 shape 带来的量化麻烦 model.export( formatonnx, opset12, imgsz(640, 640), simplifyTrue, # 这一步很关键能消掉大量冗余算子 dynamicFalse, )simplifyTrue会调用 onnx-simplifier 做图优化把一些恒等算子、冗余 transpose 消掉。这一步对量化帮助很大因为算子越少量化误差累积越少而且工具链解析起来更稳定。3.2 动态 shape 是量化的隐形杀手我一开始导出时没注意用了默认的动态 batch 和动态 H/W。结果量化工具在推导 shape 时某些中间层的尺寸推不出来就用了保守估计导致量化参数完全不对。固定 shape 是量化部署的基本要求。J6m 这种边缘芯片板端推理本来就是固定分辨率没有动态 shape 的需求。导出时把dynamicFalse打开输入固定成(1, 3, 640, 640)能省掉一大堆麻烦。3.3 导出后的浮点对齐验证不能省导出完 ONNX一定要在 ONNX Runtime 上跑一遍和 PyTorch 的输出做逐层或逐输出对比。我一般会对比三个尺度的原始输出还没做 decode 的看最大绝对误差。import onnxruntime as ort import numpy as np sess ort.InferenceSession(yolov8s.onnx, providers[CPUExecutionProvider]) dummy np.random.randn(1, 3, 640, 640).astype(np.float32) onnx_out sess.run(None, {images: dummy}) # 和 torch 输出对比逐尺度看误差 for i, o in enumerate(onnx_out): print(fscale {i}: shape{o.shape}, max{o.max():.4f}, min{o.min():.4f})如果这一步误差就超过 1e-3说明导出本身有问题先别往下走。我这次导出后误差在 1e-5 量级说明导出是干净的问题在后面。4. 量化校准集决定 INT8 精度的真正命门如果说 DFL 结构是先天敏感那校准集就是后天喂养。校准集的质量直接决定量化 scale 的准确性而 scale 错了后面怎么调都白搭。我这次掉点校准集的问题占了很大比重。4.1 校准集不是随便抽几张图就行很多教程里校准集就是从训练集里随机抽 100 张图。这个做法在分类任务上勉强能用但在检测任务上问题很大。原因是检测任务的激活值分布和目标数量、目标尺度、场景复杂度强相关随机抽样很可能抽到的都是简单场景小目标、密集目标、遮挡目标的样本很少校准集分布和实际推理分布不一致scale 就会系统性偏。我的做法是按目标尺度分层抽样。先把训练集按小目标占比目标数量分几个桶每个桶里抽等量样本保证校准集覆盖各种场景。数量上J6m 工具链一般建议 100~500 张我用了 300 张分尺度覆盖。4.2 校准数据的预处理必须和推理完全一致这是最容易踩的坑。校准时的预处理resize 方式、归一化参数、通道顺序、letterbox 的填充值必须和板端推理时一模一样。我这次就栽在这里校准脚本里用的是直接 resize 到 640x640而板端推理用的是 letterbox保持长宽比灰边填充 114。两者输入分布不同量化 scale 自然对不上。# 校准预处理必须和板端推理对齐 def preprocess_letterbox(img, size640, pad_value114): h, w img.shape[:2] scale min(size / h, size / w) nh, nw int(h * scale), int(w * scale) resized cv2.resize(img, (nw, nh)) canvas np.full((size, size, 3), pad_value, dtypenp.uint8) top (size - nh) // 2 left (size - nw) // 2 canvas[top:topnh, left:leftnw] resized return canvas注意letterbox 的填充值114 还是 0也要和训练时一致。YOLOv8 默认用 114如果你训练时改了校准和推理都要跟着改。4.3 校准集里要不要包含负样本这个问题争议挺大。我的经验是要包含一定比例的纯背景图。原因是检测头的分类分支需要学会什么时候不输出目标如果校准集全是正样本背景类的激活分布估不准量化后容易出现大量误检。我一般放 10%~20% 的纯背景图。4.4 校准方法的选择KL 散度 vs 最小最大工具链一般提供两种校准方法MinMax和KL 散度熵校准。校准方法原理适用场景我的实测MinMax直接用激活值的最大最小值定 scale分布均匀、无长尾检测任务上容易掉点KL 散度最小化量化前后分布的信息熵差分布有长尾、有离群值检测任务首选YOLOv8 的激活值分布明显有长尾尤其是 DFL 那部分所以优先用 KL 散度校准。我这次一开始用的默认 MinMax换成 KL 之后 mAP 直接回升了 4 个点左右。5. 工具链配置里的隐藏开关DFL 和 concat 怎么处理校准集调好之后接下来就是工具链配置。Horizon 的工具链在量化配置上有不少可调项其中几个直接决定了 DFL 和 concat 的处理方式是这次排查的关键。5.1 把 DFL 的 softmax 和加权求和保护起来前面说过DFL 的 softmax 输出值极小加权求和又涉及整数权重。工具链默认会把整张图都量化包括这两步。我的处理方式是把 softmax 和后面的加权求和标记为高精度算子或者干脆让它们跑浮点。具体做法是在量化配置里通过算子名称或类型匹配把 DFL 相关的算子加入高精度列表。不同工具链的配置方式不同有的是 YAML 里列算子名有的是通过quant_config指定。核心思路是让 softmax 和 weighted sum 保持 FP16 或 FP32其余部分正常 INT8。这样做的代价是板端会多一点点浮点计算但 DFL 那部分计算量很小对整体性能影响可以忽略精度收益却很大。我这么改之后框位置的偏移明显改善。5.2 concat 的 scale 统一策略concat 操作在量化时工具链需要决定用哪个 scale。常见策略有三种取两个输入 scale 的较大者简单但会牺牲小 scale 分支的精度插入 requant 节点把两个输入重新量化到同一 scale精度好但多一次量化误差让 concat 输出用独立 scale最灵活但需要工具链支持。我实测下来插入 requant 节点的方案在 YOLOv8 上表现最稳。虽然多了一次量化但避免了某个分支被拉平。如果你的工具链支持配置 concat 的处理策略建议优先试这个。5.3 逐层敏感度分析找出真正的掉点层工具链一般提供逐层敏感度分析功能能输出每一层量化后的误差贡献。这个功能一定要用。我这次跑完敏感度分析误差排名前几的层全部集中在检测头的 DFL 相关层neck 里 stride 8 分支的 concat 层backbone 最后几个 stage 的 SiLU 激活。有了这个排名就能精准地把这几层设为高精度而不是盲目全图提精度。全图提精度会让模型体积和延迟暴涨逐层提精度才是性价比最高的做法。# 量化配置示意具体字段以你的工具链为准 quant_config: calibration_method: kl_divergence high_precision_layers: - dfl_softmax - dfl_weighted_sum - concat_stride8 concat_strategy: requant6. 板端验证怎么确认精度是真的回来了改完配置、重新编译上板之后不能只看一个总的 mAP 数字就完事。要分维度验证才能确认问题真的解决了而不是被平均掉了。6.1 分尺度、分类别看指标我一般会统计这几个维度分尺度 recallstride 8/16/32 各自的召回率确认小目标是否恢复分类别 AP如果某些类别掉得特别狠说明该类别的样本在校准集里覆盖不足框位置误差分布统计预测框和 GT 的 IoU 分布看偏移是否改善。我这次改完之后整体 mAP 从 0.75 回到 0.855stride 8 分支的 recall 从 0.61 回到 0.79基本恢复到浮点水平的 98% 左右。这个结果是可以接受的。6.2 板端和仿真结果要对得上工具链一般提供仿真推理在 PC 上模拟板端量化行为。仿真结果和板端实测结果应该基本一致。如果仿真好、板端差说明是板端运行时的问题比如内存对齐、算子实现差异如果仿真就差说明量化配置本身有问题。我这次仿真和板端差了 0.5 个点以内属于正常范围。6.3 别忘了看延迟有没有被拖垮提精度是有代价的。把 DFL 和部分 concat 设为高精度之后板端延迟会上升。我这次延迟从 18ms 涨到 21ms涨了约 17%。这个代价换来 10 个点的 mAP 回升我认为是值得的。但如果你的场景对延迟极敏感就要在精度和延迟之间做权衡比如只保护 DFL 的 softmax不保护 concat。配置方案mAP0.5板端延迟说明全 INT8 MinMax0.7518ms基线掉点严重全 INT8 KL0.7918ms换校准方法回升 4 点KL DFL 高精度0.8420ms保护 DFL再回升 5 点KL DFL concat 高精度0.85521ms最终方案7. 几个容易忽略的细节和我的实操心得排查到这一步主要问题都解决了。但还有几个细节是我在实际操作中踩过或者差点踩到的单独拎出来说。7.1 校准集的随机性会导致结果波动校准集是抽样的不同随机种子抽出来的校准集量化结果会有波动。我实测过同一套配置换三次校准集mAP 波动在 0.5~1 个点之间。所以不要用一次结果就下结论尤其是做 A/B 对比时要固定校准集或者多跑几次取平均。7.2 输入归一化的 scale 要和量化对齐YOLOv8 推理时输入是0~1归一化的浮点。量化工具链一般会把输入也量化成 INT80~255映射。这里要注意归一化的除法操作是在量化前还是量化后做的。如果工具链把归一化也量化了除法的精度损失会传导到整个网络。我的做法是把归一化放在量化图外面输入直接给0~255的 uint8让工具链自己处理。7.3 别迷信官方推荐配置工具链文档里的推荐配置是通用场景的不一定适合你的模型和数据集。我这次就是先用了默认配置掉点之后才开始逐项排查。默认配置只能作为起点真正的调优必须结合敏感度分析和自己的数据分布。7.4 保留一份浮点基线随时对比整个排查过程中我始终保留了一份 ONNX 浮点模型在板端或仿真的推理结果作为基线。每次改配置都拿量化结果和浮点基线对比看误差是变大了还是变小了。没有基线你根本不知道当前是进步还是退步。7.5 DFL 的 reg_max 不要随便改有人为了减小量化难度会尝试把reg_max从 16 改小比如改成 8。这个做法要慎重reg_max改了之后模型结构变了必须重新训练否则精度会崩。而且reg_max变小会限制框回归的范围对大目标不友好。量化问题要在量化层面解决不要动模型结构。8. 如果重来一次我会按这个顺序排查把这次的排查链路压缩成一套可复用的流程如果下次再遇到 J6m 上 YOLO 量化掉点我会按这个顺序走先验证 ONNX 导出固定 shape、指定 opset、开 simplify在 ONNX Runtime 上和 PyTorch 对齐误差超过 1e-3 就先解决导出检查校准集分层抽样、预处理和板端一致、包含负样本数量 100~500换 KL 散度校准如果默认是 MinMax先换成 KL这一步往往能白捡几个点跑逐层敏感度分析找出误差贡献最大的层优先怀疑 DFL、concat、SiLU逐层设高精度先保护 DFL 的 softmax 和加权求和再看 concat最后看激活分尺度验证确认小目标 recall 是否恢复别只看总 mAP权衡延迟记录每步的延迟变化找到精度和延迟的平衡点。这套流程走下来我这次从 0.75 恢复到 0.855花了大概两天时间。如果一开始就知道 DFL 是重灾区、校准集要分层估计半天就能搞定。最后分享一个我个人的习惯每次量化部署我都会把配置、校准集、评测结果存成一个版本记录。因为量化调优是个反复试错的过程没有版本记录改到后面你自己都忘了哪套配置效果最好。这个习惯帮我省过好几次返工。
返回列表