Skip to content

A rule set asks only the rules the node's type can match: −36% of SimplifyEasy - #1085

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
feat/rules-indexed-by-node-type
Aug 26, 2026
Merged

A rule set asks only the rules the node's type can match: −36% of SimplifyEasy#1085
Rafael-SOWNet merged 1 commit into
masterfrom
feat/rules-indexed-by-node-type

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

MatchedRuleSet.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 rewrite pass, because thirty sets run as data, so several hundred rules are
asked at each node where a switch made one jump.

Indexed by the root type each pattern requires — which MatchPattern.RequiredRootType already
reports, and MatchedRuleSet.AsAddressable already reads:

master (2d6bcfca) with the index
SimplifyEasy 148,223 ns 94,904 ns — −36.0%
SimplifyEasy allocated 128,100 B 128,100 B — identical
ParseEasy 5,733 ns 5,730 ns
SolveEasy 4,798,880 ns 4,795,827 ns
SimplifyHard 1,542,491,599 ns 1,525,152,589 ns

Measured 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

MatchedRuleSet carries a comment recording a per-type index as measured and rejected — 24 B
per 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:

  • It was a cache that allocated per node. This allocates once per runtime type ever seen and
    then never again — a pass over a tree of products and sums touches two entries.
  • It was tried on a set of three rules. Common is sixty-two, InequalityEquality
    sixty-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, the
indexed 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 that
under-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 (Common as data), which measured +34.6% on SimplifyEasy — sixty-two
rules against a switch's single jump. With this in, that branch measures 92,221 ns, which is
28.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

…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
Rafael-SOWNet merged commit d7d94f6 into master Aug 26, 2026
31 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the feat/rules-indexed-by-node-type branch August 26, 2026 20:01
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
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>
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