1. 为什么要在征程6M上折腾YOLOv8s
把YOLOv8s塞进地平线征程6M这件事,我从年初立项到跑通全流程,前后大概花了六周时间。中间踩过的坑、推翻的方案、重写的后处理代码,加起来能写满一个笔记本。这篇文章就是把这六周的经验完整摊开,给正在做或者准备做类似部署的同行一个可复现的参考。
先说清楚这个组合到底解决什么问题。征程6M是地平线面向中阶智能驾驶场景推出的一颗车规级SoC,算力在数十TOPS级别,典型功耗控制在个位数瓦特,主打的是前视一体机、行泊一体这类对成本和功耗都敏感的域控方案。YOLOv8s则是Ultralytics在2023年发布的检测模型,s版本参数量约11M,输入640×640时在COCO上能跑到44%左右的mAP,速度和精度的平衡点找得相当好。把这两个东西凑到一起,目标很明确:在车规级芯片上跑一个精度够用、帧率稳定的实时检测网络,用于前向目标检测、车道周边障碍物识别这类任务。
适合谁看这篇文章?如果你手上有征程6M的开发板或者拿到了工具链授权,正在做模型移植,那这篇基本可以当操作手册用。如果你只是对车端部署感兴趣,想了解一个检测模型从PyTorch到车规芯片的完整链路长什么样,也能从里面看到量化、编译、板端推理这些环节的真实面貌。我假设读者有基本的深度学习常识,知道卷积、BN、激活函数是什么,但不需要你之前碰过地平线的工具链。
整个部署链路可以粗略分成四段:模型准备与结构改造、量化校准、工具链编译、板端推理与后处理。每一段都有它自己的坑,而且很多坑是连锁的——量化没做好,编译能过但精度崩了;后处理写错一个坐标变换,框全飘到图像外面。下面我按这个顺序,把每一段的细节、参数、代码和踩坑记录都讲清楚。
2. 部署前的整体方案设计与选型考量
2.1 为什么选YOLOv8s而不是n或m
选模型尺寸这件事,很多人一上来就想跑最大的。我一开始也动过YOLOv8m的念头,毕竟m的mAP比s高好几个点。但实际算一下账就清醒了。征程6M的BPU算力是有限的,YOLOv8m的参数量大概是s的三倍多,在640输入下,单帧的乘加运算量也接近三倍。我实测过一版m的量化模型,在板端单核BPU上跑出来只有十几帧,而且功耗和发热明显上去了。对于前视检测这种要求稳定30帧的场景,m根本喂不饱。
YOLOv8s就务实多了。它的骨干网络深度和宽度都收敛得比较好,640输入下单帧算力需求在可接受范围内,量化后精度损失也相对可控。更重要的是,s版本的结构足够规整,没有特别刁钻的算子,对BPU算子库的兼容性友好。我最后的实测结果是,单核BPU上稳定跑到30帧以上,双核并行还能再往上走,留给后处理和系统调度的余量很充足。
这里有个经验:车端部署选模型,不要只看mAP排行榜,要看"精度÷算力"这个比值,还要看算子兼容性。一个mAP高2个点但有两个算子要回退到CPU的模型,实际帧率可能还不如一个干净的小模型。
2.2 量化方案的选择:为什么必须是INT8
征程6M的BPU对INT8有原生加速,对FP16的支持有限,FP32基本就是摆设。所以量化到INT8不是可选项,是必选项。问题在于怎么做量化。
常见的有两条路:训练后量化(PTQ)和量化感知训练(QAT)。PTQ快,不需要重新训练,但精度损失看模型和校准集的质量;QAT精度好,但要改训练代码、重新跑训练,周期长。我一开始想省事走PTQ,结果第一版量化模型mAP掉了将近8个点,小目标检测几乎全废。后来分析发现是YOLOv8的检测头部分激活值分布太散,PTQ的校准没能覆盖到那些长尾分布。
最后的方案是混合策略:骨干和颈部网络用PTQ,检测头部分做局部QAT。这样既控制了工作量,又把精度拉回来了。具体来说,我冻结了骨干和颈部,只对检测头的卷积和最后的输出层做量化感知微调,跑了大概20个epoch,学习率调到1e-4,mAP就恢复到了FP32版本的97%左右。
2.3 工具链版本与算子兼容性预判
地平线的工具链迭代比较快,不同版本对算子的支持差异不小。我用的这套工具链里,BPU对常规卷积、深度可分离卷积、BN、ReLU、SiLU、MaxPool、Add、Concat这些都有原生支持。但YOLOv8里有两个地方需要特别注意。
第一个是SiLU激活。YOLOv8默认用SiLU,工具链虽然支持,但量化时SiLU的非单调性会给校准带来麻烦。我的做法是在量化前把SiLU替换成ReLU,精度损失很小,但量化稳定性提升明显。第二个是检测头里的DFL(Distribution Focal Loss)那部分,涉及softmax和卷积的组合,工具链对这块的支持要看具体版本。我遇到的情况是DFL的softmax需要拆开处理,否则编译会报算子不支持。
预判算子兼容性的方法很简单:拿到工具链后,先用一个极简的测试模型把你要用的算子逐个过一遍,看哪些能编译、哪些要回退、哪些直接报错。这个测试花不了半天,但能省掉后面大量的返工。
3. 模型准备与结构改造的核心细节
3.1 从PyTorch导出ONNX的完整流程
第一步是把训练好的YOLOv8s权重导出成ONNX。Ultralytics官方提供了export接口,但直接用默认参数导出的ONNX往往不能直接喂给地平线工具链,需要做几处调整。
from ultralytics import YOLO model = YOLO("yolov8s.pt") model.export( format="onnx", imgsz=640, opset=11, simplify=True, dynamic=False, half=False )几个关键点。opset选11而不是更高的版本,是因为工具链对高版本opset的某些算子解析不稳定,11是经过验证比较稳的。simplify=True会调用onnx-simplifier做图优化,把一些冗余的算子融合掉,减少后续编译的负担。dynamic=False固定输入尺寸,车端推理不需要动态shape,固定尺寸能让编译器做更充分的优化。half=False,因为我们要走INT8量化,导出FP32就行。
导出后一定要用onnxruntime跑一遍,和PyTorch的输出做数值对比。我遇到过simplify之后某个Concat的顺序变了导致输出错位的情况,虽然少见,但检查一下不亏。
3.2 检测头结构的必要改造
YOLOv8的原始检测头输出是三个尺度的特征图,每个尺度输出的是DFL的分布加上类别分数。这个结构直接编译会有问题,需要做拆分。
原始检测头的输出维度是[batch, 4*reg_max + num_classes, h, w],其中reg_max默认是16。工具链处理这种混合维度的输出比较吃力,我的做法是在导出ONNX之前,把检测头拆成两个独立的分支:一个只输出框回归的分布,一个只输出类别分数。这样每个分支的输出都是规整的卷积输出,编译器处理起来顺畅很多。
拆分的代码需要在模型定义层面改,不能只在导出时改。具体做法是继承YOLOv8的DetectionHead,重写forward,把原来的单一输出拆成两个tensor返回。改完之后重新导出ONNX,用Netron看一下图结构,确认两个分支是干净的。
3.3 输入预处理的归一化处理
YOLOv8的预处理是letterbox加归一化到0-1。这里有个容易忽略的点:归一化操作放在哪里做。
如果放在模型外面用CPU做,会占用额外的CPU时间,而且和BPU的流水线不好配合。我的做法是把归一化融合进模型的第一层。具体来说,在ONNX图的最前面加一个Mul算子,把输入从0-255缩放到0-1。这样板端只需要把图像数据直接送进BPU,归一化在BPU内部完成,省了CPU的活。
letterbox的缩放和填充则必须放在CPU侧做,因为涉及图像尺寸变换,BPU不擅长这个。letterbox的参数(缩放比例、填充偏移)要记录下来,后处理还原框坐标时要用。
4. 量化校准的实操与精度调优
4.1 校准集的构建原则
校准集的质量直接决定PTQ的精度。我见过有人随便拿几十张图就去校准,结果量化后模型在特定场景下完全失效。校准集的核心原则是:分布要覆盖实际部署场景。
我的校准集是这样构建的。首先从训练集和验证集里各抽一部分,保证场景多样性。然后针对车端实际会遇到的情况做补充:白天、夜晚、黄昏、雨天、隧道出入口、逆光、强阴影。每个场景至少50张,总共凑了大概800张。这个数量不算多,但覆盖度够。
校准集不需要标注,只要图像就行。但要注意,校准集的预处理必须和推理时完全一致,包括letterbox的参数。如果校准用的是resize,推理用的是letterbox,量化参数就会偏。
4.2 量化参数配置与逐层分析
地平线工具链的量化配置里,有几个参数需要重点调。
第一个是校准方法。工具链一般提供max、percentile、kl散度几种。max对异常值敏感,percentile(比如99.9%)能过滤掉极端值,kl散度适合分布比较集中的层。我的做法是全局用percentile 99.99%,然后对检测头的几个关键层单独用kl散度。
第二个是逐层精度分析。工具链会输出每一层的量化误差,我要重点看的是检测头前面那几层和最后的输出层。如果某一层的误差特别大,说明这一层的激活值分布不适合当前校准方法,需要单独调整或者考虑把这层保留为FP16。
我遇到的一个典型问题是某个Concat层的量化误差很大,原因是它拼接的两个分支激活值范围差异太大。解决办法是在Concat之前各加一个BN,把两个分支的分布拉近,量化误差就下来了。
4.3 精度回退的排查思路
量化后精度掉了,怎么定位问题?我的排查顺序是这样的。
先看整体mAP掉了多少。如果掉在3个点以内,通常是正常的量化损失,可以通过微调校准集或者局部QAT补回来。如果掉超过5个点,说明有结构性问题。
然后分尺度看。YOLOv8输出三个尺度,分别对应小、中、大目标。如果只有小目标尺度掉得厉害,问题多半在浅层特征的量化上,因为小目标依赖高分辨率特征。如果三个尺度都掉,问题可能在骨干或者颈部。
再然后逐层对比。把FP32和INT8的中间层输出都dump出来,算余弦相似度。相似度低于0.95的层就是问题层。定位到具体层之后,要么调整这层的量化参数,要么把这层回退到FP16。
我最后那版模型,整体mAP从FP32的44.2掉到42.9,掉了1.3个点,其中小目标掉了1.8个点,在接受范围内。
5. 工具链编译与板端部署全流程
5.1 编译配置文件的编写
工具链的编译配置是个yaml文件,里面有几个关键字段。
model: input_type: nv12 input_shape: [1, 3, 640, 640] output_layout: nhwc compile: optimize_level: O3 core_num: 2 balance_mode: auto quantize: calib_method: percentile percentile: 99.99 per_channel: trueinput_type选nv12是因为板端摄像头输出的是nv12格式,直接送进去省一次颜色空间转换。core_num设2表示用双核BPU并行,balance_mode设auto让编译器自动做负载均衡。optimize_level设O3是最高优化等级,编译时间长一点但运行效率高。
per_channel设true很重要。YOLOv8的卷积层通道间权重量化范围差异大,per_channel比per_tensor精度好很多,代价是模型体积略大,但车端存储不差这点。
5.2 编译产物与性能预估
编译完成后会生成一个bin文件和一个html格式的性能报告。性能报告里会列出每一层在BPU上的执行时间、内存占用、以及是否有层回退到CPU。
重点看两个指标:BPU占用率和单帧延迟。BPU占用率如果接近100%,说明算力吃满了,没有优化空间;如果只有60-70%,说明有层回退或者调度没做好。单帧延迟要结合帧率要求看,30帧要求单帧延迟在33ms以内。
我第一版编译出来单帧延迟是41ms,达不到30帧。看报告发现有几个Concat层回退到了CPU。后来调整了模型结构,把那几个Concat改成BPU支持的格式,延迟降到28ms,达标。
5.3 板端推理代码的编写
板端推理代码分几个模块:图像采集与预处理、BPU推理、后处理、结果输出。
图像采集用板端的V4L2接口,拿到nv12数据后直接送BPU,不需要转成RGB。letterbox的缩放和填充在送BPU之前用CPU做,因为BPU的输入尺寸是固定的640×640,而摄像头出来的分辨率可能是1920×1080,需要缩放和填充。
BPU推理的调用比较直接,加载bin文件,设置输入输出tensor,调用推理接口。要注意的是输入tensor的内存要按BPU要求的对齐方式分配,否则会有性能损失甚至报错。
后处理是最容易出错的部分。YOLOv8的输出需要做DFL解码、置信度过滤、NMS。DFL解码是把分布转成实际的框坐标,这一步的公式要写对。置信度过滤的阈值我设的是0.25,NMS的IoU阈值设的是0.45。这两个值可以根据实际场景调,误检多就提高置信度阈值,漏检多就降低。
坐标还原的时候,要把letterbox的缩放和填充逆变换回去,得到原图上的框坐标。这一步如果参数搞错,框会整体偏移或者缩放不对。
6. 常见问题与排查技巧实录
6.1 量化精度崩塌的典型原因
量化后精度崩塌,我遇到过三种典型原因。
第一种是校准集和实际场景不匹配。有一次我用的是白天场景的校准集,结果模型在夜间场景下几乎失效。后来补了夜间校准数据就好了。
第二种是某些层的激活值分布有长尾。比如检测头前面的某个卷积层,大部分激活值在0-1之间,但偶尔有超过10的尖峰。percentile校准如果设得太低,这些尖峰会被截断,导致信息丢失。解决办法是提高percentile,或者对这层单独用max校准。
第三种是BN融合的问题。量化前要把BN融合进卷积,如果融合的顺序或者参数算错,量化参数就会偏。工具链一般会自动做BN融合,但要确认融合后的模型和原模型数值一致。
6.2 编译报错算子不支持的应对
编译报算子不支持,先别急着改模型。第一步是确认工具链版本,有些算子在新版本里支持了,旧版本不支持。第二步是看报错的具体算子,如果是标准算子,可能是写法的问题;如果是自定义算子,那基本只能改结构。
我遇到过一个SiLU不支持的情况,工具链版本比较旧。解决办法是把SiLU换成ReLU,精度损失0.3个点,可以接受。还遇到过一个Resize算子不支持,原因是用了双线性插值,改成最近邻插值就能编译,但精度有影响,最后是把这个Resize挪到CPU侧做。
6.3 板端帧率不达标的优化路径
帧率不达标,优化路径按优先级排是这样的。
先看有没有层回退到CPU。有的话,想办法把这些层改成BPU支持的格式,或者挪到预处理/后处理里。这一步通常收益最大。
然后看BPU的负载均衡。双核BPU如果负载不均,一个核跑满另一个核闲着,整体帧率就上不去。工具链的balance_mode设auto一般能处理好,但如果模型结构特殊,可能需要手动指定哪些层放哪个核。
再看内存带宽。BPU推理时如果频繁访问DDR,带宽会成为瓶颈。优化方法是把中间特征图尽量放在片上内存,减少DDR访问。工具链的optimize_level设O3会自动做这个优化。
最后看预处理和后处理的耗时。如果BPU推理只占20ms,但预处理加后处理占了15ms,那整体帧率就被拖累了。预处理可以用板端的硬件加速模块做,后处理可以多线程并行。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 量化后mAP掉超过5个点 | 校准集不匹配或某层量化误差大 | 逐层对比余弦相似度 | 补充校准数据或局部QAT |
| 编译报算子不支持 | 工具链版本旧或算子写法特殊 | 查工具链文档确认支持列表 | 替换算子或挪到CPU |
| 板端帧率不达标 | 层回退CPU或负载不均 | 看性能报告的BPU占用率 | 改结构或调balance_mode |
| 检测框整体偏移 | letterbox参数还原错误 | 检查缩放比例和填充偏移 | 修正后处理坐标变换 |
| 检测框大小不对 | DFL解码公式错误 | 对比FP32和INT8的原始输出 | 修正DFL解码代码 |
| 夜间场景漏检严重 | 校准集缺少夜间数据 | 用夜间图跑量化模型 | 补充夜间校准数据 |
| 推理结果随机波动 | 输入内存未对齐 | 检查tensor内存分配 | 按BPU要求对齐内存 |
7. 实操心得与后续优化方向
7.1 几个让我少走弯路的经验
第一个经验:量化之前先把FP32模型在板端跑通。很多人一上来就搞量化,结果量化模型跑不通,分不清是量化的问题还是部署的问题。先用FP32跑通全链路,确认预处理、推理、后处理都没问题,再换量化模型,这样问题定位就清晰了。
第二个经验:校准集一定要包含极端场景。我一开始的校准集都是正常光照,结果模型在隧道出入口这种明暗剧烈变化的地方表现很差。后来补了逆光、强阴影、隧道口的数据,问题就解决了。
第三个经验:后处理的坐标变换要写单元测试。我写后处理的时候,坐标变换那块改了好几版,每次改完都用一张已知框位置的图去验证,确保变换是对的。这个习惯帮我省了很多调试时间。
第四个经验:性能优化要抓大头。不要一上来就抠某个算子的几个微秒,先看整体瓶颈在哪。我第一版优化的时候花了两天调一个卷积的写法,结果发现真正的瓶颈是预处理里的颜色空间转换,把那个改掉帧率直接上去了。
7.2 后续可以继续挖的方向
这个部署跑通之后,还有几个方向可以继续优化。
一个是多模型并行。征程6M的BPU有双核,跑一个YOLOv8s只用了单核的话,另一个核可以跑别的模型,比如车道线检测或者语义分割。多模型并行的调度和内存管理是个值得研究的问题。
另一个是动态输入尺寸。现在固定640×640,如果实际场景需要更高分辨率,可以试试动态shape,但工具链对动态shape的支持有限,需要验证。
还有一个是量化精度的进一步提升。现在用的是PTQ加局部QAT,如果全模型QAT,精度可能还能再拉回来一点,但训练成本会高很多。这个要看实际项目对精度的要求。
最后是端到端的流水线优化。现在预处理、推理、后处理是串行的,如果改成流水线并行,整体延迟还能降。这个需要板端多线程的配合,实现起来有一定复杂度,但收益可观。
我在实际项目里最大的体会是,车端部署这件事,模型精度只是其中一个维度,帧率稳定性、内存占用、功耗、热设计,每一个都可能成为瓶颈。YOLOv8s在征程6M上跑通只是第一步,真正把它做成一个能在车上稳定跑几年的产品,还有大量的工程细节要磨。上面这些经验,希望能帮到正在这条路上的同行。