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、蒸馏用小模型学习大模型的行为、算子融合把 ConvBNReLU 合并成一个算子等等。我把 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 * gw 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 模型结构直接决定了该用谁模型的结构和优化器选择高度相关不能只看“哪个效果好”就无脑换。CNNResNet / MobileNet / EfficientNetSGD momentum 是经典选择实测稳定性和泛化能力都很好如果时间紧张AdamW 也能收敛但建议最后 1/3 训练阶段切回 SGD 做精调。TransformerBERT / GPT / ViTAdamW 是事实标准配合 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 momentum0.90.1配合 cosine256 batch 可下探到 0.051e-4 ~ 5e-4256cosine annealNLP 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_norm1.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, lrlr, momentumconfig.get(momentum, 0.9), weight_decaywd) if name adamw: return AdamW(params, lrlr, betas(config.get(beta1, 0.9), config.get(beta2, 0.999)), weight_decaywd) raise ValueError(fUnsupported 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_lrconfig[lr], total_stepstotal_steps, pct_start0.1, anneal_strategycos) if strategy warmup_linear: warmup_steps int(total_steps * config.get(warmup_ratio, 0.06)) warmup LinearLR(optimizer, start_factor0.01, end_factor1.0, total_iterswarmup_steps) decay LinearLR(optimizer, start_factor1.0, end_factor0.0, total_iterstotal_steps - warmup_steps) return # 需要用 SequentialLR 组合见下 if strategy cosine: return CosineAnnealingLR(optimizer, T_maxtotal_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 散度找出异常偏大的层针对性调整该层的量化方式比盲目换校准集有效得多。量化前必须做 ConvBN 融合。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, T4.0, alpha0.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), reductionbatchmean, ) * (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 / recallk 等所有优化前后必须用同一套评测集和评测代码时延测量有个常见错误跑一次两次就取平均噪声会淹没真实的优化收益。正确做法是先 warmup 让 GPU 进入稳定状态再连续测 100 次取平均和中位数。注意 batch size 也要固定因为 tiny batch 和 big batch 的瓶颈完全不同。4.2 为什么时延常常和 FLOPs 对不上这里必须说清楚一个底层机制现代大模型的推理瓶颈很多时候不是计算是内存带宽。一个 4B 参数模型光 FP16 权重就有 8GB每次 forward 都需要把这些权重从显存搬到计算单元。即使 FLOPs 减半如果权重读取量没变时延可能只降 10%。反之量化为 INT8 后权重读取量直接减半即使计算单元没变快时延也能明显下降。所以量化在推理侧最大的红利往往不是“量化后的 INT8 算得快”而是“权重变小了、带宽压力下来了”。设计优化方案时多用这个视角去分析你的瓶颈在哪里计算密集型的层剪枝有效带宽密集型的层量化有效。搞反了优化半天等于白做。4.3 三件套的 benchmark 流程建议每一步都记账我自己跑压缩流水线一定是这个流程拿优化前的模型作为 baseline记录 checkpoint 大小、参数量、FLOPs、时延、精度执行剪枝跑一遍同一套评测记录剪枝后的所有指标在剪枝结果上做量化再跑一遍评测如果精度低于业务阈值加蒸馏恢复再次评测产出 summary 表看每一步的收益和代价分别是什么这个流程看起来麻烦但在排查问题的时候价值巨大。否则你无法回答“精度掉了到底是谁的锅”这个问题。4.4 我踩过的三个值得说出口的坑坑一小模型上硬调 PTQ不如直接换 QAT。我一开始总想用 PTQ 省事结果校准集换了三版、量化粒度来回改精度还是掉了 3 个多点。后来想通了小模型容量小对量化误差的鲁棒性差与其在校准集上折腾不如直接上 QAT。QAT 虽然训练成本高但结果是可预期的。坑二剪枝和量化叠着做精度崩了之后不知道谁的锅。这是最典型的没有记账导致的问题。我当时一次性把剪枝和量化都做了精度掉了 15%完全定位不到问题。后来拆开做才发现剪枝本身只掉 2%量化只掉 1.5%但两者叠加会掉 15%。优化手段之间是会互相放大的所以每一步都要单独评估。坑三只看验证集 loss不看业务指标。loss 是连续变化的业务指标比如 recallk 掉 1%对线上影响可能是灾难级的但 loss 曲线可能只浮动零点几。现在我的原则是每次优化实验必须在固定的评测集上跑业务指标并设一个硬性阈值低于阈值就回退。5. 实际使用 Model-Optimizer 的一点体会项目做到后面我最大的体会是真正有效的优化不是把所有手段都堆上去而是每一步都清楚自己在干什么、收益是多少、代价是什么。几个具体的实操建议不要一上来就四件套全上。每做一个优化必须跑一次完整 benchmark把收益和代价记到账上。收益 1% 但代价巨大的手段直接放弃。优化器方面AdamW warmup linear/cosine 能覆盖 80% 的场景。想追求极致精度再把 CV 模型切到 SGD cosine 做最后收尾。不要天天试新优化器时间花在数据上更值。量化先做因为它单项收益最高。模型体积砍半、带宽压力砍半大多数场景精度损失在 1% 以内前提是校准集选好。剪枝要克制超过 60% 的通道剪枝率基本无法通过 fine-tune 完全恢复。蒸馏是兜底手段成本最高最后再用。最后分享一个小技巧所有实验都用配置文件记录每次 benchmark 结果自动 append 到一个 CSV 文件里。两个月后你想复现任何一次优化、查任何一个参数直接查表不用靠记忆。优化这件事最怕的不是不会调参而是调完之后忘了当初为什么这么调。文件每天都在追加趋势却一目了然哪些优化是真正有效的哪些只是自我感动时间会告诉你答案。
返回列表