Checkpoint/resume: design + phase 1a (serial hub, dill-reload) - #777
Checkpoint/resume: design + phase 1a (serial hub, dill-reload)#777DLWoodruff wants to merge 45 commits into
Conversation
cdc8b0f to
e5f8c4e
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #777 +/- ##
==========================================
+ Coverage 77.77% 78.01% +0.23%
==========================================
Files 177 179 +2
Lines 23764 24137 +373
==========================================
+ Hits 18482 18830 +348
- Misses 5282 5307 +25 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
31f8ad5 to
02ee029
Compare
Design for checkpointing mpi-sppy runs (serial, multi-rank, and cylinders) so a killed or interrupted job can resume where it left off. Approach: reconstruct the scaffolding (MPI comms, RMA windows, persistent solvers, Pyomo models) via the normal startup path, then restore the algorithmic state (iteration counter, W, nonant values, rho, the incumbent / best xhat, and stateful-extension internals). Pickling the whole object graph with dill is not viable -- the core objects hold live MPI communicators, RMA windows, and persistent solver handles. Doc-only; no library code yet. Covers: why dill-everything fails, the state inventory (with a reconstructed-vs-restored and bit-reproducible-vs-carried- forward breakdown), the determinism contract (primal trajectory bit-identical; bounds/incumbent valid but carried forward, not reproducible), the required core changes, a proposed file layout, and a 6-phase rollout. Validated by a serial + multi-rank + cylinders proof of concept on farmer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
02ee029 to
be8b367
Compare
The method definition is at spcommunicator.py:1010, not 1019. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| primal state (W/nonants/rho) is unaffected (it is gathered from nonants/params), | ||
| but any *full-objective* read taken right after an eval is wrong. Snapshot and | ||
| restore all vars around an eval, or evaluate on a copy. | ||
| 5. **Geometry / cfg fingerprint** (§5.6) with a clear refusal on mismatch. |
There was a problem hiding this comment.
I'm confused on this point -- doesn't serializing the best solution as already stored by the xhat / incumbent spokes already do this?
There was a problem hiding this comment.
Yes — and that's the direction I took the revision. §5.3 already identified the xhat spoke's best_solution_cache as the thing to serialize; I've now made it explicit (revised §5.3 and §9 item 6) that the spoke checkpoints that cache itself, asynchronously on each improvement (reusing _maybe_write_incumbent_on_improvement), with no separate hub-driven coordination. So there's no extra machinery beyond serializing what the spoke already holds.
| 7. **Atomic, per-rank, barriered writes** (already in the PoC): each rank writes | ||
| only its local state to a rank-tagged file via temp-then-rename, inside a | ||
| barrier, so a hard kill never yields a half-written or partial-across-ranks | ||
| checkpoint. Retain the last *k* checkpoints (configurable) so a kill *during* | ||
| a checkpoint still leaves a usable earlier one. |
There was a problem hiding this comment.
Isn't it sufficient to just keep a single snapshot? After we complete the operations to write the new snapshot, can't we just immediately delete the prior one?
There was a problem hiding this comment.
Agreed — revised §9 item 7 to a single published generation. The new generation is written to fresh files, then manifest.json is atomically renamed to point at it (the single commit point), then the prior generation is deleted. A kill before the flip keeps the previous complete checkpoint; a kill after keeps the new one. So one committed generation is enough for kill-safety; keeping older generations is now just optional history, not a requirement.
There was a problem hiding this comment.
Follow-up: the doc now goes one step further and pins this down — retention of more than one checkpoint is explicitly not supported (d11832a). Exactly one committed generation exists at any time; the prior one is deleted right after the manifest flip, and the only time two exist is transiently during a publish (the documented peak disk footprint). The earlier "older generations are optional history" wording is gone.
Adopt bknueven's review suggestions on Pyomo#777: - Drop the hub-triggered snapshot barrier. Each spoke checkpoints its own incumbent asynchronously on improvement (reusing _maybe_write_incumbent_on_improvement); the determinism contract already makes bounds/incumbent carried-forward best-so-far, so a globally-consistent cross-cylinder snapshot at iteration k is unnecessary — and this removes the stall/deadlock risk against got_kill_signal. (§5.3, §9 item 6, §11 Phase 4) - Single published generation instead of last-k retention: the manifest flip is the atomic commit point, so one committed generation is enough for kill-safety; older generations are optional history. (§8, §9 item 7, §10) - File layout: hub/ keeps iteration-tagged generations; spokes/ keeps latest-wins per-spoke files overwritten async on improvement. (§10) - Clarify the variable_probability note: restore must reproduce the zero-prob W-masking invariant (surrogate fixed-at-0), not just reload raw W. (§12) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ned stop/resume
Re-center the checkpoint/resume design on the concrete use case: a multi-day
run that intentionally stops at end of day and resumes the next morning, with
large MIP scenarios, in the same environment, checkpointing about twice as
often as it resumes.
At that (infrequent) checkpoint cadence the per-write overhead of dilling the
whole scenario model is negligible, and same-day resume makes dill's version
fragility irrelevant -- so dilling the mid-run scenario models becomes the
recommended backend. It captures W/rho/nonants/fixedness, the second-stage
MIP warm start (via the existing warmstart_subproblems / solution_available
path), the linearized-prox cuts + ProxApproxManager, and model-attached
extension state (fixer's counter) in one consistent shot, and avoids re-running
an expensive scenario_creator on resume.
Keep the leaf-rebuild path as the low-cost backend (tiny, fast, version-robust
checkpoints) for small/cheap-creator runs or frequent kill-safety writes;
select via --checkpoint-backend {dill-model, leaf}. Reconstructing the
scaffolding (comms/windows/solvers) and restoring the non-model state
(iteration counter, hub bounds/incumbent, spoke best-xhat by name, extension-
object state, cursor/RNG) is common to both backends.
Soften the determinism contract for MIPs (correct warm-started continuation +
preserved incumbent, not bit-reproducible), shift the primary trigger to
end-of-run/on-signal, and flag the one load-bearing unvalidated assumption --
that a mid-run MIP model round-trips through dill -- as Phase 1's first job.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…un-dill risk Fold the MIP dill-reload PoC (sizes SIZES3, gurobi_persistent single-thread) into the design: - §6: the dill-reload backend is now validated on a MIP. A mid-run scenario model -- including the linearized-prox case (845 xsqvar_cuts + 65 ProxApproxManagers) -- round-trips through dill in-process and cross-process, re-solving to identical objective + decision vars; a serial stop->reload-> continue is bit-identical to an uninterrupted run under the deterministic single-thread solve. Note what remains for later phases (multi-rank, cylinders, incumbent carry, warm-start speedup, scale footprint). - §9 item 2: the reload branch must skip attach_Ws_and_prox as well as attach_PH_to_objective, and refresh saved_objectives[sname] (Eobjective reads those handles; they dangle to the discarded fresh model after a swap). The swap targets local_scenarios (there is no local_subproblems). - §12: move the mid-run-dill round-trip from "Still open" to "Resolved". Also reword the leaf-rebuild backend in §2.2 from "is retained as" to "remains available as" the low-cost backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a design document describing a checkpoint/resume architecture for mpi-sppy runs (including cylinders), focusing on planned stop/resume of long-running, large-scenario MIP studies by reconstructing MPI/solver scaffolding and restoring algorithm/model state (primarily via dilling mid-run scenario models).
Changes:
- Introduces a doc-only design specifying what state is reconstructed vs restored, and proposes two backends (
dill-modelrecommended;leafalternative). - Defines checkpoint semantics (triggers, atomic publication via a manifest, geometry/cfg fingerprinting) and details state inventory including incumbents and extension state.
- Proposes a phased implementation plan with testing expectations and rollout milestones.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| dangle to the discarded fresh model — and note the reload targets | ||
| `local_scenarios` only (there is no `local_subproblems`; the solve path | ||
| iterates `local_scenarios`). This is a distinct branch from the leaf-rebuild |
There was a problem hiding this comment.
Good catch — this was inaccurate, and it flagged a real implementation gotcha. Corrected in 3bcc029: a plain PH has no local_subproblems, but the generic file-based path the dill-reload backend builds on sets sp.local_subproblems = sp.local_scenarios (scenario_io.py) and CGBase.solve_loop iterates it, so the reload branch must refresh that alias where it exists or it dangles to the discarded model. (Re-opened — this had been resolved by mistake.)
| - **Warm-start plumbing:** `spopt.py` already supports warm-starting subproblem | ||
| solves — the `warmstart_subproblems` option plus `WarmstartStatus.PRIOR_SOLUTION` | ||
| use a warm start when `s._mpisppy_data.solution_available` is set | ||
| (`spopt.py:301-305`). Restoring the model's variable values and setting | ||
| `solution_available=True` feeds the restored MIP solution straight into this | ||
| path — no new solver code. |
There was a problem hiding this comment.
Agreed — replaced the file.py:line references with file + symbol (e.g. warmstart_subproblems in spopt.py) in 3bcc029 so they stay stable as the code moves.
| Status: **draft** (framework and dill-reload backend both PoC-validated — the | ||
| latter on a serial MIP; multi-rank and cylinders pending). Scope: checkpoint a | ||
| running mpi-sppy job so it can be stopped and resumed later. Must work on multiple | ||
| MPI ranks and for cylinder (hub-and-spoke) runs. |
There was a problem hiding this comment.
Fixed in 3bcc029 — the CLI flag is now --checkpoint-backend dill-reload (was dill-model), so the flag and the narrative use one name, with "reload" as the restore step.
- Correct the local_subproblems claim (§9 item 2): a plain PH has none, but the generic file-based path the dill-reload backend builds on sets sp.local_subproblems = sp.local_scenarios (scenario_io.py) and CGBase.solve_loop iterates it, so the reload branch must refresh that alias where it exists or it dangles to the discarded model. - Reconcile backend naming: the CLI flag is now --checkpoint-backend dill-reload (was dill-model) so the flag and the narrative use one name; "reload" is the restore step. - Replace volatile file:line references with file + symbol (e.g. "warmstart_subproblems in spopt.py" instead of "spopt.py:301-305") so they do not drift as the code moves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gers Extend §8 from the single `--checkpoint-every k` cadence to three composable triggers, enabled by `--checkpoint-dir`: - `--checkpoint-at-termination` (default on) — terminal checkpoint fired from the hub's existing `post_everything` hook when the run terminates for any internal reason (convergence, `--max-iterations`, cylinder convergence, `--time-limit`); the planned-stop use case pairs it with `--time-limit`. Not driven by external OS signals (recorded as a non-goal in §1). - `--checkpoint-every-seconds S` — wall-clock cadence checked at `enditer` via `allreduce_or(now - last_checkpoint >= S)`, mirroring the existing `time_limit` collective check so ranks decide together (no barrier deadlock). - `--checkpoint-every-iterations K` — the former `--checkpoint-every k`, renamed for symmetry. Also update §9 item 8 (two write points: periodic at `enditer`, terminal at `post_everything`, plus the scenario_denouement caveat) and §11 Phase 1 flag list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tly planned
The primary use case is fully served by the dill-reload backend (Phases 1-4), so
the leaf-rebuild backend and the broader spoke coverage bundled with it are
deferred. Reframe §11 Phase 6 as a possible-but-unscheduled future phase (design
keeps its hooks and the shared framework/manifest so it can be added later), and
reconcile the three surfaces that implied leaf ships alongside dill-reload:
§1 ("still supported" -> future option, not currently planned), §2.2 ("remains
available" -> designed but not currently planned), and the §8 --checkpoint-backend
flag (dill-reload is the only implemented/valid backend until Phase 6 lands).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rness - New §8.2: stochastic ADMM contract (naming/file discovery must not use module name creators or extract_num; creator-cost/memory caveats incl. releasing wrapper-held models; variable_probability identity-keying invariant; wrapper-mutated model dill round-trip to validate; AdmmBundler; spoke-set differences). - Restore is now an in-core resume branch in Iter0 replacing the iter-0 solve (precedent: iter0_from_pickle) — no throwaway W=0 solve; dropped the special end-of-iteration-0 checkpoint. - New §11.1: A/B full-run vs stop+resume CI harness with explicit similarity criteria; instances farmer, farmer+cvar, stoch-distr (--stoch-admm), sizes; wired into the phase test lists. - §10: define <S> in file names; fold in the disk-footprint peak. - §12 recast as resolved decisions + deferrals (former open items folded into the sections where they belong). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add `--checkpoint-before-seconds S` to §8: a one-shot, anticipatory trigger that writes one checkpoint at the last iteration boundary preceding S wall-clock seconds and lets the run continue. The existing `--checkpoint-every-seconds` is reactive -- it fires at the first boundary at or after its interval, so the write lands up to a full iteration late, which is no good when the deadline is a scheduler walltime. The new trigger tests allreduce_or(elapsed + last_iteration_seconds >= S) at enditer, using the same collective pattern as the time_limit check in phbase.py, then latches. S is used exactly as given: no fudge factor on the iteration estimate and no attempt to predict the checkpoint write time. Users pick S with their own margin, and the doc says how to measure the write cost. Supporting changes: §9 item 9 records the most-recent iteration duration on self (today it is a local in iterk_loop used only by the display_progress print), seeded with iteration 0's duration; §9 item 10 brackets every checkpoint write with global_toc, which both makes a long stall legible and gives the user the write duration needed to choose S. Phase 1 in §11 picks both up, and the §1 signal-handling non-goal now points at this trigger as the walltime answer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--checkpoint-before-seconds compares against elapsed time from self.start_time, stamped in SPBase.__init__ -- not job submission and not process launch. Queue wait, interpreter startup, imports, MPI init, and cfg setup all fall outside S; scenario construction and the resume-time dill reload fall inside it. A user aiming S at a scheduler walltime has to subtract that startup gap the same way they subtract the write cost, so say so, and point at the existing "Initializing mpi-sppy" toc as the way to measure it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the design's §9 touch-points against current main turned up three things the implementation would have hit immediately. The model swap belongs *before* `_create_solvers()`, not after it. Placing the branch after solver creation makes every scenario pay `set_instance` twice per resume -- once on the fresh model that is about to be discarded -- which is the very cost the dill-reload backend exists to avoid at this design's target scale. The two objective attaches are not symmetric, and the design named the wrong one. `attach_Ws_and_prox` runs in `PH_Prep`, upstream of `Iter0`, so a branch inside `Iter0` cannot skip it (harmlessly -- it decorates models the swap discards). What must be handled is the deferred attach at the *end* of `Iter0`, which would double the W terms and duplicate the prox components on a reloaded model; the branch clears `_deferred_ph_attach` rather than relying on control flow to miss it. `_initial_fixed_varibles` is opt-object state keyed by variable identity, and the swap breaks it both ways: left alone, a resumed run with any fixed nonant silently stops updating its best bound; naively rebuilt, mid-run `fixer` / `slammer` fixings pass as original and an invalid bound is accepted. It is now §9 item 11 -- checkpoint the baseline by name -- and sits in the first phase, since a plain PH run with a user-fixed nonant hits it with no extension involved. Also splits §11 Phase 1 into two review-sized PRs: 1a is the whole serial stop-and-resume story with the terminal trigger the primary use case needs, 1b adds the optional triggers plus the CVaR and MIP instances. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the first phase of doc/designs/checkpointing_design.md: a serial PH hub can stop with a checkpoint and resume from it, continuing as if it had not stopped. Farmer stop-at-3-resume-to-6 is bit-identical to an uninterrupted six-iteration run. The resume branch lives in Iter0, replacing the iteration-0 solve, with the model swap ahead of _create_solvers so a persistent solver's set_instance is paid once on the reloaded model rather than twice. It disarms the deferred objective attach (the reloaded model already carries the spliced objective and the prox cuts), refreshes saved_objectives so Eobjective does not dangle to the discarded model, and rebuilds the initially-fixed-nonant baseline from names -- that cache is keyed by variable identity and would otherwise be meaningless after the swap. _PHIter is now the global iteration, so generations do not collide across a resume and --max-iterations bounds the run as a whole. Writing is a Checkpointer extension attached only when --checkpoint-dir is given, firing on the run's own termination. Each generation is staged and renamed, then published by an atomic manifest rewrite; exactly one committed generation is kept. Resume refuses a mismatch -- rank count, scenario distribution, or a named subset of structural options -- while deliberately allowing the iteration limit, time limit, and display options to change, since picking a run back up with a different budget is the point. Checkpointing also probes one scenario for dill-serializability at setup. A model can be undillable through the user's own modeling code -- a Pyomo rule closing over cfg pulls Pyomo's ConfigDict into the graph, and that does not survive dill -- and discovering it at the terminal checkpoint would destroy the state checkpointing exists to protect. See issue Pyomo#828; this also means stoch_distr cannot serve as the phase 2 stoch-ADMM vehicle, which the design now records. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each fix to the terminal-checkpoint approach exposed another thing that had to be unwound: first the dual weights, then rho, then variable fixedness, and domains and cuts were next. That list has no end. iterk_loop gives every extension a miditer hook before the early breaks, so "what changed in the first half of this iteration" is open by construction, and any rewind is a list of the extensions someone has thought about so far. So the checkpoint is no longer taken at termination. It is written at enditer, after the solve, where W and the nonants agree -- and it therefore always describes a completed iteration, whatever extensions are loaded and whatever they touched. The invariant is one sentence and needs no knowledge of anything else. The rewind machinery, its cache, and the terminal trigger are deleted. The cost is a serialization per iteration rather than one per run, paid only when --checkpoint-dir is given: with no checkpointing the extension is never constructed and none of its hooks exist. Retention is still one generation, so disk does not grow with the iteration count, and each write is bracketed by toc so the per-iteration cost is visible rather than guessed at. A run that ends before completing iteration 1 now publishes nothing, which is the honest answer: no iteration completed, so there is no iterate to resume from. Iteration 0 is not a checkpoint point because Iter0 splices the W and proximal terms into the objective after the last hook available, so a checkpoint taken during it would capture a model whose objective is not the one PH goes on to iterate. Also in this commit: - The publish order no longer destroys the live checkpoint. The generation the manifest names stays intact until its replacement is fully published, and the sweep afterwards removes every generation the manifest does not name, so a kill between any two steps cannot leave orphans accumulating. - The resume fingerprint's exempt list is widened to what it should always have been: per-cylinder solver settings and mipgaps, diagnostics and tracing, and which cylinders run. Refusing a resume because the user tightened --starting-mipgap was a false alarm on the most ordinary day-two adjustment there is. The gate is now an acceptance matrix rather than a spot check: every way a run can end (iteration limit, convergence, --time-limit inside iteration 1 and later) crossed with plain PH, smoothing, linearized prox, a rho updater, and an extension that changes rho and fixes variables in miditer. Every cell asserts the checkpoint round-trips exactly and names the last completed iteration; cells without a stateful extension additionally assert a resumed run is indistinguishable from one that was never interrupted. It caught a real bug in the iteration-0 handling within minutes of existing. Cells with a stateful extension deliberately assert only the round trip. Such an extension starts fresh on resume, so its trajectory legitimately parts company with an uninterrupted run until extension state is checkpointed too; the user docs now say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--checkpoint-dir could be given to a hub that never wires the Checkpointer, or to a run whose extension list is rebuilt from scratch by configure_extensions (it assigns opt_kwargs['extensions'] rather than composing with what is already there, so anything attached earlier is dropped). Either way the run exited 0 having written nothing, and a multi-day study discovered the next morning that it had no checkpoint. --resume-from on such a hub was worse: it silently started from scratch. The underlying wiring is a separate, older problem and a separate change. The silence is not: this revision's whole principle is to refuse rather than misbehave quietly, and the earlier setup guards do not help here, because on these paths the Checkpointer is never constructed at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hing Moving the write to the iteration boundary deleted the terminal trigger, but the flag that controlled it stayed registered, documented and forwarded into the options dict -- while nothing read it any more. Passing --disable-checkpoint-at-termination silently had no effect: checkpoints were written regardless. That is exactly the failure this revision has been removing everywhere else, so the flag goes rather than being rewired. There is no terminal checkpoint to enable or disable now; a checkpoint is written at the end of every completed iteration, and one generation is kept. A test asserts the option is no longer registered, so it cannot quietly come back as a no-op. Found by a review pass, in code committed an hour earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wo exempts Third review pass. The blocker was APH: aph_hub is built by calling ph_hub, so --APH inherited the checkpointing wiring and wrote a checkpoint every iteration, while APH_iterk keeps its own hardcoded range(1, limit+1) that no resume offset touches -- so a resumed APH run renumbered from 1 and overwrote the checkpoint it had just resumed from, at lower generation numbers. The startup guard could not catch it, because the extension really was attached. APH also breaks the invariant the design rests on: it dispatches a fraction of the scenarios per pass and runs its loop on a worker thread, so enditer is not an iteration boundary there at all. The Checkpointer now refuses any hub that is not the synchronous PH, which also pre-empts the same problem for anything else that inherits ph_hub's wiring. The same-generation rewrite could still lose everything. write_checkpoint deleted the live generation directly before moving its replacement in, so a kill in that window left the manifest naming a directory that no longer existed. Re-running the same command into the same --checkpoint-dir hit it on every iteration. The old generation is now retired by rename, so the manifest never names a missing directory, and the existing sweep reclaims the retired copy. Two entries were wrongly structural. Every per-cylinder *_solver_options_file was exempt through the suffix rule while the global solver_options_file was not, so the same setting became structural purely by being written in a file; and track_scen_gaps is a diagnostic. Four entries in the list were not cfg keys at all and are gone. Tests. The startup guard added in the previous commit had none, and it is the kind of code that fails by silently not firing -- five now cover it, including the composed-under-MultiExtension case. The smoothing row of the acceptance matrix was vacuous, because it ran at rho = 1 where the smoothing rescale is the identity; it now runs at rho = 2, and the snapshot compares xbars, z, p and nonant fixedness rather than just x, W and rho. The incumbent test asserted the value read off disk instead of the one applied, so it could not fail; it now asserts the restored value and that a worse candidate is rejected. The setup refusals were built on a hand-rolled stub that the new hub-type check would reject before reaching them, so they use a real PH object. Docs record the measured per-iteration cost -- 38-48% on MIP instances, 262% on 50-scenario farmer -- since that is the trade this design makes and the numbers belong in front of the user, not in a review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth review pass. Renaming the old generation aside instead of deleting it shrank the dangerous window from an rmtree of the whole generation to a single rename, but did not close it: a kill between the two renames left the manifest naming a directory that was momentarily absent, and -- worse -- the next attempt began by deleting the retired copy, so a retry destroyed the last good data it was retrying to protect. Two changes close it without touching the write order. The retired copy is only cleared when there is a live generation to replace it with, so an interrupted attempt no longer eats its own predecessor. And load_checkpoint falls back to the retired copy when the generation directory is absent, since in that state the retired copy *is* the generation the manifest names, intact. The comment above the renames claimed the window did not exist; it now says what is true. Two tests that were not testing what they claimed: - The APH refusal asserted against a types.SimpleNamespace, which is not a PHBase either -- so widening the check from PH to PHBase, the single most plausible future edit (to admit Subgradient or FWPH) and one that would silently readmit APH, left the test passing. It now uses a real PHBase subclass, and that mutation fails it. - Nothing pinned that the sweep reclaims the .incoming and .retiring artifacts a killed write leaves behind. That reclamation is what stops a long run accumulating dead generations, and what recovers an interrupted write on the next successful one. A mutant that stopped matching them survived; it now fails. Also: a test that an interrupted same-generation write is still loadable, the last stale "terminal checkpoint" phrase out of the user docs, and the denylist reflowed after the previous commit's edit left it ragged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PR carries this document as its specification, and it still described the terminal-checkpoint trigger that three review rounds established does not work and that was deleted several commits ago -- including listing --checkpoint-at-termination as a shipped phase 1a flag. Section 8 now records the mechanism as built, and why: writing at enditer is the only point where the dual weights and the nonants describe the same iteration, because iterk_loop may break between Update_W and the solve, and --time-limit takes that path every time. It also records what the earlier approach cost to discover, since "reconstruct a coherent iterate at termination" is an attractive idea that reappears unless the reason it fails is written down: miditer lets any extension move model state, so the rewind set is open-ended. The consequences are stated as consequences rather than left implicit: a serialization per iteration with the measured overheads, no checkpoint at all for a run that completes no iteration, and iteration 0 excluded because Iter0 splices the objective after the last available hook. The periodic and anticipated triggers are marked deferred, with a note that writing every iteration subsumes what they were for. Their rationale is kept for the record, but the useful trigger is now the inverse of the one designed: --checkpoint-every-iterations as a way to write *less* often and buy back the per-iteration cost, not as insurance against missing a checkpoint. Also corrects two claims overtaken by merged work: the ConfigDict failure is not a dill-versus-pickle difference (it fails under both, for reasons traced to Pyomo's UninitializedMixin), and stoch_distr is no longer blocked as the phase 2 validation vehicle since Pyomo#830 merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two pieces of knowledge from this work were living only in a working log on one machine, and both are the kind that gets rediscovered expensively. notes/checkpointing_decisions.md records what was tried and rejected, with the measurement for each: snapshotting at termination (37.8 divergence), rewinding the interrupted iteration (fixed nothing, relocated the bug from W to rho), checkpointing iteration 0 (330 divergence), and naming the structural options rather than the exempt ones (a farmer LP checkpoint resumed as a MIP, silently). It also states the measured per-iteration cost and what writing every iteration does to the planned phase 1b triggers -- it subsumes two of them and inverts the meaning of the third. The design document says what the design is; that note says what it is not. Without it, "just snapshot at termination" and "just rewind the iteration" look like obvious simplifications, because the reasons they fail are not visible in the final code. notes/pyomo_configvalue_pickle.md records the upstream Pyomo bug behind issues Pyomo#828 and Pyomo#830: a freshly declared ConfigValue cannot be pickled because UninitializedMixin flips __class__ while __getstate__ is reading _data. It includes the three-line reproducer and, deliberately, the warning that two independent investigations reached opposite wrong conclusions about it by serializing the same object twice in one script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resume-path correctness: - Refuse --resume-from on any non-PH hub, mirroring the write-side refusal. A resume-only APH run bypassed every guard and spliced a PH checkpoint into APH's own loop and model annotations. - The rho-setting extensions (SepRho, CoeffRho, SensiRho, GradRho) keep the checkpointed rho on a resume instead of recomputing it in post_iter0, for the same reason Iter0 skips the rho_setter. - pre_iter0 now fires after the checkpoint splice, so extension hooks (WXBarReader loading --init-W-fname, the dillability probe) act on the models the run will iterate rather than on fresh ones the splice discards. - The issue-Pyomo#762 solver-capability checks gate on the first solve of this process rather than literally iteration 1, so they fire on a resumed run -- which is exactly when a solver switch is permitted. - Key the initially-fixed-nonant baseline by scenario. Pyomo component names are not scenario-qualified, so the flat name list smeared one scenario's fixing onto every scenario and could admit a best-bound update the uninterrupted run refuses. - Warn at resume that converger state is not restored, and correct the design doc's claim that restore_state hooks exist. Robustness and cleanup: - Refuse scenario names whose sanitized checkpoint file names collide (at setup and at write) instead of silently overwriting one model with another. - fsync the directories that record the publish renames, so the documented manifest commit point holds across power loss, not just a kill; _publish_manifest now reuses _atomic_write_bytes. - A transient mid-run checkpoint write failure (disk full, NFS hiccup) warns and continues on the still-published previous generation instead of killing the run. - configure_extensions composes via extension_adder instead of rebuilding the extension list, so --checkpoint-dir works together with --wtracker/--grad-rho/--w-and-xbar-writer; the startup guard stays as a backstop. - The acceptance matrix tolerates last-bit solver noise (measured 8e-13, from a fresh set_instance vs an updated-in-place instance) in the resumed-vs-reference comparison only; round-trip checks stay exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The capability checks now gate on the first solve of this process rather than literally iteration 1, so they read _resume_iteration -- which the duck-typed stand-in in test_prox_solver_compat.py did not provide, and the whole file errored. The stub carries only what the checks touch, so it gains the attribute (default 0, i.e. a run that started from scratch, which keeps every existing case at iteration 1). Cover the resumed leg here too, next to the rest of the Pyomo#762 checks and in the CI step dedicated to them, and drop the duplicate of those assertions from test_checkpoint.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Writing at every completed iteration costs roughly 7-25 ms per scenario per iteration: noise against a MIP whose solves take minutes, but measured at 262% overhead on 50-scenario farmer, where serialization dominates. K makes that a knob -- write at every K-th completed iteration -- and the trade is explicit: a stop that is not a checkpoint point loses the iterations since the last one, up to K-1 of them. This is the one deferred trigger the design still called for, with its meaning inverted along the way: it was conceived as insurance (write more often) and lands as cost control (write less often). Writes still happen only at iteration boundaries, so the coherence argument that puts them at enditer is untouched; K only changes which boundaries are checkpoint points. The cadence counts absolute iteration numbers, so a resume continues it rather than shifting it. The last iteration of an exhausted iteration limit is written whatever K is. Resuming with a raised --max-iterations is an ordinary way to extend a study, and that iterate is coherent and already in memory, so dropping it to save one write would be a real loss. The limit is also the only stop knowable at enditer: convergence, the user converger and --time-limit are all decided in the next iteration's top half. K is non-structural -- a cadence knob like the iteration limit, not a description of the problem -- so changing it never blocks a resume. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Writes at every K-th completed iteration" left the reader to guess whether the count follows the study or restarts at a resume. State the rule -- checkpoints land on iteration numbers that are multiples of K, using the iteration numbers shown in the log -- and work both cases through with numbers: K=10 stopping at 34 keeps generation 30 and resumes toward 40 rather than 41, and K=30 under a 100-iteration limit writes 30, 60, 90 and 100. Also spell out what is lost (up to K-1 iterations, redone on resume), why only the iteration limit gets the always-write exception, the default of 1, and that K may be changed between a stop and a resume. Fixes an inline literal that was split across a line break, which RST renders as "--checkpoint- every-iterations". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The warn-and-continue policy was only half wired. write_checkpoint wraps
just the model dump in its try/except, so the leaf pickle, the three
publishing renames and the manifest write all raise a bare OSError --
and enditer caught RuntimeError alone. A disk that filled between the
last model .dill and the leaf write therefore killed the run by exactly
the route the policy exists to prevent, contradicting both the module
docstring and doc/src/checkpointing.rst.
Catch Exception instead, and name the exception type in the warning so
a genuine defect stays legible rather than reading as a full disk.
Continuing is safe at each of these points: every one is either
pre-commit, leaving the manifest naming the previous intact generation,
or the atomic manifest flip itself.
The existing test only raised RuntimeError, which is why this survived
the first review; it is now parameterized and covers ENOSPC as an
OSError. Verified to fail against the narrow catch.
Also drop two stale rst claims that the cadence option had already
contradicted ("A checkpoint is written at every completed iteration",
"There is currently no way to write less often"), and point the cost
section at --checkpoint-every-iterations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MultiExtension dispatches enditer in attach order, and add_checkpointing runs at the end of ph_hub -- before configure_extensions appends the rest -- so the Checkpointer's enditer fires first. An extension whose enditer mutates a scenario model therefore acts after that iteration's checkpoint was written: the change is absent from the checkpoint, and a resume never re-applies it, because enditer for that iteration is done. Nothing shipped is affected. Every enditer in the tree is a no-op or read-only; the xhat evaluators work in post_everything, which is also what keeps an xhat evaluation from contaminating a checkpoint. The exposure is --user-defined-extensions, appended after the Checkpointer, so the docs say to attach a mutating extension ahead of it. Reordering the list would fix only the do_decomp path and would still rest on attach order. The durable fix is a dedicated write call in iterk_loop, which removes the dependency for every driver; that is recorded against phase 4, which is already adding the same hook to the xhatter loop. Also recorded there: enditer_after_sync is not a substitute, since it fires after the spcomm.is_converged() break and a run ending on cylinder convergence would write nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The phase list is ordered by machinery added, not by dependencies, so reading it as a sequence puts three PRs in front of the one that decides whether the feature has users at all: mpi-sppy is run hub-and-spoke, and serial-hub-only checkpointing has few. Phase 4 needs neither phase 2 nor phase 3. Not phase 2, because n_proc is Get_size() on the cylinder's comm rather than COMM_WORLD, so one rank per cylinder already passes the 1a multi-rank guard -- phase 2 is multi-rank within a cylinder, phase 4 is multiple cylinders. Not phase 3, because each spoke checkpoints its own best xhat asynchronously with no hub-spoke coordination, which is spoke-specific rather than the general extension state contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.
Suppressed comments (6)
doc/designs/checkpointing_design.md:969
- The rollout record is stale: this heading still calls phase 1a “terminal trigger only,” although the implementation has no terminal trigger and this PR includes
--checkpoint-every-iterations; the phase 1b bullet below also still claims that option. Update the phase split so the design agrees with the implemented scope described in §8 and the PR description.
- **Phase 1a — Serial hub checkpoint/resume, terminal trigger only.** The
mpisppy/phbase.py:1230
- Setting
solution_availablealone does not enable a warm start.SPOpt._solve_oneonly passeswarmstartto the solver whenoptions["warmstart_subproblems"]is true, and that option defaults to false (config.py:305-308). Thus the documented default resume path is cold-started. Either arrange a one-shot warm start for the first resumed solve or document/require--warmstart-subproblems.
model._mpisppy_data.solution_available = True
mpisppy/utils/checkpointing.py:278
- This probes only the first local scenario, so a later scenario with scenario-specific unserializable state is not discovered until the first real checkpoint.
enditerthen catches that failure and the run can finish after hours without ever publishing a checkpoint, contrary to the setup-time refusal guarantee. Probe every local scenario here.
sname, s = next(iter(opt.local_scenarios.items()))
mpisppy/tests/test_checkpoint.py:254
- The A/B resume occurs in the same interpreter, despite the design's acceptance gate requiring a fresh process (
checkpointing_design.md:907-909) and the real use case being a new job. This can miss failures caused by module re-import, process-local registrations, or dill behavior that only appears when no writer-process state remains. Run the resumed leg in a subprocess and compare serialized snapshots across the process boundary.
resumed = _make_ph(_options(self.N, resume_from=self.ckpt_dir))
resumed.ph_main()
mpisppy/tests/test_checkpoint.py:79
- This fixed 0.25-second threshold is not guaranteed to allow iteration 1 to complete: the time limit includes scenario construction, solver startup, and Iter0, and several matrix cells add smoothing or linearized-prox setup. On a slower CI host the “later” case becomes the “iteration 1” case and incorrectly expects a checkpoint. Control
perf_counterin the test (or synchronize the limit to an observed completed iteration) instead of relying on wall-clock speed.
_LATE_TIME_LIMIT = 0.25
doc/designs/checkpointing_design.md:940
- This leftover fragment says the stoch-ADMM model still needs to be fixed immediately after the preceding lines state that #830 fixed and unblocked it. Remove the stale sentence so the validation status is unambiguous.
This issue also appears on line 969 of the same file.
the model is fixed or another stoch-ADMM model is chosen.
Six findings from the Copilot pass on this PR. Four were real; the two test ones were about tests that pass for the wrong reason. *The warm-start claim was false by default.* The resume marks solution_available on each reloaded model and the comment called it a warm start, but solve_one consults that flag only when warmstart_subproblems is set, and it defaults to False. So the documented default resume path is cold. The flag is what makes --warmstart-subproblems work across a resume, and the comment and the user docs now say exactly that instead of promising a warm start nobody asked for. *The dillability probe checked one scenario.* What makes a model undillable is usually something the scenario_creator closed over, and a creator that does it for one scenario is the case a single-scenario probe waves through -- after which the run fails at every write, survives each failure by design, and finishes having published nothing, which is what the setup-time refusal exists to rule out. It now probes every local scenario, and a test covers both the first-scenario and later-scenario cases; nothing covered it before. *The A/B harness never left the interpreter that wrote the checkpoint*, while the design's acceptance gate asks for a fresh process and the use case is a new job. The resumed leg now runs in a subprocess. It also asserts the subprocess actually resumed: farmer is deterministic, so a run that ignored the checkpoint and did all N iterations from scratch would produce the same answer and pass. *The acceptance matrix raced the clock.* Its "time limit, later" cell set a 0.25s limit and trusted farmer to be fast, but the limit also covers model construction, solver startup and Iter0 -- so on a slow host the cell became the "no checkpoint" case and inverted its own assertion. An extension now rewinds start_time at a chosen iteration, so the real check, allreduce and break all still run with the clock under control; the cell asserts the run stopped there, since otherwise it would pass by hitting the iteration limit instead. The design's rollout record also still called phase 1a "terminal trigger only" and left phase 1b claiming an option that shipped in 1a, and a clause about fixing the stoch-ADMM model dangled after Pyomo#830 fixed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — all six of the suppressed findings from the latest review were real. Each was checked against the code before being acted on; fixes are in 511decf. 1. 2. 3. §11 phase 1a still said "terminal trigger only". Confirmed stale. The heading now describes what shipped, and phase 1b no longer claims 4. The dangling stoch-ADMM clause at §11.1. Confirmed — a leftover from the rewrite when #830 landed. Removed. 5. The A/B harness never left the writing interpreter. Confirmed, and it matters for the reason given: the real use case is a new job. The resumed leg now runs in a subprocess and the snapshots are compared across the process boundary. One addition worth noting — the test also asserts the subprocess actually resumed, because farmer is deterministic: a leg that ignored the checkpoint and ran all N iterations from scratch would produce identical state and pass. 6. On the followup branch ( 🤖 Generated with Claude Code |
--max-iterations was compared against the global iteration counter, so a resumed run read it as a bound on the whole study: resuming a run that stopped at 4 and wanting two more meant passing 6, and passing 2 silently did nothing. The name says otherwise, and the name is the one people trust. It now bounds the run being started. The study gets its own bound, --stop-at-iteration-number, an absolute iteration number counted across every run linked by checkpoints; it defaults to unset, and a run ends at whichever of the two arrives first. Reaching a study bound that is already behind the checkpoint reports a finished study instead of doing nothing quietly. Nothing outside the resume path changes meaning: on a fresh run the resume offset is zero, so the loop bound is what it always was. Along the way: - Checkpointer._is_final_iteration reads the loop bound iterk_loop computed rather than PHIterLimit, so the always-write-the-last-iteration rule follows whichever bound stopped the run. - IntegerRelaxThenEnforce measures its ratio from the resume offset. It compared an absolute _PHIter against a now per-run limit, which would have enforced integrality on the first iteration of any resume. - Both bounds are non-structural, and the docs now name every termination criterion a resume may change rather than gesturing at "the thresholds". The docs also gain the run/study vocabulary this distinction needs, and the first example carries --checkpoint-every-iterations rather than leaving the default to be discovered later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overhead is one model serialization per checkpoint written; at the default K = 1 that happens to be one per iteration, which is what the table measures, but stating it per iteration reads as though K did not divide it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing compares an mpi-sppy version: the manifest carries a format_version, which is an on-disk layout number bumped deliberately, and what refuses a resume is the configuration fingerprint, the rank count and the scenario-to-rank distribution. The version fragility that does exist is a separate mechanism -- the payload is dilled Pyomo models, so a library upgrade can leave a checkpoint unreadable even when every option matches. The two are now stated separately, with the model-module case naming the error it produces so the reader recognizes it in a log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checkpoint / resume — design + phase 1a
This PR carries both the design document
(
doc/designs/checkpointing_design.md) and its phase 1a implementation.Phase 1 is implemented in the design's own branch so that corrections the
implementation turns up ride along with the code that motivated them — and it
turned up a great many.
What it does
A serial PH hub writes a resumable checkpoint at the end of every completed
iteration, and a later run picks up from it. Flags:
--checkpoint-dir(enablesit),
--resume-from,--checkpoint-backend, and--checkpoint-every-iterations K.That last one is the cost control. Serializing every scenario every iteration
is negligible against a MIP whose solves take minutes, but it was measured at
262% overhead on 50-scenario farmer, where serialization dominates.
Kwritesat every K-th completed iteration instead — checkpoints land on iteration
numbers that are multiples of K, counted from the start of the study, so a
resume continues the cadence rather than restarting it. A stop that is not a
checkpoint point loses the iterations since the last one, up to K-1 of them,
and that is the whole trade. Writes still happen only at iteration boundaries,
so the invariant below is untouched; K only changes which boundaries are
checkpoint points. The final iteration of an exhausted
--max-iterationsiswritten whatever K is, since raising the limit and resuming is an ordinary way
to extend a study and that iterate is coherent and already in memory.
There is deliberately no flag to disable the checkpoint at termination.
--checkpoint-at-terminationexisted for the trigger this design replaced,and was removed once writes moved to the iteration boundary -- it had been
left registered and documented while nothing read it, which is the same
silent-no-op failure this PR removes everywhere else.
A checkpoint always describes a completed PH iteration. That is the whole
invariant, and everything else follows from it.
It is worth saying why, because the obvious alternative — snapshot whatever
state exists when the run ends — does not work and cannot be patched into
working.
iterk_loopcomputes xbar, updates the dual weights, runs themiditerextension hook, and may break (user converger, convergencethreshold,
--time-limit) before it solves. A run ending that way leaves themodels describing half an iteration: W at iteration k, nonants still at k-1.
--time-limit— the planned-stop recipe this feature exists for — exits thatway every time. Reconstructing a coherent iterate means undoing everything the
first half of the iteration did, and
miditerlets any extension change rho,fix variables, relax domains or add cuts, so that list is open by construction.
An earlier revision of this PR tried it and had to unwind W, then rho, then
fixedness, with domains and cuts next.
Writing at
enditer— after the solve — sidesteps all of it, with no knowledgeof any extension. The cost is a model serialization per iteration rather than
one per run, paid only when
--checkpoint-diris given: without it theextension is never constructed and none of its hooks exist. Retention is a
single generation, so disk does not grow with the iteration count, and each
write is bracketed by
global_tocso the per-iteration cost is visible ratherthan guessed at.
Consequence, deliberately: a run that ends before completing iteration 1
publishes nothing. No iteration completed, so there is no iterate to resume.
Resuming
The resume branch lives in
Iter0, replacing the iteration-0 solve, with themodel swap ahead of
_create_solversso a persistent solver'sset_instanceis paid once. It disarms the deferred objective attach while restoring the
opt-object state that attach would have set, refreshes
saved_objectives,restores the bound, the incumbent objective and the initially-fixed-nonant
baseline, and skips the
rho_setterand the smoothing rescale that thereloaded model already reflects.
_PHIteris the global iteration, so--max-iterationsbounds the run as a whole.What is refused, rather than half-supported
At setup, before any solve: non-PH hubs, multi-rank hubs, an unimplemented
--checkpoint-backend, an unwritable--checkpoint-dir, scenario names thatwould collide once made safe for a file name (they would otherwise overwrite
each other's models and restore one scenario for both), and — the one that
used to lose a day silently — a configuration where
--checkpoint-dirwasgiven but the extension is not actually attached to the hub.
The hub-type refusal applies to
--resume-fromas well as--checkpoint-dir.Only the write side had it at first, so a resume-only run never constructed the
extension that carried the check, and
--APH --resume-fromwould splice a PHcheckpoint into a hub that had already annotated its own models and iterates a
range ignoring the resume offset — with no error at startup.
A mid-run write failure is the deliberate exception to all of this: a full disk
or an NFS hiccup warns and the run continues on its still-published previous
generation, retrying at the next checkpoint point. Refusing at startup protects
a run that has done no work; aborting at iteration 400 destroys the very
progress the checkpoint exists to preserve.
On resume: a different rank count or scenario distribution, or a configuration
that differs anywhere outside a named list of settings a resume may change
(budget, solver choice and mipgaps, display/tracking/output, which cylinders
run). Checking by default is what stops a farmer checkpoint being resumed with
--farmer-with-integersand quietly answering the linear program.The gate
mpisppy/tests/test_checkpoint.py(64 tests) includes a 20-cell acceptancematrix: every way a run can end — iteration limit, convergence,
--time-limitinside iteration 1,
--time-limitlater — crossed with plain PH, smoothing,linearized prox, a rho updater, and an extension that changes rho and fixes
variables in
miditer. Every cell asserts the checkpoint round-trips exactlyand names the last completed iteration; cells without a stateful extension
additionally assert that a resumed run is indistinguishable from one that was
never interrupted. It caught a real bug within minutes of existing.
Known limitations, stated rather than implied
cylinders and stoch-ADMM currently run without refusal and are not validated.
fixerand
slammerstart fresh on a resume, so a resumed run using one will notretrace an uninterrupted one. The model state is correct and the run
continues correctly; the matrix encodes this distinction explicitly and the
user docs say so. A converger is the same story and now says so at runtime —
the resume warns, since one that accumulates history restarts empty at
iteration N+1 and can terminate at a different iteration.
The rho-setting extensions are the exception: they no longer recompute rho
in
post_iter0on a resume, which would have clobbered the checkpointed rhothat the resume path skips the
rho_setterspecifically to preserve.enditer. The Checkpointer is attached beforeconfigure_extensionsappends anything else, andMultiExtensiondispatchesin attach order, so an extension whose
enditermutates models acts afterthat iteration's checkpoint was written and a resume never re-applies it.
Nothing shipped is affected — every
enditerin the tree is a no-op orread-only, and the xhat evaluators work in
post_everything— so this isdocumented rather than reordered. The durable fix — a dedicated write call in
iterk_loop— is implemented on the phase 4 branch below, not in this PR.User documentation
doc/src/checkpointing.rst, in Advanced Topics besidepickling.rst. Thedesign document under
doc/designs/is a design record; Sphinx does not buildit and users do not read it.
What comes next
The rest of the design is built, as a stack of branches on this one. Each is
based on the branch above it, so each carries one phase's diff. None can open
upstream until the one below it merges — a cross-repo PR has to target
main,which would make every stacked PR carry all the phases beneath it. Each has a
CI-green draft preview PR on the fork instead (not for merging there), so
a phase can be read, or tried, on its own:
checkpoint-cylinderscheckpoint-multirankcheckpoint-extension-statecheckpoint-spoke-cursorPhase 4 went first, rather than 1b or 2, because mpi-sppy is run hub-and-spoke:
a serial-hub-only feature has few real users. It depends on neither phase 2 nor
phase 3, and §11 of the design records why —
n_procis the cylinder's commsize rather than
COMM_WORLD, so one rank per cylinder already passes themulti-rank guard here (phase 2 is multi-rank within a cylinder; phase 4 is
multiple cylinders).
One thing on that stack is worth naming here, because it retires a limitation
listed above:
checkpoint-cylindersgives the checkpoint write a hook of itsown,
Extension.maybe_checkpoint, whichiterk_loopcalls directly after everyextension's
enditer. A model change a user extension makes in itsenditeristhen part of that iteration's checkpoint, rather than something a resume drops
for good. (
enditer_after_syncis not a substitute — it is skipped on thespcomm.is_converged()break, so a run ending on cylinder convergence wouldwrite nothing.)
One option beyond the phases, on
checkpoint-spoke-cursor:--checkpoint-before-seconds Swrites at the end of the last iteration expectedto finish within S seconds of the start of the run. §8 had dropped it,
reasoning that writing at every completed iteration leaves a recent checkpoint
on disk anyway — which assumes
K = 1, andK = 1is what--checkpoint-every-iterationsexists to avoid on the models this featuretargets. At
K > 1a run stopped by a wall clock stops between multiples ofK, or before the first one, in which case there is no checkpoint at all while
the directory still holds
spokes/, so it looks like it worked. The test iselapsed + the last iteration's duration >= S; it goes throughallreduce_or,because elapsed wall clock is rank-local while the hub write is a collective
bracketed by barriers, and it fires at most once.
Phase 1b is retired. Phase 6 (the leaf-rebuild backend) stays deliberately
unplanned: the use case driving this is served without it.
Review history
This PR has been through five review passes. The first found that the
--time-limitplanned-stop recipe silently produced a wrong resume; thesecond found that the fix for it was incomplete in the same way; the third
motivated the rewrite above. Individual findings and their resolutions are in
the commit messages, which record the measured evidence for each.
The fourth pass concentrated on the resume path, where most of what it found
was silent rather than loud:
--APH --resume-frombypassing every guard;post_iter0rho extensions clobbering the checkpointed rho;pre_iter0running on models the splice then discarded, so
--init-W-fnamewas quietlyignored; the initially-fixed-nonant baseline keyed by a Pyomo name that is not
scenario-qualified, smearing one scenario's fixing across all of them; the
issue-#762 solver-capability checks gated on iteration 1 and therefore never
firing on a resume, which is exactly when a solver switch is permitted; and a
publish sequence that fsynced every model file but no directory, so the
documented commit point did not survive power loss.
The fifth pass found that the fourth's own warn-and-continue fix was
incomplete — only the model dump is wrapped as a
RuntimeError, so the leafwrite, the renames and the manifest raise a bare
OSErrorthat still took therun down — along with the
enditerordering note above and two staledocumentation claims. That finding is the reason the write-failure test is now
parameterized over both exception types.
Two corrections to earlier revisions of this description are preserved below
for anyone who read them:
stoch_distris no longer undillable (#830 merged),and the claim that a Pyomo
ConfigDictsurvives stdlibpicklebut notdillwas false — it fails the first serialization under both, for reasons traced to
Pyomo's
UninitializedMixin.Original design PR description
This PR adds a design document (
doc/designs/checkpointing_design.md) forcheckpointing an mpi-sppy run so it can be stopped and resumed later. It is
doc-only and intended for design review before any implementation.
Use case driving the design
A long (multi-day) run that is intentionally stopped and resumed on a schedule
— e.g. a three-day study that ends each day and picks up the next morning on the
same cluster — with large MIP scenarios. Checkpoints are infrequent (a
handful over the whole run, roughly twice as many writes as resumes). This regime
is what makes the primary design choice below the right one; a different regime
(frequent checkpoints purely for hard-kill safety) is served by the low-cost
backend, also in the doc.
TL;DR
windows, and persistent solver handles. Those are always reconstructed via
normal startup (a fresh MPI job each morning).
the use case above. It captures
W/rho/nonants/fixedness, the second-stageMIP warm start, the linearized-prox cuts, and model-attached extension state
in one consistent shot, and avoids re-running an expensive
scenario_creator.At a few checkpoints the write cost is negligible, and same-environment resume
makes dill's version fragility a non-issue.
--checkpoint-backend leaf): rebuild viascenario_creatorand overlay tiny numeric arrays — small,fast, version-robust — for small/cheap-creator runs or frequent kill-safety
writes.
xhat solution values live on the xhat spoke, while the hub holds only the
incumbent objective. The design checkpoints both.
Validated by proofs of concept
gurobi_persistent): serial andmulti-rank (
-np 3, uneven-np 2) resume reproduces a full runbit-identically for
W/nonants/rho; cylinders (PH hub + lagrangian +xhatshuffle) hub primal resumes bit-identical inside
WheelSpinner; geometrymismatch is refused with a clear error.
sizesSIZES3,gurobi_persistent,single-thread for a deterministic solve): a mid-run scenario model — including
the linearized-prox case with 845 cuts + 65
ProxApproxManagers — dills andreloads in-process and cross-process, re-solving to identical objective and
decision variables; a serial stop → reload → continue is bit-identical to
an uninterrupted run for both quadratic and linearized prox.
cylinders, incumbent carry across a dill-reload stop, a measured warm-start
speedup, and the disk footprint at true model scale.
Determinism contract
best xhat preserved — but is not bit-reproducible (multi-threaded MIP solves
are nondeterministic and admit multiple optima). The PoC's bit-identity results
used a single-thread deterministic solve as a validation crutch.
bit-identical (a bonus, not the target).
What the doc specifies
State inventory (reconstructed vs restored; what rides in the dilled model vs
non-model leaf data), the required core changes (global iteration counter, a
reload-model resume branch that skips re-attach and refreshes objective handles, a
one-line xhatter write hook so a single
Checkpointerserves hub + spoke, anextension
checkpoint_state/restore_statecontract, async per-spoke incumbentcheckpoints with no hub↔spoke coordination, atomic single-generation writes via a
manifest), a proposed file layout, and a 6-phase rollout (each phase a
review-sized PR).
Ready for review. Implementation would land as the phased PRs described in the doc.
Update log
Per review (@bknueven): leaned into mpi-sppy's asynchrony — dropped the
hub-triggered snapshot barrier (each spoke checkpoints its own incumbent
asynchronously on improvement) and simplified retention to a single
manifest-published generation.
Design pivot (this revision): re-centered on the concrete use case above
(multi-day planned stop/resume, huge MIP scenarios, same environment). The
recommended backend is now dill the mid-run scenario models rather than
leaf-data-only — at this checkpoint cadence the overhead is negligible and it
captures the MIP warm start + prox cuts for free — with leaf-rebuild kept as the
low-cost alternative. Softened the determinism contract for MIPs, shifted the
primary trigger to end-of-run/on-signal, and validated the load-bearing
assumption (a mid-run MIP model round-trips through dill) with the MIP PoC above.
Design review pass (this revision): added §8.2 stochastic ADMM (wrapped-name
file discovery — no
scenario_names_creator/extract_num; the creator-cost savingdoes not apply and resume must release the wrapper-held fresh models; the
variable_probabilityidentity-keying invariant; the wrapper-mutated model dillround-trip flagged for early validation; AdmmBundler; spoke-set differences).
Restore is now an in-core resume branch in
Iter0replacing the iter-0 solve(precedent:
iter0_from_pickle) — no throwawayW = 0solve on resume, and thespecial end-of-iteration-0 checkpoint is dropped. Added §11.1, an A/B
(uninterrupted vs stop+resume) CI harness with explicit similarity criteria over
small instances: farmer, farmer+
--cvar, stoch-distr (--stoch-admm), andsizes. §12 recast as resolved decisions + deferrals.Anticipated checkpoint trigger (this revision): added
--checkpoint-before-seconds S(§8) — a one-shot, anticipatory triggerthat writes one checkpoint at the last iteration boundary preceding
Swall-clock seconds and lets the run continue (it terminates nothing). The existing
--checkpoint-every-secondsis reactive: it fires at the first boundary at orafter its interval, so the write lands up to a full iteration late — no good
when the deadline is a scheduler walltime and the iterations are hour-scale MIP
solves. The new trigger tests
allreduce_or(elapsed + last_iteration_seconds >= S)atenditer, the samecollective pattern as the
time_limitcheck inphbase.py, then latches. ItOR's with the other triggers.
Sis used exactly as given: no fudge factor on the iteration estimate, andno attempt to predict the checkpoint write cost. Instead the library measures
and the user does the arithmetic — every checkpoint write is now bracketed by
global_toc(§9 item 10), on every trigger, hub and spokes, so the pair oftimestamps is the measured write duration; the doc tells the user to set
Stotheir walltime minus that duration minus their own margin. The doc is also
explicit about what the clock measures —
elapsedruns fromself.start_timein
SPBase.__init__, so queue wait, interpreter startup, imports, and MPI initfall outside
S(scenario construction and the resume-time dill reload fallinside), and that startup gap has to be subtracted too.
Supporting core change: §9 item 9 hoists the most-recent iteration duration onto
self(today it is a local initerk_loopused only by thedisplay_progressprint), seeded with iteration 0's duration so no special case is needed the first
time the trigger is tested. Phase 1 in §11 picks all of this up, and the §1
signal-handling non-goal now points at this trigger as the walltime answer instead
of a signal handler.
Pre-implementation verification pass (this revision): re-checked every §9
touch-point against current
mainbefore starting Phase 1, and corrected threethings the implementation would have hit immediately.
_create_solvers(), not after (§5.1, §9 item2). The old placement makes every scenario pay
set_instancetwice per resume— once on the fresh model that is about to be discarded — which is exactly the
cost the dill-reload backend exists to avoid at this design's target scale.
one.
attach_Ws_and_proxruns inPH_Prep, upstream ofIter0, so abranch inside
Iter0cannot skip it — and does not need to, since it decoratesmodels the swap discards. What must be handled is the deferred attach at the
end of
Iter0, which would double theWterms and duplicate the proxcomponents on a reloaded model. The branch clears
_deferred_ph_attachratherthan relying on control flow to miss it.
_initial_fixed_variblesis opt-object state keyed by variable identity, sothe model swap breaks it in both directions. Left alone it holds vardata from
the discarded models, so
_can_update_best_boundsees every fixed nonant asunrecognized and a resumed run silently stops updating its best bound —
including a run whose only fixed nonant came from the user's own
scenario_creator, no extension involved. Naively rebuilt after the swap itabsorbs whatever
fixer/slammerpinned before the stop, so those pass asoriginal and the resumed run accepts a bound the uninterrupted run would have
refused. The checkpoint records the baseline by variable name. Same
identity-keying hazard §8.2 item 3 already catches for ADMM's
varprob_dict,here in the core PH path — which is why it lands in the first phase rather than
with the fixing extensions.
Phase 1 split (this revision): §11 Phase 1 was one PR carrying the framework,
four triggers, CLI flags, and a three-instance A/B harness. It is now 1a (the
whole serial stop-and-resume story with the terminal trigger the primary use case
actually needs; farmer A/B) and 1b (the optional periodic and anticipated
triggers, plus the farmer+CVaR and
sizesMIP instances). Each is green on itsown, and 1a is independently useful: a run that stops at
--time-limitandresumes the next morning needs nothing from 1b.
🤖 Generated with Claude Code