PyTorch 训练框架内核深度解析
第18章 FSDP:ZeRO 风格的参数分片
"FSDP shards module parameters, gradients, and optimizer states across data parallel workers to save memory at the cost of communication."
——
torch.distributed.fsdp.fully_shard的 docstring(torch/distributed/fsdp/_fully_shard/_fully_shard.py:101-103)
本章要点
- DDP 在 70B 模型时崩溃:每张卡需要 280GB(params) + 280GB(grad) + 560GB(Adam state)= 1120GB,单 H100 才 80GB
- FSDP 灵感来自 DeepSpeed ZeRO:把 params / grads / optimizer state 都切到 N 张卡,每张卡只持有 1/N
- 核心机制:forward 时 AllGather 把分片重建成完整 param、用完后立即 reshard;backward 时同理 + 最后 ReduceScatter 同步梯度
- 5 种 ShardingStrategy:
NO_SHARD(=DDP)、SHARD_GRAD_OP(ZeRO-2)、FULL_SHARD(ZeRO-3)、HYBRID_SHARD、_HYBRID_SHARD_ZERO2 - FSDP-1 vs FSDP-2:v2.3 以
torch.distributed._composable.fsdp.fully_shard引入、v2.6 移到公开路径torch.distributed.fsdp.fully_shard,模块化 + 与 torch.compile 兼容性好得多 - prefetch 是性能命脉:在 layer N forward 时 prefetch layer N+1 的 unshard,隐藏通信延迟
18.1 为什么 DDP 不够用
第 17 章 DDP 假设"每张卡放得下完整模型"。70B 模型:
- params:70B × 4B(fp32) = 280 GB
- grads:280 GB
- Adam optimizer state (m + v):560 GB
- 加上 activations 几十 GB
- 合计 1120+ GB
H100 80GB,新一代 Blackwell 单卡也不到 200GB —— 单卡都装不下。这是 DDP 在大模型时代的天花板。
FSDP(Fully Sharded Data Parallel)的解法:把这堆显存按 rank 数 N 均匀切分到 N 张卡。每张卡只持有 1/N 份数据,需要时通过通信临时凑齐完整张量。
8 卡 FSDP:
每张卡 params: 35 GB
每张卡 grads: 35 GB
每张卡 optimizer: 70 GB
合计 ~140 GB / 卡 ← 80GB 仍然装不下
16 卡: ~70 GB / 卡 ← 接近但还要 activation
32 卡: ~35 GB / 卡 ← 舒服了
70B 模型至少 32 张卡才能跑稳。FSDP 的"分片粒度"是这套机制的核心。
18.2 5 种 ShardingStrategy
graph TB
DDP["DDP / NO_SHARD<br/>每卡完整 params + grads + optimizer"]
Z2["ZeRO-2 / SHARD_GRAD_OP<br/>fw 后不 reshard(计算期 params 完整)<br/>grads + optimizer 切片"]
Z3["ZeRO-3 / FULL_SHARD<br/>params + grads + optimizer 全切片<br/>显存最省"]
HZ["HYBRID_SHARD<br/>同节点内 ZeRO-3,跨节点 DDP<br/>跨节点带宽不足时的折中"]
DDP -.显存大.-> Z2
Z2 -.通信增多.-> Z3
Z3 -.通信占比可能太高.-> HZ
style Z3 fill:#dcfce7,stroke:#22c55e,stroke-width:2px
style HZ fill:#fef3c7,stroke:#f59e0b
通信代价:
| Strategy | params 通信 | grads 通信 | optimizer 通信 | 显存 / 卡 |
|---|---|---|---|---|
| NO_SHARD (DDP) | 0 | AllReduce | 0 | 完整 |
| SHARD_GRAD_OP | 1× AllGather(只在 fw;fw 后不 reshard,所以 bw 省掉那次) | ReduceScatter | 0 | 计算期 params 完整;grads + opt 为 1/N |
| FULL_SHARD | 2× AllGather (fw + bw) | ReduceScatter | 0(params 已分片) | 全部 1/N |
| HYBRID_SHARD | 节点内 AllGather | 节点内 RS + 跨节点 AR | - | 1/N (节点内) |
FULL_SHARD 显存最省、通信最多。按 ZeRO 论文(SC'20)的通信量分析,以参数量 Ψ 计:DDP 的一次 AllReduce 约 2Ψ;ZeRO-2 的 ReduceScatter + AllGather 合计仍是 2Ψ;ZeRO-3 在前向、反向各 AllGather 一次参数、再 ReduceScatter 一次梯度,共约 3Ψ,即 DDP 的 1.5 倍。生产代码里 70B+ 通常用 FULL_SHARD 或 HYBRID_SHARD。
18.3 unshard / reshard 的精确时机
FSDP 把参数按 N 切片后,每张卡平时只持有 1/N。但算子需要完整 param 才能算(Linear 需要完整 weight 矩阵做 GEMM)。所以 forward 前要 AllGather 把分片凑成完整 param,算完立即 reshard(释放完整副本,回到 1/N):
sequenceDiagram
autonumber
participant Layer as Layer N forward
participant Comm as AllGather
participant GPU as GPU 显存
Layer->>Comm: 发起 AllGather (异步)
Note over GPU: 此时 GPU 上 layer N 的 param 是分片
Comm->>GPU: AllGather 完成 → 完整 param 在显存
Layer->>GPU: 跑 layer N 的 GEMM
GPU->>GPU: reshard: 立即释放完整 param
Note over GPU: 回到 1/N 状态, 等下次 forward
backward 同理:算每层 grad 前再 AllGather 一次 param,算完 reshard,最后用 ReduceScatter 把这层的 grad 切片同步:
Layer N backward:
1. AllGather param (因为反向也要用 weight 算 grad_input)
2. 计算 grad_input + grad_param
3. ReduceScatter grad_param: 8 卡各自只保留自己的 1/N 切片
4. 释放完整 param
ReduceScatter 是 AllReduce 的"半成品" —— 每个 rank 只拿到 reduce 结果的 1/N 切片,不再 AllGather。这刚好对应 FSDP 的需求:每个 rank 只更新自己持有的 1/N 参数。
18.4 prefetch:隐藏通信延迟
朴素实现里:
fw layer 1 = AllGather + GEMM + reshard (串行)
fw layer 2 = AllGather + GEMM + reshard
...
每层都等 AllGather 完成才开始算 —— 通信延迟全暴露。prefetch 优化:
fw layer 1: AllGather_1 → GEMM_1 (期间 prefetch AllGather_2)
fw layer 2: GEMM_2 (用已经 prefetch 完的 param) → prefetch AllGather_3
...
每层 GEMM 进行时,下一层的 AllGather 在另一个 stream 上跑。理想情况下通信完全 overlap 到计算里,FSDP 性能接近 DDP(如果带宽足够)。
实现上 FSDP 用 dedicated CUDA stream(_unshard_stream、_pre_unshard_stream 在 torch/distributed/fsdp/_runtime_utils.py:263-269)跑 collective,与训练主 stream 并发。
FSDP-1 的 prefetch 行为由 forward_prefetch=True/False 与 backward 的 backward_prefetch=BACKWARD_PRE/POST 控制。BACKWARD_PRE 在当前层梯度计算之前就 prefetch 下一组参数;BACKWARD_POST 在当前层梯度计算之后才 prefetch(显存更省但 overlap 少,见 torch/distributed/fsdp/api.py:72 的 BackwardPrefetch docstring)。
18.5 FSDP-1 vs FSDP-2
PyTorch 推出了新一代 FSDP:fully_shard(也叫 FSDP-2)。它在 v2.3 以 prototype 形态落在 torch.distributed._composable.fsdp(PR #117776 / #118525),v2.6 移到公开路径 torch.distributed.fsdp(PR #141868);v2.11 的源码在 torch/distributed/fsdp/_fully_shard/。它解决了 FSDP-1 的几个痛点:
1. 模块化:FSDP-1 把整个模型 wrap 成一个 FullyShardedDataParallel(model),所有逻辑放一个大类(fully_sharded_data_parallel.py 单文件 2167 行)。FSDP-2 用 fully_shard(submodule) API 给每个 submodule 单独 wrap,更精细控制:
# FSDP-2
from torch.distributed.fsdp import fully_shard
for layer in model.layers:
fully_shard(layer) # 每个 transformer 层单独 shard
fully_shard(model) # root module
2. 与 torch.compile 兼容:Dynamo 会跳过全部 torch.distributed.fsdp 的 FSDP-1 代码、在 FSDP 模块边界 graph break,且只接受 use_orig_params=True(torch/distributed/fsdp/_dynamo_utils.py 的 note、torch/_dynamo/variables/builder.py:2065-2071)。FSDP-2 重新设计了参数管理,hook 可以被 trace 进图(默认仍在图外 eager 执行,见 §18.19)。
3. DTensor 后端:FSDP-2 内部用 DTensor(distributed tensor)抽象,让张量分片成为 first-class concept。
生产代码里 v2.6+ 推荐用 FSDP-2。FSDP-1 仍然保留在 torch.distributed.fsdp.FullyShardedDataParallel,但新功能(如 set_custom_all_gather / set_custom_reduce_scatter 自定义 collective、shard_placement_fn 按参数指定分片维)只在 FSDP-2 上加。
18.6 mixed precision:FSDP 的另一大优化
FSDP 提供专门的 MixedPrecision 配置:
from torch.distributed.fsdp import MixedPrecision
mp_policy = MixedPrecision(
param_dtype=torch.bfloat16, # AllGather 时用 bf16 通信
reduce_dtype=torch.float32, # ReduceScatter 时用 fp32 (避免梯度精度损失)
buffer_dtype=torch.bfloat16,
)
model = FSDP(model, mixed_precision=mp_policy)
上面是 FSDP-1 的 MixedPrecision(torch/distributed/fsdp/api.py:112,字段 param_dtype / reduce_dtype / buffer_dtype)。FSDP-2 对应的是 MixedPrecisionPolicy(_fully_shard/_fsdp_api.py:15),字段是 param_dtype / reduce_dtype / output_dtype / cast_forward_inputs —— 没有 buffer_dtype,通过 mp_policy= 传给 fully_shard。
为什么这个比朴素 model.bfloat16() 好?因为 FSDP 控制每种张量的 dtype 独立:
- params 在 bf16(省一半 AllGather 带宽)
- gradients 在 fp32 reduce(保证数值稳定)
- master weights 仍是 fp32(让 Adam 更新精确)
这套"分别控制"是大模型训练 fp32+bf16 混合精度的标配。第 20 章量化与混合精度会展开。
18.7 DeviceMesh:拓扑的 first-class 表达
FSDP-2 之前,分布式训练靠 ProcessGroup 表达"哪些 rank 参与同一通信"。混合并行(DP + TP + PP)时要手工管理多个 group,容易出错。
torch.distributed.device_mesh(1553 行)引入 DeviceMesh 抽象:把 N 个 rank 排成 K 维网格,每维有自己的 ProcessGroup。
from torch.distributed.device_mesh import init_device_mesh
# 32 卡 = 4 节点 × 8 卡
# 节点内 8 卡 ZeRO-3, 跨节点 4 个 replica DDP
mesh_2d = init_device_mesh(
device_type="cuda",
mesh_shape=(4, 8),
mesh_dim_names=("replica", "shard"),
)
# 取出某一维的 ProcessGroup
shard_pg = mesh_2d["shard"].get_group() # 节点内 8 卡 group
replica_pg = mesh_2d["replica"].get_group() # 跨节点 4 卡 group
每个 rank 在 mesh 里有 K 个坐标(如 (replica=2, shard=5))。在某一维上的 collective(如 shard.all_gather)只在那一维的 group 内做,不涉及其他维。
DeviceMesh 让 HSDP / 3D parallel(DP + TP + PP)的实现从"手动管 N 个 group"变成"声明一个 mesh 然后取维度"。这是 FSDP-2 / DTensor 的基础。
18.8 DTensor:分片张量的 first-class 类型
FSDP-2 内部不直接持有 plain Tensor,而是 DTensor(distributed tensor)。一个 DTensor 由三部分组成:
from torch.distributed.tensor import DTensor, Shard, Replicate
dtensor = DTensor.from_local(
local_tensor, # 本 rank 持有的切片
device_mesh, # 它在哪个 mesh 上
placements=[Shard(0)] # 在 mesh 维度上怎么分布: Shard / Replicate / Partial
)
三种 Placement:
Shard(dim):按某 dim 切到 mesh 上,每 rank 持有 1/NReplicate():每 rank 都有完整副本Partial:每 rank 持有"部分和",下次访问前需 reduce
DTensor 算子(dtensor + dtensor)会自动选最优通信策略:如果两边都是 Shard(0),相加无需通信;如果一个 Shard(0) 一个 Replicate,会把 Replicate 那边就地切成 Shard(0) 再加,结果仍是 Shard(0),同样不通信(本机 gloo 两进程实测,CommDebugMode 记录的 collective 数为 0)。需要通信时(如显式 redistribute 到 Replicate())才发 collective。这套自动 dispatch 让用户不用手写 collective。
FSDP-2 把每个 nn.Parameter 包成 DTensor(local_shard, mesh, [Shard(0)]) 作为常驻的分片表示;但 unshard 并不走 DTensor.redistribute:它把同一 param group 里所有参数的本地分片 copy-in 到一块扁平 buffer,发一次 AllGather(torch/distributed/fsdp/_fully_shard/_fsdp_collectives.py 的 foreach_all_gather),再 copy-out 成普通的 unsharded nn.Parameter 挂回 module(torch/distributed/fsdp/_fully_shard/_fsdp_param.py 的 to_unsharded)。本机 gloo 实测:forward 内部看到的 weight 是 Parameter、形状完整,forward 结束后又变回 DTensor。DTensor 负责表示"分片后是什么",调度与通信由 FSDP-2 自己完成。
18.9 HSDP 的具体拓扑
HYBRID_SHARD 的关键是用 2D mesh:节点内 shard、跨节点 replicate:
mesh = init_device_mesh("cuda", (2, 8), mesh_dim_names=("inter", "intra"))
# 配 FSDP-2: 节点内 (intra) shard, 跨节点 (inter) replicate
fully_shard(model, mesh=mesh)
通信模式:
- forward 的 AllGather 只在 intra 维(节点内),走 NVLink(带宽以百 GB/s 计,具体值随卡与代际差别很大)
- backward 的 ReduceScatter 也在 intra 维
- backward 完成后额外做一次 AllReduce 在 inter 维(跨节点)同步梯度
这条策略让"高带宽节点内通信"承担分片代价、"低带宽跨节点"只做一次梯度同步。在节点内带宽远大于跨节点的集群上,HSDP 比纯 FULL_SHARD 更快 —— 具体幅度取决于节点内外带宽比与模型规模,须自己 profile。
18.10 _unshard 的 stream 编排
打开 torch/distributed/fsdp/_runtime_utils.py:277 的 _unshard(节选:删去类型注解、docstring 与 rate limiter 外层的 record_function,注释为本专栏所加):
def _unshard(state, handle, unshard_stream, pre_unshard_stream) -> None:
if not handle:
return
with state._device_handle.stream(pre_unshard_stream):
# 1. 在 pre-unshard stream 上做准备(mixed precision 的低精度 cast / CPU offload 的 H2D 拷贝)
ran_pre_unshard = handle.pre_unshard()
if ran_pre_unshard:
# 2. wait_stream 让 unshard stream 等 pre-unshard 完成
unshard_stream.wait_stream(pre_unshard_stream)
if state.limit_all_gathers:
event = state._free_event_queue.dequeue_if_needed()
if event:
event.synchronize() # rate limiter: 限制在飞的 AllGather 数量
with state._device_handle.stream(unshard_stream):
handle.unshard() # 3. 在 unshard stream 上分配 padded buffer 并发起 AllGather
handle.post_unshard()
两个 stream 的分工:
pre_unshard_stream:跑 mixed precision 的低精度 cast、CPU offload 的 H2D 拷贝等"准备工作"unshard_stream:分配 AllGather 的目标 buffer,并跑 AllGather 本身
(分工以 torch/distributed/fsdp/_runtime_utils.py:261-269 建 stream 处的注释为准。)为什么不放在主 stream?因为那样会阻塞下一层 forward;独立 stream 让这些工作与主 stream 上的计算 overlap。
中间那段 limit_all_gathers 分支是 rate limiter:靠 _free_event_queue(_limiter_utils.py)限制同时在飞的 unsharded buffer 数量,避免 prefetch 过深把显存打爆。
wait_stream 是 CUDA 的 stream-to-stream 同步原语:让 unshard stream 等 pre-unshard stream 上之前发的 op 完成。这种"多 stream 协同"是 FSDP 性能的关键 —— 配合 prefetch(§18.4),让通信完全隐藏到计算里。
18.11 activation_checkpoint × FSDP 的协作
第 7 章 §7.5.4 讲过 activation_checkpoint:前向不保存中间激活,反向时重新 forward 一遍取回。这套机制配 FSDP 用时多一层复杂度。
考虑 FSDP+checkpoint 的反向流程:
- 正常 forward(FSDP layer N):
- AllGather 凑完整 param → forward 计算(不存 activation)→ reshard 释放完整 param
- 反向走到 layer N:
- 需要重新 forward 取 activation → 但 param 已经被 reshard!
- 必须 再次 AllGather 凑完整 param → 重 forward → 拿到 activation → backward
- 完成后又 reshard
sequenceDiagram
autonumber
participant FW as Forward layer N
participant Mem as 显存
participant BW as Backward layer N
FW->>Mem: AllGather param (1)
FW->>FW: 跑 forward, 不存 activation
FW->>Mem: reshard param (释放完整副本)
Note over BW: 反向到 layer N
BW->>Mem: AllGather param (2) ← pre-backward hook 里这一次
BW->>BW: 重 forward 拿到 activation(复用这份完整 param,不再 unshard)
BW->>BW: backward 算梯度
BW->>Mem: reshard param
BW->>Mem: ReduceScatter grad
这里要破掉一个很常见的误解:重 forward 并不会再多发一次 AllGather。_runtime_utils.py 在 pre-forward 与 post-forward 两处都有同一条守卫(:373-380 与 :468-472)——注释写的就是 "For fully_shard + checkpoint, skip pre-forward logic in the recomputed forward",条件是 handle._training_state == HandleTrainingState.BACKWARD_PRE。也就是说重 forward 既不 unshard 也不 reshard,直接复用 pre-backward hook 刚 AllGather 出来的那份完整 param。
FSDP-2 在 torch/distributed/fsdp/_fully_shard/_fsdp_state.py:246-255 与 :276-279 有同样的守卫(TrainingState.PRE_BACKWARD 时跳过;只有嵌套 FSDP 且参数已被先前的 post_backward reshard 这种边角情形才补一次 unshard);本机 gloo 两进程实测 apply_activation_checkpointing + fully_shard,3 个 block 开不开 checkpoint 都是 forward 3 次、backward 3 次 AllGather。
所以一个 checkpointed layer 每步仍然是 forward 1 次 + backward 1 次 = 2 次 AllGather,和不开 checkpoint 的 FULL_SHARD 一样。activation checkpointing 换的是"算力换 activation 显存",不是"通信换显存" —— 它多出来的成本是重跑一遍 forward 的 FLOPs。
torch/distributed/algorithms/_checkpoint/checkpoint_wrapper.py:112 的 CheckpointWrapper 是 PyTorch 给 FSDP 的官方 checkpoint 接口。它的关键设计:
apply_activation_checkpointing(model, ...)(:239) 递归给每个匹配check_fn的 module 套上CheckpointWrappercheckpoint_wrapper(module, checkpoint_impl=...)(:196)的默认值是CheckpointImpl.NO_REENTRANT,即非重入实现:用saved_tensors_hooks(第 7 章 §7.5.3)实现,与 FSDP / torch.compile 兼容性好- 老的重入实现(
use_reentrant=True)用torch.autograd.Function(第 7 章 §7.8),与 FSDP 反向 hook 互动有 corner case,新代码避开 - 注意底层的
torch.utils.checkpoint.checkpoint至今没有把默认切成False:use_reentrant默认None,走告警后仍回落到True(torch/utils/checkpoint.py:480-490)。自己直接调checkpoint()时务必显式传use_reentrant=False
实战决策:开 FSDP 后是否再加 activation_checkpoint?看显存。FSDP 只管 params / grads / optimizer state,activation 那一块它一点都省不到 —— 大 batch、长序列下这部分能吃掉相当可观的显存。activation_checkpoint 按 block 粒度只保留边界处的激活、其余重算,省下的比例取决于 block 内部有多少中间张量。代价是每个 checkpointed block 的 forward 要多跑一遍(通信次数不变,见上)。GPU 时间比显存便宜时这笔账就划算 —— 大模型训练里几乎默认开。
apply_activation_checkpointing 的 check_fn 通常设成"是 transformer block 就 wrap":
from functools import partial
apply_activation_checkpointing(
model,
check_fn=lambda m: isinstance(m, TransformerBlock),
)
这种"按 block 粒度 checkpoint"是 LLM 训练的标准做法。block 内部不 checkpoint —— 否则 attention / mlp 各自重 forward,重算的 FLOPs 与 checkpoint 自身的记账开销都会失控。
18.12 FlatParameter (FSDP-1):把多个 param 平铺成一个大张量
FSDP-1 内部不直接管理用户的 nn.Parameter,而是把同一 wrap unit 内的多个 param 平铺合并成一个 FlatParameter:
# 用户 module 有 3 个 param
linear.weight shape [768, 768] fp32
linear.bias shape [768] fp32
norm.weight shape [768] fp32
# FSDP-1 合并 (flatten + concat)
flat_param = FlatParameter(torch.cat([
linear.weight.flatten(), # 589824 元素
linear.bias.flatten(), # 768
norm.weight.flatten(), # 768
])) # 共 591360 元素
torch/distributed/fsdp/_flat_param.py:202 的 FlatParameter 是 nn.Parameter 子类,元数据里记着每个原始 param 的 shape / stride / numel / FQN 等(dtype 必须统一,torch/distributed/fsdp/_flat_param.py:815 会拒绝 dtype 不一致的参数)。访问 linear.weight 时通过 view 从 FlatParameter 取出对应区段。
为什么要 flatten?
- AllGather 能一次拉所有 param(vs 分别拉每个 param 的 N 次 collective)
- shard 时只切一次(按 1/N 切 FlatParameter,不是切几十个 param)
- memory 连续让 GPU memory bandwidth 利用率高
代价是 view 重建复杂、与 torch.compile 兼容性差。FSDP-2 抛弃 FlatParameter,每个 param 用 DTensor 直接 shard —— 与 compile 兼容性大幅提升;collective 数量并没有变多:同一次 fully_shard 管的参数仍 copy-in 到一块扁平 buffer、发一次 AllGather(本机 gloo 实测 4 个各含 weight + bias 的 unit,forward 共 4 次 AllGather),代价换成了 copy-in / copy-out 两次拷贝。
理解 FlatParameter 让你看到 FSDP-1 用 LOCAL_STATE_DICT 存出的 ckpt 里那些"奇怪 key"(如 0._flat_param,本机 gloo 实测)时不困惑 —— 那是 flat 后的合并张量;FULL_STATE_DICT / SHARDED_STATE_DICT 则还原成原始参数名。
18.13 auto_wrap_policy:决定 sharding 粒度
auto_wrap_policy 让 FSDP-1 自动决定哪些子 module 各自成为一个 wrap unit(FSDP-2 的 fully_shard 没有这个参数,粒度由你对哪些 submodule 调 fully_shard 决定)。粒度选择:
- 太粗(整个 model 一个 unit):unshard 一次拉全部参数,参数显存峰值与不分片相同
- 太细(每个 Linear 一个 unit):每层 unshard 一次 collective,通信开销爆炸
- 合适粒度:每个 transformer block 一个 unit,平衡 collective 数量与显存峰值
torch/distributed/fsdp/wrap.py:178 的 ModuleWrapPolicy 是常用 policy:按 module 类型选择 wrap unit。
# FSDP-1:交给 auto_wrap_policy
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp.wrap import ModuleWrapPolicy
policy = ModuleWrapPolicy({TransformerBlock}) # 每个 TransformerBlock 一个 unit
model = FSDP(model, auto_wrap_policy=policy)
# FSDP-2:手动按同样粒度逐个 fully_shard
for blk in model.layers:
fully_shard(blk)
fully_shard(model)
其他 policy(都在 torch/distributed/fsdp/wrap.py):
size_based_auto_wrap_policy(:341):按参数量阈值(默认min_num_params=1e8)决定transformer_auto_wrap_policy(:298):按transformer_layer_cls集合匹配,语义上等价于ModuleWrapPolicy- 自定义 callable policy
LLM 训练几乎都是"每个 transformer block 一个 unit" —— 与 activation_checkpoint 的 wrap 粒度对齐,让两者协作最优。
18.14 lazy init 与 meta device:训练前不分配显存
70B 模型直接 model = LlamaModel(config) 会立即在 GPU 上分配 280GB params —— 单卡装不下、初始化崩。
torch.device("meta") 是个"假 device",张量只有 shape / dtype 元信息、没有实际数据。FSDP-2 配 meta device 流程:
with torch.device("meta"):
model = LlamaModel(config) # 不分配显存
fully_shard(model, mesh=mesh, ...) # FSDP 用 meta module 构造 sharded param
model.to_empty(device='cuda') # 把 meta param 替换成真实显存 (1/N 大小)
init_model_weights(model) # 用户实现的 init 函数 (按 rank 自己 init 那 1/N)
整个流程全程没有"完整 280GB"在任何 rank 显存里出现。这套 meta 初始化让 70B+ 模型在 80GB 显存的卡上也能启动训练 —— 每卡只物化自己那 1/N(FSDP-1 对应的入口是 param_init_fn 参数)。本机 gloo 两进程实测:meta 上 fully_shard 后 to_empty(device="cpu"),每 rank 的本地分片形状为 [4, 8](原 weight [8, 8])。
to_empty() 是个特殊 API:把 meta param 替换成同 shape 的真实张量但不初始化。后续用户调 init_model_weights 在每个 rank 上各自 init 自己持有的那 1/N 数据。这种"分片 init"避免了"先 init 完整 model 再切"的中间显存峰值。
18.15 CPU Offload:参数 / optimizer state 卸到 CPU
显存极紧张时(如单卡训练超大模型),FSDP 提供 cpu_offload:
# FSDP-2
from torch.distributed.fsdp import CPUOffloadPolicy, fully_shard
fully_shard(model, offload_policy=CPUOffloadPolicy(pin_memory=True))
# FSDP-1 的对应写法
from torch.distributed.fsdp import CPUOffload, FullyShardedDataParallel as FSDP
model = FSDP(model, cpu_offload=CPUOffload(offload_params=True))
注意两代 API 名字不同:FSDP-1 是 cpu_offload=CPUOffload(offload_params=...),FSDP-2 是 offload_policy=CPUOffloadPolicy(pin_memory=...)(_fully_shard/_fsdp_api.py:131 的 OffloadPolicy / :139 的 CPUOffloadPolicy)。
机制:
- params 平时存在 CPU RAM(host memory)
- forward 时分片 CPU → GPU 拷贝 + AllGather + GPU 跑 forward;reshard 时直接释放 GPU 上的完整副本(参数没变,不需要拷回)
- backward 同样先 H2D + AllGather;ReduceScatter 后的梯度分片 GPU → CPU 拷回
- optimizer step 在 CPU 上跑,optimizer state 也在 CPU(
CPUOffloadPolicydocstring:offloads parameters, gradients, and optimizer states to CPU)
代价是每步多出参数 H2D、梯度 D2H 拷贝,走 PCIe,比 NVLink 上的 AllGather 慢一个量级。整个训练吞吐会显著下降(具体幅度取决于 PCIe 代数与模型规模,须自己 profile),但能让"放不下的模型放下"。
optimizer state 不需要单独的开关:开了 offload 后 sharded param 与 grad 本来就在 CPU,optimizer 在这些 CPU 张量上建出的 exp_avg / exp_avg_sq 自然也在 CPU(FSDP-1 的 CPUOffload(offload_params=True) 同理,docstring 写明 optimizer step runs on CPU)。
实战:能用多卡分摊就用多卡(HSDP),cpu_offload 是"硬件不够时的最后兜底"。生产 70B 训练几乎从不用 cpu_offload —— 性能损失太大、不如多租几张卡。
18.16 state_dict_type:完整 vs 分片
FSDP-1 常用的 state_dict 视图有两种(另有保存 FlatParameter 原样分片的 LOCAL_STATE_DICT):
StateDictType.FULL_STATE_DICT(枚举成员在torch/distributed/fsdp/api.py:270,配套的FullStateDictConfig在torch/distributed/fsdp/api.py:293):rank 0 收集所有 rank 的分片,组装成完整 state_dict(与未 shard 的 model 视图等价)StateDictType.SHARDED_STATE_DICT(torch/distributed/fsdp/api.py:272,配套ShardedStateDictConfig在torch/distributed/fsdp/api.py:340):每 rank 输出自己持有的 1/N 切片
from torch.distributed.fsdp import StateDictType
# 完整 state_dict (rank 0 持有完整, 其他 rank 空)
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT):
sd = model.state_dict()
if rank == 0:
torch.save(sd, "ckpt.pt")
完整 state_dict 简单但 rank 0 要装下完整模型(70B = 280GB),实战不可行。
# 分片 state_dict (每 rank 写自己那 1/N)
with FSDP.state_dict_type(model, StateDictType.SHARDED_STATE_DICT):
sd = model.state_dict()
dcp.save(sd, checkpoint_id="ckpt_dir") # import torch.distributed.checkpoint as dcp
分片 state_dict 与第 19 章 §19.6.2 的 DCP 配合,每 rank 并行写自己那份 —— 这是 70B+ 训练 ckpt 的标配。SHARDED_STATE_DICT 写出的不是 ddp model 的视图,而是分片张量字典:FSDP-1 默认每个 entry 是 ShardedTensor(构造 FSDP 时传了 device_mesh 才换成 DTensor,见 torch/distributed/fsdp/_state_dict_utils.py 的 _sharded_post_state_dict_hook;本机 gloo 实测默认得到 ShardedTensor);FSDP-2 的 model.state_dict() 直接就是 DTensor。DCP 知道怎么序列化这两类分片张量,加载时按当前拓扑重建。
18.17 summon_full_params:临时凑齐完整参数
某些操作(如 model surgery、debug print 完整 weight)需要完整参数。FSDP.summon_full_params context manager 让 FSDP 临时 unshard 整个 group:
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
with FSDP.summon_full_params(model, recurse=True):
# 此时所有 param 都是完整的 (每 rank 都有完整副本)
print(model.linear.weight.shape) # [hidden, hidden] 完整尺寸
实现机制:进入 with 块时触发对所有 wrap unit 的 AllGather → 完整副本临时驻留显存 → 退出时立即 reshard。显存峰值临时翻 N 倍,所以 70B 模型 8 卡 unshard 后单卡瞬间需要 280GB —— 不可行。生产 summon_full_params 用于小 unit / 测试。
writeback=True(默认值)让退出时把完整 param 写回到分片(用于 model surgery 后保存改动)。offload_to_cpu=True 让完整副本驻留 CPU 而非 GPU(省 GPU 但慢)。
18.18 FSDP × clip_grad_norm
第 17 章 §17.35 提过 DDP 下 clip_grad_norm_ 直接用就行(grad 已经全局平均)。FSDP 不行:每 rank 持有 1/N 的 grad,本地 norm 不是全局 norm。
FSDP-1 把它做成 FSDP 包装实例自己的方法——clip_grad_norm_(self, max_norm, norm_type=2.0),带 @torch.no_grad()(torch/distributed/fsdp/fully_sharded_data_parallel.py:1074):
# 调用侧用法示例,不是 PyTorch 源码
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
model = FSDP(model)
model.clip_grad_norm_(max_norm=1.0)
内部实现:
- 每 rank 算 local grad norm²(即 sum of squares)
- AllReduce sum 得全局 norm²
- 算 sqrt 得全局 norm
- 每 rank 用全局 norm 缩放自己的 grad 切片
只多一次 AllReduce 标量(极小开销),数学上与 single-rank clip 等价。FSDP-2 没有自己的 clip_grad_norm_:它的 param / grad 都是 DTensor,直接用顶层 torch.nn.utils.clip_grad_norm_,norm 的求和由 DTensor 的算子规则自动变成跨 rank 的 AllReduce。
18.19 FSDP × torch.compile:FSDP-2 的兼容路径
FSDP-1 与 torch.compile 兼容性差(Dynamo 跳过全部 FSDP-1 代码、在每个 FSDP 模块边界 graph break,§18.5)。FSDP-2 重新设计后可以直接套 compile:
fully_shard(model, mesh=mesh)
compiled_model = torch.compile(model)
但先要分清两种模式:
- 默认模式:
torch._dynamo.config.skip_fsdp_hooks默认True(torch/_dynamo/config.py:413),FSDP-2 的 hook 被torch._dynamo.disable包住(torch/distributed/fsdp/_fully_shard/_fsdp_state.py:60-72的disable_if_config_true),在编译图外以 eager 执行 —— AllGather / ReduceScatter 不进图,图里只有计算,hook 处会 graph break(本机 gloo 两进程 +backend="aot_eager"实测,graph break 原因正是这个disable) - Traceable FSDP2:把
skip_fsdp_hooks设为False并开启 compiled autograd,hook 才会被 trace 进图。FSDP-2 的调度全挂在标准 hook 上:register_forward_pre_hook/register_forward_hook管 unshard-reshard,输出张量上的Tensor.register_hook触发 pre-backward,引擎的queue_callback收尾;eager 下负责 post-backward 的RegisterPostBackwardFunction(torch/distributed/fsdp/_fully_shard/_fsdp_param_group.py:876)在这一模式下不再被调用,改由下一个 param group 的 pre-backward 与 root 的root_post_backward_callback补做(见该类的报错说明);all-gather 的 copy-in / copy-out 是torch.ops.fsdp.*自定义算子(torch/distributed/fsdp/_fully_shard/_fsdp_collectives.py),Inductor 另有专门的图 pass(torch/_inductor/comms.py的reinplace_fsdp_all_gather、remove_fsdp2_unsharded_param_graph_input_usage)
本机没有 GPU,无法给出实测倍数。能确定的是:两代 FSDP 在默认配置下通信都在图外;FSDP-2 的优势在于参数就是普通 Tensor / DTensor、没有 FlatParameter view,且提供了把 collective 纳入编译图的 Traceable FSDP2 路径。新代码用 FSDP-2,老代码尽快迁移。
18.20 BACKWARD_PRE vs BACKWARD_POST prefetch
§18.4 提过 prefetch 有两种模式:
BackwardPrefetch.BACKWARD_PRE(推荐):layer N+1 的梯度计算开始前就 prefetch layer N。overlap 最多,显存占用也最高(峰值同时持有当前与下一组参数)BackwardPrefetch.BACKWARD_POST:layer N+1 的梯度计算结束后才 prefetch layer N。overlap 少但显存峰值更低
BACKWARD_PRE 是默认,几乎所有场景最优。BACKWARD_POST 仅在显存极紧张时考虑(少 overlap 但少一份 unsharded param 同时驻留)。
forward_prefetch=True 是另一档(FSDP-1 参数,默认 False):forward 时也 prefetch 下一个 unit 的 AllGather,代价是多一份 unsharded param 同时驻留、显存峰值上升。FSDP-2 没有这个开关:它默认就有隐式 forward 预取(下一组的 copy-in / AllGather 放在独立 stream 上,与当前计算重叠,见 torch/distributed/fsdp/_fully_shard/_fsdp_param_group.py 开头的 implicit prefetching 注释),backward 则默认按 post-forward 的逆序预取;要更激进时用显式的 set_modules_to_forward_prefetch(modules) / set_modules_to_backward_prefetch(modules)(torch/distributed/fsdp/_fully_shard/_fully_shard.py:418 / :438)指定 prefetch 目标 —— docstring 说只传下一个模块时 overlap 与默认相同、只是 CPU 更早发出 AllGather,传两个以上才更激进、也更占显存。
18.21 ZeRO-2 (SHARD_GRAD_OP) 的具体节省机制
§18.2 表里讲 ZeRO-2 是"计算期 params 完整、grads + optimizer 切片"。具体怎么实现?
这里要先纠正一个流传很广的说法:PyTorch 的 SHARD_GRAD_OP 不是"params 根本不切、forward 不用 AllGather"。ShardingStrategy 的 docstring 写得很直白(torch/distributed/fsdp/api.py:43-49):"parameters are sharded outside computation... this strategy unshards before the forward, does not reshard them after the forward, and only reshards them after the backward computation"。所以:
- forward:params 在计算之外仍是 1/N,forward 前照样要 AllGather 成完整 param —— 与 FULL_SHARD 的 forward 完全一样
- forward 之后不 reshard:完整 param 一直留在显存里,所以 backward 省掉了 FULL_SHARD 那次 pre-backward AllGather。这才是 ZeRO-2 相对 ZeRO-3 真正省下的通信
- backward:每 rank 算自己 batch 的本地 grad → ReduceScatter(不是 AllReduce!)让每 rank 拿到全局 grad 的 1/N 切片,然后才 reshard params
- optimizer step:每 rank 只更新自己持有的 1/N 参数(用 1/N grad + 1/N optimizer state);下一次 forward 的 AllGather 自然会把更新后的分片拼回来,没有单独一次"step 后 AllGather"
FSDP-2 把同一件事表达成 reshard_after_forward=False,它的 docstring 也是同一句话:"keeps the unsharded parameters in memory after forward and avoids the all-gather in backward"(torch/distributed/fsdp/_fully_shard/_fully_shard.py:152-154)。
这套流程让 grads 与 optimizer state 各只占 1/N(与 ZeRO-3 同),而完整 params 从 forward 一直驻留到 backward 结束(vs ZeRO-3 的用完即还)。以 fp32 + Adam 的经典账算(params : grads : optimizer state = 1 : 1 : 2,共 4 份),ZeRO-2 把每卡的 grads 与 optimizer state 各降到 1/N,N 足够大时每卡只剩那 1 份完整 params —— 省掉的是 4 份里的 3 份,同时保留完整 params 的灵活性。
适用场景:模型刚好能装下 params 但 optimizer state(Adam 是 params × 2)装不下。比如 13B 模型 fp32 params 52GB(单张 80GB 卡放得下)但 Adam state 104GB(放不下)—— ZeRO-2 完美。
18.22 NO_SHARD:FSDP 退化成 DDP
ShardingStrategy.NO_SHARD 让 FSDP 不分片任何东西,等价于 DDP:
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP, ShardingStrategy
FSDP(model, sharding_strategy=ShardingStrategy.NO_SHARD)
# 等价于
DDP(model)
注意 ShardingStrategy 是 FSDP-1 的枚举(torch/distributed/fsdp/api.py:32,成员 FULL_SHARD / SHARD_GRAD_OP / NO_SHARD / HYBRID_SHARD / _HYBRID_SHARD_ZERO2)。FSDP-2 的 fully_shard 没有 sharding_strategy 参数 —— 对应能力由 mesh 的维数与 reshard_after_forward 表达。
这个看似无意义的选项有真实工程价值:
- 统一接口:训练框架可以"用 FSDP 一套接口表达 DDP / ZeRO-2 / ZeRO-3 / HSDP",配置切换简单
- fallback 路径:HSDP 的跨节点维度本质是 NO_SHARD(跨节点不切,节点内才切)
- debugging:怀疑 FSDP 引入的 bug 时切到 NO_SHARD 看是否消失
NO_SHARD 让 FSDP-1 的接口涵盖 DDP 全部功能。FSDP-2 换了一套表达:不分片就是不对该 module 调 fully_shard,ZeRO-2 语义则由 reshard_after_forward=False 表达。
18.23 use_orig_params=True:兼容老 optimizer
FSDP-1 用 FlatParameter(§18.12)会让用户原本的 nn.Parameter 引用失效 —— optimizer 创建时拿到的是 user param,FSDP wrap 后这些 param 引用指向已经"被合并"的位置。
use_orig_params=True 让 FSDP-1 保留原始 param 引用:用户构造 optimizer 时传 model.parameters(),FSDP 内部建立 orig_param ↔ FlatParameter 切片的映射。optimizer step 时按 orig_param 更新,FSDP 自动写回 FlatParameter。
这条选项让"现有 optimizer 代码不改"就能上 FSDP。代价是内部多一层映射开销(小)。生产代码强烈建议开启。FSDP-2 默认行为就这样(每个 param 独立 DTensor,根本不需要这个选项)。
18.24 FSDP × 3D parallel (DP + TP + PP)
70B+ 训练经常需要 3D parallel:
- Data Parallel (DP):FSDP 在 DP 维度上分片
- Tensor Parallel (TP):把单层 weight 切到多卡(如 attention QKV 切 4 路)
- Pipeline Parallel (PP):把多层切到多卡,流水线执行
DeviceMesh(§18.7)让 3D 配置变简单:
mesh = init_device_mesh("cuda", (PP, DP, TP), mesh_dim_names=("pp", "dp", "tp"))
# 先 TP:在 tp 维度上切单层 weight
# tp_plan 形如 {"attn.q_proj": ColwiseParallel(), "attn.o_proj": RowwiseParallel(), ...}
parallelize_module(model, mesh["tp"], tp_plan)
# 再 FSDP:在 dp 维度上 shard(此时参数已是 TP 的 DTensor,FSDP-2 在其上再叠一层 dp 分片)
fully_shard(model, mesh=mesh["dp"])
# PP 用 mesh["pp"] 配 PipelineStage
3D parallel(再加上序列维就是 4D)是 405B 级别训练的基础 —— Llama 3 405B 论文(arXiv:2407.21783)报告用了最多 16K 张 H100。每个维度的 group 用 functional collectives(第 16 章 §16.12)通信,互不干扰。
实战参数选择:8 卡节点内 TP=8(NVLink 高带宽)+ 4 节点 DP(FSDP)+ 4 PP(跨更多节点)= 128 卡训练。具体取决于模型与硬件拓扑。
18.25 FSDP × LoRA:高效微调
LoRA(Low-Rank Adaptation)只训练注入的低秩矩阵 A、B,冻结原始 weight。FSDP 与 LoRA 的协作:
# 冻结 base model 参数
for p in base_model.parameters():
p.requires_grad = False
# 注入 LoRA adapter (有 grad)
inject_lora(base_model, rank=8)
# FSDP wrap (只 shard 有 grad 的部分?)
fully_shard(base_model, ...)
关键问题:FSDP 默认 shard 所有 params,包括 frozen 的 base weights。但 frozen weights 不需要 grad / optimizer state,shard 浪费空间吗?
实际不浪费 —— frozen weights 仍要 unshard:forward 要用,backward 里算 grad_input 也要用(梯度得穿过它们流到前面层的 LoRA adapter);但它们没有 grad 要 ReduceScatter、也没有 optimizer state,显存账上反而占更少。FSDP 在内部识别 requires_grad=False 的 param、跳过它们的 grad 处理。
LoRA + FSDP 是 fine-tuning 70B 模型的标配 —— base model 冻结后只训几十兆 LoRA 参数,FSDP 让 base model 分片到多卡能装下。
18.26 FSDP × gradient accumulation
显存不够调 batch size 时常用 gradient accumulation:跑 N 个 micro batch 累积梯度、再一次 step。
FSDP 下的微妙点:每 micro batch 默认会触发 ReduceScatter 同步梯度。N 次 micro batch = N 次 collective —— 浪费。
FSDP-1 用 with model.no_sync():(torch/distributed/fsdp/fully_sharded_data_parallel.py:1034);FSDP-2 没有 no_sync,改成显式开关 set_requires_gradient_sync(bool)(_fully_shard/_fully_shard.py:319,docstring 里直接写着 This is the equivalence of no_sync in FSDP1):
# FSDP-2
model.set_requires_gradient_sync(False)
for i in range(accumulation_steps - 1):
loss = model(batch[i])
loss.backward() # 不触发 ReduceScatter / AllReduce, 只本地累积
model.set_requires_gradient_sync(True)
loss = model(batch[-1])
loss.backward() # 这次正常触发 ReduceScatter
optimizer.step()
它同时关掉 reduce_grads 与 all_reduce_grads(HSDP 下两维一起关)。N-1 次 micro batch 跳过 collective,只在最后一次同步累积总梯度:通信次数从 N 降到 1。
DDP 那边仍是 no_sync 上下文管理器,思想一致。
18.27 FSDP × 量化训练 / 推理
第 20 章会讲量化。FSDP 与量化协作有几个工程点:
训练时 fp8:把 AllGather 的通信 dtype 降到 fp8(8 bit)能比 bf16 再省一半带宽。但 fp8 数值范围窄、需要 per-tensor scale,MixedPrecisionPolicy.param_dtype 本身只管 cast、不管 scale —— 要正确处理 scale 得靠 torchao 的 float8 或 NVIDIA TransformerEngine 这类库把量化逻辑塞进 param 的表示里,再让 FSDP-2 去 AllGather 那个已量化的表示。
推理时 INT8:FSDP shard 的 model 直接量化遇到障碍 —— 量化要看完整张量做 calibration。一般做法:训练用 FSDP / 量化前把 model 收到单卡 / 量化后再加载(不分片或者用 PT2E 静态图)。
LLM 推理几乎不用 FSDP(vLLM / SGLang 用 TP + KV cache 管理替代)。FSDP 主要是训练时的工具,推理时通常切换到其他并行策略。
18.28 FSDP root module 的特殊性
FSDP 中的"root module"(最外层 fully_shard 的 module)有几个特殊职责:
- 管理整个 FSDP unit 树:root 持有所有子 unit 的引用,调度它们的 unshard / reshard 时机
- 管理 stream:root 在 lazy init 里建好通信 stream,子 unit 共用(FSDP-2 是
FSDPCommContext的all_gather_copy_in_stream/all_gather_stream/reduce_scatter_stream/all_reduce_stream;FSDP-1 是_unshard_stream/_pre_unshard_stream等) - 触发 root_pre_forward:第一次 forward 时 root 做整体初始化(首次 AllGather、CUDA stream sync 等)
- forward 完成后调 reshard:root 决定 root unit 自身的 reshard 时机(最后一个 reshard 在整 forward 完成后)
root 在首次 forward 的 lazy init 里确定(torch/distributed/fsdp/_fully_shard/_fsdp_state.py 的 _lazy_init):最先跑 forward 的 fully_shard 模块就是 root,通常就是最外层 model。如果用 fully_shard(layer) 给每层都 wrap,最外层依然是 root,每层是 sub-unit。root 的 lifecycle 决定整体训练流程,理解这点能解释为什么"在 root 之外的代码看到的 param 是分片的、root forward 内部看到完整的"。
18.29 FSDP × torch.export 与部署
FSDP 是训练工具,部署时通常不带 FSDP 直接 export。部署流程:
- 训练完成后用
FSDP.summon_full_params(model)或者state_dict_type=FULL_STATE_DICT收集完整权重 - 在单卡(或推理用的并行配置)重建 model(不带 FSDP wrap)
- 加载完整权重
torch.export.export(model, ...)+ AOTI(第 15 章 §15.10.1)
这条流程让训练时的 FSDP 与部署时的 AOTI 完全解耦。FSDP 不存在于部署 binary 里 —— 它是纯粹的训练时工具。
实战:HuggingFace Transformers 的 FSDP 训练流程结尾通常调 unwrap_model(model) 取出原始 nn.Module、再保存 state_dict。这是与 FSDP 解耦的标准做法。
18.30 FSDP-2 与 DTensor 的层次关系
FSDP-2 内部把每个 nn.Parameter 替换成 DTensor(§18.8)。这意味着:
- 用户视角:forward 之外,
model.linear.weight是 DTensor(local_shard 是 1/N),optimizer、clip_grad_norm_、state_dict 看到的都是它 - forward 视角:FSDP-2 的 hook 自己发 AllGather,把 unsharded 的普通
nn.Parameter换上去,计算算子看到的是普通 Tensor,而不是靠 DTensor 算子去触发 placement 转换(§18.8) - backward 视角:FSDP-2 自己发 ReduceScatter,把结果包成 DTensor 梯度(本机 gloo 实测
weight.grad是Shard(0)的 DTensor)
也就是说,FSDP-2 把分片的表示下沉到 DTensor,调度与通信仍由自己负责(按 param group 批量 AllGather / ReduceScatter)。
顺带纠正一个常见印象:FSDP-2 并不比 FSDP-1「代码更少」。v2.11 里 torch/distributed/fsdp/_fully_shard/ 共 4552 行,而 FSDP-1 的两个主文件 fully_sharded_data_parallel.py(2167 行)+ _runtime_utils.py(1654 行)合计 3821 行。FSDP-2 的优势不在体量,而在抽象层次:手写的 flat-buffer 簿记换成了类型化的 placement。
DTensor 思想的胜利:把分片表示为类型而非协议。Tensor 的 placement 是 Tensor 类型的一部分,编译器 / 运行时都能利用这个信息做优化。这是 PyTorch 分布式训练演进的下一代方向。
18.31 完整训练 step 时间分解
70B Llama 训练单 step 时间(H100,32 卡 4 节点 HSDP,bf16):
forward
├─ AllGather params ← 理想情况完全 overlap 进上一个 unit 的计算
└─ forward 计算
backward ← 通常是 forward 的两倍量级 (要算 grad_input + grad_weight)
├─ AllGather params ← overlap
├─ backward 计算
└─ ReduceScatter grads ← overlap
跨节点 AllReduce (HSDP only) ← 只在需要同步梯度的那一次 backward 末尾
optimizer.step ← 每 rank 只更新自己那 1/N, 占比很小
本机无 GPU,给不出具体毫秒数 —— 这类数字必须在你自己的集群上用 profiler(第 21 章)实测。能确定的是相对关系:FSDP 比 DDP 慢,换来的是显存从「单卡装不下」降到「每卡 1/N + 一个 unit 的 unsharded peak」。这就是 FSDP 的工程哲学:用通信换显存。
如果显存够(中等模型),用 DDP 性能更好;如果装不下,FSDP 是唯一选择。混合 HSDP 是中间值 —— 跨节点不分(DDP 风格)、节点内分(FSDP 风格),平衡通信与显存。
18.32 内存账:FSDP vs DDP 的精确对比
7B 模型 fp32 训练详细内存账(每卡):
| 项 | DDP | FSDP-2 ZeRO-3 (8 卡) |
| params | 28 GB | 3.5 GB |
| grads | 28 GB | 3.5 GB |
| optimizer (Adam m+v) | 56 GB | 7 GB |
| activations | 8 GB | 8 GB (相同) |
| 临时 buffer | 4 GB | 28 GB (unsharded peak)|
| 单卡总 |124 GB | 50 GB |
注意 FSDP-2 的"临时 buffer"列:这 28GB 是把整个 model 当成一个 wrap unit 的退化情形——一次 unshard 就要凑齐全部 params。真实配置按 transformer block 逐个 fully_shard(§18.13),同时在飞的 unsharded buffer 只有「当前 unit + prefetch 中的下一个 unit」那么大,通常是几百 MB 到几 GB 量级。unsharded peak 是 per-unit 的,不是整模型的——这正是 §18.13 强调 unit 粒度的原因,也是为什么 FSDP-2 的实际显存峰值不是简单的 1/N。
70B 模型同样表(32 卡 ZeRO-3):
DDP: params 280 + grads 280 + opt 560 + activ 几十 + buffer = 1100+ GB → 装不下
FSDP-2: params 8.75 + grads 8.75 + opt 17.5 + activ 几十 + buffer 280 (unsharded) = ~330 GB
上面那个 280 同样是"整模型一个 unit"的极端值。按 block 粒度 wrap 之后,unsharded buffer 只剩「当前 unit + 预取的下一个 unit」,70B 才真正能在 32 卡上跑起来 —— 这告诉我们 unit 粒度不能太大(每 unit unshard 后的临时显存是关键约束)。
18.33 实测带宽 vs 计算的临界点
FSDP 性能取决于"通信能不能 overlap 进计算"。临界点:
- 节点内 NVLink:带宽以百 GB/s 计,一个 transformer block 的 params AllGather 通常远快于该 block 的计算 → 能完全 overlap
- 节点间 InfiniBand:按每卡计带宽明显低于 NVLink(常差到接近一个数量级),但配合 prefetch 多数情况下还能 overlap
- 节点间普通以太网:带宽比 NVLink 低一到两个数量级,同样大小的 AllGather 会比计算慢得多,基本无法 overlap —— 这就是为什么以太网集群用 HSDP 而非纯 FULL_SHARD
具体数字随代际(NVLink 4/5、IB HDR/NDR、100/400 GbE)差异极大,别抄别人的表,用 torch.distributed 自带的 benchmark 或 NCCL tests 在自己集群上测。
判断你的硬件是否适合纯 FULL_SHARD:
- 算每 backward step 的 GPU 计算时间(用 profiler 看)
- 算每个 wrap unit 的 AllGather 通信时间(unit_param_size / 带宽)
- 比较两者,AllGather > 计算时间就要切 HSDP 或减小 unit 粒度
把 unit 的 param_bytes / 实测带宽 与 profiler 里该 unit 的 kernel 时间放一起比:前者远小于后者就是健康的 overlap,两者接近就说明要么减小 unit 粒度、要么换 HSDP。
18.34 FSDP × torch.compile 的协作细节
FSDP-2 + compile 的具体内部协作:
fully_shard(model, mesh=mesh)
compiled = torch.compile(model)
out = compiled(x)
发生的事:
以下 1–4 步描述的是 Traceable FSDP2(torch._dynamo.config.skip_fsdp_hooks = False + compiled autograd);默认配置下 FSDP-2 的 hook 在图外 eager 执行,collective 不进图(§18.19)。
- 第一次调用:Dynamo trace
model(x),FSDP-2 的 pre/post-forward hook 被一起 trace 进来 - AOTAutograd functionalize:unsharded param 的
resize_/torch.ops.fsdp.copy_这类 mutation 被 functionalize 进图 - Inductor 图 pass:
torch/_inductor/fx_passes/post_grad.py在skip_fsdp_hooks=False时额外跑remove_fsdp2_unsharded_param_graph_input_usage与reinplace_fsdp_all_gather(均在torch/_inductor/comms.py),清理这些 resize / copy、让 all-gather 原地写 - Inductor codegen:collective 仍以 extern call 的形式留在生成的 wrapper 里(Inductor 不会把 NCCL 塞进 Triton kernel),但它进入了同一张图的调度,能和前后的 compute kernel 一起排序、一起决定 buffer 复用
最终编译产物里 collective 与 compute 处在同一张图、由同一份 wrapper 顺序发射。这与 FSDP-1 + compile(在每个 FSDP 模块边界 graph break)相比有本质提升。
本机无法实测加速比。定性上,compile 在这条路径上省掉的是 eager 的 Python 调度开销与 kernel 启动开销,模型越大、单 kernel 越大,相对收益越小;Traceable FSDP2 额外的收益来自 collective 与 compute 被放进同一个调度决策里。
18.35 FSDP × Pipeline Parallel
Pipeline Parallel (PP) 把模型按层切到多卡,与 FSDP 在不同维度上分。3D parallel 中两者协同:
mesh = init_device_mesh("cuda", (PP, DP), mesh_dim_names=("pp", "dp"))
# 每 pipeline stage 内用 FSDP
for stage_id in range(PP):
if local_pp_rank == stage_id:
layers = model.layers[stage_id*L:(stage_id+1)*L] # nn.ModuleList
for layer in layers: # fully_shard 不接受没有 forward 的 ModuleList 容器
fully_shard(layer, mesh=mesh["dp"])
# Pipeline schedule 处理跨 stage 通信;stage_module 是把这段 layers 包起来、有 forward 的 nn.Module
pipe = PipelineStage(stage_module, ...)
每个 PP stage 内部用 FSDP shard 自己那部分 layers。stage 之间用 P2P send/recv 传 activation。这种"stage 内 FSDP、stage 间 PP"是 405B 训练的标准配置。
实战调优:PP 的"bubble"(pipeline 启动 / 结束的空闲时间)与 FSDP 的 collective 时间互动复杂,需要 profiler(第 21 章)实测每个 stage 的 timeline 优化。
18.36 FSDP × evaluation:临时切到完整模型
训练循环里偶尔要做 eval(计算 val loss / metrics)。FSDP 模型默认是分片状态,eval 时需要完整 forward。两条路:
A. 保持 FSDP 状态做 eval(推荐):
model.eval()
with torch.no_grad():
for batch in val_loader:
out = model(batch) # FSDP 仍 unshard / reshard, 与训练相同路径
eval 期间 FSDP 仍触发 AllGather + reshard,但因为 no_grad,没有 backward、也不需要 ReduceScatter grads。开销是单纯的 forward unshard。
B. summon_full_params 一次性 unshard(FSDP-1 接口,fully_shard 没有对应的 summon_full_params):
with FSDP.summon_full_params(model, recurse=True):
model.eval()
with torch.no_grad():
for batch in val_loader:
out = model(batch)
完整模型驻留显存,eval 期间不触发 collective —— 但单卡显存压力翻 N 倍。只在 eval batch 多 + 模型小时用,70B 训练绝对不能用(OOM)。
实战推荐 A 路线:FSDP eval 速度足够,省心。
18.37 FSDP 错误诊断速查表
| 症状 | 可能原因 | 诊断 |
|---|---|---|
| 反向卡在 AllGather | 某 rank 进度不一致 / NCCL hang | 开 TORCH_NCCL_DESYNC_DEBUG=1,watchdog 超时后报告哪个 rank 没跟上(TORCH_NCCL_ASYNC_ERROR_HANDLING 在 v2.11 默认已是 3,见 torch/csrc/distributed/c10d/ProcessGroupNCCL.cpp:910-911) |
| OOM 在 forward 第一层 | wrap 粒度太粗(整 model 一个 unit) | 按 transformer block 粒度逐个 fully_shard(FSDP-1 则用 ModuleWrapPolicy) |
| 比 DDP 慢很多 | 跨节点带宽不够,AllGather 无法 overlap | 改 HYBRID_SHARD / _HYBRID_SHARD_ZERO2,或减小 unit 粒度 |
fully_shard 之后替换子模块 / 参数,前向报类型或形状错误 |
model surgery 发生在 wrap 之后 | 检查是否在 fully_shard 之后改了 model,改动挪到 wrap 之前 |
| ckpt save 时 rank 0 OOM | 用了 FULL_STATE_DICT | 切到 SHARDED_STATE_DICT + DCP |
| 加载 HF safetensors 后训练异常 | weight 没正确切片到 DTensor | 用 torch.distributed.checkpoint.state_dict.set_model_state_dict(torch/distributed/checkpoint/state_dict.py:1369)加载,别手写 load_state_dict |
| forward 与 eager 数值不一致 | precision 配置错 | 检查 MixedPrecisionPolicy 的 reduce_dtype(应为 fp32) |
这几类症状覆盖了 FSDP 日常调试里的绝大多数情况,遇到问题先按表对号入座能省掉大量瞎猜。
18.38 FSDP 的 unshard 时机决策
FSDP 的 unshard 调度可以示意为(简化的概念图,不对应某个具体函数):
flowchart TB
Start[forward 开始]
Start --> Stage{当前 unit?}
Stage -->|root unit| RootUnshard[unshard root + 立即 prefetch unit 1]
Stage -->|sub unit| SubCheck{已被 prefetch?}
SubCheck -->|是| Use[直接用]
SubCheck -->|否| LazyUnshard[紧急 unshard]
Use --> Compute[执行 forward]
Compute --> Reshard[reshard 当前 unit]
Reshard --> Prefetch[启动 prefetch 下下个 unit]
Prefetch --> Stage
style RootUnshard fill:#fef3c7
style LazyUnshard fill:#fee2e2
核心规则:当前 unit 计算时,下一个 unit 的 AllGather 应该已经在飞。FSDP-2 默认靠独立 stream 做隐式预取,需要更激进时用 set_modules_to_forward_prefetch;FSDP-1 则要开 forward_prefetch=True(§18.20)。
如果实际跑出来 prefetch 没赶上(latency-bound 场景),会触发 lazy unshard —— 当前 unit 等 AllGather 完成,整个 timeline 出现 gap。这时 profiler 的 distributed view 能直接看到。优化:调整 wrap policy 让 unit 更小(每个 unit AllGather 时间更短、prefetch 更容易及时)。
18.39 grad accumulation 的 FSDP 显存账
§18.26 提了 no_sync 跳过中间 ReduceScatter。但还有显存账值得展开:
正常 FSDP grad accumulation 流程:
micro_step 1: forward (unshard / reshard) + backward (有 ReduceScatter)
→ grad 保留在 1/N 切片
micro_step 2: 同上, grad 累积到 1/N 切片
...
micro_step N: 同上 + optimizer.step
每 micro step 都触发 ReduceScatter,累积 N 次通信。no_sync 模式:
set_requires_gradient_sync(False) # 内部不触发 ReduceScatter
micro_step 1: forward + backward (grad 保留为 unsharded, 与完整 params 同尺寸)
micro_step 2: grad 就地累积, 仍是 unsharded
...
micro_step N-1: 同上
set_requires_gradient_sync(True)
micro_step N: forward + backward (这次触发 ReduceScatter, grad 切到 1/N)
optimizer.step()
关键差异:跳过 reduce 期间累积的 grad 是 unsharded 的(每 rank 持有与完整 params 同尺寸的一份),而不是 1/N。注意它不随 micro batch 数放大 —— 是就地累加,不是攒 N 份;但相对分片状态仍然是 N 倍的 grad 显存。
所以 FSDP 下要不要跳过 reduce 是一道显存 / 通信的取舍题:显存宽裕就跳(省 N-1 次 collective),显存紧张就每个 micro batch 都 reduce(grad 常驻 1/N)。DDP 那边没有这个矛盾(grad 本来就是完整的),这是 FSDP 与 DDP 工程取舍的不同。
18.40 FSDP 与其他并行框架对比
| 框架 | 核心思想 | 对比 |
|---|---|---|
| PyTorch FSDP-2 | DTensor + DeviceMesh | 与 PyTorch 生态深度集成、与 compile 兼容 |
| DeepSpeed ZeRO | Shard params/grads/state | 更早实现,是 FSDP 的灵感来源;与 PyTorch 集成靠 wrapper |
| Megatron-LM | TP + PP 为主 | NVIDIA 主推,专攻 TP/PP;DP 侧用自带的 DDP,可配 distributed optimizer 分片 optimizer state |
| ColossalAI | 多策略统一抽象 | 国产框架,支持多种并行策略 |
| OneFlow | 全局视角自动并行 | 算子级自动决定并行策略 |
PyTorch FSDP-2 的工程优势是与 torch.compile / torch.export / DTensor 共生 —— 不是独立框架而是 PyTorch 生态的一等公民。其他框架要么是 wrapper(依赖 PyTorch 但侵入式扩展),要么是独立运行时(迁移成本高)。
实战选择:
- 用 PyTorch 训练 → 用 FSDP-2
- 已有 DeepSpeed 代码 → 继续用 DeepSpeed(迁移成本高、收益不一定值得)
- 极致 TP/PP 优化 → Megatron-LM
- 国产芯片支持 → ColossalAI / 厂商定制
18.41 FSDP × HF Transformers / Lightning 实战
HuggingFace Trainer 与 PyTorch Lightning 都支持 FSDP,但配置方式不同:
HuggingFace TrainingArguments:
training_args = TrainingArguments(
fsdp="full_shard auto_wrap",
fsdp_config={
"transformer_layer_cls_to_wrap": ["LlamaDecoderLayer"],
"min_num_params": 0,
},
)
PyTorch Lightning Strategy:
trainer = pl.Trainer(strategy="fsdp", devices=8, ...)
# 或者更细粒度
trainer = pl.Trainer(strategy=FSDPStrategy(
auto_wrap_policy={LlamaDecoderLayer},
sharding_strategy="FULL_SHARD",
))
这两段配置用的都是 FSDP-1 的语汇(sharding_strategy、auto_wrap_policy / transformer_layer_cls_to_wrap)—— 框架替用户把 wrap policy 写好了。但理解原始 API 让你能在 Trainer / Lightning 配置出错时定位是哪一层包装的问题。
国内 Llama-Factory、Firefly 等微调框架也内置 FSDP 支持,配置类似 HF Trainer。
18.42 FSDP 与 elastic 训练的协作
第 17 章 §17.29 / §17.40 讲过 elastic(torchrun + max_restarts)。FSDP 与 elastic 协作时有几个细节:
- 重启时 mesh 配置必须一致:如果重启时用了不同 world_size,必须用 DCP 加载(自动 reshard 到新 mesh)
- ckpt 频率要够高:失败重启等于丢失从最近 ckpt 到现在的所有计算。间隔要在「存 ckpt 的开销」与「故障时丢掉的计算」之间权衡
- DCP 的写入要 robust:跨节点写文件可能因为某 rank 网络抖动失败,DCP 要支持"部分 rank 重写"
实战:torchrun + DCP + 定期 ckpt 是 LLM 训练的常见组合。失败时整 job 重启、加载最近 ckpt 继续训。大规模集群上长时间训练期间故障不可避免(Llama 3 405B 论文报告 54 天预训练里有 419 次意外中断),没有 elastic 自动恢复就要全人工介入。
18.43 实战训练流程的完整推荐
70B 模型从零训练的标准 FSDP-2 流程:
# 1. 设置环境
import os
import torch
import torch.distributed as dist
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy
from torch.distributed.algorithms._checkpoint.checkpoint_wrapper import (
apply_activation_checkpointing,
)
# 2. 初始化 process group
dist.init_process_group(backend='nccl')
local_rank = int(os.environ['LOCAL_RANK'])
torch.cuda.set_device(local_rank)
# 3. 创建 DeviceMesh (32 卡 4 节点 HSDP)
mesh = init_device_mesh("cuda", (4, 8), mesh_dim_names=("inter", "intra"))
# 4. meta device 构造模型
with torch.device("meta"):
model = LlamaModel(config)
# 5. activation_checkpoint 每层
apply_activation_checkpointing(
model,
check_fn=lambda m: isinstance(m, LlamaDecoderLayer),
)
# 6. FSDP-2 wrap (HSDP)
mp_policy = MixedPrecisionPolicy(
param_dtype=torch.bfloat16,
reduce_dtype=torch.float32,
)
for layer in model.layers:
fully_shard(layer, mesh=mesh, mp_policy=mp_policy)
fully_shard(model, mesh=mesh, mp_policy=mp_policy)
# 7. lazy init weights
model.to_empty(device='cuda')
init_model_weights(model)
# 8. compile
model = torch.compile(model)
# 9. optimizer 与 scheduler
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, fused=True)
scheduler = ...
# 10. 训练循环
for batch in loader:
optimizer.zero_grad()
loss = model(batch).loss
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # param 是 DTensor, norm 求和自动跨 rank
optimizer.step()
scheduler.step()
整套流程把全专栏前面章节的内容串起来:DeviceMesh(§18.7)+ FSDP-2(§18.5)+ 混合精度(§18.6 + 第 20 章)+ activation_checkpoint(§18.11)+ HSDP(§18.9)+ torch.compile(第 12-15 章)+ fused optimizer(第 10 章)+ DTensor grad clip(§18.18)。
这是 70B 级 LLM 训练的一套常见配置。理解整章后回到这段代码,每行配置都对应某个具体的工程决策。
18.44 FSDP 调试:常用日志与工具
调 FSDP 训练的几个常用工具:
TORCH_DISTRIBUTED_DEBUG=DETAIL:开启后进程组被包进 ProcessGroupWrapper(torch/distributed/distributed_c10d.py 的 _create_process_group_wrapper),每个 collective 发出前先借一个 gloo 辅助组在各 rank 间核对操作类型与张量形状,不一致时报出各 rank 正在跑的 collective,能定位"哪个 rank 的 collective 对不上"。
TORCH_LOGS=+fsdp:FSDP-2 的日志都是 DEBUG 级,要带 + 才会打出来(本机 gloo 实测 TORCH_LOGS=fsdp 无输出);打印每个 unit 的 FSDP::pre_forward / post_forward / pre_backward / post_backward 事件,看调度顺序是否符合预期。
torch.profiler.record_function 标记:FSDP 的关键路径自带 profiler 标记(FSDP-2 如 torch/distributed/fsdp/_fully_shard/_fsdp_state.py:316 的 FSDP::root_post_backward_callback;FSDP-1 如 torch/distributed/fsdp/_runtime_utils.py 里的 FullyShardedDataParallel.rate_limiter),在 trace 里能直接按名字定位。
chrome trace + distributed view(第 21 章 §21.9):终极调试工具。能看到每 rank 的 collective timeline + 各 rank 等待时间。
实战调试流程:先开 TORCH_LOGS=+fsdp 看调度顺序对不对、再用 chrome trace 看实际 timeline。多数 FSDP 性能问题(如 unshard 没及时 prefetch)能在这两层定位。
18.45 FSDP 设计上的几个隐形约束
FSDP 不是万能的。几个不能突破的工程约束:
1. 每个 unit 的 unshard 必须能装下完整 params:FSDP 的最大单 unit param 大小不能超过单 rank GPU 显存。70B 单层 1B 参数 → 4GB(fp32)单 rank 装得下。但 1T 模型单层 100B → 400GB 装不下,FSDP 必须配合 TP 才能跑。
2. 同一 unit 的所有 param 必须同 dtype:FlatParameter(FSDP-1)合并要求 dtype 一致;FSDP-2 用 DTensor 没这个限制但一个 unit 同 dtype 仍是最优。
3. unit 边界不能跨越复杂的 module 控制流:if-branch / loop 内的 module 不适合自己成为 wrap unit,因为不是每次 forward 都被调到,prefetch 决策困难。
4. FSDP-2 的 mesh 一旦决定就不能改:训练中途换 mesh 配置(如从 8 卡变 16 卡)需要先 ckpt 落盘 → 用新 mesh 重 init → DCP load 时自动 reshard。不能"在线变"。
理解这些约束让你设计训练架构时心里有数。"一切都用 FSDP" 不是答案 —— 极限场景下还要 TP / PP / 自定义并行。
18.46 几条工程经验
1. ShardingStrategy 选择:完整 params 单卡放得下、只是 optimizer state 放不下时用 SHARD_GRAD_OP(ZeRO-2,grads 与 optimizer state 降到 1/N,见 §18.21);连 params 都放不下用 FULL_SHARD;跨节点带宽不足用 HYBRID_SHARD
2. 用 v2.6+ 公开路径下的 FSDP-2 (torch.distributed.fsdp.fully_shard):除非你已经有大量 FSDP-1 代码,否则新代码直接用 FSDP-2
3. backward_prefetch=BACKWARD_PRE 是默认,forward_prefetch 默认是 False(torch/distributed/fsdp/fully_sharded_data_parallel.py:404 / :410):反向 prefetch 开箱即用,forward prefetch 要显式开、且换显存峰值(§18.20)
4. wrapping policy 设到合适粒度:每个 transformer block 单独 shard 通常最优。粒度太细(每个 Linear 都 shard)通信开销过大;太粗(整个模型一个 shard)overlap 不好
5. use_orig_params=True:让 FSDP 不破坏 param 引用,原 optimizer 能直接复用。FSDP-1 的兼容选项,FSDP-2 默认就这样
6. cpu_offload=CPUOffload(offload_params=True):参数卸到 CPU,省更多显存,代价是 H2D 拷贝拖慢训练。仅在显存极紧张时用
7. checkpoint 与 FSDP:FSDP 的 ckpt 用 torch.distributed.checkpoint(DCP)存分布式格式。每 rank 只存自己那 1/N,避免 rank 0 写 1TB 文件的瓶颈
8. activation_checkpoint 与 FSDP 叠加用:FSDP 省 params/grads/optimizer 显存,checkpoint 省 activation 显存。两者正交、可叠加
18.47 实战决策路径
flowchart TD
Start[要训练大模型]
Start --> Size{模型 / GPU?}
Size -->|装得下| DDP[用 DDP]
Size -->|装不下| Bandwidth{跨节点带宽?}
Bandwidth -->|InfiniBand 高带宽| Full[FULL_SHARD]
Bandwidth -->|普通 ethernet| Hybrid[HYBRID_SHARD<br/>节点内 shard 跨节点 DDP]
Full --> Mp[配 MixedPrecision]
Hybrid --> Mp
Mp --> Ckpt[启用 activation_checkpoint]
Ckpt --> Compile[用 torch.compile 提速]
style Full fill:#dcfce7,stroke:#22c55e
style Hybrid fill:#fef3c7,stroke:#f59e0b
70B Llama 训练的典型配置:32 张 H100、HYBRID_SHARD(同 8 卡节点 ZeRO-3、跨 4 节点 DDP)、bf16 通信 + fp32 reduce、每个 transformer block 单独 fully_shard 并做 activation_checkpoint(与 §18.43 一致)。
18.48 FSDP 与 PyTorch 演进路线
FSDP 在 PyTorch 演进路线上的位置:
几个能从仓库 git 历史里查证的节点:
- v1.11:
torch/distributed/fsdp目录公开(PR #72084 "make fsdp folder to be public"),FSDP-1 登场,思想源自 DeepSpeed ZeRO - v2.3:FSDP-2 的
fully_shard前端首次落地,路径是私有的torch.distributed._composable.fsdp(PR #117776,reland #118525) - v2.6:
fully_shard移到公开的torch.distributed.fsdp(PR #141868) - v2.11(本专栏基准):两代并存 ——
torch/distributed/fsdp/__init__.py的__all__里明确分成# FSDP1与# FSDP2两块;FSDP-2 侧还多出share_comm_ctx、register_fsdp_forward_method等导出,以及FSDPModule.set_custom_all_gather/set_custom_reduce_scatter自定义 collective 接口
v2.11 的源码里 FSDP-1 整体尚未被标记 deprecated(只有 FSDP.state_dict_type / set_state_dict_type、full_optim_state_dict 这类个别老接口带 deprecation warning,见 torch/distributed/fsdp/fully_sharded_data_parallel.py:674-680 与 :1246-1253),但新功能只往 FSDP-2 加。新代码用 FSDP-2。
FSDP / ZeRO 这套"分片 + 临时凑齐"的思想已经被各家训练框架广泛借鉴。理解 FSDP 不只是理解 PyTorch 一个工具,是理解整个大模型训练时代的工程基础。
18.49 完整决策树:用 DDP 还是 FSDP-2?
flowchart TB
Start[要训练大模型]
Start --> SizeCheck{单 rank 装得下完整 params + grads + optimizer state?}
SizeCheck -->|是| DDP[DDP - 性能最优]
SizeCheck -->|否, 装不下 optimizer state| ZERO2[FSDP SHARD_GRAD_OP - ZeRO-2]
SizeCheck -->|否, 连 params 也装不下| ZERO3[FSDP FULL_SHARD - ZeRO-3]
ZERO2 --> Bandwidth{跨节点带宽足够?}
ZERO3 --> Bandwidth
Bandwidth -->|是, NVLink/IB| Flat[平面 mesh: 单维度 shard]
Bandwidth -->|否, ethernet| Hybrid[HSDP 2D mesh: 节点内 shard 跨节点 replicate]
Flat --> Compile{要 torch.compile?}
Hybrid --> Compile
Compile -->|是| FSDP2[FSDP-2 fully_shard API]
Compile -->|否, 用老代码| FSDP1["FSDP-1 (维护模式)"]
style DDP fill:#dcfce7
style FSDP2 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style FSDP1 fill:#fee2e2
这条决策树覆盖常见的大模型训练场景;极端场景(如 1T+ 模型必须配 TP/PP)需要专门设计。
18.50 跨专栏关联
- 第 16 章 ProcessGroup:FSDP 的 AllGather / ReduceScatter 都通过 ProcessGroupNCCL
- 第 4 章 Caching Allocator:FSDP 的 unshard 触发临时分配大量显存,对 allocator 压力大,常需设
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True - 第 7 章 §7.5.3 saved_tensors_hooks:FSDP 的 activation checkpoint 与这套 hook 配合
- 第 13 章 AOTAutograd:开启 Traceable FSDP2 时,FSDP-2 的 collective 也走 AOTAutograd,让通信 op 进入 Inductor 的编译图(默认配置下在图外,见 §18.19)
18.51 FSDP × Engine:与第 8 章的协作
第 17 章 §17.47 讲过 DDP × Engine。FSDP 的协作更复杂:
- forward 时插入 unshard hook:FSDP 通过
register_forward_pre_hook(第 9 章 §9.8)在 forward 前触发 unshard - forward 后插入 reshard hook:
register_forward_hook触发 reshard - backward 前通过输出张量上的
Tensor.register_hook触发 unshard(torch/distributed/fsdp/_fully_shard/_fsdp_state.py:356的_register_pre_backward_hook) - backward 完成的 ReduceScatter 由
RegisterPostBackwardFunction(一个torch.autograd.Function,torch/distributed/fsdp/_fully_shard/_fsdp_param_group.py:876)+ 引擎的 final callback(torch/distributed/fsdp/_fully_shard/_fsdp_state.py:313的_root_post_backward_final_callback)驱动(与 DDP §17.3 的思路类似,但用 ReduceScatter 而非 AllReduce)
整套机制让 FSDP 把分片调度寄生在 PyTorch 的 hook 体系上。Engine 完全不知道 FSDP 存在,只是按 DAG 调度反向 —— hook 触发的 collective 是 Engine 视角的"普通副作用"。
这种"分布式策略寄生在框架 hook 之上"的设计是 PyTorch 分布式训练能持续演进的根本(DDP / FSDP / 用户自定义并行都共享 hook 接口)。
18.52 整章信息密度的小结
读完 Ch 18 你应该能:
- 决策:DDP / ZeRO-2 / ZeRO-3 / HSDP 各自适用场景(§18.49 决策树)
- 配置:选 wrap 粒度、配
MixedPrecisionPolicy、决定是否开 CPU offload - 理解:unshard / reshard 时机、prefetch 怎么 overlap、activation_checkpoint 怎么与 FSDP 配合
- 调试:错误诊断速查表 + chrome trace 看 timeline
- 接生态:HF Trainer / Lightning / DCP / torch.compile 怎么协作
- 预判演进:FSDP-1 → FSDP-2 → 未来与 DTensor 进一步融合
70B Llama 训练的实战配置(§18.43)把整章串起来 —— 每行配置都对应某节的工程决策。这类配置在大模型训练里很常见,理解它就理解了"为什么 LLM 训练长这样、不能简化成更朴素的形式"。
18.53 设计启示
FSDP 的核心思想:
第一:显存与计算 / 通信可以互换:FSDP 把显存压力转成通信压力,让"训练大模型"从"硬件极限"变成"调优问题"
第二:分片 + 临时凑齐是分布式数据结构的通用模式:DTensor / 分布式哈希表 / 分片数据库都用这套思路。每节点常态只持有部分数据,需要时凑齐
第三:prefetch 是 overlap 通信的标配:任何"计算 - 通信 - 计算"链路都要考虑 prefetch,让通信延迟隐藏到计算时间里
第四:模块级 wrap 比模型级 wrap 灵活:FSDP-2 的 fully_shard(submodule) 思想可以借鉴到所有"框架增强 module"的场景
下一章拆序列化:torch.save / torch.load + safetensors + Distributed Checkpoint,看大模型训练的 ckpt 怎么管理。