Skip to content

필사 모드: SlurmでGPUクラスタを使う — 提出より大事なのはなぜ動かないかを知ること

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 提出は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ノードごとにGPU8枚(タイプ指定時はgpu:h100:8)古いスクリプト、依然として有効
--gpus-per-node=8ノードごとにGPU8枚ノードあたり一タスクでtorchrunを立ち上げるとき
--gpus-per-task=1タスクごとにGPU1枚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ドメインに分かれ、GPU0〜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エラーとリマップされたメモリ行がハードウェア故障の一次指標です。

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かKubernetesか

両方ともコンテナを動かし両方ともGPUを割り当てます。しかし設計の前提が違います。Slurmは有限の資源を待ち行列で公正に分けるバッチスケジューラであり、Kubernetesは宣言した状態を維持し続けるオーケストレータです。

SlurmKubernetes
基本のジョブモデル始まって終わるバッチジョブ生き続けるべきワークロード
ギャングスケジューリング内蔵。ノード8台を一度に確保デフォルトスケジューラにない。KueueかVolcanoが必要
キューと公正配分アカウントツリーとQoSで成熟した対応別コンポーネントで補強する必要あり
選点ポリシーきめ細かいポリシーが内蔵優先順位クラスで可能だが性質が違う
トポロジー認識配置内蔵別設定とプラグインが必要
サービング、オートスケール、ロールアウト対象外本領
運用者の学習曲線HPC運用経験が必要クラウドネイティブ経験が必要

実務の判断は大体こう整理されます。大規模事前学習と長いバッチジョブが主力ならSlurm推論サービングとマイクロサービスが主力ならKubernetesです。ひとつの組織で両方必要な場合が多く、そのため境界をつなぐプロジェクトが出てきています。SchedMDのSlinkyはKubernetesの上でSlurmを動かすオペレータであり、逆方向にはKueue(2026-07-22時点でv0.19.0)とVolcano(2026-07-30時点でv1.15.1)がKubernetesにキューとギャングスケジューリングを加えます。

新たに導入するなら判断基準は技術比較表ではなく誰が運用するかです。すでにHPCチームがSlurmクラスタを運用しているなら学習ワークロードをそこに載せる方が圧倒的に速く、プラットフォームチームがKubernetesしか知らない組織ならSlurmを新規導入するコストがバッチスケジューリングの利得を上回ります。

おわりに — スケジューラは待ち行列ではなく契約です

Slurmをうまく使うということは、フラグをたくさん知ることではなく、自分が要求する資源とクラスタが与えられる資源の間の契約を正確に書くことです。必要な時間を正直に書けばバックフィルで先に回り、選点可能なQoSを使いつつチェックポイントで備えれば待機時間が減り、PENDINGの理由をコード別に読めるようになれば三日待つことがなくなります。

そして学習スクリプトはいつでも死にいつでも再開されるという前提で組んでください。次の記事で扱うLLM Opsの半分は実はこの前提をコードに移す作業です。

현재 단락 (1/149)

Slurmを初めて使う人が学ぶのは大抵`sbatch`一行です。しかし実際に時間を食うのは提出ではありません。提出したジョブが三日間PENDINGに座っているのにその理由がわからない状況、八時間動いて...

작성 글자: 0원문 글자: 11,170작성 단락: 0/149