JuliaDiff / JuliaDiff/DifferentiationInterface.jl

Bug(Enzyme): SecondOrder(ForwardDiff, AutoEnzyme(Reverse)) silently returns 0 from second_derivative

Open
#1,017 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bot
Dominant language
Julia
Stars
313
Forks
35
PR merge metrics
No merged PRs in 30d

Description

Description

second_derivative returns 0.0 for every scalar input when using SecondOrder(AutoForwardDiff(), AutoEnzyme(; mode=Enzyme.Reverse)). The expected combination is forward-outer over reverse-inner, which the docs recommend as the best-performance pattern.

The root cause is that the inner derivative operator returns a ForwardDiff.Dual with the correct primal but a 0.0 partial:

derivative(t -> t^4, AutoEnzyme(; mode=Enzyme.Reverse), Dual(1.5, 1.0))
# → Dual(13.5, 0.0)    # partial should be 27.0 (d²/dt² t⁴ at 1.5)

The outer ForwardDiff.derivative then extracts 0.0. For comparison, the same pattern with AutoZygote as the inner backend returns Dual(13.5, 27.0) and the full second_derivative returns 27.0.

The Hessian and HVP variants of SecondOrder(FD, Enzyme.Reverse) fail loudly (Enzyme: Active return values with automatic pullback ... not ForwardDiff.Dual), so the silent failure is specific to second_derivative for scalar inputs.

MWE

using DifferentiationInterface
using ForwardDiff: ForwardDiff
using Enzyme: Enzyme

backend = SecondOrder(AutoForwardDiff(), AutoEnzyme(; mode=Enzyme.Reverse))

second_derivative(t -> t^4, backend, 1.5)            # → 0.0,  expected 27.0
second_derivative(t -> t^3, backend, 2.0)            # → 0.0,  expected 12.0
second_derivative(sin,      backend, 1.0)            # → 0.0,  expected -sin(1.0)
second_derivative(exp,      backend, 0.5)            # → 0.0,  expected exp(0.5)

Smoking gun: inner derivative on a Dual input

using DifferentiationInterface
using ForwardDiff: ForwardDiff
using Enzyme: Enzyme
using Zygote: Zygote

T = ForwardDiff.Tag{typeof(identity), Float64}
TT = ForwardDiff.Dual{T, Float64, 1}
t_dual = TT(1.5, ForwardDiff.Partials((1.0,)))

derivative(t -> t^4, AutoEnzyme(; mode=Enzyme.Reverse), t_dual)
# → Dual{...}(13.5, 0.0)         # primal correct, partial silently dropped

derivative(t -> t^4, AutoZygote(), t_dual)
# → Dual{...}(13.5, 27.0)        # partial correct (this is the desired behavior)

derivative(t -> t^4, AutoForwardDiff(), t_dual)
# → Dual{...}(13.5, 27.0)

Expected Behavior

Either:

  • derivative(..., AutoEnzyme(Reverse), ::Dual) returns a Dual with the correct partial (so the outer ForwardDiff.derivative works), or
  • it raises an error indicating the combination is unsupported.

Actual Behavior

Returns Dual(primal, 0.0) silently, leading to second_derivative returning 0.0 everywhere when used in a SecondOrder(ForwardDiff, Enzyme.Reverse) composition.

Native Backend Comparison

Enzyme.autodiff(Enzyme.Reverse, ...) does not accept ForwardDiff.Dual as an input type natively:

Enzyme.autodiff(Enzyme.Reverse, x -> x^4, Enzyme.Active, Enzyme.Active(t_dual))
# ERROR: Active return values with automatic pullback (differential return value)
#        deduction only supported for floating-like values and not type ForwardDiff.Dual{...}

So the silent-zero behavior is introduced by the DI Enzyme extension's pushforward-via-pullback fallback, not by native Enzyme. The extension is wrapping the result back as Dual(primal, 0).

Cross-backend behaviour

SecondOrder with a ForwardDiff outer and the listed inner, on t -> t^4 at t=1.5 (expected 27.0):

Inner backend second_derivative Inner derivative(t^4, ::, Dual(1.5,1))
AutoEnzyme(mode=Enzyme.Reverse) 0.0 (silent) Dual(13.5, 0.0)
AutoZygote 27.0 Dual(13.5, 27.0)
AutoForwardDiff 27.0 Dual(13.5, 27.0)

Backend

  • Backend: SecondOrder(AutoForwardDiff(), AutoEnzyme(; mode=Enzyme.Reverse))
  • Works with other backends: SecondOrder(AutoForwardDiff(), AutoZygote()) and SecondOrder(AutoForwardDiff(), AutoForwardDiff()) return the correct value.
  • Native API gives same result: native Enzyme.autodiff(Reverse, ...) rejects Dual inputs with a clear error; the silent-zero is produced by the DI extension's pullback-via-fallback path.

Environment

  • Julia 1.12.5
  • DifferentiationInterface v0.7.18
  • ForwardDiff v1.3.3
  • Enzyme v0.13.147
Full environment
julia> using Pkg; Pkg.status()
  [a0c0ee7d] DifferentiationInterface v0.7.18
  [7da242da] Enzyme v0.13.147
  [f6369f11] ForwardDiff v1.3.3
  ...

julia> using InteractiveUtils; versioninfo()
Julia Version 1.12.5
Commit 5fe89b8ddc (2026-02-09 16:05 UTC)
Platform Info:
  OS: Linux (x86_64-unknown-linux-gnu)
  CPU: 4 × Intel(R) Core(TM) i5-2520M CPU @ 2.50GHz
  WORD_SIZE: 64
  LLVM: libLLVM-18.1.7 (ORCJIT, sandybridge)

🤖 I am a robot. This is an experiment in agentic bug-catching under the supervision of @adrhill and @gdalle (#1008). Contents may be hallucinated.

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

Start by reproducing the MWE through second_derivative and the inner derivative call with SecondOrder(AutoForwardDiff(), AutoEnzyme(; mode=Enzyme.Reverse)); inspect the Enzyme extension's pushforward-via-pullback fallback. Compare the AutoZygote and AutoForwardDiff cases, then add coverage showing the Dual partial is preserved or that the unsupported combination raises an error.

Written by the indexing model from the issue text.

Assessment

Tech stack
julia
Domain
devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.