Skip to content

Bound a solver grind: wall-clock guard inside PETSc's convergence tests, and a sub-solve work gauge - #442

Merged
lmoresi merged 3 commits into
developmentfrom
feature/solver-wallclock-guard
Jul 28, 2026
Merged

Bound a solver grind: wall-clock guard inside PETSc's convergence tests, and a sub-solve work gauge#442
lmoresi merged 3 commits into
developmentfrom
feature/solver-wallclock-guard

Conversation

@lmoresi

@lmoresi lmoresi commented Jul 27, 2026

Copy link
Copy Markdown
Member

The problem

A viscoplastic Stokes solve near its conditioning floor does not fail. It grinds — for
hours, inside a single Newton step, with no error and no sign of stopping. Any study that
sweeps a parameter plane hits this immediately, because the interesting cells are exactly
the unreachable ones.

Two obvious defences were tried first and both were measured to not work:

An iteration cap does not bound wall time. The grind happens within one outer Krylov
iteration
, so the counter any cap watches never advances. Measured: 3814 s inside one
outer iteration. Nor can caps be tuned around it — tight caps misclassify in both
directions (a continuation march settles at its starting parameter and reports success;
a step that would have converged in fifteen iterations is called unreachable at ten),
loose caps classify correctly and run past ten minutes a station. The number of iterations
a feasible step needs is not knowable in advance, which is exactly why a time budget has
to be an independent guard.

A Python timer does not fire. signal.alarm schedules a handler, but Python runs
handlers only between bytecodes, and during the grind control is inside PETSc's C code the
whole time. Measured: a 90 s alarm still unfired at 10 minutes.

So the only code that runs during a grind is PETSc's own, and that is where the deadline
has to live.

What this adds

stokes.guard(wall_per_step=150.0)
stokes.solve()
if stokes.solve_report.deadline_expired:
    ...                # too hard at these parameters — back off a continuation step
stokes.unguard()

cycles = stokes.solve_report.sub["velocity"].its      # the honest work axis

A guard exit is a report, not an exception: the current iterate stays in the fields so a
continuation driver can revert or retry from it.

The clock starts at the beginning of each solve and restarts at each outer KSP
iteration 0
, which is once per Newton step — so no SNES hook is needed. Starting it at
the beginning of the solve is not cosmetic: PETSc defaults GMRES to left preconditioning,
and KSPInitialResidual applies the preconditioner before iteration 0, so a clock armed
only at iteration 0 leaves one full velocity solve plus one pressure solve outside the
budget on every solve.

The deadline is checked inside the fieldsplit sub-KSP tests, which is where the
granularity is. In a representative solve the outer KSP performed 1 iteration while the
velocity block performed 285; only the sub-KSP tests can interrupt anything finer than a
whole Newton step.

The guard runs in front of PETSc's own test, not instead of it.
KSP.addConvergenceTest(..., prepend=True) composes with whatever native test the KSP is
configured with, so returning ITERATING hands the decision straight back to
KSPConvergedDefault with its options-configured context intact. Verified: a prepended
always-ITERATING test gives identical iteration count and reason to an uninstrumented
solve. That is also what makes unguard() exact — PETSc cannot remove an added test, so
it stays installed and goes inert, which is the uninstrumented behaviour.

The part that is easy to get wrong

PETSc's KSPCheckSolve deliberately exempts KSP_DIVERGED_ITS from marking the
preconditioner failed — truncating an inner solve at its iteration cap is normal
behaviour, not an error. A guard that returns DIVERGED_MAX_IT from a sub-KSP therefore
does nothing at all. Measured: the outer solve absorbed 36 truncated velocity solves and
reported CONVERGED. The earlier prototype in ~/+Simulations/spiegelman_hardcase used
exactly that reason, so its wall-time cap was weaker than it appeared.

DIVERGED_BREAKDOWN marks the sub-preconditioner failed, which surfaces as
DIVERGED_PC_FAILED at the outer KSP and DIVERGED_LINEAR_SOLVE at the SNES.

Parallel

Wall clocks are not synchronised across ranks, and a PETSc convergence test that returns
different verdicts on different ranks leaves the ranks on different code paths — the next
collective deadlocks. The expiry decision is therefore reduced over the solver's
communicator. That is well posed because Krylov iterations are globally synchronised, so
every rank reaches the same check the same number of times.

ptest_0203 does not take this on trust. It provokes the hazard with deliberately skewed
per-rank clocks (rank 0 fast, the others effectively frozen), so the ranks would reach
opposite verdicts if each decided alone. It passes at np=2 and np=4 — and deadlocks when
the reduction is removed
, confirmed against a 90 s mpirun --timeout.

Extra reductions are paid only when a guard is armed. An unarmed solver installs no
convergence tests at all.

The work axis (needed before any of this means anything)

solve_report.ksp_its counts outer Krylov iterations, which Eisenstat–Walker collapses
to about one per Newton step. It is not a measure of cost, and using it as one understates
a Stokes solve by two orders of magnitude — measured, outer 1 against 285 multigrid cycles
in the velocity block.

solve_report.sub records iterations and applications per fieldsplit block. For the
velocity block under pc_type=mg that count is the multigrid cycle count. It is
collected by monitors, which observe and never decide, so it cannot change a solve.

Cost of leaving it always on: 624 monitor callbacks in a 280 ms solve, with no measurable
change in wall time (medians 282 ms instrumented against 284 ms not, run alternately;
run-to-run noise exceeds the difference).

Limitations, made honest rather than hidden

  • First solve's counts. The fieldsplit blocks do not exist until PCSetUp, which runs
    part-way through the first KSPSolve. So that solve's sub-counts are a lower bound
    (measured: about half, because left-preconditioned GMRES applies the preconditioner once
    in KSPInitialResidual before the guard can attach). SubSolveReport.complete is
    False to say so. The deadline is unaffected — the clock runs from the start of the
    solve. A driver wanting exact work should do one cheap solve before arming, which it
    usually does anyway to pay for the JIT compile.
  • Rotated free-slip. guard() raises there, matching estimate_difficulty(): that
    path runs its own Krylov loop outside self.snes, so a guard would attach and never
    fire — protection in appearance only.
  • preonly and richardson outer KSPs are skipped entirely: both change what they
    do when a monitor is attached (they are gated on ksp->numbermonitors in PETSc), and
    neither iterates, so instrumenting them would be all cost and no information.

Review

Two independent adversarial reviewers were given this branch and told to break it. They
found four major defects in the new code — including a deadline that was disabled for
the first preconditioner application of every solve, and instrumentation that re-opened
the memory leak BUGFIX(#157) closed — plus a test suite that one of them demonstrated
would pass with several parts of the feature broken. All are fixed; the full report is in
docs/reviews/2026-07/solver-wall-clock-guard-adversarial-review.md and reproduced as a
comment on this PR.

One reviewer finding is recorded as not sustained: they argued a retry or continuation
stage would be handed a fresh budget after an expiry. Measurement says PETSc prevents this
itself (34 clock ticks against 32 with the expiry latch removed), so the latch is kept as
belt-and-braces and no test claims to prove it.

Testing

  • tests/test_0203_solver_wallclock_guard.py — 10 tests, tier_b. Each verified to fail
    without its feature by neutering the implementation: the deadline, the counting, and the
    per-Newton-step re-arming each break a distinct set.
  • Cost parity, not just answer parity, is asserted between a guarded and an unguarded
    solve — a change confined to the sub-blocks moves the cost and not the answer.
  • The per-step meaning of wall_per_step is exercised on a nonlinear model, because one
    linear solve cannot distinguish per-step from per-solve.
  • The test that has to prove the deadline bites inside the blocks drives an injected
    ticking clock rather than a seconds budget. A budget in seconds cannot express "expire
    during the block solves" on an unknown machine — too large and the solve finishes, too
    small and it expires at the outer iteration before the blocks are reached. Both
    real-budget versions were fixture-dependent and flaky before being replaced.
  • tests/parallel/ptest_0203_wallclock_guard_parallel.py — np=2 and np=4, registered in
    tests/parallel/mpi_runner.sh. It hangs rather than fails if the reduction is dropped,
    so it must be run under a timeout.
  • level_1 across all tiers: 966 passed. (Tier A alone is not a sufficient gate for a
    change that touches solver behaviour — that lesson came from this session.)

Design note

docs/developer/design/solver-wall-clock-guard.md.

Underworld development team with AI support from Claude Code

lmoresi added 3 commits July 27, 2026 17:58
A hard viscoplastic Stokes solve does not fail, it grinds -- for hours, inside a
single Newton step. Two measurements say why nothing outside PETSc can stop it:
an iteration cap never fires because the grind happens within one outer Krylov
iteration, so the counter it watches does not advance; and a Python signal.alarm
never fires because Python runs signal handlers only between bytecodes while
control sits in PETSc's C code (a 90s alarm measured unfired at 10 minutes).

So the deadline lives in PETSc's own convergence tests. solver.guard(
wall_per_step=...) restarts a budget at each outer KSP solve -- once per Newton
step -- and checks it inside the fieldsplit sub-KSP tests, where iterations are
frequent enough for seconds of granularity. A guard exit is a report, not an
error: solve_report.deadline_expired distinguishes it from a real divergence.

The reason returned matters and is easy to get wrong. PETSc's KSPCheckSolve
deliberately exempts KSP_DIVERGED_ITS from marking the preconditioner failed, so
a guard returning DIVERGED_MAX_IT from a sub-KSP does nothing at all -- measured,
the outer solve absorbed 36 truncated velocity solves and reported CONVERGED.
DIVERGED_BREAKDOWN marks the sub-PC failed and surfaces as DIVERGED_LINEAR_SOLVE.

In parallel the expiry decision is reduced over the solver's communicator: wall
clocks are not synchronised, and a convergence test returning different verdicts
on different ranks deadlocks the next collective. ptest_0203 provokes this with
deliberately skewed per-rank clocks -- it passes at np=2 and np=4, and deadlocks
when the reduction is removed (confirmed at a 90s limit).

Also adds the work axis the guard needs. solve_report.ksp_its counts OUTER
Krylov iterations, which Eisenstat-Walker collapses to about one per Newton step;
measured on a small box, outer 1 against 285 multigrid cycles in the velocity
block. solve_report.sub records iterations and applications per fieldsplit block,
collected by monitors so it cannot influence a solve. Cost of leaving it on: 624
callbacks in a 280ms solve, no measurable change in wall time.

Known and documented rather than hidden: on a solver's first solve the fieldsplit
blocks do not exist until PCSetUp runs part-way through, so one preconditioner
application is uncovered and that solve's sub-counts are a lower bound
(SubSolveReport.complete says so). guard() raises on the rotated free-slip path,
which solves outside self.snes and cannot be reached.

Underworld development team with AI support from Claude Code
…guard

Two independent reviewers were given the change and told to break it. Four of
the findings were real defects in the new code and are fixed here; two more were
claims in the docs that measurement did not support.

MAJOR - the deadline was disabled for the first preconditioner application of
EVERY solve, not just the first. begin_solve() left the clock at infinity and
only outer-KSP iteration 0 armed it -- but PETSc's default for GMRES is LEFT
preconditioning, and KSPInitialResidual applies the preconditioner BEFORE
iteration 0. Verified: for pc_side LEFT the sub-block monitors fire before the
outer monitor. So one full velocity solve plus one pressure solve ran outside
the budget on every solve. The clock now starts in begin_solve().

MAJOR - the premise that forced a hand-rolled convergence test was false.
petsc4py DOES expose composition: KSP.addConvergenceTest(prepend=True) runs a
test in FRONT of the native one, and returning ITERATING hands the decision back
to KSPConvergedDefault with its options-configured context intact. Verified: a
prepended always-ITERATING test gives identical iteration count and reason to an
uninstrumented solve. _default_convergence is deleted, and with it four silently
dropped KSPConvergedDefault behaviours (-ksp_converged_maxits, the
non-zero-initial-guess residual convention, the norm-type early return,
-ksp_min_it) and a bug reporting an infinite residual as CONVERGED_RTOL. It also
makes unguard() exact: the test stays installed and goes inert, which IS the
uninstrumented behaviour, rather than being swapped for a fresh default context.

MAJOR - the instrumentation held petsc4py references to a destroyed SNES's KSP,
PC and multigrid hierarchy until the next solve, so a rebuilding solver carried
two hierarchies at once. That is the leak BUGFIX(#157) was written to close. It
now releases them before the destroy.

MAJOR - guard()'s rotated-free-slip refusal was order-dependent: arming first and
adding the BC afterwards armed cleanly and then sat inert on a path that never
reaches the instrumentation. Re-checked at solve time.

MINOR - preonly and richardson outer KSPs are now skipped: both change what they
DO when a monitor is attached (gated on ksp->numbermonitors in PETSc), and
neither iterates, so instrumenting them was all cost and no information.

MEASURED, NOT FIXED - a reviewer argued a retry or a continuation stage would be
handed a fresh budget after an expiry. PETSc prevents this on its own: once the
sub-preconditioner is marked failed the next snes.solve bails before iterating,
so removing the expiry latch changed the cost by 34 ticks against 32. The latch
is kept because that protection is implicit, but it is belt-and-braces and no
test claims to prove otherwise.

Tests reworked against the second reviewer's demonstration that several of them
passed with the feature broken. Now covered: cost parity (not just answer parity)
between a guarded and an unguarded solve; the per-Newton-step meaning of
wall_per_step, on a nonlinear model, since one linear solve cannot distinguish
per-step from per-solve; the first-solve lower-bound contract; arming before the
first solve; both call orders of the rotated refusal. The suite is tier_b, not
tier_a: Charter 8 reserves tier A for tests that have been through use in anger.
The parallel test is registered in mpi_runner.sh -- it was unreachable by any
runner before, so the hazard the design calls severe had no automated coverage.

Underworld development team with AI support from Claude Code
Underworld development team with AI support from Claude Code
Copilot AI review requested due to automatic review settings July 27, 2026 08:35

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown

🔍 Architectural Review Checklist

Thank you for submitting an architectural review! Reviewers should validate:

Design & Architecture

  • Design rationale is clear and well-justified
  • Trade-offs are documented with alternatives considered
  • System architecture is comprehensible
  • Integration points are clearly identified

Implementation

  • Implementation matches documented design
  • Code quality meets project standards
  • Breaking changes are identified and justified
  • Backward compatibility is properly addressed

Testing & Validation

  • Testing strategy is adequate for the changes
  • Test coverage is sufficient
  • Edge cases are properly covered
  • Performance impact has been assessed

Documentation

  • Known limitations are clearly documented
  • Benefits are quantified with metrics
  • User-facing changes are documented
  • Migration guide provided (if needed)

Review Process: See CODE-REVIEW-PROCESS.md

Approval: This PR merge = Review formally approved

@lmoresi

lmoresi commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

This is the adversarial review this branch was put through, posted in full per the standing rule that reviews are public. Two independent reviewers, each given one dimension and told to break the change rather than praise it. Everything below is fixed on the branch except where explicitly marked as not sustained by measurement.


Adversarial review — wall-clock guard and sub-solve work gauge

Branch feature/solver-wallclock-guard vs development.

Method: two independent adversarial reviewers, each given one dimension and instructed to
break the change rather than praise it. Both were told to report only findings they had
verified against the source; both went further and verified empirically, one by driving
standalone petsc4py to establish PETSc's callback ordering, the other by neutering parts
of the implementation and observing which tests still passed.

Verdict: not ready as submitted. Four major defects in the new code, two false claims
in its documentation, and a test suite that a reviewer showed would pass with several
parts of the feature broken. All are addressed on the branch; the two findings that
measurement did not sustain are recorded as such rather than quietly fixed.


MAJOR — the deadline was disabled for the first preconditioner application of every solve

begin_solve() left the clock at infinity, and only outer-KSP iteration 0 armed it. But
PETSc's default for GMRES — the Stokes outer solver — is left preconditioning, and
KSPInitialResidual applies the preconditioner before iteration 0. The reviewer
established the ordering directly:

outer=gmres  pc_side=LEFT :  sub0_mon(0), sub0_mon(1), sub1_mon(0), sub1_mon(1), outer_mon(0), ...
outer=fgmres pc_side=RIGHT:  outer_mon(0), sub0_mon(0), ...

So one complete velocity-block solve plus one pressure-block solve ran entirely outside
the budget — on every solve, not just the first. That is precisely the grind the guard
exists to bound. Both the docstring and the design note described this as a first-solve
limitation caused by the blocks not yet existing; the blocks exist from solve 2 onward,
and the clock was simply not running.

Fixed: the clock starts in begin_solve(), and outer iteration 0 restarts it per
Newton step.

MAJOR — the premise that forced a hand-rolled convergence test was false

Both the code and the design note asserted that "a custom convergence test replaces the
default — petsc4py exposes no way to chain to it". petsc4py ships
KSP.addConvergenceTest(fn, prepend=True), which composes the custom test with the
native one, whatever it is configured to be.

Verified here before acting on it:

iterations reason
uninstrumented 30 CONVERGED_RTOL
prepended test returning ITERATING 30 CONVERGED_RTOL
prepended test returning DIVERGED_BREAKDOWN at it 3 3 DIVERGED_BREAKDOWN

Every divergence the reviewer then listed was a self-inflicted consequence of
reimplementing rather than prepending: four dropped KSPConvergedDefault behaviours
(-ksp_converged_maxits, the non-zero-initial-guess residual convention, the norm-type
early return, -ksp_min_it), and a NaN guard written as rnorm != rnorm that let an
infinite residual through to be reported as CONVERGED_RTOL — silent false convergence,
in exactly the inf/inf viscoplastic regime the guard targets.

Fixed: _default_convergence is deleted. The guard returns ITERATING and PETSc
decides. This also makes unguard() exact rather than approximate — PETSc offers no way
to remove an added test, so it stays installed and goes inert, which is the
uninstrumented behaviour, instead of being swapped for a freshly created default context
that would have lost any options-configured flags.

MAJOR — the instrumentation re-opened the leak BUGFIX(#157) was written to close

SNES.getKSP() and PC.getFieldSplitSubKSP() both increment the reference count. The
instrumentation held those entries until the next solve, so a rebuild
(is_setup=False, remesh, adapt) did not free the old KSP, PC, coarse operators or
multigrid hierarchy: they survived precisely while the new hierarchy was being built.

The comment at the destroy site records that this exact retention "at Gadi scale … push[ed]
past memory limits" for SNES_Tensor_Projection.solve() cycling six components in 3D —
a loop that drives is_setup=False on every component.

Fixed: the instrumentation releases its references before the SNES is destroyed.

MAJOR — guard()'s rotated-free-slip refusal was bypassed by call order

Found independently by both reviewers, and demonstrated live: guard(...) then
add_rotated_freeslip_bc(...) armed without error, and the subsequent solve took the
rotated path — which never reaches the instrumentation — and reported
deadline_expired=False, converged=True. The guard was silently inert: "looks like
protection and is not", which is the exact state the NotImplementedError exists to
prevent.

Fixed: re-checked at solve time as well as at arm time; the test now covers both
orders.

MINOR — preonly and richardson outer KSPs are changed by being monitored

Both are gated on ksp->numbermonitors in PETSc: KSPSolve_PREONLY adds a norm, a
matvec and a second norm purely to have something to report, and KSPRICHARDSON gives up
the fused PCApplyRichardson path. The gauge attached a monitor to the outer KSP of every
solver unconditionally, and model.py ships preonly presets.

Fixed: both types are skipped. Neither iterates, so there was nothing to count or
bound — it was all cost and no information.


The test suite was the sharpest hit

The second reviewer neutered parts of the implementation and recorded which tests still
passed. Four separate holes:

  • unguard() was untested. Making the removal a no-op left 7/7 passing. The test
    passed only because disarming also reset the deadline, so the solve converged — it
    never observed which convergence test was installed.
  • No work parity assertion. Tightening the guard's rtol by 1e-4 left 7/7 passing.
    The healthy-solve test compared field values only, so any change confined to the
    sub-blocks — which cannot move the answer, only the cost — was invisible. Against
    "Solver Stability is Paramount" that is the wrong thing to be blind to.
  • wall_per_step's per-Newton-step meaning was never exercised. The fixture was
    constant-viscosity, hence linear, hence nl_its == 1 on every solve in the file — so
    per-step and per-solve budgets were indistinguishable. That is the entire meaning of
    the only parameter the feature takes.
  • The parallel test was dead code. Not listed in mpi_runner.sh, referenced by no CI
    workflow, and not pytest-collectable (ptest_* does not match test_*). The serial
    suite did not cover the reduction either — replacing the Allreduce with a rank-local
    read left 7/7 passing. So the hazard the design note calls "specific and severe" had
    zero automated coverage.

Also: the rotated-BC test corrupted the module-scoped fixture (add_rotated_freeslip_bc
sets is_setup=False), so reordering the file made the gauge test fail — a failure
pointing at the wrong culprit. And tests left the solver armed at a microsecond budget if
they failed part-way, cascading into everything after.

Fixed: cost parity is asserted alongside answer parity; the per-step semantics is
exercised on a nonlinear model by measuring the solve's total cost and then setting a
budget only a per-solve interpretation would blow; the first-solve lower-bound contract
and the arm-before-first-solve path are covered; the rotated tests use their own solvers;
a fixture guarantees disarming. The parallel test is registered in mpi_runner.sh.

The suite is marked tier_b, not tier_a. Charter §8 reserves tier A for hardened tests;
these are new and have never run in CI.


Findings that measurement did NOT sustain

Recorded rather than silently fixed, because the reasoning was sound and only the
conclusion was wrong.

"A retry or continuation stage is handed a fresh budget after an expiry." Mechanically
true — deadline_expired latches while open_step() could re-arm — and a real concern,
since a deadline exit is a negative reason and therefore triggers a warm-start retry.
But PETSc prevents it independently: once the sub-preconditioner is marked failed, the
next snes.solve in the same call bails before reaching an iteration. Measured with the
expiry latch removed, on both the retry and the continuation path: 34 clock ticks against
32 with it, and converged=False either way. The latch is kept — relying on PC-failure
propagation is implicit rather than guaranteed — but it is belt-and-braces, and no test
claims to prove otherwise.

"PETSc handles in _ksps could be reused after an object is freed, aliasing a new
KSP."
Checked and clean: the retained reference keeps the address occupied, so no freed
handle can alias. The same fact is what caused the memory-retention finding above — the
aliasing safety was being paid for in memory.


Verified clean

  • Allreduce(MPI.IN_PLACE, flag, MPI.MAX) on the outer KSP's communicator is correct, and
    the fieldsplit blocks live on that same communicator.
  • DIVERGED_BREAKDOWN returned at outer iteration 0 is safe (KSPGMRESBuildSoln handles
    it-1 == -1 explicitly).
  • The rotated path builds its own report, so sub / deadline_expired default rather
    than carrying stale values.
  • ksp.getTolerances() returns (rtol, atol, divtol, max_it) — a different order from
    SNES — and was read correctly.
  • The fake clock intercepts every clock read the guard makes: time.monotonic appears
    exactly twice in the diff, both via the module global.
  • A non-fieldsplit solver (Poisson) reports sub == {}, and the guard still fires on its
    outer KSP.
  • The DIVERGED_BREAKDOWN-not-DIVERGED_MAX_IT claim is load-bearing and caught by the
    suite: swapping the reason back fails the "bites inside the blocks" test.
  • The parallel claim is true: the ptest passes at np=2 and hangs past a 120 s limit when
    the reduction is replaced by a rank-local read.

Known limitations, documented rather than hidden

  • The injected-clock tests can catch a guard that never fires, not one that fires too
    eagerly — with a fake clock, PETSc's real work costs zero ticks, so expiry is guaranteed
    for any budget. The real-clock generous-budget test covers the other direction.
  • SubSolveReport.complete is set conservatively: False whenever the block was attached
    mid-solve, even in a right-preconditioned configuration where the count would in fact be
    exact.
  • solve_report.sub keys come from the split names, so the block-constrained Stokes path
    yields "0" / "1" rather than "velocity" / "pressure". Documented; read the keys.

@github-actions

Copy link
Copy Markdown

Test Suite: success

@lmoresi
lmoresi merged commit a3978fc into development Jul 28, 2026
5 checks passed
@lmoresi
lmoresi deleted the feature/solver-wallclock-guard branch July 28, 2026 01:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants