深入解析VeRL中WorkerGroup与Ray资源调度机制 📅 发布时间:2026/9/19 16:36:01 👁 浏览次数: 做 RLHF 训练的特别是调过 VeRL 的同学应该都体会过这种困惑代码里明明只写了一个WorkerGroup怎么到 Ray Dashboard 上就多出几十个 Actor为什么有的 Actor 被调度到了别的节点导致训练数据传来传去为什么 rollout 和 training 明明应该并行却会互相抢卡这些问题如果只停留在“改配置、看日志”的层面很难真正解决。我在把 VeRL 从单机脚本挪到多机集群的过程中被这几个问题折磨了挺长时间最后把 WorkerGroup 和 Ray 资源调度的关系啃了一遍才算是真正把这块跑顺。这篇内容就从源码设计和实际部署两个角度讲清楚 VeRL 里 WorkerGroup 到底是一个什么级别的抽象、Ray 在背后怎么给它分配资源、两者配合的关键路径在哪以及我踩过的一些坑。适合已经跑通过 VeRL 基础流程、准备深入理解或者调优分布式资源的同学不管你是做训练框架开发还是负责集群上的 RLHF 任务交付应该都能找到能直接用到的东西。1. 先搞清楚 VeRL 的整体架构1.1 一个 RLHF 训练任务里有多少个“角色”RLHF 训练和普通的大模型 SFT 训练不一样它不是一个 loss 反传就完事的流程。以最常用的 PPO 类算法为例一次训练迭代里通常要同时跑四个模型角色Actor策略模型负责生成动作也要做更新、Critic价值模型给 reward 拟合一个 baseline、Reward Model给生成结果打分、Reference Model冻结的参考模型用来算 KL 惩罚。除了这四类模型本身你可能还需要一个 rollout 用的采样引擎。VeRL 里最常见的组合是 Actor/Critic/Ref/ Reward 这些 PyTorch 训练进程加上一个 vLLM 的 rollout 推理引擎。在单机脚本里这四个角色可以粗暴地全部塞进同一块 GPU轮流前向。到了分布式场景下这个“塞”就行不通了Actor 训练时要做梯度更新需要较大的 batchrollout 推理时又要做高并发的文本生成这两种负载对显存和算力的需求差异很大。如果硬放在一起要么训练时 rollout 卡住要么生成时训练显存不够。所以 VeRL 把这些角色拆成了多个并行的 Worker 集合每个集合里的 Worker 只干一件事。这些“干同一件事的 Worker 集合”就是 WorkerGroup。你在 VeRL 的配置里经常会看到类似actor_rollout_wg、critic_wg、ref_wg、reward_wg这样的命名它们本质上都是 WorkerGroup 实例但底层跑的模型、承担的任务完全不同。理解了这个前提后面看代码才不会被一堆WorkerGroup对象搞晕。1.2 为什么基座选 Ray 而不是自研一套调度器VeRL 最初给我的感觉是“用 Python 写了一套训练框架又用 Python 写了一套调度逻辑”但后来发现调度这块它并没有从零造轮子而是踩在 Ray 的肩膀上。为什么选 Ray我个人的理解有三点。第一Ray 提供了成熟的分布式执行原语特别是 Actor 模型。RLHF 里有大量“需要长期存活、随时接收命令”的进程比如一个 vLLM 引擎实例你不可能每次生成都重新拉起一个进程Ray Actor 刚好能表达这种有状态的远端对象。第二Ray 自带了资源管理和 placement group 机制可以用声明式的方式告诉集群“我需要多少张卡、这些卡最好在同一个节点”。这对 vLLM 这种依赖张量并行的推理引擎很重要因为张量并行需要高频的 GPU 通信如果两张卡被调度到不同节点性能会差很多。第三Ray 的生态和 vLLM、Tune 这些项目是打通的VeRL 可以直接复用 vLLM 的 Ray 集成把 vLLM engine 作为 Ray actor 跑起来。这里顺便提一个容易让新人懵的点在 GitHub 上搜verl或者veRL会搜到不少和“volumetric ray marching 渲染技术”相关的结果这俩只是名字缩写撞了一个是 RLHF 训练框架一个是图形学里的体渲染方法。看源码的时候注意仓库地址和文件结构别拿错代码。1.3 核心概念速查表在往下走之前先把几个关键词扣清楚。我整理了一个速查表后面所有讨论都围绕这些概念展开。概念在 VeRL 中的角色在 Ray 中的对应关系Main Process / Driver跑训练逻辑的入口进程负责编排流程Ray 集群的 driver 进程ResourcePool一组硬件资源的抽象定义有哪些 GPU/CPU 可用由节点上的自定义资源和 GPU 数量共同决定WorkerGroup一组“干同一件事”的 Worker 集合管理一组同质 Ray Actor 的逻辑组Worker单个远端执行单元一个 Ray Actor底层可能是 vLLM 引擎或 PyTorch 进程Placement Group资源放置策略决定一组资源如何分布Ray 原生调度单元包含多个 bundle2. WorkerGroup 的设计与职责拆解2.1 WorkerGroup 不是配置项而是一个可编程抽象看 VeRL 源码时会发现WorkerGroup不只是一个名字它有一整套接口。我读 master 分支时印象比较深的是它包含了create_object、initialize、start、stop这类生命周期管理方法同时还有针对训练场景的generate_sequences、update_weights、fit等业务方法。也就是说无论是做 rollout 的 vLLM worker还是做训练的 PyTorch worker都会被包在这一层统一的接口下面。从调用方角度来看主进程根本不关心某个 Worker 到底跑在哪个节点、里面是 vLLM 还是自定义 PyTorch 训练循环它只需要拿到一个 WorkerGroup 对象然后调用generate_sequences等接口。这种设计有一个很直接的好处框架层面的控制流只依赖“一组同质的 Worker”这个概念底层换成新的推理引擎或者新的训练后端上层不需要改变。但是统一抽象不意味着没有代价。你写 WorkerGroup 的实现时要自己负责把一组 Ray Actor 管理好包括初始化顺序、失败重试、梯度同步、数据通信等。VeRL 的做法是让它内部的 Worker 继承 Ray Actor 特性然后用 Ray 的 remote 调用把这些 Actor 的地址和句柄收集起来。WorkerGroup 相当于一个“演员池的导演”它自己不做计算但知道每个演员在哪里、状态怎么样。2.2 WorkerGroup 在 RLHF 流程里的位置为了更具体地说明 WorkerGroup 到底管什么我以一次 PPO step 为例拆解它的位置。当主进程开始一次迭代时首先需要采样。它会调用actor_rollout_wg.generate_sequences()这个调用内部会把这个大任务分发给 group 里的每个 rollout worker。每个 rollout worker 是一个 vLLM engine它对分到的 prompt batch 做推理生成一串 response然后返回给主进程。主进程拿到生成结果后经过 reward 打分、advantage 计算再调用critic_wg.fit()更新价值模型调用actor_rollout_wg.update_weights()把更新后的权重同步给 vLLM 引擎最后调用ref_wg和reward_wg做前向计算。这里面每一步都是一个“主进程 - WorkerGroup - 多个 Ray Actor - 数据返回 -主进程”的过程。WorkerGroup 不是简单地把任务 broadcast 出去它还要处理数据分桶、结果汇聚、异常重试等逻辑。比如在生成过程中某个 vLLM worker 因为长尾请求特别慢其他 worker 已经返回了这时候 WorkerGroup 要能处理部分完成的结果或者等待超时否则整个 PPO step 会卡死。2.3 Worker 内部有哪些关键组件一个 Worker 在 Ray 里是一个 Actor在 VeRL 里通常还包了一层角色逻辑。对于训练类 Worker它内部会创建模型、优化器、数据加载器并且维护一个 PyTorch 分布式训练环境。对于 rollout 类 Worker它内部会维护一个 vLLM engine包括 KV cache、采样参数等。在 Worker 初始化阶段最复杂的问题通常是“模型权重怎么从主进程同步到各个 Worker”。VeRL 一般会让一个 Worker 作为 weight provider其他 Worker 从它那里拉取权重这在跨节点场景下比每个节点单独加载一个 checkpoint 更高效。这个 provider 通信一般会走 NCCL 或者 gloo具体取决于模型张量和设备类型。理解这个机制之后你就明白为什么 WorkerGroup 内部对“同节点”约束的需求那么强了如果不同 Worker 跨节点权重同步和梯度同步的通信开销都会显著增加。3. Ray 资源调度的协同机制3.1 Ray 的原语以及 VeRL 怎么调用它们Ray 的资源调度核心可以分成三个层次资源的定义、Actor 的调度、Placement Group 的调度。资源定义层面每个 Ray 节点启动时会向 GCSGlobal Control Service汇报自己有多少 CPU、GPU 和自定义资源。在 VeRL 场景里通常会用 GPU 数量和显存大小作为主要资源约束同时也会用自定义资源区分“训练池”和“推理池”。有些集群为了不让训练任务和推理任务互相挤占会把部分节点打个自定义 tag比如verl_train1、verl_rollout1然后在定义 ResourcePool 的时候指定 tag。Ray 的调度器在分配资源时会要求同一份 Ray Task 或 Actor 所需的自定义资源必须同时满足。Actor 调度层面当 VeRL 创建一个 Worker 时它会用ray.remote(num_gpus...)或者options(num_gpus..., resources...)来发起一个 Actor 申请。这个申请不是立即成功的Ray 会检查整个集群的资源余量。如果资源不够申请就进入 pending 状态直到有资源释放。这里有一个容易被忽略的点Ray 的num_gpus是逻辑卡数不是物理卡数。只要你申请了num_gpus1Ray 就会把这个 Actor 和一个 GPU 资源绑定即使你后续在代码里没有真正使用 CUDA这张卡也被占住了。反过来如果你没申请num_gpus只在自己的 Python 代码里用了 CUDARay 不会帮你隔离两个 Actor 可能会同时抢一张物理卡。Placement Group 调度层面它解决的是“一组资源要放在哪些节点”的问题。比如说 vLLM 要跑张量并行需要 2 张卡在同一节点训练进程需要 4 张卡最好也在同一节点。如果你只是分别创建 2 个和 4 个 ActorRay 无法保证它们被调度到一个节点上。但如果你创建一个 placement group定义 bundle 为[{GPU: 2}, {GPU: 4}]并且指定strategyPACKRay 就会尽量把这两个 bundle 放到同一个节点。VeRL 在创建 WorkerGroup 时会在内部创建对应的 placement group并把该 group 的placement_group参数传给每个 Worker Actor从而让一组 Worker 按你预期的拓扑分布。3.2 WorkerGroup 如何申请资源从源码逻辑看WorkerGroup 创建时通常需要两个关键输入一个是worker_cls也就是具体执行逻辑的 Ray Actor 类另一个是resource_pool也就是目标资源池定义。它先根据resource_pool中的资源配置构造 Ray 的 bundle 和 placement group然后在 placement group 的上下文里创建指定数量的 Actor。伪代码大致长这样# 伪代码为了说明逻辑结构 def create_worker_group(resource_pool, worker_cls, num_workers, pg_strategyPACK): # 根据每个 worker 需要的资源构建 bundle bundles [] for _ in range(num_workers): bundles.append({ CPU: resource_pool.cpu_per_worker, GPU: resource_pool.gpu_per_worker, }) pg ray.util.placement_group(bundles, strategypg_strategy) workers [] for i in range(num_workers): worker ray.remote(worker_cls).options( num_cpusresource_pool.cpu_per_worker, num_gpusresource_pool.gpu_per_worker, placement_grouppg, ).remote() workers.append(worker) return WorkerGroup(pgpg, workersworkers)这段代码里最关键的是placement_group参数。如果把它去掉每个 Worker 就是独立调度虽然总数对但分布不可控加上之后这组 Worker 会被当成一个整体进行调度。如果集群资源充足PACK 策略会让它们尽量落在同一个节点如果集群资源不足但其他节点有空位Ray 也可能把 bundle 拆到多个节点。实际上对于需要跨节点通信的任务SPREAD策略反而更适合比如不同的训练副本之间要尽量减少 NCCL 通信的争抢。有一点必须提醒placement group 创建不等于 Actor 创建成功。PG 申请到配额后Actor 的启动还需要等待这些资源真正空闲。如果你在代码里一边创建 PG 一边立刻调用 Actor 方法有可能会因为 Actor 还没有跑起来而抛异常。VeRL 在初始化阶段一般会做一轮wait_until_ready这个等待不是毫无理由的。3.3 协同的关键路径从创建 WorkerGroup 到第一个 batch 输出把整个协同过程串起来看大概有下面几个阶段。第一个阶段是初始化。主进程通过ray.init(address...)连接已经启动的 Ray 集群读取自己的配置构建 ResourcePool 列表。此时主进程还没有占用任何 GPU 资源它的任务只是下发指令。第二个阶段是创建 WorkerGroup。主进程按照配置里的角色创建多个 WorkerGroup。比如先创建actor_rollout_wg它会申请一个包含若干 vLLM Worker 的 placement group再创建critic_wg、ref_wg、reward_wg。这个阶段最耗时的不是代码执行而是等 Ray 把所有 Actor 在世界各地或者说各个节点启动起来。如果是几百 GB 的模型还要加上权重加载和初始化的时间。第三个阶段是数据流转。第一个 batch 的数据进入主进程后主进程根据 rollout WorkerGroup 的数量和数据大小做切分然后调用每个 Worker 的generate_sequences。Ray 内部会通过 RPC 把数据序列化后发送给远端 Actor生成结果再传回来。如果是多机场景这一步的网络开销会直接影响整体吞吐所以你会看到很多人会刻意限制 rollout Worker 的数量避免小 batch 被切得太碎。第四个阶段是资源释放与复用。一个 batch 跑完后WorkerGroup 不会被销毁而是继续等待下一轮命令。只有在整个训练任务结束、或者主进程显式调用stop时底层的 Ray Actor 才会被释放。所以如果你在长时间运行的任务里反复创建和销毁 WorkerGroup资源碎片和幽灵 Actor 的问题会逐渐暴露出来。4. 一次完整的配置实操4.1 单机多卡最小配置我拿一个相对常见的场景举例一台 8 卡 A100 机器训练一个 7B 模型的 PPO。这个规模下最省心的配置是让 rollout 和训练共用节点但通过资源池限制各自的 GPU 数量。在 VeRL 里我会这样定义资源池基于 YAML 或 Python dict 的配置形式具体以你安装的版本为准resource_pool: - name: default gpu: 8 cpu: 64这表示整个集群默认有 8 张 GPU。然后创建两个 WorkerGroup 时一个负责 rollout使用 vLLM一个负责训练使用 PyTorch。为了不让它们互相抢占同一种资源通常会为 rollout 和训练分别指定自定义资源或者不同的 GPU 编号比如 rollout 占 4 张卡训练占 4 张卡。我实际跑通的最小脚本大概是这样的思路python3 -m verl.trainer.ppo.ppo_trainer \ --config-pathconfig \ --config-nameppo_trainer \ actor_rollout_ref.model.path/path/to/7b \ actor_rollout_ref.model.use_remove_paddingTrue \ actor_rollout_ref.actor.ppo_mini_batch_size1024 \ actor_rollout_ref.rollout.log_prob_micro_batch_size128 \ actor_rollout_ref.rollout.tensor_parallel_size4 \ actor_rollout_ref.rollout.gpu_memory_utilization0.4 \ critic.model.path/path/to/7b \ critic.ppo_mini_batch_size1024 \ resource_pool.default.gpu8这里tensor_parallel_size4意味着一个 rollout 的 vLLM 引擎会占用 4 张卡。你在这个节点上最多同时启动 2 个这样的 rollout worker再多的 worker 就会一直 pending。训练侧的 PyTorch 进程数量则由ppo_mini_batch_size和数据并行大小共同决定。第一次配置时最容易出的问题就是训练进程申请了 4 张卡rollout worker 也申请了 4 张卡两边都以为自己是独享 8 卡中的全部资源最后 Ray 一直在等待任务卡死。4.2 多机多卡下的资源池和 Placement Group 配置到了多机场景配置的核心不变但要额外关注节点间的拓扑。假设你有 4 个节点每个节点 8 卡总共 32 卡。如果只是简单地把resource_pool.gpu设为 32Ray 并不会自动知道“这 32 张卡分布在哪些节点”。它会根据每个节点的实际资源供给来调度这一点没问题但如果你的策略模型是 7Brollout 的 vLLM 引擎只需要 1 张卡就能跑那么让 32 个 rollout Actor 各自占 1 张卡也可以。问题出在需要通信的场景。比如训练侧 Actor 更新权重时4 个 trainer 之间需要做梯度同步如果这四个 trainer 被 Ray 分散到了不同节点那就要走跨节点 NCCL性能可能下降一半。这时候我会建议把训练 WorkerGroup 的 placement group strategy 设为PACK让 trainer 尽量落在同一个节点。而对于多个独立的实验或多个 rollout 副本SPREAD反而更好它可以避免把所有负载压在一台机器上。在 VeRL 里这些参数不一定全部暴露在 CLI 里很多需要直接在代码或配置文件的 WorkerGroup 构造处调整。我自己的习惯是先确认角色数量再确认每个角色需要几张卡最后考虑是否要跨节点。如果模型小到单卡能装下就尽量把所有角色塞进同一节点减少跨节点通信如果模型大到需要两卡或四卡张量并行那就要先把节点内的卡数预留好再考虑数据并行。4.3 通过 Ray Dashboard 验证调度是否合理启动任务后不要只盯着训练日志多看看 Ray Dashboard。我通常会看三个页面。第一个是 Cluster 页面里面能看到每个节点的资源总量和使用量。如果你看到某个节点的 GPU 使用率只有 20%而另一个节点爆满大概率是 WorkerGroup 的调度策略没有匹配好或者 actor 在等待资源。第二个是 Actors 页面这里能看到当前所有活着的 Actor以及它们所在的节点。对照 VeRL 启动日志里的 WorkerGroup 信息可以确认每个角色是否被放到了你预期的节点上。如果发现某些 Actor 被调度到了 CPU-only 节点说明该 Actor 在创建时没有携带 GPU 资源的请求或者资源池配置里没写 GPU 数量。第三个是 Placement Groups 页面这里可以看到当前有几个 PG、状态是PENDING还是CREATED。如果某个 PG 一直PENDING大概率是资源不足或者资源碎片化。比如某个节点只剩 2 张卡但你的 rollout WorkerGroup 申请了一个包含 4 张卡的 bundle那这个 PG 永远也不会有全部满足的时候需要把 bundle 拆小或者把 strategy 改成SPREAD。我在实际调参时会先用一个很小的 batch 把任务跑起来然后去 Dashboard 上截图确认资源分布没问题再拉大 batch。这个习惯帮我避开了很多“一上来就 OOM 或者卡死”的问题。5. 我踩过的坑和排查思路5.1 GPU 数量与实际卡数对不上这是一个特别经典的问题。明明 8 卡机器只创建了 4 个 ActorDashboard 上却显示 8 张卡全部被占用新任务一直 pending。原因通常是主进程或者某些依赖库提前初始化了 CUDA context导致 Ray 认为它需要占据所有可见的 GPU。如果你在启动 VeRL 之前手动设置了CUDA_VISIBLE_DEVICES但又没有把同样的环境变量传给 Ray Actor那么 Ray 启动的子进程可能会看到不同的 GPU 集合。更隐蔽的是一些 Python 包在 import 的时候就会触发 CUDA context比如某个库调用了torch.cuda.current_device()。这种情况下即使你只在主进程里做数据加载主进程也会占用一张 GPU且这张 GPU 无法被 Ray 调度。排查思路很简单看nvidia-smi里每个进程的 PID然后在 Ray Dashboard 里对照 Actor 的 PID。如果你发现某个没有对应 Actor 的 Python 进程占了 GPU基本就是主进程或者残留进程在搞事。解决办法是给主进程设置CUDA_VISIBLE_DEVICES让它在无卡环境下跑只保留真正需要计算的 Actor 进程访问 GPU。也可以在所有 import 之后显式调用torch.cuda.empty_cache()但这只能释放显存缓存不能解决 context 占用的问题。5.2 Actor 一直 PENDING或者被调度到奇怪的位置这个问题在跨节点场景特别常见。我遇到过一次一个需要 4 卡张量并行的 rollout WorkerGroup 一直 pending看 Ray 日志显示“bundle cannot be satisfied”。原因是我有两个节点一个 8 卡一个 4 卡。8 卡节点上的其他任务占了 5 张卡剩下 3 张4 卡节点虽然还有 4 张卡但我的 placement group 要求 4 个 bundle 全部在同一个节点所以两边都无法满足。解决路径有两条要么把 rollout worker 的tensor_parallel_size从 4 降到 2这样 8 卡节点剩余的 3 张卡可以满足 2 个 bundle其中一两个 bundle 会落到4卡节点可能可以要么把其他任务清理掉空出 4 张连续卡。这种问题在 Dashboard 上看不到太多异常因为它不是资源耗尽而是资源碎片化。另一个值得注意的点是 Ray 的自定义资源和 CPU quota。如果你的 Worker Actor 在options里没有指定num_cpus默认只占 1 个 CPU。但 vLLM 初始化时可能会创建很多 CPU 线程如果节点上 CPU 配额不够Actor 也可能一直无法启动。所以我一般会在配置里给每个 Worker 留足 CPU避免莫名其妙的 pending。5.3 动态扩缩容时的幽灵 Actor 与资源泄漏长周期训练任务里有时候我们会根据数据量动态调整 rollout Worker 的数量。VeRL 的 WorkerGroup 本身支持动态创建和销毁但如果你在一个 python 脚本里反复创建新的 WorkerGroup旧的 WorkerGroup 如果没有被显式 stop它的 Ray Actor 会一直留在集群里占用资源。更麻烦的是placement group 如果没被删除也会一直占用配额导致后续任务资源不足。我的排查方法是# 查看当前所有 actor ray list actors # 查看当前所有 placement group ray list placement-groups # 强制清理某个 actor ray kill actor_id如果发现大量状态为DEPENDENCY_PENDING或者DEAD的残留就需要检查代码里 WorkerGroup 是否在异常分支被正确释放。一个好习惯是给每个 WorkerGroup 写一个finally块确保不管任务正常结束还是异常退出都调用 stop 并等待 Ray 资源回收。另一个好习惯是在创建新的 WorkerGroup 之前先查一下当前集群的可用资源而不是盲目申请。5.4 常见问题速查表现象可能原因解决办法训练日志长时间不打印Dashboard 显示 Actor 一直 PENDING资源不足或 placement group 无法满足看 PG 状态拆小 bundle 或清空其他任务模型 loss 正常但 rollout 巨慢Worker 被调度到不同节点vLLM 张量并行通信开销大设置 PACK 策略确保 TP worker 同节点显存充足但训练 OOMWorker 没有按 num_gpus 申请多个进程抢卡给每个 Worker 明确配置 num_gpus不要依赖环境变量任务结束后 GPU 仍被占用WorkerGroup 未释放或存在幽灵 Actor使用ray list actors和ray list placement-groups清理多机环境下权重同步很慢主进程和 worker 跨节点且 node to node 带宽不够尽量让一个 WorkerGroup 内部贴近或使用 NVLink 节点数量充足的 pool新任务一开始正常跑一会后卡住某个 Worker 崩溃但 WorkerGroup 没有感知检查 Actor 日志设置 Worker 的心跳或超时重试6. 最后再分享一点调优心得如果只是想把任务跑起来你可能不需要关心 WorkerGroup 内部怎么和 Ray 交互照着官方脚本改一改也能跑。但一旦涉及性能调优、多机部署、或者在共享集群上和其他任务共存这块知识就是必须的。我个人觉得最值得花时间的地方不是去看 Ray 的源码而是先把自己的训练流程拆解清楚哪个阶段需要多少卡、哪些卡必须靠近、哪些可以分散然后再回来看 VeRL 的配置和 WorkerGroup 日志思路会清晰很多。一个非常实用的做法是在正式跑大任务之前先启动一个只有 1 个 rollout Worker、1 个训练 Worker 的小任务然后跑一个极小的 batch观察 Ray Dashboard 上资源的变化。这样能快速确认你对资源池的理解是否和真实调度一致。我自己就是这么干的后来在 32 卡集群上一次性把 7B PPO 跑起来没有再被调度问题卡住。另外VeRL 的版本迭代速度很快有些配置项和接口名可能过几个版本就变了。如果你在实操中发现和我描述的不一致优先翻当前版本的源码重点看WorkerGroup和ResourcePool的实现以及生成 Ray Actor 时传入了哪些options。把这几个点搞懂不管版本怎么变你都能快速定位问题。