简介:这份资源面向希望压缩 YOLOv8 模型体积、提升推理速度的算法工程师与深度学习学习者,聚焦模型剪枝这一模型轻量化关键环节。包内集成多种主流剪枝策略,包括 LAMP、Slimming、Group Slimming、Group Hessian、Taylor 以及 Regularization 剪枝等,代码支持一键运行,并配有 md 文档说明,便于对照理解各方法的实现差异与适用场景。资源共 20 个文件,以 9 个 py 脚本和 9 个 yaml 配置为主,前者承载剪枝与通道可视化等核心逻辑,后者提供多种骨干与颈部结构的模型配置,另含压缩包与说明文档,整体约 1.33MB,目录结构清晰。目前已有 2863 人学习下载,读者可借此快速搭建剪枝实验环境,对比不同策略的压缩效果,并在此基础上迁移到自有数据集与检测任务中,适合具备一定 PyTorch 与目标检测基础的中高级开发者参考实践。
1. 从一次 RK3588 部署翻车说起:yolov8 剪枝源码到底解决什么问题
去年帮一个做边缘盒子的团队把 yolov8s 往 RK3588 上搬,模型转 RKNN 之后推理一帧要 180ms,产线要求压到 60ms 以内。第一反应是换 yolov8n,精度掉了 4 个点,客户不认。后来走的路子是剪枝:拿训练好的 yolov8s 权重,按通道重要性砍掉一批卷积核,再微调 20 个 epoch,模型体积从 22MB 降到 9MB,RKNN 上单帧 55ms,mAP 只掉 1.2 个点。这套流程里最费时间的不是训练,而是找一份能直接跑、策略可切换、剪完还能正常导出 ONNX 的剪枝源码。
网上搜「yolov8 剪枝源码」,出来的东西大致三类:一类是论文复现的 demo,只剪 backbone 不剪 head,剪完模型结构对不上;一类是通用剪枝库套在 yolov8 上,通道索引和 Detect head 的 anchor 对不齐,导出直接报错;还有一类是「集成多种剪枝策略」的整合包,但依赖锁死、配置文件写死路径,换自己的数据集就崩。这篇笔记就按我自己落地过的方案,把 yolov8 剪枝源码里该有的东西拆开讲:结构化剪枝和非结构化剪枝怎么选、L1 与 BN scale 两种重要性评估怎么接、剪枝率怎么定、剪完怎么微调、导出 ONNX 和 RKNN 时哪些层不能碰。适合已经能跑通 yolov8 训练、想往边缘设备压模型的同学,也适合手里有剪枝源码但跑不通、想搞清每一步在干什么的人。
2. 剪枝策略选型:结构化与非结构化在 yolov8 上的真实差别
2.1 为什么 yolov8 落地几乎只选结构化剪枝
剪枝分两大类。非结构化剪枝(+非结构化剪枝 是热搜里常出现的词)是把权重矩阵里绝对值小的元素置零,掩码稀疏,理论压缩率高,但产出的是稀疏矩阵。问题在于:通用 GPU 和绝大多数边缘 NPU 对稀疏矩阵没有原生加速,你得靠稀疏推理库才能吃到收益。RK3588 的 NPU、Hi3516CV610 这类芯片,跑的是稠密卷积,稀疏权重进去还是按稠密算,等于白剪。所以做嵌入式部署,结构化剪枝是主力。
结构化剪枝(channel pruning / filter pruning)直接删掉整个卷积核或整个通道,输出特征图通道数真实减少,模型结构变窄,任何推理后端都能吃到加速。代价是精度掉得比非结构化明显,必须靠微调补回来。yolov8 的 backbone 是 C2f 结构,每个 C2f 里堆了多个 Bottleneck,通道之间有残差和 concat 连接,剪枝时如果只按单个卷积的重要性删,很容易把 concat 两侧通道数剪得不一致,模型直接跑不起来。这是 yolov8 剪枝源码里第一个必须处理的问题:依赖感知的通道分组。
常见做法是把有 concat、add 关系的层归到一个 group,group 内所有层的剪枝掩码必须一致,要么一起剪,要么一起留。源码里一般用一个channel_groups列表维护这种约束,剪枝前先做一次图分析,把 yolov8 的 neck 部分(尤其是 Detect head 前的三个 concat)标出来。
2.2 L1 范数与 BN scale:两种重要性评估怎么接进源码
结构化剪枝的核心是「判断哪个通道不重要」。主流两种:
| 策略 | 依据 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| L1-norm | 卷积核权重绝对值之和 | 实现简单,不依赖 BN | 对 BN 后的缩放不敏感 | 快速 baseline |
| BN scale | BatchNorm 的 gamma 系数 | 与训练耦合,精度更稳 | 必须有 BN 层 | 推荐默认 |
| 组合 | L1 × BN gamma | 兼顾两者 | 计算稍多 | 精度敏感场景 |
我一般默认用 BN scale,因为 yolov8 每个 Conv 后面都跟 BN,gamma 直接反映该通道对输出的贡献,剪掉 gamma 接近 0 的通道,精度损失最小。源码里对应的是一个 hook:在model.train()或model.eval()之后遍历所有nn.BatchNorm2d,取abs(bn.weight)作为该通道分数。
下面是一段可直接抄的最小实现,作用是把 yolov8 每个 BN 层的通道分数收集起来,并按依赖分组做全局排序:
import torch import torch.nn as nn from ultralytics import YOLO def collect_bn_scores(model): """遍历模型,收集每个 BN 层的通道重要性分数""" scores = {} for name, module in model.named_modules(): if isinstance(module, nn.BatchNorm2d): # gamma 绝对值即通道重要性,detach 避免影响计算图 scores[name] = module.weight.detach().abs().clone() return scores def build_prune_groups(model): """按 concat/add 依赖把通道分组,组内必须同剪同留""" groups = [] # 简化示意:实际需遍历 graph,识别 C2f 与 Detect 前的 concat # 这里把每个 C2f 内的 Bottleneck 归为一组 for name, module in model.named_modules(): if module.__class__.__name__ == 'C2f': groups.append([f"{name}.cv1", f"{name}.cv2", f"{name}.m"]) return groups if __name__ == "__main__": model = YOLO("yolov8s.pt").model model.eval() bn_scores = collect_bn_scores(model) groups = build_prune_groups(model) print(f"共 {len(bn_scores)} 个 BN 层,{len(groups)} 个剪枝组")逻辑说明:collect_bn_scores只取nn.BatchNorm2d的 weight,不取 bias,因为 gamma 才是缩放因子。build_prune_groups是简化版,真实源码里要用torch.fx或 ultralytics 自带的model.model图结构做拓扑分析,把 concat 两侧的 Conv 归到同一组。参数上,detach()必须加,否则后面做全局排序时会误建计算图,显存直接爆。分组数量决定了剪枝的粒度:组越粗,剪枝越保守,精度越稳但压缩率越低。
提示:如果你的源码里没有分组逻辑,剪枝后导出 ONNX 报
Concat维度不匹配,八成就是这里漏了。
3. 剪枝率怎么定:全局阈值、逐层阈值与敏感度分析
3.1 全局排序剪枝的最小可跑流程
定剪枝率是剪枝里最玄学的部分。拍脑袋设 0.5,backbone 可能没事,head 直接崩。我一般分三步:先做全局排序,再按层设上限,最后跑敏感度分析微调。
全局排序的做法:把所有 BN 层的 gamma 分数拼成一个大 tensor,排序后取分位数,低于阈值的通道标记为待剪。这样能保证整体剪枝率可控,而不是每层都剪一样多。下面这段代码在上一节基础上补全剪枝掩码生成:
import torch def global_prune_mask(bn_scores, prune_ratio=0.4): """全局排序,返回每层保留通道的索引""" all_scores = torch.cat([v.flatten() for v in bn_scores.values()]) # 取分位数作为阈值,prune_ratio=0.4 表示剪掉 40% 通道 threshold = torch.quantile(all_scores, prune_ratio) masks = {} for name, score in bn_scores.items(): keep_idx = torch.where(score > threshold)[0] # 至少保留 8 个通道,防止某层被剪空 if len(keep_idx) < 8: keep_idx = torch.topk(score, 8).indices.sort().values masks[name] = keep_idx return masks def apply_mask_to_conv(conv, bn, keep_idx): """按保留索引裁剪 Conv 和 BN 的权重""" idx = keep_idx.to(conv.weight.device) conv.weight.data = conv.weight.data[idx].clone() conv.out_channels = len(idx) if conv.bias is not None: conv.bias.data = conv.bias.data[idx].clone() bn.weight.data = bn.weight.data[idx].clone() bn.bias.data = bn.bias.data[idx].clone() bn.running_mean.data = bn.running_mean.data[idx].clone() bn.running_var.data = bn.running_var.data[idx].clone() bn.num_features = len(idx)逻辑说明:global_prune_mask用torch.quantile取全局分位数,prune_ratio=0.4意味着分数最低的 40% 通道被剪。keep_idx强制至少保留 8 通道,这是血泪经验——某些深层 BN 的 gamma 整体偏小,全局阈值一卡就全剪没了,模型直接输出 NaN。apply_mask_to_conv里 BN 的running_mean、running_var必须同步裁剪,否则推理时统计量对不上,精度崩得莫名其妙。
参数说明:prune_ratio是全局剪枝率,不是每层剪枝率。实际每层剪多少由分数分布决定,浅层通常剪得少,深层剪得多。min_channels=8是经验值,小模型可以设 4,大模型设 16。
3.2 逐层上限与敏感度分析:别让某一层被剪穿
全局排序有个坑:如果某一层 gamma 整体偏小,会被剪掉一大半,而这一层恰好是 Detect head 前的关键层,精度直接崩。所以源码里一般再加一层逐层上限,比如每层最多剪 60%。更稳的做法是敏感度分析:逐层单独剪 10%、20%、30%,看 mAP 掉多少,掉得多的层降低剪枝率。
| 层位置 | 建议最大剪枝率 | 理由 |
|---|---|---|
| backbone 浅层 | 30% | 纹理信息密集,剪多掉点快 |
| backbone 深层 | 50% | 语义信息冗余度高 |
| neck C2f | 40% | 有 concat 依赖,需分组约束 |
| Detect head | 20% | 直接决定输出,最敏感 |
敏感度分析不用跑完整训练,剪完直接 eval 一次验证集,看 mAP50 掉幅。掉超过 3 个点的层,把该层剪枝率砍半。这一步在源码里通常是一个sensitivity_analysis.py脚本,遍历每层跑一次前向,耗时大概十几分钟,比盲目剪完再微调省事得多。
注意:敏感度分析必须在
model.eval()下做,且要关掉 BN 的 running stats 更新,否则每跑一次统计量都在变,结果不可比。
4. 剪枝后微调:学习率、冻结策略与精度回补
4.1 微调不是重训:学习率与 epoch 的取值逻辑
剪枝完的模型精度一般掉 3 到 8 个点,必须微调回补。微调不是从头训,学习率要小,一般设原始训练的 1/10 到 1/20。yolov8 原始训练 lr0=0.01,微调我一般用 0.001 到 0.0005,cosine 衰减,epoch 20 到 50 足够。数据增强要关掉 Mosaic 和 MixUp,因为剪枝后模型容量变小,强增强反而拖慢收敛。
from ultralytics import YOLO # 加载剪枝后的模型结构(需先把剪枝后的 state_dict 存成 pt) model = YOLO("yolov8s_pruned.yaml").load("yolov8s_pruned.pt") model.train( data="coco128.yaml", epochs=30, imgsz=640, lr0=0.001, # 原始 1/10 lrf=0.01, # 最终 lr = lr0 * lrf warmup_epochs=0, # 微调不需要 warmup mosaic=0.0, # 关掉 Mosaic mixup=0.0, close_mosaic=0, optimizer="SGD", freeze=10, # 冻结前 10 层,先让 head 适应 )逻辑说明:freeze=10冻结 backbone 前 10 层,先让 neck 和 head 适应新的通道数,跑 5 个 epoch 后再解冻全部。mosaic=0.0是关键,剪枝后模型对拼接边界敏感,Mosaic 的随机裁剪会让精度震荡。warmup_epochs=0因为权重已经预训练过,不需要再 warmup。
参数说明:lr0太大(比如 0.01)会让剪枝后的通道权重被冲乱,精度先掉再涨,浪费 epoch。freeze层数按模型深度调,yolov8s 用 10,yolov8m 用 12。epochs不用多,30 个 epoch 后 mAP 基本稳定,再训收益很小。
4.2 精度回补的验证方法:别只看 mAP50
微调完不能只看 mAP50,要同时看 mAP50-95 和每类 AP。剪枝容易让某些小目标类掉点严重,因为浅层通道被剪后小目标特征丢失。验证时跑一次model.val(),把per_class结果导出来对比剪枝前。
metrics = model.val(data="coco128.yaml", split="val") print(metrics.box.map) # mAP50-95 print(metrics.box.map50) # mAP50 print(metrics.box.maps) # 每类 AP如果某类 AP 掉超过 5 个点,说明该类依赖的通道被剪多了,回去把对应层的剪枝率调低,重新剪再微调。这个迭代一般两三轮能收敛。RK3588 部署时还要注意,剪枝后的模型导出 ONNX 要用opset=12,RKNN 工具链对 opset 13 以上的某些算子支持不全,导出后转 RKNN 容易报Unsupported op。
5. 避坑与排查:剪枝源码跑不通的 5 个高频问题
5.1 导出 ONNX 报 Concat 维度不匹配
现象:剪枝后model.export(format="onnx")报Concat输入维度不一致,或者转 RKNN 时Add算子 shape 对不上。
原因:concat 两侧的 Conv 没有归到同一剪枝组,各自按自己的分数剪了不同数量的通道,concat 时维度对不上。yolov8 的 neck 有三个 concat,分别接 backbone 的 P3、P4、P5,最容易出问题。
解决:在剪枝前用torch.fx做一次图追踪,把所有 concat 的输入节点找出来,强制归到同一组,组内用统一的 keep_idx。源码里一般有一个fuse_concat_groups()函数,剪枝前先跑一遍。
5.2 剪枝后模型输出全 NaN
现象:剪枝完直接 eval,输出全是 NaN,loss 也是 NaN。
原因:某一层被剪空,或者 BN 的running_var被剪后出现 0,除零导致 NaN。全局阈值卡太狠时,深层 BN 的 gamma 整体偏小,容易被全剪。
解决:global_prune_mask里加min_channels保护,每层至少留 8 个通道。另外检查 BN 的running_var,如果有 0 值,加一个eps=1e-5兜底。剪枝后先跑一次前向,确认无 NaN 再进微调。
5.3 微调精度不升反降
现象:微调 30 个 epoch,mAP 比剪枝后还低。
原因:学习率太大,或者数据增强没关。剪枝后模型容量小,大学习率会把预训练权重冲乱,Mosaic 的随机拼接让模型学不到稳定特征。
解决:lr0降到 0.0005,关掉 Mosaic 和 MixUp,freeze前 10 层先跑 5 个 epoch。如果还不升,检查数据集标注是否有问题,剪枝会放大标注噪声的影响。
5.4 RKNN 转换报 Unsupported op
现象:ONNX 转 RKNN 时提示某个算子不支持,常见的是SiLU或Split。
原因:剪枝后某些 Conv 的通道数变成非 8 的倍数,RKNN 对通道对齐有要求。或者导出 opset 版本太高。
解决:剪枝时把每层保留通道数对齐到 8 的倍数,keep_idx取完后做一次len(idx) // 8 * 8。导出用opset=12。如果还报错,把SiLU换成ReLU再微调几个 epoch,精度掉得不多但兼容性好很多。
5.5 剪枝率设了但模型体积没变
现象:prune_ratio=0.5,剪完保存的 pt 文件大小几乎没变。
原因:只改了out_channels属性,没有真正重建模型结构。PyTorch 的state_dict里权重 tensor 还是原来的 shape,保存时按原 shape 存,体积自然不变。
解决:剪枝后必须用新的通道数重建模型(重新实例化YOLO(yaml)),再把裁剪后的权重 load 进去。或者用torch.nn.utils.prune的remove做永久裁剪。源码里一般有一个rebuild_model()步骤,不能省。
6. 进阶技巧:把剪枝率搜索做成自动化流水线
手动调剪枝率太慢,我后来把整个流程包成了一个搜索脚本:给定目标推理延迟(比如 RK3588 上 60ms),自动搜剪枝率。思路是二分搜索——先试 0.3,转 RKNN 测延迟,不够就加到 0.4,超了就降到 0.35,每轮微调 10 个 epoch 看 mAP 是否达标。一般 4 到 5 轮能收敛到目标。
import subprocess def search_prune_ratio(target_latency=60, min_map=0.35): lo, hi = 0.2, 0.6 best = None for _ in range(5): mid = (lo + hi) / 2 # 剪枝 + 微调 + 导出 + 测延迟,这里用子进程调各步骤脚本 subprocess.run(["python", "prune.py", "--ratio", str(mid)]) subprocess.run(["python", "finetune.py", "--epochs", "10"]) latency = measure_latency("yolov8s_pruned.rknn") mAP = evaluate_map("yolov8s_pruned.pt") if latency <= target_latency and mAP >= min_map: best = mid lo = mid # 还能再剪 else: hi = mid # 剪过头了 return best逻辑说明:二分搜索的边界lo=0.2、hi=0.6是经验范围,低于 0.2 压缩收益不明显,高于 0.6 精度基本救不回来。每轮微调只跑 10 个 epoch 是为了快速筛,最终选中的剪枝率再跑完整 30 epoch。measure_latency要在真实板子上测,PC 上模拟的延迟和 RK3588 差很多。
参数说明:target_latency按产线要求设,min_map按客户能接受的精度下限设。搜索轮数 5 轮足够,再多边际收益很低。这套流水线跑一轮大概 2 小时,比手动试快得多。
最后说个习惯:我每次剪枝前都会把原始模型的 mAP、每类 AP、推理延迟存一份 baseline,剪枝后逐项对比,掉点超过阈值的类单独回去查是哪层剪多了。剪枝这事没有后悔药,剪错了只能重训,所以敏感度分析和分组约束这两步千万别省。希望帮到你。
本文还有配套的精品资源,点击获取