去年下半年,我接手了一个文本分类服务的性能优化任务,模型是 BERT-base,线上 P99 延迟已经涨到了 380 毫秒,显存稳稳吃掉一块 12GB 的 T4。老板给的指标很直接:延迟砍一半,精度不能掉超过 1 个点。当时我把散落在各个项目里的优化手段——换优化器、调学习率、上混合精度、做剪枝、量化、蒸馏、换推理引擎——全部收拢成了一条工具链,给它起名Model-Optimizer。
一开始它只是我笔记本里的一堆脚本,后来慢慢变成了一个能让团队其他人也直接上手的工作流。这篇内容不打算讲什么高大上的理论,就说说我在做训练优化、模型瘦身和推理加速这条路上实际踩过的坑、测过的数据、用过的配置,以及 Model-Optimizer 最终沉淀下来的那套可复用流程。如果你手头也有一批模型要优化,或者准备入职做算法工程化的活,这里面的东西应该能直接抄作业。
1. 我为什么做 Model-Optimizer:三个真实痛点逼出来的工具链
做模型优化之前,我一直以为"优化"就是调参。后来发现完全不是。模型优化是一个从训练到部署的完整链路,每一项改动都会牵动另一项的表现,不把它当成一个系统来对待,结果就是今天改了学习率,明天显存不够,后天上线延迟又回去了。
1.1 训练侧:loss 降不下去不等于模型结构有问题
有段时间我在微调一个中文 NER 模型,loss 一直卡在 0.35 下不去,换了更深的层、加了 dropout,收敛速度反而更慢。后来把优化器从 Adam 换成 AdamW,又把学习率从固定的 2e-5 改成 warmup + 余弦退火,loss 在同样轮数内降到了 0.18。模型结构一个字没动,纯靠训练侧的优化策略就拉开了差距。
这个经历让我意识到,很多团队把"优化模型"等价于"改模型结构",但训练策略侧的优化空间往往被严重低估。优化器的选择、学习率调度、梯度累积、混合精度、批量大小,这些因素互相耦合,单独调一个看不出效果,组合起来变化非常明显。
1.2 部署侧:模型体积和延迟的账必须用"端到端"的口径算
另一个痛点在部署。之前团队上线过一个蒸馏后的意图识别模型,单看 PyTorch 的推理时间,从 12 毫秒降到了 6 毫秒,大家觉得已经达标。结果换到 ONNX Runtime 之后,反而比原模型还慢 2 毫秒。排查了半天才发现,问题出在动态 shape 没有固定、算子没有做层融合,图优化根本没走完。
模型优化必须用"端到端"的口径来算账:从输入到输出、包含前后处理的整体耗时,而不是只看某个单算子的时间。Model-Optimizer 里所有指标都以整条推理链路的延迟为准,一开始就把这个口径固定下来,后面才好做对比。
1.3 实验管理侧:没有基线记录的优化等于白做
第三痛点是实验记录混乱。以前同事剪完枝,说"精度没怎么掉",结果一问,基线是什么、用了哪份校验集、跑了多少次平均,全都说不清楚。没有可复现的基线,任何优化结论都是靠感觉。
Model-Optimizer 从设计上强制每一次优化都跑同样的评测脚本,自动记录模型版本、优化方式、评估指标、耗时和显存占用。数据喂进去,最后自动生成一张对比表。没有基线的优化实验,系统直接拒绝执行。
这三点是我做这套工具的出发点:把训练策略、模型压缩、推理加速、实验管理统一到一个流程里。
2. 训练阶段的优化器选型与参数联动:不是玄学,是收敛轨迹的代价权衡
第一个模块,是训练侧的优化策略配置。Model-Optimizer 里维护了一份我长期测试下来的优化器推荐矩阵,不同任务类型配不同的优化器默认参数。
2.1 主流优化器的适用边界:从 SGD 到 AdamW 到 LAMB
| 优化器 | 适合场景 | 收敛速度 | 泛化性 | 显存占用 | 我的实测说明 |
|---|---|---|---|---|---|
| SGD + Momentum | CNN 分类、目标检测等 CV 任务 | 慢 | 好 | 低 | ResNet-50 上大概多跑 1.5 倍 epoch 才能达到 Adam 的精度,但最终泛化更好 |
| Adam | 通用任务快速收敛 | 快 | 中 | 中 | 文本分类直接上 Adam 很方便,但带 weight decay 时表现不如 AdamW |
| AdamW | Transformer、预训练模型微调 | 快 | 好 | 中 | BERT 微调我用它作为默认选项,比 Adam 稳不少 |
| LAMB | 大 batch 分布式训练 | 快 | 好 | 高 | batch size 开到 8K 以上才有明显收益,小 batch 反而浪费 |
刚开始做优化器选型时,我犯过一个错:只看收敛速度,谁 loss 降得快选谁。后来做 CV 分类任务时发现 Adam 虽然前期猛,后期精度被 SGD 反超。原因在于 Adam 的自适应学习率相当于给每个参数单独调步长,收敛快但对梯度噪声的鲁棒性弱,最后会在极小值附近震荡;SGD 带 Momentum 虽然步子慢,但整体轨迹更平稳,能在更平缓的极小值处落脚,泛化自然更好。
因此 Model-Optimizer 里有一个"收敛轨迹对比"功能,把优化器在验证集上的表现按 epoch 画成曲线,而不是只看最终精度。如果曲线前期拉升快、后期抖动大,我会优先考虑换成泛化性更好的优化器,而不是死磕 epoch 数。
2.2 学习率 warmup、批量大小与梯度裁剪的联动关系
学习率是另一个很容易被单点调参误导的因素。固定学习率 2e-5 训练 BERT,前面几百步容易把预训练权重冲坏;后来加上 10% 步数的 warmup,再配合余弦退火,收敛速度和最终精度都明显更好。warmup 的逻辑很简单:训练初期参数离最优解还很远,太大步长容易让 loss 爆炸,先用小学习率预热,让优化器摸清梯度方向,再逐步加大步长。
批量大小和学习率也不是独立的。线性缩放规则说批量翻倍,学习率也大致可以翻倍,但要注意上限。BERT 类模型 max lr 我一般控制在 2e-5 到 5e-5 之间,超过这个范围 loss 容易跳到无法恢复的位置。梯度裁剪值我固定在 1.0,主要防止偶发的大梯度把训练干崩。
这些参数看起来很常规,可它们是一条链路上的几个阀门,单独动哪一个都会影响其他几个的表现。Model-Optimizer 会把"批量大小、warmup 比例、峰值学习率、衰减策略、梯度裁剪值"打包成一个组合配置,跑实验时像填表单一样统一提交,而不是散落在不同脚本里各改各的。
2.3 不改模型结构的前提下,用混合精度、梯度累积和激活检查点省显存
显存不够时,很多人上来就改模型结构,比如变小 hidden size。其实训练侧的显存优化手段还有一大把。我实测下来最有效的是三件套:自动混合精度、梯度累积、激活检查点。
- 自动混合精度(AMP):把部分算子从 FP32 降到 FP16,显存直接省一半,A100 和 V100 上都支持。要注意的是损失缩放和梯度裁剪的配合,AMP 默认会动态调整 loss scale,遇到 inf 时自动跳过这步更新,训练日志里如果频繁出现 loss scale 下降,就得检查学习率是不是太高了。
- 梯度累积:显存不够但又想用更大 batch 时,把一个小 batch 的梯度攒够若干步再更新。Model-Optimizer 里我会这样配置:真实 batch size = 32,显存只能跑 8,那就做 4 步累积。注意 BatchNorm 在这种模式下统计量会偏,CV 任务里要谨慎。
- 激活检查点(activation checkpointing):前向不保留中间激活,反向需要时重新计算。对长序列 Transformer 尤其有效,BERT 可以做几层开启、几层不开启的组合,而不是全开全关,这样显存和计算之间能取一个平衡。
我用一个 128 长度的中文情感分类任务,batch size 32,未开启任何优化时显存 6.2GB,开 AMP 变 3.8GB,再开激活检查点变成 2.1GB。模型结构完全没动,训练照样正常收敛。
3. 部署阶段的模型压缩三板斧:剪枝、量化和蒸馏的实际收益
训练搞定了,接下来是部署。Model-Optimizer 的压缩模块提供三个独立但可以叠加的流程:剪枝、量化、蒸馏。策略是先蒸馏再剪枝再量化,顺序反了会互相干扰。
3.1 结构化剪枝:为什么我不用非结构化剪枝
剪枝听起来简单,就是把不重要的权重置零。但实际落地时有个大坑:非结构化剪枝会把模型变成稀疏矩阵,PyTorch 里推理速度不但没提升,有时候反而更慢,因为大多数硬件对稀疏计算支持有限。所以我只做结构化剪枝,比如 Transformer 的 attention head 剪枝和 FFN 维度的剪枝。
试验是在一个 6 层 BERT 上做的,剪掉 2 个 attention head 之后,F1 掉了 0.2 个点,模型参数量减少约 10%。继续剪到 4 个 head 时 F1 掉了 1.4 个点,这个损失已经超出容许范围。所以剪枝不是越多越好,要画一条精度-剪枝比例的曲线,找一个拐点。
剪枝算法我用的是基于梯度的显著性评估:每个 head 的梯度越大,说明它对预测的贡献越关键。训练时记录每个 head 的平均梯度,按显著性排序后裁剪。工程上我建议用 PyTorch 自带的torch.nn.utils.prune配合自己写的结构化剪枝逻辑,不要直接拿整个大模型做实验,先在一个小模型上验证流程。
3.2 PTQ 与 QAT:INT8 量化里的精度回撤岔路
量化是把 FP32 权重和激活变成 INT8,推理时能显著提速。我踩过最大的坑是 PTQ 校准数据太少,校准只喂了 100 条样本,量完模型在某个分类类别上直接失灵。后来把校准集扩到 500 条,并且覆盖各类别,问题才缓解。
PTQ 与 QAT 的区别在于:PTQ 是训练后直接量化,不改权重;QAT 是在量化感知训练中让模型自己去适应量化误差。我在中文 NER 模型上的实测数据如下:
| 方案 | 大小降幅 | F1 变化 | 推理耗时 |
|---|---|---|---|
| FP32 原始模型 | - | 88.4 | 12 ms |
| PTQ INT8 | 约 4 倍 | 87.6 | 7 ms |
| QAT INT8 | 约 4 倍 | 88.1 | 7 ms |
QAT 比 PTQ 的精度回撤少了 0.5 个点,但训练成本高不少,需要在量化模型上继续微调。如果任务的精度余量足够,我建议先上 PTQ,如果掉点不能接受再上 QAT。Model-Optimizer 默认输出两种量化模型,让使用者自己选。
3.3 蒸馏:花教师 7% 的算力保住教师 95% 的效果
知识蒸馏是一个性价比很高的方案。我拿 BERT-large 做教师,蒸馏了一个 4 层的小 BERT 给学生,见的做法是同时用软标签和硬标签计算损失,软标签蒸馏让模型学到教师输出的分布,硬标签监督保持真实任务的精度。
最终学生的参数量只有教师的 27%,推理延迟从 20ms 降到 6ms,而学生模型的 F1 是教师模型的 95% 左右。这个损失买卖是比较划算的:延迟降了 70%,换来了 5% 的精度回撤。如果任务本身要求极高精度,蒸馏方案要谨慎;如果精度余量在 5 个点以上,蒸馏往往是最优先考虑的压缩手段。
蒸馏和之前讲的剪枝、量化是可以叠加的。我的固定顺序是:先用教师蒸馏出小模型,再对小模型做结构化剪枝,最后做 INT8 量化。
3.4 推理引擎加速:从 PyTorch 到 ONNX Runtime 和 TensorRT
模型压缩完,还有一个常见加速手段是换推理引擎。同一份 BERT 模型,我从 PyTorch 切到 ONNX Runtime,默认设置下就快了 20% 左右;再切到 TensorRT FP16,能比 PyTorch 快 2 到 3 倍。
但换推理引擎的坑很多。最开始我直接用公司的主干网络跑到 TensorRT,结果 FP16 模式下精度掉了 2 个多点。后来定位到是某些算子对 FP16 支持不完整导致溢出,解决方法是把特定算子显式保留为 FP32,或者在 ONNX 里先做算子融合。ONNX Runtime 里有个工具叫onnxruntime.transformers.optimizer,专门做 Transformer 的层融合和维度优化,建议任何 Transformer 模型部署前都先跑一遍。
另一个值得注意的点是固定序列长度。动态序列长度在 ONNX 和 TensorRT 里都会引入额外的 shape 推断开销,如果业务场景里长度波动不大,直接固定到一个合理的最大值,延迟会更稳定。
4. 避坑实录:我在 Model-Optimizer 迭代过程中推翻过的方案
整个工具链不是一次成型的。中间有好几次我自认为找到了最优方案,没过多久就发现被某个隐蔽问题打脸。这部分单独拿出来说,是为了帮你少走重复路。
4.1 全量微调改成 LoRA 后,我把"过拟合"误判成了优化失效
有一段时间为了避免全量微调带来的显存压力,我把一个文本分类模型切成 LoRA 训练。跑完验证集精度比全量微调版本低了 1 个点,当时第一反应是 LoRA 结构表达力不够,差点把它判定为不可用。
后来排查发现,真正的元凶是 LoRA 的 rank 和学习率设置不匹配。全量微调时峰值学习率是 3e-5,LoRA 里因为可训练参数少,学习率得调大一些才能充分更新,改成 1e-4 之后精度追平了全量微调。这个现象说明:改变训练策略时,第一时间怀疑的是参数没对齐,而不是方法本身不行。
4.2 精度对比只盯 ACC,导致一次优化方案的误判
还有一次做类别不平衡任务的量化评估,模型总体 ACC 下降了 0.3 个点,大家都觉得可以接受。但拆到每个类别看,样本数最少的那一类 F1 从 82 直接掉到 71。这类问题在整体指标上不明显,却在真实线上场景里会被放大。
从这以后,Model-Optimizer 的评测报告强制按类别输出指标,并且专门标注"尾部类别的退化程度"。任何优化如果导致尾部类别大幅回撤,哪怕整体指标很漂亮,也不允许直接上线。
4.3 剪枝最优组合不等于逐层独立的贪心结果
剪枝判断时最容易犯的错是一层一层独立找最优,然后把每层最优组合起来。实操时会发现,不同层的重要度是相互关联的,前面层剪掉的东西,可能后面层靠冗余还能兜底;也可能前面层一旦剪多了,后面层想补都补不回来。
我在 6 层 BERT 上跑了全局剪枝搜索,把各层剪枝比例当成一组超参数,用少量步数做组合评估,效果比逐层贪心好了不少。代价是实验次数变多,所以 Model-Optimizer 里默认先做一层粗搜索找大致范围,再做局部精调。
5. Model-Optimizer 的落地工作流与留给团队的建议
到这里,Model-Optimizer 的功能和踩坑都讲完了。下面说下最终沉淀的工作流:一个可以复现、可以交给他人的完整流程。
5.1 一条命令跑通的优化流水线
整个流程分六步,每一步都有独立脚本,但也有一个总入口,传一个配置文件就能顺序执行:
python run_optimize.py --config configs/text_cls.yaml配置文件里主要包含:模型路径、任务类型、优化器策略、剪枝比例、量化方案、评估集路径、延迟测试集路径。流程内部顺序是:
- 基线评估:记录原始模型的精度、体积、延迟。
- 训练策略优化:按任务类型套用优化器推荐配置,输出收敛曲线对比。
- 模型压缩:执行蒸馏或剪枝,生成候选模型。
- 精度验证:按类别输出指标。
- 量化:PTQ 跑完检查掉点,必要时启用 QAT。
- 引擎转换与延迟测试:导出 ONNX 或 TensorRT,端到端计时。
这套流程最核心的设计是每一步都会留一份中间产物,后面发现问题随时能回溯到上一步,而不是从头再来。
5.2 在线上模型上的收益数字
用这套流程优化一个中文意图识别服务,初始模型是 BERT-base,FP32 推理平均延迟 22ms,显存占用 1.4GB。走完全流程后:蒸馏到 4 层模型,然后 INT8 量化,再切到 TensorRT FP16,最终平均延迟 6ms,显存占用 180MB。精度方面,整体 ACC 从 91.2% 降到了 90.1%,下降了 1.1 个点,但尾部类别的 F1 退化控制在了 2 个点以内,业务方可以接受。
这个过程耗掉的开发时间是两个人两周,其中一半时间花在排查各个工具之间的兼容性上。如果你打算在团队里推广这套流程,建议留出至少一周的磨合周期。
5.3 给普通团队的选型建议
根据我的经验,普通团队做模型优化不用一上来就全流程铺开。如果你的模型还在训练阶段,优先改训练策略:优化器、学习率调度、AMP,这些是投入产出比最高的地方。如果模型已经部署,先做推理引擎切换,再做蒸馏压缩。如果模型对精度特别敏感,QAT 比 PTQ 更值得投入。
另外建议从一开始就建一个统一的评测基线,所有优化都基于同一份数据、同一个脚本去评估。模型优化本质上是交易:用一部分精度、一部分时间,换显存、延迟和成本。没有基线,交易就没有参照物,所有数字都会变成安慰自己的报表。
这套流程我现在每个新项目都在用,基本养成了条件反射:拿到新模型,先跑一遍 Model-Optimizer 的 profile 脚本,看清楚训练曲线和部署瓶颈,再决定动哪一块。很多团队花大力气改网络结构之前,其实先用这套流程压一遍,往往收获比想象中大得多。