Skip to content

Rotated free-slip: the custom-FMG velocity sub-KSP must converge, not just apply - #465

Open
lmoresi wants to merge 12 commits into
developmentfrom
bugfix/rotated-bc-preconditioner-anisotropy
Open

Rotated free-slip: the custom-FMG velocity sub-KSP must converge, not just apply#465
lmoresi wants to merge 12 commits into
developmentfrom
bugfix/rotated-bc-preconditioner-anisotropy

Conversation

@lmoresi

@lmoresi lmoresi commented Jul 29, 2026

Copy link
Copy Markdown
Member

Stacked on #458 (feature/rotated-datum-snes). Retarget to development when that merges — do not delete the base branch first, it would close this PR.

Why

Louis asked whether the rotated free-slip BC, which rotates the matrices after they are assembled, does that successfully for the preconditioner matrix as well — the concern being that an error there would be invisible under isotropy (a viscous operator is nearly rotation-invariant) but not under anisotropy, and the free-surface + transverse-isotropy fault model had flagged something.

The audit answer: the preconditioner is fully rotated

An algebraic identity probe reaches into the live PC after PCSetUp and compares every matrix it holds against two independently-formed references: d_rot (the rotated block, built by the opposite route to the solver — block the native Jacobian, then rotate) and d_unrot (the same block un-rotated, which serves as the negative control).

matrix d_rot d_unrot
velocity sub-KSP operator (Amat) 0.0 7.1e-2
velocity sub-PC operator (Pmat) 0.0 7.1e-2
MG level 1 (fine) 0.0 7.1e-2
MG level 0 (Galerkin coarse) 0.0 2.3e-1

Identical for the isotropic control. So:

  • The rotated KSP has no Pmat distinct from Amat — ksp.setOperators(Ahat) is single-argument, so the velocity preconditioner is the rotated operator.
  • The Galerkin coarse operators inherit the rotation correctly even though only the finest prolongation is rotated. This was the one place where "rotated operator, un-rotated preconditioner" could still have been true in effect.
  • The Schur preconditioner is correctly un-rotated: Q is the identity on pressure, and the p-p block of the rotated operator is identically zero, so it must come from the native Pmat. UW3's Pmat differs from its Amat only in that p-p block (the velocity and coupling blocks are registered with identical pointwise functions, petsc_generic_snes_solvers.pyx:7803-7809), so discarding the rest costs nothing.

No un-rotated matrix reaches the preconditioner. The original hypothesis is not the defect.

The defect that is there

The custom-FMG velocity block was driven by preonly — "a full-MG cycle per Schur application, by design". But PCFieldSplit forms S = A11 - A10 A00^-1 A01 and applies A00^-1 through that same velocity KSP, so preonly replaces A00^-1 with a single multigrid cycle and hands the pressure Krylov a different system S~ != S, preconditioned by a 1/mu mass matrix built for S.

Pressure sub-KSP residual history (TI annulus, weak plane reaching the constrained boundary, eta_1/eta_0 = 1e-3):

preonly:  it 0 |r|=1.354e-02 -> it 16 |r|=3.112e-07 -> it 192 |r|=3.111e-07
          reduction 4.4e4, monotone=False -- 184 iterations for nothing
fgmres:   it 0 |r|=1.076e-01 -> it 17 |r|=9.757e-10
          reduction 1.1e8, monotone=True

It stagnates at a floor, on every outer iteration. Not slow convergence, and not a moving operator: applying the Schur operator twice to the same vector is bitwise identical under both settings. S~ is a fixed linear operator, just the wrong one. Why the floor sits at 3.1e-7 is not isolated here (a range/null-space inconsistency between S~ and the constant-pressure null space attached to S is the obvious suspect).

Cost, timed sequentially in-process, three repeats:

velocity sub-KSP outer pres best (s) median (s)
preonly (shipped) 9 200 (cap) 2.767 2.968
fgmres 0.1 x tol 1 17 0.672 0.705

4.1x. The isotropic control moves the same way (5 -> 1 outer), so this is the Schur application rather than the anisotropy — the anisotropy only makes it hurt more. On the test_1018 FMG configuration (SolCx box, 2-level barycentric hierarchy): 6 outer -> 1.

Is this a general problem with our FMG defaults?

No — the rotated custom-FMG branch was the sole exception. Verified at runtime on the native path with the identical hierarchy: velocity sub-KSP is fgmres with PC mg, outer 1 / vel 6 / pres 68. The native path sets that KSP type explicitly and custom_mg._configure_pcmg only replaces the PC; the rotated GAMG fallback was already FGMRES.

The transferable rule, now in docs/developer/subsystems/rotated-freeslip.md: a velocity sub-KSP that is preonly under pc_fieldsplit_schur_fact_type=full gives the pressure Krylov the wrong system to solve. Multigrid is fine as the preconditioner; it cannot be the whole solve when a Schur complement is applied through it.

Changes

  • Wrap the custom-FMG velocity block in FGMRES. max_it 200 matches the GAMG fallback and the native path; rtol 0.1 x tol matches the GAMG fallback (the native path asks 0.033 x tol).
  • Warn when the velocity sub-KSP exhausts its cap — it returns KSP_DIVERGED_ITS, which KSPCheckSolve deliberately does not escalate, so FGMRES can degrade silently where preonly could not fail.
  • Report velocity_pc, schur_pre, velocity_pc_type (PETSc's own view of the sub-PC) and vel_its_last / pres_its_last in _rotated_freeslip_info. The outer count alone hides a degraded inner solve — a full Schur factorisation with a good pressure mass still reports ~1 outer iteration while the inner solve grinds underneath. That is how this went unnoticed. The _last names are deliberate: these are last-application samples, not the summed work axis solver_health uses. Wiring the rotated path into SolverInstrumentation.sub_reports() is the proper fix and is not done here.
  • Guard the test_1018 FMG configuration against a return to preonly, and assert the velocity sub-PC really is multigrid rather than restating our own bookkeeping.
  • Fix a pre-existing test_1064 failure: int(info["ksp_its"]) on a value the unified rotated loop of Rotated free-slip: prescribed-normal datum through the SNES path; one unified Newton loop (#438, #403) #458 made a list, so the 3D spherical partition-independence test errored at np>1 before reaching any of its assertions.
  • Document the interaction in the rotated-freeslip subsystem page.

Also measured, no change made

The constraint-row diagonal (zeroRowsColumns(normal_rows, diag=s)) is a free conditioning parameter — any positive s is exact. Before the fix it was a real lever on the GAMG route (37 velocity iterations at s <= 1e-2 x mean, 44 at the shipped mean, 142 at 1e4 x mean), and the isotropic control had the same shape, so it was general rather than an anisotropy defect. After the fix, on the FMG route it is completely inert: 11 velocity / 17 pressure iterations at every factor from 1e-4 to 1e4. The sensitivity was a symptom of the degraded configuration, so nothing was changed here.

Tests

test_1018 18 passed; test_1064 + test_1066 10 passed at np2 and np4; the TI fault smoke passes (24 nonlinear iterations, datum relL2 4.4e-05).

Its 24 iterations are #457 (the TI consistent tangent is wrong when anisotropy is active), a native-path defect that reproduces with no rotation involved. It is not a preconditioner problem and should not be confounded with one.

Full study, scripts and numbers: ~/+Simulations/rotated_pmat_audit/README.md. Adversarial review in the comment below.

Filed separately while auditing: #463 (TI-VEP .viscosity discards the yield-limited eta_1), #464 (block-constrained Stokes + rotated free-slip is unguarded).

Underworld development team with AI support from Claude Code

lmoresi added 10 commits July 27, 2026 22:58
…rotated path (#438)

- v_n = u_n now carried through the manual Newton/Picard loop: accepted
  iterates are kept feasible (affine snap in _impose_normal_constraint),
  so increments stay homogeneous (n.delta = 0) and the datum never touches
  the tangent, the increment RHS, the custom-FMG prolongation or the
  nullspace handling.
- A COLD start imposes the datum through the FIRST increment's affine lift
  (zeroRowsColumns x/b at the rest-state tangent, exactly the linear
  one-shot's treatment): snapping the zero state onto the datum manufactures
  a boundary-strip strain state whose shear-thinning tangent cannot descend
  (measured: first Newton step 4 orders too large, no line-searched alpha
  improves). Warm starts take the exact affine snap directly.
- Collective datum-activity flag in BOTH solve paths: branching on the
  rank-local datum_map desyncs the ranks' collective sequences at np>1
  (the two zeroRowsColumns variants scatter differently; the lift skips
  the line-search collectives). The linear path carried the same latent
  pattern, tolerated only because annulus partitions happen to give every
  rank a piece of the outer arc.
- Regression: nonlinear power-law annulus with a u.n = cos(theta) datum
  imposed to machine precision through genuine Newton iteration
  (test_1018).

Tests: test_1018 serial 18/18; test_1066 np2; test_1070 serial + np2 (7);
test_1071 serial.

Underworld development team with AI support from Claude Code
…t bare MPI

The rank-consistent datum-activity flag now rides the x-hat lift vector's
collective norm (PETSc-before-MPI house rule): x-hat carries the prescribed
rotated-frame datum for the zeroRowsColumns lift anyway, so one object does
both jobs and the hand-rolled ownership loop collapses into _set_rows_local.
Removes the mpi4py allreduce introduced in the previous commit.

The deeper cure is a surface submesh (the 1D-manifold gap, #202 lineage):
a boundary-field datum would make this flag a field norm and retire the
hand-rolled surface bookkeeping in this layer - workstream C.

Underworld development team with AI support from Claude Code
…d solves; datum via conds (#438, #403)

One constraint, one path: the linear one-shot is deleted and every rotated
solve runs the manual Newton/Picard loop. A linear model converges after its
first increment (the cold-start affine lift IS the linear solve), so the
up-front _residual_is_nonlinear probe leaves the dispatch entirely - two
Jacobian assemblies saved per linear solve (test_1018: 60s -> 55s). The probe
survives only as a lazy guard in the picard + pure-Newton corner, where a
linear model now ignores the meaningless warmup instead of raising.

- solver._rotated_use_lu becomes an increment-solver choice (direct LU per
  Newton increment, serial PC-free diagnostic; naive pressure pin extracted
  to _naive_pressure_pin with its TODO(BUG)). Verified against the iterative
  path to 6e-10.
- One result-dict shape: reaction = F(u) always (equals A.u - b exactly for
  linear residuals); the linear-only A/b keys and their consumer fallback in
  boundary_normal_traction retire. ksp_its is always the per-increment list
  (test_1018 3D assertion moved to max-per-increment).
- add_rotated_freeslip_bc(conds, ...) accepts the non-zero wall-normal datum
  (number or scalar expression, value-first; #403 item 2) and stores it in
  _rotated_freeslip_datum; vector values rejected. Tests and FreeSurface now
  use the public argument instead of poking the attribute.
- FreeSurface: the penalty-fallback NotImplementedError catch in
  _solve_consistent is gone - consistent_constraint="strong" now runs through
  the SNES rotated path for nonlinear rheologies.

Linear solutions change at solver-tolerance level only (agreed relaxation of
bit-preservation); an occasional cheap polish increment can follow the lift
when the first residual check sits at the tolerance boundary (free-surface
plume wall-time unchanged).

Tests: test_1018 serial 18/18; test_1066 np2; test_1070 serial + np2 (7);
test_1071 serial; LU-vs-iterative and lazy-probe smokes.

Underworld development team with AI support from Claude Code
…ll modes

Two defects surfaced by the TI fault smoke (#438 acceptance):

1. The step-norm exit reported converged=True on tiny increments while the
   residual was still O(1) (a stiff power-law tangent at the regularisation
   floor produces tiny steps far from the solution). The exit is now VERIFIED
   against the problem's intrinsic scale ||F(0)||: a warm start at the
   solution passes; a stagnating crawl goes back through the line search and
   either progresses (bounded by max_it) or ends on the stall exit honestly.

2. _mode_satisfies_constraints admitted rigid-rotation modes that are PINNED
   by an essential BC on another boundary (it only checked the rotated
   constraint rows). The admitted mode was projected out of every increment
   RHS, leaving an irreducible residual component: Newton converged
   superlinearly to rel ~2e-5 and floored there (measured on the
   essential-inner + rotated-outer power-law annulus). Modes are now also
   verified as null vectors of the ASSEMBLED operator (||J.m|| against the
   velocity diagonal scale), which catches any pinning without enumerating BC
   types; the null space is built after the first Jacobian assembly to make
   that possible. Same fix protects the post-solve rotation-gauge removal.
   With the fix the same solve converges to rel 2e-13 in 11 iterations and
   correctly leaves the gauge in place.

TI fault-bearing smoke (power-law background, weak-plane band, cos(theta)
datum) now passes: 24 its to rel 6e-8, datum at the quadrature floor,
sigma_nn recovery clean.

Tests: test_1018 serial 18/18 (incl. gauge-removal cases), test_1066 np2
(reference unchanged - the linear-case imprint of the spurious projection is
below the 1e-6 test tolerance), test_1070 serial (7) + test_1071.

Underworld development team with AI support from Claude Code
…scale convergence for warm starts

- FreeSurface._solve_consistent now warm-starts from the free solve's fields:
  the consistent solution is the free solution with a small material-boundary
  datum correction, and the free solve has already converged this step.
  Starting there keeps a power-law tangent at physical strain rates - a cold
  start puts it at the regularisation floor, where the Newton line search
  stalls at O(0.1) relative residual (measured, acceptance attempt 2). With
  the warm start the first acceptance step converges in 3 iterations.

- The rotated loop's convergence reference is now max(r0, ||F(0)||): rtol
  relative to a warm start's own (small) initial residual demands ever-more
  absolute accuracy the better the guess (the warm-start rtol trap). The
  rest-state residual is the intrinsic forcing scale; cold starts unchanged
  (their r0 IS that scale). The step-norm verification and the non-convergence
  warning read the same reference.

Acceptance (#438) evidence in
~/+Simulations/FreeSurface/annulus_fs_convection/b_snes_datum_acceptance/:
power-law annulus FS convection with consistent_constraint="strong" through
the SNES rotated path holds the material boundary at the strong-datum level
(err 5.3e-3 vs penalty 3.7e-2; net flux 1.4e-4 vs 2.0e-3) at matched steps;
TI fault-bearing smoke passes (24 its to rel 6e-8). Open finding recorded
there: deformed-ring datum compatibility floors the interior residual at rel
~2e-3 (honestly reported unconverged; suspected arc-length-vs-FE-quadrature
demean gap - the principled fix is a trace-mass-weighted demean).

Tests: test_1018 18/18 serial; test_1070 serial+np2 (7); test_1071;
test_1066 np2.

Underworld development team with AI support from Claude Code
…ith the PETSc Jacobian checker

Regroup note (#438 B-session): TI power-law under consistent_jacobian=True
shows an early trajectory identical to the frozen tangent and Picard-paced
convergence (24 its vs ~11 isotropic) - consistent with the dC/d(eps_II)
director-orientation terms being dropped or mis-oriented in the JIT-lowered
tangent, while the isotropic terms survive (isotropic Newton is superlinear
through the same rotated machinery, which also argues the rotation algebra
itself is transparent). Discriminating harness (native -snes_test_jacobian
vs rotated directional-derivative probe, with an isotropic control) is
staged in the #438 acceptance run directory; not yet run - joint item.

Underworld development team with AI support from Claude Code
#457

The PETSc Jacobian checker (bounded-curvature protocol; at 1e-12
regularisation the FD reference is invalid for power-law and every model
reads the same large mismatch) convicts the TI anisotropy tangent on the
NATIVE path: ||J-Jfd||/||J|| ~ 2e-3-5e-3 in developed flow vs an FD-limited
4e-6 isotropic control. The rotated machinery is exonerated. Evidence and
harness recorded in #457.

Underworld development team with AI support from Claude Code
…inear datum test

- docs/developer/subsystems/rotated-freeslip.md: the unified rotated path -
  constraint semantics, datum-through-Newton design (feasible iterates,
  cold-start lift), convergence reference scale, operator-verified null
  modes, parallel rules, reaction/sigma_nn hand-off. Linked from the
  developer index.
- Changelog entry for the #438/#403 work at the conceptual level.
- tests/parallel/test_1066: nonlinear (power-law) rotated datum at np>=2 -
  guards the collective-sequence class through the Newton loop (lift,
  line-search residuals, mode verification). Passes np2 and np4.

Underworld development team with AI support from Claude Code
… refusal

Conflict in the rotated free-slip dispatch only: development added the armed
wall-clock-guard refusal (#442) around the old two-path dispatch; this branch
had unified the dispatch into one Newton loop. Resolution keeps BOTH: the
guard re-check (an armed guard would be silently inert on the rotated path,
which runs its own Krylov loop outside self.snes) followed by the single
unified call. Development's _record_convergence_status hook after the rotated
report (#441 warm-start flag) auto-merged and stands.

Underworld development team with AI support from Claude Code
…ust apply

The rotated free-slip path drove its custom-FMG velocity block with `preonly`
("a full-MG cycle per Schur application, by design"). PCFieldSplit forms the
Schur complement S = A11 - A10 A00^-1 A01 and applies A00^-1 through that same
velocity KSP, so `preonly` replaces A00^-1 with a single multigrid cycle and
hands the pressure Krylov a different system S~ != S, preconditioned by a 1/mu
mass matrix built for S.

Measured (annulus, weak plane reaching the constrained boundary,
eta_1/eta_0 = 1e-3, transversely isotropic): under `preonly` the pressure
residual falls 4.4e4 in ~16 iterations then STAGNATES at a floor ~3.1e-7 and
burns the remaining 184 iterations of its cap for nothing, every outer
iteration -- 9 outer iterations, 2.77 s. Under FGMRES it converges 1.1e8
monotonically in 17 pressure iterations -- 1 outer, 0.67 s (4.1x). The isotropic
control moves the same way (5 -> 1), so this is the Schur application and not the
anisotropy. SolCx box + 2-level barycentric hierarchy (the test_1018 FMG
configuration): 6 outer -> 1.

The Schur application is bitwise reproducible under both settings (measured), so
the floor is NOT "the operator changes between applications" -- S~ is a fixed
linear operator, just the wrong one. The precise origin of the floor is not
isolated.

Wrap the FMG in FGMRES: max_it 200 matches the GAMG fallback and the native path,
rtol 0.1 x tol matches the GAMG fallback (the native path asks 0.033 x tol).

Found while auditing whether the rotated path preconditions the ROTATED operator.
It does: an algebraic identity probe against independently formed references
(block the native Jacobian, then rotate -- the opposite order to the solver) shows
the velocity sub-KSP operator, the velocity sub-PC operator and every Galerkin
multigrid level operator match the rotated reference exactly, while differing
from the un-rotated reference by 7e-2 to 2.3e-1. The Schur preconditioner
correctly comes un-rotated from the native Pmat: Q is the identity on pressure,
and the p-p block of the rotated operator is identically zero. No un-rotated
matrix reaches the preconditioner.

Also:
- warn when the velocity sub-KSP exhausts its cap. It returns KSP_DIVERGED_ITS,
  which KSPCheckSolve deliberately does not escalate, so FGMRES can degrade
  silently where `preonly` could not fail.
- report `velocity_pc`, `schur_pre`, `velocity_pc_type` (PETSc's own view of the
  sub-PC) and `vel_its_last` / `pres_its_last` in `_rotated_freeslip_info`. The
  outer count alone hides a degraded inner solve, which is how this went
  unnoticed. The `_last` names are deliberate: these are last-application
  samples, not the summed work axis solver_health uses -- wiring the rotated path
  into SolverInstrumentation.sub_reports() is the proper fix and is not done here.
- guard the FMG configuration in test_1018 against a return to `preonly`, and
  assert the velocity sub-PC really is multigrid rather than restating our own
  bookkeeping.
- fix a pre-existing failure in test_1064: `int(info["ksp_its"])` on a value the
  unified rotated loop made a list, so the 3D spherical partition-independence
  test errored at np>1 before reaching its assertions.

Tests: test_1018 18 passed; test_1064 + test_1066 10 passed at np2 and np4;
TI fault smoke passes (24 nonlinear its, datum relL2 4.4e-05).

Underworld development team with AI support from Claude Code
@lmoresi
lmoresi force-pushed the bugfix/rotated-bc-preconditioner-anisotropy branch from d9f7651 to 4a557b8 Compare July 29, 2026 03:08
@lmoresi

lmoresi commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Adversarial review

Findings against the first push (d9f76516), and what we did about each. Amended in 4a557b81.

1. The stated mechanism was inverted — accepted, corrected

We wrote that preonly makes the Schur operator "not fixed from one application to the next", so the pressure Krylov "cannot converge". That is wrong. The FMG cycle is richardson/sor smoothing over a redundant-LU coarse solve — a fixed linear operator — so S̃ = A₁₁ − A₁₀ M A₀₁ is itself a fixed linear operator.

Measured directly: applying the Schur operator twice to the same vector gives ||Sx − Sx||/||Sx|| = 0.000e+00 under both preonly and FGMRES.

What the residual history actually shows (pressure sub-KSP monitor, TI annulus):

preonly:  it 0 |r|=1.354e-02 -> it 16 |r|=3.112e-07 -> it 192 |r|=3.111e-07
          r0->rN reduction 4.4e4, monotone=False, 184 iterations for nothing
fgmres:   it 0 |r|=1.076e-01 -> it 17 |r|=9.757e-10
          reduction 1.1e8, monotone=True

So it is stagnation at a floor, not slow convergence and not non-convergence against a moving operator. preonly hands the pressure Krylov a different system S̃ ≠ S, preconditioned by a 1/μ mass built for S, and it stalls above its tolerance. We have not isolated why the floor sits at 3.1e-7 (a range/null-space inconsistency between and the constant-pressure null space attached to S is the obvious suspect). The commit message, code comment and docs now say exactly this and no more.

2. FGMRES can now fail silently where preonly could not — accepted, fixed

KSPCheckSolve deliberately exempts KSP_DIVERGED_ITS, so a velocity FGMRES that exhausts its cap does not escalate. The rotated path now calls _warn_if_ksp_diverged on the velocity sub-KSP.

3. The parity claim was numerically false — accepted, fixed

max_it was 100 against 200 in both the GAMG fallback and the native path; rtol is 0.1×tol against the native path's 0.033×tol (petsc_generic_snes_solvers.pyx:6311). max_it is now 200. rtol stays at 0.1×tol (matching the GAMG fallback) because the FMG cycle reaches it in ~11 iterations; the comment no longer claims sameness with both.

4. velocity_pc="direct-LU" mislabels a converged warm start — accepted, fixed

The defaults were keyed on ctx is None, but the iterative path also reaches the return with ctx unset when a warm start is already converged at increment 0. Now keyed on use_lu.

5. vel_its / pres_its are last-application samples, not work — accepted, renamed

KSPGetIterationNumber reports the most recent solve, and the velocity KSP is applied once per Schur MatMult. Reporting these as its contradicts the convention in solver_health.py where its is summed. Renamed vel_its_last / pres_its_last and documented as samples. Wiring the rotated path into SolverInstrumentation.sub_reports() is the proper fix and is not done here.

6. The new test assertions were tautological — accepted, fixed

velocity_pc == "custom-FMG" and schur_pre == "1/mu-mass" restate our own bookkeeping (custom_Pl is not None, Mp is not None) and cannot detect a failed MG install. We now also report and assert velocity_pc_type, which is vel_ksp.getPC().getType() — PETSc's own view.

7. Iteration counts do not establish cost — accepted, measured

9 outer x 200 pressure iterations at one MG cycle each, against 1 outer x 17 at ~11 cycles each, could in principle go either way. Timed sequentially in-process, three repeats, best/median:

velocity sub-KSP outer best (s) median (s)
preonly 9 2.767 2.968
fgmres 0.1×tol 1 0.672 0.705

4.1x. The iteration-count story holds.

8. max(info["ksp_its"]) on an empty list — accepted as latent, not fixed

A zero-increment exit (warm start already converged) leaves ksp_its == [] and max raises. Not reachable from these cold-start tests, and the same pattern pre-dates this PR at test_1018:101. Left alone rather than widened here.

9. max(...) vs sum(...) in test_1064 — accepted, left as is

_capture_rotated_report sums; our one-line fix uses max, matching the pre-existing test_1018:101. It weakens its <= 25 from a total to a per-increment bound, with no practical difference for this ksponly single-increment case. Consistency across the two test files was the deciding factor; worth revisiting if ksp_its semantics are ever settled properly.

10. GOLDEN_ANNULUS_FMG sits on the changed path and was not re-recorded — checked

Correct that it is the only golden on this path. It passes unchanged at np2 and np4, which is the check; we did not re-record it.

11. Options-database growth — real, pre-existing, not ours to fix here

_configure_pcmg writes ~7 keys under the unique-per-solve rotfsN_fieldsplit_vel_ prefix and only mg_coarse_pc_type is removed, so the database grows under time-stepping. The "stays clean (and bounded under time-stepping)" comment in the block we touched is therefore false as written. Pre-existing and outside this change; flagging rather than fixing.

Not accepted, for the record: nothing. Every finding above was either a real defect or a real overclaim.

Underworld development team with AI support from Claude Code

@lmoresi
lmoresi marked this pull request as ready for review July 29, 2026 03:10
…ent the Krylov axes

The rotated path was looser than the native Stokes path on both fieldsplit
sub-solves, with no recorded reason:

                  rotated (was)     native
  velocity rtol   0.1   x tol       0.033 x tol
  pressure rtol   1.0   x tol       0.1   x tol

House policy is that defaults err on the side of robust generality, with callers
loosening guardrails at their own risk. Matching native costs ~17% wall clock with
IDENTICAL outer iteration counts, measured on both velocity-block routes (custom
FMG and the GAMG fallback) and both rheologies (isotropic and transversely
isotropic). It also drops the TI fault smoke from 24 to 15 nonlinear iterations —
tighter inner solves feed a cleaner Newton step.

A too-loose inner solve does not degrade gracefully: it walks back toward the
`preonly` failure this branch already fixed (velocity rtol 1e-1 -> 6 outer
iterations, 1e-2 -> 4, 0.033 x tol -> 1). Cheaper remains available to anyone who
measures their own configuration; it is not the default.

Also documents the three axes that keep getting argued as one, in the solvers
subsystem page (which listed "preconditioner selection missing" as a known gap):

1. Flexible vs non-flexible Krylov. The question is whether the PRECONDITIONER is
   stationary, not whether the operator is symmetric — the velocity block is SPD,
   so cg is admissible on symmetry grounds and still fails, because GAMG with
   mg_levels_ksp_converged_maxits is a non-linear application. Settled by #147.
2. `preonly` vs an iterative wrapper. Not a Krylov-taste question but a positional
   one: a preconditioner application may be `preonly`, an operator inverse
   underneath a Schur complement may not, because it defines the system the
   pressure Krylov solves rather than merely conditioning it.
3. Inner tolerance. The genuine judgment call, decided here by the guardrail
   policy.

Plus how to detect a degraded sub-solve: the outer count hides it, an exhausted cap
returns KSP_DIVERGED_ITS which KSPCheckSolve does not escalate, and
KSPGetIterationNumber on a sub-KSP is a last-application sample rather than work.

Tests: test_1018 18 passed; test_1064 + test_1066 10 passed at np2 and np4; TI
fault smoke passes (15 nonlinear its, datum relL2 4.4e-05).

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Follow-up: sub-solve tolerances brought to native parity, and the Krylov axes written down

Louis's steer: defaults should err on the side of robust generality, with callers removing guardrails at their own risk. Applying that here found two more places where the rotated path was the looser of two paths solving the same problem, with no recorded reason:

sub-solve rotated (was) native
velocity rtol 0.1 × tol 0.033 × tol
pressure rtol 1.0 × tol 0.1 × tol

Measured cost of matching native, timed sequentially in-process, three repeats:

configuration outer best (s) TI/FMG TI/GAMG iso/FMG iso/GAMG
rotated (was) vel .1 pres 1 1 0.577 0.258 0.999 0.732
native parity vel .033 pres .1 1 0.677 0.303 1.300 0.871

~17–30%, with identical outer iteration counts in every case. Adopted, because a too-loose inner solve does not degrade gracefully — it walks back toward the preonly failure this branch already fixes (velocity rtol 1e-1 → 6 outer, 1e-2 → 4, 0.033×tol → 1), and it fails by silent stagnation rather than loudly.

Unexpected bonus: the TI fault smoke dropped from 24 → 15 nonlinear iterations. Tighter inner solves feed a cleaner Newton step. Worth noting against #457 — some of that 24 was loose inner solves rather than the tangent alone, though the remaining Picard-pace behaviour is still consistent with the TI tangent defect.

Cheaper remains available to anyone who measures their own configuration; it is no longer the default.

Documentation

docs/developer/subsystems/solvers.md listed "preconditioner selection missing" as a known gap. Added a section there laying out the three axes that keep getting argued as one:

  1. Flexible vs non-flexible (fgmres vs gmres/cg/fcg) — the question is whether the preconditioner is stationary, not whether the operator is symmetric. The velocity block is SPD so cg is admissible on symmetry grounds and still fails, because GAMG with mg_levels_ksp_converged_maxits is a non-linear application. Settled by Kramer free-slip Nitsche fieldsplit solve needs flexible subsolves on Gadi #147 at np=144 on Gadi.
  2. preonly vs an iterative wrapper — positional, not a matter of taste. A preconditioner application may be preonly; an operator inverse underneath a Schur complement may not, because it defines the system the pressure Krylov solves rather than merely conditioning it.
  3. Inner tolerance — the genuine judgment call, decided by the guardrail policy above.

Plus how to detect a degraded sub-solve, since all three failures are quiet: the outer count hides them, an exhausted cap returns KSP_DIVERGED_ITS which KSPCheckSolve deliberately does not escalate, and KSPGetIterationNumber on a sub-KSP is a last-application sample rather than work.

Tests: test_1018 18 passed; test_1064 + test_1066 10 passed at np2 and np4.

Underworld development team with AI support from Claude Code

…ov are one design

Provenance and framing from Louis. The Stokes configuration descends from the
Citcom solver of Moresi & Solomatov (1995), whose central choice is that the inner
solves are DELIBERATELY inexact. Two consequences follow and must hold together:

1. Inexact inner solves perturb the search directions, so the outer/Schur Krylov
   must be FLEXIBLE (fgmres or similar) — a non-flexible method's residual
   recurrence is invalidated by a search direction that drifts.
2. Inexact is not unbounded: an inner solve must still converge WELL BELOW the
   tolerance demanded of the outer solve. The 0.033 and 0.1 factors are that
   margin — a safety margin, not a tuned constant.

So what looked like two independent axes (flexible-or-not, how-tight) is one
decision: flexibility buys tolerance of inexactness, the margin bounds how inexact.
Arguing them separately is why the question never settled. `preonly` is then the
degenerate case — no tolerance at all, so no margin for the flexible outer Krylov
to work with, which is exactly the stagnation floor measured on this branch.

This also sharpens the previous commit's justification. The rotated path ran its
outer KSP at rtol = tol and its pressure sub-solve ALSO at rtol = tol: the inner
solve permitted to be no better than the answer it feeds. That is the invariant
broken outright, not a tuning inconsistency with the native path.

Recorded in the solvers subsystem page and at the point in rotated_bc.py where the
margin is set. The size of the factors is noted as inherited convention with no
derivation recorded beyond "well below the outer tolerance".

Tests: test_1018 18 passed.

Underworld development team with AI support from Claude Code
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.

1 participant