BigMac 训练多模态模型,先分清流水切分与显存占用

文章导读
多模态模型在多卡上跑不起来,显存问题通常不是一个原因造成的。可以先把占用拆成三块:单卡静态占用(参数、梯度、优化器状态)、随微批线性增长的激活占用、以及流水切分引入的在途微批缓冲。照着示例配置上多卡仍然 OOM,常见情况有三种:把微批大小当成全局 batch 来算,流水段数增加后在途微批没有计入激活,重计算开关写进配置却没被真正加载。下面按“先看日志、再复算、最后调参”的顺序给一套可复现的排查路径
📋 目录
  1. A 在训练启动日志里定位显存峰值出现在哪个阶段
  2. B 按层数与隐藏维度估算单层参数与激活占用
  3. C 把流水段数与微批大小写成可改的配置项
  4. D 开启重计算后对比前后显存峰值
  5. E 用固定随机种子跑短步数确认切分没改变收敛路径
A A

多模态模型在多卡上跑不起来,显存问题通常不是一个原因造成的。可以先把占用拆成三块:单卡静态占用(参数、梯度、优化器状态)、随微批线性增长的激活占用、以及流水切分引入的在途微批缓冲。照着示例配置上多卡仍然 OOM,常见情况有三种:把微批大小当成全局 batch 来算,流水段数增加后在途微批没有计入激活,重计算开关写进配置却没被真正加载。下面按“先看日志、再复算、最后调参”的顺序给一套可复现的排查路径。

适用场景:多卡多模态训练按示例配置启动后显存仍不足。操作动作:先用训练日志确认峰值落在前向、反向还是优化器步,再用参数量与激活公式复算单卡下限,最后才动流水段数、微批和重计算。验证方式:每次只改一个变量,对比改动前后的显存峰值日志和短跑 loss。风险边界:流水切分会把参数按层摊到各卡,同时引入在途微批和气泡;微批与重计算直接改激活峰值;三者不能互相替代,也不能保证调完一定跑得起来。

在训练启动日志里定位显存峰值出现在哪个阶段

很多框架只打一条总峰值,这个数字对定位帮助有限。建议在训练循环里分阶段打点,每个阶段前重置峰值统计,日志里的数值才对应单个阶段:

import torch

for phase in ('forward', 'backward', 'optimizer'):
    torch.cuda.reset_peak_memory_stats()
    run_phase(phase)  # 换成自己的前向/反向/更新调用
    peak = torch.cuda.max_memory_allocated() / 2**30
    print(f'phase={phase} step={step} peak_alloc={peak:.2f}GiB')

日志字段固定成 phase、step、peak_alloc 三个之后,用下面两条命令筛关键行和找最大值,字段顺序不同时把 sort 的 -k 改到对应位置:

grep -nE 'phase=(forward|backward|optimizer).*peak_alloc' train.log | head -n 20
grep -oE 'phase=[a-z]+ step=[0-9]+ peak_alloc=[0-9.]+' train.log | sort -t= -k3 -n | tail -n 5

判断方向:峰值落在 forward,多半在激活或序列长度,动微批和重计算最直接;落在 backward,是激活与梯度同时驻留,重计算收益最大;落在 optimizer,是优化器状态和主权重这类静态占用,调微批基本没用,要靠切分层数或换优化器状态精度。这一步不做,后面所有调整都是猜。

按层数与隐藏维度估算单层参数与激活占用

给一份能自己代数的清单,公式用最常见的 Transformer block 近似,多模态里视觉编码器单独算一层再相加:

  • 单个 block 参数量 ≈ 12h²(注意力 QKVO 约 4h²,MLP 约 8h²),加归一化层是小项
  • 总参数 ≈ L × 12h² + V × h(词表嵌入,视觉侧按实际投影层另算)
  • 激活占用 ≈ B_micro × S × h × L × k × 2 字节,k 是每层每 token 保留的激活份数经验系数,不重计算时通常取 10~20,全重计算后只留层边界激活,k 可降到 2 左右

代入一组示例数值(示例,非实测):h=1024、L=24、V=50000、S=1024、微批 2、bf16 权重加 fp32 优化器状态。

项目估算示例值
总参数L×12h²+V×h约 3.5e8(含嵌入约 5.1e7)
bf16 权重参数量 × 2 字节约 0.65 GiB
bf16 梯度参数量 × 2 字节约 0.65 GiB
fp32 主权重参数量 × 4 字节约 1.3 GiB
Adam 一阶二阶状态参数量 × 8 字节约 2.6 GiB
激活(不重计算)B×S×h×L×16×2约 1.5 GiB
激活(全重计算)B×S×h×L×2×2约 0.19 GiB

k 不要照抄系数,用日志反推更可靠:把实测峰值减去静态部分,再除以 (B×S×h×L×2),得到自己模型在当前配置下的 k,之后换层数、换微批都能直接乘。流水段数大于 1 时,在途微批数通常接近段数,激活估算要再乘这个系数,漏掉这一项是“加了卡反而更容易 OOM”的常见来源。

把流水段数与微批大小写成可改的配置项

把三个会互相影响的量提到配置顶层,改完能直接复现同一次实验:

BigMac 训练多模态模型,先分清流水切分与显存占用
parallel:
  pipeline_parallel_size: 2      # 流水段数,通常让层数能被整除,或框架支持不均匀切分
  tensor_parallel_size: 1
train:
  micro_batch_size: 2            # 单次前向的样本数,直接乘进激活公式
  gradient_accumulation_steps: 8 # 累积到全局 batch
  global_batch_size: 16          # 校验项:等于 micro × accum × 数据并行度
  seq_length: 1024
recompute:
  enabled: false
  granularity: none              # full / selective / none

三条容易踩的:改流水段数不影响 global_batch_size 等式,等式对不上说明配置被别处覆盖了;段数增加时单卡层数减少、静态占用下降,但在途缓冲上升,最终峰值不一定下降,必须靠日志对比;微批是激活公式里唯一线性的因子,先降微批再加累积步,比直接砍全局 batch 更不容易改变训练语义。

开启重计算后对比前后显存峰值

开关写成显式配置项,避免只在代码里改:

recompute:
  enabled: true
  granularity: full              # full 全层重算,selective 只重算指定层
  num_layers: 24                 # selective 时生效

需要对比的日志字段是 max_memory_allocated(真实占用)、max_memory_reserved(缓存池保留量)、每步耗时,以及能拿到的 tokens/s。判断方向:allocated 明显下降而 reserved 没怎么变,是缓存池没归还,属正常现象,不要据此认为开关没生效;allocated 和 reserved 都不变,先去确认配置有没有被命令行参数覆盖,再确认 granularity 是否只作用于少数层;峰值下降同时 step 时间上升,是重计算的预期代价,用吞吐指标判断值不值。重计算主要削减的是反向阶段的激活驻留,所以回看第一步的阶段标记,峰值原本就落在 optimizer 的话,开重计算不会有明显变化。

用固定随机种子跑短步数确认切分没改变收敛路径

切分本身应该只是调度方式的变化,不该改变每一步的数学结果。用同一种子、同一全局 batch 跑两段短任务:

bash train.sh `--seed` 1234 `--max-steps` 30 `--log-interval` 1 `--pipeline-parallel-size` 2 `--dump-loss` loss_pp2.jsonl
bash train.sh `--seed` 1234 `--max-steps` 30 `--log-interval` 1 `--pipeline-parallel-size` 1 `--dump-loss` loss_pp1.jsonl

loss 打印位置放在每步优化器更新之后,别把前向 loss 和更新后 loss 混在一起比。两次跑完读 jsonl 对齐步号:

python - <<'PY'
import json

def load(path):
    out = {}
    for line in open(path):
        r = json.loads(line)
        out[r['step']] = r['loss']
    return out

a, b = load('loss_pp1.jsonl'), load('loss_pp2.jsonl')
steps = sorted(set(a) & set(b))[:30]
print('max_abs_diff', max(abs(a[s] - b[s]) for s in steps))
PY

判断方法:前若干步 loss 差在千分位量级,通常说明切分没有改变收敛路径;差异随步数放大或直接发散,按三处排查——global batch 是否因为改段数而变了、梯度累积是否跨微批正确求和、归一化统计是否只在微小批内计算。容差要按自己任务的 loss 量级定,loss 数值小的任务容差要收得更紧。