1. 为什么要在征程6M上折腾YOLOv8s
1.1 这个组合到底解决什么问题
把YOLOv8s塞进地平线征程6M,本质上是在做一件很具体的事:让一个参数量约11M、计算量约28.6 GFLOPs的检测网络,跑在一颗面向车载前视感知场景设计的国产SoC上,并且要跑到实时甚至多路实时。征程6M的BPU算力标称是128 TOPS(INT8),这个数字看着很唬人,但真正上手的人都知道,标称算力和实际能跑出来的帧率之间隔着一条鸿沟——算子支持度、DDR带宽、量化精度、前后处理的开销,任何一环没处理好,帧率就能从30掉到8。
我之所以选这个题目来写,是因为过去大半年里,前前后后在这个平台上调过好几个检测模型,踩的坑足够写一本小册子。YOLOv8s是个很典型的"甜点级"模型:它不像nano那样精度捉襟见肘,也不像m/l那样在边缘端算力吃紧,对征程6M这种级别的芯片来说,s版本刚好卡在一个"努努力能实时、精度也够看"的位置。适合谁看?适合已经会训练YOLO、但对地平线工具链还比较陌生的算法工程师,也适合做域控制器产品、需要评估这颗芯片到底能不能扛住某类检测任务的系统工程师。
1.2 先搞清楚征程6M的脾气
征程6M属于征程6系列里的中算力档,架构上延续了地平线自研BPU的路线。和上一代相比,它在INT8稠密算力上有明显提升,同时对外设接口、多传感器接入做了加强。但你要记住一个核心事实:BPU不是GPU,它是一块为定点张量运算高度定制的加速器。这意味着两件事。
第一,你的模型最终必须被量化成INT8(部分算子可以走INT16),浮点权重在部署阶段是要被"压扁"的。第二,不是所有PyTorch里能跑的算子它都认,遇到不支持的算子,要么被拆解成若干支持的算子组合,要么直接回落到CPU上跑——而CPU一介入,延迟立刻爆炸。
所以整个部署流程的主线,其实就是一句话:把YOLOv8s的计算图,改造成BPU喜欢的样子,再把它量化到精度可接受的INT8,最后用高效的前后处理把整条流水线串起来。
1.3 整体路线图
我把整个流程拆成五个阶段,后面每个阶段都会展开讲:
| 阶段 | 核心任务 | 关键产出 |
|---|---|---|
| 模型准备 | 导出ONNX、算子检查、图改造 | 干净的ONNX |
| 量化校准 | 准备校准集、配置量化参数 | 量化模型 |
| 编译部署 | 工具链编译、生成bin | 可上板的模型文件 |
| 前后处理 | 预处理对齐、后处理解码 | 完整推理流水线 |
| 调优验证 | 精度对齐、性能压榨 | 达标指标 |
提示:不要一上来就想着跑通全流程。我的习惯是先拿官方提供的参考模型跑通一遍,确认工具链环境没问题,再换成自己的模型。这样出问题时能快速定位是环境问题还是模型问题。
2. 模型准备阶段的关键细节
2.1 从PyTorch到ONNX,别急着导出
很多人拿到训练好的.pt就直接torch.onnx.export,然后丢给工具链,结果报一堆算子不支持。正确的做法是先做算子体检。
YOLOv8s的原始结构里,有几个地方是部署时的"重灾区":
- Detect头里的后处理:Ultralytics官方导出ONNX时,默认会把decode部分(把anchor偏移解码成xywh)也导进去。这部分包含大量动态shape操作和gather,BPU处理起来很别扭。我的建议是导出时把后处理剥离,只导出到三个特征图的原始输出,decode放到CPU或专门的算子里去算。
- SiLU激活:YOLOv8用的是SiLU(swish),这个算子在BPU上通常有支持,但量化时对精度比较敏感,需要重点关注。
- 上采样和Concat:Neck部分的Upsample和Concat是常规操作,一般没问题,但要注意Upsample的scale必须是整数倍,别用动态尺寸。
导出ONNX的时候,固定输入尺寸很关键。征程6M上我一般用640x640作为基准,如果你的实际场景是宽幅(比如1920x736这种车载常见分辨率),那要提前想好letterbox策略,因为输入尺寸直接决定了编译后的内存占用和带宽压力。
# 导出时固定shape,剥离后处理 torch.onnx.export( model, dummy_input, "yolov8s_head.onnx", opset_version=11, input_names=["images"], output_names=["output0", "output1", "output2"], dynamic_axes=None # 坚决不用动态轴 )2.2 算子替换的取舍逻辑
工具链一般会提供算子支持列表。拿到列表后,你要做的是逐算子比对,把不支持的算子找出来替换。常见的替换策略:
- 如果某个激活函数不支持,换成ReLU或Hardswish,然后重新微调几个epoch把精度找回来。这一步很多人偷懒跳过,结果量化后mAP掉好几个点,回头再查就麻烦了。
- 如果某个reshape/transpose组合不支持,尝试用等价的卷积或slice操作替代。
- 遇到实在绕不过去的算子,评估它回落到CPU的代价。如果它只在网络末端出现一次,CPU开销可能可以接受;如果它在主干里,那就必须换掉。
这里有个经验:改造完的模型一定要重新在GPU上验证一遍精度。因为你的改造可能引入了语义偏差,如果改造后GPU上精度就掉了,那后面量化掉点你根本分不清是谁的锅。
2.3 输入预处理的坑
预处理看起来最简单,其实最容易埋雷。YOLOv8训练时的预处理是:letterbox到640x640、归一化到0-1、RGB通道。部署时你要保证板上预处理和训练预处理完全一致。
我见过太多案例:训练用RGB,部署时摄像头出来是BGR没转,结果模型输出一堆乱七八糟的框,还以为是量化问题,查了两天才发现是通道顺序。还有归一化,有的工具链要求输入是0-255的uint8,把归一化放到模型第一层去做,这时候你要确认这个归一化层是被编译进BPU还是留在CPU——如果留在CPU,每帧都要多一次全图乘加,带宽和延迟都上去了。
注意:letterbox的填充值也要对齐。训练时通常填114(灰色),部署时如果填0(黑色),边缘区域的检测精度会有肉眼可见的下降,尤其是图像边缘的目标。
3. 量化校准:精度与速度的博弈
3.1 校准集怎么选才靠谱
量化校准的本质,是用一批真实数据去统计每个算子激活值的动态范围,从而确定INT8的scale因子。校准集选得好不好,直接决定量化后的精度。
我的做法是:从验证集里挑300-500张,覆盖所有典型场景。什么叫典型场景?白天、夜晚、逆光、雨天、密集目标、稀疏目标、大目标、小目标,每个类别都要有足够样本。千万别图省事拿训练集的前100张,那大概率是同一段视频的连续帧,分布极度单一,校准出来的scale根本不具备代表性。
校准集的数量也不是越多越好。500张和2000张的精度差异通常很小,但校准时间差好几倍。300-500张是个比较平衡的点。
3.2 量化配置里的关键参数
工具链的量化配置一般会暴露几个关键选项,我挑最重要的说:
- 量化粒度:per-tensor还是per-channel。per-channel精度更好但可能影响性能,实践中对卷积权重量化用per-channel,激活量化用per-tensor,是比较常见的组合。
- 混合精度:对精度敏感的层(比如检测头的前几层、SiLU激活)可以指定走INT16。代价是这部分计算速度减半,但如果它占比不大,整体帧率影响可控,精度却能救回来不少。
- 校准算法:常见的有min-max、KL散度、百分位截断。KL散度对激活值长尾分布的处理更好,是我在检测模型上的首选。如果工具链支持,优先用KL。
3.3 精度对齐的排查方法
量化完第一件事就是逐层对比。工具链一般会提供量化模型和浮点模型的逐层输出对比功能。你要看的是:哪一层的余弦相似度突然掉下去了。
如果掉点在主干浅层,通常是输入预处理或第一个卷积的量化问题;如果掉点在Neck的Concat附近,往往是不同分支的scale差异太大;如果掉点在检测头,那基本就是激活值分布太散,考虑给这几层上INT16。
我踩过的一个典型坑:某个版本的YOLOv8s在Neck的一个C2f模块里,残差相加的两个分支量化scale差了将近20倍,导致相加后小分支的信息几乎被吞掉。解决办法是给这个add操作前的两个分支分别做scale对齐,或者干脆把这个add标记为高精度。
| 掉点位置 | 常见原因 | 处理手段 |
|---|---|---|
| 主干浅层 | 输入分布异常 | 检查预处理、调整校准集 |
| Neck Concat | 分支scale差异大 | 分支scale对齐或INT16 |
| 检测头 | 激活长尾 | KL校准+局部INT16 |
| 全局均匀掉点 | 校准集不具代表性 | 重选校准集 |
4. 编译与上板部署实操
4.1 编译配置怎么填
编译阶段你要告诉工具链:模型跑在哪颗芯片上、用什么精度、内存怎么分配、输入输出怎么布局。几个关键配置项:
- 输入layout:NV12还是RGB。车载场景摄像头出来通常是NV12(YUV420),如果工具链支持直接吃NV12,能省掉一次颜色空间转换,这对带宽和延迟都是实打实的收益。
- 输出layout:建议输出成NHWC或者直接展平,方便后处理读取。
- 内存分配:征程6M的片上内存有限,如果模型太大放不下,工具链会自动做内存复用和分块调度。你要关注编译日志里的内存占用报告,如果接近上限,考虑减小输入尺寸或精简模型。
编译完成后会生成一个bin文件(或者叫hbm),这就是最终上板的东西。
4.2 板端推理代码的骨架
板端代码的核心是:取图 → 预处理 → 送BPU → 取输出 → 后处理 → 画框/上报。我用伪代码把骨架列出来:
// 初始化 hbDNNInitialize(); loadModel("yolov8s.bin"); // 主循环 while (running) { // 1. 取图(NV12) getFrame(&frame); // 2. 预处理:resize + letterbox(尽量用硬件加速) preprocess(&frame, &input_tensor); // 3. 推理 hbDNNInfer(&output_tensors, &input_tensor); hbDNNWait(); // 4. 后处理:decode + NMS decodeOutputs(&output_tensors, &detections); nms(&detections); // 5. 输出 drawOrReport(&detections); }这里有个性能关键点:预处理尽量别用CPU逐像素搞。征程6M上有专门的图像处理单元(比如VPS/VPU),resize和颜色转换能硬件做就硬件做。我实测过,纯CPU做640x640的letterbox,单帧就要好几毫秒,而硬件做几乎不占时间。
4.3 后处理的性能陷阱
后处理是很多人忽略的性能黑洞。YOLOv8s在640输入下,三个特征图分别是80x80、40x40、20x20,加起来8400个anchor。decode的时候如果每个anchor都用浮点算一遍,再排序做NMS,CPU分分钟跑满。
优化手段有几个:
- 阈值前置过滤:先用objectness阈值把绝大多数anchor干掉,只对剩下的做完整decode。8400个里通常只有几十个能过阈值。
- NMS用快速实现:别用O(n²)的朴素NMS,用基于排序+IoU矩阵的向量化实现。
- 考虑把decode放到BPU:有些工具链支持把简单的decode算子编译进去,能省不少CPU。
我做过对比:优化前后处理,单帧后处理时间从12ms降到2ms以内,整体帧率直接翻倍。这个收益比抠模型结构来得快多了。
5. 常见问题与排查实录
5.1 精度类问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 量化后mAP掉5个点以上 | 校准集不具代表性 | 重选校准集,覆盖全场景 |
| 特定类别全丢 | 该类激活值被截断 | 检查该类样本在校准集中的占比 |
| 小目标检测变差 | 浅层特征量化损失 | 浅层上INT16或调高分辨率 |
| 框位置偏移 | 预处理letterbox不一致 | 对齐padding值和缩放比例 |
| 置信度整体偏低 | 输出层量化scale偏大 | 调整输出层量化参数 |
5.2 性能类问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 帧率远低于预期 | 算子回落CPU | 看编译日志的算子分配 |
| 首帧特别慢 | 模型加载+内存分配 | 预热几帧再计时 |
| 多路时帧率骤降 | DDR带宽瓶颈 | 降低输入分辨率或减少路数 |
| 延迟抖动大 | CPU调度抢占 | 绑核+实时优先级 |
| 内存占用超限 | 特征图太大 | 减小输入或精简Neck |
5.3 几个我踩过的真实坑
坑一:校准集里全是白天,夜里全瞎。有一次赶进度,随手拿了验证集前200张做校准,结果那批数据全是白天高速场景。模型上线后夜间误检率飙升。后来重新用覆盖昼夜的校准集,问题解决。教训是:校准集的场景覆盖度,比数量重要得多。
坑二:以为INT16越多越好。一开始为了保精度,把检测头整块都设成INT16,结果帧率掉了30%。后来逐层分析发现,其实只有两三个卷积层对精度敏感,其余INT8完全够用。混合精度要精准打击,不要大面积撒网。
坑三:忽略了DDR带宽。模型本身跑得飞快,但多路视频一接进来帧率就崩。查了半天发现是预处理阶段反复读写大图,把DDR带宽吃满了。后来改成硬件预处理+零拷贝,问题消失。在边缘芯片上,带宽往往比算力更先成为瓶颈。
坑四:NMS的IoU阈值和训练时不一致。训练时验证用的IoU阈值是0.7,部署时随手写了0.5,结果密集场景下框被大量误删。所有后处理参数都要和训练验证阶段严格对齐,一个都不能拍脑袋。
5.4 一个实用的调试技巧
当你怀疑是量化问题时,做一个二分定位:把模型从中间切成两半,前半段用浮点、后半段用量化(或者反过来),看精度掉在哪一半。然后继续二分。这样几轮下来就能精确定位到出问题的层,比盲目调参高效得多。
工具链如果支持逐层dump输出,那就更直接了——直接对比浮点和量化模型每一层的输出,算余弦相似度,掉得最狠的那层就是元凶。
6. 一些关于调优的碎碎念
部署这件事,说到底是个平衡的艺术。精度、速度、内存、功耗,四个维度互相拉扯,你不可能全都拉满。我的经验是先把精度底线定死(比如mAP不能低于某个值),然后在这个约束下疯狂压速度,压到压不动为止,再看内存和功耗还有没有余量。
还有一点:别迷信标称算力。128 TOPS是个理论峰值,实际能用到30%就算不错了。真正决定帧率的是数据搬运效率、算子融合程度、以及前后处理有没有拖后腿。我见过太多人拿着算力数字算理论帧率,算出来能跑100帧,实际跑出来15帧,然后怀疑人生。
最后分享一个我个人的习惯:每次部署新模型,我都会建一个部署日志,记录这次用了什么校准集、量化配置怎么设的、编译参数是什么、最终精度和帧率多少、遇到了什么问题怎么解决的。下次再部署类似模型,直接翻日志,能省掉大量重复试错。这个习惯看起来笨,但真的是我这些年效率提升最大的一招。