3. 模型剪枝技术
模型剪枝,说白了就是给模型「减肥」。VLM 动辄几十亿参数,部署到边缘设备上,显存和延迟都是大问题。我最早接触剪枝是在一个车载视觉项目上,模型太大,推理卡得没法用。后来靠剪枝硬是把模型体积砍掉 40%,速度翻了一倍。嗯,今天咱们就把剪枝这件事聊透。
3.1 非结构化剪枝:细粒度,但硬件不买账
非结构化剪枝,就是直接把不重要的单个权重置为零。粒度最细,理论上压缩率可以很高。
举个例子,一个 4×4 的权重矩阵,剪枝后可能变成这样:
原始权重:
[[ 0.12, -0.05, 0.98, 0.01],
[ 0.00, 0.45, -0.02, 0.77],
[ 0.33, 0.88, -0.01, 0.04],
[ 0.67, 0.02, 0.11, -0.03]]
剪枝后(阈值 0.05):
[[ 0.12, 0.00, 0.98, 0.00],
[ 0.00, 0.45, 0.00, 0.77],
[ 0.33, 0.88, 0.00, 0.00],
[ 0.67, 0.00, 0.11, 0.00]]
你看,很多权重变成了 0。但问题来了——这些 0 是散乱分布的,硬件没法做批量跳过。我在项目里试过,非结构化剪枝到 50% 以上,推理速度几乎没变,因为 GPU 还是得老老实实做稀疏矩阵乘法。
3.2 结构化剪枝:硬件友好,但更「暴力」
结构化剪枝,是按「块」来剪的。比如直接干掉整个通道、整个卷积核、甚至整个层。剪完之后模型结构还是规整的,硬件跑起来毫无压力。
| 剪枝粒度 | 典型操作 | 硬件友好度 | 压缩率上限 |
|---|---|---|---|
| 权重级 | 单个权重置零 | 低 | 高(理论) |
| 通道级 | 删除整个通道 | 高 | 中 |
| 卷积核级 | 删除整个卷积核 | 高 | 中 |
| 层级 | 删除整层 | 最高 | 低 |
我个人习惯优先用通道级剪枝。为什么呢?因为 VLM 里的视觉编码器(比如 ViT)有很多注意力头,有些头其实学不到什么有用信息,直接砍掉不影响精度。
3.3 重要性评分方法:怎么判断哪些权重该剪?
剪枝的核心问题就一个:怎么知道哪个权重不重要? 我见过不少新手直接随机剪,结果模型直接崩了。下面说几种靠谱的方法。
3.3.1 L1/L2 范数评分
最简单也最常用。L1 范数就是值之和,L2 范数是平方和的平方根。值越小,说明这个权重对输出的贡献越小。
import torch
import torch.nn as nn
def l1_importance(conv_layer):
"""计算卷积核的 L1 重要性"""
weight = conv_layer.weight.data # shape: [out_channels, in_channels, k, k]
importance = weight.abs().sum(dim=(1, 2, 3)) # 每个输出通道的 L1 范数
return importance
def l2_importance(conv_layer):
"""计算卷积核的 L2 重要性"""
weight = conv_layer.weight.data
importance = (weight ** 2).sum(dim=(1, 2, 3)).sqrt()
return importance
我在实际项目里发现,L1 和 L2 的排序结果其实差不多。但 L1 计算更快,所以我一般用 L1。
3.3.2 梯度重要性评分
光看权重大小不够,还得看它对 loss 的影响。梯度大的权重,说明 loss 对它敏感,剪掉会掉精度。
def gradient_importance(model, loss_fn, dataloader):
"""基于梯度的剪枝重要性评分"""
model.train()
importance_dict = {}
for name, param in model.named_parameters():
if param.grad is not None:
# 梯度 × 权重的值
importance = (param.grad.abs() * param.abs()).sum().item()
importance_dict[name] = importance
return importance_dict
3.3.3 三种方法对比
| 方法 | 计算成本 | 准确度 | 适用场景 |
|---|---|---|---|
| L1 范数 | 低 | 中 | 快速粗筛、大模型 |
| L2 范数 | 低 | 中 | 与 L1 互补验证 |
| 梯度评分 | 高 | 高 | 精细剪枝、小模型 |
3.4 剪枝流程:从评估到执行
下面这张图是我在项目中总结的剪枝流程,你可以直接照着做。
3.5 剪枝后的微调策略
剪完枝,模型精度一定会掉。怎么恢复?我试过三种策略,效果差别很大。
3.5.1 一次性剪枝 + 全量微调
最直接的做法:一次剪到位,然后用全部训练数据微调几个 epoch。适合小模型,大模型这么搞容易过拟合。
3.5.2 迭代剪枝 + 局部微调
每次只剪 10%-20%,然后微调几个 step,再继续剪。我比较推荐这种方式,精度掉得少,收敛也快。
def iterative_pruning(model, dataloader, target_sparsity=0.5, steps=5):
"""迭代剪枝:每次剪一点,微调一下"""
current_sparsity = 0.0
step_sparsity = target_sparsity / steps
for step in range(steps):
current_sparsity += step_sparsity
# 1. 评估重要性
importance = evaluate_importance(model, dataloader)
# 2. 剪枝
prune_model(model, importance, current_sparsity)
# 3. 微调几个 step
finetune(model, dataloader, num_steps=100)
print(f"Step {step+1}: sparsity={current_sparsity:.2f}")
return model
3.5.3 知识蒸馏辅助微调
如果剪枝后精度掉得厉害(比如掉了 5% 以上),我会用原始大模型做 teacher,剪枝后的模型做 student,做蒸馏微调。效果比单纯微调好很多。
3.6 实战避坑清单
- 不要一次性剪太多:我见过有人一次剪 70%,模型直接变成随机猜测。建议每次不超过 20%。
- 注意 BatchNorm 层:剪掉通道后,对应的 BN 参数也要一起删掉,否则推理会报错。
- 验证集上做决策:重要性评分用训练集算,但剪枝比例的选择要在验证集上试。
- VLM 的特殊性:视觉编码器和文本编码器的剪枝敏感度不同。我一般对视觉部分剪得多一些(30%),文本部分只剪 10%-15%。
好了,剪枝这部分就聊到这儿。记住一个原则:剪枝不是目的,部署才是。选对方法,控制好比例,你的 VLM 就能在边缘设备上跑得又快又准。