Skip to content
Published on

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

공유하기
Authors

들어가며 — 제출은 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의 절반이 사실 이 전제를 코드로 옮기는 일입니다.