Claude Media
Claude CodeでSlurm job arrayを回す — 失敗した添字だけ再投入

Claude CodeでSlurm job arrayを回す — 失敗した添字だけ再投入

sbatch --arrayでパラメータスイープを回し、sacctでFAILEDなどの添字だけ抜いて--array=3,5,7で再投入する手順と、Claude Codeに任せる操作の権限設計をまとめます。

24通りのハイパーパラメータを試すジョブ配列のうち、3本だけが落ちた。ここで24本を丸ごと投げ直すと、計算資源も待ち時間も無駄になります。Slurmのジョブ配列は、落ちた添字だけを--array=3,5,7の形で再投入できます。

この記事では、SLURM_ARRAY_TASK_IDから設定を引くラッパ、sacctで失敗した添字を抜くスクリプト、再投入の流れを順に書きます。後半では、sbatchやscancelをClaude Codeに触らせるときの権限の切り方も扱います。

添字から設定を引くラッパを先に決める

ジョブ配列の全タスクは、同じ初期オプションで動きます。違うのは環境変数SLURM_ARRAY_TASK_ID、つまり添字だけです。したがって、パラメータの組み合わせは「添字から一意に引ける表」にしておく必要があります。

次の例は、学習率3通り、バッチサイズ2通り、シード4通りの24通りをitertools.productで並べ、添字で引くラッパです。

# run_sweep.py
import itertools, os
 
LRS = [1e-4, 3e-4, 1e-3]
BATCH = [16, 32]
SEEDS = [0, 1, 2, 3]
GRID = list(itertools.product(LRS, BATCH, SEEDS))  # 24 通り
 
def main():
    i = int(os.environ["SLURM_ARRAY_TASK_ID"])
    lr, batch, seed = GRID[i]
    print(f"task={i} lr={lr} batch={batch} seed={seed}", flush=True)
    # ここで本来の学習・評価を呼ぶ
 
if __name__ == "__main__":
    main()

環境変数を手で与えて動かすと、添字7は次の組になります。

SLURM_ARRAY_TASK_ID=7 python3 run_sweep.py

出力はtask=7 lr=0.0001 batch=32 seed=3でした。GRIDの並びを変えると、同じ添字が別の組を指します。再投入は添字で指定するので、表の順序は再投入が終わるまで固定します。表を足したいときは、末尾に追加すれば既存の添字がずれません。

添字の範囲外を渡すと、このラッパはIndexErrorで落ちます。--array=0-30のように表より広く投げた場合の挙動なので、ラッパの先頭で範囲を検査して分かりやすいメッセージで落とすと、失敗の原因がsacctの状態だけで読み取れます。

検査には、Slurmが各タスクに渡す環境変数が使えます。SLURM_ARRAY_TASK_COUNTは配列のタスク数、SLURM_ARRAY_TASK_MAXは最大の添字、SLURM_ARRAY_TASK_MINは最小の添字です。再投入の配列は--array=3,5,7のように飛び飛びなので、SLURM_ARRAY_TASK_COUNTは24ではなく3になります。表の大きさとの比較には使わず、len(GRID)と添字を直接比べてください。

ジョブ配列の投入とログの分け方

投入側は、--arrayで添字の集合を渡します。範囲は0-15、個別指定は0,6,16-32、刻みは0-15:4の形で書けます。最小の添字は0で、最大は設定値MaxArraySizeより1小さい値です。

同時実行数を絞るには%を使います。--array=0-15%4なら、同時に走るタスクは4本までです。

#!/bin/bash
#SBATCH --job-name=sweep
#SBATCH --array=0-23%4
#SBATCH --time=00:30:00
#SBATCH --output=logs/%x_%A_%a.out
set -euo pipefail
python3 run_sweep.py

%Aは配列全体のジョブID、%aは添字に置き換わります。既定の出力名はslurm-%A_%a.outです。例に出した%xはジョブ名で、logs/ディレクトリは投入前に作っておきます。

mkdir -p logs
sbatch sweep.sbatch

sbatchはSubmitted batch job 36のようにIDを返します。以降、各タスクは36_0、36_1と、配列ID、アンダースコア、添字の形で識別されます。

失敗した添字だけをsacctで抜く

状態の確認にはsacctを使います。ジョブ配列のIDはArrayJobID_ArrayTaskIDの形で表示されます。

押さえる点は3つです。

  • -Xでジョブ本体の行だけに絞り、ステップ行を除く
  • -Pで|区切りにし、-nで見出しを消す
  • --arrayを付けて、配列を1タスク1行に展開する

--arrayを付けないと、未実行のタスクは36_[14-20]のようにまとめて1行で出ます。まとまりの行は添字として使えないので、スクリプトの側でも読み飛ばします。

sacct -j 36 -X -P -n --array -o JobID,State

失敗とみなす状態は、FAILED、TIMEOUT、OUT_OF_MEMORY、NODE_FAIL、CANCELLEDにしました。CANCELLEDはCANCELLED by 1000のように後ろへ実行者が付くことがあるので、先頭の語で判定します。COMPLETED、PENDING、RUNNINGは対象外です。

# failed_indexes.py
import sys
 
BAD = {"FAILED", "TIMEOUT", "OUT_OF_MEMORY", "NODE_FAIL", "CANCELLED"}
 
def failed_indexes(lines):
    idx = set()
    for line in lines:
        job_id, _, state = line.strip().partition("|")
        if "_" not in job_id or not state:
            continue
        task = job_id.split("_", 1)[1]
        if task.isdigit() and state.split()[0] in BAD:
            idx.add(int(task))
    return sorted(idx)
 
if __name__ == "__main__":
    print(",".join(map(str, failed_indexes(sys.stdin))))

次の入力は、sacct -Pの出力形式に合わせて用意した例です。完了、失敗、メモリ不足、取り消し、実行中、保留のまとまり、時間切れを混ぜています。

printf '36_0|COMPLETED\n36_3|FAILED\n36_7|OUT_OF_MEMORY\n36_12|CANCELLED by 1000\n36_13|RUNNING\n36_[14-20]|PENDING\n36_5|TIMEOUT\n' \
  | python3 failed_indexes.py

結果は3,5,7,12でした。実行中の13と、保留のまとまりは混ざりません。全部がCOMPLETEDだった場合、出力は空行です。この「空」を再投入の分岐に使います。

再投入のコマンドと注意点

抜いた添字は、そのまま--arrayに渡せます。

FAILED=$(sacct -j 36 -X -P -n --array -o JobID,State | python3 failed_indexes.py)
[ -n "$FAILED" ] && sbatch --array="${FAILED}%2" sweep.sbatch

sweep.sbatchの中の#SBATCH --array=0-23%4は残したままで構いません。コマンドラインで--arrayを渡せるので、スクリプトを書き換えずに添字だけ差し替えられます。この例では%2を付けて、再投入分の同時実行を2本に絞りました。

再投入のつまずきどころは、次のとおりです。

  • 再投入すると、ジョブIDが新しくなります。別のsbatchなので、配列IDが変わります。ログは%A_%aの名前で別ファイルに出るため、旧ログは上書きされません。
  • 原因を直さないと、同じ所で落ちます。TIMEOUTなら--time、OUT_OF_MEMORYならメモリ指定を見直してから投入します。同じ配列の中のタスクは同じ初期オプションですが、再投入は新しい配列なので、オプションを変えられます。
  • 添字の表は変えません。前節のとおり、GRIDの順序を変えると、同じ添字が別の組を指します。
  • NODE_FAILは設定の問題ではありません。ノードの障害で終了した状態なので、設定を疑う前に再投入の対象に入れます。

依存関係を使う方法もあります。--dependency=afternotok:36は、配列の全タスクが終わったあとで、少なくとも1本が正常終了しなかったときに満たされる条件です。ただしこれは配列全体の条件で、どの添字が落ちたかは教えてくれません。添字を指定した再投入にはsacctが要ります。

scontrol requeueとの使い分け

ジョブIDを保ったまま再実行したいときは、scontrol requeueという道もあります。scontrolのhold、release、requeue、suspendなどは、配列全体にも、36_3のように個別の要素にも使えます。

使い分けは次のとおりです。

  • 新しいsbatch --array: --timeやメモリなどのオプションを変えて投げ直せます。ジョブIDは新しくなります
  • scontrol requeue 36_3: 同じ配列のIDのまま、その要素を待ち行列へ戻します。投入時のオプションは元のままです

原因が時間やメモリの不足なら、オプションを変えられる新しいsbatchが向きます。NODE_FAILのように設定に非がない失敗は、IDを保てるrequeueでも足ります。終了済みのタスクをrequeueできるかは、サイトの設定やジョブの再投入可否(--requeue / --no-requeue)に左右されるので、手元のクラスターで一度試してから運用に入れてください。

sbatchのドキュメントには、バッチジョブはノード障害や、優先度の高いジョブによる横取りで再投入される場合があり、そのときバッチスクリプトは先頭から実行し直されると書かれています。つまりrequeueされたタスクは途中から再開しません。run_sweep.pyに途中経過の保存機構がないなら、再実行は最初からになります。配列の資料にも、再投入があるとタスクが添字の順に作られないことがあると注意書きがあるので、sacctの行の並びで成否を判断せず、添字そのものを見てください。

Claude Codeに任せる範囲をpermissionsで切る

調査から再投入までを人手で回すと、ログを開く、状態を見る、投入し直すの繰り返しになります。この反復をClaude Codeに任せるときは、先に権限を切っておきます。

Claude CodeのBashルールは、コマンド文字列に*を当てて書きます。*はサブコマンドの後ろに置きます。次の設定は、状態の確認だけを許可し、投入とrequeueは毎回確認、取り消しは拒否する構成の例です。

{
  "permissions": {
    "allow": [
      "Bash(sacct *)",
      "Bash(squeue *)",
      "Bash(python3 failed_indexes.py)"
    ],
    "ask": [
      "Bash(sbatch *)",
      "Bash(scontrol requeue *)"
    ],
    "deny": [
      "Bash(scancel *)"
    ]
  }
}

この設定には、知っておく限界が3つあります。

  1. 複合コマンドは、全部の部分が許可されている必要があります。sacct ... | python3 failed_indexes.pyは|で分かれ、各部分がルールに合う必要があります。上の例でpython3 failed_indexes.pyを許可に入れたのはそのためです。
  2. denyは、別の書き方を止めません。Bash(scancel *)が止めるのはscancel 36の形です。/usr/bin/scancel 36やbash -c 'scancel 36'は別の文字列として扱われます。取り消しを確実に止めたいなら、PreToolUseフックかサンドボックスで補います。
  3. 環境変数の前置きには注意が要ります。許可ルールは、既知の安全な変数以外の代入を挟んだ形には一致しません。SBATCH_ARRAY_INX=3,5 sbatch ...のように環境変数で--array相当を渡すと、許可ルールに合わず確認が出ます。sbatchはこの変数を--arrayと同じ意味で読みますが、Claudeには--arrayの形で書かせるほうが運用が単純です。

sbatchを許可にせずaskに置くのは、投入が資源を消費するからです。再投入の本数と添字を画面で見てから承認できます。allowに移すなら、先に同時実行数の%を必ず付ける運用を決めておくと、一度に大量のタスクが走る事態を避けられます。

CLAUDE.mdには、投入の作法を書いておきます。

## Slurm ジョブ配列
- 失敗の確認は `sacct -j <ID> -X -P -n --array -o JobID,State` で行う
- 再投入は `python3 failed_indexes.py` の出力を `--array` に渡す。空なら投入しない
- 投入時は必ず `%` で同時実行数を付ける
- `scancel` は実行しない。取り消しが必要なら添字を報告して止まる
- `run_sweep.py` の `GRID` の順序は変えない。追加は末尾に足す

CLAUDE.mdは指針であって強制力はありません。取り消しの禁止は、先のdenyルールと両方で効かせます。

他の再実行の型との違い

失敗した分だけを再実行する考え方は、別のツールにもあります。Slurmは添字で失敗を指せますが、dbtでは、失敗したノードだけを成果物から抜いて直します。手順はdbt runの失敗を特定する記事にまとめています。パイプラインの型の食い違いを先に潰す場合は、Kubeflow Pipelinesのcompileとローカル実行が参考になります。

パラメータ側の設定が合成で壊れるなら、Hydraの設定合成エラーを直す手順で、投入前に--cfg jobで合成後の中身を確かめられます。GRIDの各組をHydraの設定に渡す構成にするときの下準備になります。

cronから定期的に回す場合は、静かに失敗しない仕組みが必要です。cron失敗の調査と、失敗時だけ通知する書き方がそのまま使えます。大学や研究機関のHPCにClaudeをつなぐ経路は、Claude ScienceのHPC接続で仕組みを説明しています。

まとめ

ジョブ配列の再投入は、添字の表を固定し、sacct --arrayの出力から失敗した添字だけを抜き、--arrayに渡すだけで成り立ちます。表の順序を動かさないことと、原因を直してから投入することの2点が、手戻りを避ける要です。

Claude Codeに任せるなら、確認はallow、投入とrequeueはask、取り消しはdenyと分け、denyの抜け道はフックかサンドボックスで補う構成が出発点になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. slurm.schedmd.com/job_array.html
  2. slurm.schedmd.com/sbatch.html
  3. slurm.schedmd.com/sacct.html
  4. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn