Reuse the rotated free-slip solver workspace across solves — reworked with a constants-aware key (#417, supersedes #418) - #543
Conversation
Repeated transient rotated free-slip Stokes solves rebuilt the rotation Q, the PtAP'd operator, the fieldsplit Schur KSP/PC and the GAMG hierarchy on every timestep — an allocator high-water problem (RSS growth to OOM, issue #417) and roughly half the per-step cost of the Zhong A1 production runs. Cache the workspace on the solver between solves, re-derived from the original PR #418 (bec76bb) at the seam of the rewritten unified Newton loop: - geometry tier (Q/Qt, constrained rows, fault pair blocks, custom-FMG prolongation) reused while the boundary/fault registration and DM are unchanged; - structure tier (Ahat, Schur pmat, nullspaces, KSP/PC) reused as objects with values refreshed in place — the loop's own between-iteration pattern; - an iteration-0 fast path skips Jacobian assembly / ptap / PCSetUp entirely when the operator coefficient state counters match (RHS-only timesteps); - the cache is forfeited for direct-LU, prescribed-datum and fault interface-law solves, and torn down by _reset() and the _build() full rebuild before the SNES/DM are destroyed; - expose the existing time= argument through the Stokes.solve wrapper; an explicit time vetoes the fast path (petsc_t bypasses every counter). Unlike the original one-shot linear path, a wrong fast-path verdict here cannot return a stale solution: the loop measures the true residual at every iterate and reassembles from iteration 1 on. Regression: RHS-only reuse (same Q/Ahat/KSP handles), viscosity-field invalidation with in-place refresh, and the time= veto, in test_1018_rotated_freeslip. Production evidence on the original mechanism: 310 guarded Zhong A1 steps, flat RSS (PR #418 thread). Underworld development team with AI support from Claude Code
…ey, the verdict goes collective The PR #418 review's unresolved finding: the reuse key was built from MeshVariable._state counters, which are BLIND to rampable UWexpression constants — the #416 contract lets a constant change value with no state bump, so a 2x viscosity ramp between solves reported "unchanged" and (on the original one-shot path) returned a bit-identical stale solution while the matrix-probe safety net was disabled by the very verdict it was meant to check. The reworked loop already made a stale verdict non-fatal (the true residual is measured every iterate and iteration 1 onward always reassembles), but the verdict itself must still be honest: - the operator key now includes the packed constants[] values the kernels will actually assemble with, plus the JIT bundle key (covers an in-place kernel rewire). Measured on the ramp probe: the naive key reported workspace_reused=True for a 2x constant ramp; this key reports False and reassembles. Over-invalidation on RHS-only constant changes is accepted — reassembly is the safe default; - if the constants manifest or the coefficient enumeration cannot be read, the fast path is forfeited outright — correctness first; - the fast path additionally requires a self-measured linear hint (last solve converged in <= 1 increment): for a nonlinear model the cached operator is the previous solve's tangent, and the skip would only trade an assembly for a wasted increment; - the match verdict is allgathered and must be unanimous before it gates any collective PETSc call — state counters follow rank-local writes, and a rank-divergent verdict is a deadlock; - on a detected change the stored signature is poisoned before the in-place refresh, so an exception mid-refresh cannot leave a stale key that later matches half-updated values. Regression: test_rotated_workspace_constant_ramp_invalidates (fail-before validated on the naive key: the reused flag lies there) with its own armed- fast-path negative control and a fresh-solver control at the ramped viscosity; test_rotated_workspace_deform_invalidates re-proves the mesh.deform teardown on the reworked cache. Underworld development team with AI support from Claude Code
…cache, interface laws opt out solve_with_fault drives the same rotated Newton loop, so the cross-solve workspace decision had to be made explicitly for the fault machinery: - FRICTIONLESS pair blocks are geometry (coincident-node pairing + fault normals live in Q, keyed by the fault registration in the geometry signature) — they cache. A warm repeat reuses the rotation; a cold re-solve rides the iteration-0 fast path; both match a fresh-solver control on the same mesh. - INTERFACE-LAW solvers (viscous / Coulomb / rate-state) opt out entirely: the interface tangent is reassembled per iterate at the current slip rates and the reaction-fed normal stress is Picard-lagged solver state — neither is keyable registration state, so cache_allowed excludes them at the top of solve_rotated_freeslip. Regression: test_fault_repeat_solve_composes_with_workspace_reuse covers both arms, with a fresh-solver control for the cached arm and the absent- cache assertion for the opt-out. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR introduces a cross-solve cache for the rotated strong free-slip Stokes solve, reusing the PETSc rotation/operator/KSP workspace across repeated solves to reduce rebuild cost and bound memory growth, while adding a constants-aware invalidation key (to catch rampable UWexpression constants) and a collective reuse verdict for MPI safety.
Changes:
- Add a persistent rotated-workspace cache (geometry + structure tiers) and an iteration-0 “fast path” that can skip Jacobian/PtAP/PCSetUp when the operator is provably unchanged.
- Extend invalidation to include packed
constants[]values + JIT bundle key (and make reuse verdict collective across ranks); addworkspace_reused/rotation_reusedreporting. - Add
time=pass-through onStokes.solve()and veto the fast path for explicit time evaluation; add focused regression tests and a design note.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_1018_rotated_freeslip.py | Adds regression coverage for workspace reuse, constants-ramp invalidation, and deform invalidation. |
| tests/test_0846_fault_contact.py | Adds coverage that fault contact (frictionless vs interface-law) composes correctly with the rotated workspace cache. |
| src/underworld3/utilities/rotated_bc.py | Implements cross-solve workspace caching, constants-aware operator signature, and collective reuse gating. |
| src/underworld3/systems/solvers.py | Exposes time= on solve() and forwards it to the underlying solver call sites. |
| src/underworld3/cython/petsc_generic_snes_solvers.pyx | Adds cache teardown hooks and vetoes the rotated fast path when time= is provided. |
| docs/developer/design/ROTATED_FREESLIP_LINEAR_REUSE.md | Documents the reuse tiers, safety net, invalidation rules, and validation approach. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| from underworld3.utilities._jitextension import _pack_constants | ||
| try: | ||
| manifest = getattr(solver, "constants_manifest", None) | ||
| values = tuple(float(v) for v in _pack_constants(manifest)) \ | ||
| if manifest else () | ||
| except Exception: | ||
| return None | ||
| jit_key = getattr(solver, "_current_jit_cache_key", None) | ||
| if jit_key is None: | ||
| jit_key = getattr(solver, "_last_jit_cache_key", None) | ||
| return (str(jit_key), values) |
… null before destroy Two minors from the #543 review. (m2) The persisted operator_sig now requires that this solve either reassembled Ahat or rode a key-matched fast path - a poisoned solve exiting at iteration 0 without assembling can no longer store a fresh key against unrefreshed values (the window was bounded by the always-reassemble net; now it is closed). (m4) Both teardown sites null the cache attribute before destroying, so an exception mid-destroy leaves objects unreachable rather than arming a double-destroy. Underworld development team with AI support from Claude Code
Adversarial review — PR #543: rotated free-slip workspace reuse (rework of #418, fixes #417)Reviewed independently of the implementer at f8c2b72 (3 commits on 689523f), The structural claim we held #418 on is now real: the safety net MERGE-BLOCKERSNone. One condition we would attach: gthyagi's A1 re-validation segment on Findings (severity-ranked)m1 (minor) — solver configuration latches into the cached KSP
m2 (minor) — persist can store a key describing values
|
| Change between solves | invalidates / forfeits? | err vs fresh control |
|---|---|---|
| second solver, same mesh | caches independent (distinct handles); interleaved drift 0.0 | 0.0; ¼-viscosity ratio 0.0 |
| add a Dirichlet wall | full rebuild, rotation_reused=False |
0.0 |
| add a second rotated boundary | full rebuild + boundaries in geometry sig | 0.0 |
| tolerance tightened 1e-4→1e-10 | honoured on the true residual (m1) | 1.8e-11 |
| nonlinear rheology switched on, η(v) | fast path defeated; linear_hint withdrawn after the 2-increment solve |
5.7e-16 |
| bodyforce expression REPLACED | rebuild/rewire; no stale answer | 3.4e-10 |
| rampable-constant 2× ramp (THE #418 killer) | their test: fast path defeated, halved velocity exact | (in test, passes) |
direct mesh.X.coords writes |
not a live path: the property returns scaled/wrapped arrays and writes never reach the PETSc coordinate vec; deform is the sanctioned route and tears the cache down (their test + probe 3) |
n/a |
2. Iteration-0 fast path (probe 2-G): after a fast-path solve, poisoned
the key to force a slow-path in-place reassembly of the SAME Mat: every
constrained row identical, worst abs diff 0.0, |Ahat| unchanged to all
digits. The skipped zeroRowsColumns/PCSetUp is genuinely redundant.
Poisoning order verified in code AND by probe 2-I (hand-poison, ramp, no
fast path, err 3.5e-11): an interrupt in the poison→refresh window can only
under-reuse.
3. Collective discipline (probe 4, np2): code audit — every verdict
input is registration state, packed-constants (global), or goes through
the allgather; exception paths inside both signature helpers swallow to
None symmetrically (→ forfeit). Probe: fresh / RHS×2 / rank-VARYING RHS
values / constant ramp — flags allgathered at every solve, rank-identical:
[(F,F),(T,T),(T,T),(T,F)], ramp defeated on both ranks, no hang, final
err vs fresh control 1.2e-10. (The rank-asymmetric-write variant is
unreachable — see the positive observation above.)
4. The structural safety net (probe 2-H): monkeypatched
_operator_constants_signature to a frozen value (equivalent to reverting
the blind-spot fix), ramped 2×: workspace_reused=True (the lie fires),
halved-velocity error 1.7e-11, exactly +1 increment (2 vs 1). The net
that replaced the dropped matrix probe does what the PR claims — a
corrupted key costs work, never an answer.
5. Fault composition (their test + probes 5/5b): fault set passes (30).
Changing the fault's normal= override between solves is caught by the
geometry signature (rotation_reused=False); a COLD post-change solve
matches a fresh override control bit-identically (0.0, floor between
two fresh controls also 0.0). For the record: a WARM start straight across
the normal change landed 6.3e-2 off while reporting converged — with the
cache already destroyed (pre-PR behavior identical), in a deliberately
flux-incompatible override configuration, and with the loop's
mass-conservation gauge warning printed loudly. Pre-existing rotated-loop
warm-start semantics, not a cache defect.
6. Teardown (probe 3): 30 solves with 5 interleaved mesh.deform
teardown/rebuild cycles: 25/30 fast-pathed, ru_maxrss slope
0.79 MiB/solve including the five full rebuilds (PR's pure-reuse figure
0.12; high-water RSS overstates); repeated _reset() /
_reset_rotated_solver_cache() idempotent, no double-destroy; σ_nn
traction recovery off a fast-path solve doubles with the load (1.3e-10) —
verified in code that the recoveries read only reaction+boundaries, so
the "result dict survives cache teardown" contract holds. Custom-FMG
prolongation deref-not-destroy is correct (shared coarse Mats; the rotated
Pfine/Qv fall to petsc4py GC).
7. Tests & negative control: test_1018 22 passed; fault set
0845/0846/0847/0848 30 passed. House-rule negative control: swapped
commit 1's rotated_bc.py (naive key) into the INSTALLED copy → the ramp
test fails exactly at the lying-flag assertion, restored and re-verified.
The regression test has teeth, and its own internal negative control (fast
path proven armed before the ramp assertion) fires. Full gate
-m "level_1 and tier_a" (–test_0050): 599 passed, 0 failed, 17 skipped
(MPI-gated), 1 xfailed, in 8:13.
Notes for the record
- PR base is 2 commits behind
origin/development; the divergence touches
the pyx by 6 lines (Boundary flux on degree-3 traces: per-slot DOF identity, true edge-node coordinates, and the consistent line mass (#459) #537), disjoint from this change — expect a clean
merge. - Commit 1 carries gthyagi's authorship, as it should.
time=semantics:petsc_tpersists on the DM after atime=solve; a
later no-time solve fast-paths against the operator assembled at that
time, which is CONSISTENT (operator and residual agree). The veto covers
the transition solve — the only place it must._pack_constantspacks 0.0 for an un-floatable constant, but signature
and kernels share the same packing, so the key cannot desync from the
assembly.- Suggest (non-blocking): an np2
ptest_for the reuse verdicts — the
parallel evidence currently lives only in probe scripts, and
tests/parallel/test_1064/1066predate the cache.
Verdict
APPROVE (with the A1 re-run segment requested of gthyagi, and m2/m3 as
cheap hardenings here or in a follow-up). The #418 failure class — a silent
stale answer — is structurally closed: under deliberate key corruption the
branch still produced the correct field at +1 increment, and every
mutation we threw at the key (BCs, rheology, bodyforce, fault normals,
tolerances, np2 rank-varying state) either invalidated, forfeited, or was
rescued by the always-reassemble net. Memory (#417) stays bounded through
deform/rebuild cycles.
|
Response commit 2f17743 takes m2 (operator_sig persisted only when Ahat provably holds the keyed values) and m4 (null-before-destroy at both teardown sites); test_1018 22/22 after rebuild. m1 (config latched into the cached KSP) and m3 (coefficient-enumeration reuse safe by teardown coincidence, not construction) are tracked as a follow-up issue. The attached condition stands: @gthyagi — when you have a slot, a 50-step guarded A1 restart segment on this branch would re-anchor your 310-step production trail against the reworked loop; the memory numbers here (+0.12 MiB/solve over 35 solves, 2.10x warm-solve speedup) match your original measurements. Underworld development team with AI support from Claude Code |
Reuse the rotated free-slip solver workspace across solves (rework of #418, fixes #417)
This is the landing rework of #418 (gthyagi's "Reuse linear rotated free-slip
solver workspace", bec76bb), which went CONFLICTING after the rotated solve
loop was rewritten (#437, #458, #465/#471, #469, #493, #500, #502, #530/#534),
and which the 2026-07-27 adversarial review held on one structural finding.
gthyagi's commit is cherry-picked with his authorship preserved; the caching is
re-derived at the current seam rather than force-fitting the old text.
What was ported, and where it lives now
The original PR split a linear path from a nonlinear one and cached the linear
path's workspace whole — operator, KSP, and (implicitly) the solution state —
keyed on
MeshVariable._statecounters, with a matrix probe as safety net.Since the fork,
rotated_bc.solve_rotated_freeslipbecame ONE manualNewton/Picard loop for linear and nonlinear models alike, which already reuses
its operator and KSP context between its own iterations. The port extends
exactly that in-loop pattern across solves, split by what each piece
actually depends on:
fault registration, DM identity): the rotation
Q/Qt, constrained normalrows, fault pair blocks, custom-FMG prolongation.
place):
Ahatvia ptap-with-result, the Schur pmat viacreateSubMatrix-with-submat, the fieldsplit KSP/PC via a
setOperatorspoke. This is the identical operation sequence the Newton loop performs
between iterations, so it carries the production-validated risk profile.
when the operator key proves nothing operator-relevant changed AND the last
solve on this workspace behaved linearly (converged in ≤ 1 increment — a
self-measured hint, no up-front nonlinearity probe).
Nothing about the rotated BC's discrete equations changed. All current result
keys (
rnorm/rnorm0for the solve report, the #534 tolerance/reportplumbing, the #502 fault keys) are preserved;
rotation_reusedandworkspace_reusedare added.Teardown hooks:
_reset_rotated_solver_cache()runs from_reset()and fromthe
_build()full-rebuild branch before the SNES/DM are destroyed(mesh.deform funnels there). The in-place rewire fast path keeps the cache;
its new kernels are caught by the JIT-key half of the operator signature.
The cache is forfeited outright for direct-LU, prescribed-datum and fault
interface-law solves.
Stokes.solvegains thetime=pass-through from theoriginal PR; an explicit time vetoes the fast path (
petsc_tbypasses everycounter and constant).
The blind-spot fix (the finding that held the PR)
The review probed that a 2× viscosity ramp via a rampable UWexpression
constant (the #416 contract: value changes bump NO state counter) returned a
bit-identical stale solution flagged
workspace_reused=True, and that thesame "unchanged" verdict short-circuited the matrix-probe safety net that was
supposed to catch it. Both halves are fixed, structurally:
includes the packed
constants[]values the compiled kernels will actuallyassemble with, plus the JIT bundle key. If the manifest or the coefficient
enumeration cannot be read, the fast path is forfeited — correctness first.
(This deliberately over-invalidates on RHS-only constant changes;
RHS-only field changes — the production temperature pattern — still ride
the fast path.)
loop there is no separate one-shot linear path to poison: the loop measures
the TRUE residual (fresh kernels, current constants) at every iterate,
declares convergence only on that, and always reassembles from iteration 1
on. A wrong fast-path verdict therefore costs one extra increment — it
cannot return a stale solution. Measured on the naive-key port (negative
control, house rule): the ramp probe showed
reused=Truewith the WRONGflag but the CORRECT halved velocity (4.1e-11 vs a fresh control) — the
old code returned the stale field bit-identically. The key fix makes the
flag honest and removes the wasted work.
the match verdict is allgathered and must be unanimous before it gates any
collective PETSc call (a rank-divergent verdict is a hang, not a wrong
answer).
The matrix probe from the original PR is dropped: its role is subsumed by the
loop's exact-residual verification, which is stronger (it validates the
solution, not two matvecs) and cannot be disabled by its own trigger.
Fault composition (#502)
solve_with_faultdrives the same rotated loop, so the decision is explicitand tested:
normals live in
Qand are geometry, keyed by the fault registration inthe geometry signature. Warm repeat reuses the rotation; a cold re-solve
rides the iteration-0 fast path; both match a fresh-solver control.
cache_allowedexcludes them):the interface tangent is reassembled per iterate at the current slip rates
and the reaction-fed normal stress is Picard-lagged solver state — neither
is keyable registration state.
Verification
All serial runs sequential on the pr418-rework worktree (amr-dev env).
reused=False); error vs fresh control 4.8e-08–4.1e-11 across configs; fail-before validated: naive key reportsreused=TrueQ/Ahat/KSP handles assertedrotation_reused=False), post-deform solve matches fresh control < 1e-6 (test)[(F,F),(T,T),(T,T),(F,T)]×2 ranks; RHS scaling 9.7e-08, ramp scaling 4.8e-08test_fault_repeat_solve_composes_with_workspace_reuse)-m "level_1 and tier_a"(–test_0050)Production evidence (gthyagi)
The original mechanism carries a substantial production validation trail that
this rework inherits and that deserves explicit credit: 310 guarded Zhong A1
steps (
cellsize=1/8, 8 ranks, checkpoints 500→810 in guarded 50-stepsegments,
UW_MEMPROBE=1, 0.25 s RSS sampling, hard memory/time stops thatnever fired), flat warm RSS with periodic collective releases, a continuous
physical trajectory (Vrms 57.83→57.97, Nu_surf 2.694→2.706 over 810 steps),
and a ~1.92× per-step speedup over the pre-fix rate. Since the loop internals
moved under the cache in this rework, gthyagi may wish to re-run an A1
segment on the reworked branch (a 50-step guarded restart from an existing
checkpoint would do) before merge.
Commits
3eba6aa4— Reuse the rotated free-slip solver workspace across solves(Repeated rotated free-slip Stokes solves rebuild solver state and grow RSS until OOM #417) — author: Tyagi, cherry-pick of bec76bb re-derived at the
current seam.
78338211— Close the rotated-workspace blind spot: rampable constantsjoin the key, the verdict goes collective.
f8c2b726— Fault contact composes with the rotated workspace cache:pair blocks cache, interface laws opt out.
Underworld development team with AI support from Claude Code