Skip to content

JIT: memoize the assertion-prop subtype walk over PHIs - #136138

Open
glyh wants to merge 2 commits into
dotnet:mainfrom
glyh:jit-memoize-subtype-phi-walk
Open

glyh wants to merge 2 commits into
dotnet:mainfrom
glyh:jit-memoize-subtype-phi-walk

Conversation

@glyh

@glyh glyh commented Oct 20, 2026 •

Copy link
Copy Markdown

DISCLAIMER: This is AI generated, then iterated and reviewed by me.


The subtype walk in optAssertionVNIsSubtype passes budget - 1 down each PHI edge, so budget limits depth and nothing limits work. The same merge gets re-proven by every path that reaches it, which is exponential in the number of merges, and optVisitReachingAssertions allocates two BitVecs per call so the arena climbs alongside. Nine 8-way switches into one if (o is A a) is enough to hit it (#133857).

The memo wraps the PHI walk, and only the walk. The recursive body moved to optAssertionVNIsSubtypeWorker; optAssertionVNIsSubtype creates the memo and calls it, and the recursion goes through the worker so every frame shares the one table. It lives on the top-level frame, so it dies with the query — no generation stamp, no compiler state.

Watch out for

  • Never cache false. A false from this walk means either "no proof" or "ran out of budget", and the second isn't a property of the key — the same key can come back false at depth 3 and true at depth 9, so storing it would permanently discard a proof. A true is a real derivation and stays valid at any depth and on any path, cyclic or not, which is also why cyclic PHIs need no in-progress marking.
  • The key is only sound because the memo sits below the direct-assertion check. That check is the one part that reads the caller's assertion set. The walk below it reads each edge's own set instead (optVisitReachingAssertions: "We assume that the caller already checked assertions for the current block"), so (value, claim) is the walk's whole input. Hoisting the lookup above the check would serve a proof derived from a different set.
  • A hit can be served at a shallower budget than the one that produced it. No depth is in the key, so a true found with 9 levels left answers a frame with 3. That is safe because the budget bounds search effort, not the validity of a derivation, and it can only fold casts that are genuinely provable.

Numbers

Whole-process wall time on the reproducer: 100 in ~145 ms with this JIT, 102 at 10,688 ms against the parent commit's JIT built in the same tree, which trips the test's own 2000 ms bound.

Memory: AssertionProp goes 8,892 → 9,404 bytes on that repro, so the memo costs 512. My first cut hard-coded a 2048-entry array, which was 49,152 bytes for a method that needs 73 entries.

Not in this PR

optAssertionVNIsNonNull has the same shape and no memo. That's #134462; the attempt there (#134916) is closed rather than merged, and doesn't overlap this.

@github-actions github-actions Bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Oct 20, 2026
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Oct 20, 2026
@glyh

glyh commented Oct 20, 2026

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

@glyh
glyh marked this pull request as draft October 20, 2026 08:54
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.

optAssertionVNIsSubtype walked PHI definitions recursively, passing budget - 1
to every incoming edge, so `budget` bounded depth rather than work. With no memo
the cost is O(fan-in ^ depth) in a loop-free PHI DAG, and
optVisitReachingAssertions allocates two BitVecs per call out of the compilation
arena. Nine 8-way switches merging into one object and then a single
`if (o is A a)` took 10.9 s and 629 MB, measured before this branch was rebased
onto its current base.

Only the PHI walk is memoized. The body moves to optAssertionVNIsSubtypeWorker;
optAssertionVNIsSubtype creates the memo and calls it, and the recursion goes
through the worker so every frame shares the one table. The memo lives on the
top-level frame, so it dies with the query: no generation stamp, no Compiler
state.

The key is (objVN, castToVN). The walk reads each edge's own assertion set
rather than the caller's -- optVisitReachingAssertions documents that the caller
has already checked assertions for the current block -- so that pair is the
walk's whole input. The direct-assertion scan, which does depend on the caller's
set, stays above the memo and is never cached; hoisting the lookup above it
would make the key unsound.

Only "proved" is stored. A `false` may mean "budget exhausted", which is not a
property of the key: the same key can be false at depth 3 and true at depth 9,
so caching it would serve a failure where a proof existed. A `true` is a real
derivation and stays valid at any depth and on any path, cyclic or not, which is
also why no depth is in the key and why cyclic PHIs need no in-progress marking.

A JitHashTable, keyed through a small Equals/GetHashCode pair like
BasicBlockLocalPair and AssertionKeyFuncs, rather than a hand-rolled table. It is
allocated on the first proof, so a query answered from the value's own type never
pays for it. On the reproducer AssertionProp goes from 8,892 bytes without the
memo to 9,404 with it.

The regression test is in the same commit and covers two different checks: the
nine-switch shape, timed on the call that JITs it and failing past 2000 ms --
100 in ~145 ms against this JIT, 102 at 10,688 ms against the parent commit's JIT
built in the same tree -- and two loop-carrying shapes that guard termination
when a PHI cycle makes the walk revisit itself and carry a null edge and a
non-A edge into the cast, so an unsound proof shows up as an exception or a
wrong value. AggressiveOptimization is load-bearing: under default tiering the
first call compiles as Tier0, assertion propagation does not run, and both JITs
pass in milliseconds.

Fixes dotnet#133857

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

area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant