从OOM到多卡训练:DeepSpeed与ZeRO显存优化实战
做大模型训练的朋友大概率都遇到过这样一个窘境:模型权重明明塞得进显卡,一开训显存就爆,把 batch size 压到 1 也还是 OOM。DeepSpeed 就是在这个环节被反复提起的名字,它的核心价值不在于"多快",而在于把原本一张卡装不下的训练任务,拆解成多张卡能协同完成的形态。这篇内容面向刚接触分布式训练的工程师,也适合已经能用单卡跑小模型、想往更大参数量推进的同学。我会从显存到底被什么吃掉讲起,一路说到配置文件怎么写、脚本怎么改、报错怎么查,中间穿插我自己踩过的坑和几组实测数据。读完你应该能独立把一个普通的单机训练脚本,改造成能在多卡上用 DeepSpeed 跑起来的版本。
1. 显存去哪儿了:先搞清楚问题的来源
1.1 混合精度训练下的显存账本
很多人对显存的直觉是"参数量 × 2",7B 模型就是 14GB,80G 的卡随便放。这个直觉在推理阶段勉强成立,在训练阶段错得离谱。
训练时除了 fp16/bf16 的模型权重本身,还要维护梯度、fp32 的主权重副本,以及 Adam 系列优化器的两个状态量(一阶动量 m 和二阶动量 v)。用 Φ 表示参数量,按字节逐项算:
| 组成项 | 数据类型 | 占用 |
|---|---|---|
| 模型权重 | fp16/bf16 | 2Φ |
| 梯度 | fp16/bf16 | 2Φ |
| fp32 主权重 | fp32 | 4Φ |
| 一阶动量 m | fp32 | 4Φ |
| 二阶动量 v | fp32 | 4Φ |
| 合计 | - | 16Φ |
也就是说,每 10 亿参数大约要吃掉 16GB 的"静态"显存。7B 模型光这些状态量就要 112GB,一张 80G 的卡连权重都放不全,更别提还有激活值这个大头。激活值的占用和 batch size、序列长度、层数正相关,序列长度翻倍,激活值大致翻倍,这也是为什么长文本训练特别容易爆显存。
我第一次认真算这笔账的时候有点被吓到:原来"能不能训"这件事,在参数量达到十亿级别之后就和单卡显存没什么关系了,它变成了一个分布式系统问题。
1.2 普通数据并行为什么撑不住
数据并行的思路很朴素:每张卡放一份完整模型,喂不同的数据,反向后用 all-reduce 把梯度平均一下。这个方案在 BERT-base 那个年代很好用,因为每张卡的状态量总共也就几 GB。
但按上面的账本算,数据并行下每张卡的显存占用完全不随卡数下降。8 张卡训 7B,每张卡还是要 112GB 的模型状态——卡的数量只提升了算力,没解决显存。这就是所谓的"数据并行不省显存"。
要突破这个天花板,就必须让模型状态本身被切开。切法有三种大方向:按层切(流水线并行)、按张量维度切(张量并行)、按状态类型切(ZeRO 这一类分片方案)。DeepSpeed 的主线是第三条路,也就是 ZeRO,它把优化器状态、梯度、参数这三类东西依次做跨卡切分,形成三个递进的阶段。这条路的好处是对模型代码几乎零侵入,不需要你把模型拆成 pipeline stage,也不需要改模型结构的并行切分逻辑。
1.3 DeepSpeed 解决的问题边界
得先把预期摆正:DeepSpeed 不是万能的加速器。它主要解决三件事——显存容量不足、超大批量训练、以及训练流程中的工程化封装(检查点、混合精度、梯度累积的自动化管理)。
它不太擅长的事情也有:单卡小模型上它可能还不如裸跑,因为引入了额外的通信和调度开销;模型本身算子不支持半精度时,它也没法帮你变魔术;网络带宽很差的机器上强行开 Stage 3,性能可能掉得让你怀疑人生。
我在一个 4 卡的小集群上试过用 Stage 3 训一个 1.3B 的模型,结果吞吐比 Stage 2 还低了约 18%,因为参数量太小,切分带来的通信开销占比太高。这件事让我后面形成了一个习惯:先评估问题出在显存还是出在吞吐,再决定要不要动 DeepSpeed 的开关。
2. ZeRO 三个阶段的取舍逻辑
2.1 从"谁被切分"理解 Stage 1 到 3
ZeRO 的三个阶段是递进关系,标记为 Stage 1、2、3,每一级都在上一级基础上多切一类状态:
- Stage 1:切优化器状态。每张卡只维护 1/N 的 fp32 主权重、m 和 v,反向算完梯度后通过 reduce-scatter 把梯度归到对应的卡上,各卡独立更新自己负责的那部分参数,最后 all-gather 把参数同步回去。
- Stage 2:在 Stage 1 基础上再切梯度。梯度不再完整保存在每张卡上,用完即弃。
- Stage 3:再切模型参数。每张卡只常驻 1/N 的参数,前向和反向时按需 all-gather 出完整层,算完立刻释放。
按 16Φ 的账本,三种模式下的单卡静态占用大致是:
| 模式 | 单卡模型状态占用 | 通信量相对纯数据并行 |
|---|---|---|
| 纯数据并行 | 16Φ | 1x |
| Stage 1 | 4Φ + 12Φ/N | 约 1x |
| Stage 2 | 2Φ + 14Φ/N | 约 1x |
| Stage 3 | 16Φ/N | 约 1.5x |
这个表是关键:Stage 1 和 Stage 2 的通信量和纯数据并行基本一致,因为它们本来就要做 all-reduce,改成 reduce-scatter + all-gather 之后总量不变。Stage 3 因为要在前向反向过程中反复 all-gather 参数,通信量会涨到大约 1.5 倍左右。所以从性价比看,能停在 Stage 2 就不要上 Stage 3。
2.2 各阶段的隐性代价
通信量只是明面上的代价,真正让人头疼的是隐性成本。
Stage 1 的隐性成本最低,基本可以理解为"免费的显存优化",除了优化器步骤被拆到各卡上执行,其余逻辑不变。它对模型代码的侵入几乎为零,是新手最应该先尝试的档位。
Stage 2 开始有讲究了。梯度被切分之后,如果代码里存在"拿到完整梯度做点什么"的逻辑,比如自定义的梯度裁剪、梯度范数统计、或者某些需要全量梯度的自定义优化器,就可能出问题。DeepSpeed 提供了clip_grad_norm的封装来兜底,但你得记得用它的版本而不是框架自带的。
Stage 3 的坑最多。参数被切分意味着在任意时刻,model.parameters()拿到的可能只是一部分参数的占位符,很多依赖参数完整性的操作会失效——比如在某些层上做权重初始化、计算参数总量、或者用参数做自定义正则。另外 Stage 3 下每个 rank 只持有一部分层,如果你的模型里有跨层的共享权重(比如 embedding 和输出层 tied weights),需要额外配置才能正确处理。
注意:Stage 3 下如果模型有 tied embedding,务必确认配置文件里没有把这类共享参数错误地当成独立参数切分,否则训练会出现难以察觉的数值偏差。
2.3 参数量与阶段的粗选算法
给一个我常用的粗算流程,用来决定从哪个阶段起步。
第一步,算出模型状态的静态占用 16Φ。第二步,用单卡可用显存减去静态占用,得到留给激活值的余量。第三步,如果余量为负,说明必须切分;把 16Φ 除以卡数 N,看 Stage 3 下是否能塞进显存。
举个具体例子:24GB 的卡,8 张,训一个 7B 模型。静态占用 112GB,单卡完全放不下。Stage 2 下单卡是 2Φ + 14Φ/8 = 14 + 98 = 112GB?这个算法要小心——2Φ 是梯度,14Φ/N 是参数 + 主权重 + 动量的分片。代入 7B 得 14GB + 98GB÷8 ≈ 14 + 12.25 = 26.25GB,还是超了 24GB。那就得上 Stage 3,16Φ/8 = 14GB,留 10GB 给激活值,序列长度控制在 1024 以内、开梯度检查点,是可以跑的。
这个粗算不含激活值,实际还得再打七折。我的经验是:算完之后给激活值预留至少 40% 的显存,再决定 batch size,否则第一天跑通、第二天改个序列长度就 OOM。
3. 环境搭建:版本对齐比版本新更重要
3.1 三个版本必须一起看
DeepSpeed 的安装踩坑,九成出在版本不匹配上。需要同时对上的有三方:深度学习框架版本、CUDA 版本、DeepSpeed 版本。这三者之间是强约束关系,不是"越新越好"。
我见过最典型的情况是:框架是较新的版本,CUDA 也是新的,但装了个旧版 DeepSpeed,编译时直接报找不到某个符号;反过来,用最新版 DeepSpeed 配老框架,会在deepspeed.initialize阶段报参数签名不匹配。
比较稳妥的做法是先确定框架版本,再查它对应的 CUDA 版本要求,最后选一个和这两者都兼容的 DeepSpeed 版本。装之前先看一眼当前环境:
python -c "import torch; print(torch.__version__, torch.version.cuda)" nvcc --version pip show deepspeed三个输出对齐了再往下走。如果机器上有多套 CUDA,务必确认CUDA_HOME指向的是框架编译时用的那个,否则 JIT 编译自定义算子时会链接到错误的运行时。
3.2 安装方式的选择
安装有两条路:pip 预编译包和源码编译。
pip 安装快,但预编译包覆盖的硬件架构有限。如果你的卡比较新,预编译包里可能没有对应架构的 kernel,跑起来会退化成慢速路径,甚至直接报错。源码编译慢,十几分钟到半小时不等,但可以针对本机架构编译,性能更好。
# 路线一:预编译安装 pip install deepspeed # 路线二:源码编译,指定目标架构 git clone <源码地址> deepspeed-src cd deepspeed-src TORCH_CUDA_ARCH_LIST="8.0;8.6;9.0" pip install -e .TORCH_CUDA_ARCH_LIST这个环境变量值得单独说。它告诉编译器要为目标显卡架构生成代码,填错会导致 kernel 跑不起来。8.0 对应 A100 那一代,8.6 对应 A10/3090 那一代,9.0 对应 H100。不知道自己卡是哪个架构,用nvidia-smi --query-gpu=name,compute_cap --format=csv查一下 compute capability 就行。
提示:源码编译前先确认装了匹配版本的编译器和 CUDA toolkit,
nvcc不在 PATH 里是最常见的编译失败原因。
3.3 装完先验证,别急着上训练
装完之后花两分钟做个最小验证,比后面花两小时排查环境要划算得多。
ds_report这个命令会打印一堆环境信息,重点看三行:DeepSpeed general environment info里的版本号、torch info里的框架版本和 CUDA 版本、以及deepspeed install info里的Ninja和cuda状态。如果cuda匹配状态显示为 False,说明编译时的 CUDA 版本和运行时不符,得重装。
再跑一个单卡的最小用例,确认deepspeed.initialize能正常返回引擎对象:
import torch import deepspeed # 构造一个极小的模型和优化器做冒烟测试 model = torch.nn.Linear(8, 8).cuda() ds_config = { "train_batch_size": 1, "optimizer": {"type": "Adam", "params": {"lr": 1e-4}}, "fp16": {"enabled": False}, } engine, optimizer, _, _ = deepspeed.initialize(model=model, config=ds_config) print("engine ready:", type(engine).__name__)能打印出engine ready: DeepSpeedEngine就算过了第一关。这一步挂了的话,后面全是白搭。
4. 改造训练脚本:从单卡到 DeepSpeed
4.1 启动方式的两个变化
最直观的变化是启动命令。原来python train.py,现在要么用命令行工具,要么在脚本里嵌deepspeed.initialize。
deepspeed --num_gpus=4 train.py --deepspeed_config ds_config.json--num_gpus指定本机使用的卡数,多机训练时换成--hostfile指定节点列表。这里有个容易忽略的点:--include和--exclude参数可以精确控制用哪几张卡,在共享机器上很有用,比如--include localhost:0,1只用前两张。
第二种方式是在脚本内部初始化:
import deepspeed model_engine, optimizer, train_loader, lr_scheduler = deepspeed.initialize( args=args, model=model, model_parameters=model.parameters(), training_data=dataset, config=ds_config, )两种方式不能混用。用了命令行启动,脚本里就不需要再手动调deepspeed.initialize(它会自动被调用);反过来,如果脚本里已经手动调了,命令行就只负责拉起进程,不要重复传 config。
4.2 三件套的接管顺序
deepspeed.initialize接管了模型、优化器、数据加载器三样东西,但接管的时机有讲究。
模型要在初始化之前准备好,但优化器不要提前创建。这是个高频错误:很多人在脚本开头就optimizer = AdamW(model.parameters()),然后再传给 DeepSpeed,结果 Stage 1/2 下优化器状态没有被正确分片,显存一点没省。
正确的做法是把参数列表交给 DeepSpeed,让它自己造优化器:
# 正确做法:只传参数,优化器由 DeepSpeed 创建 model_engine, optimizer, _, _ = deepspeed.initialize( model=model, model_parameters=model.parameters(), config=ds_config, ) # 训练循环里的调用也跟着变 outputs = model_engine(inputs) loss = criterion(outputs, labels) model_engine.backward(loss) model_engine.step()原来那三行loss.backward()、optimizer.step()、optimizer.zero_grad()被model_engine.backward()和model_engine.step()替掉了。step()内部会自动处理梯度累积——当你配了gradient_accumulation_steps大于 1 时,它会自己判断该不该真正更新参数,不需要你在外面写if step % N == 0的逻辑。
数据加载器如果不传,就用你自己的;如果传了,DeepSpeed 会帮你做分布式采样器的包装。我个人的习惯是数据加载器自己管,因为常常要做自定义的采样策略,交给框架反而不好控制。
4.3 batch size 的三元约束
DeepSpeed 的配置里有三个 batch 相关的参数,它们之间是硬约束关系:
train_batch_size = micro_batch_size × gradient_accumulation_steps × 数据并行度数据并行度就是参与训练的进程数(单机多卡就等于卡数)。这三个数填错是最高频的报错来源,典型错误信息是Check batch related parameters。
我的实操顺序是:先定micro_batch_size(单卡单次前向能塞下的最大 batch,靠试出来),再用目标总 batch 除以它,得到累积步数。
举个例子,4 卡训练,实测单卡 micro batch 能到 8,目标总 batch 是 128。那么gradient_accumulation_steps = 128 ÷ (8 × 4) = 4。配置里就这么填:
{ "train_batch_size": 128, "micro_batch_size": 8, "gradient_accumulation_steps": 4 }如果除不尽,就必须调整 micro batch 或总 batch,不能硬填。另外提一句,train_batch_size在 Stage 3 下是必须显式给出的,因为框架要靠它推算各种分片桶的大小。
4.4 检查点的目录结构和存取方式
这一块是我认为最容易翻车的地方,因为报错信息往往很隐晦。
DeepSpeed 保存的是分片检查点,不是单个.bin文件。调用方式:
model_engine.save_checkpoint(save_dir="./ckpt", tag="step_1000")生成的结构大致是:
ckpt/ step_1000/ zero_pp_rank_0_mp_rank_00_optim_states.pt zero_pp_rank_1_mp_rank_00_optim_states.pt ... latest每个 rank 各存各的那一份。加载的时候注意,tag传的是标签名不是完整路径:
_, client_state = model_engine.load_checkpoint("./ckpt", tag="step_1000")想拿回普通格式的权重文件(比如要拿去部署),Stage 3 下需要在配置里打开stage3_gather_16bit_weights_on_model_save,或者事后用zero_to_fp32.py脚本做合并。这个脚本在安装目录的bin下面,基本用法是给一个检查点目录和一个输出文件名,但要注意它需要所有 rank 的文件齐全,缺一个就会失败。
注意:Stage 3 保存检查点时,如果没开
stage3_gather_16bit_weights_on_model_save,有些框架的save_pretrained会保存出一个空壳或者报错,因为参数是分片的拿不全。这个坑我踩过至少两次。
5. 配置文件逐项拆解
5.1 顶层参数:先保证训练能转起来
配置文件是 JSON 格式,顶层参数控制训练的整体行为。最小可用的配置大概长这样:
{ "train_batch_size": 64, "gradient_accumulation_steps": 2, "fp16": { "enabled": true, "loss_scale": 0, "initial_scale_power": 16 }, "optimizer": { "type": "AdamW", "params": { "lr": 2e-5, "betas": [0.9, 0.999], "eps": 1e-8, "weight_decay": 0.01 } }, "scheduler": { "type": "WarmupDecayLR", "params": { "warmup_min_lr": 0, "warmup_max_lr": 2e-5, "warmup_num_steps": 500, "total_num_steps": 10000 } }, "zero_optimization": { "stage": 2, "allgather_bucket_size": 500000000, "reduce_bucket_size": 500000000, "overlap_comm": true, "contiguous_gradients": true } }这里有几个点需要解释。
loss_scale填 0 表示启用动态损失缩放。半精度训练时梯度容易下溢成 0,动态缩放会自动调整倍数,是省心的选择。initial_scale_power是初始缩放的 2 的幂次,16 就是 65536,一般不用改。
scheduler里如果配了,DeepSpeed 会接管学习率调度,脚本里就不要再手动scheduler.step()。有个微妙的地方:warmup_num_steps和total_num_steps都要按优化器步数填,不是按数据批次数。如果你配了gradient_accumulation_steps=2,那 10000 个优化器步对应 20000 个批次。
allgather_bucket_size和reduce_bucket_size控制通信的桶大小。桶大,通信次数少但单次显存占用高;桶小反之。默认值 5e8 在大多数场景下够用,显存吃紧时可以调到 2e8 试试。
5.2 zero_optimization 的细节开关
这块是整个配置的核心,也是调优的主战场。
stage就是前面讲的 1/2/3。overlap_comm打开后让通信和计算重叠,在 Stage 2/3 下能明显降低通信带来的停顿,代价是多占一点显存做缓冲区。这个开关我觉得基本属于"能开就开"。
contiguous_gradients让梯度在内存里连续存放,默认为真。它的作用是减少 all-reduce 时的小块内存访问,对性能有正向帮助,除非你遇到内存碎片问题,否则不建议关。
Stage 3 还有一组专属参数:
"zero_optimization": { "stage": 3, "stage3_prefetch_bucket_size": 50000000, "stage3_param_persistence_threshold": 100000, "stage3_max_live_parameters": 1000000000, "stage3_gather_16bit_weights_on_model_save": true }stage3_param_persistence_threshold是个很有意思的参数:小于这个值的参数张量不会被换出,一直常驻显存。对于 LayerNorm 的缩放参数、bias 这类小张量来说,反复 all-gather 它们的开销远大于常驻的成本。所以这个值给大一点通常是划算的。
stage3_max_live_parameters限制同一时刻常驻 GPU 的参数上限,超过就换出。调大能减少换入换出次数但吃显存,调小反之。这个值和序列长度、batch size 一起构成显存的三方博弈,需要实测。
5.3 offload:用 NVMe 换显存的最后手段
显存实在不够了,可以把优化器状态甚至参数卸载到内存或者 NVMe 上。配置大概这样:
"zero_optimization": { "stage": 3, "offload_optimizer": { "device": "nvme", "nvme_path": "/path/to/nvme", "buffer_count": 4, "buffer_size": 1e8 }, "offload_param": { "device": "nvme", "nvme_path": "/path/to/nvme", "buffer_count": 5, "buffer_size": 1e8 } }这个方案的代价必须说清楚:训练速度会掉得很厉害。我实测过一个 13B 模型,Stage 3 加优化器卸载到 NVMe,相比纯 Stage 3,吞吐下降了将近 60%。原因很简单,每一步更新都要读写几十 GB 的数据,盘的速度成了瓶颈。如果盘是普通的机械盘或者共享存储,掉得更多。
所以卸载这招的使用场景很明确:能训起来比训得快更重要的时候。比如你只有一台普通机器,想验证一个想法能不能收敛,那慢一点就慢一点。但要做正经的训练产出,还是老老实实加卡或者减模型。
提示:用 NVMe 卸载时,
nvme_path一定要指向本地盘,不要指向网络挂载的存储。另外buffer_count和buffer_size会直接影响显存占用和 IO 效率,调大缓冲区能提升吞吐但会吃掉显存,需要平衡。
6. 报错排查:几个反复出现的老问题
6.1 显存没降反升
这是最让人困惑的一类问题。明明开了 Stage 2,nvidia-smi一看显存还涨了。
我遇到过的原因主要有三个。
第一个是优化器被重复创建。前面说过,如果在deepspeed.initialize之前就造了优化器,DeepSpeed 会新建一个,旧的还占着显存,等于双份。检查方法是看初始化后显存里是否多出了一倍优化器状态的内存。
第二个是train_batch_size填小了。这听起来反直觉:总 batch 填小不是应该更省显存吗?问题在于 DeepSpeed 会用train_batch_size ÷ (micro_batch_size × 并行度)反推累积步数,如果填的数字导致这个除法得出一个很大的累积步数,激活值的缓存量反而上升。所以三个 batch 参数必须自己算清楚,不要指望框架兜底。
第三个是allgather_bucket_size太大。在 Stage 2 下,如果这个桶配得过大(比如误填了 5e9),每次通信都要申请一大块缓冲区,显存直接起飞。这个参数的单位是元素个数不是字节,填的时候容易多数一个零。
6.2 loss 变成 NaN 或者不收敛
半精度训练下的数值问题占了大头。
先看是不是梯度下溢。如果loss_scale是固定值而不是动态的,梯度小的层会直接归零。检查配置里的loss_scale是否设成了 0(动态)。如果已经是动态的还是不稳,可以试着把initial_scale_power从默认的 16 降到 12 或 10,让初始缩放小一些。
再看 batchnorm 之类的层。半精度下 BN 的统计量容易累积误差,如果模型里有 BN,建议把它固定到 fp32 或者换成 LayerNorm。这类问题在视觉模型里更常见,语言模型相对少,但如果你的模型里混了视觉编码器就要注意。
还有一种情况是 batch size 变了导致学习率不合适。从单卡切到多卡,总 batch 可能翻了好几倍,原来的学习率就不对了。经验法则是总 batch 扩大 k 倍,学习率可以按 sqrt(k) 或者 k 倍调整,具体看优化器和任务。这个不是 DeepSpeed 的锅,但换框架的时候很容易忘。
6.3 卡在初始化,进程一直不往下走
多机训练最常见的现象:日志停在Initializing NCCL或者干脆什么都不打印。
排查顺序我一般是这样:
先确认各节点之间能不能互相通信。用ping和nc测一下端口通不通。NCCL 默认用的端口范围可能被防火墙挡了,需要确认。
再确认网卡选择对不对。多机环境下,如果机器有多张网卡(比如一张管理网、一张高速网),需要显式指定:
export NCCL_SOCKET_IFNAME=eth0 export NCCL_IB_DISABLE=1 # 没有 InfiniBand 时关掉NCCL_SOCKET_IFNAME填错网卡名,进程会一直等在握手阶段。这个环境变量的值要和ifconfig里的网卡名对上。
最后看是不是主机文件的问题。--hostfile里的节点名要能解析成 IP,且各节点的 SSH 免密要配好。有一次我排查了两个小时,最后发现是 hostfile 里有一台机器的 hostname 拼错了,那台机器根本没被拉起进程,其余节点就一直等它。
6.4 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 报错提及 batch related parameters | 三个 batch 参数不自洽 | 按 train = micro × accum × 并行度 重算 |
| Stage 2/3 下显存没降 | 优化器重复创建或桶参数过大 | 只传参数不传优化器,检查桶大小 |
| 保存的权重无法加载 | Stage 3 未合并分片 | 开 gather 开关或跑合并脚本 |
| 训练中途 loss 突增 | 动态损失缩放频繁回退 | 降低初始 scale,检查异常样本 |
| 多机初始化卡住 | 网卡或端口不通 | 设 NCCL_SOCKET_IFNAME,检查 hostfile |
| Stage 3 下自定义操作报错 | 参数不完整 | 改用 engine 提供的接口,避免直接遍历参数 |
| 吞吐远低于预期 | 通信未重叠或 Stage 过高 | 开 overlap_comm,评估能否降到 Stage 2 |
| 单卡启动报错 | 未指定 num_gpus | 加--num_gpus=1 |
7. 调优实操中的几点体会
7.1 先用 Stage 2 摸清上限
我的固定流程是:拿到一个新任务,先单卡跑通,确认 loss 能降;然后 4 卡 Stage 2,把 micro batch 试到最大;最后才根据显存情况决定要不要上 Stage 3。
这个顺序的理由是 Stage 2 的通信开销和纯数据并行基本一致,性能损失最小,能让我先看清"这个模型在这批卡上大概能跑多快"。有了这个基准,再切 Stage 3 时,性能掉了多少心里有数,不会误以为是配置写错了。
实测下来,在 4 卡 A100 上训 7B 模型,Stage 2 配 micro batch 2、序列长度 2048,吞吐大约是纯数据并行(能跑起来的话)的 92%;切到 Stage 3 之后掉到 76% 左右。这个差距在可以接受的范围内,因为 Stage 2 根本跑不动这个规模。
7.2 micro batch 尽量给满
在总 batch 固定的前提下,micro_batch_size和gradient_accumulation_steps是一对反向变量。原则是:micro batch 尽量大,累积步数尽量小。
原因是累积步数增加意味着多次前向反向才有一次参数更新,中间的激活值要一直挂着,调度开销也在累积。而 micro batch 增大只是单次计算量变大,GPU 利用率更高。
具体做法是把 micro batch 一点点往上加,直到 OOM,然后回退一档。回退的时候留点余量,因为训练过程中激活值的占用会有波动,卡在临界点上很容易在某个 step 突然挂掉。我一般会回退到"再加 1 就 OOM"的那个值的 85% 左右。
7.3 长序列场景要配合激活检查点
序列长度上去之后,激活值会迅速成为显存的主要占用方。这时候光靠 ZeRO 切模型状态不够用,得配合激活检查点(activation checkpointing)——前向时不保存中间激活,反向时重新算一遍。
这个技术在 DeepSpeed 里有对应的配置项,也可以直接用模型库自带的实现。代价是训练变慢,因为反向多了一次前向计算,通常慢 20% 到 30%。
我的判断标准是:如果不开激活检查点就 OOM,那没什么好犹豫的,开;如果开了能换来的收益是 batch size 翻倍,那也值得,因为大 batch 对训练稳定性的帮助通常能抵消掉那点速度损失。
7.4 关于"能不能不学这个"
最后聊个务实的判断。如果你的模型在单卡上能跑,batch size 也够用,那确实没必要引入 DeepSpeed,多一层封装就多一层调试成本。它的价值在参数量跨过十亿这条线之后才开始显现,在这之前,普通的混合精度加工梯度累积就够用了。
我在实际使用中的一个感受是,DeepSpeed 的学习曲线主要不在 API 上——它的 API 其实很少,就这么几个调用——而在对显存账本和通信代价的理解上。把 16Φ 这个数记住,把三个 Stage 的通信量关系记住,剩下的基本都是工程细节。遇到报错的时候,先问自己两个问题:当前这个配置下,显存应该占多少?通信应该发生几次?答案对不上,问题往往就在那里。