A rule set asks only the rules the node's type can match: −36% of SimplifyEasy - #1085
Merged
Merged
Conversation
…plifyEasy `ApplyHere` asked every rule of a set at every node of the tree. A rule that cannot match still costs a virtual call and a type test to say so, and that is now the dominant cost of a pass: thirty sets run as data, so several hundred rules are asked at every node where a `switch` made one jump. Indexed by the root type each pattern requires -- which `MatchPattern.RequiredRootType` already reports, and which `MatchedRuleSet.AsAddressable` already reads -- `SimplifyEasy` goes from 148,223 ns to 94,904 ns, **-36.0%**, with allocation identical to the byte and `ParseEasy`, `SolveEasy` and `SimplifyHard` unmoved. What it removes is dispatch and nothing else. The note on `rules` records a per-type index as measured and rejected, and that stays true of what was measured: it was a *cache* that allocated per node, tried on a set of three rules, and it bought a time difference inside this machine's drift. Both halves have changed. This allocates once per runtime type ever seen and then never again -- a pass over a tree of products and sums touches two entries -- and the sets it is asked of are no longer sets of three. The dictionary is only ever added to, and adding builds a copy that replaces the field, so a reader racing a writer sees the old map or the new one and never a torn one. `RuleIndexTest` is what says skipping the rest skips nothing: for every set and every node of every expression its grammar builds, the indexed answer against the answer a scan over the whole set gives. The index is derived from `RequiredRootType`, so a pattern that under-reported what it requires would have rules skipped that could have fired and the answer would silently stop changing -- comparing against a linear scan is the check that cannot be fooled by the same mistake. 20,000+ pairs compared, and it asserts the rules actually fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
Rafael-SOWNet
added a commit
that referenced
this pull request
Aug 26, 2026
…last set `Patterns.CommonRules` is the largest `switch` in the transformation layer and the one the exchange was always going to be judged on: a hundred arms, and the great majority of them one shape written out in every orientation its operands can take. A `switch` has to write all of them; a commutative pattern says them at once. So `x + a*x`, `x + x*a`, `a*x + x` and `x*a + x` are four arms and one rule. None of that collapsing is a guess about what the arms cover. Every orientation gathered here is one the `switch` writes out, and `MatchedRulesAgreeWithTheSwitchTest` runs both forms over 3,487 generated expressions with an empty `firesWhereTheSwitchDoesNot` -- so a pattern that reached one shape more than its arms did would fail rather than pass quietly. Where the `switch` writes only one orientation, the rule is a node pattern and stays one. Order is load-bearing here more than in any set so far. `a * a` becoming `a ^ 2` sits in the middle of the file and would swallow half of what is above it if it moved up; the rules are in the arms' order and the agreement run is what says that is enough. Ten rules bind their operands as `Number` rather than as `Entity`, so `c1 * c2` folds to a literal in the `switch` where `Entity` arithmetic would build a node. The agreement run found all sixteen affected expressions at once, `1 * y + -y` among them, which is what running two forms over generated inputs is for and what no hand-written case list would have done. `Rational(var one, var den) when one == 1 && den != 1` is now `IsWholeReciprocal` and `DenominatorOf` in `Patterns.Common.cs`, asked by both forms rather than restated here. **Measured, and the set is now cheaper than the `switch` it replaces**: `SimplifyEasy` 93,115 ns against master's 97,048 ns, -4.1%, with allocation identical at 128,100 B and `ParseEasy` and `SolveEasy` unmoved. That took two changes underneath it. Written first, this set cost **+34.6%** -- 128,995 ns to 173,659 ns -- because sixty-two rules were asked at every node against a `switch`'s single jump; #1085 indexes a set's rules by the root type each pattern requires and took `SimplifyEasy` down 36% on master alone. And its twenty commutative patterns cost 169,708,912 B of `SolveMediumHard` against 165,054,016 B before #1080, because a commutative pattern could not take the single-match path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
This was referenced Aug 26, 2026
Rafael-SOWNet
added a commit
that referenced
this pull request
Aug 26, 2026
…last set (#1083) `Patterns.CommonRules` is the largest `switch` in the transformation layer and the one the exchange was always going to be judged on: a hundred arms, and the great majority of them one shape written out in every orientation its operands can take. A `switch` has to write all of them; a commutative pattern says them at once. So `x + a*x`, `x + x*a`, `a*x + x` and `x*a + x` are four arms and one rule. None of that collapsing is a guess about what the arms cover. Every orientation gathered here is one the `switch` writes out, and `MatchedRulesAgreeWithTheSwitchTest` runs both forms over 3,487 generated expressions with an empty `firesWhereTheSwitchDoesNot` -- so a pattern that reached one shape more than its arms did would fail rather than pass quietly. Where the `switch` writes only one orientation, the rule is a node pattern and stays one. Order is load-bearing here more than in any set so far. `a * a` becoming `a ^ 2` sits in the middle of the file and would swallow half of what is above it if it moved up; the rules are in the arms' order and the agreement run is what says that is enough. Ten rules bind their operands as `Number` rather than as `Entity`, so `c1 * c2` folds to a literal in the `switch` where `Entity` arithmetic would build a node. The agreement run found all sixteen affected expressions at once, `1 * y + -y` among them, which is what running two forms over generated inputs is for and what no hand-written case list would have done. `Rational(var one, var den) when one == 1 && den != 1` is now `IsWholeReciprocal` and `DenominatorOf` in `Patterns.Common.cs`, asked by both forms rather than restated here. **Measured, and the set is now cheaper than the `switch` it replaces**: `SimplifyEasy` 93,115 ns against master's 97,048 ns, -4.1%, with allocation identical at 128,100 B and `ParseEasy` and `SolveEasy` unmoved. That took two changes underneath it. Written first, this set cost **+34.6%** -- 128,995 ns to 173,659 ns -- because sixty-two rules were asked at every node against a `switch`'s single jump; #1085 indexes a set's rules by the root type each pattern requires and took `SimplifyEasy` down 36% on master alone. And its twenty commutative patterns cost 169,708,912 B of `SolveMediumHard` against 165,054,016 B before #1080, because a commutative pattern could not take the single-match path. Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Rafael-SOWNet
added a commit
that referenced
this pull request
Aug 26, 2026
… was (#1086) `MatchedRules.Sort` records why it is expressed and not wired: paying matcher dispatch on the normalisation cost +48% of `SimplifyEasy`, which the kernel gate reported as 4.14x. That figure was measured, and it was a statement about the matcher of the day. The matcher has changed twice since -- bounded matching (#1080) and rules indexed by node type (#1085) -- and the same wiring now measures **+13.3%**: 97,048 ns to 109,968 ns on the repository's own benchmark, allocation +0.28% and inside the band. The decision does not move. Thirteen percent of the flagship benchmark for no capability is still no. What moves is the reason, and what it is a reason *about*. It is not dispatch across the rules any more. Every rule in this set is typed -- `Any<Sumf>`, `Any<Mulf>` -- so the index tries one or two at a node rather than seven. What is left is the layer: a rule is a match that binds a name and a delegate that reads it back, where a `switch` arm is a type test and a call. Everywhere else that layer buys something -- a pattern that says what the rewrite is, reversible, addressable. Here every rule is a type test and a call *already*, so there is nothing for it to buy. That is a better boundary than "the normalisation is too hot", and it is about what a rule is rather than about where it runs. It also has to be re-measured whenever the matcher changes, which is now written down, because this is the second measurement and the first one had been read as settled. Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
MatchedRuleSet.ApplyHereasked every rule of a set at every node of the tree. A rulethat cannot match still costs a virtual call and a type test to say so — and that is now the
dominant cost of a rewrite pass, because thirty sets run as data, so several hundred rules are
asked at each node where a
switchmade one jump.Indexed by the root type each pattern requires — which
MatchPattern.RequiredRootTypealreadyreports, and
MatchedRuleSet.AsAddressablealready reads:2d6bcfca)SimplifyEasySimplifyEasyallocatedParseEasySolveEasySimplifyHardMeasured with the repo's own BenchmarkDotNet job on both sides. What it removes is dispatch and
nothing else.
The note that said this was tried and rejected
MatchedRuleSetcarries a comment recording a per-type index as measured and rejected — 24 Bper node, 912 B per pass, for a time win inside the machine's drift. That stays true of what was
measured, and both halves of it have changed:
then never again — a pass over a tree of products and sums touches two entries.
Commonis sixty-two,InequalityEqualitysixty-five, and there are thirty sets.
The dictionary is only added to, and adding builds a copy that replaces the field, so a reader
racing a writer sees the old map or the new one and never a torn one.
What says the skipping is safe
RuleIndexTest: for every set and every node of every expression its grammar builds, theindexed answer against the answer a linear scan over the whole set gives. 20,000+ pairs, and
it asserts the rules actually fired rather than agreeing about nothing.
That comparison matters because the index is derived from
RequiredRootType. A pattern thatunder-reported what it requires would have rules skipped that could have fired, and the answer
would silently stop changing — an assertion written from the same property would be fooled by
the same mistake; a scan cannot be.
Why now
This came out of #1083 (
Commonas data), which measured +34.6% onSimplifyEasy— sixty-tworules against a
switch's single jump. With this in, that branch measures 92,221 ns, which is28.5% below master rather than 34.6% above it. It is split out here because it is a change to
the kernel that stands on its own measurement, not on that branch's.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd