文章

LLM 梯度累积生产实战:用 Token-Normalized Loss、DDP no_sync 与 Scheduler 对齐避免等效批次失真

梯度累积看似只是把多个微批次相加,但变长样本、分布式同步、混合精度和学习率调度会悄悄改变等效批次。本文深入剖析 Token 归一化、DDP no_sync、AMP 更新边界与尾部窗口处理等生产级方案,帮助你在 LLM 微调中实现数学一致的梯度累积。

背景:显存省下来了,等效批次却可能已经变了

梯度累积(Gradient Accumulation) 常被理解为一种简单的显存技巧:把一个大批次拆成多个微批次,连续执行多次前向和反向传播,只在累积窗口结束时调用一次 optimizer.step()

在定长、单卡、全精度的简单任务中,把每个微批次的 Loss 除以累积步数,通常能得到接近大批次训练的结果。但 LLM 微调经常同时存在以下条件:

  • 每个样本的有效 Token 数量不同;
  • Prompt、Padding 和被 Mask 的标签不参与 Loss;
  • 多个 DDP Rank 上的有效 Token 数量不一致;
  • 使用 BF16、FP16 或 GradScaler;
  • 学习率、Warmup、日志和保存周期按 Step 推进;
  • Epoch 末尾可能只有不完整的累积窗口。

这些因素叠加后,“微批次数相同”不等于”优化器看到的数据权重相同”。最隐蔽的问题不是程序报错,而是训练可以正常运行,Loss 也持续下降,但与预期的大批次基线不再数学等价。

核心原理:LLM 的有效批次应按可训练 Token 计算

样本数只是外壳,Loss 的真实分母是有效标签数

常见的等效批次公式是:

samples_per_update = micro_batch_size × accumulation_steps × data_parallel_world_size

它适合描述吞吐和 DataLoader 行为,但对自回归语言模型而言还不够。Causal LM 的交叉熵通常只计算 labels != -100 的位置,因此一次更新的真实统计质量更接近:

valid_tokens_per_update = 所有 Rank、所有微批次中 labels != -100 的 Token 总数

假设两个微批次分别有 100 个和 900 个有效 Token。若先对每个微批次取 Mean Loss,再把两个 Mean 相加后除以 2,那么两个微批次各占 50% 权重;正确的全批次 Loss 应让它们分别占 10% 和 90%。

因此,Token 级任务应优先采用:

loss = 所有有效 Token 的 loss_sum / 所有有效 Token 的总数

而不是:

loss = 每个微批次 mean_loss 的平均值

Hugging Face 在修复 Gradient Accumulation 问题时明确指出,Causal LM 等 Token 级任务需要把整个累积窗口的 Loss 总和除以全部非 Padding Token 数,而不能平均各微批次已经归一化的 Loss。

分母必须跨 Rank 汇总

DDP 默认会对各 Rank 的梯度做平均。若每个 Rank 的有效 Token 数差异较大,只按本地 Token 数归一化,会让不同 Rank 的梯度权重失真。

一种清晰的实现方式是:

  1. 每个 Rank 统计本累积窗口的本地有效 Token 数;
  2. 使用 all_reduce(SUM) 得到全局有效 Token 数;
  3. 每个微批次用 reduction="sum" 计算本地 Loss Sum;
  4. 考虑 DDP 最终会除以 World Size,对本地 Loss 乘以 world_size / global_valid_tokens
  5. 累积窗口最后一次反向传播触发梯度同步。

这样最终梯度对应的是全局所有有效 Token 的平均 Loss。

工程落地:把一次优化器更新作为最小事务

下面代码展示了 BF16 + DDP 场景的核心结构,强调边界和归一化逻辑:

from contextlib import nullcontext
import torch
import torch.distributed as dist
import torch.nn.functional as F

IGNORE_INDEX = -100

def causal_lm_loss_sum(logits: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
    shift_logits = logits[..., :-1, :].contiguous().float()
    shift_labels = labels[..., 1:].contiguous()
    return F.cross_entropy(
        shift_logits.view(-1, shift_logits.size(-1)),
        shift_labels.view(-1),
        ignore_index=IGNORE_INDEX,
        reduction="sum",
    )

def train_update(ddp_model, microbatches, optimizer, scheduler, max_grad_norm: float):
    """microbatches 共同形成一次 optimizer update。"""
    optimizer.zero_grad(set_to_none=True)
    device = next(ddp_model.parameters()).device
    local_tokens = torch.zeros((), device=device, dtype=torch.long)

    for batch in microbatches:
        local_tokens += batch["labels"].ne(IGNORE_INDEX).sum()

    global_tokens = local_tokens.clone()
    if dist.is_initialized():
        dist.all_reduce(global_tokens, op=dist.ReduceOp.SUM)
    world_size = dist.get_world_size() if dist.is_initialized() else 1

    if global_tokens.item() == 0:
        raise ValueError("Accumulation window contains no trainable tokens")

    denominator = global_tokens.to(torch.float32)

    for index, batch in enumerate(microbatches):
        is_last_microbatch = index == len(microbatches) - 1
        sync_context = (
            nullcontext() if is_last_microbatch else ddp_model.no_sync()
        )
        # DDP 要求 forward 也位于 no_sync 上下文内
        with sync_context:
            with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
                outputs = ddp_model(
                    input_ids=batch["input_ids"],
                    attention_mask=batch.get("attention_mask"),
                )
            loss_sum = causal_lm_loss_sum(outputs.logits, batch["labels"])
            # DDP 会对 Rank 梯度取平均,因此乘 world_size 抵消该平均
            loss = loss_sum * world_size / denominator
            loss.backward()

    grad_norm = torch.nn.utils.clip_grad_norm_(
        ddp_model.parameters(), max_norm=max_grad_norm,
    )
    optimizer.step()
    scheduler.step()

    return {
        "global_valid_tokens": int(global_tokens.item()),
        "microbatches": len(microbatches),
        "grad_norm": float(grad_norm),
    }

这段代码有三个关键约束:

  • Loss 使用 reduction="sum",统一在累积窗口级别归一化;
  • 中间微批次使用 no_sync(),最后一个微批次才触发 DDP 同步;
  • optimizer.step()scheduler.step() 都只在完整更新边界执行一次。

注意:如果使用 Hugging Face Trainer、Accelerate、DeepSpeed 或 FSDP,应优先遵循框架自己的 Loss、同步和 Step 契约,不要把上述 World Size 补偿公式机械叠加到框架已经完成的归一化逻辑上。

no_sync 的正确边界

PyTorch DDP 的 no_sync() 会暂停跨进程梯度同步,并把梯度保存在本地,直到第一次退出该上下文的 Forward/Backward 才同步。

容易写错的地方是只把 backward() 放入 no_sync(),但把 Forward 留在外面。PyTorch 官方文档明确要求 Forward 也必须位于上下文内,否则梯度仍可能发生同步。

推荐规则:

微批次位置ForwardBackward梯度同步
前 N-1 个no_sync()no_sync()不触发
第 N 个正常上下文正常上下文触发累计同步

每个 Rank 必须以一致顺序进入和退出同步边界。

AMP、梯度裁剪和跳步必须处于同一更新边界

FP16 + GradScaler 下,累积窗口内的梯度应保持相同 Scale。PyTorch 的 AMP 文档要求:

  • 中间微批次只执行 scaler.scale(loss).backward()
  • 累积完成后再调用一次 scaler.unscale_(optimizer)
  • 在 Unscale 后进行梯度裁剪;
  • 最后调用 scaler.step(optimizer)scaler.update()
  • 一个有效批次内不能改变 Scale,也不能把已缩放梯度与未缩放梯度混合累积。

如果检测到 Inf/NaN,GradScaler 可能跳过本次 Optimizer Step。此时 Scheduler 也不应独立前进,否则学习率时间轴会领先于参数更新次数。生产代码应以”优化器是否真实更新”为 Scheduler、EMA、全局 Update ID 和 Checkpoint 命名的共同信号。

尾部窗口:不要继续除以固定累积步数

Epoch 最后经常只剩 1 到 accumulation_steps - 1 个微批次。若仍固定除以完整累积步数,尾部梯度会被额外缩小。

更稳妥的策略有两种:

  1. 动态窗口:按尾部实际有效 Token 总数归一化,并正常执行一次更新;
  2. 丢弃尾部:使用 drop_last 或数据层补齐,使所有更新窗口固定,但要明确记录被丢弃或重复的数据。

对流式数据集还要额外注意:框架是否在 DataLoader 结束时自动触发同步和 Step。Accelerate 的 Gradient Accumulation Plugin 默认会与当前 DataLoader 结束状态同步;自定义无限流或跨 Epoch 累积时,需要显式配置该行为。

Scheduler 与训练预算:统一使用 optimizer_update_id

训练系统中至少存在三种 Step:

Step 类型触发频率适用场景
Micro Step每个微批次一次仅用于调试、细粒度日志
Optimizer Update每个累积窗口一次Warmup、LR Scheduler、EMA、Checkpoint
Token Step累计到一定有效 Token 数吞吐统计、跨实验对齐

Warmup、LR Scheduler、权重衰减、EMA 和 Checkpoint 周期通常应基于 Optimizer Update;吞吐、数据预算和跨实验对齐则更适合同时记录累计有效 Token。

建议为训练日志固定以下字段:

micro_step_id
optimizer_update_id
epoch
global_valid_tokens_in_update
cumulative_train_tokens
learning_rate
optimizer_step_skipped
grad_norm_before_clip
grad_norm_after_clip

不要让一个含义模糊的 global_step 同时承担三套语义。

适用场景

本文方案尤其适合:

  • 变长对话数据的 Causal LM 或 SFT;
  • Prompt 部分使用 -100 Mask、只训练 Assistant 输出的任务;
  • Sequence Packing 后每个 Pack 的有效训练 Token 数不同;
  • 多卡 DDP/FSDP,且各 Rank 的长度分布可能不完全一致;
  • 需要比较不同 Micro Batch Size 或 Accumulation Steps 的可复现实验。

对于分类、排序或偏好学习等样本级目标,分母未必应是 Token 数。核心原则不是”所有任务都按 Token 除”,而是根据目标函数定义选择正确统计单位,并确保累积前后保持一致。

常见误区

误区一:Mean Loss 再除以累积步数一定正确

只有各微批次统计单位和有效数量相同时才成立。变长 Token 任务应对 Loss Sum 和有效 Token 总数做统一归一化。

误区二:每个微批次同步一次只是性能差一点

它通常不会改变数学结果,但会产生不必要的 All-Reduce,显著削弱梯度累积原本想减少通信频率的价值。

误区三:Scheduler 可以按 DataLoader Batch 推进

这会让学习率随 Micro Step 而不是参数更新推进。改变累积步数后,训练曲线会直接失去可比性。

误区四:每个微批次都做梯度裁剪

这会改变向量相加后的方向和幅度。梯度裁剪应作用于完整累积窗口的最终梯度。

误区五:尾部窗口继续使用固定除数

不完整窗口必须使用实际统计量,或明确丢弃;不能假装它仍包含完整数量的微批次。

上线检查清单

  • 用同一随机种子比较”大批次单步”与”多个微批次累积一步”的参数更新差异;
  • 构造长度悬殊的样本,确认短样本不会被等权放大;
  • 检查全局有效 Token 数是否跨 Rank 汇总;
  • Profile 每个 Optimizer Update 的 All-Reduce 次数,确认中间微批次未同步;
  • 验证 no_sync() 同时覆盖 Forward 和 Backward;
  • 确认尾部窗口、空标签窗口和 DataLoader 结束行为;
  • FP16 下确认 Unscale → Clip → Step → Update 的顺序;
  • 发生 Overflow 跳步时,确认 Scheduler、EMA 和 Update ID 不前进;
  • 日志同时记录 Micro Step、Optimizer Update 和累计有效 Token;
  • 改变 gradient_accumulation_steps 后重新核对 Warmup 与总更新次数。

参考资料

常见问题

梯度累积步数相同,为什么训练结果仍可能不同?
变长序列下若先对每个微批次取平均再累积,短批次和长批次会被赋予相同权重;此外 DDP 同步、尾部窗口、AMP 跳步和 Scheduler 步数也可能改变实际更新。
DDP 梯度累积时是否每个微批次都要 All-Reduce?
通常不需要。中间微批次可使用 no_sync 暂停梯度同步,最后一个微批次退出 no_sync 后完成同步,但前向和反向都必须位于相同上下文内。
混合精度训练中什么时候做梯度裁剪?
应在完整累积窗口结束后先把梯度 unscale,再执行裁剪,然后调用 optimizer step 和 GradScaler update;不要在中间微批次提前 unscale 或更新缩放因子。
梯度累积步数越大越好吗?
不是。更大的累积步数可以降低单次显存压力并减少同步频率,但会增加一次参数更新的等待时间,也可能改变优化动态。应按显存、通信、目标有效批次和收敛表现共同选择。