Skip to content

Scale the JVP finite difference step by norm(x)/norm(v) - #230

Merged
ChrisRackauckas merged 1 commit into
JuliaDiff:masterfrom
ChrisRackauckas-Claude:fix/jvp-step-size-scaling
Aug 8, 2026
Merged

Scale the JVP finite difference step by norm(x)/norm(v)#230
ChrisRackauckas merged 1 commit into
JuliaDiff:masterfrom
ChrisRackauckas-Claude:fix/jvp-step-size-scaling

Conversation

@ChrisRackauckas-Claude

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

Copy link
Copy Markdown

Please ignore until reviewed by @ChrisRackauckas.

What changed and why

finite_difference_jvp/finite_difference_jvp! computed their step as
epsilon = max(relstep*sqrt(|dot(x,v)|), absstep)*dir. That expression is dimensionally
wrong: the step multiplies v, so it must carry units of [x]/[v], while sqrt(x⋅v) carries
sqrt([x][v]). This replaces it with epsilon = max(relstep*norm(x), absstep)*dir/norm(v), so the
perturbation epsilon*v is a relstep relative change of x floored at absstep — the same rule
SparseDiffTools.num_jacvec! used, and the same rule the colored-Jacobian path in jacobians.jl
already uses for its own perturbation directions.

The dimensional argument

The JVP evaluates f(x + h*v), so h is not a step in x and is not dimensionless: h*v must be
a perturbation of x, hence h ~ [x]/[v].

  • Old: h = max(relstep*sqrt(|x⋅v|), absstep). For a unit-norm direction (the Krylov/Arnoldi case)
    and a random v, |x⋅v| ~ norm(x)/sqrt(n), so sqrt(|x⋅v|) stays O(1) no matter how large the
    state is, and h sits at the absstep floor (~1.5e-8 for Val(:forward) with the default
    relstep = absstep = sqrt(eps())) forever. It is also not invariant to rescaling v: the
    perturbation grows like norm(v)^1.5 instead of being independent of norm(v).
  • New: h = max(relstep*norm(x), absstep)*dir/norm(v), i.e. norm(h*v) = max(relstep*norm(x), absstep).
    Scaling v by s scales h by 1/s and leaves the computed JVP unchanged (up to the floor).

norm(v) == 0 falls back to the unscaled step (so v == 0 returns exactly zero rather than NaN),
and a non-finite norm(x) falls back to the absstep floor rather than making every output NaN.

Accuracy

2D Brusselator, Val(:forward), 20 unit-norm random directions per size, exact reference from a
ForwardDiff dual; relerr = norm(jvp - exact)/norm(exact). Old rule evaluated with the same code
path and the old epsilon formula:

N=   8 n=   128 |x|=    22.0  eps_old=1.515e-08 relerr_old=1.249e-07 | eps_new=3.284e-07 relerr_new=6.183e-09 | ratio=   20.2
N=  16 n=   512 |x|=    45.6  eps_old=1.700e-08 relerr_old=2.657e-07 | eps_new=6.795e-07 relerr_new=6.611e-09 | ratio=   40.2
N=  32 n=  2048 |x|=    92.7  eps_old=1.722e-08 relerr_old=5.340e-07 | eps_new=1.381e-06 relerr_new=6.711e-09 | ratio=   79.6
N=  64 n=  8192 |x|=   186.9  eps_old=1.616e-08 relerr_old=1.092e-06 | eps_new=2.785e-06 relerr_new=6.279e-09 | ratio=  173.9
N= 128 n= 32768 |x|=   375.3  eps_old=1.631e-08 relerr_old=2.292e-06 | eps_new=5.592e-06 relerr_new=6.727e-09 | ratio=  340.7

The old rule's error grows linearly with norm(x) because the step is stuck at the floor; the new
rule's error is flat. The regime is roundoff-dominated (relerr ∝ 1/h), so being 70× low in h
costs 70× in accuracy — h sweep at N=32:

  h=1e-12  relerr=8.961e-03
  h=1e-10  relerr=8.907e-05
  h=1e-08  relerr=9.104e-07
  h=1e-07  relerr=8.997e-08
  h=1e-06  relerr=9.472e-09
  h=1e-05  relerr=9.191e-10
  h=1e-04  relerr=2.921e-10
  h=1e-03  relerr=2.703e-09
  h=1e-02  relerr=2.702e-08
  eps_old=3.003e-08 eps_new=1.381e-06

Invariance to the scaling of v (same problem, same direction, v scaled by s, result divided
by s):

  scale=1e-06  relerr_new=6.742e-09  relerr_old=5.479e-01
  scale=1e-03  relerr_new=6.742e-09  relerr_old=6.093e-04
  scale=1e+00  relerr_new=6.742e-09  relerr_old=3.108e-07
  scale=1e+03  relerr_new=6.742e-09  relerr_old=2.567e-09
  scale=1e+06  relerr_new=6.742e-09  relerr_old=8.117e-05

Why it matters downstream

Matrix-free Krylov cannot see the inaccurate operator. GMRES (Krylov.jl) on the same Brusselator
with A = J - I/gamma applied through each JVP variant: identical iteration counts and identical
"converged" status, but the residual measured against the exact (ForwardDiff) operator is 30–70×
worse with the old step.

gamma      variant        rtol      iters   status                         true_rel_resid
1e-03      exact(AD)      1.49e-08      66   solution good enough given a   1.390e-08
1e-03      fd-new(pkg)    1.49e-08      66   solution good enough given a   5.000e-08
1e-03      fd-old         1.49e-08      66   solution good enough given a   2.991e-06
1e-02      exact(AD)      1.49e-08     115   solution good enough given a   1.101e-08
1e-02      fd-new(pkg)    1.49e-08     115   solution good enough given a   2.757e-07
1e-02      fd-old         1.49e-08     115   solution good enough given a   1.114e-05
1e-01      exact(AD)      1.49e-08     140   solution good enough given a   1.487e-08
1e-01      fd-new(pkg)    1.49e-08     140   solution good enough given a   2.139e-06
1e-01      fd-old         1.49e-08     140   solution good enough given a   7.284e-05

In OrdinaryDiffEq's matrix-free Krylov path that inaccurate Newton direction makes Newton stagnate,
burn its iteration budget, record a convergence failure and collapse dt.

End-to-end solver impact

Brusselator N=32 (2048 unknowns), KenCarp4(linsolve = KrylovJL_GMRES(), concrete_jac = false),
abstol = reltol = 1e-8, tspan = (0.0, 11.5), one solve per process, wall clock including compile:

build autodiff retcode wall clock naccept nreject nnonlinconvfail nsolve
this PR AutoForwardDiff() Success 40.9s 135 36 0 2694
this PR AutoFiniteDiff() Success 42.0s 135 36 0 2694
released 2.32.1 AutoFiniteDiff() did not finish in 1800s

With the corrected step, AutoFiniteDiff reaches 1.03x the ForwardDiff wall clock with bit-identical
step statistics. With the old step the same solve was still running when I killed it at a 30 minute
cap, i.e. >43x slower and not converging: Newton cannot make progress on an operator that is
100-5000x less accurate than the tolerance it is being asked to hit, so it exhausts its iteration
budget, records a convergence failure and dt collapses. (nf is higher for AutoFiniteDiff
because DifferentiationInterface recomputes f(x) per matvec — see the note at the bottom.)

Tests: failing before, passing after

New file test/jvp_accuracy_tests.jl: f_i(x) = x_i * x_{i+1} (cyclic) at norm(x) ≈ 2164 with a
deterministic unit direction, checked against the analytically exact J*v; plus v-scaling
invariance, degenerate v == 0 / x == 0 / NaN-in-x behaviour, and a complex-valued state.

With src/jvp.jl stashed (i.e. the old step rule, everything else identical):

JVP step size scales with norm(x): Test Failed at test/jvp_accuracy_tests.jl:22
  Expression: relerr(FiniteDiff.finite_difference_jvp(fcyc, copy(x_big), v_unit), ref_big) < 1.0e-7
   Evaluated: 2.691754109246403e-6 < 1.0e-7
JVP step size scales with norm(x): Test Failed at test/jvp_accuracy_tests.jl:25
  Expression: relerr(jvp, ref_big) < 1.0e-7
   Evaluated: 2.691754109246403e-6 < 1.0e-7
JVP step size scales with norm(x): Test Failed at test/jvp_accuracy_tests.jl:27
  Expression: relerr(FiniteDiff.finite_difference_jvp(fcyc, copy(x_big), v_unit, Val{:central}), ref_big) < 1.0e-10
   Evaluated: 5.359559035026237e-9 < 1.0e-10
JVP step size scales with norm(x): Test Failed at test/jvp_accuracy_tests.jl:30
  Expression: relerr(jvp, ref_big) < 1.0e-10
   Evaluated: 5.359559035026237e-9 < 1.0e-10
JVP is invariant to the scaling of v: Test Failed at test/jvp_accuracy_tests.jl:37
  Expression: relerr(scaled, base) < 1.0e-8
   Evaluated: 0.07123010316723483 < 1.0e-8
JVP is invariant to the scaling of v: Test Failed at test/jvp_accuracy_tests.jl:38
  Expression: relerr(scaled, ref_big) < 1.0e-7
   Evaluated: 0.07123008225350437 < 1.0e-7
JVP is invariant to the scaling of v: Test Failed at test/jvp_accuracy_tests.jl:37
  Expression: relerr(scaled, base) < 1.0e-8
   Evaluated: 2.6904504585643056e-6 < 1.0e-8
JVP is invariant to the scaling of v: Test Failed at test/jvp_accuracy_tests.jl:37
  Expression: relerr(scaled, base) < 1.0e-8
   Evaluated: 1.0322574174012406e-5 < 1.0e-8
JVP is invariant to the scaling of v: Test Failed at test/jvp_accuracy_tests.jl:38
  Expression: relerr(scaled, ref_big) < 1.0e-7
   Evaluated: 1.0257164449764475e-5 < 1.0e-7
JVP degenerate directions and states: Test Failed at test/jvp_accuracy_tests.jl:57
  Expression: count(isnan, nanjvp) == 2
   Evaluated: 200 == 2
JVP complex-valued state: Test Failed at test/jvp_accuracy_tests.jl:65
  Expression: relerr(FiniteDiff.finite_difference_jvp(fcyc, copy(xc), vc), refc) < 1.0e-7
   Evaluated: 3.7920290032038712e-6 < 1.0e-7
JVP complex-valued state: Test Failed at test/jvp_accuracy_tests.jl:68
  Expression: relerr(jvpc, refc) < 1.0e-7
   Evaluated: 3.7920290032038712e-6 < 1.0e-7

Test Summary:                          | Pass  Fail  Total  Time
JVP step size and accuracy             |    5    12     17  5.9s
  JVP step size scales with norm(x)    |          4      4  4.4s
  JVP is invariant to the scaling of v |    1     5      6  0.1s
  JVP degenerate directions and states |    4     1      5  0.3s
  JVP complex-valued state             |          2      2  1.1s
ERROR: LoadError: Some tests did not pass: 5 passed, 12 failed, 0 errored, 0 broken.

With the fix applied:

Test Summary:              | Pass  Total  Time
JVP step size and accuracy |   17     17  2.9s

Existing suite

GROUP=Core julia --project -e 'using Pkg; Pkg.test()' on Julia 1.12.6, unchanged assertions and
unchanged tolerances — nothing in the existing suite encoded the old step rule:

Test Summary:      | Pass  Total  Time
Public API Tests |   30     30  0.5s
Test Summary:             | Pass  Broken  Total   Time
FiniteDiff Standard Tests |  219       1    220  48.7s
Test Summary:               | Pass  Total     Time
Color Differentiation Tests |   39     39  4m53.9s
Test Summary:      | Pass  Total  Time
JVP Accuracy Tests |   17     17  3.2s
Test Summary:      | Pass  Total   Time
Out of Place Tests |   28     28  54.4s
Test Summary:            | Pass  Total  Time
Cache Reuse Safety Tests |   27     27  7.6s
     Testing FiniteDiff tests passed

(The one Broken is pre-existing on master.)

GROUP=Downstream errors, but identically on unmodified master — it is not caused by this PR:

OrdinaryDiffEq Tridiagonal: Error During Test
  LoadError: ArgumentError: array of type LinearAlgebra.Tridiagonal{Float64, Vector{Float64}} and size (10, 10) can
      not be filled with 1.0, since some of its entries are constrained.
   [2] build_J_W(alg::OrdinaryDiffEqRosenbrock.Rodas4P{ADTypes.AutoSparse{ADTypes.AutoForwardDiff{...}}, ...)
     @ OrdinaryDiffEqDifferentiation ~/.julia/packages/OrdinaryDiffEqDifferentiation/G755Y/src/derivative_utils.jl:1242
  [14] top-level scope
     @ test/downstream/ordinarydiffeq_tridiagonal_solve.jl:20

I ran GROUP=Downstream twice, once on this branch and once on a git worktree of origin/master
(9bae080) with no other differences: both fail with the same fill!-on-Tridiagonal error, from
solve(prob, Rodas4P(), saveat=0.1) on line 20 — the default AutoForwardDiff path, before any
AutoFiniteDiff solve is reached. It is an upstream OrdinaryDiffEqDifferentiation problem and is
being tracked separately.

Docs build: julia --project=docs docs/make.jl exits 0 (only the pre-existing :missing_docs
warning list, which make.jl already sets to warnonly). typos is clean over the diff. The repo
declares no formatter (.JuliaFormatter.toml absent, no format CI), so nothing was reformatted;
the new code follows the surrounding style.

Versioning

2.32.1 → 2.33.0. No exported name, signature or type changes, but this changes the numerical output
of finite_difference_jvp/finite_difference_jvp! for every caller, so it is not a patch. I judged
it a minor bump rather than 3.0 because the change is an accuracy fix inside an approximation rather
than an API break, and a major bump would strand the ecosystem's FiniteDiff = "2" bounds. Push
back if you would rather ship it as 3.0.

What I did not verify

  • No GPU/CUDA path was exercised.
  • No unitful/AD element types were exercised. The new rule uses norm(x) where the old used
    sqrt(|dot(x,v)|); both mix absstep with a quantity carrying units of x, so unitful states
    are no better and no worse than before, but I did not test them.
  • No NonlinearSolve or DifferentiationInterface test suite was run. The OrdinaryDiffEq
    InterfaceII integration group was run locally against this branch: AutoSparse Detection Tests
    passed 4/4, then Enum Tests errored on an OrdinaryDiffEq master test-file bug
    (UndefVarError: DiscreteProblem) which aborts the group.
  • The released-2.32.1 end-to-end row above is a lower bound, not a measurement: I capped it at 1800s
    rather than letting it run to completion, so "how much slower" is unquantified beyond >43x.

Related, deliberately not in this PR

  • DifferentiationInterface's pushforward! for AutoFiniteDiff never passes f_in to
    finite_difference_jvp!, so f(x) is recomputed on every matvec — a 2× cost in matrix-free
    Krylov. Different repo, separate PR.
  • finite_difference_jvp!(jvp, f, x, v, fdtype, f_in) (the cache-less in-place method) builds its
    cache with JVPCache(x, fx, fdtype), the non-allocating constructor, so x1 === x and the
    user's x is silently overwritten with x + epsilon*v. Reproduces on released 2.32.1
    (x = [1.0,2.0,3.0] comes back as [1.0000000149011612, 2.0, 3.0]), so it is pre-existing and
    independent of this change. Separate PR.

The JVP step multiplies `v`, so it must have units of `[x]/[v]`. The old rule
`max(relstep*sqrt(|dot(x,v)|), absstep)` has units of `sqrt([x][v])`, which pins
the step near the `absstep` floor for unit-norm directions and makes the result
depend on the scaling of `v`. Use `max(relstep*norm(x), absstep)*dir/norm(v)`
instead, so the perturbation `epsilon*v` is a `relstep` relative change of `x`.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Author

CI status on the first push, with both red jobs traced to pre-existing breakage rather than to this PR:

I also ran that integration group locally against this branch (GROUP=InterfaceII, OrdinaryDiffEq master, Pkg.develop of this FiniteDiff): AutoSparse Detection Tests | 4 4 2m14.3s passed, then Enum Tests errored with the same DiscreteProblem UndefVarError, which aborts the group before Get du Tests.

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Author

Root cause of the red Downstream job, now bisected (independent of this PR):

build_J_W gained an unconditional fill!(J, one(eltype(J))) on the jac_prototype branch in SciML/OrdinaryDiffEq.jl@d8cbb9e (PR SciML/OrdinaryDiffEq.jl#3833, first released in OrdinaryDiffEqDifferentiation v3.2.3). fill! with a nonzero value throws for every LinearAlgebra structured type, so any Tridiagonal jac_prototype errors at cache construction — which is exactly test/downstream/ordinarydiffeq_tridiagonal_solve.jl:20, under the default AutoForwardDiff.

Standalone reproducer with no FiniteDiff involved:

using OrdinaryDiffEq, OrdinaryDiffEqRosenbrock, LinearAlgebra
const n = 10
f(du, u, p, t) = (du .= 0; du[2:(end-1)] .= p[1] .* (u[3:end] .- 2 .* u[2:(end-1)] .+ u[1:(end-2)]); nothing)
u0 = sin.(range(0, 1, length = n))
jp = Tridiagonal(similar(u0, n-1), similar(u0), similar(u0, n-1))
prob = ODEProblem(ODEFunction(f; jac_prototype = jp), u0, (0.0, 1.0), [0.42])
solve(prob, Rodas4P(), saveat = 0.1)   # ArgumentError from fill! in build_J_W

Three open draft PRs upstream already fix this fill! (SciML/OrdinaryDiffEq.jl#3913, SciML/OrdinaryDiffEq.jl#3942, SciML/OrdinaryDiffEq.jl#4072), so no new one was opened. FiniteDiff's Downstream group will stay red on master until one of them merges and OrdinaryDiffEqDifferentiation gets a patch release.

@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review August 8, 2026 12:01
@ChrisRackauckas
ChrisRackauckas merged commit 58ad147 into JuliaDiff:master Aug 8, 2026
4 of 6 checks passed
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.

2 participants