项目初期用YOLOv5x做检测,精度不错(mAP 84.2%),但延迟太高(38ms),帧率只有26fps,不满足产品要求的30fps。单独做量化优化(int8 PTQ)虽然延迟降到了24ms,但精度掉到81.5%。后来尝试把模型剪枝和量化结合起来,先剪枝减少模型参数量,再量化降低精度损失,最终延迟干到18ms(55fps),精度82.7%,只比原始浮点掉1.5%。这篇把联合优化的完整流程和踩坑经验记下来。
一、为什么单独量化不够?
YOLOv5x的参数量有86M,在征程6M上跑起来延迟38ms。我做过的优化尝试:
优化手段 延迟 mAP 问题
原始浮点 38ms 84.2% 基准
int8 PTQ 24ms 81.5% 精度掉2.7%,不够
int8 PTQ + mix 26ms 82.1% 精度还是不够
换YOLOv5s 12ms 77.2% 精度掉7%,差距太大
剪枝+int8 PTQ 18ms 82.7% 最优
单独量化只能解决"跑得慢"的问题,解决不了"精度掉太多"的问题。因为YOLOv5x本身参数量太大,量化误差被放大了。
思路:
先把模型剪枝变小(减少参数量),再量化。小模型量化后的精度损失比大模型更小,因为参数量少了,每个参数的重要性更高,量化时更"珍惜"每个参数。
二、剪枝策略选择
剪枝分两种:
1. 非结构化剪枝:随机剪掉单个权重,压缩率高但硬件支持差(BPU不支持稀疏矩阵)
2. 结构化剪枝:剪掉整个filter或channel,压缩率低但硬件友好
征程6的BPU只支持结构化剪枝,所以必须选方案2。
结构化剪枝的具体做法
以YOLOv5x的backbone为例,每层的channel数:
层 原始channel 剪枝后channel 剪枝比例
conv1 80 64 20%
conv2 160 128 20%
conv3 320 256 20%
conv4 640 512 20%
conv5 1280 1024 20%
统一剪掉20%的channel,模型参数量从86M降到55M,减少了36%。
剪枝方法: 基于L1 norm的filter importance排序,剪掉importance最低的filter。
```python
import torch
import torch.nn as nn
def prune_conv_layer(conv_layer, prune_ratio=0.2):
"""剪掉一个Conv2d层中importance最低的filter"""
# 计算每个filter的L1 norm(作为importance指标)
filters = conv_layer.weight.data # shape: [outC, inC, kH, kW]
importance = torch.sum(torch.abs(filters), dim=[1, 2, 3]) # 每个filter的L1 norm
# 排序,找出最不重要的filter
num_prune = int(conv_layer.out_channels * prune_ratio)
_, prune_indices = torch.topk(importance, num_prune, largest=False)
# 保留剩余的filter
keep_mask = torch.ones(conv_layer.out_channels, dtype=torch.bool)
keep_mask[prune_indices] = False
# 剪枝weight
conv_layer.weight.data = conv_layer.weight.data[keep_mask]
if conv_layer.bias is not None:
conv_layer.bias.data = conv_layer.bias.data[keep_mask]
# 更新out_channels
conv_layer.out_channels = conv_layer.out_channels - num_prune
return prune_indices
def prune_model(model, prune_ratio=0.2):
"""对整个模型做结构化剪枝"""
for name, module in model.named_modules():
if isinstance(module, nn.Conv2d):
# 剪掉当前层的output filter
prune_indices = prune_conv_layer(module, prune_ratio)
# 找到下一层,剪掉对应的input channel
next_module = find_next_conv(model, name)
if next_module is not None:
next_module.weight.data = next_module.weight.data[:, ~prune_indices]
next_module.in_channels = next_module.in_channels - len(prune_indices)
return model
```
注意: 剪枝不是一次性完成的,需要 iterative pruning + fine-tuning:
1. 剪掉10%的filter
2. fine-tune 5个epoch恢复精度
3. 再剪掉10%
4. fine-tune 5个epoch
5. ...直到达到目标压缩率
一次剪太多(比如直接剪50%),模型精度会崩,fine-tune也救不回来。iterative的方式可以让模型逐步适应新的结构。
三、剪枝后的精度恢复
剪枝20%后,模型精度从84.2%掉到了78.5%。需要通过fine-tune恢复:
```python
# 剪枝后的fine-tune
optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.937)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)
for epoch in range(100):
for batch in dataloader:
images, targets = batch
predictions = model(images)
loss = compute_loss(predictions, targets)
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step()
# 每10个epoch评估一次
if epoch % 10 == 0:
mAP = evaluate(model, val_dataset)
print(f"Epoch {epoch}: mAP = {mAP:.1f}%")
```
fine-tune 100个epoch后,精度恢复到了83.8%,只比原始掉0.4%。这个精度对于后续量化来说已经足够好了。
一个小技巧: fine-tune的时候用knowledge distillation(知识蒸馏),用原始大模型(未剪枝的YOLOv5x)当teacher,剪枝后的模型当student。distillation loss可以帮助student学到teacher的"暗知识",精度恢复更快。
```python
# Knowledge distillation
teacher_model = YOLOv5x() # 原始大模型
teacher_model.load_state_dict(torch.load('yolov5x.pt'))
teacher_model.eval()
for batch in dataloader:
images, targets = batch
with torch.no_grad():
teacher_preds = teacher_model(images)
student_preds = model(images) # 剪枝后的模型
# 检测loss
detection_loss = compute_loss(student_preds, targets)
# distillation loss(teacher和student的分类分数要一致)
distillation_loss = F.kl_div(
F.log_softmax(student_preds[0], dim=1),
F.softmax(teacher_preds[0], dim=1),
reduction='batchmean'
)
loss = detection_loss + 0.5 * distillation_loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
```
加了distillation后,fine-tune 50个epoch精度就恢复到了83.5%,省了50%的训练时间。
四、量化:剪枝后模型的int8 PTQ
剪枝后的模型参数量从86M降到了55M,但直接做int8 PTQ精度还是掉不少(83.8% -> 80.2%)。原因是剪枝后的模型更"脆弱",每个参数的重要性更高,量化误差更敏感。
解决: 用更精细的量化策略。
1. per-channel量化
默认的int8量化是per-tensor(整个tensor共用一个scale和zero_point),但对于剪枝后的模型,不同channel的权重分布差异很大,共用一个scale精度损失大。
per-channel量化给每个output channel单独的scale:
```yaml
calibration_parameters:
calibration_type: mix
quantization_strategy:
weight_quantization: per_channel # per_tensor | per_channel
activation_quantization: per_tensor
```
per-channel比per-tensor精度提升1.2%(80.2% -> 81.4%),但模型文件会稍微大一点(每个channel多存一个scale值)。
2. 对敏感层用int16
剪枝后的模型对某些层更敏感,这些层用int16:
```yaml
layer_config:
# 检测头(所有输出层)用int16
- layer_name: "model\\.24\\.m\\.0"
precision_mode: int16
- layer_name: "model\\.24\\.m\\.1"
precision_mode: int16
- layer_name: "model\\.24\\.m\\.2"
precision_mode: int16
# backbone的前两层用int16(剪枝后这几层更敏感)
- layer_name: "model\\.0"
precision_mode: int16
- layer_name: "model\\.1"
precision_mode: int16
```
敏感层升int16后,精度从81.4%回升到82.7%,延迟从16ms涨到18ms,帧率从62.5fps降到55.6fps。
五、性能对比
配置 参数量 延迟 FPS mAP 相比原始
YOLOv5x 浮点 86M 38ms 26.3 84.2% 基准
YOLOv5x int8 PTQ 86M 24ms 41.7 81.5% 精度掉2.7%
YOLOv5x mix 86M 26ms 38.5 82.1% 精度掉2.1%
剪枝20% + int8 55M 14ms 71.4 80.2% 延迟好但精度不够
剪枝20% + per-channel 55M 15ms 66.7 81.4% 精度好一些
剪枝20% + per-channel + mix 55M 18ms 55.6 82.7% 最优
YOLOv5s 浮点 7.2M 12ms 83.3 77.2% 精度太低
结论: 剪枝20% + per-channel量化 + 敏感层int16,延迟18ms(55fps),mAP 82.7%,只比原始浮点掉1.5%。这是延迟和精度的最佳平衡点。
如果精度要求更严(比如必须83%+),可以:
· 剪枝比例降到10%(延迟22ms,mAP 83.5%)
· 或者更多的层用int16(延迟20ms,mAP 83.1%)
六、剪枝+量化联合优化的坑
坑1:剪枝后模型的channel数不是8/16的倍数
BPU要求输入channel是8的倍数,输出channel是16的倍数。剪枝的时候如果随便剪掉20%的filter,channel数可能变成不被8/16整除的数,导致BPU padding浪费。
解决: 剪枝的时候确保channel数是8的倍数。
```python
def prune_to_multiple(conv_layer, multiple=8, prune_ratio=0.2):
"""剪掉filter,但确保剩余的channel数是multiple的倍数"""
original_out = conv_layer.out_channels
target_out = int(original_out * (1 - prune_ratio))
# 确保是8的倍数
target_out = (target_out // multiple) * multiple
num_prune = original_out - target_out
# 计算importance,剪掉最不重要的num_prune个filter
importance = torch.sum(torch.abs(conv_layer.weight.data), dim=[1, 2, 3])
_, prune_indices = torch.topk(importance, num_prune, largest=False)
keep_mask = torch.ones(original_out, dtype=torch.bool)
keep_mask[prune_indices] = False
conv_layer.weight.data = conv_layer.weight.data[keep_mask]
if conv_layer.bias is not None:
conv_layer.bias.data = conv_layer.bias.data[keep_mask]
conv_layer.out_channels = target_out
return prune_indices
```
坑2:剪枝后BN层的running_mean和running_var没有同步更新
剪枝filter的时候,如果BN层的running_mean和running_var没有同步剪掉对应的channel,后续inference时BN的计算会出错。
解决: 剪枝Conv的同时剪掉BN层对应的channel。
```python
def prune_conv_bn(conv_layer, bn_layer, prune_ratio=0.2):
prune_indices = prune_conv_layer(conv_layer, prune_ratio)
# 同步剪掉BN层
bn_layer.weight.data = bn_layer.weight.data[~prune_indices]
bn_layer.bias.data = bn_layer.bias.data[~prune_indices]
bn_layer.running_mean = bn_layer.running_mean[~prune_indices]
bn_layer.running_var = bn_layer.running_var[~prune_indices]
bn_layer.num_features = bn_layer.num_features - len(prune_indices)
return prune_indices
```
坑3:shortcut连接的channel数不匹配
YOLOv5里有大量的shortcut连接(残差连接),如果剪枝的时候两边的channel数剪得不一样,shortcut相加时会shape mismatch。
解决:
剪枝的时候记录每一层剪掉了哪些channel,shortcut连接的另一边要剪掉相同的channel。
```python
# 记录每一层的剪枝信息
prune_info = {}
for name, module in model.named_modules():
if isinstance(module, nn.Conv2d):
prune_indices = prune_conv_layer(module, prune_ratio)
prune_info[name] = prune_indices
# 处理shortcut连接
for shortcut in model.shortcuts:
# shortcut的两端剪掉相同的channel
source_name = shortcut.source
target_name = shortcut.target
if source_name in prune_info:
# target端也要剪掉相同的channel
target_conv = get_module(model, target_name)
target_conv.weight.data = target_conv.weight.data[~prune_info[source_name]]
target_conv.in_channels = target_conv.in_channels - len(prune_info[source_name])
```
坑4:fine-tune学习率设太高
剪枝后的模型结构变了,fine-tune的时候如果学习率设得太高(比如0.01),模型会震荡,精度恢复很慢。
建议:
fine-tune的学习率设成原始训练的1/10(比如0.001),训练50-100个epoch。
七、注意事项总结
1. 征程6只支持结构化剪枝:非结构化剪枝的稀疏矩阵BPU不支持。
2. iterative pruning + fine-tune:一次剪太多精度会崩,逐步剪逐步恢复。
3. knowledge distillation加速恢复:用原始大模型当teacher,省50%训练时间。
4. 剪枝后channel数是8/16的倍数:满足BPU对齐要求,减少padding浪费。
5. per-channel量化比per-tensor精度高1-2%:剪枝后的模型更敏感,需要更精细的量化。
6. 敏感层升int16:检测头和backbone前几层用int16,精度回升明显。
7. BN层参数同步剪:running_mean/running_var要一起剪掉对应channel。
8. shortcut连接两边剪掉相同的channel:避免shape mismatch。
9. fine-tune学习率要低:0.001左右,训练50-100个epoch。
10. 剪枝比例建议10-20%:超过30%精度恢复困难,性价比不高。

