JuliaDiff / JuliaDiff/DifferentiationInterface.jl
AutoEnzyme forward-mode jacobian() silently returns wrong values for closure-captured arrays reused unmodified in vcat
Nobody has claimed this yet.
- Dominant language
- Julia
- Stars
- 313
- Forks
- 35
- PR merge metrics
- No merged PRs in 30d
Description
Summary
DifferentiationInterface.jacobian(f, AutoEnzyme(; mode=Enzyme.Forward), y) silently returns numerically wrong values (no error, no warning) for a fairly simple closure. AutoEnzyme(; mode=Enzyme.Reverse) and AutoForwardDiff() both give the correct answer on the same function, which is how I noticed the discrepancy.
I ran into this while debugging Enzyme rules for ImplicitDifferentiation.jl (see PR #221), where the internal Jacobian built via DifferentiationInterface.jacobian with an Enzyme forward backend came out wrong for any conditions function with more than one output component. I've since isolated it to a minimal, ImplicitDifferentiation-free reproduction below.
MWE
using DifferentiationInterface, ADTypes
import Enzyme
using ForwardDiff # for the ground truth
x = [1.0, 2.0, 3.0]
f(y) = y .- vcat(x .+ 1, x) # closes over `x`; `x` appears both transformed and bare in the same vcat
y = collect(1.0:6.0)
backend = AutoEnzyme(; mode=Enzyme.set_runtime_activity(Enzyme.Forward), function_annotation=Enzyme.Const)
J_enzyme = DifferentiationInterface.jacobian(f, backend, y)
J_true = DifferentiationInterface.jacobian(f, AutoForwardDiff(), y)
println("Enzyme forward:\n", J_enzyme)
println("ForwardDiff (truth):\n", J_true)
println("Match: ", J_enzyme ≈ J_true)
Output:
Enzyme forward:
[1.0 0.0 0.0 0.0 0.0 0.0; 0.0 1.0 0.0 0.0 0.0 0.0; 0.0 0.0 1.0 0.0 0.0 0.0; -1.0 -1.0 -1.0 0.0 -1.0 -1.0; -2.0 -2.0 -2.0 -2.0 -1.0 -2.0; -3.0 -3.0 -3.0 -3.0 -3.0 -2.0]
ForwardDiff (truth):
[1.0 0.0 0.0 0.0 0.0 0.0; 0.0 1.0 0.0 0.0 0.0 0.0; 0.0 0.0 1.0 0.0 0.0 0.0; 0.0 0.0 0.0 1.0 0.0 0.0; 0.0 0.0 0.0 0.0 1.0 0.0; 0.0 0.0 0.0 0.0 0.0 1.0]
Match: false
The correct Jacobian is the identity (since vcat(x .+ 1, x) doesn't depend on y at all). The Enzyme-forward result instead has a spurious, roughly -batch_index-scaled contamination in the bottom-right 3×3 block, which is exactly the block corresponding to the bare, unmodified x inside the vcat. AutoEnzyme(; mode=Enzyme.Reverse) gives the correct identity matrix on the same f, so this looks specific to the forward-mode batching path.
Isolating the trigger
I bisected this a fair amount (see table below, y = collect(1.0:6.0), x = [1.0, 2.0, 3.0] closed over throughout):
f(y) = y .- ... |
Enzyme forward matches ForwardDiff? |
|---|---|
vcat(x, x) |
✅ OK |
vcat(x .+ 1, x) |
❌ wrong |
vcat(x, x .+ 1) |
❌ wrong |
vcat(x .+ 1, x .+ 1) |
✅ OK |
vcat(x .+ 1, copy(x)) |
✅ OK |
vcat(x .+ 1, x .+ 0) |
✅ OK |
[x .+ 1; x .+ 1] (bracket syntax) |
✅ OK |
a plain closure-captured constant vector c (no vcat) |
✅ OK |
same function but x/additions inlined as literals (no closure capture) |
✅ OK |
x precomputed once outside the closure, then subtracted |
✅ OK |
So the trigger seems to require all of:
xis captured from an enclosing scope (not a literal, not passed as aConstantcontext argument via DI — I checked, that's fine too),xisvcat-ed together with a freshly computed array derived from itself (x .+ 1) and the same, unmodifiedxreference in the same call,- the output has more than one component (batched forward mode); scalar/1-row cases are unaffected.
It smells like Enzyme's batched forward-mode is misattributing/aliasing the shadow of the bare x reference once a transformed copy of x also appears in the same vcat, and the resulting garbage happens to scale with the batch index (compare the -1/-2/-3 row-wise pattern in the wrong output above).
Environment
julia> versioninfo()
Julia Version 1.12.6
(test) pkg> status DifferentiationInterface Enzyme EnzymeCore ADTypes ForwardDiff
[47edcb42] ADTypes v1.23.0
[a0c0ee7d] DifferentiationInterface v0.7.20
[7da242da] Enzyme v0.13.199
[f6369f11] ForwardDiff v1.4.5
Both Enzyme and DifferentiationInterface are at their latest released versions as of this report. I haven't yet determined whether this belongs in DifferentiationInterface's batching logic or in Enzyme.jl itself — a raw Enzyme.autodiff(Forward, ...) call with manually constructed BatchDuplicated seeds on the same f hit a different, unrelated compile-time error for me (runtime_generic_fwd) rather than reproducing the silent wrong-value result, so I wasn't able to fully rule Enzyme.jl in or out; happy to dig further or move this issue if it turns out to belong upstream.
Why this is worth flagging
Unlike most Enzyme issues I've seen filed here, this doesn't throw — it silently returns a plausible-looking but wrong matrix, which is much more dangerous for downstream correctness (it's what led to the ImplicitDifferentiation.jl Enzyme rules I was reviewing looking correct at a glance but failing numerical cross-checks against ForwardDiff).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the supplied Julia MWE and compare DifferentiationInterface.jacobian with AutoEnzyme forward mode against AutoForwardDiff. Trace the AutoEnzyme forward batching path, using the mentioned raw Enzyme.autodiff entry point if needed; done means the reproduced Jacobian is the identity and a regression test prevents the silent mismatch.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- julia
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100