Skip to content
Published on

用 Slurm 跑 GPU 集群 —— 比提交更重要的是知道为什么不跑

分享
Authors

引言 —— 提交只要五分钟,等待却要三天

刚接触 Slurm 的人学到的通常就是一行 sbatch 命令。可实际上真正耗费时间的从来不是提交这一步,而是:提交上去的作业在 PENDING 状态卡了三天却不知道原因,跑了八个小时的作业毫无提示地消失,或者续训之后损失从一个莫名其妙的值开始。

所以本文有一半篇幅花在失败诊断上。前半部分是坐标系和脚本结构,后半部分是"验尸"。

先把核实基准写在前面。截至 2026 年 8 月 2 日,根据 SchedMD 仓库的 release tag,当前稳定系列是 26.05,最新 tag 是 26.05.2,发布于 2026 年 7 月 14 日。上一个系列 25.11 同一天也发布了 25.11.7,仍在维护中。每个集群安装的版本不同,所以在套用下面的内容之前,先用 sinfo --version 确认实际版本,再去看对应版本的官方文档比较稳妥。选项的行为在小版本之间发生变化的先例已经出现过好几次。

坐标系 —— 分区、QoS、账户

提交作业之前需要了解三个轴。不了解这三个,你永远解读不了 PENDING 的原因。

  • 分区(partition) 是节点的集合。H100 节点和 A100 节点位于不同的分区,每个分区的最大运行时长和可访问账户都不一样。
  • 账户(account) 是记录资源用量的会计单位。它构成一棵类似组织架构的树,公平共享(fair-share)优先级就是沿着这棵树计算的。
  • QoS 是贴在作业上的策略标签。优先级权重、同一用户可同时运行的作业数上限、是否可被抢占、资源用量总上限,都定义在这里。
# 我能用的分区及其限制
sinfo -o "%20P %5a %10l %6D %10T %N"

# 我的账户与 QoS 的关联关系
sacctmgr show assoc user="$USER" format=Account,Partition,QOS,GrpTRES,MaxJobs

# 按 QoS 查看策略
sacctmgr show qos format=Name,Priority,MaxTRESPU%30,MaxJobsPU,Flags%30

# 某个分区里有多少张什么型号的 GPU
sinfo -p gpu-h100 -o "%20N %10c %10m %30G"

最后一条命令的最后一列就是 GRES 定义。如果看到类似 gpu:h100:8 的字符串,说明类型名是 h100,而这个名字在不同集群之间是不一样的。直接照抄文档里的示例,大多数时候就是栽在这个名字上。

sbatch 脚本剖析

提交一个训练作业的最小形式如下。每一行为什么存在,下面会解释。

#!/bin/bash
#SBATCH --job-name=llm-pretrain
#SBATCH --account=research
#SBATCH --partition=gpu-h100
#SBATCH --qos=normal
#SBATCH --nodes=4
#SBATCH --ntasks-per-node=1
#SBATCH --gpus-per-node=8
#SBATCH --cpus-per-task=64
#SBATCH --mem=0
#SBATCH --time=24:00:00
#SBATCH --exclusive
#SBATCH --output=/scratch/%u/logs/%x-%j.out
#SBATCH --error=/scratch/%u/logs/%x-%j.err

set -euo pipefail
echo "job=$SLURM_JOB_ID nodes=$SLURM_JOB_NODELIST start=$(date -Is)"
srun --cpus-per-task="$SLURM_CPUS_PER_TASK" python -c "import torch; print(torch.cuda.device_count())"

申请 GPU 有三种方式,不能混用。

flag含义什么时候用
--gres=gpu:8每节点 8 张 GPU(指定型号时写作 gpu:h100:8)旧脚本写法,依然有效
--gpus-per-node=8每节点 8 张 GPU每节点用一个任务启动 torchrun 时
--gpus-per-task=1每个任务 1 张 GPUsrun 按 rank 逐个启动进程时

--gpus-per-task 时,Slurm 会隐式地做 GPU 绑定,所以每个任务只能看到自己的那一张 GPU。这个行为有时方便,有时碍事。比如你本想每节点一个任务启动 torchrun,却误用了 --gpus-per-task=1,那个进程就只能看到一张 GPU,torch.cuda.device_count() 会返回 1。

CPU 和内存也必须一起申请。数据加载器(data loader)的 worker 要用 CPU,分词后的数据要放进主机内存。--mem=0 表示申请节点的全部内存,常用于独占整个节点的训练作业。每张 GPU 能分到多少 CPU 核心,决定了数据加载器的 worker 数量,所以应该把 --cpus-per-task 除以 GPU 数得到的值,作为 OMP_NUM_THREADSnum_workers 的基准。

这里有一个坑。srun 是否继承 sbatch 的 CPU 申请,在不同 Slurm 版本之间是不一样的。 在 22.05 版本里,srun 不再继承这个值,转而引入了单独的环境变量 SRUN_CPUS_PER_TASK,之后的版本又对这个行为做了调整。因为在不同集群版本上的表现可能不同,像上面脚本那样,在 srun 里显式地再传一次 --cpus-per-task,是不受版本影响的安全做法。这个值如果掉到 1,数据加载器就会被绑死在单核上,GPU 利用率会停滞在 30% 左右。

绑定情况也值得检查。一个 8-GPU 节点通常分属两个 NUMA 域,GPU 0-3 挂在插槽 0 上,4-7 挂在插槽 1 上。如果进程被分配到了对面插槽的核心上,主机到 GPU 的数据传输带宽就会下降。

# 节点内 GPU 互联拓扑与 NUMA 布局
nvidia-smi topo -m

# 实际绑定到了哪些核心
srun --cpu-bind=verbose,cores --gpu-bind=verbose,closest hostname

多节点训练 —— srun 与 rendezvous

模式只有两种。

模式 A:每节点一个 torchrun。 srun 每个节点只启动一个任务,torchrun 在其内部按 GPU 数量拉起多个进程。rendezvous 由 torchrun 的 c10d backend 负责。

#!/bin/bash
#SBATCH --job-name=pretrain-7b
#SBATCH --partition=gpu-h100
#SBATCH --account=research
#SBATCH --nodes=8
#SBATCH --ntasks-per-node=1
#SBATCH --gpus-per-node=8
#SBATCH --cpus-per-task=96
#SBATCH --mem=0
#SBATCH --exclusive
#SBATCH --time=48:00:00
#SBATCH --output=/scratch/%u/logs/%x-%j.out

set -euo pipefail

# 把第一个节点当作 rendezvous 点
MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)
# 端口号从作业编号推导,避免与同时运行的其他作业冲突
MASTER_PORT=$(( 20000 + SLURM_JOB_ID % 20000 ))
export MASTER_ADDR MASTER_PORT
export OMP_NUM_THREADS=$(( SLURM_CPUS_PER_TASK / 8 ))
export NCCL_DEBUG=WARN
export TORCH_NCCL_ASYNC_ERROR_HANDLING=1

srun --cpus-per-task="$SLURM_CPUS_PER_TASK" --kill-on-bad-exit=1 \
  torchrun \
    --nnodes="$SLURM_NNODES" \
    --nproc-per-node=8 \
    --rdzv-backend=c10d \
    --rdzv-endpoint="$MASTER_ADDR:$MASTER_PORT" \
    --rdzv-id="$SLURM_JOB_ID" \
    train.py --config configs/7b.yaml

模式 B:srun 按 rank 逐个启动进程。 不用 torchrun,Slurm 的任务本身就直接是 rank。这样少了一层进程管理,日志更干净,而且 Slurm 能直接看到哪个 rank 挂了。

#SBATCH --nodes=8
#SBATCH --ntasks-per-node=8
#SBATCH --gpus-per-task=1
#SBATCH --cpus-per-task=12

srun python train.py --config configs/7b.yaml
# train.py 开头 —— 把 Slurm 的环境变量搬到分布式初始化所需的变量上
import os
import torch
import torch.distributed as dist

if "SLURM_PROCID" in os.environ and "RANK" not in os.environ:
    os.environ["RANK"] = os.environ["SLURM_PROCID"]
    os.environ["WORLD_SIZE"] = os.environ["SLURM_NTASKS"]
    os.environ["LOCAL_RANK"] = os.environ["SLURM_LOCALID"]

torch.cuda.set_device(int(os.environ["LOCAL_RANK"]))
dist.init_process_group(backend="nccl")
print(f"rank {dist.get_rank()}/{dist.get_world_size()} "
      f"on {os.uname().nodename} gpu {torch.cuda.current_device()}")

如果用容器跑,可以用 pyxisenroot。截至 2026 年 8 月 2 日核实的版本是 pyxis v0.24.0(2026-05-12)、enroot v4.2.1(2026-06-09)。装好之后,srun 就会多出容器相关的选项。

srun --container-image=/scratch/images/train-2026-08.sqsh \
     --container-mounts=/scratch:/scratch,/data:/data:ro \
     --container-workdir=/workspace \
     python train.py

rendezvous 常出的问题有三个。第一,如果把 MASTER_PORT 写死成固定值,一旦同一节点上跑了别的作业就会端口冲突,应该像上面那样从作业编号推导出来。第二,如果节点上有多个网卡(管理网、存储网、高速网),NCCL 可能会选到慢的那一个。用 NCCL_SOCKET_IFNAME 明确指定,再用 NCCL_DEBUG=INFO 确认实际选中的是哪一个。第三,如果没有 --kill-on-bad-exit=1,一个 rank 挂掉之后,其余的会一直存活到 NCCL 超时(大约 30 分钟),白白烧掉这段时间的 GPU 资源。

数组作业与依赖链

超参数扫描要以数组作业(array job)的形式提交。它不是单个作业,而是一批带编号的作业,调度器会自动把它们填进空出来的位置。

#!/bin/bash
#SBATCH --job-name=lr-sweep
#SBATCH --array=0-11%4          # 12 个作业,同时最多跑 4 个
#SBATCH --gpus-per-node=1
#SBATCH --cpus-per-task=8
#SBATCH --time=02:00:00
#SBATCH --output=/scratch/%u/logs/%x-%A_%a.out

LRS=(1e-5 2e-5 5e-5 1e-4)
RANKS=(8 16 32)
lr=${LRS[$(( SLURM_ARRAY_TASK_ID % 4 ))]}
rank=${RANKS[$(( SLURM_ARRAY_TASK_ID / 4 ))]}

echo "task=$SLURM_ARRAY_TASK_ID lr=$lr rank=$rank"
srun python finetune.py --lr "$lr" --lora-rank "$rank" \
     --run-name "sweep-$SLURM_ARRAY_JOB_ID-$SLURM_ARRAY_TASK_ID"

日志文件名里的 %A 是数组作业的父编号,%a 是索引号。这两个如果都不写,12 个作业就会互相覆盖着写进同一个文件。

长时间的预训练因为时间上限的限制,无法一次跑完,要用依赖关系串起来。

# 把五段 24 小时的训练串联起来。每一段都要等前一段成功后才开始
prev=""
for i in $(seq 1 5); do
  if [ -z "$prev" ]; then
    prev=$(sbatch --parsable pretrain.sbatch)
  else
    prev=$(sbatch --parsable --dependency="afterok:$prev" pretrain.sbatch)
  fi
  echo "segment $i -> job $prev"
done

afterok 只有在前一个作业成功结束时才会继续。如果前面某一段失败,后面的作业会全部停在 DependencyNeverSatisfied 状态,不会出现没注意到失败、白白空转好几天的情况。反过来,如果希望即便因为抢占而中断也要继续跑下去,就用 afterany。训练脚本必须写成能自动找到最新的检查点并续训,这条依赖链才真正有意义。

PENDING 的解剖学

squeue 的最后一列就是原因,这是诊断的起点。

squeue -u "$USER" -o "%.10i %.12P %.20j %.8T %.10M %.6D %R"
squeue -j 123456 --start          # 预计开始时间
sprio -j 123456 -l                # 按优先级分量拆解出的分数
scontrol show job 123456          # 完整的请求资源与原因字符串
sinfo -R                          # down/drain 状态的节点及原因
REASON实际含义下一步该做什么
Resources请求的资源现在没有空闲squeue --start 查看预计开始时间,属于正常等待
Priority前面有优先级更高的作业sprio -l 查看分量构成,可能是账户公平共享的问题
QOSMaxJobsPerUserLimit触及了该 QoS 下单用户并发上限减少正在运行的作业,或换一个 QoS
AssocGrpGRESRunMinutes账户级别的 GPU 时长总额已耗尽找管理员沟通,等待不会解决
ReqNodeNotAvail请求的节点处于 down 或已被预定sinfo -R 查看节点状态
Reservation预定窗口还没开始scontrol show res 查看开始时间
PartitionTimeLimit--time 超过了该分区的最大值缩短请求时长或更换分区
Dependency前置作业尚未完成scontrol show job 确认是哪个作业
DependencyNeverSatisfied前置作业已失败,永远不会运行取消并重新提交,放着不管会一直留在队列里

最容易被搞混的是 ResourcesPriority 的区别。Resources 是"现在没有空位",时间一到就会解决;Priority 是"有空位了但还没轮到你",需要账户用量下降或者前面的作业结束。AssocGrpGRESRunMinutes 这一类,不管等多久都不会自己解决,一旦看到这个原因,应该立刻联系管理员。

缩小请求规模,往往能让作业跑得快得多。与其一口气申请 8 个节点跑 24 小时,不如申请 4 个节点跑 12 小时、分两次申请,这样更容易被回填调度(backfill)选中、提前运行。把 --time 写得接近实际所需时长,是回填能生效的前提。习惯性地填写最大值,只会让自己吃亏。

死亡作业验尸 —— 以及为抢占做准备

作业消失时,该看三个地方:sacct、日志文件,以及节点本身。

sacct -j 123456 --format=JobID,JobName%20,State%20,ExitCode,Elapsed,MaxRSS,ReqTRES%40,NodeList%20
sacct -j 123456 --format=JobID,State,DerivedExitCode,Comment%40
seff 123456        # 如果装了 slurm-contribs,会给出一份摘要

主机内存 OOM 和 GPU OOM 是完全不同的两码事。 分不清这一点,就会去调错误的参数。

症状在哪里能看到原因处理方式
State=OUT_OF_MEMORY,ExitCode 0:125 或 137sacct主机 RAM 超限,cgroup 杀掉了进程调高 --mem,减少数据加载器的 worker/预取数量
torch.OutOfMemoryError 报错堆栈,State=FAILED日志文件GPU 显存超限减小 micro batch,启用激活检查点,调高 ZeRO 阶段
State=TIMEOUTsacct--time 超时检查检查点周期,改用续训链
State=NODE_FAILsacct节点硬件故障sinfo -R,排除该节点后重新提交
State=PREEMPTEDsacct被更高优先级的作业顶替应用下面的重新入队模式
没有日志就消失了检查日志路径--output 目录不存在或权限不足提前 mkdir -p 好这个路径

最后一行出奇地常见。Slurm 打不开日志文件时会直接把作业标记为失败,但在用户看来就是"什么都没发生"。提交之前,先把 --output 路径对应的目录建好。

如果怀疑是节点本身的问题,就看 GPU 那一侧。内核日志里的 Xid 错误和被重映射的内存行,是硬件故障的一线指标。

srun -w gpu-node-042 nvidia-smi --query-gpu=index,name,ecc.errors.uncorrected.volatile.total --format=csv
srun -w gpu-node-042 nvidia-smi --query-remapped-rows=gpu_bus_id,remapped_rows.pending,remapped_rows.failure --format=csv
srun -w gpu-node-042 bash -c 'dmesg -T | grep -i xid | tail -20'

为抢占做准备应该是训练作业的默认设计。用可抢占的 QoS 能大幅缩短等待时间,代价是随时可能被顶掉。Slurm 会在终止前先发一个信号,你要做的就是在那段时间里保存检查点,然后自己把作业重新入队。

#SBATCH --signal=B:USR1@180     # 终止前 180 秒向批处理 shell 发送 USR1
#SBATCH --requeue               # 允许重新入队

CKPT_DIR=/scratch/$USER/ckpt/$SLURM_JOB_NAME
mkdir -p "$CKPT_DIR"

on_preempt() {
  echo "[$(date -Is)] 收到 USR1,请求保存检查点。"
  touch "$CKPT_DIR/SAVE_AND_EXIT"     # 训练循环每一步都会检查这个文件
  wait "$SRUN_PID"                    # 等待保存完成
  echo "[$(date -Is)] 正在重新入队。"
  scontrol requeue "$SLURM_JOB_ID"
  exit 0
}
trap on_preempt USR1

srun --cpus-per-task="$SLURM_CPUS_PER_TASK" python train.py --ckpt-dir "$CKPT_DIR" &
SRUN_PID=$!
wait "$SRUN_PID"

--signal 里的 B: 前缀很关键。没有它,信号会发给任务而不是批处理 shell,shell 里的 trap 就不会被触发。而且 srun 必须放到后台运行、再用 wait 等待,trap 才能立刻响应;如果放在前台运行,信号处理会被推迟到 srun 结束之后。

预留多长的缓冲时间,取决于保存检查点要花多久。如果把一个 70B 模型的分布式检查点写到并行文件系统需要 90 秒,那 180 秒就相当紧张。先实际测一次,然后把那个数字翻倍。 25.11 系列的发布说明里提到新增了一种在节点故障时自动重新入队的模式,但具体行为我没有核实,请在你实际使用的版本的 release notes 里确认。

该用 Slurm 还是 Kubernetes

两者都能跑容器,都能分配 GPU,但设计前提不同。Slurm 是把有限资源按队列公平分配的批处理调度器,Kubernetes 是持续维持所声明状态的编排系统

维度SlurmKubernetes
默认作业模型有始有终的批处理作业需要持续存活的工作负载
gang scheduling内置。能一次性拿下 8 个节点默认调度器不支持,需要 Kueue 或 Volcano
排队与公平共享靠账户树和 QoS,支持成熟需要额外组件来补足
抢占策略内置精细的策略可通过优先级类实现,但性质不同
拓扑感知调度内置需要单独配置和插件
服务化、自动伸缩、滚动发布不在其范围内这正是它的本行
运维人员的学习曲线需要 HPC 运维经验需要云原生经验

实务判断大致可以这样归纳:以大规模预训练和长批处理作业为主,用 Slurm;以推理服务和微服务为主,用 Kubernetes。 很多组织两者都需要,所以出现了一批连接两边的项目。SchedMD 的 Slinky 是一个在 Kubernetes 之上运行 Slurm 的 operator;反方向则有 Kueue(截至 2026-07-22 为 v0.19.0)和 Volcano(截至 2026-07-30 为 v1.15.1),它们给 Kubernetes 补上了排队和 gang scheduling 能力。

如果是新引入,判断标准不该是一张功能对比表,而是谁来负责运维。如果 HPC 团队已经在运维 Slurm 集群,把训练负载挂上去无疑是更快的路径;如果平台团队只熟悉 Kubernetes,那么新引入 Slurm 的成本,会超过批处理调度带来的收益。

结语 —— 调度器不是队列,而是一份契约

用好 Slurm,靠的不是记住多少个 flag,而是准确写清楚"我请求的资源"和"集群能给出的资源"之间的这份契约。老老实实地写出所需时长,就更容易被回填提前跑起来;用可抢占的 QoS 同时靠检查点做好准备,等待时间就会缩短;能按代码读懂 PENDING 的原因,就不会再莫名其妙地等上三天。

而且,训练脚本必须建立在"随时可能死掉、随时需要续训"这个前提之上来写。下一篇要讲的 LLM Ops,其实有一半内容就是在把这个前提落实成代码。