先算清单卡显存与微批大小——BigMac 再决定流水怎么切

文章导读
手上有多张卡时,先确定“每张卡能跑多大微批”,再决定“切几段、每段几层”;顺序反过来(先定卡数再勉强凑切分)最容易出现配置完成却跑不起来的情况。单卡微批上限由模型参数量、精度、序列长度和优化器状态共同决定,无法凭经验猜;可以用二分试跑在十几分钟内测出。得到这个上限后,段数、每段层数和微批大小都能通过计算得到,剩下只需确认各段负载是否均衡。下面按“测上限→算分配→写配置→短步验证”的顺序说明,命令与
📋 目录
  1. 一 固定模型与精度,用二分试跑找单卡能跑的最大微批
  2. 二 把层数按段数均分并记录每段参数量差
  3. 三 按反推结果写流水段数与微批配置
  4. 四 跑短步数比较切分前后的吞吐与显存
  5. 五 把不能均分的层交给首末段并验证负载差
A A

手上有多张卡时,先确定“每张卡能跑多大微批”,再决定“切几段、每段几层”;顺序反过来(先定卡数再勉强凑切分)最容易出现配置完成却跑不起来的情况。单卡微批上限由模型参数量、精度、序列长度和优化器状态共同决定,无法凭经验猜;可以用二分试跑在十几分钟内测出。得到这个上限后,段数、每段层数和微批大小都能通过计算得到,剩下只需确认各段负载是否均衡。下面按“测上限→算分配→写配置→短步验证”的顺序说明,命令与配置中的模型路径、卡号、层数请替换为本机实际值。

先固定模型与精度,用二分试跑测出单卡能跑通的最大微批,再按层数除以段数的方式均分,把余数交给首末段;微批按上限留出余量后写进配置。判断方案是否可行,要看短步数下每卡显存峰值、步时稳定性和各段负载差,而不是看单次能否启动。层数与段数除不尽、或显存峰值集中在某一段时,应调整切分或降低微批,不要靠临时换出显存硬撑。

固定模型与精度,用二分试跑找单卡能跑的最大微批

试跑时只使用一张卡,模型结构、精度(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 计数)
  • 层数、可训练参数量
  • 与上一段的参数量差、与前一段的比值(若需要)
  • 备注(是否含嵌入层/输出头/额外结构)

如果首末段的参数量明显高于中间段,属于正常情况,但要在后面的验证里重点看这两段的显存峰值和等待时间。

按反推结果写流水段数与微批配置

把前面两步的结果落成一份配置:段数来自层数能否被整除的判断,微批来自单卡试跑上限打折后的值。下面是一个通用骨架,字段名按你使用的框架替换,结构含义不变:

先算清单卡显存与微批大小——BigMac 再决定流水怎么切
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 层;若末段本身含输出头,则改为末段承担余数。

验证时看日志里的三个字段:每段实际层数、每段显存峰值、每段前向耗时。失衡的表现通常是:承担余数的段显存峰值比其他段高出一截;其他段已经进入等待,该段仍在计算;整体步时由这一段决定,缩短其他段并不能改善步时。出现这些表现时,先尝试把余数换给另一端的段,再不行就降低微批或增加段数。

还有一种情况容易被忽略:层数均分后参数量差很大,但显存峰值相差不大,这通常说明瓶颈在激活值而不是参数,这时调整方向是微批和序列长度,而不是继续挪层。把这两种现象分开记录,后面换模型或换精度时才能复用这套判断。