Skip to content

Commit fa9f41d

Browse files
Add Jacobian reuse for Rosenbrock-W methods (rebased onto master)
Implements CVODE-inspired Jacobian reuse for Rosenbrock-W methods. W-methods guarantee correctness with a stale Jacobian, so we skip expensive J recomputations when conditions allow: - Reuse J but always rebuild W (cheap LU vs expensive AD/finite-diff) - Recompute J on: first iter, step rejection, callback, resize, gamma ratio change >30%, every 20 accepted steps, algorithm switch - Disabled for: strict Rosenbrock, DAEs, linear problems, CompositeAlgorithm Squashed and rebased from PR SciML#3075 (7 commits) onto current master after substantial upstream restructuring (cache consolidation, generic_rosenbrock.jl deletion, RodasTableau unification). Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
1 parent 7246fba commit fa9f41d

File tree

5 files changed

+315
-27
lines changed

5 files changed

+315
-27
lines changed

lib/OrdinaryDiffEqDifferentiation/src/OrdinaryDiffEqDifferentiation.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ using OrdinaryDiffEqCore: OrdinaryDiffEqAlgorithm, OrdinaryDiffEqAdaptiveImplici
4444
isnewton, _unwrap_val,
4545
set_new_W!, set_W_γdt!, alg_difftype, unwrap_cache, diffdir,
4646
get_W, isfirstcall, isfirststage, isJcurrent,
47-
get_new_W_γdt_cutoff,
47+
get_new_W_γdt_cutoff, isWmethod,
4848
TryAgain, DIRK, COEFFICIENT_MULTISTEP, NORDSIECK_MULTISTEP, GLM,
4949
FastConvergence, Convergence, SlowConvergence,
5050
VerySlowConvergence, Divergence, NLStatus, MethodType, constvalue, @SciMLMessage

lib/OrdinaryDiffEqDifferentiation/src/derivative_utils.jl

Lines changed: 237 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,135 @@
11
using SciMLOperators: StaticWOperator, WOperator
22

3+
"""
4+
get_jac_reuse(cache)
5+
6+
Duck-typed accessor for the `jac_reuse` field. Returns `nothing` if the cache
7+
does not have a `jac_reuse` field.
8+
"""
9+
get_jac_reuse(cache) = hasproperty(cache, :jac_reuse) ? cache.jac_reuse : nothing
10+
11+
"""
12+
_rosenbrock_jac_reuse_decision(integrator, cache, dtgamma) -> Union{Nothing, NTuple{2,Bool}}
13+
14+
Decide whether to recompute the Jacobian and/or W matrix for Rosenbrock methods.
15+
For W-methods (where `isWmethod(alg) == true`) on non-DAE problems, implements
16+
CVODE-inspired Jacobian reuse:
17+
- Always recompute on first iteration
18+
- Recompute after step rejection (EEst > 1), since the old J wasn't good enough
19+
- Recompute when gamma ratio changes too much: |dtgamma/last_dtgamma - 1| > 0.3
20+
- Recompute every `max_jac_age` accepted steps (default 20)
21+
- Recompute when u_modified (callback modification)
22+
- Otherwise reuse J but rebuild W from the cached J and current dtgamma.
23+
The Jacobian evaluation (finite-diff / AD) is the expensive part; W
24+
construction and LU factorization are comparatively cheap.
25+
26+
Returns `nothing` (delegate to `do_newJW`) for strict Rosenbrock methods,
27+
linear problems, mass-matrix (DAE) problems where stale Jacobians cause
28+
order reduction, and CompositeAlgorithm where rapid stiff↔nonstiff
29+
transitions make reuse counterproductive.
30+
"""
31+
function _rosenbrock_jac_reuse_decision(integrator, cache, dtgamma)
32+
alg = OrdinaryDiffEqCore.unwrap_alg(integrator, true)
33+
34+
# Non-W-methods: delegate to do_newJW (preserves linear problem optimization etc.)
35+
if !isWmethod(alg)
36+
return nothing
37+
end
38+
39+
jac_reuse = get_jac_reuse(cache)
40+
# If no reuse state (e.g. OOP cache without jac_reuse), delegate to do_newJW
41+
if jac_reuse === nothing
42+
return nothing
43+
end
44+
45+
# Linear problems: delegate to do_newJW (which returns (false, false) for islin)
46+
islin, _ = islinearfunction(integrator)
47+
if islin
48+
return nothing
49+
end
50+
51+
# Mass matrix (DAE) problems: delegate to do_newJW.
52+
# Stale Jacobians cause order reduction for DAEs because the algebraic
53+
# constraint derivatives must remain accurate. See Steinebach (2024)
54+
# for W-method DAE order conditions.
55+
if integrator.f.mass_matrix !== I
56+
return nothing
57+
end
58+
59+
# CompositeAlgorithm: delegate to do_newJW, which always recomputes J
60+
# for Rosenbrock methods in composite context (the Jacobian may be stale
61+
# from a different algorithm, and rapid stiff↔nonstiff transitions make
62+
# reuse counterproductive due to increased step rejections).
63+
if integrator.alg isa CompositeAlgorithm
64+
return nothing
65+
end
66+
67+
# First iteration: always compute J and W.
68+
if integrator.iter <= 1
69+
return (true, true)
70+
end
71+
72+
# Commit pending_dtgamma from previous step if it was accepted.
73+
# This ensures rejected steps don't pollute last_dtgamma, keeping
74+
# IIP-adaptive and OOP-non-adaptive reuse decisions synchronized.
75+
naccept = integrator.stats.naccept
76+
if naccept > jac_reuse.last_naccept
77+
jac_reuse.last_dtgamma = jac_reuse.pending_dtgamma
78+
jac_reuse.last_naccept = naccept
79+
end
80+
81+
# Fresh cache (e.g., algorithm switch where iter > 1 but the Rosenbrock
82+
# cache is freshly created with cached_J = nothing).
83+
if iszero(jac_reuse.last_dtgamma)
84+
return (true, true)
85+
end
86+
87+
# Detect algorithm switch in CompositeAlgorithm: if integrator.iter jumped
88+
# by more than 1 since our last Rosenbrock step, another algorithm ran in
89+
# between and the cached Jacobian is evaluated at a stale u.
90+
if jac_reuse.last_step_iter != 0 && integrator.iter > jac_reuse.last_step_iter + 1
91+
return (true, true)
92+
end
93+
94+
# Callback modification: recompute
95+
if integrator.u_modified
96+
return (true, true)
97+
end
98+
99+
# Resize detection: if u changed length since last J computation,
100+
# the cached LU factorization has wrong dimensions.
101+
# (u_modified is already cleared by reeval_internals_due_to_modification!
102+
# before perform_step! runs, so we need this explicit check.)
103+
if length(integrator.u) != jac_reuse.last_u_length && jac_reuse.last_u_length != 0
104+
return (true, true)
105+
end
106+
107+
# Previous step was rejected (EEst > 1): the old W wasn't good enough.
108+
# Recompute everything since we're retrying with a different dt anyway.
109+
if integrator.EEst > 1
110+
return (true, true)
111+
end
112+
113+
# Gamma ratio check (uses only accepted-step dtgamma)
114+
last_dtg = jac_reuse.last_dtgamma
115+
if !iszero(last_dtg) && abs(dtgamma / last_dtg - 1) > 0.3
116+
return (true, true)
117+
end
118+
119+
# Age check: recompute J after max_jac_age accepted steps.
120+
# Uses naccept (not a local counter) so rejected steps don't desynchronize
121+
# IIP-adaptive and OOP-non-adaptive solves.
122+
if (naccept - jac_reuse.last_naccept) >= jac_reuse.max_jac_age
123+
return (true, true)
124+
end
125+
126+
# Reuse J but rebuild W with the current dtgamma. Following CVODE's
127+
# approach: the Jacobian evaluation (finite-diff / AD) is expensive,
128+
# while reconstructing W = J − M/(dt·γ) and its LU is comparatively
129+
# cheap and keeps the step controller accurate.
130+
return (false, true)
131+
end
132+
3133
function calc_tderivative!(integrator, cache, dtd1, repeat_step)
4134
return @inbounds begin
5135
(; t, dt, uprev, u, f, p) = integrator
@@ -689,15 +819,120 @@ function calc_rosenbrock_differentiation!(integrator, cache, dtd1, dtgamma, repe
689819
# we need to skip calculating `J` and `W` when a step is repeated
690820
new_jac = new_W = false
691821
if !repeat_step
692-
new_jac, new_W = calc_W!(
693-
cache.W, integrator, nlsolver, cache, dtgamma, repeat_step
822+
# For W-methods, use reuse logic; for strict Rosenbrock, always recompute
823+
newJW = _rosenbrock_jac_reuse_decision(integrator, cache, dtgamma)
824+
new_jac,
825+
new_W = calc_W!(
826+
cache.W, integrator, nlsolver, cache, dtgamma, repeat_step, newJW
694827
)
828+
# Record pending dtgamma only when J was freshly computed; it will be
829+
# committed as last_dtgamma when the step is accepted (checked in
830+
# _rosenbrock_jac_reuse_decision). This tracks the dtgamma at the
831+
# last J computation for the gamma ratio heuristic.
832+
jac_reuse = get_jac_reuse(cache)
833+
if jac_reuse !== nothing
834+
jac_reuse.last_step_iter = integrator.iter
835+
if new_jac
836+
jac_reuse.pending_dtgamma = dtgamma
837+
jac_reuse.last_u_length = length(integrator.u)
838+
end
839+
end
695840
end
696841
# If the Jacobian is not updated, we won't have to update ∂/∂t either.
697842
calc_tderivative!(integrator, cache, dtd1, repeat_step || !new_jac)
698843
return new_W
699844
end
700845

846+
"""
847+
calc_rosenbrock_differentiation(integrator, cache, dtgamma, repeat_step)
848+
849+
Non-mutating (OOP) version of `calc_rosenbrock_differentiation!`.
850+
Returns `(dT, W)` where `dT` is the time derivative and `W` is the factorized
851+
system matrix. Supports Jacobian reuse for W-methods via `jac_reuse` in the cache.
852+
"""
853+
function calc_rosenbrock_differentiation(integrator, cache, dtgamma, repeat_step)
854+
jac_reuse = get_jac_reuse(cache)
855+
856+
# If no reuse support or repeat step, use standard path
857+
if repeat_step || jac_reuse === nothing
858+
dT = calc_tderivative(integrator, cache)
859+
W = calc_W(integrator, cache, dtgamma, repeat_step)
860+
return dT, W
861+
end
862+
863+
newJW = _rosenbrock_jac_reuse_decision(integrator, cache, dtgamma)
864+
865+
if newJW === nothing
866+
# Delegate to standard path (linear problems, non-W-methods, etc.)
867+
dT = calc_tderivative(integrator, cache)
868+
W = calc_W(integrator, cache, dtgamma, repeat_step)
869+
return dT, W
870+
end
871+
872+
new_jac, new_W = newJW
873+
874+
# Track iteration for algorithm-switch detection in CompositeAlgorithm
875+
jac_reuse.last_step_iter = integrator.iter
876+
877+
# For complex W types (operators), delegate to standard calc_W
878+
if cache.W isa StaticWOperator || cache.W isa WOperator ||
879+
cache.W isa AbstractSciMLOperator
880+
dT = calc_tderivative(integrator, cache)
881+
W = calc_W(integrator, cache, dtgamma, repeat_step)
882+
jac_reuse.pending_dtgamma = dtgamma
883+
return dT, W
884+
end
885+
886+
mass_matrix = integrator.f.mass_matrix
887+
update_coefficients!(mass_matrix, integrator.uprev, integrator.p, integrator.t)
888+
889+
# Safety: if cached_J or cached_W is nothing (e.g. first use after algorithm switch),
890+
# force a fresh computation regardless of the decision.
891+
if !new_jac && jac_reuse.cached_J === nothing
892+
new_jac = true
893+
new_W = true
894+
end
895+
if !new_W && jac_reuse.cached_W === nothing
896+
new_W = true
897+
end
898+
899+
if new_jac
900+
J = calc_J(integrator, cache)
901+
dT = calc_tderivative(integrator, cache)
902+
903+
# Cache for future reuse
904+
jac_reuse.cached_J = J
905+
jac_reuse.cached_dT = dT
906+
else
907+
# Reuse cached J and dT
908+
J = jac_reuse.cached_J
909+
dT = jac_reuse.cached_dT
910+
end
911+
912+
# Record pending dtgamma only when J was freshly computed;
913+
# committed as last_dtgamma on the next accepted step.
914+
if new_jac
915+
jac_reuse.pending_dtgamma = dtgamma
916+
jac_reuse.last_u_length = length(integrator.u)
917+
end
918+
919+
# Always rebuild W from the (possibly cached) J and the current dtgamma.
920+
# The Jacobian evaluation is the expensive part; W = J − M/(dt·γ) and
921+
# its factorization are comparatively cheap and keep step control accurate.
922+
if new_W
923+
W = J - mass_matrix * inv(dtgamma)
924+
if !isa(W, Number)
925+
W = DiffEqBase.default_factorize(W)
926+
end
927+
integrator.stats.nw += 1
928+
jac_reuse.cached_W = W
929+
else
930+
W = jac_reuse.cached_W
931+
end
932+
933+
return dT, W
934+
end
935+
701936
# update W matrix (only used in Newton method)
702937
function update_W!(integrator, cache, dtgamma, repeat_step, newJW = nothing)
703938
return update_W!(cache.nlsolver, integrator, cache, dtgamma, repeat_step, newJW)

lib/OrdinaryDiffEqRosenbrock/src/OrdinaryDiffEqRosenbrock.jl

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ using OrdinaryDiffEqDifferentiation: TimeDerivativeWrapper, TimeGradientWrapper,
3535
calc_W, calc_rosenbrock_differentiation!, build_J_W,
3636
UJacobianWrapper, dolinsolve, WOperator, resize_J_W!
3737

38+
# On Julia 1.11+, [sources] in Project.toml provides the local OrdinaryDiffEqDifferentiation
39+
# which has calc_rosenbrock_differentiation (OOP version with J reuse).
40+
# On Julia 1.10, [sources] is not supported, so the registry version is used which
41+
# doesn't have this function. Define a simple fallback without J reuse.
42+
@static if VERSION >= v"1.11-"
43+
using OrdinaryDiffEqDifferentiation: calc_rosenbrock_differentiation
44+
else
45+
function calc_rosenbrock_differentiation(integrator, cache, dtgamma, repeat_step)
46+
dT = calc_tderivative(integrator, cache)
47+
W = calc_W(integrator, cache, dtgamma, repeat_step)
48+
return dT, W
49+
end
50+
end
51+
3852
using Reexport
3953
@reexport using SciMLBase
4054

0 commit comments

Comments
 (0)