我最早在超算上跑多机训练,对 SLURM 和 torchrun 的关系有一个朴素到错误的认知:SLURM 管排队,torchrun 管训练,资源一到就能开跑。

有一天,我在 sbatch 脚本里写了一个自认为完美的命令,2 台节点 × 8 张卡。SLURM 如约执行,16 个训练进程被拉起,然后集体卡死。我等了一下午的排队,收获的只有日志里反复出现的「waiting for process 0」。
后来我才想明白:分布式训练的启动流程,本质上就是「给每个进程编号,再让它们互相找到对方」。我那 16 个进程,编号步骤根本没对上。SLURM 和 torchrun 也不是「排队系统 + 训练工具」的关系,它们各自负责了一段完全不同的工作。
先把 RANK、WORLD_SIZE 这几个词刻进脑子里
多机多卡训练,是 N 个进程同时跑,每个进程在自己的 GPU 上算梯度,然后互相同步。要完成这件事,每个进程必须知道:我是谁,别人是谁,怎么联系别人。这对应的就是一组全局信息。
| 信息 | 含义 | 你实际用它做什么 |
|---|---|---|
| WORLD_SIZE | 参与训练的总进程数 | 判断数据采样步数、学习率缩放 |
| RANK | 全局唯一的进程编号 | 决定谁负责保存 checkpoint、打日志 |
| LOCAL_RANK | 本机内的进程编号 | 选 GPU:torch.cuda.set_device(local_rank) |
| LOCAL_WORLD_SIZE | 本机上的进程数 | 本机资源感知 |
| MASTER_ADDR / MASTER_PORT | 0 号进程的地址和端口 | 所有进程初始化时都要连到这里 |
代码里最常用的是 LOCAL_RANK 和 RANK:LOCAL_RANK 决定当前进程用哪块卡,RANK 决定谁负责保存模型。这两个数字一旦搞混,轻则模型存错地方,重则进程之间互相等不到。
SLURM 是包工头,torchrun 是发牌员
先说 SLURM。它管资源,不管身份。SBATCH 文档里列了一堆注入的环境变量:SLURM_JOB_ID、SLURM_NODEID、SLURM_NTASKS、SLURM_PROCID。这些变量相当于「你被派到了第几个工位」。它并不知道你的 PyTorch 代码里说的 RANK 是什么。
而 torchrun 恰好就是那个翻译。torchrun 官方文档对它的定位很清楚:负责为训练进程生成 RANK、LOCAL_RANK、WORLD_SIZE 等环境变量,并管理整个进程组的握手。具体来说它做三件事:
- 根据 –nproc-per-node 在本机拉起对应数量的训练进程;
- 给每个进程算好 RANK、LOCAL_RANK、WORLD_SIZE,注入环境变量;
- 通过 rendezvous(会合)机制,让所有节点确认「人都到齐了」,然后才启动训练。
在训练代码里,你只需要调用 init_process_group(init_method=’env://’),剩下的身份信息 torchrun 已经通过环境变量传好了。
两套系统的接缝处,就是那一行 srun
于是问题变成:SLURM 在每个节点上执行什么命令,才能把「包工头」的活交接给「发牌员」?
srun torchrun --nnodes=2 --nproc-per-node=8 \
--rdzv-backend=c10d \
--rdzv-endpoint=node01:29500 \
train.py
这里的关键是配合 sbatch 里的 –ntasks-per-node=1:srun 在每个节点只拉一个 torchrun 实例;这个实例再根据 –nproc-per-node=8 在本机 fork 8 个训练进程。两个节点共 16 个进程,通过 –rdzv-endpoint 指定的地址完成会合。
rdzv-endpoint 里的 node01 是关键。它必须是一个所有节点都能访问的地址,通常取自 0 号节点的内网主机名或 IP。实操里很多人用 scontrol show hostname 从 $SLURM_NODELIST 里取第一个。
一个完整的 sbatch 提交脚本长这样
#!/bin/bash
#SBATCH --job-name=dist_train
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1
#SBATCH --gres=gpu:8
MASTER_ADDR=$(scontrol show hostname "$SLURM_NODELIST" | head -n 1)
srun torchrun \
--nnodes=2 \
--nproc-per-node=8 \
--rdzv-backend=c10d \
--rdzv-endpoint="$MASTER_ADDR:29500" \
train.py
这套组合的唯一任务,就是把这个密集的握手过程自动化。
我踩过的四个坑,每个都烧掉过我一下午的排队时间
忘了写 –nnodes,第二个节点自顾自地等
torchrun 的默认 –nnodes 是 1。你只写了 –nproc-per-node=8,srun 却老老实实地在两个节点各拉了一个 torchrun。结果两台机器上的 torchrun 都以为世界上只有自己,都在等那个「不存在的 0 号进程」。症状就是卡死到超时。
rdzv-endpoint 写成了 localhost,或者干脆没写
如果这一项漏掉,torchrun 默认在 localhost 上创建 rendezvous,另一台机器根本拿不到地址。这跟漏写 nnodes 的症状几乎一样,但排查方向完全不同:先查 endpoint,再查 nnodes。
NCCL 挑错了网卡
多机通信的底层是 NCCL。机器上通常有好几个网口:管理网、数据网、Docker 桥接网。NCCL 如果选错了一个,初始化阶段就会报 connect to 失败。修法是在启动命令里显式指定网络接口:export NCCL_SOCKET_IFNAME=eth1(网卡名以你的机器为准)。详细变量说明见 NCCL 环境变量文档。
MASTER_PORT 撞车
同一台机器上可能同时排着好几个作业。默认端口 29500 一旦被占,后到的作业就起不来。我习惯把端口跟作业号绑定,保证每个作业一个独立端口。
把整条链路串起来,其实就五步
- sbatch 把作业交给 SLURM,调度器在集群里挑好两台机器,注入一批 SLURM_ 开头的环境变量。
- srun 在每个节点上各拉起一个 torchrun 实例。
- torchrun 按 –nproc-per-node=8 把本机的 8 个训练进程 fork 出来,算好每个进程的 RANK 和 LOCAL_RANK。
- 16 个进程通过 rdzv-endpoint 完成 rendezvous,互相确认存在,建立进程组。
- 训练脚本里的 init_process_group 调用返回,第一个 allreduce 正式开跑。
到了第 5 步,SLURM 和 torchrun 的使命都完成了。后面跑多快、稳不稳,是 NCCL 和训练代码的事。
什么时候可以不用这套搭配
单机 8 卡,只需要 torchrun –nproc-per-node=8 train.py,SLURM 完全不参与。
用 Kubernetes 的团队是另一套逻辑:K8s 负责拉起多个 Pod,PyTorch 弹性训练框架在容器内部完成 rendezvous。两者也在通过 operator 模式逐步融合。
老一点的代码仓库里还会看到 mpirun 的身影,那是更早期的多机启动方案,需要自己维护 hostfile。相比之下,srun 直接消费 SLURM 的资源分配结果,省掉不少手工活。
这套搭配的能力边界也很明显:SLURM + torchrun 只管「启动成功」,不管「跑得好」。它不会根据你的 GPU 拓扑自动选择集合通信算法,也不会替你选网卡。到了调优阶段,NCCL 的环境变量、通信算子开关、日志分析,每一项都还得靠人肉。
换句话说,SLURM 把机器交给你,torchrun 把进程组织起来,剩下能不能跑得快,依然是你自己的事。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/1687.html