Skip to content

Add R worker cleanup trap and HPC doctor tooling#52

Open
smjenness wants to merge 6 commits into
mainfrom
hpc-worker-cleanup-and-doctor
Open

Add R worker cleanup trap and HPC doctor tooling#52
smjenness wants to merge 6 commits into
mainfrom
hpc-worker-cleanup-and-doctor

Conversation

@smjenness

Copy link
Copy Markdown
Collaborator

RSPH runs ProctrackType=proctrack/linuxproc with no cgroup containment, so SLURM finds a job's processes by walking parent-PID chains. R's PSOCK workers reparent to init, severing that chain. On scancel, TIME_LIMIT or preemption the master dies and the workers survive, still pinned to their original core mask, while SLURM marks those cores free and schedules new work onto them. Two process sets then share the same cores, so the next task gets about half a core, runs many times slower, and is eventually killed at its walltime, leaking more workers in turn.

Measured 2026-07-18: cancelling one calibration array left 163 orphans on 15 nodes for about 2.5 hours (roughly 400 core-hours). Two unrelated projects (India_COVID19_model and LA-PrEP-2026) were degraded on the same node in the same two-hour window.

What this adds

swf_cleanup_r_workers() returns the TERM/EXIT trap that reaps a task's own workers. swf_configs_rsph() now appends it to r_loader by default via a new cleanup_workers argument (set FALSE to opt out).

This matters beyond the mechanism: the trap already existed, hand-written, in LA-PrEP-2026's hpc_configs.R, and was simply absent from India_COVID19_model. Neither project calls swf_configs_rsph(); both hardcode their own node setup. That divergence is why the leak went unnoticed for months. Putting it here lets projects stop maintaining private copies.

inst/hpc_doctor/ ships four shell tools, located via hpc_doctor_script():

script role
probe_node_cpu.sh node-side; instantaneous CPU per SLURM task, one ssh per node
degen_watch.sh classifies running tasks, requeues degenerate ones
deploy_doctor.sh 1-CPU companion job for the life of a campaign, self-terminating
term_orphans.sh SIGTERMs genuinely orphaned workers, safe on shared nodes

Detect by CPU, not by runtime

Healthy workers sit at ~99% CPU, starved ones at ~50%. That signal is distribution-free: no runtime history, so no cold-start window on a new deploy, and no per-project calibration. Critically it cannot mistake a legitimately long scenario for a broken one, since a slow task doing real work still reads ~99% and is spared. A runtime threshold cannot make that distinction at all.

Two things worth reviewing carefully

  1. PPID=1 is not an orphan test. parallelly::makeClusterPSOCK reparents healthy workers to init, so a live worker legitimately shows PPID=1. Verified: 88 such processes across six nodes were all healthy workers of live jobs. term_orphans.sh instead tests whether the process's SLURM_JOB_ID still resolves to a job alive in squeue. Getting this wrong destroys running work.

  2. Three load-bearing details in the trap, each a bug in an earlier draft: grep -qxz (not -qz, or an empty SLURM_JOB_ID matches every R process the user owns, including other live jobs); the [ -n "${SLURM_JOB_ID:-}" ] guard; and the trailing : so set -e steps do not exit non-zero on a normal exit.

Scope and limits

The trap is containment, not protection: it stops a dying job leaving orphans, but does nothing to protect a job from orphans already squatting on cores it was handed. It does not cover SIGKILL or, reliably, preemption. The root fix is administrative (proctrack/cgroup + task/cgroup with ConstrainCores=yes); everything here is mitigation until that lands.

The doctor deliberately does not submit workflow slices early (pipelining). That would need exclusive ownership of slice progression, and if it submitted while a step's built-in array_max chaining also submitted, two tasks would write the same output file concurrently and corrupt it.

Notes

  • man/ was regenerated with roxygen 8.0.0. I reverted the unrelated .Rd churn and the RoxygenNote bump so the diff stays reviewable; you may want to regenerate with the project's pinned version.
  • Regenerating also surfaced a pre-existing stale doc in merge_simfiles.Rd (control.netsim should be control.net). Left out of this PR.
  • The shell tools are RSPH-shaped (they assume pgrep -x R, compute-to-compute ssh, and this cluster's no-cgroup behaviour). They should grow capability guards before being relied on elsewhere.

RSPH runs ProctrackType=proctrack/linuxproc with no cgroup containment, so
SLURM finds a job's processes by walking parent-PID chains. R's PSOCK workers
reparent to init, which severs that chain. On scancel, TIME_LIMIT or preemption
the master dies and the workers survive, still pinned to their original core
mask, while SLURM marks those cores free and schedules new work onto them. Two
process sets then share the same cores, so the next task gets about half a core,
runs many times slower, and is eventually killed at its walltime, leaking more
workers in turn.

Measured 2026-07-18: cancelling one calibration array left 163 orphans on 15
nodes for about 2.5 hours (roughly 400 core-hours), and two unrelated projects
were degraded on the same node in the same two-hour window.

swf_cleanup_r_workers() returns the TERM/EXIT trap that reaps a task's own
workers, and swf_configs_rsph() now appends it to r_loader by default via a new
cleanup_workers argument. This trap had been copy-pasted into one project and
was missing from another, which is how the leak persisted unnoticed; putting it
here means projects stop maintaining their own copies.

inst/hpc_doctor/ ships four shell tools, located via hpc_doctor_script(). They
detect degeneration by CPU utilisation rather than elapsed time, which needs no
runtime history and no per-project calibration, and so cannot mistake a
legitimately long scenario for a broken one.
First production campaign turned up a case the classifier got right by accident.
A task read median 0% CPU across nine processes with only one in D-state. Eight
of nine were idle rather than blocked, so it was hung, not stalled on the
filesystem, but it was routed down the I/O branch because that branch triggered
on any D-state process without reference to how many.

The classifier now weighs dstate against nproc. A majority blocked is a
filesystem stall and still gets the long IO_RECHECK wait; few blocked with
near-zero CPU is a hung task and is requeued promptly, since waiting accomplishes
nothing when nearly every process is idle. Neither excludes the node. The
ambiguous middle keeps the previous behaviour, so only the clearly-hung case
changes. IO_MIN_FRAC (50) and HUNG_CPU (5) are the new knobs.

Also adds a README covering the guardrails and the field evidence from that
campaign, including two things worth knowing before tuning this: requeuing does
not fix a filesystem stall, since the task relocates but meets the same
filesystem; and requeue cost scales with queue depth, because a requeued array
task gets a new SubmitTime and re-enters behind everything submitted earlier.

Fixes a log bug where a task finishing during the wait printed 'cleared (gone%)'.
The field-evidence note claimed the orphan-contention mode had not been observed
in the wild. It was, about six hours into the same campaign: a nine-process task
at 51% CPU with dstate=0 on node4, which the doctor requeued and excluded the
node for, as designed. Correcting the note and adding that node4 produced nearly
every intervention, which is the repeat-degradation case the static-exclusion
argument is reserved for.
The first production campaign put every one of its four confirmed interventions
on a single node (node4): three filesystem stalls and one orphan-contention. The
classifier spares the node on a filesystem stall, which is correct for one
occurrence, but the doctor had no memory across sweeps, so it kept letting fresh
tasks land on node4 and stall again. The pattern was only visible by grepping the
log.

degen_watch.sh now keeps a per-node count of confirmed interventions. Past
NODE_OFFENSE_LIMIT (default 3) the node is a repeat offender: logged loudly and
excluded on that requeue even when the symptom alone would have spared it, since
a node that degenerates repeatedly is the common factor whatever the proximate
cause. Replayed against the campaign, node4's first two events stay spared, the
third escalates, and a lone stall on any other node does not.

The ledger is campaign-scoped, not a static blocklist: STATE_FILE is keyed to the
doctor's own SLURM job id, so it resets each campaign and two concurrent doctors
never share it. Counting runs even in report-only mode so a dry run still
surfaces the pattern. This keeps a genuine cross-campaign offender visible for a
real --exclude rather than papering over it.
…lusion

Two lessons from the second (overnight) campaign, where all four interventions
were filesystem (D-state) stalls and none was the orphan-contention mode the
tool targets.

Filesystem stalls are transient: every overnight requeue had a sibling on the
same node recover to 98-99% in the same window, and one suspect finished during
the wait. Requeuing on the first recheck discards ~20 min of work for a task that
would likely have completed, and a requeue barely helps (PanFS is cluster-wide).
A D-state stall now gets IO_MAX_CYCLES recheck cycles (default 3) before requeue,
sparing it if it recovers at any cycle.

The offense ledger contradicted the classifier: it escalated any node to
exclusion at NODE_OFFENSE_LIMIT confirmed events, but the classifier calls a
filesystem stall node-not-at-fault. node3 filesystem-stalled twice overnight with
its siblings recovering each time; excluding it would have been wrong. Escalation
is now gated by classification: only repeated CPU-starvation events (a node
genuinely harbouring orphans) escalate; filesystem stalls are logged but never
drive exclusion.

Also fixes the 'cleared (gone%)' log substitution and updates the README field
evidence to lead with filesystem stalls being the dominant, and orphan contention
the rare, degeneration mode.
node3 filesystem-stalled a third time and the pre-fix ledger did escalate and
exclude the node, confirming the exact case the classification-gated fix removes.
Strengthens the field-evidence note from 'would have been wrong' to 'did happen'.
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