- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 —— 提交只要五分钟,等待却要三天
- 坐标系 —— 分区、QoS、账户
- sbatch 脚本剖析
- 多节点训练 —— srun 与 rendezvous
- 数组作业与依赖链
- PENDING 的解剖学
- 死亡作业验尸 —— 以及为抢占做准备
- 该用 Slurm 还是 Kubernetes
- 结语 —— 调度器不是队列,而是一份契约
引言 —— 提交只要五分钟,等待却要三天
刚接触 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 张 GPU | srun 按 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_THREADS 和 num_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()}")
如果用容器跑,可以用 pyxis 和 enroot。截至 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 | 前置作业已失败,永远不会运行 | 取消并重新提交,放着不管会一直留在队列里 |
最容易被搞混的是 Resources 和 Priority 的区别。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 或 137 | sacct | 主机 RAM 超限,cgroup 杀掉了进程 | 调高 --mem,减少数据加载器的 worker/预取数量 |
torch.OutOfMemoryError 报错堆栈,State=FAILED | 日志文件 | GPU 显存超限 | 减小 micro batch,启用激活检查点,调高 ZeRO 阶段 |
State=TIMEOUT | sacct | --time 超时 | 检查检查点周期,改用续训链 |
State=NODE_FAIL | sacct | 节点硬件故障 | sinfo -R,排除该节点后重新提交 |
State=PREEMPTED | sacct | 被更高优先级的作业顶替 | 应用下面的重新入队模式 |
| 没有日志就消失了 | 检查日志路径 | --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 是持续维持所声明状态的编排系统。
| 维度 | Slurm | Kubernetes |
|---|---|---|
| 默认作业模型 | 有始有终的批处理作业 | 需要持续存活的工作负载 |
| 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,其实有一半内容就是在把这个前提落实成代码。