Prhub

#1313 RDT weight sync: GPU->GPU zero copy transfer through SGLang Ray actor backend

原始 PR 作者 xyuzh 合并时间 2026-08-23 08:00 文件变更 15 提交数 21 评论 48 代码增减 +793 / -21

执行摘要

RDT/NIXL 零拷贝权重同步:GPU 直传较 NCCL 提速 2 倍

PR body 明确指出设计动机:"RDT weight sync shares P2P and nccl distributed weight sync bucketed all-gather + HF conversion pipeline, moving the bucket payload over NIXL (ray.put(_tensor_transport="nixl") + RDMA pull) instead of holding a full GPU replica of the rollout model. We have measured faster weight sync speed across models than P2P RDMA and NCCL." 核心诉求有三:一是消除 NCCL broadcast 的串行 lock-step 瓶颈;二是避免 Mooncake RDMA P2P 在每个 trainer rank 上保留完整 CPU 模型副本;三是实现 per-rank 零拷贝 GPU→GPU 拉取,且 2→4 节点扩展零额外开销,为更大模型的多节点 rollout 扩展扫清路径。

值得精读。这不仅是功能新增,更是一份「零拷贝跨进程/跨节点 GPU 传输」的工程教材:VMM expandable_segments 与 CUDA-IPC 导出的冲突、NIXL 注册生命周期与远端缓存失效的关联、EFA 小 MR 走 host bounce pool 的假阳性探测、以及 Ray concurrency group 导致的主线程外执行语义,每一个都是只有实测才能发现的坑。建议重点研读 update_weight_from_rdt.py 的桶分配 / 钉扎 / 分轮逻辑、actor_factory.py 的 max_concurrency 分析(配合 guapisolo 对 Ray 2.56 的源码追踪评论),以及 sglang#34621 的 actor 命名稳定化设计。

讨论亮点

评审中最有价值的交锋集中在三处。第一处是架构层面:yueming-yuan 指出用同 job SGLangServerActor 替代 mp.Process 会破坏 miles 以进程控制引擎生命周期的契约(涉及 cleanup 与 fault tolerance);xyuzh 以「非 RDT 路径下非零 node rank 本就返回 None 进程」回应,并补齐 shutdown 对 Actor 的 ray.kill 清理,最终保留 Actor 托管方案。第二处是资源语义:maocheng23 问 RDT 每 GPU 独占一 rank 是否与 PPO 冲突,guapisolo 确认这会在 Shared Actor/Critic PPO 下确定性死锁,并直接推送参数校验拒绝组合(commit e2f87822)。第三处是并发调优:guapisolo 对 max_concurrency 改动先提 P1 质疑,经追踪 Ray 2.56 concurrency group 实现后撤回——命名 concurrency group 会为 default 单独建 executor,FT 路径的 update_weights 已跑在主线程外,非 FT 路径必须借 max_concurrency>1 达到同样效果;同时确认 NIXL 是 one-sided 传输,目标侧并发提升由 sglang#27723 承担。另有 gemini-code-assist 提出的桶溢出风险(TP=1 时 Qwen3-30B-A3B 的 embed_tokens 约 0.58 GiB 超过默认 512 MiB buffer),由动态扩容与分轮传输闭环解决。

实现拆解

步骤 1:参数入口与校验接入

miles/utils/arguments.py 中把 --update-weight-transfer-mode 的 choices 扩展为 broadcast / p2p / disk-delta / rdt,并在 miles_validate_args 中增加 RDT 专属断言:仅支持 --train-backend megatron、拒绝 Shared Actor/Critic PPO(RDT 每个 trainer rank 独占整张 GPU,PPO 的 critic 复用 actor bundle 必然无法调度)、与 p2p 共用 --colocate / PD 分离 / LoRA 的不兼容检查。miles/backends/megatron_utils/actor.pyinitupdate_weight_transfer_mode == "rdt" 优先选择新类 UpdateWeightFromRDT

步骤 2:核心传输实现

新增 miles/backends/megatron_utils/update_weight/update_weight_from_rdt.py(+430 行)。UpdateWeightFromRDT 继承 DistBucketedWeightUpdateMixin,完全复用其 bucketed TP/EP all-gather 与 HF 转换流程;差异化部分在于:_EngineRankBucket 用固定大小 GPU staging bucket 替代完整 rollout 模型副本,stage()_staging_span 对齐算法把每个 param 的 .data 重指向桶内连续 view;connect_rollout_engines 按 engine rank 分组 target,为每个 rank 建立本地 GPU replica(dummy load 后释放存储、只保留 weight_loader 元数据)并分配桶,期间完成两个关键修复——临时关闭 expandable_segments 再分配桶(VMM 段无法导出 CUDA-IPC handle,否则 NIXL 退化为 TCP 软件模拟约 0.3 GB/s)以及 register_nixl_memory 进程级钉扎(避免每次 flush 重注册导致远端重握手);桶大小取 max(配置值, 最大参数 nbytes) 动态扩容,超限 flush 拆分为顺序 NIXL rounds。_get_engine_scheduler_actors 缓存目标 SchedulerActor 并调用 register_weight_for_rdt 保持远端注册存活。

步骤 3:Rollout 引擎托管改造

新增 miles/ray/rollout/sglang_server_actor.pySGLangServerActor 在同一个 Ray job 内调用 sglang 的 launch_engine 创建全部 SchedulerActor,再在 daemon 线程运行 uvicorn,start() 直接返回 SchedulerActor handle。miles/backends/sglang_utils/sglang_engine.py_init_normal 在 RDT 模式设置 use_ray=Trueenable_rdt_weight_sync=True,经 _launch_sglang_server 启动 Actor 托管服务;多节点 engine 仅 node-0 启动(RayEngine 跨节点放置全部 rank),新增 get_scheduler_actors 暴露 handle,shutdownray.kill 清理。

步骤 4:Ray 资源与并发适配

miles/ray/placement_group.py_create_placement_group 在 RDT 模式按节点密集度重排 bundle(先填满 trainer 节点,避免 RayEngine STRICT_PACK 死锁),allocate_train_groupnum_gpus_per_actor 从 0.4 提为 1。miles/ray/rollout/server_group.py 复用 miles 的 rollout PG(对应 MILES_RDT_REUSE_PG=1 语义),coordinator actor 不再占 GPU,node-0 facade 拿到整套 bundle 列表。miles/ray/train/actor_factory.py 为训练 Actor 注入 RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICESPYTORCH_CUDA_ALLOC_CONF=expandable_segments:True(回收碎片)与 MILES_RDT_NIXL_VALIDATE_BYTES(按真实桶大小探测 EFA GPUDirect),并把 max_concurrency 提升到 1 + rollout_num_gpus_per_engine,避免阻塞在 ray.get()update_weights 饿死 NIXL 传输线程。

步骤 5:测试与配置配套

新增 e2e tests/e2e/megatron/test_qwen3_30B_A3B_rdt.py:8 卡 H100、actor 与 rollout 池分离(6+2)、显式 --update-weight-transfer-mode rdt,并注册 5 个 metric gate;tests/fast/utils/test_arguments.py 新增 TestRdtValidation 覆盖合法 megatron+grpo 与非法 fsdp/ppo 组合;修复 tests/fast/ray/rollout/conftest.pytest_actor_factory.py 缺失的 update_weight_transfer_mode 字段;requirements.txt 钉 sglang 版本以匹配 sglang-miles 分支。

文件 模块 状态 重要度
miles/backends/megatron_utils/update_weight/update_weight_from_rdt.py 权重同步 added 9.18
miles/ray/rollout/sglang_server_actor.py 服务器托管 added 8.1
miles/backends/sglang_utils/sglang_engine.py 引擎启动 modified 7.91
miles/ray/placement_group.py 资源分配 modified 6.71
miles/ray/train/actor_factory.py 角色工厂 modified 6.22
miles/ray/rollout/server_group.py 引擎组管理 modified 6.14
miles/utils/arguments.py 参数校验 modified 6.14
miles/backends/megatron_utils/actor.py 依赖装配 modified 5.72
tests/e2e/megatron/test_qwen3_30B_A3B_rdt.py 端到端测试 added 6.95
tests/fast/utils/test_arguments.py 参数校验 modified 6.18
miles/backends/megatron_utils/update_weight/update_weight_from_distributed/p2p_transfer_utils.py 依赖装配 modified 4.87
miles/backends/training_utils/ci_utils.py CI 工具 modified 4.67
tests/fast/ray/rollout/conftest.py 测试配置 modified 3.28
tests/fast/ray/test_actor_factory.py 测试配置 modified 3.28
requirements.txt 依赖配置 modified 1.72

关键符号

_staging_span _EngineRankBucket.stage UpdateWeightFromRDT.connect_rollout_engines UpdateWeightFromRDT.create_gpu_replica UpdateWeightFromRDT._get_engine_scheduler_actors UpdateWeightFromRDT.is_rollout_engines_fresh UpdateWeightFromRDT.mark_engine_connection_stale SGLangServerActor.start SGLangServerActor.is_alive _launch_sglang_server SGLangEngine._init_normal SGLangEngine.get_scheduler_actors SGLangEngine.shutdown _create_placement_group allocate_gpus_for_actor ServerGroup.start_engines

关键源码片段

miles/backends/megatron_utils/update_weight/update_weight_from_rdt.py core-logic

RDT 传输核心实现,新增 430 行:固定 GPU staging bucket、VMM/expandable 修复、NIXL 生命周期钉扎、动态扩容与分轮传输、SchedulerActor 发现与缓存。

def _staging_span(offset: int, dtype: torch.dtype, nbytes: int) -> tuple[int, int]:
    # 在 uint8 桶内按 dtype 对齐放置 nbytes 字节,返回 ( 起始 , 结束 ) 偏移。
    # 桶是全模型共用的连续显存,stage 时按各参数 shape 切 view,避免每次重建。
    start = (offset + dtype.itemsize - 1) // dtype.itemsize * dtype.itemsize
    return start, start + nbytes
​
​
class _EngineRankBucket:
    """单个 engine rank 的传输上下文:GPU 模型副本、固定大小显存桶、
    参数规格(shape/dtype/nbytes)以及负责拉取该分片的 SchedulerActor 列表。"""
​
    def __init__(
        self,
        model_replica: torch.nn.Module,
        params_dict: dict[str, torch.nn.Parameter],
        param_specs: dict[str, tuple[torch.Size, torch.dtype, int]],
        gpu_bucket: torch.Tensor,
        actors: list[ActorHandle],
    ) -> None:
        self.model_replica = model_replica
        self.params_dict = params_dict
        self.param_specs = param_specs
        self.gpu_bucket = gpu_bucket
        self.actors = actors
​
    def stage(self, names: list[str]) -> list[torch.Tensor]:
        # 把 params_dict[name].data 重新指向桶内的连续 view:load_weights 写入的
        # 显存,就是随后 ray.put(views, _tensor_transport="nixl") 导出并被
        # SchedulerActor pull 到 param.data 的那块显存,全程零拷贝。
        offset = 0
        views: list[torch.Tensor] = []
        capacity = self.gpu_bucket.numel()
        for name in names:
            shape, dtype, nbytes = self.param_specs[name]
            offset, end = _staging_span(offset, dtype, nbytes)
            assert end <= capacity, (
                f"[RDT] Bucket overflow while staging '{name}': "
                f"need {end} bytes but bucket is {capacity} bytes. "
                f"Increase --update-weight-buffer-size."
            )
            view = self.gpu_bucket[offset:end].view(dtype).reshape(shape)
            self.params_dict[name].data = view
            views.append(view)
            offset = end
        return views
        # 关键修复(throughput fix):expandable (VMM / cuMemCreate) 段无法导出
        # CUDA-IPC handle,UCX 会静默丢弃 cuda_ipc 通道,NIXL 退化为 TCP 软件模拟,
        # 带宽从 ~150 GB/s (NVLink) 掉到 ~0.3 GB/s。因此临时关掉
        # expandable_segments 再分配桶。
        expandable = "expandable_segments:True" in os.environ.get("PYTORCH_CUDA_ALLOC_CONF", "")
        if expandable:
            torch._C._accelerator_setAllocatorSettings("expandable_segments:False")
        try:
            # 桶大小 = max( 用户配置值 , 该 rank 最大目标参数 ),避免超大参数
            # (如 TP=1 时 Qwen3-30B-A3B 的 embed_tokens 约 0.58 GiB)触发断言。
            max_param_nbytes = max((spec[2] for spec in param_specs.values()), default=0)
            if max_param_nbytes > self.args.update_weight_buffer_size:
                logger.warning(
                    f"[RDT] Largest destination parameter needs {max_param_nbytes} bytes, above "
                    f"--update-weight-buffer-size ({self.args.update_weight_buffer_size}); "
                    f"growing bucket to fit."
                )
            gpu_bucket = torch.empty(
                max(self.args.update_weight_buffer_size, max_param_nbytes),
                dtype=torch.uint8,
                device=torch.cuda.current_device(),
            )
        finally:
            if expandable:
                torch._C._accelerator_setAllocatorSettings("expandable_segments:True")
​
        # 进程生命周期内钉住注册:否则每次 ray.put 重新注册桶,ObjectRef 释放还会
        # 抬高 NIXL agent 元数据版本,迫使远端引擎每次 flush 都重新握手。
        from ray.experimental import register_nixl_memory
​
        register_nixl_memory(gpu_bucket)
miles/ray/rollout/sglang_server_actor.py core-logic

新增的 SGLangServerActor 用同一 Ray job 的 Actor 托管 SGLang HTTP 服务与 SchedulerActor,替代 mp.Process,是 RDT 路径能直接拿回 SchedulerActor handle 的关键。

class SGLangServerActor:
    """Miles 托管的进程,承载 SGLang HTTP 服务(RDT / use_ray 模式)。    与 SGLangEngine facade 处于同一个 Ray job:start() 通过 launch_engine
    创建 SchedulerActor,再在守护线程里跑阻塞式 uvicorn,让 RPC 调用能
    直接拿回这些 handle。杀掉该 Actor 即拆除所有 scheduler。
    """
​
    def __init__(self):
        self._serve_thread: threading.Thread | None = None
​
    def start(self, server_args: ServerArgs, bundle_indices: list[int]) -> list:
        from sglang.srt.ray.http_server import launch_engine, serve_http
​
        # 必须在这里设置:父 Actor 的 os.environ 修改不会传到子进程。
        envs.SGLANG_RAY_BUNDLE_INDICES.set(",".join(str(i) for i in bundle_indices))
        placement_group = ray.util.get_current_placement_group()
        assert placement_group is not None
        engine = launch_engine(server_args, placement_group=placement_group)
        _, _, _, scheduler_init_result, _ = engine
        # uvicorn 是阻塞式的,放进 daemon 线程后 start() 才能返回 SchedulerActor。
        self._serve_thread = threading.Thread(
            target=serve_http,
            args=(engine, server_args),
            daemon=True,
            name="sglang-uvicorn",
        )
        self._serve_thread.start()
        return list(getattr(scheduler_init_result, "scheduler_actors", None) or [])
​
    def is_alive(self) -> bool:
        return self._serve_thread is not None and self._serve_thread.is_alive()
miles/ray/placement_group.py core-logic

RDT 模式下按节点密集度重排 placement group bundle,避免 RayEngine STRICT_PACK 死锁;同时把每个训练 actor 的 GPU 占用从 0.4 提为 1,是资源语义的关键变更。

    bundle_infos = [(i, gpu_ids[i][0], gpu_ids[i][1]) for i in range(num_bundles)]
    if is_rdt:
        # 让 trainer 先占满节点(node PACK 填满),rollout bundle 才能落到仍有空闲
        # GPU 的节点:RayEngine 会 STRICT_PACK 到 engine actor 所在节点,若该节点
        # 没有未预留资源就会死锁等待。
        node_bundle_counts: dict = {}
        for _, node_identifier, _ in bundle_infos:
            node_bundle_counts[node_identifier] = node_bundle_counts.get(node_identifier, 0) + 1
        sorted_bundle_infos = sorted(
            bundle_infos,
            key=lambda info: (-node_bundle_counts[info[1]], *sort_key(info)),
        )
    else:
        sorted_bundle_infos = sorted(bundle_infos, key=sort_key)
def allocate_train_group(args, num_nodes, num_gpus_per_node, pg, role, with_ref, rollout_manager, with_opd_teacher=False):
    # RDT 每物理 GPU 钉一个 NIXL/NCCL rank,不能像 0.4 分数预留那样与 colocated
    # rollout 共享设备;因此 rdt 模式下每个训练 actor 独占一张 GPU。
    num_gpus_per_actor = 1 if args.update_weight_transfer_mode == "rdt" else 0.4
    train_group_cls = _select_train_group_class()
    return train_group_cls(
        args=args,
        num_nodes=num_nodes,
        num_gpus_per_node=num_gpus_per_node,
        pg=pg,
        num_gpus_per_actor=num_gpus_per_actor,
        role=role,
        with_ref=with_ref,
        rollout_manager=rollout_manager,
        with_opd_teacher=with_opd_teacher,
    )

评论区精华

pull_weights 的定义位置与跨仓依赖 question

stephanie-wang 在 update_weight_from_rdt.py 上提问『Where is pull_weights defined?』;xyuzh 回复指向 sgl-project/sglang#27723,pull_weights 由 sglang-miles 分支的 SchedulerActor 提供。

结论:确认该 PR 依赖 sglang 侧配套分支,需在 requirements 中钉版本才能稳定。 · 已解决

双 flag 并存(--use-rdt-weight-sync 与 --update-weight-transfer-mode) 设计

stephanie-wang 质疑『Why do we have both this flag and the --update-weight-transfer-mode option?』;xyuzh 在后续提交中删除该弃用别名,统一以 transfer mode 为唯一事实源。

结论:移除 --use-rdt-weight-sync,收敛到 --update-weight-transfer-mode rdt。 · 已解决

同 job Actor 托管是否破坏进程生命周期契约 设计

yueming-yuan 指出 SGLangServerActor 方案『breaks Miles' contract of using the process to control the lifecycle of server engines, which will conflict with the process-based control logics, including cleanup, fault tolerance』;xyuzh 回应非 rdt 路径下非零 node rank 本就返回 None 进程,且 shutdown() 已兼容 _sglang_server_actor 的 ray.kill 清理。

结论:保留 Actor 托管方案,但补齐 shutdown 分支处理。 · 已解决

固定桶在超大参数下溢出(默认 512 MiB vs embed_tokens 0.58 GiB) 正确性

gemini-code-assist 提示单参数超过 --update-weight-buffer-size 会在首次 flush 触发 bucket overflow 断言;guapisolo 补充 TP=1 的 Qwen3-30B-A3B embed_tokens 约 0.58 GiB。xyuzh 实现动态扩容:桶取 max( 配置值 , 最大参数 nbytes),并把超限 flush 拆成顺序 NIXL rounds;e2e 故意删除 1 GiB 覆盖,让 CI 每次走扩容路径。

结论:动态扩容 + 分轮传输,e2e 强制覆盖扩容路径。 · 已解决

异构 engine GPU 数导致 transfer plan 错位 正确性

guapisolo P2 指出 --sglang-config 允许每组引擎覆盖 num_gpus_per_engine,而 RemoteTransferPlan 基于全局 args.rollout_num_gpus_per_engine 构建,异构布局会生成越界 scheduler rank 并漏掉部分引擎;xyuzh 在 connect_rollout_engines 中显式拒绝异构 GPU 数,per-engine plan 留待未来。

结论:先拒绝异构布局,per-engine plan 延迟实现。 · 已解决

max_concurrency 与 NIXL 传输服务线程饿死 性能

guapisolo 曾对 actor_factory 的 max_concurrency 改动提出 P1 质疑,随后追踪 Ray 2.56 concurrency group 实现后撤回:定义命名 concurrency group 会让 Ray 为 default group 单独建 executor,FT 路径的 update_weights 已天然跑在主线程外,非 FT 路径必须靠 max_concurrency>1 强制离开主线程,否则阻塞在 ray.get() 中的 update_weights 会饿死服务 NIXL 读取的传输线程。

结论:保留 max_concurrency = 1 + rollout_num_gpus_per_engine 的设定。 · 已解决

RDT 每 GPU 单 rank 与 Shared Actor/Critic PPO 冲突 正确性

maocheng23 在 placement_group.py 问『Would this be conflict with PPO?』;guapisolo 确认确定性冲突(RDT 每 trainer rank 占满一张 GPU,而 PPO 复用 actor bundle 放 critic,导致 critic 无法调度),并推送拒绝该校验的提交(e2f87822)。

结论:参数校验阶段拒绝 rdt + PPO,并补充 TestRdtValidation 测试。 · 已解决

sglang_engine.py 引入 ray 与 placement group 超出文件职责 style

yueming-yuan 认为 ray/PG 逻辑应属于 miles/ray,且 get_scheduler_actors 的 name 拼接偏硬编码;xyuzh 移除 SGLangEngine 构造参数,ray 仅方法内 import,并改用 sglang 导出的 get_scheduler_actor_name 重建名字。

结论:职责收敛 + 消除硬编码。 · 已解决

风险与影响

  1. 跨仓强依赖:RDT 依赖 sglang-miles 分支(sglang#27723、#34621)与 ray>=2.55.1,PR 末尾仍需钉 sglang 版本才能过 CI;若 sglang 侧接口(pull_weightsget_scheduler_actor_nameenable_engine_info_bootstrap)不稳定,本 PR 的传输路径会整体失效。
  2. 核心传输路径为全新代码:update_weight_from_rdt.py 单文件 +430 行,含显存生命周期、对齐、分轮等精细逻辑;_shared_param_mapper 为 None 的分支与 bucket 溢出断言都曾被 review 指出为潜在崩溃点。
  3. 兼容性缺口:rdt 与 --colocate、Shared PPO、PD 分离、LoRA 均不兼容(参数校验会拒绝),异构 engine GPU 布局被显式拒绝而非支持。
  4. 资源占用变化:num_gpus_per_actor 从 0.4 提到 1,且每个映射训练端桶的 SchedulerActor 额外消耗约 520 MiB CUDA context,对显存紧张的训练配置是新的约束。
  5. 共享代码回归面:mixin._finalize_and_resume_engines 新增的 needs_post_process 门控会同时影响 p2p/broadcast 路径的 post_process_weights 调用行为。
  6. 测试覆盖:e2e 仅覆盖单节点 8 卡、TP1·CP2·PP3 的 Qwen3-30B-A3B;TP8/EP8 跨 5 节点的 GLM-4.5-Air 与跨节点 EFA 场景靠人工验证,未进入 CI。

对用户:新增 --update-weight-transfer-mode rdt 后,RL 训练在 H100/EFA 集群可获得最快的权重同步(基准中 4/5 模型领先,最高约 2.2 倍于 NCCL),且不再需要每个 trainer rank 保留 rollout 模型的 CPU 副本,显著降低显存/内存压力;代价是必须配套 sglang-miles 分支与最新 Ray,并对资源布局有额外约束。对系统:权重同步从「广播 / 捎带完整副本」走向「per-rank GPU 直传」,实测 2→4 节点同步开销为零增长,为大模型多节点 rollout 扩展扫清路径。对团队:miles 与 sglang 两个仓库需同步演进,验收时须统一钉版本;该 PR 沉淀了 NIXL 注册生命周期、VMM/CUDA-IPC、EFA GPUDirect 探测等多条可复用的工程经验,对后续 zero-copy 通道(如 disk-delta、Mooncake 演进)有直接参考价值。

关联 Issue

#27723 [sglang-miles] RDT/NIXL weight sync support for Ray scheduler actors
#34621 [ray] expose get_scheduler_actor_name so callers can look up the schedulers

完整报告

参与讨论