SLURM job scripts¶
Run a whole annotation pipeline on a SLURM cluster from one config file.
First ensure that all variables relevant to your SLURM cluster are set:
cp slurm/cluster.env.example slurm/cluster.env
$EDITOR slurm/cluster.env
# then simply submit
./slurm/submit_pipeline.sh my-pipeline.yaml
submit_pipeline.sh asks the config what each step needs and submits one job
chain per step, so a step only starts once the one before it has succeeded.
Nothing about the allocation lives in an #SBATCH header you have to edit, and
nothing about the annotation is repeated on the command line.
Submitting per step is what makes a real pipeline work on a cluster: each step gets servers for its own model, and GPUs are released as soon as that step is done rather than being held for the whole run. A step that calls a hosted API gets no GPU at all.
| File | Role |
|---|---|
cluster.env.example |
Template for the one file you edit: partitions, accounting, cores per GPU, modules. |
submit_pipeline.sh |
Run on a login node. Reads the config and submits the job chain. |
vllm_annotate.sh |
Runs one step of the config. Waits for its pool if it has one. |
vllm_server.sh |
One array task = one vLLM server. Publishes its base URL once healthy. |
vllm_common.sh |
Sourced by all of the above: cluster file, environment setup, ports, health polling. |
Three layers, kept apart: the pipeline config says what is annotated, the cluster file says what your cluster calls things, and the scripts hold the job shapes, which are the same everywhere. Nothing is configured twice.
Try it before you submit¶
--dry-run prints the sbatch command lines it would run and submits nothing.
It is the fastest way to check a new cluster file:
Step 1 'write-qa' (vllm_pool)
sbatch --account=my_project --time=04:00:00 --job-name=vllm-write-qa --array=1-4 \
--gres=gpu:2 --cpus-per-task=36 --partition=gpu_a100 ... slurm/vllm_server.sh
The cluster file¶
Copy cluster.env.example to cluster.env and fill it in; it is read
automatically. To keep several clusters side by side, write one file each and
pick one per run:
Overriding the config for one submission¶
--set KEY=VALUE is the same flag llm-annotate takes, forwarded to every step
job of the submission. A dotted key reaches a nested value, and repeating the
flag sets more than one:
Every step of one submission therefore sees the same overrides, which is what a
growing run needs: resubmitting with a higher dataset.max_num_samples extends
the pipeline as a whole rather than one step at a time, which is what
docs/growing-a-run.md describes.
| Variable | Default | Meaning |
|---|---|---|
SLURM_ACCOUNT |
none | Project to charge. Empty means no --account. |
GPU_PARTITION, CPU_PARTITION |
none | Partitions for the GPU and CPU-only jobs. Empty means the cluster's default. |
SERVER_TIME, CLIENT_TIME |
04:00:00, 05:00:00 |
Wall time per job kind. |
CPUS_PER_GPU |
8 |
Cores a GPU job asks for per GPU. |
CLIENT_CPUS |
8 |
Cores a CPU-only annotation job asks for. |
MAX_GPUS_PER_NODE |
8 |
Ceiling a step's engine.tensor_parallel_size is checked against before anything is submitted. |
GPU_REQUEST, GPU_TYPE |
gres, none |
How GPUs are requested: --gres=gpu:N or --gpus-per-node=N, optionally typed (gpu:a100:N). |
MAX_CONCURRENT_SERVERS |
none | Most elements of a server array that may run at once (--array=1-N%M), for a per-user GPU limit. Empty means no throttle. |
SERVER_HOST_CMD |
hostname |
Command a server runs to get the address other nodes reach it at. Use hostname -f, or a command that prints one interface's address, where the short name does not resolve. |
MODEL_DOWNLOAD |
0 |
1 fetches every model this submission serves in a CPU job before any GPU is allocated. |
DOWNLOAD_PARTITION, DOWNLOAD_TIME |
$CPU_PARTITION, 02:00:00 |
Partition and wall time of those download jobs. |
SERVER_SBATCH_ARGS, CLIENT_SBATCH_ARGS |
() |
Extra sbatch flags per job kind: QoS, memory, constraints, reservations. |
CLUSTER_MODULES |
none | Environment modules to load inside a job. |
VENV_PATH, UV_SYNC |
<repo>/.venv, 0 |
Python environment to activate, and whether to uv sync first. |
CUDA_MODULE |
none | Toolkit module loaded only when nvcc is missing and vLLM has to JIT-compile a kernel. |
LOG_DIR |
<repo>/logs |
Where job logs and pool directories go. |
Every scalar can be overridden for one submission by setting it on the command
line, which wins over the file and reaches the jobs through --export=ALL:
If modules plus a virtualenv do not describe your cluster (conda, a container,
a wrapper script of your own), define cluster_setup_env in the cluster file
instead. It replaces the environment handling entirely and only has to leave
llm-annotate and vllm on PATH:
cluster_setup_env() {
module load Python/3.12 CUDA/12.8.0
source /projects/shared/llm-annotator/.venv/bin/activate
}
A container works the same way, because llm-annotate and vllm may be shell
functions rather than executables. Define them at the top level of the
cluster file, not inside cluster_setup_env: every script sources the cluster
file, so a top-level function is visible in both job scripts and on the login
node, where submit_pipeline.sh reads the config. A function defined inside
cluster_setup_env reaches the job scripts only, because the login node never
calls it.
SIF=/projects/shared/images/vllm-0.29.0.sif
BINDS="/projects,/scratch-local"
llm-annotate() { apptainer exec --nv -B "$BINDS" "$SIF" llm-annotate "$@"; }
vllm() { apptainer exec --nv -B "$BINDS" "$SIF" vllm "$@"; }
cluster_setup_env() { :; }
The empty cluster_setup_env keeps the module-and-venv handling out of the way.
Two limits: the login node has to be able to run the container runtime, and the
bind mounts have to cover the repo, the config, the output directory and the
Hugging Face cache.
What each step becomes¶
The shape of a job is derived from the step's client, never configured twice. Check it before submitting anything:
{"index": 1, "name": "write-qa", "kind": "vllm_pool", "provider": "vllm_online", "model": "Qwen/Qwen3-8B", "servers": 4, "min_servers": 2, "gpus_per_vllm_server": 2, "step_dir": "...", "batch_size": 64, "max_concurrent_batches_per_client": 4, "queue_size": 32, "max_requests_per_server": 256, "max_requests_in_flight": 1024}
{"index": 2, "name": "rate-qa", "kind": "api", "provider": "claude", "model": "claude-haiku-4-5", "servers": 1, "min_servers": 1, "gpus_per_vllm_server": 1, "step_dir": "...", "batch_size": 256, "max_concurrent_batches_per_client": null, "queue_size": null, "max_requests_per_server": null, "max_requests_in_flight": null}
submit_pipeline.sh reads the same fields as shell assignments
(--describe-steps --format env, one STEP_KEY=VALUE line per step, every
value shell-quoted) and evaluates a line per step, so a step name or a model
containing a space or a quote reaches sbatch intact. Both formats are
documented under "Running one step at a time" in docs/pipeline.md.
max_requests_per_server is max_concurrent_batches_per_client times
batch_size: the number of prompts one server is asked to hold at once, and so
the number its engine.max_num_seqs has to cover before requests start queueing
inside vLLM. submit_pipeline.sh prints it per step, so a pool can be sized
before any GPU is allocated.
kind |
When | Jobs submitted |
|---|---|---|
vllm_pool |
provider: vllm_online, servers not given |
GPU server array + CPU client |
vllm_online |
provider: vllm_online with base_urls/hosts_file/url_glob |
CPU client only |
vllm_offline |
provider: vllm_offline |
one GPU job, model loaded in-process |
api |
openai / claude |
CPU-only job |
A vllm_pool step must name a model. It is optional for provider:
vllm_online in general (a client can ask a running server what it serves),
but a submitter that has to start those servers has nothing to ask, so
submit_pipeline.sh rejects such a step on the login node rather than after the
GPUs have been allocated.
A step sizes its own pool in the config, next to the model it belongs to:
client:
provider: vllm_online
model: Qwen/Qwen3-8B
engine:
tensor_parallel_size: 2 # GPUs per server, at most MAX_GPUS_PER_NODE
pool:
servers: 4 # four such servers for this step
min_servers: 2 # start annotating once two are ready
Several small server jobs schedule far sooner than one large allocation, because
each one fits on a partially used node. They also start at different times,
which is fine: the client starts once pool.min_servers are ready, and the
remaining servers join the run as they leave the queue.
pool.min_servers is a target with a fallback. The client
waits for that many .url files and gives up waiting when squeue reports that
the server array has no element left in the queue, or when POOL_WAIT is up. It
then annotates on the servers it does have, and says how many in its log
(Annotating over 2 of 4 server(s)). A pool that half fills therefore still
produces data, at half the throughput. With no server at all the step still
runs: a step that already finished loads its result and ends with status 0, and
any other step fails with url_glob ... matched no files, in which case the
vllm-<step>_*.err logs say why no server came up.
At a site with a per-user GPU limit, set MAX_CONCURRENT_SERVERS in the cluster
file. The array is then submitted as --array=1-4%2, so two servers run while
the other two wait. It has to be at least the largest pool.min_servers in the
config, otherwise the client's threshold can never be reached; the submitter
rejects that combination on the login node.
Serving profiles¶
engine: is the whole vllm serve command line, per step. The server job runs
and passes the result straight to vllm serve, so a value may contain spaces
and a pipeline whose steps use different models needs one submission, not one
per model:
engine:
tensor_parallel_size: 2
max_model_len: 8192
gpu_memory_utilization: 0.90
max_num_seqs: 256
speculative_config: # nested YAML, sent as JSON
model: my-org/my-draft-model
num_speculative_tokens: 4
extra: # anything not named above
reasoning_parser: qwen3
Check what a step will serve before allocating any GPU:
There is deliberately no cluster variable for anything vllm serve takes: a
server asks the config for its own flags, so two steps of one submission can
serve different models with different serving profiles.
How the two jobs of a step find each other¶
The server array writes into <LOG_DIR>/pool_<array-job-id>/, one <task>.url
file per server containing that server's http://<host>:<port>/v1. A file
appears only after the server answers /health, and is removed when the job
ends, so every URL in the directory belongs to a server that is up right now.
The client polls that directory until min_servers of the files are there,
then passes the directory itself to the CLI as --url-glob, which attaches it
to that step alone, so a step on another provider is left untouched. A file of
URLs would be read once; the glob is re-read while the run continues, so a
server whose file appears after the run has started still joins the pool.
A server that ends before the run does (hits SERVER_TIME, is preempted, or
otherwise stops answering /health) is dropped from the pool once a batch
fails on it, and the run continues on the servers that are left. A requeued
server job that publishes its .url file again is picked up the same way a
late starter is, so servers of one array reaching SERVER_TIME at different
moments do not end the client.
The host in that URL comes from SERVER_HOST_CMD, which is hostname unless
the cluster file says otherwise. Set it to hostname -f where compute nodes
only resolve fully qualified names, or to a command that prints the address of
the fabric the client should use.
Ports are VLLM_PORT + array task id, then probed upward for the first free one.
Two array tasks can land on the same node (a 4-GPU node fits two
tensor_parallel_size: 2 servers), so a fixed port would collide. The probe and
the bind are two separate moments, so another process can take the port in
between; vLLM then dies with Address already in use before /health ever
answers, and the server job retries on the next free port, at most
PORT_RETRIES times (5 by default). Any other early exit fails the task.
The client is submitted with one after: dependency per element of its server
array, or-joined (--dependency=after:1234_1?after:1234_2?...), which releases
it as soon as the first server has begun. after:<array-id> as a whole is
satisfied only once every element has started, which would leave a ready
server sitting idle behind pool-mates that are still queued (a per-user GPU
quota is enough to do this: one server can occupy the whole quota, so the rest
of the pool queues behind it), burning that server's own SERVER_TIME before
the client ever gets to use it. Waiting in the queue rather than on a compute
node also means CLIENT_TIME starts counting when there is something to
annotate against: a CPU partition schedules in minutes and a GPU partition can
take days, and a client that starts first spends that gap idle and then dies on
its wall clock.
That dependency replaces the step's own afterok:<previous client> rather than
adding to it, since Slurm reads one separator per expression (, for and, ?
for or) and the two cannot be mixed. Nothing is lost: a server cannot start
before the previous step has succeeded, so the client inherits that through the
array it waits on. A cancelled job also satisfies after:, so an array that
Slurm kills off releases the client instead of stranding it, and
--kill-on-invalid-dep=yes covers what Slurm flags as unsatisfiable outright.
The client recognises that case rather than sitting out its POOL_WAIT: while
it waits for URLs it asks squeue whether its server array still has an
element in the queue, and stops waiting when it has none left.
When a step finishes, its client scancels that step's server array instead of
leaving GPU jobs idling until their time limit. That happens in an EXIT trap,
which a cgroup OOM kill, a scancel -s KILL or a dead node skips, so the
submitter also queues a small clean-up job per pool step:
sbatch --time=00:05:00 --job-name=cancel-write-qa --cpus-per-task=1 \
--dependency=afterany:<client> --kill-on-invalid-dep=yes --wrap="scancel <array>"
afterany means it runs however the client ended, and it cancels that one
array, so the servers of a later attempt are untouched. It uses the CPU
partition and the client's sbatch flags, and it is not part of the chain
between steps: the next step waits for the client, never for the clean-up. Set
CANCEL_SERVERS_ON_EXIT=0 to keep servers alive after their step ends; the
clean-up job is then not submitted either.
Resubmitting automatically¶
A step that runs out of CLIENT_TIME leaves a resumable run behind, but
somebody has to notice and submit it again. --max-resubmits N queues that
follow-up in advance:
Every step then gets up to N + 1 attempts. Attempt k runs the same command
as attempt k-1 and carries --dependency=afternotok:<attempt k-1>, so it
starts only when the attempt before it failed, timed out or was cancelled. Being
the same command is what makes it a resume: the step's progress files are read
and only the rows that are missing are sent to the model. A pool step gets a
fresh server array and a fresh clean-up job per attempt, and that attempt's
clean-up only cancels that attempt's array.
What happens to the attempts that turn out not to be needed:
- The attempt before it succeeded.
afternotokcan then never be satisfied, so--kill-on-invalid-dep=yeshas Slurm remove the attempt rather than leave it queued. For a pool step the removed job is the server array, and Slurm counts a cancelled job as a satisfiedafter:dependency, so that attempt's client does start. It finds no server, runs the step anyway, and the library loads the finished step'soutput/snapshot and exits with status 0 within seconds. No GPU is allocated, and every later attempt ends the same way. - The attempt before it failed, but the step had already finished in an earlier
submission. The attempt runs, the library loads the step's
output/snapshot and the job ends in seconds.
The next step waits for "any attempt of the previous step succeeded", which Slurm writes as an or-joined dependency list:
A , between dependencies means all of them must be satisfied, a ? means any
one of them is enough, and the two cannot be mixed in one expression. So the
next step starts as soon as any attempt of its predecessor succeeds, and the
attempts that were removed do not hold it back. If every attempt fails, the list
can never be satisfied and the next step stays in the queue with
DependencyNeverSatisfied until you cancel it (or, at a site that configures
kill_invalid_depend, Slurm removes it), which is the right outcome: nothing
runs on a half-finished input.
--dry-run prints the whole chain, attempts included, which is the way to check
it before you use it:
Model downloads¶
The servers of a pool all start at once, and on a cold cache they all download
the same weights. MODEL_DOWNLOAD=1 in the cluster file turns that into one
small CPU job per distinct model, submitted before anything else, which the
first step then waits for with afterok. Every later step inherits that wait
through the chain, so a pipeline whose steps use different models downloads all
of them up front:
sbatch --time=02:00:00 --job-name=download-Qwen3-8B --cpus-per-task=2 \
--wrap="... && vllm_download_model Qwen/Qwen3-8B"
The job runs hf download <model>, the CLI that comes with huggingface_hub.
Only the models this submission has to serve itself are fetched (the
vllm_pool and vllm_offline steps); a hosted provider has nothing to
download, and a model that is an existing directory is a local checkout and is
left alone. DOWNLOAD_PARTITION and DOWNLOAD_TIME size the job.
It is off by default. The job needs a route to huggingface.co from whichever
node runs it, which not every site has, and against a warm cache it costs a
queue wait for nothing. A gated model needs a token, which the job reads the
same way any other hf command does: hf auth login once, or HF_TOKEN in the
environment, which --export=ALL carries.
Wherever you run it, HF_HOME decides where the weights land; it defaults to
.cache/huggingface in your home directory. On a cluster, point it at a project
or scratch filesystem that every compute node can read, from your shell profile
or from the cluster file. A home directory is usually too small for a few 8B
checkpoints, and a node-local path is downloaded again by every server.
Threads of a pool client¶
A pool client holds one thread per request in flight, and such a thread only
waits for the network. The count is servers x
max_concurrent_batches_per_client x batch_size: four servers with the
defaults (four batches of 256 each) is 4096 threads. A site with a low
ulimit -u, or with a cgroup pids.max on its CPU jobs, fails the run with
RuntimeError: can't start new thread, so the client logs the limit it found
at the top of its log:
Two settings on the step's client block lower the count: batch_size (fewer
requests per batch) and init.max_workers (how many requests of one batch go
out at once, None by default, which sends all of them).
init.timeout belongs next to SERVER_TIME. It is 3600 seconds per request and
covers the wait in the server's queue as well as the generation itself. A
request that outlives its server, because the server hit SERVER_TIME or was
preempted, becomes an error row with an error and an error_type rather than
a failed run. A resubmission keeps those rows as they are; llm-annotate's own
--retry-errors is what annotates them again, and the end-of-run summary lists
the error types to name:
Run-level environment variables¶
These are about a single run rather than the cluster, so they stay out of the
cluster file and are set on the submit_pipeline.sh command line; both job types
are submitted with --export=ALL.
| Variable | Default | Meaning |
|---|---|---|
ANNOTATE_CONFIG |
the positional argument | JSON/YAML pipeline config to run |
EXTRA_DEPENDENCY |
none | Slurm dependency expression (e.g. afterok:123456) the chain waits for. Applied to the first submitted step only; later steps inherit it through their predecessor, which is what lets several submissions be chained into one workflow. |
POOL_WAIT |
1800 |
Seconds a client waits for min_servers of its servers to register once it is running. Matches READY_TIMEOUT, so a client cannot give up on a server before the server gives up on itself. The or-joined dependency means the client only starts once a server of its pool has, so this covers a model load rather than an allocation; raise it when running vllm_annotate.sh by hand against a pool that is still queued. |
VLLM_PORT |
8000 |
Base port a server starts probing from. The array task id is added to it, then the first free port is taken. |
READY_TIMEOUT |
1800 |
Seconds a server waits for its own /health before giving up |
PORT_RETRIES |
5 |
How often a server job retries on the next port after vllm serve failed to bind the one it probed. |
CANCEL_SERVERS_ON_EXIT |
1 |
Whether a finished client scancels its step's server array. 0 leaves the GPUs running. |
SBATCH_CMD |
sbatch |
The command that queues a job, for a site whose sbatch is wrapped. A submit this refuses ends the run: the steps after it would otherwise depend on a job id that was never issued. |
OUTPUT_DIR, HUB_ID, OVERWRITE |
from the config | Override the config's output_dir / hub_id, or discard existing step output |
ANNOTATE_SET |
none | What --set fills: config overrides for this submission, one KEY=VALUE per line, passed to llm-annotate --set on every step job. Set it directly only when scripting the submitter; --set is the way in. |
Resuming¶
Two levels, both automatic:
- Within a step, every annotated sample is appended to
<output_dir>/<NN>-<step>/annotate/<prefix>progress_backup/*.jsonland flushed immediately; a restart re-reads those files and skips the ids already present. A half-written final line from a killed job is detected and re-annotated. - Between steps, a finished step writes
<output_dir>/<NN>-<step>/output/, which a later run loads instead of recomputing.
So after a crash, a timeout or a preemption you run the same
submit_pipeline.sh command again. Finished steps are skipped, and the step
that died continues where it stopped. Resubmitting with a higher
dataset.max_num_samples extends a finished run the same way, which
docs/growing-a-run.md describes in full. To resubmit only part of a pipeline,
name the steps:
--max-resubmits queues those follow-up
submissions up front, which is the same resume without waiting for you to
notice. It needs nothing else: the attempt runs on the same cluster, against the
same output_dir, so the progress files it reads are already there.
A purged scratch directory is a different case, and so is a move to another
cluster. The prepared data comes back from its Hub branch on its own; the JSONL
progress files do not. Restore a step's progress backup before you resubmit,
with that step's own directory (<output_dir>/<NN>-<step>/annotate/) and prefix
(<step>_):
llm-annotate-restore --hub-id user/my-dataset --output-dir outputs/qa/02-rate-qa/annotate --task-prefix rate-qa_
Running a step yourself¶
The scripts add nothing the CLI cannot do, so any step can be run by hand, from a login node, an interactive session, or your laptop:
# one step, against servers that already exist
llm-annotate my-pipeline.yaml --steps write-qa --hosts-file logs/pool_<id>/hosts.txt
# the next step, wherever you like
llm-annotate my-pipeline.yaml --steps rate-qa
Running steps one at a time produces exactly the dataset a single
llm-annotate my-pipeline.yaml would, so you can start locally and finish on the
cluster, or vice versa. Only the run that includes the last step writes
<output_dir>/final/ and pushes to the Hub.
To start servers without a client: