Skip to content

Modular range folds - #136994

Draft
NinoFloris wants to merge 14 commits into
dotnet:mainfrom
NinoFloris:jit/modular-range-folds
Draft

NinoFloris wants to merge 14 commits into
dotnet:mainfrom
NinoFloris:jit/modular-range-folds

Conversation

@NinoFloris

@NinoFloris NinoFloris commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to, and based on, #136926.

Move the modular interval arithmetic of redundant branch opts into
modularrange.h, a ring interval that models constant comparisons of
either signedness, with symbolic upper bounds by identity. The
redundant branch optimizer is converted to construct and intersect
these intervals; it produces no code differences.
Replace GetIntersection, FoldRangeTests and FoldNeverNegativeRangeTest
with a single FoldComparisons that intersects the two tests as modular
intervals and emits the comparison form that matches the result.

The conversion keeps the previously accepted shapes only: two tests of
the same signedness on a plain local that enclose a range of at least
two non-negative values, or "X >= 0" paired with a never-negative
symbolic bound. It produces no code differences. The shapes the
interval representation makes available, equality tests, negative
bounds, offset bases and single values, are enabled separately.
…ools

Accept equality tests, offset bases, field locals, mixed signedness, overlapping ranges and single values in the range test fold. The dominant new shape is "X != a && X != a + 1", which becomes a single unsigned comparison.
The previous fold rejected negative constants. On the modular interval they are ordinary points, so "lo <= X <= hi" with a negative lower bound becomes "(X - lo) u<= hi - lo" like any other range.
SUB/CMP needs as many arithmetic instructions as CMP/CCMP. Decline the offset form of a fold when its constants cost more than the two original bounds.
@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 5, 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 5, 2026
@azure-pipelines

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

@NinoFloris
NinoFloris force-pushed the jit/modular-range-folds branch from 0aaaeab to 166e5b7 Compare November 5, 2026 04:13
The general case of the range fold lowered an interval as "(X - lower) u<= width" whatever its position on the ring. An interval that covers more than half of the ring is the complement of a small one, and one that ends near the top of the signed range is the signed test of its offset value, so the literal encoding materialized constants like 0xFFFFFFFD for "x != a && x != a + 1" and 0x7FFFFFF9 for "(uint)x < 257 || (x - 257) < 6". On arm64 the wrapped bound still encodes in a cmn while the compare stands alone, but once the compare joins a ccmp chain its 5-bit immediate forces a movn, which is where the folds grew code.

An interval has four encodings: the unsigned test of itself or of its complement, and the signed test of either, which starts the interval at the minimum value. They differ only in their constants. Price each with the cost model and take the cheapest, with the smallest constants on a tie, since those fit the most immediate forms. The two shapes above become "(x - a) u> 1" and "(x - 257) <s 6".
…ptors

The value number store and the assertion table are released after the SSA based phases, so a later phase that asks GenTree::IsNeverNegative about a local only sees the flag on its descriptor, which import and promotion set for span lengths and little else. A length read into a plain local is never negative by value number and nothing after value numbering could tell.

Add a phase at the end of the SSA section that writes such facts onto the descriptors. The first is never-negative: a local in SSA whose every definition is never negative by value number gets the flag, where the implicit initial definition counts as zero when the method zero-initializes its locals and disqualifies the local otherwise. Optimize bools, if conversion, lowering and codegen then see array and span lengths, checked bounds and the temps CSE made for them the way the value number phases did.
A value that is never negative takes only the nonnegative half of the ring, but the range fold intersected its tests over the whole ring, so a pair like "(uint)length < 257 || (length - 257) < 6" on an array length produced an interval straddling the sign boundary and a signed test with an offset. Intersect the first test's interval with the nonnegative half when the value is never negative; the pair above is then "length < 263" with no arithmetic.
@NinoFloris
NinoFloris force-pushed the jit/modular-range-folds branch from 166e5b7 to 61d264e Compare November 5, 2026 04:24
The range fold only took values compared at their own width, so a byte or char local compared against an int constant was not a candidate although the compare is an int compare. Accept the small-typed value, do the arithmetic at int width, and start its interval from what IntegralRange::ForNode knows about it: the type's bounds for a byte or char, the result range of a cast or a shift otherwise. The never-negative narrowing becomes one case of that. A byte local's "x != 255 && x > 99" is then the one interval [100, 254] instead of two tests.
The range fold required a local as the compared value, so two tests of the same field or array length only folded when CSE had already turned the repeated load into a local. Where CSE declines, the second block's load is the first's again, and nothing between them can store to it: the second block is a single statement with the first as its only predecessor. Accept a load as the base when it carries no persistent effects and is not volatile, keep the first test's load in place, and drop the second. The two loads differ only in that the second is marked non-faulting once the first has taken the null check, which GenTree::Compare treats as a different load; compare them by their address instead.

The test disables CSE so the fold has to recognize the load itself.
Use the existing tree splitter to evaluate the first comparison's operands before combining the tests. This exposes pure comparisons when CSE embeds a definition in the first test, while retaining the second test's side-effect and cost restrictions.
NinoFloris added a commit to NinoFloris/dotnet-runtime that referenced this pull request Nov 5, 2026
OperExceptions answered a signed division's overflow from the never-negative status of its operands, which reads the local descriptor, the integral range and the value number store, so the exception set of the node changed whenever the JIT learned something about an operand while its GTF_EXCEPT stayed what morph computed. A phase that records never-negative on locals after the SSA phases, in dotnet#136994, turns that into an extra flags assert in the checker on a signed division by such a local.

OperExceptions now consults only the two GTF_DIV_MOD_NO flags and constant operands, and whoever proves an operand safe records it on the node: morph on every target instead of only arm64, loongarch64 and riscv64, assertion propagation as before, and the stack level setter before codegen, which already re-derived the flags at that point. Zero diffs on the osx-arm64 libraries pmi collection.
@NinoFloris
NinoFloris force-pushed the jit/modular-range-folds branch from f358648 to 9a57770 Compare November 5, 2026 12:59
NinoFloris added a commit to NinoFloris/dotnet-runtime that referenced this pull request Nov 5, 2026
OperExceptions answered a signed division's overflow from the never-negative status of its operands, which reads the local descriptor, the integral range and the value number store, so the exception set of the node changed whenever the JIT learned something about an operand while its GTF_EXCEPT stayed what morph computed. A phase that records never-negative on locals after the SSA phases, in dotnet#136994, turns that into an extra flags assert in the checker on a signed division by such a local.

OperExceptions now consults only the two GTF_DIV_MOD_NO flags and constant operands, and whoever proves an operand safe records it on the node: morph on every target instead of only arm64, loongarch64 and riscv64, assertion propagation as before, and the stack level setter before codegen, which already re-derived the flags at that point. Zero diffs on the osx-arm64 libraries pmi collection.
@NinoFloris
NinoFloris force-pushed the jit/modular-range-folds branch from 9a57770 to d7b232b Compare November 5, 2026 14:09
Every other oper's exception set is a property of the node. A signed division's overflow was derived from the never-negative status of its operands, read from the local descriptor, the integral range and the value number store, so the set moved whenever a later phase learned something about an operand while GTF_EXCEPT stayed what morph computed.

OperExceptions consults only the GTF_DIV_MOD_NO flags and constant operands. Whoever proves an operand safe records it on the node with SetDivModExceptionFlags: morph on every target, assertion propagation, and the stack level setter before codegen. Zero diffs on the osx-arm64 libraries pmi collection.
costEx prices a constant that fits a cmp or sub immediate the same as one that needs a mov, and one that fits an add immediate the same as one that takes movz and movk to reach a register. ConstantCost answers what the consuming instruction pays: nothing for an immediate it takes, otherwise the instructions that put the value in a register, with a ccmp's five bit immediate told apart from the twelve of cmp and sub.

The range fold picks its encoding by it, and on arm64 the pair left alone, a cmp and ccmp chain with as many arithmetic instructions as the fold's sub and cmp, is one more candidate priced the same way. Compare chain formation declines a chain whose second constant fit its cmp but costs more than the removed branch to reach the ccmp.
AndyAyersMS pushed a commit that referenced this pull request Nov 7, 2026
Forming a chain moves the first block's condition into the second and
compacts the two, and compaction gives the combined block the second
block's weight. The second block's only predecessor is the first and the
edge between them becomes unconditional, so the combined block runs as
often as the first block and inherits its weight.

This showed up as some false positive perf score regressions in
#136994 because a fold keeps the
first block's weight while the chain logic on main only kept the second.

@AndyAyersMS

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