4、非结构化剪枝:权重剪枝原理、阈值设定策略、掩码矩阵实现、稀疏矩阵存储
好,咱们今天来聊聊非结构化剪枝。说实话,这是我最喜欢的一种剪枝方式。为什么?因为它直接、暴力、效果好。你想想看,一个训练好的Transformer模型,里面有多少权重是真正有用的?我做过实验,有些层里超过90%的权重其实都在「摸鱼」。
非结构化剪枝,说白了就是把这些「摸鱼」的权重直接干掉。它不像结构化剪枝那样整行整列地砍,而是单个神经元级别地剪。这样做的好处是精度损失小,坏处是硬件不友好。嗯,咱们先把这个核心矛盾记住,后面会反复提到。
权重剪枝原理
原理其实特别简单。我给你打个比方:
你有一堆学生,考试成绩从0到100分不等。现在你要裁员,只留最优秀的。你会怎么做?设定一个分数线,低于这个线的全部开除。权重剪枝就是这个逻辑。
每个权重都有一个绝对值,代表它对最终输出的贡献大小。绝对值小的权重,说白了就是「可有可无」的。剪掉它们,模型精度几乎不受影响。
核心思想: 保留大权重,剪掉小权重。就这么简单。
我在项目中遇到过这样一个情况:一个BERT模型,第一层注意力头的权重分布特别均匀,几乎都在0.01到0.05之间。这时候你如果硬剪,精度直接崩。后来我发现,这种层其实不适合做非结构化剪枝,更适合做低秩分解。嗯,这个经验后面会用到。
阈值设定策略
阈值怎么设?这是个好问题。我见过太多人直接拍脑袋设个0.01,结果模型直接废了。阈值设定有几种主流策略:
1. 全局阈值法
整个模型用一个阈值。比如设定剪掉所有绝对值小于0.01的权重。这种方法简单粗暴,但问题也很明显:不同层的权重分布不一样。有些层权重普遍偏小,你一刀切下去,那层可能就没了。
2. 按比例剪枝法
不设绝对值阈值,而是设一个剪枝比例。比如剪掉50%的权重。然后对每一层,找到该层权重的中位数,低于中位数的全部剪掉。这样做的好处是每层保留的比例一致,不会出现某层被剪光的情况。
我的习惯: 我一般先用按比例剪枝法,剪到目标稀疏度。然后再用全局阈值法做微调,把那些绝对值特别小的「漏网之鱼」也干掉。
3. 基于梯度的阈值
这个稍微高级一点。不光看权重的大小,还看它的梯度。如果一个权重很小,但梯度很大,说明它还在学习,剪掉可能不合适。反之,如果一个权重很大,但梯度几乎为零,说明它已经「学废了」,可以考虑剪掉。
我曾经踩过一个坑:用全局阈值法剪一个GPT模型,结果某个注意力头的所有权重都被剪光了。那个头直接变成「死人头」,模型精度掉了3个点。后来我加了一个保护机制:每个注意力头至少保留一个权重。嗯,这个经验分享给你。
| 策略名称 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 全局阈值法 | 实现简单 | 不同层分布不均 | 权重分布均匀的模型 |
| 按比例剪枝法 | 每层保留一致 | 可能剪掉重要权重 | 大多数场景 |
| 基于梯度法 | 考虑学习状态 | 计算量大 | 训练过程中剪枝 |
掩码矩阵实现
掩码矩阵,说白了就是一个「开关」。每个权重对应一个0或1,0表示剪掉,1表示保留。实现起来特别简单:
import torch
# 假设我们有一个权重矩阵
weight = torch.randn(4, 4)
# 设定阈值
threshold = 0.5
# 生成掩码矩阵
mask = torch.abs(weight) > threshold
# 应用掩码
pruned_weight = weight * mask
print("原始权重:")
print(weight)
print("\n掩码矩阵:")
print(mask)
print("\n剪枝后的权重:")
print(pruned_weight)
你看,就这么几行代码。但这里有个坑:掩码矩阵本身也是要占用内存的。如果你的模型有1亿个参数,掩码矩阵也要1亿个元素。虽然每个元素只占1个bit,但加起来也不小。
注意: 在训练过程中,被剪掉的权重不会更新梯度。但它们的值还保留在内存中。如果你后续想恢复这些权重(比如做动态剪枝),它们还在那里等着你。
我个人的习惯是,把掩码矩阵和权重分开存储。权重用float32,掩码用uint8。这样既节省空间,又方便操作。你想想看,如果你把剪掉的权重直接设成0,那后续想恢复就麻烦了——你都不知道原来的值是多少。
稀疏矩阵存储
好,现在权重剪完了,模型里到处都是0。问题来了:怎么存储?
你当然可以继续用密集矩阵存,但那样太浪费了。一个90%稀疏的矩阵,你用密集格式存,90%的空间都在存0。这不是傻吗?
常用的稀疏矩阵存储格式有几种:
1. CSR(Compressed Sparse Row)
这种格式适合行稀疏的矩阵。它用三个数组来存储:
- values:存储所有非零元素的值
- column_indices:每个非零元素所在的列索引
- row_ptr:每行第一个非零元素在values中的位置
举个例子:
# 一个3x3的稀疏矩阵
# [[1, 0, 0],
# [0, 2, 0],
# [0, 0, 3]]
values = [1, 2, 3]
column_indices = [0, 1, 2]
row_ptr = [0, 1, 2, 3]
2. COO(Coordinate Format)
这个更直观。每个非零元素用三个值表示:(行, 列, 值)。适合构建稀疏矩阵时使用。
# 还是上面那个矩阵
rows = [0, 1, 2]
cols = [0, 1, 2]
values = [1, 2, 3]
3. 位图格式
这个是我在项目中常用的。用一个bitmap表示哪些位置是非零的,然后只存储非零元素的值。优点是查找速度快,缺点是存储开销比CSR大一点。
我的建议: 如果你做推理加速,用CSR格式。如果你做训练过程中的动态剪枝,用COO格式,因为频繁增删非零元素时COO更方便。
我记得有一次,我用CSR格式存储一个剪枝后的Transformer模型,模型大小从500MB降到了80MB。但推理速度反而变慢了。为什么?因为CSR格式在GPU上并行计算效率不高。后来我改用了一种叫做「块稀疏」的格式,把非零元素聚集成块,才真正实现了加速。
嗯,这个块稀疏的内容,咱们后面会专门讲。今天先把非结构化剪枝的基础打牢。
最后总结一下:非结构化剪枝的核心就是「砍小权重,留大权重」。阈值设定要灵活,掩码矩阵要分开存,稀疏格式要选对。你把这些搞明白了,后面学结构化剪枝、块剪枝都会轻松很多。