Stop the cache-less JVP from overwriting the caller's x - #231
Merged
ChrisRackauckas merged 1 commit intoAug 8, 2026
Merged
Conversation
finite_difference_jvp! built its internal cache with the non-allocating JVPCache(x, fx, fdtype) constructor, so cache.x1 === x and the in-place perturbation overwrote the caller's input. With fdtype = Val(:central) and f_in supplied it also overwrote f_in and returned half the correct JVP, because the restored x1 put the second evaluation back at x. The cache-less path now perturbs a copy of x and never writes into f_in. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas
marked this pull request as ready for review
August 8, 2026 13:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ignore until reviewed by @ChrisRackauckas.
What changed and why
The cache-less
finite_difference_jvp!built its internal cache with the non-allocatingJVPCache(x, fx, fdtype)constructor, socache.x1 === x; the in-place perturbation@. x1 = x + ϵ*vthen overwrote the caller's input array. Withfdtype = Val(:central)andf_insupplied it was worse: the caller'sf_inwas also clobbered (it was handed over as the cache'sfx1scratch), and the returned JVP was half the correct value, because afterx1 = x - ϵvthe "second" evaluationx1 = x + ϵvlanded back onxrather thanx + ϵv.The cache-less path now perturbs a copy of
x, and treatsf_inas read-only. No public API changes; the cached methods and theJVPCacheconstructors are untouched.Reproducer (on master,
58ad147)Before:
After:
(
central, f_inshowsxchanged=falseonly because(x - ϵv) + ϵvhappened to round back toxfor these values; that is not guaranteed, and the halved answer is the real damage. Thef_inarray is observably clobbered — see the failing test output below.)Affected methods, established by direct test rather than inspection:
finite_difference_jvp!,:forward, nof_inxoverwrittenfinite_difference_jvp!,:forward, withf_inxoverwrittenfinite_difference_jvp!,:central, withf_inf_inoverwritten, result halvedfinite_difference_jvp!,:central, nof_infinite_difference_jvp(out-of-place)cache.x1(it rebindsx1 = @. x + ϵ*v) — left untouchedfinite_difference_jacobian!/gradient/hessiancache-lessxunchanged;JacobianCache(x, fx, …)andHessianCache(x, …)already copyPre-existing: identical code in
v2.32.1, so this predates #230.Design tradeoff
Three options were on the table:
xinto the cache (chosen).try/finally, and floating-point(x - ϵv) + ϵvis not bit-exact in general, so it silently returns a slightly differentxthan it was given.xmay be mutated.(3) was ruled out on evidence: the cache-less method's docstring says only "Cache-less." and documents no mutation;
docs/src/jvp.mddocuments none; the mutation contract that does exist is on the non-allocatingJVPCache(x, fx1, fdtype)constructor ("The arraysxandfx1will be modified during JVP computations") — i.e. mutation is the documented behavior of the opt-in path, which this PR leaves exactly as-is. Nothing in the test suite or docs asserts that the cache-less path is non-allocating; there is no@allocatedtest anywhere near JVP. (test/jvp_accuracy_tests.jldefensively passescopy(x_big)everywhere, which is itself a symptom of this bug.)Allocation cost, measured with
@allocatedatn = 1000(oneVector{Float64}= 8000 bytes)::forward, nof_in:forward, withf_in:central, nof_in:central, withf_inThe cache-less path was never allocation-free except in the
f_incases, and it was allocation-free there precisely because it was writing into the caller's arrays. Callers who want zero allocation already have the supported route: build aJVPCacheonce and call the cached method. Silently corrupting caller data is not a performance feature.Failing before / passing after
git stash push src/jvp.jl(test present, fix reverted), theninclude("test/cache_reuse_tests.jl"):git stash pop, same command:Existing suite
GROUP=Core julia +1.12 --project -e 'using Pkg; Pkg.test()'on this branch:Nothing broke. The single
Brokenis the pre-existing@test_brokenattest/finitedifftests.jl:277(a forward-mode gradient tolerance), present on master and unrelated. No test encoded the aliasing behavior.Relationship to #230
#230 merged (
58ad147) and was released asv2.33.0before this branch was cut, so this branches from a master that already contains it and there is no conflict — the diff does not touchjvp_epsilonor any step-size code.Versioning
Patch:
2.33.0 → 2.33.1.2.33.0is already in the General registry, so master's version is taken. This adds no public API and removes none; the behavior change is the removal of undocumented data corruption.Not verified
GROUP=Downstream(OrdinaryDiffEq) — not run; it exceeded my time budget while instantiating. This changes allocation counts on a code path OrdinaryDiffEq's matrix-free JVPs may use, so that job is worth watching in CI..JuliaFormatter.tomland no format/typos CI in this repo. Current Runic wants ~420 lines of unrelated changes tosrc/jvp.jlon master alone, so running it here would be a mechanical sweep that belongs in its own PR.typosover the touched files is clean.Arrayinputs (StaticArrays, GPU arrays) were not exercised for the JVP path; the fix relies only oncopy/zero.For a reviewer to push back on
finite_difference_jvp!with:centraland nof_inbuildsJVPCache(x, fdtype), whosefx1iscopy(x)— i.e. it assumes a square Jacobian. For a 2→3 map it throwsBoundsError, for 3→2DimensionMismatch. That fails loudly rather than silently, so it is a separate issue; the:central-with-f_inbranch added here happens to be immune becausezero(f_in)has the right size.