Skip to content

Give TypeAnalysis caller context for dispatch-table callees - #3089

Draft
vchuravy wants to merge 1 commit into
mainfrom
vc/flang-vtable-typeinfo-pr
Draft

Give TypeAnalysis caller context for dispatch-table callees#3089
vchuravy wants to merge 1 commit into
mainfrom
vc/flang-vtable-typeinfo-pr

Conversation

@vchuravy

@vchuravy vchuravy commented Aug 5, 2026

Copy link
Copy Markdown
Member

flang lowers a type-bound procedure call to a load out of the derived type's
binding table followed by an indirect call, so the bound procedures have no
direct call site at all — their address appears only as a ptrtoint entry
inside the table. Enzyme reaches them from GetOrCreateShadowConstant walking
that table, and GetOrCreateShadowFunction then seeded every pointer argument
with an empty TypeTree, leaving TypeAnalysis to recover the layout of each
argument from the body alone.

Motivating case: a whole-program module built from Fortran containing the ODE
solver DVODE, where dvindy, dvjac, dvjust, dvstep, dvhin and dvode
are each referenced exactly three times — their own definition plus two
binding-table entries — and nowhere called directly.

The change

Two new utilities in Utils.cpp:

  • getDevirtualizedCallee constant-folds an indirect callee through loads
    out of provably immutable memory. Loads go exclusively through LLVM's
    ConstantFoldLoadFromConstPtr, which only succeeds on constant globals with
    a definitive initializer; that, plus traversing nothing but Load/GEP/Cast, is
    what makes a non-null result a guarantee rather than a guess.
    TypeAnalyzer::visitCallBase uses it so that the existing visitIPOCall
    applies to such calls.

  • getIndirectCallCandidates over-approximates the targets of a
    call (inttoptr? (load (base + const offset))) with the function at that
    offset in every constant dispatch table in the module. base need not be
    known, which is what reaches a dispatch off a runtime class descriptor —
    the shape flang actually emits, and the one plain constant folding cannot
    resolve.

GetOrCreateShadowFunction uses the latter to find the call sites that may
reach the function it is about to differentiate, and takes the meet of
their argument types.

Why this is sound

The type information constrains the shadow of fn, not fn, and a shadow is
only reachable through the shadow table Enzyme writes into the same module — an
external caller invokes the original. So those really are all of the shadow's
call sites, despite fn itself having external linkage.

Erring towards less information is therefore the safe direction, and both
approximations do: a candidate set that is too large only adds call sites,
which the meet then weakens against, and an indirect call that cannot be
bounded at all makes us give up and return no information.

Enzyme-created shadow globals are tagged with enzyme_shadow_of so a shadow
table is not counted as a source-level one (otherwise every slot would have two
candidates once shadows exist). The whole thing is gated on
-enzyme-devirtualize, default on.

Tests

  • test/TypeAnalysis/flangvtable.ll — hand-reduced from flang -O1 output.
    @caller reaches the table through a constant descriptor: asserts the
    dispatch resolves, that the callee is analyzed interprocedurally at all
    (before this, it never appears in the output), and that the caller's
    %rwork: {[-1]:Pointer, [-1,0]:Float@double} reaches the callee's %yh.
    @runtime_caller reaches it through a runtime descriptor, and RUNTIME-NOT
    pins that nothing is concluded there.
  • test/Fortran/ForwardMode/type_bound_procedure.f90 — end-to-end forward-mode
    differentiation through a real class(...) / procedure :: dispatch.
    Passes before and after, so it is a regression guard rather than a
    discriminator: a program this small always recovers its types from the body.

Results

On the DVODE module, cannot handle unknown binary operator drops from 27 to
19 (all of the difference in mul nuw nsw i64 index arithmetic that needed
caller context) and total error: lines from 95 to 85.
-enzyme-devirtualize=0 reproduces the old numbers exactly.

check-typeanalysis and check-enzyme failure sets are byte-identical to
baseline; check-typeanalysis gains the one new passing test. Verified against
LLVM 24, where both suites have many pre-existing environmental failures
(removed -opaque-pointers flag, typed-pointer CHECK lines), so the comparison
is failure-set diffs rather than absolute counts.

Notes for review

  • Developed and tested on top of llvm-split-branchinst (needed to build
    against LLVM 24 locally); this branch is the same commit cherry-picked onto
    main, so CI here is the first build against main.
  • The candidate set over-approximates only over tables present in the module.
    A table in another TU could hold a different function at the same slot; the
    argument for why that is acceptable is the shadow-reachability one above, and
    is the part most worth a second opinion.
  • The DVODE module still aborts, at the same getNewFromOriginal assertion as
    before this change — a separate pre-existing failure.

🤖 Generated with Claude Code

flang lowers a type-bound procedure call to a load out of the derived
type's binding table followed by an indirect call, so the bound procedures
have no direct call site at all -- their address only ever appears as a
`ptrtoint` entry inside the table. Enzyme reaches them from
GetOrCreateShadowConstant walking that table, and GetOrCreateShadowFunction
then seeded every pointer argument with an empty TypeTree, leaving
TypeAnalysis to recover the layout of each argument from the body alone.

Two additions, both in terms of a new pair of utilities in Utils.cpp:

  * getDevirtualizedCallee constant-folds an indirect callee expression
    through loads out of provably immutable memory (LLVM's
    ConstantFoldLoadFromConstPtr only folds constant globals with a
    definitive initializer), so a non-null answer is guaranteed. TypeAnalyzer
    ::visitCallBase uses it so that visitIPOCall applies to such calls.

  * getIndirectCallCandidates over-approximates the target set of a
    `call (inttoptr (load (base + const offset)))` by collecting, out of
    every constant dispatch-table global in the module, the function at that
    same offset -- the base itself need not be known, which is what makes it
    apply to a dispatch off a runtime class descriptor.
    GetOrCreateShadowFunction uses it to find the call sites that may reach
    the function it is about to differentiate and takes the *meet* of their
    argument types: an over-approximated call site only weakens the result,
    and if any matching-signature indirect call cannot be bounded we fall
    back to no information at all.

Enzyme-created shadow globals are tagged with "enzyme_shadow_of" so that a
shadow table is not mistaken for a source-level one. The whole thing can be
turned off with -enzyme-devirtualize=0.

On a whole-program flang module containing DVODE this takes the
"cannot handle unknown binary operator" count from 27 to 19.
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.

1 participant