剪掉了一半的权重,模型文件为什么没变小
为了让模型在边缘设备上跑得动,有人用 PyTorch 的剪枝工具剪掉了一半的权重,保存后一看,文件反而变大了。调用
prune.remove之后,文件大小恢复到和原来一模一样,推理也没有变快。
「剪枝」「稀疏」「量化」常常被一起提起,好像都能让模型变小变快。实际上它们改变的是不同的东西:剪枝改变的是数值(一部分权重变成零),稀疏格式改变的是存储方式,量化改变的是数据类型,而只有改变结构(层变窄)或者有专门支持的硬件和算子,才可能更快。
本文用一个 256→256→64 的两层网络,在 PyTorch 2.14.1(CPU)上逐项比较。体积用 torch.save 写到内存缓冲区的字节数衡量。实验见文末。
prune 多存一份掩码,体积从 323 KiB 变成 580 KiB;remove 之后回到 323 KiB;50% 稀疏时 COO 比稠密大。只有结构化剪枝后重建层、半精度与量化让体积变小;推理速度没有测量。一、prune 是参数化,不是删除
原模型 state_dict:['weight', 'bias'],保存 323 KiB
l1_unstructured(amount=0.5) 后第一层 state_dict:['bias', 'weight_orig', 'weight_mask'];weight 不再是参数,是前向前由 weight_orig × weight_mask 算出来的属性:True
保存 580 KiB;weight 形状 (256, 256),零元素占比 0.50torch.nn.utils.prune 的设计目标是方便地实验剪枝:它把原参数改名为 weight_orig 保留下来,额外存一个同样大小的 weight_mask,每次前向之前用钩子算出 weight = weight_orig * weight_mask。这样可以反复调整掩码、迭代剪枝,原始权重也能恢复。代价是存了两份同样大小的张量,文件变大。
prune.remove 后:state_dict ['bias', 'weight'],保存 323 KiB;仍是 torch.strided 稠密张量,形状 (256, 256)prune.remove 把掩码「烧」进权重,去掉参数化。结果是一个普通的稠密张量,只是一半元素为零。零也是 4 个字节的 float32,所以文件大小和原来一样;稠密矩阵乘法也不会因为零多就跳过计算。
二、稀疏格式:稀疏度不够时反而更大
把零元素多的张量存成稀疏格式,就只存非零元素和它们的位置:
零元素占比 0.5:稠密 258 KiB,COO 642 KiB(索引 torch.int64),CSR 644 KiB
零元素占比 0.9:稠密 258 KiB,COO 130 KiB(索引 torch.int64),CSR 132 KiBCOO 格式给每个非零元素存两个 int64 的坐标,一个元素从 4 字节变成 4 + 16 字节。50% 稀疏时,体积是稠密的两倍多;到 90% 稀疏才变成一半。CSR 的行指针更紧凑,但列索引同样是 int64。稀疏格式能不能变快,还要看算子:非结构化稀疏在通用 CPU/GPU 上通常不比稠密快,需要很高的稀疏度,或者硬件支持的特定模式(例如 2:4 结构化稀疏)。
三、结构化剪枝:要变小,得重建层
结构化剪枝按整行(整个输出神经元)或整个通道置零。这样就能真正把层变窄:
ln_structured(dim=0, amount=0.5):weight 形状 (256, 256),全零的行 128 个形状仍然是 256×256。要真正变小,得把非零的行抽出来重建第一层,同时去掉第二层里对应的列。这里有一个容易漏掉的细节:
只保留非零行、去掉下一层对应列,重建为 256→128→64:与剪枝后模型输出一致:False(被剪掉的行偏置仍在,ReLU 后非零的有 58 个)
把这些常数输出折进下一层偏置后:一致:True;保存 163 KiB(原 323 KiB)ln_structured 只剪了 weight,没有剪 bias。被剪掉的神经元权重全零,但输出是 ReLU(bias),一个不随输入变化的常数,不一定是零。直接删掉这些神经元,就丢掉了它们对下一层的常数贡献。正确的做法是把 W2[:, 被剪的列] @ ReLU(bias[被剪的行]) 加进下一层的偏置。这是一次标准的「保持行为的重构」:先证明新结构和旧结构输出一致,再谈它更小。
当然,剪枝本身改变了模型:
剪枝改变了模型:与原模型输出的最大差 0.59,需要重新评估或微调重建只保证「和剪枝后的模型一致」,不保证「和原模型一致」。剪枝后要在任务数据上重新评估,通常还要微调。
四、半精度与动态量化:改变的是数据类型
half():保存 163 KiB;与原模型输出的最大差 0.0004
quantize_dynamic(qint8):第一层变成 torch.ao.nn.quantized.dynamic.modules.linear.Linear,保存 85 KiB
与原模型输出的最大差 0.0090;输入输出仍是 torch.float32,只有权重以 int8 存储,激活在运行时按批量化half() 把每个参数从 4 字节变成 2 字节,体积减半。动态量化把 Linear 的权重存成 int8 加上缩放因子,体积约为四分之一;激活值在每次推理时按当前批次的范围动态量化。模块被替换成了另一种类型,所以量化后的模型通常不能直接继续训练,保存和加载也要用对应的方式。
调用 quantize_dynamic 时的弃用警告:torch.ao.quantization is deprecated and will be removed in 2.10.在 2.14.1 里,torch.ao.quantization 仍能调用,但每次调用都给出弃用警告(警告里写的移除版本已经过了),并建议迁移到 torchao。新项目应该直接查阅 torchao 的文档;本文没有验证 torchao。
五、它们分别改变了什么
| 操作 | 数值 | 形状 | 存储 | 是否可能更快 |
|---|---|---|---|---|
prune 参数化 | 部分为零 | 不变 | 多一份掩码,更大 | 否 |
prune.remove | 部分为零 | 不变 | 与原来相同 | 否(稠密乘法照算) |
| 稀疏格式 | 不变 | 不变 | 取决于稀疏度 | 取决于算子与硬件 |
| 结构化剪枝 + 重建 | 改变 | 变小 | 变小 | 是,计算量减少 |
half() / 量化 | 近似 | 不变 | 1/2、约 1/4 | 取决于硬件与算子 |
本文没有测量推理速度。是否更快只能在目标硬件、目标批大小上实测,不能从体积推断。
六、检查清单
prune用于实验;要交付时先remove,再决定是否转成稀疏格式或重建结构。- 非结构化稀疏要到很高的稀疏度才省空间;省时间还要看算子支持。
- 结构化剪枝后重建层时,把被剪神经元的常数输出折进下一层偏置,并验证与剪枝后模型输出一致。
- 剪枝和量化之后都要在任务数据上重新评估。
- 速度只信目标环境的实测。
配套实验
- codesphere-labs/ai/pruning-and-quantization:Python 3.14.8、PyTorch 2.14.1 CPU:prune 的参数化与 remove、稀疏格式的体积、结构化剪枝后重建层与偏置、半精度与动态量化(验证记录)
参考资料