Skip to content

Route the solution's prob.p shadow back to p in the Enzyme solve_up rule - #4904

Open
SebastianM-C wants to merge 7 commits into
SciML:masterfrom
SebastianM-C:ccr-f22b1caf-3fuu96
Open

SebastianM-C wants to merge 7 commits into
SciML:masterfrom
SebastianM-C:ccr-f22b1caf-3fuu96

Conversation

@SebastianM-C

@SebastianM-C SebastianM-C commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Fixes the Enzyme part of SciML/SciMLSensitivity.jl#1714: under Enzyme, a parameter that enters the loss only through the returned solution (e.g. an observed variable, sol[sym] / getsym(prob, sym)(sol)) gets a zero gradient.

Cause

solve_up's Enzyme rule builds the solution shadow with Enzyme.make_zero(res[1]), so it includes a zeroed prob.p. When the caller evaluates an observed variable, Enzyme accumulates ∂y/∂p into dval.prob.p. The rule then hands dval to the ChainRules pullback, which reads u. It never reads prob.p: SciMLSensitivity's solution_parameter_cotangent treats a solution-shaped Δ like a ChainRules cotangent, whose prob.p holds parameter values. So that part of the gradient was dropped. Under AutoDespecialize (MTK's default) the shadow's p is also a DespecializedParameters, not the MTKParameters of p.

Change

sol.prob.p is built from the p argument, so the reverse rule now routes dval.prob.p back to p itself, before calling the pullback:

  • unwrap DespecializedParameters (unwrap_parameters is the identity otherwise);
  • add numeric arrays directly, and add the tunable buffer of an aliasing SciMLStructure (e.g. MTKParameters);
  • zero what was read, so the pullback sees zeros and the contribution is counted exactly once (also if SciMLSensitivity later learns to read the shadow);
  • skip Const / non-Duplicated p and runtime-activity-aliased shadows, mirroring the existing guard.

What this means

MTK users. An objective that reads observed variables now gets the gradient of every tunable parameter that appears in them. This holds for observed-only and mixed state + observed objectives, with the automatic sensealg and Gauss/Interpolating/Quadrature adjoints. Checked locally against ForwardDiff with MTK 11.45.5, including:

  • θ that contains Initial(x) (the way to vary an initial condition through setsym_oop);
  • a tunable parameter solved by initialization (c2 = missing, initialization_eqs = [c2 ~ 2x]) that appears in the observed variable. remake already runs initialization there, so Enzyme differentiates it directly.

Non-tunable parameters (tunable = false, integer-typed) get no gradient through this path, as everywhere else in the adjoint. ForwardDiff can't differentiate them through setsym_oop either, since their buffer is Float64-typed. Generated observed functions don't read the initials buffer, so that part of the shadow stays zero.

Non-MTK users. If p is a numeric array (Vector, ComponentArray, …), any derivative of the loss with respect to sol.prob.p now reaches p. For other parameter types (tuples, NamedTuples, plain structs, arrays of arrays), that part of the gradient is still dropped, as before.

Less common cases not covered yet
  • The caches portion of a SciMLStructure, relevant under diff_tunables = Val(false) (SCC problems). Planned as a follow-up.
  • Non-aliasing SciMLStructures (canonicalize returns a copy), and a shadow whose type differs from p's.
  • Discrete parameters updated by callbacks, which live in sol.discretes.
  • Δt on the Enzyme shadow, dropped the same way.
  • NonlinearSolveBase's solve_up rule, which has the same pattern.
  • Unrelated to this rule: MTK + FullSpecialize errors in remake (EnzymeNoTypeError), and a plain function under AutoDespecialize with EnzymeVJP hits MethodError: -(::DespecializedParameters).

Tests

test/downstream/enzyme_solve_up_rule.jl gets two testsets:

  • Plain array p: a loss that reads sol.prob.p, with Gauss/Interpolating/Quadrature adjoints (EnzymeVJP). Without the change, the Enzyme gradient is short by the direct term 2p.
  • MTK observed variable: the reproducer from Enzyme: parameter cotangent of observed variables read from the solution is dropped (follow-up to #1669) SciMLSensitivity.jl#1714 (y ~ x + c, read with getsym(prob, y)(sol)). It covers AutoDespecialize with MTKParameters, the automatic sensealg and Gauss/Interpolating/Quadrature adjoints (EnzymeVJP), and an observed-only and a mixed state + observed loss. Each case is checked against ForwardDiff, for the observed-only loss against the exact ∂L/∂c (the number of save points), and for agreement across repeated calls.

ModelingToolkit (compat 11) and ModelingToolkitBase (compat 1.60.1) are added to the downstream test environment for this.

Locally, on Julia 1.12.7 (SciMLBase 3.58.0, ModelingToolkit 11.45.5, ModelingToolkitBase 1.77.5, SciMLSensitivity 7.119.13), the whole file:

  • with this PR: 32 pass;
  • with the original rule: 17 pass, 15 fail. The MTK cases give ∂L/∂c = 0 instead of 9, and the plain-array cases are short by 2p.

In CI (DiffEqBase Downstream, Julia 1.13.2) the file passes, 32/32. A local Julia 1.13.2 run hit an Enzyme IllegalTypeAnalysisException in SymbolicIndexingInterface's observed-function broadcast, before the rule runs; that is addressed separately in SciML/SymbolicIndexingInterface.jl#197 and SciML/SciMLBase.jl#1652, and did not reproduce in CI.

An earlier local MTK check script (issue reproducer, mixed losses, a Duplicated problem, plain-array p, all four specializations, each with automatic, Gauss, Interpolating and Quadrature sensealgs): original rule 16 pass, 46 fail, 6 error; with this PR 62 pass, 0 fail, 6 error. The 6 errors are the two unrelated ones listed above. The SciMLSensitivity Enzyme tests (Core8 is not part of this repo's downstream CI) are still running locally with and without this change; I'll post the comparison.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B6CVXLpT5vamupf2iC87KN
https://claude.ai/code/session_01FXAQW4otxDigUtMqrQywZW

Checklist

  • Appropriate tests were added
  • Any code changes were done in a way that does not break public API
  • All documentation related to code changes were updated
  • The new code follows the
    contributor guidelines, in particular the SciML Style Guide and
    COLPRAC.
  • Any new documentation only uses public API

Additional context

Add any other context about the problem here.

…_up` rule

The returned solution's `prob.p` is the `p` argument (wrapped in
`DespecializedParameters` under `AutoDespecialize`), so a loss that reads it,
e.g. an observed variable evaluated with `sol[sym]`, leaves its parameter
derivative in the shadow's `prob.p`. The ChainRules pullback never reads that
field, so the derivative was dropped and such parameters got a zero gradient
(SciML/SciMLSensitivity.jl#1714).

The reverse rule now adds the tunable part of `dval.prob.p` into the shadow of
`p` before calling the pullback, and zeroes what it read so it is consumed
exactly once. Parameter types other than numeric arrays and aliasing
SciMLStructures keep the previous behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6CVXLpT5vamupf2iC87KN
Adds the reproducer from SciML/SciMLSensitivity.jl#1714 to the rule's
downstream tests: an observed variable `y ~ x + c` read from the solution
of an MTK problem (`AutoDespecialize`, `MTKParameters`), with the automatic
sensealg and Gauss/Interpolating/Quadrature adjoints (`EnzymeVJP`), for an
observed-only and a mixed state + observed loss. Checks against ForwardDiff,
the exact ∂L/∂c = 21, and that repeated calls agree.

Adds ModelingToolkit to the downstream test environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXAQW4otxDigUtMqrQywZW
Adds ModelingToolkitBase to the downstream test environment and narrows
the ModelingToolkit compat to 11, which ModelingToolkitBase 1.60 implies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXAQW4otxDigUtMqrQywZW
The test passed `saveat = 0.1` before `solve_kwargs...`, whose `saveat = 0.25`
overrode it, so the exact value is 9, not 21. Use the shared `solve_kwargs` and
compare against the number of saved points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXAQW4otxDigUtMqrQywZW

Copy link
Copy Markdown
Member Author

CI on 5d15d2e: the new rule tests pass (DiffEqBase Downstream, Julia 1.13.2: Enzyme solve_up rule | 32 32). Four other jobs fail, and none of the failures comes from this change:

  • SciMLSensitivity Core4 (Ensemble Tests, EnsembleThreads() errors) and ModelingToolkit InterfaceI also fail on master at this PR's base d2adddd.
  • SciMLSensitivity Core2 (stiff_adjoints.jl:238: unexpected passes of @test_broken with ReverseDiffAdjoint; Autodiff Events: "tape has finished recording") and AD (julia lts) (autodiff_events.jl:117, same ReverseDiff error) start failing with ReverseDiff v1.18.6. Both passed on this PR's previous head c06095a with ReverseDiff v1.18.5. These are ReverseDiff code paths, while this PR only touches the Enzyme rule and DiffEqBase's own tests. No fix exists yet.

Generated by Claude Code

@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review November 13, 2026 16:43
Both sides appended testsets to enzyme_solve_up_rule.jl; keep both, with
master's AutoSpecialize testset ahead of the MTK section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXAQW4otxDigUtMqrQywZW

Copy link
Copy Markdown
Member Author

CI on 916ba2c (after merging master): the rule tests pass (DiffEqBase Downstream, Julia 1.13.3), and AD (julia 1) passes. Six other jobs fail, none of them because of this change, which only touches the Enzyme solve_up rule and DiffEqBase's downstream tests:

  • SciMLSensitivity Core2, AD (julia lts), GPU, ProbNumDiffEq: also fail on master's last three pushes, including 535e41f, the master commit merged here. Core2 and AD lts are ReverseDiff errors ("tape has finished recording", and unexpected passes of @test_broken with ReverseDiffAdjoint). GPU trips @test_nowarn on a GPUCompiler compile_hook deprecation warning.
  • DiffEqBase QA: ExplicitImports reports stale imports of AbstractIntegralProblem, AbstractLinearProblem, AbstractNonlinearProblem and AbstractSteadyStateProblem in src/DiffEqBase.jl. This PR doesn't change lib/DiffEqBase/src, and other PRs from today fail the same way. Dropping those four names from the using SciMLBase: … list should fix it; I haven't found an open PR doing that.
  • downgrade-sublibraries (OrdinaryDiffEqBDF): BDF state resizing (resize_tests.jl:46) fails with a DimensionMismatch. Master's run of this job resolved the same package versions and passed, and the test passes locally on this branch (3/3). It's a forward solve with no AD, so this looks nondeterministic rather than caused by the rule change.

Generated by Claude Code

This branch has not been deployed

No deployments
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