ml-engineering 实战指南:在 SLURM 环境中训练大模型——从节点分配、作业调度到分布式启动器配置

原创2026-10-02 12:33:241,748 阅读
文章标签:人工智能大模型深度学习文档教程AI 技能

ml-engineering 实战指南:在 SLURM 环境中训练大模型——从节点分配、作业调度到分布式启动器配置

导读:本文以 Machine Learning Engineering Open Book(ml-engineering)仓库的 orchestration/slurm 章节为主体,系统讲解如何在共享的 SLURM 集群上完成大模型训练作业的申请、调度、资源优化与故障排查。你会学到:用 salloc/sbatch/srun 申请并复用 GPU 分配、用作业数组和自延续作业模拟 crontab、用 --export/--signal 精细控制作业环境与优雅退出,以及将 torchrun、Hugging Face Accelerate、Lightning 与原生 srun 四种启动器接入 SLURM 的完整可运行脚本。文末还覆盖 Hyper-Threads 与 --cpus-per-task 等直接影响训练吞吐的性能细节。

SLURM 是什么:为什么大模型训练离不开它

SLURM(Simple Linux Utility for Resource Management,现官方名为 The SLURM Workload Manager)是一款免费开源的 Linux/Unix 作业调度器,被大量超算中心和计算机集群采用。除非你运气足够好、拥有一套完全归自己支配的专用集群,否则在多用户共享 GPU 的集群上训练,几乎必然要与 SLURM 打交道;即使在 HPC 环境中拿到了专用 partition,通常也仍需使用 SLURM。

原文档定位是"不打算穷尽式地教 SLURM",而是聚焦于对训练过程真正有用的细节。整个章节按四份文档展开:

  • SLURM For Users:在 SLURM 环境中完成训练所需的一切日常操作;
  • SLURM Administration:当你除了使用集群、还不得不维护集群时,这里有一份不断增长的运维配方;
  • Performance:与训练性能直接相关的 SLURM 设置细节;
  • Launcher scripts:如何用 torchrun、accelerate、PyTorch Lightning、srun 在 SLURM 环境启动分布式训练。

快速开始:复制 example.slurm 并适配

最快的上手方式是把 example.slurm 复制过来,按需修改。这是一个 2 节点作业模板,核心头部参数如下:

#SBATCH --job-name=example-job
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1          # crucial - only 1 task per dist per node!
#SBATCH --cpus-per-task=96
#SBATCH --gres=gpu:8
#SBATCH --time=0:10:00
#SBATCH --exclusive
#SBATCH --partition=xyz-cluster
#SBATCH --output=%x-%j.out

其中有两处注释值得注意:

  • --ntasks-per-node=1 被标记为 crucial(关键):对分布式训练启动器而言,每个节点只需要一个 task(该 task 再派生 N 个 GPU 进程),而不是每 GPU 一个 task。这是与 Lightning 或 srun 启动方式(见后文)在语义上的根本区别。
  • --exclusive 表示独占节点,避免与他人共享节点带来的资源竞争与干扰。

脚本主体用 scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n 1 推导出 MASTER_ADDR,固定 MASTER_PORT=6000,再以 torchrun(python -u -m torch.distributed.run)作为启动器,最后用 srun --wait=60 --kill-on-bad-exit=1 --jobid $SLURM_JOB_ID bash -c "$CMD" 在全部节点上统一拉起。这种"先 srun 一个 bash、在 bash 内部做延迟变量展开"的手法在仓库所有启动器中反复出现,详见"SLURM 专属的启动器陷阱"一节。

查看集群与分区:dev / prod 示例

文档以一个双分区集群为例:dev 与 prod。查看各分区节点的宿主名与可用状态:

sinfo -p dev
sinfo -p prod

slurm.conf 的路径因站点而异(常见于 /etc/slurm/ 或 /opt/slurm/etc/),可通过以下命令定位当前集群实际加载的配置文件:

scontrol show config | grep -i slurmconf

查看所有分区的完整配置:

scontrol show partition

查看节点分配/空闲概览最常用的两个命令:

sinfo -p PARTITION     # 分区内各节点状态
sinfo -s               # 汇总视图,输出形如 NODES(A/I/O/T)

其中 sinfo -s 的 A/I/O/T 表示 allocated/idle/other/total,例如 597/0/15/612 表示 612 个节点中 597 个已分配、0 个空闲、15 个因其他原因不可用。按分区细化可以用:

sinfo -p gpu_p1 -o "%A"

输出 236/24 即该分区已分配 236 个节点、空闲 24 个,可快速判断 4x V100-32G 分区上是否还有空位。

sinfo 节点状态与单字符后缀

  • idle:无作业运行;
  • alloc:节点已分配给正在执行的作业;
  • mix:部分 CPU 被分配,其余空闲;
  • drain:因管理原因不可用;
  • drng:正在运行作业,但完成后将因管理原因不再可用。

节点状态后可跟一个单字符后缀,含义特殊:* 节点当前无响应、不再分配新工作(持续无响应将进入 DOWN);~ 已关机;# 正在上电或配置;! 待下电;% 正在下电;$ 处于 maintenance 预留;@ 待重启;^ 已发出重启指令;- 已被 backfill 调度器预留给更高优先级作业。

作业状态码速查

  • CD Completed:作业成功完成;
  • CG Completing:作业即将结束但仍有进程存活;
  • F Failed:以非零退出码终止;
  • PD Pending:等待资源分配,最终会运行;
  • PR Preempted:被其他作业抢占而终止;
  • R Running:已分配节点并运行中;
  • S Suspended:运行中的作业被停止且核心已释放给其他作业;
  • ST Stopped:运行中的作业被停止但核心仍被保留。

作业排队与时间控制:等待、预约与依赖

预测等待时间

squeue -u `whoami` --start

会显示所有 pending 作业预计开始的时间;若其他人提前取消预留,实际开始时间可能更早。

用依赖而非 --begin 缩短排队

要在某个正在运行的作业结束后(无论其是否已开始)立刻调度新作业,使用依赖机制:

sbatch --dependency=CURRENTLY_RUNNING_JOB_ID tr1-13B-round1.slurm

文档特别指出:用 --dependency 往往比 --begin 等待更短。因为 --begin 指定的时刻即使比前一作业结束仅晚几分钟,调度器在那一刻之前都会忽略该作业,从而可能把机会让给优先级更低的作业。

定时申请分配

salloc --begin=YYYY-MM-DDTHH:MM:SS

sbatch 同样支持 --begin。它会把作业在指定时刻投入队列,如同届时才执行该命令;若届时资源空闲则立即分配,否则进入排队。--begin 还接受多种格式:

--begin now+2hours
--begin=16:00
--begin=now+1hour
--begin=now+60  # seconds by default
--begin=2010-01-20T12:34:00

时间单位默认为 seconds,也支持 minutes、hours、days、weeks。

申请一次、反复使用:预分配节点与分配复用

对反复进行的交互式实验,最划算的策略是"一次性申请一段较长的分配窗口,然后用交互式 srun 在该分配内反复执行任务",避免每次实验都重新排队。

salloc --partition=dev --nodes=1 --ntasks-per-node=1 --cpus-per-task=96 --gres=gpu:8 --time=6:00:00 bash
salloc: Pending job allocation 1732778
salloc: job 1732778 queued and waiting for resources
salloc: job 1732778 has been allocated resources
salloc: Granted job allocation 1732778

随后复用该分配反复跑任务(传 salloc 的作业号):

srun --jobid $SLURM_JOBID --pty bash

该 srun 超时或手动退出后,可在同一预留节点上重新启动。srun 当然也可以直接调用真实训练命令,而不只是 bash。注意事项:

  • 单节点分配时,salloc 启动的 shell 本身并不在计算节点上;需通过分配授予时的输出或 squeue 获知节点 hostname,再 ssh 过去;
  • 结束后释放资源:退出 salloc 打开的 shell,或 scancel JOBID;
  • 该预留节点在整个分配期间都计入时长配额,用完尽快释放。

如果是单节点,还可以跳过 salloc,直接用 srun 一步完成"申请 + 给 shell":

srun --pty --partition=dev --nodes=1 --ntasks=1 --cpus-per-task=96 --gres=gpu:8 --time=60 bash

复用同一分配的跨 shell 操作

当需要在相同节点分配上运行多种作业时(如多节点 16 台):一个 shell 内申请并记下作业号:

salloc --partition=prod --nodes=16 --ntasks=16 --cpus-per-task=96 --gres=gpu:8 --time=3:00:00 bash
echo $SLURM_JOBID

另一个 shell 里据此复用:

export SLURM_JOBID=<JOB ID FROM ABOVE>
srun --jobid=$SLURM_JOBID ...

跑诊断类作业时可能需要 --gres=gpu:0 以免阻塞等待 GPU。例如检查所有主机的共享内存:

srun --jobid 631078 --gres=gpu:0 bash -c 'echo $(hostname) $(df -h | grep shm)'

分配在 shell 退出后仍存活

普通的 salloc ... bash 一旦退出 shell(或 ssh 连接断开),分配即丢失。若希望分配在 shell 退出后继续存在,用 --no-shell 且不带 bash:

salloc --no-shell --partition=dev --nodes=1 --ntasks-per-node=1 --time=1:00:00

之后按"如何交互式重新加入分配节点"一节加入会话。但要注意:ssh 到节点上正常启动的程序,在断开连接时其父 shell 会向子进程发送 SIGHUP 导致进程被杀;要用 nohup 放入后台:

nohup my-program &
nohup my-program >> some-file.txt &

nohup 忽略 SIGHUP,并把 stderr 重定向到 stdout、追加写入 nohup.out(可用普通重定向 >/>> 控制输出位置)。缺点是无法再正常交互。若需要"断开后重连并继续正常使用",应改用终端复用器(tmux、GNU screen)或 mosh 等工具。

如何交互式重入已分配的节点

要向同一作业开多个交互 shell,需使用 --overlap。控制台 A 申请单节点:

$ salloc --partition=dev --nodes=1 --ntasks-per-node=1 --cpus-per-task=26 --gres=gpu:1 --time=2:00:00 bash
salloc: Granted job allocation 1916
salloc: Nodes my-node-1 are ready for job

控制台 B:

$ srun --overlap --pty --jobid 1916 bash

可重复在任意多个控制台执行。第一个伪终端 shell 不需要 --overlap,但后续 shell 都需要。若最初用 srun --pty 分配,同样适用:

srun --pty -p dev --gpus 8 --time=2:00:00 bash

也可以 ssh 进入节点,但若集群配置了虚拟化(如每人仅可见若干 GPU、退出时自动清理 /tmp//scratch),ssh 视角会与分配内不一致——例如作业只分配 2 块 GPU 时,ssh shell 会看到全部 GPU,与他人共享节点时这会出问题。

多节点分配默认进入第一个节点;要指定进入某个节点用 -w:

srun --pty -p dev --gpus 8 --time=2:00:00 -w node-3 bash

若报错 srun: error: Unable to create step for job 1930: Invalid generic resource (gres) specification,把 --gres=gpu:8 补回即可(若原分配命令已带此参数则无需)。

精确控制作业环境:--export 的四种形态

#SBATCH --export=... 控制提交 shell中的哪些变量被复制进作业。在标准 Slurm 上默认是 ALL(部分站点通过 SBATCH_EXPORT/SLURM_EXPORT_ENV 或 cli_filter 覆盖默认值)。SLURM_* 变量无论何种模式始终存在;作业脚本内部 export KEY=value 的变量也始终对启动的程序可见——--export 只管从提交 shell 继承什么。四种形态:

  1. 完整提交环境(默认):
#SBATCH --export=ALL

在标准 Slurm 上等价于省略 --export,单独写这一行是 no-op。仅在需要撤销站点默认的 NONE、或与形态 4 组合时才有意义。

  1. 最小环境:
#SBATCH --export=NONE

丢弃提交 shell 环境,适合需要可复现、不继承登录 shell 里任意导出变量的作业。

  1. 只保留指定变量(替换列表):
#SBATCH --export=KEY1,KEY2=value

只传播列出的变量(KEY1 取当前值,KEY2 置为 value),其余全部丢弃——这不是对 ALL 的追加。

  1. 完整环境 + 设置/覆盖:
#SBATCH --export=ALL,KEY=value

保留完整提交环境并额外设置或覆盖 KEY。若既要"全部"又要额外变量,ALL 必须出现在列表中,否则落到形态 3。

节点选择:排除坏节点与指定节点

当某些节点处于 IDLE 但实际损坏时,排除它们:

sbatch --exclude nodeA,nodeB
# 或写在脚本里
#SBATCH --exclude ...

指定使用特定节点:

sbatch --nodelist=nodeA,nodeB

--nodelist 可用短选项 -w 代替。管理员也可在 slurm.conf 定义 feature=example,用户通过 --constraint=example 请求该子集节点。

信号与优雅退出:--signal 与 checkpoint

SLURM 每次运行都有时限。可配置在分配时间结束前指定秒数给程序发送指定信号:

--signal=[[R][B]:]<sig_num>[@<sig_time>]

推荐的工作模式是:在 wall-clock 限时前几分钟捕获 SIGUSR1(或类似信号),完成当前 step、保存 checkpoint 后不开启下一 cycle 直接退出——完整方案见 training/fault-tolerance 章节的 Approach B1: Sending a custom signal X minutes before the end。关于更复杂的抢占场景(基于时间与 QoS 的抢占),用户文档 指引读者到 Dealing with forced job preemption 深入了解多种优雅处理抢占的方式。

查看作业详情与历史:scontrol 与 sacct

大部分有用信息都在 SLURM_* 环境变量里,但偶尔缺项,此时用:

scontrol show -d job $SLURM_JOB_ID

再按需解析输出。作业结束后查历史:

sacct -j JOBID

sacct 还能用来发现某个分配上是否仍有 srun step(含已结束或被取消的)。例如可通过 scancel <jobid>.<step-id> 杀掉失控的 srun step,step-id 即可从上命令得到;即使取消了所有 step,主交互作业仍会继续运行。查看更细的信息:

sacct --format=jobid,start,end,state,exitcode,nodelist%300 -j JOBID
sacct -j JOBID --long

只看某个分区、自己名下所有作业及其子 step:

sacct -u `whoami` --partition=dev  --format=jobid,start,end,state,exitcode,nodelist%300
sacct -u `whoami` --partition=prod --format=jobid,start,end,state,exitcode,nodelist%300

查看某作业是如何启动的、其所有 srun 子 step 的命令行:

sacct -j JOBID -o submitline -P

sacct 的数据来自 SLURM 作业记账日志/数据库,是分析历史事件的好工具;%300 表示输出字段宽 300 字符避免截断,更多字段见 man sacct。

查询作业时长

squeue 只能看到运行中作业的时长;作业结束后要用 sacct:

$ sacct -j 22171 --format=JobID,JobName,State,Elapsed
JobID           JobName      State    Elapsed
------------ ---------- ---------- ----------
22171          example   COMPLETED   00:01:49

查看作业用了多少 GPU

运行中作业使用的 GPU 数量可从 scontrol show job -d 输出的 JOB_GRES=gpu: 条目解析。例如作业以 --gres=gpu:8 在 2 节点启动(共 16 GPU):

$ TOTAL_JOB_GPUS=$(scontrol show job -d $SLURM_JOBID | perl -ne 'm|JOB_GRES=gpu:(\d+)| && print $1')
$ echo $TOTAL_JOB_GPUS
16

若当前 shell 未设置 $SLURM_JOBID,替换为实际作业号即可。

查看队列与取消作业

squeue -u `whoami`          # 只看自己的作业
squeue -j JOBID             # 按作业号查看
squeue --partition=dev      # 查看某分区所有作业
scancel [jobid]             # 取消单个作业
scancel -u <userid>         # 取消某用户全部作业
scancel -u <userid> -p <partition>  # 取消某分区上某用户全部作业

小技巧:如果 salloc 的交互作业排期比你预期晚很多,试试取消后申请更短的时长——短时窗口往往更近。

常用别名

alias myjobs='squeue -u `whoami` -o "%.16i %9P %26j %.8T %.10M %.8l %.6D %.20S %R"'
alias groupjobs='squeue -u foo,bar,tar -o "%.16i %u %9P %26j %.8T %.10M %.8l %.6D %.20S %R"'
alias myjobs-pending="squeue -u `whoami` --start"
alias idle-nodes="sinfo -p prod -o '%A'"

作业脚本内获取分配信息

在 SLURM 脚本内部可通过环境变量与 scontrol 推导出常用信息:

export HOSTNAMES=$(scontrol show hostnames "$SLURM_JOB_NODELIST")
export NUM_NODES=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | wc -l)
export MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n 1)

这是仓库所有 launcher 脚本的公共基础:scontrol show hostnames 会把紧凑节点列表展开为逐行 hostname,再取 head -n 1 即得到 rank 0 节点,充当分布式训练的 master。

紧凑节点列表展开与 hostfile 生成

SLURM 工具常给出形如 node-[42,49-51] 的紧凑串,需展开为 node-42,node-49,node-50,node-51。专用工具:

$ scontrol show hostnames node-[42,49-51]
node-42
node-49
node-50
node-51

用途一:批量列出因"作业退出过慢"被 drain 的节点并逐个解除(配合 admin.md 的自动化脚本):

sinfo -R | grep "Kill task failed" | perl -lne '/(node-.*[\d\]]+)/ && print $1' | xargs -n1 scontrol show hostnames

用途二:为需要 hostfile 的多节点启动器生成 hostfile(依赖 SLURM_STEP_GPUS=0,1,2... 计算每节点 GPU 槽位数):

# usage:
# makehostfile > hostfile
# relies on SLURM_STEP_GPUS=0,1,2... to get how many gpu slots per node
function makehostfile() {
  local slots
  slots=$(perl -F, -lane 'print scalar @F' <<<"${SLURM_STEP_GPUS:-0}")
  scontrol show hostnames "$SLURM_JOB_NODELIST" | perl -slne 'print "$_ slots=$s"' -- -s="$slots"
}

作业数组:让下一轮训练自动衔接

训练需要"前一个作业一结束、下一个立刻开始",此时使用作业数组。先看只允许 10 个、且同时最多 1 个运行的例子:

sbatch --array=1-10%1 array-test.slurm

%1 限制该数组同时运行的任务数为 1;去掉 %1 则所有任务并行。也可写进脚本:

#SBATCH --array=1-10%1

演示脚本(2 分钟超时,观察串行衔接效果):

#!/bin/bash
#SBATCH --job-name=array-test
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1          # crucial - only 1 task per dist per node!
#SBATCH --cpus-per-task=1            # number of cores per tasks
#SBATCH --time 00:02:00              # maximum execution time (HH:MM:SS)
#SBATCH --output=%x-%j.out           # output file name
#SBATCH --error=%x-%j.out            # error file name (same to watch just one file)
#SBATCH --partition=dev

echo $SLURM_JOB_ID
echo "I am job ${SLURM_ARRAY_JOB_ID}_${SLURM_ARRAY_TASK_ID}"
date
sleep 10
date

$SLURM_ARRAY_JOB_ID 与 $SLURM_JOB_ID 相同,$SLURM_ARRAY_TASK_ID 是任务序号。查看运行情况:

$ squeue -u `whoami` -o "%.10i %9P %26j %.8T %.10M %.6D %.20S %R"
     JOBID PARTITION                       NAME    STATE       TIME  NODES           START_TIME NODELIST(REASON)
591970_[2-   dev             array-test  PENDING       0:00      1  2021-07-28T20:01:06 (JobArrayTaskLimit)

此时任务 2 正在运行。取消整个数组(用下划线前的作业号)或单个任务:

scancel 591970
scancel 591970_2

若日志文件希望包含数组编号,加 %A_%a:

#SBATCH --output=%x-%j.%A_%a.log

作业数组"列车":可暂停、可续跑,不丢排队年龄

SLURM 环境非常苛刻,一个小失误可能浪费数天排队时间。排队中的作业有"年龄(age)"概念,与项目优先级共同决定调度顺序;新作业年龄为 0,通常排在更早进入队列的作业之后。以下方案可以在需要修改脚本或切换脚本时保住年龄、不丢队:

  1. sbatch 一个长作业数组,如 --array=1-50%1;
  2. 脚本体内只有一行 source another-script.slurm——于是可在下一个作业开始前随意修改目标脚本或改软链指向另一个脚本;
  3. 需要暂停时不要取消数组,而是 scontrol hold,既不丢队也不执行;
  4. 准备好后 scontrol release——只有 hold 期间不计入年龄,此前的年龄全部保留。

示例(64 节点、8 GPU/节点、20 小时):

$ cat train-64n.slurm
#!/bin/bash
#SBATCH --job-name=tr8-104B
#SBATCH --nodes=64
#SBATCH --ntasks-per-node=1          # crucial - only 1 task per dist per node!
#SBATCH --cpus-per-task=96           # number of cores per tasks
#SBATCH --gres=gpu:8                 # number of gpus
#SBATCH --time 20:00:00              # maximum execution time (HH:MM:SS)
#SBATCH --output=%x-%j.out           # output file name
#SBATCH --partition=dev

source tr8-104B-64.slurm

启动:

sbatch --array=1-50%1 train-64.slurm

下一个作业运行前可随时编辑 tr8-104B-64.slurm;要中止当前轮就只杀当前任务 1557903_5(而非整个数组 1557903),下一轮会带着新脚本继续。此方案无需改动原始训练脚本,后者仍可独立启动。

暂停与恢复:

scontrol hold <jobid>
scontrol release <jobid>

<jobid> 可以是普通作业、作业数组 id 或数组 step id。此外,运行中作业的节点数、时间、硬件与分区不可改,但可修改数组中 pending 的任务:

scontrol update jobid=<desired_job_id> numnodes=<new number> partition=<new partition>

若有 sudo,还可以改当前作业的时间。

用 SLURM 模拟 crontab:自延续调度作业

SLURM 环境通常没有 crontab,必须自己模拟。方案以 $WORK/cron/ 为基目录(WORK 是你在 ~/.bash_profile 等处导出的环境变量),分三个子目录:

$ mkdir -p $WORK/cron/scheduler
$ mkdir -p $WORK/cron/cron.daily
$ mkdir -p $WORK/cron/cron.hourly

1. 自延续的调度器作业

把 cron-daily.slurm 与 cron-hourly.slurm 复制到 $WORK/cron/scheduler(编辑其中的账户与分区),然后启动:

$ cd $WORK/cron/scheduler
$ sbatch cron-hourly.slurm
$ sbatch cron-daily.slurm

cron-daily.slurm 的核心机制是先自我重启、再执行每日任务:

#SBATCH --job-name=cron-daily
#SBATCH --ntasks=1
#SBATCH --nodes=1
#SBATCH --hint=nomultithread
#SBATCH --time=0:30:00
#SBATCH --output=%x-%j.out
#SBATCH --partition=PARTITION     # edit me
#SBATCH --account=GROUP@PARTITION # edit me

# do not set -e - we must run all of it

cd $WORK/cron/scheduler

# ensure to restart self first
sbatch --begin=now+24hour cron-daily.slurm

# now launch any slurm scripts in cron.daily
cd $WORK/cron/cron.daily
for f in *.slurm; do
  sbatch "$f"
done

注意注释"不要 set -e"——必须跑完整个脚本。这些作业一旦启动便会自我延续,通常无需再操心,除非发生让 SLURM 丢失全部作业的事件。

2. 日/时级 cron 作业

需要每天跑一次的作业,放进 $WORK/cron/cron.daily。例如用 updatedb 重建 $WORK 文件索引:

$ cat $WORK/cron/cron.daily/mlocate-update.slurm
#!/bin/bash
#SBATCH --job-name=mlocate-update    # job name
#SBATCH --ntasks=1                   # number of MP tasks
#SBATCH --nodes=1
#SBATCH --hint=nomultithread         # we get physical cores not logical
#SBATCH --time=1:00:00               # maximum execution time (HH:MM:SS)
#SBATCH --output=%x-%j.out           # output file name
#SBATCH --partition=PARTITION     # edit me
#SBATCH --account=GROUP@PARTITION # edit me

set -e
date
echo "updating file index db"
/usr/bin/updatedb -o $WORK/lib/mlocate/work.db -U $WORK --require-visibility 0

之后快速检索:

/usr/bin/locate -d $WORK/lib/mlocate/work.db pattern

要停跑就把该作业移出目录即可。cron.hourly 同理。由于 SLURM 调度存在各种延迟,这套实现是近似定时(大约每小时/每天一次);需要精确时间可另写,但多数场景下本方案已足够。也可自行扩展出每 30 分钟、每 12 小时等变体。

3. 日志清理

每个 cron 启动器作业都会留下日志文件,日志虽小但可能堆积成千上万份,耗尽 inode。在日作业中加入:

find $WORK/cron -name "*.out" -mtime +7 -exec rm -f {} +

只删 7 天前的文件,保留近期日志便于诊断。

自延续作业的一般形式

与调度器同理,可构造独立的自我延续作业。例:每 1 小时重启自身的 watchdog:

#!/bin/bash
#SBATCH --job-name=watchdog
#SBATCH --ntasks=1
#SBATCH --nodes=1
#SBATCH --time=0:30:00
#SBATCH --output=%x-%j.out
#SBATCH --partition=PARTITION        # edit me

# ensure to restart self first 1h from now
RUN_FREQUENCY_IN_HOURS=1
sbatch --begin=now+${RUN_FREQUENCY_IN_HOURS}hour watchdog.slurm

... do the watchdog work here ...

启动一次:

sbatch watchdog.slurm

它会立刻把自己排到 1 小时后,再执行正常业务;无论业务成败,作业都会大约每小时重启一次(精度受调度开销与节点可用性影响)。注意:多数 SLURM 集群在昂贵 GPU 节点之外还提供便宜的纯 CPU 节点,凡不需要 GPU 的作业应选用 CPU-only 分区。

用户视角的分布式启动器实战

launchers 目录 提供了四份可直接套用的完整 SLURM 脚本,覆盖基于 torch.distributed 的软件(也可轻松适配其他分布式环境):

这些脚本都以 debug/torch-distributed-gpu-test.py 作为演示程序——它检查集群内所有 GPU 能否通过 NCCL 互通、能否分配显存,并打印 NUMA 亲和等有用信息。复制到本地即可:

cp ../../../debug/torch-distributed-gpu-test.py .

(前提是你 clone 了本仓库;换成任何你自己的程序也可以。)

通用脚本骨架(torchrun 版)

torchrun-launcher.slurm 的完整内容(已含注释):

#!/bin/bash

# this is a 2 node SLURM script using `torchrun` launcher
# Important: you will need to adapt setting where you see EDIT in the comments

#SBATCH --job-name=torchrun-launcher
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1          # crucial - only 1 task per node
#SBATCH --cpus-per-task=96           # EDIT this to how many cpu cores the node has
#SBATCH --gres=gpu:8                 # EDIT this if it's not 8-gpus per node
#SBATCH --time=0:10:00               # EDIT the desired runtime
#SBATCH --exclusive
#SBATCH --partition=xyz-cluster      # EDIT to the desired partition name
#SBATCH --output=%x-%j.out

echo "START TIME: $(date)"

# auto-fail on any errors in this script
set -eo pipefail

# logging script's variables/commands for future debug needs
set -x

# EDIT the conda evn and any startup scripts
# source /path/to/start-xxx-user # if you have something to preload before the job
# conda activate stas-xxx        # if you have conda env to activate

LOG_PATH="main_log.txt"

# EDIT if it's not 8-gpus per node
GPUS_PER_NODE=8
NNODES=$SLURM_NNODES

# define the node 0 hostname:port
MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
MASTER_PORT=6000

# note `\$SLURM_PROCID` we don't want it interpolated till `srun` since otherwise all nodes will get
# 0 and the launcher will hang
#
# same goes for `\$(hostname -s|tr -dc '0-9')` - we want it to interpolate at `srun` time
LAUNCHER="python -u -m torch.distributed.run \
    --nproc_per_node $GPUS_PER_NODE \
    --nnodes $NNODES \
    --node_rank \$SLURM_PROCID \
    --rdzv_endpoint $MASTER_ADDR:$MASTER_PORT \
    --rdzv_backend c10d \
    --max_restarts 0 \
    --role \$(hostname -s|tr -dc '0-9'): \
    --tee 3 \
    "

# EDIT the path+name of the python script and whatever args it needs
PROGRAM="torch-distributed-gpu-test.py"

export CMD="$LAUNCHER $PROGRAM"

echo $CMD

# EDIT if you want to redirect /tmp to /scratch (some local SSD path) since /tmp is tiny on compute nodes
# export TMPDIR=/scratch

# EDIT: useful for debug if needed
#
# to debug NCCL issues
# export NCCL_DEBUG=INFO
#
# to unravel async errors w/o the correct traceback - potentially makes everything very slower
# export CUDA_LAUNCH_BLOCKING=1
#
# to force crashing on nccl issues like hanging broadcast
# export TORCH_NCCL_ASYNC_ERROR_HANDLING=1

# srun error handling:
# --wait=60: wait 60 sec after the first task terminates before terminating all remaining tasks
# --kill-on-bad-exit=1: terminate a step if any task exits with a non-zero exit code
SRUN_ARGS=" \
    --wait=60 \
    --kill-on-bad-exit=1 \
    --jobid $SLURM_JOB_ID \
    "

# bash -c is needed for the delayed interpolation of env vars to work
srun $SRUN_ARGS bash -c "$CMD" 2>&1 | tee -a $LOG_PATH

echo "END TIME: $(date)"

关键点解读:

  • MASTER_ADDR 由 scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1 得到,即分配中第一个节点的 hostname;MASTER_PORT 手工固定(示例 6000),供 c10d rendezvous 使用。
  • \$SLURM_PROCID 与 \$(hostname -s|tr -dc '0-9') 之所以带反斜杠,是为了延迟到 srun 实际启动时才在每节点展开——若提前展开,所有节点都会拿到 0 导致启动器挂起。
  • --role $(hostname -s|tr -dc '0-9'): 与 --tee 3 组合,让 torchrun 自动为所有日志行加 [hostname:rank] 前缀(hostname 数字尾缀提取自短主机名)。
  • --max_restarts 0 关闭 torch elastic 的自动重启,避免训练意外重启掩盖问题。
  • --wait=60:首个任务退出后再等 60 秒才终止其余任务;--kill-on-bad-exit=1:任一任务非零退出即终止整个 step。
  • 输出用 2>&1 | tee -a $LOG_PATH 累积到 main_log.txt。

Accelerate 版差异

accelerate-launcher.slurm 与 torchrun 版几乎相同,差异仅在启动器段:需要先准备 ACCELERATE_CONFIG_FILE=accelerate.yaml,并额外计算 NUM_PROCESSES=$(($NNODES * $GPUS_PER_NODE)),然后:

LAUNCHER="python -u -m accelerate.commands.launch \
    --rdzv_conf "rdzv_backend=c10d,rdzv_endpoint=$MASTER_ADDR:$MASTER_PORT" \
    --config_file $ACCELERATE_CONFIG_FILE \
    --num_processes $NUM_PROCESSES \
    --num_machines $NNODES \
    --main_process_ip $MASTER_ADDR \
    --main_process_port $MASTER_PORT \
    --machine_rank \$SLURM_PROCID \
    --role \$(hostname -s|tr -dc '0-9'): --tee 3 \
    "

注意 --machine_rank \$SLURM_PROCID 同样是延迟展开。其余 SRUN_ARGS 与 srun 收尾完全一致。

Lightning 版差异

lightning-launcher.slurm 的头部参数与 torchrun 版不同:--ntasks-per-node=8(Lightning 自带分布式启动,SLURM 每个 task 对应一个进程),--cpus-per-task=12(节点总核数除以 GPU 数)。启动器就是普通 python -u——PTL(PyTorch Lightning)不需要专门的启动器:

LAUNCHER="python -u"
PROGRAM="torch-distributed-gpu-test.py"
export CMD="$LAUNCHER $PROGRAM"

其余 srun 收尾相同。

srun 原生版:手动补全 torch.distributed 环境变量

srun-launcher.slurm 直接用 srun 充当启动器,--ntasks-per-node=8 必须与每节点 GPU 数一致,--cpus-per-task=10 为每任务分配的核数。因为 torch.distributed 期望的环境变量不会自动齐全,需手动预置:

# we are preparing for torch.distributed programs so it wants:
# - MASTER_ADDR, MASTER_PORT, WORLD_SIZE - already known before `srun`
# - RANK, LOCAL_RANK - will set at `srun` command
export MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
export MASTER_PORT=6000
export WORLD_SIZE=$SLURM_NPROCS

srun 本身就是启动器,因此 LAUNCHER="python -u" 即可。最后一行与其他脚本不同,需要在 bash 内补上 RANK/LOCAL_RANK(同样延迟展开):

srun $SRUN_ARGS bash -c "RANK=\$SLURM_PROCID LOCAL_RANK=\$SLURM_LOCALID $CMD" 2>&1 | tee -a $LOG_PATH

注意 $SLURM_NPROCS 提供 world size;$SLURM_PROCID 是全局 rank、$SLURM_LOCALID 是节点内局部 id。

SLURM 专属的启动器陷阱

SLURM_PROCID 提前展开导致挂起

多节点场景下必须让 --machine_rank/--node_rank 在启动时刻才被插值:

"--machine_rank \$SLURM_PROCID"

若写成 "--machine_rank $SLURM_PROCID"(提前展开),启动器会挂起。最佳实践是把启动器与程序隔离,做成"只改 PROGRAM 变量"的模板(accelerate 版):

export MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
export MASTER_PORT=3333
ACCELERATE_CONFIG_FILE=path/to/accelerate.config.yaml # edit me
LAUNCHER="python -u -m accelerate.commands.launch \
    --rdzv_conf "rdzv_backend=c10d,rdzv_endpoint=$MASTER_ADDR:$MASTER_PORT" \
    --config_file $ACCELERATE_CONFIG_FILE \
    --main_process_ip $MASTER_ADDR \
    --main_process_port $MASTER_PORT \
    --machine_rank \$SLURM_PROCID \
    --role \$(hostname -s|tr -dc '0-9'): --tee 3 \
    "
PROGRAM="myprogram.py"

CMD="$LAUNCHER $PROGRAM"

SRUN_ARGS=" \
    --wait=60 \
    --kill-on-bad-exit=1 \
    --unbuffered \
    --jobid $SLURM_JOBID \
    "

srun $SRUN_ARGS bash -c "$CMD" 2>&1 | tee -a main_log.txt

torchrun 版同理:

GPUS_PER_NODE=8
NNODES=$SLURM_NNODES
export MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
export MASTER_PORT=3333
# note `\$SLURM_PROCID` and `\$(hostname ...)` - interpolate at `srun` time, not here
LAUNCHER="python -u -m torch.distributed.run \
    --nproc_per_node $GPUS_PER_NODE \
    --nnodes $NNODES \
    --node_rank \$SLURM_PROCID \
    --rdzv_endpoint $MASTER_ADDR:$MASTER_PORT \
    --rdzv_backend c10d \
    --max_restarts 0 \
    --role \$(hostname -s|tr -dc '0-9'): \
    --tee 3 \
    "

完整可运行示例见 launchers。

节点数不匹配:分布式训练最常见失败原因

PyTorch 启动器失败,往往因为 SLURM 节点数与启动器节点数不一致:

grep -ir nodes= tr123-test.slurm
#SBATCH --nodes=40
NNODES=64

这必然失败,两者必须一致。可在脚本中加入健全性检查:

#!/bin/bash
#SBATCH --job-name=test-mismatch
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1          # crucial - only 1 task per dist per node!
#SBATCH --cpus-per-task=96           # number of cores per tasks
#SBATCH --gres=gpu:8                 # number of gpus
#SBATCH --time 0:05:00               # maximum execution time (HH:MM:SS)
#SBATCH --output=%x-%j.out           # output file name
#SBATCH --partition=prod

[...]

NNODES=2

# sanity check for having NNODES and `#SBATCH --nodes` match, assuming you use NNODES variable
if [ "$NNODES" != "$SLURM_NNODES" ]; then
    echo "Misconfigured script: NNODES=$NNODES != SLURM_NNODES=$SLURM_NNODES"
    exit 1
fi

[...]

更稳妥的做法是直接从环境变量取值,永不出错:

#SBATCH --nodes=2
[...]
NNODES=$SLURM_NNODES

日志按节点拆分

不同节点日志分文件,可在 --output 中加入 %N(短主机名):

#SBATCH --output=%x-%j-%N.out

这样能快速定位是哪台节点行为异常(例如 GPU 损坏)。torchrun 在入口被 @record 装饰时能把首个失败归因到 host/rank,但未装饰脚本只有裸 traceback;按节点一份日志胜过一份汇总。

性能:影响训练吞吐的 SLURM 设置

srun 的 --cpus-per-task 需要显式指定

确保 srun 启动的程序拿到预期数量的 CPU 核。典型 ML 训练中每块 GPU 至少需要 1 核驱动进程 + 若干 DataLoader worker 核;8 GPU 且每 GPU 2 个 DataLoader worker 时,每节点至少 3*8=24 核。

--cpus-per-task 传给 sbatch/salloc 后,历史上 srun 会继承该值,但行为已变化。sbatch 手册原文:

NOTE: Beginning with 22.05, srun will not inherit the --cpus-per-task value requested by salloc or sbatch. It must be requested again with the call to srun or set with the SRUN_CPUS_PER_TASK environment variable if desired for the task(s).

即从 SLURM 22.05 系列起,srun 不再继承 --cpus-per-task(文档作者实测 22.05.09 仍是旧行为,但 23.x 系列已确定改变,变化可能发生在 22.05 后期)。若不处理,程序将只拿到 1 个核(除非站点改了 srun 默认值)。

用 os.sched_getaffinity(0) 可快速验证当前进程可见的核数。测试脚本:

$ cat test.slurm
#!/bin/bash
#SBATCH --job-name=test-cpu-cores-per-task
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=48   # adapt to your env if you have less than 48 cpu cores
#SBATCH --time=0:10:00
#SBATCH --partition=x        # adapt to your env to the right partition name
#SBATCH --output=%x-%j.out

srun python -c 'import os; print(f"visible cpu cores: {len(os.sched_getaffinity(0))}")'

输出 visible cpu cores: 48 则不受影响;输出 1 或小于 48 则受影响。修复方式二选一:

#SBATCH --cpus-per-task=48
[...]

srun --cpus-per-task=48 myprogram

或通过环境变量:

#SBATCH --cpus-per-task=48
[...]

export SRUN_CPUS_PER_TASK=48
srun myprogram

一劳永逸的自动化写法:

#SBATCH --cpus-per-task=48
[...]

export SRUN_CPUS_PER_TASK=$SLURM_CPUS_PER_TASK
srun myprogram

Hyper-Threads:开关都要实测

默认情况下,若 CPU 开启超线程(Hyper-Threads,HT;通用概念是 SMT),SLURM 会使用之;不想要则加 --hint=nomultithread。例如每节点 2 颗 CPU、每颗 24 核 2 线程,共 96 个超线程或 48 个物理核:

#SBATCH --cpus-per-task=96

或不用 HT:

#SBATCH --cpus-per-task=48
#SBATCH --hint=nomultithread

后一种方式每核分配一个线程,只有 48 个核可用。两种模式性能差异可能相当大,务必都试一下再定。案例:AWS p4 节点上启用 HT 使网络吞吐慢了 4 倍,因此该配置下刻意禁用了 HT;而另一些环境反之,无 HT 吞吐更差。检查是否启用 HT:

$ lscpu | grep Thread
Thread(s) per core: 2

值为 2 表示启用,为 1 表示未启用。

故障排查:坏节点的定位与排除

坏节点会反复打断训练——重启作业往往又命中同一批节点,因此必须能隔离并排除它们。

基础诊断:CUDA 可用性

先在全部节点上执行探针(必须经 srun 才能在每台节点跑,而非只在首节点):

srun --jobid $SLURM_JOBID bash -c 'python -c "import torch, socket; print(socket.gethostname(), torch.cuda.is_available())"'

或只报告失败节点:

python -c 'import torch, socket; torch.cuda.is_available() or print(f"Broken node: {socket.gethostname()}") '

GPU 无法分配显存也是常见问题,可改为小规模分配测试:

python -c "import torch; torch.ones(1000,1000).cuda()"

命令行引号易出问题,建议写成独立脚本:

$ cat << EOT >> test-nodes.py
#!/usr/bin/env python
import torch, socket
print(socket.gethostname(), torch.cuda.is_available())
EOT
$ chmod a+x ./test-nodes.py

驱动脚本(申请几分钟即可,SLURM 会更快放行):

#!/bin/bash
#SBATCH --job-name=test-nodes
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=1          # crucial - only 1 task per dist per node!
#SBATCH --cpus-per-task=96           # number of cores per tasks
#SBATCH --gres=gpu:8                 # number of gpus
#SBATCH --time 0:05:00               # maximum execution time (HH:MM:SS)
#SBATCH --output=%x-%j.out           # output file name
#SBATCH --partition=prod

source $six_ALL_CCFRWORK/start-prod   # edit me - site-specific env setup
srun --jobid $SLURM_JOBID ./test-nodes.py

检查日志中报 False 的节点,然后排除:

sbatch --exclude=hostname1,hostname2 ...

NCCL 断连检测

涉及分布式时,用以下脚本验证 NCCL 正常、barrier 可通(要点:先初始化 NCCL 进程组,再 dist.barrier(),并借助 printflock 解决多进程打印交错的经典问题):

#!/bin/bash
#SBATCH --job-name=test-nodes-nccl
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1          # crucial - only 1 task per dist per node!
#SBATCH --cpus-per-task=96           # number of cores per tasks
#SBATCH --gres=gpu:8                 # number of gpus
#SBATCH --time 0:05:00               # maximum execution time (HH:MM:SS)
#SBATCH --output=%x-%j.out           # output file name
#SBATCH --partition=prod

source $six_ALL_CCFRWORK/start-prod   # edit me - site-specific env setup

NNODES=2

GPUS_PER_NODE=4
MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
MASTER_PORT=6000

export LAUNCHER="python -u -m torch.distributed.run \
    --nproc_per_node $GPUS_PER_NODE \
    --nnodes $NNODES \
    --master_addr $MASTER_ADDR \
    --master_port $MASTER_PORT \
    "

export SCRIPT=test-nodes-nccl.py

cat << EOT > $SCRIPT
#!/usr/bin/env python
import torch.distributed as dist
import torch
import socket
import os
import fcntl

def printflock(*msgs):
    """ print """
    with open(__file__, "r") as fh:
        fcntl.flock(fh, fcntl.LOCK_EX)
        try:
            print(*msgs)
        finally:
            fcntl.flock(fh, fcntl.LOCK_UN)

local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
dist.init_process_group("nccl")
header = f"{socket.gethostname()}-{local_rank}"
try:
    dist.barrier()
    printflock(f"{header}: NCCL {torch.cuda.nccl.version()} is OK")
except:
    printflock(f"{header}: NCCL {torch.cuda.nccl.version()} is broken")
    raise
EOT

echo $LAUNCHER --node_rank $SLURM_PROCID $SCRIPT

srun --jobid $SLURM_JOBID bash -c '$LAUNCHER --node_rank $SLURM_PROCID $SCRIPT'

printflock 通过 fcntl.flock 串行化打印,仓库 debug/torch-distributed-gpu-test.py 也采用了同样的多进程打印保护,并提供了 printflock.py 工具。

GPU 显存分配检查

测试分配节点上每块 GPU 能否成功分配 77GiB(面向 80GB A100,需为 CUDA kernel 预留几个 GiB):

import torch, os
import time
import socket
hostname = socket.gethostname()

local_rank = int(os.environ["LOCAL_RANK"]);

gbs = 77
try:
    torch.ones((gbs*2**28)).cuda(local_rank).contiguous() # alloc on cpu, then move to gpu
    print(f"{local_rank} {hostname} is OK")
except:
    print(f"{local_rank} {hostname} failed to allocate {gbs}GiB of accelerator memory")
    pass

time.sleep(5)

网络故障:NCCL 连接被拒

坏节点的另一症状是其他节点连不上它,典型报错:

work = default_pg.barrier(opts=opts)
RuntimeError: NCCL error in: /opt/conda/conda-bld/pytorch_1616554793803/work/torch/lib/c10d/ProcessGroupNCCL.cpp:825, unhandled system error, NCCL version 2.7.8
ncclSystemError: System call (socket, malloc, munmap, etc) failed.

排查步骤:先在 srun 前加 export NCCL_DEBUG=INFO 重跑;日志中出现类似:

r11i6n2:486514:486651 [1] include/socket.h:403 NCCL WARN Connect to 10.148.3.247<56821> failed : Connection refused

对报错中的 IP 反查主机名:

nslookup 10.148.3.247
247.3.148.10.in-addr.arpa       name = r10i6n5.ib0.xa.idris.fr.

然后 sbatch --exclude=r10i6n5 ... 排除该节点,并向集群管理员报告。

用 py-spy 对所有节点进程抓栈

训练挂起时,可在所有节点上自动抓取 py-spy 栈。先申请分配并运行训练脚本(以 40 节点 200B 训练为例):

cd ~/prod/code/tr8b-104B/bigscience/train/tr11-200B-ml/

salloc --partition=prod --nodes=40 --ntasks-per-node=1 --cpus-per-task=96 --gres=gpu:8 --time 20:00:00

bash 200B-n40-bf16-mono.slurm

另一 shell 中查到 JOBID 后执行(关键是用 --gres=gpu:0,否则第二个 srun 会阻塞等待第一个释放 GPU):

srun --jobid=2180718 --gres=gpu:0 --nodes=40 --tasks-per-node=1 --output=trace-%N.out sh -c 'ps aux | grep python | egrep -v "grep|srun" | grep `whoami` | awk "{print \$2}" | xargs -I {} py-spy dump --native --pid {}' || echo "failed"

各节点 trace 写入当前目录的 trace-$nodename.out。注意:假设 py-spy 所在 conda 环境已在 ~/.bashrc 激活,否则需在命令前加载环境;用完后手动释放分配。同样的手法也可用于在正常运行中批量执行任意命令(如每进程 dump 显存用量)。

运维向:节点管理、配置重载与批量命令

admin.md 面向需要同时维护集群的用户。

批量在多个节点执行命令(pdsh)

先避免 ssh 对每个新节点弹出确认提示:

echo "Host *" >> ~/.ssh/config
echo "  StrictHostKeyChecking no" >> ~/.ssh/config

(需自行评估安全性;前提是你在集群内部、不会 ssh 出集群。)再安装 pdsh,即可批量执行:

$ PDSH_RCMD_TYPE=ssh pdsh -w node-[21,23-26] date
node-25: Sat Oct 14 02:10:01 UTC 2023
node-21: Sat Oct 14 02:10:02 UTC 2023
node-23: Sat Oct 14 02:10:02 UTC 2023
node-24: Sat Oct 14 02:10:02 UTC 2023
node-26: Sat Oct 14 02:10:02 UTC 2023

实用案例:作业取消后仍有 GPU 进程未退出,先列出占用 GPU 的进程号,再统一 kill -9:

nvidia-smi --query-compute-apps=pid --format=csv,noheader | sort | uniq
 PDSH_RCMD_TYPE=ssh pdsh -w node-[21,23-26]  "nvidia-smi --query-compute-apps=pid --format=csv,noheader | sort | uniq | xargs -n1 sudo kill -9"

SLURM 配置查看与热重载

sudo scontrol show config

配置文件在控制器节点的 /etc/slurm/slurm.conf。更新配置后热重载:

sudo scontrol reconfigure

节点状态变更

scontrol update 完成节点状态切换。解除 drain、恢复可用:

scontrol update nodename=node-5 state=idle

摘除节点(如维护):

scontrol update nodename=node-5 state=drain reason="maintenance"

自动解除"进程退出过慢"导致的 drain

作业取消后进程退出缓慢时,若 SLURM 配置为不无限等待,会自动把这类节点置为 drain。仓库提供一键脚本 undrain-good-nodes.sh,逻辑是:抓取因 "Kill task failed" 被 drain 的节点列表(sinfo -R | grep "Kill task failed"),展开节点名,逐节点用 nvidia-smi --query-compute-apps=pid 检查是否还有 GPU 进程占用;若有则先 sudo kill -9 再等 3 秒复检;确认干净后 scontrol update NodeName=$n State=idle 解除 drain。配合上文 pdsh 的批量命令能力,即可把"基本就绪却被误伤"的节点批量拉回 idle。

查看所有被 drain 节点及原因:

% sinfo -R -o "%50E %12U %19H %6t %N"

或简短的 sinfo -R。

安全重启节点

镜像更新后需安全重启节点,等待当前作业结束后再重启并拉回 idle:

scontrol reboot ASAP node-[1-64]

前置条件是在控制器节点的 /etc/slurm/slurm.conf 配置:

RebootProgram = "/sbin/reboot"

并 scontrol reconfigure 重载。

修改作业时限

运行中作业的新时限(例:2 天):

scontrol update JobID=$SLURM_JOB_ID TimeLimit=2-00:00:00

在原时限基础上追加(例:+10 小时):

scontrol update JobID=$SLURM_JOB_ID TimeLimit=+10:00:00

SLURM 异常排查

分析控制器日志:

sudo cat /var/log/slurm/slurmctld.log

可用于理解某节点为何提前取消作业或从集群被移除。

集群治理与配额:FairShare、kill-switch 与残留进程清理

FairShare 优先级

许多集群采用 FairShare:用得越多,获得的优先级越低,抢占机制下被抢占概率越高。查看分数:

sshare

示例输出:

Account                    User  RawShares  NormShares    RawUsage  EffectvUsage  FairShare
-------------------- ---------- ---------- ----------- ----------- ------------- ----------
root                                          0.000000   711506073      1.000000
 all                                     1    0.500000   711506073      1.000000
  all                      stas          1    0.022727    14106989      0.019827   0.288889

FairShare 大于 0.5 表示集群用量低于配额,小于 0.5 表示超用。该分数随时间衰减——长期低用量会让分数回升。查看指定用户:

sshare -u username

查看所有人并按分数排序(最关键——绝对分数无意义,重要的是相对他人的排名,比你高的用户作业更容易先被调度、更难被抢占):

sshare --all | sort -nk7 -r

除 FairShare 外,优先级通常还综合排队时长、作业规模、QOS、分区等因素,具体取决于管理员在 slurm.conf 的配置。

克服 SLURM 没有"作业组归属"的缺陷

SLURM 没有 Unix 式的组作业所有权:若队友启动了一个 10×20h 的作业数组后去度假,而你无 sudo,则无法停止出问题的作业。仓库给出的 workaround 是 kill switch:在框架内实现轮询某个文件系统路径,发现文件即自行退出(例如 Megatron-DeepSpeed 的实现)。用法:以 --kill-switch-path $WORK/tmp/training17-kill-switch 启动,需要停时执行:

touch $WORK/tmp/training17-kill-switch

程序下次轮询到即自愿退出(作业数组需等每个任务依次启动并退出)。停完别忘了删除:

rm $WORK/tmp/training17-kill-switch

局限:若作业挂死,永远轮询不到 kill-switch,只能联系管理员;部分情况下 PyTorch 分布式会在 10 分钟超时后自动退出,但并非总是有效。

跨节点清理僵尸进程

srun pkill python

一条命令杀掉各节点遗留的 python 进程。

小结

在 SLURM 上做大模型训练,核心是四件事:申请与复用分配(salloc/srun/--no-shell/--overlap/--jobid)、控制调度时机(--dependency、--begin、作业数组、scontrol hold/release 保年龄)、正确接入分布式启动器(torchrun / Accelerate / Lightning / 原生 srun 四种模板,重点是 \$SLURM_PROCID 延迟展开与 --ntasks-per-node 语义)、性能与故障隔离(--cpus-per-task 显式化、HT 实测、scontrol show hostnames 展开节点、坏节点排除)。配合 launchers 脚本 与 torch-distributed-gpu-test.py,即可在数小时内搭出可复现、可诊断的多节点训练流水线。更深入的分布式故障排查可继续阅读 debug 章节 与 network/comms 文档。

登录后查看全文
ml-engineering