Chapter 04

核心概念:Controller / WorkerGroup / ResourcePool

📌 本章源码引用基于 verl/single_controller/ · commit 9c38b8bb1876 。

4.1 阅读本章前先记住的"三角形"

verl 全部代码都建立在这三个抽象上。先把名字记住,后面所有源码都会反复出现:

抽象角色(一句话)
Controller verl/single_controller/base/single_controller.py单进程、跑算法控制流,发起 RPC
WorkerGroup verl/single_controller/ray/base.py · RayWorkerGroup一组同质 Ray Actor,比如所有 actor
ResourcePool verl/single_controller/ray/base.py · RayResourcePool一片 GPU 资源池,给 WorkerGroup 用

4.2 ResourcePool:硬件抽象

RayResourcePool 就是"我有 N 台机器,每台机器 K 张 GPU,告诉 Ray 把它们记成一个池"。

resource_pool = RayResourcePool(
    process_on_nodes=[8, 8],     # 2 节点,每台 8 卡
    use_gpu=True,
    name_prefix="actor_pool",
)

这一对 [8, 8] 在 Ray 内部会变成一组 PlacementGroup,告诉 Ray scheduler "这 16 张 GPU 要打包在一起调度"。

verl 允许定义多个 ResourcePool,这样可以做"actor 用一片、rollout 用另一片"的异构放置。这是 HybridFlow 的核心 idea,第 5 章会讲。

4.3 WorkerGroup:一组同质 Ray Actor

每个 WorkerGroup 是一组同样代码、同样配置的 worker 进程,分别绑在 ResourcePool 的一张张 GPU 上。verl 里典型的 WorkerGroup 包括:

WorkerGroup 类跑什么
ActorRolloutRefWorker 训练 actor + reference + 内嵌 rollout(hybrid engine 模式)
CriticWorker 价值函数
RewardModelWorker 神经 reward 模型
AsyncActorRolloutRefWorker 异步 rollout 的变体(fully_async)

WorkerGroup 让 controller "看上去像调用单机方法一样调多卡 worker":

actor_wg = RayWorkerGroup(
    resource_pool=resource_pool,
    ray_cls_with_init=RayClassWithInitArgs(cls=ActorRolloutRefWorker, config=cfg),
)
actor_wg.init_model()                   # 实际在 16 张卡上同时跑 init
log_probs = actor_wg.compute_log_prob(data)   # 实际是分布式 forward
actor_wg.update_actor(data)              # 实际是分布式 train step

这就是"看起来单机、跑起来分布式"的精髓——RPC 透明化

4.4 Controller:算法主控

Controller 是单进程的 Python 脚本verl/trainer/main_ppo.py + verl/trainer/ppo/ray_trainer.py)。它做的事可以一句话总结:

调度 WorkerGroup 的方法,写"看起来像单机的算法主循环"。

例如,PPO 主循环在 controller 里大致是:

for epoch in range(total_epochs):
    for batch in dataloader:
        # 1. rollout(采样)
        rollouts = actor_wg.generate_sequences(batch)

        # 2. 计算 reference logp(用于 KL)
        ref_log_probs = ref_wg.compute_log_prob(rollouts)

        # 3. 算 reward
        rewards = reward_wg.compute_reward(rollouts)

        # 4. 算 advantage
        advantages = compute_advantages(rollouts, ref_log_probs, rewards)

        # 5. 训练 actor
        for ppo_epoch in range(ppo_epochs):
            actor_wg.update_actor(rollouts, advantages)

        # 6. 训练 critic
        critic_wg.update_critic(rollouts, returns)

这段代码看起来是单线程的 6 步,实际上每一行背后都是 Ray RPC + 多卡分布式计算。算法工程师只要懂 PPO,就能改这个循环,不用懂 Ray

4.5 Worker:每个 RPC 节点

Worker 是真正跑在 GPU 上的进程。所有 worker 都继承自 verl/single_controller/base/worker.py · Worker。它的关键属性:

属性说明
self.rank 本 worker 在所属 group 内的 rank
self.world_size group 大小
self.local_rank 本机内 rank
self.distributed_global_infogroup 间的全局拓扑

Worker 通过装饰器 @register 把方法标注为"controller 可调用":

class ActorRolloutRefWorker(Worker):
    @register(dispatch_mode=Dispatch.DP_COMPUTE_PROTO)
    def update_actor(self, data: DataProto) -> DataProto:
        ...

    @register(dispatch_mode=Dispatch.ALL_TO_ALL)
    def init_model(self):
        ...

dispatch_mode 控制 RPC 时数据怎么切分:

dispatch_mode含义
ALL_TO_ALL 每个 worker 都收到完整的 args(如 init_model)
DP_COMPUTE_PROTO 按 data parallel rank 切 DataProto(如 update_actor)
ONE_TO_ALL 主 rank 拿到结果后广播

4.6 DataProto:worker 间的"数据信封"

verl 不用 Python dict / torch.Tensor 直接传,而是包了一层 DataProtoverl/protocol.py)。它的好处:

controller 和 worker 之间所有 RPC 的 payload 都是 DataProto。看 verl 源码时,遇到 data: DataProto 就知道这个函数被 RPC 调用

4.7 一张全景图把这些串起来

flowchart TB Ctrl["Controller
(main_ppo.py + ray_trainer.py)"] RP1["ResourcePool A
GPU 0-7"] RP2["ResourcePool B
GPU 8-15"] ActorWG["ActorRolloutRefWorkerGroup
(同时持有 actor / ref / rollout)"] CriticWG["CriticWorkerGroup"] RewardWG["RewardModelWorkerGroup"] Ctrl -->|init| ActorWG Ctrl -->|init| CriticWG Ctrl -->|init| RewardWG ActorWG -.- RP1 CriticWG -.- RP2 RewardWG -.- RP2 Ctrl -->|RPC: generate_sequences| ActorWG Ctrl -->|RPC: compute_value| CriticWG Ctrl -->|RPC: compute_reward| RewardWG Ctrl -->|RPC: compute_log_prob| ActorWG Ctrl -->|RPC: update_actor| ActorWG Ctrl -->|RPC: update_critic| CriticWG

4.8 hybrid engine:rollout 和 actor 共置

verl 默认把 actor 和 rollout 放在同一组 GPU 上actor_rollout_ref.hybrid_engine: true)。原因:

这就是 README 标题里说的 "3D-HybridEngine"。第 5 章会展开它的"权重 resharding"细节。

4.9 为什么这种抽象比 OpenRLHF 优雅

OpenRLHF 也用 Ray,但把 controller 也写成 Ray Actor,每个 worker 之间用 Ray 对象引用传数据。这种风格的好处是非常 Ray-native,但缺点是算法代码长得像 Ray 代码而不是 RL 算法

verl 选择的"controller 是单进程 + worker 是 Ray Actor"路线:

4.11 完整的 Dispatch 枚举

4.5 列了 3 个最常见的 dispatch mode。实际上 verl/single_controller/base/decorator.py(line 26–47)枚举了 8 种。下表是读源码遇到陌生 mode 时的查表

Dispatch含义典型用在
RANK_ZERO 只有 rank 0 收到调用 checkpoint save / 全局 logging
ONE_TO_ALL 同一份参数广播到所有 rank init_model / update_weights 触发
ALL_TO_ALL 每个 rank 各自有一份不同的 args 少用;明确每 rank 独立的初始化
DP_COMPUTE 调用方预先把 args 切成 world_size 个 tuple已 chunked 的低阶 RPC
DP_COMPUTE_PROTO 自动把 DataProto / TensorDict 沿 dim 0 切到所有 DP rank训练 / log_prob / value 的主要路径
DP_COMPUTE_PROTO_WITH_FUNC同上 + 把一个函数 broadcast 给每个 rank per-rank 定制 reward 等
DP_COMPUTE_METRIC 各 rank 返回的 metric 不拼接,只汇总 训练 loss / KL 等单值统计
DIRECT_ROLLOUT_METHOD vLLM ExternalRayDistributedExecutor 直连 vLLM 路径下不经过 worker_group 直发 vllm engine

对于 DP_COMPUTE_PROTO,dispatch 函数是 dispatch_dp_compute_data_proto()(decorator.py:167):

  1. _split_args_kwargs_data_proto_with_auto_padding()(line 91)把 DataProto / TensorDict 按 world_size 切;
  2. 不够整除时自动 paddingVERL_AUTO_PADDING=true,默认行为);
  3. collect 时通过 BatchData(output).concat() 把各 rank 输出按原顺序拼回,并丢弃 padding 尾部(_padding_size_key 标记,ray/base.py:52–62)。
自定义 worker 方法只写 @register 不写 dispatch_mode 时,默认是 ALL_TO_ALL —— 每个 rank 都拿到完整数据,不是你想要的"切到本 rank"。 写训练 / inference 方法必须显式 @register(dispatch_mode=Dispatch.DP_COMPUTE_PROTO)

4.12 DataProto 不只是个字典

verl/protocol.pyDataProto(class 起于 line 318)远比 4.6 简介里描述的复杂。读源码时该知道的几个事实:

字段含义
batch 一个 TensorDict,所有张量字段都在这里,沿 dim 0 是 batch 维
non_tensor_batch numpy 数组字典,存"按样本"的非张量元信息(uid、原始 prompt 字符串、tools 字段等)
meta_info 整个 batch 共享的算法元数据(learning rate、scheduler step、KL coef ...)

关键方法(都在 protocol.py,对应行号):

方法行号用途
chunk(chunks) 864沿 dim 0 切;auto_padding=True 时自动 pad(line 74–107)
concat([list]) 917拼回;metric 字段不拼接而是 list of dict(line 958)
select_idxs(idxs) 635布尔 / 整数索引;rollout 后过滤垃圾样本常用
make_iterator(mini_batch_size, epochs)800包成 PyTorch DataLoader 给训练循环用

DataProtoFuture(line 1173–1228)是一个延迟版本——controller 调 worker 拿回的不是数据本身,而是一个 future。后续 collect_fn / dispatch_fn 在数据真正落地前就能链式组合。

跨进程序列化通过 VERL_DATAPROTO_SERIALIZATION_METHODnumpy(省内存)和 torch.save(cache 友好)之间切换,嵌套张量(如 ragged)走 torch.nested.as_nested_tensor(line 287)。这是排查"为什么我 DataProto 跨 worker 后 shape 不对"时第一档查的地方。

4.13 @register 装饰器的真实签名

读 verl 源码时会看到形形色色的 @register 注解,比如:

# engine_workers.py 大约这样
@register(dispatch_mode=Dispatch.ONE_TO_ALL, blocking=False)
def update_weights(self, global_steps): ...

@register(dispatch_mode=make_nd_compute_dataproto_dispatch_fn(mesh_name="actor"))
def compute_log_prob(self, data: DataProto) -> DataProto: ...

解读:

4.14 NaiveWorkerGroup:把分布式 RL 退化为单进程

调试 verl 时第一招就是把 RPC 关掉。verl/single_controller/ 提供了 NaiveWorkerGroup(基类在 base/worker_group.py),把 @register 的方法直接在本地进程里同步调用,不走 Ray。

用法:

# 把 RayWorkerGroup 换成 NaiveWorkerGroup
from verl.single_controller.base.worker_group import NaiveWorkerGroup

actor_wg = NaiveWorkerGroup(
    resource_pool=None,
    ray_cls_with_init=RayClassWithInitArgs(cls=ActorRolloutRefWorker, config=cfg),
)
# RPC 都变成本地函数调用 —— 可以 pdb 单步

代价:没有真正的分布式,只能跑单卡小模型。但 algorithm 层的 bug(GRPO 算错 advantage、loss 公式写错、DataProto 字段缺)99% 能在 Naive 模式下复现,比开着 Ray 调试快十倍。

4.15 RayResourcePool 的 placement 细节

4.2 给的是 RayResourcePool 的"一句话"用法。读 verl/single_controller/ray/base.py(line 112 起)能看到几个值得知道的事实:

特性行号含义
strategy="STRICT_PACK" 130同一 PG 里的 bundle 必须打包在同一节点,否则报错。RLHF 同 actor group 必须同节点共享 NVLink
bundle = {"CPU": max_colocate_count, "GPU": 1, <accelerator_type>: 1e-4}148每张 GPU 一个 bundle,附带最多 10 个 CPU,可加 accelerator label(H100/A100 选定)
NPU 时 GPU 字段重命名 138Ascend NPU 用 "NPU" 而不是 "GPU"
sort_placement_group_by_node_ip(pgs)69–77关键:PG 在分配 rank 前按节点 IP 排序——保证 Ray 重启后 rank 编号不变,FSDP checkpoint 能 resume

"PG 按 IP 排序"这件事看似 trivial,但它是 verl 在跑多机训练能稳定 resume 的根本原因。其他 RLHF 框架经常因为重启后 rank 重映射,导致 sharded checkpoint 对不齐。

4.16 一份"controller 在做什么"的速读路径

第一次读 verl 源码时建议这样推进:

  1. verl/trainer/main_ppo.py:39 → 入口和 ray.init;
  2. verl/trainer/main_ppo.py:111 TaskRunner → controller 怎么装配 workers;
  3. verl/trainer/ppo/ray_trainer.py:1361 fit() → 主循环;
  4. verl/single_controller/base/decorator.py:26 Dispatch → RPC 数据分发;
  5. verl/single_controller/ray/base.py:48 func_generator → RPC dispatch → execute → collect 的具体串接;
  6. verl/protocol.py:318 DataProto → 数据信封;
  7. verl/workers/engine_workers.py:434 ActorRolloutRefWorker → 真正干活的 worker。

读完这 7 步,整个 controller-worker 的工作流应该完全清晰。

4.17 这章你需要带走什么