BigMac 的流水并行配置——该写在训练脚本哪一层?

文章导读
BigMac 这类训练脚本里,流水并行的参数通常要分三层落地:跟设备与进程数有关的写在启动脚本,跟切分方式和喂数据节奏有关的写在训练入口(参数解析或配置加载处),跟“从哪一层切开”有关的写在模型定义。改了一处不生效,多数情况是改在了调用方而不是消费方,或者后加载的一份默认值把前面改的值覆盖掉了。判断标准很简单:这个参数最终被谁读走,就写在哪一层,其余地方只传不写。
📋 目录
  1. Ⅰ 把现有配置项按启动、调度、模型结构三层归类
  2. Ⅱ 在启动脚本里固定卡数与进程数
  3. Ⅲ 在训练入口设置段数、微批与累积步数
  4. Ⅳ 在模型定义里标注可切分的层边界
  5. Ⅴ 改一个参数后从日志确认它真的生效
A A

BigMac 这类训练脚本里,流水并行的参数通常要分三层落地:跟设备与进程数有关的写在启动脚本,跟切分方式和喂数据节奏有关的写在训练入口(参数解析或配置加载处),跟“从哪一层切开”有关的写在模型定义。改了一处不生效,多数情况是改在了调用方而不是消费方,或者后加载的一份默认值把前面改的值覆盖掉了。判断标准很简单:这个参数最终被谁读走,就写在哪一层,其余地方只传不写。

按“谁消费这个参数”分层,比按文件习惯分层可靠:启动脚本只放进程与设备拓扑,训练入口放段数、微批与累积步数,模型定义放可切分边界。改完一处后打印一段生效配置做核对,日志里看不到新值,就说明改错了层或被默认值覆盖。不同框架的配置合并优先级不一样,覆盖顺序需要结合当前环境确认。

把现有配置项按启动、调度、模型结构三层归类

先别急着改。把当前启动脚本、训练入口、模型定义里出现的并行相关字段列出来,逐个标注“谁读它”,归错层是后面一切排查困难的根源。

r>
层写在哪典型参数谁消费
启动层(环境)启动脚本 / 提交作业的命令进程数、每节点卡数、可见设备、主节点地址与端口、通信后端分布式启动器
调度层(训练入口)train.py 的参数解析或 yaml 配置流水段数、张量并行度、微批大小、梯度累积步数、序列长度、调度形状并行组构建与数据加载
结构层(模型定义)模型类内部层命名与编号、切分点列表、embedding 与输出头归属、共享权重切分器与 checkpoint 加载

三层之间有硬约束,不能各写各的:总进程数一般要能被“数据并行 × 流水段数 × 张量并行度”整除;微批大小 × 梯度累积步数 × 数据并行度 才等于全局批大小;可切分的层数要不小于流水段数。这些约束里任何一条不满足,通常表现为启动阶段就断言失败,而不是训练几步之后才出错,看到报错可以优先往这里查。

在启动脚本里固定卡数与进程数

启动层只回答“起多少个进程、用哪些卡、怎么连”,不要在这里写微批大小。字段集中到一处,避免命令行和环境变量各写一份。

BigMac 的流水并行配置——该写在训练脚本哪一层?
#!/usr/bin/env bash
set -euo pipefail

export CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
export MASTER_ADDR=127.0.0.1
export MASTER_PORT=29500

NNODES=1
GPUS_PER_NODE=8

python -m torch.distributed.run \
  `--nnodes` "${NNODES}" \
  `--nproc`_per_node "${GPUS_PER_NODE}" \
  `--master`_addr "${MASTER_ADDR}" \
  `--master`_port "${MASTER_PORT}" \
  train.py `--config` configs/pp4.yaml

生效后的日志特征很直观:进程数与 GPUS_PER_NODE 一致,每个进程打出自己的 rank 与 world_size,随后能看到流水并行组的成员划分(例如每几个 rank 组成一个 stage)。如果日志里 world_size 和你设的进程数对不上,先查启动器参数,不要去动 yaml。

在训练入口设置段数、微批与累积步数

调度层回答“怎么切、一次喂多少”。建议放在训练入口统一加载的配置里,并在解析后立刻做一次合法性校验。

BigMac 的流水并行配置——该写在训练脚本哪一层?
# configs/pp4.yaml —— 调度层
parallel:
  pp_size: 4          # 流水段数,需整除总进程数,且不大于可切分层数
  tp_size: 1          # 张量并行度,与 pp_size 共同决定数据并行度
train:
  micro_batch_size: 1            # 单段单次前向的样本数
  gradient_accumulation_steps: 8 # 累积多少微批做一次更新
  seq_length: 2048

注意这里不要再写数据并行度,它应当由“总进程数 ÷ (pp_size × tp_size)”反推出来。两处同时写,改了启动脚本而忘了改 yaml,就会出现“配置看起来没问题、实际并行组对不上”的情况。校验可以写成一段断言:进程数不能整除就直接抛错,并打印参与计算的那几个数。

在模型定义里标注可切分的层边界

结构层回答“从哪切”。切分点和模型结构要能对上号,靠的是稳定的层命名与显式编号,而不是依赖模块注册顺序的隐式约定。

class BigMacBlock(nn.Module):
    def __init__(self, layer_id: int):
        super().__init__()
        self.layer_id = layer_id   # 全局唯一,切分、日志、checkpoint 都靠它
        ...

class BigMacModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.embed = ...
        self.layers = nn.ModuleList(
            [BigMacBlock(i) for i in range(cfg.model.num_layers)])
        self.final_norm = ...
        self.lm_head = ...

    def split_points(self, pp_size: int):
        # 返回“切在该层之前”的 layer_id 列表,长度应为 pp_size - 1
        n = len(self.layers)
        return [n * i // pp_size for i in range(1, pp_size)]

标注错误的常见表现:切分点数量与段数减一不符,启动时立刻断言失败;用字典而不是有序结构存层,导致切分后顺序错乱;embedding 与输出头没有显式归属,各段显存明显不均;被共享的权重落到两个段上,checkpoint 加载时缺 key 或报重复。出现这些现象,先看切分点列表,再看层容器是否有序。

BigMac 的流水并行配置——该写在训练脚本哪一层?

改一个参数后从日志确认它真的生效

建立闭环只需要一段打印。放在分布式初始化之后、模型切分完成之后,只让 rank 0 输出,避免刷屏。

def log_effective_config(cfg, model, rank: int = 0):
    if rank != 0:
        return
    ws = torch.distributed.get_world_size()
    dp = ws // (cfg.parallel.pp_size * cfg.parallel.tp_size)
    print(f"[cfg] world_size={ws} pp={cfg.parallel.pp_size} "
          f"tp={cfg.parallel.tp_size} dp={dp}")
    print(f"[cfg] micro_bs={cfg.train.micro_batch_size} "
          f"accum={cfg.train.gradient_accumulation_steps} "
          f"global_bs={cfg.train.micro_batch_size * cfg.train.gradient_accumulation_steps * dp}")
    print(f"[cfg] split_points={model.split_points(cfg.parallel.pp_size)}")

不生效时按这个顺序排查:先确认打印的是合并后的配置而不是文件里的默认值;再搜一遍代码里有没有第二处硬编码或环境变量覆盖;然后看日志里的 world_size 与并行组划分是否和预期一致;接着看切分点数量与每段层数;最后核对全局批大小。跑到这一步都对得上,再去关心 loss 曲线。反过来,一上来就盯 loss,往往会把配置问题和数值问题混在一起。