Add spur (Crusoe) scheduler support for multi-node runs - #157
Conversation
Adapt the SLURM deployment path for the spur scheduler (slurm-compat shim,
v0.4.1) whose CLI is a subset of stock SLURM and whose control plane is
Raft-based / eventually consistent. These changes keep stock SLURM behavior
intact (guarded by feature/format detection) while making the same code work
on spur.
- Nodelist expansion: add _expand_nodelist() (Python) and mad_expand_nodelist()
(job.sh.j2) helpers. Stock SLURM emits a compressed nodelist and needs
`scontrol show hostnames`; spur exposes an already-expanded comma list and
does not implement `scontrol show hostname[s]`. Prefer comma-split for the
expanded form, fall back to scontrol only for compressed "node[..]" forms.
MASTER_ADDR, sglang-disagg node IPs, and the deepspeed hostfile now use this.
- Node rank: derive SGLANG_NODE_RANK from SLURM_PROCID, then NODE_RANK /
PMI_RANK / SLURM_NODEID, since spur leaves SLURM_PROCID empty inside srun.
- Submission: retry sbatch on transient Raft errors ("not the Raft leader",
"no leader elected yet", "service is currently unavailable").
- Completion check: drop unsupported `sacct -X`; parse the first (main job)
row; treat active states (RUNNING/PENDING/...) as RUNNING instead of FAILED
so a transient empty squeue result does not mark a live job as failed.
Co-authored-by: Cursor <cursoragent@cursor.com>
spur's srun cannot dispatch tasks to other nodes (runs once on the head node,
SLURM_PROCID empty), which breaks the standard `srun bash task` per-node model.
Add a spur deployment target that drives multi-node execution via a job ARRAY
of single-node tasks instead.
- New SpurDeployment(SlurmDeployment) target ("spur"), registered in the
deployment factory. It reuses the SLURM template/flow and only injects
scheduler="spur" + a shared-filesystem rendezvous_dir into the template context.
- job.sh.j2 gains {% if scheduler == 'spur' %} branches:
* header uses --nodes=1 --ntasks=1 --array=0-(N-1) instead of a multi-node job;
* node rank = SLURM_ARRAY_TASK_ID; MASTER_ADDR is resolved via a shared-FS
rendezvous (rank 0 publishes its ens3 IP, peers read it), matching the model
launcher's TCP rendezvous; port keyed off SLURM_ARRAY_JOB_ID;
* the per-node task body runs directly (bash) instead of via `srun`.
- Stock SLURM behavior is unchanged (scheduler defaults to "slurm").
Co-authored-by: Cursor <cursoragent@cursor.com>
spur's `sacct -j <id>` ignores the id filter and returns the whole cluster accounting history, so per-job state cannot be read from it (this is what made job states appear to "flap"). Make the spur backend robust: - SpurDeployment.monitor(): detect completion from per-rank marker files on the shared filesystem (<rendezvous_dir>/<array_job_id>/done_rank<N> = exit code), written by each array task. squeue (filtered by job name) is used only as a liveness guard so monitoring cannot hang if a task dies without a marker. - job.sh.j2: each spur array task writes its done_rank<N> marker; per-node logs and the node collection dir are now keyed by MAD_COLLECT_JOB_ID (= SLURM_ARRAY_JOB_ID on spur, = SLURM_JOB_ID on stock SLURM) so the existing collect_results(deployment_id=array_job_id) finds them. Stock SLURM behavior is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
- run_orchestrator: an explicit non-local deployment_config.target now takes precedence over structural inference. The spur backend reuses the "slurm" config block, so inference alone mis-detected "spur" manifests as "slurm". - job.sh.j2 (spur branch): pin SLURM_JOB_ID to SLURM_ARRAY_JOB_ID for the container so the model launcher's rendezvous port and shared /run_logs/<id> dir are identical on every node (array tasks otherwise each have a distinct SLURM_JOB_ID). The per-node task script filename now includes the node rank to stay unique on the shared filesystem, and SLURM_SUBMIT_DIR is defaulted. Co-authored-by: Cursor <cursoragent@cursor.com>
On spur, squeue does not list freshly-submitted array tasks for ~1-2 min (registration lag / eventual consistency), so a healthy run reports 0 live tasks at startup. The previous liveness guard counted those startup polls and aborted the run as failed, orphaning the still-starting tasks. Only start the "died without a marker" countdown after the tasks have been observed alive at least once, and treat squeue-unavailable (unknown) polls as non-fatal. Co-authored-by: Cursor <cursoragent@cursor.com>
…iters)
spur's squeue renders `-o "%j|%T"` as space-separated "<name> <state>",
ignoring the literal delimiter, so the previous partition("|") parse always
yielded 0 live tasks. Request "%j %T" and split on whitespace instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
Phase B / portable fallback: the self-managed "slurm_multi" launcher path also relied on srun to fan out (parallel docker pull with `srun --nodes=$SLURM_NNODES` and the model script's own internal srun), which does not work on spur. Add an IS_SPUR flag (False on SlurmDeployment, True on SpurDeployment) and spur-conditional branches in _prepare_slurm_multi_script: - header submits a job array (--nodes=1 --ntasks=1 --array=0-(N-1)); - each array task derives NODE_RANK from SLURM_ARRAY_TASK_ID, pins SLURM_JOB_ID to the shared SLURM_ARRAY_JOB_ID, resolves MASTER_ADDR via the shared-FS rendezvous, and pulls the image locally (no srun); - each task writes a done_rank<N> marker consumed by SpurDeployment.monitor(). Stock SLURM slurm_multi generation is unchanged (verified: nodes=N/ntasks=N, srun pull, no array/rendezvous/markers). Co-authored-by: Cursor <cursoragent@cursor.com>
Code reviewFound 16 issues. Ordered by severity; the "checked and cleared" section at the end lists what I verified as fine. Blocking1. Target-precedence flip regresses runtime overrides for all existing users The manifest's build-time Failure: build a manifest with Precedent: when the bare-metal backend hit the same "new backend reuses another's config shape" problem ( madengine/src/madengine/orchestration/run_orchestrator.py Lines 252 to 268 in 625e608 2.
Also missing: madengine/src/madengine/orchestration/run_orchestrator.py Lines 494 to 500 in 625e608 3. The
Cost: Worse, the madengine/src/madengine/deployment/slurm.py Lines 1618 to 1625 in 625e608 madengine/src/madengine/deployment/slurm.py Lines 1653 to 1673 in 625e608 madengine/src/madengine/deployment/base.py Lines 240 to 251 in 625e608 4. A job array gives no gang-scheduling guarantee — the rendezvous can silently produce an empty
The timeout is silent: madengine/src/madengine/deployment/templates/slurm/job.sh.j2 Lines 95 to 110 in 625e608 madengine/src/madengine/deployment/slurm.py Lines 550 to 560 in 625e608 5. Log collection is broken on the spur
The template path survives because the task script writes madengine/src/madengine/deployment/slurm.py Lines 481 to 484 in 625e608 madengine/src/madengine/deployment/slurm.py Lines 2256 to 2259 in 625e608 6.
7. Infinite monitor hang when squeue is unavailable on spur
madengine/src/madengine/deployment/spur.py Lines 160 to 164 in 625e608 Should fix8. Zero test coverage for 189 lines of new branchy state logic. No test references 9. madengine/src/madengine/deployment/templates/slurm/job.sh.j2 Lines 63 to 66 in 625e608 10. madengine/src/madengine/deployment/slurm.py Lines 577 to 594 in 625e608 11. Rendezvous logic duplicated in two already-drifting copies. madengine/src/madengine/deployment/slurm.py Lines 530 to 560 in 625e608 12. All N array tasks write the same completion-marker file. The path is namespaced by madengine/src/madengine/deployment/slurm.py Lines 635 to 637 in 625e608 Minor13. madengine/src/madengine/deployment/templates/slurm/job.sh.j2 Lines 179 to 181 in 625e608 14. 15. madengine/src/madengine/deployment/slurm.py Lines 107 to 109 in 625e608 16. None of the 7 commits use conventional-commit prefixes ( Checked and cleared
The two worth fixing first are 1 (silently misroutes existing non-spur users) and 3 (a spur-motivated change that only affects stock SLURM, and can hang the monitor). 4 is the one most likely to make real spur runs fail unexplainably. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Fixes issues 1-15 from the review of #157. Target routing - Revert the manifest-over-runtime precedence flip in RunOrchestrator: the manifest's build-time target no longer overrides --additional-context, restoring Convention over Configuration for existing k8s/slurm users. - Select spur with a distinguishing key instead, `slurm.scheduler == "spur"` (matching the template's existing `scheduler` context variable), taught to _infer_deployment_target, ConfigLoader.infer_and_validate_deploy_type, and _save_deployment_config. `target: "spur"` was previously unreachable from `madengine run`, since only "deploy" is merged back from the manifest. ConfigLoader now rejects an unknown slurm.scheduler and a "deploy"/scheduler mismatch, and routes spur through the SLURM presets. Correctness - Revert the _check_job_completion changes (dropping -X, the _ACTIVE_STATES early return): SpurDeployment.monitor() overrides monitor() and never reaches that code, so they only affected stock SLURM - where the early return could make monitor() report RUNNING forever. - Fail fast on the rendezvous instead of silently continuing with an empty MASTER_ADDR: `export VAR="$(cat missing)"` does not trip `set -e`, so a peer that timed out failed obscurely inside the launcher. Peers now print a diagnostic, write a non-zero done_rank marker so monitor() reports the failure, and exit non-zero. The wait is configurable via slurm.rendezvous_timeout and defaults to 900s (was a hardcoded 180s), since a job array carries no gang-scheduling guarantee. - Use %A_%a (array job id + index) rather than %j_%t for spur sbatch logs. %j is each array task's own job id, so _stream_job_output, _show_log_summary, collect_results and _collect_slurm_multi_results - which all glob on the id sbatch returned - found nothing. - Honour live_output in SpurDeployment.monitor(). - Bound consecutive "squeue unavailable" polls (_SPUR_UNKNOWN_POLLS) so a persistently unreachable control plane returns UNKNOWN instead of polling forever; the caller has no timeout. - Make the slurm_multi completion marker per-rank on spur: SLURM_JOB_ID is pinned to the shared array id, so all N tasks raced on one file. - Export SLURM_NODEID in the spur block; each array task is its own single-node allocation, so MAD_NODE_RANK was 0 on every node. - Match array tasks by job id in _live_task_count (falling back to the job name only if no row carries our id) so a concurrent run of the same model cannot inflate the liveness count, and omit `-u` when USER is unset. - shlex.quote the image name in the new spur docker-pull branch. - `export -f mad_expand_nodelist`: the generated TASK_SCRIPT runs as a separate bash process and does not inherit shell functions, so `command -v` was always false and the duplicated fallback ran instead. Cleanup - Extract the duplicated rendezvous bash into render_rendezvous_block(), shared by job.sh.j2 and the slurm_multi wrapper (the two copies had already drifted on the rank variable). - _expand_nodelist returns List[str], not list. Tests and docs - New tests/unit/test_spur.py (57 tests): target inference and the ConfigLoader validation paths, nodelist expansion, the fail-fast rendezvous, the rendered job script and slurm_multi wrapper for both schedulers, monitor()'s marker state machine, and _live_task_count parsing. - Document slurm.scheduler / slurm.rendezvous_timeout in docs/deployment.md and add a CHANGELOG entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
625e608 to
79b6791
Compare
Resolve conflicts between the spur backend and v2.2.0: - job.sh.j2 / slurm.py (slurm_multi script): keep the spur job-array branching and wrap `#SBATCH --gpus-per-node` in develop's `skip_gpus_directive` guard, so the opt-out applies to both flavors. - docs/deployment.md: keep the new `scheduler` / `rendezvous_timeout` keys; drop `mail_user` / `mail_type`, which #161 removed as documented keys madengine never implemented. - CHANGELOG.md: keep both Unreleased sections (spur Added, #161 Docs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…olls Follow-up review fixes on the spur deployment backend: - Skip the srun-based node health preflight on spur. `srun -w <node>` does not execute on the requested node there, and the `#SBATCH --nodelist` it pins conflicts with the job array, whose tasks each request `--nodes=1` (with --exclusive that deadlocks the rendezvous). Stock SLURM behavior is unchanged. - Accept an explicit `"deploy": "spur"` in `_infer_deployment_target()`. ConfigLoader already validates that form for the build path, so without this the same config would build for spur and run the stock SLURM template. - Bound the monitor() startup grace window. An array that fails before `squeue` ever lists it (bad partition, node failure, scheduler reject) writes no marker and never appears, so monitor() polled forever; it now fails with a diagnostic after ~10 minutes. Adds unit coverage for backend selection, inference consistency across ConfigLoader/build/run, the preflight skip, generated-script bash syntax, array log-collection ids, and rendezvous config plumbing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Adds support for the spur (Crusoe) scheduler, whose SLURM-compatible CLI
breaks stock multi-node fan-out (
srundoes not dispatch tasks to othernodes, empty
SLURM_PROCID;scontrol show hostnamesunsupported; Raft-basedeventually-consistent squeue/sacct).
SpurDeploymentbackend (target: spur) that fans out multi-node workvia a job array (one single-node task per node);
SLURM_ARRAY_TASK_IDis the node rank. Nodes self-form the cluster through the launcher's TCP
rendezvous (rank 0 publishes its transport IP to a shared FS, peers read it
as
MASTER_ADDR).monitor()(per-rankdone_rank<N>files) because spur
sacct -jdoes not filter by job id; squeue used onlyas an eventually-consistent liveness guard.
job.sh.j2: all spur logic gated behind{% if scheduler == 'spur' %};MAD_COLLECT_JOB_IDunifies log/artifact paths (==SLURM_JOB_IDon stockSLURM,
SLURM_ARRAY_JOB_IDon spur).targetnow wins over structuralinference (spur reuses the
slurmconfig block).slurm_multilauncher path reworked to fan out via job array on spur.Stock SLURM behavior is unchanged (spur paths are fully gated; render + import
smoke-checked).
Test plan
slurmandspurschedulersunrelated docker image build issue)