Skip to content

fix(slurm): stop the multi-node template retrying a completed run - #720

Merged
lfengad merged 3 commits into
nvidia-cosmos:mainfrom
shuangwu:fix/coordinated-shutdown-multinode
Jul 30, 2026
Merged

fix(slurm): stop the multi-node template retrying a completed run#720
lfengad merged 3 commits into
nvidia-cosmos:mainfrom
shuangwu:fix/coordinated-shutdown-multinode

Conversation

@shuangwu

Copy link
Copy Markdown
Collaborator

The bug

COSMOS_SHUTDOWN_ON_NO_POLICY_REPLICAS makes the controller SIGTERM itself once the last policy replica unregisters, so the allocation is released instead of idling to wall-clock. That is how a successful run ends — but the batch script sees 128+SIGTERM and cannot tell it from a crash, so it requeues and re-runs finished training until the retry budget is gone, then reports FAILED.

Observed on a 2-node GCP run (job 1781412) — four full 500/500-step runs across ~1.5 h of allocation, every one of them logging a clean shutdown:

run last step coordinated shutdown logged outcome
run_0 500/500 yes retried
run_1 499/500 yes retried
run_2 500/500 yes retried
run_3 500/500 yes FAILED
[cosmos-rl-sbatch]: Main loop exited with status: 143
[cosmos-rl-sbatch]: Job failed with status 143. No retries remaining.

The cost isn't only wasted allocation: sacct reports FAILED for a run that completed, so there is no way to tell a finished job from a broken one without reading logs.

Why the existing guard didn't catch it

handle_auto_retry already skips retry on SIGTERM:

if [[ "${received_signal}" == "SIGTERM" ]]; then
    log "Job was manually cancelled (SIGTERM). Skipping auto-retry."

But received_signal is set only by this script's own trap — it answers "was I cancelled?", not "how did the child exit?". A controller self-SIGTERM never signals the batch script, so the guard was never consulted.

The fix

Classify at the point where the monitor already knows which process exited and with what code:

if is_coordinated_controller_exit "${exit_code_controller}"; then
    log "Controller exited ... via coordinated shutdown. Treating as success."
    status=0
else
    status=${exit_code_controller}
fi

handle_auto_retry needs no change — it already returns early on status -eq 0. So one classification fixes the requeue, the FAILED state, and the misleading sacct output together.

Peer reaping now runs on both paths; it previously sat under the failure log only. On a coordinated shutdown the policy replicas are already gone (that is what triggered it), but a rollout can still be draining.

Kept deliberately narrow

Mirrors _is_coordinated_controller_exit (launcher/launch_all.py:397), which fixed the same misreading for the single-node CLI path in #714. Excusing too much would be worse than the original bug, so:

  • only the controller — the caller passes ${exit_code_controller}
  • only 128+SIGTERM
  • only when the feature that produces it is on, with truthiness matching utils/constant.py (1/true/yes, case-insensitive) rather than a non-empty test, so =0 stays off
  • only when the script was not itself signalled

That last clause is new relative to the single-node version, and it matters: SLURM's wall-clock SIGTERM also reaches the controller as 143. Without it, a job killed at its time limit would now report success — trading one false verdict for another. scancel and the --signal=B:SIGUSR1@... pre-timeout both set received_signal, so requiring it empty excludes both.

Tests

tests/test_slurm_multinode_exit.py, 13 cases, registered in run_test.sh. The template is an sbatch script with no import surface, so the functions are extracted and driven directly.

The first draft tested the predicate in isolation and was not sufficient — deleting the call site, i.e. reverting the fix entirely, left all 8 predicate tests green. Confirmed by mutation. So the suite now also drives the real monitoring loop with real child processes and asserts on the resulting status; the same mutation fails it with 143 != 0.

Covered in both directions:

  • controller self-SIGTERM with the feature on → status=0; with it off → still 143
  • an ordinary crash, a SIGKILL (137), a policy-side failure → unaffected
  • scancel and the pre-timeout SIGUSR1 → still fail loudly
  • the 1/true/yes truthiness utils/constant.py accepts

CPU-only, ~1.7 s.

Verification

Unit-tested and reasoned against a real failure. Not re-run on SLURM — the fix is in the template that job 1781412 exercised, but confirming end-to-end would need another multi-node allocation.

shuangwu added 2 commits July 29, 2026 22:10
COSMOS_SHUTDOWN_ON_NO_POLICY_REPLICAS makes the controller SIGTERM itself once
the last policy replica unregisters, releasing the allocation instead of idling
to wall-clock.  That is how a successful run ends, but the batch script saw
128+SIGTERM and could not tell it from a crash, so it requeued -- re-running
finished training until the retry budget ran out, then reporting FAILED.

Observed on a 2-node GCP run (job 1781412): four full 500/500-step runs over
~1.5h of allocation, final state FAILED, with every run logging a clean
coordinated shutdown.

The existing guard could not catch this.  handle_auto_retry skips retry when
received_signal == SIGTERM, but that is set only by this script's own trap --
it answers "was I cancelled?", not "how did the child exit?".  A controller
self-SIGTERM never signals the batch script, so the guard was never consulted.

Classify at the point where the monitor already knows which process exited and
with what code.  Setting status=0 there is enough: handle_auto_retry already
returns early on success, so the requeue, the FAILED state and the misleading
sacct output all follow from the one classification.

PR nvidia-cosmos#714 fixed the same misreading in launch_all.py for the single-node CLI
path; this mirrors _is_coordinated_controller_exit and keeps it equally narrow.
One clause is new: SLURM's wall-clock SIGTERM also reaches the controller as
143, so a job killed at its time limit would otherwise now report success --
requiring received_signal to be empty excludes both that and scancel.

tests/test_slurm_multinode_exit.py drives the extracted shell functions
directly, since the template is an sbatch script with no import surface.  It
covers both directions -- excusing too much would be worse than the original
bug -- including scancel, the pre-timeout SIGUSR1, SIGKILL, ordinary crashes,
and the 1/true/yes truthiness that utils/constant.py accepts.  Verified to fail
against the pre-fix behaviour.
The predicate tests passed with the call site deleted -- i.e. with the fix
fully reverted -- so they could not catch a regression.  Verified by mutation.

Drive the real monitoring loop with real children instead: a controller
exiting 143 must yield status=0 with the feature on, and still 143 with it
off, while a crash or a policy failure is unaffected.
Two failures, both from the test rather than the fix.

The template was located by repo layout.  CI copies only tests/ into the
image and installs the package into site-packages, so that path resolved to
nothing and every case errored with FileNotFoundError.  Resolve it next to
the installed module instead -- the .sh ships alongside it either way.

TestMonitorLoopWiring sat below `if __name__ == "__main__": unittest.main()`,
so `python tests/<file>.py` -- how run_test.sh invokes it -- registered only
the first 8 tests and never reached the wiring class.  pytest imports the
whole module, which is why local runs showed 13 while CI ran 8: the tests
added specifically to catch a revert of the fix were the ones not running.
Entry point moved to the end.

Verified as CI invokes it, from a foreign cwd: 13 run, and neutering the
call site still fails test_controller_self_sigterm_yields_success_status.
@lfengad
lfengad merged commit 05092d0 into nvidia-cosmos:main Jul 30, 2026
3 checks passed
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.

2 participants