先说结论,省得你踩坑
gradient_accumulation_steps 这个参数,如果你在 DeepSpeed 的 json 配置文件和命令行都设了,别高兴太早——DeepSpeed Zero 的某些版本会以配置文件为准,不管你命令行传了什么。
我这次的问题就是这样:命令行里传了 --gradient_accumulation_steps 8,但 ds_config.json 里这个字段是 1(或者干脆没写,用了默认值),结果实际跑的还是 1。
有效 batch size 不是 32,是 1。
loss 降得飞快不是学习率太高,是 batch 太小模型在过拟合每个 batch。
这个 bug 我排查了大概 40 个小时,损失了差不多 3 天的训练时间。写出来让各位少走弯路。
业务场景
事情是这样的——上周三,我接手了一个 LoRA 微调任务。
用的是 4 卡 A100 80G 的机器,模型是 LLaMA-3-8B。显存不够跑大 batch,这大家都懂,单卡最多塞个 batch_size=1 再带个 LoRA 模块。我当时想着,既然显存这么紧张,梯度累积总得开起来吧。
数据说明:训练数据是公司内部的中文对话数据集,大概 50 万条对话,格式是 instruction-output 对。我用这个数据集跑了 2 个 epoch,期间监控了训练 loss、验证 loss、梯度范数和显存占用。
于是配了:
torchrun --nproc_per_node=4 \
train.py \
--batch_size 1 \
--gradient_accumulation_steps 8 \
--learning_rate 2e-4
理论有效 batch = 1 × 8 × 4卡 = 32。
过了大概 2 个 epoch,我一看 wandb 的 loss 曲线——
好家伙,这 loss 下降的速度比我当年做 SGD 的时候还猛,一度以为是自己天赋异禀调出了绝妙超参。
然后我看了下验证集。
验证 loss 几乎不动,train loss 狂掉,这味儿我太熟了——过拟合。
排查过程:我是怎么一步步走进坑里的
第一阶段:以为是学习率的问题
Loss 下降太快,第一反应就是学习率太高了。
我把学习率从 2e-4 降到 5e-5,又跑了半天,没啥变化。
回头看这步操作,纯属瞎调——当时我根本没想到是 batch size 的问题。我当时还怀疑是学习率的问题,调了两天完全没效果,现在想想应该第一时间看显存占用才对。这是我当时最蠢的决定。
第二阶段:看梯度范数
后来在 discord 上问人,有人让我打印一下梯度范数看看。
我在训练脚本里加了个日志:
# 放在 optimizer.step() 之前
total_norm = 0.0
for p in model.parameters():
if p.grad is not None:
param_norm = p.grad.data.norm(2)
total_norm += param_norm.item() ** 2
total_norm = total_norm ** 0.5
logger.info(f"Step {global_step}, grad_norm: {total_norm:.4f}")
跑出来一看,grad_norm 大概在 0.01~0.05 之间浮动。
说实话这个值看起来不算太离谱,我当时也就没当回事。
但问题是,这是在单步更新之前打印的,每次更新都只积累了 1 个 batch 的梯度,而不是 8 个。
第三阶段:终于想到看 DeepSpeed 的配置
大概过了 30 个小时,我开始怀疑是不是 DeepSpeed 的配置有问题。
我用的 ds_config.json 长这样:
{
"train_batch_size": 32,
"gradient_accumulation_steps": 1,
"fp16": {
"enabled": true
},
"zero_optimization": {
"stage": 3,
"offload_optimizer": {
"device": "cpu"
}
}
}
等等——
我看到问题了。
gradient_accumulation_steps 在配置文件里是 1,但我在命令行传的是 8。
DeepSpeed 加载配置的优先级是:json 文件 > 命令行参数。
至少在我用的 0.14.2 版本是这样。
验证:加个计数器确认
为了确认这个问题,我在训练循环里加了个计数器:
class AccumulationCounter:
def __init__(self):
self.count = 0
def step(self):
self.count += 1
if self.count % 8 == 0:
logger.info(f"Effective batch boundary reached at step {self.count}")
# 打印这 8 步的平均 loss
counter = AccumulationCounter()
然后我发现,这个 “effective batch boundary” 打印出来的 loss 变化,远没有我预期的那么大。
这说明 8 步才更新一次参数这个事儿,根本没发生。
第四阶段:修复和对比
修改后的 ds_config.json:
{
"train_batch_size": 32,
"gradient_accumulation_steps": 8,
"gradient_clipping": 1.0,
"fp16": {
"enabled": true
},
"zero_optimization": {
"stage": 3,
"offload_optimizer": {
"device": "cpu"
}
}
}
同时命令行改成:
torchrun --nproc_per_node=4 \
train.py \
--batch_size 1 \
--gradient_accumulation_steps 8 \
--learning_rate 2e-4 \
--deepspeed ds_config.json
跑起来之后,grad_norm 从 0.01~0.05 变成了 0.08~0.3,这个范围更合理一些。
Loss 曲线也正常了:train loss 和 val loss 同时下降,大致同步。
显存占用对比
这是最有说服力的证据。
修复前(gradient_accumulation_steps=1,实际生效):
$ nvidia-smi
|===============================+======================+======================|
| GPU 0 | 62°C | 28GiB / 80GiB | 8% |
| GPU 1 | 61°C | 27GiB / 80GiB | 7% |
| GPU 2 | 63°C | 28GiB / 80GiB | 8% |
| GPU 3 | 62°C | 27GiB / 80GiB | 7% |
+-------------------------------+----------------------+----------------------+
修复后(gradient_accumulation_steps=8,真正生效):
$ nvidia-smi
|===============================+======================+======================|
| GPU 0 | 71°C | 45GiB / 80GiB | 52% |
| GPU 1 | 70°C | 44GiB / 80GiB | 51% |
| GPU 2 | 72°C | 45GiB / 80GiB | 52% |
| GPU 3 | 71°C | 44GiB / 80GiB | 51% |
+-------------------------------+----------------------+----------------------+
显存占用从 28GB 跳到 45GB,多了 17GB。这差得可不是一星半点——如果 gradient_accumulation_steps=8 真的生效,显存会在这 8 步里逐步累积梯度,峰值显存应该比单步高不少。
后来我想明白了:当时配置失效时,显存只有单步的梯度+优化器状态+模型+激活值;而真正累积 8 步的话,梯度缓冲区要乘以 8,这 17GB 的差距就是这么来的。
所以下次配置不生效,nvidia-smi 一眼就能看出来。
DeepSpeed 调用方式:正确的打开姿势
这部分我要重点说,因为排查的时候我才发现,好多人其实不知道 DeepSpeed 正确的调用方式长什么样。我把正确的代码模板贴出来,各位对照着检查自己的脚本。
初始化阶段怎么写
首先你得用 deepspeed.initialize 来包装模型和优化器,这是 DeepSpeed 的核心入口。错误写法是直接用 PyTorch 原生的 model = model.cuda() 和 optimizer = torch.optim.AdamW(model.parameters())。
import deepspeed
from transformers import AutoModelForCausalLM, AutoTokenizer
# 1. 先加载模型和分词器
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B",
device_map="auto",
torch_dtype=torch.float16,
)
# 2. 用 DeepSpeed 包装模型,同时创建优化器
# 注意:这里传进去的是普通 optimizer,不需要先.cuda()
model, optimizer, _, _ = deepspeed.initialize(
model=model,
config="ds_config.json", # 配置文件路径必须传
optimizer=None, # 可以传 None 让 DeepSpeed 自动创建 AdamW
lr=2e-4,
)
# 3. 从配置文件读取真实生效的配置值(重点!)
ds_config = json.load(open("ds_config.json"))
effective_grad_accum = ds_config.get("gradient_accumulation_steps", 1)
print(f"[DeepSpeed Init] gradient_accumulation_steps = {effective_grad_accum}")
关键点:deepspeed.initialize 会读取 ds_config.json,然后用文件里的值覆盖你传入的任何参数。所以你在这里传的 lr、optimizer 之类的,其实只是占位符,真正生效的是 json 文件里的配置。
训练循环怎么写
训练循环里最常见的坑是手动做了梯度累积,但 DeepSpeed 本身也在做累积,两边不同步。我见过有人这么写:
# ❌ 错误写法:自己做了梯度累积,但没告诉 DeepSpeed
for step, batch in enumerate(dataloader):
outputs = model(**batch)
loss = outputs.loss / gradient_accumulation_steps
loss.backward()
if (step + 1) % gradient_accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
这种写法在普通 PyTorch 训练里没问题,但配合 DeepSpeed 就会出事——DeepSpeed 会按照 json 文件里的 gradient_accumulation_steps 来控制何时执行 optimizer.step(),你手动写的 if 判断根本不会被调用到。
正确写法是让 DeepSpeed 控制整个循环:
# ✅ 正确写法:让 DeepSpeed 接管训练循环
for step, batch in enumerate(dataloader):
# 1. Forward pass
outputs = model(
input_ids=batch["input_ids"].to(model.device),
attention_mask=batch["attention_mask"].to(model.device),
labels=batch["labels"].to(model.device),
)
# 2. Backward pass - 传入 scale_by_lr=True 让 DeepSpeed 自动处理梯度累积
model.backward(loss)
# 3. Optimizer step - DeepSpeed 内部会根据 gradient_accumulation_steps 决定何时执行
model.step()
# 不需要手动写 optimizer.zero_grad(),DeepSpeed 自动管理
# 不需要手动判断 step % gradient_accumulation_steps == 0
配置加载的优先级问题
我查了 DeepSpeed 的源码,deepspeed.initialize 内部的调用链是这样的:
# 伪代码,来自 deepspeed/runtime/engine.py
class DeepSpeedEngine:
def __init__(self, ..., config=None, ...):
# 1. 加载 json 配置文件
self.config = self._load_config(config)
# 2. 用 json 配置覆盖传入的参数
# 这一步会忽略你传入的 optimizer、lr 等参数
if "optimizer" in self.config:
self.optimizer = self._configure_optimizer(self.config["optimizer"])
elif optimizer is not None:
self.optimizer = optimizer # 只有 json 里没有时才用传入的
if "learning_rate" in self.config:
self.global_grad_norm = self.config.get("gradient_accumulation_steps", 1)
# 3. 设置 gradient_accumulation_steps
self.gradient_accumulation_steps = self.config.get("gradient_accumulation_steps", 1)
所以问题就出在第 2 步:json 文件里的值会覆盖你传入的任何参数。这和 argparse 的习惯完全相反,所以特别容易踩坑。
我为什么后来改用配置文件为主
一开始我是抗拒的,觉得命令行更直观。后来想明白了,有两个原因让我接受这个设计:
第一,配置集中方便排查。出了问题你只需要看一个文件,不用在代码和命令行之间来回对。分布式训练的坑本来就多,配置越分散越容易出错。
第二,方便版本管理和复现。json 配置文件可以直接 git commit,命令行参数写完就忘,下次复现还得翻历史记录。
我喜欢用 JSON 管理配置,还有一个私心是方便做实验对比——同一个目录放 3 个配置文件,ds_baseline.json、ds_lr5e5.json、ds_grad accum16.json,改一行代码就能切换实验。
技术细节:配置优先级拆解
我查了 DeepSpeed 的官方文档,文档里写得很清楚:
gradient_accumulation_stepsmust be set in the DeepSpeed JSON config file. The value set via command line arguments will be ignored.
说实话,这个设计挺让人困惑的。参数说明方面,DeepSpeed 的思路是把所有训练相关的超参都集中在 JSON 里管理,这样排查问题的时候看一个文件就够了,配置的一致性也更好。但问题是,它和 argparse 的习惯完全相反——大多数人本能地会往命令行里传参数。
为什么我最后选择把参数写在 JSON 里而不是命令行里?
有两个原因:
第一,DeepSpeed 官方推荐的就是这种方式,文档明确说了要在 JSON 里写。既然框架本身就这么设计的,那我顺着框架来,出问题的概率最小。
第二,JSON 文件可以 git 管理、可以做实验对比、可以加注释说清楚为什么这么配。命令行参数写完就跑,下次想复现实验还得翻 bash history。
至于这个设计的利弊,我觉得:
- 好处是配置集中、容易审计、适合多实验对比
- 坏处是和 PyTorch 原生的 argparse 风格割裂,新人容易踩坑(比如我)
我后来想,如果 DeepSpeed 能像 Hydra 那样做一个配置优先级合并,按命令行 > JSON 的顺序覆盖,可能就不会有这个 bug 了。但它没这么做,所以我只能适应它。
| 配置来源 | gradient_accumulation_steps | 是否生效 |
|---|---|---|
| ds_config.json | 1 | ✅ 生效 |
| 命令行 –gradient_accumulation_steps 8 | 8 | ❌ 被覆盖 |
所以最简单的解法就是:别在两个地方同时写,要么只写在 json 里,要么只写在命令行里。
我建议写在 json 里,因为这样更明确,也方便做实验对比。
复盘:骂自己的环节
其实这个问题,在 DeepSpeed 官方文档里有写。
我当时急着跑实验,根本没仔细看文档。
文档没看就上手配置,这毛病我犯了不是一次两次了。
另外,我应该早点注意到几个信号:
-
显存使用量比预期低:如果 gradient_accumulation_steps=8,实际只有 1,那显存会省很多。我当时没注意到这一点。
-
loss 曲线形态不对:训练 loss 和验证 loss 差距太大,基本就是过拟合的信号。我当时光顾着调学习率,没往 batch size 方向想。
-
没有单独验证有效 batch:应该在训练中途加个计数器,验证一下 8 步之后才更新参数这件事到底有没有发生。
常见坑:还有谁也踩过类似的
写这篇文章的时候,我又想起以前用 FSDP 的时候也踩过类似的坑——FSDP 的 backward_prefetch 参数也是 JSON 配置优先于命令行。这似乎是分布式训练框架的一个通病,不知道是设计问题还是文档问题。
还有一个坑是 Megatron-LM,它的 tensor parallelism 和 pipeline parallelism 的配置方式又不一样,经常有人把 global batch size 和 micro batch size 搞混。这些框架各有各的配置风格,但共同点就是:配置不对,努力白费。
上线后评估
改完配置重新跑了 2 个 epoch,这次的效果就好多了:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 训练 loss (step 1000) | 0.12 | 0.85 |
| 验证 loss (step 1000) | 2.31 | 0.92 |
| loss gap | 2.19 | 0.07 |
| grad_norm 范围 | 0.01~0.05 | 0.08~0.30 |
| 每步显存占用 | 28GB | 45GB |
修复前的验证 loss 几乎不动,只有训练 loss 在狂掉,这就是典型的过拟合。修复后两者基本同步,这才是正常的收敛。
另外我注意到,grad_norm 从 0.01 涨到了 0.08~0.30。这个变化也很好理解——当梯度累积 8 步时,单步的梯度会更大(因为要累积更多样本的信息),所以 grad_norm 整体上了一个台阶。如果你的 grad_norm 长期偏低,也可以怀疑一下是不是有效 batch size 太小了。
怎么避免类似问题
如果再让我来一次,我会做这几件事:
- 在训练脚本里加一个启动检查
def validate_batch_config(model, ds_config):
"""训练开始前打印关键配置,验证是否正确"""
world_size = torch.distributed.get_world_size()
# 从 DeepSpeed config 读取真实值
effective_batch = ds_config.get("train_batch_size", "auto")
grad_accum = ds_config.get("gradient_accumulation_steps", 1)
# 如果是 auto,从模型并行度算
if effective_batch == "auto":
effective_batch = batch_size * grad_accum * world_size
print(f"[Config Check] world_size = {world_size}")
print(f"[Config Check] grad_accum_steps = {grad_accum}")
print(f"[Config Check] effective_batch = {effective_batch}")
# 可以加个断言,防止配置错误
if effective_batch < 16:
print("⚠️ WARNING: effective_batch is too small, likely misconfiguration")
print("⚠️ This usually means gradient_accumulation_steps is not working")
- 在训练初期观察梯度范数的变化周期
梯度范数应该在一个 accumulation cycle 内累积。如果你发现每一步的梯度范数都差不多,没有周期性变化,那很可能 gradient_accumulation_steps 没有生效。
- 对一下 nvidia-smi 的显存占用曲线
有效 batch size 越大,显存占用越高。如果显存占用比预期低 30% 以上,基本可以怀疑配置没生效。
结尾
这次踩坑的根本原因说起来很简单:DeepSpeed 的配置优先级规则我没搞清楚,就直接上手跑了。
代价是浪费了 3 天训练时间,模型最后过拟合得一塌糊涂。
如果你也在用 DeepSpeed,我建议写个启动检查函数把这个坑堵死,否则迟早会再踩一次。 这个检查函数不复杂,但能让你在训练开始的第一分钟就发现配置问题,而不是跑了两天之后才从 loss 曲线上看出来。
配置不对,努力白费。