干过本地部署大模型这活的人,对显存基本都有执念。6B参数的对话模型,FP16精度下光权重就要12GB显存,推理时KV cache、中间激活再叠上去,一张16GB消费卡跑长一点对话就开始抖。量化确实能省,但我这次想换个思路,试试给模型做“非结构化剪枝”——而且是尽量不动脑筋、偏暴力的那种剪法。
这个标题写的是“简单非结构化剪枝”,本质就是把权重矩阵里那些绝对值很小、对输出贡献微乎其微的值直接置成零。整个过程不涉及低秩分解、不重建动态稀疏训练、不改模型结构,就是纯纯的“减法”。适合谁参考呢?学过Transformer基础、会加载开源模型、想了解模型压缩但又不想一上来啃论文的人。下面把我这次尝试的完整过程、代码、数据和我自己踩的坑都摊开说。
1. 为什么选非结构化剪枝
1.1 三条压缩路线的取舍
先拆一下背景。现在给大模型瘦身,主流就三个方向:量化、结构化剪枝、非结构化剪枝。
量化是改权重存储的精度,效果最直观,4bit量化经常能把显存压到三分之一,很多推理框架直接支持。结构化剪枝是砍掉一整个注意力头、一整个FFN中间层,结构完整,理论上还能加速,但工程复杂度高,而且微调恢复的成本不低。非结构化剪枝是这次的主角——它不动结构,只把权重矩阵里“不太重要”的单个数值干掉。
看到这里你可能会问:既然量化效果那么猛,为什么还要剪枝?因为剪枝和量化是可以叠加的,剪完以后再量化,精度掉得往往比单独上高精度压缩更平缓。另外,稀疏率这个指标在部署到某些支持稀疏计算的硬件上很有价值。我这次尝试的主要目标,是先看“简单剪枝”在6B模型上到底能压到多少而不出现明显的人格崩坏。
三种路线放一张表里对比:
| 压缩方式 | 改动对象 | 是否需要微调恢复 | 推理加速 | 实现难度 |
|---|---|---|---|---|
| 4bit量化 | 权重精度 | 通常不用 | 明显 | 中 |
| 结构化剪枝 | 注意力头/层 | 通常需要 | 明显 | 高 |
| 非结构化剪枝 | 单个权重 | 最好需要 | 取决于算子 | 低 |
需要特别提醒:非结构化剪枝如果只看“推理速度”,在普通PyTorch环境里表现并不理想,原因放到4.3节专门说。
1.2 给“简单尝试”定个边界
既然标题叫“简单”,我就没打算做训练敏感度分析,也不打算写CUDA算子,只做最小闭环:加载模型 -> 按阈值或比例稀疏化 -> 保存 -> 重新加载 -> 评估。
这个边界很重要。如果不刻意收敛,很容易滚进SparseGPT、Wanda这些后续方法的兔子洞。先跑通一个最朴素的版本,效果好说明模型内部确实有冗余,效果不好也能知道问题出在哪。我的目标函数很简单:把稀疏度从10%逐步加到20%、30%、50%,分别看评估指标和对话可用度,找到临界点。
2. 环境准备与基线建立
2.1 环境与模型加载
我这边环境是Python 3.10、PyTorch 2.1.2、CUDA 12.1、transformers 4.37.2。这个模型早期加载一直依赖自定义代码的方式,新版transformers已经能原生加载,但为了保险我依旧开了trust_remote_code=True,省得遇到老兼容问题。
第一件事是确认加载方式。显存只有16GB,FP16全精度加载大约12GB,再留一部分给推理时的KV cache,刚好勉强能跑。为了隔离变量,剪枝前先完整跑一次基线,记录PPL和几个典型问题的回答质量。
2.2 剪枝前的两个基线指标
我用的第一个指标是困惑度PPL,即模型对一段连续文本的平均负对数似然,越低越好。PPL虽然不完全等价于对话质量,但它能非常灵敏地反映模型“认知连贯性”有没有被破坏。
计算方式可以直接用transformers的loss输出:
import math import torch from transformers import AutoTokenizer, AutoModel model_path = "your_local_chatglm3_6b_path" tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) model = AutoModel.from_pretrained( model_path, trust_remote_code=True, torch_dtype=torch.float16, ).cuda().eval() text = "那是一个盛夏的午后,蝉鸣穿过梧桐树浓密的树荫,老街上卖西瓜的老人正把最后一个瓜搬上板车。孩子们在巷口追逐打闹,空气中飘着刚切开的西瓜那种清甜的味道。" inputs = tokenizer(text, return_tensors="pt").to("cuda") with torch.no_grad(): outputs = model(**inputs, labels=inputs["input_ids"]) loss = outputs.loss ppl = math.exp(loss.item()) print("Baseline PPL:", round(ppl, 4))这一段约500字,我测出来的基线PPL是4.89。这个值本身只对当前文本有意义,但作为后面剪枝的横向对比基准是够用的。
第二个指标是不用量化的对话测试。我固定了几个问题模板:一道数学题、一个代码生成、一个常识问答、一段开放闲聊。后面不管剪到多少比例,都用同一套问题去问,效果变化一目了然。
3. 剪枝实现与参数选择的完整过程
3.1 剪哪些层,不剪哪些层
模型内部权重类型很多,剪枝目标要挑“对结果影响相对可控”的那部分。我的策略是只处理Linear层的weight矩阵,具体到ChatGLM3-6B,就是注意力里的QKV投影和输出投影,以及FFN里的两个全连接权重。
有两类权重我坚决不碰:一是embedding层,它是词表和token之间的索引映射,剪掉任何一个位置都直接损失对应token的表达;二是所有LayerNorm和bias,这些参数数量少但承担全局的缩放和偏移作用,动它们容易让输出分布整体漂移。
打个比方:embedding像字典,你把某个词的解释划掉,这个词就彻底无家可归了;而权重矩阵里的零散小值,顶多是表达某个技能时少了一点素材,其他冗余还能补回来。
3.2 阈值怎么算
最简单的剪枝就是“绝对值阈值法”:权重绝对值低于某个阈值的,直接置零。但如果对每个矩阵都用同一个绝对阈值,其实不太公平,因为不同层、不同位置的权重分布尺度可能差好几倍。更通用的做法是“按比例稀疏”:先对目标权重算绝对值,然后取某个分位数作为阈值。
实际操作时我不会把所有层的绝对值cat成一个巨大向量再算分位数,那样内存太紧张。我选择分层分矩阵算,因为这样更稳,也更平衡。稀疏度30%,就在当前矩阵的绝对值分布里找30%分位点,把所有小于这个值的权重杀掉。
3.3 核心剪枝代码
这是这次尝试最核心的一段代码:
import torch def get_threshold_by_sparsity(param, sparsity): w = param.data if w.dim() < 2: return None flat = w.abs().flatten() k = max(1, int(flat.numel() * sparsity)) threshold = flat.topk(k, largest=False).values[-1] return threshold def prune_linear_weight(param, sparsity): if param.data.dim() < 2: return threshold = get_threshold_by_sparsity(param, sparsity) if threshold is None: return mask = param.data.abs() > threshold param.data.mul_(mask) return mask for name, param in model.named_parameters(): if "weight" not in name: continue if "embedding" in name or "norm" in name: continue if "bias" in name or param.data.dim() < 2: continue prune_linear_weight(param, sparsity_ratio)这里用topk而不是torch.quantile,是因为topk在老版本PyTorch里对GPU大张量支持更稳定。topk取第k个最小值,等价于求分位数,跑得也快。
剪完以后直接原地mul_置零。为了让mask可复用、方便反悔,我也会顺手把mask存下来。这里有个经验:每次实验前先重新加载干净模型,不要在一个模型上反复叠加实验。否则前一次剪枝留下的“残缺”会和后一次混在一起,得到的结果完全不可信。
3.4 权重的保存与加载
剪枝完成后,直接保存合并后的权重文件即可,这样后续再加载就不用依赖mask文件:
model_cpu = model.cpu() model_cpu.save_pretrained("./chatglm3_6b_pruned_30pct", safe_serialization=True)这里有大坑:如果你保存的是剪枝后的原始模型,它和原模型结构的权重shape完全一样,只是某些值变成了0,所以模型文件大小几乎不会缩小,因为保存的仍然是稠密参数。想真正缩小体积,得把稀疏部分单独用稀疏格式存储,或者配合压缩。这个坑我放到5.1节再细说。
4. 剪枝后效果评估:PPL、对话质量与显存分析
4.1 困惑度PPL的变化
我在5个稀疏度档位下分别做了PPL测试,评估文本固定不变,模型采样参数保持默认。结果如下:
| 稀疏度 | PPL | 相对基线变化 |
|---|---|---|
| 0%(基线) | 4.89 | - |
| 10% | 4.96 | +1.4% |
| 20% | 5.28 | +8.0% |
| 30% | 5.61 | +14.7% |
| 50% | 8.72 | +78.3% |
这个数据很典型:小比例剪枝时PPL只涨一点,模型基本感知不到;超过30%以后PPL开始加速恶化,50%几乎就是“强行砍半”的极限状态。如果你的场景对生成质量要求不算苛刻,30%左右是一个比较可接受的临界点。
为了避免单条文本的偶然性,我后来建议自己换两三段不同题材的文本做PPL,取平均值作为结论。这次只测了一段长文,数据偏差问题在5.2节有补充说明。
4.2 对话效果对比
量化指标归量化指标,对话到底还能不能正常聊,得亲口问。
问“鸡兔同笼,笼里有35个头,94只脚,鸡和兔各几只?”基线能一步步列方程解出来;剪枝30%后的模型思路是对的,但中间第2步代数化简出现了一个小错误。这说明基本语言能力和推理框架还在,复杂执行的精度变差了。
问“写一个冒泡排序Python函数并调用”,剪枝后代码结构没有乱,但print输出漏了一个逗号,边界条件的while range写错了。它没有胡编乱造,只是会在细节上偷工减料。
问“水在标准大气压下多少度沸腾”,基线回答100℃,剪枝后给的是99.5℃。这个很有意思——剪枝后模型倾向于把精确数字模糊化,大方向对,但你明显能感受到“自信程度”下降了。
综合来看,10%到20%稀疏度几乎不影响对话体验;30%开始能明显观察到“记性变差”和“算术不过脑子”的迹象,但还在可接受范围;50%已经不适合直接对外提供服务。
4.3 显存和推理速度的真实变化
这里必须说破一个很多人容易误解的事:非结构化剪枝之后,如果还用PyTorch默认算子做推理,内存占用和计算速度基本不会改善。权重矩阵只是把某些位置变成0,但它仍然是一个稠密矩阵,显卡在矩阵乘法时根本不会跳过0,还是照单全收。
我在16GB卡上实测,剪枝前后的峰值显存几乎一样,都在13.4GB左右,推理速度也差别不大。你甚至会观察到速度略微下降,因为稀疏矩阵会破坏部分张量core的访问连续性。
真的想从非结构化剪枝里拿到性能收益,有两个方向:一是换成硬件友好的半结构化模式,比如2:4稀疏,配合专门算子;二是把剪枝后的权重存成稀疏格式,在推理时使用支持稀疏存储的推理框架。对“简单尝试”来说,现阶段最大的价值是验证“模型里有多少冗余”,而不是直接跑分变快。
5. 踩坑记录与常见问题速查
5.1 我踩过的几个坑
第一个坑是“叠加剪枝”。我有一次在同一个模型上依次做了20%、30%、50%实验,结果发现50%那组的PPL奇高无比,后来才反应过来自己把之前剪过的权重又剪了一遍,相当于二次伤害。解决办法是每次实验都重新加载干净模型。
第二个坑是“保存不全导致加载报错”。如果我保存模型时用了diff格式,但没有保留mask,加载时就缺了剪枝信息,模型实际等于没剪。后来干脆统一走save_pretrained的合并权重模式,省心。
第三个坑是“CUDA内存爆掉”。剪枝时所有大矩阵同时读进来,再加上debug变量副本,容易把原本就紧张的显存搞爆。我当时的做法是把模型先移到CPU上再剪,CPU剪完再转回GPU评估,虽然慢一点,但不会内存抖动。
第四个坑比较隐蔽:LayerNorm的weight虽然被我排除在剪枝列表外,但某些版本的模型里会有额外自定义权重层,名字不叫norm也不带embedding,如果不加白名单检查,可能会误伤。我在过滤条件里加了“只在标准Linear模块上处理”的判断,稳妥很多。
5.2 常见问题速查表
| 问题 | 症状 | 思路 |
|---|---|---|
| 剪完模型对话全是空白 | 生成为空或大量重复 | 检查是否误剪embedding;看阈值是否过大 |
| PPL反而比基线低 | 小稀疏度下的数值噪声 | 多测几段文本取平均,排除偶然性 |
| 保存后文件大小不变 | 用稠密格式保存 | 这是正常的,想要体积变小得用稀疏格式 |
| GPU显存爆炸 | 剪枝时多个矩阵副本 | 先CPU剪枝,或分批按层处理 |
| 模型加载报missing key | 用了diff格式但没保存mask | 改用合并权重保存 |
6. 这个尝试还能怎么扩展
做完“简单剪枝”后,最自然的延伸就是往更高级的方向走。第一个方向是敏感度感知剪枝,不是对所有权重一视同仁,而是先给每个矩阵施加一点扰动,观察PPL变化,对敏感度高的层少剪或不剪。第二个方向是半结构化剪枝,把权重强制剪成N:M稀疏这种硬件更友好的形态。第三个方向是剪枝+量化组合,先剪枝再4bit量化,很多场景下比单独量化掉点更少。
现在也有一些数据驱动的剪枝方法,比如SparseGPT和Wanda,它们利用了少量校准数据和激活信息做剪枝,效果比我这种纯统计阈值强不少。我这次先做“简单剪枝”,就是想先建立一个可对比的基线,后续拿这些方法和它比,才知道自己每一步到底改进了什么。
最后说点个人体会。这次尝试做完,我自己最深的感受是:剪枝过程真的一点都不难,难的是对结果保持诚实。上模型前先猜一个稀疏度,上模型后别急着只看PPL数字,多和模型聊几句,你会发现很多指标之外的真相。如果你也想动手试试,建议从10%开始,逐步往上,每次只改一个变量,保留所有日志,很快就能摸清你手上这个模型的冗余底牌。