Skip to content

Commit 081bf79

Browse files
ChrisRackauckas-ClaudeChrisRackauckasclaude
authored
Recompute the spectral radius every 25 steps by default in ROCK2/ROCK4/RKC (#3952)
When no user eigen_est is provided, the spectral radius is estimated by power iteration. Previously this ran on every step (several extra f evaluations per step even with the warm-started eigenvector). Add an eigen_est_interval option so the power iteration only reruns every eigen_est_interval accepted steps, plus always on the first step, after step rejections, and at derivative discontinuities. A stale estimate that under-resolves the stability region self-corrects through a rejection, so there is no silent loss of stability. The default is 25, matching the classical RKC (Sommeijer-Shampine-Verwer) and ROCK Fortran codes; pass eigen_est_interval = 1 to restore the previous recompute-every-step behavior. Benchmarked across linear heat, Allen-Cahn, Brusselator, and time-ramping diffusion, in-place and out-of-place, at abstol=reltol in {1e-4,1e-6,1e-8} with the internal power iteration: - slowly varying spectral radius (the common parabolic case): 10-44% fewer f evaluations at identical accuracy and identical accepted-step counts; up to 1.39x faster wall clock on nonlinear RHS problems. - an interval sweep {1,2,5,10,25,50,100} shows the knee near 10-25; 25 captures ~95% of the available saving. - rapidly ramping spectral radius: a stale estimate costs a few extra rejections and up to ~25% more f evaluations at loose tolerance, but always converges (retcode Success); users with fast-varying stiffness can set eigen_est_interval = 1. Convergence orders are unchanged (added as tests). Adds an eigen_est_interval testset asserting the default is 25, that it spends fewer f evaluations than interval 1 at equal accuracy, and that the order is preserved. Claude-Session: https://claude.ai/code/session_015WU5V4PXt8SZ3BpjxdtM7d Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 26582e1 commit 081bf79

7 files changed

Lines changed: 118 additions & 15 deletions

File tree

lib/OrdinaryDiffEqStabilizedRK/src/algorithms.jl

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,30 @@
1212
`(integrator) -> integrator.eigen_est = upper_bound`,
1313
where `upper_bound` is an estimated upper bound on the spectral radius of the Jacobian matrix.
1414
If `eigen_est` is not provided, `upper_bound` will be estimated using the power iteration.
15+
- `eigen_est_interval`: with the internal power-iteration estimator, only recompute
16+
the spectral radius estimate every `eigen_est_interval` accepted steps. It is always
17+
recomputed on the first step, after step rejections, and at discontinuities, so a
18+
stale estimate that under-resolves the stability region self-corrects through a
19+
rejection. The default of 25 matches the classical RKC/ROCK Fortran codes and is a
20+
good choice for parabolic problems whose spectral radius varies slowly; set it to 1
21+
to recompute every step (more robust when the dominant eigenvalue changes rapidly,
22+
at the cost of a few extra function evaluations per step).
1523
""",
1624
"""
1725
min_stages = 0,
1826
max_stages = 200,
1927
eigen_est = nothing,
28+
eigen_est_interval = 25,
2029
"""
2130
)
2231
struct ROCK2{E} <: OrdinaryDiffEqAdaptiveAlgorithm
2332
min_stages::Int
2433
max_stages::Int
2534
eigen_est::E
35+
eigen_est_interval::Int
2636
end
27-
function ROCK2(; min_stages = 0, max_stages = 200, eigen_est = nothing)
28-
return ROCK2(min_stages, max_stages, eigen_est)
37+
function ROCK2(; min_stages = 0, max_stages = 200, eigen_est = nothing, eigen_est_interval = 25)
38+
return ROCK2(min_stages, max_stages, eigen_est, eigen_est_interval)
2939
end
3040

3141
@doc generic_solver_docstring(
@@ -43,25 +53,35 @@ end
4353
`(integrator) -> integrator.eigen_est = upper_bound`,
4454
where `upper_bound` is an estimated upper bound on the spectral radius of the Jacobian matrix.
4555
If `eigen_est` is not provided, `upper_bound` will be estimated using the power iteration.
56+
- `eigen_est_interval`: with the internal power-iteration estimator, only recompute
57+
the spectral radius estimate every `eigen_est_interval` accepted steps. It is always
58+
recomputed on the first step, after step rejections, and at discontinuities, so a
59+
stale estimate that under-resolves the stability region self-corrects through a
60+
rejection. The default of 25 matches the classical RKC/ROCK Fortran codes and is a
61+
good choice for parabolic problems whose spectral radius varies slowly; set it to 1
62+
to recompute every step (more robust when the dominant eigenvalue changes rapidly,
63+
at the cost of a few extra function evaluations per step).
4664
""",
4765
"""
4866
min_stages = 0,
4967
max_stages = 152,
5068
eigen_est = nothing,
69+
eigen_est_interval = 25,
5170
"""
5271
)
5372
struct ROCK4{E} <: OrdinaryDiffEqAdaptiveAlgorithm
5473
min_stages::Int
5574
max_stages::Int
5675
eigen_est::E
76+
eigen_est_interval::Int
5777
end
58-
function ROCK4(; min_stages = 0, max_stages = 152, eigen_est = nothing)
59-
return ROCK4(min_stages, max_stages, eigen_est)
78+
function ROCK4(; min_stages = 0, max_stages = 152, eigen_est = nothing, eigen_est_interval = 25)
79+
return ROCK4(min_stages, max_stages, eigen_est, eigen_est_interval)
6080
end
6181

6282
# SERK methods
6383

64-
for Alg in [:ESERK4, :ESERK5, :RKC, :TSRKC2, :TSRKC3]
84+
for Alg in [:ESERK4, :ESERK5, :TSRKC2, :TSRKC3]
6585
@eval begin
6686
struct $Alg{E} <: OrdinaryDiffEqAdaptiveAlgorithm
6787
eigen_est::E
@@ -70,6 +90,12 @@ for Alg in [:ESERK4, :ESERK5, :RKC, :TSRKC2, :TSRKC3]
7090
end
7191
end
7292

93+
struct RKC{E} <: OrdinaryDiffEqAdaptiveAlgorithm
94+
eigen_est::E
95+
eigen_est_interval::Int
96+
end
97+
RKC(; eigen_est = nothing, eigen_est_interval = 25) = RKC(eigen_est, eigen_est_interval)
98+
7399
@doc generic_solver_docstring(
74100
"""Second order method. Exhibits high stability for real eigenvalues.""",
75101
"RKC",
@@ -82,9 +108,18 @@ end
82108
`(integrator) -> integrator.eigen_est = upper_bound`,
83109
where `upper_bound` is an estimated upper bound on the spectral radius of the Jacobian matrix.
84110
If `eigen_est` is not provided, `upper_bound` will be estimated using the power iteration.
111+
- `eigen_est_interval`: with the internal power-iteration estimator, only recompute
112+
the spectral radius estimate every `eigen_est_interval` accepted steps. It is always
113+
recomputed on the first step, after step rejections, and at discontinuities, so a
114+
stale estimate that under-resolves the stability region self-corrects through a
115+
rejection. The default of 25 matches the classical RKC/ROCK Fortran codes and is a
116+
good choice for parabolic problems whose spectral radius varies slowly; set it to 1
117+
to recompute every step (more robust when the dominant eigenvalue changes rapidly,
118+
at the cost of a few extra function evaluations per step).
85119
""",
86120
"""
87121
eigen_est = nothing,
122+
eigen_est_interval = 25,
88123
"""
89124
)
90125
function RKC end

lib/OrdinaryDiffEqStabilizedRK/src/rkc_caches.jl

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mutable struct ROCK2ConstantCache{T, T2, zType} <: OrdinaryDiffEqConstantCache
1212
start::Int
1313
min_stage::Int
1414
max_stage::Int
15+
eig_age::Int
1516
end
1617
@cache struct ROCK2Cache{uType, rateType, uNoUnitsType, C <: ROCK2ConstantCache} <:
1718
StabilizedRKMutableCache
@@ -66,6 +67,7 @@ mutable struct ROCK4ConstantCache{T, T2, T3, T4, zType} <: OrdinaryDiffEqConstan
6667
start::Int
6768
min_stage::Int
6869
max_stage::Int
70+
eig_age::Int
6971
end
7072

7173
@cache struct ROCK4Cache{uType, rateType, uNoUnitsType, C <: ROCK4ConstantCache} <:
@@ -116,6 +118,7 @@ end
116118
mutable struct RKCConstantCache{zType} <: OrdinaryDiffEqConstantCache
117119
#to match the types to call maxeig!
118120
zprev::zType
121+
eig_age::Int
119122
end
120123
@cache struct RKCCache{uType, rateType, uNoUnitsType, C <: RKCConstantCache} <:
121124
StabilizedRKMutableCache
@@ -135,7 +138,7 @@ function alg_cache(
135138
dt, reltol, p, calck,
136139
::Val{true}, verbose
137140
) where {uEltypeNoUnits, uBottomEltypeNoUnits, tTypeNoUnits}
138-
constantcache = RKCConstantCache(copy(u))
141+
constantcache = RKCConstantCache(copy(u), 0)
139142
gprev = zero(u)
140143
tmp = zero(u)
141144
atmp = similar(u, uEltypeNoUnits)
@@ -151,7 +154,7 @@ function alg_cache(
151154
dt, reltol, p, calck,
152155
::Val{false}, verbose
153156
) where {uEltypeNoUnits, uBottomEltypeNoUnits, tTypeNoUnits}
154-
return RKCConstantCache(u)
157+
return RKCConstantCache(u, 0)
155158
end
156159

157160
mutable struct RKMC2ConstantCache{zType, T} <: OrdinaryDiffEqConstantCache

lib/OrdinaryDiffEqStabilizedRK/src/rkc_perform_step.jl

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ end
1616
(; t, dt, uprev, u, f, p, fsalfirst) = integrator
1717
(; ms, fp1, fp2, recf) = cache
1818
alg = unwrap_alg(integrator, true)
19-
alg.eigen_est === nothing ? maxeig!(integrator, cache) : alg.eigen_est(integrator)
19+
maybe_maxeig!(integrator, cache, alg)
2020
T = typeof(one(t))
2121
# The number of degree for Chebyshev polynomial
2222
mdeg = floor(Int, sqrt((T(1.5) + abs(dt) * integrator.eigen_est) / T(0.811))) + 1
@@ -98,7 +98,7 @@ end
9898
(; ms, fp1, fp2, recf) = cache.constantcache
9999
ccache = cache.constantcache
100100
alg = unwrap_alg(integrator, true)
101-
alg.eigen_est === nothing ? maxeig!(integrator, cache) : alg.eigen_est(integrator)
101+
maybe_maxeig!(integrator, cache, alg)
102102
T = typeof(one(t))
103103
# The number of degree for Chebyshev polynomial
104104
mdeg = floor(Int, sqrt((T(1.5) + abs(dt) * integrator.eigen_est) / T(0.811))) + 1
@@ -181,7 +181,7 @@ end
181181
(; t, dt, uprev, u, f, p, fsalfirst) = integrator
182182
(; ms, fpa, fpb, fpbe, recf) = cache
183183
alg = unwrap_alg(integrator, true)
184-
alg.eigen_est === nothing ? maxeig!(integrator, cache) : alg.eigen_est(integrator)
184+
maybe_maxeig!(integrator, cache, alg)
185185
T = typeof(one(t))
186186
# The number of degree for Chebyshev polynomial
187187
mdeg = floor(Int, sqrt((3 + abs(dt) * integrator.eigen_est) / T(0.353))) + 1
@@ -320,7 +320,7 @@ end
320320
(; ms, fpa, fpb, fpbe, recf) = cache.constantcache
321321
ccache = cache.constantcache
322322
alg = unwrap_alg(integrator, true)
323-
alg.eigen_est === nothing ? maxeig!(integrator, cache) : alg.eigen_est(integrator)
323+
maybe_maxeig!(integrator, cache, alg)
324324
T = typeof(one(t))
325325
# The number of degree for Chebyshev polynomial
326326
mdeg = floor(Int, sqrt((3 + abs(dt) * integrator.eigen_est) / T(0.353))) + 1
@@ -459,7 +459,7 @@ end
459459
@muladd function perform_step!(integrator, cache::RKCConstantCache, repeat_step = false)
460460
(; t, dt, uprev, u, f, p, fsalfirst) = integrator
461461
alg = unwrap_alg(integrator, true)
462-
alg.eigen_est === nothing ? maxeig!(integrator, cache) : alg.eigen_est(integrator)
462+
maybe_maxeig!(integrator, cache, alg)
463463
T = typeof(one(t))
464464
# The number of degree for Chebyshev polynomial
465465
mdeg = floor(Int, sqrt(T(1.54) * abs(dt) * integrator.eigen_est + T(1))) + 1
@@ -545,7 +545,7 @@ end
545545
(; t, dt, uprev, u, f, p, fsalfirst) = integrator
546546
(; k, tmp, gprev, atmp) = cache
547547
alg = unwrap_alg(integrator, true)
548-
alg.eigen_est === nothing ? maxeig!(integrator, cache) : alg.eigen_est(integrator)
548+
maybe_maxeig!(integrator, cache, alg)
549549
T = typeof(one(t))
550550
# The number of degree for Chebyshev polynomial
551551
mdeg = floor(Int, sqrt(T(1.54) * abs(dt) * integrator.eigen_est + T(1))) + 1

lib/OrdinaryDiffEqStabilizedRK/src/rkc_tableaus_rock2.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4552,6 +4552,6 @@ function ROCK2ConstantCache(::Type{T}, ::Type{T2}, zprev) where {T, T2}
45524552
]
45534553

45544554
return ROCK2ConstantCache{T, T2, typeof(zprev)}(
4555-
ms, NTuple{46, T}(fp1), NTuple{46, T}(fp2), recf, zprev, 1, 1, 1, 0, 200
4555+
ms, NTuple{46, T}(fp1), NTuple{46, T}(fp2), recf, zprev, 1, 1, 1, 0, 200, 0
45564556
)
45574557
end

lib/OrdinaryDiffEqStabilizedRK/src/rkc_tableaus_rock4.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4957,6 +4957,6 @@ function ROCK4ConstantCache(::Type{T}, ::Type{T2}, zprev) where {T, T2}
49574957
zprev,
49584958
1, 1,
49594959
1, 0,
4960-
152
4960+
152, 0
49614961
)
49624962
end

lib/OrdinaryDiffEqStabilizedRK/src/rkc_utils.jl

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,33 @@ function maxeig!(integrator, cache::OrdinaryDiffEqMutableCache)
179179
end
180180
return false
181181
end
182+
183+
"""
184+
maybe_maxeig!(integrator, cache, alg) -> nothing
185+
186+
Update `integrator.eigen_est`. Uses `alg.eigen_est` when provided; otherwise
187+
runs the power iteration (`maxeig!`), skipping it when the previous estimate is
188+
still fresh: with `alg.eigen_est_interval > 1` the estimate is only recomputed
189+
every `eigen_est_interval` steps, plus always on the first step, after step
190+
rejections, and at discontinuities.
191+
"""
192+
function maybe_maxeig!(integrator, cache, alg)
193+
if alg.eigen_est !== nothing
194+
alg.eigen_est(integrator)
195+
return nothing
196+
end
197+
ccache = cache isa OrdinaryDiffEqConstantCache ? cache : cache.constantcache
198+
EEst = OrdinaryDiffEqCore.get_EEst(integrator)
199+
if ccache.eig_age >= alg.eigen_est_interval || integrator.iter <= 1 ||
200+
integrator.derivative_discontinuity || EEst > one(EEst)
201+
maxeig!(integrator, cache)
202+
ccache.eig_age = 1
203+
else
204+
ccache.eig_age += 1
205+
end
206+
return nothing
207+
end
208+
182209
"""
183210
choosedeg!(cache) -> nothing
184211

lib/OrdinaryDiffEqStabilizedRK/test/rkc_tests.jl

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,44 @@ end
317317
end
318318
end
319319

320+
@testset "eigen_est_interval" begin
321+
# Stiff parabolic problem solved with the internal power iteration. The default
322+
# sparse recompute interval (25) must stay accurate while spending fewer f
323+
# evaluations than recomputing the spectral radius on every step.
324+
N = 40
325+
dx = 1.0 / (N + 1)
326+
xs = collect(range(dx, 1 - dx; length = N))
327+
D2 = Tridiagonal(fill(1 / dx^2, N - 1), fill(-2 / dx^2, N), fill(1 / dx^2, N - 1))
328+
u0 = sin.(pi .* xs)
329+
tspan = (0.0, 0.5)
330+
prob = ODEProblem((du, u, p, t) -> mul!(du, D2, u), u0, tspan)
331+
exact = exp(-pi^2 * tspan[2]) .* sin.(pi .* xs)
332+
333+
# default interval is 25; compare against recomputing every step (interval = 1)
334+
@test ROCK2().eigen_est_interval == 25
335+
@test ROCK4().eigen_est_interval == 25
336+
@test RKC().eigen_est_interval == 25
337+
for (alg_every, alg_default) in [
338+
(ROCK2(eigen_est_interval = 1), ROCK2()),
339+
(ROCK4(eigen_est_interval = 1), ROCK4()),
340+
(RKC(eigen_est_interval = 1), RKC()),
341+
]
342+
sol_every = solve(prob, alg_every, abstol = 1.0e-6, reltol = 1.0e-6)
343+
sol_default = solve(prob, alg_default, abstol = 1.0e-6, reltol = 1.0e-6)
344+
@test sol_default.retcode == ReturnCode.Success
345+
@test norm(sol_default.u[end] - exact) <
346+
10 * max(norm(sol_every.u[end] - exact), 1.0e-6)
347+
@test sol_default.stats.nf < sol_every.stats.nf
348+
end
349+
350+
# convergence order is unaffected by sparse recomputes (default interval)
351+
dts = 1 .// 2 .^ (8:-1:4)
352+
sim = test_convergence(dts, prob_ode_2Dlinear, ROCK2())
353+
@test sim.𝒪est[:l∞] 2 atol = 0.2
354+
sim = test_convergence(dts, prob_ode_2Dlinear, ROCK4())
355+
@test sim.𝒪est[:l∞] 4 atol = 0.2
356+
end
357+
320358
@testset "Number of function evaluations" begin
321359
x = Ref(0)
322360
u0 = [1.0, 1.0]

0 commit comments

Comments
 (0)