Add NordsieckBDF and DNordsieckBDF: Newton-based Nordsieck BDF solvers - #4017
Add NordsieckBDF and DNordsieckBDF: Newton-based Nordsieck BDF solvers#4017ChrisRackauckas-Claude wants to merge 5 commits into
Conversation
Adds a variable-order variable-step BDF pair built on a propagated Nordsieck
history array `zn[j] = h^j/j! * y^(j)(t_n)`, ported from SUNDIALS CVODE:
- `NordsieckBDF` — ODE / mass-matrix form
- `DNordsieckBDF` — fully implicit DAE form, `f(du, u, p, t) = 0`
Both support in-place and out-of-place problems.
Motivation. `FBDF` stores raw `(t_i, u_i)` history and reconstructs the predictor
(Lagrange) and the error/order estimates (finite-difference stencils) every step.
A nonlinear-solve residual left in that stored history is amplified by the
extrapolating predictor, which inflates EEst and shrinks the step, so FBDF has to
over-solve the corrector. The Nordsieck array propagates instead of
reconstructing: predicting is a Pascal-triangle shift, a step-size change is
`zn[j] *= eta^j`, and accepting is a rank-1 update, so a loose corrector cannot
collapse the step size.
Measured at matched accuracy over ROBER/HIRES/OREGO/POLLU/VDPOL/BRUSS1D
(min-of-5 timing, L2 error against Rodas5P at 1e-14): NordsieckBDF is 0.65x
FBDF's time with 0.68x the W factorizations. CVODE_BDF is 0.52x; the remaining
gap is the nonlinear convergence test's iteration economy, not the history
representation.
Implementation notes:
- Reuses the package nonlinear solver. ODE: COEFFICIENT_MULTISTEP with
gamma = 1/l1. DAE: correction relative to `cache.u₀` giving cj = l1/h, the
same role IDA's cj plays. Both give gamma*W = dt/l1, so the existing W and
Jacobian-reuse machinery is unchanged.
- `NLNewton(κ = …)` acts as CVODE's NLSCOEF: the increment norm is scaled by
the test quantity tq[2] via the existing `has_special_newton_error` hook.
- Dense output is the Nordsieck polynomial itself, so it is free; `fsallast`
is recovered algebraically with no extra f evaluation.
This is distinct from the existing `JVODE_BDF` in OrdinaryDiffEqNordsieck, which
uses functional iteration and so is not usable on stiff problems (on ROBER it
hits MaxIters at 2e5 steps, on HIRES it returns 55% error, on OREGO it throws
StackOverflowError). These are the first Newton-based Nordsieck BDF solvers here.
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bHrkkvJzfYPMMJMRJ5GqX
The pluggable NonlinearSolve.jl backend gives results identical to the built-in
Newton on both the out-of-place and in-place ODE paths, and `has_special_newton_error`
is applied in the NonlinearSolveAlg `compute_step!` methods too, so `κ` keeps its
NLSCOEF meaning on either backend.
NonlinearSolveAlg is not exercised for the DAE solvers: it does not currently work
with fully implicit DAEs at all, failing identically for the existing `DFBDF`
("type DAEFunction has no field nlstep_data" in-place, a `_compute_rhs` MethodError
out-of-place). That is a pre-existing gap rather than anything specific to
`DNordsieckBDF`.
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bHrkkvJzfYPMMJMRJ5GqX
|
| problem | NLNewton steps/nf | NonlinearSolveAlg steps/nf |
|---|---|---|
| ROBER | 349 / 700 | 369 / 793 |
| HIRES | 312 / 900 | 315 / 824 |
| OREGO | 770 / 2316 | 853 / 2273 |
The spread is the same shape as FBDF vs FBDF(nlsolve = NonlinearSolveAlg()).
Worth noting: has_special_newton_error is applied in the NonlinearSolveAlg
compute_step! methods as well as the NLNewton ones, so the tq[2] scaling
holds on both backends and κ keeps its NLSCOEF meaning either way.
DAE — does not work, but not because of this PR. NonlinearSolveAlg currently
fails on fully implicit DAEs for the existing DFBDF with exactly the same
errors it gives for DNordsieckBDF:
- in-place:
type DAEFunction has no field nlstep_data - out-of-place:
MethodError: no method matching _compute_rhs(::Vector{Float64}, …)
So it looks like a pre-existing gap in the DAE ↔ NonlinearSolveAlg plumbing rather
than anything specific to the new solver — DNordsieckBDF fails identically to
DFBDF, which is the consistent behavior. I have not tried to fix that here; happy
to open it as a separate issue if useful.
…ailures - `stald` kwarg on both solvers (default `false`, matching CVODE) wiring the existing `StabilityLimitDetectionState` with CVODE `cvBDFStab`'s quantities: sqm2 = (q-1)!*‖zn[q-1]‖, sqm1 = (q-1)!*q*‖zn[q]‖, sq = (q-1)!*q*(q+1)*acnrm/tq[5]. A detected violation caps the newly chosen order and forbids step growth. - `post_newton_controller!` now drops the order after three consecutive corrector failures instead of only shrinking dt, matching FBDF. On a discontinuous-RHS test this reaches the end in 446 steps with 4 convergence failures against FBDF's 670 and 8. - Added `NordsieckBDF` to the precompile workload. - Removed an `etamax` ternary whose branches were identical; the first-step growth cap is set in `_nordsieck_start_common!` and dropped to the steady value here. STALD caveat: I could not get the detector to fire, on stiff problems or on purely imaginary spectra, and neither could FBDF's (identical results with stald=true and stald=false, and the detector returns false on synthetic growing-ratio data). The wiring mirrors FBDF's, so this looks like a property of the shared stald.jl rather than of either caller, but it means the path is unexercised here. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bHrkkvJzfYPMMJMRJ5GqX
Follow-ups: STALD, failure-driven order reduction, precompile (0d74c18)Plus one negative result worth recording. Crate-based convergence test — implemented, measured, reverted. My hypothesis
Identical, and identical at the default STALD ( Caveat I'd rather state than bury: I could not get the detector to fire — not on Order reduction on repeated corrector failures. Precompile. Cleanup. Removed an Tests are now 11 testsets, all passing against master. Runic clean. |
Instrumenting the remaining gap to CVODE — and a correctionCorrection first. In my previous comment I said the crate-based convergence test Per-step wall time (ns per accepted+rejected step, abstol 1e-8 / reltol 1e-6).
So per step this solver is already 1.5–1.8x cheaper than FBDF, and the residual Where the time goes (profile, self-time). Components of the package-vs-standalone gap, measured rather than guessed:
Conclusion. There is no single remaining hotspot to fix in |
Pure code motion: the single nordsieck.jl is split so each piece lives with its
peers, matching how QNDF/FBDF/DFBDF are organised.
algorithms.jl algorithm structs, constructors, docstrings, the alg union
alg_utils.jl alg_order / isadaptive / get_current_*_order / interpolation
and Newton-error traits
bdf_utils.jl error_constant (next to the QNDF/FBDF one)
nordsieck_utils.jl the Nordsieck array machinery: CVODE constants, coefficient
setup, predict/restore/rescale/complete, order raise/lower,
eta selection, weighted norms, cold start
bdf_caches.jl NordsieckBDFCache / ConstantCache + their alg_cache
dae_caches.jl DNordsieckBDFCache / ConstantCache + their alg_cache
controllers.jl stepsize/accept/reject/post_newton + controller defaults
bdf_perform_step.jl ODE initialize! / perform_step!
dae_perform_step.jl DAE initialize! / perform_step!
bdf_interpolants.jl the Nordsieck polynomial interpolant
interp_func.jl interp_summary for the new caches
`nordsieck_utils.jl` is included after stald.jl, before the caches, since the
constants and array operations are needed by both the caches and the controllers.
Behaviour is unchanged: step/f/W counts are identical to before the split
(ROBER 349/700/77, HIRES 312/900/46, OREGO 770/2316/126, POLLU 147/318/33,
VDPOL 767/1891/152), all 11 testsets pass, and bdf_regression_tests /
dae_convergence_tests / dae_event are unaffected. Runic clean.
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bHrkkvJzfYPMMJMRJ5GqX
Two CI failures, both mine: - `nordsieck_tests.jl` uses `Rodas5P` for reference solutions but `OrdinaryDiffEqRosenbrock` was never declared as a test dependency, so BDF Core and the BDF downgrade job failed with "Package OrdinaryDiffEqRosenbrock not found in current path". It passed locally only because my scratch environment happened to have it. Declared it the same way `DiffEqDevTools` is — [extras], [targets] test, [compat], and [sources] pointing at the monorepo sibling. - `indx_acor` -> `index_acor`, which the typos check flagged. The name came from CVODE's `cv_indx_acor`; renaming is better than an allowlist entry since it really is a misspelling. Behaviour is unchanged: step/f/W counts are identical (ROBER 349/700/77, HIRES 312/900/46, OREGO 770/2316/126) and all 11 testsets still pass. Not addressed here, because it is not from this PR: the BDF QA job fails ExplicitImports on `nlsolve!`, `nlsolvefail`, `build_nlsolver`, `du_alias_or_new`, `markfirststage!`, `CompositeControllerCache`, `lorenz_pref` and `lorenz_pref_params`. Every one of those is present identically on unmodified master (OrdinaryDiffEqBDF.jl:56-57, controllers.jl:169, the precompile workload), and the qa.jl ignore list is itself stale — it lists `:lorenz_p`/`:lorenz_p_params` while the code uses `lorenz_pref`/`lorenz_pref_params`. The job is path-filtered, so master commits that do not touch lib/OrdinaryDiffEqBDF never run it; this PR does, which is why it surfaced here. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bHrkkvJzfYPMMJMRJ5GqX
Please ignore this PR until reviewed by @ChrisRackauckas. Opened as a draft.
Adds a variable-order variable-step BDF pair built on a propagated Nordsieck
history array
zn[j] = h^j/j! * y^(j)(t_n), ported from SUNDIALS CVODE:NordsieckBDF— ODE / mass-matrix formDNordsieckBDF— fully implicit DAE form,f(du, u, p, t) = 0Both support in-place and out-of-place problems.
Why
FBDFstores raw(t_i, u_i)history and reconstructs the predictor (Lagrange)and the error/order estimates (finite-difference stencils) every step. A residual
left in that stored history by the nonlinear solve gets amplified by the
extrapolating predictor, which inflates
EEstand shrinks the step — so FBDF hasto over-solve the corrector to keep its own step size. Measured on the stiff suite,
FBDF runs ~2.5 Newton iterations/step against CVODE's ~1.5, and that accounts for
essentially the whole FBDF↔CVODE gap.
The Nordsieck array propagates rather than reconstructs: predicting is a
Pascal-triangle shift, a step-size change is
zn[j] *= eta^j, and accepting is arank-1 update. Nothing is rebuilt from stored points, so a loose corrector cannot
collapse the step size.
I arrived at this after implementing and benchmarking seven localized fixes to
FBDF (cheaper nonlinear tolerance,
EEst-driven step sizing, Gustafssonpredictive control, a true predictor-polynomial FLC corrector, Jacobian/W reuse
policy, CVODE's crate convergence test, CVODE's never-shrink step policy) — every
one was neutral or negative, because each policy works and the history
representation then cancels it.
Results
Matched accuracy over ROBER/HIRES/OREGO/POLLU/VDPOL/BRUSS1D (min-of-5 timing, L2
error vs
Rodas5Pat 1e-14, log-log interpolation onto common accuracy targets):So ~35% faster than FBDF, with a third fewer W factorizations. It does not yet
reach CVODE — the residual gap is the nonlinear convergence test's iteration
economy (the package's Hairer–Wanner
η·‖dz‖ < κtest withmax_iter = 10against CVODE's crate test with
MAXCOR = 3), which is shared infrastructurerather than anything about the history representation. I left the default
NLNewton()alone rather than ship a tuned default I can't fully justify.On DAEs at a fixed tolerance,
DNordsieckBDFvsDFBDF: index-1 test 92 steps /116 nf vs 150 / 174; Robertson DAE 459 / 962 vs 616 / 1408.
Relation to the existing
JVODE_BDFOrdinaryDiffEqNordsieckalready hasJVODE_BDF, but it uses functionaliteration (
nlsolve_functional!), so it is not usable on stiff problems — onROBER it hits MaxIters at 2e5 steps, on HIRES it returns 55% error, and on OREGO
it throws
StackOverflowError. It is also marked experimental and still carries a# TODO: BDFin its order-increase path. These are the first Newton-basedNordsieck BDF solvers in the package. Happy to move them into
OrdinaryDiffEqNordsieckinstead if you'd prefer them to live alongside JVODE.Implementation notes
COEFFICIENT_MULTISTEPwithgamma = 1/l1. DAE: correction relative tocache.u₀, givingcj = l1/h— the same role IDA'scjplays. Both givegamma*W = dt/l1, so the existing W and Jacobian-reuse machinery is untouched.NLNewton(κ = …)acts as CVODE's NLSCOEF (the fraction of the local errorbudget the corrector may consume), because the increment norm is scaled by the
test quantity
tq[2]through the existinghas_special_newton_errorhook.fsallastisrecovered algebraically with no extra
fevaluation.OrdinaryDiffEqBDF.jlandruntests.jl. No existing solver's code path is modified.Testing
New
nordsieck_tests.jl(9 testsets, all passing locally against masterd8cd163): adaptive accuracy and tolerance scaling for both iip and oop,max_order1–5, five stiff problems, dense output, mass matrices, callbackdiscontinuity restart, index-1 DAEs (iip + oop), Robertson DAE, and the
loose-corrector step-count property. Existing
bdf_regression_testsanddae_convergence_testspass unchanged. Runic clean.Correctness was additionally checked by verifying the Nordsieck invariant
zn[j] = h^j/j! * y^(j)holds on every column against an analytic solution.Two notes for reviewers, neither introduced here:
prob_ode_filamentthrows aDimensionMismatchunder both FBDF and NordsieckBDF on current master; andFBDF returns
Unstableonprob_ode_vanderpol_stiffat abstol=1e-12/reltol=1e-10.🤖 Generated with Claude Code
https://claude.ai/code/session_016bHrkkvJzfYPMMJMRJ5GqX