BigMac 这类训练脚本里,流水并行的参数通常要分三层落地:跟设备与进程数有关的写在启动脚本,跟切分方式和喂数据节奏有关的写在训练入口(参数解析或配置加载处),跟“从哪一层切开”有关的写在模型定义。改了一处不生效,多数情况是改在了调用方而不是消费方,或者后加载的一份默认值把前面改的值覆盖掉了。判断标准很简单:这个参数最终被谁读走,就写在哪一层,其余地方只传不写。
按“谁消费这个参数”分层,比按文件习惯分层可靠:启动脚本只放进程与设备拓扑,训练入口放段数、微批与累积步数,模型定义放可切分边界。改完一处后打印一段生效配置做核对,日志里看不到新值,就说明改错了层或被默认值覆盖。不同框架的配置合并优先级不一样,覆盖顺序需要结合当前环境确认。
把现有配置项按启动、调度、模型结构三层归类
先别急着改。把当前启动脚本、训练入口、模型定义里出现的并行相关字段列出来,逐个标注“谁读它”,归错层是后面一切排查困难的根源。
| 层 | 写在哪 | 典型参数 | 谁消费 |
|---|---|---|---|
| 启动层(环境) | 启动脚本 / 提交作业的命令 | 进程数、每节点卡数、可见设备、主节点地址与端口、通信后端 | 分布式启动器 |
| 调度层(训练入口) | train.py 的参数解析或 yaml 配置 | 流水段数、张量并行度、微批大小、梯度累积步数、序列长度、调度形状 | 并行组构建与数据加载 |
| 结构层(模型定义) | 模型类内部 | 层命名与编号、切分点列表、embedding 与输出头归属、共享权重 | 切分器与 checkpoint 加载 |
三层之间有硬约束,不能各写各的:总进程数一般要能被“数据并行 × 流水段数 × 张量并行度”整除;微批大小 × 梯度累积步数 × 数据并行度 才等于全局批大小;可切分的层数要不小于流水段数。这些约束里任何一条不满足,通常表现为启动阶段就断言失败,而不是训练几步之后才出错,看到报错可以优先往这里查。
在启动脚本里固定卡数与进程数
启动层只回答“起多少个进程、用哪些卡、怎么连”,不要在这里写微批大小。字段集中到一处,避免命令行和环境变量各写一份。
#!/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。
在训练入口设置段数、微批与累积步数
调度层回答“怎么切、一次喂多少”。建议放在训练入口统一加载的配置里,并在解析后立刻做一次合法性校验。
# 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 或报重复。出现这些现象,先看切分点列表,再看层容器是否有序。
改一个参数后从日志确认它真的生效
建立闭环只需要一段打印。放在分布式初始化之后、模型切分完成之后,只让 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,往往会把配置问题和数值问题混在一起。