第2章:模型轻量化基础——参数量与计算量(FLOPs)概念、模型剪枝(Pruning)原理、通道剪枝与结构化剪枝实战
做端侧部署,第一个要面对的问题就是:模型太大,跑不动。
我刚开始接触YOLO部署时,拿着一个YOLOv5s模型就往开发板上塞。结果呢?推理一帧要800多毫秒,卡得跟幻灯片似的。后来我才明白——不搞轻量化,端侧部署就是纸上谈兵。
这一章,咱们把模型轻量化的两个核心指标讲透,再上手剪枝。你想想看,一个模型几百万参数,真正有用的可能不到一半。剪掉冗余,速度就上来了。
2.1 参数量与FLOPs——衡量模型的两个硬指标
先说参数量。说白了,就是模型里所有可学习参数的总数。卷积层的权重、偏置,全连接层的矩阵,都算。
计算公式其实不复杂:
参数量 = (输入通道 × 输出通道 × 卷积核高 × 卷积核宽) + 输出通道(偏置)
举个例子,一个3×3卷积,输入64通道,输出128通道:
参数量 = 64 × 128 × 3 × 3 + 128 = 73,856
嗯,七万多个参数。一个YOLOv5s模型,参数量大概在700万左右。这个数字直接决定了模型占多大存储空间。700万个float32参数,算下来就是:
7,000,000 × 4字节 ≈ 28 MB
28MB,听起来不大?放到只有几十MB Flash的MCU上,你就知道什么叫捉襟见肘了。
再说FLOPs——浮点运算次数。它衡量的是模型推理一次需要做多少次乘加运算。这个指标直接跟推理速度挂钩。
卷积层的FLOPs计算公式:
FLOPs = (2 × 输入通道 × 卷积核高 × 卷积核宽 × 输出通道) × 输出特征图高 × 输出特征图宽
注意那个2,代表一次乘法和一次加法。我见过不少新手只算乘法,漏了加法,结果FLOPs少算一半。
关键区别:参数量决定模型能塞进多大的存储空间,FLOPs决定模型能跑多快。两个指标都要看,缺一不可。
我在项目中遇到过一件事:一个模型参数量剪掉了一半,但推理速度只提升了20%。为什么?因为FLOPs没降下来。剪枝剪在了参数量大但计算量小的层上,白费功夫。
2.2 模型剪枝原理——为什么要剪?剪什么?
模型剪枝,说白了就是把不重要的连接或通道砍掉。神经网络训练完后,很多权重值接近0,这些权重对最终结果贡献极小。砍掉它们,模型照样能工作。
剪枝的核心逻辑就三步:
- 评估重要性:判断哪些参数是冗余的
- 剪掉冗余:把不重要的参数置零或直接移除
- 微调恢复:剪完后重新训练,让模型找回精度
为什么要微调?我刚开始做剪枝时,剪完直接部署,结果mAP从0.85掉到0.3。后来才明白——剪枝相当于给模型做了个手术,术后需要康复训练。
剪枝按粒度分,主要有两种:
| 剪枝类型 | 粒度 | 优点 | 缺点 |
|---|---|---|---|
| 非结构化剪枝 | 单个权重 | 压缩率高,精度损失小 | 硬件加速难,稀疏矩阵效率低 |
| 结构化剪枝 | 通道/卷积核 | 直接减少计算量,硬件友好 | 压缩率相对低,精度损失大 |
避坑指南:我曾经在项目里只做非结构化剪枝,剪完模型文件小了,但推理速度一点没变。因为NPU不支持稀疏矩阵加速。端侧部署,结构化剪枝才是王道。
2.3 通道剪枝实战——以YOLOv5s为例
通道剪枝,是结构化剪枝里最常用的一种。它直接剪掉卷积层的整个通道,效果立竿见影。
怎么做?核心思路是:用BN层的缩放因子γ来判断通道重要性。
BN层的计算公式里有个γ参数,每个通道对应一个γ。训练时,γ值越大,说明这个通道对后续层贡献越大。γ接近0的通道,基本就是冗余的。
具体流程:
- 稀疏化训练:在损失函数里加一个L1正则项,让γ尽量往0靠
- 设定剪枝率:比如剪掉50%的通道
- 剪掉低γ通道:把γ值排序,砍掉最小的那一批
- 微调:用原始数据重新训练几个epoch
代码实现的核心部分:
# 稀疏化训练时,在损失函数中加入γ的L1正则
def sparsity_loss(model, s=0.0001):
reg_loss = 0
for module in model.modules():
if isinstance(module, nn.BatchNorm2d):
reg_loss += torch.norm(module.weight, 1) # L1范数
return s * reg_loss
# 剪枝:根据γ阈值移除通道
def prune_channels(model, prune_ratio=0.5):
gamma_list = []
for module in model.modules():
if isinstance(module, nn.BatchNorm2d):
gamma_list.extend(module.weight.data.cpu().numpy())
# 计算阈值
threshold = np.percentile(gamma_list, prune_ratio * 100)
# 对每个BN层,保留γ大于阈值的通道
for module in model.modules():
if isinstance(module, nn.BatchNorm2d):
mask = module.weight.data.abs() > threshold
# 实际剪枝操作需要重建卷积层
# 这里省略了具体重建逻辑
return model
实战经验:剪枝率不是越高越好。我试过剪掉70%的通道,精度直接崩了。建议从30%开始试,每次增加10%,观察精度变化。YOLOv5s一般剪到40%-50%还能保持95%以上的原始精度。
2.4 结构化剪枝的完整流程
通道剪枝只是结构化剪枝的一种。完整的结构化剪枝还包括卷积核剪枝和层剪枝。
我画了一张流程图,帮你理清关系:
三种剪枝方式各有适用场景:
- 通道剪枝:最常用,直接减少特征图数量,计算量下降明显
- 卷积核剪枝:针对单个卷积核,粒度更细,但实现复杂
- 层剪枝:直接移除整个层,适合冗余度高的深层网络
我个人的习惯是:先通道剪枝,再层剪枝。通道剪枝能砍掉大部分冗余,层剪枝再处理那些确实没用的残差块。卷积核剪枝我一般不用,因为对硬件加速不友好。
2.5 剪枝后的效果评估
剪枝不是剪完就完事了。你得验证两个东西:精度和速度。
精度验证很简单,跑一遍验证集,看mAP。速度验证就得真刀真枪上板子测了。我见过有人只在PC上测FLOPs,觉得降了50%就万事大吉。结果上板子一跑,速度只快了20%。为什么?因为FLOPs是理论值,实际推理还受内存带宽、算子优化程度影响。
我的建议:剪枝后一定要在目标硬件上做benchmark。PC上的FLOPs降低比例,跟实际加速比例,往往不是线性关系。
给你一个参考数据,我之前在RK3588上做的YOLOv5s剪枝测试:
| 剪枝率 | 参数量 | FLOPs | mAP@0.5 | 推理速度(ms) |
|---|---|---|---|---|
| 0%(原始) | 7.2M | 16.5G | 0.837 | 45 |
| 30% | 5.0M | 11.6G | 0.831 | 33 |
| 50% | 3.6M | 8.3G | 0.812 | 25 |
| 70% | 2.2M | 5.0G | 0.745 | 18 |
看到没?剪到50%时,mAP只掉了2.5个点,但速度提升了将近一倍。这就是剪枝的价值。
曾经踩过的坑:我试过剪完枝后不做微调,直接部署。结果检测框全飘了。记住,剪枝后的微调不是可选项,是必选项。至少微调50个epoch,学习率用原始训练的1/10。
好了,这一章的内容就到这。参数量和FLOPs这两个概念,你得刻在脑子里。剪枝的原理和实战,我建议你拿YOLOv5s跑一遍,剪个30%试试手。只有亲手做过,才能真正理解。