剪枝与量化实操指南:顺序、细节与避坑经验
剪枝和量化这两个词我第一次听的时候以为是什么高深莫测的手术。后来自己动手跑了一圈才发现本质上就是两件事让模型里的参数变少让每个参数占的内存变小。上一篇写的是我终于把它们跑通之后的兴奋这一篇想换个心态把真正实操之后卡住人的那些细节用大白话重新捋一遍。这篇适合已经知道剪枝是删参数、量化是降精度、但还没亲自踩过坑的人如果连基本概念都还陌生建议先把基础术语补一下再回来不然我会默认你已经知道BN层、通道、浮点精度这些名词是怎么回事。1. 剪枝也要讲究姿势非结构化与结构化差在哪1.1 非结构化剪枝权重变零矩阵还是那个矩阵非结构化剪枝的操作方式非常直接训练完模型之后把权重里那些绝对值很小、对结果几乎没什么贡献的参数直接置为零。听起来简单但它带来的效果非常特别。模型里确实多了大量的零你的模型在存储时也能靠稀疏格式省下不少空间可问题在于这个矩阵的形状一点没变算起来仍然是一整块一整块地算。我打个比方你把衣柜里的旧衣服全扔了但衣柜的架子一根没拆衣柜门也还是那扇门想拿衣服照样得走原来的通道。你省的是衣服不是通道本身。真正要发挥非结构化剪枝的速度优势必须依赖专门的稀疏算子或者稀疏推理库去跳过那些零元素。这些工具在某些特定场景下确实很猛能让理论计算量大幅下降但问题在于它跟常见的密集算子不是一回事不是随便拿个现成框架就能跑出极限加速的。所以如果你只是想在普通设备上让模型快点跑纯非结构化剪枝经常是“看着很美实测一般”。1.2 结构化剪枝直接拆掉一整块结构化剪枝的思路就不同了。它不满足于把单个参数置零而是把一整块结构直接删掉最常见的做法是删掉某些卷积核也就是某个输出通道。删完之后这一层的输出特征图通道数变少下一层的卷积核数量也跟着变少整个网络真的“瘦”了一圈。这种剪法对硬件特别友好因为它没有破坏矩阵的连续性和规则形状普通算子就能吃下这种瘦身后的模型。那问题来了怎么知道该删哪个通道一个很经典的做法是看BN层的gamma参数。在训练过程中BN层会给每个通道学出一个缩放系数gamma这个值如果很小说明该通道的输出经过缩放之后几乎不影响后面层那它就属于可以被剪掉的“闲人”。我第一次听到这个思路的时候觉得挺神的后来想想也合理神经网络里确实有不少通道是冗余的把它们整条去掉精度损失往往比想象中小很多。这里有一个取舍表可以对照着看对比项非结构化剪枝结构化剪枝剪的对象单个权重参数整条通道或整个卷积核参数稀疏率可以很高通常有限矩阵形状不变变小硬件加速依赖专用稀疏算子依赖常规优化即可受益主要风险加速效果不稳定精度容易在剪多了之后暴跌适合场景研究极限压缩率工程落地、移动端部署我刚入门的时候一度觉得非结构化剪枝更高级因为稀疏率看着吓人。但后来想明白了实践里你关心的是“在同样的精度下延迟到底降了多少”而不是“参数少了百分之多少”。这句话说起来轻巧真跑过几个模型之后才会懂。2. 量化参数对称非对称、逐层逐通道到底在说啥2.1 先从scale和zero point说起量化做的事情简单讲就是把模型里原本用浮点数存的参数和中间结果换成占用位数更少的整数来存最典型的就是float32换成int8。问题在于浮点数范围是连续的、散乱的你非要把它塞进int8的256个刻度里总得有取舍。这里有两个最核心的东西scale和zero point。scale就是“每一格刻度代表多少真实数值”zero point就是“真实0映射到哪个整数刻度”。假设某一层的权重范围是[-0.6, 1.8]想让它们落入0到255这256个整数刻度里那scale就等于(1.8-(-0.6))/255约等于0.0094。zero point的作用是处理那些不对称的情况比如真实值里没有0或者0并不是范围正中间这时就必须加一个偏移量避免量化完之后真实0对应不到整数。这个机制不复杂但它决定了后面所有误差从哪来。量化误差本质上就是你把连续数值强行压成离散整数时那个“塞不进去”的余数。2.2 对称和非对称一个是规则箱子一个是定制箱子对称量化假设数据的正负范围是对称的只看最大值和最小值的绝对值谁更大然后取它作为边界。这样zero point就可以固定成0整数和实数之间的换算少一步实现起来轻巧硬件算得也快。非对称量化则允许下限和上限各自独立能更贴合数据实际分布。打个比方同一个行李箱对称量化像是给你发一个统一规格的方箱子不管东西是什么形状都往里面塞非对称量化则是量好尺寸定做一个箱子贴合度更高但定制总归更麻烦。实际用的时候激活值往往分布不对称很多情况下非对称效果更好而权重经过训练后一般比较对称用对称量化也能扛得住。你不需要自己选大多数现成工具都会在里面对不同的张量类型选择默认策略。但你应该心里有数如果换了一种部署引擎之后精度忽高忽低先去看看它默认用的是哪种方案这个排查思路能帮你省半天时间。2.3 逐层量化与逐通道量化一个尺子还是一堆尺子逐层量化是整个层里的所有输出通道共用同一套scale和zero point。好处是简单、计算量小坏处是一刀切误差大因为不同通道的数值尺度可能差得很远。逐通道量化则是给每个输出通道单独配一套scale有的通道数值范围大一点有的小一点各用各的尺子量。这个方案对权重特别友好能在相同bit数下明显提升精度尤其是在那些通道间数值方差很大的模型里。代价就是计算和存储开销变大某些硬件上支持还不完整。你可以理解为班级统一订校服和每人量身定制的区别统一订便宜但总有人穿着不合身量身定制舒服但贵。量化这里也一样要在成本和精度之间找平衡点。3. 剪枝和量化一起上顺序到底怎么选3.1 我推荐“先剪后量”因为下游统计得跟着变我最早天真地以为先剪后量、先量后剪都一样反正都是做压缩。实操之后才发现顺序会直接决定结果能不能看。如果你先把模型量化到int8再去剪枝剪完之后的权重分布已经变了但量化参数还是剪之前定下来的两者对不上误差就会叠着放大。更别扭的是量化本身已经让模型变得“脆”了你再动它的结构精度往往直接崩塌。反过来先做通道剪枝把那些不重要的通道删掉模型本身已经变成一个新的、更紧凑的结构然后再做量化校准让量化参数去适配剪完之后的新分布这样每一步的误差来源都是清晰的。整条链路我一般这么走先训练好一个完整模型做一遍每层剪枝的敏感度探查然后按比例剪掉冗余通道再做一段很短的微调让剩下的通道重新适应最后挑好校准数据做量化。顺序一旦反了后面排查起来会特别痛苦因为你说不清精度掉到底是剪枝引起的还是量化引起的还是两者叠加引起的。3.2 “先剪后量”不是万能顺序也要留后路当然也有例外。如果模型压缩的目标是尽量保住精度而部署引擎对量化方式限制很多那我会先跑一版全量模型直接量化拿到一个“量化后的精度天花板”作为参照。然后再跑剪枝加量化的实验如果结果和天花板差很多就知道是剪枝部分出了问题如果差得不多说明剪枝是划算的。这个方法很土但排查问题的时候特别有用。另外要注意剪完之后必须微调但别微调过头。微调太久模型会慢慢偏离之前学到的信息反而可能过拟合。我一般只做几百步或者两三个epoch的学习率很小的微调目的只是让剩下的通道重新适应而不是重新训练整个模型。这个分寸感说多了没用自己跑两遍就明白了。4. 用大白话走一遍从完整模型到int8的完整流程4.1 伪代码先摆出来我不建议把这篇文章当成某个特定框架的教程来看因为剪枝和量化在不同工具里的API差别很大。为了把思路说清楚我用一套故意简化的伪代码走一遍你只需要看懂每一步在干嘛# 打开你常用的训练框架加载一个训练好的小分类模型 model load_pretrained_model(path/to/checkpoint) # 第一步结构化剪枝 # 假设用BN层的gamma参数来衡量每个通道的重要性 gamma_list extract_bn_gamma(model) keep_index choose_top_channels(gamma_list, keep_ratio0.6) model_pruned prune_channels_by_index(model, keep_index) # 第二步短微调 for step in range(300): loss train_step(model_pruned, train_loader, lr1e-4) # 这一步不是从头再训而是让剩下的通道重新适应 # 第三步量化 model_pruned.eval() calib_data build_calib_loader(val_loader, num_batches16) calibrate(model_pruned, calib_data, bit_width8) quant_model convert_to_int8(model_pruned) # 第四步验证 acc evaluate(quant_model, test_loader) size_mb get_model_size(quant_model) latency_ms benchmark(quant_model, devicecpu) print(acc, size_mb, latency_ms)这段代码里的函数都是我编出来的不同框架里各有各的对应接口但流程骨架就是这四步。4.2 每一步实际在干什么第一步里extract_bn_gamma就是把每个通道的gamma值取出来按重要性排序。choose_top_channels里的keep_ratio0.6表示我要保留60%的通道也就是剪掉40%。这个比例千万别第一次就拍板建议从一个比较保守的值开始比如0.75然后逐步往下降同时记录精度变化。prune_channels_by_index是真正动手删除通道的操作这一步会把模型的结构重新整理生成一个更薄的网络。做完之后模型就真的小了但精度多半会掉一点所以第二步的短微调是用来“止血”的。第三步的calibrate是量化的关键它不训练参数只是拿一批有代表性的数据跑前向观察每一层激活值的分布然后根据这些分布去定scale和zero point。你可以把这一步理解成先派人去量房间尺寸量完之后才敢买家具。最后一步当然是验证但验证别只看精度要把模型大小和延迟一起看。4.3 为什么要用伪代码而不是直接抄真实API因为我见过太多人第一步就卡在“到底调用哪个函数”上反而忘了整个流程在做什么。剪枝和量化的通用流程跟具体工具的关系其实没有想象中那么大。你只要理解了“先剪、再微调、再校准、最后量化”这个次序换成任何框架都是去套对应的函数而已。反过来如果一上来就对着某个框架的文档猛抄换个模型换个工具就抓瞎了。流程比API值钱这是我的个人体会。5. 实测最容易翻车的四个坑5.1 BN的running stats剪完之后会变成“猪队友”BN层在训练时会维护一组running mean和running variance也就是整个训练集统计出来的均值和方差。这组统计量在模型剪枝之后很容易失效因为通道被删了剩下的通道输出分布已经变了但BN里的统计量还停留在剪枝之前。如果不重新校准模型前向传播出来的激活值可能整体偏移精度直接掉一截。我第一次剪完不微调直接量化精度掉到跟没训练一样后来排查了半天才发现是BN统计量的问题。解决办法是在短微调之后用一小批数据重新跑一遍前向把running stats更新一下再去做量化校准。5.2 bias的量化问题很多人直接忘了它卷积和全连接层的bias在量化时很容易被忽略。bias的数值范围和权重不一样直接拿整层的scale去量化bias误差会特别大甚至可能把一个很小的偏置量给吃掉导致输出产生固定偏移。常见的做法是bias保留浮点或者做专门的bias矫正。另外如果你做了BN融合那bias里其实还藏着BN里的均值偏移信息这个细节要是没处理干净量化后的模型精度会莫名其妙地掉。我的习惯是量化之前先看一下每一层bias的数值范围如果和权重的范围差了一两个数量级多半要单独处理。5.3 校准集太小或太偏量化参数被带跑偏校准阶段要选一批数据去统计激活值的分布。这个校准集不是越多越好但太少、太偏肯定不行。如果你只拿几十张图而且全是同一个类别那统计出来的激活值范围就会被这几个样本死死拽住实际推理时其他样本的数值全得往这个范围里压误差自然大。我的经验是校准集至少要覆盖所有类别数量在几百张级别就可以而且要从验证集里随机抽别傻乎乎地拿前几十张。校准集的选择影响的是所有层的scale和zero point这一步偷懒后面所有误差都会叠起来。5.4 剪枝率拍脑袋定每个层能剪的比例完全不一样有些人喜欢给所有层统一设一个剪枝率比如每层都删掉40%的通道这样写代码省事但效果往往很糟。不同层对剪枝的耐受度差别很大靠近输入层的卷积通常对结构损失很敏感剪多了细节特征直接没了而靠近分类层的层往往冗余更多剪一剪影响不大。正确做法是先做一层一层的小实验固定其他层不动单独把某一层的通道删掉一点看看精度掉多少这样逐层摸清哪些层能下手重、哪些要轻拿轻放。这个过程听起来费时间但它能让你对模型结构有非常直观的认识。6. “模型变小了”和“跑得更快”之间隔着三件事6.1 硬件要真的认识你的新格式模型从float32变成int8文件体积确实会明显变小但推理速度能不能提升取决于你用的硬件推理引擎有没有针对int8做专门的加速。有些设备上普通浮点算子反而优化得更好int8转换之后如果不走专用的快路径还得临时做反量化速度不一定变快。所以我一直强调不要只看理论计算量要真的去测一下端到端延迟。这个道理跟压缩包一样zip文件体积确实小但解压本身也要时间解压完打开文档未必比直接打开快。6.2 算子和内存排布决定加速的上限就算硬件支持int8执行时还有算子融合和内存排布的问题。比如卷积后面接着BN再接激活函数如果能融合成一个算子数据在内存里少搬运好几次提速非常明显。通道剪枝如果剪得不够彻底内存排布还是乱的访存效率上不去加速也会打折。这些属于部署优化的细节初学时不用全懂但至少别把“模型变小”直接等同于“一定变快”否则后面发现延迟没降下来会特别挫败。6.3 验证速度时别忘了同时测精度和模型体积我现在验证一个压缩方案永远把三个数放在一起看测试集精度、模型大小、单次推理延迟。只看任何一个都是片面的。你能把模型压缩到原来的四分之一但精度掉了八个点这方案多半不能用精度只掉了一点但延迟纹丝不动那说明压缩成果还没有真正转化成加速需要继续优化算子支持或者调整内存排布。把这三个数摆在一起你才能判断这一次到底是真优化还是自我安慰。我自己现在再上手一个新模型一定会先做一次剪枝敏感度探查再决定动不动手。剪枝量化不是一锤子买卖而是一套需要反复试的工程活。最初觉得调参数好麻烦后来发现这种麻烦恰恰是值得的因为它逼着你一点一点理解模型的每一个结构到底在干什么。