拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深度学习模型优化全攻略:从训练优化器到部署量化剪枝蒸馏

深度学习模型优化全攻略:从训练优化器到部署量化剪枝蒸馏

“Model-Optimizer”这个名字在我的工作目录里挂了快两年,它从一个只写着几行优化器参数的 Python 文件,慢慢长成了一整套覆盖训练到部署的模型优化流水线。做深度学习工程的都知道,“模型优化”这四个字其实塞了两件完全不同的事:训练阶段要让 loss 收敛得更稳、更快、更准,部署阶段要让模型跑得更轻、更快、更省显存。这两件事经常被混在一篇文档里讲,但实际操作方法和工具链几乎完全不同。这篇文章就是把我做 Model-Optimizer 时踩过的坑、验证过的参数、复现过的方案完整复盘一遍:训练阶段怎么选优化器、怎么配学习率;部署阶段怎么量化、怎么剪枝、怎么蒸馏;以及怎么科学衡量优化前后的收益。不管你是正在训练深度学习模型,还是准备给模型做推理加速,后面这些内容都应该能直接抄作业。

1. 先搞清楚 Model-Optimizer 到底在优化什么

1.1 训练阶段的优化:让 loss 走得更稳

在深度学习训练流程里,我们说的“优化器”通常指 torch.optim.SGD、torch.optim.Adam 这一类算法。它们负责根据梯度更新参数,解决的核心问题可以概括成:在多维的非凸误差曲面上,以什么样的步长、朝什么方向更新参数,才能让 loss 稳定下降,并且在测试集上有好表现。

这里有个很容易被忽略的点:优化器解决的不只是“收敛快不快”,还包括“最后落到什么样的解上”。SGD 更倾向于落到比较平坦的极小值区域,泛化表现往往更好;Adam 因为是逐参数自适应学习率,有时候会落到尖锐的极小值附近,训练 loss 很低但评测掉点。这不是玄学,是很多研究论文反复验证过的现象,也是为什么很多 CV 模型至今还在用 SGD + momentum,而不是无脑上 Adam 的原因。

Model-Optimizer 在训练侧的核心目标,就是把优化器选型、学习率策略、权重衰减、梯度裁剪这些事从“每次手写”变成“可配置、可复现”。我在项目里见过太多因为 optimizer 和 scheduler 不配套,导致模型怎么训都不收敛,最后白白浪费几百卡时的案例。这个模块存在的意义就是杜绝这类问题。

1.2 部署阶段的优化:让模型跑得更轻

部署优化完全是另一套逻辑。它的目标是减少参数量、降低计算量、压缩内存带宽占用、缩短单次推理时延。常用手段包括剪枝(把不重要的通道删掉)、量化(把 FP32 权重变成 INT8 或 FP16)、蒸馏(用小模型学习大模型的行为)、算子融合(把 Conv+BN+ReLU 合并成一个算子)等等。

我把 Model-Optimizer 的功能边界从一开始就收敛成了四个模块:

  • OptimizerFactory:统一封装 SGD / Adam / AdamW / LAMB,解决“换个模型就要重写一遍训练代码”的问题
  • SchedulerHub:管理 warmup、cosine、linear decay 等学习率策略,解决“优化器和学习率策略不匹配”的问题
  • CompressionPipeline:编排量化、剪枝、蒸馏三条压缩路线,解决“三件套各自为战、顺序混乱”的问题
  • BenchmarkSuite:统一评估时延、FLOPs、参数量、精度变化,解决“优化效果说不清、报告没法横向比较”的问题

设计这四个模块时我坚持一个原则:默认配置必须开箱即用,但配置不允许藏在代码里写死。所有超参全部走配置文件,实验才能复现,不同模型才能直接套用同一套流程。

1.3 为什么这件事值得单独做一个工具

坦白说,torch.optim 已经提供了优化器,torch.quantization 也提供了量化接口,市面上有剪枝库也有蒸馏框架。那 Model-Optimizer 的价值在哪里?我自己的感受是:单个环节工具很好找,但是把它们串成一条有顺序、有度量、可回退的流水线这件事,没有任何现成工具能直接给我。

优化是一个系统工程。你单独把 AdamW 换成 SGD,不一定知道为什么有效;单独把模型量化了,精度掉了也说不清是校准集问题还是量化粒度问题。Model-Optimizer 的另一个隐藏功能,是强制你把“优化前”和“优化后”的评测数据记录在同一条流水线上。这点后面第 4 部分会细讲,先记住结论:没有度量的优化都是玄学。

2. 训练期优化器选型:从 SGD 到 LAMB,每种选择都有代价

2.1 四个主流优化器的底层逻辑

SGD + momentum:更新公式是 v = momentum * v - lr * g,w = w + v。动量本质是对历史梯度做指数加权平均,用来抑制梯度振荡、加速收敛。它的优势是泛化表现稳定、实现简单、对超参数敏感度相对可控;缺点是纯靠学习率控制步长,学习率稍大就可能发散,所以必须配套好的学习率衰减策略。

Adam:维护一阶矩估计 m_t 和二阶矩估计 v_t,各自做指数移动平均,再做偏差校正。更新方向近似等于 m_hat / (sqrt(v_hat) + eps),相当于每个参数维度都有独立的学习率。这个特性让它在稀疏特征、Transformer 这类模型上非常有效,收敛快且不怎么需要精细调学习率。但 Adam 的经典实现有一个问题:权重衰减(L2 正则)是混在梯度里的,这会污染梯度的均值方差估计,导致正则效果失真。

AdamW:改动其实很小,把 weight decay 从梯度计算里拿出来,直接在参数更新时减去 lr * lambda * w。很多实验都证明,在长训练序列、大模型、复杂任务上,AdamW 的泛化能力明显优于原始 Adam。现在 HuggingFace Transformers 的 Trainer 默认优化器就是 AdamW,是有道理的。

LAMB:在 AdamW 基础上,对每一层计算 trust ratio = ||w|| / ||update||,然后按这个比例缩放该层更新幅度。它解决的核心问题是超大 batch 下学习率难以同步放大的问题。用 64K batch 做 BERT 预训练时,普通 AdamW 几乎无法收敛,LAMB 可以稳定训练且保持精度不下降。

2.2 模型结构直接决定了该用谁

模型的结构和优化器选择高度相关,不能只看“哪个效果好”就无脑换。

  • CNN(ResNet / MobileNet / EfficientNet):SGD + momentum 是经典选择,实测稳定性和泛化能力都很好;如果时间紧张,AdamW 也能收敛,但建议最后 1/3 训练阶段切回 SGD 做精调。
  • Transformer(BERT / GPT / ViT):AdamW 是事实标准,配合 warmup + linear decay 基本不会踩大坑。
  • 超大 batch 预训练:用 LAMB,前提是你真的需要把 batch size 推到 8K 以上。
  • 对比学习 / 自监督学习:LARS 和 LAMB 都有应用,核心原因是这种场景下 batch 通常很大,学习率策略需要特殊设计。

不要只凭“Adam 收敛快”就全部任务都用 Adam。收敛快不等于效果好。我在一个 4B 参数的检索模型上做过对照实验,同样 batch、同样数据量,AdamW 比 SGD 快 30% 收敛,但最终离线指标低了接近 1.5 个点。这个代价对线上业务来说是很痛的。

2.3 关键超参数怎么定才靠谱

我自己沉淀了一套默认配置,可以直接作为起点:

模型类型optimizer初始学习率weight decaybatch size 建议学习率策略
CV CNNSGD momentum=0.90.1(配合 cosine,256 batch 可下探到 0.05)1e-4 ~ 5e-4256cosine anneal
NLP TransformerAdamW1e-4 ~ 5e-50.01 ~ 0.132 ~ 512warmup + linear decay
大 batch 预训练LAMB1e-3 ~ 4e-30.018192 ~ 65536warmup + linear decay

注意几个细节:

学习率的量级怎么找。不要上来就做大规模 grid search,太费卡。先用 learning rate finder,从一个很小的 lr(比如 1e-6)指数增长到 1e-1,跑几个 epoch 观察 loss 曲线,找一个“loss 还在明显下降但没发散的区间”,再在这个区间里做一维扫描。这样通常两三轮就能定出量级。

warmup 一定要加。尤其大 batch + 大模型场景,前 5%~10% 的 step 让学习率从 0 线性爬到目标值,能显著避免训练初期 loss 爆炸。特别是 Transformer,不做 warmup 很容易出现 NaN。

梯度裁剪几乎零成本装上。clip_grad_norm_(model.parameters(), max_norm=1.0) 这一行代码,能在学习率策略写错、数据里出现异常样本时保住训练进程。我在项目里不止一次靠它避免重跑整个训练流程。

weight decay 别照抄默认值。不同任务差异很大。大规模推荐模型里 0.1 相当合理,小规模图像任务 1e-4 可能就够了。建议按模型参数量做粗略换算:参数量越大,weight decay 可以越偏向大值。

2.4 一个可以直接抄走的 PyTorch 封装

Model-Optimizer 里的 OptimizerFactory 和 SchedulerHub 核心逻辑其实不长,把最主干的部分贴出来:

import math import torch.nn as nn from torch.optim import SGD, AdamW from torch.optim.lr_scheduler import OneCycleLR, LinearLR, CosineAnnealingLR class OptimizerFactory: @staticmethod def build(model, config): # 如果模型有专用的分组配置(比如不同层不同 lr), # 这里的 parameters 可以是多个 param_group params = model.parameters() name = config["name"].lower() lr = config["lr"] wd = config.get("weight_decay", 0.0) if name == "sgd": return SGD(params, lr=lr, momentum=config.get("momentum", 0.9), weight_decay=wd) if name == "adamw": return AdamW(params, lr=lr, betas=(config.get("beta1", 0.9), config.get("beta2", 0.999)), weight_decay=wd) raise ValueError(f"Unsupported optimizer: {name}") def build_scheduler(optimizer, config, train_steps_per_epoch, epochs): total_steps = train_steps_per_epoch * epochs strategy = config["schedule"].lower() if strategy == "one_cycle": # 对 CV 任务非常友好,单周期策略,warmup 和退火都自动完成 return OneCycleLR(optimizer, max_lr=config["lr"], total_steps=total_steps, pct_start=0.1, anneal_strategy="cos") if strategy == "warmup_linear": warmup_steps = int(total_steps * config.get("warmup_ratio", 0.06)) warmup = LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=warmup_steps) decay = LinearLR(optimizer, start_factor=1.0, end_factor=0.0, total_iters=total_steps - warmup_steps) return # 需要用 SequentialLR 组合,见下 if strategy == "cosine": return CosineAnnealingLR(optimizer, T_max=total_steps)

实际的 scheduler 组合代码比这个长,核心思想是:warmup 用 LinearLR,再加一个主调度器,然后用torch.optim.lr_scheduler.SequentialLR串起来。Transformer 场景我建议直接用 HuggingFace 的get_linear_schedule_with_warmup,它把 warmup 和 linear decay 打包好了,少写很多样板代码。

3. 部署期压缩流水线:量化、剪枝、蒸馏怎么编排

3.1 推荐的优化顺序,以及为什么是这个顺序

部署压缩不外乎三件套:剪枝、量化、蒸馏。很多人习惯“哪个顺手先上哪个”,但这恰恰是精度崩盘的开始。我的经验是固定顺序:

先剪枝 → 再量化 → 精度不达标再用蒸馏恢复。

原因很实际:量化感知训练(QAT)会改变权重的数值分布,如果你先量化再剪枝,剪枝操作会影响已经确定好的量化尺度,后面所有精度分析全部要重做。反过来,先剪枝保持结构完整,再量化,每一步的副作用都是可控的、可归因的。

蒸馏我通常放在最后,不是因为它不重要,而是因为它成本最高。蒸馏要额外维护一个 teacher 模型、跑 teacher 的推理、调整温度和 loss 权重,如果前面的剪枝和量化已经把精度救回来了,就没必要增加这个复杂度。

3.2 量化:从 FP32 到 INT8 的精度保卫战

量化是收益最明显的单项优化。INT8 模型体积直接变成 FP32 的 1/4,内存带宽压力大幅下降,推理时延通常也能砍掉一大截。但是精度怎么保住,才是真正考验工程经验的地方。

先分清 PTQ 和 QAT 的适用场景。训练后量化(PTQ)不需要重新训练,只需要一小部分校准数据,转换一天内能跑完,适合模型较大、校准数据充足、精度要求不那么苛刻的场景。量化感知训练(QAT)是在训练过程中模拟量化误差,让网络自己适应低精度表达,适合小模型、精度敏感业务,但训练成本高、流程长。

这里有个很容易翻车的判断:模型越小,越不要迷信 PTQ。小模型容量小,冗余度低,激活分布的微小误差就会被放大。我做过一个 MobileNetV3 变体的 PTQ,校准集换了三版,精度还是掉了 4 个点以上,最后切到 QAT 才拉回 1.5 个点以内。所以小模型直接上 QAT,反而更省时间。

校准集的选择是最容易出问题的环节。校准集不需要用完整训练集,一般 100~1000 个有代表性的样本就够,但必须覆盖真实输入分布。做人脸识别模型,校准集里就不能全是正脸标准照,得有各种光照、姿态、遮挡,否则量化后真实场景的精度会掉得让你怀疑人生。一个实用技巧:PTQ 之后逐层对比校准集激活值的 KL 散度,找出异常偏大的层,针对性调整该层的量化方式,比盲目换校准集有效得多。

量化前必须做 Conv+BN 融合。Conv 后接 BN 时,BN 可以折叠进卷基层的权重和 bias。如果量化之前不做融合,BN 算子会在 INT8 推理时产生额外的计算和精度误差。这里要留意,PyTorch 里torch.quantization.fuse_modules可以一键完成,但在导出 ONNX 或者用 TensorRT 时,融合逻辑需要自己处理或者依赖推理框架的图优化能力。

per-channel 还是 per-tensor,要分开看。权重用 per-channel 量化,精度明显好于 per-tensor,尤其是通道独立性强的深度可分离卷积;激活值用 per-tensor 是主流做法,因为激活值的动态范围本身是逐层统计的,per-channel 反而容易让量化尺度不稳定。

3.3 剪枝:删掉的是通道,不是单个权重

剪枝分两大类:非结构化剪枝是把单个权重直接置零,稀疏度高,但如果硬件不支持稀疏矩阵指令,实际加速很有限;结构化剪枝是删除整个输出通道或滤波器,可以直接在通用硬件上减少 FLOPs 和内存占用。Model-Optimizer 走的是结构化剪枝路线,原因很简单:通用硬件上可落地。

比较常见的实现是训练时对 BN 层的 gamma 施加稀疏正则,训练结束后按 gamma 绝对值排序,低于阈值的通道剪掉。这个方案实现成本低,但有两个坑必须提前知道:

坑一:gamma 小的通道不一定不重要。尤其是 BN 后面接了残差连接的结构,剪错通道可能导致梯度流断裂,模型直接废掉。所以不要只看 gamma 绝对值,要结合该层在残差结构里的位置一起判断。

坑二:剪枝率不能拍脑袋。我踩出来的安全区大致是 30%~50%。低于 30% 基本无感,50% 以上精度开始明显下滑,超过 60% 基本无法通过 fine-tune 完全恢复。正确做法是从 20% 开始,观察精度,再逐步加到 30%、50%,每一步都跑完整 benchmark 而不是看 loss 曲线拍板。

剪枝之后必须 fine-tune。不 fine-tune 直接量化,两段误差会叠加,最后掉点不是加法,是乘法。

3.4 蒸馏:让小模型接管大模型的行为

蒸馏的核心是让 student 同时拟合 hard label 和 teacher 的 soft label。soft label 就是 teacher 输出 logits 经过温度 T 软化后的概率分布:softmax(logits / T)。T 越大,分布越平滑,类别间相似关系的信息传递得越多。T 的常见取值在 3~7 之间。

常见的 loss 写法是:

import torch.nn.functional as F def distillation_loss(student_logits, teacher_logits, labels, T=4.0, alpha=0.7): hard_loss = F.cross_entropy(student_logits, labels) soft_loss = F.kl_div( F.log_softmax(student_logits / T, dim=-1), F.softmax(teacher_logits / T, dim=-1), reduction="batchmean", ) * (T * T) return alpha * hard_loss + (1 - alpha) * soft_loss

注意那个* T * T,这是必须的缩放项。因为经过温度软化后,KL 散度梯度的量级会随 T 变大而变小,乘上 T^2 才能让 soft loss 和 hard loss 的量级对齐,否则蒸馏基本失效。

一个反常识的经验:teacher 不一定是越大越好。teacher 和 student 能力差距过大时,teacher 输出的 soft label 里包含的信息,student 根本学不会,反而引入大量噪声。我在一个 1B 的大模型蒸馏到 50M 小模型的任务上,换用了同架构但参数约为 student 2 倍大小的 teacher,效果反而优于用更大的 teacher。所以选 teacher 时,优先考虑“同架构、大 2~4 倍参数量”,而不是一昧追求大模型。

4. 优化效果怎么度量:指标、实验设计和常见误区

4.1 该看哪些数字,不能只看 FLOPs

很多工程师汇报优化成果时喜欢贴参数量和 FLOPs,这两个指标在模型结构简化的汇报里有用,但放到优化效果评估里,很容易掩盖真正的问题。Model-Optimizer 的 BenchmarkSuite 里我固定记录五类数据:

指标测量方式注意事项
参数量统计模型 state_dict 中可学习参数BN 的 buffer 也占空间,别漏算
FLOPsthop / ptflops只反映理论计算量,不反映内存带宽瓶颈
时延真实设备 warmup 20 次 + 测量 100 次取平均必须固定 batch size、线程数、输入分辨率
显存占用推理时torch.cuda.max_memory_allocated()FP16 和 INT8 差距明显
业务精度Top-1 / mAP / recall@k 等所有优化前后必须用同一套评测集和评测代码

时延测量有个常见错误:跑一次两次就取平均,噪声会淹没真实的优化收益。正确做法是先 warmup 让 GPU 进入稳定状态,再连续测 100 次取平均和中位数。注意 batch size 也要固定,因为 tiny batch 和 big batch 的瓶颈完全不同。

4.2 为什么时延常常和 FLOPs 对不上

这里必须说清楚一个底层机制:现代大模型的推理瓶颈很多时候不是计算,是内存带宽。一个 4B 参数模型,光 FP16 权重就有 8GB,每次 forward 都需要把这些权重从显存搬到计算单元。即使 FLOPs 减半,如果权重读取量没变,时延可能只降 10%。反之,量化为 INT8 后,权重读取量直接减半,即使计算单元没变快,时延也能明显下降。

所以量化在推理侧最大的红利,往往不是“量化后的 INT8 算得快”,而是“权重变小了、带宽压力下来了”。设计优化方案时,多用这个视角去分析你的瓶颈在哪里:计算密集型的层,剪枝有效;带宽密集型的层,量化有效。搞反了,优化半天等于白做。

4.3 三件套的 benchmark 流程,建议每一步都记账

我自己跑压缩流水线一定是这个流程:

  1. 拿优化前的模型作为 baseline,记录 checkpoint 大小、参数量、FLOPs、时延、精度
  2. 执行剪枝,跑一遍同一套评测,记录剪枝后的所有指标
  3. 在剪枝结果上做量化,再跑一遍评测
  4. 如果精度低于业务阈值,加蒸馏恢复,再次评测
  5. 产出 summary 表,看每一步的收益和代价分别是什么

这个流程看起来麻烦,但在排查问题的时候价值巨大。否则你无法回答“精度掉了到底是谁的锅”这个问题。

4.4 我踩过的三个值得说出口的坑

坑一:小模型上硬调 PTQ,不如直接换 QAT。我一开始总想用 PTQ 省事,结果校准集换了三版、量化粒度来回改,精度还是掉了 3 个多点。后来想通了:小模型容量小,对量化误差的鲁棒性差,与其在校准集上折腾,不如直接上 QAT。QAT 虽然训练成本高,但结果是可预期的。

坑二:剪枝和量化叠着做,精度崩了之后不知道谁的锅。这是最典型的没有记账导致的问题。我当时一次性把剪枝和量化都做了,精度掉了 15%,完全定位不到问题。后来拆开做才发现,剪枝本身只掉 2%,量化只掉 1.5%,但两者叠加会掉 15%。优化手段之间是会互相放大的,所以每一步都要单独评估。

坑三:只看验证集 loss,不看业务指标。loss 是连续变化的,业务指标(比如 recall@k 掉 1%)对线上影响可能是灾难级的,但 loss 曲线可能只浮动零点几。现在我的原则是,每次优化实验必须在固定的评测集上跑业务指标,并设一个硬性阈值,低于阈值就回退。

5. 实际使用 Model-Optimizer 的一点体会

项目做到后面,我最大的体会是:真正有效的优化,不是把所有手段都堆上去,而是每一步都清楚自己在干什么、收益是多少、代价是什么。

几个具体的实操建议:

不要一上来就四件套全上。每做一个优化,必须跑一次完整 benchmark,把收益和代价记到账上。收益 < 1% 但代价巨大的手段,直接放弃。

优化器方面,AdamW + warmup + linear/cosine 能覆盖 80% 的场景。想追求极致精度,再把 CV 模型切到 SGD + cosine 做最后收尾。不要天天试新优化器,时间花在数据上更值。

量化先做,因为它单项收益最高。模型体积砍半、带宽压力砍半,大多数场景精度损失在 1% 以内,前提是校准集选好。剪枝要克制,超过 60% 的通道剪枝率基本无法通过 fine-tune 完全恢复。蒸馏是兜底手段,成本最高,最后再用。

最后分享一个小技巧:所有实验都用配置文件记录,每次 benchmark 结果自动 append 到一个 CSV 文件里。两个月后你想复现任何一次优化、查任何一个参数,直接查表,不用靠记忆。优化这件事,最怕的不是不会调参,而是调完之后忘了当初为什么这么调。文件每天都在追加,趋势却一目了然:哪些优化是真正有效的,哪些只是自我感动,时间会告诉你答案。

返回列表