JuliaDiff / JuliaDiff/DifferentiationInterface.jl

AutoEnzyme forward-mode jacobian() silently returns wrong values for closure-captured arrays reused unmodified in vcat

Open
#1,057 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

backend
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:

  • x is captured from an enclosing scope (not a literal, not passed as a Constant context argument via DI — I checked, that's fine too),
  • x is vcat-ed together with a freshly computed array derived from itself (x .+ 1) and the same, unmodified x reference 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.