Ray-Learn(三):异步语义、引擎子进程与数据面
推理引擎为什么不能塞进 actor 的 event loop,ObjectRef 为什么会自动解引用,故障该分成哪三类,以及 ray.util.collective 在权重广播里的正确姿势。这一篇覆盖 RL 系统里最容易写出「看起来能跑」的那部分代码。
这是 Ray-Learn 系列第三篇。前两篇讲「谁、在哪」,这一篇讲「怎么传、怎么活下去」。
RL 系统里有两条链路特别耗时间:一条是推理引擎的生命周期,另一条是数据与权重在角色之间的流动。它们的共同点是:写错了不会立刻报错,而是慢慢变成奇怪的性能问题或结果问题。
一、引擎必须是子进程
SGLang、vLLM 这类推理引擎,本质上是「长 event loop + 多线程 CUDA + 自己的调度器」。把它直接塞进 Ray actor,有三个硬伤。
第一,事件循环互相污染。 Ray 的 actor 方法调用会排在引擎的 loop 后面。引擎一忙,健康检查就堵住;健康检查一堵住,故障发现就延迟;故障发现一延迟,故障恢复就没意义了。
第二,信号和清理不可靠。 进程被 kill -9 时 finally 不会跑。GPU 上下文残留、端口残留、共享内存残留,都会毒死下一次启动。
第三,阻塞饿死。 上一篇讲过,同步方法阻塞在 C 扩展里会拖住整个 actor。引擎的 generate、CUDA synchronize 正是这种阻塞。
所以标准形状是:actor 持资源,子进程跑服务。
import os
import subprocess
import sys
import ray
@ray.remote(num_cpus=0.5)
class EngineSupervisor:
def __init__(self):
self.proc: subprocess.Popen | None = None
def spawn(self, cmd: list[str]) -> dict:
self.proc = subprocess.Popen(
cmd,
env=os.environ.copy(), # 继承 Ray 注入的 CVD 等
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
return {'pid': self.proc.pid}
def health(self) -> str:
if self.proc is None:
return 'not-started'
rc = self.proc.poll()
return 'running' if rc is None else f'exited:{rc}'
def shutdown(self) -> str:
if self.proc and self.proc.poll() is None:
self.proc.terminate() # 先 TERM
try:
self.proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self.proc.kill() # 超时再 KILL
return 'stopped'
ray.init()
sup = EngineSupervisor.remote()
print(ray.get(sup.spawn.remote([sys.executable, '-c', 'import time; time.sleep(30)'])))
print(ray.get(sup.health.remote()))
print(ray.get(sup.shutdown.remote()))
ray.shutdown()
分工很清楚:
| 层 | 职责 |
|---|---|
| Ray actor | 拿资源、起进程、探活、优雅退出 |
| 子进程 | 真正跑引擎 event loop / CUDA |
两个必须记住的细节
清理顺序也值得写死:TERM → 超时 → KILL → 确认无 GPU 占用 → 再释放 actor/PG。不要反过来。反过来的话,资源已经还给调度器了,残留进程还在占卡,下一次调度又是一次静默失效。
AReaL 把这条路走到另一端:Ray 只当进程启动器,业务 RPC 全走 HTTP。逻辑是——服务本来就有自己的 loop,就不要再包一层 actor loop。这是同一问题的另一种解法。
二、ObjectRef 会自动解引用
rollout 输出一批 token id,可能是几十 MB 到几 GB。正确姿势是 ray.put 进 Object Store,再传引用。但这里有个很隐蔽的语义:
import ray
@ray.remote
def consume_value(data) -> int:
return len(data) # 收到的是「值」
@ray.remote
def consume_box(box) -> int:
return len(ray.get(box['inner'])) # 收到的是「引用」
ray.init()
payload = list(range(200_000))
ref = ray.put(payload)
# 顶层 ObjectRef 参数会被自动解引用
print(ray.get(consume_value.remote(ref))) # 200000
# 包一层容器就不解引用
print(ray.get(consume_box.remote({'inner': ref}))) # 200000
ray.shutdown()
规则其实只有两条:
| 传法 | task 收到什么 | 拷贝发生在何时 |
|---|---|---|
f.remote(ref) |
解开后的值 | 调度前,由 Ray 完成 |
f.remote({'r': ref}) |
仍是 ObjectRef | 显式 ray.get 时 |
f.remote(big_list) |
值,序列化进参数 | 提交任务时 |
slime 里的 Box(ray.put(...)) 不是过度设计,而是为了把 ref 当句柄传递,让 trainer 在需要的时刻再取。如果直接传 ref,Ray 会在调度前帮你解开,延迟加载就没有意义了。
顺带一提,ray.put 本身也有成本。小对象频繁 put 会把 Object Store 打成碎片场。经验法则是:批级别 put,不要样本级别 put。
三、故障语义:三类异常
| 异常 | 触发 | RL 侧动作 |
|---|---|---|
RayTaskError |
task / 方法 raise | 业务错误,一般不重建 |
RayActorError(含 ActorDiedError) |
ray.kill、进程死 |
重建 |
GetTimeoutError |
ray.get(timeout=) |
任务可能还活着,要 cancel 或放弃 |
import time
import ray
from ray.exceptions import GetTimeoutError, RayActorError
ray.init()
@ray.remote
def slow():
time.sleep(5)
return 'done'
@ray.remote
class Worker:
def ping(self):
return 'pong'
w = Worker.remote()
ray.get(w.ping.remote())
ref = slow.remote()
try:
ray.get(ref, timeout=0.3)
except GetTimeoutError:
ray.cancel(ref) # 不取消的话,它还会继续占资源
ray.kill(w)
time.sleep(0.2)
try:
ray.get(w.ping.remote())
except RayActorError as e:
print(type(e).__name__) # ActorDiedError
ray.shutdown()
分类的意义在于:重建策略不同。
RayTaskError:调用方逻辑问题,重建 actor 没用,可能反复失败;RayActorError:进程没了,不重建就没有服务;GetTimeoutError:最暧昧,任务可能只是慢。要么延长等待,要么ray.cancel,要么放弃这个调用但保留 actor。
异常类型在跨 actor 时会丢
NeMo-RL 踩过这个坑:RefitAborted 从引擎进程传到 Ray actor 后,变成 Exception("Call to ... failed")。于是 except RefitAborted 成了死代码,watchdog 明明 abort 了,run 还是卡住。
对策有两个:
- 看字符串里的固定 token(NeMo-RL 的做法);
- 事先约定错误码协议,不要依赖异常类型跨边界。
自动重启的角色(环境服务、路由)可以用 max_restarts=-1。训练 rank 通常手动管——重启代价大,而且要整组 re-rendezvous,不是拉起单个进程那么简单。
四、队列:从 ref 列表到消息泵
| 结构 | 适合 | 注意 |
|---|---|---|
list[ObjectRef] |
一次性 fan-out / fan-in | 无背压 |
ray.util.queue.Queue |
生产者-消费者,maxsize 背压 |
实现是 actor |
| 自写 MessageQueue actor | 自定义协议、多播 | verl fully-async 用这个 |
| ZMQ / HTTP | 大张量、跨框架 | Ray 只协调 |
from ray.util.queue import Queue
q = Queue(maxsize=8)
q.put_nowait(1)
print(q.get_nowait())
什么时候需要队列,而不是 ref 列表?
一个简单判据:如果生产者和消费者的速率差一个数量级,就需要队列。 ref 列表是「我发出去了」,队列是「你慢慢取」。verl 的全异步策略里有一个 MessageQueue actor,生产者往里塞 rollout 结果,trainer 按自身节奏取。全局锁被换成了队列水位。
五、权重广播:ray.util.collective
训练 step 结束后,要把新权重送到所有推理引擎。常见三条路:
| 路径 | 谁在用 | 特点 |
|---|---|---|
| 磁盘 / NIXL | slime disk 模式 | 解耦,带宽差一截 |
| torch.distributed 自建组 | slime、NeMo-RL | 跨 train + engine 的大组 |
ray.util.collective |
verl | Ray 托管 NCCL 组,适配 actor 边界 |
import ray
import ray.util.collective as collective
@ray.remote
class WSync:
def __init__(self, rank: int, world: int, name: str):
self.rank = rank
self.world = world
self.name = name
def init_group(self, uid):
collective.init_collective_group(
world_size=self.world,
rank=self.rank,
backend='gloo', # GPU 上改 'nccl'
name=self.name,
)
return f'rank {self.rank} ready'
def broadcast_from_root(self):
import torch
t = (
torch.tensor([1.0, 2.0, 3.0, 4.0])
if self.rank == 0
else torch.zeros(4)
)
collective.broadcast(t, root_rank=0, name=self.name)
return t.tolist()
ray.init()
workers = [WSync.remote(r, 2, 'wsync') for r in range(2)]
uid = collective.get_unique_id() # 真实系统经具名 actor 发布
ray.get([w.init_group.remote(uid) for w in workers])
print(ray.get([w.broadcast_from_root.remote() for w in workers]))
ray.shutdown()
verl 的 NCCLIDStore 就是把 get_unique_id() 的结果放进具名 actor,其他 rank 用 ray.get_actor 去取。这是「命名服务 + 集合通信」的组合拳。
slime 另外有一个 24 行的 Lock actor:权重广播与生成不能并发,于是用分布式锁把 pause → flush → update → resume 串起来。这种锁值得手写——需要的只是一次互斥,没必要引依赖。
六、版本屏障:权重更新的完整形状
把上面几块拼起来,权重更新不是「调一个函数」,而是一段有序编排。slime 的 disk 模式大致是:
1. onload_weights() # 有 offload 时先唤醒
2. get_updatable_engines_and_lock() # 取引擎 + 加锁
3. engine.pull_weights(version) # 可与生成重叠的准备
4. engine.pause_generation()
5. engine.flush_cache()
6. engine.update_weights_from_disk(...)
7. 校验 weight_version == 预期
8. engine.continue_generation()
第 3 步值得单独看:slime 注释明确写了,pull 是 disk-only,可以提到 pause 之前,和生成重叠。这是免费的吞吐收益,代价只是把更新流程拆细一点。
七、这一层的 checklist
| 主题 | 必做 |
|---|---|
| 引擎 | 子进程 + capture_child_tasks + 异步健康探测 |
| 大对象 | ray.put + 容器包 ref;监控 plasma |
| 故障 | 分类捕获;kill / timeout / 业务错误分开 |
| 权重 | 版本屏障;广播顺序确定;与生成互斥 |
下一篇收尾,讨论:runtime_env 的覆盖顺序、具名服务与拓扑调度,以及五种编排范式——从 slime 的 1,228 行,到 AReaL 的两万五千行,中间的取舍就是 RLinfra 的设计空间。
源码与仓库
| 用途 | 链接 |
|---|---|
| 本系列配套练习(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 |