Tutorial

Ray-Learn(四):生产 API 补全与五种编排范式

runtime_env 的覆盖顺序、具名服务与拓扑调度、作业生命周期;再把 slime、verl、 NeMo-RL、AReaL、prime-rl 放在同一张表上对比。抽象每加一层,代码量涨一个量级—— 选型问题到最后是预算问题。

这是 Ray-Learn 系列最后一篇。做两件事:把生产里还会用到的 Ray API 面补完,然后把五个主流框架的编排层放在同一张表上对比。

对比的目的不是评出「谁最好」,而是看清一件事:它们的差异不在会不会用 Ray,而在愿意为抽象付多少行代码。

一、runtime_env:四层覆盖

环境变量是编排的一部分。Ray 场景下注入路径不止一条,NeMo-RL 把优先级文档化成了四层,从高到低:

优先级 路径 用途
4(最高) worker 类的静态配置方法 按实例微调
3 实验 YAML 按实验控制
2 系统 export 集群统一
1(最低) 类级 runtime_env={'env_vars': ...} 类默认值
@ray.remote(runtime_env={'env_vars': {'FOO': 'from_cls'}})
class Worker:
    def foo(self):
        import os
        return os.environ.get('FOO')

实测下来有一个容易误判的点:

另外,凭据不要进 runtime_env。它会原样写进 job 日志,miles 在注释里专门提醒过这一点。密钥走环境注入之外的通道,或者至少走不落日志的路径。

二、具名 actor 与命名服务

跨进程找单例(KV、控制器、NCCL id 发布点)时,用 name + namespace:

ray.init(namespace='rlinfra')

KV.options(
    name='kv',
    namespace='rlinfra',
    lifetime='detached',        # 跨 driver 存活
    get_if_exists=True,         # 幂等创建
).remote()

kv = ray.get_actor('kv', namespace='rlinfra')

工程上其实是两条路线:

路线 做法 代表
启动时定拓扑 显式传 ActorHandle slime
运行中动态发现 具名 actor / KV miles、TransferQueue、AReaL

第一种更简单,编排器手里握着全部句柄,调试时也好查。问题是「事后查找」做不了:新起的进程不知道去哪里拿句柄。

第二种反过来。AReaL 走得最远:DistributedKVStore 是 detached 的命名服务,带 TTL 和租约,引擎之间互相发布 host:port。Ray 在这里当命名基板,不当消息总线。TransferQueue 的 Controller 也是具名 actor,消费者用 get_actor 重连。

怎么选?如果拓扑在启动时就完全确定,传句柄;如果有动态加入、故障后重连、或者跨作业共享状态,上具名服务。

三、拓扑:NodeAffinity 与自定义资源

「PACK 偏好」不能替代硬约束。训练池和推理池要么用不同的 PG,要么用不同的自定义资源。

from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy

ray.init(resources={'nvlink_domain_0': 2})

# 拓扑域约束
Actor.options(resources={'nvlink_domain_0': 1}).remote()

# 钉节点
Actor.options(
    scheduling_strategy=NodeAffinitySchedulingStrategy(
        node_id=node,
        soft=False,             # 调度不到就失败,而不是凑合
    )
).remote()

几个典型用法:

需求 做法 谁在用
NVLink 域内放 TP 组 自定义资源 nvlink_domain_* NeMo-RL
控制器钉 head 节点 NodeAffinity + is_head_node miles
奖励模型贴近数据 NodeAffinity(soft=False) verl agent loop
训推池隔离 不同 PG 或不同自定义资源 各家通用

NeMo-RL 的做法比较完整:Slurm 脚本给每节点注入 nvlink_domain_* / topo_rank,RayVirtualCluster 按域组 PG。拓扑正确性进调度,而不是靠人肉对齐。

ray.util.placement_group_table(pg) 能读出 bundle → 节点的映射。verl 用它按节点 IP 排序,保证 rank 跨重启稳定——这是上一篇讲的 bundle 重排序的另一种实现。

四、作业生命周期

形态 入口 场景
本地自起 ray.init() 学习、单机
接入集群 ray.init(address='auto') ray start 已起好
作业系统 JobSubmissionClient KubeRay / 多租户
平台内自举 rank0 ray start --head Slurm gang 作业

多机验收的通过条件适合写死成断言:

expected = 16  # 规划分给 Ray 的额度
assert ray.cluster_resources()['GPU'] == expected

这个断言看起来简单,但它挡掉的是第一篇讲的那类「账本不一致」问题。很多项目把这一步跳过,结果后面所有 rank 映射、PG 布局都建立在错误的账上。

JobSubmissionClient 适合「作业系统要管生命周期」的场景:提交、查状态、拉日志、取消。NeMo-RL 的 nrl_k8s 用它对接 KubeRay。AReaL 则在 Slurm gang 作业里自举 Ray 集群——rank0 当 head,其他节点 join,head 退出则解散。

五、五种编排范式

现在可以回答开头那个问题了。把五个框架的编排层摆在一起:

范式 代表 Ray 的角色 编排代码量
命令式 actor_group slime / miles 全权:资源 + RPC + 数据面 1.2k / 5.1k
声明式 Dispatch/Collect verl 藏在 WorkerGroup 后 2.2k 抽象层
RayWorkerGroup 四能力 NeMo-RL 资源/隔离/协调/通信全覆盖 最大
薄 Ray + HTTP 服务化 AReaL 只做 launcher + 命名 25k+ 自建
无 Ray asyncio prime-rl 不用 6.8k 自建

C1 · slime:最小编排

3 种角色(TrainActor / RolloutManager / Lock)+ 单 PG 偏移切分 + 手工 rendezvous。1,228 行做完全部编排。

适合:目标一万行以内、单环境、角色少。如果正在自建精简 RLinfra,这是最值得先读的参考。

C2 · verl:编程模型藏住 Ray

@register(dispatch_mode=Dispatch.DP_COMPUTE_PROTO)
def train_batch(self, batch):
    ...

调用方写单机语义,框架自动切分 DataProto、扇出、收集。代价是抽象层本身两千多行。

还有一个值得单独看的机制:FusedWorker。actor + critic + ref 合成一个 Ray actor,再用 spawn() 拆成多个虚拟 WorkerGroup。类级模块化,actor 级共置,解决的是「逻辑上分开、物理上省卡」的矛盾。

C3 · NeMo-RL:教科书分层

RayVirtualCluster(资源)、RayWorkerGroup(隔离)、单进程 Controller(协调)、NCCL 或队列(通信)。不用 torchrun,分布式全是 Ray actor。

模块边界最清晰,代码也最多。如果要做多后端、多算法、还要长期演进,这套分层的成本是值得付的。

C4 · AReaL:Ray 尽量薄

Worker 是 OS 进程,业务走 HTTP RPC。Ray 只负责分配资源、拉起进程、记名字。fork_workers 能在同节点不新占卡的情况下再起一个角色。

适合多平台、多租户、agent 服务。代价是两万五千行起,而且故障模式变多——HTTP 超时、重试、半开连接,都要自己处理。

C5 · prime-rl:干脆不用 Ray

单 driver Popen 拉起 trainer / inference / orchestrator,orchestrator 是纯 asyncio,用事件做版本屏障。进程数固定且少时,这样反而简单。

资源账本和故障恢复要另行实现。这提醒了一件事:Ray 解决的是分布式资源与生命周期问题,不是 RL 算法问题。 问题规模不够大时,不用 Ray 也是合理选择。

规律很直白:每加一层抽象,代码量涨一个量级。 选型前先确认需要哪一层,而不是默认选最「完整」的那个。

六、精简 RLinfra 的裁剪清单

如果目标是自建一套 <1 万行的 RL 编排层,slime 路线的取舍大致如下。

必须保留

  • 单 PG + offset 切分:共置/分离一张布局表
  • bundle 重排序:rank ↔ GPU 稳定,这是正确性
  • 手工 rendezvous:大约 10 行,绕不开
  • 权重更新的版本屏障:准备(可与生成重叠)→ 暂停 → 替换 → 校验 → 恢复

可以砍掉

  • 多平台抽象:只支持一种环境
  • 服务化拆分:单 Driver + 少量角色足够
  • 复杂健康恢复:先做好「出错能看见」

明确不学

ray.serve   ray.train   ray.data   ray.tune   RLlib   ActorPool

主流 RL 框架零使用。编排层只做三件事:拉起、排序、出错能看见。

七、Ray-Learn 系列回顾

篇 主题 关键结论
一 actor 与资源账本 num_gpus 是账本;阻塞饿死
二 PG 与 rank 稳定 bundle 重排序是正确性
三 引擎与数据面 子进程 + 引用传递 + 版本屏障
本篇 生产 API 与范式 抽象每加一层,代码涨一个量级
五 多机、并发池、进程监督 角色资源进 bundle
六 API 对账与源码地图 读框架时的速查表

配套仓库 Ray-Learn 提供 20 课可运行脚本与 API 对账表。读源码可以从 slime 的 slime/ray/ 开始——1,228 行,适合整个通读一遍;读完再看 verl 的 single_controller/,会更清楚「抽象贵在哪里」。

源码与仓库

用途 链接
本系列配套练习(20 课脚本 + API 对账) MLsys-Note/Ray-Learn
Ray ray-project/ray
slime(最小编排) THUDM/slime
miles radixark/miles
verl verl-project/verl
NeMo-RL NVIDIA/NeMo-RL
AReaL areal-project/AReaL
prime-rl PrimeIntellect-ai/prime-rl