Skip to content

Call primals of functions with custom rules natively - #3783

Draft
vchuravy wants to merge 2 commits into
vc/native-inactive-calleesfrom
claude/project-thread-r38hr1
Draft

vchuravy wants to merge 2 commits into
vc/native-inactive-calleesfrom
claude/project-thread-r38hr1

Conversation

@vchuravy

@vchuravy vchuravy commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Before: Enzyme never ran the primal of a function with a (non-easy) custom rule. When no rule method matched, fwd_mi and aug_fwd_mi substituted custom_rule_method_error, so has_rule stayed true. Every call went to the rule handlers, and a call with only constant arguments threw a MethodError at runtime. This happened for example with rules written for active arguments only, as ChainRules imports make them. Separately, on every version Enzyme inferred, emitted, optimized and code generated the body of such a function in every thunk.

After (user-visible semantics change): when no rule method applies to a constant call, Enzyme now runs the primal as is, as it would for a function without a rule, instead of throwing. A constant call is one where the function, every argument, the keyword tuple and the return are all Const in the activities the rule lookup uses. That primal is called through its natively compiled entry point, like a derivative-free callee, and its body is no longer emitted into the thunk. A rule that does apply to a constant call still runs. A call with a non-constant argument or return and no matching rule still throws a MethodError. A return through an sret buffer that activity analysis gives a shadow (for example a struct holding a Vector{Float64}) is not constant, so such a call still needs a rule.

How (semantics): in src/rules/customrules.jl, has_rule returns false when the lookup fell back to custom_rule_method_error and no_rule_for_constant_call finds every activity from enzyme_custom_setup_args / enzyme_custom_setup_ret to be Const. The four handlers (enzyme_custom_fwd, _augfwd, _rev, _diffuse) only consult has_rule for a constant instruction and value. They then use Enzyme's default handling of a constant call, so no shadow, tape or reverse code is produced. This is the same path as an easy rule on a constant call.

How (native calls): native_entry now also accepts callees that have a forward or reverse rule (including keyword calls). For these it skips the guaranteed-const and "Julia would inline" checks, since NoInlineCallInfo already prevents inlining, but keeps the specsig and specptr checks. EnzymeContext.native_callees records the kind. bind_native_callees! does not replace the stub Julia's codegen emits for such a callee. Instead it keeps the stub as a thin forwarder to a declaration bound to the native entry (bind_ruled_stub!). The reason is that the rule handlers index call operands by the emitted signature, which has no pgcstack parameter. The stub is what runs when has_rule is false. With return roots, the native entry returns into a local buffer and roots array, and the stub splits the value into its own sret and roots, because enzyme-fixup-julia rejects an sret with GC roots that is passed on to a call. The stub gets the attributes prepare_llvm gives an emitted function. That logic is moved out into mark_compiled! with no behavior change. is_native_edge does not treat a ruled callee as inactive. Failing to bind a ruled callee throws instead of silently leaving a jl_invoke stub, which would drop the rule.

For nested AD, materialize_native_invokes! (which now takes the method table) leaves the declaration as a declaration when the outer mode has a rule for it, and otherwise gives it a body, for example for forward over reverse of a function that has only a reverse rule. It also drops stale enzyme_math=enzyme_custom marks from a linked inner derivative when the outer mode has no rule for that function.

Julia 1.10/1.11 keep emitting the body of a ruled callee, but the has_rule change applies there too. GPU, non-host and package-image compilation are unchanged, because the host_interp and use_native_callees gates still apply.

Stacked on #3768.

Not included: the planned 1.10/1.11 follow-up (in handle_compiled, swap the emitted body of a ruled function for a native declaration).

Also changed: on 1.12 and 1.13, inside a natively called ruled primal, within_autodiff() returns false and ignore_derivatives is the identity. When the body is emitted (1.10/1.11), within_autodiff() is folded to true.

Performance

TensorKit enzyme tests (TensorKit#464, ksh/enz_fact), Julia 1.13, wall time of each test file in its own process (CI=true). Each test ran with this PR and with #3768 (9d97ea0) at the same time, one test after another, so both see the same machine load:

test #3768 this PR change
flip 400 s 169 s −58%
contract 311 s 188 s −40%
norm 214 s 131 s −39%
mul 626 s 508 s −19%
projections 475 s 430 s −9%
qr 225 s 219 s −3%
total 2251 s 1645 s −27%

The full TensorKit enzyme suite (21 files) passes on 1.13 and on 1.10. The exception is trace on 1.10, which fails identically on #3768 and main. That one comes from TensorKit: QuantumKitHub/TensorKit.jl@832ceb7 removed its Enzyme rule for trace_permute!, and Enzyme's raw-storage adjoint differs from TensorKit's dim-weighted one by dim(c_A)/dim(c_C). 1.10 is not compared here, since this PR still emits ruled callees there.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Results

main f4dc7b9... main / f4dc7b9...
basics/make_zero/namedtuple 0.0441 ± 0.0054 μs 0.0468 ± 0.0051 μs 0.941 ± 0.16
basics/make_zero/struct 0.199 ± 0.0097 μs 0.201 ± 0.0089 μs 0.994 ± 0.066
basics/overhead 4.07 ± 0 ns 4.07 ± 0 ns 1 ± 0
basics/remake_zero!/namedtuple 0.179 ± 0.0067 μs 0.184 ± 0.0074 μs 0.97 ± 0.053
basics/remake_zero!/struct 0.179 ± 0.0093 μs 0.184 ± 0.0071 μs 0.976 ± 0.063
fold_broadcast/multidim_sum_bcast/1D 0.436 ± 0.0072 μs 0.435 ± 0.0071 μs 1 ± 0.023
fold_broadcast/multidim_sum_bcast/2D 0.317 ± 0.0062 μs 0.316 ± 0.0052 μs 1 ± 0.026
inline_abi/call 12.2 ± 0 ns 13 ± 0.001 ns 0.935 ± 7.2e-05
inline_abi/call_alloc 0.0377 ± 0.0011 μs 0.0362 ± 0.0016 μs 1.04 ± 0.055
inline_abi/loop 3.52 ± 0.0026 μs 3.52 ± 0.0036 μs 1 ± 0.0013
time_to_load 1.41 ± 0.034 s 1.36 ± 0.059 s 1.04 ± 0.051

Benchmark Plots

A plot of the benchmark results has been uploaded as an artifact at https://github.com/EnzymeAD/Enzyme.jl/actions/runs/39576540264/artifacts/12341814530.

@vchuravy
vchuravy added this pull request to stack #3777 October 7, 2026 14:00

vchuravy commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

CI status on 06fe8b6:

  • Integration Tests - Flux - Julia 1.11 also fails on main (0dfa740) and on the base Call derivative-free callees natively instead of inferring and emitting them #3768 (7bc78da). It is the LSTM/GRU EnzymeInternalError, so it does not come from this PR, and no fix exists yet.
  • Integration Tests - Lux - Julia 1.12 is caused by this PR. The stub of a ruled callee forwarded its rooted sret parameter into the .native call, and enzyme-fixup-julia rejects that ("Illegal GC setup in which rerooting is required"). A fix with a regression test is being pushed next.

Generated by Claude Code

vchuravy commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

On b69e3b9 the native_callees testsets fail on Julia 1.13 (11 errors) and in Downgrade 1.10 (9 errors). The cause is this PR's premise, not the new sret buffer code.

The new tests assume Enzyme runs the primal when every argument is Const and no rule method matches. It doesn't: fwd_mi/aug_fwd_mi substitute custom_rule_method_error instead of returning nothing, so has_rule stays true. Every call to a function with a (non-easy) rule ends up in the rule or in that MethodError, for example no method matching forward(::FwdConfigWidth{1,...}, ::Const{typeof(nc_ruled)}, ::Type{Const{Float64}}, ::Const{Float64}). Julia 1.10 fails the same way, and 1.10 never takes the native path.

The fix depends on a decision about Enzyme's semantics, which is being asked of the PR author:

  • Narrow: only easy rules take the native primal path.
  • Change has_rule: all-Const calls with no matching rule run the primal natively instead of throwing.

Integration Flux 1.11 is the same baseline failure as on main.


Generated by Claude Code

@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.02%. Comparing base (504c17c) to head (fffac06).

Files with missing lines Patch % Lines
src/compiler/native_callees.jl 94.84% 5 Missing ⚠️
src/compiler.jl 97.43% 1 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##           vc/native-inactive-callees    #3783      +/-   ##
==============================================================
+ Coverage                       79.92%   80.02%   +0.10%     
==============================================================
  Files                              72       72              
  Lines                           25335    25451     +116     
==============================================================
+ Hits                            20248    20367     +119     
+ Misses                           5087     5084       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@vchuravy

vchuravy commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

@claude rebase and squash

@vchuravy
vchuravy force-pushed the claude/project-thread-r38hr1 branch from 1325ee9 to 31e181c Compare October 8, 2026 10:41

vchuravy commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Rebased onto vc/native-inactive-callees (a41eb31) and squashed into a single commit, 31e181c. The tree is identical to the previous head 1325ee9.


Generated by Claude Code

@vchuravy
vchuravy force-pushed the claude/project-thread-r38hr1 branch 2 times, most recently from 6340be4 to 4c9a829 Compare October 14, 2026 16:20

Copy link
Copy Markdown
Member Author

On 6340be4, Downgrade - Julia 1.10 failed in "Native derivative-free callees" (test/native_callees.jl:64, LLVM error: function failed verification). 6340be4 was based on 9d97ea0. The base branch then added 1762496, which skips that nc_branch test on Julia 1.10 with code coverage until Enzyme_jll carries EnzymeAD/Enzyme#3491.

I rebased this PR onto 1762496 (now 4c9a829). The diff is unchanged apart from the base, and the guard is back in place. The Flux 1.11 failure is the same main-branch failure noted above.


Generated by Claude Code

@vchuravy
vchuravy force-pushed the claude/project-thread-r38hr1 branch 2 times, most recently from 2cc5365 to fffac06 Compare October 15, 2026 02:39

Copy link
Copy Markdown
Member Author

Integration Tests - Lux - Julia 1.13 fails on fffac06 in Pkg.instantiate(), before any test runs. It failed the same way on a re-run. The resolver error is Unsatisfiable requirements detected for package JET. On Julia 1.13.1 only JET 0.12.x is allowed, but LuxTestUtils 2.x requires JET 0.9.6–0.11.6.

This PR doesn't touch test/integration/Lux, so the failure comes from the Lux test environment, not from this change. The same job passed on main (9daa2be) on 10-14, before the current resolver state. There's no fix for it yet. It needs a LuxTestUtils release that allows JET 0.12, or a compat or Julia-version pin in test/integration/Lux/Project.toml.


Generated by Claude Code

claude and others added 2 commits October 21, 2026 09:22
When no custom rule method applies to a call whose function, arguments,
keyword tuple and return are all Const, `has_rule` now returns false and
Enzyme runs the primal as it would for a function without a rule, instead
of throwing a MethodError. Calls with a non-constant argument or return
and no matching rule still throw.

On Julia 1.12 and 1.13 such primals are called through their natively
compiled entry point, like derivative-free callees: `native_entry` accepts
ruled callees, and `bind_native_callees!` keeps the emitted stub as a thin
forwarder to the native entry (`bind_ruled_stub!`) so the rule handlers
still see the emitted signature. `materialize_native_invokes!` handles
ruled callees for nested AD. Julia 1.10/1.11 keep emitting the body.
The function-attribute logic of `prepare_llvm` moves into `mark_compiled!`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Qv3fN6RpocyrsdKXh7W26
…d it

The rule handlers build a rule's annotations from the signature of the
MethodInstance a specsig call calls. For a callee with `@nospecialize`
arguments (finalizer, deepcopy) compileable_specialization swaps the
inferred MethodInstance for its compilation signature, whose arguments are
`Any`, and now that native_uses_specsig follows uses_specsig for up to three
parameters, such a callee got a ruled stub: its rule was called with
`Duplicated{Any}` or `BatchDuplicated{Any, N}`. Upgrade a ruled callee to
its native CodeInstance only when it is the MethodInstance inference
resolved the call to; otherwise the jl_invoke is handled with the run-time
types, as before.

A keyword call of a function with a rule is resolved natively as ruled on
Julia 1.12 and later, and never derivative-free: adjust the test.

Assisted-by: Claude Code (Opus 5.5)
@vchuravy
vchuravy force-pushed the claude/project-thread-r38hr1 branch 2 times, most recently from fffac06 to f4dc7b9 Compare October 21, 2026 09:23

Copy link
Copy Markdown
Member Author

Buildkite cuda-julia-v1-dot-13-gpucompiler-default failed on f4dc7b9 (exit status 255). It isn't this PR's failure: the same job is red on main at the moment (latest main build, 15:32 UTC), and every other CUDA job here passed (1.10, 1.11 and 1.12, with both GPUCompiler variants). No fix exists yet to port.


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