Skip to content

Apply the integrator tolerance to the Krylov linear solve - #4192

Merged
ChrisRackauckas merged 2 commits into
SciML:masterfrom
ChrisRackauckas-Claude:krylov-linear-tolerance
Aug 9, 2026
Merged

Apply the integrator tolerance to the Krylov linear solve#4192
ChrisRackauckas merged 2 commits into
SciML:masterfrom
ChrisRackauckas-Claude:krylov-linear-tolerance

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented Aug 9, 2026

Copy link
Copy Markdown
Member

Closes #4161.

Please ignore until reviewed by @ChrisRackauckas.

What changed and why

dolinsolve forwarded the integrator's tolerance as solve!(linsolve; reltol = integrator.opts.reltol), but SciMLBase.solve!(::LinearCache, ::KrylovJL; kwargs...) takes its tolerances off the cache and ignores the keywords. The argument was dead, so every Krylov solve on the Newton path ran at LinearSolve's sqrt(eps) = 1.49e-8 default regardless of what the user asked for. Same pattern at the one other site, firk_perform_step.jl (GaussLegendre's stage solve).

The fix applies the tolerance through LinearSolve.update_tolerances!, which is the entry point that takes effect, and stops passing the dead keyword. It is factored into set_linear_reltol! so the two call sites cannot drift.

I made the tolerance live rather than deleting the argument. Two reasons, both checked:

  1. It is what the rest of the repo already does. Rosenbrock (rosenbrock_caches.jl:257) and RadauIIA3 (firk_caches.jl:116) pass abstol = reltol, reltol = reltol to LinearSolve.init, so their Krylov solves have always run at the integrator tolerance. The Newton path is the outlier: build_nlsolver never receives reltol, so it cannot set it at init, and dolinsolve was the only place left that could. This is not a new regime, it is the shipped behaviour of the most-used stiff family.
  2. Measured below: fewer RHS evaluations, same answers.

set_linear_reltol! skips solvers that need a concrete A (a factorization has no tolerance and update_tolerances! throws for one) and skips non-scalar reltol, since a LinearCache holds one scalar tolerance. Rosenbrock and RadauIIA3 caches are already at the right value, so the guard makes them a no-op and their behaviour is bit-identical.

Failing before / passing after

All evidence below was re-run on the rebased commit (4f6dedcf5, on top of master b9889d3ae), not carried over from the pre-rebase branch.

The new test asserts the integrator's tolerance reaches the linear cache. To show it discriminates, the two behavioural call sites were reverted to solve!(linsolve; reltol) / LinearSolve.solve!(linsolve; reltol = integrator.opts.reltol) — i.e. exactly master's behaviour — with set_linear_reltol! and the committed test file left untouched:

$ julia +1.12 --project=<env> lib/OrdinaryDiffEqDifferentiation/test/krylov_linear_tolerance_tests.jl

integrator tolerance reaches the Krylov cache: Test Failed at krylov_linear_tolerance_tests.jl:29
  Expression: (newton_cache(integrator)).reltol == tol
   Evaluated: 1.4901161193847656e-8 == 1.0e-6

integrator tolerance reaches the Krylov cache: Test Failed at krylov_linear_tolerance_tests.jl:29
  Expression: (newton_cache(integrator)).reltol == tol
   Evaluated: 1.4901161193847656e-8 == 1.0e-10

integrator tolerance reaches the Krylov cache: Test Failed at krylov_linear_tolerance_tests.jl:36
  Expression: (newton_cache(integrator)).reltol == 1.0e-7
   Evaluated: 1.4901161193847656e-8 == 1.0e-7

Test Summary:                                 | Pass  Fail  Total   Time
integrator tolerance reaches the Krylov cache |    1     3      4  24.7s
ERROR: LoadError: Some tests did not pass: 1 passed, 3 failed, 0 errored, 0 broken.

The one pass is the Rodas5P case, which confirms Rosenbrock was already correct. With the call sites restored, same command, same environment:

Test Summary:                                 | Pass  Total   Time
integrator tolerance reaches the Krylov cache |    4      4  24.1s
Test Summary:                                                 | Pass  Total  Time
set_linear_reltol! only touches solvers that have a tolerance |    3      3  0.2s

The original report also reproduced directly on the pre-rebase base: FBDF(linsolve = KrylovJL_GMRES()) on a 2D Brusselator (N=32, 2048 ODEs) at reltol = abstol = 1e-6 left the cache at 1.4901161193847656e-8 before step 1 and after 30 steps; with the change it reads 1.0e-6 after the first solve. That reproduction was not re-run after the rebase — the testset above covers the same assertion.

Effect on results

Measured against the pre-rebase base (9d23061e) and not re-run after the rebase over #4193; #4193 changes how nf is counted on this path, so the absolute nf numbers below no longer match what master reports today. The relative comparison and the error/step columns are unaffected by that. Three problems (2D Brusselator N=32/2048 ODEs, 1D Allen-Cahn 256, 2D nonlinear heat 576), four Newton-based integrators, KrylovJL_GMRES() throughout, error against an FBDF reference at reltol = abstol = 1e-11, BLAS.set_num_threads(1), Julia 1.12.6, LinearSolve 5.6.0.

problem alg integrator tol nf master nf PR Δnf rel. err master rel. err PR steps master/PR
bruss2d FBDF 1.0e-6 47401 41793 -11.8% 0.000308 0.00031 552/553
bruss2d FBDF 1.0e-8 74964 78420 +4.6% 1.23e-5 1.12e-5 1085/1088
bruss2d QNDF 1.0e-6 38721 34317 -11.4% 0.000617 0.000617 408/408
bruss2d QNDF 1.0e-8 64109 67789 +5.7% 1.43e-5 1.38e-5 815/821
bruss2d KenCarp4 1.0e-6 89300 78756 -11.8% 0.0162 0.0162 63/63
bruss2d KenCarp4 1.0e-8 205935 209288 +1.6% 0.000268 0.000268 135/135
bruss2d TRBDF2 1.0e-6 95384 82815 -13.2% 0.0129 0.0129 164/164
bruss2d TRBDF2 1.0e-8 240592 244548 +1.6% 0.00131 0.00131 485/485
allencahn1d FBDF 1.0e-6 141 109 -22.7% 2.25e-7 2.25e-7 21/21
allencahn1d FBDF 1.0e-8 301 304 +1.0% 3.28e-9 3.28e-9 34/34
allencahn1d QNDF 1.0e-6 124 102 -17.7% 1.73e-6 1.73e-6 19/19
allencahn1d QNDF 1.0e-8 167 172 +3.0% 2.56e-8 2.56e-8 31/31
allencahn1d KenCarp4 1.0e-6 248 219 -11.7% 6.38e-8 6.38e-8 4/4
allencahn1d KenCarp4 1.0e-8 501 508 +1.4% 1.15e-8 1.15e-8 7/7
allencahn1d TRBDF2 1.0e-6 330 289 -12.4% 2.82e-5 2.82e-5 6/6
allencahn1d TRBDF2 1.0e-8 683 690 +1.0% 3.22e-6 3.22e-6 15/15
heatnl2d FBDF 1.0e-6 519 404 -22.2% 1.38e-6 1.38e-6 53/53
heatnl2d FBDF 1.0e-8 883 902 +2.2% 1.06e-8 1.06e-8 108/108
heatnl2d QNDF 1.0e-6 464 357 -23.1% 2.85e-6 2.85e-6 50/50
heatnl2d QNDF 1.0e-8 644 665 +3.3% 4.32e-8 4.32e-8 88/88
heatnl2d KenCarp4 1.0e-6 1267 978 -22.8% 3.11e-5 3.11e-5 6/6
heatnl2d KenCarp4 1.0e-8 2573 2644 +2.8% 7.65e-6 7.65e-6 11/11
heatnl2d TRBDF2 1.0e-6 1509 1171 -22.4% 0.00325 0.00325 8/8
heatnl2d TRBDF2 1.0e-8 2482 2529 +1.9% 0.000293 0.000293 21/21

bruss2d wall time (2048 ODEs, the only size where timing is above noise):

alg tol master PR ratio
FBDF 1.0e-6 5.256s 4.217s 0.80×
FBDF 1.0e-8 7.316s 7.993s 1.09×
QNDF 1.0e-6 3.602s 3.119s 0.87×
QNDF 1.0e-8 6.741s 6.805s 1.01×
KenCarp4 1.0e-6 8.973s 8.345s 0.93×
KenCarp4 1.0e-8 22.162s 22.267s 1.00×
TRBDF2 1.0e-6 9.373s 7.618s 0.81×
TRBDF2 1.0e-8 24.831s 25.491s 1.03×

At reltol = 1e-6 the linear solve loosens from 1.49e-8 and costs 11–23% fewer RHS evaluations, with relative error unchanged to three significant figures and step counts unchanged in 22 of 24 cases (Brusselator FBDF 552→553 accepted steps, QNDF 815→821 at 1e-8). At reltol = 1e-8 the integrator tolerance is tighter than sqrt(eps), so the linear solve correctly tightens and costs 1–6% more. No configuration failed and no relative error got worse.

Other silently-dropped-kwarg sites

solve!(linsolve; ...) appears at exactly two places in the repo and both are in this diff. No caller of dolinsolve ever passes an explicit reltol — every one takes the integrator.opts.reltol default — so the keyword's only job was the one it was not doing.

Worth raising separately, not changed here: KrylovJL's solve! swallowing unrecognised keywords is what let this sit since the LinearSolve v2 migration in 2023. Erroring on them instead would have surfaced it at the first call. That is a LinearSolve change.

Also for LinearSolve, and again not changed here: LinearSolve.update_tolerances! is documented in the LinearSolve manual (docs/src/advanced/algorithm_interface.md) but not declared public, so ExplicitImports flags the qualified access. I added it to this sublib's existing ignore list next to needs_concrete_A, which is in the same situation; the real fix is a public declaration upstream. Separately, SimpleGMRES snapshots its tolerances into cacheval and defines no update_tolerances_internal!, so it hits LinearSolve's AbstractKrylovSubspaceMethod fallback and emits one @warn (maxlog = 1) rather than an error; its cacheval is rebuilt whenever A changes, so the new tolerance does take effect on the next W refresh, and the warning is a false alarm from the conservative default. A one-line update_tolerances_internal!(cache, ::SimpleGMRES, …) upstream would remove it.

Rebase over #4193

#4193 (Krylov nf accounting) merged first and touched the same dolinsolve. This branch is rebased onto master b9889d3ae. Four conflicts, all "both sides added at the same place":

The dolinsolve body merged without a conflict, which is where a silent bug could have hidden, so it was checked rather than trusted. The merged order is set_linear_reltol!(linsolve, reltol)solve!(linsolve)drain_jvp_count!(integrator, _alg, linsolve.A). That is correct for both changes and needed no rework: the tolerance must be set before the solve, and the JVP tally must be drained after it. The two do not interact — LinearSolve.update_tolerances! writes cache.abstol/cache.reltol and then calls update_tolerances_internal!, which for KrylovJL is = nothing (LinearSolve src/iterative_wrappers.jl:573); it does not touch cacheval, does not set isfresh, and applies the operator zero times, so it cannot add to or reset JVPCache.njvps. The nf accounting test from #4193 was re-run on the rebased commit and passes (below).

lib/OrdinaryDiffEqDifferentiation is bumped 3.8.0 → 3.9.0: #4193 took 3.8.0 for drain_jvp_count!/jvp_counter, and this PR adds a further public name (set_linear_reltol!), so it is another minor bump rather than a reuse of the same version. lib/OrdinaryDiffEqFIRK goes 2.6.2 → 2.6.3 with its OrdinaryDiffEqDifferentiation compat raised "3.8""3.9", since it now imports set_linear_reltol!.

Verification

All of the following was run on the rebased commit 4f6dedcf5. Julia 1.12.6.

GROUP=Core julia --project -e 'using Pkg; Pkg.test()' in lib/OrdinaryDiffEqDifferentiation — note Krylov nf accounting (#4193's test) is included and green, which is the specific regression risk of this merge:

DAE jacobian2W sparse |    4      4  6.5s
prepare_user_sparsity mass matrix |    9      9  2.8s
ScalarOperator mass matrix |    9      9  6m45.1s
nzval helpers |   26     26  25.1s
prepare_sparse_jac! |    9      9  0.5s
OOP J_t Tracking |    5      5  16.5s
Differentiation Trait Tests |    5      5  9.8s
Autodiff Error Tests |    4      4  3m22.2s
No Jac Tests  |    7      7  48.8s
Stale W Linear Operator Tests |   12     12  6m05.1s
Krylov warm_start default |    7      7  0.1s
Krylov nf accounting |   21     21  47.9s
Krylov linear tolerance |    7      7  1m07.3s
     Testing OrdinaryDiffEqDifferentiation tests passed

GROUP=QA in lib/OrdinaryDiffEqDifferentiation (JET + Aqua; this is where ExplicitImports runs and so where the new LinearSolve.update_tolerances! ignore entry is exercised):

JET Tests     |    1      1  35.9s
Aqua          |   18     18  40.6s
     Testing OrdinaryDiffEqDifferentiation tests passed

GROUP=Core in lib/OrdinaryDiffEqFIRK (the other call site this touches):

FIRK Tests    |  107    107  4m48.2s
FIRK Krylov Tests |   93     93  6m10.7s
     Testing OrdinaryDiffEqFIRK tests passed

GROUP=QA in lib/OrdinaryDiffEqFIRK — this failed on the pre-rebase branch with isempty([:SciMLBase]), and it failed the same way on master. SciMLTesting 2.6.3 is now registered and the group passes:

Allocation Tests |      5      5  2m54.6s   (5 Broken, pre-existing)
JET Tests     |    1      1  49.4s
Aqua          |   20     20  1m23.2s
     Testing OrdinaryDiffEqFIRK tests passed

Runic (Runic.main(["--check","--diff", <changed .jl files>])) exits 0, and typos over the changed files is clean.

activate_group_env rewrites lib/OrdinaryDiffEqDifferentiation/test/qa/Project.toml during a QA run; that churn was reverted and is not in this diff.

Not verified

  • GPU, Downstream, and Documentation CI; the docs build was not run because the diff touches no docs/ and no rendered API entry (set_linear_reltol! is in the sublib's internal public block, like dolinsolve and drain_jvp_count! beside it, and is not rendered in the OrdinaryDiffEq manual).
  • The ## Effect on results benchmark tables were not re-run after the rebase; see the note there.
  • StochasticDiffEq's two dolinsolve call sites: they go through the same helper and take the same default, but the SDE test groups were not run.
  • Test groups other than the ones listed above.
  • Per-component (array) abstol/reltol with a Krylov solver: the new guard skips them, which is the same behaviour as master. Note rosenbrock_caches.jl and firk_caches.jl pass such an array straight into LinearSolve.init without a guard, which looks like a latent bug on those paths; not touched here.

Worth pushing back on

  • Whether the Newton path should track integrator.opts.reltol at all. The adjacent NonlinearSolve inner-algorithm branch in build_nlsolver deliberately uses a fixed tight _inner_lintol = eps^(4/5) ≈ 3e-13 for its descent solve instead, so this PR makes the two branches of the same function disagree about the right linear tolerance. I matched Rosenbrock/RadauIIA3 because that is what dolinsolve's argument said it was doing, but the reverse choice is defensible and a forcing-term (Eisenstat–Walker) scheme would be better than either.
  • abstol is left at LinearSolve's default on the Newton path, so the stopping bound is 1.49e-8 + reltol·‖b‖. Rosenbrock and RadauIIA3 set both. dolinsolve has no abstol argument and adding one is beyond this fix.

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Confirming the FIRK GROUP=QA failure mentioned in the Verification section is pre-existing and not caused by this PR. Same command on the unmodified base commit 9d23061ef8a2b155ca2b6ff78670f6e320967940, in a clean worktree:

Allocation Tests |      5      5  3m05.7s
JET Tests        |    1      1  48.5s
public API has docstrings: Test Failed at SciMLTesting.jl:1205
  Expression: isempty(undocumented)
   Evaluated: isempty([:SciMLBase])
Aqua             |   19     1     20  1m23.6s
ERROR: LoadError: Some tests did not pass: 19 passed, 1 failed, 0 errored, 0 broken.
ERROR: Package OrdinaryDiffEqFIRK errored during testing

Identical counts and identical single failure to the branch run. :SciMLBase is reported as an undocumented public name because lib/OrdinaryDiffEqFIRK/src/OrdinaryDiffEqFIRK.jl does @reexport using SciMLBase, which puts the module's own name into names(OrdinaryDiffEqFIRK). Being tracked and fixed separately; nothing in this diff touches it.

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Root cause of the pre-existing FIRK QA failure above, so it does not get mistaken for something this PR should carry: it is a SciMLTesting regression, not an OrdinaryDiffEq one, and it affects the whole monorepo rather than FIRK.

@reexport using SciMLBase puts :SciMLBase into names(pkg), and SciMLBase has no module docstring. Up to SciMLTesting v2.6.1 the check tested !occursin("No documentation found", ...), but Julia renders an undocumented module as "No docstring found for public module ...", so the substring never matched and modules were silently counted as documented. SciMLTesting v2.6.2 (PR #41, commit 6d0dd9e) switched to Base.Docs.hasdoc, which correctly returns false, and the latent gap became a hard failure everywhere. 26 sublibraries here do that reexport and all fail identically; so do the StochasticDiffEq sublibs and DelayDiffEq. FIRK's test/qa/qa.jl is byte-identical to BDF's, SDIRK's and Tsit5's, so there is no FIRK-local misconfiguration to fix.

CI brackets the registration exactly: a run at 2026-08-05T03:11 on v2.6.1 was green, a run at 2026-08-05T12:39 on v2.6.2 was red, and the General registration of v2.6.2 merged at 04:01 between them.

It is already fixed upstream and merged — SciMLTesting PR #45, commit 1796019, version = "2.6.3" — but v2.6.3 is not in the General registry yet, which is the only remaining action. Verified locally: FIRK master with a Pkg.develop'd SciMLTesting 2.6.3 turns Public API documentation green. FIRK's compat is SciMLTesting = "2.1", so it picks 2.6.3 up with no bump.

Deliberately not adding a local ignore = (:SciMLBase,) here: it would have to be repeated across ~38 qa.jl files and all of it becomes dead weight the moment 2.6.3 registers.

Links:

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Re-ran the Core group on the exact pushed commit (the Verification section's numbers were taken before the public declarations and version bumps were added). Same result, lib/OrdinaryDiffEqDifferentiation, GROUP=Core:

DAE jacobian2W sparse |    4      4  5.8s
prepare_user_sparsity mass matrix |    9      9  2.3s
ScalarOperator mass matrix |    9      9  7m48.8s
nzval helpers |   26     26  31.0s
prepare_sparse_jac! |    9      9  0.6s
OOP J_t Tracking |    5      5  20.3s
Differentiation Trait Tests |    5      5  12.3s
Autodiff Error Tests |    4      4  3m35.8s
No Jac Tests  |    7      7  16.9s
Stale W Linear Operator Tests |   12     12  7m55.3s
Krylov warm_start default |    7      7  0.1s
Krylov linear tolerance |    7      7  2m10.1s
     Testing OrdinaryDiffEqDifferentiation tests passed

`dolinsolve` forwarded `reltol` as a `solve!` keyword, but
`SciMLBase.solve!(::LinearCache, alg; kwargs...)` reads the tolerances off the
cache and drops the keywords, so the Newton path's Krylov solves ran at
LinearSolve's `sqrt(eps)` default however tight or loose the integrator was.
Rosenbrock and RadauIIA3 already set both tolerances at `LinearSolve.init`;
`build_nlsolver` never receives `reltol`, so `dolinsolve` is the only place the
Newton path can set it.

Apply it through `LinearSolve.update_tolerances!`, factored into
`set_linear_reltol!` so the `dolinsolve` and GaussLegendre call sites cannot
drift. Solvers needing a concrete `A` and non-scalar tolerances are skipped.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F75XsVeZ94QH3PmCuq6QUF
@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review August 9, 2026 21:54
@ChrisRackauckas
ChrisRackauckas merged commit 4bb8495 into SciML:master Aug 9, 2026
11 of 22 checks passed
ChrisRackauckas-Claude pushed a commit to ChrisRackauckas-Claude/OrdinaryDiffEq.jl that referenced this pull request Aug 15, 2026
…erance

`build_nlsolver` zeroes the inner nonlinear tolerances so the integrator owns convergence,
and restores a linear tolerance through `linsolve_kwargs` as a fixed `eps^(4//5)`. That
value is independent of `reltol`, so a Krylov descent solve runs to ~3e-13 however loose
the integrator is: at `reltol = 1e-3` it is ten orders tighter than the step can use, paid
in Jacobian-vector products.

Set it per step from the integrator instead, the way SciML#4192 made `dolinsolve` do for
`NLNewton`, reusing that PR's `set_linear_reltol!` so a solver that needs a concrete `A`
is left alone.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas added a commit that referenced this pull request Aug 15, 2026
…erance (#4268)

`build_nlsolver` zeroes the inner nonlinear tolerances so the integrator owns convergence,
and restores a linear tolerance through `linsolve_kwargs` as a fixed `eps^(4//5)`. That
value is independent of `reltol`, so a Krylov descent solve runs to ~3e-13 however loose
the integrator is: at `reltol = 1e-3` it is ten orders tighter than the step can use, paid
in Jacobian-vector products.

Set it per step from the integrator instead, the way #4192 made `dolinsolve` do for
`NLNewton`, reusing that PR's `set_linear_reltol!` so a solver that needs a concrete `A`
is left alone.

Co-authored-by: Chris Rackauckas <accounts@chrisrackauckas.com>
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.

dolinsolve forwards reltol to solve!(linsolve) but KrylovJL drops it: every Krylov solve runs at sqrt(eps)

2 participants