
上一篇解释了 single controller:PPO 主循环保留在一个 controller 进程里,一行 WorkerGroup 调用会被展开成 dispatch、Ray remote execution 和 collect。这篇继续往下问:这些远端 worker 到底落在哪些 GPU 上?actor、rollout、critic、reward、teacher 为什么不能只被看成“一堆 GPU 任务”?
本文的核心判断是:verl 不是按函数分配 GPU,而是先把系统职责抽象成 Role,再把 Role 映射到 resource pool,最后由 WorkerGroup 或对应 manager 把这些资源变成可调用的远端执行单元。理解这一层,才能判断“GPU 不够”到底是 actor 更新慢、rollout 长尾、reward 变重,还是 controller/collect 边界在堵。
读这篇抓住一条链:
Role -> worker class / resource pool name
-> ResourcePoolManager -> RayResourcePool
-> placement group / bundle
-> RayWorkerGroup / reward loop / teacher manager
下面这张图先把角色、资源池和 WorkerGroup 的关系摆出来。重点不是每个名字,而是边界:Role 是系统职责,resource pool 是资源归属,WorkerGroup 是 controller 可调用的远端代理。

角色如何映射到 GPU 资源
这张图对应 TaskRunner的两张表:role_worker_mapping决定某个 Role 用哪个 worker class,mapping决定某个 Role 去哪个 resource pool(verl/trainer/main_ppo.py:107-120)。后面的资源生命周期都从这两张表出发。
在 main_ppo.py里,actor/rollout/ref 相关角色会映射到 ActorRolloutRefWorker,critic 会映射到 TrainingWorker,默认都放进 "global_pool"(verl/trainer/main_ppo.py:122-152)。reward model 和 teacher model 不一定注册成普通 WorkerGroup,但它们会被登记到 mapping里:reward 可以放在 global_pool或独立的 reward_pool,teacher 会放到 teacher_pool(verl/trainer/main_ppo.py:189-208)。
这说明 verl 的入口不是“创建几个 GPU 进程”,而是“这些进程承担什么角色”。这个顺序很关键:actor 的压力在训练显存、优化器状态和权重同步;rollout 的压力在 KV cache、decode 吞吐和长尾;reward 可能是 CPU 规则,也可能是另一个模型或环境;teacher 又是蒸馏场景里的额外推理负载。只说“占 GPU”会掩盖这些差异。
TaskRunner.run()在完成角色注册后,才创建 ResourcePoolManager,再把 role_worker_mapping、resource_pool_manager和 ray_worker_group_cls一起交给 RayPPOTrainer(verl/trainer/main_ppo.py:219-311)。也就是说,PPO trainer 拿到的不是散落的 Ray actor,而是一套已经按角色命名的资源计划。
角色映射之后,ResourcePoolManager.create_resource_pool()会遍历 resource_pool_spec,为每个 pool name 创建 RayResourcePool,并检查 Ray 集群里的可用 GPU 是否满足总需求(verl/single_controller/ray/base.py:181-240)。global_pool = [8, 8]这类配置不是抽象数字,而是在说:这个 pool 跨两个节点,每个节点上需要 8 个可调度位置。
RayResourcePool.get_placement_groups()进一步把 pool 转成 Ray placement group。每个 bundle 包含 CPU 配额,启用 GPU 时还会包含一个 GPU/NPU 资源;placement group 创建完成后会等待 ready,并按节点 IP 排序(verl/single_controller/ray/base.py:112-160)。这一层的作用是把“逻辑资源池”变成 Ray 可以稳定调度的物理占位。
下面这张生命周期图补上了从 resource pool spec 到 worker 的中间层。看图时注意 env vars 这一步:worker 不只是被创建出来,还会拿到 WORLD_SIZE、RANK、MASTER_ADDR、MASTER_PORT这些分布式训练必须的身份信息。

ResourcePool 到 WorkerGroup 的生命周期
RayWorkerGroup._init_with_resource_pool()会从 resource pool 取 placement groups,按 node 和 local rank 创建 worker;_create_worker()给每个 Ray actor 注入 WORLD_SIZE、RANK、WG_PREFIX、RAY_LOCAL_WORLD_SIZE、MASTER_ADDR、MASTER_PORT等环境变量,再把 actor handle 记录到 WorkerGroup 里(verl/single_controller/ray/base.py:536-681)。所以 WorkerGroup 不是一个名字列表,而是带有 rank 拓扑和远程 actor 句柄的执行边界。
RayPPOTrainer.init_workers()会先创建 resource pool,再把 actor/critic/ref 等角色包装成 RayClassWithInitArgs,按 resource pool 聚合到 resource_pool_to_cls,最后对每个 pool 使用 create_colocated_worker_cls()和 RayWorkerGroup生成可调用的 WorkerGroup(verl/trainer/ppo/ray_trainer.py:688-783)。
这段代码旁边有一个直接的设计提示:如果想让不同角色使用不同 resource pool,支持不同并行规模,就不要用 colocated worker class,而是直接把不同 pool 传给不同 WorkerGroup(verl/trainer/ppo/ray_trainer.py:750-755)。换句话说,共置和拆分是资源拓扑选择,不是代码风格。
下面这张图对应这个取舍。左侧强调共置的好处:actor、rollout、critic/ref 在同一个 global pool 里,资源拓扑简单,训练和 rollout 间的权重同步路径也短;右侧强调隔离的好处:reward 或 teacher 变重时,可以避免它们挤占主训练链路。

共置还是拆分
文档也说明,ActorRolloutRefWorker可以组合 actor、rollout、reference policy;actor 和 rollout 共置的原因之一是方便通过 NCCL 做快速权重传递,actor 和 reference 共置也服务于 LoRA PPO 的效率(docs/hybrid_flow.rst:118-120)。但这不是“永远共置”的结论。RewardLoopManager会根据 reward model 是否使用额外 resource pool 接收不同资源,teacher policy 也会通过 MultiTeacherModelManager使用 teacher_pool(verl/trainer/ppo/ray_trainer.py:812-868)。
ResourcePool 和 WorkerGroup 的意义,不只是把代码跑起来。它们给了我们定位瓶颈的坐标系。
如果 actor 慢,通常要看训练显存、forward/backward、optimizer state、micro-batch 和通信;如果 rollout 慢,要看 KV cache、response length、采样长尾和推理引擎吞吐;如果 critic/ref 慢,要看额外 forward 是否拉长 step;如果 reward 慢,要看它是规则函数、模型推理、环境调用还是工具链;如果 controller 慢,要看 DataProto 分发、序列化、collect 聚合和调度等待。
下面这张图把这些压力点放回各个角色。它不是为了穷举所有性能问题,而是提醒读者:同样是 GPU 利用率低,背后的等待对象可能完全不同。

不同角色的瓶颈地图
这也解释了为什么 04 必须放在 DataProto 之前:只有先知道哪些角色在消耗资源,下一篇再看“这些角色之间流动的数据”才有意义。否则 DataProto 只是一个容器;放到 ResourcePool/WorkerGroup 的上下文里,它才会变成 controller、worker、reward、rollout 之间的数据协议和潜在瓶颈。
verl 的资源组织可以压缩成一句话:
先定义系统角色,再把角色映射到资源池,最后把资源池实例化成可调用的 WorkerGroup 或 manager。
这套分层让 PPO 主循环不用关心每个角色具体放在哪张卡上,但它没有消灭资源取舍。共置可以缩短权重同步和共享路径,拆分可以隔离 reward/teacher 这类长尾或额外负载;两者都要回到模型大小、rollout 长度、reward 形态和硬件拓扑上判断。
下一篇进入 DataProto:当 actor、rollout、critic、reward、teacher 都被角色化以后,它们之间传来传去的那批数据到底是什么,为什么它会不断变胖,又为什么它会成为 controller 边界上的系统成本。
verl/trainer/main_ppo.py:107-120:TaskRunner中 role 到 worker class、resource pool 的两张映射表。verl/trainer/main_ppo.py:122-152:actor/rollout/ref 与 critic 的 worker class 和默认 pool 映射。verl/trainer/main_ppo.py:154-187:global_pool、reward_pool、teacher_pool的 resource pool spec。verl/trainer/main_ppo.py:189-208:reward model 和 teacher model 的资源池登记。verl/trainer/ppo/ray_trainer.py:688-783:init_workers()如何创建 role class、colocated worker class 和 WorkerGroup。verl/trainer/ppo/ray_trainer.py:812-868:reward loop、LLM server manager、teacher manager 如何接入资源池。verl/single_controller/ray/base.py:112-160:RayResourcePool如何创建 Ray placement group。verl/single_controller/ray/base.py:181-240:ResourcePoolManager如何创建资源池并检查资源。verl/single_controller/ray/base.py:536-681:RayWorkerGroup如何按 rank/local rank 创建 worker 并注入分布式环境变量。