前一阵子帮朋友调一个RK3588上的YOLOv8检测项目,他把模型从s换成了n,帧率只涨了2帧,跑来问我是不是该直接上YOLOv5s。我让他先别急着换模型,先用profiler看了两分钟数据,结果瓶颈根本不在模型本身,而在预处理里的letterbox和Python侧NMS。这件事让我特别想写一篇关于YOLO模型轻量化的完整梳理——因为大部分人对“轻量化”的理解,还停留在“换小模型”这一个选项上,但实际上,模型轻量化是一个系统工程,从算法结构、参数压缩、量化部署到工程管线,每一层都有文章可做。这篇文章就把我在实际项目中用过的、踩过坑的轻量化方向完整拆一遍,给准备做边缘端部署或者推理加速的朋友一个可落地的路线图。
1. 先搞清楚“轻量化”到底要减什么,否则白干
1.1 参数量、FLOPs和延迟,经常被混为一谈的三个指标
我见过太多人一开口就是“我这个模型多少M、多少FLOPs,应该很快”。这三样东西关系密切,但在真实部署里它们经常互相“欺骗”。
参数量(Params)决定的是模型文件大小和显存占用的下限。FLOPs决定的是理论计算量。而真正决定跑多快的是推理延迟(Latency),它由计算、访存、算子调度、数据搬运共同决定。举个例子,有些轻量注意力模块参数量很小,但需要频繁做矩阵转置和reshape,在GPU上不显眼,在CPU或NPU上却可能变成灾难;又比如ShuffleNet的通道混洗(channel shuffle)几乎不增加FLOPs,但逐通道的数据重排会在很多嵌入式计算单元上变成明显开销。
我自己的习惯是先跑一个量化前的baseline并把三项分开测量,用NVIDIA系工具就是Nsight Systems,CPU平台用perf或者简单的计时脚本,NPU平台则用厂商自带的profiler(RKNN Toolkit、嘉楠的NNRT工具链都自带)。只有把时间开销拆到算子级,才能判断当前瓶颈是计算密集、访存密集还是调度开销大。很多时候轻量化改了半天,最后发现时间全花在数据从H2D拷贝上,这就不是“减模型”能解决的问题了。
1.2 先问目标平台:CUDA、CPU还是NPU
轻量化方案的选择,跟部署平台强相关。这里说的平台不是笼统的“服务器”或“边缘设备”,而是具体的计算单元和运行时。
- NVIDIA GPU:有CUDA、TensorRT,算子库最丰富,FP16、INT8的生态最成熟。
- AMD显卡:很多人问AMD 580显卡能不能跑YOLO,答案是能。Windows下走DirectML或ONNX Runtime的DML执行提供程序,Linux下可以走ROCm,但TensorRT那套优化用不了,很多CUDA专属算子也不兼容。这时候“轻量化”往往靠换小模型和减少输入分辨率来弥补。
- CPU:更依赖指令集和线程库,OpenVINO、NCNN在x86和ARM上各有优势,算子支持范围直接决定哪些结构能跑得动。
- NPU(RK3588、K230这类):算力有一些,但算子支持是硬约束。很多我认为“轻”的模块,比如Multi-Head Attention、动态尺寸的reshape,落到NPU上直接不支持或跑到CPU回退,速度立刻崩塌。
所以做轻量化之前,先回答一个问题:这段模型最终跑在什么硬件上?同样的模型架构,不同平台的瓶颈和优化手段完全不一样。这也是我通常建议团队在项目早期就定硬件的主要原因——后面所有结构改动的收益都依赖这个前提。
1.3 定一个“过线”标准再动手
轻量化不是为了“看起来参数少”,而是为了在满足业务精度的前提下,把延迟、内存、功耗压到指标线以内。最好在动手前就把指标写成一行:
| 指标 | 当前值 | 目标值 |
|---|---|---|
| mAP@0.5 | 0.82 | >= 0.78 |
| 单帧延迟 | 45ms | <= 20ms |
| 峰值内存 | 680MB | <= 300MB |
| 模型文件大小 | 23MB | <= 8MB |
没有这样的目标,项目就会在“减一点精度换一点速度”的无限循环里出不来。每次改完一组配置,用同一组测试集、同一个输入分辨率、同一套评测脚本量一遍,记录到表格里。这个习惯能让整个优化过程可追踪、可复盘,也方便后期说服别人“为什么这个改动值得上”。
2. 网络结构层面的“减法”:换骨干、改Neck、动检测头
2.1 换Backbone:收益最大的同时,坑也最多
YOLO系列的Backbone决定了下采样的特征提取能力。想轻量化时,最直接的想法是换一个更轻量的骨干网络,比如MobileNetV4、ShuffleNetV2、EfficientFormer-Lite等。理论上,这些结构能在相同FLOPs下提供更好的特征提取或更少的参数,换完确实能明显降低计算量。
但换Backbone不是把model.backbone换成另一个类那么简单,踩坑的地方不少。
第一,输出stride和特征层通道数必须和后续Neck对齐。YOLOv8的Neck用到了Backbone的P3、P4、P5三组特征,也就是下采样8倍、16倍、32倍的特征图。很多轻量网络的特征金字塔可能只有两组有效输出,或者某一层的通道数特别大(比如MobileNetV4的最后一层通道数会比YOLOv8默认的512大很多),直接接进Neck会导致内存和算力不降反升。
第二,预训练权重和数据集之间要匹配。从ImageNet预训练换过来当然没问题,但如果你的是医学影像、工业缺陷这类和ImageNet分布差异很大的数据,轻量Backbone的收敛难度会明显增加。我曾经用一个轻量Backbone在缺陷检测数据集上从零训练,效果还不如YOLOv8n,原因就是轻模型容量小,对数据分布变化更敏感。
第三,YOLO本身是一个完整的检测pipeline,Backbone改了以后,Neck(PANet结构)与Head的搭配也需要重新调。我的建议是:除非硬件指标确实卡得很死(比如内存上限只有几MB、芯片算力极弱),否则优先保留YOLO官方Backbone,只在Neck和Head上做文章。因为官方结构配套的预训练权重大概率比你自己从头训练的轻量Backbone精度高,而且省掉一大批调试成本。
2.2 Neck模块替换:GSConv、PConv这类操作的真实收益
Neck是YOLO里参数和计算量比较集中的地方,尤其是PANet里的多次跨尺度特征融合,用了很多C2f模块和卷积。近两年轻量化研究很喜欢把Neck里的标准卷积或CSP模块换成GSConv、PConv、C3k2这一类的轻量模块。
GSConv的核心思路是用“分组卷积+shuffle”近似标准卷积,降低参数量和FLOPs,同时尽可能保留通道间的信息融合。FasterNet提出的PConv更激进,只对一部分输入通道做卷积,其他通道直接透传,理论FLOPs能降不少。
但这里必须泼一盆冷水:这些模块在论文里报告的“FLOPs下降”和“延迟下降”不一定相等。尤其是PConv,它在代码实现里需要把输入通道从内存里按维度切分成几份,一部分走Conv,一部分直接走恒等映射,在支持高并行度的GPU或NPU上,内存切分、拼接、写入这些操作的时间可能超过省下来的卷积时间。GSConv里的split和concat操作也类似。
所以在这种轻量模块的选型上,我的流程是:先在一批候选模块里各出一个改写后的模型配置,在目标平台上用同一输入尺寸跑一遍benchmark,再结合实际精度决定留谁。不要只看论文数据,更不要只看FLOPs数字。
2.3 检测头的取舍:解耦头、辅助头、通道裁剪
检测头(Head)在很多YOLO优化里是被忽略的部分,但它占的内存和算力都不小。YOLOv8的Detect头是解耦的,分类分支和回归分支各走几个卷积,输出多个尺度框的类别概率和坐标偏移。轻量化思路通常有三个方向。
方向一:把分类分支和回归分支的通道数砍半。例如原来hidden channel是64,改成32或16,可以在几乎不改变主干结构的情况下减少参数。这个改动尤其适合类别数少或目标尺度集中的场景,比如工业质检只有“缺陷/正常”两类,保留64个通道完全是浪费。
方向二:去掉辅助预测头。YOLOv8训练时会用到多个尺度的输出,其中P3小目标特征层的辅助Head在推理时有时候可以被合并或删除,但具体要看你用的变体,有的版本P3是主输出的一部分,不能删。改动前一定要看清配置里的from字段是怎么连接的。
方向三:把回归分支和分类分支做权重共享或结构化共享。这部分实现起来工程量不小,收益也看场景,适合已经定型的模型做进一步裁剪。
检测头改动最大的影响是后面NMS前解析的张量维度会变。很多自写的部署代码里解析框的部分是hardcode通道数的,改Head时一定要同步改推理解析逻辑,否则模型跑起来但框全乱。
2.4 改了结构,损失函数和训练策略也得跟着改
这也是“YOLO损失函数”为什么会成为高频搜索词的原因。换完轻量模块后直接拿默认的CIoU Loss和DFL去训,结果往往掉点明显——因为一个容量更小的模型,需要更明确的梯度信号来优化边界框细节。
几个常见做法:
- 用SIoU或MPDIoU替换CIoU。CIoU在目标尺度变化大或小目标多的情况下收敛效率不算好,SIoU考虑了角度损失,对小模型有时能多拉回一点mAP。
- 保留并调整DFL的权重。Distribution Focal Loss对边界框回归质量帮助大,但轻量化模型容量变小时,DFL权重过大容易让模型过拟合到训练集边界分布,需要调小。
- 配合蒸馏Loss。这一点放到后面第四节里详细说,它是轻量结构最近几年很少翻车的补精度手段。
- 训练epoch要相应拉长。轻量模型收敛速度慢,不能沿用大模型那一套训练配置。我的经验是,结构轻量化后训练epoch加50%甚至翻倍,配合cosine LR或者带warmup的下垂策略,通常精度会回到一个可用范围。
3. 剪枝和重参数化:把“冗余”真正减掉
3.1 结构化剪枝 vs 非结构化剪枝:部署时的天壤之别
剪枝是一个看似美好、实际操作门槛最高的轻量化方向。它分两类:
非结构化剪枝把不重要的单个权重置零,得到稀疏权重矩阵。这类方法用论文里的稀疏度和理论压缩率看很漂亮,但实际部署时需要一个对稀疏矩阵友好且能加速的推理库——NVIDIA GPU上的cuSPARSE在某些场景下有效,NPU上则往往完全支持不了稀疏计算,稀疏权重反而变成存储和计算的双重负担。所以对于大多数做落地的团队,我不建议优先选非结构化剪枝。
结构化剪枝是把不重要的卷积通道、层或整个Block直接删掉。删除通道后,模型的参数量和FLOPs是真实下降的,而且不需要特殊推理库,导出成ONNX后就能在大多数运行时上跑。YOLO系列通道剪枝最常见的方法是依赖BN层的gamma系数:训练时对BN层的gamma做稀疏化约束,让一部分通道的gamma逼近0,然后按gamma值排序把接近0的通道裁掉。
3.2 一条可复现的剪枝流程:稀疏训练→剪枝→微调
我实践下来比较稳的剪枝流程是这样:
- 在一个已经训好的baseline基础上,加入对BN gamma的L1正则化约束,正则系数可以先设1e-5量级,观察gamma分布再调。训练足够多的epoch,让gamma分布出现明显的两极分化。
- 统计BN gamma的分布,设定剪枝比例。通常从30%开始试,剪太多对精度伤害大,后面微调也救不回来。
- 依据gamma值剪掉对应通道,同时重建模型的yaml配置和权重。这一步我强烈建议写个脚本自动做,手动改配置容易对错层之间的channels。
- 剪枝后的模型加载剪枝前的微调权重,在原始训练集上重新训练。微调epoch不能太少,我见过有人只训10个epoch,精度当然回不来。
注意几个坑:第一,BN层的统计量在剪枝后会发生很大变化,加载权重后先跑几百张图校准一下再评估,否则看到的精度损失是虚假偏大的。第二,剪枝后NMS的置信度阈值可以适当回调一下,因为小模型的置信度分布和大模型不太一样。第三,剪枝和蒸馏是绝配,微调时加一份大模型的蒸馏信号,能把剪枝掉的血回一大截。
3.3 重参数化:训练时复杂,推理时“白嫖”
重参数化的思想是:训练时用多分支结构增强模型表达能力,推理前把多分支合并成单卷积,不增加推理负担。典型代表是RepVGG和它的后续DBB(Diverse Branch Block)。
在YOLO里应用时,一般把Backbone里的某些3x3卷积改换成RepVGG风格——训练时有3x3主分支、1x1分支和BN分支,推理时通过代数变换合并成一个3x3卷积。这样训练阶段的精度可以接近大模型,而推理时的结构和轻量模型一样。
实际落地时最大的坑是重参数化权重转换的时机。必须保证训练表现记录的是“多分支结构+BN”状态,转换时要把所有分支的卷积核和BN参数先融合、再加权合并,顺序搞错精度直接崩。有些框架需要保存两套模型权重,部署流程会被拉长。
我的个人经验是:重参数化适合和结构轻量化结合使用,但它是锦上添花项,不是雪中送炭项。如果项目工期紧张,优先保证模型能跑、能导出、能量化,再考虑重参数化带来的那一两个点精度收益。
3.4 知识蒸馏:用大模型带小模型不是玄学
知识蒸馏的本质是让小模型从大模型的输出中学到更丰富的“软标签”,而不只是数据集中那个one-hot硬标签。YOLO检测里的蒸馏可以在三个层面做:预测logits层面的蒸馏、特征图层面的蒸馏、以及中间层的feature蒸馏。
在我的实践里,特征图蒸馏比纯logits蒸馏稳定得多。因为检测任务输出是框+类别,直接蒸馏logits对“框的边界”这种连续信息帮助不足。常用做法是用一个大模型的Backbone或Neck特征作为teacher,引导小模型的对应特征尽量靠近。实现上可以选L2距离或Pearson相关系数类的损失。
蒸馏的另一个隐藏价值是:它不增加推理成本,只增加训练成本。所以即使不做任何结构轻量化,在算力充裕的训练阶段“带着”一个teacher,也能让小模型在部署时取得更高的精度上限。越是剪完枝、量化完的模型,蒸馏的必要性越大。
4. 量化:把精度和速度的账算清楚再动手
4.1 PTQ、QAT、FP16到底怎么选
量化是部署侧收益最直接的一个环节,但也是问题最多的一环。先分清几个概念:
| 方式 | 精度影响 | 部署速度收益 | 成本 |
|---|---|---|---|
| FP16 | 几乎无损 | 中等(NVIDIA GPU上明显,CPU无收益) | 低,通常直接转换 |
| INT8 PTQ | 较小,需要校准 | 明显(CPU/NPU上最明显) | 低,但校准集要选好 |
| INT8 QAT | 较小甚至无损 | 明显 | 高,需要微调训练流程 |
| INT4/混合精度 | 较大 | 取决于硬件支持 | 高,通用性差 |
FP16在所有NVIDIA GPU上基本是免费午餐,很多YOLO模型跑TensorRT时直接转成FP16,精度几乎不掉,速度提升明显。INT8的收益在大算力GPU上不如在CPU/NPU上那么极致,但对边缘端部署是刚需。
QAT比PTQ更能保住精度,但对训练Pipeline的侵入性强——需要在训练图中插入fake quantization节点,让模型在训练时就适应量化误差。对YOLO这种检测模型来说,QAT的工程复杂度比分类模型高很多,所以我的建议是:先做PTQ,如果精度掉点超过业务红线,再考虑QAT。不要一上来就QAT,调试成本会吓到你的队友。
4.2 校准集和评估集:量化翻车的第一大坑
PTQ过程里有一个“校准”(Calibration)步骤,就是跑一批数据统计activation的数值范围,决定INT8的缩放因子。这个环节最容易翻车。
不少人图省事,拿训练集中的100张图去校准,结果推理时遇到不同光照、不同季节的图,activation范围完全变了,精度掉得一塌糊涂。正确做法是:校准集要从真实部署场景中采样,覆盖目标的分布,比如工业场景里就拍现场不同工位的图,要包含白天、黑夜、逆光、局部遮挡这些变化。数量不用太多,我的经验是300到500张足够,但质要足够杂。
校准算法上,MinMax最简单,但容易被离群点带偏。MinMSE或者基于熵的校准方法鲁棒性更好,很多框架都支持,比如TensorRT的Calibrator里就有几种可选策略。如果你发现量化后某些类别或者某些尺寸的框整体丢失,第一步就是回头检查校准集,而不是调模型。
4.3 NPU上的算子兼容性,比想象中更影响速度
在RK3588(RKNN工具链)和K230(NNRT工具链)上做INT8部署,算子兼容性是大变量。Conv、BN、ReLU、Concat、MaxPool这些基础算子支持得都不错,但如果你为了轻量化引入了一些“巧思”结构,比如DCNv2可变形卷积、Multi-Head Attention、LayerNorm、动态控制流,这些在NPU上经常会回退到CPU执行,或者直接报不支持。
算子回退的代价不只是慢一点那么简单:数据要先从NPU内存搬回CPU内存,算完再搬回去,这个搬运开销可能比算子本身大一个数量级。很多人在手里模型上看到FP32延迟挺低,一上NPU就崩,查到最后全是某个“看起来没多少计算量”的自定义算子在拖后腿。
所以在设计轻量化结构时,除了看FLOPs和精度,还要把自己限定在目标NTU工具链支持的算子白名单里。动手之前,先去查阅对应平台的算子支持文档,把“能跑”作为第一约束。这也解释了为什么很多成熟的NPU部署项目,模型结构会故意选得很保守——因为稳定压倒一切。
4.4 量化后精度掉点的排查链路
如果量化后发现mAP下降超过预期,我的排查顺序是:
- 先做单图对比。取20到50张图片,分别用FP32模型和INT8模型推理,保存每张图的检测框和置信度,用可视化方式对比,找出掉点最严重的场景类型(小目标、低光照、重叠框)。
- 逐层对比中间特征。很多工具链支持导出各层tensor,对INT8模型和FP32模型跑同一张图,计算每层输出的余弦相似度。相似度低的那几层,就是量化误差的放大器。
- 对敏感层做“豁免”处理。把相似度明显偏低的层从INT8量化中排除,改成FP16或FP32。YOLO的Detect头最后一层卷积往往很敏感,因为它的输出直接决定最终检测框的解析结果,一旦有偏差,NMS的结果会被放大。
- 换校准策略或重新校准。有时只是校准集代表性不够,换一批真实验证的图能解决。
这套链路基本能覆盖90%的量化掉点问题。另外提醒一点,量化后的精度评估最好和FP32模型用完全相同的输入预处理流程,包括letterbox的填充方式、像素归一化方式,任何差异都会变成“伪掉点”。
5. 最容易被忽视的工程侧优化:预处理、分辨率与推理管线
5.1 letterbox的隐藏成本
YOLO系列依赖letterbox做等比例缩放+填充,保证输入图片到640x640不扭曲。但如果这一步在Python里用OpenCV逐帧做resize和copyMakeBorder,你会发现它对CPU的占用率高得吓人。
我在RK3588上做过实验,Python侧letterbox单帧耗时能到8到12毫秒,而模型推理本身可能才20毫秒。这相当于模型已经优化了半天,结果被预处理吃掉了40%。
解决办法有几种:一是用C++实现预处理并把数据直接送进推理库,省掉Python和C++间的内存拷贝;二是用硬件加速模块,RKNN和很多NPU工具链都内置了预处理算子和缩放功能,能把letterbox放进专用单元来做。三是极端情况下直接去掉letterbox,改用定尺寸的裁剪,虽然会损失一部分边缘信息,但如果你场景里的目标分布相对居中,又追求低延迟,这是性价比很高的取舍。
此外还要注意,测试阶段的letterbox参数(目标尺寸、填充值、缩放比例)要和训练阶段保持一致,否则检测框坐标解析会出现系统性偏移。这个问题在量化部署后尤其明显,因为量化模型对偏移的容忍度更低。
5.2 输入分辨率不是越低越好
降输入分辨率是减少计算量最粗暴有效的方式。640降到416,理论上FLOPs能减少接近50%,再降到320会更快,但代价是小目标会迅速丢失。
关键原则是:分辨率的选择必须结合目标尺度和部署场景。如果你的检测场景是近距离的人脸闸机,1024还是640几乎不影响什么——目标在整图中占很大比例;但如果是智慧城市的监控画面,行人只有几十个像素高,降到416就已经开始丢目标了。
更稳的做法是训练阶段就做多尺度训练(比如0.5倍到1.5倍随机缩放),让模型学会适应不同分辨率下的目标形态。测试阶段再用一个相对小的固定分辨率去跑,模型的“抗缩放能力”会明显强于从头就用固定小分辨率训练出来的模型。
另外补充一点,有些NPU工具链对输入尺寸有对齐要求,比如RKNN经常要求32或者16的整数倍,416、480、512这类值天然友好,自己乱用奇怪尺寸反而可能触发补齐填充,白增算力。
5.3 推理框架层面的优化
模型结构和权重都优化好了,最终还要靠运行时来落地。这部分的工作量往往是决定“纸面变快”还是“实际变快”的关键。
TensorRT上,尽量用静态输入尺寸而不是动态尺寸。动态shape虽然方便,但会导致TensorRT自动选择更保守的优化策略,速度比静态shape慢10%以上。如果业务输入尺寸固定,直接指定固定H、W,能让TensorRT做更激进的算子融合和显存复用。
导出ONNX后,用onnxsimplifier和onnx-graphsurgeon清理一遍图结构,去掉多余的Identity节点、合并BN到Conv等。很多从PyTorch导出的模型自带一堆无用算子,在GPU上不明显,在NPU上就会增加调度开销。
推理前做warm-up。GPU和NPU都有“冷启动”问题,第一帧推理通常明显比后续帧慢。在正式推流前跑个十几次推理,把显存池、NPU内存映射、线程池都热起来。
此外,如果模型在CPU上部署,可以考虑集成OpenVINO或NCNN这类针对CPU指令集做过深度优化的推理库。同一个ONNX文件,用它们跑和用原始PyTorch跑,帧率差距往往是倍级的。
5.4 NMS和后处理:小模型最容易被忽略的瓶颈
模型推理时间下降以后,后处理在总耗时里的占比会显著上升。YOLO的原生后处理包括解码框、按置信度过滤、NMS去重。如果在Python里用循环写NMS,当目标数量多的时候,这部分时间可能比卷积推理还长。
我遇到过一个实际例子:小模型在NPU上推理只用了12ms,但Python侧NMS和解析耗时20ms。后来我把后处理改成C++实现,总耗时降回预期,并配合了TensorRT的EfficientNMS插件来替代手工NMS。很多推理框架都能把NMS集成进模型图内部(TensorRT、OpenVINO都有类似插件),这样省掉的不仅是计算时间,还有图内算子和图外代码之间的来回数据拷贝。
预处理和后处理的优化不改变模型的任何权重,所以它是最安全的一类优化。建议所有做YOLO部署的人,先做这一层优化,再回头去折腾结构和量化。热门的YOLO实例分割和姿态估计也一样,Mask分支的Resize、Keypoint分支的Group操作都有类似瓶颈,工程侧优化的思路是通用的。
另外还有一个容易被忽略的训练侧因素:数据集质量。轻量化模型容量小,对标注噪声的容忍度更低。做KITTI标注转YOLO格式、训练自定义数据集时,我建议把标注质量检查放在前面。框稍微偏差几个像素,大模型可能自己纠正回来了,小模型却会把这个噪声直接学进去。这也是为什么同一个轻量化模型在不同人手里精度差异巨大的原因之一:数据没对齐的话,再好的模型结构优化都白搭。
如果让我给一个优先级清单,对于大多数边缘端YOLO项目,我会建议先做工程侧优化(letterbox、后处理、推理框架调优),然后上量化(先FP16再INT8,注意校准集和算子兼容性),跑通以后再评估是否需要换轻量Backbone或做剪枝,最后用蒸馏和重参数化去补精度。这个顺序背后的逻辑很简单:工程侧优化和量化基本不改变模型的行为,风险最低、收益最稳定;结构改动和剪枝的调试成本高,适合在项目有余量时慢慢做。
还有一个道理我想多强调一遍:不要只盯着FLOPs和参数量说话。我之前接过一个项目,给我展示“FLOPs从30G降到了5G”,但实测延迟几乎没变。后来用profiler一查,时间全花在数据拷贝和调度上了。轻量化的本质是在目标硬件的约束下,重新寻找精度和速度的平衡点。每一次改动,最终都要用自己场景的测试集和实测帧率来验证,而不是用论文里的数字。先把这些方向记下来,动手之前明确目标和平台,比急着改代码更有用。