Ray-Learn(二):Placement Group、GPU 身份与 rank 稳定
资源从「几张卡」推进到「卡落在哪」。Placement Group 的 gang 语义、 bundle 顺序为什么不可控、get_gpu_ids 如何翻译成 local rank,以及手工 rendezvous 怎样把 N 个 actor 收成一个 torch 进程组。
这是 Ray-Learn 系列第二篇。上一篇解决的是「资源怎么记账」,这一篇要解决「资源落在哪」。
分布式训练对调度的要求,比普通批处理高一截。普通任务只要「给我 N 个 CPU」就行;训练任务还要知道:这 N 张卡是不是同一批、谁是 rank 0、rank 3 重启之后还是不是同一张物理卡。这些事,靠散落的 num_gpus=1 是不够的。
一、为什么需要 Placement Group
单个 num_gpus=1 的 actor 只能保证「拿到了一张卡」。多 rank 训练还需要三件事:
- gang scheduling:要么一起就绪,要么都别动。避免 16 个 rank 起来了,第 17 个还在排队,前面 16 个干等。
- 相对位置可控:TP 组最好在同一台机器上,跨机 TP 会撞网络。PP 的 stage 划分也希望有稳定的拓扑。
- 共置 / 分离可配置:rollout 和 trainer 同卡可以省机器,分卡可以省显存。这个选择应该由配置决定,而不是由调度器随机决定。
Placement Group(PG)就是给这三件事用的:
import ray
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
ray.init(num_gpus=4)
pg = ray.util.placement_group(
bundles=[{'GPU': 1, 'CPU': 1}] * 4,
strategy='PACK', # 尽量同节点;STRICT_PACK 强制同节点
)
ray.get(pg.ready()) # gang 就绪屏障
@ray.remote(num_gpus=1)
class Rank:
def meta(self):
return {
'gpu': ray.get_gpu_ids(),
'node': ray.get_runtime_context().get_node_id(),
}
actors = [
Rank.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=i,
)
).remote()
for i in range(4)
]
print(ray.get([a.meta.remote() for a in actors]))
ray.util.remove_placement_group(pg)
ray.shutdown()
几个参数值得单独说:
| 参数 | 语义 | 典型用途 |
|---|---|---|
bundles |
一组资源打包,调度时整体决策 | 一张卡 + 若干 CPU 为一个训练 rank |
strategy='PACK' |
尽量挤在同一节点 | 机内 TP、NVLink |
strategy='STRICT_PACK' |
强制同节点 | 同机不可妥协时 |
strategy='SPREAD' |
尽量打散 | 容错、数据并行 |
pg.ready() |
全部 bundle 就绪,或永远不返回 | gang 屏障 |
placement_group_bundle_index |
把 actor 钉到指定 bundle | rank ↔ bundle 映射 |
pg.ready() 值得强调一下。它不是「检查一下有没有资源」,而是要么整个 PG 都就绪,要么就一直等。这正是 gang 语义:不允许「起了一半」这种状态进入训练。
slime 的做法:一个大 PG + 索引偏移
实际工程里还有一种更省事的布局。slime 不给每个角色单独建 PG,而是建一个大 PG,再用索引偏移切角色:
| 模式 | 总 bundle 数 | rollout 起始偏移 | 含义 |
|---|---|---|---|
| 分离 | actor + rollout | actor_num_gpus | rollout 排在 actor 后面,各占各的 |
| 共置 | max(actor, rollout) | 0 | rollout 与 actor 用同一批 bundle |
好处是:共置和分离用同一张布局表表达,代码里只有一个 _get_placement_group_layout()。代价是 offset 算错就会静默错位,所以这个函数通常是全文件里注释最多的。
二、bundle 顺序不稳定——这是正确性问题
Ray 分配 {'GPU': 1} × 8 时,bundle 与物理 GPU 的对应顺序不可控。今天 rank 0 可能落在 GPU 0,下次启动变成 GPU 3。
这件事对纯数据并行或许只是日志难看。但对下面这些情况是直接错误:
- 流水并行:stage 0 和 stage 1 的通信拓扑变了
- 跨 rank 的 NCCL 组:rank 号变了,集合通信顺序就乱了
- 依赖「rank 0 持有全局 buffer」的逻辑:rank 0 不再是那张卡
slime / miles / verl / NeMo-RL 都做了同一件事:探测物理位置,再重排序。
import ray
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
@ray.remote(num_gpus=1)
class Probe:
def where(self):
return (
ray.util.get_node_ip_address(),
ray.get_gpu_ids()[0],
)
def build_rank_map(pg, num_bundles: int):
probes = [
Probe.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=i,
)
).remote()
for i in range(num_bundles)
]
infos = ray.get([p.where.remote() for p in probes])
for p in probes:
ray.kill(p)
# 逻辑 bundle 下标 -> 按 (node, gpu) 排序后的物理次序
order = sorted(range(num_bundles), key=lambda i: infos[i])
return {rank: bundle for rank, bundle in enumerate(order)}
流程是:
- 每个 bundle 上临时起一个探针 actor;
- 问它「你在哪个节点、哪张卡」;
- 按
(node, gpu)排序,得到稳定的 rank 映射; - 探针杀掉,之后按映射表创建真正的 worker。
verl 的写法略有不同,它读 placement_group_table 按节点 IP 排序;NeMo-RL 在 RayVirtualCluster 里记录物理 bundle 顺序。思路一致:不要依赖调度器的偶然顺序。
三、ray.get_gpu_ids():从账本 id 到进程内设备
actor 进程里,真正回答「该用哪张卡」的 API 是 ray.get_gpu_ids()。
import os
import ray
@ray.remote(num_gpus=1)
class TrainActor:
def __init__(self):
visible = os.environ.get('CUDA_VISIBLE_DEVICES', '')
gid = ray.get_gpu_ids()[0]
# 账本给的 GPU id,翻译成进程内可见设备下标
self.local_rank = (
visible.split(',').index(str(gid)) if visible else 0
)
def get_local_rank(self) -> int:
return self.local_rank
ray.init(num_gpus=2)
a = TrainActor.remote()
print(ray.get(a.get_local_rank.remote()))
ray.shutdown()
miles 的 train_actor.py 就是这个模式:把 Ray 的 GPU id 翻译成 LOCAL_RANK,再写进环境变量,交给 Megatron / FSDP。为什么需要翻译?因为 CUDA_VISIBLE_DEVICES 里的下标是进程内的,而 ray.get_gpu_ids() 返回的是账本层的 id,两者不一定连续。
这个选择会影响后面很多事:要不要自己算 local rank、能不能把某几张卡留给子进程、日志里的 GPU 号指账本还是指物理。建议在项目早期就定下来,不要中途换。
四、手工 rendezvous:把 N 个 actor 收成进程组
torch.distributed 需要四个环境变量:MASTER_ADDR、MASTER_PORT、WORLD_SIZE、RANK。Ray 不会自动填充。slime 用一个 10 行的基类手写 rendezvous:
import socket
import ray
@ray.remote
class RayActor:
def get_master_addr_and_port(self):
addr = ray.util.get_node_ip_address()
with socket.socket() as s:
s.bind(('', 0)) # 端口 0 = 随机空闲端口
return addr, s.getsockname()[1]
# 概念代码:rank 0 先起,把地址交给后续 rank
master_addr, master_port = None, None
actors = []
world_size = 4
for rank in range(world_size):
actor = TrainActor.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
placement_group_bundle_index=reordered[rank],
)
).remote(world_size, rank, master_addr, master_port)
if rank == 0:
master_addr, master_port = ray.get(
actor.get_master_addr_and_port.remote()
)
actors.append(actor)
要点按顺序说:
- rank 0 先起。它从所在节点取 IP,从绑定端口 0 的临时 socket 取一个空闲端口。
- 这个
(addr, port)就是后续所有 rank 的MASTER_ADDR/MASTER_PORT。 - 真正的
init_process_group在 actor 方法里完成。进程组跨 actor 边界,和 Ray 的调度是两层事。
端口用 0 而不是固定值,是为了避免多作业同机冲突。地址用 get_node_ip_address() 而不是 localhost,是因为跨机时 rank 0 的地址必须是其他节点可达的。
多机上的一个高频坑
NCCL 卡在启动阶段,往往不报错,只是不动。表现是所有进程都活着,nvidia-smi 也正常,就是不往下走。排查顺序建议是:
- 先跑一个极小的
all_reduce微基准,确认网络通; - 再确认
MASTER_ADDR是否其他节点可达; - 最后才看模型并行配置。
很多「训练启动失败」的问题,在这三步里就能定位。真上模型再调 NCCL,成本会高很多。
五、环境变量是编排的一部分
rendezvous 之前,还有一层「启动期正确性」。slime 的 utils.py(75 行)里有三条几乎是必抄的:
| 变量 | 值 | 原因 |
|---|---|---|
RAY_USE_UVLOOP |
0 |
修 async actor 的间歇性故障 |
RAY_EXPERIMENTAL_NOSET_*_VISIBLE_DEVICES |
1 |
设备映射由上层管理 |
NCCL_CUMEM_ENABLE |
0 |
与 SGLang 保持一致,否则 NCCL 报错 |
def add_default_ray_env_vars(extra: dict[str, str]) -> dict[str, str]:
env = {
'RAY_USE_UVLOOP': '0',
'RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES': '1',
'NCCL_CUMEM_ENABLE': '0',
}
env.update(extra) # 调用方优先
return env
RolloutManager.options(
runtime_env={'env_vars': add_default_ray_env_vars({'RL_STAGE': 'dev'})}
).remote()
NeMo-RL 把注入优先级写成了四层,从低到高:类级 runtime_env、系统 export、实验 YAML、worker 静态配置方法。
「export 了但没生效」多半是被更高层覆盖了。如果只保留一条注入路径,就能少掉一整类 bug——这也是小型项目值得做的取舍:能少一层配置链,就少一层。
六、这一层在 RLinfra 里的位置
把本篇的机制按依赖关系排一下:
| 机制 | 解决的问题 | 对应源码 |
|---|---|---|
PG + pg.ready() |
gang:要么都好,要么都别动 | slime placement_group.py |
| bundle 重排序 | rank ↔ GPU 跨重启稳定 | slime / verl / NeMo-RL |
get_gpu_ids |
账本 id → 进程内设备 | miles train_actor.py |
| 手工 rendezvous | actor 图 → torch 进程组 | slime actor_group.py |
| env 注入 | 启动期一致性 | slime utils.py |
下一篇进入数据面与引擎生命周期:推理引擎为什么必须是子进程,rollout 大张量为什么不能当参数传,以及权重广播该怎么做。
源码与仓库
| 用途 | 链接 |
|---|---|
| 本系列配套练习(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 |