核心概念:Controller / WorkerGroup / ResourcePool
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_info | group 间的全局拓扑 |
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 直接传,而是包了一层 DataProto(verl/protocol.py)。它的好处:
- 带元信息(batch size、device、dtype);
- 支持跨 worker 切分(
chunk())和合并(concat()); - 支持选择性传输:rollout 阶段只传 prompt token,不传 logits。
controller 和 worker 之间所有 RPC 的 payload 都是 DataProto。看 verl 源码时,遇到 data: DataProto 就知道这个函数被 RPC 调用。
4.7 一张全景图把这些串起来
(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)。原因:
- 训练阶段 actor 在用 GPU;rollout 阶段 actor 闲着;
- 把同一片 GPU 既给 actor 又给 rollout,能在不同阶段切换角色,省一半显存。
这就是 README 标题里说的 "3D-HybridEngine"。第 5 章会展开它的"权重 resharding"细节。
4.9 为什么这种抽象比 OpenRLHF 优雅
OpenRLHF 也用 Ray,但把 controller 也写成 Ray Actor,每个 worker 之间用 Ray 对象引用传数据。这种风格的好处是非常 Ray-native,但缺点是算法代码长得像 Ray 代码而不是 RL 算法。
verl 选择的"controller 是单进程 + worker 是 Ray Actor"路线:
- 算法工程师写的代码看起来就是单机 PPO,可读;
- 系统工程师改 worker 实现时,不会影响算法逻辑;
- 调试时可以把 worker group 改成
NaiveWorkerGroup,所有 RPC 直接走本地函数,绕开 Ray。
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):
- 调
_split_args_kwargs_data_proto_with_auto_padding()(line 91)把 DataProto / TensorDict 按 world_size 切; - 不够整除时自动 padding(
VERL_AUTO_PADDING=true,默认行为); - collect 时通过
BatchData(output).concat()把各 rank 输出按原顺序拼回,并丢弃 padding 尾部(_padding_size_key标记,ray/base.py:52–62)。
@register 不写 dispatch_mode 时,默认是
ALL_TO_ALL —— 每个 rank 都拿到完整数据,不是你想要的"切到本 rank"。
写训练 / inference 方法必须显式 @register(dispatch_mode=Dispatch.DP_COMPUTE_PROTO)。
4.12 DataProto 不只是个字典
verl/protocol.py 的 DataProto(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_METHOD 在 numpy(省内存)和 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: ...
解读:
dispatch_mode:可以是Dispatch枚举(4.11 那 8 个),也可以是一个返回 (dispatch_fn, collect_fn) 二元组的工厂函数;blocking:False 时 controller 立刻拿到 future,可以并发发下一条 RPC(异步训练用);True 时同步等结果;make_nd_compute_dataproto_dispatch_fn(mesh_name="..."):N-D 并行(TP + PP + DP)下,把 batch 沿 "mesh_name" 那一维切;这就是 FSDP 和 Megatron 都能复用同一个 worker 方法的关键。
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 字段重命名 | 138 | Ascend 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 源码时建议这样推进:
verl/trainer/main_ppo.py:39→ 入口和 ray.init;verl/trainer/main_ppo.py:111 TaskRunner→ controller 怎么装配 workers;verl/trainer/ppo/ray_trainer.py:1361 fit()→ 主循环;verl/single_controller/base/decorator.py:26 Dispatch→ RPC 数据分发;verl/single_controller/ray/base.py:48 func_generator→ RPC dispatch → execute → collect 的具体串接;verl/protocol.py:318 DataProto→ 数据信封;verl/workers/engine_workers.py:434 ActorRolloutRefWorker→ 真正干活的 worker。
读完这 7 步,整个 controller-worker 的工作流应该完全清晰。
4.17 这章你需要带走什么
- ResourcePool = GPU 池;
- WorkerGroup = 一组同质 worker 进程;
- Controller = 单进程算法主控;
- 三者通过
DataProto通信,@register装饰器声明 RPC 方法; - actor + rollout 默认 hybrid engine,省一半显存。