1. 为什么要给yolov8s做剪枝:项目背景与方案选择
1.1 yolov8s到底哪里“肥”了
先从一个很实际的问题说起:yolov8s这个模型,官方给的数据是参数量大约11.2M,FP16精度下权重文件大概22MB左右。听着不算大,但真正跑到边缘设备上,比如Jetson Nano、RK3588这种板子,或者是要做高并发视频流推理的服务器,你会发现显存占用和推理延迟都挺吃紧的。我实测过,一张1080p的图片在2080Ti上单卡跑yolov8s,TensorRT FP16下大概要2.5ms左右,听起来还行,但如果同时跑8路视频流,显存占用会迅速爬升,帧率也撑不住。这就是模型“冗余”带来的代价。
那冗余到底在哪儿?其实卷积神经网络里大量的卷积核权重都非常接近零,或者说很多通道对最终预测结果的贡献微乎其微。有研究统计过,像ResNet、VGG这类模型,剪掉50%甚至更多的通道,精度损失都能控制在1%以内。yolov8s本质上继承了CSPDarknet的结构,骨干网络里大量的C2f模块其实叠了很多Bottleneck分支,这些分支里的卷积通道数动不动就是128、256,其中确实有不少是“划水”的。
我给这类项目定的目标很明确:把yolov8s的FLOPs减掉40%以上,参数量减掉30%以上,同时保证mAP@0.5的下降不超过2个百分点,模型文件体积尽量控制在12MB以内。这样模型就可以直接塞进轻量级设备,也能在普通GPU上做更多路数的并行推理。
1.2 结构化与非结构化:为什么最终选通道剪枝
模型剪枝大致分两类:非结构化剪枝和结构化剪枝。非结构化剪枝会把权重矩阵里那些绝对值接近零的单个权重直接置零,模型变成稀疏矩阵存储,压缩效果很依赖硬件和推理框架对稀疏性的支持。说句实话,如果你打算最终用TensorRT或者OpenVINO部署,非结构化剪枝在目前的主流硬件上基本享受不到真正的加速红利,因为计算单元还是按稠密矩阵来算的,你得额外做稀疏卷积的定制算子,工程量直接翻倍。
结构化剪枝是另一条路,它把整个卷积核或者整条通道删掉,模型结构本身就变瘦了,导出后无论用PyTorch、ONNX还是TensorRT,计算量都实打实地降下来。我这次选的就是结构化剪枝里的通道剪枝,具体做法是利用BatchNorm层的gamma系数(缩放因子)来评估每个通道的重要性,把gamma值低的通道连带对应的卷积核剪掉。这个方案在工程上非常成熟,而且实现起来不需要对yolov8的检测头做任何改动,骨干和颈部网络剪完以后,输出层的shape完全不变,这对后续微调和部署特别友好。
这里有个关键点要提前说清楚:yolov8的原作者Ultralytics在代码里默认关闭了BatchNorm的gamma初始化策略,所有BN层的gamma初始值都是1。这本来不影响正常训练,但到了剪枝的时候就有问题了——gamma全为1意味着你没法在模型加载后立刻用gamma分布来判断通道重要性,你必须先做一段时间的稀疏化训练,把gamma分布拉开,让一部分通道的gamma明显变小。
1.3 剪枝后的预期收益与评估指标
动手之前,先把收益预期和评估指标定下来,不然最后剪完连好坏都说不清楚。我一般会用这么几个指标来做评估:
- mAP@0.5和mAP@0.5:0.95,这是目标检测模型最核心的精度指标。
- 模型权重文件大小,可以直接看剪枝前后pth文件的变化。
- FLOPs和参数量,推荐用thop或者ptflops这个库统计。
- 单张图片推理延迟,这个要在固定硬件、固定batch size下测,最好用TensorRT测,因为PyTorch的Eager模式推理延迟波动太大,没有参考价值。
我给自己定了个底线:剪枝后的模型mAP@0.5比原模型下降不超过2个点,FLOPs降低40%以上。算下来大概需要把C2f模块里的冗余通道剪到只剩60%~70%。如果剪完掉点超过3个,那就说明稀疏化训练没做够,或者剪枝阈值选得太激进了,得回炉重调。
2. 源码级核心实现:三个关键模块
2.1 最小剪枝单元的定位
通道剪枝的工程难点在于:你不能只剪一个Conv层就完事,因为卷积层的输入输出通道是跟前后层强耦合的。比如某个Conv层的输出是64通道,它后面接的BN层也是64通道,再后面的Conv层输入通道也必须对应改成64。你只改中间一个层,后面立刻shape mismatch跑不起来。
所以第一步就是定义“最小剪枝单元”。我实际操作中是把Conv2d + BatchNorm2d这对组合作为一个基本单元来对待的,剪枝以通道为单位,一次剪掉一整条通道。这里有个需要特别小心的场景:如果某个Conv2d后面跟着的是add操作(比如C2f里的Bottleneck分支),那你剪通道的时候必须两个分支同步剪,保证相加时shape一致,这个在yolov8的C2f模块里特别容易踩坑。
定位剪枝单元,我建议直接遍历模型的named_modules,把类型是Conv2d的层全部收集起来,然后分析它们的连接关系。对于每个Conv层,你要知道三件事:它的输入通道数、输出通道数、以及它的输出会被哪些后续模块消费。最稳妥的方案是写一个剪枝注册表,按顺序记录每一层的通道索引映射。剪枝的时候先统计出要剪的通道索引,然后再统一执行,千万不能边遍历边剪,否则索引一乱,整个模型就废了。
2.2 稀疏化训练与BN伽马排序
整个剪枝流程的第一步,不是剪,而是先做稀疏化训练。这一步的本质是通过在损失函数里加入一个针对BN层gamma参数的L1正则项,强行让一部分gamma往零靠。gamma值的物理意义可以理解成每个通道的“音量旋钮”——旋钮拧到接近零的通道,就说明这个通道输出的特征对后续计算基本没有贡献,剪掉它对模型能力的影响最小。
代码上,最简单的做法就是这么一段:
def sparse_regularization(model, lambda_factor): reg_loss = 0.0 for name, module in model.named_modules(): if isinstance(module, torch.nn.BatchNorm2d): reg_loss += torch.norm(module.weight, p=1) return lambda_factor * reg_loss然后把这段正则项加进原有的总损失里,比如总损失 = 原始损失 + 0.0001 * sparse_regularization。稀疏化训练一般需要跑完整训练流程的10%到20%的epoch量级,比如原来训练300个epoch的项目,稀疏化阶段跑30~50个epoch就够了。学习率不能太大,建议用正常训练最后阶段的学习率再降一半,大概1e-4到1e-5之间,防止稀疏化把模型的语义特征破坏掉。
稀疏化训练结束后,把所有BN层的gamma值收集起来,看分布。理想状态下gamma应该是两极分化,一部分明显集中在0.01以下,一部分还在0.5以上。如果你看到所有gamma都还待在1附近,那说明lambda_factor太小或者epoch不够,剪枝出来的模型大概率会掉点。
2.3 剪枝状态恢复与模型重建
剪枝不是改改参数就完事的,关键是要把权重状态正确地搬运到裁剪后的新模型里。这里最朴素的思路是:先按剪枝索引把每层的权重和偏置精简一下,然后用这些精简后的权重去初始化一个结构已经变小了的新模型。我不推荐直接在原模型上做in-place剪枝,因为PyTorch的nn.Conv2d对象一旦实例化,权重shape就定死了,强行改in_channels和out_channels会带来一堆意想不到的麻烦。
具体流程分成几步:先克隆原始模型的结构,新建一个模型实例,这个模型的结构会被修改成剪枝后的瘦身结构。然后把原模型每个Conv层的权重按索引切片,clip掉对应的输入通道和输出通道,再把这些切片后的权重和偏置填到新模型对应的层里。最麻烦的是C2f模块里的Bottleneck分支,因为它的add结构要求两个输入分支的通道数完全一致,剪枝的时候要么两个分支剪同样的索引,要么就必须保证剪完后的通道数还相等,我一般选择前者,省事且稳定。
剪完重建后,一定要加载state_dict检查一遍所有层的shape是否匹配,然后跑一个前向测试。如果前向测试过了,模型基本就是可用的;如果卡在某个层报shape错误,九成是某个Bottleneck分支的索引没对上。
3. 实操流程:从原模型到可部署模型
3.1 环境准备与依赖
这个项目依赖的东西不算多,但版本要稳。建议直接用Ultralytics官方提供的环境,Python版本3.8~3.10之间,PyTorch选1.13或者2.x都行,CUDA版本根据你的显卡驱动来。我本地的组合是Python 3.9 + PyTorch 2.0.1 + CUDA 11.8,跑得很顺。
代码结构上,我建议不要动yolov8源码本身,单独建一个prune_yolov8的目录,里面放几个模块文件,这样随时能同步Ultralytics上游更新,不影响剪枝代码。目录大概是这样的:
prune_yolov8/ ├── sparse_train.py # 稀疏化训练脚本 ├── prune.py # 剪枝核心脚本 ├── finetune.py # 剪枝后微调脚本 ├── export_onnx.py # ONNX导出脚本 └── utils/ ├── module_utils.py # 模型结构解析与通道索引分析 ├── weight_utils.py # 权重切片与复制工具 └── metric_utils.py # mAP、FLOPs、延迟统计3.2 剪枝前准备:稀疏化训练的参数设置
稀疏化训练这块,我直接复用Ultralytics的YOLO训练接口,但需要自己往损失里注入正则项。Ultralytics的Trainer类里有一个回调机制,可以在每次backward之后对模型参数做额外操作,但更省事的做法是直接改动损失计算部分:在loss.backward()之前,把稀疏正则项加到总loss上。
我实际用的lambda_factor是1e-4,放在一个含6000张图片的工业检测数据集上,跑了30个epoch,初始学习率调低到5e-5,优化器保持SGD+momentum0.937不变,weight_decay保持5e-4。跑完之后我统计过gamma分布,大约30%的通道gamma值低于0.03,说明稀疏化起作用了。但如果你的数据集规模特别小(几百张那种),建议lambda_factor再小一点,比如5e-5,否则稀疏化容易把模型学崩。
训练完之后记得保留一份稀疏化后的权重文件,这是剪枝的输入模型。剪枝脚本读的就是这个权重。
3.3 剪枝阈值怎么选
剪枝阈值是整个流程里最玄学也最核心的一步。阈值定得太小,剪完没什么效果;定得太大,精度崩得惨不忍睹。工程上我用的是一个相对可复现的策略:统计所有BN层gamma的绝对值,画直方图,然后取一个百分位数作为全局阈值。实际操作时,我会先把gamma值排序,然后尝试不同的百分比:20%、30%、40%、50%。每种百分比都剪一版模型,用验证集跑一下mAP,选出满足精度底线的前提下剪枝率最高的那档。
这里有个经验值可以参考:剪枝率在30%到50%之间通常是安全的,超过50%就要非常谨慎了。yolov8s总共大概有十几个C2f模块,每个模块内部通道数从64到512不等。如果统一用全局百分比剪,高层特征图的通道会被剪得更狠,因为高层的BN gamma通常更低,这倒不一定是坏事,深层特征本身就冗余更多。
我在代码里是这样实现阈值选择的:
gammas = collect_all_bn_gammas(model) threshold = torch.quantile(gammas, 0.35)然后把所有gamma小于threshold的通道索引收集起来,作为待剪通道。不过这里有个细节:有些层如果剪完output channels少于16,模型基本就废了,表达能力直接塌掉。所以我在剪枝逻辑里加了一个下限保护,每个Conv层至少保留16个输出通道。这个16也不是拍脑袋定的,是我自己跑了十几个实验试出来的下限,再低精度就会断崖式下跌。
3.4 微调与导出
剪完之后的模型一定要做微调,让保留的通道重新学一下被剪掉通道原本承担的特征。我的建议是:用原训练集,从头训练一个完整的短周期,比如80个epoch,学习率从1e-4开始然后余弦衰减到1e-6。这里的关键是——不要用稀疏化那套带L1正则的损失了,就把微调当成一次普通的重新训练,让模型在紧凑的结构下回归到稳定状态。
微调完成后,导出ONNX和TensorRT版本。yolov8官方就提供了export.py,直接指定剪枝后的模型文件路径就行。导出的时候注意要固定输入尺寸,比如640x640,并且关闭dynamic batch,这样TensorRT优化得最充分。我自己导出的剪枝后TensorRT引擎,在2080Ti上单张640x640的推理延迟从2.5ms降到了1.4ms左右,模型权重从22MB降到了14MB,整体效果还是很可观的。
4. 常见问题与排错记录
4.1 常见问题速查表
剪枝这个事,踩坑的密度相当高。我把自己反复遇到的几个典型问题整理成了表格,方便大家对照排查。
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
| 剪枝后前向时报shape mismatch | C2f模块里Bottleneck分支的通道没同步剪 | 检查add操作的两个分支索引是否一致,按同一索引集剪 |
| 模型权重变小了但FLOPs没降多少 | 剪枝主要剪掉了参数量小的层,高FLOPs层没动 | 打印每层FLOPs分布,定位大头层并针对性剪 |
| mAP直接掉10个点以上 | 剪枝阈值选太大,或稀疏化训练没做够 | 降低剪枝百分比,重新做稀疏化训练,提高lambda_factor和epoch |
| 微调后精度没回升 | 学习率太大,微调破坏了原有特征 | 学习率降到1e-5级别,增加微调epoch |
| 导出ONNX时部分算子不支持 | 模型里存在自定义算子或动态分支 | 固定输入shape,关闭dynamic_axes,升级onnx版本 |
| 某些层剪完通道数太少 | 没有设置最小通道下限保护 | 每个Conv层保留至少16个输出通道 |
4.2 实操中的避坑经验
第一个要强调的坑:不要边遍历边剪。我第一次做剪枝的时候就是边遍历named_modules边改通道数,结果后续层的索引全乱了,整个模型直接崩掉。正确做法是先用两个阶段分离——先统计所有需要剪的通道索引,再统一重建模型。你可以在模型前向传播里加一个hook来验证每一层的输入输出shape是否连续,这样能快速定位断点。
第二个坑是稀疏化训练过度。稀疏化训练时间太长或者lambda_factor太大,会让大量通道的gamma变成绝对零,剪枝后模型虽然结构很小,但精度恢复非常困难。我自己的经验是:稀疏化训练的epoch控制在完整训练时长的15%以内,lambda_factor控制在1e-4量级,剪枝后留10%~20%的通道冗余,给微调留一点回旋空间。
第三个值得提醒的点是关于BatchNorm层本身的处理。有些剪枝代码会把BN层直接删掉或者融合进Conv层,但yolov8在训练时默认开了BN,如果你把BN全融合了,剪枝后的精度波动会变得特别大。实际操作中我倾向于保留BN层到微调之后再融合,让模型结构保持跟训练时一致,这样数值稳定性最好。等微调结束后再用torch.utils.fusion或者Ultralytics提供的fuse方法把BN融合进Conv,再导出部署模型。
最后再补充一个关于数据集的经验。如果你要剪的是一个在COCO上预训练的模型,然后迁移到你的私有数据集上,一定要先做完整的数据集微调,把模型调到私有数据集上的最优状态,然后再做稀疏化训练和剪枝。如果直接拿COCO预训练权重开剪,剪出来的模型在私有数据上的表现会非常不稳定。先适配再剪枝,这个顺序是最稳的。
实际上我做这个项目最大的体会就是:剪枝流程本身不算难,难的是怎么在精度和压缩率之间找到那个平衡点。剪多了掉精度,剪少了没意义,每个数据集的平衡点都不一样,只能靠实验去试。所以也别指望一上来就能复现别人的完美效果,多跑几组阈值对比,记录下每个方案的mAP和FLOPs,你会慢慢摸到规律,后面再做就顺手多了。