Skip to content

Add spur (Crusoe) scheduler support for multi-node runs#157

Draft
mkuznet1 wants to merge 7 commits into
developfrom
adapt/spur
Draft

Add spur (Crusoe) scheduler support for multi-node runs#157
mkuznet1 wants to merge 7 commits into
developfrom
adapt/spur

Conversation

@mkuznet1

Copy link
Copy Markdown
Contributor

Summary

Adds support for the spur (Crusoe) scheduler, whose SLURM-compatible CLI
breaks stock multi-node fan-out (srun does not dispatch tasks to other
nodes, empty SLURM_PROCID; scontrol show hostnames unsupported; Raft-based
eventually-consistent squeue/sacct).

  • New SpurDeployment backend (target: spur) that fans out multi-node work
    via a job array (one single-node task per node); SLURM_ARRAY_TASK_ID
    is 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).
  • Marker-based completion detection in monitor() (per-rank done_rank<N>
    files) because spur sacct -j does not filter by job id; squeue used only
    as an eventually-consistent liveness guard.
  • job.sh.j2: all spur logic gated behind {% if scheduler == 'spur' %};
    MAD_COLLECT_JOB_ID unifies log/artifact paths (== SLURM_JOB_ID on stock
    SLURM, SLURM_ARRAY_JOB_ID on spur).
  • Orchestrator: an explicit non-local target now wins over structural
    inference (spur reuses the slurm config block).
  • slurm_multi launcher 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

  • Offline: template renders for both slurm and spur schedulers
  • Stock SLURM multi-node run unaffected (regression)
  • spur amd-spur 4-node run reaches container launch (blocked only by an
    unrelated docker image build issue)

Mikhail Kuznetsov and others added 7 commits July 15, 2026 12:50
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>
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>
@mkuznet1 mkuznet1 self-assigned this Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant