做模型优化这几年我最大的感受是很多人一听到“Model-Optimizer”这个叫法就下意识以为它是某个一键调参的工具或者某个开箱即用的库。但真正上手做过几轮项目之后你会发现这个名词背后其实是两条完全不同的技术线——一条是训练阶段的优化器选型管的是模型怎么学、学多快、学得好不好另一条是推理阶段的模型瘦身与加速管的是模型落地之后跑多快、占多大、扛不扛得住线上流量。这两件事经常被混为一谈但在实际工程里它们各自踩的坑完全不同。写这篇内容就是想把这两条线拆开讲透把我自己从选优化器、调学习率到做量化、剪枝、蒸馏踩过的坑和验证过的做法完整梳理出来。不管你是刚入门的算法工程师、正准备把模型部署上线的工程同学还是自己搞研究需要跑大量实验的学生这份梳理应该都能帮你在“Model-Optimizer”这个方向上少走不少弯路。1. 训练侧优化器究竟在优化什么1.1 梯度下降的本质与学习率直觉先回到最朴素的那个问题优化器在做什么一句话说清楚它就是在根据损失函数对参数的梯度不断更新模型参数让损失值一步步往下走。听起来简单但真正实操过就会知道这里的学问几乎全藏在“每一步走多远”和“往哪个方向走”这两件事上。拿“下山”来类比你站在山上某处想走到山谷最深处但雾太大看不清路。梯度就是脚下最陡的坡面方向学习率就是你迈出的步幅。步幅太大可能一脚踩空越过谷底甚至直接滚到另一座山上去步幅太小走半天还在原地磨蹭训练时间成倍拉长。实际训练中我见过不少新手一来就把学习率设成 0.1 甚至更高结果 loss 直接飞出天际。也见过有人保守到用 1e-5模型倒是稳定但训练几十个 epoch 后指标还卡在原地。判断学习率是否合适最直观的标准是前几个 epoch 的 loss 下降曲线如果刚开始就剧烈震荡甚至变成 NaN大概率是步幅过大如果曲线下降得无比平缓那多半是步幅太小。这里有一个我一直沿用的经验找初始学习率时不靠猜靠做一轮简单的“学习率扫描”。从 1e-4 开始按指数增长到 1.0跑上几百个 step记录每个学习率下的 loss 变化。通常你会看到 loss 先下降、再上升那个“谷底之前”的量级就是适合这个模型的学习率范围。这个操作看起来土但比任何经验公式都管用尤其当你换了新数据集或者新模型结构时。1.2 SGD、Adam、AdamW 到底怎么选这是个老生常谈但永远有人纠结的问题。先把我自己的结论放在前面如果做的是 CV 类任务尤其是分类、检测这类我优先选 SGD Momentum如果是 NLP 或 Transformer 类模型选 AdamW如果只是快速验证一个想法能不能跑通那就直接 Adam。为什么这么分核心在于两类优化器的特性差异很大。SGD 的更新路径比较“笨”但正因为笨它的泛化能力往往更好容易收敛到比较平缓的极小值区域Adam 则通过一阶动量与二阶动量的自适应机制给每个参数分配不同的有效步长在稀疏梯度和非平稳目标上表现更稳收敛速度也快。但 Adam 有个老毛病它对权重衰减的处理方式存在耦合问题。标准 Adam 把 L2 正则直接加在梯度上这和真正意义的权重衰减并不是一回事尤其是当学习率较大时效果会有偏差。AdamW 就是把权重衰减从梯度里解耦出来单独做参数收缩这也是它在 Transformer 训练里几乎成为标配的原因。说到这顺便提一下动量参数。SGDMomentum 里的动量通常取 0.9这个值意味着当前的更新方向会保留一部分上一个 step 的方向信息相当于给参数更新加了“惯性”。我见过不少人调模型时忽略动量其实在 CV 任务里动量从 0.9 调到 0.99 往往能带来明显的稳定性和精度提升代价是收敛路径会变得“冲”一些学习率需要相应往下调。下面这个表是我在不同场景下选型时的直接参考任务类型推荐优化器常用配置注意事项CNN 分类/检测SGD Momentumlr0.01~0.1momentum0.9需要配合 warmup 和学习率衰减Transformer/NLPAdamWlr1e-4~3e-4betas(0.9, 0.999)权重衰减通常取 0.01~0.1快速实验验证Adamlr1e-3~3e-3不追求极致精度只求快速收敛大规模预训练AdamW 分层衰减底层 lr 更小顶层 lr 更大最好配合余弦退火策略1.3 学习率策略warmup 与余弦退火优化器选对了学习率策略跟不上效果照样打折扣。这里重点说两个我每次都会用的策略warmup 和余弦退火。warmup 的存在理由是训练刚开始时模型参数完全是随机的梯度方向噪声很大。如果一上来就用大学习率早期的几步就可能把参数推到某个不好的区域后面很难救回来。所以先让学习率从很小很小开始用几百或几千个 step 线性增长到预设值等梯度方向相对稳定后再用正常节奏训练。为什么强调这一点因为我踩过太多次“同样的模型、同样的优化器别人跑得好我跑得差”的坑排查到最后往往是 warmup 没做。余弦退火则是在训练中期开始起作用。它让学习率按照半个余弦周期从初始值慢慢降到接近 0前半段下降平缓后半段下降加快。相比固定步长衰减这种做法能让模型在后期更精细地“磨”到局部最优附近尤其是大 epoch 数训练时效果特别明显。PyTorch 里做这个组合很直接import torch from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR, CosineAnnealingLR optimizer AdamW(model.parameters(), lr3e-4, weight_decay0.01) # warmup 2000 步 def warmup_lambda(step): if step 2000: return step / 2000 return 1.0 warmup_scheduler LambdaLR(optimizer, lr_lambdawarmup_lambda) # 之后再切换到余弦退火这里用 SequentialLR 做衔接 from torch.optim.lr_scheduler import SequentialLR cosine_scheduler CosineAnnealingLR(optimizer, T_maxtotal_steps - 2000) scheduler SequentialLR(optimizer, schedulers[warmup_scheduler, cosine_scheduler], milestones[2000]) # 训练循环中每个 step 调用 scheduler.step()这段代码里有个容易被忽略的细节warmup 阈值2000 步不是拍脑袋定的它与 batch size 和数据集规模有关。通常我让它覆盖大约 2~3 个 epoch。如果你的数据集很小可能 warmup 几百步就够数据集很大就要相应拉长。总之warmup 的核心目的是让优化器“先稳住方向再加速前进”这个阶段省不得。2. 推理侧从“能跑”到“跑得快”2.1 模型量化FP32 到 INT8 的科学训练优化解决了“学得好不好”的问题但模型要上线还得面对另一个硬约束资源。我见过一个模型在 GPU 上推理只要 5 毫秒结果部署到 CPU 上直接变成 200 毫秒业务方当场摇头。这时候第一个想到的手段几乎都是量化。量化的核心原理是把神经网络里默认的 FP32 权重和激活值用更低精度的数值类型来表示。最常见的是 INT8。从 FP32 到 INT8 并不是简单截断而是要做一次数值映射把原始浮点数值范围映射到 [-127, 127] 的整数范围中间需要 scale缩放因子和 zero_point零点偏移。为什么量化能让模型变小变快模型大小很容易理解参数从每个 4 字节变成 1 字节直接缩到原来的四分之一。速度方面则受益于低精度计算在 CPU 和部分 GPU 上的硬件加速尤其在使用汇编级优化指令的推理引擎里INT8 运算比 FP32 快不止一个数量级。但量化不是没有代价的。最典型的代价是精度损失尤其是对激活值分布比较敏感的结构比如注意力机制、有 BatchNorm 的早期卷积层等。实际操作中量化的方式也分几种区别很大量化方式是否需要数据实现难度精度表现适配场景动态量化否低较好主要以权重为主、激活不吃紧的模型静态量化是且需要校准集中好CNN、Transformer 推理部署量化感知训练 QAT是需要完整训练流程高最好大模型、精度要求极高场景静态量化的流程里最关键的是校准calibration。校准的目的是统计模型各层激活值的真实分布从而确定合理的 scale 和 zero_point。我一般从训练集的验证集里抽 500~1000 个有代表性的样本做校准集跑一遍前向统计每个激活张量的 min/max 或百分位分布。用 min/max 还是用百分位对结果影响不小。min/max 容易受离群点影响如果激活值里有一两个突兀的大数整个映射范围被拉宽大部分值反而被压缩在很小的区间精度损失严重。所以我在实践中更常用 99.99% 百分位牺牲极小部分极端值换取绝大多数数值的表示精度。2.2 结构化剪枝与非结构化剪枝剪枝的想法很直觉模型里有很多参数其实贡献很小把它们砍掉模型自然更小更快。但怎么砍砍完怎么恢复精度这里面的门道不少。按粒度分剪枝分为非结构化剪枝和结构化剪枝。非结构化剪枝是删掉权重矩阵里的单个参数比如把绝对值小于阈值的权重置零。它的优点是灵活能在同等稀疏度下保持更高的精度缺点也明显——矩阵变成稀疏的除非底层计算库专门优化过稀疏运算否则实际推理速度几乎不提升。你在 PyTorch 里调用prune.l1_unstructured后虽然参数矩阵里多了很多 0但模型跑起来并不变快这个体验我相信很多人都有过。结构化剪枝则是按 channel、filter 甚至整个 block 为单位来删直接改变网络结构的形状因此能真正带来计算量和内存带宽的下降。代价是精度损失往往更大而且需要有配套的重构或微调来恢复。我的建议是如果目标是部署加速优先用结构化剪枝别做非结构化。做结构化剪枝时剪哪些层也有讲究。比如卷积网络里一般浅层特征比较通用剪多了影响大深层的冗余参数更多可以先从冗余度大的层下手。更聪明的做法是用一些基于统计量的重要性判断比如 BN 层的 gamma 系数——gamma 越小说明这层输出对最终结果的贡献越小越适合剪掉。剪枝的整体流程我习惯用“训练-剪枝-微调”三步走第一步正常训练得到一个精度达标的模型 第二步按比例剪掉冗余 channel / filter设置目标稀疏度 第三步用一个较小的学习率在原始数据集上微调几个 epoch 恢复精度这里有个经验参数微调的学习率通常是原始训练的 1/10 到 1/20微调 epoch 不用太多。如果剪完掉点超过 1%第一反应不要是加大学习率而是降低稀疏度或者改成渐进式剪枝——比如先剪 20% 微调一轮再剪 20% 再微调比一步到位剪 50% 稳得多。2.3 知识蒸馏用大模型教小模型如果说量化、剪枝是从“结构”上做减法那知识蒸馏就是从“学习”上做迁移。核心思路是训练一个小模型学生让它去模仿一个大模型教师的行为。学生学到的不仅是训练集的硬标签还包括教师模型输出的软概率分布。为什么软标签比硬标签更“有营养”举个例子图片分类里一张猫的照片硬标签只告诉你它是猫而教师模型的软概率可能显示92% 是猫、5% 是狗、3% 是老虎。这 5% 和 3% 里其实藏着类别之间的相似性信息——猫和老虎外观相近这是硬标签完全无法传递的知识。学生模型正是通过模仿这种“知识暗线”来提升自己的表现。在蒸馏里有个关键参数叫温度 T。它对教师输出的概率分布做一个软化处理T 越高分布越平滑类别之间的细微差异越容易被暴露出来。蒸馏的 loss 一般由两部分组成一部分是让学生输出接近真实硬标签的交叉熵另一部分是让学生输出接近教师软标签的 KL 散度。这种多目标方式需要平衡两个损失的比例我一般从 0.5 分配权重开始根据验证集表现再调。蒸馏说起来不复杂但落地时有个很现实的障碍需要一个足够好的教师模型且教师推理一次的成本不低。我常用的做法是先冻结教师离线把训练集或子集的软预测批量跑一遍存下来学生训练时直接读预计算的软标签这样整个蒸馏过程的额外开销就控制在了“跑一遍推理”的量级线上训练时完全跑得起。3. 实操一套可落地的优化流程3.1 环境准备与基线测量聊完原理直接进入实操。我以一套典型的 CPU 端部署场景为例模型是 ResNet50 图像分类模型框架是 PyTorch目标是把模型从 FP32 优化到可接受的 INT8 精度并把单张图片的推理延迟从约 30ms 压到 10ms 以内。动手之前先把基线量清楚。基线不只有“准确率”一个数字你需要记录三个维度模型体积、单次推理延迟、验证集精度。没有基线就学别人做优化等于蒙眼开车。我自己会先写一个小脚本把这三个指标统一打出来python baseline.py --model resnet50 --dataset val --device cpu输出示例Model size: 97.5 MB Inference latency (avg 100 iters): 31.2 ms Top-1 accuracy: 76.13%有了这份基线后续每一步优化的效果就都有了对照物。特别注意延迟要取多次运行的平均值并且要排除第一次预热带来的加载开销。我一般跑 100 次去掉前 10 次再求平均这样出来的数字才稳定。3.2 训练侧实操AdamW 与余弦退火的组合在这个 ResNet 场景里如果模型是从头训练我会直接上 SGDMomentum 配 warmup 和余弦退火。但如果是从某个预训练权重开始微调那 AdamW 更稳妥——它对新任务的适应性更好对微调数据量的要求也更宽容。举一个我实际用过的微调配置from torch.optim import AdamW from torch.optim.lr_scheduler import OneCycleLR optimizer AdamW(model.parameters(), lr2e-4, weight_decay0.05, betas(0.9, 0.999)) total_steps len(train_loader) * epochs scheduler OneCycleLR( optimizer, max_lr2e-4, total_stepstotal_steps, pct_start0.1, # 前 10% 的 step 做 warmup anneal_strategycos )这里有两个细节值得说。第一weight_decay0.05看起来比常规的 0.01 大不少但在微调场景里预训练权重已经比较成熟适当加权重衰减能防止在新任务上学得过拟合。第二pct_start0.1意味着 warmup 阶段占整个训练周期的 10%这个比例是我在新数据集上反复试出来的太短起不到稳定作用太长会浪费训练时间。3.3 推理侧实操转 ONNX、算子融合与静态量化训练完模型进入部署链路。我首选的中间表示是 ONNX。它不仅能把模型从 PyTorch 的动态图转成静态计算图更重要的是能暴露许多优化机会。PyTorch 模型导出 ONNX 很简单import torch import torchvision.models as models model models.resnet50(pretrainedFalse) # 假设是训练好的权重 model.load_state_dict(torch.load(resnet50_finetuned.pth)) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, resnet50.onnx, opset_version13, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}} )导出时有个很实用的点dynamic_axes让模型支持动态 batch但会牺牲一部分静态优化能力。如果线上 batch 大小固定建议去掉动态轴静态 shape 能让 ONNX Runtime 做更多指令级的优化。ONNX 导出后先用 ONNX Runtime 跑一遍 FP32确认没问题再考虑量化。静态量化的操作能用 ONNX Runtime 的量化工具完成from onnxruntime.quantization import quantize_static, QuantType from onnxruntime.quantization import CalibrationMethod # 准备校准数据 calibration_loader create_calibration_loader(dataset, batch_size16, num_batches64) quantize_static( model_inputresnet50.onnx, model_outputresnet50_int8.onnx, calibration_data_readercalibration_loader, quant_formatQuantType.QInt8, per_channelTrue, calibration_methodCalibrationMethod.Percentile, percentile99.99 )这里per_channelTrue值得强调。Per-tensor 量化是为整个张量算一个 scale而 per-channel 量化是为每个 channel 分别计算 scale对权重 tensor 来说per-channel 能保留更多的数值精度——代价是推理引擎的兼容性要求更高。大多数时候CNN 的权重量化用 per-channel 是划算的。3.4 优化结果对比与验证这一步把前面所有操作拼起来做一次完整的效果对比。我自己的记录习惯是做完一个优化动作就更新一次对照表最后汇总起来非常清楚。优化阶段模型体积推理延迟Top-1 精度原始 FP3297.5 MB31.2 ms76.13%结构化剪枝30% 稀疏度72.8 MB24.6 ms75.61%剪枝 INT8 静态量化18.4 MB8.9 ms75.02%剪枝 INT8 微调 3 epoch18.4 MB8.9 ms75.84%这组数字很有代表性。可以看到剪枝加上量化体积压缩到原来的 1/5 左右延迟降到原来的 1/3精度损失总共只有 0.3 个点。但要注意把表里“剪枝 INT8”那一行的精度和最终“微调后”对比能明显看出微调的价值——它把量化剪枝造成的损失几乎全部恢复了。这也是我一再强调“不要做完量化就收工微调一下往往有惊喜”的原因。4. 常见问题与排查技巧实录4.1 训练不收敛loss 卡住或者震荡遇到这种问题先别急着怀疑模型结构几乎 80% 的情况出在优化配置上。先看学习率。如果 loss 在前几个 epoch 就震荡大概率是学习率过高直接把学习率降一个量级再试。如果 loss 稳但降不下去可能是学习率过低或者 warmup 太长把有效训练时间挤压掉了。还有一类容易忽略的情况数据归一化没做好。输入范围不一致会导致梯度方向不稳定模型很难收敛——这和优化器本身没关系但会伪装成优化器的问题。4.2 量化后精度损失严重掉了好几个点这是部署环节最常见的痛。排查顺序我总结过很多次第一检查校准集是否覆盖了足够的分布样本太少或不具代表性scale 计算就是歪的。第二尝试从 min/max 换成 percentile 校准尤其是激活值里有离群点时。第三确认敏感层是否被跳过了量化比如某些归一化层或注意力层ONNX Runtime 支持配置nodes_to_exclude来跳过特定节点损失大的话可以先找出最敏感的层保持 FP32 计算其他层量化精度往往能恢复不少。4.3 剪枝后模型确实小了但延迟几乎没变这个问题几乎每个人都碰过。原因我之前提过——大概率做的是非结构化剪枝稀疏矩阵并没有得到底层计算库的特殊优化。排查方法很简单看一眼剪枝后的模型如果里面大量参数是 0但卷积运算还是按稠密矩阵去算那时间当然省不下来。解决办法是改用结构化剪枝或者确认你的推理引擎确实支持稀疏计算加速。如果两个都不愿意动那索性把“剪枝”理解为“减参数”目标转向模型体积压缩而不是推理延迟优化。4.4 优化器与学习率速查表把常见场景下的配置直接整理成一个速查表方便大家直接抄作业场景优化器初始学习率权重衰减学习率策略小规模 CNN 分类SGD Momentum0.011e-4warmup 余弦退火ResNet 系列从零训练SGD Momentum0.051e-4warmup 余弦退火Transformer 微调AdamW2e-5 ~ 5e-50.01线性 warmup 线性衰减BERT 类预训练AdamW1e-40.01warmup 多项式衰减模型量化感知训练AdamW1e-40.01warmup 余弦退火剪枝后微调SGD原始 lr 的 1/101e-4固定小学习率或余弦退火理解这张表背后的逻辑比背下数字更重要SGD 能撑起大学习率它对噪声不那么敏感AdamW 的默认学习率低因为它已经根据梯度做了加速。权重衰减的作用是抑制过拟合在数据量小的情况下尤其重要。最后说一点个人体会。Model-Optimizer 这个叫法很有迷惑性它让你以为有一个“一键搞定”的按钮。但真正做过一轮才会明白它是一条从数据、模型、优化器、学习率一路延伸到量化、剪枝、蒸馏和推理引擎的完整链路。每个环节单独拎出来都不算复杂难的是组合起来之后互相牵制学习率调高了量化可能更稳剪枝太狠微调都救不回来校准集没选好 INT8 直接崩盘。我自己的习惯是永远保持一份完整基线记录每一步只改一个变量把每次变化都量化对比。这个习惯帮我快速定位了无数次问题。如果你正准备对模型做优化我建议按这个顺序来先确认训练侧的优化器与学习率没有问题再去做推理侧的量化、剪枝、蒸馏。因为一个训练不充分或者过拟合的模型后面的所有优化都建立在沙地上。模型优化没有银弹只有在充分理解原理的前提下用工程手段一步步逼近那个精度、速度和体积的平衡点。