Ray-Learn(五):多机角色约束、并发池与进程监督
前四篇把单机语义讲完了。这一篇补上多机上真正会疼的三块:节点角色怎么用自定义资源钉死、同一 actor 里长短任务怎么分池、以及 Ray 与子进程之间的责任边界。另附具名 actor 死亡后的调用语义。
这是 Ray-Learn 系列第五篇。前四篇主要在单机上把语义踩实;这一篇进入多机与生产形态:资源落在哪个角色、并发怎么分池、服务进程谁来管。
一、多机账本:看得见全部卡,反而更危险
单机调试时有一种偷懒做法:让 Ray 只「看见」一部分 GPU。例如推理节点 ray start --num-gpus=0,训练节点 --num-gpus=8。这样调度器永远不会把 TrainActor 放到推理卡上。
多机生产里这条路线经常不够用:
- 推理节点的卡也要被 Ray 记账,才能统一做显存与调度;
- 「少报卡」是一种隐藏,不是一种约束;
- 一旦某次运维改了启动参数,约束就悄悄消失了。
于是更稳的形状是:账本如实登记全部 GPU,再用自定义资源给节点打角色。
# 训练节点
ray start --head --num-gpus=8 --resources='{"role_train":8,"nvlink_domain_0":8}'
# 推理节点
ray start --address=<head>:6379 --num-gpus=8 \
--resources='{"role_infer":8,"nvlink_domain_1":8}'
import ray
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
ray.init(address='auto')
# bundle 规格里写死角色,而不是事后靠运气
train_pg = ray.util.placement_group(
bundles=[{'GPU': 1, 'CPU': 1, 'role_train': 1}] * 8,
strategy='STRICT_PACK',
)
infer_pg = ray.util.placement_group(
bundles=[{'GPU': 2, 'CPU': 1, 'role_infer': 2}] * 4,
strategy='PACK',
)
ray.get([train_pg.ready(), infer_pg.ready()])
几个要点:
| 机制 | 作用 |
|---|---|
--num-gpus |
账本额度,必须与规划一致 |
--resources='{"role_*":N}' |
角色标签,可进 bundle |
STRICT_PACK / PACK |
同机或尽量同机 |
capture_child_tasks |
子进程继承 bundle 资源 |
多机验收仍然是那条断言,只是数字变成整个集群的规划额度:
assert ray.cluster_resources()['GPU'] == 32
assert ray.cluster_resources()['role_train'] == 16
assert ray.cluster_resources()['role_infer'] == 16
后两条比第一条更有用:它们验证的是角色拓扑,不是单纯卡数。
NCCL 启动失败的排查顺序
多机上最常见的是「进程都活着,就是不动」。建议顺序固定:
- 极小
all_reduce微基准,确认网络与 NCCL 初始化能通; - 核对
MASTER_ADDR/MASTER_PORT是否跨机可达; - 核对 rank 映射是否稳定(第二篇的 bundle 重排序);
- 最后才怀疑模型并行配置。
真上大模型再调 NCCL,成本会高一个数量级。
二、并发:max_concurrency 不够用的时候
第三篇讲过:同步 actor 方法串行;阻塞在 C 扩展里的调用会饿死整个 actor。异步 actor 配合 max_concurrency 能并发 IO,但还有一个细分场景没展开——
同一个 actor 里,既有长任务,又有短探活。
典型例子是推理 Server actor:
generate可能跑几百毫秒到数秒;health必须在百毫秒内返回,否则监控失效。
若两者共用一个并发池,长生成一多,健康检查就排队。miles 的做法是 concurrency_groups:
import asyncio
import time
import ray
@ray.remote(concurrency_groups={'health': 1, 'generate': 4})
class Server:
async def generate(self, i: int) -> str:
await asyncio.sleep(1.2)
return f'gen {i}'
async def health(self) -> str:
return 'healthy'
ray.init()
s = Server.remote()
t0 = time.time()
busy = [s.generate.remote(i) for i in range(3)]
print(ray.get([s.health.remote() for _ in range(3)]), f'{time.time()-t0:.2f}s')
ray.get(busy)
ray.shutdown()
语义拆开:
| 配置 | 含义 |
|---|---|
max_concurrency=N |
整个 actor 最多 N 个协程 |
concurrency_groups={name: N} |
多个独立执行池 |
| 方法进哪个池 | 创建时用 method_concurrency_groups 指定(miles) |
verl 的 server 另一种取舍:把 max_concurrency 设得比引擎容量还高(例如 max(1000, max_num_seqs+16)),让引擎内部排队,Ray 只保证不把请求挡在门外。两种都能用,前提是想清楚队列在哪一层。
三、Ray 管资源,进程跑服务
推理引擎是「长 event loop + 多线程 CUDA」的服务。正确分工是:
| 层 | 职责 |
|---|---|
| Ray actor | 拿资源、起进程、探活、优雅退出 |
| 子进程 | 真正跑引擎 loop / CUDA / NCCL |
import os
import subprocess
import ray
@ray.remote(num_cpus=0.5)
class ProcessSupervisor:
def __init__(self):
self.proc = None
def spawn(self, cmd: list[str]) -> dict:
self.proc = subprocess.Popen(cmd, env=os.environ.copy())
return {'pid': self.proc.pid, 'cvd': os.environ.get('CUDA_VISIBLE_DEVICES')}
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()
try:
self.proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self.proc.kill()
return 'stopped'
三个必须写进代码的细节:
placement_group_capture_child_tasks=True
不加的话,子进程会被当成新任务去抢资源,而不是继承父 actor 的 bundle。- actor 活着 ≠ 子进程活着
必须poll()/ 异步 reap + 有界健康探测。父进程被硬杀时,finally不会跑。 - 清理顺序
TERM → 超时 → KILL → 确认无 GPU 占用 → 再释放 actor/PG。反过来会让残留进程占着已归还的卡。
第三种「共置」:fork
前几篇提过两种共置:
- 单 PG + offset(slime);
- FusedWorker,多逻辑类合成一个 actor(verl)。
AReaL 还有第三种:fork_workers。在同一节点上通过 HTTP 向 guard 进程要端口、fork 新角色,不新开 placement group,直接复用该节点已占资源。
适合「节点已经占卡,还要再起一个旁路服务」的场景。代价是清理路径更复杂:要单独 kill fork 出来的进程树。
四、具名 actor 的死亡语义
第四篇讲了如何「找到」单例;这里补上「找到之后它死了怎么办」。
| 调用 | actor 已被 ray.kill |
含义 |
|---|---|---|
actor.method.remote() + ray.get |
RayActorError / ActorDiedError |
进程没了 |
ray.get_actor(name) |
通常仍返回句柄或报错(版本相关) | 名字还在不代表实例活 |
创建时 max_restarts=-1 |
可能自动拉起 | 适合 env/router,不适合训练 rank |
lifetime='detached' |
driver 退出后仍在 | 跨作业单例,记得显式 kill |
工程建议:
- 调用方按
RayActorError走重建,不要按业务异常处理; - 具名服务要么配自动重启,要么由 Driver 负责探测与重建;
- detached actor 用完必须杀,否则占资源直到集群回收。
五、作业生命周期
| 形态 | 入口 | 场景 |
|---|---|---|
| 本地自起 | ray.init() |
学习、单机 |
| 接入已有集群 | ray.init(address='auto') |
ray start 已起好 |
| 作业系统 | JobSubmissionClient / ray job submit |
KubeRay、多租户 |
| 平台内自举 | rank0 ray start --head |
Slurm gang 作业 |
# 形状示意:作业系统侧
from ray.job_submission import JobSubmissionClient
client = JobSubmissionClient('http://127.0.0.1:8265')
# job_id = client.submit_job(entrypoint='python train.py', runtime_env={...})
# client.get_job_status(job_id)
# client.get_job_logs(job_id)
NeMo-RL 的 nrl_k8s 用 JobSubmissionClient 对接 KubeRay;AReaL 在 Slurm 里自举 Ray 集群;slime/miles 脚本层更常见 ray job submit CLI。
无论哪种入口,第一道门禁都相同:
assert ray.cluster_resources()['GPU'] == expected
六、这一篇的 checklist
| 主题 | 必做 |
|---|---|
| 多机 | 全卡进账本 + 自定义角色资源进 bundle |
| 并发 | 长短任务分 concurrency_groups |
| 服务 | actor 管进程,子进程跑引擎 |
| 具名 | 按 RayActorError 重建;detached 显式回收 |
| 作业 | 统一入口 + 账本断言 |
下一篇是附录:把各框架真实用到的 Ray API 压成一张对账表,方便读源码时对照。
源码与仓库
| 用途 | 链接 |
|---|---|
| 本系列配套练习(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 |