Skip to content

Split View: Slurm으로 GPU 클러스터 쓰기 — 제출보다 중요한 것은 왜 안 도는지 아는 일

✨ Learn with Quiz
|

Slurm으로 GPU 클러스터 쓰기 — 제출보다 중요한 것은 왜 안 도는지 아는 일

들어가며 — 제출은 5분, 대기는 3일

Slurm을 처음 쓰는 사람이 배우는 것은 대개 sbatch 한 줄입니다. 그런데 실제로 시간을 잡아먹는 것은 제출이 아닙니다. 제출한 작업이 사흘째 PENDING에 앉아 있는데 그 이유를 모르는 상황, 여덟 시간 돌던 작업이 아무 메시지 없이 사라진 상황, 재개했더니 손실이 이상한 값에서 시작하는 상황입니다.

이 글은 그래서 절반을 실패 진단에 씁니다. 앞의 절반은 좌표계와 스크립트 구조, 뒤의 절반은 부검입니다.

확인 기준을 먼저 적습니다. 2026년 8월 2일 SchedMD 저장소 릴리스 태그 기준으로 현재 안정 계열은 26.05이고 최신 태그는 26.05.2, 배포일은 2026년 7월 14일입니다. 이전 계열인 25.11도 같은 날 25.11.7이 나와 유지보수 중입니다. 클러스터마다 설치된 버전이 다르므로, 아래 내용을 적용하기 전에 sinfo --version으로 실제 버전을 확인하시고 해당 버전의 공식 문서를 보시는 편이 안전합니다. 옵션 동작이 마이너 버전에서 바뀐 전례가 여러 번 있습니다.

좌표계 — 파티션, QoS, 계정

작업을 제출하기 전에 세 개의 축을 알아야 합니다. 이 셋을 모르면 PENDING 사유를 절대 해석할 수 없습니다.

  • 파티션은 노드의 묶음입니다. H100 노드와 A100 노드가 다른 파티션에 있고, 파티션마다 최대 실행 시간과 접근 가능한 계정이 다릅니다.
  • 계정은 자원 사용량이 기록되는 회계 단위입니다. 조직도와 비슷하게 트리를 이루고, 공정 배분 우선순위가 이 트리를 따라 계산됩니다.
  • QoS는 정책 꼬리표입니다. 우선순위 가중치, 동시 실행 한도, 선점 여부, GPU 시간 총량 한도가 여기 붙습니다.
# 내가 쓸 수 있는 파티션과 한도
sinfo -o "%20P %5a %10l %6D %10T %N"

# 내 계정과 QoS 연관 관계
sacctmgr show assoc user="$USER" format=Account,Partition,QOS,GrpTRES,MaxJobs

# QoS별 정책
sacctmgr show qos format=Name,Priority,MaxTRESPU%30,MaxJobsPU,Flags%30

# 파티션에 어떤 GPU가 몇 장씩 있는지
sinfo -p gpu-h100 -o "%20N %10c %10m %30G"

마지막 명령의 마지막 열이 GRES 정의입니다. gpu:h100:8 같은 문자열이 보이면 타입 이름이 h100이라는 뜻이고, 이 이름은 클러스터마다 다릅니다. 문서에 적힌 예제를 그대로 복사하면 대개 이 이름 때문에 실패합니다.

sbatch 스크립트의 해부

학습 작업 하나를 제출하는 최소 형태입니다. 각 줄이 왜 있는지는 아래에서 설명합니다.

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

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

GPU를 요구하는 세 가지 방법이 있고 섞어 쓰면 안 됩니다.

플래그의미언제
--gres=gpu:8노드마다 GPU 8장 (타입 지정 시 gpu:h100:8)오래된 스크립트, 여전히 유효
--gpus-per-node=8노드마다 GPU 8장노드당 한 개 태스크로 torchrun을 띄울 때
--gpus-per-task=1태스크마다 GPU 1장srun이 랭크마다 프로세스를 띄울 때

--gpus-per-task를 쓰면 Slurm이 GPU 바인딩을 암묵적으로 걸어 주므로, 각 태스크는 자기 GPU 하나만 보게 됩니다. 이 동작이 편할 때도 있고 방해가 될 때도 있습니다. 예를 들어 노드당 태스크 하나로 torchrun을 띄우는데 실수로 --gpus-per-task=1을 쓰면 그 프로세스가 GPU 한 장만 보게 되어 torch.cuda.device_count()가 1을 반환합니다.

CPU와 메모리도 함께 요구해야 합니다. 데이터 로더 워커가 CPU를 쓰고, 토큰화된 데이터를 호스트 메모리에 올립니다. --mem=0은 노드의 전체 메모리를 요구한다는 뜻으로, 노드를 통째로 쓰는 학습 작업에서 흔히 씁니다. GPU당 CPU 코어 수는 데이터 로더 워커 수를 결정하므로, --cpus-per-task를 GPU 수로 나눈 값을 OMP_NUM_THREADSnum_workers의 기준으로 삼으십시오.

여기 함정이 하나 있습니다. srun이 sbatch의 CPU 요청을 상속하는지가 Slurm 버전에 따라 달랐습니다. 22.05에서 srun이 상속을 멈추고 별도 환경 변수 SRUN_CPUS_PER_TASK를 도입했고, 이후 버전에서 동작이 다시 조정되었습니다. 클러스터 버전마다 다르게 동작할 수 있으므로, 위 스크립트처럼 srun--cpus-per-task를 명시적으로 다시 주는 것이 버전에 무관하게 안전합니다. 이 값이 1로 떨어지면 데이터 로더가 코어 하나에 묶여 GPU 사용률이 30퍼센트대에서 정체합니다.

바인딩도 확인 대상입니다. 8-GPU 노드는 대개 두 개의 NUMA 도메인으로 나뉘고, GPU 0~3이 소켓 0에, 4~7이 소켓 1에 붙어 있습니다. 프로세스가 반대편 소켓 코어에 배치되면 호스트에서 GPU로 데이터를 보내는 대역폭이 떨어집니다.

# 노드 안 GPU 연결 토폴로지와 NUMA 배치
nvidia-smi topo -m

# 실제로 어떤 코어에 묶였는지
srun --cpu-bind=verbose,cores --gpu-bind=verbose,closest hostname

멀티노드 학습 — srun과 랑데뷰

패턴은 두 가지뿐입니다.

패턴 A: 노드당 torchrun 하나. srun은 노드마다 태스크 하나만 띄우고, 그 안에서 torchrun이 GPU 수만큼 프로세스를 만듭니다. 랑데뷰는 torchrun의 c10d 백엔드가 담당합니다.

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

set -euo pipefail

# 첫 번째 노드를 랑데뷰 지점으로 삼습니다
MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)
# 포트는 작업 번호에서 유도해 동시 실행 작업끼리 충돌하지 않게 합니다
MASTER_PORT=$(( 20000 + SLURM_JOB_ID % 20000 ))
export MASTER_ADDR MASTER_PORT
export OMP_NUM_THREADS=$(( SLURM_CPUS_PER_TASK / 8 ))
export NCCL_DEBUG=WARN
export TORCH_NCCL_ASYNC_ERROR_HANDLING=1

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

패턴 B: srun이 랭크마다 프로세스를 띄움. torchrun을 쓰지 않고 Slurm의 태스크가 곧 랭크가 됩니다. 프로세스 관리 계층이 하나 줄어 로그가 깔끔해지고, 죽은 랭크를 Slurm이 직접 봅니다.

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

srun python train.py --config configs/7b.yaml
# train.py 앞부분 — Slurm 환경 변수를 분산 초기화 변수로 옮깁니다
import os
import torch
import torch.distributed as dist

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

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

컨테이너로 돌린다면 pyxisenroot를 씁니다. 2026년 8월 2일 기준 확인한 버전은 pyxis v0.24.0(2026-05-12), enroot v4.2.1(2026-06-09)입니다. 설치되어 있으면 srun에 컨테이너 옵션이 생깁니다.

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

랑데뷰에서 자주 나는 문제 세 가지입니다. 첫째, MASTER_PORT를 고정 상수로 두면 같은 노드에 다른 작업이 걸릴 때 충돌합니다. 위처럼 작업 번호에서 유도하십시오. 둘째, 노드에 인터페이스가 여러 개(관리망, 스토리지망, 고속망)라면 NCCL이 느린 쪽을 고를 수 있습니다. NCCL_SOCKET_IFNAME으로 명시하고 NCCL_DEBUG=INFO로 실제 선택을 확인하십시오. 셋째, --kill-on-bad-exit=1이 없으면 한 랭크가 죽어도 나머지가 NCCL 타임아웃까지 30분씩 살아남아 GPU 시간을 태웁니다.

배열 작업과 의존성 체인

하이퍼파라미터 스윕은 배열 작업으로 던집니다. 작업 하나가 아니라 인덱스가 붙은 작업 묶음이 되어, 스케줄러가 빈 자리에 알아서 채워 넣습니다.

#!/bin/bash
#SBATCH --job-name=lr-sweep
#SBATCH --array=0-11%4          # 12개 작업, 동시에 최대 4개만 실행
#SBATCH --gpus-per-node=1
#SBATCH --cpus-per-task=8
#SBATCH --time=02:00:00
#SBATCH --output=/scratch/%u/logs/%x-%A_%a.out

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

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

로그 파일 이름의 %A는 배열 작업의 부모 번호, %a는 인덱스입니다. 둘 다 넣지 않으면 12개 작업이 같은 파일에 겹쳐 씁니다.

긴 사전학습은 시간 한도 때문에 한 번에 못 끝냅니다. 의존성 체인으로 이어 붙입니다.

# 24시간짜리 구간을 다섯 번 이어 붙입니다. 각 구간은 앞 구간이 성공해야 시작합니다
prev=""
for i in $(seq 1 5); do
  if [ -z "$prev" ]; then
    prev=$(sbatch --parsable pretrain.sbatch)
  else
    prev=$(sbatch --parsable --dependency="afterok:$prev" pretrain.sbatch)
  fi
  echo "segment $i -> job $prev"
done

afterok은 성공 종료일 때만 이어집니다. 앞 구간이 실패하면 뒤가 전부 DependencyNeverSatisfied 상태로 남으므로, 실패를 눈치채지 못하고 며칠을 흘려보내는 일이 없습니다. 반대로 선점으로 죽어도 이어가고 싶다면 afterany를 씁니다. 학습 스크립트는 반드시 최신 체크포인트를 자동으로 찾아 재개하도록 짜여 있어야 이 체인이 의미를 갖습니다.

PENDING의 해부학

squeue의 마지막 열이 사유입니다. 여기가 진단의 출발점입니다.

squeue -u "$USER" -o "%.10i %.12P %.20j %.8T %.10M %.6D %R"
squeue -j 123456 --start          # 예상 시작 시각
sprio -j 123456 -l                # 우선순위 성분별 점수
scontrol show job 123456          # 요청 자원 전체와 사유 문자열
sinfo -R                          # 다운/드레인 노드와 그 이유
REASON실제 뜻다음 행동
Resources요청한 자원이 지금 비어 있지 않음squeue --start로 예상 시각 확인. 정상 대기
Priority앞에 우선순위 높은 작업이 있음sprio -l로 성분 확인. 계정 공정 배분 문제일 수 있음
QOSMaxJobsPerUserLimitQoS의 사용자당 동시 실행 한도실행 중 작업을 줄이거나 다른 QoS 사용
AssocGrpGRESRunMinutes계정 단위 GPU 시간 총량 소진관리자와 상의. 대기해도 안 풀림
ReqNodeNotAvail요청한 노드가 다운이거나 예약됨sinfo -R로 노드 상태 확인
Reservation예약 창이 아직 안 열림scontrol show res로 시작 시각 확인
PartitionTimeLimit--time이 파티션 최대치 초과요청 시간을 줄이거나 파티션 변경
Dependency선행 작업 미완료scontrol show job으로 어느 작업인지 확인
DependencyNeverSatisfied선행 작업이 실패해 영원히 안 돎작업 취소 후 재제출. 방치하면 계속 남음

가장 자주 오해받는 것이 ResourcesPriority의 차이입니다. Resources는 "지금 자리가 없다"이고 시간이 지나면 풀립니다. Priority는 "자리가 나도 내 차례가 아니다"라 계정 사용량이 줄거나 앞의 작업이 끝나야 합니다. AssocGrpGRESRunMinutes 계열은 아무리 기다려도 안 풀리는 종류이니, 이 사유가 보이면 즉시 관리자에게 문의하는 것이 맞습니다.

요청을 줄이면 훨씬 빨리 도는 경우가 많습니다. 노드 8대를 24시간 요구하는 대신 4대를 12시간씩 두 번 요구하면 백필 스케줄링에 걸려 먼저 실행됩니다. --time을 실제 필요치에 가깝게 적는 것이 백필의 전제 조건입니다. 습관적으로 최대치를 적으면 손해를 봅니다.

죽은 작업 부검 — 그리고 선점 대비

작업이 사라졌을 때 볼 것은 세 곳입니다. sacct, 로그 파일, 그리고 노드 자체입니다.

sacct -j 123456 --format=JobID,JobName%20,State%20,ExitCode,Elapsed,MaxRSS,ReqTRES%40,NodeList%20
sacct -j 123456 --format=JobID,State,DerivedExitCode,Comment%40
seff 123456        # slurm-contribs가 설치돼 있으면 요약을 보여 줍니다

호스트 메모리 OOM과 GPU OOM은 완전히 다른 사건입니다. 이 구분을 못 하면 엉뚱한 값을 만집니다.

증상어디서 보이는가원인처방
State=OUT_OF_MEMORY, ExitCode 0:125 또는 137sacct호스트 RAM 초과. cgroup이 프로세스를 죽임--mem 상향, 데이터 로더 워커/프리페치 축소
torch.OutOfMemoryError 트레이스백, State=FAILED로그 파일GPU VRAM 초과마이크로배치 축소, 활성화 체크포인팅, ZeRO 단계 상향
State=TIMEOUTsacct--time 초과체크포인트 주기 확인 후 재개 체인으로 전환
State=NODE_FAILsacct노드 하드웨어 장애sinfo -R, 노드 배제 후 재제출
State=PREEMPTEDsacct우선순위 높은 작업에 밀림아래 재큐 패턴 적용
로그 없이 사라짐로그 경로 확인--output 디렉터리가 없거나 권한 부족경로를 미리 mkdir -p

마지막 줄이 의외로 흔합니다. Slurm은 로그 파일을 열지 못하면 작업을 실패로 끝내는데, 사용자 눈에는 "아무 일도 안 일어남"으로 보입니다. --output 경로의 디렉터리는 제출 전에 만들어 두십시오.

노드 자체가 의심되면 GPU 쪽을 봅니다. 커널 로그의 Xid 오류와 리매핑된 메모리 행이 하드웨어 고장의 1차 지표입니다.

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

선점 대비는 학습 작업의 기본 설계여야 합니다. 선점 가능한 QoS를 쓰면 대기 시간이 크게 줄어드는 대신 언제든 밀립니다. Slurm이 종료 전에 신호를 먼저 보내 주므로, 그 사이에 체크포인트를 남기고 스스로 재큐하면 됩니다.

#SBATCH --signal=B:USR1@180     # 종료 180초 전에 배치 셸로 USR1 전송
#SBATCH --requeue               # 재큐 허용

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

on_preempt() {
  echo "[$(date -Is)] USR1 수신. 체크포인트 저장을 요청합니다."
  touch "$CKPT_DIR/SAVE_AND_EXIT"     # 학습 루프가 매 스텝 이 파일을 확인합니다
  wait "$SRUN_PID"                    # 저장이 끝나기를 기다립니다
  echo "[$(date -Is)] 재큐합니다."
  scontrol requeue "$SLURM_JOB_ID"
  exit 0
}
trap on_preempt USR1

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

--signalB: 접두사가 중요합니다. 이것이 없으면 신호가 배치 셸이 아니라 태스크로 가고, 셸의 trap이 안 걸립니다. 그리고 srun을 백그라운드로 띄우고 wait로 기다려야 trap이 즉시 반응합니다. 포그라운드로 두면 신호 처리가 srun이 끝난 뒤로 밀립니다.

여유 시간을 얼마로 잡을지는 체크포인트 저장 시간에 달려 있습니다. 70B 모델의 분산 체크포인트를 병렬 파일 시스템에 쓰는 데 90초가 걸린다면 180초는 빠듯합니다. 한 번은 실제로 재 보고 그 두 배로 잡으십시오. 25.11 계열 릴리스 발표에는 노드 장애 시 자동 재큐를 다루는 모드가 추가되었다는 내용이 있는데, 세부 동작은 확인하지 못했으니 사용하실 버전의 릴리스 노트에서 확인하시기 바랍니다.

Slurm인가 쿠버네티스인가

둘 다 컨테이너를 돌리고 둘 다 GPU를 할당합니다. 그런데 설계 전제가 다릅니다. Slurm은 유한한 자원을 대기열로 공정하게 나누는 배치 스케줄러이고, 쿠버네티스는 선언한 상태를 계속 유지하는 오케스트레이터입니다.

Slurm쿠버네티스
기본 작업 모델시작하고 끝나는 배치 작업계속 살아 있어야 하는 워크로드
갱 스케줄링내장. 노드 8대를 한 번에 잡음기본 스케줄러에 없음. Kueue나 Volcano 필요
대기열과 공정 배분계정 트리와 QoS로 성숙하게 지원별도 컴포넌트로 보강해야 함
선점 정책세분화된 정책 내장우선순위 클래스로 가능하나 결이 다름
토폴로지 인지 배치내장별도 설정과 플러그인 필요
서빙, 오토스케일, 롤아웃영역 밖본령
운영자 학습 곡선HPC 운영 경험 필요클라우드 네이티브 경험 필요

실무 판단은 대체로 이렇게 정리됩니다. 대규모 사전학습과 긴 배치 작업이 주력이면 Slurm, 추론 서빙과 마이크로서비스가 주력이면 쿠버네티스입니다. 한 조직에서 둘 다 필요한 경우가 많고, 그래서 경계를 잇는 프로젝트들이 나와 있습니다. SchedMD의 Slinky는 쿠버네티스 위에서 Slurm을 돌리는 오퍼레이터이고, 반대 방향으로는 Kueue(2026-07-22 기준 v0.19.0)와 Volcano(2026-07-30 기준 v1.15.1)가 쿠버네티스에 대기열과 갱 스케줄링을 더합니다.

새로 도입한다면 판단 기준은 기술 비교표가 아니라 누가 운영할 것인가입니다. 이미 HPC 팀이 Slurm 클러스터를 운영 중이라면 학습 워크로드를 거기 얹는 것이 압도적으로 빠르고, 플랫폼 팀이 쿠버네티스만 아는 조직이라면 Slurm을 새로 도입하는 비용이 배치 스케줄링의 이득을 넘습니다.

마치며 — 스케줄러는 대기열이 아니라 계약입니다

Slurm을 잘 쓴다는 것은 플래그를 많이 아는 것이 아니라, 내가 요청한 자원과 클러스터가 줄 수 있는 자원 사이의 계약을 정확히 쓰는 일입니다. 필요한 시간을 정직하게 적으면 백필로 먼저 돌고, 선점 가능한 QoS를 쓰되 체크포인트로 대비하면 대기 시간이 줄고, PENDING 사유를 코드별로 읽을 줄 알면 사흘을 기다릴 일이 없습니다.

그리고 학습 스크립트는 언제든 죽고 언제든 재개된다는 전제로 짜십시오. 다음 글에서 다룰 LLM Ops의 절반이 사실 이 전제를 코드로 옮기는 일입니다.

Running a GPU Cluster with Slurm — Knowing Why a Job Will Not Run Matters More Than Submitting

Introduction — Submitting Takes Five Minutes, Waiting Takes Three Days

What someone learns first with Slurm is usually a single sbatch line. But what actually eats up time isn't submission. It's a submitted job sitting in PENDING for three days with no idea why, a job that ran for eight hours vanishing with no message, or a resumed job whose loss starts at some bizarre value.

So this post spends about half its length on failure diagnosis. The first half is the coordinate system and script structure; the second half is the autopsy.

Let me state the reference up front. As of August 2, 2026, per the SchedMD repository release tags, the current stable line is 26.05, with the latest tag at 26.05.2, released July 14, 2026. The previous line, 25.11, also got a 25.11.7 release the same day and is under maintenance. The installed version varies by cluster, so before applying anything below, check your actual version with sinfo --version and consult that version's official documentation. Option behavior has changed across minor versions more than once.

The Coordinate System — Partition, QoS, Account

Before submitting a job, you need to know three axes. Without these three, you can never interpret a PENDING reason.

  • A partition is a grouping of nodes. H100 nodes and A100 nodes live in different partitions, and max runtime and accessible accounts differ by partition.
  • An account is the accounting unit against which resource usage is logged. It forms a tree similar to an org chart, and fair-share priority is calculated along that tree.
  • QoS is a policy tag. Priority weight, concurrent-run limits, preemptibility, and total resource-usage caps all attach here.
# Partitions I can use, and their limits
sinfo -o "%20P %5a %10l %6D %10T %N"

# My account and QoS associations
sacctmgr show assoc user="$USER" format=Account,Partition,QOS,GrpTRES,MaxJobs

# Policies by QoS
sacctmgr show qos format=Name,Priority,MaxTRESPU%30,MaxJobsPU,Flags%30

# How many of which GPU a partition has
sinfo -p gpu-h100 -o "%20N %10c %10m %30G"

The last column of that final command is the GRES definition. If you see a string like gpu:h100:8, the type name is h100, and this name differs by cluster. Copying an example straight out of the docs usually fails because of this name.

Anatomy of an sbatch Script

Here's the minimal shape for submitting a training job. Why each line is there is explained below.

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

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

There are three ways to request GPUs, and you shouldn't mix them.

FlagMeaningWhen
--gres=gpu:88 GPUs per node (with type: gpu:h100:8)Older scripts, still valid
--gpus-per-node=88 GPUs per nodeLaunching torchrun as one task per node
--gpus-per-task=11 GPU per tasksrun launching one process per rank

With --gpus-per-task, Slurm implicitly applies GPU binding, so each task only sees its own single GPU. That behavior is sometimes convenient and sometimes in the way. For instance, if you're launching torchrun as one task per node but accidentally use --gpus-per-task=1, that process ends up seeing only one GPU, and torch.cuda.device_count() returns 1.

You need to request CPU and memory together too. The data-loader workers use CPU, and tokenized data gets loaded into host memory. --mem=0 means "request the entire memory of the node," commonly used for training jobs that take a node exclusively. The CPU cores per GPU determines your data-loader worker count, so base OMP_NUM_THREADS and num_workers on --cpus-per-task divided by GPU count.

There's a trap here. Whether srun inherits sbatch's CPU request has varied by Slurm version. In 22.05, srun stopped inheriting it and introduced the separate SRUN_CPUS_PER_TASK environment variable, and behavior was adjusted again in later versions. Since it can behave differently across cluster versions, explicitly re-passing --cpus-per-task to srun, as in the script above, is safe regardless of version. If this value falls to 1, the data loader gets pinned to a single core and GPU utilization plateaus in the 30s.

Binding is also worth checking. An 8-GPU node is usually split across two NUMA domains, with GPUs 0-3 attached to socket 0 and 4-7 to socket 1. If a process gets placed on cores on the opposite socket, bandwidth from host to GPU drops.

# GPU interconnect topology and NUMA placement within a node
nvidia-smi topo -m

# Which cores things actually got bound to
srun --cpu-bind=verbose,cores --gpu-bind=verbose,closest hostname

Multi-Node Training — srun and Rendezvous

There are only two patterns.

Pattern A: one torchrun per node. srun launches exactly one task per node, and torchrun spawns as many processes as there are GPUs inside it. Rendezvous is handled by torchrun's c10d backend.

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

set -euo pipefail

# Use the first node as the rendezvous point
MASTER_ADDR=$(scontrol show hostnames "$SLURM_JOB_NODELIST" | head -n1)
# Derive the port from the job number so concurrent jobs don't collide
MASTER_PORT=$(( 20000 + SLURM_JOB_ID % 20000 ))
export MASTER_ADDR MASTER_PORT
export OMP_NUM_THREADS=$(( SLURM_CPUS_PER_TASK / 8 ))
export NCCL_DEBUG=WARN
export TORCH_NCCL_ASYNC_ERROR_HANDLING=1

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

Pattern B: srun launches one process per rank. No torchrun; Slurm's own task becomes the rank directly. This removes one layer of process management, keeps logs cleaner, and lets Slurm directly see a dead rank.

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

srun python train.py --config configs/7b.yaml
# Top of train.py — carries Slurm env vars over into distributed init vars
import os
import torch
import torch.distributed as dist

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

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

If you're running in a container, use pyxis and enroot. As of August 2, 2026, the versions confirmed are pyxis v0.24.0 (2026-05-12) and enroot v4.2.1 (2026-06-09). Once installed, container options become available on srun.

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

Three problems come up often with rendezvous. First, if you pin MASTER_PORT to a fixed constant, it collides whenever another job lands on the same node. Derive it from the job number as shown above. Second, if a node has several interfaces (management, storage, and a fast fabric), NCCL might pick the slow one. Specify it explicitly with NCCL_SOCKET_IFNAME and confirm the actual choice with NCCL_DEBUG=INFO. Third, without --kill-on-bad-exit=1, one dead rank leaves the rest alive for up to 30 minutes until an NCCL timeout, burning GPU-hours the whole time.

Array Jobs and Dependency Chains

A hyperparameter sweep gets thrown as an array job. It becomes not one job but a bundle of indexed jobs, which the scheduler fills into open slots on its own.

#!/bin/bash
#SBATCH --job-name=lr-sweep
#SBATCH --array=0-11%4          # 12 jobs, at most 4 running at once
#SBATCH --gpus-per-node=1
#SBATCH --cpus-per-task=8
#SBATCH --time=02:00:00
#SBATCH --output=/scratch/%u/logs/%x-%A_%a.out

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

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

In the log filename, %A is the array job's parent number and %a is the index. Leave either one out and 12 jobs will overwrite each other in the same file.

A long pretraining run can't finish in one go because of the time limit. Chain it together with dependencies.

# Chain five 24-hour segments together. Each segment starts only if the previous one succeeded
prev=""
for i in $(seq 1 5); do
  if [ -z "$prev" ]; then
    prev=$(sbatch --parsable pretrain.sbatch)
  else
    prev=$(sbatch --parsable --dependency="afterok:$prev" pretrain.sbatch)
  fi
  echo "segment $i -> job $prev"
done

afterok only chains onward on a successful exit. If an earlier segment fails, everything after it sits as DependencyNeverSatisfied, so you never lose days without noticing the failure. Conversely, if you want to keep going even after death by preemption, use afterany. Your training script must be written to automatically find and resume from the latest checkpoint for this chain to actually mean anything.

Anatomy of PENDING

The last column of squeue is the reason. That's the starting point for diagnosis.

squeue -u "$USER" -o "%.10i %.12P %.20j %.8T %.10M %.6D %R"
squeue -j 123456 --start          # expected start time
sprio -j 123456 -l                # priority broken down by component
scontrol show job 123456          # full requested resources and reason string
sinfo -R                          # down/drained nodes and why
REASONWhat it actually meansNext action
ResourcesThe requested resources aren't free right nowCheck the expected time with squeue --start. Normal wait
PriorityA higher-priority job is ahead of youCheck components with sprio -l. Might be an account fair-share issue
QOSMaxJobsPerUserLimitHit the QoS's per-user concurrent-run limitReduce running jobs or use a different QoS
AssocGrpGRESRunMinutesAccount-level GPU-time budget exhaustedTalk to an admin. Won't resolve by waiting
ReqNodeNotAvailThe requested node is down or reservedCheck node state with sinfo -R
ReservationThe reservation window hasn't opened yetCheck the start time with scontrol show res
PartitionTimeLimit--time exceeds the partition's maxReduce the requested time or switch partitions
DependencyAn upstream job hasn't finishedCheck which job with scontrol show job
DependencyNeverSatisfiedAn upstream job failed, so this will never runCancel and resubmit. Left alone, it just sits there

The most commonly confused pair is Resources versus Priority. Resources means "no slot right now," and it resolves as time passes. Priority means "a slot opened up but it's not your turn yet," requiring your account's usage to drop or the jobs ahead of you to finish. The AssocGrpGRESRunMinutes family never resolves no matter how long you wait, so if you see this reason, go straight to an admin.

Shrinking your request often makes it run much faster. Instead of requesting 8 nodes for 24 hours, requesting 4 nodes for 12 hours twice gets picked up by backfill scheduling and runs sooner. Writing --time close to what you actually need is the precondition for backfill to work. Habitually writing the maximum costs you.

Autopsy of a Dead Job — And Preparing for Preemption

When a job disappears, there are three places to look: sacct, the log file, and the node itself.

sacct -j 123456 --format=JobID,JobName%20,State%20,ExitCode,Elapsed,MaxRSS,ReqTRES%40,NodeList%20
sacct -j 123456 --format=JobID,State,DerivedExitCode,Comment%40
seff 123456        # gives you a summary if slurm-contribs is installed

Host-memory OOM and GPU OOM are completely different events. Fail to make this distinction and you end up tweaking the wrong knob.

SymptomWhere it shows upCauseRemedy
State=OUT_OF_MEMORY, ExitCode 0:125 or 137sacctHost RAM exceeded. cgroup killed the processRaise --mem, shrink data-loader workers/prefetch
torch.OutOfMemoryError traceback, State=FAILEDLog fileGPU VRAM exceededShrink microbatch, activation checkpointing, raise ZeRO stage
State=TIMEOUTsacct--time exceededCheck checkpoint interval, switch to a resume chain
State=NODE_FAILsacctNode hardware failuresinfo -R, exclude the node, resubmit
State=PREEMPTEDsacctBumped by a higher-priority jobApply the requeue pattern below
Vanished with no logCheck the log path--output directory missing or no permissionmkdir -p the path ahead of time

That last line comes up surprisingly often. Slurm ends a job as failed if it can't open the log file, but to the user it looks like "nothing happened at all." Create the --output path's directory before submitting.

If you suspect the node itself, check the GPU side. Xid errors in the kernel log and remapped-memory rows are the first-line indicators of hardware failure.

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

Preparing for preemption has to be the default design of a training job. Using a preemptible QoS cuts your wait time a lot, but you can get bumped at any moment in exchange. Slurm sends a signal before terminating, so you checkpoint in that window and requeue yourself.

#SBATCH --signal=B:USR1@180     # send USR1 to the batch shell 180 seconds before termination
#SBATCH --requeue               # allow requeuing

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

on_preempt() {
  echo "[$(date -Is)] Received USR1. Requesting a checkpoint save."
  touch "$CKPT_DIR/SAVE_AND_EXIT"     # the training loop checks for this file every step
  wait "$SRUN_PID"                    # wait for the save to finish
  echo "[$(date -Is)] Requeuing."
  scontrol requeue "$SLURM_JOB_ID"
  exit 0
}
trap on_preempt USR1

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

The B: prefix on --signal matters. Without it, the signal goes to the task instead of the batch shell, and the shell's trap never fires. And srun has to be launched in the background with wait so the trap reacts immediately — launched in the foreground, signal handling gets pushed until after srun exits.

How much lead time to set depends on how long the checkpoint save takes. If writing a distributed checkpoint for a 70B model to a parallel filesystem takes 90 seconds, 180 seconds is cutting it close. Measure it for real once, and set double that. The 25.11-line release announcement mentions a mode added for automatic requeue on node failure, but I haven't confirmed the details, so check the release notes for the version you're actually using.

Slurm or Kubernetes

Both run containers, both allocate GPUs. But the design premise is different. Slurm is a batch scheduler that fairly divides a finite pool of resources across a queue; Kubernetes is an orchestrator that continuously maintains a declared state.

AxisSlurmKubernetes
Default job modelBatch jobs that start and finishWorkloads that need to stay alive
Gang schedulingBuilt-in. Grabs 8 nodes at onceNot in the default scheduler. Needs Kueue or Volcano
Queueing and fair-shareMaturely supported via account trees and QoSNeeds to be bolted on as a separate component
Preemption policyFine-grained policy built inPossible via priority classes, but a different flavor
Topology-aware placementBuilt-inNeeds separate config and plugins
Serving, autoscaling, rolloutsOut of scopeHome turf
Operator learning curveRequires HPC operations experienceRequires cloud-native experience

The practical judgment mostly settles as: Slurm if large-scale pretraining and long batch jobs are your main workload; Kubernetes if inference serving and microservices are your main workload. Many organizations need both, so bridging projects have appeared. SchedMD's Slinky is an operator that runs Slurm on top of Kubernetes, and going the other direction, Kueue (v0.19.0 as of 2026-07-22) and Volcano (v1.15.1 as of 2026-07-30) add queueing and gang scheduling to Kubernetes.

If you're adopting new, the deciding factor isn't a feature comparison table, it's who's going to operate it. If an HPC team is already running a Slurm cluster, putting your training workload on it is overwhelmingly the faster path; if a platform team only knows Kubernetes, the cost of newly adopting Slurm outweighs the benefits of batch scheduling.

Closing — The Scheduler Isn't a Queue, It's a Contract

Using Slurm well isn't about knowing a lot of flags, it's about accurately writing the contract between the resources you're requesting and what the cluster can actually give you. Write your needed time honestly and you run sooner via backfill; use a preemptible QoS but prepare with checkpointing and your wait shrinks; know how to read PENDING reasons by code and you never sit waiting three days without knowing why.

And write your training script on the premise that it can die and get resumed at any moment. Half of the LLM Ops post covered next is really about turning that premise into code.