fix(spur-sched): allocate CPUs and cap nodes when tasks fewer than nodes - #570
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a scheduling/accounting bug where CPU-only jobs with fewer tasks than requested nodes (e.g. -N4 -n1) could reserve 0 CPUs per node due to truncating division, making the allocation effectively invisible to resource tracking and allowing oversubscription. It also aligns persisted/reporting node counts with what the scheduler can actually allocate by capping num_nodes at submission time when no explicit per-node layout is provided.
Changes:
- Normalize submitted
JobSpec.num_nodestoeffective_num_nodes()(caps nodes to tasks unless--ntasks-per-nodeis set) so persisted specs and reporting reflect allocatable resources. - Fix per-node CPU reservation to use ceiling division and floor at 1 CPU, preventing “0 CPU” allocations when tasks < nodes.
- Add unit tests covering node normalization and CPU reservation edge cases.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| crates/spurctld/src/cluster.rs | Normalizes num_nodes at submit-time and adds tests ensuring persisted specs match allocatable node counts. |
| crates/spur-sched/src/backfill.rs | Fixes per-node CPU calculation via div_ceil and adds tests for CPU/memory reservation behavior when tasks < nodes. |
| crates/spur-core/src/job.rs | Introduces JobSpec::effective_num_nodes() and unit tests for node-capping semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #570 +/- ##
==========================================
+ Coverage 75.83% 76.19% +0.36%
==========================================
Files 166 166
Lines 64696 65235 +539
==========================================
+ Hits 49058 49701 +643
+ Misses 15638 15534 -104 🚀 New features to boost your workflow:
|
biluriuday
left a comment
There was a problem hiding this comment.
The Raft reasoning in the description checks out against the code: normalization runs before expand_job_specs and propose, the JobSubmit apply arm (crates/spurctld/src/cluster.rs:3450-3468) is a pure insert with no re-derivation, followers forward raw requests, and effective_num_nodes is pure and idempotent — so replicas cannot diverge and pre-upgrade entries replay byte-identically. The two test fixups adding num_tasks = 2 (crates/spurctld/src/cluster.rs:8009, 10446) preserve each test's intent rather than weakening assertions, which is the honest way to handle it.
Few comments on the changes. Please take a look
[C1] Clients that don't send a task count silently lose nodes. The reduction is applied unconditionally in submit_job, but by then "the user didn't pass -n" has already been erased and replaced with the literal 1:
crates/spurctld/src/rest/handlers.rs:91-92 — num_nodes: body.job.nodes.unwrap_or(1), num_tasks: body.job.ntasks.unwrap_or(1)
crates/spurctld/src/server.rs:2163-2164 — num_tasks: spec.num_tasks.max(1), so proto3's absent-field 0 becomes 1
crates/spur-ffi/src/types.rs:34 defaults num_tasks: 1 while crates/spur-ffi/src/lib.rs:78 maps min_nodes to num_nodes
So POST {"job": {"nodes": 4, "script": "..."}} with no ntasks allocated 4 nodes before this PR and allocates 1 after, with no error or warning. Same for a C caller that sets min_nodes = 4 and leaves num_tasks alone — the natural thing to do, since real Slurm's slurm_init_job_desc_msg sets num_tasks = NO_VAL meaning "one per node". sbatch escapes this only because effective_ntasks (crates/spur-cli/src/sbatch.rs:799-812) already defaults tasks to the node count. That asymmetry is why cluster testing through the CLI couldn't catch it: the one path that has the information does the right thing, and the three that don't now silently misbehave. These are surfaces AGENTS.md requires to stay Slurm-compatible.
Upstream Slurm handles this in src/sbatch/opt.c, and the guard is the whole point:
} else if (opt.nodes_set && opt.ntasks_set) {
if (opt.ntasks < opt.max_nodes)
opt.max_nodes = opt.ntasks;
if (opt.ntasks < opt.min_nodes) {
warning("can't run %d processes on %d nodes, setting nnodes to %d",
opt.ntasks, opt.min_nodes, opt.ntasks);
opt.min_nodes = opt.max_nodes = opt.ntasks;
}
}
Your empirical NODES=1 observation is correct — Slurm does reduce. But it gates on nodes_set && ntasks_set, it warns, and it runs in the client because that's the only layer that knows which flags the user typed. effective_num_nodes is this logic with the guard removed.
Preferred fix, which keeps the part of your design that's genuinely good: make num_tasks an optional uint32 in proto/slurm.proto (wire-compatible — field number and type unchanged), map None to num_nodes in proto_to_job_spec, and take body.job.ntasks as an Option in REST. Normalization then stays in submit_job where the determinism property holds, but only fires when a task count was actually supplied — fixing REST, gRPC, FFI, and any future adapter at once.
[C2] --gpus=N totals collapse onto a single node. resolve_gpu_demand derives per-node placement from spec.num_nodes (crates/spur-core/src/gpu_request.rs:203), and the allocator spreads a total across assigned nodes (crates/spur-sched/src/backfill.rs:138-146). So --gpus=8 -N4 -n1, which meant 2 GPUs on each of 4 nodes, now demands 8 GPUs on one node — unschedulable on any cluster with 4-GPU nodes. Related: --gpus-per-node=2 -N4 -n1 drops the job's total from 8 GPUs to 2, and --mem drops 4× because effective_memory_mb multiplies by num_nodes (crates/spur-core/src/job.rs:616-627). For a GPU-first scheduler this is the highest-impact consequence of the change, and there's no test for any of it.
[C3] Validation and persistence disagree about the node count. GPU validation runs before the mutation, inside proto_to_job_spec (crates/spurctld/src/server.rs:2353) and at crates/spurctld/src/rest/handlers.rs:105. So -N4 -n1 --gpus=2 is rejected as TotalLessThanNodes { total: 2, nodes: 4 }, even though the spec that would be persisted (1 node, 2 GPUs) is valid and would run. The scheduler then re-resolves the same demand against the normalized count, so two different node counts are in play for one job. The error message is actively misleading.
[I1] The min_nodes case is a silent permanent stall, not a minor follow-up. validate_partition (crates/spurctld/src/cluster.rs:387-414) checks only partition existence and account ACLs — it never looks at node bounds. Those live in partition_limits_allow (crates/spurctld/src/cluster.rs:4665-4673), consulted only by pending-reason tagging. So -N4 -n1 on a MinNodes=4 partition is accepted, shrunk to 1, tagged PartitionConfig, and dropped from scheduling entirely — the behavior your own existing test asserts at crates/spurctld/src/cluster.rs:8062 ("min_nodes-blocked job must be dropped from scheduling"). It never runs and nothing cancels it. I don't think this is acceptable as a follow-up: Slurm rejects at submit with a clear error, and this PR introduces the condition — before it, the job had num_nodes=4 and passed. Accepting a submission you've already determined is unschedulable is a defect. Either clamp with .max(part.min_nodes) or move the bounds check into validate_partition, run it after normalization, and return SubmitError::invalid.
[I2] The user is never told their request was shrunk. crates/spurctld/src/cluster.rs:333 rewrites a user-facing field with no client-visible warning, no distinct log line, and nothing in SubmitJobResponse; the only log is the unconditional info!(job_id, "job submitted"). Slurm prints sbatch: Warning: can't run 1 processes on 4 nodes, setting nnodes to 1 precisely because this surprises people. At minimum emit a warn! with both counts; better, return a warning in SubmitJobResponse and have sbatch/srun/salloc print it.
[I3] div_ceil changes packing for every uneven job, and the PR doesn't mention it. -N2 -n7 -c1 goes from 3 to 4 CPUs per node — 8 CPUs reserved for 7 tasks. Because the per-node figure drives feasibility filtering, a job that genuinely fits can now be rejected: two nodes with 4 and 3 free CPUs can host -N2 -n7, but every candidate must clear the 4-CPU bar. It also desynchronizes accounting, since job_tres charges num_tasks * cpus_per_task = 7 (crates/spurctld/src/cluster.rs:4698-4701) against a reservation of 8. Rounding up is still the right direction versus the old truncation, so this is a net improvement — but it's the wrong model, and the machinery for the right one already exists: tasks_per_node_counts (crates/spur-core/src/gpu_request.rs:153-170) computes exact per-node counts honoring the distribution policy, and GpuDemand::PerNode already threads a per-node vector through the scheduler. CPUs should use the same representation instead of a scalar.
[I4] The Slurm-compat comments name the wrong layer. crates/spurctld/src/cluster.rs:332 says "matching Slurm's submission-time reduction" and crates/spur-core/src/job.rs:601 says "like Slurm's cons_tres". The reduction lives in the sbatch/srun/salloc option verifier, not cons_tres and not slurmctld, and only fires when both flags are explicit.
[I5] PMIx validation silently flips from reject to accept. validate_single_node_pmix is called immediately after the mutation (crates/spurctld/src/cluster.rs:335-337), so -N4 -n1 --mpi=pmix used to error at submit and now passes. Probably the desired outcome, but it's an unstated and untested change to a guardrail.
[I6] --nodelist flips from additive to restrictive. nodelist_is_additive compares the node count to the list length (crates/spur-sched/src/node_match.rs:88-93). -N4 -n1 -w node001,node002 was additive (4 > 2, a floor the scheduler could grow) and is now a hard restriction (1 > 2 is false). The end result is arguably more sensible, but it's an unanalyzed semantic change to a flag the description doesn't mention. Worth a test and a line in the description.
|
Thanks, all addressed in
No wire/Raft/config break; one operator note: partition-bound violations that previously pended are now rejected at submit. |
|
Thanks @shiv-tyagi — the earlier round looks well handled, and the cross-surface ntasks defaulting (gRPC/REST/FFI all treating an absent ntasks as one-per-node) reads consistently now. No correctness concerns from me. Two asks before merge:
Minor, non-blocking: the reduction-warning test asserts |
CPU-only jobs requesting fewer tasks than nodes (e.g. `sbatch -N4 -n1`) reserved 0 CPUs per node because the per-node CPU share used floor division (num_tasks / num_nodes). The allocation was invisible to resource tracking, so any number of such jobs could stack on the same nodes, and the job also held nodes it could never place work on. - base_node_request now uses ceiling division and floors at one CPU, so the busiest node's share is never under-reserved and every allocated node consumes tracked capacity. - submit_job normalizes num_nodes to min(num_nodes, num_tasks) at intake (unless --ntasks-per-node pins the layout), matching Slurm cons_tres. The persisted count equals what is allocated, so squeue, scontrol, sacct, and the in-job SLURM_NNODES all stay consistent with the nodelist. Verified against Slurm 25.11.7 on a 4-node bare-metal cluster: T1-T7 node selection cases all produce matching RUNNING/PENDING outcomes.
…ently lost Address the review of the node-count normalization fix. The reduction to min(num_nodes, num_tasks) was correct for the CLI but the other intake surfaces and the scheduler mishandled the surrounding cases. - Default an unset ntasks to one task per node (num_nodes), not 1, on every surface: gRPC treats num_tasks=0 as unset, REST filters ntasks<=0, FFI maps NO_VAL. Previously REST/gRPC/FFI collapsed a multi-node request to one node and under-counted GPUs/memory. - Move GPU validation after normalization in submit_job so a request valid once reduced (e.g. -N4 -n1 --gpus=2) is accepted instead of wrongly rejected. - Reject a node count outside the partition's MinNodes/MaxNodes at submit, matching Slurm, instead of accepting a job that would pend forever. - Return warnings from submit_job and surface them over gRPC, REST, and the CLI (plus a controller warn! log) when the node count is reduced. - Reserve the exact per-node CPU vector (block split, e.g. -N2 -n7 -> [4,3]) through the heterogeneous path instead of a uniform scalar, removing both over-reservation and wrongful rejection for uneven jobs. - Correct the comments to name Slurm's sbatch/srun/salloc option verifier as the layer that performs the reduction. Slurm-parity rationale (moved out of inline comments per review): real Slurm caps the node count in its sbatch/srun/salloc option verifier, only when both --nodes and --ntasks are given, and warns the user; it does not do this in slurmctld. effective_num_nodes reproduces that reduction at intake, and every surface now supplies one-task-per-node when ntasks is absent so the guard sees the same inputs the CLI does. Proto is wire-compatible: num_tasks keeps its tag and type (a comment documents 0 = unset); SubmitJobResponse.warnings is an append-only field. JobSpec is unchanged, so existing Raft logs replay unaffected. Adds unit tests per surface (C1), GPU accept after reduction (C3), partition reject (I1), shrink warning (I2), PMIx single-node flip (I5), restrictive nodelist after reduction (I6), and the uneven per-node CPU split (I3).
42268d0 to
b491486
Compare
|
Thanks @yansun1996. All three addressed in
Re-verified the full intake/allocation matrix on the 4-node bare-metal cluster through the real CLI and REST: C1 (CLI and REST both keep 4 nodes when ntasks is absent), the reduction warning surfaces to the client, C3 ( |
What this fixes
CPU-only jobs requesting fewer tasks than nodes (e.g.
sbatch -N4 -n1) reserved 0 CPUs per node. The per-node CPU share used floor divisionnum_tasks / num_nodes, which truncates to 0 when tasks are fewer than nodes. The allocation was then invisible to resource tracking, so any number of such jobs stacked on the same nodes, and the job also held nodes it could never place work on. Tracked as SPUR-13.Approach
base_node_requestcomputes per-node CPUs with ceiling division and floors at one CPU. The busiest node's share is never under-reserved, and every allocated node consumes tracked capacity.submit_jobnormalizesnum_nodestomin(num_nodes, num_tasks)at intake, unless--ntasks-per-nodepins the per-node layout. This matches Slurm cons_tres. Because the persisted count equals what is allocated, squeue, scontrol, sacct, and the in-jobSLURM_NNODESall stay consistent with the nodelist. The scheduler trustsnum_nodesas the single source of truth.Design choices
num_nodeskeeps its type; only its stored value is reduced for affected jobs, so serialized Raft/WAL state stays compatible.Testing
cargo test --locked: 2521 passed, 0 failed.cargo clippy --workspace --exclude spur-ffi --all-targets --locked: clean.effective_num_nodes, thebase_node_requestCPU cases, andsubmit_jobnormalization.-N4-N4 -n1-N4 -n1 -c1-N4 -c4-N2 -n1(x2)-N4 --exclusive-N4 --ntasks-per-node=1Reporting:
-N4 -n1now reportsNODES=1/NumNodes=1, matching Slurm.Follow-ups
-N4 -n1job submitted to a partition withmin_nodes>1now pends as PartitionConfig instead of running. Narrow edge case, arguably correct. Slurm's bump-up-to-min_nodesbehavior can be added separately if wanted.