多机多卡训练的启动流程:从 SLURM 到 torchrun,分布式训练的工程实践

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

AI technology illustration

有一天,我在 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 一旦被占,后到的作业就起不来。我习惯把端口跟作业号绑定,保证每个作业一个独立端口。

把整条链路串起来,其实就五步

  1. sbatch 把作业交给 SLURM,调度器在集群里挑好两台机器,注入一批 SLURM_ 开头的环境变量。
  2. srun 在每个节点上各拉起一个 torchrun 实例。
  3. torchrun 按 –nproc-per-node=8 把本机的 8 个训练进程 fork 出来,算好每个进程的 RANK 和 LOCAL_RANK。
  4. 16 个进程通过 rdzv-endpoint 完成 rendezvous,互相确认存在,建立进程组。
  5. 训练脚本里的 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

赞 (0)
上一篇 4天前
下一篇 4天前

相关推荐