What Slurm is.
Before the commands and the configuration knobs, the substrate. Slurm is a workload manager: it takes a request for resources, decides when and where to run it, makes the resources available to the program, and accounts for what happened. Understanding what Slurm is — and what it is not — sets the frame for everything else.
§01What Slurm is, and why it dominates HPC
SLURM — Simple Linux Utility for Resource Management — began at Lawrence Livermore National Laboratory in 2002 as a lightweight replacement for proprietary HPC batch systems. It is now maintained primarily by SchedMD and has become the de facto standard for HPC and AI supercomputers. Of the TOP500 supercomputers, the large majority run Slurm. Every major sovereign-AI cluster, most academic and national-lab HPC systems, and many private AI labs use it as their workload manager.
What a workload manager does
A workload manager sits between users (who have programs to run) and the hardware (which is finite). Its job has four parts:
- Accept a description of what resources a job needs and what it should run.
- Schedule — decide when the job should run and on which nodes.
- Execute — launch the job's processes on the allocated nodes, with the right environment, isolation, and resource constraints.
- Account — track what ran, on what, for how long, by whom.
Everything else Slurm does — preemption, fair-share, QoS, reservations, topology, GRES — is in service of one of those four.
Why HPC adopted Slurm, not Kubernetes
This is the historical question the role keeps confronting. Slurm and Kubernetes are both schedulers; they grew up serving different worlds.
| Concern | HPC world (Slurm) |
|---|---|
| Workload shape | Long-running batch jobs that need all their resources at once |
| Resource accounting | Hourly billing of GPU/CPU-hours against budgeted allocations |
| Communication | Tight MPI/NCCL, latency-sensitive, all-or-nothing scaling |
| Failure model | Whole-job failure on node loss; restart from checkpoint |
| User base | Few hundred to few thousand researchers, well-known |
| Operational style | Bare-metal, hand-tuned, performance-first |
Kubernetes grew up serving web services: many independent containers, elastic scaling, partial failures absorbed by replicas, anonymous traffic, ops-as-platform. The two worlds had different needs; the schedulers diverged.
At the modern AI scale they are converging. Slurm now supports containers natively (via Pyxis, Apptainer, and the upcoming scrun). Kubernetes has grown gang scheduling, topology-aware placement, and GPU-aware operators (Volcano, Kueue, Run:ai, KAI Scheduler). The decision between them is still real — covered in depth in Part VII — but neither is obviously wrong for AI workloads anymore.
The two-sentence definition to remember
Slurm is a batch-oriented, node-allocating workload manager: it gives a job a set of physical resources for a bounded duration and lets the job run whatever processes it wants inside that allocation. Everything that looks idiosyncratic about Slurm — its CLI, its config, its quirks — is downstream of those two design decisions.
The cluster engineer's role
If you are an AI Performance and Efficiency Engineer, Slurm is not your job to operate (that is the cluster operations team) but it is your job to use fluently. You will:
- Submit large training jobs across hundreds or thousands of GPUs and need to know why one rank started 90 seconds after the others.
- Help researchers whose jobs sit in PENDING and they don't know why.
- Diagnose why an allreduce on this allocation is slower than the same allreduce on yesterday's.
- Influence the configuration (topology plugin, GRES weights, QoS) to make AI workloads efficient.
- Collaborate with ops on health checks, prologues, and epilogues that catch broken nodes before researchers run into them.
You don't need to write Slurm patches. You do need to read Slurm's logs and translate them into actionable diagnoses faster than anyone else in the room.
§02Architecture: daemons, plugins, control flow
Slurm is built from a small number of daemons that communicate over a custom RPC protocol, with the actual scheduling logic, allocation logic, accounting, and resource discovery factored into plugins. Knowing which daemon does what and where the plugin boundaries are is the foundation for reading the logs.
The daemons
slurmctld
The brain. There is one active slurmctld per cluster (with an optional standby for HA, covered in §23). It holds all cluster state — node states, the job queue, running jobs, reservations, partitions — in memory, with periodic state-save to disk for crash recovery. The scheduler runs inside this daemon. Every sbatch, squeue, and scontrol command ultimately talks to slurmctld.
slurmd
The node agent. One slurmd runs on each compute node. It registers itself with slurmctld on startup (telling it the node's CPUs, memory, GPUs, features), maintains a heartbeat, and when the controller wants to launch a job on this node, slurmd receives the RPC and spawns slurmstepd processes.
slurmstepd
The step process. When a job runs on a node, slurmd forks a slurmstepd per job-step (a "step" is an srun invocation within a job — more on this in §04). slurmstepd sets up cgroups, applies the user's process, opens stdio sockets back to the launching srun, and waits for the user processes to finish. Crucially: slurmstepd is the parent of the user's actual workload. Kill slurmstepd and you kill the job's processes.
slurmdbd
The accounting daemon. A buffering layer between slurmctld and a SQL backend (MariaDB or MySQL). Records every job's resource usage, plus the association tree (users, accounts, QoS, limits) that powers fair-share and access control. Running without slurmdbd is possible for tiny clusters but standard everywhere else.
slurmrestd
Optional. Provides a REST/OpenAPI interface to Slurm — useful for web UIs, dashboards, and integrations that don't want to shell out to squeue.
The plugin model — where Slurm's flexibility lives
Slurm is, structurally, a thin daemon framework with dozens of plugin interfaces. Each interface has multiple implementations; the operator picks one per cluster in slurm.conf. This is why two Slurm clusters can behave so differently while running the same code.
| Plugin family | What it does · choices |
|---|---|
| SelectType | How nodes are partitioned for allocation. Almost universally select/cons_tres on modern AI clusters (treats each consumable resource — CPU, memory, GPU — independently). The legacy select/linear allocates whole nodes only. |
| SchedulerType | The scheduling strategy. sched/backfill is the standard (main + backfill, §09). sched/builtin is FIFO and used only in trivial setups. |
| PriorityType | How job priority is computed. priority/multifactor is universal — combines age, fair-share, QoS, partition, job size, and other factors (§10). |
| PreemptType | Whether and how jobs can preempt others. preempt/qos (preempt based on QoS) and preempt/partition_prio are common (§11). |
| AcctGatherProfileType | What per-job stats get recorded (CPU, memory, IO, energy). |
| TopologyPlugin | Network topology awareness. topology/tree reads topology.conf and places ranks to minimize switch hops (§13). |
| TaskPlugin | How tasks are bound to CPUs/GPUs. task/affinity,task/cgroup is standard. |
| ProctrackType | How job processes are tracked for cleanup. proctrack/cgroup is the modern choice (process tracking via cgroups, robust against forks). |
| JobAcctGatherType | How per-job resource accounting is sampled. jobacct_gather/cgroup is standard. |
| GresTypes | Generic resource types declared. For GPUs: gpu; for NICs: nic; for storage: nvme, etc. |
| SwitchType | Network switch plugin (mostly historical; switch/none is universal now). |
| MpiDefault | How MPI is launched. pmix is modern; pmi2 is legacy. |
| AuthType | RPC authentication. auth/munge is the universal choice. |
Control flow: what happens when you sbatch
Trace a single sbatch hello.sh from press-Enter to running:
sbatchparses the script (including#SBATCHdirectives), packages a JOB_DESC RPC, and sends it toslurmctldover MUNGE-authenticated TCP.slurmctldvalidates the request (does the user have access to the requested partition? is the QoS valid? are the resources possible?), assigns a job ID, persists the job to its state files, and inserts it into the priority-ordered queue.- On the next scheduling cycle (every few seconds, plus event-driven), the scheduler considers the job. If resources are available and priority allows, the job is "started": the controller picks nodes, builds a job credential, and sends LAUNCH RPCs to the
slurmdon each allocated node. - Each
slurmdvalidates the credential, runs the configured prolog scripts (which can drain the node if a health check fails), and — for a batch job — launches oneslurmstepdthat runs the batch script on the head node of the allocation. - When the user script invokes
srun, that creates a step:srunsends another RPC toslurmctld, which dispatches further launch RPCs to all nodes in the allocation, each spawning its ownslurmstepdfor the step. - The user's processes run.
slurmdsamples their resource usage. Output is streamed back throughslurmstepdinto the configured output file. - When the user processes exit (or the job hits its time limit, or someone
scancels),slurmstepdreports completion toslurmd, which reports toslurmctld, which marks the job COMPLETED, runs epilog scripts, frees the resources, and writes the accounting record toslurmdbd.
Read the steps above twice. Almost every diagnostic question — why didn't my job start, why is my job stuck, why didn't my output appear — maps to "which of those seven steps did it fail at?"
Authentication: MUNGE
Every Slurm RPC is authenticated by MUNGE — a service that creates short-lived, signed credentials shared by all nodes in the cluster. MUNGE requires a shared key file (/etc/munge/munge.key) identical on every node, and time-synchronized clocks (NTP/chrony must be working). If MUNGE breaks on a node, every Slurm command on that node fails with "Invalid Credential" — a classic intermittent failure that turns out to be clock drift.
Cluster ground truth
slurmctld holds the canonical state in memory. Every squeue, sinfo, scontrol show job is a snapshot of that state. If you see contradictory states between commands, suspect clock drift, RPC backpressure, or that you're talking to a stale standby controller. The state in slurmctld's memory is the truth.
§03The data model: nodes, partitions, jobs, steps, GRES
Slurm has a small, carefully chosen vocabulary. Learn the seven terms below and most of the documentation will read naturally.
Node
A physical (or virtual) compute host. Each node has a name (node001), a set of features (haswell,broadwell,gpu), a count of consumable resources (CPUs, memory, GPUs, NICs), and a state. Node states are the alphabet of cluster health:
| State | Meaning |
|---|---|
| IDLE | Available, no jobs running. |
| ALLOCATED | One or more jobs running; node is fully or partially used. |
| MIXED | Partially allocated — some CPUs/GPUs free, some in use. |
| DRAIN/DRAINING/DRAINED | Administrator marked this node not to accept new jobs. Still finishes running ones (DRAINING) or is fully clear (DRAINED). |
| DOWN | Controller cannot reach the slurmd, or admin marked it down. |
| FAIL/FAILING | Node failed during a job; usually transitions to DRAINED for investigation. |
| RESERVED | Part of an advance reservation; only matching jobs allowed. |
| MAINT | Maintenance reservation active. |
| UNKNOWN | State unknown — usually a controller restart that hasn't heard from this node yet. |
| POWER_DOWN, POWERED_DOWN, POWER_UP | Power-saving states; node is being or has been powered off to save energy. |
| FUTURE | Configured for future use; not currently expected. |
| NOT_RESPONDING | Node missed heartbeats but is not yet DOWN. |
Partition
A named subset of nodes with policies attached. Think "queue" if it helps: gpu partition might be 100 H100 nodes with a 24-hour time limit, cpu partition might be 50 CPU-only nodes with a 7-day limit, debug partition might be 4 nodes with a 30-minute limit and high priority.
Partitions are the primary place admins enforce policy: time limits, QoS access, allowed users/accounts, default resources, preemption modes. A job specifies a partition (or gets the default); the partition's policies apply.
Job
An accepted resource request, assigned a unique JobID. A job has a state (PENDING, RUNNING, COMPLETED, CANCELLED, FAILED, TIMEOUT, OUT_OF_MEMORY, NODE_FAIL, PREEMPTED, etc.), an allocation (set of nodes + resources), a user/account, a partition, and a script that defines what to run.
Step
A unit of execution within a job. Each call to srun inside a batch script creates a step. A job can have many steps, sequentially or in parallel. Steps inherit the job's allocation but can use a subset of it. sstat reports per-step statistics; sacct shows them as JOBID.STEPID rows (e.g., 12345.0, 12345.1, 12345.batch, 12345.extern).
Two special pseudo-steps every batch job has:
- batch step (
JOBID.batch) — the slurmstepd that ran the submitted shell script. - extern step (
JOBID.extern) — created to track any processes spawned outsidesrun(PAM sessions, ssh-into-node, etc.), so they get the right cgroups.
Task
A single process within a step. When you write srun -n 16 ./prog, you're saying "run 16 tasks of ./prog." Tasks are usually MPI ranks or distributed-training ranks. Each task gets its environment variables (SLURM_PROCID, SLURM_NODEID, etc.) so it knows its identity.
GRES — Generic Resources
Slurm's mechanism for tracking and allocating things other than CPUs and memory. GPUs are the most common: declared as Gres=gpu:8 on each node, requested via --gres=gpu:4 or --gpus=4 on a job. Other common GRES: NICs (nic), high-speed scratch storage (nvme), licenses (license), and custom resources operators want to ration.
GRES can be typed (gpu:a100:8 vs gpu:h100:8) so users can request a specific kind. GRES can also carry per-resource attributes — the cores binding for GPUs that says "GPU 0 is closest to cores 0-15" so Slurm can do NUMA-aware allocation.
Account & Association
Accounting concepts that drive fair-share. An account is a project/group (e.g., physics, nlp-team). A user can belong to multiple accounts and uses --account=name to charge a particular one. The combination (user, account, partition, QoS) is an association — and limits like maximum running jobs or fair-share shares live on associations. More in §22.
QoS — Quality of Service
A named bundle of policy: priority bias, preemption rules, limits, time limits, GRES caps. Users select a QoS via --qos=name. Common patterns: a "normal" QoS for daily work, a "high" QoS with priority bias for time-sensitive work (and a strict GPU-hour cap), a "preemptible" QoS for cheap-but-interruptible jobs that can fill the cluster.
Reservation
An advance booking of resources by an admin. Used for scheduled maintenance windows, dedicated time for a workshop or benchmark, holding nodes for an incoming job that needs everything. Jobs that don't match a reservation's parameters are excluded from those nodes during the window.
Master this seven-word vocabulary — node, partition, job, step, task, GRES, association — and three quarters of Slurm reads itself.
The user's interface.
Slurm has three submission verbs, a single configuration grammar shared across them, and a small set of query commands. Once you've internalized this surface, the rest is variation. The five chapters here are what every researcher and engineer working on a Slurm cluster should know cold.
§04The submission trinity: sbatch, salloc, srun
Three commands, each with a distinct role. People confuse them frequently — the confusion is the source of a remarkable share of new-user problems.
sbatch — submit a batch job
The most common verb. sbatch myscript.sh submits a job described by the script. The script runs (eventually) on one of the allocated nodes, with the user's allocation reserved for the whole duration. Inside the script, the user runs whatever they want — most commonly, an srun command that fans the work out across the allocation.
# myscript.sh #!/bin/bash #SBATCH --job-name=train_llama #SBATCH --partition=gpu #SBATCH --nodes=8 #SBATCH --gpus-per-node=8 #SBATCH --ntasks-per-node=8 #SBATCH --cpus-per-task=12 #SBATCH --time=06:00:00 #SBATCH --output=logs/%x-%j.out module load cuda/12.4 nccl/2.21 srun python train.py --config=configs/llama70b.yaml # Submit it $ sbatch myscript.sh Submitted batch job 8472341
sbatch returns immediately; the job is now in the queue. The script will run when scheduled. Its stdout/stderr go to the configured output files.
salloc — interactive allocation
"Give me an allocation; I'll use it interactively." After salloc succeeds, the user gets a shell on the submit host (or, optionally, on a compute node) with the allocation reserved. They can then run srun within the allocation. Useful for debugging, interactive notebooks, multi-step experiments where they want to keep the allocation between commands.
$ salloc --partition=gpu --nodes=1 --gpus=8 --time=1:00:00 salloc: Granted job allocation 8472342 salloc: Nodes node-42 are ready for job # Now you have a shell with the allocation held. $ srun --pty bash # interactive shell on the compute node [node-42]$ nvidia-smi [node-42]$ exit $ exit # or scancel from elsewhere salloc: Relinquishing job allocation 8472342
srun — launch a step inside an allocation
This is the verb that actually runs processes on nodes. Inside a sbatch script or after salloc, srun starts a step using the existing allocation. From outside any allocation, srun first creates an allocation and then launches the step — a one-shot interactive run.
# Inside a batch script: launch 64 ranks of train.py across the allocation srun --ntasks=64 --ntasks-per-node=8 python train.py # One-shot interactive: allocate AND launch, blocks until done srun --partition=debug --nodes=1 --gpus=1 --time=00:30:00 nvidia-smi # Within salloc: just runs the step using the existing alloc $ salloc --nodes=2 --gpus=16 --time=02:00:00 $ srun --ntasks=16 python train.py
The mental model
sbatch creates an allocation and runs your batch script. salloc creates an allocation and gives you a shell. srun creates a step inside an allocation — and if no allocation exists, it creates one for itself.
Inside a batch script, you almost always want exactly one srun per parallel work unit. The batch script itself runs on one node (the head node of the allocation) — only srun fans out across all allocated nodes.
The classic confusion: forgetting srun in a batch script
A surprising number of researchers write batch scripts like this and wonder why their distributed training runs only on one node:
#!/bin/bash #SBATCH --nodes=8 --gpus-per-node=8 python train.py # NO srun — runs on the head node only! # The other 7 nodes sit idle for the whole 6h job.
Without srun, the python process only runs on the batch host. To launch on all eight nodes, you need either srun python train.py (which creates one task per CPU/GPU on each node) or a framework launcher (torchrun, accelerate) that uses srun under the hood or uses Slurm env vars (SLURM_*) to identify ranks. More in §16.
Per-job vs per-step resources
The #SBATCH directives request the job's total resources. The srun flags request the step's resources — by default, the same as the job, but you can request less.
# Job has 8 GPUs total. Run two steps in parallel, each using 4 GPUs. #SBATCH --gpus=8 --ntasks=2 srun --gpus=4 --ntasks=1 python train_a.py & srun --gpus=4 --ntasks=1 python train_b.py & wait
Less common in practice, but worth knowing when you see it.
§05Submit script anatomy and idioms
The structure that every well-written Slurm batch script shares. Idioms here matter because researchers copy-paste from each other; getting these conventions right propagates through the cluster.
The skeleton
#!/bin/bash #SBATCH --job-name=my-experiment # readable name in squeue #SBATCH --account=ai-research # billing/fair-share account #SBATCH --partition=h100 # which queue #SBATCH --qos=normal # quality of service #SBATCH --nodes=16 # node count #SBATCH --ntasks-per-node=8 # one task per GPU #SBATCH --gpus-per-node=8 # GPUs per node #SBATCH --cpus-per-task=14 # CPUs per task (dataloader workers + overhead) #SBATCH --mem=0 # 0 = all available memory on the node #SBATCH --time=12:00:00 # wall clock limit (HH:MM:SS) #SBATCH --output=logs/%x-%j.out # %x = job name, %j = job ID #SBATCH --error=logs/%x-%j.err #SBATCH --signal=B:USR1@60 # send SIGUSR1 to batch shell 60s before kill #SBATCH --requeue # auto-requeue on node failure # --- fail-fast --- set -euo pipefail # --- environment --- module purge module load cuda/12.4 nccl/2.21 openmpi/4.1.6 source /shared/envs/training/bin/activate # --- diagnostics for postmortem --- echo "=== job ${SLURM_JOB_ID} on ${SLURMD_NODENAME} ===" echo "nodes: ${SLURM_JOB_NODELIST}" echo "start: $(date)" nvidia-smi --query-gpu=name,driver_version --format=csv,noheader # --- the actual work --- srun --kill-on-bad-exit=1 \ --label \ python -u train.py \ --output_dir=/scratch/${SLURM_JOB_ID} \ --resume_from_checkpoint=auto echo "end: $(date)"
The idioms that matter
set -euo pipefail— fail fast on errors, undefined variables, and pipe failures. Without this, a typo inmodule loadsails through silently and your job runs with the wrong environment.--output=logs/%x-%j.out— separate output files per job, named for both the job name and the ID. Predictable paths matter when you have hundreds of past jobs.python -u— unbuffered stdout. Without it, Python buffers output and the log file appears to hang for minutes, which terrifies researchers.srun --kill-on-bad-exit=1— if any task fails, kill the rest immediately. The default behavior in many Slurm configurations is to let other ranks continue while one has crashed, leading to NCCL hangs.srun --label— prefix each line of output with the task ID. Indispensable for multi-rank debugging.--signal=B:USR1@60— Slurm sends SIGUSR1 to the batch shell 60 seconds before forced kill. The "B:" prefix means signal the batch shell (not the user processes). Researchers use this to trigger checkpoint-on-timeout. PyTorch Lightning, NeMo, and similar frameworks have signal handlers built in.--requeue— auto-requeue the job if a node fails or it's preempted. Combined with checkpoint/restart, this means a 3-day training run can survive a node failure without the user noticing.
Environment variables Slurm sets
Inside the job, dozens of SLURM_* variables describe the allocation. The ones every distributed-training launcher reads:
| Variable | Meaning |
|---|---|
| SLURM_JOB_ID | The numeric job ID — use for log paths, scratch directories. |
| SLURM_JOB_NUM_NODES | Total nodes in the allocation. |
| SLURM_JOB_NODELIST | Compact node list, e.g., node-[01-08]. Use scontrol show hostnames to expand. |
| SLURM_NTASKS | Total task count across all nodes. |
| SLURM_NTASKS_PER_NODE | Tasks per node. |
| SLURM_PROCID | The rank of the current task (0-based). Useful as RANK in distributed training. |
| SLURM_LOCALID | The local rank on the node (0-based). Useful as LOCAL_RANK for GPU binding. |
| SLURM_NODEID | The node's ordinal within the job (0-based). |
| SLURM_CPUS_PER_TASK | CPUs assigned per task. |
| SLURM_GPUS_PER_NODE / SLURM_GPUS_ON_NODE | GPU counts. |
| SLURM_SUBMIT_DIR | Directory the job was submitted from (slurm cd's there by default). |
Output-file patterns
The pattern characters in --output= and --error=:
%j job ID %J job ID with array index (12345_3) %x job name %u user name %N master node name %n node ID within the job (0..N-1) %t task ID within the job %A master job ID for arrays %a array task ID
For an array job, use --output=logs/%x-%A_%a.out so each array task gets its own file.
Signals and graceful termination
The --signal flag is one of the most underused. The full syntax:
--signal=[B:]<sig>[@<seconds-before-timeout>]
--signal=USR1@120— send SIGUSR1 to user processes 120 seconds before time limit.--signal=B:USR1@120— send SIGUSR1 to the batch shell (not the workload).--signal=R:USR1@120— only for requeued jobs.
The receiving program installs a signal handler:
import signal, sys def on_timeout(signum, frame): save_checkpoint() sys.exit(0) signal.signal(signal.SIGUSR1, on_timeout)
Combined with --requeue, this is the canonical pattern for jobs that ride out time limits indefinitely.
§06Resource requests in depth
Most user confusion in Slurm centers on the resource-request flags. They are powerful and intersect in ways that are not obvious. This is the chapter to bookmark.
The four dimensions
Every Slurm resource request lives on four axes:
- Nodes — how many machines.
- Tasks — how many parallel processes total.
- CPUs (cores) — per-task or per-job count.
- Memory — per-task, per-CPU, or per-node.
Plus GPUs, which have their own dedicated flags but layer on top.
The node-task-cpu flags
| Flag | Effect |
|---|---|
| --nodes=N or -N N | Request N nodes. Can be a range: --nodes=4-8. |
| --ntasks=N or -n N | Total tasks (the unit MPI/torchrun calls a "process"). |
| --ntasks-per-node=N | Tasks per node. Combined with --nodes determines total. |
| --ntasks-per-socket=N | Tasks per CPU socket (rare; useful for NUMA-balanced placement). |
| --cpus-per-task=N or -c N | CPU cores allocated to each task. |
| --threads-per-core=N | SMT threads to use (1 disables hyperthreading from the job's view). |
| --exclusive | Don't share nodes with other jobs. Strongly recommended for performance-sensitive multi-node training. |
The arithmetic
The three quantities — total tasks, nodes, tasks-per-node — are over-determined. Specify any two; Slurm derives the third. Common shapes:
# 8 nodes, 8 GPUs each, one task per GPU = 64 tasks total --nodes=8 --ntasks-per-node=8 --gpus-per-node=8 --cpus-per-task=14 # Equivalent --nodes=8 --ntasks=64 --gpus-per-node=8 --cpus-per-task=14 # Single node, multi-GPU (FSDP within one box) --nodes=1 --ntasks=8 --gpus=8 --cpus-per-task=14 # CPU-only training or preprocessing --nodes=1 --ntasks=1 --cpus-per-task=32 --mem=128G
Memory flags
| Flag | Effect |
|---|---|
| --mem=SIZE | Total memory per node. --mem=128G. --mem=0 means "all available on the node." |
| --mem-per-cpu=SIZE | Memory per CPU. Total = mem-per-cpu × cpus-per-task × ntasks-per-node. |
| --mem-per-gpu=SIZE | Memory per GPU. |
For GPU AI workloads, --mem=0 on a dedicated node is usually right — you've already reserved the whole node via --exclusive or by requesting all the GPUs, so claim all the host RAM too.
GPU flags
Slurm has two generations of GPU-request flags. Both work; new code should prefer the modern ones (Slurm 19.05+).
| Flag | Effect |
|---|---|
| --gpus=N (modern) | Total GPUs for the job. |
| --gpus-per-node=N | GPUs per node. |
| --gpus-per-task=N | GPUs allocated per task. Convenient for "1 GPU per rank" patterns. |
| --gpus-per-socket=N | For balancing across CPU sockets. |
| --gpu-bind=TYPE | Binding policy: closest, single:N, map_gpu:0,1,2,3, mask_gpu:0xF,0xF0. |
| --gres=gpu:N (legacy) | Equivalent to --gpus-per-node. Still widely used. |
| --gres=gpu:TYPE:N | Request specific GPU types: --gres=gpu:a100:4. |
| --constraint=FEATURE | Restrict to nodes with a tag, e.g., --constraint=h100. |
Time
Always set --time. Without it, you get the partition default, which is usually too long or too short and may cost you in priority.
Format: MM, HH:MM:SS, D-HH:MM:SS, or D-HH.
--time=30 # 30 minutes --time=04:00:00 # 4 hours --time=2-12:00:00 # 2 days 12 hours
Shorter time limits help the backfill scheduler (§09) find slots for your job. Overestimating wastes opportunities; underestimating gets your job killed mid-run.
Constraints and features
Nodes can be tagged with arbitrary features in slurm.conf (Feature=h100,sxm5,rail-A). Users request features with --constraint=:
--constraint=h100 # nodes tagged h100 --constraint="h100&sxm5" # both tags --constraint="h100|a100" # either --constraint="[rail-A*4&rail-B*4]" # 4 of rail-A, 4 of rail-B # (heterogeneous constraint syntax)
Use constraints judiciously. Each unique constraint set reduces the number of nodes available to your job and increases queue time.
The over-specification trap
Over-specified resource requests sit longer in PENDING because fewer slots match them. The pattern: a user copies a script from a colleague, leaves in a --constraint=h100&rail-A&ssd-fast that mattered for one specific experiment, and wonders why their two-node job has been queued for six hours. The first diagnostic move on a stuck PENDING job is "are the constraints reasonable for what you actually need?"
§07Job arrays, dependencies, heterogeneous jobs
Three orthogonal mechanisms for jobs that have structure beyond "one allocation, one program." All three appear in real AI workflows.
Job arrays
Submit one description, get N jobs. Each job sees a different SLURM_ARRAY_TASK_ID and runs independently. Perfect for hyperparameter sweeps, evaluation runs, embarrassingly parallel data processing.
#!/bin/bash #SBATCH --array=0-99%10 # 100 tasks, at most 10 running concurrently #SBATCH --job-name=hp-sweep #SBATCH --gpus=1 --time=2:00:00 #SBATCH --output=logs/%x-%A_%a.out LR_VALUES=(1e-5 3e-5 1e-4 3e-4 1e-3) WD_VALUES=(0 0.01 0.1) lr=${LR_VALUES[$((SLURM_ARRAY_TASK_ID % 5))]} wd=${WD_VALUES[$((SLURM_ARRAY_TASK_ID / 5 % 3))]} srun python train.py --lr=$lr --wd=$wd --seed=$SLURM_ARRAY_TASK_ID
Array task IDs can be a range (0-99), a list (1,5,7,9), a step (0-100:10), or any combination (0-99,200,300-400:5).
The %N suffix throttles concurrent execution. --array=0-1000%50 means "1001 tasks, never more than 50 running at once" — essential when you don't want a single user's sweep to eat the whole cluster.
Dependencies
"Don't start until that other job finishes." Useful for staged pipelines: preprocess → train → evaluate.
# Submit the preprocess job $ jid1=$(sbatch --parsable preprocess.sh) # Train depends on preprocess completing successfully $ jid2=$(sbatch --parsable --dependency=afterok:$jid1 train.sh) # Eval depends on training (any exit status) $ sbatch --dependency=afterany:$jid2 evaluate.sh
| Dependency type | Trigger condition |
|---|---|
| after:JOBID | Other job has started |
| afterok:JOBID | Other job completed successfully (exit 0) |
| afternotok:JOBID | Other job failed |
| afterany:JOBID | Other job ended, success or fail |
| afterburstbuffer:JOBID | Other job's burst buffer stage-out is done |
| aftercorr:JOBID | For arrays: each array task waits on the corresponding task of the other array |
| singleton | No other job with the same job name from this user is running |
Dependencies can be chained, comma-separated (AND), or question-marked (OR). Watch for unsatisfiable dependencies — if the upstream job fails with afterok, the dependent job is automatically cancelled with reason DependencyNeverSatisfied.
Heterogeneous jobs (job packs)
A single job that combines different resource shapes. Useful for client/server topologies (database server + compute clients) and for the increasingly common pattern of training + inference + telemetry components in one allocation.
#!/bin/bash #SBATCH --nodes=1 --gpus=1 --time=1:00:00 # pack 0 — coordinator #SBATCH hetjob #SBATCH --nodes=8 --gpus-per-node=8 # pack 1 — workers #SBATCH hetjob #SBATCH --nodes=1 --cpus-per-task=16 # pack 2 — telemetry sidecar srun --pack-group=0 python coordinator.py & srun --pack-group=1 python worker.py & srun --pack-group=2 python telemetry.py & wait
Each pack is a separate set of resources; they share a job ID but have different node-lists and resource shapes. The --pack-group selects which when launching.
Heterogeneous jobs are powerful but operationally awkward — they make queueing harder (the scheduler must find resources for all packs simultaneously) and confuse downstream tooling. Use when the workflow really demands it; otherwise stick to plain jobs with shaped resources.
§08Querying and introspection
Slurm exposes everything through six command-line tools. Knowing the useful invocations is a daily-driver skill.
squeue — what's running and what's queued
# Default format — too verbose, often not what you want squeue # Your jobs only squeue -u $USER # A specific partition, with priority squeue -p gpu --sort=-p # Custom format — this is what experienced operators use squeue -o "%.10i %.9P %.20j %.8u %.2t %.10M %.6D %.4C %.4z %R" # jobid partit name user state time nodes cpus gpu reason # Show GPU/GRES request squeue -o "%.10i %.20j %.8u %.10M %.6D %b %R" # ^ GRES # Why are pending jobs pending? squeue --start # estimated start times squeue -t PENDING -o "%.10i %.20j %.8u %.10M %R"
The %R field is the most valuable for diagnostics: it shows the reason a job is in its current state. For PENDING jobs, that's the Reason — Priority, Resources, QOSMaxJobsPerUser, ReqNodeNotAvail, etc. (Full list in §27.)
sinfo — cluster state
# Default — partition summary sinfo # By node, with state and reason sinfo -N -o "%.20n %.10T %.10P %.30G %.30E" # node state part gres reason # Only nodes in non-healthy states sinfo -N -t DOWN,DRAIN,FAIL,MAINT -o "%n %T %E" # How much idle GPU capacity is in each partition right now? sinfo -h -o "%P %D %t" | awk '{print}' # Show topology if topology plugin is on sinfo --topology
scontrol — control-plane queries and actions
The Swiss Army knife. Read state from anything; write to most things if you have permission.
# Full info on one job scontrol show job 12345 # Full info on a node — its features, state, GRES, last reason scontrol show node node-042 # Expand a node list to individual hostnames scontrol show hostnames "node-[042-049,053]" # What's in this partition? scontrol show partition gpu # Update a running job (admin or job owner with limits) scontrol update jobid=12345 TimeLimit=12:00:00 # Drain a node (admin) scontrol update NodeName=node-042 State=DRAIN Reason="investigating XID" # Re-enable a drained node (admin) scontrol update NodeName=node-042 State=RESUME # Cancel future state changes (rare) scontrol cancel_reboot node-042
sacct — accounting history
Slurm's view of jobs that have completed. Lives in the accounting database — queries can go back years. Use it for postmortems, capacity planning, anything historical.
# Your jobs from today sacct -u $USER -S today # Custom format with the things that matter sacct -u $USER -S "2026-05-20" -o "JobID,JobName%30,State,Elapsed,NodeList,ExitCode,ReqMem,MaxRSS" # All steps of one job, including .batch and .extern sacct -j 12345 -o "JobID,JobName,State,Elapsed,ConsumedEnergy,MaxRSS" # All FAILED jobs in the last week on a partition sacct -S "now-1week" -X -s FAIL,TIMEOUT,NODE_FAIL,OUT_OF_MEMORY \ -p gpu -o "JobID,User,JobName,State,Elapsed,Reason" # Per-user GPU-hours consumed last month sacct -S "2026-04-01" -E "2026-04-30" -X --format=User,AllocTRES%50
sstat — live stats on running steps
# Per-step max memory, CPU, etc., for a running job sstat -j 12345.batch -o "JobID,MaxRSS,MaxVMSize,AveCPU,AveCPUFreq" # For an srun-launched step sstat -j 12345.0
Only running jobs. Once the step completes, use sacct instead.
scancel — stop jobs
scancel 12345 # cancel one job scancel 12345.0 # cancel a single step (leaving job alive) scancel -u $USER # cancel all your jobs (careful) scancel -t PENDING -u $USER # cancel only your pending jobs scancel -p gpu --state=PENDING # cancel all pending in a partition (admin) scancel --signal=USR1 12345 # send SIGUSR1 instead of SIGTERM (for checkpoint)
Cancel with a custom signal (--signal=USR1) is the right way to ask a job to checkpoint without killing it abruptly. The job's signal handler does the right thing.
seff — quick efficiency check
A small wrapper on sacct that prints a one-screen summary: requested CPUs/memory vs used, efficiency percentages. Use it on every long job after it completes; gross overprovisioning shows up immediately.
$ seff 12345 Job ID: 12345 Cluster: ai-prod User/Group: alice/researchers State: COMPLETED (exit code 0) Nodes: 8 Cores per node: 112 CPU Utilized: 13-14:22:17 CPU Efficiency: 12.4% <-- huge over-request of CPUs Job Wall-clock time: 02:11:04 Memory Utilized: 412.31 GB Memory Efficiency: 18.7% <-- requested too much memory
For an efficiency engineer, seff across the fleet is one of the simpler fleet-wide signals to mine.
How Slurm decides.
The scheduler is where Slurm's character lives. Every "why is my job not running" investigation eventually drops into the priority calculation, the backfill window, the topology graph, or the GRES allocator. Understanding these in depth lets you make precise, technical statements about why something is or isn't happening.
§09The scheduling cycle & backfill
Slurm has two cooperating schedulers running on different cadences: the main scheduler and the backfill scheduler. They have different jobs.
The main scheduler
Wakes up every SchedulerParameters: sched_interval seconds (typical default: 60), plus on certain events (job submitted, job ended, node changed state). It walks the queue in priority order. For each PENDING job, in priority order:
- Are required resources currently available?
- Are all dependencies satisfied?
- Is the user/account under their limits (running jobs, GPU-hours)?
- Is the partition open and within its limits?
- Does the QoS allow this job to run now?
If all yes, the job starts. If not, the scheduler moves on. The main scheduler does not look further than the highest-priority job that cannot be satisfied right now — unless backfill is enabled.
The backfill scheduler
The whole point of backfill: many small jobs would otherwise sit behind one big job that can't run yet. Backfill runs them — but only if they won't delay the big job.
Backfill runs every bf_interval seconds (typical default: 30) and is much more expensive than the main scheduler. It looks at every PENDING job, projects when it could run given the current allocation forecast, and starts any job that:
- Has all required resources available now;
- Has a time limit short enough that it will complete before the next reservation/higher-priority job needs the nodes.
This is why your time limit affects when your job runs. A job with --time=01:00:00 is much easier to backfill into a 1.5-hour gap than a job with --time=24:00:00 that doesn't fit anywhere. Realistic time limits get jobs started faster.
The scheduling cycle, drawn
The knobs operators tune
From SchedulerParameters in slurm.conf — these matter for cluster behavior at scale:
| Parameter | What it controls |
|---|---|
| default_queue_depth=N | How many jobs the main scheduler considers per cycle (default 100). Large clusters with deep queues raise this. |
| bf_continue | Backfill keeps its position across yields, so a single cycle can cover the whole queue. |
| bf_max_job_user=N | Max jobs per user the backfill scheduler considers (prevents one user's array from dominating). |
| bf_max_job_part=N | Max jobs per partition for backfill. |
| bf_max_job_test=N | Max total jobs tested per backfill cycle (default 100; large clusters: 1000+). |
| bf_resolution=N | Backfill time resolution in seconds. Smaller = more accurate but more expensive. |
| bf_window=N | How far in the future backfill considers (minutes; default 1440 = 1 day). |
| bf_yield_interval / bf_yield_sleep | How often backfill yields to other RPCs and for how long. Critical at scale; tuning here keeps the controller responsive. |
| sched_max_job_start=N | Cap on jobs the main scheduler starts per cycle. |
| sched_min_interval=microseconds | Minimum gap between scheduler runs (prevents thrashing). |
On a 5,000-node cluster with 50,000 pending jobs, default values are wrong. The controller spends all its time scheduling and stops responding to squeue. Tuning these is a recurring conversation between performance engineers and cluster ops.
Why your job starts (or doesn't)
Q: Is the job at the head of the priority-ordered queue (within main sched's depth)? no -> wait for backfill to consider it. yes -> Are resources available right now? no -> PENDING / Resources. Wait for jobs to finish. yes -> Dependencies satisfied? no -> PENDING / Dependency. yes -> Under all limits? no -> PENDING / QOSMax... or AssocMax... yes -> RUNNING.
Most "why isn't my job running" questions resolve to one of those branches. squeue -t PENDING -o "%i %j %R" shows the Reason in the rightmost field; matching it against the tree above is the first diagnostic move.
§10Priority and the multifactor plugin
Job priority is a single number, but it's computed from several weighted factors. Understanding the factors lets you predict, and influence, queue order.
The formula
priority = PriorityWeightAge × age_factor
+ PriorityWeightFairshare × fairshare_factor
+ PriorityWeightJobSize × jobsize_factor
+ PriorityWeightPartition × partition_factor
+ PriorityWeightQOS × qos_factor
+ PriorityWeightTRES × Σ tres_weights × tres_factors
- nice (set by user with --nice)
Each ...factor is a number in [0, 1]. The ...weight coefficients are configured in slurm.conf — typically integers between 0 and 10,000,000. The sum is the job's priority value, visible in sprio output.
The factors
| Factor | Definition |
|---|---|
| Age | How long the job has been eligible to run. Grows linearly until PriorityMaxAge (typically 7 days). Prevents starvation. |
| Fairshare | The big one. Higher when the user/account has used less than their fair share recently. Computed from accounting history; decays with a half-life set by PriorityDecayHalfLife. |
| JobSize | Larger jobs get higher priority (default), or smaller jobs get higher priority (SMALL_RELATIVE_TO_TIME). Operator choice. |
| Partition | A partition-level priority bias (set with PriorityTier on the partition). Lets ops boost "debug" or "interactive" partitions. |
| QOS | QOS-level priority bias (set with Priority on the QOS). Lets ops define "express" tiers. |
| TRES | Trackable RESources — per-resource priority weights. PriorityWeightTRES=GRES/gpu=10 makes GPU jobs get an extra boost. |
| Nice | User-applied bias to deprioritize their own jobs (positive nice = lower priority). Useful for self-throttling. |
Fair-share in depth
Fair-share is the most important and least-understood factor. Operators allocate "shares" to accounts (an organizational construct) and to users within accounts. The cluster maintains a usage history; the factor is high when your historical usage is below your share, low when above.
Fair-share computes hierarchically. Alice's effective fair-share is the multiplication of nlp-team's standing within ai-research, ai-research's within the root, and Alice's within nlp-team. A user in an over-using team starts at a disadvantage even if they personally haven't used much.
Looking at priority components
# Show factor breakdown for a specific job sprio -j 12345 # All pending jobs sorted by priority sprio -S -y -o "%.10i %.10Y %.10A %.10F %.10J %.10P %.10Q" # age fairshare jobsize partition qos # Show your fair-share standing sshare -u $USER --format=Account,User,RawShares,NormShares,RawUsage,EffectvUsage,FairShare
Common fair-share questions
"My priority dropped overnight"
Usually fair-share. You ran a job over the weekend; the system noticed; your factor decreased. It will recover with the decay half-life — typically days to weeks, depending on configuration.
"My new colleague's job started before mine"
Their fair-share is fresh (no usage history); yours is depressed from recent runs. Often correct behavior; sometimes a problem when the new colleague is part of a team that has plenty of allocation. The fix is at the operator level: make sure account shares reflect intent.
The PriorityFlags worth knowing
SMALL_RELATIVE_TO_TIME— JobSize factor favors smaller (or shorter) jobs.FAIR_TREE— the modern fair-share algorithm (vs. olderCLASSIC_FAIRSHARE). Almost universally used.DEPTH_OBLIVIOUS— fair-share ignores account-tree depth, treating each user as independent.NO_FAIR_TREE— disables fair-tree, fallback only.CALCULATE_RUNNING— recompute priorities of running jobs (affects preemption decisions).
§11Preemption, QoS, requeue
Preemption: high-priority jobs displacing lower-priority ones. Done well, it's how a cluster offers both reliable production jobs and cheap "scavenge" capacity for experimental work. Done badly, it shows up as random job kills with no clear pattern.
How preemption is configured
Two pieces in slurm.conf:
PreemptType = preempt/qos # or preempt/partition_prio PreemptMode = REQUEUE # what happens to preempted jobs PreemptExemptTime = 5:00 # protect a job for the first 5 min
PreemptMode options:
- CANCEL — kill the preempted job. Harsh but simple.
- REQUEUE — kill and re-submit; will start again when resources are free.
- SUSPEND — pause the processes (cgroup freeze), keep memory; resume later when resources are available. Rarely used in AI clusters because GPU memory is held; cluster can't actually use the freed nodes.
- OFF — disable for this partition/QoS.
The QoS-based preemption model
Each QoS has a Priority value. With PreemptType=preempt/qos and PreemptMode=REQUEUE, the canonical setup is:
| QoS | Properties |
|---|---|
| normal | Standard priority; Preempt=preemptible (can preempt lower); default for most users. |
| high | Higher priority; Preempt=preemptible,normal; capped GPU-hours. |
| preemptible | Lowest priority; NoPreempt; runs only when others don't need the nodes; cheap or free; will be killed on demand. |
Researchers running speculative experiments use --qos=preemptible and accept the requeue cost; production fine-tunes use --qos=normal; time-critical work uses --qos=high within their cap.
Preemption-aware job design
A job in a preemptible QoS must handle being requeued at any time. Best practice:
#SBATCH --qos=preemptible #SBATCH --requeue #SBATCH --signal=B:USR1@90 # 90s warning before kill # In the training code: # - register a SIGUSR1 handler that saves checkpoint and exits 0 # - on startup, look for existing checkpoint and resume # - the job auto-requeues; loop continues from checkpoint
For a researcher using NeMo, PyTorch Lightning, or any framework with built-in signal handling, the pattern is "free." For custom training loops, the signal handler is a five-line addition that pays for itself the first time a preemption happens.
Requeue without preemption
Preemption is only one trigger. Requeue also happens on:
- Node failure (
NODE_FAILstate) — if--requeueis set, the job goes back to the queue. - Manual requeue by an admin (
scontrol requeue 12345) — for maintenance, fixing a misbehaving job, etc. - Held-then-released — jobs held with
scontrol holdand released withscontrol releaseare re-queued (subject to dependencies and priority).
QoS in depth: the limits
Beyond priority and preemption, a QoS carries limits that protect the cluster from any single user/account dominating:
| QoS limit | Effect |
|---|---|
| MaxJobs | Max concurrent jobs for one association in this QoS. |
| MaxSubmitJobs | Max jobs that can be in the queue (running + pending). |
| MaxTRES | Max resources (CPUs, GPUs, memory) the QoS can consume at once. |
| MaxTRESPerUser | Same as MaxTRES, but per user. Common pattern: MaxTRESPerUser=gres/gpu=64. |
| MaxWallDurationPerJob | Time limit cap for jobs in this QoS. |
| GrpTRESMins | Total GPU-minutes (or CPU-minutes) over a window. Implements monthly budgets. |
| UsageFactor | Multiplier on accounting charges. 0.5 makes the QoS "half-priced" for fair-share; 2.0 makes it "double-priced." |
When a user hits a QoS limit, jobs sit in PENDING with reason like QOSMaxJobsPerUserLimit or QOSGrpGRESMinutes. sacctmgr show qos dumps the configured values.
QoS as a policy lever
QoS is the operator's primary policy interface. New cluster-wide policy almost always lands as new QoS definitions plus updates to who can use them. The performance engineer who can read QoS configuration and predict "this user is going to hit limit X next Tuesday" is more useful than one who can only diagnose individual jobs.
§12Reservations & maintenance windows
Reservations are advance bookings of resources. They appear in three patterns: maintenance, dedicated time, and "hold the resources for this incoming big job."
Creating a reservation
# Maintenance reservation: drain these nodes for 6 hours starting tomorrow 02:00 scontrol create reservation \ ReservationName=maint_2026_05_26 \ StartTime=2026-05-26T02:00:00 \ Duration=06:00:00 \ Nodes=node-[001-016] \ Users=root \ Flags=MAINT,IGNORE_JOBS # Dedicated time for a benchmark run scontrol create reservation \ ReservationName=mlperf_run_q2 \ StartTime=2026-06-15T14:00:00 \ Duration=48:00:00 \ Nodes=node-[100-355] \ Accounts=ai-research # Held-for-a-specific-user, takes effect immediately scontrol create reservation \ ReservationName=urgent_train \ StartTime=now \ Duration=12:00:00 \ Nodes=node-[001-064] \ Users=alice
Reservation flags worth knowing
| Flag | Effect |
|---|---|
| MAINT | Maintenance — affects health, doesn't run prologue. |
| IGNORE_JOBS | Start the reservation even if existing jobs are still running on the nodes. Use cautiously. |
| OVERLAP | Allow other reservations on these nodes. |
| FLEX | Jobs can run inside the reservation; reservation can shrink at boundaries. |
| STATIC_ALLOC | Hold the exact same nodes; can't substitute. |
| FIRST_CORES | Only allocate from the first cores of each node. |
| NO_HOLD_JOBS_AFTER | After the reservation ends, don't hold any jobs that were running. |
Using a reservation
# Submit a job that runs inside a reservation sbatch --reservation=mlperf_run_q2 mlperf_job.sh # See active and upcoming reservations scontrol show reservations # Delete a reservation scontrol delete ReservationName=maint_2026_05_26
The performance engineer's reservation usage
Three common needs:
- Benchmark windows — dedicated, contention-free time to measure baselines. Critical when results need to be reproducible across days.
- Suspected-fault investigation — drain suspect nodes via reservation, run diagnostics, return them to service.
- Multi-day large training — a 5,000-GPU run that takes a week. Without a reservation, you can't reliably gather all 5,000 GPUs at the right time. With one, ops aligns scheduling and maintenance around your window.
Reservations are politically sensitive — they explicitly take capacity from the queue. Most clusters have process around creating large ones. The performance engineer's job is to make the case with data: "this benchmark needs uncontended time on 100 nodes for 12 hours; here's what we'll learn and the cost of not learning it."
§13Topology-aware scheduling
The most important scheduler feature for AI workloads. Slurm's topology plugin places jobs on nodes that share switches, dramatically reducing inter-rack/inter-spine traffic for collectives. Without it, an 8-node training job might be spread across the cluster; with it, it lands on 8 nodes hanging off the same leaf.
The topology.conf format
The cluster's physical network is declared in topology.conf:
# Leaf switches: each holds nodes 0-63, 64-127, ... SwitchName=leaf01 Nodes=node-[001-064] SwitchName=leaf02 Nodes=node-[065-128] SwitchName=leaf03 Nodes=node-[129-192] SwitchName=leaf04 Nodes=node-[193-256] # Spine switch: connects the leaves SwitchName=spine01 Switches=leaf01,leaf02 SwitchName=spine02 Switches=leaf03,leaf04 # Root: connects spines SwitchName=root01 Switches=spine01,spine02
With TopologyPlugin=topology/tree in slurm.conf, the scheduler treats this as a tree and prefers placements that minimize the highest switch level the job spans.
Topology in the job request
# Default: scheduler does its best with topology sbatch --nodes=8 train.sh # Explicit hint: keep within a single leaf if possible sbatch --nodes=8 --switches=1 train.sh # Stronger: keep within 1 switch level; wait up to 1 hour to find such a placement sbatch --nodes=8 --switches=1@1:00:00 train.sh # Match parallelism shape: 64 nodes split across 4 leaves, 16 each sbatch --nodes=64 --switches=4 train.sh
The --switches=N@TIMEOUT syntax is the operationally important one: "I want topology, but I won't wait forever." Without the timeout, a job demanding strict topology can sit in PENDING indefinitely.
Block topology — for rail-optimized fabrics
For modern AI clusters with rail-optimized InfiniBand (each GPU pinned to a specific NIC, each NIC to a specific rail switch), Slurm's topology/block plugin is becoming the right choice. It models "blocks" of nodes that share the same fast-path connections to each rail, allowing the scheduler to place training jobs so that allreduce traffic stays on its own rail.
# topology.conf, block-style BlockName=block01 Nodes=node-[001-032] BlockName=block02 Nodes=node-[033-064] BlockName=block03 Nodes=node-[065-096] BlockSizes=32,64,128
BlockSizes defines allowed allocation granularities — the scheduler will allocate in multiples of these. For training jobs that need to fit "exactly one rack" or "exactly one pod," block topology gives precise placement guarantees.
Verifying placement
# What nodes did my job get? $ scontrol show job 12345 | grep -E 'NodeList|BatchHost' NodeList=node-[041-048] BatchHost=node-041 # Are they on the same leaf? $ scontrol show topology node-041 SwitchName=leaf01 Level=0 Nodes=node-[001-064] LinkSpeed=400000 ... # All 8 nodes on the same leaf? Compare against topology.conf.
The performance engineer's diagnostic
"My allreduce is slower today than yesterday" — first move is to look at placement. If yesterday's job had all 8 nodes on one leaf and today's is split across two leaves, the spine traffic explains the slowdown. The fix is either to retry with --switches=1@5:00 or to talk to ops about why the placement diverged.
The placement-drift surprise
A cluster that was previously placing jobs cleanly within leaves can degrade as fragmentation accumulates over weeks. The scheduler can't compact a cluster without preempting running jobs. The performance engineer's signal: track per-job "switch-spread" (how many leaves the allocation spans) and alert when the distribution shifts.
§14GRES, GPUs, and the device plugin layer
Generic Resources (GRES) is how Slurm tracks anything that isn't a CPU or memory. The mechanism is general; in practice, 99% of GRES configuration on AI clusters is about GPUs.
GRES configuration on nodes
Each node declares its GRES in slurm.conf or via the autodetect mechanism:
# slurm.conf NodeName=node-[001-100] Gres=gpu:h100:8,nic:8,nvme:8 # gres.conf - detailed per-resource info, on each node Name=gpu Type=h100 File=/dev/nvidia0 Cores=0-13 Name=gpu Type=h100 File=/dev/nvidia1 Cores=14-27 Name=gpu Type=h100 File=/dev/nvidia2 Cores=28-41 Name=gpu Type=h100 File=/dev/nvidia3 Cores=42-55 Name=gpu Type=h100 File=/dev/nvidia4 Cores=56-69 Name=gpu Type=h100 File=/dev/nvidia5 Cores=70-83 Name=gpu Type=h100 File=/dev/nvidia6 Cores=84-97 Name=gpu Type=h100 File=/dev/nvidia7 Cores=98-111
The Cores= field is the magic. It tells Slurm which CPU cores are "closest" (PCIe-direct, same NUMA node) to each GPU. With this in place, Slurm can bind tasks to CPUs that match the GPU they're using — automatic NUMA-aware allocation.
The AutoDetect mechanism
Modern Slurm supports auto-detection so the operator doesn't have to write out every device by hand:
# gres.conf, autodetected AutoDetect=nvml Name=gpu Type=h100 File=/dev/nvidia[0-7]
With AutoDetect=nvml, Slurm queries NVML at startup, learns which GPUs are present, their model, NVLink topology, and PCIe affinity. Less to maintain by hand.
Requesting and binding GPUs
# Whole-node, all GPUs --gpus-per-node=8 # One GPU per task (most common for distributed training) --gpus-per-task=1 --ntasks-per-node=8 # Specific type --gres=gpu:h100:4 # Bind each task to the closest GPU (NUMA-aware) --gpu-bind=closest # Bind explicitly: rank 0 -> GPU 0, rank 1 -> GPU 1, etc. --gpu-bind=map_gpu:0,1,2,3,4,5,6,7 # Bind by bitmask: each task can see specific GPUs --gpu-bind=mask_gpu:0x01,0x02,0x04,0x08
How GPU isolation actually works
When Slurm allocates a subset of a node's GPUs to a step, it sets CUDA_VISIBLE_DEVICES in the step's environment to limit which GPUs the user's process can see. The other GPUs on the host still physically exist but are hidden by the runtime.
This is a key fact to internalize: Slurm doesn't enforce GPU isolation at the kernel level — it relies on CUDA respecting CUDA_VISIBLE_DEVICES. A motivated user can override it. The cgroup device controller (ConstrainDevices=yes in cgroup.conf) adds a second layer that actually denies access at the device-node level, and that's the production-grade configuration.
# cgroup.conf
ConstrainCores=yes
ConstrainRAMSpace=yes
ConstrainDevices=yes
With ConstrainDevices=yes, Slurm uses cgroup device controllers to make non-allocated GPUs literally unreadable to the job's processes. nvidia-smi from inside the job sees only the allocated GPUs; opening /dev/nvidia2 directly fails with EPERM.
MIG support
NVIDIA Multi-Instance GPU (MIG) partitions an A100/H100 into multiple isolated slices. Slurm exposes MIG slices as typed GRES:
# gres.conf with MIG slices AutoDetect=nvml Name=gpu Type=h100 File=/dev/nvidia0 Name=mig Type=1g.10gb File=/proc/driver/nvidia/capabilities/gpu0/mig/gi0/access Name=mig Type=2g.20gb File=/proc/driver/nvidia/capabilities/gpu0/mig/gi1/access
Users request slices: --gres=mig:1g.10gb:1. Common in inference fleets and shared-development clusters; less common in training, where whole-GPU allocation dominates.
Other GRES patterns worth knowing
- NICs —
Name=nic File=/dev/infiniband/uverbs[0-7]ingres.conf, requested as--gres=nic:4. For workloads that want explicit RDMA HCA allocation. - NVMe scratch —
Name=nvme File=/dev/nvme1n1 Count=2T, allowing per-job claim of local scratch storage. Critical for clusters where node-local NVMe is a shared resource. - Licenses —
Licenses=ansys:10,matlab:50inslurm.conf; jobs request--licenses=ansys:1. Centralized rationing of paid software seats.
GRES is Slurm's primary mechanism for "things that aren't CPUs and memory." Every device that gets allocated gets modeled here, and every per-device policy lives in this layer.
Where Slurm meets the GPU.
The previous three parts apply to any Slurm cluster — bioinformatics, weather, particle physics. This part is the AI-specific overlay: multi-node training launchers, NCCL integration, container conventions, and the operational patterns that keep large GPU jobs running. This is the territory the AI Performance & Efficiency role lives in.
§15GPU jobs: from request to running kernel
Trace a single GPU training job through the layers. Understanding this trace converts vague performance hypotheses into testable ones.
The interesting bugs live at the boundaries: between step 3 and 4 (cgroup misconfigurations), between 6 and 7 (rank-to-GPU mapping mismatches), between 7 and 8 (CUDA_VISIBLE_DEVICES exposure), between 8 and 9 (NCCL not picking the right NICs).
The rank-to-GPU contract
The convention that almost every distributed training framework assumes:
- One Slurm task per GPU on the node.
SLURM_LOCALIDon the node corresponds to the GPU index inCUDA_VISIBLE_DEVICES.- The framework reads
SLURM_PROCIDas global rank,SLURM_NTASKSas world size,SLURM_LOCALIDas local rank.
For PyTorch:
import os, torch, torch.distributed as dist rank = int(os.environ["SLURM_PROCID"]) world_size = int(os.environ["SLURM_NTASKS"]) local_rank = int(os.environ["SLURM_LOCALID"]) # Talk to the right GPU torch.cuda.set_device(local_rank) # Coordinate master_addr = os.environ["SLURM_LAUNCH_NODE_IPADDR"] master_port = 29500 dist.init_process_group( backend="nccl", init_method=f"tcp://{master_addr}:{master_port}", rank=rank, world_size=world_size, )
The diagnostic moves when "GPU jobs are weird"
| Symptom | First check |
|---|---|
| "Job sees no GPUs" | scontrol show job → did GRES actually allocate? Then CUDA_VISIBLE_DEVICES from inside the step. |
| "All ranks land on GPU 0" | Missing torch.cuda.set_device(local_rank) in user code, or all tasks see CUDA_VISIBLE_DEVICES=0,1,...7 instead of one each. |
| "Two ranks fight over a GPU" | --gpus-per-task not set; Slurm gave the whole step access to all 8 GPUs and the framework picked the same one twice. |
| "GPU is allocated but nvidia-smi shows nothing" | cgroup ConstrainDevices misconfigured, or the device files were created after Slurm started. |
| "NCCL hangs at init" | Master address/port reachability, or rank 0 didn't start, or firewall on the head node. |
§16Multi-node launchers: srun, pmix, torchrun
Three real launcher choices for distributed training. They have different ergonomics and different failure modes. A senior engineer can recognize all three in the wild.
Pattern A: srun directly
The Slurm-native way. srun launches one task per rank, sets SLURM_* env vars, and the training script reads them. PMIx coordinates the bootstrap if MPI is involved.
#!/bin/bash #SBATCH --nodes=8 --ntasks-per-node=8 --gpus-per-node=8 #SBATCH --cpus-per-task=14 export MASTER_ADDR=$(scontrol show hostname $SLURM_NODELIST | head -n1) export MASTER_PORT=29500 srun --mpi=pmix \ --cpu-bind=cores \ --gpu-bind=closest \ --label \ python train.py
In train.py, the rank info comes from SLURM_PROCID/SLURM_LOCALID. Cleanest. Requires that Slurm and PMIx are correctly built and that the user script is Slurm-aware.
Pattern B: srun + torchrun
The PyTorch-native pattern. srun launches one torchrun per node; torchrun fans out to one process per GPU within the node. PyTorch's rendezvous handles coordination.
#!/bin/bash #SBATCH --nodes=8 --ntasks-per-node=1 --gpus-per-node=8 #SBATCH --cpus-per-task=112 # all CPUs on the node for the single Slurm task export MASTER_ADDR=$(scontrol show hostname $SLURM_NODELIST | head -n1) export MASTER_PORT=29500 export NNODES=$SLURM_JOB_NUM_NODES export GPUS_PER_NODE=8 srun --label torchrun \ --nnodes=$NNODES \ --node_rank=$SLURM_NODEID \ --nproc_per_node=$GPUS_PER_NODE \ --rdzv_id=$SLURM_JOB_ID \ --rdzv_backend=c10d \ --rdzv_endpoint=$MASTER_ADDR:$MASTER_PORT \ train.py
Note --ntasks-per-node=1: each Slurm task is one torchrun, which then spawns 8 Python processes. This gives torchrun control over per-GPU subprocesses, including failure handling and elastic scaling.
Pattern C: srun --multi-prog or accelerate
Variations on the above with extra abstraction. HuggingFace accelerate launch, DeepSpeed's launcher, NeMo's launcher, Mosaic Composer — all wrap one of patterns A or B with friendlier defaults.
Comparison: which pattern when
| Concern | srun direct (A) | srun + torchrun (B) |
|---|---|---|
| Slurm awareness | Full — every rank is a Slurm task | Partial — Slurm sees node-level tasks |
| Failure detection | Slurm sees per-rank exits immediately | torchrun absorbs sub-process failures |
| Elastic training | Hard — Slurm allocation is fixed | Native — torchrun supports elastic |
| GPU pinning | --gpu-bind handles it | torchrun + framework set CUDA_VISIBLE_DEVICES |
| Cgroup containment | Each rank in its own cgroup | All ranks in one node-level cgroup |
| NCCL env consistency | Set once via Slurm env | Pass through torchrun |
| Restart on failure | Job-level requeue | torchrun can restart sub-processes |
For pure performance work — benchmarking, profiling, MFU measurement — Pattern A is cleaner because Slurm has direct visibility into every rank. For research workloads that need elastic resizing or per-rank fault tolerance, Pattern B is the better fit.
PMIx — what it is and why it matters
PMIx (Process Management Interface — Exascale) is the protocol Slurm and MPI use to bootstrap distributed jobs. Without it, MPI processes can't find each other; with it, MPI_Init in any rank can ask "who else is in this job and where do I reach them?" and the answer comes from slurmstepd via the PMIx server.
The Slurm option --mpi=pmix enables it. For PyTorch's NCCL backend, PMIx isn't strictly required (PyTorch uses its own rendezvous), but having it correctly installed means MPI-using libraries (UCX, NCCL-with-MPI bootstrap, certain profilers) work without further configuration.
The bootstrap surprise
NCCL has multiple bootstrap mechanisms. PyTorch defaults to its own TCP rendezvous (using MASTER_ADDR/MASTER_PORT). If you set NCCL_BOOTSTRAP=tcp explicitly but forget MASTER_ADDR, NCCL falls back to a different mechanism that may or may not work. The "first NCCL call hangs for two minutes" symptom is usually here.
§17NCCL integration: env, plugins, topology
NCCL doesn't know about Slurm. NCCL knows about networks, GPUs, and ranks. The integration is in the user's hand: set the right environment variables so NCCL picks the right network path, the right topology, and the right number of channels.
The NCCL env-var battery
The canonical setting block for a multi-node GPU job in a Slurm script:
# --- NCCL essentials --- export NCCL_DEBUG=INFO # at least once; remove after working export NCCL_DEBUG_SUBSYS=INIT,NET,ENV # filter to relevant subsystems # --- Network selection --- export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_6,mlx5_7,mlx5_8,mlx5_9 export NCCL_IB_GID_INDEX=3 # RoCE / IPoIB; check with show_gids export NCCL_SOCKET_IFNAME=ib0 # bootstrap interface export NCCL_IB_DISABLE=0 # 1 to force TCP for diagnostic export NCCL_NET_GDR_LEVEL=PIX # GPUDirect level # --- Stability/timeout --- export TORCH_NCCL_ASYNC_ERROR_HANDLING=1 export TORCH_NCCL_BLOCKING_WAIT=1 export NCCL_TIMEOUT=600 # seconds (override Python default) # --- Algorithm/protocol (tune only with measurement) --- export NCCL_MAX_NCHANNELS=16 export NCCL_BUFFSIZE=8388608
The principle: NCCL has many tuning knobs that interact with the fabric. For a healthy production cluster the operator has already established good defaults (often as cluster-wide environment loaded by module load nccl). The user's job is to not override them unless they have a measured reason.
Rail-aware HCA selection
The single most impactful NCCL setting on a rail-optimized fabric is NCCL_IB_HCA. On a DGX-class node with 8 GPUs and 8 IB HCAs (mlx5_0 through mlx5_9, sometimes with two for storage that aren't for fabric), specifying the right 8 HCAs ensures NCCL uses GPU-local NICs.
# Check NIC-to-GPU affinity $ nvidia-smi topo -m GPU0 GPU1 ... mlx5_0 mlx5_1 GPU0 X NV18 ... PIX NODE GPU1 NV18 X ... NODE PIX ... # PIX = direct PCIe switch path = fast. NODE = same NUMA, slower. SYS = crosses NUMA. # Set NCCL_IB_HCA to the 8 HCAs with PIX affinity to GPUs export NCCL_IB_HCA=mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_6,mlx5_7,mlx5_8,mlx5_9
Slurm-specific NCCL conventions
Three patterns that recur in well-run AI clusters:
- Cluster-wide NCCL env in a module. The cluster provides
module load nccl/2.21that exports all the NCCL_* variables the cluster expects. Users source it from their script; if it's wrong, ops fixes it once for everyone. - NCCL_DEBUG=WARN by default, INFO opt-in. Verbose NCCL output is too noisy for normal use but invaluable for debugging. The pattern:
export NCCL_DEBUG=${NCCL_DEBUG:-WARN}in the module, so users can override on demand. - Per-rank NCCL log files.
NCCL_DEBUG_FILE=/path/log.%h.%pso each rank's output goes to a separate file. Without this, an 8-node job interleaves all output into one stream and you can't tell whose log line you're reading.
The NCCL plugin layer
NCCL supports network plugins that replace its default transport. The most important is the AWS OFI plugin (and its EFA-specific variant), which lets NCCL use libfabric instead of NCCL's native TCP/IB transports. NVIDIA's own SHARP plugin enables in-network reduction on Mellanox switches.
# Tell NCCL where to find network plugins (some clusters set this in their module) export NCCL_NET_PLUGIN=ofi export LD_LIBRARY_PATH=/opt/aws-ofi-nccl/lib:$LD_LIBRARY_PATH # Or for SHARP export NCCL_COLLNET_ENABLE=1
Plugin selection happens at NCCL_DEBUG=INFO time. The log line "Using network IB" vs "Using network AWS Libfabric" vs "Using network CollNet" tells you which transport you got.
The diagnostic loop for "NCCL is slow"
Q: Is bandwidth lower than expected (nccl-tests baseline)? | +-- Yes, dramatically so -> wrong transport (TCP fallback?) | Check NCCL_DEBUG=INFO: "Using network ___" | Check NCCL_IB_DISABLE accidentally =1 | Check IB link state (ibstatus) | +-- Yes, mildly (10-30%) -> rail/topology issue | Check NCCL_IB_HCA matches node topology | Check rank-to-host placement (scontrol show job) | Check switch placement (sinfo --topology) | +-- Bandwidth ok but latency high -> protocol choice | Check NCCL_PROTO; LL/LL128 vs Simple matters | Check NCCL_ALGO; Tree vs Ring | +-- Bandwidth ok in tests, bad in framework -> overlap broken Profile with nsys; check stream serialization
§18Containers: enroot + pyxis, Apptainer
HPC clusters have always been allergic to root-required containers. The Docker-style daemon model doesn't fit shared multi-tenant systems where users can't be trusted with root. Two solutions have emerged: NVIDIA's enroot + pyxis pair, and Apptainer (formerly Singularity).
enroot + pyxis
enroot is a rootless container runtime — converts OCI images into chroot-style sandboxes, no daemon, no setuid binary beyond an unprivileged user mount helper. pyxis is a SPANK plugin (a Slurm extension point) that adds container-aware flags to srun and integrates with enroot.
# Pull and convert a container image to enroot's local format (cached) $ enroot import docker://nvcr.io#nvidia/pytorch:25.04-py3 # Use it in a Slurm job #!/bin/bash #SBATCH --nodes=8 --gpus-per-node=8 --ntasks-per-node=8 srun \ --container-image=nvcr.io/nvidia/pytorch:25.04-py3 \ --container-mounts=/scratch:/scratch,/home/$USER:/home/$USER \ --container-workdir=/scratch/$SLURM_JOB_ID \ --container-env=NCCL_DEBUG,NCCL_IB_HCA,MASTER_ADDR,MASTER_PORT \ python train.py
What pyxis does for you
- Each task runs inside the container, with Slurm's resource limits applied.
CUDA_VISIBLE_DEVICES, NCCL env, and Slurm rank vars are passed through into the container.- The container's userspace sees the host's GPU devices (via nvidia-container-runtime).
- Mounts are explicit; nothing else from the host is visible by default.
- Image caching is per-node; the same image is reused across jobs on the same node.
Apptainer (formerly Singularity)
The older HPC container runtime. Single-binary, no daemon, can run as a regular user. Uses a different image format (SIF) — a single file you can ship around like a script.
# Build an SIF from a Docker image $ apptainer build pytorch.sif docker://nvcr.io/nvidia/pytorch:25.04-py3 # Run inside Slurm srun apptainer exec --nv pytorch.sif python train.py # ^-- --nv passes through GPU devices
The --nv flag enables NVIDIA GPU passthrough. Apptainer's appeal: SIF is one file, easy to share, no registry needed. The downside: no native Slurm integration like pyxis.
Comparing the two
| Aspect | enroot + pyxis | Apptainer |
|---|---|---|
| Image format | OCI; converted to local sqsh | SIF (single file) |
| Slurm integration | Native (--container-image flag) | User runs apptainer exec |
| Per-task container | Yes — each srun task gets its own | Yes — but user manages it |
| Image distribution | Registry + per-node cache | Shared filesystem (one .sif file) |
| Build privileges | Can convert images as user | Building needs root or fakeroot |
| NVIDIA support | First-class (NVIDIA project) | Strong via --nv |
| Adoption | NVIDIA-led clusters | Older HPC, academic |
For modern AI infrastructure, enroot + pyxis is the conventional choice — first-class NVIDIA support, tight Slurm integration. Apptainer remains important in legacy and academic settings.
The container-image idioms
- Pre-pull images at the cluster level. A first-pull on demand takes minutes; pre-cached images start instantly.
- Pin image tags.
nvidia/pytorch:25.04-py3, notlatest. Reproducibility depends on it. - Mount the home directory and scratch explicitly; don't rely on defaults that might change.
- Use --container-env to pass through only what's needed.
§19Resilience: checkpoint/restart, requeue, hot spares
The reality of 1000+ GPU jobs: hardware fails, networks degrade, processes die. A multi-day training run survives only because it expects failure. The patterns:
Pattern: requeue on node failure
The simplest. With --requeue in the job script, Slurm automatically re-submits the job if a node fails (NODE_FAIL). If the training code resumes from the latest checkpoint, the run continues with at most a few minutes lost.
#!/bin/bash #SBATCH --requeue #SBATCH --signal=B:USR1@90 # 90s checkpoint warning before kill # Resume from the latest checkpoint if present srun python train.py \ --output_dir=/checkpoints/$SLURM_JOB_NAME \ --resume_from_checkpoint=auto
The script must be idempotent under requeue. Common pitfalls:
- Output directory named with timestamp at start — different name on requeue, breaks resume.
- Wandb/Tensorboard run created at start — second instance creates a new run, fragmenting metrics.
- Dataloader random state not in checkpoint — requeued job sees different data ordering.
Pattern: signal-triggered checkpoint
The signal handler is the key. Combined with --signal=B:USR1@SECS, the framework gets a heads-up before Slurm hard-kills:
import signal, sys, torch def on_signal(signum, frame): if rank == 0: torch.save(checkpoint_state, f"{ckpt_dir}/latest.pt") dist.barrier() sys.exit(0) signal.signal(signal.SIGUSR1, on_signal)
For PyTorch Lightning, NeMo, transformers Trainer, MosaicML Composer — all have built-in handlers that just need the right Slurm flag.
Pattern: hot spare nodes
For very large jobs, even with requeue the restart cost is large (cold disk caches, reloading weights, NCCL rebuild). Hot-spare patterns mitigate this:
- Over-allocate by 1-2 nodes. The job runs on N-2 nodes; the spares sit idle, ready to take over if a node fails.
- Elastic training (torchrun --max_restarts=N). The job continues with one fewer rank if a node fails, automatically rebalancing.
- Replica training. Two independent jobs train on the same data; if one fails the other has lost no progress.
Pattern: distributed checkpoint, async write
Checkpoint write time matters because it's pure overhead. For a 70B model, naive checkpoint can take 20+ minutes; distributed-async checkpoint drops it to under a minute.
In the Slurm context, what matters: the signal-to-actual-kill window must be larger than checkpoint write time. If checkpoint takes 5 minutes and --signal=B:USR1@60 only gives 60 seconds warning, the kill arrives mid-write. Match the warning to the actual checkpoint duration.
Pattern: prologue health checks
The other half of resilience: stop bad nodes from joining jobs in the first place. The Slurm Prolog script runs at job start on every node; if it exits non-zero, the node is drained and the job is requeued.
# /etc/slurm/prolog.d/10-gpu-check.sh, run as root before each job #!/bin/bash set -e # Are all 8 GPUs present and healthy? gpu_count=$(nvidia-smi --query-gpu=count --format=csv,noheader | head -n1) if [[ "$gpu_count" != "8" ]]; then echo "GPU count is $gpu_count, expected 8" >&2 exit 1 fi # Any XID errors in recent dmesg? if dmesg --since="1 minute ago" | grep -q NVRM:; then echo "Recent NVRM events found" >&2 exit 1 fi # Is IB up on all 8 HCAs? for hca in mlx5_{0..9}; do state=$(cat /sys/class/infiniband/$hca/ports/1/state 2>/dev/null || echo MISSING) if [[ "$state" != *ACTIVE* ]]; then echo "HCA $hca state: $state" >&2 exit 1 fi done exit 0
A failed prologue drains the node with the script's stderr as the drain reason — exactly what an operator needs for triage. The performance engineer's contribution here is recommending what checks to add based on patterns observed in past failures.
The resilience ladder
Tier 1: --requeue + a signal handler. Buys you survival across node failures. Cost: minutes per failure. Sufficient for most jobs.
Tier 2: --requeue + signal handler + over-allocate spares. Buys you near-instant recovery. Cost: a few wasted GPUs. Worth it for jobs > 24 hours.
Tier 3: elastic training + distributed checkpoint + spare pool. Buys you continuous availability across multiple failures. Cost: complex framework integration. Worth it for jobs > 1 week.
§20Cgroups, NUMA, CPU/GPU/NIC pinning
The last mile of performance for distributed training: making sure every task runs on the right CPU cores, with access to the right NUMA-local memory, the right GPU, and the right NIC. Done wrong, you pay 10-25% in throughput silently.
The cgroup hierarchy Slurm creates
With ProctrackType=proctrack/cgroup and TaskPlugin=task/cgroup, every job step lives in its own cgroup tree. Each task within the step has its own cgroup; resources are enforced at that level.
Each task_N directory has the standard cgroup v2 control files: cpu.max, memory.max, cpuset.cpus, cpuset.mems, devices.allow. Slurm writes these based on the job's allocation and the cgroup.conf settings.
The cgroup.conf knobs
# /etc/slurm/cgroup.conf ConstrainCores=yes # cpuset limits each task to allocated cores ConstrainRAMSpace=yes # memory.max enforces memory limit ConstrainSwapSpace=yes # prevent swap (kills before swap on GPU nodes) ConstrainDevices=yes # device cgroup limits /dev/nvidiaN access MaxRAMPercent=98 # cap memory at 98% of node (leave room for kernel) AllowedKmemSpace= # empty = unlimited kernel memory CgroupAutomount=yes
ConstrainDevices=yes is the one that matters most for AI clusters. Without it, GPU isolation is advisory (just CUDA_VISIBLE_DEVICES); with it, the kernel itself denies access to non-allocated GPUs.
CPU pinning options
srun has rich CPU-binding options:
| --cpu-bind= | Effect |
|---|---|
| none | No binding; OS scheduler is free. |
| threads | Bind to allocated SMT threads. |
| cores | Bind to allocated physical cores (recommended for HPC). |
| sockets | Bind to allocated sockets. |
| ldoms | Bind to NUMA "locality domains." |
| map_cpu:0,8,16,... | Explicit per-task CPU list. |
| mask_cpu:0xFF,0xFF00,... | Explicit per-task CPU bitmask. |
| verbose | Print the binding chosen (use during diagnosis). |
For multi-GPU training, the right answer is almost always --cpu-bind=cores plus relying on the GRES Cores= directive to pin each task to the cores closest to its GPU. Verify with --cpu-bind=verbose,cores and look at the stderr.
The NUMA-locality story
On a modern dual-socket node, half the GPUs are PCIe-attached to socket 0, half to socket 1. If a task assigned to GPU 0 runs on a CPU in socket 1, every host-to-device transfer crosses the QPI/UPI link — adding latency and contention.
With GRES Cores=0-55 for GPU 0 (cores on socket 0) and Cores=56-111 for GPU 4 (cores on socket 1), Slurm's allocator picks CPU cores matching the GPU. The result: each task's CPUs are on the same socket as its GPU, and pinned memory lives in the local NUMA node.
# Verify NUMA placement inside a job $ srun --cpu-bind=verbose,cores --label hostname 0: cpu-bind=MASK - node-041, task 0 0 [12345]: mask 0x00000000FFFFFFFF000000000000FFFF set 1: cpu-bind=MASK - node-041, task 1 0 [12346]: mask 0x0000FFFFFFFF00000000FFFF00000000 set ... # Check memory NUMA from inside the task $ srun -n 1 numactl --show policy: bind preferred node: 0 physcpubind: 0 1 2 3 ... 13 cpubind: 0 nodebind: 0 membind: 0
The NIC pinning question
NCCL picks NICs via NCCL_IB_HCA. There's no Slurm flag that maps "task N uses NIC X"; that decision lives in NCCL based on its topology view. The Slurm contribution: ensure rank-to-host placement is correct, and that the cluster's NCCL_IB_HCA module setting matches the hardware. NIC affinity then falls into place.
The diagnostic loop for "training is slow and I think it's affinity"
Q: Run --cpu-bind=verbose,cores and see the masks. Are they right? no -> Slurm allocator didn't pick correctly. Check gres.conf has Cores= field per GPU. Check ConstrainCores=yes. yes -> Run numactl --show in a task. Is membind = the same NUMA node as cpubind? no -> NUMA mismatch. Add --cpu-bind=ldoms or check task layout. yes -> Run nvidia-smi topo -m. Are GPU and CPU on same NUMA? no -> Wrong gres.conf Cores= mapping. yes -> Affinity is fine. Look elsewhere (NCCL, dataloader, etc).
When affinity is wrong, the symptoms are subtle: 10-25% throughput loss, slightly higher H2D copy time, sometimes mysteriously variable step times. None of them look like "affinity is wrong" — until you check.
Running it for thousands.
The performance engineer is not the operator, but works closely with operations. Understanding how Slurm is configured, monitored, and maintained at scale is what makes those collaborations productive. This is the part most user-facing Slurm books skip.
§21Configuration tuning for large clusters
A Slurm config that works fine for 100 nodes will choke on 5,000. The scheduling cycle, the RPC backlog, the state-save cadence, the database commit rate — all of these have knobs that need tuning as the cluster grows.
The configuration files
| File | Contents |
|---|---|
| slurm.conf | Main configuration. Cluster name, controller, plugins, scheduler params, partitions, nodes. |
| slurmdbd.conf | Accounting daemon configuration; DB credentials, storage type. |
| cgroup.conf | Cgroup constraints (cores, memory, devices, swap). |
| gres.conf | Per-node generic resources (GPUs, NICs, etc.). |
| topology.conf | Network topology — switches and the nodes hanging off them. |
| plugstack.conf | SPANK plugins (extension points): pyxis, plugins for accounting hooks, etc. |
| job_submit.lua | Lua script that runs on every job submission for validation, defaults, rewrites. |
Changes require an scontrol reconfigure — which gracefully reloads. Some changes (plugin changes, port changes) require a controller restart. Compute nodes pick up most changes via the same mechanism.
Scheduler tuning at scale
The single most-tuned section in any large cluster's slurm.conf:
SchedulerType=sched/backfill SchedulerParameters=\ bf_continue,\ bf_interval=30,\ bf_max_job_test=2000,\ bf_max_job_user=100,\ bf_max_job_part=1000,\ bf_resolution=60,\ bf_window=10080,\ bf_yield_interval=1000000,\ bf_yield_sleep=200000,\ default_queue_depth=1000,\ sched_max_job_start=100,\ sched_min_interval=2000000,\ defer DefMemPerCPU=3000 MaxJobCount=500000 # total jobs in queue + recently-completed MaxArraySize=100001 # max array task index + 1 MaxTasksPerNode=512 TreeWidth=128 # fanout for fwd controller->slurmd msgs MessageTimeout=30 SlurmctldTimeout=600 SlurmdTimeout=600
bf_max_job_test— total jobs backfill considers per cycle. Defaults to 100; large clusters with deep queues need 1000-5000.bf_yield_interval / bf_yield_sleep— how often backfill yields to other RPCs (microseconds). Critical: if backfill never yields,squeuehangs for users while the cycle runs.default_queue_depth— main scheduler depth. Same scale-up reasoning.defer— don't run the main scheduler on every job submission; coalesce. Hugely reduces controller load on bursty submission patterns (job arrays, workflow tools).TreeWidth— Slurm uses a tree topology to dispatch messages from controller to slurmds. With 128, the controller talks to 128 nodes directly; each of those forwards to up to 128 more. For 5000 nodes, 128 gives a 2-level tree; for 50000 a 3-level. Tune so depth stays at ≤3.
State-save and snapshots
StateSaveLocation=/var/spool/slurmctld SlurmctldDebug=info SlurmSchedLogLevel=0 # 0 off, 1 on; very expensive at scale
The state-save location is where slurmctld persists job/node state. It must be on fast, reliable storage — NVMe or strong network filesystem. State files are written every few seconds; slow storage backs up the controller. On 5,000-node clusters, this is non-negotiable: state-save on slow storage is the #1 cause of controller unresponsiveness.
The controller's resource needs
The controller is a single process. For a large cluster:
- CPU: enough cores for the scheduler thread + multiple RPC handler threads. 16-32 cores is typical.
- Memory: holds all job/node/reservation state. Plan ~1 KB per pending job + per node. 64 GB is generous for most clusters.
- Network: every node connects to the controller. Make sure NIC bandwidth and conntrack capacity scale.
- Storage: state-save dir on local NVMe with strict latency targets.
The "controller fell behind" syndrome
Symptoms of an overloaded controller
squeuehangs for tens of seconds.sbatchtakes seconds to return.- Job state transitions visibly lag — jobs stay in COMPLETING for minutes.
- Nodes intermittently go into
NOT_RESPONDINGthen come back. slurmctld.logshows lines like "Warning: scheduler running for X seconds."
The fixes are almost always in the SchedulerParameters above, plus state-save performance, plus TreeWidth tuning.
§22Accounting (slurmdbd), fair-share, limits
The accounting database is more than a record-keeping system. It's where the cluster's allocation policies live, where every job's resource use is captured, and where fair-share gets its inputs.
The slurmdbd architecture
slurmdbd sits between the controller and the SQL database. It buffers writes, batches them, and presents a single point of contact for accounting queries. Multiple controllers from federated clusters can talk to the same slurmdbd.
Associations and the hierarchy
An association is the tuple (cluster, account, user, partition, qos). Limits and fair-share shares are set per association. The full hierarchy is a tree:
# Create accounts sacctmgr add account ai-research description="AI research org" sacctmgr add account nlp parent=ai-research description="NLP team" sacctmgr add account vision parent=ai-research description="Vision team" # Assign shares (fair-share weights) sacctmgr modify account ai-research set Fairshare=50 sacctmgr modify account nlp set Fairshare=30 sacctmgr modify account vision set Fairshare=20 # Add users sacctmgr add user alice account=nlp Fairshare=parent sacctmgr add user bob account=vision Fairshare=parent # Per-user limits sacctmgr modify user alice account=nlp set MaxJobs=20 MaxSubmitJobs=200 # Per-account TRES (resource) limits sacctmgr modify account vision set GrpTRES=cpu=5000,gres/gpu=256 # Budget — total GPU-minutes over a window sacctmgr modify account nlp set GrpTRESMins=gres/gpu=100000
Common limits and what they enforce
| Limit | Meaning |
|---|---|
| MaxJobs | Concurrent running jobs. |
| MaxSubmitJobs | Jobs in the queue (running + pending). |
| MaxTRESPerJob | Per-job resource cap (e.g., max GPUs per job). |
| GrpTRES | Total concurrent resources across all the association's jobs. |
| GrpTRESMins | Cumulative resource-minutes over time. The budget. |
| MaxWallDurationPerJob | Per-job time limit cap. |
| GrpWall | Total wall-clock time consumed. |
When a limit is hit, jobs stay in PENDING with reason AssocGrpGRESMinutes or similar. The job will eventually run when usage decays below the cap (for cumulative limits) or finishes when other jobs complete (for concurrent limits).
The decay mechanism
PriorityDecayHalfLife in slurm.conf controls how fast historical usage "decays" out of fair-share calculations. Common values: 7 days (typical), 14 days (for slower-moving research clusters), 30 days (for very long-cycle workloads). A 7-day half-life means a job from 7 days ago counts half as much; a job from 14 days ago counts a quarter; etc.
Researchers ask: "When will my fair-share recover?" The answer is "with the half-life cadence, conditional on you not using more in the meantime." Set researcher expectations: bursts of usage will depress priority for ~2-3 half-lives.
Reporting from the database
# Per-user GPU-hours last month sreport user TopUsage start=2026-04-01 end=2026-05-01 TopCount=20 -t hours # Per-account utilization sreport cluster AccountUtilizationByUser start=2026-04-01 end=2026-05-01 # Cluster utilization (idle vs allocated vs drained, over time) sreport cluster Utilization start=2026-04-01 end=2026-05-01 # Direct SQL on slurm_acct_db (read-only user, please) SELECT acct, SUM(time_end - time_start) / 3600 AS gpu_hours, COUNT(*) AS job_count FROM cluster_job_table WHERE time_end > UNIX_TIMESTAMP('2026-04-01') AND tres_req LIKE '%1001=8%' -- 1001 is gres/gpu in TRES IDs GROUP BY acct ORDER BY gpu_hours DESC;
The performance engineer mines this database for fleet-wide signals: per-account efficiency, per-job-class MFU, the long-tail of users whose jobs all OOM, the distribution of queue times. The accounting DB is the single most valuable observability surface that isn't a profiler.
§23HA, failover, disaster patterns
Slurm has supported controller HA since early days. The pattern: primary + backup slurmctld sharing a state-save directory. If the primary dies, the backup takes over by reading the shared state.
The HA setup
# slurm.conf SlurmctldHost=ctl-01(10.0.0.10) # primary SlurmctldHost=ctl-02(10.0.0.11) # backup StateSaveLocation=/shared/slurmctld # on a shared FS, NFS or Lustre
Both hosts run slurmctld; only one is active at a time. The active one writes state to StateSaveLocation. The backup polls; if the primary stops responding, it takes over by reading the state and resuming.
The shared filesystem for state-save needs:
- Low latency (every state operation hits it).
- POSIX-compliant locking (Slurm uses file locks for coordination).
- Higher availability than the controllers (you can't lose state if the FS dies).
NFS works for clusters up to a few thousand nodes. Lustre or GPFS for larger. Some clusters use DRBD-replicated local NVMe with manual switchover.
Failover semantics
When the backup takes over:
- Running jobs continue uninterrupted.
slurmdon the compute nodes doesn't need to talk to the controller for already-running jobs; it just reports state back and the backup picks up where the primary left off. - Queued jobs continue queuing. The backup reads the same queue from the state files.
- In-flight RPCs (sbatch, scontrol, etc.) may fail and need retry. Most clients retry automatically.
- Failover detection takes some seconds to minutes depending on timeout values.
The slurmdbd HA story
The accounting database is also a single point. The pattern: MariaDB/MySQL replication with manual failover (active-passive), or Galera/InnoDB cluster for active-active. slurmdbd itself can be active-passive (the standby just sits there until the primary fails).
The implication: short outages of slurmdbd are tolerable. The controller buffers accounting writes; when the DB comes back, the buffer drains. Multi-hour DB outages eventually fill the buffer and start dropping accounting data, which is non-recoverable.
Disaster scenarios
| Scenario | Behavior |
|---|---|
| Primary controller dies | Backup takes over in seconds; running jobs unaffected. |
| Both controllers die | Running jobs continue, but no new jobs start, no completions registered. Restart either to recover. |
| Shared state-save FS goes offline | Controller cannot persist state; eventually exits. Jobs continue running but new state is lost. |
| slurmdbd offline | Controller buffers accounting writes; users see degraded sacct queries. Time-limited. |
| A node's slurmd dies | Node goes NOT_RESPONDING after SlurmdTimeout (5-10 min). Running jobs on it may be killed. |
| Network partition between controller and compute | Affected nodes go NOT_RESPONDING; jobs on them are at risk after timeout. |
Key principle: Slurm is robust against controller failure but not against state loss. Run the state-save directory on storage you trust. Snapshot it regularly.
§24Monitoring & observability
Three observability layers a healthy cluster has: Slurm-internal metrics, cluster-physical metrics, and per-job metrics. The performance engineer cares about all three but lives mostly in the third.
The Prometheus exporter
prometheus-slurm-exporter (or its modern variants) scrapes Slurm via squeue, sinfo, sacct and exposes them as metrics. The standard set:
| Metric | Use |
|---|---|
| slurm_partition_nodes | Nodes per partition by state (idle/alloc/drain/down). |
| slurm_queue_pending | Pending jobs per partition. |
| slurm_queue_running | Running jobs per partition. |
| slurm_job_state | Jobs by state (running, pending by reason, etc.). |
| slurm_node_cpu_used / _alloc | CPU utilization per node. |
| slurm_node_gres_used / _alloc | GRES (GPU) allocation per node. |
| slurm_scheduler_cycles | Counter of scheduler runs. |
| slurm_scheduler_seconds | How long each cycle took. |
The dashboards that matter
For an AI cluster, six dashboards are non-negotiable:
- Cluster heatmap — every node shown as a tile, colored by state. One glance shows draining, down, busy.
- Queue depth by partition — pending jobs over time. Spikes suggest scheduling issues or sudden demand.
- Per-job GPU utilization — DCGM-sourced. Outliers below 30% are candidates for performance attention.
- Scheduler health — cycle time, RPC backlog, controller CPU usage. Detects controller overload before users complain.
- Per-account usage — GPU-hours, last 24h / 7d / 30d. Detects budget overruns and fair-share imbalances.
- Job lifecycle — pending → running → completed durations. Detects scheduler problems and excessive failure rates.
The Slurm logs
Three logs every operator and performance engineer can grep:
| Log | Where | What's in it |
|---|---|---|
| slurmctld.log | Controller host | Job submissions, scheduling decisions, node state changes, errors. Most important log. |
| slurmd.log | Each compute node | Local node events: job launches, prolog/epilog runs, errors, slurmd-controller comms. |
| slurmdbd.log | DB host | Accounting database operations. |
Useful grep patterns:
# Find what happened to a specific job grep "JobId=12345" /var/log/slurmctld.log # Recent scheduling delays grep "Warning: scheduler" /var/log/slurmctld.log # Node failures and reasons grep -E "(node_fail|DRAINED|NODE_FAIL)" /var/log/slurmctld.log # Prolog failures (the root cause of mysterious requeues) grep -A 3 "Prolog failed" /var/log/slurmd.log # Communication issues from a controller's view grep "unable to register" /var/log/slurmctld.log # Failed RPC retries grep "Failed to forward RPC" /var/log/slurmctld.log
Per-job metrics via DCGM
For GPU jobs, Slurm itself doesn't capture detailed GPU metrics. The pattern: DCGM exporter on every node, scraped per-GPU into Prometheus, labeled with the Slurm job ID via DCGM-Slurm integration. The result: time-series GPU metrics queryable by job.
# PromQL: GPU utilization for job 12345 over time DCGM_FI_PROF_PIPE_TENSOR_ACTIVE{slurm_job="12345"} # MFU (approximated by tensor pipe active rate) averaged over the job avg_over_time(DCGM_FI_PROF_PIPE_TENSOR_ACTIVE{slurm_job="12345"}[1h])
The integration is achieved either by tagging GPUs at job-start (via prolog scripts that write the job ID to a per-GPU label) or by querying DCGM-aware exporters that already correlate.
Slurm-web and friends
Several open-source web UIs sit on top of Slurm: Slurm-web, Open OnDemand, sview (Slurm's own X11 tool, mostly dead). For most users the CLI is enough; for casual users and managers, a web UI showing queue state and per-account usage justifies itself.
§25Health checks & node lifecycle
Bad nodes are the cluster's most persistent enemy. A single degraded node can ruin large training jobs by becoming the straggler. The defense: continuous health checks and aggressive draining.
The Slurm health-check hook
HealthCheckProgram in slurm.conf is a script run periodically on every node. Returns 0 = healthy; non-zero = drain the node with the script's output as the reason.
# slurm.conf HealthCheckProgram=/etc/slurm/healthcheck.sh HealthCheckInterval=300 # every 5 minutes HealthCheckNodeState=ANY,CYCLE # run on all nodes always
# /etc/slurm/healthcheck.sh #!/bin/bash set -e # GPU count check gpu_count=$(nvidia-smi --list-gpus | wc -l) [[ $gpu_count -eq 8 ]] || { echo "gpu_count=$gpu_count"; exit 1; } # GPU temperature check while read -r temp; do [[ $temp -lt 85 ]] || { echo "gpu_temp=$temp"; exit 1; } done < <(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader) # ECC errors ecc=$(nvidia-smi --query-gpu=ecc.errors.uncorrected.aggregate.total --format=csv,noheader,nounits | head -n1) [[ $ecc -eq 0 ]] || { echo "ecc_errors=$ecc"; exit 1; } # IB link state for hca in /sys/class/infiniband/mlx5_*; do state=$(cat $hca/ports/1/state) [[ $state == *ACTIVE* ]] || { echo "ib_$(basename $hca)=$state"; exit 1; } done # NVLink errors nvlink_errors=$(nvidia-smi nvlink -e 2>/dev/null | grep -i error | wc -l) [[ $nvlink_errors -eq 0 ]] || { echo "nvlink_errors=$nvlink_errors"; exit 1; } # File systems mounted for mp in /scratch /home /shared; do mountpoint -q $mp || { echo "unmounted=$mp"; exit 1; } done exit 0
DCGM diagnostics
dcgmi diag runs structured GPU health checks. Three diagnostic levels:
dcgmi diag -r 1 # quick: ~10 seconds, basic memory and SM checks dcgmi diag -r 2 # medium: ~2 minutes, more thorough dcgmi diag -r 3 # long: ~30 minutes, includes pcie bandwidth, NVLink, heat
Run level 1 as part of health checks; level 2 nightly on drained nodes; level 3 on suspected-faulty nodes before returning to service.
Node lifecycle automation
A mature cluster has automation that closes the loop:
- Health check fails → node DRAINED with reason.
- Monitoring sees the drain → ticket created in the ops system.
- Diagnostic suite runs automatically on the drained node.
- If hardware fault confirmed → human triage.
- If transient (e.g., one ECC retired) → auto-resume after diagnostic passes.
- Persistent issues → node tagged for hardware replacement.
The performance engineer's role: when a job's straggler analysis points at a specific node, ensure that node enters this lifecycle and doesn't get re-allocated to the next unsuspecting job.
The drain culture
A cluster's culture about draining matters. Two failure modes:
- Too cautious: a node with one transient hiccup stays drained for days; capacity is wasted.
- Too aggressive: the same flaky node returns to service repeatedly and ruins multiple jobs before someone notices.
The right balance is "drain on first sign of trouble; require diagnostic-passing return-to-service; track per-node failure history; retire nodes with chronic issues." The performance engineer contributes to this by surfacing the per-node correlation: "this node has been a straggler in 4 of the last 10 jobs; here's the data."
A cluster's effective capacity isn't its node count; it's its node count minus bad nodes minus drained capacity. Healthy node lifecycle automation is what keeps that number close to the first.
When things go wrong.
Slurm has a clear state machine and a predictable failure vocabulary; once you know them, diagnosis collapses from "no idea" to "look at this specific log for this specific reason." The four chapters here are the troubleshooting layer.
§26The state machine: every job state explained
Slurm tracks a job through a state machine. Knowing every state and its valid transitions is the foundation for "why is my job stuck?"
| State | Meaning |
|---|---|
| PENDING (PD) | Waiting in the queue. The Reason field tells you why. |
| CONFIGURING (CF) | Allocation chosen; prolog scripts running; brief. |
| RUNNING (R) | Actively executing. |
| COMPLETING (CG) | Job's processes exited; epilog running; brief. |
| COMPLETED (CD) | Finished successfully (exit 0). |
| CANCELLED (CA) | Killed by user/admin via scancel. |
| FAILED (F) | Process exited non-zero. |
| TIMEOUT (TO) | Hit the wall-clock time limit. |
| NODE_FAIL (NF) | A node failed during the job. |
| OUT_OF_MEMORY (OOM) | Memory cgroup killed the job. |
| PREEMPTED (PR) | Preempted by a higher-priority job. |
| SUSPENDED (S) | Paused (preempt-suspend or admin). |
| REQUEUED (RQ) | Returned to queue (preempt-requeue, node-fail-requeue, manual). |
| SPECIAL_EXIT (SE) | Exited with the magic 142 code that means "requeue me." |
| REVOKED (RV) | Sister federation job, this cluster declined. |
| BOOT_FAIL (BF) | Node boot during job startup failed. |
The state-and-reason pair
Every job state, especially PENDING, comes with a Reason. The pair is the diagnostic key. squeue -o "%i %T %r" shows JobID, State, Reason.
The state transitions that surprise people
- RUNNING → REQUEUED: a node failed; with
--requeue, the job goes back to the queue. To the user, the job "disappeared" briefly and re-emerged with the same ID. - RUNNING → NODE_FAIL → COMPLETED: NODE_FAIL is the terminal state when
--requeueis not set. - COMPLETING for minutes: epilog is slow, or output flushing is, or filesystem is. Watch for this pattern; it indicates infrastructure issue.
- PENDING → CANCELLED with "DependencyNeverSatisfied": an upstream job failed and
afterokcan never trigger.
§27PENDING reasons decoded
The Reason field on a PENDING job is the single most important diagnostic in everyday Slurm use. Below: every common reason, what it means, and what to do about it.
| Reason | Meaning · action |
|---|---|
| Priority | Higher-priority jobs ahead of you. Wait or boost priority. Check sprio. |
| Resources | Required resources not currently available. Wait for jobs to finish. |
| ReqNodeNotAvail | You requested specific nodes; some are down/drained. Check sinfo for them. |
| Dependency | Waiting for the dependency job to satisfy its condition. |
| DependencyNeverSatisfied | Upstream job ended in a state that won't trigger your dependency (e.g., afterok on a failed job). Job will be auto-cancelled. |
| JobHeldUser / JobHeldAdmin | Job is held; release with scontrol release JOBID. |
| BeginTime | Submitted with --begin=; waiting for that time. |
| ReqNodeNotAvail, UnavailableNodes | Specific named nodes are not available now. |
| PartitionDown / PartitionInactive | The partition is offline. Operator action needed. |
| PartitionNodeLimit | Requested more nodes than partition allows. |
| PartitionTimeLimit | Requested --time exceeds partition's max. |
| QOSMaxJobsPerUserLimit | You've hit the per-user job cap in this QoS. |
| QOSMaxJobsPerAccountLimit | Your account has hit its job cap. |
| QOSGrpCpuLimit, QOSGrpGresLimit | QoS's concurrent-resource cap is full. |
| QOSGrpGRESMinutes | QoS budget exhausted (cumulative GPU-minutes). |
| AssocMaxJobsLimit | Hit per-association concurrent-job cap. |
| AssocGrpCPUMinutesLimit, AssocGrpGRESMinutes | Association's cumulative budget exhausted. |
| NodeDown | The node(s) your job was about to run on went down. Will reschedule. |
| ReservationRequired | Partition requires a reservation, and you didn't specify one. |
| InvalidQOS | Requested QoS doesn't exist or you don't have access. |
| InvalidAccount | Requested account invalid for your user. |
| BadConstraints | Constraint expression syntactically wrong or unsatisfiable. |
| QOSMaxWallDurationPerJobLimit | Requested time exceeds QoS's max. |
| NonZeroExitCode | Job in a dependency chain — predecessor exited non-zero (with afterok). |
| SystemFailure | Slurm internal error. Check controller logs. |
| PartitionConfig | Partition can't satisfy the request structurally (e.g., max nodes, max time). |
"Priority" — the most common
If your job is PENDING with reason "Priority", it means: there are jobs ahead of you in priority order, and they together would consume the resources you want. Wait, raise priority (if you have a way), or change request to something easier (shorter, smaller).
"Resources" — the second most common
You're at the top of priority but the resources aren't physically available. Different from "Priority"; here, there's no contention from queued jobs, just no free hardware. Check sinfo to see how much is free in the partition.
The "I waited an hour and my job didn't start" diagnostic
Q: What's the Reason in squeue? | +-- Priority -> look at sprio. Job ahead of you? | +-- yes -> wait, or lower request, or change QoS for priority bias. | +-- no -> backfill cycle isn't reaching you. Check bf_max_job_test. | +-- Resources -> sinfo -t IDLE | wc shows free nodes. | +-- enough free -> probably constraint mismatch. Check --constraint. | +-- not enough -> wait for jobs to finish. Estimate via squeue --start. | +-- ReqNodeNotAvail -> the specific nodes you named are down/drained. | Drop --nodelist or wait for the nodes. | +-- QOS/Assoc...Limit -> you're over a limit. sacctmgr show association. | Wait for usage to decay, or talk to the admin. | +-- Dependency -> upstream job. Check it with squeue. | +-- BadConstraints / InvalidQOS / etc. -> bug in submit script. Fix and resubmit.
Estimated start time
# Slurm's estimate of when each pending job will start squeue --start -u $USER # For a specific job scontrol show job 12345 | grep StartTime
The estimate comes from the backfill scheduler's lookahead. It's an upper bound — jobs can start earlier if higher-priority jobs finish ahead of schedule — but rarely later. Useful for users planning their day.
§28Logs: what's where, what to grep for
Every Slurm bug is a log-grep problem. Knowing what to look for, in which file, on which host, is half the skill.
Log file map
| Log file | Host · contents |
|---|---|
| /var/log/slurmctld.log | Controller host. Everything that touches the scheduler. |
| /var/log/slurmd.log | Each compute node. Local job lifecycle events. |
| /var/log/slurmdbd.log | Accounting DB host. Database operations and errors. |
| /var/log/munge/munged.log | Each host. MUNGE credential service. |
| job's stdout/stderr | Wherever --output/--error directed them. |
| /var/spool/slurmctld/ | Controller state files. Not human-readable but their presence/age tells stories. |
| /var/spool/slurmd/job.X/ | Compute node's per-job working directory. Contains script.sh, environment, etc. |
Tracing one job
# 1. Get the full job record scontrol show job 12345 > /tmp/job12345.txt # 2. Controller log entries for this job grep "JobId=12345" /var/log/slurmctld.log # 3. Pull the allocated nodes and check their slurmd logs NODES=$(scontrol show job 12345 -o | grep -oP 'NodeList=\S+' | cut -d= -f2) for n in $(scontrol show hostnames $NODES); do ssh $n "grep '12345' /var/log/slurmd.log" > /tmp/slurmd-$n.log done # 4. Look at accounting for the final state sacct -j 12345 -o "JobID,State,ExitCode,Reason,DerivedExitCode,Comment"
The grep patterns that pay off
# Recent prolog failures (mysterious requeues come from here) grep "prolog" /var/log/slurmd.log | grep -i fail # Communication failures between controller and slurmd grep -E "(unable to connect|connection refused|timeout)" /var/log/slurmctld.log # OOM events grep -E "(out of memory|oom|memory limit)" /var/log/slurmd.log # NCCL or torch errors that ended up in job stderr grep -E "(NCCL|c10d|distributed)" /path/to/job.err # Backfill scheduler taking too long grep "backfill" /var/log/slurmctld.log | grep -E "seconds"
Increasing log verbosity
# slurm.conf SlurmctldDebug=debug3 # or info, verbose, debug, debug2, debug3 SlurmdDebug=debug DebugFlags=Backfill,Gres,Priority,Steps # subsystem flags
Bumping SlurmctldDebug from info to debug generates much more output (hundreds of MB/day on a busy cluster). Use temporarily; turn it back down.
The DebugFlags approach is better for targeted debugging: DebugFlags=Backfill adds backfill-cycle details without flooding other categories.
sdiag — controller introspection
$ sdiag ******************************************************* sdiag output at Sun May 25 14:23:19 2026 Data since Sun May 25 00:00:02 2026 ******************************************************* Server thread count: 9 Agent queue size: 0 # RPCs waiting to fan out — >0 is congestion Agent count: 0 Jobs submitted: 8472 Jobs started: 8311 Jobs completed: 8204 Main schedule statistics (microseconds): Last cycle: 12343 # last scheduling cycle duration Max cycle: 442181 # worst case in this period Total cycles: 1843 Mean cycle: 28401 # average ... Backfilling stats: Total backfilled jobs (since last slurm start): 3209 Total backfilled jobs (since last stats cycle start): 187 Last cycle: 94002 # last backfill duration Mean cycle: 88301 ...
sdiag is the single best command for "is the controller healthy?" Agent queue size > 0 sustained means RPCs are backing up. Mean scheduling cycle > a few hundred ms suggests tuning is needed.
§29Decision trees for common failures
Six recurring user-reported issues, mapped to diagnostic paths. The senior engineer can run these without thinking.
"My job won't start"
Q: squeue -j JOBID -o "%T %r" — state and reason?
PENDING / Priority -> wait, or see §27
PENDING / Resources -> sinfo for capacity; estimate via squeue --start
PENDING / Dependency -> check upstream job
PENDING / *Limit -> hit a limit, see §22
PENDING / BadConstraints-> fix submit script
PENDING / NodeDown -> nodes are down; scontrol show nodes
CONFIGURING (long) -> prolog is slow or hanging
RUNNING -> it already started; user is confused
"My job ran but produced no output"
Q: Did the output file get created? no -> path doesn't exist or no write permission. Check --output path. yes, empty -> job exited too fast OR stdout is buffered. Add python -u. yes, partial -> job died. Check job state. Q: sacct -j JOBID -o "JobID,State,ExitCode,Reason" State=COMPLETED ExitCode=0:0 -> job ran clean, just produced nothing State=FAILED -> grep .err for the trace State=OUT_OF_MEMORY -> increase --mem State=TIMEOUT -> increase --time State=NODE_FAIL -> hardware; check node status, requeue State=CANCELLED -> someone (or something) ran scancel
"My distributed training hangs at startup"
Q: Did all ranks make it past process_group init?
no -> bootstrap problem
-> grep "Connecting" in NCCL_DEBUG=INFO output
-> verify MASTER_ADDR is reachable from all ranks
-> firewall? scontrol show node | check
yes, some ranks did, some didn't
-> mismatched config; some ranks have different env
-> check NCCL_IB_HCA, MASTER_PORT same everywhere
yes all initialized, hangs at first collective
-> network not actually working between some pairs
-> ib_write_bw between the suspect pairs
-> check NCCL channel construction in INFO log
"My job is using less GPU than expected"
Q: nvidia-smi inside the job shows what? fewer GPUs than allocated -> CUDA_VISIBLE_DEVICES wrong -> torch.cuda.device_count() check all GPUs, low utilization -> framework issue, see AI perf manual §09 GPUs showing wrong model -> different gres.conf type than expected Q: scontrol show job — what GRES was allocated? matches request -> Slurm did its job; investigate further in framework doesn't match -> gres.conf miscount, or --gres typo, or partition limits
"Jobs from one user are failing on a specific node"
Q: Are the failures NODE_FAIL, FAILED, or something else? NODE_FAIL -> the node itself died during the job OUT_OF_MEMORY -> per-job; user requested too little memory OR memory leak FAILED with non-zero exit -> the job's command exited TIMEOUT -> wall clock hit; not node-related Q: Is it always the same node? yes -> investigate the node: dcgmi diag, XID errors, IB state no -> distributed across nodes; likely user code issue, not infra Q: Same user only, or others affected? same user only -> user-script issue (env, paths, output handling) others too -> infra issue; drain node, escalate to ops
"Backfill seems broken — many jobs would fit but don't run"
Q: Check sdiag. Is backfill running successfully? Last cycle > 60s -> backfill is stuck; controller may be overloaded Total backfilled = 0 -> bf_max_job_test or bf_max_job_user too restrictive Reasonable counts -> the candidate jobs may not be eligible for backfill Q: Are pending jobs in backfill-eligible states? Held, dependent, ineligible BeginTime, etc. -> backfill skips them Have explicit --time limits? -> jobs without --time can't backfill because backfill needs to know they'll fit in the window
The senior diagnostic move
When a user reports a Slurm issue, the first three commands are always:
squeue -j JOBID -o "%T %r %R" · scontrol show job JOBID · sacct -j JOBID -o "JobID,State,ExitCode,Reason"
Together they tell you the state, the reason, the allocation, and the final disposition. Most diagnoses converge after these three.
Two schedulers, one question.
Slurm and Kubernetes are both schedulers, both used for AI workloads, and increasingly compared by anyone planning AI infrastructure. This part walks the comparison feature by feature — not from a "which is better" stance, but from the operational reality of what each does well and where they hurt. The decision framework is in §37.
§30Architectural comparison
The two systems were designed for different problems, and their architectures still reflect that.
Slurm
Single controller (with optional standby), one daemon per compute node, MUNGE-authenticated RPC. State held in memory, periodically persisted. Plugin-driven. No assumption about what's running on the nodes beyond Linux + Slurm daemons.
Kubernetes
Replicated control plane (etcd quorum + API server + controllers + scheduler), one kubelet per node, mutual TLS. State held in etcd, the source of truth. Declarative model: users describe what they want, controllers reconcile reality to match. Assumes container runtime + CNI + CSI on every node.
Side by side
| Aspect | Slurm | Kubernetes |
|---|---|---|
| Control plane | 1 active slurmctld (+1 standby) | 3+ replicas of API server, controller-manager, scheduler; HA via etcd raft |
| State backend | In-memory + on-disk state files | etcd (distributed KV store) |
| Node agent | slurmd (single daemon) | kubelet + container runtime + CNI + CSI plugins |
| Authentication | MUNGE (shared key, time-synced) | mTLS + RBAC + ServiceAccount tokens |
| API style | Imperative CLI (sbatch, scancel) | Declarative REST (YAML, kubectl apply) |
| Extension model | Plugins compiled in; SPANK for stepd; job_submit.lua for policy | CRDs + custom controllers (operators); admission webhooks |
| State recovery | State files + RPC re-registration with slurmd | etcd is authoritative; controllers reconcile from there |
| Scale ceiling | ~10K-100K nodes per cluster (with tuning) | ~5K nodes per cluster (official); larger with federation |
Why etcd vs in-memory matters
Slurm's state model gives it speed: scheduling decisions don't hit a database. Slurm can schedule tens of thousands of jobs per minute on hardware where Kubernetes would struggle. The flip side: state loss is catastrophic (state-save FS failure = job state loss), and HA is limited to active-passive.
Kubernetes's etcd backend gives it durability and horizontal HA: kill any control-plane node and the cluster keeps working. The cost: every state change is a raft consensus write. Pod creation rates of 100/sec are healthy; 1000/sec needs careful tuning; 10000/sec is research territory. Slurm tops these numbers by orders of magnitude.
The declarative vs imperative split
Kubernetes: "I want a Deployment with 5 replicas of my container." The system figures out how to make that true and keeps it true. If a pod dies, a new one starts.
Slurm: "Run this script with these resources for up to 24 hours." Slurm runs it, and if it dies, it stays dead unless you submitted with --requeue.
The declarative model is a beautiful match for service workloads (you want 5 replicas, always). It is awkward for training (you don't want 5 replicas of a 70B training run; you want one run that, if it dies, restarts from checkpoint). Both systems have grown features to address the mismatch, but the underlying mental models differ.
§31Scheduling models compared
The schedulers do very different things by default.
The default scheduling unit
Slurm schedules a job: a set of resources allocated together for a bounded time. Either all resources arrive or none do (gang scheduling is the default). Time-limited.
Kubernetes schedules a pod: a set of containers running on one node. Pods are individual; a Deployment of 5 pods means 5 independent scheduling decisions. No time limit by default.
For AI training that needs all-or-nothing on N nodes, this is the largest gap. Kubernetes ecosystem closed it with gang schedulers: Volcano, Kueue, KAI Scheduler (NVIDIA), Run:ai. They group pods into PodGroups that schedule together. With these in place, Kubernetes can do gang scheduling — but the operator has to install and configure them.
Backfill, queueing, priority
| Feature | Slurm | Kubernetes |
|---|---|---|
| Job queue | Built-in, priority-sorted, time-limited | Built-in: pods are pending until scheduled. No time limit. Requires Kueue or Volcano for proper queueing. |
| Backfill | Native, mature, tunable | Native scheduler doesn't backfill. Volcano/YuniKorn add it. |
| Fair-share | Native, hierarchical, decay-based | Not native. Kueue cohorts approximate it; Run:ai and others add fair-share layers. |
| Multi-factor priority | Native (age, fairshare, qos, size, etc.) | PriorityClass per workload; static priorities only by default. Volcano adds more factors. |
| Preemption | Native, QoS or partition-based | Native pod preemption based on PriorityClass; Volcano adds richer policies. |
| Gang scheduling | Always | Add-on (Volcano, Kueue, KAI, Run:ai) |
| Topology-aware placement | Native via topology.conf | Add-on via topology managers, custom schedulers |
| Reservations | First-class (scontrol create reservation) | Approximate via taints + tolerations + dedicated nodes |
The Kueue / Volcano / KAI ecosystem
The Kubernetes community has converged on a stack to bring HPC-style scheduling to k8s:
- Kueue (Kubernetes upstream): job-level queueing, cohorts (groups of teams), borrowing/lending of quotas, gang scheduling via PodGroups, dynamic resource reclamation.
- Volcano: full-featured batch scheduler with gang scheduling, fair-share, preemption, backfill, and topology awareness. Most "HPC-on-k8s" deployments use Volcano.
- KAI Scheduler (NVIDIA, open-sourced 2025): GPU-first batch scheduler designed for AI workloads — gang scheduling, fractional GPUs, topology-aware placement, hierarchical queues.
- Run:ai (acquired by NVIDIA): commercial product layered on Kubernetes, providing fair-share GPU scheduling, fractional GPUs, hierarchical queues. Very popular in enterprise AI.
- YuniKorn (Apache): a Kubernetes scheduler aimed at big-data + ML, supporting hierarchical queues and gang scheduling.
The trajectory: out-of-the-box Kubernetes is weak for batch ML; Kubernetes + Volcano/Kueue/KAI is competitive with Slurm; Kubernetes + Run:ai is a higher-cost, higher-feature commercial choice.
What this means in practice
For a single user submitting a 64-GPU training job:
- Slurm:
sbatch --nodes=8 --gpus-per-node=8 train.sh. The 64 GPUs allocate or pend together. Done. - Kubernetes (vanilla): write a Job with 64 pods, each requesting 1 GPU. Pods schedule independently; you may end up with 30 pods running and 34 waiting, blocking other workloads. Training can't start.
- Kubernetes + Volcano: PodGroup of 64, gang-scheduled. Behaves like Slurm.
The pattern recurs: anything Slurm does natively, Kubernetes can do with the right add-ons; without them, things break in surprising ways.
§32Resource isolation & cgroups
Both systems use cgroups to enforce resource limits, but apply them differently.
Slurm
Slurm sets cgroups per task: every srun task gets its own cgroup, with CPU set, memory limit, and device list. Containers (via pyxis) add another layer of namespacing, but the cgroup boundary is Slurm's.
Kubernetes
The kubelet sets cgroups per pod (and per-container within pod). The pod is the cgroup boundary. Multi-container pods share network and IPC namespaces but have their own cgroups.
| Concern | Slurm | Kubernetes |
|---|---|---|
| CPU pinning | --cpu-bind=cores; explicit per-task | CPU Manager policy: static for exclusive cores; cpu-set per container |
| NUMA awareness | Built-in via gres.conf Cores= | Topology Manager (multiple policies: best-effort, restricted, single-numa-node) |
| Memory limits | --mem per job, enforced by memory cgroup | resources.limits.memory per container |
| GPU isolation | cgroup devices controller + CUDA_VISIBLE_DEVICES | NVIDIA Device Plugin + cgroup devices |
| Swap behavior | ConstrainSwapSpace=yes (typical: no swap) | Off by default; opt-in support |
| Network namespacing | Optional via container; Slurm step has host network by default | Pod gets its own network namespace + CNI-assigned IP |
The GPU-isolation story
Both systems rely on NVIDIA's container toolchain to expose GPUs into containers/cgroups. The mechanism is the same; the orchestration is different.
Slurm: when a job is allocated, slurmstepd writes the allowed GPU devices to the cgroup device controller. The job's CUDA_VISIBLE_DEVICES is also set. Both layers must agree.
Kubernetes: the NVIDIA Device Plugin advertises GPUs as extended resources (nvidia.com/gpu). When a pod requests them, the kubelet asks the device plugin which devices to provide; the device plugin returns mount specifications and environment variables (including NVIDIA_VISIBLE_DEVICES). The container runtime sets these up.
For MIG and shared GPUs (MPS), both systems have grown support, but Kubernetes has more momentum here: the NVIDIA Device Plugin supports MIG slices, time-slicing, and CUDA MPS as configurable resources.
§33Containers & packaging
The packaging stories differ.
Slurm
Workload packaging is whatever the user chose. Native binaries (compiled with module load environment), Python in conda/venv, or containers via pyxis/Apptainer. The cluster supplies a shared filesystem with module hierarchies; users live in their home directory + scratch. Containers are layered on as a user-choice option.
Kubernetes
Everything is a container. Workloads are described in YAML manifests with image references; the registry is the distribution mechanism; the kubelet pulls images on demand. No shared filesystem assumed; users mount what they need explicitly (PVCs, ConfigMaps, Secrets). The packaging story is uniform but rigid.
Implications
- Iteration speed: Slurm wins here. Edit Python file, resubmit, done. Kubernetes: build image, push image, update manifest, apply, wait for pull, run.
- Reproducibility: Kubernetes wins. Image digests are immutable; "what software ran" is in the manifest. Slurm: depends on whatever the user's environment was at submit time.
- Distribution: Kubernetes wins. Pull an image, it's the same everywhere. Slurm: shared filesystem dependency, or per-user environment setup.
- Layering complexity: Kubernetes adds a registry + image-builder workflow that Slurm shops can skip.
The container-image cache problem
On a 1000-node Kubernetes cluster, pulling a 10 GB image on first use for a 1000-pod job means 1000 simultaneous registry hits. Modern Kubernetes deployments solve this with pre-pulled images, image streaming (estargz, zstd:chunked), or peer-to-peer (Dragonfly, Spegel). Slurm avoids the problem entirely because enroot caches per-node.
For AI workloads with 20+ GB containers (CUDA + PyTorch + NCCL + framework), image distribution is a real cluster-design question on Kubernetes that doesn't exist on Slurm.
§34Networking, storage, devices
Networking
Slurm: jobs see the host network. They use the cluster's IB/Ethernet fabric directly with whatever IP addresses the host has. NCCL picks NICs via topology. Firewall rules are operator-managed and stable.
Kubernetes: every pod gets a network namespace and a CNI-assigned IP. The CNI plugin (Calico, Cilium, Flannel, etc.) handles routing. For IB/RDMA, special CNI plugins (Multus + SR-IOV) expose the IB device into the pod alongside the standard pod network.
The Multus + SR-IOV pattern is the production approach for Kubernetes AI clusters: each pod gets a pod-network NIC (for the Kubernetes service mesh) plus one or more IB virtual functions (for NCCL traffic). It works, but it's significantly more complex than Slurm's "the network is the network."
Storage
| Concern | Slurm | Kubernetes |
|---|---|---|
| Shared filesystem | Mounted on every node (Lustre, GPFS, NFS); jobs read directly | Mounted via CSI driver as PersistentVolume; pods mount PVCs |
| Local scratch | Per-node NVMe; users manage their files | EmptyDir or hostPath; lifecycle tied to pod |
| User home | Always available; same UID everywhere | Mounted via PVC if needed; harder to share across users |
| Object storage | User application talks to S3/GCS/Azure directly | Same; or via CSI drivers (s3fs, ceph) for POSIX semantics |
The HPC-style storage model (one big parallel FS, mounted everywhere) is intrinsic to Slurm. Kubernetes can achieve the same with the right CSI drivers, but the cultural default is "pods don't share state outside their explicit volumes."
Devices
GPUs, NICs, NVMe scratch, FPGAs, custom accelerators — all are "devices" in both systems. Slurm uses GRES (§14); Kubernetes uses the Device Plugin framework + extended resources. Both mechanisms work; both require per-device-type plugins.
For GPUs specifically, NVIDIA maintains plugins for both systems, and the feature parity is close: MIG, time-slicing, MPS, vGPU all supported on both. The choice rarely turns on device support.
§35Operator & user ergonomics
The day-to-day feel of working with each system differs in ways that matter for adoption.
User ergonomics
Slurm — pros
Trivial onboarding: write a shell script, prepend SBATCH directives, submit. CLI matches what HPC users already know. Output is just files. Quick iteration. No image build step.
Slurm — cons
Environment drift between submit time and run time. No native versioning of code or environment. Less helpful when "my script worked yesterday" is the bug.
Kubernetes — pros
Reproducible by construction: an image is an image. Service-style workloads (long-running inference) get first-class scaling, networking, rolling updates. Mature observability ecosystem.
Kubernetes — cons
Steep on-ramp for HPC/research users. Image build/push/pull loop is friction. YAML is verbose. Debugging needs kubectl exec, log aggregation, container quirks. "Why is my pod pending" is a longer conversation than "why is my job pending."
Operator ergonomics
Slurm — pros
Single config file (mostly). Single source of truth (the controller). Mature, stable, doesn't change architecture year to year. Tuning knobs are documented and don't shift.
Slurm — cons
Controller is a single process; HA is active-passive only. Plugin compilation is C-level; extending behavior often means hooking via SPANK or Lua, both legacy-feeling. Limited self-service for users; many things require ops intervention.
Kubernetes — pros
True multi-replica HA control plane. Extension model (CRDs + operators) is modern. Massive third-party ecosystem (monitoring, security, networking, storage). Self-service via RBAC. Same skills transfer across orgs and clouds.
Kubernetes — cons
Complexity. Many moving parts (etcd, API, scheduler, controllers, CNI, CSI, ingress, ...). Each upgrade is a project. AI-batch stack (Volcano/Kueue/KAI/Run:ai) adds more components on top. Operationally heavier than Slurm.
§36AI/ML fit: which wins where
The honest answer: depends on the workload mix. Below: the workload classes most AI orgs run, and which system fits each better.
| Workload | Fit |
|---|---|
| Pretraining (100s–1000s of GPUs, weeks) | Slurm wins. Gang scheduling is intrinsic; topology-aware placement is mature; researchers' workflow (sbatch + python) is unchanged from past experience. |
| Fine-tuning (8–64 GPUs, hours–days) | Either. Slurm if you're already on it; Kubernetes if the team already has k8s for inference and they want one cluster. |
| Hyperparameter sweeps (many small jobs) | Either. Slurm's job arrays are excellent; Kubernetes with Argo Workflows or Kueue is equivalent. |
| Inference serving (online, scaling, multi-tenant) | Kubernetes wins decisively. Deployments, HPA, service mesh, ingress — all native and mature. Slurm has no good story here. |
| Long-running RL with simulation | Either, leaning Kubernetes for the heterogeneous shapes (CPU sim workers + GPU learners). |
| Mixed CPU-only + GPU workloads | Either; Slurm if HPC-style, Kubernetes if web/microservice-style. |
| Multi-tenant cluster with strong isolation | Kubernetes wins. Namespaces + NetworkPolicy + RBAC give finer-grained isolation than Slurm's accounts. |
| Research cluster with diverse users | Slurm wins. Familiarity, low friction, easy iteration. Most academic and lab clusters. |
| Heterogeneous training + serving on same hardware | Either with the right scheduler. Kubernetes + KAI/Run:ai is a strong story; Slurm + careful partition design also works. |
The performance question
For a well-tuned, large-scale training job, is one faster than the other? Almost always: no. The bottleneck is GPU, NCCL, fabric — not the scheduler. Both systems can place a job on the right nodes, with the right cgroups, with NCCL using the right NICs. Once running, the workload is identical.
The schedulers do differ in scheduling overhead: Slurm starts jobs faster, has lower controller overhead per submission, and handles deep queues better at the upper end. For a cluster running 1000 jobs/day this doesn't matter. For one running 100,000 jobs/day, it does.
The fleet-wide efficiency question
Slurm's fair-share and accounting tooling is more mature for "how much did each project use last month" questions. Kubernetes is catching up via Kueue cohorts and per-namespace usage reporting, but the out-of-box experience favors Slurm. For an AI Performance and Efficiency Engineer, this matters: the cluster you're auditing should give you per-account, per-job, per-resource utilization without custom integration work.
§37Hybrid patterns & decision framework
Many real AI shops end up running both. Knowing the patterns saves you from re-inventing them.
Hybrid pattern A: Slurm for training, Kubernetes for inference
The most common split. Reasoning: training is HPC-shaped (gang-scheduled, big, periodic); inference is web-shaped (replicated, scaling, multi-tenant). Different clusters, different schedulers, sometimes different hardware (training on H100/B200, inference on a mix of H100, L40S, A10G).
Operational reality: two clusters, two on-call rotations, sometimes two storage tiers. The trade is paying for the duplication vs forcing one system to do both jobs poorly.
Hybrid pattern B: Kubernetes for everything, Slurm-on-Kubernetes for batch
Newer pattern. Kubernetes is the substrate; Slurm runs as Kubernetes pods (the controller as a Deployment, the slurmds as a DaemonSet). Tooling like "Slinky" (SchedMD's Kubernetes operator for Slurm) makes this real.
Why: you want one orchestration plane, but you also want Slurm's batch features for training. The cost: complexity, since now you have both schedulers' issues + an integration layer.
Hybrid pattern C: Slurm with Kubernetes-style features bolted on
Slurm clusters that adopt the JWT REST API (slurmrestd), pyxis containers, and prometheus-based monitoring — getting closer to Kubernetes ergonomics without leaving Slurm. Most "modernizing HPC clusters" land here.
Hybrid pattern D: Kubernetes with KAI Scheduler or Volcano
The other direction: Kubernetes, but with a batch-quality scheduler. KAI Scheduler (NVIDIA) or Volcano gives you gang scheduling, fair-share, topology awareness. For a Kubernetes-native team running AI workloads, this is increasingly the answer.
The decision framework
Q: What's your team's existing infrastructure expertise? HPC/MPI background -> Slurm. Build on what they know. Cloud/k8s background -> Kubernetes + KAI/Volcano. Same. Mixed -> Likelier to hybridize. Q: What's the workload mix? Mostly large training -> Slurm or Kubernetes + KAI. Both work. Mostly inference -> Kubernetes. No competition. Mostly research, small -> Slurm. Simpler. Strict mix of all -> Hybrid (training/inference split). Q: Cluster scale and operational team? < 100 nodes, small team -> Slurm. Simpler ops. 100-1000 nodes -> Either, with mature ops. > 1000 nodes -> Slurm scales further, but k8s+KAI works. Q: Multi-tenant isolation requirements? Loose (research) -> Slurm. Strict (production+research mixed) -> Kubernetes RBAC + namespaces. Q: Cloud-portability requirements? Cloud-native, multi-region -> Kubernetes (managed offerings everywhere). On-prem, single site -> Either.
The pragmatic answer
For pure AI training workloads at the supercomputer scale (1000+ GPUs, multi-week jobs, MPI/NCCL-heavy), Slurm remains the standard, and there's no compelling reason to switch.
For AI inference, agentic workflows, and service-style ML at any scale, Kubernetes is the standard, and Slurm has no comparable offering.
For the middle — mid-sized AI orgs with both training and inference — the choice is real, and increasingly defaults to Kubernetes + KAI / Volcano / Run:ai for new builds, because it unifies the stack. Existing Slurm shops have little incentive to migrate.
The performance engineer's job is the same on both: profile the workload, find the bottleneck, fix it, prevent the next one. The orchestrator is plumbing; the work doesn't change.
Patterns & closing.
Six worked debugging cases from the kinds of problems the role encounters on Slurm clusters, the cheatsheets you want one click away, and a closing on the signals that distinguish a senior cluster engineer.
§38Production debugging cases
Symptom
Researcher's 64-node Llama training job runs at ~60% of expected tokens/sec. Same script, same model, same data — ran at full speed last week. No code changes.
Initial hypothesis
Cluster congestion or a NCCL regression.
What the data showed
nsys trace: NCCL allreduce time was 2.5× yesterday's. scontrol show job + topology check: the 64 nodes were spread across 6 leaf switches, vs 2 leaves last week. The job had no --switches hint; the scheduler placed it wherever it could fit.
Resolution
Re-submitted with --switches=2@10:00 — wait up to 10 min to find a placement using 2 or fewer leaf switches. Job started 4 minutes later, ran at full speed. The deeper fix: update the team's job template to always include the switches hint for >= 8-node jobs.
Takeaway
Topology drift is invisible to the user until you compare placements. The performance engineer's contribution: surface per-job "switch-spread" as a fleet metric, and educate teams about --switches hints.
Symptom
One researcher reports 5 of their last 8 jobs ended in NODE_FAIL. Other users on the cluster are not affected.
Initial hypothesis
Bad luck, or a specific node the user is hitting.
What the data showed
sacct -u alice -S "now-1week" -X -o "JobID,NodeList,State,Reason": the failures spanned 7 different nodes. Not a single bad node.
sacct -j JOBID -o "JobID,State,Reason,Comment": each failure listed a different node as the source, but always with reason "Prolog error." Looking at slurmd.log on those nodes: the prolog had failed on a custom health check looking for a particular kernel module loaded. The module was loaded — but the check used lsmod | grep $MODULE, which failed when stdout was redirected in the prolog context.
Resolution
Fixed the prolog script (used grep -q and tested return code directly). All future jobs passed. The user's previous failures were because their jobs happened to be the first to hit nodes after a kernel-module-related event drained-and-resumed them; the prolog had been quietly broken for weeks.
Takeaway
"User-specific issues" sometimes aren't. Always check the actual log of the failed event — don't conclude based on whose jobs failed. The prolog ran for everyone; only certain timing patterns hit the bug.
Symptom
Multiple researchers report Slurm commands are slow. sbatch takes 20-40s instead of subseconds. squeue sometimes returns immediately, sometimes 30s later.
Initial hypothesis
Controller overload.
What the data showed
sdiag on the controller: agent queue depth 1500-2000 sustained. Mean scheduling cycle 800ms (normal: 30ms). The controller was spending most of its time inside backfill cycles, blocking new RPCs.
Root cause: a user had submitted a 50,000-task array job that morning. With the default bf_max_job_user of 30, backfill was still considering many of those tasks (some were running, some were still pending), and the array's combined state ballooned the controller's working set.
Resolution
Short-term: raised bf_max_job_user to 50 (counter-intuitive: bigger cycle to finish faster), enabled defer in SchedulerParameters, and added bf_yield_interval=500000 to make backfill yield to RPCs more often. Within minutes, RPCs caught up. Long-term: a Lua job_submit rule capped array size at 10000 per submission.
Takeaway
One user's submission can degrade the cluster for everyone. The scheduler params are not "set and forget"; they need re-tuning as workloads change. sdiag is the diagnostic to reach for first.
Symptom
Researcher's training job won't start. squeue shows reason AssocGrpGRESMinutes. They're surprised.
Initial hypothesis
Cluster has a budget; this team is over.
What the data showed
sacctmgr show assoc account=ai-research -p showed GrpTRESMins=gres/gpu=100000 for the parent account. sreport user TopUsage account=ai-research start="$(date -d 'now-7 days' +%F)" end=now showed the team had consumed 102,000 GPU-minutes in 7 days — over the cap.
The team's manager hadn't been notified; the policy was new; the cap was set conservatively.
Resolution
Negotiated a temporary cap raise for the team while they restructured their training plan. Surfaced the underlying issue: per-account GPU-minute budgets are useful but invisible to users; need a dashboard.
Takeaway
Budget-based limits are politically delicate but operationally essential. The fix is rarely "raise the limit." The fix is making consumption visible early and forcing intentional decisions about who gets the next slot.
Symptom
A new researcher's 8-rank single-node torchrun job hangs at init_process_group. Logs show rank 0 waiting; ranks 1-7 produce no output.
Initial hypothesis
Misconfigured launcher.
What the data showed
The submit script had --ntasks-per-node=8 AND was running torchrun --nproc_per_node=8. Result: Slurm launched 8 task processes per node, each of which was a torchrun that then launched 8 sub-processes — 64 worker processes total on a node with 8 GPUs. Most of them fought over the same GPUs and most never reached init_process_group.
Resolution
Changed to --ntasks-per-node=1 (one torchrun per node) which then internally launches the 8 worker processes. Documented the pattern in the team's onboarding.
Takeaway
The interaction between Slurm's task count and PyTorch's --nproc_per_node is the single most common new-user error. Two launchers, both convinced they need to fan out. Pick one and tell the other "one task per node."
Symptom
A 6-hour training job ended with state COMPLETED, exit 0. No final checkpoint was saved. The team lost a half-day of training.
Initial hypothesis
User code didn't save a checkpoint.
What the data showed
Job script had --time=06:00:00 and --signal=B:USR1@30 (30s warning). Trace showed: at 5h59m30s Slurm sent SIGUSR1 to the batch shell. The batch shell forwarded it to the python process. Python's signal handler started writing the checkpoint — but the checkpoint of this 30B-param model took ~90s. After 30s, Slurm SIGKILL-ed the job mid-write. The corrupt checkpoint was silently overwritten when the next job's resume code saw a malformed file and started fresh.
Job state was COMPLETED because the python process exited 0 before the kill landed (signal handler called sys.exit(0)). The kill arrived during the write but the script reported success.
Resolution
Two fixes: --signal=B:USR1@180 (3-minute warning to match the real checkpoint time), and verify-on-resume code that fails loudly if the existing checkpoint is malformed instead of silently restarting.
Takeaway
The --signal warning window must be larger than the actual graceful-shutdown work. Measure checkpoint time before setting the value. And: corrupted checkpoints should fail loudly, not silently. "Silent restart from corrupted state" is the worst-case bug.
Every case here turned on one specific log line or one specific command output. The senior signal is knowing which log to read, in which file, on which host, in the first minute.
§39Cheatsheets: commands, env, conf
Submission
Querying state
Admin (read-only equivalents exist for users)
Slurm environment variables in jobs
slurm.conf — the knobs that matter at scale
NCCL env battery for Slurm jobs
Diagnostic flow on a user-reported problem
§40The senior signals & closing
What distinguishes a senior cluster engineer from a strong individual contributor. Slurm-specific, but most of these generalize.
You read the log file, not the user's narration
"My job didn't run" is the user's story. The reason field in squeue and the lines around JobId= in slurmctld.log are the truth. The senior signal: ask for the JobID, run the three diagnostic commands (§29), conclude in 30 seconds.
You know the state machine cold
NODE_FAIL, OUT_OF_MEMORY, PREEMPTED, TIMEOUT, CANCELLED — each has different causes, different evidence, different fixes. Knowing the difference means you don't have to guess.
You can explain priority to a researcher in 30 seconds
"Your job is pending because you have higher fair-share usage this week than the team average. It will run after these three higher-priority jobs finish — about 90 minutes. Or you can use the preemptible QoS for an immediate start if you accept that the job can be killed."
You know the operator's knobs
The performance engineer doesn't change slurm.conf, but knows what's in it. When ops says "we're tuning backfill," you know what they mean. When a researcher's job has weird scheduling behavior, you can ask the right questions about the cluster configuration.
You write the prolog check, not the post-mortem
Once a problem has surfaced as a job failure, much of the cost has already been paid. The senior move is to add a prolog check, a Lua submit rule, or a default in the job template — so the next person hits the issue at submit time, not 4 hours into a 6-hour run.
You bridge HPC and modern
Slurm is an HPC tradition; modern AI workloads come from a different culture. Bridging "this is how HPC has always worked" with "but we need elastic training and Kubernetes-style observability" is increasingly the role. Both sides have legitimate reasons; the bridge is technical translation, not advocacy.
You ship dashboards, not just answers
Helping a researcher with one job is good. Helping the next 50 users avoid the same issue without your involvement is the leverage. The senior engineer ships dashboards, defaults, templates, alerts — the infrastructure that makes the cluster systematically more usable.
You respect the system
Slurm has been in production at every major HPC site for two decades. The conventions feel old-fashioned because they are; they also work. Resist the urge to "modernize" before understanding why things are the way they are. The reasons are usually load-bearing.
A great Slurm engineer is the person whose presence makes researchers' workflows smoother, whose telemetry catches problems before users feel them, and whose collaborations with ops make the cluster systematically better quarter after quarter.