Skip to content

[NOMERGE] Key assertions and range facts by CSE VN equivalences - #136950

Draft
NinoFloris wants to merge 9 commits into
dotnet:mainfrom
NinoFloris:jit/cse-vn-equivalence
Draft

NinoFloris wants to merge 9 commits into
dotnet:mainfrom
NinoFloris:jit/cse-vn-equivalence

Conversation

@NinoFloris

Copy link
Copy Markdown
Contributor

Based on #136932, which records the equivalences and changes nothing. This stacks the consumers on it, as a draft to show what that PR unlocks. The last commit is the generalized form of what the experiment in #136739 needed to match exact-type assertions through CSE.

What it breaks down as, measured on my local libraries pmi collection (172,277 contexts, 1,393 changed methods):

Removed Methods with fewer Methods with more Net individual occurrences
Null check (ldr wzr / ldrb wzr) 771 1 1 -983
Bounds check (CORINFO_HELP_RNGCHKFAIL call) 107 0 -107
Other throw helper call 7 0 -7
Overflow helper 6 0 -6
Write barrier 4 0 -4
Cast helper (CHKCAST / ISINSTANCEOF) 1 0 -1
Conditional branch 207 6 2 -287
Compare (cmp / cmn / tst) 290 0 -405
Load (ldr*) 1,021 4 3 -1,329

Footnotes

  1. UnixDomainSocketEndPoint..ctor(ReadOnlySpan<byte>): two bounds checks and the loop's zero-trip test are gone, so the array length load that used to feed them is kept for its exception semantics only and becomes ldr wzr, [x0, #0x08]. Same access, result discarded. The method is smaller. ↩

  2. Six loops whose bounds check disappeared and that were then rewritten to count down (sub plus cbnz instead of add, cmp, blt). All six are smaller, between 12 and 24 bytes each. ↩

  3. ValueTask.Preserve (+20 bytes): assertion prop now substitutes the known-null _obj into the returned struct on the null path, and a multi-register struct return with a constant field goes through a stack temp. Pre-existing return shaping, reproduced on main with new VT(null, v.Token, v.Flag). OptionsFactory.Create (+8 bytes): loop rewritten from a post-incremented pointer to an indexed form after its zero-trip test folded, costing one more callee-saved register. OdbcTransaction.Commit and .Rollback (-8 bytes each): a SafeHandle null check folded, after which the handle lives in a stack slot instead of a callee-saved register. One reload more, one register pair less. ↩

@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 Nov 4, 2026
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Nov 4, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
10 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.

CSE rewrites a use's conservative value number to its reaching definition's. Other nodes that carried the old value number, such as copies made before CSE, keep it, so facts created under one number are invisible under the other.

Record the rewrite as a value number equivalence. The value number store keeps a union-find over rewritten numbers, empty for methods where no use changed. The representative is the number that says the most about the value: a constant, else a phi definition or a function, else the reaching definition's number; a constant never keys the map. Nothing consults it yet: the existing checked-bound transfer stays as it is, so this produces no code differences. A follow-up keys assertions by the representative.
About one method in nine records a rewrite, so the inline buckets of an embedded table would be carried by every value number store for nothing. Keep a pointer and allocate on the first record.
Most lookups are for numbers without a recorded equivalence, so a bit per number answers them without hashing, and the list behind it holds only the few pairs a method records, scanned in place. GrowableBitSet in bitvec.h is BitVec's shape without traits or a size up front, for a set whose index space is still being allocated while it fills.
Value numbers read out of a function application still name the operand from before CSE, so the peeled base of an address, the receiver of a method-table load, the operands of index arithmetic and the bound in the "i + k" and "bound - x" forms are canonicalized where they are extracted. Checked-bound membership moves to the representative when the equivalence is recorded, which replaces the special case in CSE. The representative is the side a consumer can reason about: a phi definition can be walked to its reaching values and a function can be decomposed, while an opaque load offers neither.
A number CSE rewrote may stand for a phi definition while naming an opaque load, so VNVisitReachingVNs and optVisitReachingAssertions canonicalize at entry rather than at each caller that passes a raw value number.
CSE rewrites a use's value number but not its parents', so a function over the old number stays distinct from the same function over the representative. Such a function is rebuilt over canonical arguments when its representative is asked for, and the result is remembered so that a function has one canonical form regardless of the order of lookups: a bit per number for the common case where the function is already canonical, and a list entry for a rebuilt number, which also inherits the checked-bound memberships of the original. Only value functions are rebuilt. Maps, phis, memory and exception sets are left alone. A rebuilt root can fold to a constant, which then becomes the representative of its class and never keys the map.
Value number based constant propagation folds a tree when the tree's own conservative number is a constant. The representative can be a constant when the tree's number is not: a load whose value CSE tied to a known one, or a function whose rebuilt form folded. Relops and range checks already see that through canonicalization, so a compare over such a tree folds and a load that only fed the compare dies with it, but a value used as data stayed. Asking for the representative folds it too, unless the constant takes more than one instruction to materialize: the value is already computed, often kept in a register by CSE, and a large immediate at each use costs more than it saves.
Treat address-exposed local reads as memory-load CSE candidates so repeated reads can share a captured value, enabling further bounds-check elimination. Existing memory dependence and availability checks govern reuse.

Object references are included. On their own, capturing a receiver left cached method-table numbers referring to the earlier reload, so exact-type assertions stopped matching; the value number equivalences recorded by the rewrite carry those facts to the temp.

Cover guarded span slicing and intervening writes and calls. The throw-elimination disasm check is scoped to ARM64: the Windows x86 and x64 JITs retain the unsigned Slice comparison after the signed guard, and the functional coverage runs on all targets without requiring that separate signedness optimization in their codegen.
A CSE definition that is the value of a store to a local produced two locals holding one value: the fresh temp, defined by the expression, and the original local, defined by a copy of the temp. The copy is one instruction at the definition and one more live register for as long as both are needed; on arm64 the register allocator cannot coalesce it when the two lifetimes diverge.

When the candidate has a single definition site and that site is the value of a store to a local whose only definition node is that store, the local is the temp. The store stays as it is and becomes the completed CSE definition, each use reads the local under the store's SSA number, liveness is marked backwards from the use to the store with the same builder the fresh-temp path uses, and the use adopts the definition's value number as before. The entry value of a parameter or a must-init local has no definition node and cannot reach a use the store dominates, so it does not disqualify the local; a phi does. Shared-constant candidates and address-exposed, struct, or already-CSE locals keep a fresh temp.
@NinoFloris
NinoFloris force-pushed the jit/cse-vn-equivalence branch from 5189f19 to 78c0a69 Compare November 5, 2026 00:30

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