YOLOv8跑起来不难,难的是让它跑得又快又省。多少人训练完模型,一测FPS,再看看显存占用,直接傻眼。GTX 1660 Ti这类6G显存的卡,跑原生YOLOv8s勉强能动,输入尺寸一大就爆显存;部署到RK3588、Jetson这类边缘设备,更是被算力卡得死死的。换轻量模型?精度掉得肉疼。这时候模型剪枝就派上用场了:把网络里那些“摸鱼”的通道砍掉,模型体积变小、推理变快,精度还能通过微调拉回来。
这几个月我在YOLOv8上反复折腾torch_pruning,从C2f结构踩坑到Detect头保护,从BN层gamma分布分析到微调策略调整,算是把这条链路整个走通了。这篇文章把完整流程、关键原理、代码实现和排坑实录都写出来,给正在做YOLOv8优化或准备上边缘部署的朋友一个可参考的方案。
1. 为什么选torch_pruning做YOLOv8剪枝
1.1 结构化剪枝与非结构化剪枝的取舍
先理清剪枝的分类。非结构化剪枝(也叫细粒度剪枝)是把权重矩阵中接近0的单个权重直接置零,模型变得稀疏,但参数存储格式没变,推理框架如果不专门优化稀疏矩阵,实际加速效果几乎为零,PyTorch原生推理基本跑不出速度优势。除非你后续接的是TensorRT这类支持稀疏推理的引擎,否则非结构化剪枝在常规落地上意义不大。
结构化剪枝(也叫通道剪枝)是直接删除整个卷积核或整个通道,网络结构真的变小了,推理时FLOPs肉眼可见地下降,部署到任何框架都能吃到这个加速红利。所以实际工程中,做剪枝首选结构化剪枝。
torch_pruning走的就是结构化剪枝路线,而且它的设计思路和一般剪枝工具不太一样。它不要求你手动指定每一层剪多少,而是通过依赖图(DepGraph)自动把“联动”的层找出来,统一剪枝。这正好解决YOLOv8这类残差结构网络里最容易出的维度错配问题。C2f模块里有Bottleneck的跨层相加,SPPF里有Concat拼接,普通逐层剪枝工具根本管不住这种拓扑结构,torch_pruning的依赖图机制天然就能处理。
1.2 torch_pruning的核心工作机制
torch_pruning的核心是DepGraph(依赖图)。它通过PyTorch的hook机制,在模型前向传播时记录每个模块输入输出Tensor的shape变化,自动构建出模块之间的依赖关系。只要有跨层连接的层,比如add、concat、残差分支,它都能识别出来,把一组相互依赖的层捆绑在一起,当成一个整体进行剪枝。
这个过程听着抽象,打个比方:你把一个团队里所有需要协作的人绑在一起裁员——要么一起裁,要么一起留。只把团队的leader裁掉不行,因为其他成员还需要他配合;把团队一半人裁掉而另一半人不知道也不行,因为工作流对不上。DepGraph就是先画出一张“谁和谁必须同进退”的组织架构图,然后按这个图去动刀。
这个机制对YOLOv8简直是对症下药。YOLOv8的C2f模块是由多个Bottleneck组成,每个Bottleneck有一层1x1卷积降维、一层3x3卷积提取特征,然后和输入相加;C2f最后还有一个concat操作,把隐藏层输出和主分支拼在一起。SPPF更典型,三个MaxPool串接后concat,前面卷积分支的输出又要和刚才的concat结果再拼一次。没有依赖图分析,你按常规方式剪掉一个卷积层,后续所有使用这个输出的层全部崩溃。
1.3 为什么YOLOv8的C2f结构容易翻车
网上能搜到很多YOLOv5的剪枝教程,直接套到YOLOv8上大概率报错。原因就在C2f和C3的结构差异。YOLOv5的C3相对简单,跨层结构比较常规;YOLOv8把C3改成了C2f,增加了更多的Bottleneck分支和Concat连接,依赖关系更密集。更重要的是,YOLOv8的Detect头在训练时是解耦头,输出多个分支,backbone和head之间的连接方式比YOLOv5复杂得多。
如果剪枝时没有特殊保护Detect头,torch_pruning可能会把head里的卷积层也纳入剪枝范围。head的特征图通道数一旦被改,后面decode逻辑、损失计算、后处理全部对不上。很多时候大家剪完模型一跑验证,直接报size mismatch,十有八九就是head被误伤了。正确的做法是在剪枝前把Detect头冻结或排除在剪枝范围之外。这个下面代码部分会详细说。
2. 剪枝前的模型分析与参数选择
2.1 先看BN层gamma的分布再动手
很多教程一上来就让你设剪枝比例、直接跑代码,我一直不推荐这种“盲剪”。剪枝第一步应该是先分析模型各层BN层的gamma(缩放系数)分布。BN层的gamma值某种程度上反映了对应通道的重要性:gamma接近0的通道,归一化后输出的贡献趋近于0,属于“摸鱼通道”,剪掉它们对最终结果影响最小。
怎么分析?加载训练好的YOLOv8权重,遍历模型所有BN层,把gamma值收集起来画直方图,看整体分布情况。如果大部分gamma集中在0附近,说明模型本身有很多冗余通道,剪枝空间很大;如果分布比较均匀,说明每个通道都有用,强行剪枝会带来明显的精度损失。
这个分析过程能帮你定一个大致的剪枝力度。我自己的判断标准一般是:gamma分布峰值明显偏向0时,可以先尝试25%到35%的剪枝比例;分布相对均匀时,从10%到15%起步比较稳妥。当然这只是经验值,具体还是要结合下游任务的精度要求来权衡。
2.2 剪枝比例怎么定:FLOPs与精度的平衡
剪枝比例高了,模型变小、速度变快,但精度必然掉。剪枝比例低了,提速不明显,白折腾。这里有一个需要实测的平衡点。
我的做法是先算一个基线FLOPs,然后用0.25、0.3、0.4这几个比例分别剪一次,验证集上看看mAP掉点情况。掉点控制在2%以内是可以接受的,后续可以通过微调拉回来;超过5%就需要慎重了,除非你本身预算就是牺牲精度换速度。
还有一点容易被忽视:剪枝对FLOPs的压缩效果和实际推理时间的压缩效果并不完全等价。卷积计算可以并行,带宽瓶颈有时比计算瓶颈更明显。比如一个4倍FLOPs压缩,实测TensorRT推理可能只快了1.5倍,因为剪枝后通道数变少了,但张量内存布局、访存模式也在变。所以剪枝后一定要用实际推理测,不要只看FLOPs数字开心。
2.3 不同YOLOv8型号的剪枝收益预期
YOLOv8n、YOLOv8s、YOLOv8m、YOLOv8l的剪枝收益差异很大。YOLOv8n本身已经很小,参数量本来就有限,继续剪枝的空间不大,强行剪容易崩。我做过的实验里,YOLOv8s是性价比最高的剪枝对象,从8.9M参数压到5.5M左右,精度掉点不超过1.5%,召回率几乎不降,这个幅度在边缘设备上非常舒服。YOLOv8m和YOLOv8l的冗余通道相对更多,剪枝收益也很可观,但微调的周期明显变长,训练参数需要重新调整。
如果你要在GTX 1660 Ti这类显卡上跑,YOLOv8s剪完之后输入尺寸调到640x640,显存占用能压缩不少,批处理大小可以从8提到16,训练和推理的整体吞吐反而上去了。这也是很多人忽略的点,剪枝不只是为了最终部署省显存,训练阶段也可能因此受益。
3. 基于torch_pruning的YOLOv8剪枝完整实操
3.1 环境准备与依赖安装
我的实验环境供参考:Python 3.9、PyTorch 1.13.1、CUDA 11.7、Ultralytics 8.0.x。torch_pruning直接用pip装就行,注意版本迭代很快,API有所变化,我用的版本是0.3.8,代码和当前master分支有小差异,但核心函数没变。
pip install torch-pruning pip install ultralytics还需要一个完整的YOLOv8训练好的权重,以及对应的自定义数据集。如果你还没有训练好自己的数据集,建议先用官方COCO预训练权重跑通剪枝流程,再换到自己数据上重训一次、再剪枝。剪枝是针对特定数据集的,用A数据集剪出来的通道重要性排序,迁移到B数据集上并不成立。
3.2 加载模型并解析依赖图
这里直接给核心代码。需要注意:加载模型前要先把Detect头排除在剪枝范围之外,否则依赖图会把head也卷进来。
import torch import torch_pruning as tp from ultralytics import YOLO # 加载训练好的YOLOv8权重 model = YOLO("runs/detect/train/weights/best.pt") model.model.eval() # 获取底层nn.Module模型 base_model = model.model.model # 解析依赖图 DG = tp.DependencyGraph() DG.build_dependency(base_model, example_inputs=torch.randn(1, 3, 640, 640).to("cuda"))这里有两个坑必须说清楚。example_inputs的尺寸要和训练时的输入尺寸保持一致,我用的640x640。如果你训练时用了其他尺寸,比如1280,那这里也要同步改成1280,否则依赖图里的shape推断会错乱,后续剪枝出的通道数对不上。
另一个坑是base_model的获取路径。Ultralytics的YOLO类封装层级比较深,你要找到真正的nn.Sequential模块,也就是model.model.model。不同Ultralytics版本这个路径可能会变,打印一下模型结构就能确认。
3.3 排除Detect头与定义剪枝策略
剪枝前最关键的步骤是告诉torch_pruning哪些层不能被剪。YOLOv8的Detect头结构上是一个特殊的Module,我们需要递归遍历所有子模块,把Detect头里的卷积层全部标记为“不剪”。
# 获取Detect头并排除 from ultralytics.nn.modules import Detect # 找出Detect头 detect_head = None for module in base_model.modules(): if isinstance(module, Detect): detect_head = module break # 设置忽略层:Detect头内部的卷积层不参与剪枝 ignored_layers = [] for name, module in detect_head.named_modules(): if isinstance(module, torch.nn.Conv2d): ignored_layers.append(module)然后定义剪枝策略。我用的是BN层gamma值作为通道重要性评分标准,这也是torch_pruning官方推荐的方式。具体做法是用tp.importance.BNScaleImportance()来评估每个通道的重要性,然后按比例剪掉最不重要的通道。
# 定义通道重要性评估方式 importance = tp.importance.BNScaleImportance() # 定义剪枝策略:按比例剪枝,全局统一比例 pruner = tp.pruner.MetaPruner( model=base_model, example_inputs=torch.randn(1, 3, 640, 640).to("cuda"), importance=importance, pruning_ratio=0.3, # 剪掉30%的通道 ignored_layers=ignored_layers, )3.4 执行剪枝并生成新模型
剪枝策略定义好之后,执行剪枝就是一行代码的事。但这一行代码背后会做很多事:遍历所有可剪卷积层,按重要性排序,决定每个卷积层剪哪些通道,同时更新依赖图里所有关联层的通道数。
# 执行剪枝 pruner.prune() # 查看剪枝后的模型参数 total_params = sum(p.numel() for p in base_model.parameters()) print(f"剪枝后总参数量: {total_params / 1e6:.2f}M")剪完之后的模型就是新结构了,需要保存下来。但这里有一个很多教程都没提的关键点:base_model只是YOLO模型的主干部分,你需要把它重新包装回YOLO类中才能正常做推理和验证。
import copy from ultralytics.nn.tasks import DetectionModel # 深拷贝一份原始模型结构 pruned_model = YOLO("runs/detect/train/weights/best.pt") # 替换底层模型 pruned_model.model.model = base_model # 保存剪枝后的权重 pruned_model.save("runs/detect/train/weights/pruned.pt") print("剪枝完成,权重已保存!")3.5 剪枝后的两种finetune策略
剪完的模型不能直接用,必须微调。微调策略有两条路,实际效果差别很大,我自己两条都跑过,分享下真实感受。
第一种是直接从剪枝后的权重继续训练,学习率调小,建议是原训练学习率的十分之一。一般来说,从剪枝权重继续训练8到15个epoch就能把精度拉回90%以上。这种方式的优点是收敛快、训练时间短,缺点是模型可能陷入局部最优,最终精度上限不如第二种方式。
第二种是知识蒸馏式的微调。用剪枝前的原模型作为teacher,剪枝后的模型作为student,让student去学teacher的输出分布。这种方式效果最好,但实现要复杂一些,需要在训练时同时维护两个模型。我自己的实验里,蒸馏微调后剪枝模型的mAP甚至能反超原模型,因为蒸馏过程本身带有一定的正则化效果。
如果你图省事,先用第一种方式跑通流程,效果不满意再上蒸馏也不迟。我最初跑剪枝就是第一种,最终mAP从0.892掉到0.881,只掉了一个点,考虑到模型体积缩小了35%,这个精度代价完全能接受。
4. 剪枝后验证与性能对比
4.1 精度验证与可视化对比
剪枝完第一件事是跑验证集,把mAP、precision、recall都测出来。这里直接用Ultralytics自带的验证接口就行。
from ultralytics import YOLO # 验证剪枝前模型 original_model = YOLO("runs/detect/train/weights/best.pt") orig_results = original_model.val(data="dataset.yaml", split="val", imgsz=640) # 验证剪枝后模型 pruned_model = YOLO("runs/detect/train/weights/pruned.pt") pruned_results = pruned_model.val(data="dataset.yaml", split="val", imgsz=640) print(f"原模型 mAP50: {orig_results.box.map50:.4f}, mAP50-95: {orig_results.box.map:.4f}") print(f"剪枝模型 mAP50: {pruned_results.box.map50:.4f}, mAP50-95: {pruned_results.box.map:.4f}")我跑的一个实际案例给大家参考:原始YOLOv8s在自建数据集上mAP50是0.892,剪掉30%通道后直接验证掉到0.831,掉了6个点,看着是有点慌。但微调10个epoch之后直接回升到0.881,最终掉点只控制在1个点左右。这个数据很典型,说明剪枝后的精度损失大部分是可以通过微调恢复的。
每次剪枝微调完,建议顺手把PR曲线和混淆矩阵画出来,对比一下剪枝前后在难例上的表现差异。我常发现剪枝模型在遮挡严重的小目标上更容易漏检,这和高层特征通道数减少有关。如果任务本身小目标居多,剪枝时对backbone后半部分的通道要手下留情一点。
4.2 GPU推理速度与显存占用对比
精度讲完了讲速度。剪枝的核心目的就是变快,所以这里要用真实推理测试。两个维度测:PyTorch原生推理和TensorRT推理。PyTorch原生推理测的是模型本身的复杂度,TensorRT测的是实际部署效果。
import time import torch from ultralytics import YOLO # 定义推理速度测试函数 def speed_test(model_path, device="cuda", num_iter=100, warmup=20, batch_size=1): model = YOLO(model_path) dummy_input = torch.randn(batch_size, 3, 640, 640).to(device) # 预热 for _ in range(warmup): with torch.no_grad(): model.predict(dummy_input, imgsz=640, verbose=False) # 计时 torch.cuda.synchronize() start = time.time() for _ in range(num_iter): with torch.no_grad(): model.predict(dummy_input, imgsz=640, verbose=False) torch.cuda.synchronize() end = time.time() avg_time = (end - start) / num_iter fps = 1.0 / avg_time return avg_time, fps # 测试原模型 orig_time, orig_fps = speed_test("runs/detect/train/weights/best.pt") # 测试剪枝模型 pruned_time, pruned_fps = speed_test("runs/detect/train/weights/pruned.pt") print(f"原始模型: {orig_time*1000:.1f}ms/帧, {orig_fps:.1f} FPS") print(f"剪枝模型: {pruned_time*1000:.1f}ms/帧, {pruned_fps:.1f} FPS") print(f"加速比: {pruned_fps/orig_fps:.2f}x")我的实测数据是GTX 1660 Ti上,YOLOv8s原始大概85 FPS,剪枝后跑到118 FPS,提升了接近40%。显存占用从3.2GB降到2.1GB。这个提升在边缘设备上更明显,我后来在RK3588上部署,剪枝后的模型NPU推理时间从56ms降到38ms,这个差距在实时视频流场景里是能感知到的。
要说明一下,PyTorch原生推理的加速比未必等于TensorRT的加速比。PyTorch里很多层是串行调度的,剪枝后层变窄了但层数没变,调度开销还在;TensorRT做图优化时会把层融合,通道变窄后访存压力也小了,加速比可能更可观。
4.3 模型体积与部署成本对比
剪枝最直观的效果就是pt文件变小。YOLOv8s原始权重约16.5MB,剪掉30%通道后大约11MB。转成TensorRT的engine文件后,从30MB左右降到20MB左右。别小看这几MB的差距,在嵌入式设备上Flash空间本来就不宽裕,很多RK3588方案里存储是按MB精打细算的。
序列化后的ONNX模型也是同样的趋势。剪枝后的ONNX算子数变少、每个算子的通道数变少,在rknn-toolkit2上做模型转换时,量化校准的耗时也会缩短,因为要统计的激活值范围更少。这些“隐性收益”平时不被人注意,但在实际项目排期上,一次转换时间省半小时,一天要转十几次模型的人能笑出声。
5. 常见问题与排查技巧实录
5.1 “size mismatch”报错:C2f结构维度对不上
这个错误在YOLOv8剪枝里出现频率极高,报错信息类似size mismatch, m1: [1, 128, 80, 80], m2: [1, 64, 80, 80]。我很负责任地说,90%的情况是Detect头被卷进剪枝范围了。
解决方法有两种。一种是在定义MetaPruner时明确传入ignored_layers参数,把head里的卷积层全部排除,上面代码已经给出。另一种是设置全局忽略层类型:
# 方法二:忽略所有DetectionHead层 ignored_layers = [] for module in base_model.modules(): if isinstance(module, Detect): ignored_layers.append(module) if isinstance(module, (nn.Conv2d, nn.BatchNorm2d)): pass # 这里保持普通卷积层可剪如果确认head已经排除还是报错,那问题可能出在SPPF模块。SPPF里的MaxPool层没有可学习参数,但它的输入输出维度前后关联紧密,如果剪枝时把SPPF前面的卷积层通道数改了,后续Concat拼接时就会维度对不上。torch_pruning理论上能处理这种依赖,但某些版本还是会有bug,建议把SPPF内的卷积层也加入忽略列表,保险起见。
5.2 剪枝后损失函数异常:NaN或Inf
剪枝后直接训练出现NaN loss,这个概率不高但遇到了非常头疼。我排查过几次,主要原因都是某些层的权重方差变得过大,前向传播时激活值爆炸,经过几个层之后梯度就变成NaN了。
解决办法是降低初始学习率。我遇到过用0.001学习率直接NaN,降到0.0001就正常的情况。另外建议在微调前先检查剪枝模型输出是否有异常:
# 检查剪枝模型输出是否正常 with torch.no_grad(): output = base_model(torch.randn(1, 3, 640, 640).cuda()) print(f"输出shape: {output.shape}") for i, out in enumerate(output): print(f"分支{i}: min={out.min():.4f}, max={out.max():.4f}, mean={out.mean():.4f}")如果某个分支输出出现nan或数值巨大,那大概率是剪枝时某个关键通道被误删,导致信息流断裂。这种模型不建议硬训,回到剪枝前调整忽略层重新剪一次,比在NaN的泥潭里挣扎高效得多。
5.3 剪枝后精度暴跌:超过10个点
如果你剪完微调后,精度不仅没回升,反而比刚剪完还低,那就要停下来看看是不是训练超参数出了问题。YOLOv8微调时有个坑,就是数据增强太强会适得其反。剪枝后的模型容量变小了,表达能力本身就下降,如果还用原始的增强策略,比如大规模Mosaic、MixUp,模型根本学不过来。
我的经验是微调时把增强力度降低,比如把hsv_h、hsv_s、degrees这些增强参数减半,或者直接关闭Mosaic增强的前几个epoch。另外,训练轮数不要太多,10到15轮就够,多了容易过拟合。损失函数权重也要留意,box loss、cls loss的权重在剪枝后可以适当调高一点,让模型更关注定位和分类的准确性。
还有一种情况是剪枝比例设置过高。YOLOv8s我建议不要超过45%,超过这个比例精度基本救不回来,除非你用蒸馏精细化微调。YOLOv8n超过30%就容易崩,因为它本身就没有太多冗余可以挤。
5.4 通道数限制问题:为什么某些层剪不动
训练中你可能会发现,不管怎么调剪枝比例,有些层的通道数始终不变。这个是正常的,torch_pruning内置了min_channels保护机制,防止你把某个卷积层剪到0个通道。但也有另一种情况,是模型本身的下采样倍数导致的约束:YOLOv8的backbone里,每个下采样点后的第一个卷积层通道数必须是特定的倍数关系,否则后续concat维度不匹配。
我遇到过一个比较隐蔽的问题:剪枝时指定了channels参数,列出了一组特定的通道数约束,比如要求所有层的通道数都是8的倍数。这样剪出来的模型在TensorRT上更好优化,但也会导致某些层原本可以剪到很小,因为约束只能剪到8、16、32,剪不动了。如果你的TensorRT部署对通道对齐有要求,这个约束是值得的;如果只是纯PyTorch推理,没必要加这个约束,剪得更彻底。
6. 从剪枝到部署的完整链路建议
6.1 剪枝与量化、蒸馏的组合顺序
很多人做完剪枝,还要接着做INT8量化部署。剪枝和量化叠加之后,精度损失是会放大的,因为剪枝已经破坏了部分权重分布,量化再把激活值和权重的精度砍到8bit,误差会累积。所以流程上有一个经验顺序:先蒸馏、再剪枝、再微调、最后量化。
蒸馏放最前面,相当于先让大模型把知识“浓缩”一遍给还没被裁剪的模型,让模型在保持高精度的同时权重分布更规范。剪枝之后微调,让新结构适应。最后做量化,此时模型的权重范围已经相对稳定,量化误差更可控。如果反着来先量化再剪枝,量化感知训练和剪枝两个过程互相干扰,调参能调到崩溃。
6.2 部署到RK3588平台的注意事项
最近这个方案很多人用在RK3588上,正点原子的RK3588开发板做YOLOv8部署是常见玩法。剪枝后的YOLOv8转RKNN时有几个特殊注意点。
ONNX导出时,一定要把模型的动态输入固定住。RKNN工具链不支持动态shape,如果导出的ONNX输入维度是[-1, 3, -1, -1],转RKNN会报错。建议导出时固定为640x640:
model.model.eval() dummy_input = torch.randn(1, 3, 640, 640).cuda() torch.onnx.export( model.model, dummy_input, "pruned.onnx", opset_version=12, input_names=["images"], output_names=["output0"], dynamic_axes=None, # 固定shape )另外,剪枝后的模型如果某个卷积层的通道数不是16的倍数,在RKNN的NPU加速上可能不友好。瑞芯微的NPU对通道对齐有要求,建议剪枝时使用round_to参数把通道数对齐到16或32的倍数,虽然会稍微牺牲一些压缩率,但NPU的实际推理速度会更好。
pruner = tp.pruner.MetaPruner( model=base_model, example_inputs=torch.randn(1, 3, 640, 640).to("cuda"), importance=importance, pruning_ratio=0.3, ignored_layers=ignored_layers, round_to=16, # 通道数对齐到16的倍数 )6.3 后续扩展:从YOLOv8到其他检测模型
这套方法不局限于YOLOv8。torch_pruning的依赖图机制是通用的,我后来在YOLOv5、YOLOv7、甚至RT-DETR上都试过类似流程,核心思路完全相通:解析依赖图、保护关键头部结构、按重要性剪枝、微调恢复。区别主要在于不同模型的跨层连接方式不一样,需要调整忽略层的范围。
RT-DETR这类Transformer结构的模型就更复杂一些,Attention模块的剪枝策略和CNN不太一样,不能简单用BN层gamma来评估通道重要性,可能要结合梯度信息或激活统计信息。不过思路是共通的:找到冗余、评估重要度、结构剪枝、微调。只要理解了原理,换什么样的模型都只是换层与层之间的依赖关系而已。
我自己跑完这一整套流程下来的感受是:模型剪枝不是一个“调一个参数就能变快”的魔法,而是需要结合模型结构、部署目标、精度要求做综合权衡的系统工程。每一步都很值得自己亲手跑一遍,把各个阶段的精度、速度、体积数据都记录下来。这种数据积累多了,你慢慢就能形成对不同任务、不同硬件的剪枝方案直觉,以后再接到优化需求,心里基本能估算出大概能剪多少、能快多少、精度会掉多少,这个能力比任何现成工具都值钱。
最后再分享一个小技巧:剪枝前一定先备份原始权重,剪枝模型如果微调效果不理想,随时可以回到原始权重重新调整剪枝参数再来。我有一次就是贪心想多剪点,结果模型怎么微调都起不来,最后回到备份重新剪了一遍才算救回来。模型优化这事儿,稳着来,比什么都重要。