SciML / SciML/FunctionWrappersWrappers.jl
Enzyme reverse mode: wrong gradient for IIP wrapped function when args are mutated after the call
Nobody has claimed this yet.
- Dominant language
- Julia
- Stars
- 12
- Forks
- 6
- Avg merge
- 27m
- Merged PRs (30d)
- 3
Description
Summary
Reverse-mode Enzyme through a FunctionWrappersWrapper around an in-place function returns a silently wrong gradient when the caller mutates the wrapped function's arguments after the call — which is exactly what an ODE integrator does (f!(du,u,p,t) then step u). A single isolated call is correct; the bug only appears once an argument is mutated afterwards.
This is the root cause of the EnsembleProblem Enzyme adjoint failure in SciML/SciMLSensitivity.jl#1424 (under the default AutoSpecialize, DiffEqBase.wrapfun_iip wraps the ODE rhs in a FunctionWrappersWrapper, and the whole-solve Enzyme adjoint steps u between rhs calls). In the full nested ensemble it manifests as the Julia-1.11 _dispatch_ensemble_solve GC-root-rewrite segfault; reduced, it is a clean wrong-gradient.
MWE (self-contained)
using FunctionWrappersWrappers: FunctionWrappersWrapper
using Enzyme, FiniteDiff
using Enzyme: Reverse, set_runtime_activity
f!(du,u,p,t) = (du[1] = p[1]*u[1]; du[2] = p[2]*u[2]^2; nothing)
const ARGT = Tuple{Vector{Float64},Vector{Float64},Vector{Float64},Float64}
function loss(p)
u = [1.5, 2.0]; du = zero(u)
wf = FunctionWrappersWrapper(f!, (ARGT,), (Nothing,))
wf(du, u, p, 0.0) # du = f!(u,p) through the wrapper
@. u = u + 0.05*du # integrator-style mutation of u AFTER the call
du[1]^2 + du[2]^2 # loss reads du only
end
p = [0.7, 0.4]
Enzyme.gradient(set_runtime_activity(Reverse), loss, p) # -> [3.26, 13.84] WRONG
FiniteDiff.finite_difference_gradient(loss, p) # -> [3.15, 12.80] correct
# deleting the `@. u = u + 0.05*du` line makes Enzyme correct
Verified on Julia 1.11.9, Enzyme 0.13.172/0.13.173, FunctionWrappersWrappers 1.9.3.
The wrong value is explainable exactly: the rule evaluates ∂du/∂p at the mutated u[1] = 1.5 + 0.05*du[1] = 1.5525 instead of the call-time 1.5, so ∂loss/∂p1 = 2*du1*u1 = 2*1.05*1.5525 = 3.26 instead of 2*1.05*1.5 = 3.15.
Root cause
In ext/FunctionWrappersWrappersEnzymeExt.jl, the Const-return (IIP) reverse path:
EnzymeRules.augmented_primal(..., RT::Type{<:Const}, args...)runs the primal and returnsAugmentedReturn(nothing, nothing, nothing)— no tape.EnzymeRules.reverse(..., dret::Type{<:Const}, tape, args...)recomputes the VJP withEnzyme.autodiff(Reverse, Const(f_orig), Const, args...)— i.e. against the arguments' current.vals.
Because nothing snapshots the call-time argument state, any mutation of an argument between the forward call and the reverse pass makes the recomputation differentiate f_orig about the wrong state. An integrator mutates u on every step, so every per-step gradient is wrong.
Fix
Snapshot the call-time argument values in augmented_primal's tape, and in reverse temporarily restore them before recomputing the VJP (then put the live values back). PR incoming.
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
Start in ext/FunctionWrappersWrappersEnzymeExt.jl, tracing the Const-return augmented_primal and reverse paths described in the issue. Use the self-contained MWE and compare Enzyme.gradient with FiniteDiff; done means mutated arguments no longer change the call-time VJP and the reduced gradient matches [3.15, 12.80].
Written by the indexing model from the issue text.
Assessment
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100