把一个模型训到指标好看,只是走完了上半场。真正让人头疼的是下半场:参数量 7B 的模型要放进只有 8G 显存的边缘盒子,推理延迟要求 30 毫秒但单次前向就要 200 毫秒,服务端一个月的算力账单比标注数据还贵。模型压缩解决的正是这个落差——它不追求把模型做得更聪明,而是尽量在精度不掉太多(或者干脆不掉)的前提下,把显存占用、计算量和带宽成本压下来。剪枝、量化、蒸馏这三条路线我反复用过很多轮,也踩过不少坑:剪完的模型理论上稀疏度 80%,实测速度一点没变;量化后离线评估一片绿,上线三天收到一堆badcase;蒸馏训了二十轮学生模型反而比从头训还差。这篇就把这三把刀各自的物理边界、实操流程和我真正会去检查的那几个指标,完整摊开讲一遍。面向的是已经能把模型训起来、准备往部署端推的读者,也适合想搞清楚"为什么压缩完反而更慢"这类反直觉问题的同行。
1. 三把刀切的是不同的肉:剪枝、量化、蒸馏的定位差异
很多人把这三件事当成一个菜单,觉得挑一个最顺手的用就行。实际它们优化的维度根本不是同一个东西:剪枝动的是参数的存在性,量化动的是参数的表示精度,蒸馏动的是知识的载体。搞清楚这一点,后面所有的选型和顺序问题都会自然清晰。
1.1 用"删、压、教"三个动作建立直觉
剪枝可以类比成给一棵树修枝。模型里大量权重接近于零,或者某些通道的输出对最终结果几乎没有贡献,把这些结构整体摘掉,剩下的部分重新长一遍(微调恢复),就能得到一个小一圈的模型。注意这里"摘掉"必须是结构性的:如果只是把权重置零但张量形状不变,那叫稀疏,不叫变小,GPU 上该算的乘法一个都省不掉。
量化是把一本精装书换成口袋书。原来每个权重用 32 位浮点存,现在用 8 位整数存,模型体积直接降到四分之一,而且现代推理硬件对 INT8 的乘加吞吐通常是 FP32 的两到四倍。代价是每个数都被"四舍五入"到最近的格点,误差会累积。量化的魅力在于它几乎不改变网络结构,工程改造量小,见效快。
蒸馏则是师傅带徒弟。小模型(学生)自己去学任务标签,只能看到"这张图是猫"这种硬信息;而大模型(老师)输出的是一整条概率分布——"80% 是猫,15% 是猞猁,4% 是狗",这条分布里藏着类别之间的相似性关系,信息量远大于一个 one-hot 标签。学生模仿这条分布,等于免费拿到了老师的"暗知识"。
注意:这三种手段并不是互斥的,实际项目里最常见的是"先蒸馏出一个结构更小的学生,再对它剪枝,最后量化部署"。但顺序一变,效果可能差出一大截,第 5 节会专门算这笔账。
1.2 三个维度的指标对照
我把这三种手段最常被问到的几个维度拉平放在一起,这张表我基本每次做方案汇报都会拿出来用:
| 维度 | 剪枝 | 量化 | 蒸馏 |
|---|---|---|---|
| 直接收益 | 计算量 FLOPs 下降、部分场景显存下降 | 模型体积降至 1/4 或 1/2、带宽压力骤降、算力吞吐提升 | 可用更小 backbone,参数量与延迟同时下降 |
| 精度损失风险 | 中到高(结构化剪枝更明显) | 低到中(INT8 通常 1 个点以内) | 低(学生容量足够时甚至反超) |
| 工程改造量 | 大(要改模型结构、要重训) | 小到中(工具链成熟,主要在校准) | 中(要维护双模型、改训练流程) |
| 是否需要重训 | 结构化剪枝基本必须 | PTQ 不需要,QAT 需要 | 本身就是一个训练过程 |
| 加速是否"自动兑现" | 否,取决于是否结构化 | 是,取决于硬件是否支持 INT8 | 是,student 更小就是更快 |
| 典型压缩比 | 通道数砍 30%~50% | 4x(FP32→INT8) | 学生参数约为老师的 1/10~1/3 |
这张表里最容易被忽略的是"加速是否自动兑现"这一行。我见过不止一个团队做完剪枝,稀疏度报表写着 75%,结果上线延迟纹丝不动——因为他们做的是非结构化剪枝,硬件根本吃不到这个稀疏性。
1.3 怎么选:从瓶颈在哪倒推手段
选型不要从"我想用什么技术"出发,要从"现在卡在哪"倒推。下面是我自己在用的判断路径,用文字描述比画图更清楚:
- 瓶颈是显存放不下、模型文件太大、内存带宽打满:优先量化。这是投入产出比最高的一刀,往往一天就能出结果,精度掉点在 1% 以内都算正常损耗。
- 瓶颈是算力不够、FLOPs 太高、延迟要求苛刻,且模型本身有大量冗余通道:优先剪枝,但一定选结构化(通道级或层级的)剪枝。
- 瓶颈是"我没法改部署链路,只能用更小的模型架构,但又不想重新收集标注数据、不想从头训":选蒸馏。它不依赖新数据,只依赖老师能在无标注数据上给出软标签。
- 三个瓶颈同时存在:组合使用,顺序见第 5 节。这种情况下我的默认起手式是"先蒸馏定型,再剪枝瘦身,最后量化交付"。
再补一条很多人不愿意承认的经验:如果模型本来就没多少冗余(比如已经是 MobileNet 级别的轻量 backbone,或者任务本身对细微特征极其敏感,像细粒度分类、小目标检测),剪枝的性价比会急剧下降。这时候硬剪,往往花两周时间换来 5% 的速度提升和 3 个点的精度下降,不如老老实实量化加蒸馏。
2. 剪枝:从"能删"到"删完真能变快"之间的鸿沟
剪枝是三个里面最容易做出漂亮报表、也最容易自欺欺人的一个。原因很简单:稀疏度和实际加速之间隔着一整个硬件栈。
2.1 非结构化剪枝为什么常常白干
非结构化剪枝的做法是:把权重张量里绝对值最小的那批元素置零,得到一个稀疏矩阵。数学上你的参数量确实少了 70%,存储如果用稀疏格式(CSR/CSC)也确实能省。但主流 GPU 的矩阵乘法单元是为稠密计算设计的,它一个 warp 里的线程要同时取连续地址的数据,你给它一个零散分布的非零元素,它取数据的时间比算乘法的时间还长。
所以会出现这种荒诞结果:稀疏度 70%,理论 FLOPs 降 70%,实测单张图推理时间从 24ms 变成 26ms,因为多了一层索引映射的开销。这就是我标题里说的"鸿沟"。
要吃到非结构化稀疏的收益,需要硬件或推理引擎原生支持稀疏张量核。部分推理框架提供了 2:4 结构化稀疏(每 4 个连续权重里保留 2 个),这种模式下硬件能拿到接近 2 倍的加速,而且对精度的影响在可接受范围。但前提是你必须严格按 2:4 的 pattern 去剪,模型质量和稀疏模式强绑定,灵活度很低。
我的建议很直接:如果你的部署目标是通用 GPU 或移动端 NPU,默认放弃非结构化剪枝,直接做通道级结构化剪枝。除非你明确知道目标硬件支持稀疏加速,否则那些稀疏度报表只是给自己看的。
2.2 结构化剪枝:通道打分与实操流程
结构化剪枝的最小单位是卷积通道或者线性层的输出神经元。把一个通道删掉,输出特征图的对应通道就没了,下一层的输入通道也要跟着删,这就带来一个连锁约束:卷积层和紧随其后的 BN 层必须同步剪,残差连接两侧的通道必须保持对齐。这些约束是结构化剪枝麻烦的根源。
最常见的打分方式是 BN 层的缩放因子 γ。BN 在推理时等价于一个逐通道的仿射变换,如果某个通道的 γ 接近 0,说明这个通道的输出被压得非常扁,对后续贡献很小。这套思路的好处是:γ 本来就在训练里被梯度更新,你只要在 loss 里给它加一个 L1 正则,让 γ 在训练中主动趋向稀疏,然后再按 γ 大小排序剪枝,就相当于让网络自己告诉你哪些通道可以丢。
下面是我常用的一个最小可跑的骨架,基于 PyTorch 的 BN 参数做通道排序:
import torch import torch.nn as nn def collect_bn_gammas(model, threshold=1e-3): """收集所有 BN 层的缩放因子,返回 {层名: (gamma张量, 保留索引)}""" plan = {} for name, m in model.named_modules(): if isinstance(m, nn.BatchNorm2d): gamma = m.weight.detach().abs() keep_idx = torch.where(gamma > threshold)[0] plan[name] = (gamma, keep_idx) return plan def sparsity_regularizer(model, coef=1e-4): """训练时加在总loss上,逼BN的gamma向0收缩""" reg = 0.0 for m in model.modules(): if isinstance(m, nn.BatchNorm2d): reg = reg + m.weight.abs().sum() return coef * reg # 训练循环里: # loss = criterion(out, target) + sparsity_regularizer(model, 1e-4)用正则化训完之后,按照全局的 γ 分位数确定剪枝阈值,而不是每层各剪各的比例——全局剪枝能让重要层保留更多通道,比逐层均匀剪精度更好。实践里 30%~50% 的通道剪枝率是安全区,超过 60% 就需要相当长的恢复训练才能救回来。
真正改结构的时候,建议别自己手写通道索引搬运,容易在残差和 concat 处翻车。可以用 torch-pruning、NNI 这类支持依赖图自动传播的工具,你只需要告诉它"这一层的第 3、7、11 个通道不要了",它会帮你把下游所有受影响的层一起改掉。这个细节非常重要,我最早手写过一次,残差分支没对齐,训了一晚上 loss 一直在 NaN 附近震荡。
2.3 恢复训练:学习率和轮次的取值逻辑
剪枝必然掉点,掉多少取决于剪枝率。通道砍掉 40%,ImageNet 上 top-1 掉 3~8 个点很常见。恢复训练的目标不是让它学新东西,而是让剩下的通道重新分工,把被删通道的职责接过来。
关键参数的经验值:
- 学习率:用原始训练峰值学习率的 1/10 到 1/100。因为模型已经在一个不错的局部极小值附近,学习率太大会把它炸出这个盆地。我一般从 1e-4 起步,配合 cosine 衰减。
- 迭代轮次:原始训练总轮数的 10%~30%。比如原模型训了 100 epoch,恢复训练跑 15~30 epoch 通常够。剪枝率越高,需要的轮次越多。
- 冻结策略:前 1~2 个 epoch 可以只训 BN 层和最后的分类头,等统计量重新稳定了再放开全部参数。这一步能显著降低早期震荡。
还有一个反直觉的点:恢复训练时不要再加稀疏正则。正则的目的是寻找冗余,现在冗余已经剪掉了,继续加只会让剩下的通道也被压扁,反而拖慢收敛。
2.4 剪枝实操里最容易翻车的四个点
我把这些年踩过的坑整理成一张排查表,基本上遇到问题按这个顺序看就行:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 剪完不重训直接评估,精度断崖 | 正常现象,剪枝必然要恢复训练 | 直接进恢复训练流程,不要慌 |
| 稀疏度报表好看但延迟不变 | 做了非结构化剪枝,硬件吃不到 | 换通道级结构化剪枝 |
| 恢复训练 loss 震荡 / NaN | 残差或 concat 处通道没对齐;学习率过大 | 用依赖图工具重剪;学习率降一个量级 |
| 某一类精度掉得特别狠 | 该类依赖通道被批量剪掉 | 改成分组剪枝,按类敏感度保留通道;或调低整体剪枝率 |
除此之外还有两个纸质文档里不会写的事。第一,剪枝前先做一次逐层敏感度分析:每次只剪一层、剪 20%,看验证集掉多少点,把最不敏感的那些层列出来优先剪。这个分析大概花两个小时,但能让你的剪枝率上限从 40% 提到 55% 左右,非常值。
第二,分类头和 neck 部分尽量别动。很多团队的默认操作是全网均匀剪枝,结果分类头被剪得太狠,最后一层的表达能力不够,怎么训都救不回来。我一般的做法是 backbone 剪 40%,neck 剪 20%,head 完全不剪——head 参数量本来就小,剪它省不下什么算力,却最容易伤精度。
3. 量化:把 FP32 压成 INT8,误差究竟从哪儿来
量化是三条路里工程性价比最高的一条,但它也是最容易"离线评估全绿、线上翻车"的一条。理解误差来源,比会调工具重要得多。
3.1 从浮点到定点:scale 与 zero point 到底在算什么
一个浮点张量要映射到 8 位整数,本质是一个仿射变换:
q = round(x / scale) + zero_point x_approx = (q - zero_point) * scale其中 scale 是"一个整数格点代表多大的实数跨度",zero_point 是偏移量,作用是让实数 0 能精确地被某个整数表示。为什么必须保证 0 的精确表示?因为 padding 用的就是 0,如果 0 被量化成 3,那所有 pad 区域都会注入一个偏置噪声,这个坑很多人不知道。
scale 的确定方式是 range / 255(对称量化)或 range / 255 的非对称版本。问题就出在这个 range 上:它由校准数据里观测到的最大最小值决定。如果你的校准集恰好包含一个极端异常值(比如某张图特别亮),max 会被拉得很高,scale 随之变大,其余 99% 的正常数值就都被挤在很少的几个格点上,分辨率严重不足。这就是所谓的"离群值主导量化误差"。
对激活值来说这个问题尤其严重。权重的分布相对温和、接近对称,而激活值经常有长尾——某些通道在特定输入下会突然爆一个很大的值。所以权重通常能安全地用对称量化、per-channel(逐输出通道各自一个 scale),而激活值只能 per-tensor,抗离群值能力差很多。
一个实用的判断方法:拿校准集跑一遍,统计每个激活张量的 max/99.9 分位数之比。如果这个比值超过 5,说明存在明显离群通道,直接 min-max 校准大概率掉点,应该改用 KL 散度校准或者百分位截断。
3.2 PTQ 与 QAT:什么时候必须上 QAT
PTQ(训练后量化)的流程是:拿一个训练好的 FP32 模型,喂几百张校准图,统计各层激活的分布范围,然后把权重量化、把推理图改写成 INT8 算子。整个过程不需要标签、不需要反向传播,半小时能搞定。它的问题在于,量化误差是"事后"引入的,模型完全没有机会去适应。
QAT(量化感知训练)在训练时插入伪量化节点:前向时把权重和激活真的截断、取整、再反量化回浮点,模拟量化误差;反向时因为取整不可导,用 STE(直通估计器)把梯度原样传过去。这样模型在训练过程中就"体验"过量化误差,会主动调整权重分布去规避它。
选择依据我给一个简单粗暴的标准:
- 模型是 ResNet / MobileNet 这类结构规整的 CNN,任务是分类:直接 PTQ,掉点在 0.5% 以内就收工。
- 模型是 Transformer、检测、分割、或者任何含 LayerNorm / Softmax 敏感算子的结构:先试 PTQ,掉了超过 1.5 个点就转 QAT。
- 精度要求极其苛刻(比如指标必须在 0.3 个点内波动):一开始就上 QAT,别浪费时间试 PTQ。
QAT 的成本大概是 PTQ 的 20 倍工时,但它通常能把掉落从 2~3 个点收回到 0.3 个点以内。这笔投入在什么情况下划算?我的判断是:如果模型的精度直接对应线上收入或安全底线,那必须 QAT;如果是内部工具类模型,PTQ 掉 1.5 个点能接受就接受,不要过度优化。
3.3 校准集怎么选:这一步决定了量化的天花板
校准集的规模不是越大越好。我的经验是 200~500 张有代表性的样本足够,关键是覆盖度而不是数量。具体做法:
- 从验证集里按类别分层抽样,保证每个类都有样本,别随机抽出一个只有三个类的子集。
- 一定要包含"难例"和"边缘场景"——低光照、模糊、遮挡、罕见角度。校准阶段的 max 值由最难的那张图决定,如果校准集里全是干净样本,线上遇到脏数据就会溢出到饱和区,误差直接失控。
- 校准集的预处理必须和线上完全一致。我遇到过一次,校准脚本忘了做归一化,输入动态范围差了 255 倍,量化后模型输出全是常数,排查了一整天才发现。
校准算法上,min-max 最简单但最怕离群值;KL 散度校准会找一个截断阈值,让截断前后的分布 KL 距离最小,对激活长尾更友好;百分位校准(比如取 99.99 分位)介于两者之间,实现简单,我日常用得最多。经验上,KL 和百分位在 Transformer 类模型上比 min-max 稳定 0.5~1 个点。
3.4 掉点排查的完整顺序
量化掉点时不要乱试,按这个链路往下走,基本能在半天内定位:
第一步,确认是不是可量化算子之外的部分出问题。把模型拆成"量化主干 + 浮点头尾"两段,先只量化 backbone,如果 backbone 单独评估掉点很小,那问题在 head 或者后面的敏感算子。
第二步,逐层定位。用工具把每一层的输出和 FP32 版本做余弦相似度对比,掉得最狠的那几层就是重灾区。常见的重灾区是:第一个卷积层(输入动态范围大)、最后一层(输出直接决定 logits)、以及 LayerNorm 之后的激活。
第三步,针对重灾区做特殊处理。有三招:把这几层保留为 FP16/FP32(混合精度量化),这是最快见效的;把这几层的量化粒度从 per-tensor 改成 per-channel;或者把这几层排除在校准统计里,单独用更精细的百分位阈值。
第四步,如果还不行,转 QAT。但转 QAT 之前先确认一下,是不是模型本身已经处于"精度极限",量化只是压垮骆驼的最后一根稻草。这种情况下更好的方案是换一个稍微宽一点的模型再量化,而不是硬压。
特别提醒一个线上翻车的典型场景:离线评估用的是标准的 resize + center crop,线上是任意长宽比的原始图。不同长宽比会导致量化后的激活分布偏移,我见过一次 PTQ 离线掉 0.4 个点,线上实际掉了 3.1 个点,原因就是线上输入分布和校准集对不上。解决办法很简单:校准集直接采线上真实流量样本。
4. 蒸馏:学生要模仿的不是答案,是老师的"犹豫"
蒸馏看起来最简单——不就是让小模型学大模型的输出吗?但真正做起来,很多细节决定了它是提点 2 个点还是掉点 1 个点。
4.1 温度系数与软标签的作用机理
老师的原始输出经过 Softmax 之后,如果 logit 差距很大,分布会非常尖锐:0.99 对 0.01。这条分布里类别之间的相对关系信息几乎被抹平了。温度系数 T 的作用就是在做 Softmax 之前先把 logit 除以 T,把分布"摊平":
soft_target = softmax(logits / T)T 越大,分布越平,暗知识暴露得越充分;但 T 太大,分布趋近均匀,噪声也会被放大。经验区间是 T 在 2 到 5 之间,任务类别数越多(比如 1000 类),T 可以取大一些;类别少(比如 10 类),T 取 2 左右。这个值值得花半天扫一下,不同数据集上的最优值差异不小。
损失函数通常是两项加权:
import torch.nn.functional as F def distill_loss(student_logits, teacher_logits, labels, T=3.0, alpha=0.7): # 软标签损失:学生分布逼近老师分布,梯度要乘 T^2 做尺度补偿 soft = F.kl_div( F.log_softmax(student_logits / T, dim=-1), F.softmax(teacher_logits / T, dim=-1), reduction="batchmean" ) * (T ** 2) # 硬标签损失:防止学生盲目模仿老师的错误 hard = F.cross_entropy(student_logits, labels) return alpha * soft + (1 - alpha) * hard这里有两个必须说清楚的点。第一,T² 那个补偿不能漏。因为除以 T 之后梯度量级会缩小 T² 倍,不乘回来会导致软标签项在总损失里被稀释,蒸馏等于没做。第二,alpha 不要设成 1。纯软标签在老师犯错时会把错误也传给学生,硬标签是最后一道保险。我一般 alpha 取 0.6 到 0.8,老师越强、越可信,alpha 越接近 1。
还有一条:老师模型在训练时一定要开 dropout、要加数据增强,让它的软标签带一点"不确定性"。如果老师是全知全能的完美状态,输出过于确定,学生学不到东西。反过来老师如果太弱(比如只是比学生大一点),蒸馏的收益也会很小。一般师生参数量比在 5:1 到 20:1 之间比较合适。
4.2 中间层对齐:只学输出会丢掉过程信息
只对 logits 做蒸馏,学生只能学到"最终判断",而不知道老师是怎么一步步提取特征的。中间层蒸馏就是把学生的某几层特征图和老师的对应层做对齐:
loss_feat = MSE(proj(student_feat_i), teacher_feat_j)这里有个绕不开的问题:学生和老师的通道数不一样,形状对不上,所以需要一个投影层(1x1 卷积或者线性层)把学生的特征升维到老师的维度。这个投影层会增加一点参数量,但它只在训练时存在,推理时可以直接扔掉。这一点很多新手不知道,白白纠结半天。
选哪几层对齐也有讲究。我的经验是选 backbone 的中间偏后的位置,太靠前的层学到的是边缘、纹理这些低级特征,师生的表示差异本来就大,强行对齐反而有害;太靠后的层已经接近任务特定表示,对齐的收益变小。一般每个 stage 的最后一层取一个,全网络 3~4 个对齐点就够。
对 Transformer 来说,常见的做法是对齐注意力矩阵而不是特征图——因为学生和老师的头数、维度都可能不同,但注意力矩阵的语义是一致的(都是 token 之间的关联强度)。也有对 QKV 的 value 矩阵做对齐的变体,效果视任务而定,我在文本分类上试过,对齐注意力矩阵比对齐特征图稳定一些。
4.3 蒸馏不 work 的几个真实原因
我遇到过学生训不过从头训的情况,总结下来无非几类:
- 容量差距太大。学生太小(比如老师 300M,学生只有 5M),学不动老师的分布。这时候应该先做中间规模过渡,用"大→中→小"的两段蒸馏。
- alpha 和 T 没调。这是最常见的原因,默认值直接套用,十个任务里能有三四个效果很差。至少扫一遍 T ∈ {2,3,4,5},alpha ∈ {0.5,0.7,0.9}。
- 学生的数据增强比老师弱。蒸馏时学生看到的数据分布必须不小于老师训练时的分布,否则学生会在没见过的样本上崩溃。这一点常被忽略——很多人觉得学生小、训练快,就少用了几种增强。
- 老师模型过拟合。老师在自己的训练集上 99%,泛化到学生看到的数据上其实没那么强。解决办法是让老师在训练时用更强的正则,或者用 k-fold 的多个老师做集成,取平均软标签。
还有一条软经验:蒸馏的收益和任务难度强相关。在类别少、区分度高的任务上(比如二分类的缺陷检测),蒸馏提点非常有限,因为标签本身信息量就够;而在细粒度分类、多标签、检索这类任务上,类别间的相似关系信息量大,蒸馏往往能补回学生一半以上的精度差距。
5. 组合拳:三招叠加时的顺序与验收标准
单个手段做到位之后,真正的问题是:组合起来该按什么顺序?收益会不会互相抵消?
5.1 先剪后蒸还是先蒸后剪:收益叠加的规律
我实际跑过三种顺序,结论写在下面:
| 顺序 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 剪 → 蒸 | 先把大模型剪小,再用原大模型当老师蒸剪后模型 | 剪枝的精度损失能被蒸馏补回来一部分 | 剪完的模型和老师的结构差异大,蒸馏对齐困难 |
| 蒸 → 剪 | 先蒸出小结构学生,再对学生剪枝 | 学生结构规整,剪枝和量化都更友好 | 剪枝的二次损失没有老师兜底 |
| 蒸 → 剪 → 微调 | 前两步之后加一轮轻量微调 | 三种损失都能收回来,精度最稳 | 总工时最长 |
我现在的默认流程是第三种,实测下来精度损失最小。具体地说,如果目标是把一个 300M 的模型压到 20M 左右,我会这样分配:蒸馏负责把 300M 降到 60M(这一步能保住 95% 以上的精度),剪枝负责 60M → 28M(掉 2 个点左右),恢复训练把损失收回到 1 个点以内,最后量化 28M 的 INT8 版本(再掉 0.5 个点)。整体端到端精度损失控制在 1.5 个点,延迟下降 8 倍左右。
反过来,如果先剪后蒸,我遇到的主要问题是结构不匹配。剪枝之后模型有很多不规整的通道数,和老师的层对不上,做中间层蒸馏时需要写一堆映射逻辑,工程复杂度急剧上升。除非你的场景里没有可用的原版大模型(只能蒸自己),否则我不推荐这个顺序。
5.2 一条完整的流水线与每一环的验收线
把整个流程固化下来,大概是这样的:
- 基线固定。先在目标硬件上把 FP32 原模型的延迟、吞吐、显存、精度全部测一遍,把测试脚本和固定验证集版本都锁住。这一步不做,后面所有的加速比都是空的。
- 蒸馏定型。选出目标结构的学生模型,用 T 和 alpha 扫参的方式训一轮,直到学生的精度和老师的差距稳定在 5% 以内。这时候如果差距还很大,先别往下走,回去检查学生容量或者老师的软标签质量。
- 敏感度分析。在学生的结构上逐层剪 20%,记录精度变化,画出敏感度排序。这一步花两小时,能省下后面反复试错的两天。
- 结构化剪枝。按敏感度排序,从低敏感层开始剪,目标是让整体 FLOPs 下降 40% 左右。剪完立刻用小学习率恢复训练 15 个 epoch。
- 量化。先 PTQ,测精度和延迟;掉点超过阈值就转 QAT 或对敏感层做混合精度。
- 端到端验收。在真实硬件、真实输入尺寸、真实 batch size 下测三项:延迟 P50/P99、峰值显存、完整验证集精度。
每一环的验收线我列一下,方便对照:
| 环节 | 精度让步上限 | 关键验收指标 |
|---|---|---|
| 蒸馏 | 与老师差距 ≤ 3%(同等任务) | 学生参数量、实际推理延迟 |
| 剪枝 + 恢复训练 | ≤ 2% | 实际 FLOPs、延迟(不是稀疏度) |
| PTQ | ≤ 1% | 硬件 INT8 算子覆盖率 |
| QAT | ≤ 0.3% | 训练成本、算子兼容性 |
注意"硬件 INT8 算子覆盖率"这一项。即使你把模型量化成了 INT8,如果某些算子(比如某些自定义的激活函数、非常规的 reshape)在目标硬件上没有 INT8 实现,推理引擎会自动 fallback 回 FP32,这些层就成了整条流水线的瓶颈。我见过量化后体积降了 4 倍、延迟只降了 1.2 倍的情况,原因就是有 3 个算子在 fallback,占了 60% 的时间。解决办法是把这些算子替换成等价的、有 INT8 实现的算子,或者干脆把它们用 FP16 单独跑。
5.3 上线前的回归检查清单
压缩模型最容易出的问题不是"精度平均掉多少",而是"某一类样本突然崩了"。所以上线前必须做的不是看总指标,而是做切片对比:
- 按类别切片:逐类别对比压缩前后的召回率,找出下降超过 5 个点的类,重点看。
- 按难度切片:把验证集按置信度分桶,看低置信度区间的表现变化。压缩模型的损失通常集中在低置信度区间。
- 按场景切片:如果有多个数据来源(不同设备、不同光照、不同分辨率),必须逐场景看。
- 长尾样本专项:专门挑出每个类的少量困难样本人工过一遍,这是我最后一道保险。
还有一个反向的检查:对比压缩模型和原模型的"分歧样本"。把两者预测不一致的样本捞出来看 200 张,你会非常直观地知道压缩损失了哪种能力。有一次我发现压缩模型在"小目标且低对比度"的场景下错得特别多,回头看敏感度分析,果然是对应的高分辨率特征层被剪得太狠了。这种问题是切片指标看不出来的,只有人工看样本才能发现。
还有一件事值得单独说:压缩之后一定要重测置信度校准。量化会让模型的输出分布整体偏移,原本阈值 0.5 的判定可能不再适用。如果你下游有基于置信度的阈值逻辑(比如低于阈值转人工),压缩后必须重新扫一遍阈值,否则会出现"精度没掉但业务指标掉了"的诡异情况。
我个人在实际操作中的体会是,压缩这件事的难点从来不在算法本身,而在"算得准"。你得先有能力把 FP32 基线的延迟测准、把验证集的切片拆对、把硬件的算子支持摸清楚,然后剪枝、量化、蒸馏这些手段才有意义。我见过太多团队花两周调出一个"理论上快 3 倍"的模型,上线后因为一个 fallback 算子被打回原形。所以真正值得先投入的不是调参,而是把那套基准测试和切片回归的脚本搭起来——这套东西搭好之后,后面每一次压缩实验的迭代速度都会快一个数量级。另外一个小技巧,做剪枝敏感度分析的时候,别只用完整验证集,用 200 张的分层子集就够了,速度快 20 倍,结论几乎一致,这一步能把你试错的轮次从个位数提到十几次。