显存不够用这件事,几乎每个跑深度学习训练的人都撞上过。你写好模型、调好超参、满心欢喜地按下运行,结果几百毫秒后终端甩出一行红字:CUDA out of memory。那一刻的心情,大概和开车上高速发现油表见底差不多。多数人的第一反应是换卡、租更大显存的机器,或者把 batch size 一降再降。但降 batch size 有个副作用——梯度噪声变大、训练不稳定、收敛变慢,甚至最终精度掉点。梯度累积(Gradient Accumulation)就是在这个夹缝里被反复拿出来用的手段:它让你在小显存上"假装"跑大 batch,既省显存又尽量保住大 batch 的训练效果。这篇就围绕它展开,把原理、实操、坑点和边界条件一次讲透,适合正在被显存卡脖子、又不想牺牲训练质量的人参考。
1. 先搞清楚显存到底被谁吃掉了
在动手之前,得先弄明白显存都花在哪了。很多人一遇到 OOM 就盲目降 batch,其实没找准大头,降了半天效果有限。训练时的显存占用大致分四块,理解它们的量级关系,才能判断梯度累积对你有没有用。
1.1 显存占用的四个主要来源
第一块是模型参数本身。一个 7B 参数的模型,如果用 FP16 存,光权重就要约 14GB;用 FP32 则要 28GB。这部分是固定的,梯度累积动不了它。
第二块是梯度。每个可训练参数都对应一份梯度,量级和参数本身相当。FP16 训练时梯度通常也是 FP16,所以又是约 14GB(以 7B 为例)。
第三块是优化器状态。这是最容易被低估的。以 Adam 为例,它要为每个参数维护一阶动量(m)和二阶动量(v),如果都用 FP32 存,那就是参数量的两倍,7B 模型下约 56GB。再加上参数本身的 FP32 主副本(mixed precision 训练时会有),优化器状态往往是显存占用的最大头。
第四块是激活值(activations)。前向传播过程中每一层的中间输出都要留着,反向传播时算梯度要用。这部分和 batch size 成正比,也是梯度累积唯一能直接影响的显存项。
把这四块列个表对比一下,量级关系一目了然:
| 显存来源 | 是否随 batch 变化 | 7B 模型 FP16 粗略量级 | 梯度累积能否降低 |
|---|---|---|---|
| 模型参数 | 否 | ~14GB | 否 |
| 梯度 | 否 | ~14GB | 否 |
| 优化器状态 | 否 | ~56GB(Adam FP32) | 否 |
| 激活值 | 是 | 随 batch 线性增长 | 是 |
看这张表就明白了:梯度累积只能压激活值那一块。如果你的显存是被优化器状态吃掉的,那梯度累积帮不上大忙,得靠 ZeRO、8-bit 优化器这类手段。但实际训练里,尤其是大 batch 或长序列场景,激活值经常是压垮显存的最后一根稻草,这时候梯度累积就非常对症。
1.2 为什么激活值是大 batch 的隐形杀手
激活值的显存占用和 batch size、序列长度、模型层数、隐藏维度都相关。以 Transformer 为例,粗略估算,单层激活值大致和batch × seq_len × hidden_dim成正比,再乘以层数。序列长度翻倍,激活值翻倍;batch 翻倍,激活值也翻倍。这就是为什么长文本训练特别吃显存——你 batch 只开到 2,seq_len 拉到 4096,激活值照样爆炸。
我见过不少人调模型时把 seq_len 从 512 拉到 2048,结果显存直接翻四倍,然后一脸茫然。其实这就是激活值的线性叠加效应。梯度累积的价值在于:它把"一次前向的 batch"和"一次参数更新的等效 batch"解耦了。你每次前向只处理小 batch,激活值按小 batch 算,但梯度累加起来,等效于大 batch 更新。激活值降下来了,等效 batch 却保住了。
1.3 梯度累积和降 batch 的本质区别
很多人觉得"梯度累积不就是把 batch 拆开跑吗,那和直接降 batch 有什么区别"。区别大了。直接降 batch,你每次更新用的都是小 batch 的梯度,梯度噪声大,更新方向抖,学习率还得跟着调小,否则容易发散。而梯度累积是把 N 个小 batch 的梯度加起来再更新一次,等效 batch 是 N 倍,梯度噪声被平均掉了,更新方向更稳,学习率可以按大 batch 来设。
打个比方:降 batch 像是每次只尝一口菜就下结论,容易偏;梯度累积像是尝了 N 口再综合判断,结论更靠谱。两者最终"吃进去的总量"可能一样,但决策质量不同。这也是为什么在显存受限时,梯度累积通常是比降 batch 更优的选择——前提是你能接受它带来的训练时间增加。
2. 梯度累积的数学原理与等效 batch 换算
理解了显存构成,接下来得把梯度累积的数学逻辑理清楚。这部分不搞玄学,就是链式法则和期望的简单应用,但很多人对"等效 batch"的换算一知半解,导致学习率设错、训练效果打折。
2.1 从单步更新到累积更新
标准训练里,一次参数更新是这样的:取一个 batch 的数据,前向算 loss,反向算梯度,优化器用这个梯度更新参数。数学上,loss 通常是 batch 内样本 loss 的平均值:
L = (1/B) * Σ loss_i (i 从 1 到 B)梯度就是∂L/∂θ。当 B 很大时,这个平均梯度的方差小,方向稳。
梯度累积的做法是:把 B 拆成 N 个 micro-batch,每个大小 b = B/N。每次只前向反向一个 micro-batch,得到该 micro-batch 的梯度,但不更新参数,也不清零梯度,而是把梯度累加。等 N 个 micro-batch 都跑完,梯度里存的就是 N 个小 batch 梯度的和。这时候再除以 N(或者等价地在 loss 上除以 N),就得到了和一次性跑大 batch 几乎一样的平均梯度,然后更新一次参数,清零梯度,进入下一轮。
用伪代码表示更直观:
optimizer.zero_grad() for i, micro_batch in enumerate(dataloader): outputs = model(micro_batch) loss = criterion(outputs, targets) # 关键:loss 除以累积步数,保证梯度量级和真实大 batch 一致 loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()这里有个细节:loss / accumulation_steps这一步不能省。因为 PyTorch 的backward()默认是累加梯度,如果你不除,N 个 micro-batch 的梯度加起来就是真实大 batch 梯度的 N 倍,相当于学习率被放大了 N 倍,训练很容易炸。除以 N 之后,累加得到的梯度才和真实大 batch 的平均梯度在量级上一致。
2.2 等效 batch size 的精确换算
等效 batch size 的计算很简单:
等效 batch = micro_batch_size × accumulation_steps × GPU 数量比如你单卡 micro_batch 设为 4,累积步数设为 8,单卡训练,那等效 batch 就是 32。如果你用 4 张卡做数据并行,每张卡 micro_batch 4,累积 8,那全局等效 batch 是 4 × 8 × 4 = 128。
这里有个容易踩的坑:数据并行下,梯度会在卡间做 all-reduce 求平均。所以每张卡上的累积梯度在 all-reduce 后会被平均,等效 batch 的换算要乘上卡数。如果你手动在 loss 上除以了累积步数,又用了 DDP,那 DDP 的梯度平均是另一层,两者不冲突,但要把账算清楚,否则学习率会偏。
我一般建议的做法是:先确定你想要的等效 batch,再反推 micro_batch 和累积步数。比如你目标等效 batch 是 256,显存只允许 micro_batch 开到 8,单卡,那累积步数就是 32。这样思路清晰,不会乱。
2.3 学习率要不要跟着调
这是问得最多的问题。结论是:梯度累积本身不改变等效 batch 的梯度量级,所以学习率应该按等效 batch 来设,而不是按 micro_batch。
也就是说,如果你原来用 batch 256 配学习率 1e-3 训得好好的,现在显存不够改成 micro_batch 8 + 累积 32,等效 batch 还是 256,那学习率继续用 1e-3,不用动。这一点和"直接降 batch 到 8"完全不同——后者你得把学习率调小,否则更新太猛。
但有个例外:如果你用了 BatchNorm,情况会复杂。BatchNorm 的统计量是按 micro_batch 算的,micro_batch 太小会导致统计量不准,这时候梯度累积救不了你,得换 GroupNorm 或 SyncBatchNorm。这个坑后面会细说。
3. 主流框架里的落地写法与参数配置
原理清楚了,落到代码上,不同框架的写法有差异。这一节把 PyTorch 原生、HuggingFace Trainer、以及 DeepSpeed 三种常见路径的配置讲清楚,都是可以直接抄的。
3.1 PyTorch 原生训练循环的完整写法
原生循环最灵活,也最能看清每一步在干什么。下面是一个带梯度累积的完整训练片段,包含学习率调度和梯度裁剪的正确位置:
accumulation_steps = 8 micro_batch_size = 4 base_lr = 1e-3 optimizer = torch.optim.AdamW(model.parameters(), lr=base_lr) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=total_update_steps ) model.train() optimizer.zero_grad() for step, (inputs, targets) in enumerate(dataloader): inputs, targets = inputs.cuda(), targets.cuda() outputs = model(inputs) loss = criterion(outputs, targets) loss = loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: # 梯度裁剪要放在 step 之前,且只裁一次 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()几个关键点值得展开说。
梯度裁剪的位置。裁剪必须在optimizer.step()之前、累积完成之后做,而且一个累积周期只裁一次。如果你在每个 micro-batch 后都裁,那裁的是不完整的梯度,等于人为把梯度压小了,训练会变慢甚至学不动。这个坑我早期踩过,loss 一直不降,排查半天才发现是裁剪位置错了。
zero_grad()的位置。它必须在optimizer.step()之后调用,而不是循环开头。如果你习惯性地在循环开头zero_grad(),那每个 micro-batch 的梯度都会被清掉,累积就失效了。这也是新手常犯的错。
最后一个不完整周期。如果 dataloader 的步数不能被accumulation_steps整除,最后会剩几个 micro-batch 没触发更新。稳妥的做法是在循环结束后补一次判断,把剩余的梯度用掉,否则这几步的数据就白跑了。
3.2 HuggingFace Trainer 的 gradient_accumulation_steps
用 HuggingFace 的话,事情简单很多,直接设gradient_accumulation_steps就行:
from transformers import TrainingArguments training_args = TrainingArguments( output_dir="./output", per_device_train_batch_size=4, gradient_accumulation_steps=8, learning_rate=1e-3, num_train_epochs=3, fp16=True, gradient_checkpointing=True, logging_steps=10, save_strategy="epoch", )Trainer 内部会自动处理 loss 缩放、梯度累积和更新时机,你不用自己写循环。但有两个细节要注意。
第一,per_device_train_batch_size是 micro_batch,不是等效 batch。等效 batch 是per_device_train_batch_size × gradient_accumulation_steps × 卡数。很多人看日志里的 batch size 显示的是 micro_batch,误以为等效 batch 就是它,结果学习率设错。
第二,Trainer 的日志里global_step是按更新次数算的,不是按 micro-batch 步数。所以logging_steps、save_steps这些参数都是按更新步来计数的,别搞混。
3.3 DeepSpeed 配置里的累积设置
DeepSpeed 的配置文件里,梯度累积通过gradient_accumulation_steps设置,同时它和 ZeRO 优化配合能进一步省显存:
{ "train_batch_size": 256, "gradient_accumulation_steps": 8, "fp16": { "enabled": true }, "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu" } } }这里train_batch_size是全局等效 batch,DeepSpeed 会根据卡数和gradient_accumulation_steps反推每卡的 micro_batch。这个设计比 HuggingFace 更省心,因为你直接声明目标,不用自己算。但要注意train_batch_size必须能被卡数 × gradient_accumulation_steps整除,否则会报错。
DeepSpeed 的 ZeRO stage 2 会把优化器状态分片到各卡,stage 3 连参数也分片。配合梯度累积,能在有限显存下把等效 batch 拉得很大。我实测过,单卡 24GB 用 ZeRO stage 2 + 梯度累积,等效 batch 能开到 512 以上,这在不用这些技术时是不可想象的。
4. 那些文档不会告诉你的坑
梯度累积看起来简单,但实际用起来坑不少。这一节把我踩过的、见过的坑集中列出来,每个都给出排查思路和解决方案。
4.1 BatchNorm 与梯度累积的冲突
这是最隐蔽的坑。BatchNorm 在训练时用当前 batch 的均值和方差做归一化,并更新 running mean/var。当你用梯度累积时,每个 micro-batch 的统计量是独立算的,micro_batch 越小,统计量越不准,running mean/var 的更新也越频繁(每个 micro-batch 都更新一次,而不是每个等效 batch 更新一次)。
后果是:模型训练不稳定,验证集表现差,甚至完全学不动。而且这个坑很隐蔽,因为 loss 可能还在降,只是最终精度上不去,你很难第一时间怀疑到 BatchNorm 头上。
解决方案有三个。一是把 BatchNorm 换成 GroupNorm 或 LayerNorm,它们不依赖 batch 统计量,天然兼容梯度累积。二是用 SyncBatchNorm,在多卡间同步统计量,但单卡场景下没用。三是如果非要用 BatchNorm,把 micro_batch 尽量开大,减小统计量偏差,但这又和梯度累积省显存的初衷矛盾。
我的建议是:只要用梯度累积,就优先考虑无 batch 依赖的归一化层。Transformer 类模型本来就用 LayerNorm,没这个问题;CNN 类模型要特别注意,能换 GroupNorm 就换。
4.2 学习率调度器的步数计算错误
学习率调度器(如 Cosine、StepLR)是按"更新步数"来调整学习率的。如果你用梯度累积,更新步数变成了总 micro-batch 步数 / accumulation_steps。很多人设T_max或step_size时按 micro-batch 步数算,结果学习率衰减快了 N 倍,模型还没收敛学习率就降到接近零了。
正确的算法是:先算出总的更新步数,再传给调度器。
总更新步数 = (总样本数 / 等效 batch) × epoch 数或者更直接:
总更新步数 = 总 micro-batch 步数 / accumulation_steps我一般会在训练开始前打印一下总更新步数,和预期对一下,避免算错。这个检查花不了几秒钟,但能省下几小时的无效训练。
4.3 梯度累积与梯度检查点的叠加使用
梯度检查点(Gradient Checkpointing)是另一个省显存的大杀器,它不存所有激活值,而是在反向时重新计算,用时间换显存。它和梯度累积可以叠加,效果是显存占用进一步下降,但训练时间进一步增加。
叠加时要注意:梯度检查点本身会让反向传播变慢,梯度累积又增加了前向反向的次数,两者叠加后训练时间可能是原来的好几倍。我实测过一个场景,baseline 训练 1 小时,加梯度检查点变 1.5 小时,再加梯度累积 8 步变 3 小时以上。所以如果你的瓶颈是时间而不是显存,要权衡一下。
另外,梯度检查点和梯度累积叠加时,loss / accumulation_steps这一步依然要做,两者不冲突。但有些框架的梯度检查点实现会在反向时对梯度做特殊处理,建议先用小规模数据跑通,确认梯度量级正确再上大规模。
4.4 累积步数设太大反而有害
梯度累积不是越多越好。累积步数太大,等效 batch 太大,会带来两个问题。一是泛化性下降。大量实验表明,batch 太大时模型容易收敛到 sharp minimum,泛化性变差,验证集精度反而可能不如中等 batch。二是更新次数太少。同样的 epoch 数下,累积步数越大,参数更新次数越少,模型可能欠拟合。
我的经验是:等效 batch 不要盲目往大了堆,一般控制在 256 到 1024 之间比较稳妥,具体看任务和数据集大小。如果显存实在不够,宁可配合 ZeRO 或梯度检查点,也不要把累积步数设到几十上百。累积步数超过 16 之后,收益递减很明显,而训练时间线性增长。
5. 怎么判断梯度累积有没有真正生效
写完代码跑起来,怎么确认梯度累积真的按预期工作了?不能只看 loss 在降就放心,得有几个验证手段。
5.1 用梯度量级做交叉验证
最直接的办法是对比"梯度累积"和"真实大 batch"的梯度。取一个固定的小数据集,分别用两种方式跑一次前向反向,打印梯度的范数。如果实现正确,两者的梯度范数应该非常接近(差异在浮点误差范围内)。
# 方式一:真实大 batch outputs = model(big_batch_inputs) loss = criterion(outputs, targets) loss.backward() grad_norm_big = get_grad_norm(model) # 方式二:梯度累积 model.zero_grad() for micro_batch in split(big_batch, accumulation_steps): outputs = model(micro_batch) loss = criterion(outputs, targets) / accumulation_steps loss.backward() grad_norm_accum = get_grad_norm(model) print(grad_norm_big, grad_norm_accum) # 两者应接近如果两者差很多,八成是loss / accumulation_steps漏了,或者zero_grad()位置错了。这个验证方法很实用,建议在正式训练前跑一次。
5.2 观察显存占用的变化
梯度累积生效后,显存占用应该明显低于真实大 batch。你可以用torch.cuda.max_memory_allocated()打印峰值显存,对比一下。如果显存没降,说明激活值不是瓶颈,梯度累积帮不上忙,得换思路。
我一般会在训练脚本里加一行显存监控,每 N 步打印一次峰值显存,这样能直观看到显存随 batch 和累积步数的变化趋势,调参时心里有数。
5.3 检查参数更新频率
还有一个简单的验证:在optimizer.step()里打个计数器,看一个 epoch 下来更新了多少次。这个次数应该等于总 micro-batch 步数 / accumulation_steps。如果对不上,说明累积逻辑有问题。
这个检查能抓出很多隐蔽的 bug,比如 dataloader 的 drop_last 设置导致步数不对、最后一个不完整周期没处理等。花几分钟加个计数器,比事后排查几小时划算得多。
6. 梯度累积之外,还有哪些省显存的路子
梯度累积只是省显存工具箱里的一件。实际项目里,往往是多个手段组合使用。这一节把常见的省显存手段和梯度累积的配合关系理一理,帮你搭出一套完整的方案。
6.1 混合精度训练与梯度累积的配合
混合精度(AMP)用 FP16 做前向反向,FP32 存主权重,能省下可观的显存和计算时间。它和梯度累积是天然搭档:AMP 省参数和激活的显存,梯度累积省激活的显存,两者叠加效果很好。
用 AMP 时要注意梯度缩放(GradScaler)。梯度累积下,scaler.scale(loss).backward()要在每个 micro-batch 调用,scaler.step(optimizer)和scaler.update()在累积完成后调用。顺序别搞错,否则梯度缩放会失效。
scaler = torch.cuda.amp.GradScaler() for step, (inputs, targets) in enumerate(dataloader): with torch.cuda.amp.autocast(): outputs = model(inputs) loss = criterion(outputs, targets) / accumulation_steps scaler.scale(loss).backward() if (step + 1) % accumulation_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()6.2 梯度检查点、ZeRO 与累积的组合策略
把几种手段列个表,看看各自省的是哪块显存,方便组合:
| 手段 | 主要省的显存 | 代价 | 与梯度累积叠加 |
|---|---|---|---|
| 梯度累积 | 激活值 | 训练时间增加 | - |
| 混合精度 | 参数、激活、梯度 | 数值稳定性风险 | 推荐叠加 |
| 梯度检查点 | 激活值 | 训练时间明显增加 | 可叠加,时间叠加 |
| ZeRO stage 2 | 优化器状态 | 通信开销 | 推荐叠加 |
| ZeRO stage 3 | 参数、梯度、优化器状态 | 通信开销更大 | 推荐叠加 |
实际项目里,我常用的组合是:混合精度 + 梯度累积 + ZeRO stage 2。这套组合在单卡或多卡上都能把显存压得很低,同时训练速度还能接受。如果显存还是不够,再加梯度检查点,但要接受训练时间翻倍。
6.3 什么时候该放弃梯度累积换别的方案
梯度累积不是万能的。如果你的显存瓶颈在优化器状态(比如大模型全量微调),梯度累积基本没用,这时候该上 ZeRO 或 8-bit 优化器。如果瓶颈在参数本身(模型太大装不下),那得用模型并行或 ZeRO stage 3。如果瓶颈在序列长度导致的激活值爆炸,梯度累积有用,但配合 FlashAttention 这类高效注意力实现效果更好。
判断方法很简单:用torch.cuda.max_memory_allocated()分别测一下只有参数、参数加梯度、参数加梯度加优化器状态时的显存占用,看哪块是大头,对症下药。别一遇到 OOM 就无脑上梯度累积,找错瓶颈等于白忙活。
7. 一些实战中的参数经验值
最后分享一些我在实际项目里攒下来的参数经验,不是金科玉律,但能帮你少走弯路。
累积步数的选择。一般从 4 或 8 起步,观察显存和训练时间。如果显存降得不够,加到 16;如果训练时间已经难以接受,就停在当前值,转而用其他省显存手段。累积步数超过 32 的情况很少见,除非是超大模型或超长序列。
micro_batch 的下限。micro_batch 不要小于 2,太小会让 BatchNorm 统计量严重失真(如果用了 BN),也会让 GPU 利用率下降。如果显存只够 micro_batch 1,那说明该换更激进的省显存方案了,硬撑梯度累积意义不大。
等效 batch 的上限。前面说过,等效 batch 太大泛化性会下降。我的经验是,等效 batch 超过 1024 之后要格外小心,最好做一组对比实验,确认精度没有掉点再继续。如果掉了,要么减小等效 batch,要么配合学习率 warmup 和更大的权重衰减来缓解。
学习率 warmup。大等效 batch 训练时,warmup 很重要。一般 warmup 占总更新步数的 1% 到 5%,让模型在初期慢慢适应大梯度。梯度累积下 warmup 的步数要按更新步算,不是 micro-batch 步。
监控指标。训练时除了 loss,建议监控梯度范数和更新步数。梯度范数突然变大往往是累积逻辑出问题的信号;更新步数对不上说明步数计算有误。这两个指标加进日志,排查问题能快很多。
梯度累积这个技术本身不复杂,难的是把它用对、用到位,并且清楚它的边界。显存不够用的时候,先别急着换卡,把显存构成摸清楚,把梯度累积和其他省显存手段组合好,很多时候现有的卡就够用了。我在实际项目里靠这套组合,把原本需要更大显存才能跑的训练压到了单卡上,训练时间虽然长了些,但省下的成本和调参的灵活性是值得的。