FSDP2 + TP/EP
背景:为什么 RL 不再用 Megatron 那一套
在大规模预训练里,Megatron-style 多维并行(DP × TP × PP × EP × CP)几乎是事实标准:模型一旦确定,整个分布式拓扑就是一张静态图,kernel 与切分方式联合优化,能把 MFU 顶到 50% 以上。
但是到了 RL Post-Training(PPO / GRPO / DAPO / On-Policy Distillation 等),训练负载的形态发生了根本性的变化:
- 一个作业里同时跑 多个角色:actor(要训)、reference / reward / critic(推理为主)、rollout(vLLM / SGLang 这类专用推理引擎)。
- 每一步训练之前,actor 的权重要同步到 rollout 引擎——训练侧用一套 parallelism,推理侧用另一套 parallelism,中间要做一次 resharding。
- 显存压力是动态的:rollout 时希望把训练参数 / 优化器状态让给 KV cache;训练时又得把权重抓回 GPU。
- 模型迭代极快(HF 格式 checkpoint 频繁更换),开发速度比峰值 MFU 更重要。
这些差异决定了 RL 系统更倾向于 FSDP 2 为主,EP/TP 按需组合 的栈,而不是 Megatron ND 并行。
FSDP + TP/EP 优势分析
Pretraining vs RL:负载特征对比
| 维度 | Pretraining | RL Post-Training |
|---|---|---|
| 拓扑 | 一张静态图,DP × TP × PP × EP 固定 | 多角色异构:train + rollout + ref/reward |
| 单步耗时构成 | 几乎 100% 训练 | rollout(生成)+ logp 重算 + train,且 rollout 常占 50%+ |
| 权重流动 | 不流动 | 每个训练 step 后 actor → rollout 引擎 |
| 显存峰值 | 训练端固定 | 训练 / 推理交替,需要 swap |
| Checkpoint 形态 | Megatron 自定义切片 | 偏向 HF 原生 state_dict |
| 模型迭代周期 | 周 / 月级 | 天 / 周级,且常切换 backbone |
Megatron ND 并行在 RL 中的痛点
- Reshard 代价高昂:Megatron 的权重按
(tp_rank, pp_stage)物理切片存储,而 vLLM / SGLang 的 TP 排布、EP 排布与 Megatron 并不一一对应。每一步训练完都要做一次 train-layout → infer-layout 的转换,verl 在 Megatron 路径上专门写了weight_converter处理 MCore↔HF 转换,链路长而且容易出错(参考verl/workers/megatron_workers.py的_build_rollout)。 - PP 与 RL 的小 batch 性质冲突:RL 的 batch 通常被切成更小的 mini-batch(PPO epoch、GRPO group),PP 的 bubble 占比迅速放大,气泡损失甚至能吃掉一半的算力。
- 耦合度高:模型必须改写成 Megatron 的实现(
ParallelTransformerLayer等),遇到新模型(多模态、MoE、Hybrid Attention)要专门移植,节奏跟不上算法迭代。 - 角色复用差:reference / reward / critic 通常只需要前向,但 Megatron 的拓扑和 actor 是绑死的,难以独立调整 TP/PP 大小。
FSDP 2 + EP/TP 的核心优势
- 天然 HF 兼容:FSDP2 wrap 之后
state_dict()的 key 仍然是 HF 原生 fqn,且 value 是DTensor,做一次.full_tensor()就能给 vLLM 的model.load_weights(...)直接吃。 - DTensor 原生 resharding:FSDP2 把每个 sharded 参数表达成
DTensor,重新分发只是一次redistribute/full_tensor,不需要手写每一种切片到切片的转换。 - 可组合的 N-D 并行:通过
init_device_mesh把 FSDP / EP / TP / SP 写成同一张 mesh 的不同维度,“先做 SPMD 再做 FSDP” 就能拼出FSDP × EP、FSDP × EP × TP、HSDP × EP × TP等拓扑。 - 细粒度内存控制:
reshard_after_forward=True+CPUOffloadPolicy(pin_memory=True)让训练阶段的参数 / 优化器状态可以瞬时让出 GPU 给 rollout 的 KV cache,rollout 结束再 load 回来。 - 没有 PP 气泡:RL 长上下文 + 小 batch 场景下,FSDP 的 zero 3 通信比 PP 气泡更友好;veScale 还把 reduce-scatter / all-gather 跟前后向 overlap 得很好。
- 角色独立配置:actor / ref / reward / critic 各自的 device mesh 可以独立指定,rollout 那一侧的
(dp, infer_tp, infer_pp)也是单独init_device_mesh的(参考verl/workers/fsdp_workers.py:572-582)。 - 代码量小:HF 模型 + 一个
apply_fsdp2就能跑起来,不需要为每个新模型写 ParallelTransformerLayer。
FSDP + TP/EP 实现
整体思路:先做模型并行(EP/TP),再做 FSDP 2
文档开头给出的流程,本质上就是 DeviceMesh 多维拼装 + 顺序 wrap:
|
|
业界目前主要有两条实现路径:
- 路径 A(通用):纯 FSDP2 + 智能
shard_placement_fn(代表:verl)。专家参数在训练侧并不显式做 EP,而是依赖 FSDP2 自身把 stacked tensor 切到合适的维度,rollout 侧由 vLLM 的enable_expert_parallel自行做 EP。 - 路径 B(显式组合):先用 SPMD plan 把专家 / embedding 切到独立的 mesh dim 上,再叠一层 FSDP2(代表:mono_rl + veScale)。EP 在训练侧就显式存在,是真正的
FSDP × EP二维切分。
路径 A:verl 的 apply_fsdp2 + shard_placement_fn
Verl 把每个 transformer block 单独 wrap 一次,再在 root 上 wrap 一次,关键是不再默认沿 dim-0 切,而是让一个回调挑维度:
|
|
verl/utils/fsdp_utils.py:507-548
实际 wire 进 actor 是在 ActorRolloutRefWorker._build_model_optimizer:
|
|
verl/workers/fsdp_workers.py:488-510
这套方案的好处:
- 没有维度冲突:
shard_placement_fn拿到每个参数后,只挑能整除fsdp_size的第一个维度,stacked expert 张量[N_expert, H, I]也能均匀切分而不需要 padding。 - HF 模型零侵入:原生
AutoModelForCausalLM拿来就能用,不需要改一行 modeling 代码。
代价是:训练侧没有显式 EP,所有 expert 还是被切在 FSDP mesh 上;rollout 侧 vLLM 的 EP 由 expert_parallel_size 单独配置(verl/workers/config/rollout.py:122-185),训练 / 推理 EP 不一致时由 reshard 流程兜底。
路径 B:mono_rl + veScale 的显式 EP × FSDP 2 组合
mono_rl 的 fully_shard 是个组装函数:构造一个包含 MP / EP / OE 的命名 mesh,先按 SPMD plan 切一次,再 auto_wrap 进 FSDP 2:
|
|
mono_rl/worker/engine/fsdp/vescale/fully_shard.py:279-353
每个模型自己提供 EP/OE 的 plan,比如 M 12(Seed 自研多模态 MoE):
|
|
mono_rl/models/seed_models/modeling_m12.py:82-103
也就是说:128 个 expert 的 stacked weight [128, H, I],先沿 dim-0(expert 轴)按 EP=4 切成 4 份 [32, H, I],每个 EP rank 只持有自己那 32 个 expert 的参数。这就是文档开头第 1 步 [128,H,I] -> [32,H,I] 的来源。
关键约束:FSDP 2 的切分维度不能与 EP/TP 重叠
为什么文档说"FSDP2 在 dim-1 (hidden dim) 切分"?因为 PyTorch 的 DeviceMesh 在嵌套 SPMD + FSDP 时存在一个限制——同一个张量维度不能被 SPMD 和 FSDP 同时切。veScale 在 shard_state 里给出了显式策略:
|
|
vescale/parallel/fsdp2/extension/spmd.py:265-286
这段逻辑做了三件事:
- 统计 SPMD(EP/TP)已经切过的维度。
- 从剩余维度里挑一个:优先挑能整除
fsdp_size的,否则挑最大的,避免 padding。 - 生成 FSDP placement:在 fsdp 这一维写
Shard(selected),其余维度Replicate()。
回到 expert 张量 [N=128, H, I]:
- EP 切了 dim-0 →
spmd_shard_dims = {0} - FSDP 必须从
{1, 2}里挑 → 通常挑 hidden 维 (dim-1) - 最终
[128, H, I] --EP--> [32, H, I] --FSDP--> [32, H/fsdp, I]
torch native FSDP2 也支持这个语义:shard_placement_fn 可以返回非 Shard(0) 的 placement,但要求该维度能整除 fsdp mesh size:
|
|
vescale/parallel/fsdp2/fully_shard.py:168-177
N-D Mesh 拓扑总结
veScale 在 _fsdp_param.py 里把支持的拓扑总结得很清楚:
|
|
vescale/parallel/fsdp2/_fsdp_param.py:155-176
也就是说在生产里我们最常见 4 种组合:
| Mesh ndim | 组合 | 典型场景 |
|---|---|---|
| 2 | FSDP × EP 或 FSDP × TP |
dense 模型 + ulysses;MoE 中等规模 |
| 3 | FSDP × EP × TP 或 HSDP × (EP|TP) |
DeepSeek-V 3 / Seed-OSS 这类大 MoE |
| 4 | HSDP × EP × TP |
跨 pod 的多机大 MoE |
训练 ↔ 推理:权重 Resharding
整套机制最关键的就是这一步——也是 RL 框架和预训练框架最不一样的地方。
verl 的 rollout_mode 是个标准范式:
|
|
verl/workers/fsdp_workers.py:636-707
到 vLLM 那一侧:
|
|
verl/workers/sharding_manager/fsdp_vllm.py:283-355
整个流程有几个关键设计点:
- 生成式(streaming)权重传递:
per_tensor_param是个 generator,按参数粒度逐个full_tensor()→ 喂给 vLLM,避免一次性 all-gather 整个模型。 - HF fqn 直接对齐 vLLM:vLLM 的
model.load_weights本来就吃 HF 格式的(name, tensor)流,FSDP 2 的 state_dict 天然兼容,无需写"切片到切片"的复杂转换。 - MoE 融合权重 patch:vLLM 把
gate_up_proj这种 stacked expert weight 用 fused weight loader 装载,verl 单独打了个 patch 让 DTensor 也走这条路(verl/utils/vllm/patch.py:56-72)。 - EP 不一致时由 reshard 兜底:训练侧 FSDP(或 FSDP × EP)和 vLLM 侧的 EP 大小可以不一样——
full_tensor()把张量收齐后由 vLLM 重新按自己的 EP/TP 切回去。 - 配合 sleep / resume 让出显存:
self.rollout.resume(tags=["weights"])与 FSDP 的offload_policy=CPUOffloadPolicy()配合,rollout 时训练参数被踢到 CPU;rollout 结束再 load 回来。
我们解决了哪些问题
把上面散落的点收拢一下,FSDP + EP/TP 这套栈在 RL 系统里实际解决了:
- 训练-推理 resharding 难题:DTensor + HF state_dict 让"FSDP layout → vLLM layout"变成一个 streaming
full_tensor()调用,不再需要为每对(train_tp, infer_tp, ep)写转换。 - MoE 切分与 stacked expert weight:通过 SPMD plan 把 expert 维度(dim-0)独立成 EP mesh,再让 FSDP 在剩余维度(hidden dim)上切,避免与 EP 冲突,也避开 PyTorch 的 mesh 排序 bug(issue #129206 )。
- Rollout / Train 显存切换:
reshard_after_forward=True+CPUOffloadPolicy+ vLLM 的 sleep / resume,让同一批 GPU 在 rollout 阶段把空间让给 KV cache,在 train 阶段把权重抓回来。 - HF 生态零成本接入:HuggingFace 出新模型 →
AutoModel.from_pretrained→apply_fsdp2,几乎零移植成本;Megatron 路径上要花数周写ParallelTransformerLayer和weight_converter。 - 多角色异构拓扑:actor / ref / reward / critic / rollout 各自独立
init_device_mesh,可以分别选 FSDP size、EP size、TP size,而不像 Megatron 那样全部绑死在同一个 ND grid 上。 - 长上下文 + 小 batch 友好:RL 的 batch 在 PPO / GRPO epoch 切分后非常小,PP 气泡占比直线上升;FSDP zero 3 通信能与前后向 overlap,反而比 PP 更适合 RL。
- 观测与可调试性:每个 DTensor 都有显式 placement,权重值 / 梯度可以直接
.full_tensor()拿出来对比,不像 Megatron 切片后必须借助 dist debugger 才能拼回原貌。
值得提一句的是:不是说 Megatron 错了——大模型 pretraining 仍然是 Megatron + MCore 的天下。但是 RL Post-Training 的负载特征(多角色、resharding 重、迭代快)确实更适合 FSDP 2 + 按需 EP/TP 这套栈,这也解释了为什么 verl / OpenRLHF / mono_rl / TRL 等 RL 框架都不约而同地选择了这条路线。
参考资料
- FSDP2 Under the Hood — A Deep Dive:FSDP2 的
FSDPState / FSDPParamGroup / FSDPParam内部实现拆解。 - verl PyTorch FSDP Backend:verl 官方文档里 FSDP 1/FSDP 2 与 vLLM 的对接说明。
- verl PR #1026 - support fsdp2 training and inference in fsdp_workers:FSDP 2 在 verl 的接入 PR,讨论了 8% / 27% 的显存收益。
- PyTorch issue #129206 - DeviceMesh ordering with FSDP + SPMD:FSDP/SPMD 切分维度不能重叠的根本原因。
- torchtitan — fsdp.md:torchtitan 关于 FSDP 2 的设计说明。
- 代码参考:
verl/utils/fsdp_utils.py、verl/workers/fsdp_workers.py、verl/workers/sharding_manager/fsdp_vllm.pymono_rl/worker/engine/fsdp/vescale/fully_shard.py、mono_rl/models/seed_models/modeling_m12.pyvescale/parallel/fsdp2/fully_shard.py、vescale/parallel/fsdp2/extension/spmd.py、vescale/parallel/fsdp2/_fsdp_param.py
- https://zhuanlan.zhihu.com/p/1990790333823481023
-
No backlinks found.