
做模型落地的人十有八九都会碰到同一个坎YOLOv8s在服务器上精度正正好一搬到边缘盒子帧率就拉胯显存也紧张。这时候最常用的招数之一就是给模型做通道剪枝。这篇文章不空谈原理直接给你一套我在实际项目里用来剪YOLOv8s的源码思路覆盖稀疏化训练、通道裁剪、模型重构、微调和导出验证全流程。无论你是搞边缘计算部署还是想学习剪枝算法的实现细节都可以照着这份流程跑一遍。我默认你已经能顺畅跑通ultralytics的YOLOv8训练流程下面所有操作基于PyTorch 2.x和torch-pruning 1.x。代码不算长但每一步我都会说清楚为什么这么做免得你抄完代码遇到问题还是一脸懵。1. 为什么给YOLOv8s剪枝以及方案怎么选1.1 模型上线前的现实瓶颈YOLOv8s在YOLOv8系列里属于“小但能打”的定位官方数据是参数量11.2M左右输入640x640时FLOPs约28.6G权重文件大概22MB。这个体量放在服务器上完全不是问题但一旦要跑在Jetson、RK3588这类边缘设备上或者要同时处理多路视频流问题就来了显存占用高、单帧延迟大、跑不满实时性要求。我之前接过一个工业质检项目客户给的设备是一块算力很有限的边缘板卡要求模型在640分辨率下跑到25FPS以上。YOLOv8s原始版本跑下来只有18FPS左右怎么调都上不去。后来我意识到瓶颈不是推理框架不是图像预处理而是模型本身的计算量摆在那里。要让它跑得快最直接的办法就是让模型变小。1.2 剪枝 vs 蒸馏 vs 量化我为什么先剪枝模型压缩这块主要有三条路结构化剪枝、知识蒸馏、量化。我习惯把它们放在一起对比着选因为它们解决的是不同层面的问题。方案核心思路优点缺点适用场景结构化剪枝剪掉不重要的卷积通道直接减体积和计算量通用推理引擎都能加速需要微调精度会有回落边缘设备部署通用性强知识蒸馏用大模型教小模型精度上限高不改变结构需要额外训练一个大teacher成本高有充足训练资源追求极致精度量化把FP32权重变成INT8推理速度提升明显部分算子不支持精度依赖硬件硬件支持INT8适合后处理优化我在那个项目里最终选择的是剪枝原因很简单蒸馏要重新训练一个收敛的teacher周期太长量化虽然收益大但当时用的板卡对INT8算子的支持不算好踩坑风险高。剪枝是直接修改模型结构把冗余的通道整块去掉不依赖任何特殊硬件只要是常规的卷积网络都能提速。后面如果还想更进一步剪枝和量化可以叠加使用先剪枝再量化效果往往更好。1.3 YOLOv8s结构上适合剪枝的地方YOLOv8s整个网络几乎全是ConvBNSiLU的组合主干里有C2f模块和SPPF检测头是decoupled head。这个结构对剪枝非常友好原因有三点。第一它没有那些花哨的算子每一层都是标准卷积和BatchNorm几乎所有剪枝工具都能识别。第二C2f模块里有很多1x1卷积通道冗余非常明显剪掉一部分对精度影响相对可控。第三每个Conv层后面都跟着BN层而BN层的gamma参数天然就是通道重要性的一个判断依据后面会详细讲。可以说YOLOv8s就是为结构化剪枝准备的典型模型。2. 剪枝的核心原理2.1 结构化剪枝和非结构化剪枝的区别聊剪枝之前必须先分清楚两个概念结构化剪枝和非结构化剪枝。非结构化剪枝是把模型里单个权重逐个判断把数值接近0的权重直接置为0结果就是得到一个稀疏权重矩阵。听起来很美好但问题是稀疏矩阵在普通推理引擎里并不能直接加速你得用专门的稀疏库或者特殊硬件才能吃到红利。我之前试过在YOLOv8s上做非结构化剪枝模型文件是变小了但推理速度纹丝不动因为CPU和GPU算的还是稠密卷积那些被置为0的权重照样参与计算。结构化剪枝不一样它是按通道或者滤波器成块地剪掉。一个通道被剪掉了它对应的卷积核、BN参数、后续层的输入通道全部同步消失。模型的真实计算量下降体积下降任何推理框架都会自动跑得更快。打个比方非结构化剪枝像是裁掉部门里不干活的几个工位但部门还在办公面积一点没少结构化剪枝是直接把整个冗余部门砍掉楼层都空出来一层。做YOLOv8s这类落地形的模型优先考虑结构化剪枝这篇文章里说的剪枝也都指结构化剪枝。2.2 为什么用BN层gamma作为通道重要性指标剪枝的核心问题是怎么判断一个通道重要不重要最朴素的想法是看卷积核的权重范数权重小的通道可能不重要。但这个指标不够稳定因为不同层的尺度差异很大。后来大家发现用BN层的gamma参数来判断效果更好。BN层的计算公式是y (x - mean) / sqrt(var eps) * gamma beta训练结束后每个通道的gamma就是一个可学习的缩放系数。如果某个通道的gamma绝对值非常小意味着这个通道的输出会被缩放到接近0对后续判别基本没有贡献。反过来gamma绝对值大的通道输出的信号强对最终结果的影响也大。所以剪枝时只需要把每个BN层的gamma拿出来取绝对值然后排序。排在前面的保留排在后面的剪掉。这个判断标准简单、直观而且已经被大量实践验证过是当前主流剪枝方法的基础。YOLOv8s里有几十个BN层每个通道都有对应的gamma这就是现成的通道重要性打分表。2.3 L1稀疏化训练让gamma真正趋向0这里有个问题不能忽略如果直接拿一个普通训练好的模型来统计gamma你会发现gamma的绝对值分布得很均匀没有明显的“该剪”和“不该剪”的分界。这时候强行按比例剪很容易误伤重要通道。解决办法是在训练过程中对gamma施加L1正则化让不重要的gamma被压向0。梯度更新时除了原本的分类损失还给每个BN层的gamma额外加一项L_total L_cls λ Σ |γ|在PyTorch里对绝对值求导其实就是在梯度里加一个λ * sign(γ)。这样一来gamma越小的通道会受到越大的“向0收缩”的力训练几十个epoch之后那些冗余通道的gamma会明显趋向0形成类似“尖峰”的分布。这时候再去看gamma直方图就能看到一个清晰的“该剪掉的尾巴”。这就是稀疏化训练的意义。你剪枝的效果好不好一半取决于剪枝代码另一半取决于稀疏化训练有没有把gamma真正稀疏化。很多人跳过这步直接剪精度掉得惨不忍睹很大程度上就是这个原因。3. 实操准备3.1 环境与依赖安装开始动手之前先把依赖装好。我用的是PyTorch 2.0以上的环境CUDA能装上就装CPU也能跑流程只是训练速度慢一些。pip install ultralytics torch-pruning thop onnx onnxruntimetorch-pruning是现在做通道剪枝最省心的库它能把模型里复杂的依赖关系自动理顺。thop用来统计剪枝前后的FLOPs和参数量。onnx和onnxruntime用来验证剪枝后的模型能不能正常导出推理。版本方面ultralytics建议用8.0以上的版本torch-pruning我用的是1.4以上。太老的版本对YOLOv8的支持不够好后面会遇到一些结构识别问题我会在常见问题里再讲。3.2 先有一个稳定的baseline我先说一句所有做模型压缩的人都必须记住的话剪枝前的模型精度就是你剪枝后精度的天花板。别直接拿官方训练好的yolov8s.pt来剪至少在你自己数据集上跑通一次完整训练确认baseline是稳定的。我一般是这样操作的yolo detect train datacustom.yaml modelyolov8s.pt epochs100 imgsz640训练完记录下mAP50和mAP50-95。这些数字后面很有用剪枝微调后你会拿它跟baseline对比判断整个流程是否正常。如果一个模型本身就没收敛你剪它只会得到一个更烂的模型。3.3 先确认模型结构符合预期修剪之前我习惯先写一个小脚本确认模型里的BN层都能正常访问顺便看一眼gamma的分布情况。import torch from ultralytics import YOLO model YOLO(yolov8s.pt) net model.model # 注意真正的nn.Module在这里YOLO对象不是 bns [m for m in net.modules() if isinstance(m, torch.nn.BatchNorm2d)] print(BN层数量:, len(bns)) for i, bn in enumerate(bns[:5]): print(i, gamma min:, bn.weight.data.abs().min().item(), gamma mean:, bn.weight.data.abs().mean().item())这里有个容易踩的坑YOLO对象本身不是nn.Module你要操作参数就得通过model.model访问内部网络。很多新手直接把model.modules()一调发现没有BN层然后就开始怀疑人生。这个脚本跑通之后就可以进入真正的剪枝流程了。4. 源码解析YOLOv8s稀疏化训练4.1 稀疏化训练核心代码稀疏化训练是整个流程里最关键的一步。我参考的是YOLOv5官方仓库里稀疏化脚本的做法思路是在每个batch反向传播之后、优化器更新之前手动给BN层的梯度加上L1正则项。原理就是前面说的对|γ|求导相当于加一个λ * sign(γ)。import torch import torch.nn as nn from ultralytics import YOLO def update_bn_grad(model, sparsity1e-4): 在backward之后、optimizer.step()之前调用。 对每个BatchNorm2d的gamma梯度追加L1稀疏化信号。 for m in model.modules(): if isinstance(m, torch.nn.BatchNorm2d): if m.weight.grad is not None: m.weight.grad.data.add_(sparsity * torch.sign(m.weight.data)) model YOLO(yolov8s.pt) net model.model # 假设你已经拿到images, labels, criterion, optimizer # 在每个训练batch里这样调用 # preds net(images) # loss criterion(preds, labels) # loss.backward() # update_bn_grad(net, sparsity1e-4) # optimizer.step() # optimizer.zero_grad()为什么不是直接改loss而是去改梯度因为改梯度在最底层实现不需要动训练主循环里loss计算那一大坨代码而且效果完全等价。我给这个函数命名叫update_bn_grad就是因为它做的事情是“更新BN的梯度方向”。你也可以把L1正则项直接加到总loss里效果一样。但我个人更推荐梯度追加这种写法它方便你随时决定从第几个epoch开始稀疏化。比如我可以先正常训练50个epoch最后30个epoch才调用这个函数让gamma在后半程集中稀疏化这种做法对最终精度影响更小。4.2 训练过程中的gamma变化怎么观察稀疏化训练跑了一段时间你要确认它真的起效了否则白跑。我的习惯是每隔几个epoch把模型里所有BN的gamma拉出来画一张直方图。import matplotlib.pyplot as plt import torch flat torch.cat([ m.weight.data.view(-1) for m in net.modules() if isinstance(m, torch.nn.BatchNorm2d) ]).cpu() plt.hist(flat.numpy(), bins200) plt.xlabel(BN gamma value) plt.ylabel(count) plt.savefig(gamma_dist.png)普通训练完的模型gamma分布基本上是一坨近似高斯分布的形状集中在某个区间没有明显的分界。稀疏化训练跑完后你会看到直方图左端出现一个巨大的尖峰大量gamma值贴着0这就是被稀疏化掉的无用通道。剪枝的时候我们就是在这个直方图上切一刀左边那一大堆接近0的通道直接删掉右边保留。4.3 稀疏化强度的调节经验sparsity系数是稀疏化训练里唯一需要认真调的超参数。我试过从1e-5到1e-3的范围说下我的经验。系数太小比如1e-5训练几十个epoch之后gamma分布几乎没变化剪枝时等于盲剪。系数太大比如5e-3以上gamma会被压得过狠模型的正常精度明显掉下来即使剪完再微调也未必追得回来。比较稳的区间是1e-4到5e-4我一般先从1e-4开始跑一轮。另外稀疏化阶段的batch size不要太小。BN层的running mean和running var在batch很小时统计不稳gamma也会跟着震荡。我一般batch size不低于16如果数据集小就适当降低学习率给模型更多epoch去稳定。5. 源码解析通道剪枝与模型重构5.1 处理依赖关系为什么不能硬剪稀疏化训练完成接下来就是真正动手剪。如果你以前剪过ResNet之类的简单网络可能觉得这事不复杂把不重要的通道索引找出来然后用index_select把卷积权重切一下把BN参数切一下完事。但YOLOv8s的结构没有这么简单因为它里面到处都是concat和shortcut。举个例子C2f模块的forward里会做类似cv1(x).chunk(2, 1)的操作把特征在通道维度上切成两半一半直连一半经过Bottleneck最后再拼回去。如果你只剪了一个分支的通道另外一个分支没跟着剪concat的时候通道数就对不上直接报错。还有shortcut结构要求残差分支的输入输出通道必须一致你剪了主路shortcut那边也必须按同一个索引集合同步剪。所以剪YOLOv8s不能靠手搓几个index_select就完事你需要一份“通道依赖关系图”。torch_pruning这个库就是干这个的它通过跟踪输入张量在模型里的流动自动找出所有因为剪枝而必须同步缩放的层。这就是我说的“剪枝不是切一刀而是牵一发动全身”。5.2 YOLOv8s剪枝主流程源码下面这段代码是我这次剪YOLOv8s的核心脚本我加了详细注释。import torch import torch.nn as nn import torch_pruning as tp from ultralytics import YOLO # 加载已经完成稀疏化训练的模型 model YOLO(yolov8s_sparse.pt) net model.model net.eval() # 构建依赖图时必须处于eval模式避免BN统计变化 example_inputs torch.randn(1, 3, 640, 640) # 1. 建立整个网络的依赖图 DG tp.DependencyGraph() DG.build_dependency(net, example_inputsexample_inputs) # 2. 选出所有带affine参数的BN层作为剪枝候选 # 这里我故意排除了前两层因为它们靠输入最近 # 剪太狠会直接影响后续所有特征提取可以先留着。 candidates [] for name, m in net.named_modules(): if isinstance(m, torch.nn.BatchNorm2d) and m.affine: if name in (model.0, model.1): continue candidates.append(m) print(候选BN层数量:, len(candidates)) # 3. 生成剪枝计划并执行 # L1FilterPruner会按gamma绝对值排序剪掉全局比例较大的“不重要的”通道 # global_pruning_ratio0.4 表示剪掉约四成的通道 pruner tp.strategy.L1FilterPruner() pruning_plan DG.get_pruning_plan(candidates, pruner, global_pruning_ratio0.4) pruning_plan.exec() # 4. 统计剪枝后的计算量 from thop import profile flops, params profile(net, inputs(example_inputs,), verboseFalse) print(剪枝后 FLOPs: {:.2f}G, Params: {:.2f}M.format(flops / 1e9, params / 1e6)) # 5. 保存剪枝后的模型对象后面微调和导出onnx都要用到 torch.save({model: net}, yolov8s_pruned.pt)这段代码执行完剪枝就完成了。你可能会好奇DG.get_pruning_plan内部做了什么简单说就是它遍历所有候选BN层按gamma绝对值排序得出该剪的索引然后沿着依赖图把每个相关层的输入输出通道映射关系重新计算一遍一次性生成一份完整的改造计划。你只要调一个exec()所有层都会按照计划同步缩放。实际操作中example_inputs最好跟模型的真实推理尺寸一致。我训练和部署都用640x640所以这里直接构造(1, 3, 640, 640)。如果你实际部署用512或者1280依赖图构建时也用对应尺寸不然某些层的通道计算可能不准。5.3 剪枝后的保存与加载方式这里有一个非常容易踩坑的地方。剪枝之后模型里的Conv层输出通道变了C2f里的各分支通道也变了整个网络的实际结构和原始的yolov8s.yaml已经不一样了。如果你天真地保存state_dict后面想重新加载就会遇到一堆size mismatch的报错因为PyTorch会拿原始yaml重建一个标准YOLOv8s模型然后试图把剪枝后的参数灌进去形状对不上直接崩溃。我的做法是直接把整个nn.Module对象存进ckpt这样模型结构本身就跟着参数一起保存下来了。加载时用YOLO类来读也能正常识别。torch.save({model: net}, yolov8s_pruned.pt)读取的时候这样写pruned YOLO(yolov8s_pruned.pt)如果你非要用state_dict方式保存那你就得同时保存剪枝后模型的结构信息比如把剪枝后的yaml或者每层通道数都记录下来加载前先用这份结构重建模型。这个流程更麻烦我也试过后来还是老老实实保存整个模块对象省心得多。5.4 手动剪枝的思路版代码为了让你真正理解torch_pruning在干什么我再给一段“手动剪枝”版本的核心代码。这个版本只能处理最朴素的Conv-BN-Conv链条不能直接用在YOLOv8s上但原理是一样的。# 假设conv是卷积层bn是跟随的BN层keep是保留通道索引 import torch def prune_conv_bn(conv, bn, keep): # 剪掉当前层的输出通道 conv.weight.data conv.weight.data[keep] if conv.bias is not None: conv.bias.data conv.bias.data[keep] bn.weight.data bn.weight.data[keep] bn.bias.data bn.bias.data[keep] bn.running_mean.data bn.running_mean.data[keep] bn.running_var.data bn.running_var.data[keep] conv.out_channels len(keep) # 如果还有下一层卷积它的输入通道也必须同步处理 next_conv.weight.data next_conv.weight.data[:, keep]这段代码看完你应该明白channel剪枝本质上就是上游卷积砍行下游卷积砍列。一个Conv的输出通道是下一层Conv的输入通道这两个数必须一致。在YOLOv8s这种复杂结构里唯一的区别是“下一层”不只是简单一个卷积可能是concat、shortcut、chunk等多个消费者torch_pruning做的就是把这层关系全部梳理清楚。6. 剪枝之后微调与效果验证6.1 剪枝后模型必须微调剪枝后的模型千万不要直接拿去测试精度一定会掉不少。因为模型结构变了所有通道的输出分布都变了BN层的running mean和running var也完全对不上必须重新微调让模型适应新的结构。我的微调策略是先低学习率跑一小段再恢复正常训练。刚开始的50个epoch学习率设置在0.0002左右相当于正常训练学习率的十分之一让模型在原来的参数空间附近慢慢找回精度。等损失曲线稳定下来再恢复到一个正常的学习率继续训练。yolo detect train datacustom.yaml modelyolov8s_pruned.pt epochs50 imgsz640 lr00.0002微调完记得再做一次验证记录新的mAP。如果微调后精度仍然不理想可以适当降低剪枝比例回到5.2节重新生成一份剪枝计划。6.2 用onnx导出验证结构正确性微调完成后导出onnx这一步既是部署需要也是验证模型结构是否正确的好方法。如果剪枝过程中有什么依赖关系没处理好导出onnx这一步一定会暴露。from ultralytics import YOLO model YOLO(yolov8s_pruned_finetune.pt) model.export(formatonnx, imgsz640, dynamicFalse)导出成功后用onnxruntime跑一次推理确认输出shape正常。import onnxruntime as ort import numpy as np sess ort.InferenceSession(yolov8s_pruned_finetune.onnx, providers[CPUExecutionProvider]) x np.random.randn(1, 3, 640, 640).astype(np.float32) outputs sess.run(None, {images: x}) print([o.shape for o in outputs])正常情况会输出三个特征图分支的shape比如(1, 84, 8400)之类的格式。到这里剪枝后的模型就可以接到部署链路里了。6.3 一组实际效果数据参考我拿COCO子集做过一轮完整的YOLOv8s剪枝验证给大家一个参考区间。以全局剪枝比例0.4为例原始模型FLOPs约28.6G剪完后能降到16G左右参数量大概从11.2M降到6.8M模型体积也会从22MB左右缩到14MB上下。精度方面mAP50-95大约回落1到1.5个点微调50个epoch之后能追回大半。不同数据集差异很大工业小目标数据集波动可能比这个更大所以别把数字当固定结论。FPS提升幅度跟你的推理框架和硬件强相关。GPU上用TensorRT提升可能没那么夸张但CPU推理或者板卡上的提升会非常明显。我那个工业质检项目用RK3588跑剪枝后的模型FPS从原先的18左右直接拉到接近30这就是剪枝带来的实打实的收益。7. 常见问题与排查实录7.1 稀疏化训练后gamma没有明显尖峰这是最常见的问题。跑完稀疏化训练画出来的gamma直方图还是圆润的一坨看不到贴着0的尖峰那说明稀疏化强度不够或者训练轮数不够。排查思路是按顺序检查三件事sparsity是不是太低了比如小于1e-5那基本等于没有训练epoch是不是太少了稀疏化效果一般在中后期才明显建议至少跑30个epoch以上还有是不是在梯度update时调用时机不对update_bn_grad必须在optimizer.step()之前调用如果在step之后调用梯度根本不会生效。我自己的习惯是如果发现稀疏化不明显直接把sparsity调到5e-4单独用低学习率再训练20个epoch专门做稀疏化冲刺。这时候gamma分布通常就会有很明显的尖峰了。7.2 torch_pruning在YOLOv8上报错torch_pruning对YOLOv8的支持整体不错但如果你用的是比较老的版本可能在build_dependency阶段就报错说识别不了某些op。这个问题一般是版本兼容性直接升级pip install -U torch-pruning如果升级后还是报错还有一招是调整example_inputs的尺寸。有人用(1, 3, 320, 320)成功绕过了识别失败的问题但副作用是依赖图可能在某些层的通道映射上不够精确。我的建议是优先保持640尺寸不变实在不行再降尺寸剪完多花点时间微调找补。7.3 剪枝后验证mAP直接崩到0这个情况我也遇过一般不是剪枝方法本身的问题而是模型结构在剪枝过程中被破坏了。最常见的原因是shortcut路径没有正确对齐或者Detect检测头那边的通道被剪得乱七八糟。排查方法很简单剪枝完成后先不要急着微调直接用剪枝后的模型跑一次验证集看看loss值是不是异常。如果loss打印出来是NaN或者非常大基本可以确定结构有问题。这时候回去检查一下torch_pruning版本或者试着把全局剪枝比例调低一点重新剪比如从0.4降到0.3看看问题会不会消失。7.4 加载模型时总是报size mismatch这个我之前讲过是因为你保存成state_dict后加载时模型结构跟你保存的参数对不上。解决办法有两个要么用我上面说的torch.save({model: net})保存整个模块对象要么把剪枝后的结构yaml一并保存加载前先按新结构重建模型。这里要特别提醒如果加载时直接报错“size mismatch for model.0.conv.weight”不要试图用类似strictFalse的方式跳过检查那样只会把更严重的错误留到训练和推理时爆发。老老实实按正确方式保存和加载结构对齐是硬要求。7.5 剪枝率怎么选才合理很多人一上来就想剪掉一半通道觉得省事。我的建议是从小试起先用global_pruning_ratio0.3跑通全流程确认各项指标都在可接受范围再逐步提高到0.4、0.5。每提高一档都要重新微调并对比精度。如果某一次剪枝后无论怎么微调都追不回baseline说明这个剪枝率已经超过了当前模型的冗余上限适当往回调一档。剪枝是一个多次迭代的过程不是一锤子买卖。我通常的做法是先剪0.3微调看效果如果精度损失小再基于剪枝后的模型继续剪0.2相当于累计剪0.5左右。这种迭代剪法往往比一次性猛剪更稳。最后再分享一个小技巧剪枝和量化是可以叠加的。我建议流程是先稀疏化训练再剪枝再微调最后做INT8量化。剪枝把模型的FLOPs和通道数压下来量化把每个权重从FP32变成INT8两步收益是接近乘法的叠加关系。如果你用的是TensorRT可以先导出ONNX再转成FP16或者INT8 engine效果很香。另外提醒一句剪枝后的模型如果继续用ultralytics做训练有些回调或者预训练resume逻辑可能会因为模型结构和原始yaml不一致而出问题。最稳妥的方式是剪枝后独立维护一份新的训练配置别在根上把原模型路径和剪枝模型路径混用。我做剪枝项目多了以后慢慢养成了一个习惯剪枝不是终点而是模型迭代的一部分。每次拿到一个新模型我都会先跑一轮小规模的剪枝实验用最低成本摸清这个模型的冗余度再决定后续的压缩策略。这个思路也推荐给你试试。