模型剪枝基础:什么是剪枝?结构化剪枝与非结构化剪枝的区别
咱们直接切入正题。模型剪枝,说白了就是给神经网络「瘦身」。
你想想看,一个训练好的大模型,里面其实有很多参数是冗余的。有的神经元压根不干活,有的权重值小到可以忽略。我刚开始接触剪枝时也觉得不可思议——去掉一部分参数,模型居然还能正常工作?后来在项目中亲手试过才明白,这就像我们背单词,记住核心的3000个高频词就够日常交流了,剩下的那些生僻词,去掉也不影响沟通。
一、什么是模型剪枝?
模型剪枝,就是有选择性地移除神经网络中不重要的连接、神经元甚至整个层。
它的核心思想很简单:去掉那些对最终输出贡献小的参数,保留关键部分。这样做的好处很明显——模型变小了,推理速度变快了,内存占用也降低了。
我记得第一次在嵌入式设备上部署模型时,一个50MB的模型死活跑不起来。剪枝到15MB后,不仅跑起来了,帧率还从5fps飙到了30fps。嗯,那种感觉,就像给胖子减了肥,跑起来自然快。
剪枝的核心公式(非严格数学表达):
给定一个预训练模型,我们定义一个重要性评分函数 S(w),对每个参数 w 打分。然后设定一个阈值 τ,凡是 S(w) < τ 的参数,统统干掉。
剪枝流程通常分三步走:
- 训练一个大的预训练模型——先让模型学会所有知识
- 评估参数重要性并剪掉不重要的——这一步是关键
- 微调(Fine-tune)——让剩下的参数重新适应,弥补损失
这里有个坑,我曾经踩过:千万别一次性剪太多。我试过一次性剪掉90%的参数,结果模型直接崩了,准确率掉到跟随机猜差不多。后来学乖了,采用迭代剪枝——每次剪10%-20%,然后微调,再剪,再微调。这样稳得多。
二、非结构化剪枝
非结构化剪枝,是最直接、最细粒度的剪枝方式。
它不关心参数在矩阵中的位置,只看单个权重值的大小。权重绝对值小于某个阈值?直接置零。就这么简单粗暴。
个人习惯:我一般用权重的绝对值作为重要性指标。绝对值越小,说明这个连接对输出的影响越小,剪掉它损失也最小。
来看个代码示例,直观感受一下:
import torch
import torch.nn.utils.prune as prune
# 假设有一个卷积层
model = torch.nn.Conv2d(3, 64, kernel_size=3)
# 非结构化剪枝:剪掉50%的权重
prune.l1_unstructured(
model,
name='weight',
amount=0.5 # 剪掉50%
)
# 剪枝后,权重变成了稀疏张量
print(model.weight)
# 输出中会看到很多0
非结构化剪枝的优点很明显:
- 压缩率高——可以剪掉80%-90%的参数而不明显掉点
- 实现简单——几行代码就能搞定
- 灵活性高——每个参数独立判断去留
但缺点也很致命:
- 产生不规则稀疏矩阵——权重矩阵中0的位置是随机的,没有规律
- 硬件不友好——普通CPU/GPU没法加速这种稀疏计算,除非有专门的稀疏计算单元
- 内存布局混乱——存储时需要额外的索引信息,实际加速效果打折扣
我在项目中遇到过这种情况:用非结构化剪枝把模型压缩了5倍,结果部署到手机上,推理速度反而变慢了。为什么?因为手机芯片不支持稀疏矩阵加速,那些0还得参与计算,白忙活一场。
避坑指南:如果你要部署到普通硬件(手机、嵌入式设备、普通GPU),非结构化剪枝大概率会让你失望。我曾经在一个边缘计算项目上浪费了两周时间,最后不得不换成结构化剪枝。
三、结构化剪枝
结构化剪枝,思路完全不同。
它不剪单个权重,而是剪整个「结构单元」——比如一个卷积核、一个通道、甚至整个层。剪完之后,模型的维度直接变小,矩阵还是规规整整的稠密矩阵。
举个例子:
- 非结构化剪枝:把卷积核里的某些权重置0
- 结构化剪枝:直接把整个卷积核删掉,通道数从64变成32
代码实现上,结构化剪枝通常需要自己写逻辑:
import torch
import torch.nn as nn
def channel_prune(conv_layer, prune_ratio):
"""
对卷积层进行通道级剪枝
"""
# 计算每个卷积核的L1范数
weight = conv_layer.weight.data # shape: [out_channels, in_channels, k, k]
l1_norm = weight.abs().sum(dim=[1, 2, 3])
# 找到要保留的通道索引
num_keep = int(weight.size(0) * (1 - prune_ratio))
_, indices = torch.topk(l1_norm, num_keep)
# 只保留重要的卷积核
conv_layer.weight.data = weight[indices]
conv_layer.out_channels = num_keep
return conv_layer, indices
# 使用示例
conv = nn.Conv2d(3, 64, kernel_size=3)
pruned_conv, kept_indices = channel_prune(conv, 0.3)
print(f"剪枝后输出通道数: {pruned_conv.out_channels}") # 输出: 44
结构化剪枝的优势:
- 硬件友好——矩阵还是稠密的,普通硬件就能加速
- 推理加速明显——通道数减少,计算量直接下降
- 内存布局规整——不需要额外索引,存储效率高
缺点:
- 压缩率相对较低——剪枝粒度大,容易剪掉重要信息
- 实现复杂——需要处理层与层之间的维度匹配问题
- 精度损失可能更大——一次剪掉整个通道,影响范围大
四、两者的核心区别
| 对比维度 | 非结构化剪枝 | 结构化剪枝 |
|---|---|---|
| 剪枝粒度 | 单个权重/神经元 | 通道/卷积核/层 |
| 剪枝后矩阵形态 | 不规则稀疏矩阵 | 规整的稠密矩阵 |
| 硬件加速 | 需要专用硬件(如NVIDIA A100的稀疏计算单元) | 通用硬件即可加速 |
| 压缩率 | 高(可达90%+) | 中等(通常30%-50%) |
| 推理加速 | 依赖硬件支持 | 直接有效 |
| 实现难度 | 简单(框架自带支持) | 较复杂(需手动处理维度) |
| 精度恢复 | 微调后容易恢复 | 需要更精细的微调策略 |
五、怎么选?我的建议
这个问题没有标准答案,但我可以分享一些经验:
- 如果你在服务器上部署,有NVIDIA A100或V100这类支持稀疏计算的GPU,非结构化剪枝是不错的选择。压缩率高,精度损失小。
- 如果你在手机或嵌入式设备上部署,老老实实用结构化剪枝。虽然压缩率低一些,但实实在在能加速。
- 如果你追求极致压缩,可以两者结合——先用结构化剪枝砍掉不重要的通道,再用非结构化剪枝做细粒度优化。
一个小技巧:我习惯先用结构化剪枝把模型压缩到目标大小的1.5倍,然后用非结构化剪枝做最后的精调。这样既保证了硬件加速效果,又能达到较高的压缩率。
最后说一句,剪枝不是万能的。有时候模型本身就设计得很紧凑,强行剪枝反而得不偿失。我建议你先分析一下模型的参数分布——如果大部分权重都集中在0附近,那剪枝空间就很大;如果权重分布比较均匀,那剪枝效果可能有限。
嗯,这一章就到这里。下一章我们聊聊具体的剪枝策略和重要性评估方法,到时候我会分享一些我在实际项目中踩过的坑和总结的经验。