Restore auto-diff link gating to the final codegen link#11779
Conversation
…ion from shader-slang#9808) Motivation ---------- PR shader-slang#9808 (auto-diff overhaul) caused a broad compile-time perf regression between releases 2026.5 and 2026.7, visible in the compile-perf suite (tools/compile-perf). The "per-compile floor" workloads regressed sharply: e.g. the `minimal` (empty shader) `linkIR` rose ~10-13x and `linkAndOptimizeIR` rose similarly, and every compile pays this regardless of whether it uses auto-diff. Root cause ---------- shader-slang#9808 removed the `useAutodiff` gating that the IR linker previously used to avoid pulling auto-diff artifacts into programs that do not differentiate: * `shouldDeepCloneWitnessTable` used to return `useAutodiff` for the `IDifferentiable` family; shader-slang#9808 made it return `true` unconditionally and also added the new `IForwardDifferentiable` / `IBackwardDifferentiable` / `IBwdCallable` interfaces to the always-deep-clone set. As a result every program deep-clones the differentiable-interface witness tables and all their entries (`Differential`, `dzero`, `dadd`, fwd/bwd methods). * The new `cloneAnnotations` step clones module-scope `IRAnnotation`s for every cloned inst. Every `AnnotationKind` is differentiability-related (DifferentialType/Zero/Add/PairType, Forward/BackwardDerivative, ...), so this links a differentiable builtin's derivative associations into programs that never use them. This dead auto-diff IR is then carried through specialize / simplifyIR / DCE before finally being eliminated, inflating link and optimization time. Fix --- Restore the `useAutodiff` gating, but apply it ONLY to the final per-target code-generation link (`linkIR`). A new `IRSharedSpecContext::isFinalCodegenLink` flag is set true only there. The flag is required because the same clone paths run during `prelinkIR` and module precompilation, whose output module must stay complete and self-consistent (it may be serialized -- e.g. the core module). Gating those paths corrupts the serialized core module and breaks auto-diff code generation; gating only the throw-away per-target link is safe because unused symbols are dropped on demand / by later DCE. * `shouldDeepCloneWitnessTable`: the differentiable-interface decision is now made BEFORE the generic `[HLSLExport]`/`[KeepAlive]` rule. shader-slang#9808 marks these witness tables `[HLSLExport]` (for cross-module auto-diff), which previously forced a deep clone regardless of gating. For the final codegen link of a non-differentiating program we now defer the entries and clone only those actually referenced. * `cloneAnnotations`: skip cloning annotations for the final codegen link when auto-diff is not in use. Both gates fall back to the original (always-clone) behavior during prelink / precompilation and whenever the program uses auto-diff, so auto-diff semantics are unchanged. Validation ---------- * minimal `linkAndOptimizeIR`: 0.79ms vs the pre-shader-slang#9808 parent's 1.87ms (the regressed value was ~15ms); `linkIR` back to ~0.1ms. * Auto-diff correctness: the interpreter (slangi) auto-diff tests under tests/autodiff/ produce correct numeric derivatives, and auto-diff HLSL / SPIR-V code generation no longer crashes, with the core module regenerated by the patched compiler. Scope ----- This addresses the per-compile-floor / `linkIR` regression class. A separate, larger codegen-side regression remains in `simplifyIR` for shaders that use differentiable numeric builtins (`sin`/`sqrt`/...): shader-slang#9808 wove differentiability into the numeric type hierarchy (`interface IFloat : IArithmetic, IDifferentiable`, plus the new differentiable interfaces), so a concrete non-auto-diff shader transitively links `float`'s entire differentiable-trait conformance closure. That is tracked separately.
📝 WalkthroughWalkthroughThe linker now marks the final code-generation link in shared context and uses that flag to skip module-scope auto-diff annotation cloning and adjust witness-table deep cloning when autodiff is disabled. ChangesFinal codegen linking
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: d20ea141-a9d7-426b-87fd-702d97c73572
📒 Files selected for processing (1)
source/slang/slang-ir-link.cpp
- shouldDeepCloneWitnessTable: restore the pre-shader-slang#9808 rule order so table-level [HLSLExport]/[KeepAlive] (user export, dynamic-dispatch type conformance) is honored before the differentiable-interface gate and can never lose entries to auto-diff pruning. The builtin differentiable-conformance tables carry neither decoration, so the gate still fires for them: -dump-ir output across all passes is byte-identical for non-differentiating shaders (minimal and sin-using probes), i.e. the perf win is unchanged. Also collapses the two walks over the conformance type's decorations into one. - Introduce IRSharedSpecContext::canPruneAutodiffLinkArtifacts() as the single named definition of the pruning predicate (previously spelled twice with opposite polarity) and the canonical home of the safety argument; the field, cloneAnnotations, and shouldDeepCloneWitnessTable comments now state only site-specific facts and cross-reference it. - Introduce isDifferentiableInterfaceBuiltin() next to KnownBuiltinDeclName as the authoritative definition of the differentiable-interface family. - Document the invariants the gates rest on: a note at AnnotationKind that the linker assumes every kind is differentiability-related, and a contract comment on doesModuleUseAutodiff (no-false-negatives requirement, why module-scope scanning suffices for concrete translation requests, and the generic-dependent caveat). - Comment accuracy fixes: the flag is about linkIR vs prelinkIR (module precompilation flows into linkIR and prunes safely); witness entries are cloned on demand, not dropped on demand; 'compile-time regression' instead of the ambiguous 'codegen regression'; acknowledge that IRBuiltinRequirementKey entries are still cloned eagerly rather than deferred. Validated: slangc/slang-test/slangi rebuild cleanly; interpreter auto-diff tests pass; SPIR-V validation passes on fwd_diff probes including a generic-nested fwd_diff case.
There was a problem hiding this comment.
This PR is a well-scoped, well-documented fix for a real compile-time regression, and it
was already close to merge-ready. The review found no correctness bug in the shipped
configuration, but one behavioral risk and a cluster of comment-accuracy/structure issues.
All nine findings are addressed in commit fc4ced1 on this branch; the inline comments
below record what changed and why.
Main points
- The highest-risk finding (C002): the differentiable-interface gate was checked before
the table-levelHLSLExport/KeepAliverule, so a user-exported or dynamic-dispatch
conformance table in the differentiable family could silently lose its keep-alive
guarantee. The fix restores the pre-#9808 rule order. Empirically the builtin
differentiable tables carry neither decoration, so-dump-iroutput across all passes
is byte-identical for non-differentiating shaders — the perf win is unchanged. The PR
description's claim that #9808 marks these tables[HLSLExport](the stated reason for
the reordering) was not reproducible; the description is worth updating to match. - The pruning predicate is now a single named helper
(IRSharedSpecContext::canPruneAutodiffLinkArtifacts()) carrying the canonical safety
argument, and the differentiable-interface family has one authoritative definition
(isDifferentiableInterfaceBuiltin()next to the enum). - The invariants the gates rest on are now recorded at their definition sites: a note at
AnnotationKind, and a contract comment ondoesModuleUseAutodiff(including the fact,
verified by IR dumps, that generic-parameter-dependentfwd_diffrequests hoist only to
the enclosing generic and are invisible to the module-scope scan).
Remaining follow-ups (not blocking)
- The
IRBuiltinRequirementKeycarve-out incloneWitnessTableImplstill clones the
Differential/dzero/daddentries eagerly, so pruning is partial; this may be part
of the remainingsimplifyIRregression tracked in #11780. - Whether a program whose only auto-diff use is generic-parameter-dependent can slip past
doesModuleUseAutodiffdeserves a dedicated regression test; local probes compile
correctly, and the contract comment now makes the assumption auditable.
CI for this PR failed test-slang on every platform. Both failures trace to the pruning making linked modules genuinely minimal for the first time, which exposed one bug in this PR's detector and one long-latent pipeline bug that the ever-present dead auto-diff IR had been masking. 1. doesModuleUseAutodiff missed generic-nested translation requests. __fwd_diff(genericFn) lowers to a generic wrapper whose inner value is ForwardDifferentiate(specialize(genericFn, T, ...)); the translate inst hoists only into that generic, never to module scope, so the module-scope scan returned false and the final link pruned annotations that DiffPair lowering later needed — the ICE 'Unrecognized field. Cannot emit field accessor' in tests/autodiff/no-diff-interface-subscript.slang (cpu). The detector is now a full recursive walk (containsTranslateInst), making the no-false-negatives contract hold by construction. 2. specializeModule only ran its cleanup group — critically unrollLoopsInModule, the ONLY call site that implements [ForceUnroll] / [unroll] semantics and diagnoses un-unrollable loops — after an iteration that performed specialization. Every module used to contain dead auto-diff generics, so the first iteration always specialized something and the group always ran; with pruning, a program with no specialization work never had its loops unrolled (tests/ir/loop-unroll-simplify, tests/language-feature/execution-model/varying-array-input, and the obfuscation loc-check tests). The group now runs on every round, and unrollLoopsInModule gained an optional outChanged parameter (its return value reports success, not change) so unroll-exposed specialization opportunities still trigger another round, preserving the old fixpoint semantics. Validation: - All eight previously failing tests now pass locally (remaining local failures are this machine's known-broken slang-llvm backend and shader-dll line-directive tests, identical without this change). - tests/ir, tests/obfuscate, tests/serialization, tests/language-feature/execution-model, tests/language-feature/generics, tests/diagnostics, and the interpreter auto-diff tests pass. - Final SPIR-V for non-differentiating probes is byte-identical to the previous head; the -dump-ir stream shrinks ~8x (31.6k -> 4.1k lines for an empty shader) because the unconditional first-round DCE now removes the residual dead IR immediately, strengthening the compile-time win.
unrollLoopsInFunc set *outChanged as soon as collection found [ForceUnroll] candidates, before any rewrite: an orphaned loop (skipped by the !loop->parent guard) still reported a change, and the specialize fixpoint then took a full extra simplification round for nothing. Move the write after a successful _unrollLoop so the flag means what the header documents. Also add tests/ir/force-unroll-non-generic-non-diff.slang: a non-generic, non-differentiating [ForceUnroll] shader that pins the unconditional cleanup group in the specialize fixpoint — with auto-diff link pruning such a program has no other reason to iterate, so re-gating unroll on per-round change would leave its loop rolled.
…g gate cloneAnnotations' wholesale skip is safe only while every AnnotationKind is auto-diff-related — an invariant previously guarded by comments alone, the same silent failure mode that let PR shader-slang#9808 disconnect the original gating. Add a CountOf sentinel to the enum and a static_assert at the gate so adding a kind forces the author to revisit the skip. Also correct the doesModuleUseAutodiff comment: the recursive walk's necessity is empirical and broad (a module-scope-only scan fails ~240 tests/autodiff/ tests, including an ICE on no-diff-interface-subscript), not carried by any single cited test.
- link-prune-non-autodiff.slang pins the gate in both directions from one source: the non-differentiating variant's IR dump must contain no Annotation(...) insts after linking (this fails under the pre-shader-slang#11779 behavior), and the __fwd_diff variant keeps them, which also guards the CHECK-NOT spelling against vacuity. - module-prelink-keeps-autodiff.slang + diff-library-module.slang pin the prelink-vs-final-link distinction: the consumer differentiates a function from a precompiled .slang-module (import prefers the binary module), so pruning during the prelink that produced it breaks this test. - fwd-diff-nested-in-generic.slang pins the nested-in-generic translation request shape end-to-end (with the where T.Differential == T constraint needed to express it).
There was a problem hiding this comment.
Verdict: 🟡 Minor gaps — 2 test-coverage gaps, no correctness bugs found
Restores auto-diff link-time pruning under a new isFinalCodegenLink gate applied only in linkIR, and re-runs the specialize-loop cleanup group unconditionally (with unrolling now feeding an outChanged back into the fixpoint). The scope split between prelinkIR (never prune) and linkIR (prune when auto-diff unused) is sound, and the deferred-witness-entry safety argument in the PR body checks out against cloneWitnessTableImpl (mangled-name-keyed entries are pruned; IRBuiltinRequirementKey-keyed entries are still eager-cloned).
Changes Overview
Auto-diff link gating (slang-ir-link.cpp, slang-ast-support-types.h, slang-type-system-shared.h)
IRSharedSpecContext::isFinalCodegenLinkflag, set true only insidelinkIR(never inprelinkIR).canPruneAutodiffLinkArtifacts() = isFinalCodegenLink && !useAutodiffgates both the wholesale early-return incloneAnnotationsand the differentiable-interface branch ofshouldDeepCloneWitnessTable, with the keep-alive ([HLSLExport]/COM/DynamicDispatch) rules moved ahead so pruning cannot override them.doesModuleUseAutodiffnow recursively walks the entire inst tree viacontainsTranslateInst(necessary becauseIRTranslateBaserequests can be nested in generics — see the newfwd-diff-nested-in-generic.slangexplicit reproducer).AnnotationKind::CountOf = 16sentinel +static_assertincloneAnnotationsso any new annotation kind forces a visit to the wholesale-skip gate.- New
isDifferentiableInterfaceBuiltinhelper extracts the interface-name switch as the single source of truth.
Specialize fixpoint + loop-unroll wire-up (slang-ir-specialize.cpp, slang-ir-loop-unroll.{cpp,h})
- The cleanup group (DCE, peephole, mandatory early-inlining, SCCP,
unrollLoopsInModule) now runs on every fixpoint iteration, not only after a round that specialized something. Rationale:unrollLoopsInModuleimplements[ForceUnroll]/[unroll]and is its only pipeline call site. unrollLoopsInFunc/unrollLoopsInModulegrow abool* outChangedoutput parameter set true only when a loop is actually rewritten; a set flag re-armsiterChangedso the fixpoint takes another round.
Tests (tests/autodiff/*.slang, tests/ir/force-unroll-non-generic-non-diff.slang)
link-prune-non-autodiff.slang: PRUNED vs KEPT-dump-irfilecheck ofAnnotation(.module-prelink-keeps-autodiff.slang+diff-library-module.slang: cross-module precompiled.slang-moduleprelink retention.fwd-diff-nested-in-generic.slang: minimal explicit reproducer for the recursive-walk requirement.force-unroll-non-generic-non-diff.slang: non-generic, non-diff[ForceUnroll]regression pin for the ungated cleanup group.
Findings (2 total)
| Severity | Location | Finding |
|---|---|---|
| 🟡 Gap | tests/autodiff/module-prelink-keeps-autodiff.slang:24 |
bwd_diff half of the library-import prelink scenario, and of the nested-in-generic scenario, is untested — both new autodiff tests only exercise __fwd_diff. |
| 🟡 Gap | source/slang/slang-ir-specialize.cpp:1741 |
No test exercises the unrolledAnyLoop → iterChanged = true wire-up; the new force-unroll test cannot fail if that re-arming is removed. |
|
It looks like we need @saipraveenb25 to review this. |
22d2764
Fixes #11781
Motivation
Auto-diff refactor caused compile-time perf regression
between releases 2026.5 and 2026.7, visible in the compile-perf suite
(tools/compile-perf). The "per-compile floor" workloads regressed sharply:
e.g. the
minimal(empty shader)linkIRrose ~10-13x andlinkAndOptimizeIRrose similarly, and every compile pays this regardless of whether it uses
auto-diff.
Root cause
#9808 removed the
useAutodiffgating that the IR linker previously used toavoid pulling auto-diff artifacts into programs that do not differentiate:
shouldDeepCloneWitnessTableused to returnuseAutodifffor theIDifferentiablefamily; Refactor auto-diff implementation. #9808 made it returntrueunconditionally andalso added the new
IForwardDifferentiable/IBackwardDifferentiable/IBwdCallableinterfaces to the always-deep-clone set. As a result everyprogram deep-clones the differentiable-interface witness tables and all
their entries (
Differential,dzero,dadd, fwd/bwd methods).The new
cloneAnnotationsstep clones module-scopeIRAnnotations forevery cloned inst. Every
AnnotationKindis differentiability-related(DifferentialType/Zero/Add/PairType, Forward/BackwardDerivative, ...), so
this links a differentiable builtin's derivative associations into programs
that never use them.
This dead auto-diff IR is then carried through specialize / simplifyIR / DCE
before finally being eliminated, inflating link and optimization time.
Fix
Restore the
useAutodiffgating, but apply it ONLY to the final per-targetcode-generation link (
linkIR). A newIRSharedSpecContext::isFinalCodegenLinkflag is set true only there. The flag is required because the same clone paths
run during
prelinkIRand module precompilation, whose output module must staycomplete and self-consistent (it may be serialized -- e.g. the core module).
Gating those paths corrupts the serialized core module and breaks auto-diff
code generation; gating only the throw-away per-target link is safe because
unused symbols are dropped on demand / by later DCE.
shouldDeepCloneWitnessTable: the differentiable-interface decision is nowmade BEFORE the generic
[HLSLExport]/[KeepAlive]rule. Refactor auto-diff implementation. #9808 marks thesewitness tables
[HLSLExport](for cross-module auto-diff), which previouslyforced a deep clone regardless of gating. For the final codegen link of a
non-differentiating program we now defer the entries and clone only those
actually referenced.
cloneAnnotations: skip cloning annotations for the final codegen link whenauto-diff is not in use.
Both gates fall back to the original (always-clone) behavior during prelink /
precompilation and whenever the program uses auto-diff, so auto-diff semantics
are unchanged.
Validation
linkAndOptimizeIR: 0.79ms vs the pre-Refactor auto-diff implementation. #9808 parent's 1.87ms(the regressed value was ~15ms);
linkIRback to ~0.1ms.tests/autodiff/ produce correct numeric derivatives, and auto-diff HLSL /
SPIR-V code generation no longer crashes, with the core module regenerated
by the patched compiler.
Scope
This addresses the per-compile-floor /
linkIRregression class. A separate,larger codegen-side regression remains in
simplifyIRfor shaders that usedifferentiable numeric builtins (
sin/sqrt/...): #9808 wove differentiabilityinto the numeric type hierarchy (
interface IFloat : IArithmetic, IDifferentiable,plus the new differentiable interfaces), so a concrete non-auto-diff shader
transitively links
float's entire differentiable-trait conformance closure.That is tracked separately.
The remaining
simplifyIRcodegen-side regression described under Scope is tracked in issue #11780. Related broad perf-regression tracking: #11474.Measured compile-perf effect (local)
Measured with
tools/compile-perf/bench.pyon macOS arm64 (Release), comparing thisbranch (
61e49c673) against its merge-base with master (803dff915). Both binarieswere built in the same fresh worktree configuration to avoid build-config skew.
Synthetic workloads: 1 warmup + 5 samples;
mdl_dxr: 2 warmups + 9 samples; medians.minimal(the per-compile floor this PR targets) — the fixed link-side cost isremoved for non-differentiating compiles:
linkAndOptimizeIRlinkIRspecializeModulesimplifyIRgenerateOutputcompileInnerStdevs on these timers are ±0.01–0.07 ms, so the deltas are unambiguous: ~1.8 ms of
fixed IR-side work is removed from every non-differentiating compile on this machine.
Other workloads:
saving amortizes:
module_link-5.1%compileInner,specialization-3.4%,dynamic_dispatchlinkIR-16%;sema_genericsunchanged (front-end-dominated).autodiffworkload is unchanged (+1.1%compileInner, within its noise) —expected, since the gate must not prune when auto-diff is in use.
loop_unrollis +2.5%compileInner(simplifyIR55.5 -> 67.5 ms, ~9 stdevs, soreal): the price of making the specialize-fixpoint cleanup group unconditional so
[ForceUnroll]executes even when no round performs specialization work. It onlyshows on unroll-heavy stress shapes.
mdl_dxr(real-shader corpus) is neutral: -2.0%compileInnerat ±2% noise, withlink-side timers directionally better but not statistically distinguishable. This is
expected: the PR removes a fixed per-compile cost, which a ~1 s codegen-dominated
compile amortizes to ~0.2%; the benefit concentrates on the many-small-compiles
pattern that the floor dominates.
Note the master baseline already includes earlier partial recoveries of the #9808
regression (e.g. the
simplifyIR/linkIRrestorations), so the floor delta here issmaller than the original 10–13x figure measured against v2026.5 — this PR reclaims
the portion that was still outstanding.