Skip to content

Design document: individually addressable rewrite rules (#746 item 50) #825

Description

@Rafael-SOWNet

Design document for item 50 of #746"the rule registry: turn the pattern set into enumerable, attributable data without regressing Simplify performance".

The registry half landed in #818: all thirty rule sets the simplifier applies are named, attributed and enumerable, and Simplify got faster (allocation 157.6 → 125.8 KB). What remains is the finer grain — making the individual rewrites addressable, which is what #28 actually asks for and what #819 could only approximate at rule-set level.

The reason given for not doing it was a performance claim, and I made it repeatedly — in the code, in the docs, on #28 and on #746:

Splitting each case into its own object would replace one dispatch per node with one delegate call per rule per node on the hottest path in the library.

I measured it. It is not true. This document is mostly that measurement, because the whole decision turned on it.

What was measured

Fifteen rules transcribed from Patterns.CommonRules, in four shapes, applied through Entity.Replace over a 259-node expression (the one DotnetBenchmark uses for SimplifyHard). All four produce identical output, verified by structural equality, and identical allocation.

shape
A one switch, as the library writes it today
B a flat list of rules, each a delegate, tried in order
C the same rules bucketed by the node type they match, selected by Dictionary<Type, …>
D the same buckets, selected by a type switch instead of a dictionary

Method: 3000 warmup iterations, then the minimum of seven rounds of 3000 — the minimum rather than the mean because every source of error here adds time, so the fastest round is the least interfered with. net10.0, Release, x64.

Results

A switch (today)              0.0054 ms        1.27 KB
B flat list                   0.0086 ms        1.27 KB
C bucketed, dictionary        0.0036 ms        1.27 KB
D bucketed, type switch       0.0022 ms        1.27 KB

Reproduced across three separate process runs, within ±0.0004 ms.

  • The naive split (B) is ~1.6× slower — so the objection is not imaginary, it is just about the naive shape only.
  • Bucketing by node type (C, D) is 2.3× faster than the switch we have.

The mechanism is unsurprising once seen: most of the 259 nodes cannot match any rule in the set, and a type-indexed dispatch rejects those in one test, while a source-ordered switch works through the arms.

Does it hold as the set grows?

Fifteen rules is the small end; Patterns.CommonRules has about forty arms. Twenty further rules were added to both shapes, on node types this expression does not contain — so the answer is unchanged and only the dispatch grows:

A' switch, 35 rules           0.0025 ms        1.27 KB
D' bucketed, 35 rules         0.0024 ms        1.27 KB

The switch gets faster when it gets bigger — from 0.0054 to 0.0025 — and lands exactly on the bucketed registry. That is Roslyn: given enough arms over enough distinct types, it stops emitting sequential type tests and emits a real type dispatch.

Which is the most useful thing in this document. The compiler is already doing the bucketing. A registry that dispatches on node type is not a tax we would be paying for addressability — it is the same strategy, written down as data instead of inferred from the source order of a switch.

What follows

The performance argument against per-rule granularity does not survive measurement, at either size, in either direction. Nothing here is a reason not to proceed.

The real costs are elsewhere, and they are about people rather than machines:

  1. Transcription risk. Splitting forty arms by hand into forty objects is forty chances to change a pattern silently. This is the serious objection, and it is not addressed by any of the above.
  2. Per-rule metadata has to come from somewhere. Bucketing needs each rule's outer node type. Written by hand it is one more thing to get wrong and keep in sync.
  3. Order is semantics. First-match-wins within a bucket must preserve the source order of rules that can match the same node, or confluence changes. The measurement above verified identical output, which is evidence, not proof.

All three point at the same answer: a source generator over the existing switch bodies, rather than a hand transcription. The pattern already states the node type in its outermost position, and the arm order is already the priority order — so the generator has everything it needs, the switch stays the thing a human edits, and the registry becomes a build artefact that cannot drift from it. That also keeps #746's constraint that extensibility must not be bought with runtime reflection.

What I am not proposing

Not equality saturation, not an e-graph, not a rewrite of the rule sets. Only that the rewrites the library already has become individually named, so that #28 can be answered at the grain it asks for and a derivation can say which rule fired rather than which set.

Corrections owed

The claim this document refutes is currently written into the repository in four places — RewriteRuleSet.cs's remarks, Docs/Contributing/Transformations.md, and my comments on #28 and #746. I will correct all four regardless of what is decided here; an unmeasured performance claim sitting in the source as justification is worse than the design question being open.

Limits of the measurement

One rule set, one expression, one machine, one runtime. The filler rules in the scaling test never match, so it stresses dispatch and not within-bucket ordering; a set where many rules match the same node type would exercise that instead. The harness is small and I am happy to hand it over or extend it — in particular, running it against the real Patterns.CommonRules rather than a fifteen-rule transcription would settle the remaining doubt.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design documentFor issues representing detailed design of new API or feature

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions