Skip to content

Drop functions nothing calls after AD before optimizing - #3610

Open
vchuravy wants to merge 4 commits into
vc/custom-rule-regression-testsfrom
vc/custom-rule-unread-args
Open

vchuravy wants to merge 4 commits into
vc/custom-rule-regression-testsfrom
vc/custom-rule-unread-args

Conversation

@vchuravy

@vchuravy vchuravy commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3736 → #3732 → #3734 (Enzyme_jll 0.0.300).

Originally this PR addressed #3570 by turning functions with a custom rule into declarations during AD. EnzymeAD/Enzyme#3352 and #3353 (in Enzyme_jll 0.0.300) fix that in Enzyme itself: nothing infers readnone or writeonly on the parameters of a function with a custom rule any more. The regression tests from this PR are now in #3736, which passes without any change here. What is left is the compile-time part of the original idea, as suggested in #3610 (comment).

Change

Enzyme replaces the calls it differentiates to a function with a custom rule with the rule, so after enzyme! the function and everything it calls are often unused. They kept external linkage until the end of compile_unhooked_impl, so nothing could delete them before then. Right after AD, this makes every function except the entry points private and runs GlobalDCEPass. Calls Enzyme kept, e.g. from a constant callee, still reach the body.

The function bodies are not dropped before AD. Enzyme keeps calling the primal for calls where everything is Const: rules imported from ChainRules assert a non-Const return, and the lock rules from #3513 run only for active locks. So a body is still needed whenever such a call survives.

Effect

A function with a custom rule whose body calls 400 distinct @noinline functions (the shape from #3455), Julia 1.13:

path defined functions after AD reaching post_optimize! before → after
CPU (autodiff, forward and reverse) 408 / 409 1 → 1
CUDA (autodiff_deferred in a kernel, 100 callees) 104 104 → 1

Testing

  • Full test suite, Julia 1.12.7: passes.
  • Full test suite, Julia 1.13.1: one error, advanced.jl "Copy Broadcast arg" (IllegalTypeAnalysisException). It fails the same way without this change in the same environment, and does not fail in Update Enzyme_jll to 0.0.300 #3734's CI.
  • test/cuda.jl, Julia 1.13.1 (Quadro RTX 4000): 61/61 pass.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Results

main 042b6bd... main / 042b6bd...
basics/make_zero/namedtuple 0.0516 ± 0.0023 μs 0.0521 ± 0.0021 μs 0.991 ± 0.06
basics/make_zero/struct 0.274 ± 0.0067 μs 0.276 ± 0.0069 μs 0.994 ± 0.035
basics/overhead 4.34 ± 0.01 ns 4.34 ± 0.01 ns 1 ± 0.0033
basics/remake_zero!/namedtuple 0.222 ± 0.0051 μs 0.223 ± 0.0044 μs 0.995 ± 0.03
basics/remake_zero!/struct 0.223 ± 0.0079 μs 0.223 ± 0.007 μs 0.998 ± 0.047
fold_broadcast/multidim_sum_bcast/1D 0.502 ± 0.0056 μs 0.5 ± 0.006 μs 1 ± 0.016
fold_broadcast/multidim_sum_bcast/2D 0.37 ± 0.0044 μs 0.372 ± 0.0039 μs 0.994 ± 0.016
inline_abi/call 16.4 ± 0.05 ns 17.3 ± 0.05 ns 0.947 ± 0.004
inline_abi/call_alloc 0.0437 ± 0.00093 μs 0.0438 ± 0.0013 μs 1 ± 0.036
inline_abi/loop 4.01 ± 0.0041 μs 4.01 ± 0.0041 μs 1 ± 0.0015
time_to_load 1.49 ± 0.022 s 1.51 ± 0.0086 s 0.986 ± 0.015

Benchmark Plots

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

@vchuravy
vchuravy force-pushed the vc/custom-rule-unread-args branch 2 times, most recently from 1b25925 to 3df4ca1 Compare September 24, 2026 12:55
@kshyatt

kshyatt commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

I hit a version of this from a slightly different angle, and it seems one more change would help here:

Trigger: On Julia 1.13.1, a struct with inline roots is passed as a pointer to its data half plus its roots. If a function with a custom rule only uses the pointer fields in its primal body, EnzymeDetectReadonlyOrThrow marks the data-half argument readnone. DSE then removes the caller's store of the bits fields into that buffer, before AD. The rule then reads uninitialized memory for every non-pointer field. The derivative values themselves can still come out right (here they only use the array), but anything the rule reads from the bits fields is garbage. Minimal version:

using Enzyme, LinearAlgebra
import Enzyme.EnzymeRules

struct TM; data::Vector{Float64}; n::Int; end
f1(A::TM) = norm(A.data)
function EnzymeRules.forward(config::EnzymeRules.FwdConfigWidth{1}, ::Const{typeof(f1)}, ::Type{RT}, A::Annotation{TM}) where {RT}
    @show A.val.n A.dval.n          # 7 expected
    n = f1(A.val)
    return Duplicated(n, dot(A.val.data, A.dval.data) / n)
end
g1(A) = f1(A)
autodiff(Forward, g1, Duplicated(TM(randn(3), 7), TM(ones(3), 7)))

On 1.13.1 this prints garbage for both ns. It's fine on 1.12.7, where the store survives even though the parameter is also inferred readnone. This PR doesn't quite fix it because middle_optimize! gained an API.EnzymeDetectReadonlyOrThrow(mod) call.

Routing that call through the wrapper fixes it:

     function middle_optimize!(second_stage = false)
         ...
-        API.EnzymeDetectReadonlyOrThrow(mod)
+        detect_readonly_or_throw!(mod)

@kshyatt
kshyatt force-pushed the vc/custom-rule-unread-args branch from 3df4ca1 to 5c1be07 Compare September 30, 2026 15:25
@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.53%. Comparing base (d7a2f8f) to head (042b6bd).
⚠️ Report is 2 commits behind head on vc/custom-rule-regression-tests.

Additional details and impacted files
@@                         Coverage Diff                         @@
##           vc/custom-rule-regression-tests    #3610      +/-   ##
===================================================================
- Coverage                            80.03%   76.53%   -3.51%     
===================================================================
  Files                                   71       71              
  Lines                                24172    24180       +8     
===================================================================
- Hits                                 19347    18505     -842     
- Misses                                4825     5675     +850     

☔ 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 Sep 30, 2026 •

Copy link
Copy Markdown
Member Author

The better fix is not not codegen these functions to begin with / or if that is not possible to drop the bodies of the function since we are going to replace them with custom rules later.

HT Billy

@vchuravy
vchuravy force-pushed the vc/custom-rule-unread-args branch from 0691bfc to 0f798f6 Compare September 30, 2026 23:55
@vchuravy vchuravy changed the title Keep arguments alive that only a custom rule reads Drop the bodies of functions with a custom rule Sep 30, 2026
@vchuravy
vchuravy changed the base branch from main to vc/easyrule-const-return September 30, 2026 23:55

@wsmoses wsmoses left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it may still be worthwhile (and possibly necessary for sret bits) here to run the attribute detection first [and keep those attributes, minus changing readnone -> readonly, and (non sret/returnroots) writeonly -> nothing

Base automatically changed from vc/easyrule-const-return to main October 1, 2026 01:58
…lia version

The test's 1.13-only branch encoded that attribute inference proved the
data half of the split convention readnone from the body of test_trace!,
so the call worked without runtime activity there. test_trace! has a
custom rule, which may read the whole argument, and since
EnzymeAD/Enzyme#3352 the body of such a function no longer yields that
inference on any version, so 1.13 now throws EnzymeRuntimeActivityError
like the others. This is the expectation #3610 states as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G84mmXoaait2MBqCH5BKNb
vchuravy pushed a commit that referenced this pull request Oct 1, 2026
…lia version

The test's 1.13-only branch encoded that attribute inference proved the
data half of the split convention readnone from the body of test_trace!,
so the call worked without runtime activity there. test_trace! has a
custom rule, which may read the whole argument, and since
EnzymeAD/Enzyme#3352 the body of such a function no longer yields that
inference on any version, so 1.13 now throws EnzymeRuntimeActivityError
like the others. This is the expectation #3610 states as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G84mmXoaait2MBqCH5BKNb
Both failed with Enzyme_jll 0.0.299 (a segfault for #3570, a dropped
store for the aggregate with inline roots) and pass with 0.0.300, which
includes EnzymeAD/Enzyme#3352. Taken from #3610.

Assisted-by: Claude Code (Opus 5.5)
#3732 targets #3734 but does not contain it, so without this the new tests ran with 0.0.299.

Assisted-by: Claude Code (Opus 5.5)
Enzyme replaces the calls it differentiates to a function with a custom
rule with the rule, so after enzyme! the function and its callees are
often unused. They kept external linkage until after post_optimize!, so
on non-native targets the post-AD pipeline optimized them, only for them
to be dropped later. Make every function except the entry points private
right after AD and run GlobalDCE, so they are deleted before any
optimization. Calls Enzyme kept, e.g. from a constant callee, still
reach the body.

Assisted-by: Claude Code (Opus 5.5)
@vchuravy
vchuravy force-pushed the vc/custom-rule-unread-args branch from 7d61ad2 to 042b6bd Compare October 1, 2026 11:47
@vchuravy
vchuravy changed the base branch from main to vc/custom-rule-regression-tests October 1, 2026 11:47
@vchuravy vchuravy changed the title Drop the bodies of functions with a custom rule Drop functions nothing calls after AD before optimizing Oct 1, 2026
vchuravy pushed a commit that referenced this pull request Oct 1, 2026
…lia version (#3732)

The test's 1.13-only branch encoded that attribute inference proved the
data half of the split convention readnone from the body of test_trace!,
so the call worked without runtime activity there. test_trace! has a
custom rule, which may read the whole argument, and since
EnzymeAD/Enzyme#3352 the body of such a function no longer yields that
inference on any version, so 1.13 now throws EnzymeRuntimeActivityError
like the others. This is the expectation #3610 states as well.


Claude-Session: https://claude.ai/code/session_01G84mmXoaait2MBqCH5BKNb

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
vchuravy added a commit that referenced this pull request Oct 1, 2026
Both failed with Enzyme_jll 0.0.299 (a segfault for #3570, a dropped
store for the aggregate with inline roots) and pass with 0.0.300, which
includes EnzymeAD/Enzyme#3352. Taken from #3610.

Assisted-by: Claude Code (Opus 5.5)
@vchuravy
vchuravy force-pushed the vc/custom-rule-regression-tests branch from ccfe25a to 5923d10 Compare October 1, 2026 13:23

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.

Custom forward rule argument becomes jl_nothing across a @noinline helper (SIGSEGV)

3 participants