Chapter 05

Ray 在 OpenRLHF 里做什么

📌 commit 6c6056daa522 调度异构进程的底座

OpenRLHF 的核心差异化技术不是 PPO 算法本身(DeepSpeed-Chat 也有),而是用 Ray 把异构进程编排起来。 这一章把 Ray 在 OpenRLHF 里扮演的角色拆开,看看它解决了什么问题、用了哪些 API、 以及它和 verl / TRL 选择的不同路径。

5.1为什么需要 Ray

RLHF 训练有几个特征导致单进程 PyTorch 无法承载

  1. 同时运行 4 种异构进程(actor / ref / critic / reward)+ 1 种采样进程(vLLM);
  2. 它们对显存的需求不同,需要灵活的资源分配;
  3. 需要在不同 GPU 子集之间动态调度,把同一组 GPU 在不同阶段分给不同角色(hybrid engine);
  4. 多机训练时,跨节点通信不能用普通的 NCCL(角色间的接口不是张量级 all-reduce,而是 RPC)。

Ray 正好提供这套能力:

需求Ray 提供的能力
异构进程编排 @ray.remote Actor 模型
GPU 资源分配 PlacementGroup + num_gpus=N
跨节点 RPC Ray Actor handle 自动支持跨机调用
动态调度 STRICT_PACK / SPREAD 策略 + 共置 hint
故障恢复 Actor 自动重启(一定程度)
监控 Ray Dashboard(默认 :8265)

5.2Ray 三大抽象在 OpenRLHF 里

Ray 抽象OpenRLHF 用法对应源码
@ray.remote(num_gpus=1)
Actor 类
把每个训练 / 采样进程包成 Ray Actor openrlhf/trainer/ray/launcher.py · BaseDistributedActor
PlacementGroup 声明"我要 N 张 GPU 打包放在一起" RayActorGroup.create_placement_group()
Actor handle 方法调用
actor.method.remote()
controller 调 worker 的 RPC 遍布 experience_maker.py

5.3BaseDistributedActor:所有训练进程的基类

openrlhf/trainer/ray/launcher.py 里定义了 BaseDistributedActor,它就是把 PyTorch distributed worker 包成 Ray Actor

class BaseDistributedActor:
    def __init__(self, world_size, rank, master_addr, master_port):
        # 把 Ray Actor 当成一个 torch.distributed 进程
        os.environ["MASTER_ADDR"] = master_addr
        os.environ["MASTER_PORT"] = str(master_port)
        os.environ["WORLD_SIZE"] = str(world_size)
        os.environ["RANK"] = str(rank)
        os.environ["LOCAL_RANK"] = str(ray.get_gpu_ids()[0])

关键点:每个 Ray Actor 既是 Ray 调度的单位,也是 PyTorch DDP/DeepSpeed 的一个 rank。 两套并行抽象在这一层 fuse 在一起。

5.4RayActorGroup:N 个 Actor 组成的"角色集群"

每个 RLHF 角色(actor / ref / critic / reward)被一个 RayActorGroup 包起来。它本质上是:

class RayActorGroup:
    def __init__(self, num_nodes, num_gpus_per_node, ray_actor_type):
        self.world_size = num_nodes * num_gpus_per_node
        # 创建 placement group: world_size 个 GPU 打包成一组
        self.pg = placement_group(
            [{"GPU": 1, "CPU": 1}] * self.world_size,
            strategy="STRICT_PACK",        # 尽量装在一起
        )
        # 在 placement group 里启 world_size 个 actor
        self.actors = [
            ray_actor_type.options(
                num_gpus=1,
                placement_group=self.pg,
                placement_group_bundle_index=i,
            ).remote(world_size=self.world_size, rank=i, ...)
            for i in range(self.world_size)
        ]

    def async_run_method(self, method, *args, **kwargs):
        # 调所有 actor 的同名方法
        return [a.__ray_call__.remote(method, *args, **kwargs) for a in self.actors]

controller 通过 async_run_method 一次性调用所有 worker,类似 verl 的 WorkerGroup

5.5典型放置:8 卡机怎么分

train_ppo_ray_hybrid_engine.sh 那一长串 --xxx.num_nodes / num_gpus_per_node

--ref.num_nodes 1 --ref.num_gpus_per_node 8
--reward.num_nodes 1 --reward.num_gpus_per_node 8
--critic.num_nodes 1 --critic.num_gpus_per_node 8
--actor.num_nodes 1 --actor.num_gpus_per_node 8
--vllm.num_engines 4 --vllm.tensor_parallel_size 2
--train.colocate_all                              # ★ 关键

看上去要 8×4 = 32 张卡(actor 8 + ref 8 + critic 8 + reward 8)+ vLLM 4×2 = 8 张,总 40 张。但其实只有 8 张物理卡。colocate_all 让所有角色共享同一组 8 张卡,靠 sleep mode 分时复用。

flowchart TB subgraph cards[同一组 8 张 GPU] G0[GPU0] G1[GPU1] G2[GPU2] G3[GPU3] G4[GPU4] G5[GPU5] G6[GPU6] G7[GPU7] end subgraph stages[同一组 GPU 在不同阶段扮演不同角色] direction LR S1[阶段1
vLLM rollout] S2[阶段2
训练 actor / critic] S3[阶段3
评估 ref / reward] end cards -.colocate.-> stages

第 8 章会专门讲三种"colocate 程度"对应的放置方式。

5.6controller 跑在哪

OpenRLHF 的controller(算法主循环)跑在哪里

从源码看,openrlhf.cli.train_ppo_ray 的 main 函数本身就是 controller:

# 简化版
def main(args):
    ray.init()
    # 起 4 个 RayActorGroup
    actor_group = RayActorGroup(...)
    ref_group = RayActorGroup(...)
    critic_group = RayActorGroup(...)
    reward_group = RayActorGroup(...)
    vllm_engines = [LLMRayActor.remote(...) for _ in range(num_engines)]

    # 起 RemoteExperienceMaker:负责跑主循环
    experience_maker = RemoteExperienceMaker(
        actor_model_group, critic_model_group, reward_model_group,
        initial_model_group, ...
    )

    # 主循环
    for episode in range(max_episodes):
        for step in range(steps_per_episode):
            experiences = experience_maker.make_experience(prompts)
            actor_group.training_step(experiences)
            critic_group.training_step(experiences)

controller 是一个普通 Python 进程,不是 Ray Actor,跑在你提交 train_ppo_ray 的那台机器上。 所有 worker 都是 Ray Actor,通过 controller 调度。

5.7placement_group 放置策略

Ray 的 placement_group 有 4 种放置策略,OpenRLHF 默认用 STRICT_PACK

策略含义OpenRLHF 何时用
PACK 尽量同节点
STRICT_PACK 必须同节点(凑不齐就报错) 同一 RayActorGroup 默认
SPREAD 尽量分散到不同节点
STRICT_SPREAD必须分散

这样能保证一个角色(如 actor)的 N 个 worker 都在同一节点,TP 等 intra-node 通信走 NVLink。 跨节点(DP)走 IB,不在同一 placement group。

5.8跨 group 怎么通信

四个 RayActorGroup 之间不是用 NCCL 而是 Ray RPC(actor 方法调用)。例如 controller 让 actor group 同步权重到 vLLM:

def broadcast_weights_to_vllm(self):
    # 在 actor 端
    for name, param in self.model.named_parameters():
        # nccl group:actor 和 vllm 之间提前建好的 nccl 通信组
        torch.distributed.broadcast(param, src=0, group=actor_vllm_nccl_group)

注意:

5.9故障 / 监控

Ray Dashboard

启动 Ray 后,浏览器打开 http://<head_ip>:8265 能看到:

注意 Ray Actor crash 时,controller 不会自动重启它。 整个训练会卡住(因为 controller 等 RPC 永远不返回)。 这就是为什么 OpenRLHF 多机训练有时会"看似 hang"——其实是某个 worker 死了。 解决方案:用 ray list actors --filter "state=DEAD" 找出问题 actor。

故障调试

问题命令
看所有 actor 状态 ray list actors
看某 actor 日志 ray logs actor <ACTOR_ID>
看 placement group ray list placement-groups
看 task 状态 ray list tasks
提交 job ray job submit --address=http://127.0.0.1:8265 -- python ...

5.10OpenRLHF 用 Ray 的方式 vs verl / TRL

维度OpenRLHFverlTRL
controller 不是 Ray Actor,普通进程 不是 Ray Actor不用 Ray
worker Ray Actor Ray Actor
每 group 抽象 RayActorGroup WorkerGroup
角色间通信 Ray RPC + 临时 NCCL group 类似 同进程
多机扩展 Ray 集群 Ray 集群 accelerate
故障恢复 手动重启训练 手动重启

OpenRLHF 和 verl 在这个层面非常像(都是"controller 单进程 + worker Ray actor"),主要差别在具体的角色组织和训练后端选择

5.11RayActorGroup 关键设计:duplicate_actors

openrlhf/trainer/ray/launcher.py:202RayActorGroup 不是简单的 "N 个 actor 包一组"。它内部维护 duplicate_actors = ring_attn_size × tensor_parallel_size 的概念:

概念定义
world_size group 内总 actor 数 = nodes × gpus_per_node
duplicate_actors 每份数据要复制给多少个 actor
effective_actors world_size / duplicate_actors,真正不同数据的 actor 数

为什么需要 duplicate_actors?因为 ring attention(sequence parallel)和 tensor parallel 内部需要同一份数据 —— 一个 8 卡机如果开 ring_attn_size=2, tp_size=2,4 个 effective actor 各拿 1/4 batch,但每个 actor 内部又复制成 2×2 = 4 份去做 SP+TP 计算。

5.12batched RPC:round-robin 数据分片

RayActorGroup.async_run_method_batch(method_name, **kwargs)(launcher.py:319)是 OpenRLHF 把 Ray 用得跟"分布式数据加载器"差不多的关键 API:

  1. 把传入的 batch(如 prompts)切成 effective_actors 份;
  2. ray.put() 一次性把切片放进 Ray object store(零拷贝传递);
  3. 每个 actor 调 method_name(chunk_i, **kwargs),相同 ring/TP 副本拿同一份 chunk;
  4. 返回 list[ray.ObjectRef],调用方决定何时 ray.get()

对照 async_run_method(不带 batch):所有 actor 拿完整 args,不切分,常用在 init / save 这类全局动作。区分这两个 method 是看懂 controller 代码的关键。

5.13Ray Actor 三层基类

launcher.py 顶上能找到三层基类,每层多挂一点东西:

基类位置挂了什么
BaseDistributedActor line 17 torch.distributed 环境变量(MASTER_ADDR/PORT, RANK, WORLD_SIZE)
BaseModelActor line 54 load_model_from_pretrained / forward / batch execution
ReferenceModelActor line 105 ref policy 路径,@ray.remote(num_gpus=1) 标记
RewardModelActor line 158 reward 打分,@ray.remote(num_gpus=1) 标记
PolicyModelActor ray/ppo_actor.pypolicy 训练 + EMA + vLLM 权重同步
CriticModelActor ray/ppo_critic.pycritic 训练 + ValueLoss

"我想加一个新角色 worker" → 继承 BaseModelActor@ray.remote(num_gpus=1),封装到 RayActorGroup(MyActor, ...)

5.14这章带走的