Skip to content

FwdConfig{false,false} rule: delegate to Enzyme.autodiff for IIP shadow propagation#44

Merged
ChrisRackauckas merged 2 commits into
SciML:mainfrom
ChrisRackauckas-Claude:fix-enzyme-fwd-false-false-iip
Apr 21, 2026
Merged

FwdConfig{false,false} rule: delegate to Enzyme.autodiff for IIP shadow propagation#44
ChrisRackauckas merged 2 commits into
SciML:mainfrom
ChrisRackauckas-Claude:fix-enzyme-fwd-false-false-iip

Conversation

@ChrisRackauckas-Claude
Copy link
Copy Markdown
Contributor

Follow-up to #43. That PR's `FwdConfig{false, false, …}` rule invoked
`f_orig(pargs...)` directly, which fixed the original `MethodError` but
evaluated only the primal — the Duplicated arg shadows stayed at zero.
SciML solvers that rely on shadow propagation through IIP `Duplicated`
args under `AutoEnzyme(Forward, function_annotation = Const)` therefore
saw a trivially zero Jacobian.

Motivating failure (SciML/OrdinaryDiffEq.jl v7, `Downstream 1`)

`time_derivative_test.jl` solving with `AutoEnzyme(mode = Enzyme.Forward, function_annotation = Enzyme.Const)` over an IIP RHS:

alg bound master v7 with #43 merged v7 with this PR
Rosenbrock23 1e-10 2.09e-13 ✓ 5.55e-17 ✓ 2.09e-13 ✓
Rodas4 1e-10 3.33e-16 ✓ 1.11e-6 3.33e-16 ✓
Rodas5 1e-10 1.67e-16 ✓ 0.022 1.67e-16 ✓
Veldd4 1e-10 0.0 ✓ 5.56e-7 0.0 ✓

All four match master at machine epsilon after this fix.

Fix

Replace the hand-rolled primal call with `Enzyme.autodiff(Forward, Const(f_orig), Const, args...)`. The `Const` return annotation tells Enzyme there's no return-shadow to produce, while the Duplicated args get proper shadow propagation through the unwrapped function.

Test

Updated the regression test to assert `du_shadow` is populated to the analytic derivative value (`∂du[1]/∂u[1] * u_shadow[1] = -2u[1] = -6` at `u=3.0, u_shadow=1.0`) rather than left at zero.

Ran the full `Pkg.test()` locally: 25 passed, 0 failed, 1 errored (the errored test is the pre-existing `Enzyme batch forward mode (width > 1)` `TypeError: expected Tuple{Float64, Float64}, got @NamedTuple` — unrelated, looks like an EnzymeCore shape change).

Other dispatch audit

Checked every rule in the extension for similar `run-primal-when-you-shouldn't` / `don't-run-primal-when-you-should` bugs:

  • `{false, true}`, `{true, true}`, `{true, false}` forward rules: delegate correctly via `Enzyme.autodiff` or run the primal explicitly when it's needed for the return — unchanged by this PR.
  • `augmented_primal` methods (`Active{T}`, `Const`, `Duplicated{T}`, `BatchDuplicated{T, W}` return types): run the primal, which is the correct contract for reverse-mode's forward sweep. Not a probe path.
  • `reverse` methods (`Active{T}`, `Active`, `Const` dret): either compute gradients via forward AD on the unwrapped function or return `nothing` per arg for non-differentiated returns. Pure.

Only this one forward rule needed a semantic correction.

🤖 Generated with Claude Code

…rapped f

Follow-up to SciML#43.  The original revision ran `f_orig(pargs...)` by hand
to cover the IIP-void-return Enzyme-forward path that was throwing
`MethodError: no method matching forward(::FwdConfigWidth{1, false,
false, false, false}, …)`.  That version fixed the dispatch but left
the `Duplicated` arg shadow buffers untouched (the inner call only
exercised the primal-valued function wrapper), so downstream callers
that rely on shadow propagation through args got a trivially zero
Jacobian.

Observed concretely in SciML/OrdinaryDiffEq.jl v7 `Downstream`
`time_derivative_test.jl` with
`AutoEnzyme(mode = Enzyme.Forward, function_annotation = Const)`:

  Rosenbrock23 error: 5.55e-17  < 1e-10  PASS
  Rodas4       error: 1.11e-6   > 1e-10  FAIL
  Rodas5       error: 0.022     > 1e-10  FAIL
  Veldd4       error: 5.56e-7   > 1e-10  FAIL

After delegating to `Enzyme.autodiff(Forward, Const(f_orig), Const,
args...)`, all four pass at machine epsilon — matching master.

Update the regression test to assert that the Duplicated shadow buffer
is correctly updated (`∂du[1]/∂u[1] * u_shadow[1] = -2*u[1]*1 = -6`)
rather than left at zero.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
@ChrisRackauckas-Claude
Copy link
Copy Markdown
Contributor Author

Audited the other dispatches per review feedback — none of them need additional changes here, though the pattern is worth explaining.

Key constraint: Enzyme.autodiff(Forward, ..., Active(x)) errors with ErrorException: Active arguments not allowed in forward mode. Direct Enzyme.autodiff delegation therefore only works when all args are Duplicated/BatchDuplicated/Const — not when any is Active.

That shapes the pattern of every rule:

Rule Delegates via Enzyme.autodiff? How
fwd {false, true} Yes Enzyme.autodiff(Forward, Const(f_orig), Duplicated{T}, args...)
fwd {true, true} Yes (for shadow) Enzyme.autodiff(Forward, Const(f_orig), Duplicated{T}, args...)
fwd {true, false} No (primal only) f_orig(pargs...) — returning Const, no shadow needed
fwd {false, false} (this PR) Yes Enzyme.autodiff(Forward, Const(f_orig), Const, args...)
rev augmented_primal (Active RT, existing) No (just primal) f_orig(pargs...)
rev augmented_primal (Const / Duplicated / BatchDuplicated RT) No (just primal) f_orig(pargs...)
rev reverse (Active dret, existing) Yes _fww_reverse_gradsEnzyme.autodiff(Forward, Const(f_orig), Duplicated{T}, dups...) (seeded-Duplicated trick for each partial)
rev reverse (Const dret) No (nothing to delegate) ntuple(_ -> nothing, Val(N)) — Const dret means no gradient flows from the return

The reverse-side augmented_primal rules inherit the established "just run primal" pattern from the merged Active-RT rule. They can't swap in Enzyme.autodiff(Forward, ..., Active) without erroring, and there's no obvious way to do Enzyme.autodiff(Reverse, ...) inside augmented_primal because that would conflate the forward and backward sweeps Enzyme's split-reverse mechanism expects to separate.

The reverse rules that can delegate via forward-mode trick (Active dret) already do — that's _fww_reverse_grads merged in #41/43.

So this PR stays narrowly on the {false, false} forward rule, which is the one case where the merged version of the rule clearly needed to delegate and wasn't.

…ive args

The reverse rules from SciML#43 had two bugs exposed by new end-to-end tests:

1. Const-dret reverse returned `nothing` per arg, but Enzyme's rule
   protocol requires concrete scalar gradients for Active args (not
   nothing). Fixed to return `zero(T)` for Active args and `nothing`
   for Duplicated/Const args.

2. IIP reverse with Duplicated args (SciML pattern) returned nothing
   and never propagated gradients into the Duplicated shadow buffers.
   Fixed by delegating to `Enzyme.autodiff(Reverse, Const(f_orig),
   Const, args...)` when Duplicated args are present, so Enzyme
   accumulates the transposed derivative into the shadow buffers.

3. Enzyme passes `Type{<:Const}` (not an instance) for the dret slot
   in Const-return reverse rules. Updated dispatch signatures from
   `dret::EnzymeCore.Const` to `dret::Type{<:EnzymeCore.Const}`.

New end-to-end reverse-mode tests that assert derivative correctness:
- Const return + Active args: gradients are (0.0, 0.0)
- IIP f!(du, u) with Duplicated args: u_shadow accumulates ∂du/∂u
- Multi-component IIP cross-coupled Jacobian transpose
- ReverseWithPrimal IIP variant

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
@ChrisRackauckas ChrisRackauckas merged commit e1140d1 into SciML:main Apr 21, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants