Prhub

#7347 [fsdp] feat: enable non-blocking FSDP2 model transfers

原始 PR 作者 jiefangxuanyan 合并时间 2026-08-18 12:35 文件变更 5 提交数 6 评论 2 代码增减 +156 / -2

执行摘要

FSDP2 模型传输改非阻塞,H2D 提速约 3.9 倍

PR body 说明问题来自 verl-omni 项目 Qwen-Image 全参 FSDP2 负载的 profiling:old_log_prob 与 update_actor 两个阶段都会把 actor 模型从 CPU 恢复到 GPU 再卸载回 CPU,改动前恢复模型是同步 H2D 拷贝,耗时 1.489 秒,且 D2H 结果是 pageable CPU 内存,使随后的 H2D 无法异步执行。7 月 28 日 weekly report 还测得模型传输 PCIe 利用率仅 10.9%,远低于紧随其后的 optimizer-state 传输(34.6%)。PR 的目标是让整模型 D2H/H2D 都能异步化,并刻意保持整模型传输形态,不拆成逐参数分配与拷贝。

值得精读,重点看两点:一是如何在性能优化中处理“依赖未文档化平台行为”的工程姿势——用注释写明约束、用回归测试锁定行为、并严格控制影响面;二是恰当的同步语义文档化,为后续维护者规避异步传输陷阱。对同仓库写 FSDP/rollout 相关代码的开发者,建议在新增模型传输调用前先阅读该 helper 的注释。

讨论亮点

该 PR 的 review 讨论非常精简:SamitHuang 在 verl/utils/fsdp_utils.py 留下唯一一条评论“Looks clean to me”,随后 SamitHuang 与 wuxibin89 双双 APPROVED,无任何修改要求或未解决的疑虑。实质性的设计论证(为何接受 PyTorch 未文档化的 pinned 内存行为、为何保持整模型传输、异步传输的同步约束)主要写在 PR body 与源码注释中,属于“论证前置”式写法。另外 PR 声明草案与实现由 OpenAI Codex 协助生成,提交者逐行复核并对变更负责,主 commit 带有 Co-authored-by: OpenAI Codex

实现拆解

实现分四步完成:

  1. 核心逻辑改动(verl/utils/fsdp_utils.py)offload_fsdp2_model_to_cpumodel.to("cpu", non_blocking=True) 替换 model.cpu()load_fsdp2_model_to_gpumodel.to(device, non_blocking=True) 替换 model.to(device)。源码注释详细说明了 pinned 内存行为、host 访问约束、跨 stream 重载的依赖要求,并保留原有整模型传输与 empty_cache() 释放行为不变。这是唯一的 core-logic 变更,其余均为测试与配套调整。

  2. CPU 契约测试(tests/utils/test_fsdp2_model_transfer_on_cpu.py,新增):用 Mock 断言 offload_fsdp2_model_to_cpuload_fsdp2_model_to_gpu 均以 non_blocking=True 调用 model.to(),锁定两个 helper 的 API 契约。

  3. 分布式 CUDA 回归(tests/special_distributed/test_fsdp2_pinned_model_transfer.py,新增):在 2 个 CUDA rank 上用 tiny Qwen2 配置构建 full-parameter FSDP2 模型,验证 D2H offload 后本地分片 is_pinned() 为真,并验证 D2H→H2D 往返数值完全一致(atol/rtol 均为 0)。

  4. 测试编排与配套tests/special_distributed/run_all.sh 追加该回归到分布式测试套件;tests/special_sanity/check_license.py 新增 NVIDIA 2026 版权头常量 license_head_nvidia_26。提交历史还显示 CPU 套件做了可选依赖兼容处理(Megatron/Qwen-VL 缺失时跳过测试、无 FlashAttention 时保留 NPU padding backend、engine graph-release 测试固定 CPU),并在合并 upstream 后回退了强制可选依赖的改动,期间的冲突仅出现在 tests/workers/test_engine_forward_step_detach_on_cpu.py。

文件 模块 状态 重要度
verl/utils/fsdp_utils.py 模型传输 modified 5.72
tests/special_distributed/test_fsdp2_pinned_model_transfer.py 分布式测试 added 7.1
tests/utils/test_fsdp2_model_transfer_on_cpu.py 单元测试 added 6.11
tests/special_distributed/run_all.sh 测试编排 modified 2.95
tests/special_sanity/check_license.py 许可检查 modified 3.02

关键符号

offload_fsdp2_model_to_cpu load_fsdp2_model_to_gpu _build_full_parameter_fsdp2_model _local_tensor test_offload_fsdp2_model_to_cpu_uses_non_blocking_copy test_load_fsdp2_model_to_gpu_uses_non_blocking_copy

关键源码片段

verl/utils/fsdp_utils.py core-logic

唯一的核心逻辑变更文件,两个 FSDP2 整模型传输 helper 的异步化改造位于此处,是全 PR 的性能收益来源。

@torch.no_grad()
def offload_fsdp2_model_to_cpu(model, empty_cache: bool = True):
    # PyTorch 当前在 non_blocking=True 的 CUDA 到 CPU 拷贝中分配 pinned 主机内存,
    # 该行为并未在官方文档中明示保证,因此需要分布式回归测试锁定。
    # pinned 参数还保证了随后 CPU 到 GPU 的整模型拷贝能以异步方式下发。
    #
    # 注意:D2H 拷贝完成前,CPU 张量不允许被主机安全访问;empty_cache() 不是同步点。
    # 当前调用方在同一 CUDA stream 上重新加载模型,依赖 stream 顺序保证 D2H 到 H2D 往返正确;
    # 若未来调用方在 host 读取张量,必须先同步;若在另一 stream 重载,必须显式建立 stream 依赖。
    model.to("cpu", non_blocking=True)
    if empty_cache:
        get_torch_device().empty_cache()
​
​
@torch.no_grad()
def load_fsdp2_model_to_gpu(model):
    device = get_device_id()
    # 配合 offload 时生成的 pinned 内存,整模型 H2D 拷贝可异步执行,
    # 避免把整模型传输拆成逐参数分配与拷贝,从而消除拷贝内核间的空隙。
    model.to(device, non_blocking=True)
tests/special_distributed/test_fsdp2_pinned_model_transfer.py test-coverage

新增的 2 rank CUDA 分布式回归,锁定 PyTorch 未文档化的 pinned 内存行为并验证 D2H→H2D 往返正确性,是本次改动可信度的关键支撑。

def main():
    # 该回归只验证 CUDA 行为,非 CUDA 环境直接跳过
    if get_device_name() != "cuda":
        print("test_fsdp2_pinned_model_transfer skipped: pinned transfer behavior is CUDA-specific")
        return
    assert get_torch_device().device_count() >= 2, "need at least 2 GPUs for test"
    _, rank, world_size = initialize_global_process_group()
    # 构建 2 个 rank 的 DP mesh,覆盖 full-parameter FSDP2 分片场景
    device_mesh = init_device_mesh("cuda", mesh_shape=(world_size,), mesh_dim_names=("dp",))
    model = _build_full_parameter_fsdp2_model(device_mesh)
​
    # 先记录 GPU 上的本地分片作为期望值
    expected_local_params = [_local_tensor(param).detach().clone() for param in model.parameters()]
​
    # 关键断言 1:D2H offload 后,CPU 本地分片必须是 pinned 内存
    offload_fsdp2_model_to_cpu(model, empty_cache=False)
    for param in model.parameters():
        local_param = _local_tensor(param)
        assert local_param.device.type == "cpu"
        assert local_param.is_pinned(), "non-blocking FSDP2 D2H copy must produce pinned CPU parameters"
​
    # 关键断言 2:H2D 加载后数值与 offload 前完全一致(atol 与 rtol 均为 0)
    load_fsdp2_model_to_gpu(model)
    torch.cuda.synchronize()
    for param, expected in zip(model.parameters(), expected_local_params, strict=True):
        local_param = _local_tensor(param)
        assert local_param.device.type == "cuda"
        torch.testing.assert_close(local_param, expected, atol=0.0, rtol=0.0)
​
    torch.distributed.barrier()
    torch.distributed.destroy_process_group()
    if rank == 0:
        print("test_fsdp2_pinned_model_transfer passed")
tests/utils/test_fsdp2_model_transfer_on_cpu.py test-coverage

新增的 CPU mock 契约测试,用最轻量的方式锁住两个 helper 必须以 non_blocking=True 调用 model.to() 的 API 行为,可在任何环境快速执行。

from unittest.mock import Mockfrom verl.utils import fsdp_utils
​
​
def test_offload_fsdp2_model_to_cpu_uses_non_blocking_copy():
    model = Mock()
​
    fsdp_utils.offload_fsdp2_model_to_cpu(model, empty_cache=False)
​
    # 契约锁定:offload 必须使用 non_blocking=True,才能让后续 H2D 异步执行
    model.to.assert_called_once_with("cpu", non_blocking=True)
​
​
def test_load_fsdp2_model_to_gpu_uses_non_blocking_copy(monkeypatch):
    model = Mock()
    device = object()
    monkeypatch.setattr(fsdp_utils, "get_device_id", lambda: device)
​
    fsdp_utils.load_fsdp2_model_to_gpu(model)
​
    # 契约锁定:load 必须使用 non_blocking=True,配合 pinned 内存发起异步 H2D
    model.to.assert_called_once_with(device, non_blocking=True)

评论区精华

整体改动评价(Looks clean to me) other

SamitHuang 在 verl/utils/fsdp_utils.py 上留下唯一一条 review 评论:"Looks clean to me",随后与 wuxibin89 先后 APPROVED。

结论:两位 reviewer 均批准,无修改要求;设计论证主要在 PR body 与源码注释中前置完成。 · 已解决

风险与影响

  1. 依赖未文档化行为:实现依赖 PyTorch 当前“非阻塞 CUDA→CPU 拷贝产生 pinned 内存”这一未在官方文档中明示保证的行为。若上游版本改变该行为,H2D 将退回同步拷贝,性能收益消失;新增分布式回归中的 is_pinned() 断言可在 CI 中捕获该回归。
  2. 异步同步语义:D2H 拷贝未完成时 CPU 张量不允许被 host 安全访问,且 empty_cache() 不是同步点。当前调用方(old_log_prob、update_actor)在同一 CUDA stream 上重载模型,靠 stream 顺序保证往返正确,但这是约定而非强制,未来新调用方若跨 stream 重载或直接在 host 读取需显式同步。
  3. 内存驻留开销:pinned 内存不可换出,全参模型 offload 后整份参数量驻留物理内存,多卡共机时主机内存压力上升。
  4. 影响面控制良好:核心功能改动仅两个 helper 共 2 行行为变化,FSDP1、optimizer offload、Megatron、fully-async 路径均未触碰,整体回归风险低。

对用户:colocated FSDP2 全参训练(如 Qwen-Image、verl-omni 负载)每个训练步的 old_log_prob 与 update_actor 阶段的模型重载时间从约 1.49 秒降至约 0.38 秒,减少 PCIe 空闲,直接提升训练吞吐。对系统:无 API 与配置变更,行为透明,仅改变拷贝异步性与 CPU 内存类型(pageable → pinned)。对团队:改动小而聚焦,测试完备(CPU mock 测试 + 2 rank CUDA 回归 + 纳入分布式测试套件),维护成本低。

依赖未文档化 PyTorch 行为 异步传输同步语义易误用 核心训练热路径 pinned 内存驻留开销

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论