☰
分布式训练与DeepSpeed实战:从单卡到多卡的显存与性能优化
2026/9/30 3:12:48 网站建设 项目流程

机器又 OOM 了——这基本是所有想训练大模型的人遇到的第一道坎。模型参数从 7B 涨到 70B,显存却永远不够用;即便塞得下,单卡训练的速度也让人绝望。于是分布式训练不再是"大厂专属",而是每个做深度学习的人都绕不开的话题。这篇文章我打算把并行策略和 DeepSpeed 这两件事掰开揉碎讲清楚:为什么需要它们、各自解决了什么问题、实际配置怎么落、以及我在多卡集群上踩过哪些坑。适合正准备从单卡切到多卡,或者要给大模型做分布式微调的人参考。

1. 从单卡装不下到多卡训不动:分布式训练的两道门槛

1.1 显存账本:13B 模型在 A100 上为什么放不下

先把账算明白。用混合精度(FP16)训练一个参数量为 P 的模型,并不只是把权重塞进显存那么简单。训练过程中,每读到一个参数,你都至少要同时维护四份东西:

  • FP16 的权重副本:2 字节/参数
  • FP16 的梯度副本:2 字节/参数
  • Adam 优化器里的 FP32 权重主副本:4 字节/参数
  • Adam 的动量 m 和方差 v:各 4 字节/参数

也就是说,一个参数在训练状态下大约要占 16 字节。拿 13B 模型来算:13 × 10^9 × 16 字节 ≈ 208GB。一块 A100 80GB 连零头都不够,而且这还没算 forward 过程中产生的激活值(中间张量)。所以"13B 模型单卡训练"这件事从一开始就不成立,这不是代码优化能解决的问题,是物理账。

这也是为什么分布式训练的第一驱动力是显存:要么把模型切开放到多张卡上,要么把优化器状态切开放到多张卡上,总之得让总显存大于模型需求。

1.2 算力账本:单卡训到天荒地老,多卡怎么"并联"

即便模型能塞进单卡,训练速度也会逼你走向多卡。以 7B 模型为例,单卡 A100 上跑一个 batch 的 forward + backward 大约需要几秒,一个 epoch 几万步算下来,预训练周期按周计算。

于是大家最先想到的方案是数据并行(Data Parallel):每张卡放一份完整模型,把训练数据切成 N 份并行算,每迭代一步把所有卡的梯度做一次 all-reduce 同步,保证所有卡上的模型始终一致。这种方法最容易理解、工程上也最成熟,但它有两个天生短板:

  1. 显存没有省下来——每张卡都完整放了一份权重、梯度和优化器状态;
  2. 通信量随模型尺寸线性增长——每步都要同步大小为 2P 字节的全量梯度,7B 模型每次 all-reduce 要搬 14GB 数据。

你会发现,加卡能加速,但加速得越来越不线性:4 卡可能跑到 3.6 倍,8 卡可能只有 6 倍出头。当计算时间被通信时间追上时,并行收益就急剧下降。这时候再去追求"数据并行跑天下",就有点不够用了。

2. 并行策略选型地图:四种并行方式的原理与适用场景

2.1 数据并行:最朴素也最容易上手的多卡方案

数据并行是很多人接触分布式训练的第一站,PyTorch 的 DDP(DistributedDataParallel)就是它的标准实现。原理一句话:模型复制 N 份,数据切 N 份,梯度同步。

DDP 的关键设计是梯度 all-reduce 的通信优化:它不会把梯度拆成小 tensor 逐个传输,而是先做 bucket 聚合,再用 ring all-reduce 算法在卡与卡之间形成环形流水线,把通信量摊薄。实际使用 DDP 时,只要你把模型包一层DDP(model),再给 DataLoader 配上DistributedSampler,大部分事情框架就替你干了。

它的适用场景非常清晰:模型单卡能放下,但训练速度不够;或者你有大量可并行数据。对 1B 以下的模型微调,DDP 基本是默认答案。但记住一句判断标准:当你发现 8 卡扩展效率低于 70%,或者单卡放不下模型时,就该往别的并行方式上看了。

2.2 张量并行:把矩阵乘法切开算

张量并行(Tensor Parallelism)是针对"单个算子太大"的解法,最著名的实现是 Megatron-LM。它的思路非常暴力:把一层里的权重矩阵沿行或列切开,分别放在不同 GPU 上,一起算完再拼结果。

具体到 Transformer 里的线性层,一般组合两种切法。对Y = XW这类层,把 W 按列切成[W1, W2],每张卡算XW1和XW2,最终把输出沿特征维拼接;对下一个线性层,就把 W 按行切,配合一次 all-reduce 把结果汇总。两个切法交替使用,可以省掉一半的通信。

张量并行能真正降低单算子显存占用,比如一个 4096×4096 的矩阵,8 卡切开后每卡只需存 1/8。缺点是通信频繁,每个 Transformer 层前向时都要做几次 all-reduce,且通信量随激活值大小(也就是 batch size × 序列长度)增长,所以张量并行一般用在单机内部(NVLink 带宽高),跨节点慎用。

2.3 流水线并行:按层切分,用流水线填满 GPU

流水线并行(Pipeline Parallelism)更符合直觉:把模型的层按顺序切成若干段,每一段放在一张卡(或一组卡)上,前一段算完把激活值传给后一段。

如果只是朴素切分,会出现严重的 GPU 空转:前向时 4 个 stage 串联,同一时刻只有一张卡在算。所以工程上都用 micro-batch 流水线调度:把一个大 batch 拆成多个小 batch 依次送入,让每个 stage 交替处理不同 micro-batch,像工厂流水线一样把计算塞满。典型调度是 1F1B(一个前向接一个反向),GPipe、PipeDream 用的都是这类思路。

流水线的优点是把通信量降到了"激活值级别"——stage 之间只传序列长度 × 隐藏层大小的张量,跟模型参数总量相比小很多。缺点是存在流水线气泡(bubble),也就是调度刚开始和收尾时 GPU 没吃满的时间碎片,stage 数越多气泡占比越高。所以实践中流水线一般不单独用,而是和其他并行方式组合。

2.4 混合并行:大模型训练的标准答案

真实的大模型预训练,很少只用某一种并行。GPT-3 论文里 175B 模型的配置是:8 路张量并行 × 4 路流水线并行 × 8 路数据并行,在 256 张 A100 上跑。这就是混合并行(Hybrid Parallelism)的标准姿势:同一机内用张量并行降低单卡激活与权重压力,跨机用流水线切层,再用数据并行把整体吞吐堆上去。

选择组合时有一个粗略经验:张量并行先满足单层放得下的约束,流水线并行满足整体模型放得下的约束,剩下的靠数据并行堆算力。实际调优时,我会先用 Profiler 看瓶颈在哪:通信占比高就先降并行维度,GPU 利用率低就调整 micro-batch 数量和 pipeline 深度。

下面的对比表把四种方案的关键差异列出来,方便快速选型:

并行方式切分对象主要通信量显存节省典型实现
数据并行训练数据每步全量梯度 O(P)无DDP
张量并行单层权重矩阵每层激活 O(M)有,层内分摊Megatron-LM
流水线并行网络层/Stage阶段间激活有,按层分摊GPipe / PipeDream
混合并行数据+层+矩阵组合通信组合节省Megatron-DeepSpeed

3. DeepSpeed 凭什么省显存:ZeRO 三阶段与 Offload 机制拆解

3.1 ZeRO 的本质:去掉冗余,让状态"各管一段"

DeepSpeed 是微软开源的分布式训练框架,它最有影响力的贡献是 ZeRO(零冗余优化器)。ZeRO 的洞察特别朴素:数据并行之所以显存不省,是因为每张卡都完整存了一份权重、梯度和优化器状态——这些东西本质上完全一样,是赤裸裸的冗余。

ZeRO 的做法是把这些冗余状态按数据并行维度切分:假设有 N 张卡参与数据并行,那么优化器状态、梯度、权重各自只保留 1/N,需要用时再通过通信临时聚合。这就是"去掉冗余"四个字的含义。

我经常跟朋友打比方:以前是每人发一本完整教材(数据并行),ZeRO 是上百人合看拆开的教材,谁要用某一页就喊一声大家传过来。代价是多了些通信,收益是显存占用从 O(模型大小) 降到了 O(模型大小 / N)。

3.2 从 Stage 1 到 Stage 3:省到什么程度

ZeRO 是分阶段推进的,每个阶段划掉一类冗余:

  • Stage 1:只切分优化器状态。每个参数仍存完整 FP16 权重和梯度,但 Adam 的 FP32 主副本、动量、方差只留 1/N。
  • Stage 2:在 Stage 1 基础上再切分梯度。backward 后的梯度先做规约,再让各卡只保留属于自己的 1/N 分片。
  • Stage 3:继续切分 FP16 权重本身。任何时刻每张卡只有当前计算需要的参数分片,用前 all-gather,用完丢弃。

省显存的效果可以量化。还是按"每参数 16 字节"的账本算:数据并行下每卡需要 16P 字节(假设只算权重/梯度/优化器状态,不含激活)。ZeRO-1 把其中 12 字节的优化器状态砍到 12P/N;ZeRO-2 再把 2 字节梯度砍到 2P/N;ZeRO-3 最后把 2 字节权重也砍到 2P/N。当 N=64 时,理想情况每卡只存约 0.5P 字节,几乎消除了全部冗余。

各个阶段的显存对比和适用场景,我整理成了下面的表:

配置每卡仍需常驻的数据显存总量适用场景
普通数据并行权重+梯度+优化器状态16P模型单卡放得下
ZeRO-1权重+梯度+优化器状态/N~4P + 12P/N13B 级微调
ZeRO-2权重+梯度/N+优化器状态/N~2P + 14P/N13B~30B 预训练
ZeRO-3权重、梯度、状态均 /N~16P/N70B 级及更大
ZeRO-3 + Offload仅计算中的临时参数可进一步压到 CPU/NVMe单机多卡极限场景

3.3 CPU/NVMe Offload:显存不够内存凑

ZeRO-3 把参数都切到各卡了,但如果模型实在太大,连每卡 1/N 的份额也放不下,DeepSpeed 还有一手:Offload。

以 CPU Offload 为例,优化器状态(FP32 主权重、动量、方差)可以全部放到 CPU 内存里,GPU 只在更新参数的那一瞬间把对应分片拉回来,算完再放回去。NVMe Offload 更进一步,把暂时用不到的分片卸载到 NVMe 固态硬盘上,代价是带宽比内存低一个量级。

这个方案对"单机多卡训练超大模型"非常实用。我曾在 8 卡 A100 上用 ZeRO-3 + CPU Offload 微调 65B 模型,虽然没有纯 GPU 跑得快,但至少"能训"和"不能训"有本质区别。要提醒的是,Offload 开启后训练吞吐会明显下降,因为它把显存瓶颈转移成了 PCIe 带宽瓶颈。判断是否值得 Offload,我一般看两个指标:显存是否真的不够,以及你是否接受 30%~50% 的吞吐损失。

4. 实操落地:一份可复现的 DeepSpeed 训练配置与代码改造

4.1 环境准备:版本组合最容易翻车

DeepSpeed 的安装本身不复杂:pip install deepspeed即可,但真正运行时踩得最多的是版本不匹配。建议至少满足:PyTorch 2.0+,DeepSpeed 0.9+,CUDA 11.7+,NCCL 版本跟随 PyTorch 预编译包即可。装完后跑一下ds_report,确认 CUDA、NCCL 和 AArch64/x86 架构都显示 OK,这一步能过滤掉八成环境问题。

如果是多机训练,还要确认节点间网络互通。最简单的方式是节点间能互相ping通,并且 SSH 免密登录配置好(DeepSpeed 启动时会通过 ssh 拉起远端进程)。如果节点之间有 GPU 直连网卡(如 InfiniBand),可以不管;如果只有普通以太网,训练前设置NCCL_SOCKET_IFNAME=eth0这类环境变量指定网卡名,否则 NCCL 可能选错接口导致带宽极低。

4.2 改造训练脚本:三步替换原来的单卡逻辑

把单卡 PyTorch 训练代码改成 DeepSpeed 版,核心就三步,不需要重写模型结构。

第一步,把 model 和 optimizer 的创建替换成deepspeed.initialize:

import deepspeed model = build_model() model_engine, optimizer, _, _ = deepspeed.initialize( args=args, model=model, model_parameters=model.parameters(), config="ds_config.json" )

第二步,把原来手写的loss.backward()和optimizer.step()换成引擎方法:

for step, batch in enumerate(dataloader): loss = model_engine(batch) model_engine.backward(loss) model_engine.step()

第三步,给 DataLoader 加上分布式采样器,保证每个 rank 拿到的数据不重复:

from torch.utils.data.distributed import DistributedSampler sampler = DistributedSampler(dataset, shuffle=True) dataloader = DataLoader(dataset, batch_size=args.micro_batch, sampler=sampler) # 每个 epoch 开头记得 sampler.set_epoch(epoch)

这里有个细节:使用 ZeRO-3 时不要手动model.to(device)到某个显存设备,DeepSpeed 会在 forward 过程中按需把参数分片聚合到当前计算设备,你手动搬反而可能破坏它的分配逻辑。梯度裁剪也不要直接调torch.nn.utils.clip_grad_norm_,改用model_engine.clip_grad_norm(max_norm),因为 ZeRO 下梯度可能是分片状态,原生函数拿不到全局视角。

4.3 关键配置字段:每个参数背后的权衡

DeepSpeed 的配置文件是 JSON,下面这份是我在 8 卡 A100 上微调 7B 模型时常用的模板,注释都写在字段旁边:

{ "train_batch_size": 512, "train_micro_batch_size_per_gpu": 4, "gradient_accumulation_steps": 16, "optimizer": { "type": "AdamW", "params": { "lr": 3e-4, "betas": [0.9, 0.999], "weight_decay": 0.01 } }, "scheduler": { "type": "WarmupLR", "params": { "warmup_min_lr": 1e-5, "warmup_max_lr": 3e-4, "warmup_num_steps": 1000 } }, "fp16": { "enabled": true, "loss_scale": 0, "loss_scale_window": 1000, "hysteresis": 2, "min_loss_scale": 1 }, "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu", "pin_memory": true }, "overlap_comm": true, "contiguous_gradients": true, "reduce_bucket_size": 5e8, "allgather_bucket_size": 5e8 }, "gradient_clipping": 1.0, "steps_per_print": 100, "wall_clock_breakdown": true }

三个最容易理解错的字段:

  • train_batch_size、train_micro_batch_size_per_gpu、gradient_accumulation_steps三者必须满足公式:train_batch_size = micro_batch × 梯度累积步数 × 显卡数。我上面的配置是 4 × 16 × 8 = 512,如果你改了卡数,梯度累积要跟着调。
  • reduce_bucket_size控制梯度通信的桶大小。桶越大,一次通信聚合的张量越多,吞吐通常更高,但峰值显存也更高;显存吃紧时可以调小到 1e8 左右。
  • overlap_comm+contiguous_gradients这两个开关配合,能让梯度通信和 backward 计算重叠,是 ZeRO-2 提速的关键,一般直接开。

如果你的 GPU 支持 bf16(A100、H100 都支持),建议改成"bf16": {"enabled": true}并去掉fp16块。bf16 的指数位和 FP32 一样,训练稳定性比 FP16 好很多,基本不需要动态 loss scaling,省心不少。

5. 启动与运行:多机多卡的实际操作与验证方法

5.1 启动命令与其他资源文件

单机多卡最简单,一条命令搞定:

deepspeed --num_gpus=8 train.py --deepspeed --deepspeed_config ds_config.json

多机多卡则需要 hostfile 指定节点。hostfile 每行一个节点,格式是节点IP slot=显卡数:

192.168.1.10 slots=8 192.168.1.11 slots=8

然后:

deepspeed --hostfile=hostfile --master_port=29500 train.py \ --deepspeed --deepspeed_config ds_config.json

--master_port默认是 29500,多机场景经常撞端口,我习惯显式指定一个 30000 以上的端口。另外,如果节点之间防火墙没放行这个端口,初始化会一直卡在 waiting 状态,排查顺序永远是:先确认端口通,再看 SSH 免密,最后才怀疑代码。

5.2 如何判断"跑对了":日志、吞吐量与显存曲线

训练跑起来之后,不能只看 loss 在降就万事大吉。我每次开训都会盯三个东西:

第一,DeepSpeed 日志里的SamplesPerSec和TokensPerSec。这两个值直接反映吞吐,多卡训练时它们应该接近线性增长。如果 8 卡比 1 卡只快了 2 倍,赶紧查通信瓶颈。

第二,nvidia-smi的显存占用曲线。ZeRO-2 下每卡显存占用应当比较均匀,如果某张卡明显偏高,多半是数据并行切分没有生效,或者某个 rank 的 DataLoader 没配合DistributedSampler,导致数据偏斜。

第三,wall_clock_breakdown: true开启后,日志会输出每个 step 中 forward/backward/step 各自耗时。backward 占比过高通常意味着梯度通信没做好 overlap,可以检查overlap_comm是否开启;forward 占比过高则优先排查激活值过大,考虑开 gradient checkpointing。

5.3 批大小、学习率与收敛的关系

分布式训练最常见的隐性 bug 是:批大小变了,学习率没变,导致收敛曲线跟单卡对不上。

全局批大小从单卡的 32 变成多卡的 512,是同一个优化问题,但梯度的随机噪声变小了。对于 Adam 这类自适应优化器,业界通行做法是"线性缩放规则":全局批大小翻 k 倍,学习率大致按 sqrt(k) 或 k 调大,同时拉长 warmup。我的经验是学习率上限不要一次调太猛,先按 sqrt 规则估算,再看训练 loss 的前 500 步是否有发散趋势,有就降一半。

另外要注意梯度累积模拟的大批大小并不完全等价于真实大批大小:模型每个 step 的优化次数变少了,数据多样性打了折扣。如果训练集很小,建议开DistributedSampler时设置shuffle=True,每个 epoch 用sampler.set_epoch(epoch)打乱顺序,否则每个 epoch 的数据排列完全一致,模型容易过拟合到固定顺序上。

6. 事故现场与排错路径:分布式训练里最常见的坑

6.1 NCCL 初始化超时:端口、网卡与防火墙

分布式训练八成的启动报错都集中在初始化阶段。最经典的是NCCL timeout或卡在init_process_group不动。

我的排查路径固定如下:第一步,确认所有节点能互相ping通;第二步,确认--master_port没被占用,且防火墙放行了 TCP 端口;第三步,确认NCCL_SOCKET_IFNAME指向正确的物理网卡。还有一个容易忽略的点:有些集群机器名解析不到 IP,需要在/etc/hosts里把节点名和 IP 对应写清楚。按这个顺序走,十分钟内能定位绝大多数初始化问题。

6.2 显存"神秘"占用与 OOM:激活值、碎片与 bucket 设置

训练中途 OOM 比启动失败更磨人。有一次我把 7B 模型从 4 卡扩到 8 卡,反而 OOM 了,排查半天发现是激活值在作祟:batch size 没变,但张量并行维度变了,每层激活的通信和显存布局也变了。

处理 OOM 的优先级我是这样排的:先开model.gradient_checkpointing_enable()(用 20% 计算换 60% 激活显存,性价比最高);再调小train_micro_batch_size_per_gpu;然后考虑降低reduce_bucket_size和allgather_bucket_size;最后才考虑 Offload。顺序不对很容易白折腾,比如直接开 Offload 虽然显存降了,但吞吐掉得夸张,而实际上你可能只是差几个 GB,开个梯度检查点就解决了。

6.3 单节点拖慢全局:异构集群的拖尾效应

分布式训练是"木桶效应"最明显的地方:集群里混着 A100 和 V100,或者某台机器散热不好导致降频,整个训练速度都会被最慢的节点拖住。因为每步结束都要同步梯度,快的卡必须等慢的卡到齐,快的部分全被浪费。

我踩过最实际的一次:一批 8 台机器里有 1 台 PCIe 带宽异常,训练整体吞吐掉了 40%。后来学到的经验是:大规模训练前先跑一个纯通信基准(比如用 DeepSpeed 自带的 benchmark 脚本单独测 all-reduce 带宽),哪台机器明显低于均值就先修哪台。异构集群尽量保证每节点卡数一致、型号一致,实在混用的话,把慢节点放在流水线并行的浅层,或者干脆让它的 micro-batch 数少一点,做软性负载均衡。

写在最后的一点体会

这套东西我前前后后折腾了小半年,最大的感受是:分布式训练没有什么银弹,所有方案都是在显存、通信和计算三者之间做取舍。数据并行解决不了显存,张量并行解决不了跨机通信,流水线并行救不了小 batch,ZeRO 省了显存就得还通信。实际工程里最值钱的能力是"定位瓶颈"——先明确当前被哪个资源卡住,再决定往哪个方向调。刚开始接触的朋友不用害怕这些术语,把单卡的模型先跑通,再按"数据并行 → ZeRO-2 → ZeRO-3 → Offload"的顺序一步步升级,每一步都验证吞吐和显存变化,你会很快建立起自己的取舍直觉。最后分享一个小技巧:每次改配置我都把吞吐、显存、loss 记录到一张表里,几轮实验后回头一看,哪些调整真正有效一目了然,这比凭感觉调参靠谱太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询