分布式AI系统-三-
分布式AI系统(三)
译者:飞龙
每个 GPU 启动一个进程;如果某个进程失败,启动器最多重启整个进程组 --max-restarts 次。脚本 train_elastic_checkpoint.py 会加载和保存检查点,因此重启后训练可以从最后一个 epoch 继续。
多节点弹性(2–4 个节点,每个节点 2 个 GPU,最多 3 次重启):在每个节点上运行相同的命令,将端点替换为主节点的主机名或 IP:
torchrun --nnodes=2:4 --nproc_per_node=2 --max_restarts=3 \
--rdzv_id=my_job --rdzv_backend=c10d \
--rdzv_endpoint=MASTER_HOST:29400 \
code/train_elastic_checkpoint.py
--rdzv_id 在所有节点上必须相同;--rdzv_endpoint 是运行 c10d store 的主机和端口(即主节点)。对于节点数固定的容错模式(无弹性),请使用 --nnodes=2 而不是 --nnodes=2:4。
只有训练脚本保存检查点时,重启过程中的进度才能保留:启动时加载最新检查点(如果存在),进行训练,并定期保存(例如每个 epoch)。只有 rank 0 应该写入;请使用临时文件然后重命名,以实现原子写入(参见本章前面的检查点部分)。上述命令中使用的脚本 code/train_elastic_checkpoint.py 就遵循这种模式。当工作进程失败时,为了获得更清晰的错误摘要(包括 traceback),可以给入口函数加上来自 torch.distributed.elastic.multiprocessing.errors 的 @record 装饰器。[2]
何时使用弹性训练
弹性或容错训练适用于以下场景:长时间运行(数天或数周)且无法承受单个节点故障导致全部进度丢失的任务;节点故障或被抢占频繁的不可靠或共享集群;或在运行期间需要扩大或缩小节点数量的场景(仅弹性模式)。对于短任务或稳定、专用的集群,使用普通 torchrun 的标准 DDP 更简单,通常也足够了。
完整示例:带 transformer 的 DDP
脚本 code/train_transformer_ddp.py 将 DDP 设置、带 set_epoch() 的 DistributedSampler、混合精度(autocast 和 GradScaler)以及 rank 0 上的按 epoch 保存检查点整合在一起,使用一个小型 GPT 风格 transformer(嵌入、位置编码、transformer 块、下一个 token 预测)。它使用一个模拟数据集(随机 token ID),因此你无需下载真实数据即可运行;当你换成真实数据集时,同样的接线方式依然适用。要尝试真实数据,你可以接入一个已分词的语料库(例如 NanoGPT[3] 的数据管线、C4 或 OpenWebText 等公开数据集的一小部分,或任何返回整数 token 序列的 Dataset)。
在章节目录下,可以使用以下命令启动脚本:
$ torchrun --nproc_per_node=8 code/train_transformer_ddp.py
在脚本中,可以将模拟数据集或模型替换为真实数据或更大的架构。DDP、AMP 和检查点逻辑保持不变。这种模式——一个入口点、用 torchrun 启动、以及一个加载和保存检查点的训练循环——正是能够扩展到许多 GPU 并从故障中恢复的关键,它可以直接迁移到生产工作负载中。
总结
DDP 是 PyTorch 分布式训练的基础。在本章中,我们介绍了 DDP 的内部工作原理、如何为单节点和多节点训练进行设置、如何调试常见问题、如何分析和优化性能,以及如何处理检查点、异步训练和弹性扩展等高级场景。
理解 DDP 的工作原理——梯度同步、分桶、重叠——有助于你编写高效的训练代码,并在出现问题时进行调试。我们探讨的关键概念包括:
-
梯度同步:DDP 使用 AllReduce 在所有进程间聚合梯度,确保模型一致性
-
计算-通信重叠:DDP 将梯度同步与计算重叠,以隐藏通信延迟
-
进程管理:使用
torchrun启动和管理 DDP 进程 -
数据分片:使用
DistributedSampler确保每个进程看到不同的数据 -
性能优化:性能分析、混合精度、梯度累积、重叠卫生和桶调优
-
容错能力:针对长时间运行任务的检查点与弹性训练
DDP 成熟、优化良好,适用于大多数分布式训练场景。然而,对于无法在单个 GPU 上容纳的超大模型,你需要超越 DDP,使用 FSDP(全分片数据并行)等技术,我们将在下一章介绍。FSDP 通过将模型参数分片到多个 GPU 上扩展了 DDP,使得训练任何单个 GPU 内存都无法容纳的模型成为可能。
代码摘要
-
torch.distributed.init_process_group():初始化分布式通信的进程组 -
DistributedDataParallel:PyTorch 用于数据并行分布式训练的封装 -
DistributedSampler:在多个进程间划分数据集的采样器 -
torch.distributed.all_reduce():在所有 rank 之间对张量求和的集合操作 -
torch.distributed.barrier():同步组内的所有进程 -
torch.distributed.get_rank():获取当前进程的 rank -
torch.distributed.get_world_size():获取进程总数 -
torchrun:PyTorch 分布式训练启动器 -
torch.nn.parallel.DistributedDataParallel:DDP 封装类 -
torch.distributed.destroy_process_group():清理进程组
参考
-
https://docs.pytorch.org/docs/stable/distributed.elastic.html -
PyTorch Elastic 错误文档:
https://pytorch.org/docs/stable/elastic/errors.html -
https://github.com/karpathy/nanoGPT
练习
实现手动梯度同步
使用 torch.distributed 原语手动实现梯度同步,以理解 DDP 内部的工作方式。
要求:
函数签名:
def sync_gradients(model: nn.Module, world_size: int) -> None:
"""使用 AllReduce 在所有的 rank 间同步梯度。"""
pass
-
遍历所有
requires_grad=True的模型参数 -
使用
dist.all_reduce()配合ReduceOp.SUM求和梯度 -
除以
world_size计算平均值 -
处理梯度为
None的参数(跳过它们)
测试你的实现:
import torch
import torch.nn as nn
import torch.distributed as dist
# 简单模型
model = nn.Linear(100, 10).cuda()
# 前向和反向
x = torch.randn(32, 100).cuda()
y = torch.randn(32, 10).cuda()
loss = nn.MSELoss()(model(x), y)
loss.backward()
# 手动同步梯度
sync_gradients(model, world_size=dist.get_world_size())
# 验证:所有 rank 上的梯度应完全相同
print(f"Rank {dist.get_rank()}: weight grad sum = {model.weight.grad.sum().item():.4f}")
比较 DP 与 DDP 性能
编写一个基准测试脚本,比较 DataParallel(DP)和 DistributedDataParallel(DDP)的性能。
要求:
-
创建一个 ResNet-50 模型用于基准测试
-
为 DP 和 DDP 实现训练循环
-
测量:
-
吞吐量(样本数/秒)
-
每个设备的 GPU 内存使用量
-
GPU 利用率百分比
-
-
使用 2、4 和 8 个 GPU(如果可用)运行
-
绘制扩展效率:
efficiency = (N_gpu * throughput_N) / (1 * throughput_1)
测试你的实现:
import torch
import torch.nn as nn
from torchvision.models import resnet50
import time
def benchmark_dp(model, batch_size, num_iterations):
"""对 DataParallel 进行基准测试。"""
model = nn.DataParallel(model)
# ... 训练循环
return throughput
def benchmark_ddp(model, batch_size, num_iterations, rank, world_size):
"""对 DistributedDataParallel 进行基准测试。"""
model = DDP(model, device_ids=[rank])
# ... 训练循环
return throughput
# 比较结果
dp_throughput = benchmark_dp(resnet50().cuda(), batch_size=64, num_iterations=100)
ddp_throughput = benchmark_ddp(resnet50().cuda(), batch_size=64, num_iterations=100, rank, world_size)
print(f"DP throughput: {dp_throughput:.1f} samples/sec")
print(f"DDP throughput: {ddp_throughput:.1f} samples/sec")
print(f"DDP speedup: {ddp_throughput/dp_throughput:.2f}x")
实现梯度分桶
实现 DDP 梯度分桶的简化版本,以理解通信优化。
要求:
类签名:
class GradientBucketer:
def __init__(self, model: nn.Module, bucket_size_mb: float = 25.0):
"""使用模型和桶大小初始化分桶器。"""
pass
def create_buckets(self) -> list[list[nn.Parameter]]:
"""根据大小将参数分组到桶中。"""
pass
def sync_bucket(self, bucket: list[nn.Parameter]) -> None:
"""同步单个桶的梯度。"""
pass
def sync_all(self) -> None:
"""按逆序同步所有桶。"""
pass
-
将参数分组到约
bucket_size_mb兆字节的桶中 -
将每个桶内的梯度扁平化,以实现高效的 AllReduce
-
按逆序同步桶(先最后一层,与 DDP 一致)
-
测量并报告每个桶的通信时间
测试你的实现:
from torchvision.models import resnet50
model = resnet50().cuda()
bucketer = GradientBucketer(model, bucket_size_mb=25.0)
# 展示桶结构
buckets = bucketer.create_buckets()
for i, bucket in enumerate(buckets):
size_mb = sum(p.numel() * 4 / 1e6 for p in bucket)
print(f"Bucket {i}: {len(bucket)} params, {size_mb:.1f} MB")
# 前向/反向
x = torch.randn(32, 3, 224, 224).cuda()
loss = model(x).sum()
loss.backward()
# 同步并计时
import time
start = time.time()
bucketer.sync_all()
print(f"Total sync time: {(time.time() - start) * 1000:.1f} ms")
实现容错 DDP 训练
创建一个带检查点和自动故障恢复的训练脚本。
要求:
-
定期保存检查点(每 N 步)
-
检查点应包含:
-
模型状态字典
-
优化器状态字典
-
学习率调度器状态
-
当前 epoch 和 step
-
随机状态(torch、numpy、python)
-
-
实现从最新检查点自动恢复
-
处理 rank 0 保存并使用 barrier 同步
-
通过终止并重启训练来测试恢复
测试你的实现:
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
class FaultTolerantTrainer:
def __init__(self, model, optimizer, checkpoint_dir, save_every=100):
self.model = model
self.optimizer = optimizer
self.checkpoint_dir = checkpoint_dir
self.save_every = save_every
self.step = 0
self.epoch = 0
def save_checkpoint(self):
"""保存检查点(仅 rank 0)。"""
pass
def load_checkpoint(self) -> bool:
"""如果存在则加载最新检查点。若加载成功返回 True。"""
pass
def train_step(self, batch):
"""单步训练,含周期性检查点保存。"""
pass
# 使用示例
trainer = FaultTolerantTrainer(model, optimizer, "./checkpoints")
if trainer.load_checkpoint():
print(f"Resumed from step {trainer.step}")
for epoch in range(trainer.epoch, num_epochs):
for batch in dataloader:
trainer.train_step(batch)
使用 NCCL 分析通信模式
编写一个脚本,分析 DDP 训练期间的 NCCL 通信模式。
要求
-
使用
torch.cuda.Event测量通信时间 -
对不同的集合通信操作进行性能剖析:
-
AllReduce(梯度同步)
-
Broadcast(参数同步)
-
AllGather(如使用)
-
-
测量带宽利用率:
bandwidth = data_size / time -
与理论峰值带宽进行比较
-
生成一份展示通信瓶颈的报告
测试你的实现
import torch
import torch.distributed as dist
def profile_allreduce(tensor_size_mb: float, num_iterations: int = 10):
"""Profile AllReduce performance."""
tensor = torch.randn(int(tensor_size_mb * 1e6 / 4)).cuda()
# Warmup
for _ in range(3):
dist.all_reduce(tensor)
torch.cuda.synchronize()
# Profile
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(num_iterations):
dist.all_reduce(tensor)
end.record()
torch.cuda.synchronize()
time_ms = start.elapsed_time(end) / num_iterations
bandwidth_gbps = (tensor_size_mb * 2 / 1000) / (time_ms / 1000) # Ring AllReduce: 2x data
return time_ms, bandwidth_gbps
# Test different sizes
for size_mb in [1, 10, 100, 500]:
time_ms, bw = profile_allreduce(size_mb)
print(f"Size: {size_mb:4d} MB, Time: {time_ms:6.2f} ms, Bandwidth: {bw:.1f} GB/s")
预期学习成果
完成这些练习后,你应该能够:
-
从底层理解梯度同步的工作原理
-
比较和对比 DP 与 DDP 的性能特征
-
实现梯度分桶以优化通信
-
构建容错的分布式训练系统
-
对 NCCL 通信模式进行性能剖析和分析
-
识别并解决 DDP 训练中的通信瓶颈
获取本书 PDF 版本及更多内容
扫描二维码(或访问 packtpub.com/unlock)。按书名搜索本书,确认版本,然后按照页面上的步骤操作。
注意:请随手备好你的发票。直接从 Packt 购买则无需发票。
4 使用完全分片数据并行(FSDP)进行扩展
通过参数分片训练超出单块 GPU 内存容量的模型
数据中心是新的计算单元。
NVIDIA CEO 黄仁勋
从 DDP 到 FSDP
完全分片数据并行(FSDP)是一种训练策略,它将模型参数、梯度和优化器状态在多个设备之间进行分片,使每个设备只持有完整模型的一小部分。在第 3 章中,我们使用了 DDP,它会在每块 GPU 上复制整个模型——当模型能够放入单块 GPU 内存时,这种方法很有效。当模型(加上梯度和优化器状态)超过该内存时,DDP 就不再可行。FSDP 通过将模型及其训练状态分布到各 GPU 上来解决这一问题,因此你可以训练超过任意单个设备内存容量的模型。
PyTorch 为 GPU 训练提供了两个主要的 FSDP API,另外还有一个用于 TPU 的独立实现:
-
FSDP1(
FullyShardedDataParallel):使用扁平参数方法的原始包装器类。 -
FSDP2(
fully_shard()):较新的逐参数分片设计,通过fully_shard()访问。更简单、更灵活,也是 PyTorch 正在发展的方向。 -
通过 SPMD 的 FSDP(
SpmdFullyShardedDataParallel):用于 XLA/TPU 设备,使用 GSPMD 进行自动并行化。
在本章中,当我们讨论适用于两种 API 的通用技术——参数分片、all-gather、reduce-scatter——时,会使用不带数字的 FSDP。当需要区分时,我们会明确写出 FSDP1 或 FSDP2。
本章重点介绍用于 GPU 训练的 FSDP2——对于 CUDA 设备上的新项目,这是推荐的方法。FSDP1 仍然有效,并继续在生产代码库中使用;例如,Wan2.2 将 PyTorch FSDP 与 DeepSpeed Ulysses 结合用于多 GPU 推理。[1] 下面我们先简要总结 FSDP1,然后重点介绍 FSDP2。对于 TPU 训练,请参阅通过 SPMD 的 FSDP(用于 TPU/XLA)。
为什么 FSDP 能够训练超出内存容量的模型
基本思路很简单:不是在每块 GPU 上都保留完整模型,而是将模型拆分。每块 GPU 只持有参数的一个分片。在前向传播中,你 all-gather 所需的参数。在反向传播中,你在本地分片上计算梯度,然后通过 reduce-scatter 在 GPU 间进行聚合。
但让我们深入探讨一下为什么这一点很重要。使用 DDP 训练大型模型时,每块 GPU 需要存储:
-
模型参数:即权重本身。对于一个 7B 参数的 BF16 模型,就是 7B × 2 字节 = 14 GB。
-
梯度:与参数大小相同。BF16 下还需要 14 GB。
-
优化器状态:对于 Adam,动量和方差在 FP32 下是参数大小的 2 倍。即 7B × 4 字节 × 2 = 56 GB。
-
激活值:取决于批大小和序列长度,但对于大型模型来说很容易达到数十 GB。
因此,对于一个使用 Adam 的 7B 模型,仅参数、梯度和优化器状态每块 GPU 就需要 14 + 14 + 56 = 84 GB——这已经超过 80 GB H100 的容量,而且还没有算上激活值。混合精度 Adam 还会保留一份 FP32 的权重主副本(对于 7B 模型约多出 28 GB),所以 DDP 实际的内存占用更接近 112 GB。使用 FSDP 时,这些组件会被分片到各 GPU 上:每台设备持有每个组件的 1/N(N = GPU 数量)。使用 8 块 GPU 时,上述三项每块 GPU 为 84 / 8 = 10.5 GB,从而为激活值留出空间。在实践中,这正是同一个 7B 模型在 8 块 GPU 上可以用 FSDP 放下、而在单块 80 GB GPU 上用 DDP 却放不下的区别。
图 4.1:7B 模型下每块 GPU 的内存:DDP 与 FSDP 对比
图 4.1 展示了这一对比:使用 DDP 时,每块 GPU 保存完整的 84 GB(参数、梯度和优化器状态),超出了 80 GB 设备的容量;使用 FSDP 时,每块 GPU 的内存占用降至 84/N,在 8 块 GPU 时每块 GPU 只有 10.5 GB,为激活值留下了空间。
FSDP 的工作原理
FSDP 背后的核心思想来自微软研究院 2019 年发表的 ZeRO(零冗余优化器)论文。[2] ZeRO 发现,在数据并行训练中,每块 GPU 都持有模型、梯度和优化器状态的完整副本,其中大部分是冗余的。通过将这些内容在 GPU 间进行分区,并只在需要时进行聚合,你可以在不改变底层数据并行算法的情况下训练更大规模的模型。我们将在第 5 章详细介绍 ZeRO;这里重点讨论 PyTorch 对这些思想的原生实现。
PyTorch 的 FSDP 使用两种集合通信操作来实现这一思想。在前向传播中,当某一层需要其参数时,FSDP 会从所有 GPU 上 all-gather 这些参数——临时重建完整的参数张量(参见第 1 章)。该层计算完成后,被聚合的参数随即被释放。在反向传播中,梯度会在完整的(临时聚合的)参数上本地计算,然后在 GPU 间进行 reduce-scatter,使每块 GPU 最终得到聚合梯度中属于自己的分片(参见第 1 章)。
图 4.2:FSDP:前向中的 All-Gather,反向中的 Reduce-Scatter
图 4.2 展示了这两个步骤。在左图(前向)中,每个 rank 持有一个参数分片(1/N);经过 All-Gather 后,每个 rank 都临时拥有完整参数。在右图(反向)中,每个 rank 拥有完整梯度;经过 Reduce-Scatter 后,每个 rank 只保留归约后梯度的分片(1/N)。
关键的洞察在于,你不需要同时持有所有参数。神经网络是按层顺序处理的——先经过第 1 层,再经过第 2 层,依此类推。FSDP 利用这一点:为当前层 all-gather 参数,使用完毕后在进入下一层之前将其释放。这就是为什么激活检查点与 FSDP 配合得很好:它能减少激活内存,为临时 all-gather 出的参数腾出空间。
注意:激活值不会被分片
FSDP 只对参数、梯度和优化器状态进行分片——不分片激活值。在前向和反向过程中,每块 GPU 仍然会为其负责的那部分 batch 存储激活值,因此激活内存仍然是每块 GPU 各自的成本。诸如激活检查点之类的技术(在反向传播中重新计算激活值,而不是存储它们)通常与 FSDP 一起使用,以便为临时 all-gather 出的参数释放空间。
FSDP1 与 FSDP2:演进
PyTorch 最初的 FSDP(2021 年发布,通常称为 FSDP1)采用了扁平参数设计,该设计借自 FairScale 的实现。它将包装模块中的所有参数展平为一个连续的 FlatParameter 张量,然后在 GPU 间对该张量进行分片。这种方法有效,但存在局限:同一组中的所有参数必须共享相同的 dtype,冻结参数需要单独分组,而且展平操作使编译器更难优化通信模式。
FSDP2(于2024年通过 PyTorch RFC #114299[3] 引入)采用了不同的方法:使用带有 Shard(0) 的 DTensor 进行逐参数分片。它不是将参数展平,而是将每个参数张量单独在维度 0 上进行分片。形状为 (4096, 1024) 的线性层权重在 4 个 GPU 上会变成四个形状为 (1024, 1024) 的分片——每个 rank 持有四分之一的行。没有展平,也没有 FlatParameter 类。当维度 0 不能被世界大小整除时,FSDP2 会对张量进行填充;非常小的参数可能会被复制而不是分片。
图 4.3:FSDP1 与 FSDP2 的参数布局
图 4.3 展示了这种差异。FSDP1(左)将参数 W1、W2、W3 拼接成一个单一的扁平张量,然后在各 rank 之间分片。FSDP2(右)则独立地在维度 0 上对每个参数进行分片——每个 rank 持有每个参数的一个切片。
逐参数设计更简单(约 3k 行代码,而 FSDP1 约 14k 行),也更灵活。你可以混合使用不同的数据类型(某些参数用 fp8,其他用 bf16),在同一个分组中保留冻结参数和可训练参数,无需收集即可保存分片检查点,并让编译器看到单个参数以实现更好的优化。集合通信是相同的——All-Gather 和 Reduce-Scatter——但参数布局从根本上不同。
原始 FSDP(FSDP1)
原始 FSDP(通常称为 FSDP1)是 torch.distributed.fsdp 中的包装类 FullyShardedDataParallel。它将每个被包装模块的参数展平为单个 FlatParameter 对象(该类的一个实例),并在各 rank 之间进行分片;同样的 all-gather 和 reduce-scatter 思想也适用。用法与 DDP 类似:你包装模型(或通过 wrap() 包装子模块),然后像往常一样训练。
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import ShardingStrategy
model = FSDP(
model,
sharding_strategy=ShardingStrategy.FULL_SHARD, # ZeRO-3 style
device_id=torch.cuda.current_device(),
)
你可以使用 FSDP.set_state_dict_type() 以及 StateDictConfig / OptimStateDictConfig 进行检查点保存;混合精度通过 MixedPrecision 配置。
尽管 FSDP2 是新项目的推荐 API,但 FSDP1 仍然具有技术上的相关性,而不仅仅是简单的遗留惯性。以 Wan2.2 为例,这是一个仍然使用 FSDP1 的开源视频生成模型。[1]
-
首先,Wan2.2 依赖 DeepSpeed Ulysses 进行序列并行,以处理高分辨率视频帧。Ulysses 使用特定的 all-to-all 模式来分布注意力头,这些模式已经针对 FSDP1 的接口进行了加固。虽然 FSDP2 的
DeviceMesh是为多维并行设计的,但 FSDP + Ulysses 混合设置通常会发现在distributed_c10d组调用中,FSDP1 的钩子更稳定、更可预测。 -
其次,Mixture-of-Experts 架构(总计 27B / 14B 激活参数)需要对非均匀分片进行精确控制。FSDP1 通过
ModuleWrapPolicy提供的手动包装策略,使开发人员在跨时间步动态交换或卸载专家时能够进行细粒度控制——对于这种用例,通常比 FSDP2 的fully_shard注解更容易调试。 -
第三,该模型使用 UMT5-XXL 进行文本编码,这是一个巨大的冻结参数块。Hugging Face 生态系统中许多优化的 T5 包装器都是为 FSDP1 的
ShardedGradScaler和自动包装策略而构建的。将整个流水线(编码器 + DiT + VAE)迁移到 FSDP2 需要重写 T5 集成,以避免混合版本导致的分布式错误。
当你遇到或扩展此类项目时,理解 FSDP1 的包装类风格和扁平参数行为就变得至关重要。
FSDP2:逐参数分片 API
对于新项目,FSDP2 提供了更简洁的设计。FSDP2 不是将模块包装在一个类中,而是使用 fully_shard() 作为就地修改模块的函数——更具功能性和组合性。这与原始 FSDP 有很大不同,原始 FSDP 使用的是类似于 DDP 的包装类。
以下是该 API 的样子。一个完整的可运行示例位于 code/train_fsdp2.py:
torchrun --nproc_per_node=2 code/train_fsdp2.py
核心模式:
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy
from torch.distributed.device_mesh import init_device_mesh
# Initialize device mesh
mesh = init_device_mesh("cuda", (world_size,))
# Apply FSDP to your model
fully_shard(
model,
mesh=mesh,
mp_policy=MixedPrecisionPolicy(param_dtype=torch.bfloat16),
)
与原始 FSDP 的关键区别在于,fully_shard() 会就地修改模型。它不会返回包装后的模型——你的模型直接变成 FSDP 模型。这使得它更容易与其他变换组合,并且与 torch.compile 配合得更好。
关于具有分层分片(每个 block 分别包装)的最小 transformer 示例,请参阅 code/fsdp2_basic.py:
torchrun --nproc_per_node=2 code/fsdp2_basic.py
设备网格:基础
DeviceMesh 是 PyTorch 中的一个新抽象,表示设备的逻辑排列。对于 FSDP,你通常使用一维网格(所有 GPU 在单一维度上):
from torch.distributed.device_mesh import init_device_mesh
# 1D mesh for standard FSDP (4 GPUs)
mesh = init_device_mesh("cuda", (4,))
这会创建一个所有 GPU 沿单一维度排列的网格。对于 4 个 GPU,就是 [0, 1, 2, 3]。
对于非常大的集群,跨所有 GPU 的完全分片可能会产生过多的跨节点通信。混合分片数据并行(HSDP)通过仅在每个节点内分片参数并在节点间复制来解决这个问题——用一些内存换取减少的节点间流量。我们将在 混合分片 中详细介绍 HSDP;现在,先看看如何设置二维网格:
# 2D mesh for hybrid sharding
# 2 nodes × 4 GPUs per node = 8 GPUs total
mesh = init_device_mesh("cuda", (2, 4))
这会将 GPU 排列在二维网格中,适用于需要在节点内分片但在节点间复制的超大规模训练。
图 4.4:设备网格:1D(FSDP)与 2D(HSDP)
图 4.4 展示了这两种网格配置。在一维网格(左图)中,标记为 R0–R3 的四个 GPU 形成单个分片组——每个 GPU 持有每个参数的不同分片。在二维网格(右图)中,N0 和 N1 代表两个物理节点(例如,通过 InfiniBand 连接的两台服务器)。在每个节点内,GPU 沿维度 1(绿色箭头)分片,因此 N0 中的 R0–R3 各自持有不同的参数分片。跨节点时,位于相同位置的 GPU 沿维度 0(红色箭头)共享相同的分片——N0 中的 R0 和 N1 中的 R4 持有相同的数据。这种混合方法将大量的 all-gather 流量保持在快速的节点内互联(NVLink)中,而只通过较慢的节点间网络交换梯度。
关键参数
fully_shard() 函数接受多个控制分片行为的参数。mesh 参数指定用于分片的 DeviceMesh——标准 FSDP 通常使用一维网格,混合分片(HSDP)使用二维网格。
最重要的参数是 reshard_after_forward,它控制内存与通信之间的权衡。当设置为 True(默认值)时,参数会在每一层前向传播后立即重新分片,从而释放内存,但在反向传播时需要额外的 all-gather。这对应于 ZeRO-3 的行为。将其设置为 False 会在前向传播后保留参数在内存中,这会使用更多内存,但消除了反向传播中的 all-gather——类似于 ZeRO-2。你还可以传递一个整数来重新分片到中间大小;例如,reshard_after_forward=2 仅跨 2 个 GPU 而不是所有 GPU 分片,模拟 ZeRO++ 的混合参数零(hpZ)。
对于大多数内存受限的场景,默认值 True 是正确的选择。如果你有内存余量且通信是瓶颈,可以尝试 False。
图 4.5:reshard_after_forward:True 与 False
图 4.5 比较了这两种模式。每一行展示了一个两层模型的前向(Fwd)和后向(Bwd)传递。彩色块分别表示:AG(All-Gather,紫色)用于收集分片参数,L1/L2(Compute,黄色)用于层计算,RS(Reduce-Scatter,红色)用于分发梯度。当 reshard_after_forward=True(上方)时,参数在每层前向传递后即被释放(标记为“free”),并在后向传递中需要再次进行全收集(all-gather)——这样可保持较低内存占用,但会使全收集通信量翻倍。当 False(下方)时,参数在前向传递后仍保留在内存中(标记为“keep”),因此后向传递完全跳过全收集——峰值内存更高,但通信量更少。
混合精度通过 mp_policy 配置。你可以指定 param_dtype 用于参数存储(例如 torch.bfloat16),指定 reduce_dtype 用于梯度归约(通常为 torch.float32 以保证数值稳定性),还可以选择指定 output_dtype 用于层输出:
mp_policy = MixedPrecisionPolicy(
param_dtype=torch.bfloat16,
reduce_dtype=torch.float32,
)
由于 FSDP2 按参数进行分片,而不是展平为单个缓冲区,因此你可以自由混用不同的数据类型——某些层使用 fp8,另一些层使用 bf16。原始的 FSDP 要求同一组中的所有参数共享相同的数据类型。
最后,当 GPU 内存耗尽时,offload_policy 可启用 CPU 卸载:
from torch.distributed.fsdp import CPUOffloadPolicy
fully_shard(
model,
mesh=mesh,
offload_policy=CPUOffloadPolicy(pin_memory=True),
)
卸载会带来性能开销,因此应将其视为穷尽其他内存优化手段后的最后手段。性能下降幅度因 PCIe 代数、CPU 内存带宽和优化器状态大小而异——对于 CPU 卸载,经验上的粗略范围是 20%–50%。
分层分片
除了这些每次调用时的参数外,FSDP2 还允许你控制在模型层次结构中放置分片边界的位置。你可以在不同层级应用 fully_shard()——例如,包装各个 transformer 块的同时保持 embedding 层不分片:
# Shard each transformer layer individually
for layer in model.transformer.layers:
fully_shard(layer, mesh=mesh)
# Don't shard the embedding layer (it's small)
# fully_shard(model.embedding, mesh=mesh) # Skip this
这让你可以细粒度地控制哪些部分会被分片。较小的层(如 embedding 层)可能不会从分片中受益,反而会增加通信开销,因此你可以让它们保持不分片。
图 4.6 对比了这两种方法。两个面板都展示了一个 transformer 模型,包含 embedding 层(Embed)、四个 transformer 块(Block 0–3)和一个输出头(Head)。红色虚线框表示 FSDP 单元边界。使用分层分片(左)时,通过在循环中调用 fully_shard(block) 将每个 transformer 块包装为独立的 FSDP 单元,而 embedding 和输出头保持不分片。这意味着 all-gather 和 reduce-scatter 在块边界处发生,从而支持预取(当前块计算时即可收集下一个块的参数)和细粒度的内存管理(同一时刻只需要一个块的完整参数驻留内存)。使用扁平分片(右)时,单个 fully_shard(model) 调用将整个模型包装为一个 FSDP 单元。这更简单,但需要一次性收集所有参数,导致更高的峰值内存。
图 4.6:分层分片与扁平分片
完整可运行示例:使用 FSDP 进行 T5 摘要生成
code/FSDP/ 目录下有一个完整可运行的示例,它使用 FSDP1 和 FSDP2 训练 T5(FLAN-T5)[4] 进行文本摘要,包含检查点、混合精度和示例训练日志。
该示例提供三个入口点,以便你在同一任务和模型上比较单 GPU 训练、FSDP1 和 FSDP2:
-
FSDP1:
T5_training_FSDP1.py使用FullyShardedDataParallel、ShardingStrategy.FULL_SHARD、混合精度以及 FSDP1 风格的状态字典处理来包装 FLAN-T5。当你处理仍依赖原始 FSDP API 的代码库时,请使用此脚本。 -
FSDP2:
T5_training_FSDP2.py使用fully_shard()和分布式检查点(DCP)API;这是新项目的推荐脚本。 -
单 GPU 基线:
T5_training_Single.py在单个 GPU 上训练相同模型(不使用 FSDP),可用于检查正确性以及比较内存和吞吐量。
所有脚本都位于 code/FSDP/ 中。在运行训练脚本之前,请在 code/FSDP/ 目录下运行以下命令下载 WikiHow 数据集:
bash download_dataset.sh
从而将 CSV 文件下载到 data/ 中。该目录下的 README 介绍了模型选择(flan-t5-small 到 flan-t5-xxl)、显存要求和命令行选项。
运行示例
单 GPU 基线:
python code/FSDP/T5_training_Single.py
FSDP1(2 个 GPU):
torchrun --nnodes 1 --nproc_per_node 2 code/FSDP/T5_training_FSDP1.py
FSDP2(2 个 GPU):
torchrun --nnodes 1 --nproc_per_node 2 code/FSDP/T5_training_FSDP2.py
较大的模型(例如 --model-name google/flan-t5-xl 或 google/flan-t5-xxl)可能需要较小的批量大小。
对比
以下数据来自在 H200 GPU 上的示例运行。单 GPU 训练将完整模型保留在一台设备上;当模型能够放入内存时,它在每个 GPU 上可以获得最高的迭代吞吐量(it/s),但在使用 2 个 GPU 时,由于批量数据被分发,每个 epoch 完成的墙钟时间更短(例如 XL:49 秒 vs 91 秒)。单 GPU 训练无法扩展到超出单个 GPU 内存的模型,例如 FLAN-T5-XXL。FSDP1 和 FSDP2 将参数、梯度和优化器状态分片到多个 GPU 上,因此每个 GPU 的内存占用下降,可以训练更大的模型。
对于较小的 FLAN-T5-XL(3B)模型,单 GPU 训练可以放入单块 H200;使用 2 个 GPU 时,FSDP1 将每个 GPU 的内存占用大致减半,并且每个 epoch 的墙钟时间更短(49 秒 vs 91 秒)。这种加速主要来自将批量数据分散到两个 GPU 上——每个 GPU 的迭代速率(it/s)几乎不变——而不是因为 FSDP 让每一步更快。表 4.1 给出了具体数字。
| 模式 | GPU 数 | 内存/GPU | 峰值内存/GPU | 吞吐量 | 时间/epoch |
| --- | --- | --- | --- | --- | --- |
| 单 GPU | 1 | ~43 GB | ~58 GB | ~4.36 it/s | ~91 s |
| FSDP1 | 2 | ~22 GB | ~33 GB | ~4.09 it/s | ~49 s |
表 4.1:FLAN-T5-XL(3B):单 GPU vs FSDP1(2 个 GPU)。示例运行于 H200 GPU
对于 FLAN-T5-XXL(11B 参数),即使在 H200(140 GB)上,单 GPU 训练也会内存不足(OOM)。表 4.2 比较了在 2 个 GPU 上的 FSDP1 和 FSDP2。
| 模式 | GPU 数 | 内存/GPU | 峰值内存/GPU | 吞吐量 | 时间/epoch |
| --- | --- | --- | --- | --- | --- |
| 单 GPU | 1 | OOM | OOM | — | — |
| FSDP1 | 2 | ~84 GB | ~105 GB | ~1.96 it/s | ~101 s |
| FSDP2 | 2 | ~84 GB | ~105 GB | ~1.86 it/s | ~106 s |
表 4.2:FLAN-T5-XXL(11B)上单 GPU、FSDP1 和 FSDP2 的对比。示例运行于 H200 GPU
该表显示,在此设置下 FSDP1 和 FSDP2 的内存和吞吐量几乎相同。这里选择 FSDP2 并非因为原始速度,而是因为它具有更简单的 API、与 torch.compile 更紧密的集成,以及通过 DCP 实现的分片检查点,这些在任务和代码库不断增长时更为重要。
代码分析
以下代码片段展示了 T5 示例如何实现加载、分层分片和混合精度。
FSDP1:策略与包装器。 在 FSDP1 中,策略是传入包装器的配置对象:混合精度策略指定参数和梯度的数据类型(例如 bfloat16),以节省内存并加速计算;包装策略是一个可调用对象,它告诉 FSDP 应将哪些子模块包装为独立的 FSDP 单元(此处为每个 T5Block),这样分片是分层的,all-gather/reduce-scatter 发生在块边界处。脚本从 get_policies 获取这两者,然后用 FSDP 包装器类包装已加载的模型。下面的代码片段来自 T5_training_FSDP1.py 和 policies/wrapping.py。
# get_policies (T5_training_FSDP1.py): mixed precision + wrap policy
def get_policies(cfg, rank):
mixed_precision_policy = None
if cfg.mixed_precision:
# e.g. policies.bfSixteen for bfloat16
mixed_precision_policy = policies.bfSixteen
wrapping_policy = policies.get_t5_wrapper() # targets T5Block
return mixed_precision_policy, wrapping_policy
# Wrap policy (policies/wrapping.py): wrap each T5Block as an FSDP unit
def get_t5_wrapper():
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
return functools.partial(
transformer_auto_wrap_policy,
transformer_layer_cls={T5Block},
)
# Apply FSDP (T5_training_FSDP1.py): model already from setup_model()
mixed_precision_policy, t5_auto_wrap_policy = get_policies(train_config, rank)
model = FSDP(model,
auto_wrap_policy=t5_auto_wrap_policy,
mixed_precision=mixed_precision_policy,
sharding_strategy=fsdp_config.sharding_strategy,
device_id=torch.cuda.current_device(),
limit_all_gathers=fsdp_config.limit_all_gathers)
FSDP2:混合精度策略。 FSDP2 使用 MixedPrecisionPolicy(不是 FSDP1 的 MixedPrecision 对象)。脚本调用 get_policies(train_config, rank) 从配置构建策略;随后通过 fsdp_kwargs["mp_policy"] 将该策略传入每次 fully_shard(...) 调用。下面的代码片段来自 T5_training_FSDP2.py。
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy
def get_policies(cfg, rank):
"""Establish mixed precision policy for FSDP2 (no wrap policy; sharding is explicit)."""
mp_policy = None
if cfg.mixed_precision:
bfloat_available = bfloat_support()
if bfloat_available and not cfg.use_fp16:
mp_policy = MixedPrecisionPolicy(
param_dtype=torch.bfloat16,
reduce_dtype=torch.bfloat16,
)
if rank == 0:
print("bFloat16 enabled for mixed precision - using MixedPrecisionPolicy")
elif cfg.use_fp16:
mp_policy = MixedPrecisionPolicy(
param_dtype=torch.float16,
reduce_dtype=torch.float16,
)
return mp_policy
# In fsdp_main: get policy once, then pass into every fully_shard call
mp_policy = get_policies(train_config, rank)
fsdp_kwargs = {}
if mp_policy is not None:
fsdp_kwargs["mp_policy"] = mp_policy
# ... later: fully_shard(block, **fsdp_kwargs) and fully_shard(model, **fsdp_kwargs)
FSDP2:加载模型,然后对每个块和根进行分片。 模型通过 from_pretrained 加载并移到设备上。没有自动包装策略:脚本显式遍历 model.encoder.block 和 model.decoder.block(每个元素都是 T5Block),并调用 fully_shard(block, **fsdp_kwargs),使每个块成为独立的分片单元,然后调用 fully_shard(model, **fsdp_kwargs) 包裹根。顺序很重要——子模块先于根分片。
请注意,此示例省略了 mesh 参数。在单进程组(常见情况)下运行时,fully_shard() 会从全局大小推断默认 mesh。对于多维并行(例如 HSDP),您需要像前面章节展示的那样显式传递 mesh。下面的代码片段来自 T5_training_FSDP2.py。
from torch.distributed.fsdp import fully_shard
model = T5ForConditionalGeneration.from_pretrained(model_name)
model = model.to(device)
# fsdp_kwargs already holds mp_policy from get_policies()
# Shard encoder blocks (each T5Block becomes one FSDP unit)
if hasattr(model, 'encoder') and hasattr(model.encoder, 'block'):
for block in model.encoder.block:
fully_shard(block, **fsdp_kwargs)
# Shard decoder blocks
if hasattr(model, 'decoder') and hasattr(model.decoder, 'block'):
for block in model.decoder.block:
fully_shard(block, **fsdp_kwargs)
# Shard the entire model (root); children must already be sharded
fully_shard(model, **fsdp_kwargs)
对于即使在加载时也无法放入内存的模型,请参阅 初始化最佳实践(meta device、to_empty 和 reset_parameters)。
使用 FSDP2 检查点
模型完成分片并开始训练后,您需要保存检查点。长时间运行可能会失败——硬件错误、抢占、bug——丢失数天的进度令人痛苦。使用 FSDP2,检查点比以前更简单:由于分片状态字典与训练表示一致,每个 rank 只需直接保存自己的分片。无需收集到 rank 0,也无需在加载时重新分片。保存和加载在本地完成,速度更快,内存占用更少。
有两种方法:使用分布式检查点(DCP)API,或手动处理分片状态字典。
使用 DCP API(推荐)
DCP API 是保存和加载 FSDP2 检查点的推荐方式。它处理了分片状态字典的所有复杂性。完整可运行的示例位于 code/fsdp2_checkpoint_dcp.py:
torchrun --nproc_per_node=2 code/fsdp2_checkpoint_dcp.py
关键函数是 save_checkpoint_dcp 和 load_checkpoint_dcp:
from torch.distributed.checkpoint.state_dict import (
get_model_state_dict, get_optimizer_state_dict,
set_model_state_dict, set_optimizer_state_dict, StateDictOptions,
)
import torch.distributed.checkpoint as dcp
def save_checkpoint_dcp(model, optimizer, epoch, checkpoint_dir):
"""Save checkpoint using DCP API."""
model_state_dict = get_model_state_dict(
model=model,
options=StateDictOptions(full_state_dict=False, cpu_offload=True),
)
optim_state_dict = get_optimizer_state_dict(
model=model, optimizers=optimizer,
options=StateDictOptions(full_state_dict=False, cpu_offload=True),
)
checkpoint_path = os.path.join(checkpoint_dir, f"epoch_{epoch}")
dcp.save({"model": model_state_dict, "optimizer": optim_state_dict, "epoch": epoch},
checkpoint_id=checkpoint_path)
def load_checkpoint_dcp(model, optimizer, checkpoint_dir, epoch):
"""Load checkpoint using DCP API."""
checkpoint_path = os.path.join(checkpoint_dir, f"epoch_{epoch}")
opt = get_optimizer_state_dict(model, optimizers=optimizer,
options=StateDictOptions(full_state_dict=False))
state_dict = {"model": get_model_state_dict(model,
options=StateDictOptions(full_state_dict=False)),
"optimizer": opt,
"epoch": 0}
dcp.load(state_dict, checkpoint_id=checkpoint_path)
set_model_state_dict(model, state_dict["model"],
options=StateDictOptions(full_state_dict=False))
set_optimizer_state_dict(model, optimizers=optimizer,
optim_state_dict=state_dict["optimizer"],
options=StateDictOptions(full_state_dict=False))
return state_dict["epoch"]
保存路径很简单:每个 rank 使用 dcp.save 写入自己的分片。加载则不那么直观——dcp.load 不会返回新的状态字典。相反,您需要在此 rank 上对已经初始化并分片的模型和优化器调用 get_model_state_dict 和 get_optimizer_state_dict 来构建一个模板。将该字典传给 dcp.load;DCP 会从检查点分片中就地填充每个条目。模板告诉 DCP 应当期望哪些键、形状和分片布局;跳过它——即在模型于每个 rank 上构建和分片之前就进行加载——是形状不匹配或未初始化参数错误的常见来源。最后的 set_model_state_dict 和 set_optimizer_state_dict 调用完成了 PyTorch 文档中记载的加载路径;它们对优化器状态尤其必要,即使模型参数已经通过模板引用就地更新。无需收集到 rank 0,也无需广播——每个 rank 只读取自己的分片。
手动分片检查点
如果您需要更多控制,可以手动处理分片状态字典。方法如下:
def save_checkpoint_manual(model, optimizer, epoch, checkpoint_dir):
"""Manually save sharded checkpoint."""
rank = torch.distributed.get_rank()
# Get sharded state dict
model_sd = model.state_dict() # Already sharded
使用完全分片数据并行(FSDP)进行扩展
# Get optimizer state dict (also sharded)
optim_sd = optimizer.state_dict()
checkpoint_path = os.path.join(checkpoint_dir, f"epoch_{epoch}")
os.makedirs(checkpoint_path, exist_ok=True)
# Each rank saves its shard
model_path = os.path.join(checkpoint_path, f"model_rank_{rank}.pt")
optim_path = os.path.join(checkpoint_path, f"optim_rank_{rank}.pt")
torch.save(model_sd, model_path)
torch.save(optim_sd, optim_path)
# Save metadata on rank 0
if rank == 0:
metadata = {"epoch": epoch, "world_size": torch.distributed.get_world_size()}
torch.save(metadata, os.path.join(checkpoint_path, "metadata.pt"))
print(f"Checkpoint saved to {checkpoint_path}")
def load_checkpoint_manual(model, optimizer, checkpoint_dir, epoch):
"""Manually load sharded checkpoint."""
rank = torch.distributed.get_rank()
checkpoint_path = os.path.join(checkpoint_dir, f"epoch_{epoch}")
# Each rank loads its shard
model_path = os.path.join(checkpoint_path, f"model_rank_{rank}.pt")
optim_path = os.path.join(checkpoint_path, f"optim_rank_{rank}.pt")
model_sd = torch.load(model_path, map_location="cpu")
optim_sd = torch.load(optim_path, map_location="cpu")
model.load_state_dict(model_sd)
optimizer.load_state_dict(optim_sd)
if rank == 0:
metadata = torch.load(os.path.join(checkpoint_path, "metadata.pt"))
print(f"Checkpoint loaded from {checkpoint_path}, epoch {metadata['epoch']}")
return epoch
手动方法能带来更多控制权,但需要谨慎处理分片后的状态字典。对于大多数使用场景,推荐使用 DCP API。
用于评估的完整状态字典
有时你需要一个完整(未分片)的状态字典,例如为了保存最终模型用于推理或与他人共享。你可以将所有分片收集到 rank 0:
def save_full_checkpoint(model, optimizer, epoch, checkpoint_path):
"""Save full (unsharded) checkpoint on rank 0."""
rank = torch.distributed.get_rank()
# Get full state dict (gathers all shards to rank 0)
model_state_dict = get_model_state_dict(
model=model,
options=StateDictOptions(
full_state_dict=True, # Gather all shards
cpu_offload=True,
),
)
optim_state_dict = get_optimizer_state_dict(
model=model,
optimizers=optimizer,
options=StateDictOptions(
full_state_dict=True,
cpu_offload=True,
),
)
# Only rank 0 saves
if rank == 0:
checkpoint = {
"model": model_state_dict,
"optimizer": optim_state_dict,
"epoch": epoch,
}
torch.save(checkpoint, checkpoint_path)
print(f"Full checkpoint saved to {checkpoint_path}")
torch.distributed.barrier()
这会将所有分片收集到 rank 0,虽然占用更多内存,但你会得到一个可以在任意数量 GPU 上加载的单一检查点文件。
预取:优化通信
解决检查点问题之后,下一个关注点就是性能。FSDP 会增加通信开销——每次前向传播都需要一次 all-gather 来重建参数,每次反向传播都需要一次 reduce-scatter 来处理梯度。像 NVLink 这样的高速互连可以降低这种开销,但无法彻底消除。
隐藏这种延迟的一种方法是预取:在当前层仍在计算时,就开始获取下一层的参数。关键的洞见在于,现代 GPU 可以在不同的硬件单元上并发执行计算和通信(计算使用 CUDA 流,传输使用 NVLink/PCIe)。如果计算的时间长于通信,all-gather 会在需要之前就已完成,你就不需要付出任何延迟代价。
图 4.7:预取时间线:无预取与有预取
图 4.7 展示了其中的差异。未启用预取时(上图),每一层都必须等待其 all-gather(AG)完成才能开始计算——这些操作是串行的。启用预取后(下图),当 L₀ 层正在计算时,L₁ 层的 all-gather 会在另一个流上并行进行。等到 L₀ 计算完成,L₁ 的参数已经就绪。由于通信被隐藏在计算背后,总时间得以缩短。
前向预取
在处理当前层的同时,前向预取会为后续各层预先执行 all-gather:
def set_modules_to_forward_prefetch(model, num_to_forward_prefetch):
"""Set forward prefetching for transformer layers."""
for i, layer in enumerate(model.layers):
if i >= len(model.layers) - num_to_forward_prefetch:
break
layers_to_prefetch = [
model.layers[i + j] for j in range(1, num_to_forward_prefetch + 1)
]
layer.set_modules_to_forward_prefetch(layers_to_prefetch)
这会告诉每一层为接下来的 num_to_forward_prefetch 层预取参数。当第 i 层在计算时,它已经在为第 i+1、i+2 层等执行 all-gather 了。
后向预取
后向预取与此类似,但针对的是反向传播。它会为梯度计算预取所需参数:
def set_modules_to_backward_prefetch(model, num_to_backward_prefetch):
"""Set backward prefetching for transformer layers."""
for i, layer in enumerate(model.layers):
if i < num_to_backward_prefetch:
continue
layers_to_prefetch = [
model.layers[i - j] for j in range(1, num_to_backward_prefetch + 1)
]
layer.set_modules_to_backward_prefetch(layers_to_prefetch)
这样会在为当前层计算梯度的同时,预取前面各层的参数。
何时使用预取
当你的模型有很多层(10 层以上)且每一层都有足够的计算量来隐藏通信延迟时,预取的效果最好。如果层很小,或者互连已经很快(NVLink、高带宽 InfiniBand),收益就会变小——额外的调度逻辑甚至可能带来新的开销。
实用建议:先不使用预取。对训练循环进行性能分析,如果通信成为瓶颈,再尝试加入预取,设置 num_to_forward_prefetch=2 和 num_to_backward_prefetch=2,然后根据分析器给出的结果再做调整。
激活检查点与卸载
激活检查点几乎总是与 FSDP 搭配使用。与其在前向传播时存储所有激活值,不如在反向传播时重新计算它们。这可以将激活内存减少 50-80%,在内存已经受限的情况下尤为关键。
为什么激活检查点对 FSDP 很重要
使用 FSDP 时,你已经在分片参数、梯度和优化器状态了。但激活值仍可能成为内存瓶颈,尤其是在 batch size 较大或序列较长的情况下。激活检查点用计算换内存:在反向传播时重新计算激活值,而不是存储它们。
激活内存随模型架构和 batch size 的规模而变化。对于一个 transformer,粗略估算公式为:
激活内存 ≈ L × B × S × H × 每元素字节数 × k
其中 L 是层数,B 是 batch size,S 是序列长度,H 是隐藏维度,k 是一个系数(通常为 10–20),用于计入注意力和 MLP 块中的中间张量。这不是一个精确的公式——实际内存取决于实现细节,例如注意力分数是否被具体化、MLP 的扩展比例以及框架开销。但它能给出正确的数量级。以一个 7B 模型(L=32, H=4096)为例,序列长度为 2048、batch size 为 8、使用 fp16:
32 × 8 × 2048 × 4096 × 2 × 12 ≈ 52 GB
使用激活检查点时,你只存储每个被检查点化的块的输入,而不是所有中间张量,从而将内存减少 50–80%。代价是在反向传播时重新计算激活值(前向传播大约慢 30%,但反向传播差别不大,因为你本来就要计算梯度)。
使用激活检查点
最简单的方法是对整个模型启用检查点:
from torch.utils.checkpoint import checkpoint
# After applying FSDP
fully_shard(model, mesh=mesh)
# Wrap forward pass with checkpointing
def forward_with_checkpoint(x):
return checkpoint(model, x)
但你也可以更有针对性。对于 transformer,通常是对单个层启用检查点:
class TransformerBlockWithCheckpoint(nn.Module):
def __init__(self, args: ModelArgs, use_checkpoint=False):
super().__init__()
self.use_checkpoint = use_checkpoint
self.attention_norm = nn.LayerNorm(args.dim)
self.attention = Attention(args)
self.ffn_norm = nn.LayerNorm(args.dim)
self.feed_forward = FeedForward(
args.dim, hidden_dim=4 * args.dim, dropout_p=args.dropout_p
)
def forward(self, x):
if self.use_checkpoint:
# Checkpoint the entire block
return checkpoint(self._forward_impl, x)
else:
return self._forward_impl(x)
def _forward_impl(self, x):
h = x + self.attention(self.attention_norm(x))
out = h + self.feed_forward(self.ffn_norm(h))
return out
然后为某些层启用检查点:
# Checkpoint every other layer to balance memory and speed
for i, layer in enumerate(model.layers):
layer.use_checkpoint = (i % 2 == 0)
CPU 卸载
CPU 卸载将参数、梯度和优化器状态移动到 CPU 内存中,以训练速度变慢为代价释放 GPU 内存。FSDP2 API 支持这一功能:
from torch.distributed.fsdp import CPUOffloadPolicy
fully_shard(
model,
mesh=mesh,
offload_policy=CPUOffloadPolicy(pin_memory=True),
)
分片参数会在每次 all-gather 之前被复制到 GPU;梯度计算和优化器步骤在 CPU 上运行。这会带来显著开销,但对于非常大的模型可能是必要的。实际减速取决于 PCIe 代次(3.0 对 4.0 对 5.0)、CPU 内存带宽、NUMA 拓扑以及优化器状态大小——根据经验,20-50% 是常见的,但具体情况会有所不同。如果 CPU 内存也耗尽,NVMe 卸载是 DeepSpeed ZeRO-Infinity 的一项功能(第 5 章),并非 FSDP2 原生提供的功能。
何时使用卸载
CPU 卸载应该放在优化序列的后期。如果你已经启用了全分片和激活检查点,并尽可能减小了批量大小和序列长度,但仍然遇到内存不足(OOM),那么卸载就有意义。对于大多数模型,全分片加上激活检查点就足够了,无需涉及卸载。
性能优化
一旦 FSDP 正常工作,你就会希望优化性能。主要瓶颈是通信(all-gather/reduce-scatter)和激活内存。让我们看看如何进行分析和优化。
FSDP 训练性能分析
使用 PyTorch 的分析器来了解时间花费在哪里。完整的可运行示例位于 code/fsdp2_profile.py:
torchrun --nproc_per_node=2 code/fsdp2_profile.py
关键模式是用 profile() 包裹训练循环,并使用 record_function() 标记不同阶段:
from torch.profiler import profile, record_function, ProfilerActivity
rank = dist.get_rank()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
) as prof:
for i in range(num_iterations):
with record_function("forward"):
output = model(data)
loss = criterion(output, target)
with record_function("backward"):
loss.backward()
with record_function("optimizer"):
optimizer.step()
optimizer.zero_grad()
if rank == 0:
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=30))
prof.export_chrome_trace(f"fsdp_trace_rank{rank}.json")
只有 rank 0 会打印并导出跟踪结果。由于 FSDP 在所有 rank 上同步执行相同的操作,时间模式几乎相同——通常只需要检查单个 rank 的跟踪记录即可。如果怀疑存在负载不均衡或掉队者(stragglers),可以移除 if rank == 0 守卫,从所有 rank 导出跟踪记录并进行比较。
跟踪文件 fsdp_trace_rank0.json 采用 Chrome 跟踪格式。有关如何打开和解释这些跟踪记录,请参阅第 3 章。简而言之:在 Chrome 中打开 chrome://tracing,点击 “Load”,然后选择 .json 文件。在时间线中,查找 all-gather 和 reduce-scatter 操作——理想情况下它们应与计算重叠。如果发现它们阻塞了,预取(prefetching)可能会有所帮助。还要检查峰值内存使用情况(profile_memory=True 可启用此功能),以判断激活是否超出了预期。
优化通信
如果分析器显示通信是瓶颈,你有几种选择。预取(前面介绍过)可以将通信与计算重叠。如果有内存余量,设置 reshard_after_forward=False 可以避免反向传播中的 all-gather——参数在正向传播后保持不分片,因此反向传播时无需再次获取它们:
fully_shard(model, mesh=mesh, reshard_after_forward=False)
这是用内存换速度。只有在分析后确认有余量时才使用。
硬件也很重要。节点内的 NVLink 和节点间的 InfiniBand 会带来很大差异。请检查 NCCL 是否确实在使用它们:
export NCCL_IB_DISABLE=0 && NCCL_DEBUG=INFO
调试输出将显示 NCCL 检测到了哪些互连。
优化激活内存
如果激活内存是瓶颈,最简单的修复方法是减小批量大小或序列长度——输入更小意味着需要存储的激活更少。但如果你需要较大的有效批量大小来保证收敛,梯度累积可以在不增加内存开销的情况下实现这一目标:
accumulation_steps = 4
optimizer.zero_grad()
for i, (data, target) in enumerate(dataloader):
loss = criterion(model(data), target) / accumulation_steps
loss.backward()
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
这提供了 batch_size * accumulation_steps 的有效批量大小,但内存占用仅为单个 batch_size。你还可以尝试选择性检查点——只对部分层而不是所有层启用检查点,并通过实验找到内存与重计算开销之间的合适平衡点。
内存分析
为了了解内存的去向,可以在代码中穿插一些打印语句:
def print_memory_usage(step_name):
allocated = torch.cuda.memory_allocated() / 1e9
reserved = torch.cuda.memory_reserved() / 1e9
print(f"{step_name}: allocated={allocated:.2f}GB, reserved={reserved:.2f}GB")
print_memory_usage("Before model")
print_memory_usage("After FSDP")
print_memory_usage("After forward")
print_memory_usage("After backward")
这会告诉你每个阶段使用了多少内存。如果 “After forward” 远高于 “After FSDP”,激活就是罪魁祸首。如果 “After backward” 仍然很高,问题可能出在梯度或优化器状态。若需更详细的分类信息,nvidia-smi 可以显示 GPU 总内存使用量,PyTorch 的 torch.cuda.memory_summary() 则提供完整的分配报告。
多节点 FSDP 训练
多节点 FSDP 的工作方式与多节点 DDP 相同——需要初始化进程组和正确的网络配置。主要区别在于检查点:使用 FSDP2,分片状态字典(sharded state dict)非常直接——每个 rank 写入自己的分片,无需 all-gather 即可重新加载。
设置多节点 FSDP
设置与多节点 DDP 类似。在每个节点上,你需要:
-
设置进程组初始化的环境变量
-
使用
torchrun启动训练 -
确保节点之间的网络连通性
在主节点(节点 0)上:
torchrun --nnodes=2 --nproc_per_node=8 --node_rank=0 \
--master_addr=<master_ip> --master_port=29500 code/train_fsdp2.py
在工作节点(节点 1)上:
torchrun --nnodes=2 --nproc_per_node=8 --node_rank=1 \
--master_addr=<master_ip> --master_port=29500 code/train_fsdp2.py
将 <master_ip> 替换为主节点的实际 IP 地址。你可以通过以下命令找到它:
hostname -I
多节点网络配置
对于多节点 FSDP,网络带宽和延迟至关重要。FSDP 比 DDP 进行更多通信(all-gather 和 reduce-scatter),因此快速互连更加重要。
更倾向于使用 InfiniBand 而非以太网,因为:
-
更高带宽:每条链路 200-400 Gb/s,而以太网为 10-100 Gb/s
-
更低延迟:亚微秒级 vs 微秒级
-
RDMA 支持:GPU 到 GPU 的直接内存访问
确保 NCCL 正在使用 InfiniBand:
export NCCL_IB_DISABLE=0 && NCCL_DEBUG=INFO
检查 NCCL 日志以确认它正在使用 InfiniBand。你应该会看到类似这样的消息:
NCCL INFO NET/IB: Using [device] for node [rank]
多节点上的检查点
使用FSDP2,即使在多节点上,检查点也很简单。每个rank保存自己的分片,因此你需要一个可从所有节点访问的共享存储。
选项1:共享文件系统(NFS、Lustre等)
如果所有节点挂载同一文件系统,每个rank可以直接写入:
checkpoint_dir = "/shared/checkpoints"
# Mounted on all nodes
save_checkpoint_dcp(model, optimizer, epoch, checkpoint_dir)
选项2:并行写入本地存储
每个节点先写本地,之后再同步:
# Each node writes to local storage
local_checkpoint_dir = f"/local/checkpoints/node_{node_rank}"
save_checkpoint_dcp(model, optimizer, epoch, local_checkpoint_dir)
然后在训练后同步到共享存储(或使用分布式文件系统)。
选项3:对象存储(S3等)
使用像 s3fs 这样的库直接写入S3:
import s3fs
fs = s3fs.S3FileSystem()
checkpoint_path = f"s3://bucket/checkpoints/epoch_{epoch}"
# Save using DCP with S3 backend
分片方法很有帮助,因为每个rank只写入自己的分片(文件更小,带宽更少)。
SLURM集成
大多数HPC集群使用SLURM进行作业调度。下面是一个多节点FSDP的最小示例:
#!/bin/bash
#SBATCH --job-name=fsdp_train
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=8
#SBATCH --gres=gpu:8
#SBATCH --time=24:00:00
#SBATCH --partition=gpu
# Get node list
export MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
export MASTER_PORT=29500
srun torchrun --nnodes=$SLURM_NNODES --nproc_per_node=8 --node_rank=$SLURM_NODEID \
--master_addr=$MASTER_ADDR --master_port=$MASTER_PORT train_fsdp2.py
或者直接结合SLURM使用 torchrun:
#!/bin/bash
#SBATCH --job-name=fsdp_train
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=8
#SBATCH --gres=gpu:8
export MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
export MASTER_PORT=29500
srun torchrun --nnodes=$SLURM_NNODES --nproc_per_node=8 --node_rank=$SLURM_NODEID \
--master_addr=$MASTER_ADDR --master_port=$MASTER_PORT train_fsdp2.py
我们将在第8章中详细介绍SLURM。
扩展注意事项
随着扩展到更多节点,通信开销会增长。确保你的模型足够大,使计算仍然占主导地位——否则你就是在为大多数时间都在等待网络的GPU付费。
检查点也会变得更棘手。节点很多时,你最终会有许多分片文件。能很好处理小文件的分布式文件系统或对象存储(如Lustre或S3)对此有帮助。而且硬件越多,故障也会越多——请频繁保存检查点,如果集群支持,还可以考虑弹性训练。
网络拓扑也很重要。位于同一网络段并使用InfiniBand的节点,其性能会优于通过较慢链路分散在不同机架上的节点。
调试FSDP问题
FSDP增加了复杂性,而当问题出现时,错误信息并不总是有用。以下是如何处理常见问题的方法。
内存不足(OOM)
首次设置FSDP时,OOM错误很常见。一个快速的合理性检查是确认参数是否真的被分片。在FSDP2下,它们是DTensor——param.shape 是全局形状,所以使用本地张量来查看你的分片:
for name, param in model.named_parameters():
local = param.to_local() if hasattr(param, "to_local") else param
print(f"{name}: local_shape={local.shape}, device={param.device}")
如果参数看起来正确但仍然OOM,那么激活值很可能是罪魁祸首。使用前面介绍的内存分析方法来确认,然后减小批大小、序列长度,或启用激活检查点。同时注意内存泄漏——由于忘记分离或删除张量,导致张量在迭代间累积。
挂起或死锁
当进程失去同步时,FSDP会挂起。最常见的原因是只有部分rank会执行的条件逻辑:
# BAD: only rank 0 runs this collective-triggering code
if rank == 0:
model.some_operation()
# GOOD: all ranks execute the same code
model.some_operation()
其他原因:数据不平衡(一个rank比其他rank更早用完批次)、某些rank上检查点加载失败,或NCCL问题。对于NCCL问题,用 export NCCL_DEBUG=INFO 启用调试日志,并在输出中查找错误。
训练缓慢
如果训练比预期慢,先做性能分析——猜测会浪费时间。检查通信是否与计算重叠;如果没有,尝试预取。对于多节点,确认是否正在使用InfiniBand(NCCL_DEBUG=INFO 会显示)。激活检查点会增加约30%的开销,所以请确保内存节省是值得的。如果你的硬件支持,确认混合精度确实已启用。
结果不正确
结果不正确通常来自几个方面。首先,检查所有rank是否使用相同的随机种子:
torch.manual_seed(42)
torch.cuda.manual_seed_all(42)
其次,验证 DistributedSampler 是否正确设置,并且你每个epoch都调用了 sampler.set_epoch(epoch)——忘记这一步意味着所有epoch都会看到相同的洗牌顺序。第三,通过跨rank打印梯度范数来检查梯度是否同步。如果所有方法都失败,可以在单个GPU上运行相同模型以建立基线,然后扩展并比较。
调试工具
有几个工具可以帮助进行FSDP调试。对于NCCL问题,export NCCL_DEBUG=INFO(或 NCCL_DEBUG_SUBSYS=ALL 以获得更多细节)显示通信层正在发生的事情。PyTorch分析器(之前介绍过)揭示通信模式和瓶颈。对于内存问题,torch.cuda.memory_summary() 给出详细分解。对于分布式特定的问题,torch.distributed.set_debug_level(torch.distributed.DebugLevel.DETAIL) 启用分布式运行时的详细日志输出。
比较FSDP2与ZeRO和DDP
选择DDP、ZeRO(DeepSpeed)还是FSDP2,取决于你的模型大小和生态系统偏好。
DDP 是最简单的选项。每个GPU都保存模型、梯度和优化器状态的完整副本。通信只是梯度同步(AllReduce),已经过良好优化。当你的模型能装入单个GPU时使用DDP——在现代GPU上配合混合精度,这涵盖大多数最大约7B参数的模型。
ZeRO(来自DeepSpeed)进行分阶段分片:ZeRO-1分片优化器状态,ZeRO-2增加梯度,ZeRO-3分片所有内容,包括参数。它经过生产测试,并与DeepSpeed的其他功能(如ZeRO-Offload(CPU)和ZeRO-Infinity(NVMe))集成。代价是增加DeepSpeed作为依赖并学习其API。
FSDP2 是PyTorch原生的,像ZeRO-3一样进行完全分片。按参数设计更简单(约3k行 vs FSDP1的约14k行),与 torch.compile 集成良好,并且不需要外部依赖。它比ZeRO更新,所以经过实战验证的示例较少,但它是PyTorch发展的方向。
内存与性能
具体来说:一个7B参数的模型使用Adam优化器,在DDP下每个GPU需要约84 GB(参数+梯度+优化器状态,全部复制)。在8个GPU上使用FSDP2或ZeRO-3,则降至每个GPU约10.5 GB——所有内容都按8路分片。
性能方面,当模型能放入时,DDP最快(通信较少)。对于无法放入的模型,FSDP2和ZeRO-3表现相似——两者都执行all-gather和reduce-scatter,差异归结为实现细节和网络拓扑,而不是根本设计。根据生态适配性而非性能来选择。
何时使用什么
如果你的模型适合单个GPU,使用DDP——它更简单也更快。如果不适合,先尝试优化(混合精度、激活检查点、梯度累积)。仍然OOM?切换到FSDP2;由于API相似,迁移很直接。如果你需要DeepSpeed特定功能,如ZeRO-Offload或ZeRO-Infinity,或者你已经在DeepSpeed生态系统中,则使用ZeRO。
实用技巧
从真实世界的FSDP使用中可以提炼出一些经验。
使用FSDP2保存检查点时,分片状态字典与训练表示匹配——每个rank只保存自己的分片。DCP API(前面介绍过)能高效处理这一点。除非你特别需要用于推理的完整检查点,否则避免将所有分片收集到rank 0;收集过程很慢,并可能在大模型上导致OOM。
共享参数需要一些小心。如果同一个张量出现在模型中的多个位置(例如,绑定的嵌入),这些使用必须位于同一个FSDP组中。FSDP的参数交换不会跨组保留共享性,所以请构建模型,使共享参数保持在同一个模块层次结构中,或者完全避免共享。
内存分析常常会揭示意外情况。瓶颈并不总是在你预期的地方。使用 torch.profiler 或 nvidia-smi 进行调查。常见的罪魁祸首包括激活值(通过检查点解决)、跨迭代累积的临时张量(分离或显式删除它们),以及在内存受限系统上使用 pin_memory=True 的DataLoader。
最后,请记住,reshard_after_forward 默认值为 True,这会在前向传播后重新分片以节省内存,但在反向传播中需要额外的全收集操作。如果你的内存有富余,而通信是瓶颈,可以尝试将其设置为 False,以在两次传播之间保持参数不分片。
初始化最佳实践
对于非常大的模型,先在元设备上创建,应用 FSDP,然后移动到实际设备并初始化:
with torch.device("meta"):
model = Transformer(args)
fully_shard(model, mesh=mesh)
model.to_empty(device=device)
model.reset_parameters()
这样可以避免在单个设备上实体化整个模型。确保所有 rank 使用相同的随机种子,以便参数初始化一致。
数据加载与梯度裁剪
使用 DistributedSampler,并在每个 epoch 调用 sampler.set_epoch(epoch)——忘记这一点意味着所有 epoch 看到相同的打乱顺序。梯度裁剪可与 FSDP 配合使用;像往常一样调用 torch.nn.utils.clip_grad_norm_ 即可,FSDP 会自动处理去分片/重新分片。
混合精度
对于参数而言,BF16 通常优于 FP16(更大的动态范围,更少的溢出风险)。为了数值稳定性,将梯度归约保持在 FP32(reduce_dtype=torch.float32)。先让 FSDP 在无混合精度的情况下运行,然后再添加混合精度。
渐进式优化
从简单开始:使用带混合精度的 FSDP2。如果遇到 OOM,添加激活检查点。仍然 OOM?减小批大小或序列长度。CPU 卸载是最后的手段——它确实有效,但会显著降低速度。不要过早优化;先让它跑起来。
高级主题
混合分片(HSDP)
在极大规模下,你可能希望在一个节点内分片,但在节点间复制——这减少了节点间通信,而节点间通信通常比节点内通信慢(NVLink 对比 InfiniBand)。使用二维 mesh:
mesh = init_device_mesh("cuda", (4, 8)) # 4 nodes × 8 GPUs per node
fully_shard(model, mesh=mesh) # 2D mesh: replicate dim 0, shard dim 1 (HSDP)
这样会在每个节点内的 8 个 GPU 之间分片参数,但会在 4 个节点之间复制参数。权衡之处:更多内存占用(4 倍复制)但更少的跨节点流量。
编译器集成
FSDP2 与 torch.compile 配合良好——先应用 FSDP,再进行编译:
fully_shard(model, mesh=mesh)
model = torch.compile(model)
按参数(per-parameter)的设计在这里有所帮助。编译器可以看到单个参数,而不是一个扁平化的缓冲区,因此它可以更有效地优化全收集和 reduce-scatter 模式。
其他集成
FSDP2 自然地与梯度累积和学习率调度配合——无需特殊处理。对于混合精度,使用 MixedPrecisionPolicy 而不是标准的 AMP 上下文管理器。自定义通信钩子可用于细粒度控制,但大多数用户不需要它们。
通过 SPMD 的 FSDP 用于 TPU/XLA
本章重点介绍了使用 CUDA 设备的 GPU 训练的 FSDP2。不过,PyTorch 也为 TPU/XLA 设备提供了通过 SPMD 的 FSDP,它使用基于 GSPMD(广义单程序多数据)的不同方法进行自动并行化。
与 GPU FSDP2 的主要区别:
-
使用 SPMD 模式:XLA 编译器根据分片注释自动划分计算,而不是显式的 all-gather/reduce-scatter 操作
-
基于 mesh 的分片:使用 PyTorch/XLA 的
Mesh抽象,带有命名维度(例如('fsdp', 'model')) -
编译器驱动:XLA 编译器处理通信优化,类似于 JAX 的
pmap的工作方式
完整的示例位于 code/fsdp_spmd_tpu.py。请注意,这需要 TPU 硬件——它不会在 GPU 上运行:
# On a TPU VM:
python code/fsdp_spmd_tpu.py
核心模式:
import torch_xla.runtime as xr
import torch_xla.distributed.spmd as xs
from torch_xla.experimental.spmd_fully_sharded_data_parallel import (
SpmdFullyShardedDataParallel as FSDPv2
)
xr.use_spmd() # Enable SPMD mode
# Create mesh with 'fsdp' axis
num_devices = xr.global_runtime_device_count()
mesh = xs.Mesh(np.array(range(num_devices)), (num_devices, 1), ('fsdp', 'model'))
# Shard inputs and wrap model
x = xs.mark_sharding(x, mesh, ('fsdp', None))
model = FSDPv2(model, mesh)
在 TPU 设备上训练或希望获得编译器优化的通信模式时,可通过 SPMD 使用 FSDP。XLA 编译器会自动处理通信优化,类似于 JAX 的 pmap。对于 GPU 训练,请使用本章介绍的 fully_shard() API——它能让你显式控制通信模式,且不需要 XLA。
更多细节,请参阅 PyTorch/XLA SPMD 文档。[5]
总结
FSDP2 是 PyTorch 针对无法在单个 GPU 上容纳的模型训练给出的答案。通过在多个 GPU 之间分片参数、梯度和优化器状态,它可以让你训练比单个 GPU 所能容纳的大 8 倍、16 倍甚至更大的模型。
值得退一步来理解 FSDP 改变了什么——以及它没有改变什么。FSDP 将内存上限从单 GPU 的限制转变为集群范围的限制。一个需要 160 GB 内存(参数 + 梯度 + 优化器状态)的模型可以运行在 8 块各 24 GB 的 GPU 上,因为每块 GPU 只保存总数的 1/8。这是一个根本性的转变:你不再受限于能买到的最大的 GPU,而是受限于你能连接多少块 GPU。
然而,FSDP 的核心仍然是数据并行。每个 GPU 处理不同的数据批次,模型计算本身并不会跨设备拆分——每个 GPU 执行相同的操作,只是作用于不同的分片,在需要时会进行全收集。这把 FSDP 与模型并行(张量并行、流水线并行)区分开来,后者中不同的 GPU 同时计算模型的不同部分。FSDP 扩展的是内存,而不是每个样本的计算量。要扩展计算,你仍然依赖在更多 GPU 上使用更大的批大小,就像 DDP 一样。
在选择并行策略时,这种架构上的区别很重要。仅 FSDP 就能带你走得很远——在大型集群上训练多达数千亿参数的模型。但对于最大的模型(万亿+ 参数)或当你需要降低每个样本的延迟时,你会将 FSDP 与张量或流水线并行相结合。我们将在后续章节中介绍这些组合。
实用建议很简单:如果 DDP 可用,就用 DDP——它更快也更简单。当你的模型超出单个 GPU 时,先尝试优化(混合精度、激活检查点、梯度累积)。如果仍然 OOM,则切换到 FSDP2。从完整分片和用于检查点的 DCP API 开始,进行性能分析以找出瓶颈,并在扩展到更多节点之前先在 2-4 块 GPU 上测试。不要盲目优化——让性能分析器指引你。
本章中的代码示例完整且可运行。在你的硬件上试试它们,看看分片的实际效果:每个 rank 只持有模型的一部分,all-gather/reduce-scatter 操作会自动发生。
FSDP2 能很好地处理大多数大模型训练场景。但如果连完整分片都不够呢?如果你需要 CPU 或 NVMe 卸载来进一步突破内存限制,或需要针对跨多节点训练的优化通信模式呢?这正是 DeepSpeed 的 ZeRO 发挥作用的地方。在下一章中,我们将探讨 ZeRO-Offload、ZeRO-Infinity 和 ZeRO++——这些特性超出了 FSDP2 目前提供的范围——以及何时选择 DeepSpeed 而非 PyTorch 原生解决方案。
代码摘要
-
torch.distributed.fsdp.fully_shard(module, mesh=..., mp_policy=...):使用按参数分片(DTensor)就地分片模块 -
torch.distributed.fsdp.MixedPrecisionPolicy:FSDP2 的混合精度配置 -
torch.distributed.device_mesh.init_device_mesh("cuda", (world_size,)):创建用于分片的设备 mesh -
torch.distributed.checkpoint:用于保存/加载分片状态字典的分布式检查点(DCP)API -
torch.distributed.fsdp.FullyShardedDataParallel(module, ...):使用 FlatParameter 进行分片的包装器类 -
torch.distributed.fsdp.wrap():使用 FSDP1 包装子模块 -
torch.distributed.fsdp.MixedPrecision:FSDP1 的混合精度配置 -
torch.distributed.fsdp.set_state_dict_type():为检查点配置状态字典类型 -
torch.distributed.fsdp.StateDictConfig/OptimStateDictConfig:状态字典配置
相关链接
PyTorch FSDP 文档
-
PyTorch FSDP 文档:
https://pytorch.org/docs/stable/fsdp.html -
PyTorch FSDP 教程:
https://docs.pytorch.org/tutorials/intermediate/FSDP_tutorial.html -
Per-Parameter-Sharding FSDP RFC:
https://github.com/pytorch/pytorch/issues/114299 -
TorchTitan FSDP 指南:
https://github.com/pytorch/torchtitan/blob/main/docs/fsdp.md -
PyTorch XLA SPMD:
https://docs.pytorch.org/xla/master/spmd.html
教程与指南
-
UvA 深度学习 - 数据并行 FSDP (JAX):
https://uvadlc-notebooks.readthedocs.io/en/latest/tutorial_notebooks/scaling/JAX/data_parallel_fsdp.html -
Hugging Face - FSDP 与 DeepSpeed:
https://huggingface.co/docs/accelerate/concept_guides/fsdp_and_deepspeed -
Hugging Face - FSDP1 对比 FSDP2:
https://huggingface.co/docs/accelerate/en/concept_guides/fsdp1_vs_fsdp2 -
并行计算简介:
https://ggrigorev.me/posts/introduction-to-parallelism/
研究
-
PyTorch FSDP:扩展全分片数据并行的经验(2023):
https://arxiv.org/abs/2304.11277 -
分布式训练优化(2024):
https://arxiv.org/abs/2411.00284
项目
- Wan2.2(FSDP + DeepSpeed Ulysses,用于多 GPU 推理):
https://github.com/Wan-Video/Wan2.2
备注与参考
-
https://github.com/Wan-Video/Wan2.2 -
Rajbhandari 等人,“ZeRO:面向训练万亿参数模型的内存优化”,SC 2020。
https://arxiv.org/abs/1910.02054 -
https://github.com/pytorch/pytorch/issues/114299
T5(Text-to-Text Transfer Transformer,文本到文本迁移转换器)是 Google 推出的编码器-解码器模型,它把 NLP 任务统一表示为文本到文本的形式。FLAN-T5 是 T5 模型经过指令微调得到的系列模型(例如 flan-t5-small、flan-t5-xl、flan-t5-xxl),用于摘要生成和问答等任务。
https://docs.pytorch.org/xla/master/spmd.html
练习
实现手动参数分片
实现 FSDP 参数分片的简化版本,以理解它如何支持训练比 GPU 内存更大的模型。
要求:
函数签名:
def shard_parameters(model: nn.Module, world_size: int, rank: int) -> dict:
"""Shard model parameters across ranks, keeping only local shard."""
pass
def gather_parameters(sharded_params: dict, world_size: int) -> dict:
"""Gather all parameter shards to reconstruct full parameters."""
pass
-
将所有参数展平为单个张量
-
将展平后的张量平均分成
world_size片 -
每个 rank 只保留自己的分片(占总参数的 1/world_size)
-
实现 gather 以重建完整参数,用于前向传播
测试你的实现:
import torch
import torch.nn as nn
import torch.distributed as dist
model = nn.Sequential(
nn.Linear(1000, 1000),
nn.ReLU(),
nn.Linear(1000, 100)
)
# Shard parameters
sharded = shard_parameters(model, world_size=4, rank=dist.get_rank())
print(f"Rank {dist.get_rank()}: sharded size = {sharded['shard'].numel()} elements")
# Gather for forward pass
full_params = gather_parameters(sharded, world_size=4)
print(f"Full params size: {sum(p.numel() for p in full_params.values())} elements")
比较 FSDP 分片策略
编写一个基准测试,比较不同的 FSDP 分片策略及其内存/性能权衡。
要求:
-
测试三种分片策略:
-
FULL_SHARD:对参数、梯度和优化器状态进行分片 -
SHARD_GRAD_OP:仅对梯度和优化器状态进行分片 -
NO_SHARD:不分片(类似 DDP)
-
-
对每种策略进行测量:
-
GPU 峰值内存使用量
-
训练吞吐量(样本/秒)
-
通信量
-
-
使用一个在
NO_SHARD下无法放入单个 GPU 内存的模型 -
生成对比表和图表
测试你的实现:
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import ShardingStrategy
strategies = [
ShardingStrategy.FULL_SHARD,
ShardingStrategy.SHARD_GRAD_OP,
ShardingStrategy.NO_SHARD,
]
results = {}
for strategy in strategies:
model = create_large_model() # e.g., 7B parameter model
fsdp_model = FSDP(model, sharding_strategy=strategy)
memory, throughput = benchmark_training(fsdp_model, num_steps=100)
results[strategy.name] = {"memory_gb": memory, "throughput": throughput}
# Print comparison table
print("Strategy | Memory (GB) | Throughput (samples/s)")
for name, metrics in results.items():
print(f"{name:15} | {metrics['memory_gb']:10.1f} | {metrics['throughput']:10.1f}")
实现自定义 FSDP 包装策略
创建一个自定义自动包装策略,将 transformer 层逐个包装,以实现最佳内存效率。
要求:
类签名:
def transformer_auto_wrap_policy(
module: nn.Module,
recurse: bool,
nonwrapped_numel: int,
min_num_params: int = 1e6
) -> bool:
"""Custom wrap policy for transformer models."""
pass
-
将每个 transformer 块(注意力 + FFN)包装为单个 FSDP 单元
-
不要包装嵌入层(它们应位于根 FSDP 中)
-
不要包装最终输出投影层
-
允许配置最小参数阈值
测试你的实现:
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp.wrap import _module_wrap_policy
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B")
fsdp_model = FSDP(
model,
auto_wrap_policy=functools.partial(
transformer_auto_wrap_policy,
min_num_params=1e6
)
)
# Verify wrapping
def count_fsdp_units(module, depth=0):
count = 1 if isinstance(module, FSDP) else 0
for child in module.children():
count += count_fsdp_units(child, depth + 1)
return count
print(f"Number of FSDP units: {count_fsdp_units(fsdp_model)}")
使用 FSDP 实现混合精度
配置并评测采用不同混合精度策略的 FSDP。
要求:
-
测试三种精度配置:
-
完整 FP32(基线)
-
BF16 计算 + FP32 参数
-
BF16 计算 + BF16 参数和梯度
-
-
测量:
-
与 FP32 相比的内存节省
-
训练损失收敛
-
吞吐量提升
-
-
处理 FP16 的梯度缩放(如果适用)
测试你的实现:
from torch.distributed.fsdp import MixedPrecision
import torch
# Define precision policies
fp32_policy = MixedPrecision()
bf16_compute_policy = MixedPrecision(
param_dtype=torch.float32,
reduce_dtype=torch.float32,
buffer_dtype=torch.float32,
)
bf16_full_policy = MixedPrecision(
param_dtype=torch.bfloat16,
reduce_dtype=torch.bfloat16,
buffer_dtype=torch.bfloat16,
)
policies = {
"FP32": fp32_policy,
"BF16 Compute": bf16_compute_policy,
"BF16 Full": bf16_full_policy,
}
for name, policy in policies.items():
model = FSDP(create_model(), mixed_precision=policy)
memory, loss, throughput = train_and_measure(model, num_steps=100)
print(f"{name}: Memory={memory:.1f}GB, Loss={loss:.4f}, Throughput={throughput:.1f}")
实现 FSDP 检查点
为 FSDP 模型创建一个健壮的检查点系统,支持重新分片。
要求:
-
保存检查点,使其能够以不同的 world size 加载
-
同时支持完整 state dict 和分片 state dict 格式
-
实现检查点验证(确认保存的 == 加载的)
-
处理优化器状态的保存/加载
-
支持从检查点恢复训练
测试你的实现:
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import StateDictType, FullStateDictConfig
class FSDPCheckpointer:
def __init__(self, model: FSDP, optimizer, checkpoint_dir: str):
self.model = model
self.optimizer = optimizer
self.checkpoint_dir = checkpoint_dir
def save(self, step: int, full_state: bool = True):
"""Save checkpoint."""
pass
def load(self, step: int) -> dict:
"""Load checkpoint and return metadata."""
pass
def validate(self, step: int) -> bool:
"""Validate checkpoint integrity."""
pass
# Test saving and loading
checkpointer = FSDPCheckpointer(fsdp_model, optimizer, "./checkpoints")
# Train for a few steps
train_steps(fsdp_model, optimizer, num_steps=10)
# Save checkpoint
checkpointer.save(step=10)
# Validate
assert checkpointer.validate(step=10), "Checkpoint validation failed!"
# Load and resume
metadata = checkpointer.load(step=10)
print(f"Resumed from step {metadata['step']}")
预期学习成果
完成这些练习后,你应该能够:
-
理解FSDP如何在GPU之间分片参数
-
比较并选择适合你工作负载的分片策略
-
为Transformer模型实现自定义包装策略
-
配置FSDP混合精度训练
-
构建用于分布式训练的健壮检查点系统
-
在保持训练吞吐量的同时优化内存使用
5 超越状态分片:DeepSpeed与Megatron
为超大规模模型扩展内存容量并分片计算
未来已来,只是分布不均。
威廉·吉布森,作家
超越状态分片
在上一章中,我们探讨了FSDP——PyTorch在GPU之间分片参数、梯度和优化器状态的方法。FSDP2的全分片在功能上等同于DeepSpeed的ZeRO Stage 3[1]:两者都通过确保每个GPU仅持有训练状态的1/N来消除内存冗余。
状态分片解决了内存问题,但并未改变计算发生的方式。每个GPU仍然在相同的模型架构上执行相同的操作——只是使用不同的数据批次。对于最大的模型(100B+参数),这成为瓶颈:单个层可能太大而无法在单GPU上高效执行,或者模型可能太深,即使使用检查点也无法在内存中容纳激活值。
这就是Megatron的用武之地[2]。Megatron的张量并行将大型矩阵运算拆分到多个GPU上,而其流水线并行则沿深度维度对模型进行分片。这些技术分片的是计算本身,而不仅仅是训练状态。它们是训练前沿模型的关键,并且至今仍是NVIDIA、Meta等机构大规模训练基础设施的骨干。
我们将首先介绍DeepSpeed ZeRO——了解完整的ZeRO家族(阶段1-3、卸载、ZeRO++)是值得的,因为许多代码库仍在使用它。但本章的真正重点是Megatron风格的并行:张量并行、流水线并行,以及它们如何与状态分片结合实现多维并行。
图5.1:ZeRO阶段对比:DDP与ZeRO-1/2/3
图5.1展示了DDP和每个ZeRO阶段在两个rank(R0–R1)上的内存布局。每行代表一个GPU,三个彩色块显示该GPU存储的内容:P(参数,蓝色)、G(梯度,红色)和O(优化器状态,绿色)。在DDP中,所有块都是全宽的,因为每个GPU都持有所有内容的完整副本——这就是我们想要消除的内存冗余。ZeRO-1保持参数和梯度复制,但分片优化器状态(注意较小的O块)。ZeRO-2额外分片梯度,因此G和O块都缩小。ZeRO-3分片所有三个组件——在2个GPU下,每个块都变为原始大小的一半。从左到右的视觉进展显示了每个阶段每个GPU内存的减少,代价是需要增加通信以便在需要时重建完整张量。
ZeRO阶段1:优化器状态分区
回顾上一章的内存分解:对于使用Adam的模型,优化器状态主导内存使用——每个参数需要以两个FP32副本存储动量和方差,每个参数共8字节。一个7B参数的模型仅优化器状态就需要56GB。混合精度训练还保留一份FP32的权重主副本(每个参数另加4字节),因此完整优化器侧的内存占用接近12字节——我们在本章后面的70B示例中使用该核算。在DDP中,每个GPU都持有这些状态的完整副本,这是巨大的浪费。
ZeRO-1的见解很直接:既然每个GPU最终只更新其分配到的参数部分,为什么要存储完整的优化器状态?在2个GPU下,每个只存储一半的优化器状态。前向和后向传播正常进行,梯度同步后,每个GPU仅使用其本地优化器状态来更新对应的参数分片。56GB的优化器状态分布在2个GPU上,每个GPU的负担减少到28GB。
与设置极简的PyTorch原生DDP不同,DeepSpeed使用配置字典(或JSON文件)来控制所有训练设置——优化器、精度、ZeRO阶段等。我们的示例启用FP16以保持可移植性;在Hopper及更新的GPU上,BF16通常是更好的默认选择(动态范围更宽,无需损失缩放)。将配置传递给deepspeed.initialize(),它会返回一个包装后的模型引擎,自动处理分布式训练:
import deepspeed
ds_config = {
"train_batch_size": 32,
"optimizer": {"type": "Adam", "params": {"lr": 1e-4}},
"fp16": {"enabled": True},
"zero_optimization": {"stage": 1} # Enable ZeRO-1
}
model_engine, optimizer, _, _ = deepspeed.initialize(
model=model,
model_parameters=model.parameters(),
config=ds_config
)
# Training loop uses model_engine instead of model
for batch in dataloader:
loss = model_engine(batch)
model_engine.backward(loss)
model_engine.step()
与DDP的关键区别:DeepSpeed根据你的配置构建优化器,而不是使用你可能事先创建的torch.optim.Adam(...)——那个外部优化器会被忽略。model_engine包装你的模型,并提供backward()和step()方法。
ZeRO-1适用于模型本身能装入GPU内存、但加上优化器状态后会超限的场景。它只需要对训练循环做最小的改动,并且最容易调试,因此是从DDP迁移到ZeRO时的自然第一步。
要体验DeepSpeed API,请运行这个最小示例:
pip install deepspeed
# Single GPU (for API familiarization)
deepspeed --num_gpus=1 code/zero_minimal.py --zero_stage 1
# Multiple GPUs (to see actual sharding benefits)
deepspeed --num_gpus=2 code/zero_minimal.py --zero_stage 1
你应该会看到类似如下的输出:
ZeRO Stage 1 Demo
Model: 5,248,000 parameters
...
Step 1/10, Loss: 1.0342, Peak Memory: 1.06 GB
...
ZeRO Stage 1 training complete!
Final peak memory: 1.10 GB
在单GPU下,分片的效果有限(只有一个分区)。在2个GPU下,每个GPU只存储一半的优化器状态,你会观察到每个GPU的内存使用更低。
ZeRO阶段2:优化器状态和梯度分区
ZeRO-1分片优化器状态,但梯度仍完全复制。对于一个7B模型,每个GPU上仍有14GB的梯度(FP16)。ZeRO-2更进一步:也对梯度进行分片。
关键见解是:梯度与优化器状态一样,仅对每个GPU负责更新的参数是必需的。在反向传播中,ZeRO-2不使用all_reduce(它给每个GPU完整的平均梯度),而是使用reduce_scatter——每个GPU只接收其分配到的平均梯度切片。其余部分立即丢弃,在计算进行时释放内存。
在2个GPU下,每个GPU现在持有:完整参数 + 一半梯度 + 一半优化器状态。对于我们的7B模型,每个GPU的梯度内存从14GB降至7GB。
配置中增加了bucket size参数,用于控制梯度在通信前的分批方式:
ds_config = {
"train_batch_size": 32,
"optimizer": {"type": "Adam", "params": {"lr": 1e-4}},
"fp16": {"enabled": True},
"zero_optimization": {
"stage": 2,
"allgather_bucket_size": 5e8,
"reduce_bucket_size": 5e8
}
}
较大的桶(bucket)通过分摊每次集合通信操作的开销来提高通信效率,但会占用更多内存。默认的 5 亿个元素在大多数情况下效果良好。
为了比较 ZeRO-1 和 ZeRO-2:
deepspeed --num_gpus=2 code/zero_minimal.py --zero_stage 1
deepspeed --num_gpus=2 code/zero_minimal.py --zero_stage 2
当梯度内存成为瓶颈,但你仍然希望参数在各 GPU 上复制以实现快速前向传播时,ZeRO-2 是一个不错的选择。
ZeRO stage 3:完全分片(类似 FSDP)
ZeRO-2 仍然会在每块 GPU 上复制参数。对于一个 7B 模型,这意味着所有 rank 上重复存储了 14GB(FP16)的参数。ZeRO-3 通过同样切分参数来消除这最后一种冗余——现在每个组件(参数、梯度、优化器状态)都被分布到各设备上。
这在功能上等同于 PyTorch FSDP。每块 GPU 只保存全部内容的 1/N。以 2 块 GPU 训练 7B 模型为例:7GB 参数 + 7GB 梯度 + 28GB 优化器状态 = 每块 GPU 42GB;相比之下,使用 DDP 则需要 14GB + 14GB + 56GB = 84GB。
代价是通信开销。由于参数现在被切分,每一层在前向计算之前都需要一次 all_gather 来重建完整的权重,然后使用完毕后立即释放收集到的参数。反向传播同样如此,另外还需要一次 reduce_scatter 来聚合梯度。这意味着每次迭代的通信量是模型大小的 3 倍(1 次前向 all-gather、1 次反向 all-gather、1 次梯度 reduce-scatter)。
DeepSpeed 通过让通信与计算重叠来缓解这种开销——当一个层在计算时,下一层的参数正在后台收集。overlap_comm 和 stage3_prefetch_bucket_size 参数控制这一行为:
ds_config = {
"train_batch_size": 32,
"optimizer": {"type": "Adam", "params": {"lr": 1e-4}},
"fp16": {"enabled": True},
"zero_optimization": {
"stage": 3,
"overlap_comm": True,
"contiguous_gradients": True,
"stage3_prefetch_bucket_size": 5e8,
"stage3_max_live_parameters": 1e9
}
}
要查看从 ZeRO-1 到 ZeRO-3 的完整演进过程:
deepspeed --num_gpus=2 code/zero_minimal.py --zero_stage 1
deepspeed --num_gpus=2 code/zero_minimal.py --zero_stage 2
deepspeed --num_gpus=2 code/zero_minimal.py --zero_stage 3
当即使复制的参数也无法放入 GPU 内存时,或者当你希望获得最大内存效率且能容忍一定通信开销时,ZeRO-3 是合适的选择。在具有 NVLink 的单节点上,与 ZeRO-2 相比的性能差距通常不大(10-20%);而在跨多个节点时,all-gather 成本会增加,差距也会扩大。
ZeRO-Offload:CPU 内存扩展
即使使用 ZeRO-3,你仍然可能耗尽 GPU 内存——尤其是在像 RTX 4090 这样只有 24GB 显存的消费级硬件上。ZeRO-Offload 通过将优化器状态(以及可选参数)移动到 CPU 内存来解决这个问题,CPU 内存通常容量更大且成本更低。
其思路很简单:将前向和反向传播保留在速度快的 GPU 上,但把占用内存的优化器状态卸载到 CPU 内存。梯度计算完成后,通过 PCIe 传输到 CPU,优化器步骤在 CPU 上执行,更新后的参数再发送回 GPU。DeepSpeed 会让这些传输与计算重叠——当 GPU 处理下一个批次的前向传播时,CPU 同时运行上一个批次的优化器步骤。
瓶颈是 PCIe 带宽(PCIe 4.0 约 32 GB/s,而 GPU HBM 约 2 TB/s)。与仅使用 GPU 训练相比,吞吐量预计会降低 20-40%。这不是性能优化,而是一种可行性解决方案,让你能够训练原本无法容纳的模型。
ds_config = {
"train_batch_size": 32,
"optimizer": {"type": "Adam", "params": {"lr": 1e-4}},
"fp16": {"enabled": True},
"zero_optimization": {
"stage": 2,
"offload_optimizer": {
"device": "cpu",
"pin_memory": True
}
}
}
pin_memory: True 设置使用锁定(页锁定)内存来加速 CPU-GPU 传输。要尝试 CPU 卸载:
deepspeed --num_gpus=1 code/zero_offload_example.py --offload_device cpu
你应该会看到类似下面的输出:
Model: 354.3M parameters (0.71 GB in FP16)
Offload device: cpu
...
Step 0, Loss: 11.0607, GPU Memory: 3.41 GB
Step 10, Loss: 11.0829, GPU Memory: 3.52 GB
...
Training complete with CPU offloading!
Peak GPU memory: 3.52 GB
注意,尽管模型很大,GPU 内存却保持在较低水平(3.5GB)——因为优化器状态位于 CPU 上。ZeRO-Offload 非常适合在显存有限但系统内存充足的消费级 GPU 上进行训练。
ZeRO-Infinity:面向大规模模型的 NVMe 卸载
ZeRO-Offload 将优化器状态移动到 CPU,但 CPU 内存同样有限——工作站通常为 256-512GB。对于真正庞大的模型(数千亿参数),即使 CPU 内存也不够用。ZeRO-Infinity 进一步将 NVMe SSD 作为额外的内存层级,将卸载推进了一步。
图 5.2:ZeRO-Infinity 内存层级
图 5.2 展示了三层内存层级:GPU HBM(最快、最小)、CPU 内存(中等)和 NVMe(最慢、最大)。Infinity 引擎管理跨层级的数据移动,在需要之前从 NVMe → CPU → GPU 预取参数,并让传输与计算重叠。
现代 NVMe SSD 提供 5-7 GB/s 的顺序读取速度(PCIe Gen4),虽然低于 CPU 内存带宽,但能以低成本提供 TB 级容量。一个典型的配置可能将当前活跃层参数和激活值保留在 GPU 上,将优化器状态和参数缓冲区放在 CPU 上,并将冷参数放在 NVMe 上。
配置中增加了 NVMe 特有的设置:
ds_config = {
"train_batch_size": 32,
"optimizer": {"type": "Adam", "params": {"lr": 1e-4}},
"fp16": {"enabled": True},
"zero_optimization": {
"stage": 3,
"offload_optimizer": {"device": "cpu", "pin_memory": True},
"offload_param": {
"device": "nvme",
"nvme_path": "/local_nvme",
"buffer_count": 5,
"buffer_size": 1e8
}
},
"aio": {
"block_size": 1048576,
"queue_depth": 16,
"thread_count": 2
}
}
aio 部分为 NVMe 配置异步 I/O——queue_depth 和 thread_count 控制并行度,以便将读写与计算重叠。
要检查你是否拥有 NVMe SSD 并找出其挂载位置:
# List NVMe devices and partitions with mount points
lsblk -o NAME,SIZE,MOUNTPOINT | grep nvme
# Example output:
# nvme0n1 1.9T
# └─nvme0n1p7 1.8T /home
nvme_path 必须是挂载文件系统上的一个目录,而不是原始设备路径(如 /dev/nvme0n1p7)。NVMe 卸载还需要 libaio 库来支持异步 I/O:
# Install libaio (required for NVMe offloading)
sudo apt install libaio-dev # Ubuntu/Debian
# or: sudo yum install libaio-devel # CentOS/RHEL
# Increase open file limit (NVMe offloading opens many file handles)
ulimit -n 65535
# Use a directory path, NOT /dev/nvme*
deepspeed --num_gpus=1 code/zero_offload_example.py \
--offload_device nvme --nvme_path /home/$USER/nvme_offload
将 /home/$USER/nvme_offload 替换为你的 NVMe 文件系统上的一个目录。该示例默认训练一个 354M 参数的模型——调整 --hidden_size 和 --num_layers 可以尝试更大的模型。确保你选择的路径有足够的可用空间(优化器状态和参数缓冲区大约需要模型大小的 2-4 倍)。
NVMe 卸载比 CPU 卸载更慢(预计吞吐量降低 30-50%),而 CPU 卸载本身又比纯 GPU 训练慢。ZeRO-Infinity 的价值不在于性能,而在于可行性。它让你能够训练那些原本根本无法容纳的模型。
ZeRO++:通信优化的 ZeRO
ZeRO-3 消除了内存冗余,但也引入了显著的通信开销 [3]。
ZeRO++ 通过三项互补的技术来解决这个问题(这些名称沿用论文中的记号:"q" 表示量化(quantized),"hp" 表示层次划分(hierarchical partitioning),"Z" 表示 ZeRO)。第一项是量化权重(qwZ),它通过以 INT8 而非 FP16 传输参数、收到后再反量化来减少 all-gather 通信量——通信量降至原来的 1/2。
INT8 仅适用于 all-gather 过程中传输的参数;用于优化器步骤的主权重保持全精度,因此舍入误差不会在迭代之间传递。
第二项技术是层次划分(hpZ),它利用了这样一个事实:节点内通信(NVLink,约 600 GB/s)远快于节点间通信(InfiniBand,约 400 GB/s)。
hpZ 并非在所有 GPU 之间均匀分片,而是在每个节点内复制参数,仅在节点之间分片。这意味着节点内的 all-gather 使用高速 NVLink,而节点间流量被缩减为每个节点一份代表数据。
图 5.3:hpZ 层次划分:ZeRO-3 与 hpZ
图 5.3 对比了在 2 个节点、每节点 2 张 GPU(共 4 张 GPU)的配置下 ZeRO-3 和 hpZ 的差异。
在 ZeRO-3(左)中,每张 GPU 持有一个唯一分片(S0–S3),因此重建完整参数需要所有 4 张 GPU 进行 all-gather。红色箭头表示每张 GPU 都必须跨节点边界与其他所有 GPU 通信——节点 0 中的 S0 和 S1 各自需要从节点 1 获取 S2 和 S3,反之亦然。这种跨节点流量使用较慢的 InfiniBand 互联。
在 hpZ(右)中,节点 0 内的两张 GPU 持有相同分片(S0),节点 1 内的两张 GPU 持有分片 S1。单个绿色箭头表示简化后的通信模式:只需在节点之间交换一次,即可共享 S0 和 S1。在每个节点内部,GPU 已经拥有相同的数据,因此复制部分不需要节点内通信。
这大大减少了缓慢的节点间通信量。
第三种技术是量化梯度(qgZ),它在 reduce-scatter 过程中对梯度应用同样的 INT8 量化。
该配置可以有选择地启用这些优化:
ds_config = {
"train_batch_size": 32,
"optimizer": {"type": "Adam", "params": {"lr": 1e-4}},
"fp16": {"enabled": True},
"zero_optimization": {
"stage": 3,
"zero_quantized_weights": True, # qwZ
"zero_hpz_partition_size": 2, # hpZ (GPUs per node)
"zero_quantized_gradients": True
# qgZ
}
}
zero_hpz_partition_size 应与集群中每个节点的 GPU 数量匹配。如需单独试验各项优化:
deepspeed --num_gpus=2 code/zero_pp_example.py --enable_qwz
deepspeed --num_gpus=2 code/zero_pp_example.py --enable_hpz
如需同时启用全部三项优化:
deepspeed --num_gpus=2 code/zero_pp_example.py --enable_qwz --enable_hpz --enable_qgz
你应该会看到类似下面的输出:
Model: 124.0M parameters
World size: 2
ZeRO++ features:
qwZ (Quantized Weights): True
hpZ (Hierarchical Partitioning): True
qgZ (Quantized Gradients): True
...
Using quantizer for weights: CUDAQuantizer
...
Step 0, Loss: 10.9776
...
ZeRO++ training complete!
Peak GPU memory: 4.00 GB
看到 CUDAQuantizer 消息即表示 INT8 量化已生效。请注意,ZeRO++ 要求使用 FP16 精度——量化功能会反量化为 FP16,因此使用 BF16 会导致数据类型不匹配。
ZeRO++ 最适用于节点间通信成为瓶颈的大规模多节点训练。对于单节点训练或小型集群,由于节点内通信已经很快,收益相对有限。
Megatron:计算并行作为第二个轴
到目前为止,我们重点关注的是状态分片——即如何在 GPU 之间分布参数、梯度和优化器状态以降低内存占用。FSDP2 和 DeepSpeed ZeRO 这类技术从根本上解决的是一个内存冗余问题:消除复制的模型状态,使更大的模型能够放入 GPU 总内存预算之内。
然而,仅靠状态分片还不足以支撑最大的模型。随着模型规模不断增长,第二个正交的限制浮现出来:即使内存已完全分片,计算本身也变得太大,无法在单张 GPU 上高效执行。Megatron 就是从这时登场的。
Megatron-LM 于 2019 年由 NVIDIA 应用深度学习研究团队提出,并在论文《Megatron-LM:使用模型并行训练数十亿参数的语言模型》中介绍。[4] 当时,1.5B 参数的 GPT-2 已算得上大模型,研究界才刚刚开始探索如何扩展到单张 GPU 无法处理的规模。NVIDIA 团队意识到,单纯增加更多 GPU 做数据并行并不能解决根本问题:有些层实在太大,无法在单台设备上计算。他们的解决方案是将单个矩阵运算拆分到多张 GPU 上——他们称之为张量并行。Megatron 原始论文展示了 8.3B 参数模型的训练,这在当时是前所未有的。自那时起,Megatron 的技术已成为训练 GPT-3(175B)、Llama(最高 405B)以及当今几乎所有前沿模型的基础设施。
状态分片与计算分片
要理解 Megatron 带来了什么,退一步想想 FSDP 和 ZeRO 实际上做了什么会很有帮助。这些技术解决的是存储问题:它们把模型的参数、梯度和优化器状态分布到多张 GPU 上,使任何单一设备都不必持有全部内容。当需要计算某一层时,你从其他 GPU 上 all-gather 该层的参数,执行前向和反向传播,然后丢弃收集来的参数。这里的关键假设是:一旦拿到参数,计算本身就能轻松地放在一张 GPU 上完成。
对于参数量达到数百亿的模型,这个假设依然非常成立。一个 7B 模型的最大层可能有形状为 4096×16384 的权重矩阵——约 2.7 亿个参数,FP16 下大约 500MB。这完全处于现代 GPU 单次矩阵乘法可以轻松处理的范围内。
但当模型增长到数千亿参数时会发生什么?考虑一个假设的 175B 模型,其隐藏维度为 12,288,MLP 中间维度为 49,152。MLP 中的单个权重矩阵可能是 12,288×49,152——超过 6 亿个参数,仅这一个矩阵就需要 1.2GB。激活值也会同比例增长。到某个临界点,即使状态分片完美,单层的计算也会变得过大:矩阵乘法本身超出单张 GPU 能高效执行的范围,或者中间激活值超出 GPU 内存。
这正是状态分片达到极限之处。你可以把存储分得任意细,但如果计算本身放不下,再巧妙的内存管理也无济于事。Megatron 正是通过迈出下一个合乎逻辑的步骤来应对这种失效模式:它不仅分片模型存放于何处,还分片模型如何计算。
张量并行:对层进行分片
Megatron 的核心贡献是张量并行(Tensor Parallelism,TP)——一种将单个矩阵运算拆分到多张 GPU 上的技术。其核心洞察非常优雅:矩阵乘法在特定维度上天然可并行,我们可以利用这一点来同时分散计算量和内存占用。
考虑一个简单的线性层 Y = XW,其中 X 是输入,W 是权重矩阵。如果 W 的形状是 d × 4d(MLP 第一个投影层的典型情况),我们可以按列将其分成两半:W = [W0 | W1]。此时 GPU 0 持有 W0,GPU 1 持有 W1。在两块 GPU 上给定相同输入 X,各自计算自己的部分:Y0 = XW0,Y1 = XW1。完整输出就是 Y = [Y0 | Y1]——不需要通信,只需逻辑拼接。这被称为列并行线性层。
但下一层呢?它期望的是完整输入,而不是拆分后的输入。这时就要用到行并行线性层了。如果第二层的权重矩阵按行拆分为 W' = [W'0; W'1](垂直堆叠),那么每张 GPU 都可以使用其本地的
输入的一部分:GPU 0 计算 Y'0 = Y0 W'0,GPU 1 计算 Y'1 = Y1 W'1。最终输出为 Y' = Y'0 + Y'1——这是一种全归约(all-reduce)操作,将部分结果相加。
图 5.4 展示了这种两步模式。按列并行的线性层(顶部)将权重矩阵按列拆分,因此每个 GPU 计算输出的一块切片,无需通信。按行并行的线性层(底部)按行拆分,并通过全归约合并部分结果。将这两种操作配对使用——先按列并行,再按行并行——整个 MLP 块只需要一次全归约。这就是 Megatron 高效的关键:通信被最小化为每层只有一个同步点,而不是每个操作都要同步。
图 5.4:张量并行:按列并行和按行并行的线性层
同样的原理也适用于自注意力。Q、K、V 投影矩阵在 GPU 之间按列拆分,因此每个 GPU 计算一部分注意力头的注意力。由于注意力头之间相互独立,注意力计算本身不需要通信。只有输出投影使用按行并行的线性层,需要一次全归约来合并结果。
这里有一个重要的细微差别:与状态分片相比,张量并行从根本上改变了通信模式。使用 idx_59809bee FSDP 或 ZeRO 时,通信发生在层之间——在计算某一层之前先进行全收集(all-gather),然后再继续。使用张量并行时,通信发生在层内部——每个 MLP idx_08cc4608 和注意力块都需要一次全归约。这意味着张量并行对互连带宽要敏感得多。在实践中,你希望 TP 组位于同一节点内,通过快速的 NVLink(~600 GB/s)连接,而不是跨节点使用较慢的 InfiniBand(~400 GB/s)。
现代实现会使用像 Megatron 中的 --tp-comm-overlap 这样的技术,将这种通信与计算重叠。当某一层的全归约仍在进行时,下一层的计算就可以开始,从而隐藏大部分延迟。
启用张量并行时,序列并行成为 idx_12028d0ba 一个自然的延伸。序列并行不是在所有 TP rank 之间复制激活值,而是沿着序列维度拆分激活值。这会使激活内存缩小到原来的 1/TP 并行度——对于激活内存可能占主导的长上下文训练至关重要。
典型的配置如下:
--tensor-model-parallel-size 2 # 2-way tensor parallelism
--sequence-parallel # Enable sequence parallelism (recommended with TP)
--tp-comm-overlap # Overlap TP communication with computation
要在更底层查看张量并行的实际效果,你可以运行代码示例中的纯 PyTorch 实现:
# Tensor parallelism demo with 2 GPUs
torchrun --nproc_per_node=2 code/tensor_parallel_mlp.py
该示例从头实现了按列并行和按行并行的线性层,精确展示了权重矩阵如何拆分以及全归约如何合并部分结果。运行它有助于建立对 Megatron 底层机制的直觉。示例日志位于 code/tensor_parallel_mlp.log。
流水线并行:对深度进行分片
张量并行 idx_4b5e956a 将各个层拆分到多个 GPU 上,但我们还可以利用另一个维度:模型的深度。一个 32 层的 Transformer 不需要在每个 GPU 上都存放全部 32 层——我们可以将第 0–15 层分配给一个 GPU,第 16–31 层分配给另一个 GPU。这就是 idx_2dfe6d0a 流水线并行(PP)。
这个想法很直观:数据像水流经管道一样流过模型。GPU 0 处理前半部分层,然后将中间激活值传给 GPU 1,GPU 1 处理后半部分。但这里有个问题——如果我们一次只处理一个批次,GPU 1 会在 GPU 0 工作时空闲,反之亦然。这种“流水线气泡”最多会浪费 50% 的计算量。
解决方案是将批次拆分成更小的微批次,并以流水线方式处理。当 GPU 1 处理第 1 个微批次、计算第 16–31 层时,GPU 0 可以开始 idx_306c10c4 处理第 2 个微批次、计算第 0–15 层。只要在途的微批次足够多,就能让所有 GPU 在大部分时间内保持忙碌。这种方法被称为 idx_f7b2952b 1F1B(一次前向一次后向):每个 GPU 在前向和后向之间交替进行,保持所有流水线阶段都活跃的稳定状态。
图 5.5:流水线并行:朴素调度与 1F1B 调度
图 5.5 对比了朴素方法与 1F1B 调度。在朴素流水线(顶部)中,单个批次依次流过全部 4 个 GPU——GPU 0 执行前向(F),传给 GPU 1,依此类推,直到 GPU 3 完成前向,然后后向(B)再反向传播。空白区域表示空闲时间(气泡)。在 1F1B 调度(底部)中,我们将批次拆分为 4 个微批次(F1–F4,B1–B4)。每个 GPU 以交错方式处理多个微批次,从而大幅减少空闲时间。
Megatron 支持多种流水线调度。上面展示的 1F1B 调度最为常见。最初的 GPipe 调度[6] 先运行 idx_f81f388f 所有前向过程,再运行所有后向过程——更简单,但气泡更大。交错流水线[5](也称为 idx_891941b3 虚拟流水线并行)更进一步,为每个 GPU 分配多个不连续的层块,以更多通信为代价减小气泡大小。
虚拟流水线 idx_e84ccc22 并行值得一提。我们不是将第 0–15 层分配给 GPU 0、第 16–31 层分配给 GPU 1,而是可以将第 0–7 层和第 16–23 层分配给 GPU 0,将第 8–15 层和第 24–31 层分配给 GPU 1。每个 idx_b4b0a403 GPU 现在运行两个“虚拟阶段”。这种交错会减少流水线气泡,因为微批次可以更快地在各阶段之间循环。代价是阶段之间需要额外的点对点通信。
典型的配置如下:
--pipeline-model-parallel-size 4 # 4 pipeline stages
--num-layers-per-virtual-pipeline-stage 2 # VPP: 2 layers per virtual stage
流水线并行在特定的 idx_e265366a 场景中表现出色:多节点训练。在节点内部,GPU 通过快速 NVLink(~600 GB/s)连接;跨节点时,你只能使用 InfiniBand(~400 GB/s)或更差的连接。张量并行需要在每个层内进行频繁的全归约操作——在 NVLink 上没问题,但在 InfiniBand 上会很痛苦。流水线并行只需要在阶段之间进行点对点的激活值通信,因此对较慢的互连要宽容得多。
实用指南是:在节点内使用张量并行(NVLink 能保证通信速度),在节点间使用流水线并行(通信模式更宽容)。这种组合——节点内用 TP,节点间用 PP——是训练 100B+ 规模模型的标准方法。
要查看流水线并行的实际效果,可以使用一个简化的实现:
# Pipeline parallelism demo with 2 stages
torchrun --nproc_per_node=2 code/pipeline_parallel_simple.py
该示例演示了模型划分、微批次调度以及跨流水线阶段的前向/后向协调。
序列并行与上下文并行:长上下文
到目前为止,我们讨论的并行策略都着眼于解决模型规模问题——对参数、梯度、优化器状态和计算进行分片。但还有一个越来越重要的维度:序列长度。现代 idx_5a3151ef 模型训练时的上下文窗口可达 8K、32K 甚至 128K 个 token。在这些长度下,激活内存——即前向过程中为后向过程存储的中间值——可能超过模型本身所需的内存。
考虑一个隐藏维度为 4096 的 Transformer idx_a2f18bbd 处理 32K token 序列。每一层都会存储形状为 (batch, 32K, 4096) 的激活值,而在 32 层的情况下,每 GPU 的激活内存很容易达到数十 GB。这就是 idx_e5bf6c99 序列并行和上下文并行发挥作用 idx_c4352d5c 的地方。
序列并行
序列并行[7]是两者中较为简单的一种。当启用张量并行时,LayerNorm 和 Dropout 等操作不参与 TP 通信——它们在本地对完整的隐藏维度进行操作。序列并行通过沿序列维度切分激活值,将分片扩展到这些操作。如果你设置 TP=4,序列并行意味着每个 GPU 只为这些操作存储序列激活值的 1/4。它通常与张量并行一起启用,开销极小。
上下文并行(CP)
上下文并行(CP)[8]采取了一种更激进的方法。序列并行仅对 LayerNorm 和 Dropout 的激活值进行分片,而上下文并行则沿序列维度对所有内容进行分区——包括输入、所有中间激活值以及注意力计算本身。在 8K 序列上设置 CP=2 时,每个 GPU 在整个前向和反向传播过程中只处理 4K 个 token。
挑战在于注意力计算。在标准自注意力中,每个 token 的 query 必须关注序列中的所有 key 和 value。如果 GPU 0 持有 token 0–3999,而 GPU 1 持有 token 4000–7999,那么 GPU 0 上的 query 如何关注 GPU 1 上的 key 呢?上下文并行通过一种称为环形注意力的技术解决了这个问题。其思想非常优雅:不是将所有 KV 对收集到每个 GPU(那样会丧失内存节省的优势),而是让 KV 块在环中传递。GPU 0 用其本地的 KV 为它的 query 计算注意力,然后将自己的 KV 发送给 GPU 1,并接收 GPU 1 的 KV。接着 GPU 0 针对新的 KV 块计算注意力,并累加结果。绕环完整旋转一圈后,每个 query 都见过所有的键值对,但没有任何 GPU 持有过完整序列。
通信模式经过精心优化。现代实现将 KV 传输与注意力计算重叠——在针对当前 KV 块计算注意力的同时,下一个块已在传输中。结合分组查询注意力(GQA)——它在多个 query 头之间共享 KV 头——通信量得以显著减少。
其收益相当可观。没有 CP 时,在很长的序列上训练通常需要激活检查点(在反向传播期间重新计算激活值),这会增加约 30% 的开销。有了 CP,你可以通过将激活内存分布到更多 GPU 上,彻底消除这种重计算。代价是通信开销,但对于长序列而言,计算与通信的比率仍然非常有利。
图 5.6:序列并行与上下文并行(环形注意力)
图 5.6 展示了这两种技术。序列并行(左)沿序列维度切分激活值——每个 GPU 仅为 LayerNorm 和 Dropout 操作存储其序列部分。上下文并行(右)使用环形注意力:每个 GPU 持有局部的 Q、K、V 块,K、V 对在环中轮转,因此每个 query 都能关注到所有 key,而无需任何 GPU 持有完整序列。
长上下文训练的典型配置:
--tensor-model-parallel-size 2
--context-parallel-size 4 # Split 32K sequence across 4 GPUs = 8K per GPU
--sequence-parallel # Also enable sequence parallelism
经验法则是:当序列长度超过 8K token 且激活内存成为瓶颈时,使用上下文并行。对于较短的序列,张量并行和序列并行通常就足够了。
随附的 code/sp_demo.py 演示了这些概念。memory 模式计算不同序列长度的激活内存——你会看到 32K 序列配合 32 层时可能超过 40GB,这解释了为什么需要并行。sequence_parallel 模式展示了每个 GPU 如何只持有序列的一部分,并在本地应用 LayerNorm 而无需通信。ring_attention 模式演示了核心的环形注意力模式:每个 GPU 从本地 Q、K、V 块开始,然后 K 和 V 绕环轮转。完整旋转一圈后,每个 query 都关注到了所有 key,但没有任何 GPU 持有过完整序列。
# Show activation memory scaling (single GPU)
python code/sp_demo.py --mode memory
# Sequence parallelism: split activations along sequence dim (2 GPUs)
torchrun --nproc_per_node=2 code/sp_demo.py --mode sequence_parallel
# Context parallelism via ring attention (2 GPUs)
torchrun --nproc_per_node=2 code/sp_demo.py --mode ring_attention
这些运行的示例日志位于 code/sequence_parallel.log 和 code/ring_attention.log 中。
DeepSpeed-Ulysses:环形注意力的替代方案
环形注意力并不是在长序列上并行化注意力的唯一方式。DeepSpeed 引入了 DeepSpeed-Ulysses[9],这是一种以通信模式换取简洁性的不同方法。
Ulysses 背后的核心洞察直截了当:与其让 KV 块绕环轮转,不如在注意力计算之前收集完整序列,然后再分发回去?在环形注意力中,每个 GPU 针对不同的 KV 块计算部分注意力分数并累加——这需要仔细跟踪跨块的 softmax 归一化。Ulysses 完全绕开了这一复杂性。
其工作原理如下。在注意力计算之前,每个 GPU 持有一个形状为 (batch, local_seq, num_heads, head_dim) 的序列块。Ulysses 执行一次 all-to-all 通信来重新组织数据:不再是每个 GPU 持有序列一部分的所有头,而是每个 GPU 现在持有部分头的所有序列位置。经过这次转置后,形状变为 (batch, full_seq, local_heads, head_dim)。现在每个 GPU 可以对其子集的头计算标准自注意力——没有部分分数,没有累加,只是常规的注意力。注意力计算之后,另一次 all-to-all 会逆转这一变换,回到原始的分区方式。
图 5.7:DeepSpeed-Ulysses:通过 all-to-all 进行二维转置
图 5.7 展示了这种二维转置。在左侧(序列并行),每个 GPU 持有一个局部序列块 Si 以及所有头 Hall——张量形状为 (local_seq, all_heads)。all-to-all 集体转置数据:在右侧(头并行),每个 GPU 持有完整序列 Sall,但只持有其局部的头 Hi——形状变为 (full_seq, local_heads)。现在每个 GPU 可以独立地对其子集的头计算标准自注意力。注意力计算之后,另一次 all-to-all 会逆转这一变换。
这里的权衡在于通信量与通信模式。环形注意力将 KV 块在 P 个 GPU 组成的环上发送 P 次,每次传输与计算重叠。Ulysses 执行两次 all-to-all 集合通信(注意力前后各一次),这会同时涉及所有 GPU。对于较小的并行度(P ≤ 8),Ulysses 通常更占优势,因为 all-to-all 在 NVLink 等现代互连上经过了高度优化。对于更大的 P 或互连带宽受限时,环形注意力的重叠通信可能更高效。
在实践中,DeepSpeed-Ulysses 在那些希望获得序列并行、又不想处理环形注意力部分 softmax 复杂性的场景中表现出色。它能与 ZeRO 及其他 DeepSpeed 优化无缝集成。
随附的 code/ulysses_demo.py 从头实现了 Ulysses 的核心算法。它演示了两次 all-to-all 操作:all_to_all_seq_to_head 将 (batch, local_seq, num_heads, head_dim) 转置为 (batch, full_seq, local_heads, head_dim),而 all_to_all_head_to_seq 则逆转这一变换。在这两次操作之间,每个 GPU 在其子集头上运行标准自注意力——无需部分 softmax 累加。
torchrun --nproc_per_node=2 code/ulysses_demo.py
示例运行记录位于 code/ulysses_demo.log。何时应该选择 Ulysses 而不是环形注意力?如果你已经在使用 DeepSpeed 生态,并且想要一种中等并行度下直接了当的序列并行方案,Ulysses 是更轻松的路径。如果你要扩展到非常长的序列(100K+ token)且并行度很高,环形注意力的通信重叠可能会带来更高的效率。
专家并行:扩展 MoE 模型
混合专家(MoE)模型提供了一个独特的扩展机会:与其让每一层变得更宽,不如添加多个“专家”子网络,并将每个 token 只路由到其中一部分。像 Mixtral 8x7B 这样的模型在每个 MoE 层有 8 个专家,但每个 token 只激活其中 2 个。这意味着模型拥有比大得多的网络更强的能力,同时保持了可管理的每 token 计算量。但我们如何在 GPU 之间分配这些专家呢?
这正是专家并行(EP)的用武之地。其思想非常自然:如果我们有 8 个专家和 8 个 GPU,就在每个 GPU 上放置一个专家。当一个 token 需要由专家 3 处理时,它会被路由到 GPU 3,处理完成后结果被送回。通信模式是 all-to-all:来自所有 GPU 的 token 可能需要前往任何专家,而结果会流回它们的来源。
图 5.8 展示了这种分布模式。
图 5.8:专家并行将专家分布到多块 GPU 上
挑战在于负载均衡。如果路由器将 80% 的词元发送给专家 0,而只有 2% 发送给专家 7,那么 GPU 0 会过载,而 GPU 7 则闲置。MoE 训练通常包含一个辅助损失,鼓励路由器更均匀地分发词元。Megatron 支持多种负载均衡策略:辅助损失(对不均衡的路由施加惩罚)、Sinkhorn(通过迭代归一化强制均衡),以及通过架构约束实现均衡的无辅助损失方法。
专家并行可以自然地与其他并行维度结合。典型的大规模 MoE 训练可能会对专家使用 EP=8,对流水线阶段使用 PP=4,并跨节点使用 DP 进行数据并行。非专家层(注意力、LayerNorm)可以独立使用张量并行。这种灵活性对于 DeepSeek-V3 或 Qwen-MoE 这类拥有数百个专家的模型至关重要。
Mixtral 8x7B 训练的一个配置可能如下所示:
--num-experts 8
--expert-model-parallel-size 8 # One expert per GPU
--moe-router-topk 2 # Each token activates 2 experts
--moe-router-load-balancing-type aux_loss
--moe-grouped-gemm # Batch expert computations
--pipeline-model-parallel-size 4
--moe-grouped-gemm 这个标志值得一提:当一块 GPU 托管多个专家(EP < num_experts)时,它会将跨专家的计算合并为一次分组矩阵乘法,从而显著提高 GPU 利用率。对于极大的专家数量,像 DeepEP[10] 这样的专用通信库可以通过低延迟 GPU 内核和高效的跨节点传输来优化 all-to-all 的词元分派。
随附的 code/expert_parallel_demo.py 是一个从头实现的示例,演示了专家并行的核心机制,且不依赖 Megatron。它展示了路由器如何将词元分配给专家,all-to-all 通信如何将词元分派到目标 GPU,每块 GPU 如何使用其本地专家处理词元,以及另一次 all-to-all 如何返回结果。该演示会打印词元分布统计信息,以便你查看路由决策如何影响负载均衡。
torchrun --nproc_per_node=2 code/expert_parallel_demo.py
示例运行记录见 code/expert_parallel_demo.log。
为什么 FSDP2 无法取代 Megatron
一个常见的误解是 FSDP2(或 ZeRO)和 Megatron 是可互换的——你可以根据偏好二选一。这误解了每个系统实际上在做什么。
FSDP2 和 ZeRO 对状态进行分片:参数、梯度和优化器状态分布在多块 GPU 上,在需要计算时再进行收集。其关键假设是每一层的前向和反向传播都能放在单块 GPU 上。当 GPU 0 需要计算第 5 层时,它从所有 GPU 收集第 5 层的参数,在本地执行计算,然后释放内存。计算本身并不被分布——只有存储被分布。
Megatron 对计算进行分片:单个层的矩阵乘法被拆分到多块 GPU 上,每块 GPU 计算结果的一部分。通信发生在层内部,而不是在层周围。这与在计算前收集参数有着根本区别。
为什么这种区别很重要?考虑一个模型,其中单个注意力层有一个 16K × 16K 的权重矩阵。即使你用 FSDP2 将参数分片到 8 块 GPU 上,到了计算时,仍然需要一块 GPU 收集完整矩阵并执行乘法。如果该矩阵无法容纳在一块 GPU 的内存中,或者在一块 GPU 上计算太慢,FSDP2 也无济于事——它只分片存储,不分片计算。
这正是 Megatron 发挥作用的地方。借助张量并行,16K × 16K 矩阵被拆分到 8 块 GPU 上,每块持有 16K × 2K 的分片。计算并行进行,只有结果需要通信。没有哪块 GPU 需要持有或计算完整矩阵。
实际意义在于:FSDP2 和 Megatron 是互补的,而不是竞争的。你可以在节点内使用 Megatron 的张量并行将大层拆分到多块 GPU 上,同时跨节点使用 FSDP2 风格的分片以提高内存效率。选择不是“用哪个”,而是“如何组合它们”。
混合并行:结合状态分片与计算分片
既然 FSDP2/ZeRO 和 Megatron 解决的是不同的问题,自然而然的问题就是:我们可以同时使用两者吗?答案是肯定的,而这正是大规模训练系统所做的。
考虑在 8 个节点的 64 块 GPU 上训练一个 70B 参数的模型。在每个节点内(8 块 GPU 通过 NVLink 连接),你使用 Megatron 的张量并行(TP=8)来拆分大矩阵乘法。在跨节点(通过较慢的 InfiniBand 连接)时,你使用 ZeRO-3 或 FSDP2 来分片优化器状态和梯度——这降低了内存压力,且不需要张量并行所要求的高带宽通信。如果模型很深,你还可以添加流水线并行,将层分布到节点组中。
这种分层方法发挥了每种技术的优势。张量并行需要高带宽(因此节点内用 NVLink),但它使单块 GPU 无法容纳的计算成为可能。状态分片能容忍更高的延迟(因此跨节点),但能显著减少每块 GPU 的内存。流水线并行以相对适中的通信开销增加了另一个扩展维度。
状态分片层选择 FSDP2 还是 ZeRO-3,取决于你的生态系统。FSDP2 与 PyTorch 的编译器栈(torch.compile)紧密集成,是 PyTorch 原生的解决方案。ZeRO-3 通过 DeepSpeed 提供了额外功能,如针对内存受限场景的 CPU 和 NVMe 卸载,并拥有成熟的优化生态。两者都能与 Megatron 风格的计算分片干净地结合——它们作用于相互正交的维度。如果你已经在使用带张量并行或流水线并行的 Megatron Core,通常将 Megatron 的分布式优化器或 Megatron-FSDP 接入进来,比把 DeepSpeed ZeRO-3 硬塞到同一个任务中更简单,后者要求进程组保持严格隔离。
不希望手动组装 Megatron 和 DeepSpeed 的团队,也可以评估集成式框架,如 Colossal-AI[11],它集成了混合并行(DP + TP + PP)、ZeRO 风格分片,以及 Gemini[12] 异构内存管理(GPU、CPU 和 NVMe)。
Megatron Core:生产就绪的库
在本章中,我们一直在概念层面讨论 Megatron 的并行策略。但实际中如何真正使用它们呢?答案是 Megatron Core,这是一个从原始 Megatron-LM 研究代码库中提取并针对生产环境优化的库。
Megatron Core 提供了针对 GPU 优化的构建模块:内置张量并行的注意力层、理解流水线边界的 MLP 块、处理词表并行的嵌入层。你无需自己实现列并行和行并行模式——只需使用库中的 ColumnParallelLinear 和 RowParallelLinear,通信会自动处理。
除了基础构建模块之外,Megatron Core 还包含大规模训练所需的基础设施:以计算换内存的激活重计算、高效保存和加载分片模型状态的分布式检查点,以及针对 NVIDIA 最新 GPU(Hopper、Ada、Blackwell)优化的 FP8 精度支持。分布式优化器在数据并行 rank 之间分片优化器状态,与我们讨论的计算分片形成互补。
Megatron Core 需要 Python 包 pybind11 以及系统库 cuDNN 和 NCCL。请先安装它们,然后执行:
pip install --no-build-isolation megatron-core[mlm,dev] pybind11
# Or use NVIDIA's container with cuDNN and NCCL pre-installed
docker run --gpus all -it nvcr.io/nvidia/pytorch:25.04-py3
本章代码目录中的 code/megatron_gpt_pretrain.sh 脚本演示了一个生产配置:跨 GPU 的张量并行、用于内存效率的分布式优化器、用于加速的 Flash Attention,以及我们在本章中讨论过的各种标志。
Megatron-FSDP:优化的状态分片
我们已经确认,状态分片(FSDP/ZeRO)和计算分片(Megatron)是互补的。但当你将它们组合时,实现细节至关重要。PyTorch 的 FSDP2 是一个通用解决方案;它不了解 Megatron 的张量并行或其中涉及的具体通信模式。这正是 Megatron-FSDP 发挥作用的地方。
Megatron-FSDP
Megatron-FSDP 是 NVIDIA 对完全分片数据并行的实现,旨在与 Megatron 的其他并行维度无缝协作。在 NVIDIA 公布的基准测试中,与 PyTorch FSDP2 相比,它实现了约 15-25% 的吞吐量提升和约 23% 的内存节省。这些收益来源于只有当 FSDP 实现理解周围上下文时才能实现的优化——更好的参数分桶、更智能的缓冲区管理,以及更激进的通信与计算重叠。
一个值得注意的技术细节:Megatron-FSDP 使用 NCCL 的 userbuffer 特性来减少通信期间的 GPU 流式多处理器(SM)消耗。在大规模训练中,用于通信的 SM 无法用于计算。这一优化让更多 SM 能够空闲出来执行实际的矩阵乘法。
在训练脚本中启用 Megatron-FSDP 的方法如下:
--use-megatron-fsdp
--data-parallel-sharding-strategy optim_grads_params # Equivalent to ZeRO-3
--overlap-grad-reduce
--overlap-param-gather
你应该在什么时候选择 Megatron-FSDP 而不是 PyTorch FSDP2?如果你已经在使用 Megatron 的张量并行、上下文并行或专家并行,Megatron-FSDP 可以自然集成并提供更好的性能。如果你需要使用 Transformer Engine 进行 FP8 训练,Megatron-FSDP 有原生支持。另一方面,如果你想要一个纯 PyTorch 技术栈,支持 torch.compile 且没有外部依赖,PyTorch FSDP2 是更简洁的选择。
分布式优化器:内存高效的优化
即使张量并行和流水线并行处理了计算,优化器状态仍然是显著的内存负担。例如,Adam 为每个参数存储两个额外的张量(动量和方差)——即每个参数在参数和梯度本身之外额外占用 8 字节。对于一个 70B 模型,仅优化器状态就可能消耗超过 500GB。
Megatron 的分布式优化器通过在数据并行 rank 之间分片优化器状态来解决这个问题,概念上类似于 ZeRO 阶段 1。每个 GPU 只存储一部分参数的优化器状态。在优化器步骤中,梯度被 reduce-scatter(每个 GPU 获得其分片的归约梯度),本地优化器更新其分片,然后通过 all-gather 收集更新后的参数。
内存节省随数据并行规模增长。对于 8 路数据并行,使用 bf16 参数和 fp32 梯度时,每个 GPU 的内存从每个参数 18 字节下降到约 7.5 字节——减少了 2.4 倍。具体公式取决于你的精度配置:
| 配置 | 非分布式 | 分布式(d 个 GPU) |
| --- | --- | --- |
| fp16 参数,fp16 梯度 | 20 字节/参数 | 4 + 16/d 字节/参数 |
| bf16 参数,fp32 梯度 | 18 字节/参数 | 6 + 12/d 字节/参数 |
| fp32 参数,fp32 梯度 | 16 字节/参数 | 8 + 8/d 字节/参数 |
该实现包含了一些超越基本分片的优化。梯度在计算时被复制到连续缓冲区中,从而实现高效的 reduce-scatter 操作。通信可以与反向计算(--overlap-grad-reduce)以及下一次前向传播(--overlap-param-gather)重叠,从而隐藏大部分延迟。
--use-distributed-optimizer
--overlap-grad-reduce
--overlap-param-gather

浙公网安备 33010602011771号