Add R worker cleanup trap and HPC doctor tooling#52
Open
smjenness wants to merge 6 commits into
Open
Conversation
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'.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
RSPH runs
ProctrackType=proctrack/linuxprocwith 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. Onscancel, 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 tor_loaderby default via a newcleanup_workersargument (setFALSEto 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 callsswf_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 viahpc_doctor_script():probe_node_cpu.shdegen_watch.shdeploy_doctor.shterm_orphans.shDetect 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
PPID=1is not an orphan test.parallelly::makeClusterPSOCKreparents healthy workers to init, so a live worker legitimately showsPPID=1. Verified: 88 such processes across six nodes were all healthy workers of live jobs.term_orphans.shinstead tests whether the process'sSLURM_JOB_IDstill resolves to a job alive insqueue. Getting this wrong destroys running work.Three load-bearing details in the trap, each a bug in an earlier draft:
grep -qxz(not-qz, or an emptySLURM_JOB_IDmatches every R process the user owns, including other live jobs); the[ -n "${SLURM_JOB_ID:-}" ]guard; and the trailing:soset -esteps 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/cgroupwithConstrainCores=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_maxchaining 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.Rdchurn and theRoxygenNotebump so the diff stays reviewable; you may want to regenerate with the project's pinned version.merge_simfiles.Rd(control.netsimshould becontrol.net). Left out of this PR.pgrep -x R, compute-to-compute ssh, and this cluster's no-cgroup behaviour). They should grow capability guards before being relied on elsewhere.