找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2512

积分

0

好友

350

主题
发表于 昨天 01:32 | 查看: 0| 回复: 0

最近在深入研究 Verl 的架构。虽然我之前接触过强化学习(RL)的算法,但对 RL 的系统架构了解不多。

初看 Verl 的论文时,觉得其“单控制流、多计算流”的核心思想可以理解,但一旦深入细节就容易一头雾水。经过一段时间的学习,终于有了一些头绪,感觉能把 Verl 的设计思路串联起来了。

背景知识:Ray 框架

我们先简单了解一下 Ray 这个分布式计算框架。它能帮助解决分布式环境下的状态同步、数据传输和资源管理问题。

假设有这样一个场景:5个节点代表不同的计算任务,每个节点消耗特定资源(CPU/GPU),节点间的有向边代表了计算或数据流。

工作流节点与数据流动示意图

数据流示意图

在这个场景中,Ray 是如何发挥作用的呢?

  • 状态同步管理:Ray 将每个任务包装成非阻塞的异步任务,执行后会立即得到一个代表未来结果的对象(Future)。例如,worker1 执行后,会立即得到一个代表 data1 的 futures 对象。当 worker4 需要 data1 时,只需调用 ray.get() 获取这个 futures 的真实值。这种方式避免了手动控制 worker4 等待 worker1 和 worker2 执行完毕的繁琐状态同步。
  • 数据传输管理:不同 worker 可能位于不同的机器上。Ray 通过引入一个额外的 driver 节点来管理数据传输和整体调度。worker4 需要 data1 时,无需知道 data1 在哪台机器,只需通知 driver 节点,由 driver 根据计算图自动管理数据从 worker1 到 worker4 的传输。
  • 资源管理:我们只需声明每个 worker 所需的资源(如 CPU/GPU),Ray 便可自动进行资源分配和管理。

朴素的 RL 框架实现

说完背景知识,我们来看看如何实现一个最朴素的 RL 框架。

典型的 RL 流程如下:

  • Generator(即 Actor)推理生成样本。
  • 然后,将样本输入 Actor 计算 old_log_prob,输入 Critic 计算 value,输入 Ref Model 计算 ref_log_prob,输入 Reward Model 计算 reward
  • 接着基于上述值计算优势(adv)和损失(loss),更新 Actor 和 Critic 模型。重复此过程直到达到预定步数。

朴素的实现就是严格按照上述流程串行执行。但这会带来两个问题:

  1. 计算 old_log_probvalueref_log_probreward 之间没有数据依赖,本可以并行,串行执行效率低。
  2. 默认所有模型都在同一个计算集群中,这会引入复杂的并行设置问题,增加通信开销。

Verl 的改进方案

3.1 单控制流,多计算流

针对第一个效率问题,最自然的想法是将这些计算并行化:用不同进程管理不同角色,同时执行。

并行化确实能缓解效率问题,但引入了复杂的状态同步难题,尤其是在涉及多 GPU 或多机时,手动管理极易出错,代码复杂,难以优化。

Verl 的解决方案是引入 Ray 框架。如前所述,Ray 通过异步操作解决了分布式节点间的状态同步问题。因此,RL 框架的使用者只需关注算法整体流程,具体的分布式计算和节点间通信由 Ray 管理。

串行实现是单控制流,简单的并行实现是多控制流。它们的数据流对比如下图所示:

三种RL框架实现方式数据流对比图

三种 RL 框架实现方式数据流对比

  • 单控制流:需要顺序执行,Reference Model 计算完后 Critic 才能计算 Value。
  • 多控制流:可以并行执行,但状态同步难以管理,现有框架的通信逻辑与算法代码耦合严重,难以修改。
  • Verl 的数据流:大致如图 (c) 所示。表面上仍是“串行”调用,但从 Ref 到 Critic,再到 RM 的调用是异步的,不会阻塞,实际上三者是并行执行的。从而兼具了代码的简洁性和执行的高效性。

3.2 资源管理:模型放置策略

第二个问题是,所有模型角色放在同一个集群会导致:

  • 通信开销增加:多个大模型共处一集群,显存压力剧增,不得不提高模型并行度(如张量并行 TP),而这本身就会增大通信开销。
  • 分布式策略受限:不同角色对并行策略的需求矛盾。例如,Generator 为追求推理速度,希望 TP 较小;而 Actor/Critic 训练为节省显存,希望 TP 较大。

因此,Verl 提出将不同角色放置在不同的集群中。同时注意到,第一阶段(Generator 推理样本)和最后阶段(Actor 训练)使用相同的模型权重,因此可以将这两个角色放置在同一个集群中,即 Colocate(共置)策略

  • Actor 和 Generator 共置一个集群。
  • Critic 的推理和训练共置一个集群。
  • 其他角色(如 Ref Model, Reward Model)放在其他集群。

实现这种细粒度的资源管理和共置策略,依然依赖 Ray,但 Verl 在其基础上进行了优化(如构建 RayResearchPool 以支持更细粒度的资源表示)。

3.2.1 HybridEngine:共置优化

Actor 和 Generator 共置有什么特别之处?它们共享同一套权重,是否意味着训练更新后,推理可以直接使用,省去参数同步?

并不完全如此。因为推理和训练的并行策略(流水线并行 PP、张量并行 TP)通常设置不同。例如,推理为追求效率,PP 常设为 1,TP 尽量小;训练则 PP 和 TP 可能都较大。这导致权重在不同卡上的分布不同(如下图 (a) 所示)。因此,在训练转推理或推理转训练时,仍需聚合权重并重新分配,通信开销无法避免。

Verl论文中HybridEngine优化示意图

Verl 论文中 HybridEngine 示意图

Verl 提出了 HybridEngine,通过改变推理并行组的设置来避免通信开销并降低冗余内存压力。即调整每张卡上张量并行切分权重的存储顺序,如上图 (b) 所示。

传统推理并行组的排列优先级是 TP > DP(数据并行),Verl 将其改为 DP > TP,即先按 DP 数排列,再按 TP 数排列。

不过,当模型极大,需要同时使用 PP 和 TP(即两者均大于1)时,个人推导认为可能仍无法完全避免通信开销和内存冗余。

PP与TP同时存在的网格示意图

PP 和 TP 同时大于 1 时的权重分布思考

总结与深入探讨

总结一下,Verl 的核心改进在于:

  1. 使用 Ray 框架,通过异步并行执行解决状态同步问题,通过 Driver 进程管理解决数据传输问题。
  2. 采用半共置(Semi-Colocate)模型放置策略修改权重切分顺序(HybridEngine),优化资源利用与通信。

接下来,我们深入探讨两个具体问题:

  • Verl 的具体设计思路是什么?
  • 单控制流和多控制流在代码中是如何体现的?

1. 核心概念定义

1.1 单控制流 vs 多控制流

  • 单控制流:一个控制器管理整个运算逻辑。在 RL 中,即 Rollout、生成经验、训练等步骤都由一个中心控制器调度。
  • 多控制流:多个控制器分别管理一组计算单元,彼此通过消息传递或共享内存通信。

1.2 SPMD vs MPMD

  • SPMD(单程序多数据):各进程运行相同程序,处理不同数据。通过 barrierall_gatherall_reduce 等原语进行同步通信。
  • MPMD(多程序多数据):各进程运行的程序和数据都不同,需要额外调度器管理逻辑与数据。

2. Verl 的设计思想

Verl 的设计是 上层控制层采用单控制流,下层运算层采用多控制流

单控制器与多控制器实现架构对比图

Verl 设计示意图:(a)上层单控制流,(b)下层多控制流

  • 控制层(单控制流)RayPPOTrainer 作为总入口,管理所有 Workers 和 Worker Groups,协调完成整个 RL 流程。它向各个 Worker Group 调用如 generate_sequences 等接口,逻辑清晰简洁。
  • 运算层(多控制流):每个进程(每张卡)上都有一个 Worker 实例,负责具体的计算逻辑(如模型前向、反向)。这些 Worker 内部以 SPMD 范式运行,通过集合通信原语协作。

2.1 设计原因

  1. 分层设计:控制层目标在于逻辑简洁、易修改;运算层目标在于高效执行固定计算模式,并适配各种并行策略与引擎。
  2. 流式选择
    • 控制层用单控制流,将算法逻辑与底层计算解耦,直观且易于改动。
    • 运算层用多控制流,因其操作相对固定,且现代深度学习框架(如 PyTorch)的训练和推理引擎(如 FSDP, Megatron, vLLM, SGLang)本就遵循 SPMD 范式,天然适合多控制流。若在运算层也用单控制流,会使对应节点通信压力过大。

3. 代码层面的具体实现

3.1 控制层:单控制流实现

3.1.1 管理入口
单控制流由 RayPPOTrainer 类管理。在程序入口 main.ppoTaskRunner.run 函数中创建:

trainer = RayPPOTrainer(
    config=config,
    tokenizer=tokenizer,
    processor=processor,
    role_worker_mapping=role_worker_mapping,
    resource_pool_manager=resource_pool_manager,
    ray_worker_group_cls=ray_worker_group_cls,
    reward_fn=reward_fn,
    val_reward_fn=val_reward_fn,
    train_dataset=train_dataset,
    val_dataset=val_dataset,
    collate_fn=collate_fn,
    train_sampler=train_sampler,
)
# Initialize the workers of the trainer.
trainer.init_workers()
# Start the training process.
trainer.fit()

3.1.2 与运算层交互
通过 Worker Group 进行交互。在 init_workers 中,为每个资源池创建对应的 Worker 类,并用 Ray 装饰,然后基于这些类创建 Worker Group。

for resource_pool, class_dict in self.resource_pool_to_cls.items():
    worker_dict_cls = create_colocated_worker_cls(class_dict=class_dict)
    wg_dict = self.ray_worker_group_cls(
        resource_pool=resource_pool,
        ray_cls_with_init=worker_dict_cls,
        **wg_kwargs,
    )
    spawn_wg = wg_dict.spawn(prefix_set=class_dict.keys())
    all_wg.update(spawn_wg)
...
self.actor_rollout_wg = all_wg["actor_rollout"]
self.actor_rollout_wg.init_model()

Worker 对象在 Worker Group 的初始化过程中被实例化。_init_with_resource_pool 方法会为 local_world_size 的每张卡创建一个 Worker。

rank = -1
local_world_size = resource_pool.store[0]
for pg_idx, pg in enumerate(sort_placement_group_by_node_ip(pgs)):
    assert local_world_size <= pg.bundle_count, f"when generating for {self.name_prefix}, for the "
    for local_rank in range(local_world_size):
        rank += 1
        ...
        # create a worker
        worker = ray_cls_with_init(
            placement_group=pg,
            placement_group_bundle_idx=local_rank,
            use_gpu=use_gpu,
            num_gpus=num_gpus,
            device_name=self.device_name,
        )
        self._workers.append(worker)
        self._worker_names.append(name)

3.1.3 单控制流运算过程
fit 方法中,通过调用各个 Worker Group 暴露的接口,清晰直观地完成了 RL 流程:

# rollout阶段
gen_batch_output = self.actor_rollout_wg.generate_sequences(gen_batch)
# 计算reward
reward_tensor, reward_extra_infos_dict = compute_reward(batch, self.reward_fn)
# 计算old_log_prob
old_log_prob = self.actor_rollout_wg.compute_log_prob(batch)
# 计算ref_log_prob(可选)
if not self.ref_in_actor:
    ref_log_prob = self.ref_policy_wg.compute_ref_log_prob(batch)
else:
    ref_log_prob = self.actor_rollout_wg.compute_ref_log_prob(batch)
# 计算value(可选)
values = self.critic_wg.compute_values(batch)
# 更新critic模型(可选)
critic_output = self.critic_wg.update_critic(batch)
# 更新actor模型
actor_output = self.actor_rollout_wg.update_actor(batch)

3.2 运算层:多控制流实现

3.2.1 多控制流管理
由每张卡上的 Worker 实例负责。如上述代码所示,每个 Worker 管理本卡的计算引擎。RL 框架本质上是联结训练引擎和推理引擎。根据角色不同,Worker 内部可能包含:

  • 训练引擎(如 FSDP, Megatron)
  • 推理引擎(如 vLLM, SGLang)
  • 或两者皆有(共置情况)

例如,FSDP Worker 初始化模型时会用 FSDP 包装:

actor_module_fsdp = FSDP(
    actor_module,
    cpu_offload=cpu_offload,
    param_init_fn=init_fn,
    auto_wrap_policy=auto_wrap_policy,
    device_id=get_device_id(),
    sharding_strategy=sharding_strategy,  # zero3
    ...
)

而使用 vLLM 作为推理引擎时,会在 _build_rollout 方法中实例化 vLLMRollout,每张卡都会创建一个 LLM 对象作为 inference_engine:

self.inference_engine = LLM(
    model=model_path,
    enable_sleep_mode=config.free_cache_engine,
    tensor_parallel_size=tensor_parallel_size,
    ...
)

对于 SGLang,其实现略有不同,采用了“Mock SPMD”模式,仅在推理 TP 组的 rank0 卡上创建真实的 AsyncEngine,其他卡上为 None

3.2.2 多控制流运算逻辑
计算以 SPMD 范式进行。数据传入时被切分到各卡,各卡上的 Worker 调用本地引擎进行计算,最后结果被聚合返回给控制层。

例如,generate_sequences 方法会被一个负责数据分发与结果聚合的装饰器(如 DP_COMPUTE_PROTO)包装:

@register(dispatch_mode=Dispatch.DP_COMPUTE_PROTO)
def generate_sequences(self, prompts: DataProto):
    ...
    output = self.rollout.generate_sequences(prompts=prompts)

内部的 rollout.generate_sequences 会调用如 vLLM 引擎的 generate 方法。

参考资料

作者:11001001,已获作者授权发布。
原文链接:https://zhuanlan.zhihu.com/p/1926030684704704490

希望这篇对 Verl 架构的解析,能帮助你理解这个高效 强化学习 框架的设计精髓。如果你对分布式机器学习系统感兴趣,欢迎在 云栈社区 交流讨论。




上一篇:射频技术原理与核心应用:调制、扩频与无线通信基础
下一篇:DeepSeek V4架构猜想:从NSA到DSA的演进与基于层间相似度的Indexer优化思路
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-24 00:26 , Processed in 1.288494 second(s), 47 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表