Skip to content

Checkpoint/resume: design + phase 1a (serial hub, dill-reload) - #777

Open
DLWoodruff wants to merge 45 commits into
Pyomo:mainfrom
DLWoodruff:checkpoint-poc
Open

Checkpoint/resume: design + phase 1a (serial hub, dill-reload)#777
DLWoodruff wants to merge 45 commits into
Pyomo:mainfrom
DLWoodruff:checkpoint-poc

Conversation

@DLWoodruff

@DLWoodruff DLWoodruff commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

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 (enables
it), --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. K writes
at 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-iterations is
written 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-termination existed 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_loop computes xbar, updates the dual weights, runs the
miditer extension hook, and may break (user converger, convergence
threshold, --time-limit) before it solves. A run ending that way leaves the
models describing half an iteration: W at iteration k, nonants still at k-1.
--time-limit — the planned-stop recipe this feature exists for — exits that
way every time. Reconstructing a coherent iterate means undoing everything the
first half of the iteration did, and miditer lets 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 knowledge
of any extension. The cost is a model serialization per iteration rather than
one per run, paid only when --checkpoint-dir is given: without it the
extension 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_toc so the per-iteration cost is visible rather
than 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 the
model swap ahead of _create_solvers so a persistent solver's set_instance
is 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_setter and the smoothing rescale that the
reloaded model already reflects. _PHIter is the global iteration, so
--max-iterations bounds 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 that
would 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-dir was
given but the extension is not actually attached to the hub.

The hub-type refusal applies to --resume-from as 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-from would splice a PH
checkpoint 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-integers and quietly answering the linear program.

The gate

mpisppy/tests/test_checkpoint.py (64 tests) includes a 20-cell acceptance
matrix: every way a run can end — iteration limit, convergence, --time-limit
inside iteration 1, --time-limit 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 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

  • Serial PH hub only. Non-PH hubs and multi-rank are refused; bundles,
    cylinders and stoch-ADMM currently run without refusal and are not validated.
  • Extension and converger state is not checkpointed. Rho updaters, fixer
    and slammer start fresh on a resume, so a resumed run using one will not
    retrace 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_iter0 on a resume, which would have clobbered the checkpointed rho
    that the resume path skips the rho_setter specifically to preserve.
  • Extension order matters at enditer. The Checkpointer is attached before
    configure_extensions appends anything else, and MultiExtension dispatches
    in attach order, so an extension whose enditer mutates models acts after
    that iteration's checkpoint was written and a resume never re-applies it.
    Nothing shipped is affected — every enditer in the tree is a no-op or
    read-only, and the xhat evaluators work in post_everything — so this is
    documented 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.
  • A checkpoint is tied to the mpi-sppy and model version that wrote it.

User documentation

doc/src/checkpointing.rst, in Advanced Topics beside pickling.rst. The
design document under doc/designs/ is a design record; Sphinx does not build
it 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:

branch phase preview
checkpoint-cylinders 4 — the wheel: hub plus spokes, each spoke keeping its own best xhat #19
checkpoint-multirank 2 — a cylinder spanning ranks; bundles and stoch-ADMM #20
checkpoint-extension-state 3 — extension and converger state across a resume #21
checkpoint-spoke-cursor 5 — the xhat spoke resumes its own exploration #22

Phase 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_proc is the cylinder's comm
size rather than COMM_WORLD, so one rank per cylinder already passes the
multi-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-cylinders gives the checkpoint write a hook of its
own, Extension.maybe_checkpoint, which iterk_loop calls directly after every
extension's enditer. A model change a user extension makes in its enditer is
then part of that iteration's checkpoint, rather than something a resume drops
for good. (enditer_after_sync is not a substitute — it is skipped on the
spcomm.is_converged() break, so a run ending on cylinder convergence would
write nothing.)

One option beyond the phases, on checkpoint-spoke-cursor:
--checkpoint-before-seconds S writes at the end of the last iteration expected
to 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, and K = 1 is what
--checkpoint-every-iterations exists to avoid on the models this feature
targets. At K > 1 a run stopped by a wall clock stops between multiples of
K, 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 is
elapsed + the last iteration's duration >= S; it goes through allreduce_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-limit planned-stop recipe silently produced a wrong resume; the
second 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-from bypassing every guard;
post_iter0 rho extensions clobbering the checkpointed rho; pre_iter0
running on models the splice then discarded, so --init-W-fname was quietly
ignored; 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 leaf
write, the renames and the manifest raise a bare OSError that still took the
run down — along with the enditer ordering note above and two stale
documentation 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_distr is no longer undillable (#830 merged),
and the claim that a Pyomo ConfigDict survives stdlib pickle but not dill
was 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) for
checkpointing 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

  • Do not dill the opt/hub object graph — it holds live MPI communicators, RMA
    windows, and persistent solver handles. Those are always reconstructed via
    normal startup (a fresh MPI job each morning).
  • Do dill the mid-run scenario models — this is the recommended backend for
    the use case above. It captures W/rho/nonants/fixedness, the second-stage
    MIP 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.
  • A low-cost leaf-rebuild backend remains available (--checkpoint-backend leaf): rebuild via scenario_creator and overlay tiny numeric arrays — small,
    fast, version-robust — for small/cheap-creator runs or frequent kill-safety
    writes.
  • Keep the best xhat, not just the best bound: in a cylinders run the best
    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

  • Framework + leaf-rebuild backend (farmer LP, gurobi_persistent): serial and
    multi-rank (-np 3, uneven -np 2) resume reproduces a full run
    bit-identically for W/nonants/rho; cylinders (PH hub + lagrangian +
    xhatshuffle) hub primal resumes bit-identical inside WheelSpinner; geometry
    mismatch is refused with a clear error.
  • dill-reload backend on a MIP (sizes SIZES3, gurobi_persistent,
    single-thread for a deterministic solve): a mid-run scenario model — including
    the linearized-prox case with 845 cuts + 65 ProxApproxManagers — dills and
    reloads 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.
  • Still to prove in later phases: the dill-reload backend under multi-rank and
    cylinders, incumbent carry across a dill-reload stop, a measured warm-start
    speedup, and the disk footprint at true model scale.

Determinism contract

  • MIPs (the target): resume continues correctly and warm-started, with the
    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.
  • Leaf-rebuild on a deterministic LP/QP solve: primal trajectory can be
    bit-identical (a bonus, not the target).
  • Bounds and incumbent: valid, best-so-far, not bit-reproducible (async).

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 Checkpointer serves hub + spoke, an
extension checkpoint_state/restore_state contract, async per-spoke incumbent
checkpoints 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 saving
does not apply and resume must release the wrapper-held fresh models; the
variable_probability identity-keying invariant; the wrapper-mutated model dill
round-trip flagged for early validation; 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 on resume, and the
special 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), and
sizes. §12 recast as resolved decisions + deferrals.

Anticipated checkpoint trigger (this revision): added
--checkpoint-before-seconds S (§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 (it terminates nothing). 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 — 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) at enditer, the same
collective pattern as the time_limit check in phbase.py, then latches. It
OR's with the other triggers.

S is used exactly as given: no fudge factor on the iteration estimate, and
no 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 of
timestamps is the measured write duration; the doc tells the user to set S to
their walltime minus that duration minus their own margin. The doc is also
explicit about what the clock measureselapsed runs from self.start_time
in SPBase.__init__, so queue wait, interpreter startup, imports, and MPI init
fall outside S (scenario construction and the resume-time dill reload fall
inside), 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 in iterk_loop used only by the display_progress
print), 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 main before starting Phase 1, and corrected three
things the implementation would have hit immediately.

  • The model swap goes before _create_solvers(), not after (§5.1, §9 item
    2). The old placement makes every scenario pay set_instance twice 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.
  • The two objective attaches are not symmetric, and the doc named the wrong
    one.
    attach_Ws_and_prox runs in PH_Prep, upstream of Iter0, so a
    branch inside Iter0 cannot skip it — and does not need to, since 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.
  • New §9 item 11: restore the initially-fixed-nonant baseline, by name.
    _initial_fixed_varibles is opt-object state keyed by variable identity, so
    the model swap breaks it in both directions. Left alone it holds vardata from
    the discarded models, so _can_update_best_bound sees every fixed nonant as
    unrecognized 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 it
    absorbs whatever fixer/slammer pinned before the stop, so those pass as
    original 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 sizes MIP instances). Each is green on its
own, and 1a is independently useful: a run that stops at --time-limit and
resumes the next morning needs nothing from 1b.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jun 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.51407% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.01%. Comparing base (29671ec) to head (f3d9423).

Files with missing lines Patch % Lines
mpisppy/utils/checkpointing.py 86.00% 28 Missing ⚠️
mpisppy/extensions/sensi_rho.py 0.00% 3 Missing ⚠️
mpisppy/extensions/checkpointer.py 96.66% 2 Missing ⚠️
mpisppy/extensions/grad_rho.py 33.33% 2 Missing ⚠️
mpisppy/extensions/sep_rho.py 33.33% 2 Missing ⚠️
mpisppy/phbase.py 97.18% 2 Missing ⚠️
mpisppy/extensions/integer_relax_then_enforce.py 80.00% 1 Missing ⚠️
mpisppy/utils/cfg_vanilla.py 92.30% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@DLWoodruff
DLWoodruff force-pushed the checkpoint-poc branch 2 times, most recently from 31f8ad5 to 02ee029 Compare June 27, 2026 19:25
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>
@DLWoodruff
DLWoodruff marked this pull request as ready for review June 27, 2026 22:13
The method definition is at spcommunicator.py:1010, not 1019.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread doc/designs/checkpointing_design.md Outdated
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused on this point -- doesn't serializing the best solution as already stored by the xhat / incumbent spokes already do this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread doc/designs/checkpointing_design.md Outdated
Comment on lines +348 to +352
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread doc/designs/checkpointing_design.md Outdated
Comment thread doc/designs/checkpointing_design.md Outdated
DLWoodruff and others added 5 commits July 1, 2026 16:06
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-model recommended; leaf alternative).
  • 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.

Comment thread doc/designs/checkpointing_design.md Outdated
Comment on lines +458 to +460
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment on lines +157 to +162
- **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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread doc/designs/checkpointing_design.md Outdated
Comment on lines +3 to +6
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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@bknueven
bknueven requested a review from tvalenciaz July 17, 2026 17:44
DLWoodruff and others added 9 commits July 20, 2026 18:15
…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>
DLWoodruff and others added 14 commits August 12, 2026 16:03
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>
@DLWoodruff
DLWoodruff marked this pull request as ready for review August 14, 2026 13:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_available alone does not enable a warm start. SPOpt._solve_one only passes warmstart to the solver when options["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. enditer then 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_counter in 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.

DLWoodruff and others added 2 commits August 14, 2026 18:13
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>
@DLWoodruff

Copy link
Copy Markdown
Collaborator Author

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. phbase.py — the warm-start claim was false by default. Confirmed: _solve_one (spopt.py) gates on options["warmstart_subproblems"], which defaults to False (config.py), and solution_available is only consulted inside that gate. So the reloaded recourse values did nothing unless the user asked for warm starts. Fixed as documentation rather than behavior — mpi-sppy does not turn a solver option on for you, so the code comment, checkpointing.rst and §9 item 2 now say the flag is what makes --warmstart-subproblems work across a resume and is inert without it. The unqualified "warm-started" promise is gone.

2. checkpointing.py — the dillability probe checked one scenario. Confirmed, and fixed to probe every local scenario. What makes a model undillable is usually something the scenario_creator closed over, and a creator that does it for one scenario is exactly 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. Nothing covered this before; there are now tests for the first-scenario and later-scenario cases, and the later-scenario one fails against the old probe.

3. §11 phase 1a still said "terminal trigger only". Confirmed stale. The heading now describes what shipped, and phase 1b no longer claims --checkpoint-every-iterations; it also records that the two seconds-triggers are not implemented and not planned, since §8 concluded that writing at every completed iteration subsumes them.

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. _LATE_TIME_LIMIT = 0.25 raced the clock. Confirmed. The limit also covers model construction, solver startup and Iter0, so on a slow host the cell became the "no checkpoint published" case and inverted its own assertion. An extension now rewinds start_time at a chosen iteration, so the real check, allreduce and break still run with the clock under control. It asserts the run stopped at that iteration, since otherwise the cell would pass by hitting the iteration limit instead and quietly stop covering the --time-limit exit.

On the followup branch (checkpoint-cylinders, phase 4), the same fresh-process discipline turned up a real bug that had been invisible: a spoke given --resume-from without --checkpoint-dir attempted a write with no directory to write to, on every improvement. Nothing failed, because checkpoint write failures warn and continue by design — the only trace was two lines in a log.

🤖 Generated with Claude Code

DLWoodruff and others added 3 commits August 16, 2026 18:23
--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>
@DLWoodruff
DLWoodruff requested a review from bknueven August 18, 2026 21:17
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.

3 participants