最近整理手头的模型压缩实验把ChatGLM3-6B拿出来做了一次非结构化剪枝。所谓非结构化剪枝本质上就是照着权重矩阵的绝对值大小把不重要的参数直接置零让原本稠密的模型变成一个稀疏模型。这可能是所有模型压缩手段里门槛最低的一种——不需要懂复杂的优化理论不用动模型结构半天时间就能从下载权重坐到出评测结果。这组实验做下来我最大的感受是非结构化剪枝的工程落地价值有限但实验价值极高。通过剪枝你能直观看到模型哪些参数是冗余的、哪些层最“脆弱”、任务的容错度在哪。如果你正打算入门大模型压缩或者想理解稀疏训练、模型冗余这些概念用ChatGLM3-6B做载体再合适不过。这篇文章会完整记录我的方案设计、代码实现、评测方法和过程中踩过的一些坑如果你也想在某个开源模型上做剪枝实验可以直接照着抄。1. 为什么选择非结构化剪枝1.1 六个B的模型到底有多大ChatGLM3-6B是开源社区推出的中英双语大语言模型参数量约60亿。6B听起来不大但实际部署时压力不小半精度fp16下模型权重就要占12GB显存加上推理时的KV Cache和中间激活值一张24GB的显卡也就能跑个中等长度的对话。如果是CPU推理每生成一个token都需要把全部权重从内存搬到寄存器内存带宽直接决定生成速度这也是很多个人开发者觉得“能跑但慢”的根本原因。所以模型压缩在ChatGLM3-6B这个规模上是非常有现实意义的。压缩手段主流有三条路量化、蒸馏、剪枝。量化是降低每个参数的存储位宽蒸馏是拿大模型教小模型剪枝则是直接删参数。剪枝又分成结构化剪枝和非结构化剪枝我这次选择的是后者。1.2 剪枝三种流派结构化、非结构化、低秩近似先花一分钟理清概念不然做实验的时候很容易糊涂。结构化剪枝删的是有规律的权重块比如把某个注意力头整体删掉、把某一行或某一列清零甚至直接删掉几层Transformer层。好处是剪完后模型结构变成“窄模型”权重矩阵真的变小了部署时能实打实省显存、加快计算。坏处是需要改模型结构后续推理、微调、部署都要跟着适配操作成本高。非结构化剪枝不讲究规律只看单个权重值合不合格——绝对值大就留绝对值小就置零。剪完后权重矩阵的形状完全没变只是里面多了很多0。好处是操作简单不需要懂结构设计坏处是这些0如果没被稀疏计算库利用起来就只是白白躺在那里。低秩近似则是把权重矩阵拆成两个小矩阵相乘用低秩空间去逼近原矩阵属于“换一种表示方式”来省参数。这和有监督的分解相关本文先不展开。我这次选非结构化就是因为它足够简单不需要改模型代码不需要重新设计注意力机制只需要对权重矩阵做mask操作就能完整走通“剪枝——评测——结论”的闭环。1.3 非结构化剪枝的收益边界提前知道免得白做必须在文章最前面说清楚一个容易让人误判的点非结构化剪枝在标准PyTorch环境下几乎不会带来推理加速也不会让模型文件体积明显变小。原因很简单。PyTorch默认的矩阵乘法是把权重当稠密矩阵算的哪怕里面有一半是0乘法照样一个个算过去。0的存在不会减少计算量。另外模型保存时用的是稠密存储格式0也是按16位或32位存下去的所以剪完之后的模型文件大小和剪之前差不了多少。那非结构化剪枝的收益到底在哪在两个地方第一如果你把0矩阵识别出来了用稀疏矩阵格式比如CSR、COO存储内存占用会随稀疏度线性下降但PyTorch默认不支持。第二在支持稀疏算子的推理引擎或专用CPU稀疏BLAS库上才能真正把计算量降下来。所以做非结构化剪枝实验目标应该是“验证模型是否冗余、探索稀疏度上限”而不是期望立刻在生产环境里看到速度飞跃。把预期先摆正后面的实验就不会白忙。2. 剪枝原理先想清楚“剪什么”再动手2.1 权重绝对值与信息量的关系剪枝最朴素也最好用的准则就是按权重的绝对值剪。线性层的计算本质是 y Wx b输出是权重和输入逐项相乘再累加的结果。如果某个 w 绝对值很小那么它对输出的贡献 w·x 通常也小——除非对应的输入 x 特别大但经过归一化层之后输入范围通常是可控的。所以把绝对值小的权重置零对输出造成的扰动也会很小。这个思路有一个直观的生活类比一张照片里每个像素点的重要性是不一样的。边缘、纹理这些高频信息很关键而大片纯色背景里的细微色差你把它抹掉肉眼基本看不出区别。模型的权重矩阵也是一样有大量权重长期处于“低激活”状态它们在预训练过程中几乎没被有效利用剪掉它们就像抹掉背景里的轻微噪点。当然这不是一个严格数学证明而是一种启发式规则。它之所以在大量实验里都有效是因为神经网络的过度参数化程度很高训练结束时存在许多冗余解。Upcycling、彩票假设这些研究都指向同一个事实大模型里真正“干活”的参数比例可能比我们想象的低得多。2.2 稀疏度、mask、阈值三件事做实验前先把三个指标说清楚稀疏度sparsity指的是被置零的参数占总参数的比例。稀疏度50%意味着有一半权重变成了0。这个数字直接决定压缩强度。mask是一个和权重矩阵形状相同的0/1矩阵1表示保留这个权重0表示剪掉。实际操作时把mask和权重矩阵逐元素相乘就能得到剪枝后的权重。阈值是剪枝的判据。按绝对值排序后处在第 k 小位置的那个数值就是阈值绝对值小于等于阈值的权重会被置零。这三者合在一起就是一次最基础的幅值剪枝magnitude pruning。它不考虑权重之间的相互作用也不考虑某个权重是否位于更重要的层就是纯粹地“看单个权重大小办事”。听起来很粗糙实际上在10%、20%这种低稀疏度区间效果往往好得出奇这本身就是一种实验发现。2.3 为什么按层剪枝而不是全局剪枝一个容易踩的坑是对整个模型的全部权重放在一起排序然后定一个全局阈值。这个做法实现起来很简单但效果通常很差。原因在于不同层的权重分布差异很大。有些层比如第一层embedding投影、最后一层输出层的权重方差很大绝对值普遍高全局阈值会放过它们而某些层的权重整体偏小全局阈值会把一整层都剪掉直接摧毁信息传递。这就像全班统一按“低于60分退学”来刷人但如果一个班整体都考得难分数普遍低这个标准就会误伤一堆好学生。正确做法是按层剪枝也就是对每个线性层单独计算自己的阈值保证每一层都被剪掉相同比例的参数。这样做的假设是每一层都保留一定量的信息通道不会出现某一层被整层端掉的情况。实际操作下来按层剪枝的困惑度表现远优于全局剪枝尤其是稀疏度超过30%以后差距会非常明显。2.4 一次性剪枝与“剪了再训”的区别剪枝方案的第二个关键决策是剪完之后要不要微调恢复。一次性剪枝也叫one-shot pruning加载预训练权重算好mask乘上去然后直接评测。全程不做任何梯度更新。优点是快、干净、能反映模型在结构上的真实冗余程度缺点是剪得稍狠一点性能就会明显下降。剪了再训是指在mask冻结的前提下对稀疏模型继续做几轮微调只更新“幸存”的权重。这种做法通常能显著恢复性能因为零权重对应的梯度会被mask拦住模型被迫在更小的参数子空间里重新适配。我在实验里尝试过剪枝后加LoRA微调效果确实比一次性剪枝好很多。第一次做实验我建议先用one-shot把完整的折线跑出来摸清稀疏度边界再考虑要不要加恢复微调。直接上“剪枝微调”会引入两个变量出了问题不好排查。3. 实操让ChatGLM3-6B稀疏化3.1 环境与权重准备实验环境不需要很夸张一张24GB显存的显卡就够12GB显存也能跑短序列只是文本长度和batch size受限。软件方面主要是Python 3.10、PyTorch 2.x、Transformers 4.x再配一个accelerate用于设备管理。ChatGLM3-6B的权重可以从开源社区直接下载它是一个需要trust_remote_codeTrue的模型因为它包含自定义代码。模型加载代码很简单import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_path path/to/chatglm3-6b tokenizer AutoTokenizer.from_pretrained(model_path, trust_remote_codeTrue) model AutoModelForCausalLM.from_pretrained( model_path, torch_dtypetorch.float16, trust_remote_codeTrue ).to(cuda) model.eval()这里必须用fp16而不是fp32原因很简单6B参数在fp32下要占24GB显存多数消费级显卡根本放不下fp16直接减半到12GB才能有空间跑推理和评测。如果你的显存比较紧张还可以用device_mapauto让模型在多卡或CPUGPU混合加载。3.2 核心剪枝代码逐层循环加mask剪枝的核心逻辑分三步遍历模型找到所有线性层对每个层单独计算mask再把mask乘到权重上。以下是我整理好的可复现代码import torch import torch.nn as nn def prune_linear_module(module: nn.Linear, prune_ratio: float): 对单个线性层执行幅值剪枝prune_ratio0.5表示剪掉一半权重 weight module.weight.data flat_abs weight.abs().reshape(-1) # 按绝对值从小到大排序取前k个作为被剪对象 _, indices torch.sort(flat_abs) k int(flat_abs.numel() * prune_ratio) # 构建mask默认全1被剪位置置0 mask_flat torch.ones_like(flat_abs) if k 0: mask_flat[indices[:k]] 0.0 # 恢复形状并转换到和权重相同的dtype避免类型提升影响精度 mask mask_flat.reshape(weight.shape).to(weight.dtype) # 原地应用mask with torch.no_grad(): module.weight.mul_(mask) # 把mask注册为buffer后续做稀疏微调或性能分析时会用到 module.register_buffer(sparse_mask, mask, persistentTrue) return mask def prune_model_by_ratio(model, prune_ratio: float): stats [] for name, module in model.named_modules(): if isinstance(module, nn.Linear): mask prune_linear_module(module, prune_ratio) layer_sparsity (mask 0).float().mean().item() stats.append((name, layer_sparsity)) return stats有几个细节值得解释。第一我用的是排序取索引而不是直接算阈值、再比较阈值。原因前面提过排序可以精确控制每一层剪掉的数量避免阈值边界上多个浮点相等导致剪枝数量出现偏差。第二mask变成了权重同dtypefp16这样weight.mul_(mask)不会触发隐式类型提升否则mask是fp32乘完再写回fp16会经历一次精度损耗虽然单次影响很小但做实验要尽量避免无关变量。第三register_buffer这一步不是必须的但强烈建议保留因为后续如果你想做mask梯度训练需要随时访问这个mask。3.3 困惑度评估别被模型骗了剪枝完不能光靠肉眼看看生成几个句子就下结论得有一个稳定的量化指标。大语言模型压缩实验最常用的指标是困惑度perplexityppl它衡量模型对给定文本的预测能力ppl越低说明模型越“自信”地认为这段文本是合理的ppl越高说明模型越混乱。我采用的评估方式是对一组固定文本计算loss再把loss换算成pplimport math import torch from tqdm import tqdm def evaluate_ppl(model, tokenizer, texts, max_length1024): model.eval() total_nll 0.0 total_tokens 0 with torch.no_grad(): for text in tqdm(texts): inputs tokenizer( text, return_tensorspt, truncationTrue, max_lengthmax_length ).to(model.device) # 用整段文本同时作为输入和标签得到每个token的交叉熵loss outputs model( input_idsinputs[input_ids], labelsinputs[input_ids] ) loss outputs.loss seq_len inputs[input_ids].shape[1] total_nll loss.item() * seq_len total_tokens seq_len return math.exp(total_nll / total_tokens)这里有几个关键点。第一评估集必须固定。我准备了约30篇长短不一的通用中文文本统一截断到1024 tokens每一次剪枝实验都用完全相同的这份文本和相同的随机种子。如果不固定评测集ppl的波动会掩盖剪枝本身的真实影响。第二必须用model.eval()关掉dropout否则随机性会让结果抖得没法看。第三loss.item()乘上seq_len再累加最后除以总token数这样做的原因是不同文本长度不同简单平均会因为长文本占据更多token而失去公平性。3.4 一轮完整的实验表格我用稀疏度从0%到70%的一组实验记录下模型的表现。这里的数值是个人实验数据会受评测集、模型版本、随机种子影响但大致趋势有参考价值稀疏度困惑度(ppl)单次生成耗时(128 tokens)显存占用0%12.341.00x13.4GB10%12.381.00x13.4GB20%12.511.00x13.4GB30%12.871.01x13.4GB40%14.261.01x13.3GB50%21.031.01x13.3GB60%48.771.02x13.3GB70%133.651.02x13.3GB这张表能看出两件事第一模型确实存在冗余剪掉20%的权重ppl只上涨了不到0.2几乎可以忽略剪到40%时ppl开始明显抬头但生成质量仍可用超过50%后ppl迅速恶化到70%基本就是“驴唇不对马嘴”的状态。第二在标准PyTorch环境里生成耗时和显存几乎没变又一次印证了前面说的——非结构化剪枝在普通稠密计算条件下没有工程收益它的价值在于揭示模型的冗余边界。4. 踩坑记录与问题排查4.1 坑1反复加载导致稀疏度叠加第一次做实验时我想省事直接在同一个模型实例上把稀疏度从10%加到20%再加到30%。结果ppl比预期差很多后来才意识到问题prune_linear_module是在当前权重上继续剪10%剪完再做20%剪枝实际剪掉的比例不是“比原模型多20%”而是“在已有10%稀疏的基础上再剪掉剩余权重的20%”。两次剪枝的权重集合重叠导致最终稀疏度叠加不均匀某些区域被过度剪枝。正确做法是每换一个稀疏度档位都要从原始预训练权重重新加载模型再执行剪枝。这个细节看似笨拙但能保证每个实验点的剪枝对象完全一致数据才有可比性。我在最终代码里封装了一个load_original_model()函数每次实验前强制执行。4.2 坑2只盯ppl会骗人评测集必须固定中间有一轮实验我图省事用了当时手头的一些临时文本做评测。结果发现剪枝后ppl居然比剪枝前还低当时吓了一跳以为模型剪出增益了。后来仔细检查原来是评测文本里混入了两篇短文档sparse模型因为attention mask被截断后处理的边界更“平滑”反而在短文本上意外得分更高。这就是典型的评测集污染。确保可靠性的做法是准备好评测集后把文本片段固定存成一个文件每次实验都用同一个脚本、同一个切片逻辑、同一个随机种子。我在实际项目里还会把评测集分成两半一半看ppl一半用来做生成样例的人工抽检避免“指标好但实际生成乱码”的情况。4.3 坑3模型文件体积没变小是不是剪了个寂寞做完第一次剪枝我保存模型后惊讶地发现文件大小几乎没有变化。去网上查了一圈很多做剪枝的人都有同样的困惑。原因前面已经提到ChatGLM3-6B的权重保存用的是稠密格式零值也是按照fp16完整存储的一个float16的0占2字节一万个0就是20KB文件体积当然不会降。如果想让剪枝在存储层面也有收益需要把权重转成稀疏格式保存。PyTorch里可以用torch.sparse的COO格式但说实话对6B这个规模的大模型来说稀疏存储格式在推理引擎的兼容性上并不好很多推理框架根本不支持。所以如果你的目标是让模型文件变小直接做4bit量化更实在如果你的目标是验证冗余比例那0到底占不占位置根本不重要。4.4 坑4注意力层被剪后比MLP层更脆弱实验过程中我观察到一个现象同样的稀疏度下如果把注意力层的投影矩阵剪得狠一点ppl上升得特别快而MLP层的容错率明显更高。仔细想想是有道理的——注意力层的Q、K、V投影直接影响token之间的相关性建模一个Q矩阵中的权重被剪掉影响的不是一个词的输出而是整个注意力分布的权重配比误差会通过softmax扩散到所有位置。MLP层的激活函数GELU天然带有“静默区”部分神经元输出为0也不影响主路径所以剪掉一些小权重对最终结果的扰动被非线性吸收了。这也是一个可以继续做深的方向不同层、不同模块应该设定不同的稀疏度而不是全局统一一刀切。比如注意力层剪10%MLP层剪30%总平均稀疏度可能还更高但ppl表现更好。这算是非结构化剪枝实验里最有意思的发现之一。5. 后续还能做什么从“剪掉”到“部署”5.1 剪枝后的LoRA恢复实验我对剪枝到50%的模型做了一次LoRA恢复实验效果相当明显。做法是先把mask固定在权重中然后冻结整个模型只在部分层加入LoRA适配器用几千条指令数据微调。原因很简单剪枝后的模型丢失了部分表达能力但底层的通用语义结构还在LoRA在低秩子空间里做适配比全参微调更稳也不会破坏剪枝带来的稀疏性。操作时有一个关键点LoRA模块的适配器加在weight矩阵的旁边但mask冻结后权重的零值仍然在计算路径中适配器学到的更新量会叠加到这些零值上。也就是说如果某个权重被剪成0LoRA的delta在训练后会“恢复”出一个非零值。这跟稀疏训练里“梯度只作用在非零权重”的严格定义不同但从效果看LoRA恢复比完全不恢复强很多。5.2 稀疏训练只更新非零权重如果不想依赖LoRA也可以走更严格的一步——稀疏训练即让梯度只通过非零权重的位置回传零权重永远保持为零。在PyTorch里实现思路是训练循环中对梯度做一次mask乘法def apply_grad_mask(model): for name, param in model.named_parameters(): if param.grad is not None: # 查找这个参数对应的mask buffer module model for part in name.split(.)[:-1]: module getattr(module, part) if hasattr(module, sparse_mask): mask module.sparse_mask param.grad param.grad * mask这个做法能保证剪枝后的稀疏结构在微调过程中不被打乱是研究稀疏训练时常用的技术路线。但说实话在6B规模上做全参稀疏训练对显存和训练框架的要求都不低个人开发者未必划算。如果你有LoRA可用的场景LoRA恢复的性价比会更高。5.3 与量化、蒸馏的取舍把剪枝和量化放在一起对比的话我的实际体会是如果目标是单卡推理加速量化的优先级远高于非结构化剪枝。ChatGLM3-6B用4bit量化后显存占用能降到8GB左右生成速度也有明显提升且精度损失可控。而同样的稀疏度在PyTorch默认环境里没有任何生成速度收益。如果一定要组合使用建议顺序是先量化后剪枝。原因是量化会先把权重值映射到4bit空间这时候计算出的幅值分布已经和fp16不同先剪枝再量化会让量化误差在某些层被放大。反之先量化再按量化后的幅值做剪枝分布更稳定。当然这个顺序不是绝对真理不同量化方法结论可能不同但先量化后剪枝在多数实验里表现更好。5.4 真正的部署加速怎么做如果你在看完这整篇文章后还是想让模型跑得更快非结构化剪枝其实只是第一步。真正的部署加速需要两个条件一是稀疏度足够高比如60%以上模型才有足够的0可以被跳过二是计算库必须支持稀疏算子比如某些CPU上的稀疏BLAS库或者支持2:4半结构化稀疏的GPU硬件。2:4半结构化稀疏是近年特别值得关注的方向。它要求每连续4个权重中恰好有2个是0这种固定pattern可以映射到硬件加速指令在GPU上能获得接近2倍的矩阵乘法加速。用非结构化剪枝做实验时可以拿它当作一个“目标pattern”来看——通过迭代剪枝和重排逐步把稀疏权重调整为2:4结构化这是从实验到生产更现实的一条路径。一点收尾体会最后再说点个人层面的建议。非结构化剪枝在纯PyTorch环境下不是一个能立刻“变现”的部署技术它既省不了多少显存也提不了速。但作为理解大模型冗余性、评估模型压缩边界、跑通稀疏训练流程的实验载体它的实用性和性价比是其他技术很难替代的。我做完这组实验后的一个深刻感受是模型里到底有多少参数是“白养”的不同层的冗余程度差异有多大这些问题单纯靠读论文是没法真正建立直观认识的。花一个下午亲手把ChatGLM3-6B剪到只剩一半参数看它仍然能相对完整地回答问题你对“大模型为什么这么吃算力”这件事的理解会扎实很多。如果你也想动手试试建议从20%稀疏度起步逐步往上加找到你手上任务能接受的临界点再考虑要不要往稀疏训练和部署优化延伸。