Skip to content

Skip FunctionWrappersWrapper in maybe_wrap_nonlinear_f under Enzyme AD - #940

Merged
ChrisRackauckas merged 4 commits into
SciML:masterfrom
ChrisRackauckas-Claude:cr-claude/enzyme-skip-fwwrapper
May 29, 2026
Merged

ChrisRackauckas merged 4 commits into
SciML:masterfrom
ChrisRackauckas-Claude:cr-claude/enzyme-skip-fwwrapper

Conversation

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member

Note

Please ignore until reviewed by @ChrisRackauckas.

Summary

Fixes #939: Enzyme.autodiff(Reverse, ...) over a function that calls NonlinearSolve.solve was failing with EnzymeRuntimeActivityError originating from NonlinearSolveBase.maybe_wrap_nonlinear_f. The FunctionWrappersWrapper it builds (for AutoSpecialize + IIP + array-state + non-Dual eltype problems) emits ptrtoint/store patterns that defeat Enzyme's static activity analysis, and set_runtime_activity is not sufficient to recover correctness.

The wrapper exists so the inner ForwardDiff Jacobian gets precompiled dispatch signatures. It is unnecessary on the outer-AD path — the unwrapped function works fine for Enzyme reverse mode.

Fix

Add an EnzymeCore.within_autodiff() short-circuit at the top of maybe_wrap_nonlinear_f that returns the raw prob.f.f whenever we're being called from inside Enzyme reverse-mode AD. EnzymeCore is already a hard dependency, so no new packages are added.

The same opt-out pattern is consistent with the existing Enzyme-aware infrastructure in autospecialize.jl (_uses_enzyme_ad, maybe_unwrap_prob_for_enzyme) — those handle the inner AD case; this handles the outer AD case.

Effect

  • Before (Enzyme v0.13.150, NonlinearSolve v4.19.1, Julia 1.11.9): cryptic EnzymeRuntimeActivityError: Detected potential need for runtime activity raised at autospecialize.jl:134 via a FunctionWrappers / FunctionWrappersWrapper activity-mismatch.
  • After, depending on what the user has loaded:

MWE verification

The reproducer from #939:

using ChainRulesCore, SciMLSensitivity, Enzyme, NonlinearSolve

u0 = [0.0]
p = [2.0, 1.0]

function simple_loss(p)
    prob = NonlinearProblem((du, u, p) -> du[1] = u[1] - p[1] + p[2], [0.0], p)
    sol = solve(prob, NewtonRaphson())
    return sum(sol.u)
end

dp = Enzyme.make_zero(p)
Enzyme.autodiff(Enzyme.Reverse, simple_loss, Enzyme.Active, Enzyme.Duplicated(p, dp))
# Before: EnzymeRuntimeActivityError
# After:  dp == [1.0, -1.0]  ✓

Verified locally on Julia 1.11.9 with Enzyme v0.13.150 and dev-checkout of this branch.

Test

Added a regression test in lib/NonlinearSolveBase/test/runtests.jl that probes maybe_wrap_nonlinear_f from inside Enzyme.autodiff(ReverseWithPrimal, ...) and asserts the result is unwrapped. Test deps gain Enzyme. Full NonlinearSolveBase test suite passes (25/25).

Related

🤖 Generated with Claude Code

… Enzyme AD

The FunctionWrappersWrapper construction emits ptrtoint/store patterns that
defeat Enzyme's static activity analysis when an outer reverse-mode pass
differentiates through solve(::NonlinearProblem, ...). The wrapper exists for
ForwardDiff dispatch in the inner Jacobian and is unnecessary on the outer-AD
path, so short-circuit via EnzymeCore.within_autodiff() and return the raw
function. Fixes SciML#939.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas and others added 2 commits May 27, 2026 10:48
…group

Enzyme-bearing tests live in the top-level NonlinearSolve.jl test suite under
the :nopre tag (kept out of the precompile-light core path). Reverts the
NonlinearSolveBase sublib test additions and adds the regression as a sibling
@testitem next to the existing OOP Enzyme adjoint test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
…ity works on 1.12

The sibling "Adjoint Tests" testitem gates on `VERSION < v"1.12"` because that
testitem loads ForwardDiff + ReverseDiff + Tracker + Zygote + Enzyme + Mooncake
together, and parts of that combination are not 1.12-ready. The SciML#939 regression
only needs Enzyme + SciMLSensitivity, which both work on Julia 1.12.6 — verified
locally that the patched `maybe_wrap_nonlinear_f` returns the correct gradient
`dp ≈ [1.0, -1.0]` on both 1.11 and 1.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
…uption

The previous heavy form drove `Enzyme.autodiff(Reverse, simple_loss, ...)`
through a full `solve(::NonlinearProblem, NewtonRaphson())` call. Compiling
the `solve_up` Enzyme augmented_primal rule for the IIP path with deeply
nested NamedTuple tape types abort the worker on Julia LTS + macOS with
"GC error (probable corruption)" — a separate Enzyme/Julia 1.10 GC issue,
not a NonlinearSolveBase bug. The other 4 nopre combinations (Linux LTS,
Linux/macOS 1.11, Linux/macOS 1.12) all pass.

Replace with a probe-style test that calls `maybe_wrap_nonlinear_f` from
inside `Enzyme.autodiff(ReverseWithPrimal, ...)` and asserts the result is
unwrapped. This verifies the fix directly without exercising the heavy
tape-compilation path. Confirmed locally on Julia 1.10.11 (matches LTS).

End-to-end gradient correctness was manually verified on 1.11.9 and 1.12.6
and is documented in the testitem docstring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Filed upstream Enzyme issue for the macOS LTS GC corruption that the heavier test form triggered: EnzymeAD/Enzyme.jl#3130. The probe-style regression test in this PR avoids the trigger and verifies the NonlinearSolveBase fix directly.

ChrisRackauckas added a commit that referenced this pull request May 28, 2026
* docs: fix three CI failures (linkcheck, cross_references, example_block)

The Documentation workflow on master was failing with three independent
errors, all addressed here:

1. **linkcheck** — `https://iopscience.iop.org/article/10.1088/1757-899X/1276/1/012010/`
   returns a 302 to a PerimeterX bot validator from GitHub Actions runners.
   Added the URL to the existing `linkcheck_ignore` list in `docs/make.jl`.

2. **cross_references** — `docs/src/solvers/bracketing_solvers.md` referenced
   `[`modAB`](@ref)` but the exported binding (per commits `e856faf` /
   `1da0421`) is `ModAB` (uppercase). Fixed the casing.

3. **example_block** — the `@example ill_conditioned_nlprob` block at
   `docs/src/tutorials/large_systems.md:322-337` tripped
   `NoFunctionWrapperFoundError` (`No matching function wrapper was found!`)
   when running
   `solve(prob_brusselator_2d_approx_di, NewtonRaphson())` against a
   `NonlinearFunction` whose `sparsity` was
   `DifferentiationInterface.DenseSparsityDetector(AutoForwardDiff(); atol=1e-4)`.

   Root cause: `maybe_wrap_nonlinear_f` wraps IIP problem functions with a
   `FunctionWrappersWrapper` whose signatures are keyed off
   `ForwardDiff.Dual{Tag{NonlinearSolveTag, Float64}, ...}`.
   `DenseSparsityDetector{AutoForwardDiff}` runs the wrapped function with
   ForwardDiff duals whose tag is generated by DI
   (`Tag{DifferentiationInterface.FixTail{NonlinearFunction{...}}, Float64}`),
   not `NonlinearSolveTag`. Those duals are *isbits*, so FWW's
   `AllowNonIsBits` fallback does not kick in, and the dispatch trips
   `NoFunctionWrapperFoundError`. (Tracer-based detectors happen to work
   because Tracer types are non-isbits and hit the `AllowNonIsBits`
   fallback.)

   Fix: extend the same "skip wrapping in incompatible contexts" pattern
   introduced by #940 (Enzyme reverse-mode) to the sparsity-detector path.
   In `maybe_wrap_nonlinear_f`, when `prob.f.sparsity` is a non-`NoSparsityDetector`
   `AbstractSparsityDetector`, return the raw function unwrapped. The
   autospecialize gain is also less significant on the sparse path (per-color
   Jacobian columns are small), so this is the right tradeoff.

   Test added in `lib/NonlinearSolveBase/test/runtests.jl` asserting that
   `maybe_wrap_nonlinear_f` wraps under `NoSparsityDetector` (the default)
   and skips when an `AbstractSparsityDetector` is supplied.

Verified locally (Julia 1.12.6):
- `Pkg.test("NonlinearSolveBase")`: 40/40 pass (+3 new test cases).
- Standalone repro of the docs example_block: both the
  TracerSparsityDetector and DenseSparsityDetector+AutoForwardDiff variants
  now `solve` successfully (`retcode = Success`). Without the fix the
  DenseSparsityDetector variant trips `NoFunctionWrapperFoundError`.
- Full docs build with `julia +1.12 --project=docs -e 'include("docs/make.jl")'`
  (with `linkcheck = false` locally to skip the slow linkcheck pass) runs
  end-to-end through `Populate: populating indices.` /
  `RenderDocument: rendering document.` / `HTMLWriter: rendering HTML
  pages.` with no `Cannot resolve @ref`, no `failed to run @example
  block`, and no `makedocs encountered errors`. The `linkcheck = false`
  override is local-only and is not in the committed diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>

* maybe_wrap_nonlinear_f: narrow skip to DenseSparsityDetector

The prior version skipped FunctionWrappers wrapping for *any*
`AbstractSparsityDetector` other than `NoSparsityDetector`. This is
overbroad: Tracer/Symbolics-style detectors emit non-isbits eltypes
(`Tracer`, `Num`, …) which `FunctionWrappersWrappers`' `AllowNonIsBits`
fallback already handles correctly — wrapping works for them. Only
`DI.DenseSparsityDetector` is the actual failure mode, because it runs
the user function with isbits ForwardDiff duals carrying DI's own
`FixTail`-tagged Tag, for which no wrapper signature is pre-built and
which bypasses `AllowNonIsBits`.

Narrow the predicate to `prob.f.sparsity isa DI.DenseSparsityDetector`
so Tracer/Symbolics paths keep the AutoSpecialize precompile benefit.

The test now exercises three cases: default (wraps), `DenseSparsityDetector`
(does not wrap — the regression), and an `AbstractSparsityDetector`
proxy for Tracer/Symbolics (wraps). Locally verified that
`NLS.solve(prob_brusselator_2d_approx_di, NewtonRaphson())` from
docs/src/tutorials/large_systems.md returns `ReturnCode.Success` and
that the new testset passes 4/4 on Julia 1.12.6.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>

* Revert maybe_wrap_nonlinear_f change; scope PR to docs-only fixes

The FunctionWrappers/ForwardDiff `NoFunctionWrapperFoundError` triggered
by `DenseSparsityDetector(AutoForwardDiff())` is a separate, deeper issue
that shouldn't be addressed in a docs-CI PR. Restore autospecialize.jl
and its test to master; this PR now only carries the linkcheck and
cross-reference fixes.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>

---------

Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review May 29, 2026 02:57
@ChrisRackauckas
ChrisRackauckas merged commit 57f1691 into SciML:master May 29, 2026
104 of 121 checks passed
ChrisRackauckas added a commit that referenced this pull request May 31, 2026
…nator adjoint path (#946)

Follow-up to #944. When Enzyme reverse-mode differentiates through an MTK DAE
solve, the initialization solves a `NonlinearProblem` via `solve_up`. The custom
`solve_up` Enzyme rule re-runs the solve through `_solve_adjoint` ->
`get_concrete_problem`, which `maybe_wrap_nonlinear_f`-wraps the IIP function in a
`FunctionWrappersWrapper` (`AutoSpecializeCallable`). That wrapping is type-unstable:
Enzyme's traced forward solve unwraps it (`maybe_unwrap_prob_for_enzyme`, #940) so
its inferred return type carries the bare function, but the adjoint re-wraps it, so
the rule's returned primal type no longer matches and Enzyme aborts with
`EnzymeRuntimeException: Expected return type of primal to be NonlinearSolution{...}`.

`maybe_unwrap_prob_for_enzyme` keys off the solver's own autodiff, which for an MTK
DAE init is ForwardDiff even when the outer differentiation is Enzyme, so it does not
fire on this path. Key off the originator instead: unwrap `_prob.f.f` via `get_raw_f`
when `originator isa EnzymeOriginator`, matching the (unwrapped) type Enzyme's traced
forward produces.

Companion to SciML/SciMLSensitivity#1463.

Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

Enzyme reverse-mode broken on NonlinearSolveBase.maybe_wrap_nonlinear_f — autospecialize FunctionWrappers defeats activity analysis (Julia 1.11)

2 participants