rollout 要的是推理副本,训练要的是数据并行,reward 可能只跑 CPU,advantage 留在 driver。verl 用 single controller 把这些阶段包装成普通函数调用;colocate 省下 GPU,separate_async 换来 overlap,代价分别写在显存和 assert 里。
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 等约束。
读完这节你应该知道:为什么不能把 rollout、reward、训练简单塞进同一个进程,也不能各自开一套集群。
把 PPO 写成单机脚本时,一个 step 就是一条直线:取 batch → generate → reward → 算 old/ref log prob → advantage → update。写成分布式后,这条数据流没有变,变的是每一段各自需要的并行方式。
world_size ÷ (TP × DP × PP) 计算,TP 是推理侧的张量并行(verl/workers/rollout/llm_server.py:452-478)。ActorRolloutRefWorker(verl/trainer/main_ppo_v0.py:48-53)。global_pool,也可以单独开 reward_pool(verl/trainer/main_ppo_v0.py:75-82)。verl/trainer/ppo/ray_trainer.py:1667-1675 调用;uid 分组在 ray_trainer.py:240-245,组内统计在 core_algos.py:304-329)。mini_batch_size ÷ data_parallel_size 才是单卡 batch(verl/workers/engine_workers.py:302-305)。矛盾在于两件事同时成立:阶段之间有严格数据依赖,必须按顺序走;阶段的资源形态又不同,rollout 要推理副本,训练要数据并行。把 8 张卡固定分给某一阶段,其余阶段就闲置;把所有角色塞进同一组卡,又得在显存里同时容纳两套引擎。
single-controller 设计要解决的就是这个错位:数据流仍然用 Python 写在 driver 上,资源怎么绑、参数怎么切,交给 WorkerGroup 在调用时决定。
读完这节你应该知道:两条路各自省了什么、又在哪里付出代价。
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/)。
GPU 账:max_colocate_count=3 决定每个 placement-group bundle 的 CPU 配额与 worker 的 GPU 分数(1/3,verl/single_controller/),允许最多三个角色/实例共享同一张物理卡;当前 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)。
rollout.nnodes > 0 时,rollout 拥有自己的一组 GPU(verl/trainer/config/rollout/);v1 separate_async 用 standalone server manager 起副本(verl/trainer/ppo/v1/)。
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。
读完这节你应该知道:Worker、WorkerGroup、ResourcePool、DataProto 各自负责什么,以及一次调用是怎么被切开的。
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._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)。
图式说明:方法名不变,切分与收集发生在 WorkerGroup 生成的包装函数里。
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)——行为还在,实现正在换代。
它只有三个字段: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)。
repeat 再送进 rollout。engine_workers.py:322-324。RayPPOTrainer 路径的读数与锚点;默认 v1 sync 的对应位置:n 展开在 agent_loop_tq.py:113-131,权重同步在 trainer_sync.py:31-38,advantage 在 trainer_base.py:1794。口径: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)。
读完这节你应该知道:batch 维度在哪一步被放大、n 为什么会在更新次数里消失、默认配置下一次 step 到底跑了多少东西。
默认值: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 数 | — | 1024 | verl/trainer/config/data/legacy_data.yaml:42 |
| rollout 请求数 | 1024 × 1 | 1024 | verl/trainer/ppo/ray_trainer.py:1491 |
| rollout 副本数 | 8 ÷ (2×1×1) | 4 | verl/workers/rollout/llm_server.py:452-478 |
| actor mini-batch(全局样本) | 256 × 1 | 256 | verl/trainer/config/actor/actor.yaml:18 |
| actor 更新次数 / step | 1024 ÷ 256 × 1 | 4 | verl/trainer/ppo/ray_trainer.py:1353;verl/workers/engine_workers.py:322-324 |
| 每 worker GPU 份额 | 1 ÷ 3 | 0.333 | verl/single_controller/ray/base.py:641 |
| colocate 总 GPU | 1 × 8 | 8 | verl/trainer/config/ppo_trainer.yaml:168-171 |
| separate 示例总 GPU | 8 + 4 | 12 | verl/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)。
读完这节你应该能按图说出:一次 update_actor 从 driver 出发,经过哪几层才落到 GPU。
spawn 只改变暴露给 driver 的视图;DataProto 在阶段之间被 repeat、chunk、concat。虚线框是 v1 separate_async 才启用的独立 rollout 池。读完这节你应该知道:两种资源路线在默认配置下的具体账,以及三个容易反直觉的结论。
表 2 · 两条资源路线的对照;示例配置为训练 8 GPU、standalone rollout 4 GPU。
| 维度 | colocate | separate_async |
|---|---|---|
| 训练 GPU | 8 | 8 |
| 独立 GPU | 0 | 4 |
| 副本 | 8 ÷ (2×1×1) = 4 | 4 ÷ (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 |
样本数从 B 变成 B×n,mini-batch 也从 M 变成 M×n,更新次数仍是 B/M。把 n 从 1 调到 16,actor 每 step 的更新次数不变,变的是每次更新的样本量和 rollout 成本(v0 ray_trainer.py:1352-1353,1490-1491)。
同池的收益是“不用为每个阶段单独买卡”,不是“rollout 和训练并行”。同步 fit 循环里 rollout 结束会先 sleep_replicas(),actor update 完再 update_weights()(v0 ray_trainer.py:1513-1514,1714-1716)。
4 张独立 rollout GPU 在 TP=2 下只有 2 个副本;TP 翻倍,副本数减半。公式里的分母是 TP×DP×PP,不是单卡数(llm_server.py:452-478)。
读完这节你应该知道:什么时候别开 colocate、什么时候 separate_async 根本起不来,以及控制面真正的成本在哪。
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)。少一条就启动失败,不是运行期降级。
gpu_memory_utilization: 0.5 是 vLLM/SGLang 给 KV cache 的预算(verl/trainer/config/rollout/rollout.yaml:45-46),不是训练侧显存上限。训练侧还有参数、梯度、优化器状态和激活;两套引擎的峰值叠加才是 colocate 的真实门槛。
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 架构用。