Slurm 作业调度与 GPU 拓扑感知调度深度工程实战:从 backfill 回填、gang scheduling 到 cgroup v2 隔离与 MPI 全链路解析

执行摘要:当训练集群从几十张卡涨到几千张卡时,决定吞吐量的往往不是单卡 FLOPS,而是「调度器每秒能把多少张卡有效地拼成作业」。Slurm 之所以在 HPC 与 AI 训练集群里统治了二十年,不是因为它的界面好用,而是因为它把三个互相冲突的目标压进了一个确定性的调度循环里:高利用率(回填碎片)、公平性(多因子优先级)、可预测性(预约与抢占)。本文从 slurmctld 的调度主循环出发,逐层拆解 TRES 资源模型、backfill 算法的时间切片、GPU 拓扑感知(NVLink/NVSwitch/PCIe 域)绑定、cgroup v2 隔离,以及 PMIx/MPI 启动链路上那些真正会在生产里咬人的细节。


一、架构:三个守护进程与一个无状态化难题

Slurm 的集群侧由三个角色构成:

组件职责状态
slurmctld中央调度器 + 资源账本,单主多备(HA 靠 StateSaveLocation + 共享存储)有状态,是全局决策点
slurmd计算节点代理,负责派生任务、cgroup confinement、GPU 绑定近乎无状态,向 ctld 注册
slurmdbd记账数据库守护,存储 TRES/QOS/Association 历史有状态(MySQL)

关键设计取舍:slurmctld 是单点决策者。它把整集群资源视图放在内存里(node_record_table_ptr、job_record_table_ptr、part_record_ptr),调度在一个持有全局锁的线程里串行跑:

// src/slurmctld/scheduler.c —— 主循环骨架(简化)
while (!shutdown) {
    slurm_mutex_lock(&sched_mutex);
    /* 1. 从 slurmd 的注册消息更新节点状态 */
    /* 2. 处理挂起请求、超时作业、截止时间到达的预约 */
    /* 3. 若距上次调度 > sched_interval(默认 60s)或有事件触发则调度 */
    if (time_since_last_run >= sched_interval || trigger_event) {
        _schedule();                    /* backfill + 常规调度 */
    }
    slurm_mutex_unlock(&sched_mutex);
    usleep(...);                        /* 让出锁,处理 RPC */
}

这里有个容易被误解的点:sched_interval 不是调度延迟。真正的延迟来自 slurmctld 的消息循环必须在持有锁时批量完成「节点状态收敛 + 调度 + 状态落盘」。在 2000 节点集群里,一次全量调度扫描 10 万级 job 记录会吃掉数百毫秒,因此生产上通常把 sched_interval 压到 10~30s,并开启 --enable-multiple-slurmd 之外的 sched/backfill 插件与 bf_min_age_reserve 来削峰。


二、资源模型:TRES 才是现代 Slurm 的地基

早期 Slurm 只有 CPU + 内存 + 节点三元组,GPU 只能靠 Gres 这个「通用资源」字符串糊上去。现在一切都统一到 TRES(Trackable RESource):cpu、mem、node、gres/gpu、gres/gpu:a100、license/xxx、billing。

# slurm.conf 片段:把 GPU 建模成可消耗的 TRES
GresTypes=gpu
AccountingStorageTRES=gres/gpu,gres/gpu:a100,gres/gpu:h100
NodeName=gpu-[01-64] Gres=gpu:a100:8 CPUs=128 RealMemory=515000 State=UNKNOWN
PartitionName=train Nodes=gpu-[01-64] Default=YES MaxTime=7-00:00:00 State=UP \
    OverSubscribe=NO TRESBillingWeights="CPU=1.0,Mem=0.25G,gres/gpu:a100=64.0"

注意 TRESBillingWeights:它把「资源」和「计费权重」解耦。这在做多代 GPU 混部(A100 + H100 同池)时至关重要——调度按物理资源分配,账单按算力权重结算,二者不必是 1:1。

作业侧请求通过 --gpus-per-node / --gres=gpu:a100:4 表达,ctld 在 _pick_best_nodes() 里做节点选择。这里的核心数据结构是 每个节点的 GPU 位图(bitstr_t):

/* 从节点可用 GPU 位图中挑出连续的 n 张,且优先落在同一 NVSwitch 域 */
static int _select_gpus_on_node(node_record_t *node_ptr, int need,
                                bitstr_t *usable, bitstr_t **out) {
    /* topology/tree 插件会预先把 GPU 按 switch 分组,
       位图顺序在此已经被重排成「拓扑亲和序」 */
    for (int i = 0; i + need <= bit_size(usable); i++) {
        if (bit_test_range(usable, i, i + need - 1)) {
            *out = bit_alloc(...); bit_set_range(*out, i, i + need - 1);
            return SLURM_SUCCESS;
        }
    }
    return SLURM_ERROR;   /* 该节点虽有空闲 GPU,但不满足连续性要求 */
}

工程观点:这张位图的排序策略,就是「拓扑感知调度」的全部秘密。如果位图按物理 PCI BDF 顺序排列,你拿到的是 4 张跨 NUMA、跨 PCIe Root Complex 的卡,All-Reduce 带宽腰斩;如果按 NVSwitch 域重排,同一个 8 卡域内优先连续分配,集合通信走 NVLink 而非 PCIe/IB。


三、调度核心:backfill 回填的两次扫描

Slurm 默认调度插件是 sched/backfill。它的哲学是:先维护一个大作业的「预约」,再用小作业回填碎片,同时保证回填不推迟任何更高优先级作业的开始时间。

算法分两趟:

第一趟(预约):按优先级从高到低遍历队列,为队首那个暂时无法满足的作业计算「最早可开始时间」(模拟已排队作业逐个完成的时间线),并把它钉在预约表里。

# 简化示意:时间线推进
current_time = now
for job in pending_sorted_by_priority:
    if 资源立即满足:
        start now; 占用资源直到 job 结束
    else:
        # 沿时间轴释放已运行作业的资源,找到第一个满足点
        t = _find_first_fit_time(job)         # ← 回填的时间锚点
        reserve(job, at=t)                    # 后续所有低优先级作业不能越过 t
        break                                  # 默认只保护队首(bf_max_job_start 可调)

第二趟(回填):对剩余队列,只要一个作业满足两个条件就立刻启动:

  1. 资源在当前时刻可满足;
  2. 它的运行不会让任何已预约作业的启动时间推迟(即它必须在预约点之前结束,受 bf_min_age_reserve、bf_max_job_test 限制)。
# 生产上最常被忽略的三个参数
SchedulerParameters=bf_interval=30,bf_window=1440,bf_max_job_test=100,bf_max_job_start=0
#                   ↑ 回填周期(s)  ↑ 回填窗口(min) ↑ 单次测试作业数 ↑ 0=只保护队首,N=保护前N个

这里有个真实踩坑:bf_window 默认 1440 分钟,但回填只对 MaxTime 不超过窗口的作业生效。一个 MaxTime=14-00 的两周训练作业永远不会被回填,它会一直堆在队首,把后面的短作业全部饿死——除非你显式把分区 MaxTime 收紧,或者用 MaxTime 分层(debug 分区 1 小时、normal 分区 2 天、long 分区 14 天)。

gang scheduling 则是另一个维度。OverSubscribe=FORCE:4 允许一个 CPU 被多个作业分时复用,sched/gang 插件用时间片轮转让同一个节点上的多个作业交替运行。对 GPU 训练它基本无用(显存无法 oversubscribe),但对推理混部和 CPU 侧数据预处理仍然有价值。


四、GPU 拓扑感知:从 topology 插件到 --gpu-bind

拓扑感知分三层,缺一层都会漏:

1) 节点内:GPU 亲和

# slurm.conf
TopologyPlugin=topology/tree

topology/tree 生成一棵交换机树(topology.conf 可手写,也可用 slurmctld 自动探测)。调度器在选择节点时,按最低公共祖先(LCA)代价评估候选节点集合,倾向于把同一作业的多个节点压在同一个 leaf switch 下。

2) 卡间:NVSwitch/NVLink 域 每个 slurmd 启动时通过 gres.conf 声明卡的拓扑属性:

# gres.conf(节点本地)
AutoDetect=nvml
Name=gpu Type=a100 File=/dev/nvidia[0-7] LINKS=/dev/nvidia0,1,2,3,4,5,6,7

LINKS 让 Slurm 知道这些卡之间是否直连。配合 --gpu-bind=closest 或 map_gpu,任务进程被绑到与所分 GPU 最近的 CPU socket:

srun --gpus-per-node=4 --gpu-bind=closest --cpu-bind=cores ./train.sh
# 展开后等价于:CUDA_VISIBLE_DEVICES=0,1,2,3,且进程绑在 GPU0-3 所属的 NUMA 核上

3) NUMA 与内存 --hint=nomultithread 关掉超线程(训练任务几乎总是受益),--mem-bind=local 保证分配的内存来自本地 NUMA 节点。生产上更可靠的做法是把这些写进 cli_filter 插件,强制所有作业带上,避免用户忘记。

实战数据参考:在一个 8×A100 NVSwitch 节点的集群上,同一 ResNet-50 分布式作业,跨 NVSwitch 域分配 4 卡 vs 域内连续 4 卡,All-Reduce 有效带宽差异通常在 2~3 倍量级;而跨 NUMA 未绑定 CPU 时,数据加载线程与 GPU 拷贝争抢 QPI/UPI,迭代抖动(P99 step time)可能放大 40% 以上。具体数字依赖硬件代际,但方向是稳定的。


五、隔离:cgroup/v2 与 confinement 的硬边界

Slurm 用 proctrack/cgroup + task/cgroup 把作业钉在 cgroup 里。GPU 隔离尤其微妙——Linux 的设备 cgroup 只能控制「能不能看到 /dev/nvidiaN」,无法限制显存算力,所以真正生效的是两件事:

# cgroup.conf
CgroupPlugin=cgroup/v2
ConstrainCores=yes
ConstrainRAMSpace=yes
ConstrainDevices=yes      # ← 关键:只把分到的 GPU 设备节点放进作业 cgroup
ConstrainSwapSpace=yes
AllowedDevices=/dev/nvidiactl,/dev/nvidia-uvm

ConstrainDevices=yes 通过 cgroup v2 的 devices 控制器(v1 时代是 devices cgroup,v2 需 cgroup.subtree_control 与 eBPF 程序配合)把未分配的 /dev/nvidia* 从作业命名空间中摘掉,再配合 CUDA_VISIBLE_DEVICES 做双保险。

为什么必须双保险:CUDA 运行时在初始化时会枚举所有可见 GPU,如果只靠环境变量而设备节点仍在,一个 cudaMalloc 越界或第三方库的错误调用就能踩到别人的卡上,表现为显存 OOM 报错出现在「没跑任务」的 GPU 上——这是共享集群最常见的幽灵故障。真正的防线是设备 cgroup。


六、MPI / PMIx:启动链路上的隐藏开销

分布式训练作业 99% 走 srun 直接启动(Slurm 自己就是进程管理器),但 PyTorch DDP / DeepSpeed 需要 bootstrap 来交换 MASTER_ADDR/RANK/WORLD_SIZE。两条路:

路 A:让 Slurm 注入环境变量(推荐)

#!/bin/bash
#SBATCH --job-name=ddp-train
#SBATCH --nodes=4 --ntasks-per-node=8 --gpus-per-node=8
#SBATCH --partition=train --time=2-00:00:00

export MASTER_ADDR=$(scontrol show hostnames "$SLURM_NODELIST" | head -n1)
export MASTER_PORT=29500
export WORLD_SIZE=$(( SLURM_NNODES * SLURM_NTASKS_PER_NODE ))

srun --gpu-bind=closest --cpu-bind=cores \
     torchrun --nnodes=$SLURM_NNODES --nproc_per_node=8 \
              --rdzv_backend=c10d --rdzv_endpoint=$MASTER_ADDR:$MASTER_PORT \
              train.py

关键点:srun 已经负责跨节点进程派生与 PMIx 交换,所以 torchrun 的 rendezvous 用 c10d 即可,别再叠一层 mpirun(双重 PMI 会引入启动风暴)。

路 B:PMIx 深度集成(HPC 传统栈)

srun --mpi=pmix_v4 ./mpi_app        # Slurm 24.x 起 PMIx 是默认路径

slurmd 在每个 step 启动时通过 PMIx_server 把 rank 映射、本地拓扑、网络端点一次性下发给所有进程。这里最常见的坑是 step 启动的 O(N²) 扇出:srun 默认树形派生(TreeWidth),若 TreeWidth 设为 1 就是线性扇出,256 节点的作业启动可能从 3 秒劣化到 40 秒。

scontrol setdebugflags +SCHEDULING,+BACKFILL      # 打开调度调试日志
sacct -j 12345 --format=JobID,Elapsed,TotalCPU,AllocTRES,MaxRSS,ExitCode
sstat  -j 12345.step0 --format=AveCPU,MaxRSS,MaxVMSize
sreport cluster utilization Start=2026-09-01 End=2026-09-30   # 集群利用率审计

七、生产落地清单

  1. 分区分层,不要一个大池子:debug(≤1h) / normal(≤2d) / long(≤14d)。回填只对短作业友好,分层是吞吐量的第一杠杆。
  2. 给 GPU 建立 TRES 计费权重,让公平份额算的是「算力」而非「卡数」,否则 A100 和 H100 用户会互相觉得不公平。
  3. 强制拓扑绑定:写 cli_filter 插件或 job_submit/lua,把 --gpu-bind=closest --hint=nomultithread 注入所有 GPU 作业。靠用户自觉等于没做。
  4. 开 ConstrainDevices,并用 nvidia-smi -L 在作业内自检可见卡数是否等于请求数,作为冒烟断言。
  5. 监控三个指标:sdiag 里的 Backfilling since boot 计数(为 0 说明回填没在工作)、队列 P95 等待时间、GPU 分配率(AllocTRES 求和 / 物理卡数)。第三项长期低于 70% 说明调度策略或分区切分有问题,而不是卡不够。
  6. 预约用于可预期的大事件:scontrol create reservation StartTime=now+2days Duration=12:00:00 Nodes=gpu-[01-32] Flags=maint,ignore_jobs。注意 ignore_jobs 会把超时作业强制回收,提前通知用户。

八、结论

Slurm 的工程内核可以概括成三句话:

  • 资源建模靠 TRES:把 GPU、license、带宽都变成可计数、可计费、可记账的一等公民,调度器才有一致的决策语言;
  • 吞吐靠 backfill:用「保护队首 + 回填碎片」的两趟扫描,在公平性和利用率之间取到一个可证明的折中(回填永不推迟高优先级作业);
  • 性能靠拓扑:位图排序、--gpu-bind=closest、cgroup 设备隔离,这三件事决定了多卡作业到底是在跑 NVLink 还是在跑 PCIe。

一句话总结:Slurm 不是「把作业排队」的系统,而是「把资源位图按拓扑剪裁后拼装」的系统。理解到这一层,调参才有方向——你优化的从来不是队列长度,而是那张位图被切碎的速度。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部