手上有多张卡时,先确定“每张卡能跑多大微批”,再决定“切几段、每段几层”;顺序反过来(先定卡数再勉强凑切分)最容易出现配置完成却跑不起来的情况。单卡微批上限由模型参数量、精度、序列长度和优化器状态共同决定,无法凭经验猜;可以用二分试跑在十几分钟内测出。得到这个上限后,段数、每段层数和微批大小都能通过计算得到,剩下只需确认各段负载是否均衡。下面按“测上限→算分配→写配置→短步验证”的顺序说明,命令与配置中的模型路径、卡号、层数请替换为本机实际值。
先固定模型与精度,用二分试跑测出单卡能跑通的最大微批,再按层数除以段数的方式均分,把余数交给首末段;微批按上限留出余量后写进配置。判断方案是否可行,要看短步数下每卡显存峰值、步时稳定性和各段负载差,而不是看单次能否启动。层数与段数除不尽、或显存峰值集中在某一段时,应调整切分或降低微批,不要靠临时换出显存硬撑。
固定模型与精度,用二分试跑找单卡能跑的最大微批
试跑时只使用一张卡,模型结构、精度(fp16/bf16/fp32)、序列长度、是否开启梯度检查点都要与正式训练保持一致,否则测出的上限无法用于后续推算。二分区间可以先用 [1, 256] 这类明显覆盖的上下界,用下面的骨架脚本反复启动;正式环境里把 run_once 换成自己的训练入口即可。
import subprocess, os
def run_once(mbs):
env = dict(os.environ, MBS=str(mbs), CUDA_VISIBLE_DEVICES="0")
p = subprocess.run(["python", "train.py", "`--max-steps`", "3"],
env=env, capture_output=True, text=True, timeout=900)
out = p.stdout + p.stderr
oom = ("out of memory" in out.lower()) or ("CUDA error" in out and "memory" in out)
return (p.returncode == 0 and not oom), out
lo, hi = 1, 256 # lo=已确认可跑, hi=待试探上界
while lo + 1 < hi:
mid = (lo + hi) // 2
ok, out = run_once(mid)
print("try mbs=%d ok=%s" % (mid, ok))
if ok:
lo = mid
else:
hi = mid
print("max_micro_batch =", lo)
显存不足的典型输出特征是:torch.cuda.OutOfMemoryError、CUDA out of memory. Tried to allocate ... MiB,并附带 GPU 0 has a total capacity of ... of which ... MiB is free。要把它和另一类失败区分开:进程直接退出码非 0,但没有 OOM 关键字(多半是参数错误或数据路径问题);前向跑通但出现 NaN(属于数值问题,不是显存上限)。只有明确出现 OOM 关键字的那次才计入“不可行”。
停止条件有三条:二分收敛(lo + 1 == hi)、单次试跑超时、连续两次输出中 OOM 与非 OOM 失败混杂。收敛后把 lo 记为单卡最大微批。正式配置里不建议直接用这个上限,因为它没给流水并行所需的额外缓冲留空间;可以先按上限的八成左右起步,再结合短步验证结果微调,倍数需要结合框架实现确认。
把层数按段数均分并记录每段参数量差
均分规则很直接:模型总层数 L、流水段数 P,先算 base = L // P、rem = L % P,每段先分到 base 层,剩下的 rem 层按段号顺序补齐。层数不能整除时,余数不要随意分散,先按“首段优先”分配,方便后面统一处理;同一模型不要在两套脚本里用不同的补余规则,否则对比实验无法判断差异来自哪。
层数相同不等于负载相同,嵌入层、输出头、归一化层、路由层集中的段参数量会明显偏大。记录方式是按段统计可训练参数量,再与前一段求差:
# 伪代码:只统计,不改模型
for stage_id, layers in stage_layers.items():
n = sum(p.numel() for p in layers.parameters() if p.requires_grad)
print("stage=%d layers=%d params=%d" % (stage_id, len(layers), n))
差值记录表至少包含下面几列,填完后能直接看出哪一段偏重:
- 段号、层号区间(标明从 0 还是从 1 计数)
- 层数、可训练参数量
- 与上一段的参数量差、与前一段的比值(若需要)
- 备注(是否含嵌入层/输出头/额外结构)
如果首末段的参数量明显高于中间段,属于正常情况,但要在后面的验证里重点看这两段的显存峰值和等待时间。
按反推结果写流水段数与微批配置
把前面两步的结果落成一份配置:段数来自层数能否被整除的判断,微批来自单卡试跑上限打折后的值。下面是一个通用骨架,字段名按你使用的框架替换,结构含义不变:
parallel:
pipeline_size: 4 # 段数 P,等于参与流水的卡数
micro_batch_size: 4 # 单卡单次前向的样本数,来自试跑上限留余量
num_microbatches: 8 # 每轮拆成的微批个数,决定流水填充深度
layers_per_stage: [7, 7, 7, 6] # 各段层数,总和等于模型总层数
layers_per_stage_origin: 0 # 层号从 0 开始,避免与 1-based 混淆
precision:
dtype: bf16 # 与试跑时保持一致
容易填反的字段有三个。micro_batch_size 是单卡单次前向的数据量,不是全局 batch;num_microbatches 是流水拆分数,不等于梯度累积步数,两者在多数框架里是独立概念。layers_per_stage 的长度必须等于 pipeline_size,且列表元素之和必须等于模型总层数,长度对不上或求和不符时框架往往在初始化阶段就报错,或者更隐蔽地只跑了部分层。
写完后先在单步 dry-run 上确认每段实际加载的层号区间与配置一致,再做短步对比,避免层号偏移一位导致首段拿到错误权重。
跑短步数比较切分前后的吞吐与显存
用 30 到 50 步即可,不追求收敛,只要求可比:切分前后使用同一份数据、同一随机种子、同一序列长度,并且都在预热若干步之后再取数,避免把首次编译或数据加载开销算进去。记录字段建议固定为:
- 步号、单步耗时(毫秒)、最近若干步平均单步耗时
- 每卡显存峰值(
torch.cuda.max_memory_allocated()或框架日志中的 peak memory) - 每段前向、反向耗时(若日志有 per-stage timing)
- loss 值序列、是否出现 NaN 或梯度裁剪频繁触发
- 实际生效的
micro_batch_size与num_microbatches
对比表可以简单写成三列:指标、单卡基线、切分后。判定可行的依据不是“能跑够步数”这一条,而是三条同时成立:所有卡显存峰值都不接近容量上限;单步耗时在短步内波动不大,没有逐步上升趋势;loss 序列与基线在前若干步的趋势一致,没有明显偏离。任何一条不成立,就先回到微批或层数分配上调整,而不是加大步数再看。
把不能均分的层交给首末段并验证负载差
当层数除以段数有余数时,实际做法是把余数集中给首段或末段,而不是平均摊到所有段。优先给首段(输入嵌入、位置编码常在这一段)或末段(输出头、loss 计算在这一段),这样每段的层号区间仍是连续整数,便于核对。分配策略可写成:首段拿 base + rem 层,其余段各拿 base 层;若末段本身含输出头,则改为末段承担余数。
验证时看日志里的三个字段:每段实际层数、每段显存峰值、每段前向耗时。失衡的表现通常是:承担余数的段显存峰值比其他段高出一截;其他段已经进入等待,该段仍在计算;整体步时由这一段决定,缩短其他段并不能改善步时。出现这些表现时,先尝试把余数换给另一端的段,再不行就降低微批或增加段数。
还有一种情况容易被忽略:层数均分后参数量差很大,但显存峰值相差不大,这通常说明瓶颈在激活值而不是参数,这时调整方向是微批和序列长度,而不是继续挪层。把这两种现象分开记录,后面换模型或换精度时才能复用这套判断。