verl + Ray
系统源码 · Ray · commit 7de8f2e

verl + Ray:一次 PPO 迭代,如何拆给五种角色

rollout 要的是推理副本,训练要的是数据并行,reward 可能只跑 CPU,advantage 留在 driver。verl 用 single controller 把这些阶段包装成普通函数调用;colocate 省下 GPU,separate_async 换来 overlap,代价分别写在显存和 assert 里。

volcengine/verl · 读的是 commit 7de8f2e 源码 ↗ single controller 文档 ↗
TL;DR · 30 秒抓住核心

verl 的核心不是某个 PPO 变体,而是 single controller + WorkerGroup:driver 上写的是普通 Python 数据流,@register 决定参数怎么切给 worker、结果怎么收回来。

colocate 让 Actor/Rollout/Reference/Critic 共享 global_pool,每个 worker 进程只申请 1/max_colocate_count 张 GPU;separate_async 另建独立 rollout 池,用额外 GPU 换异步 overlap。

rollout.n 只放大样本数,不改变 actor 更新次数:batch 和 mini-batch 都乘 n,更新次数仍是 train_batch_size / ppo_mini_batch_size。

代价同样是结构性的:colocate 要显存同时装下训练与推理引擎,且阶段串行;separate 要跨池同步权重,并满足 train_batch_size = parameter_sync_step × ppo_mini_batch_size 等约束。

4副本
8 GPU ÷ TP=2;rollout 并行度
1/3GPU
每个 worker 进程申请的份额;上限 3 个进程/卡
1024prompt
每 step 的 prompt 数;× rollout.n 才是样本数
01

一次 PPO step 里,五个阶段的并行方式并不相同

读完这节你应该知道:为什么不能把 rollout、reward、训练简单塞进同一个进程,也不能各自开一套集群。

把 PPO 写成单机脚本时,一个 step 就是一条直线:取 batch → generate → reward → 算 old/ref log prob → advantage → update。写成分布式后,这条数据流没有变,变的是每一段各自需要的并行方式。

矛盾在于两件事同时成立:阶段之间有严格数据依赖,必须按顺序走;阶段的资源形态又不同,rollout 要推理副本,训练要数据并行。把 8 张卡固定分给某一阶段,其余阶段就闲置;把所有角色塞进同一组卡,又得在显存里同时容纳两套引擎。

single-controller 设计要解决的就是这个错位:数据流仍然用 Python 写在 driver 上,资源怎么绑、参数怎么切,交给 WorkerGroup 在调用时决定。

02

verl 没有二选一:同池分时,或独立 rollout 池

读完这节你应该知道:两条路各自省了什么、又在哪里付出代价。

路线 A

同池 colocate

Actor/Rollout/Reference/Critic 映射到 global_pool(角色 mapping 在 verl/trainer/main_ppo_v0.py:54,65;池配置在 :70-73);create_colocated_worker_cls 把多个角色的类实例塞进同一个 Ray actor,spawn 再切成多个虚拟 WorkerGroup(verl/trainer/ppo/ray_trainer.py:870-880)。

GPU 账:max_colocate_count=3 决定每个 placement-group bundle 的 CPU 配额与 worker 的 GPU 分数(1/3,verl/single_controller/ray/base.py:200,641),允许最多三个角色/实例共享同一张物理卡;当前 create_colocated_worker_cls 的 WorkerDict 融合路径通常每卡一个 actor,多角色在同一进程内实例化(base.py:1032-1046)。

卡在哪:默认 trainer_mode: sync 下阶段严格串行;v0 RayPPOTrainer 路径里 rollout 结束后 replicas 先 sleep(ray_trainer.py:1513-1514),训练完再 update weights(ray_trainer.py:1714-1716;v1 sync 对应 trainer_sync.py:40-42,35-38);显存必须同时容得下训练引擎与推理引擎。v1 的 colocate_async 开启 partial rollout,在同一池里做异步重叠(verl/trainer/ppo/v1/trainer_colocate_async.py:19-24)。

路线 B

独立 rollout 池

rollout.nnodes > 0 时,rollout 拥有自己的一组 GPU(verl/trainer/config/rollout/rollout.yaml:10-14);v1 separate_async 用 standalone server manager 起副本(verl/trainer/ppo/v1/trainer_separate_async.py:167-183)。

GPU 账:总卡数 = 训练卡 + rollout.n_gpus_per_node × rollout.nnodes。默认 rollout.nnodes=0,所以这条路必须显式打开。

卡在哪:权重每步跨池同步(trainer_separate_async.py:398-401);还要满足 train_batch_size = parameter_sync_step × ppo_mini_batch_size(:60-64)、checkpoint backend 不能是 naive(:69-71)、reward model 必须独立池(:72-75)。

两条路共用同一个 driver 数据流,换的只是 ResourcePool 和 role mapping。它们不是两套彼此独立的训练栈:共享 WorkerGroup/DataProto API,但 trainer 子类与调度、权重同步路径不同——v1 注册了 PPOTrainerSync、PPOTrainerColocateAsync、PPOTrainerSeparateAsync(verl/trainer/ppo/v1/trainer_sync.py:25、trainer_colocate_async.py:20、trainer_separate_async.py:46),v0 另有 RayPPOTrainer。

03

Single controller:driver 写普通 Python,WorkerGroup 把它变成 RPC

读完这节你应该知道:Worker、WorkerGroup、ResourcePool、DataProto 各自负责什么,以及一次调用是怎么被切开的。

ResourcePool 先把 GPU 变成可复用的 bundle

ResourcePoolManager 接收一个 {池名: [每节点进程数]} 的配置,为每个池建一个 RayResourcePool(verl/trainer/main_ppo_v0.py:70-99)。每个进程对应一个 placement-group bundle:{"CPU": max_colocate_count, "GPU": 1}(verl/single_controller/ray/base.py:150-152)。

真正让多个 worker 共享一张卡的是下一行:worker 创建时申请 num_gpus = 1 / max_colocate_count(base.py:641)。Ray 看到的是小数 GPU,物理卡仍是一张;这是调度上限,默认最多 3 个这样的进程可以落在同一张卡上(是否真的拆成 3 个进程,取决于角色是否走独立 WorkerGroup)。

WorkerGroup 在调用时决定怎么切

WorkerGroup._bind_worker_method 扫描被 @register 装饰的方法,从 DISPATCH_MODE_FN_REGISTRY 取出对应的 dispatch/collect 函数(verl/single_controller/base/worker_group.py:185-225)。ONE_TO_ALL 把参数复制给所有 worker;DP_COMPUTE_PROTO 的 dispatch 用 DataProto.chunk 把数据切成 world_size 份(实现见 verl/single_controller/base/decorator.py:167-177,collect/concat 见 :191-199;注册表在 :308-321)。

driver: actor_rollout_wg.update_actor(batch) │ dispatch_fn = DP_COMPUTE_PROTO ▼ worker: @register(Dispatch.DP_COMPUTE_PROTO) batch.chunk(world_size) → 每个 rank 一份 │ collect_fn = concat ▼ driver: DataProto.concat(...) → 交给下一阶段

图式说明:方法名不变,切分与收集发生在 WorkerGroup 生成的包装函数里。

WorkerDict 让“多个角色”变成同一个 Ray actor 里的多个实例

create_colocated_worker_cls 构造一个 WorkerDict,在 DISABLE_WORKER_INIT=1 的环境里依次实例化传入的各个角色类,再把带前缀的方法绑到父类上(verl/single_controller/ray/base.py:1032-1046)。spawn(prefix_set) 返回按前缀切开的虚拟 WorkerGroup(base.py:742-760)。

所以 self.actor_rollout_wg 和 self.critic_wg 看起来是两个组,底层是同一批 Ray actor(同一批进程、同一批 GPU),spawn 只改变 driver 看到的视图。当前 ray_trainer 仍调用这个函数(ray_trainer.py:873),函数上方已标注 deprecated, switching to FusedWorker(base.py:1011)——行为还在,实现正在换代。

DataProto 是阶段之间的共同交换协议

它只有三个字段:batch(TensorDict)、non_tensor_batch(dict)、meta_info(dict)(verl/protocol.py:326-328)。__len__ 优先取 TensorDict 的 batch 维,没有再取 non_tensor_batch 中某个键对应数组的长度(protocol.py:334-340)。DP 切分前如果长度不能被 world_size 整除,会先补齐再切(protocol.py:74-99)。

01 rollout
8 / 8 GPU
生成 B×n 条 response
02 reward
0 / 8 GPU
规则奖励在 CPU;RM 另算
03 advantage
0 / 8 GPU
driver 按 uid 分组算均值/方差
04 actor update
8 / 8 GPU
mini-batch 再按 DP 切
rollout 输入 / 输出
1024 条请求 = 1024 prompts × 1。每个 prompt 采样 n 条,DataProto 先 repeat 再送进 rollout。
rollout 副本 / GPU 占用
4 个副本,当前模式占用 8 张 GPU(训练 8 + 独立 rollout 0)。
通信
阶段间的 DataProto 由 driver 分发/收集;权重同步 1 次/step · 同池。同池同步在训练/推理引擎之间搬运权重,跨池则走 checkpoint engine。
actor 更新次数
每 step 更新 4 次 = B ÷ 256 × ppo_epochs(1);n 被约掉。v0 路径的循环在 engine_workers.py:322-324。
当前阶段
rollout:AgentLoop 把 B×n 条 prompt 切成 chunk,分给各 agent loop worker;真正生成由 LLMServerManager 管的 vLLM/SGLang 副本完成。
colocate:所有角色共享 global_pool;阶段之间串行,rollout 结束会先 sleep replicas。
以下为 v0 RayPPOTrainer 路径的读数与锚点;默认 v1 sync 的对应位置:n 展开在 agent_loop_tq.py:113-131,权重同步在 trainer_sync.py:31-38,advantage 在 trainer_base.py:1794。
手算:两组参数 → 与交互读数逐位对账JS 与下面用同一组公式

口径:samples = B × n;updates = B ÷ 256 × 1;副本 = rollout GPU ÷ (TP × DP × PP),TP=2、DP=1、PP=1;colocate 的训练 GPU 为 8,separate 的 standalone rollout GPU 为 4。

组 A · colocate,n=1,B=1024:
samples = 1024 × 1 = 1024
updates = 1024 ÷ 256 × 1 = 4
replicas = 8 ÷ (2 × 1 × 1) = 4
GPU = 8 + 0 = 8;权重同步 = 1 次/step · 同池

组 B · separate_async,n=8,B=1024:
samples = 1024 × 8 = 8192
updates = 1024 ÷ 256 × 1 = 4(n 同时出现在分子和 mini-batch 分母)
replicas = 4 ÷ (2 × 1 × 1) = 2
GPU = 8 + 4 = 12;权重同步 = 1 次/step · 跨池

把滑块调到这两组,页面读数应分别显示 1024 / 4 / 4 / 8 和 8192 / 4 / 2 / 12。separate_async 默认还要求 B=1024(4 × 256),改 B 时要同步改 parameter_sync_step 或 ppo_mini_batch_size,否则启动断言失败(verl/trainer/ppo/v1/trainer_separate_async.py:60-64)。

04

一个 PPO step 的账:B×n 条样本,B/M 次更新

读完这节你应该知道:batch 维度在哪一步被放大、n 为什么会在更新次数里消失、默认配置下一次 step 到底跑了多少东西。

样本数 = train_batch_size × rollout.n
每 step 更新次数 = train_batch_size ÷ ppo_mini_batch_size × ppo_epochs
rollout 副本数 = world_size ÷ (TP × DP × PP)
每 worker GPU 份额 = 1 ÷ max_colocate_count

默认值:B=1024、M=256、n=1、epochs=1、world=8、TP=2、DP=1、PP=1、max_colocate_count=3。

口径:以下为 v0 RayPPOTrainer 路径(verl/trainer/ppo/ray_trainer.py)。ppo_trainer.yaml:232 默认 use_v1: true;默认 v1 sync 的对应位置:n 展开在 verl/trainer/ppo/v1/agent_loop_tq.py:113-131,样本合并走 TransferQueue,权重同步在 verl/trainer/ppo/v1/trainer_sync.py:31-38,advantage 在 verl/trainer/ppo/v1/trainer_base.py:1788-1797(num_repeat 在 :1794)。数值结论 B/M 不随路径变化。

第一步放大发生在 rollout 前(v0 路径):gen_batch.repeat(repeat_times=rollout_n) 把 B 条 prompt 变成 B×n 条请求(verl/trainer/ppo/ray_trainer.py:1490-1491)。rollout 回来后,原始 batch 也按同样的 n 重复,再与生成结果 union(ray_trainer.py:1540-1541)。

第二步放大发生在 actor update 前:ppo_mini_batch_size 也乘 n(ray_trainer.py:1352-1353),再交给训练引擎按 DP size 除(verl/workers/engine_workers.py:302-305)。于是更新次数是 (B×n) ÷ (M×n) = B/M:n 同时出现在分子和分母,抵消了。

表 1 · 默认配置下一次 PPO step 的资源账;数字来自配置默认值或源码公式。

量公式默认值源码
每 step prompt 数—1024verl/trainer/config/data/legacy_data.yaml:42
rollout 请求数1024 × 11024verl/trainer/ppo/ray_trainer.py:1491
rollout 副本数8 ÷ (2×1×1)4verl/workers/rollout/llm_server.py:452-478
actor mini-batch(全局样本)256 × 1256verl/trainer/config/actor/actor.yaml:18
actor 更新次数 / step1024 ÷ 256 × 14verl/trainer/ppo/ray_trainer.py:1353;verl/workers/engine_workers.py:322-324
每 worker GPU 份额1 ÷ 30.333verl/single_controller/ray/base.py:641
colocate 总 GPU1 × 88verl/trainer/config/ppo_trainer.yaml:168-171
separate 示例总 GPU8 + 412verl/trainer/config/rollout/rollout.yaml:10-14

GRPO 的 advantage 也在 driver 上完成:先按 uid 把 n 条 response 分到同一组(ray_trainer.py:240-245),再算组内均值与标准差(torch.std 默认做 N−1 校正),(r_i − mean) ÷ (std + ε) 广播到 response 的每个 token(verl/trainer/ppo/core_algos.py:304-329)。如果 n=1,组内只有一个样本,代码把 mean 设为 0、std 设为 1(core_algos.py:315-317)。

05

Driver 只发 RPC,GPU 上的 WorkerDict 才是执行者

读完这节你应该能按图说出:一次 update_actor 从 driver 出发,经过哪几层才落到 GPU。

Driver(CPU) TaskRunner / RayPPOTrainer 只写数据流、发 RPC、算 advantage RayPPOTrainer.fit() ResourcePoolManager resource_pool_spec global_pool reward_pool / teacher_pool PlacementGroup bundles 每个 bundle:1 GPU + max_colocate_count CPU 槽 GPU0GPU1GPU2…GPU7 每个 worker 申请 1/3 GPU Ray 执行层:WorkerGroup + 管理器 actors WorkerGroup 的方法由 @register 绑定;调用被 dispatch/chunk,结果被 collect/concat WorkerDict(每个 Ray actor 一个) ActorRolloutRefWorker + TrainingWorker Actor Rollout Reference Critic → 同一个 GPU 进程 spawn(prefix_set) 把它们暴露成不同的虚拟 WorkerGroup RewardLoop 规则奖励 / RM 无 RM 时走 CPU 有 RM 时可独立池 AgentLoop 多轮 / 工具调用 chunk prompts async gather init_workers() DataProto(阶段之间) batch: TensorDict · non_tensor_batch · meta_info repeat(n) → chunk(world_size) → concat → union rollout 输出、reward、old_log_prob、advantage 都挂在同一个对象上 LLMServerManager vLLM / SGLang replicas 副本 = world ÷ (TP×DP×PP) sleep / wake / update_weights 由 checkpoint manager 驱动 generate dispatch / collect 可选:standalone rollout pool 仅 v1 separate_async,rollout.nnodes > 0 时启用
图 1 · Driver 只持有 WorkerGroup 的句柄;真正的模型进程落在 PlacementGroup 的 bundle 上。WorkerDict 可以同时装 actor_rollout_ref 与 critic,spawn 只改变暴露给 driver 的视图;DataProto 在阶段之间被 repeat、chunk、concat。虚线框是 v1 separate_async 才启用的独立 rollout 池。
06

colocate 省 GPU,separate 买 overlap;n 不改变更新次数

读完这节你应该知道:两种资源路线在默认配置下的具体账,以及三个容易反直觉的结论。

表 2 · 两条资源路线的对照;示例配置为训练 8 GPU、standalone rollout 4 GPU。

维度colocateseparate_async
训练 GPU88
独立 GPU04
副本8 ÷ (2×1×1) = 44 ÷ (2×1×1) = 2
权重同步同池,step 末 1 次(ray_trainer.py:1714-1716)跨池,step 末 1 次(trainer_separate_async.py:398-401)
显存与训练共享;rollout 的 KV cache 预算默认 0.5(rollout.yaml:46)独立池,各自预算
约束hybrid_engine=true(ppo_trainer.yaml:60);v0 入口有 assert(ray_trainer.py:334)B = parameter_sync_step×M(:60-64);backend ≠ naive(:69-71);RM 独立池(:72-75)
适用卡少、阶段可串行卡多、需要 rollout 与训练 overlap
反直觉 1

n 在更新次数里被约掉

样本数从 B 变成 B×n,mini-batch 也从 M 变成 M×n,更新次数仍是 B/M。把 n 从 1 调到 16,actor 每 step 的更新次数不变,变的是每次更新的样本量和 rollout 成本(v0 ray_trainer.py:1352-1353,1490-1491)。

反直觉 2

colocate 不等于同时跑

同池的收益是“不用为每个阶段单独买卡”,不是“rollout 和训练并行”。同步 fit 循环里 rollout 结束会先 sleep_replicas(),actor update 完再 update_weights()(v0 ray_trainer.py:1513-1514,1714-1716)。

反直觉 3

副本数由 TP 决定,不只由卡数决定

4 张独立 rollout GPU 在 TP=2 下只有 2 个副本;TP 翻倍,副本数减半。公式里的分母是 TP×DP×PP,不是单卡数(llm_server.py:452-478)。

07

这套架构的边界,写在 assert 和配置里

读完这节你应该知道:什么时候别开 colocate、什么时候 separate_async 根本起不来,以及控制面真正的成本在哪。

硬约束

不是所有入口都支持拆开 rollout 与训练

v0 的 RayPPOTrainer 在构造时直接 assert self.hybrid_engine(verl/trainer/ppo/ray_trainer.py:333-334)。把 actor_rollout_ref.hybrid_engine 设为 false 只在 v1 separate_async 路径可用;v0 路径会在启动时失败。

separate_async 还要求:rollout.nnodes > 0(trainer_separate_async.py:65-68)、checkpoint backend 不能是 naive(:69-71)、有 reward model 时必须 enable_resource_pool=True(:72-75)、以及 train_batch_size = parameter_sync_step × ppo_mini_batch_size(:60-64)。少一条就启动失败,不是运行期降级。

显存

colocate 的 0.5 不是“训练只占一半”

gpu_memory_utilization: 0.5 是 vLLM/SGLang 给 KV cache 的预算(verl/trainer/config/rollout/rollout.yaml:45-46),不是训练侧显存上限。训练侧还有参数、梯度、优化器状态和激活;两套引擎的峰值叠加才是 colocate 的真实门槛。

控制面

每个阶段的数据都要过一遍 driver / Ray object store

advantage 明确写在 driver 上算(ray_trainer.py:1667);WorkerGroup 的 DP_COMPUTE_PROTO 会先 chunk、必要时 padding,再把结果 concat 回来(verl/protocol.py:74-99;dispatch 在 decorator.py:167-177,collect 在 :191-199)。B×n 很大或序列很长时,driver 内存和序列化开销会先于 GPU 成为瓶颈。推断(依据:数据流由 driver 统一持有;代码注释称 advantage 为 light-weight,说明作者已把它放在 driver 以避开 GPU 通信)。

max_colocate_count 是调度上限,不是性能收益:每个 worker 申请 1/3 张 GPU,一张卡最多容纳 3 个这样的进程;第 4 个要么等别的卡,要么把该值调大后重新分片(verl/single_controller/ray/base.py:200,641)。融合路径下通常每卡一个 actor,多角色在同一进程内实例化(base.py:1032-1046),并不需要真的开满 3 个进程。

还有一条产品边界:AgentLoopManager 的 worker 是 Ray actor,按节点做软亲和调度,再把 prompt chunk 后 asyncio.gather(verl/experimental/agent_loop/agent_loop.py:1295-1310,1335-1341)。它服务的是 RL 采样,不是高 QPS 在线推理;别把训练期的 rollout 拓扑直接当 serving 架构用。