各自跑过模型训练的朋友应该都体会过这种“薛定谔的收敛”:网络结构照着论文抄了,数据也洗干净了,可模型就是不听话,loss 抖得像心电图,或者干脆 loss 根本不动。折腾到最后发现,问题往往不在网络,而在优化器这一层。我说的这个 Model-Optimizer,不是某一个具体框架里的 Adam 或者 SGD,而是一整套从训练到推理、从超参数到底层实现的模型优化方法论。它解决的就是“网络结构对了但效果出不来”和“模型能跑但跑不快”这两类最让人头秃的问题。这篇文章我会把训练侧的优化器选型、参数配置,以及推理侧的性能加速完整拆开讲一遍,适合刚入门深度学习、正在调参路上挣扎的新手,也适合想把推理性能再抠一抠的老手,全程都会给可直接抄作业的配置和代码。
1. 项目背景与整体设计思路
1.1 到底为什么要专门搞一个 Model-Optimizer
我在早期做模型训练的时候,对优化器的认知就是“随便选一个”,反正 PyTorch 里torch.optim一拉列表啥都有,默认用 Adam 总没错。结果实际项目一跑就露馅:图像分类任务用 Adam 收敛是挺快,但最终精度总比论文里低一两个点;NLP 任务用默认学习率训两个 epoch 直接 loss 变成 NaN;换了 SGD 加 Momentum 之后,收敛速度又慢得让人怀疑人生。
后来我逐步意识到,优化器不是一个可以“选完就忘”的参数,它直接决定了模型的收敛速度、最终精度、训练稳定性,甚至影响显存占用和推理端能压缩的空间。Model-Optimizer 这个项目的初衷,就是把训练优化器和推理优化器分开来看待——前者解决“怎么让模型练好”,后者解决“怎么让模型跑快”。两者看似独立,实际又有一条暗线连着:一个训练不充分的模型,量化或者剪枝后掉点会非常严重;反过来,训练时用了不合适的权重衰减,就算推理优化做得再漂亮,模型效果也撑不住。
我见过太多人把精力全砸在调网络结构上,把优化器当成一个无关紧要的开关。实际上,优化器是整套模型管线里“性价比”最高的调参点——改一行optimizer的配置,比改一层网络结构带来的精度收益要来得快得多和稳定得多。
1.2 方案选型:为什么把训练和推理分开做
Model-Optimizer 的整体方案我一开始就定了两个原则:训练侧以稳定收敛为先,推理侧以极致加速为目标,二者各管一段,中间用模型评估结果来串。
训练侧的优化器选择,本质上是在“探索能力”和“收敛精度”之间做权衡。SGD 系列动量项简单,泛化性能好,但对学习率和初始化非常敏感;Adam 系列自适应学习率,收敛快,对小批量和大模型友好,但容易陷入尖锐极小值,泛化性能有时不如 SGD。AdamW 是我在多数情况下的首选,因为它把权重衰减从梯度里剥离出来,解耦了 L2 正则和学习率的关系,在 Transformer 类模型上尤其明显。
推理侧的优化则是另一条路子——换掉优化器本身,改成优化“执行引擎”。这里面包括算子在硬件上的调度、内存复用、算子融合、低精度量化等一批手段。归根到底,训练时的优化器管的是“loss 曲面怎么下降”,推理时的优化器管的是“计算图怎么被最短路执行”。很多人只盯着训练,忽略了推理侧的巨大红利,而实际上一个训练好的模型,经过图优化和 INT8 量化,推理延迟砍掉一半是常有的事。
2. 训练侧核心细节解析:主流优化器的原理与适用边界
2.1 SGD、Adam、AdamW 这“三兄弟”到底怎么选
这是我在 Model-Optimizer 里被问得最多的问题。我的答案很直接:先看你的任务是 CV 还是 NLP,再看你的模型规模和数据量。
SGD 配上 Momentum,是我在中小规模图像任务上的默认盘。它的逻辑很清楚:当前这一步的更新方向 = 梯度的方向 × 学习率 + 上一次更新方向 × 动量系数。这个动量项相当于给更新方向加了惯性,让梯度在陡峭方向上的震荡被抑制掉。SGD 在测试集上的泛化表现往往好于 Adam 系列,因为它不会像 Adam 那样在训练后期还在逐参数精细缩放梯度,反而更容易收敛到平坦的极小值区域。不过它的代价是收敛慢,需要精心设置学习率、动量和学习率衰减策略,对新手并不友好。
Adam 系列则是打“暴力搜索”牌。它同时维护了梯度的一阶矩估计(动量)和二阶矩估计(梯度平方的指数移动平均),每个参数都拥有自己的自适应学习率。稀疏梯度场景、噪声较大的小批量场景下,Adam 的表现非常稳,基本不用怎么调节,第一版跑出来就不会太离谱。代价是训练后期容易被自适应学习率拖住,在最优解附近反复试探却难以精细收敛,同时两倍的优化器状态也让显存蹭蹭上涨。
AdamW 可以理解为“修正版 Adam”。标准 Adam 里的权重衰减是直接加在梯度上的,会导致 L2 正则的效果被自适应学习率扭曲——大梯度参数被正则弱,小梯度参数被正则强,这显然是不对的。AdamW 把权重衰减移到参数更新步骤中独立执行,等价于每次都先把参数往零方向收缩一下,再做梯度更新。这个改动在训练 Transformer 类模型时效果非常明显。我有一次做文本分类实验,同样的学习率,标准 Adam 训到第 10 个 epoch 验证集 F1 一直在 0.86 附近抖动,换成 AdamW 之后第 6 个 epoch 就稳定摸到了 0.90。
2.2 学习率、权重衰减和 warmup 的搭配原则
优化器的框架定下来之后,真正决定效果的是几个超参数的耦合关系。很多人单独调 learning rate,或者单独调 weight decay,其实是没抓住重点——这三者的关系是连在一起呼吸的。
先说学习率。AdamW 常用的初始学习率窗口在 1e-4 到 3e-3 之间,具体要看批次大小和模型规模。我自己的经验是:模型越大,学习率越小。比如训练几千万参数的模型,初始学习率可以放在 5e-4 到 1e-3;到了亿级参数的模型,建议直接降到 2e-4 起步。大模型对学习率极其敏感,稍微大一点就会在早期出现 loss 震荡甚至 NaN,小一点又训不动。
权重衰减这里有个很经典的坑。很多人直接把 weight_decay 设成 0.01 或者 0.001,也不管自己用的是 SGD 还是 AdamW。实际上,权重衰减的合理区间和优化器类型挂钩:SGD 搭配的 weight_decay 通常在 5e-4 附近,而 AdamW 在 Transformer 类任务上我更推荐从 0.01 起调,就算你想加大正则,也别轻易突破 0.1,否则模型会明显欠拟合。权重衰减的本质是给参数一个向零收缩的力,它是一个“慢变量”,不像学习率那样每一轮都剧烈影响更新步长,所以调起来不容易见急效,但长期缺失会让模型在训练集上表现很险。
再讲 warmup。为什么需要它?因为训练初期模型参数是随机初始化产生的,梯度方向含有大量噪声,一开始就用大学习率会把参数推到一个很差的区域,之后就很难绕回来。线性 warmup 的意思是前 N 个 step 里,学习率从 0 或很小的值平滑升到设定峰值,等模型对数据分布有基本感知了,再全力加速。N 的取法,小数据集可以设置几百步,大数据集建议按 epoch 数换算成 step 来设,通常占全部训练 step 的 5% 到 10%。我一般会顺带给学习率配上余弦退火,让它在训练后半段自己慢慢降下来,每个阶段关注不同的精度层次。
2.3 训练优化器的参数配置速查表
我把 Model-Optimizer 里经常用的几套训练优化器配置整理成了一个速查表,方便照着抄。注意这里的“适用场景”只代表一个常见起点,真正跑的项目还是要根据 input 数据分布做微调。
| 任务类型 | 优化器 | 初始学习率 | weight_decay | 批次大小 | 备注 |
|---|---|---|---|---|---|
| 小规模图像分类 | SGD+Momentum | 1e-1 到 3e-1 | 5e-4 | 128 或 256 | 配合 StepLR 或 CosineAnnealing 使用 |
| 大规模图像分类 | SGD+Momentum | 1e-1 到 4e-1 | 5e-4 到 1e-3 | 256 以上 | 建议配合 warmup 和 cosine 退火 |
| 中小规模 NLP 分类 | AdamW | 2e-5 到 5e-5 | 0.01 | 16 到 64 | 预测练模型微调时尤其适用 |
| Transformer 训练 | AdamW | 1e-4 到 3e-4 | 0.01 到 0.1 | 逐任务调整 | 必须配合 warmup,不宜直接用默认 lr |
| 生成对抗网络 | Adam | 1e-4 到 2e-4 | 0 | 32 或 64 | 两个网络分别设 lr,通常 G 稍低 |
我特别想说一下最后一行 GAN 的场景。生成对抗网络同时训练生成器和判别器,两者的梯度状态完全不同,千万别共用同一个优化器实例。正确做法是给 Generator 和 Discriminator 各建一个 Adam 优化器,学习率可以自己根据训练稳定性调。我有一个经验,Generator 的学习率设为 1e-4、Discriminator 设为 2e-4 时训练节奏最稳,一旦两个都用 2e-4,判别器会快速压过生成器,导致生成图像模式坍缩。
3. 推理侧核心细节解析:从“练得好”到“跑得快”
3.1 计算图优化与算子融合的底层逻辑
训练收敛之后,模型只是一个静态权重文件,真正的挑战是怎么在有限的硬件资源上把它跑起来。推理侧的第一件大事就是计算图优化,深度学习推理框架(比如 ONNX Runtime、TensorRT)干的第一件事就是把训练框架导出的计算图腾挪压缩一遍。
算子融合是图优化里“收益最直观”的手段。举个最常见的例子:Conv + BatchNorm + ReLU 三段结构,在训练时是三个独立算子,但推理时 BatchNorm 已经可以用训练好的均值和方差折算到 Conv 卷积核的权重和偏置里,ReLU 又可以直接跟在后面不单独分配内存。于是三段算子变成了一段算子,省掉了中间张量的写出和读入。别小看这一步,我在 1080Ti 上跑过 ResNet50 的 FP32 模型,单纯做算子融合就能获得 15% 到 20% 的加速,几乎不损失精度,因为你只是调整了计算编排,没有改变任何数值逻辑。
图优化还有一大类是“内存复用”。推理引擎会把整张计算图里所有中间张量的生命周期做分析,只要两个张量不会同时在计算图上被用到,就把它们安排到同一块显存或内存地址上。这个操作对减少显存碎片和峰值显存有奇效。实际做服务化推理时,显存不只是一个容量问题,分配得多了反而会在存算一体设备上带来更多访存开销,所以图优化做得好的引擎,往往在同等显存下能堆更大的模型实例。
3.2 量化方案:INT8 如何做到精度不崩
量化是推理加速里容易赚到大头红利的手段。把模型权重和激活值从 FP32 压到 INT8,模型体积直接缩到四分之一,卷积和矩阵乘在支持 INT8 指令的硬件上的计算速度可以成倍提升。难点在于,你不可能直接拿一个训练好的模型,把权重硬砍成 8bit 还能保证精度——数值范围的截断误差会把模型推向灾难。
我常用的量化工具有两种:训练后量化和量化感知训练。训练后量化最简单,喂几百到几千条有代表性的校准数据,逐层统计激活值的数值分布,然后算出每个张量合适的 scale 和 zero_point。这个方案不用改动训练流程,适合快速落地。风险在于如果模型对数值扰动比较敏感,精度下降会不可控。我在做目标检测模型时试过,YOLOv5 模型用训练后量化直接掉 3 个点 mAP,后面切成量化感知训练才拉了回来。
量化感知训练的思路是在训练阶段就把量化误差模拟进去——前向传播时把权重和激活值先量化再反量化,让模型在训练过程中自己适应数值精度损失。这么做虽然要重新训练一遍模型,但精度基本能保住。注意有个细节:量化感知学习率要调小个 5 到 10 倍,否则模型会被量化噪声带着跑偏。另外,很多框架对量化训练使用的是伪量化节点,训练完之后要把这些节点真正转换成 INT8 执行,这一步如果转换器处理不好,精度和速度都会打折扣。
3.3 推理加速的其他实用技巧
除了算子和精度手段,还有几个容易被忽略的推理优化项。一个是批处理(batch size)的选择,GPU 推理时单条样本的延迟低,但吞吐不理想;批量推理能把计算资源打满,代价是单条延迟变长。做线上服务时要根据业务容忍的 P99 延迟,反向计算合理的批次大小。另一个是动态形状问题,很多推理框架对固定输入形状最友好,因为所有算子的显存分配和 kernel 选择都可以做得更激进。如果业务允许,把输入分辨率固定下来,比任何框架层面的优化都更管用。
多实例并行也是一种手段。显存足够时,同时开多个模型实例做负载均衡,总吞吐几乎线性增长。但这种方案在小显存设备上不好使,只能做层内并行或算子级并行。最后我想提醒一下 CPU 推理的场景:如果你的推理跑在 CPU 上,注意把线程数、内存分配策略和算子里的数学库选对,例如 Intel 平台试试 oneDNN 后端,ARM 平台上试试 ACL 后端,这些后端在卷积和矩阵乘上做了极深的手工汇编优化,快起来比自己用原版框架跑要明显得多。
4. 实操过程与核心环节实现
4.1 训练侧优化器配置的完整代码示例
我以 PyTorch 为例,给你看一段我真正会在项目中使用的 Model-Optimizer 训练侧配置。重点在于区分“主干参数”和“偏置、Norm 层参数”——后者通常不需要权重衰减,所以放进优化器时要单独分组。
import torch from torch import nn from torch.optim import AdamW from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR model = build_model() # 以你自己的模型构建函数为准 # 分组:偏置和 Norm 层不参与权重衰减 no_decay = ["bias", "LayerNorm.bias", "LayerNorm.weight"] optimizer_grouped_parameters = [ { "params": [ p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay) and p.requires_grad ], "weight_decay": 0.01, }, { "params": [ p for n, p in model.named_parameters() if any(nd in n for nd in no_decay) and p.requires_grad ], "weight_decay": 0.0, }, ] optimizer = AdamW( optimizer_grouped_parameters, lr=2e-4, betas=(0.9, 0.999), eps=1e-8, ) # 线性 warmup 500 步 + 余弦退火,总步数 total_steps warmup_steps = 500 total_steps = len(train_dataloader) * num_epochs scheduler = SequentialLR( optimizer, schedulers=[ LinearLR(optimizer, start_factor=0.1, total_iters=warmup_steps), CosineAnnealingLR(optimizer, T_max=total_steps - warmup_steps), ], milestones=[warmup_steps], )这段代码里有几个关键动作值得展开说。参数分组那里,我把 LayerNorm 的 weight 和 bias 都排除在权重衰减之外,这是基于 Transformer 类模型的常用策略——Norm 层的参数直接在标量维度上做缩放,在特征维度上归一化时已经天然具备尺度鲁棒性,额外的 L2 惩罚会干扰这个机制。warmup 用 SequentialLR 串起线性上升和余弦下降,milestones 参数的意义是告诉调度器在第 500 步时切换策略,这个写法比手动循环改学习率要干净得多。
训练循环里,我还会额外踩一个容易忽略的坑:step 和 zero_grad 的顺序必须养成固定习惯。建议每个 iteration 先optimizer.zero_grad(),再loss.backward(),最后optimizer.step()。虽然顺序对了反向传播也能用,但养成一致的规范,代码在分布式训练或梯度累积时不容易出错。
4.2 推理侧优化的实操流水线:PyTorch → ONNX → TensorRT
推理优化我现在的常规流程是先导出 ONNX,再用 TensorRT 或 ONNX Runtime 做后续加速。先看导出 ONNX 这段:
import torch import torch.onnx model = load_trained_model().eval() # 固定一个能表达生产环境的输入尺寸 dummy_input = torch.randn(1, 3, 640, 640).to("cuda") torch.onnx.export( model, dummy_input, "model.onnx", opset_version=13, do_constant_folding=True, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, )do_constant_folding=True很关键,它会在导出时把那些不依赖输入的子图直接算成常量,省掉一次推理时的冗余计算。dynamic_axes意思是允许 batch 维度可变,但如果你的业务 batch 固定,建议干脆不要加 dynamic_axes,让所有维度静态化,后面在推理引擎里能做更深度的优化。
拿到 ONNX 模型后,我一般先用 ONNX Runtime 跑一版量化。注意校准过程不能拿训练集整批灌进去,而是从训练集里随机抽 200 到 1000 条分布有代表性的样本,搭建一个 DataLoader 来产生校准数据。
import onnxruntime from onnxruntime.quantization import quantize_static, QuantType from onnxruntime.quantization import CalibrationDataReader class CalibReader(CalibrationDataReader): def __init__(self, dataloader): self.dataloader = iter(dataloader) self.input_name = "input" def get_next(self): batch = next(self.dataloader, None) if batch is None: return None return {self.input_name: batch.cpu().numpy()} calib_reader = CalibReader(calib_dataloader) quantize_static( "model.onnx", "model_int8.onnx", calib_reader, quant_format=QuantType.QOperator, per_channel=True, weight_type=QuantType.QInt8, )这里我特意让激活值走静态量化而不是动态量化——动态量化在每个输入到达时都要实时计算 scale 和 zero_point,会带来额外开销,服务端推理场景通常不划算。per_channel=True是保障精度的重要开关,按卷积的每个输出通道各算一个 scale,比逐张量量化精度高不少。跑完量化之后,拿model_int8.onnx在验证集上跑一遍,精度和延迟都要记录和量化前对比,这一步不能省。
如果项目对延迟有硬性指标,我会再走一遍 TensorRT:把 ONNX 文件交给trtexec或TensorRT Python API,构建一个优化过的推理引擎,并选择合适的计算精度(FP32、FP16 或 INT8)。TensorRT 构建引擎时会做层融合、kernel 自动调优、显存规划等一系列事情,耗时可能从几分钟到几十分钟都有,所以引擎要构建一次然后落盘缓存,线上加载缓存文件就行。
4.3 优化效果的评估方式和结果对比表
做任何优化动作之前,一定先定义好评估指标,否则你根本分不清是优化起了作用,还是运气好。我在 Model-Optimizer 里固定记录三类指标:精度指标(比如 mAP、Acc、F1)、延迟指标(p50/p95/p99 延迟)、资源指标(显存占用、引擎大小)。每当切换一个优化手段,就把三类指标记在同一张对比表里。
| 优化阶段 | 精度(mAP) | p50 延迟(ms) | p99 延迟(ms) | 显存占用(MB) | 模型大小 |
|---|---|---|---|---|---|
| 原始 PyTorch 模型 | 0.523 | 38.6 | 72.4 | 1052 | 124.6 |
| ONNX 图优化 | 0.523 | 29.5 | 55.1 | 911 | 124.6 |
| 训练后量化 INT8 | 0.494 | 18.9 | 36.8 | 502 | 31.2 |
| 量化感知训练 + INT8 | 0.517 | 18.6 | 35.9 | 501 | 31.2 |
从这张表能清楚看到:单纯训练后量化虽然快,但 mAP 掉了近 3 个点;改用量化感知训练后,精度只掉了 0.6 个点,延迟和显存收益完全保住。这也是我一直坚持“任何加速方案都要拿精度来买单”的原因——优化结果不是越快越好,而是在精度和速度之间找到业务能接受的那个平衡点。如果你的业务对精度极其敏感,更好的选择是保住 FP32,只做图优化,而不是硬着头皮做量化。
5. 常见问题与排查技巧实录
5.1 训练时 loss 变成 NaN 或者直接发散
我遇到的最常见情况,是使用 Adam 系优化器时学习率设得过高。有些代码仓库给的默认学习率是 1e-3,放在大模型或某些 loss 震荡激烈的任务上就会爆。排查方法很简单:先把打印出来的 loss 曲线按 step 拉出来看,如果 loss 在几百步内直冲 NaN,90% 是学习率问题,先把学习率降一个量级,同时把 warmup 加上,基本都能救回来。另一个容易被忽视的原因是混合精度训练的 loss scaling 策略没配好,梯度下溢或溢出都会导致 NaN,这时候要检查 GradScaler 的配置。
在 NLP 模型里,embedding 层过大时也容易出怪问题。我踩过的一个坑是,没有给 embedding 的梯度做 clip,训练后期一个异常样本把 embedding 梯度顶得巨大,连带整个模型的权重都飞了。建议训练时一律加上torch.nn.utils.clip_grad_norm_,max_norm 我常用 1.0,这对深层模型几乎是无痛的安全垫。
5.2 量化后精度大幅下跌怎么办
训练后量化掉点超过 3 到 5 个点时,不要急着怀疑量化本身,先检查两个地方:校准数据集是否贴合真实分布,比如拿类别不均衡的数据做校准,量化尺度会被稀有类带偏;有没有对每个通道做独立缩放,很多框架默认是逐张量量化,对大动态范围的卷积层伤害很大。
如果这两步都没问题,那就只能上量化感知训练。注意一点:量化感知训练不是把优化器换成 QAT 专用的就算完,你仍然需要把原始训练策略里的 warmup 和正则保留住,减少量化伪节点带来的噪声干扰。我建议量化感知训练的学习率设成原训练学习率的十分之一到五分之一,否则模型还没适应量化误差就已经被大学习率推远了。
5.3 推理引擎构建慢和显存占用居高不下
TensorRT 这类引擎构建慢是非常正常的,因为它在为每个算子搜索当前显卡性能最优的 kernel,这个过程在拥有几百个算子的网络里可能要跑十几分钟。解决办法是把构建过程放到离线环境完成,生成 engine 或 plan 文件后序列化保存,线上直接加载,后续每次启动都省掉构建环节。
显存占用高则要区分是模型本身大,还是推理碎片多。如果是前者,优先考虑 INT8 量化和权重共享;如果是后者,开启推理引擎的内存池管理,让它自行做内存复用,不要手动在运行时反复分配和释放张量。尤其不要在请求处理循环里频繁创建小张量,这会在内存池里造成大量空洞,既拖慢速度又放大显存开销。
5.4 分布式训练下优化器状态不同步导致结果漂移
分布式训练时有个容易被忽略的坑:不同的 worker 上优化器超参不一致,或者 data shuffle 的随机种子没固定,导致每个进程模型初始状态就不一样。优化器里的随机因素(比如 dropout、数据增强)结合在一起,会让各卡上的更新路径产生分歧,最终模型精度出现小幅漂移。我的做法是:先设置全过程的随机种子(PyTorch 里要同时设置torch.manual_seed、random.seed和numpy.random.seed),再用 DistributedSampler 保证每张卡数据不重叠。如果还想让结果完全可复现,可以把cudnn.deterministic = True打开,这对训练速度只有轻微影响。
6. 写在最后的个人体会与扩展建议
Model-Optimizer 这个项目我前前后后折腾了大半年,最大的体会是:优化器不是孤立的一行代码,它和数据集分布、模型结构、硬件环境是一个整体系统。官方默认参数只是兜底方案,真实项目里的收益都是一点点抠出来的。
我个人现在的固定动作是:拿到一个新任务,先用 AdamW 把模型跑通,确认 loss 能稳定下降,再切换到 SGD 或继续用 AdamW 做精细调优;推理侧永远先做图优化,再尝试量化,量化后每一步都盯紧精度和延迟两个监控指标,不做无脑加速。这套流程虽然听起来朴素,但每次都能让我节省大量试错时间。
如果你后续想在这个方向继续深入,我建议往这几个方向拓展:一是自动化超参搜索工具(如 Optuna)和 Model-Optimizer 结合,让学习率、权重衰减的搜索不再靠手感和运气;二是把训练和推理优化串成一条 CI/CD 流水线,模型每次更新后自动跑一次推理优化和性能回归;三是在量化基础上再尝试更激进的稀疏化和结构化剪枝,这类手段和 INT8 是叠加关系,可以继续榨出硬件性能余量。
最后分享一个小技巧:任何优化器改动,都先在一个很小的子集上快速跑一次验证实验,确认方向正确后再放大到全量数据和全量参数上。这个习惯能让你避免很多因为“隐性 bug 被隐藏在大模型训练周期里”而产生的无效等待,也是我在这大半年里最想强调的一条实战经验。