<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Youness Aamiri</title>
    <description>The latest articles on DEV Community by Youness Aamiri (@younessaamiri).</description>
    <link>https://dev.to/younessaamiri</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4027147%2F7c6d8daf-bdfa-4de4-b036-2abe7e868b32.jpg</url>
      <title>DEV Community: Youness Aamiri</title>
      <link>https://dev.to/younessaamiri</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/younessaamiri"/>
    <language>en</language>
    <item>
      <title>BenchmarkGate, Part 4: benchmark-gate compare, and Why Order Matters</title>
      <dc:creator>Youness Aamiri</dc:creator>
      <pubDate>Mon, 10 Aug 2026 11:35:10 +0000</pubDate>
      <link>https://dev.to/younessaamiri/benchmarkgate-part-4-benchmark-gate-compare-and-why-order-matters-2pb6</link>
      <guid>https://dev.to/younessaamiri/benchmarkgate-part-4-benchmark-gate-compare-and-why-order-matters-2pb6</guid>
      <description>&lt;p&gt;In &lt;a href="https://younessaamiri.dev/posts/benchmark-gate-validate/" rel="noopener noreferrer"&gt;part three&lt;/a&gt;,&lt;br&gt;
&lt;code&gt;v0.3.0-alpha.1&lt;/code&gt; added &lt;code&gt;validate&lt;/code&gt; — catching a broken policy or baseline&lt;br&gt;
file before &lt;code&gt;check&lt;/code&gt; ever saw it. That release didn't touch what &lt;code&gt;check&lt;/code&gt;&lt;br&gt;
actually &lt;em&gt;does&lt;/em&gt;, though: match benchmarks, match metrics, calculate&lt;br&gt;
deltas, apply thresholds, decide pass or fail — all inside one evaluator,&lt;br&gt;
one pass, no way to get "what changed" without also asking "is that&lt;br&gt;
acceptable."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;v0.4.0-alpha.1&lt;/code&gt; splits that in two. &lt;code&gt;benchmark-gate compare&lt;/code&gt; answers the&lt;br&gt;
first question. &lt;code&gt;check&lt;/code&gt; still answers the second — but it answers it by&lt;br&gt;
applying policy on top of the &lt;em&gt;same&lt;/em&gt; comparison &lt;code&gt;compare&lt;/code&gt; produces,&lt;br&gt;
instead of computing its own.&lt;/p&gt;
&lt;h2&gt;
  
  
  A real diff
&lt;/h2&gt;

&lt;p&gt;Here's &lt;code&gt;compare&lt;/code&gt; against a real baseline from this project's own&lt;br&gt;
benchmark suite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="kd"&gt;benchmark&lt;/span&gt;&lt;span class="na"&gt;-gate &lt;/span&gt;&lt;span class="kd"&gt;compare&lt;/span&gt; &lt;span class="na"&gt;--baseline &lt;/span&gt;..\baseline.json &lt;span class="na"&gt;--results &lt;/span&gt;.\BenchmarkDotNet.Artifacts\results\CedarRecon.Tests.Performance.ClassifierPhaseBenchmark&lt;span class="na"&gt;-report-full-compressed&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Suite: CedarRecon  Comparable: 21  Added: 0  Removed: 6

Benchmark                                Metric               Reference    Candidate    Abs Delta      % Delta    Direction      Status
ClassifierPhaseBenchmark.BuildDictionar. allocatedBytesPerOp. 5.465 MB     5.465 MB     0 B            +0.00%     Unchanged      Comparable
                                         meanNanoseconds      4.998 ms     4.998 ms     0.000 ns       +0.00%     Unchanged      Comparable
[... 19 more Comparable benchmarks, all Unchanged ...]

Removed benchmarks:
  ExceptionClassifierBenchmark.DictionaryClassifier|job=Job-SNYTAA|N=10000
  ExceptionClassifierBenchmark.DictionaryClassifier|job=Job-SNYTAA|N=100000
  ExceptionClassifierBenchmark.DictionaryClassifier|job=Job-SNYTAA|N=1000000
  ExceptionClassifierBenchmark.IndexedClassifier|job=Job-SNYTAA|N=10000
  ExceptionClassifierBenchmark.IndexedClassifier|job=Job-SNYTAA|N=100000
  ExceptionClassifierBenchmark.IndexedClassifier|job=Job-SNYTAA|N=1000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the 21 &lt;code&gt;Comparable&lt;/code&gt; benchmarks moved. But an entire benchmark&lt;br&gt;
class — &lt;code&gt;ExceptionClassifierBenchmark&lt;/code&gt;, six parameterized entries — is&lt;br&gt;
gone from the current run. No policy was involved in producing that&lt;br&gt;
output. &lt;code&gt;compare&lt;/code&gt; doesn't know whether a benchmark disappearing is a&lt;br&gt;
deliberate rename, a benchmark someone deleted on purpose, or a sign the&lt;br&gt;
project no longer builds that code path. It just reports the fact: this&lt;br&gt;
existed in the baseline, it doesn't exist now. Whether that's fine is a&lt;br&gt;
judgment call for a human, or for &lt;code&gt;check&lt;/code&gt; with a policy attached — not&lt;br&gt;
for &lt;code&gt;compare&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same fact, as data
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--format json&lt;/code&gt; writes the identical comparison as a versioned,&lt;br&gt;
machine-readable document — same command, &lt;code&gt;--format json --output&lt;br&gt;
compare.json&lt;/code&gt; instead of printing a table. This isn't just another report&lt;br&gt;
format: it's the same comparison facts, serialized as a versioned&lt;br&gt;
artifact that another tool — or a future BenchmarkGate command — can&lt;br&gt;
consume without rerunning the comparison. The JSON preserves the&lt;br&gt;
comparison engine's canonical ordering exactly; reporters don't re-sort&lt;br&gt;
the results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CedarRecon"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"comparable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"added"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"removed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"benchmarks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ExceptionClassifierBenchmark.DictionaryClassifier|job=Job-SNYTAA|N=10000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Removed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"metrics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allocatedBytesPerOperation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MissingCandidateMetric"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5087065&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bytes"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"meanNanoseconds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MissingCandidateMetric"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4945967.65625&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ns"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Removed&lt;/code&gt; means "present in the reference baseline but absent from the&lt;br&gt;
candidate run." It does not imply an error.&lt;/p&gt;

&lt;p&gt;Two more things worth noticing here. First, there's no &lt;code&gt;candidateStability&lt;/code&gt;&lt;br&gt;
field on this benchmark at all — not &lt;code&gt;null&lt;/code&gt;, genuinely absent. A removed&lt;br&gt;
benchmark has no current-run observation, so there's nothing to source&lt;br&gt;
stability facts from; the field is omitted rather than filled with a&lt;br&gt;
placeholder. Second, each metric's &lt;code&gt;reference&lt;/code&gt; value is still the exact&lt;br&gt;
number from the baseline, full precision, un-rounded — &lt;code&gt;compare&lt;/code&gt;'s JSON&lt;br&gt;
output never rounds a value for display the way the console table does.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why order matters
&lt;/h2&gt;

&lt;p&gt;The title isn't about the order benchmarks appear in the output. It's&lt;br&gt;
about the order two things happen in.&lt;/p&gt;

&lt;p&gt;Before this release, the pipeline was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;baseline + current + policy → evaluation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One evaluator performed benchmark matching, metric matching, delta&lt;br&gt;
calculation, and threshold evaluation in a single pass. There was no way&lt;br&gt;
to ask "what changed" without also asking "is that acceptable" — the two&lt;br&gt;
questions were the same function call.&lt;/p&gt;

&lt;p&gt;Now it's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;baseline + current → comparison → (+ policy) → evaluation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BenchmarkComparisonEngine&lt;/code&gt; is the only place benchmark matching, metric&lt;br&gt;
matching, and delta calculation happen. It doesn't know what a policy is.&lt;br&gt;
&lt;code&gt;compare&lt;/code&gt; calls it directly and reports the result. &lt;code&gt;check&lt;/code&gt; calls the&lt;br&gt;
&lt;em&gt;exact same engine&lt;/em&gt;, then hands its output to &lt;code&gt;RegressionEvaluator&lt;/code&gt;,&lt;br&gt;
which applies policy on top — never recomputing a delta, never&lt;br&gt;
re-matching a benchmark, just interpreting facts that already exist. In&lt;br&gt;
other words: arithmetic belongs to comparison; judgment belongs to&lt;br&gt;
evaluation.&lt;/p&gt;

&lt;p&gt;That ordering is the whole point of this release. &lt;code&gt;history&lt;/code&gt; (&lt;code&gt;v0.5.0&lt;/code&gt;)&lt;br&gt;
and anything after it can depend on "what changed" being a real, callable&lt;br&gt;
thing on its own — not something baked one level deep inside an&lt;br&gt;
evaluator that also happens to know about warning thresholds.&lt;/p&gt;
&lt;h2&gt;
  
  
  What v0.4.0 deliberately didn't do
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MetricComparisonStatus&lt;/code&gt; has a &lt;code&gt;UnitMismatch&lt;/code&gt; value. &lt;code&gt;compare&lt;/code&gt; never&lt;br&gt;
produces it.&lt;/p&gt;

&lt;p&gt;That's not a bug — it's a gap in the data, not the logic. Neither a&lt;br&gt;
baseline entry nor a current observation carries a &lt;em&gt;per-value&lt;/em&gt; unit&lt;br&gt;
today; a metric's unit comes entirely from &lt;code&gt;MetricCatalog&lt;/code&gt;, keyed by&lt;br&gt;
metric name. Both sides currently derive their unit from the same&lt;br&gt;
catalog entry, so a mismatch can never be observed. The status stays in&lt;br&gt;
the schema, documented as reserved, because it's part of the intended&lt;br&gt;
shape; it just needs baseline and candidate values to carry real&lt;br&gt;
source-unit metadata before it can mean anything. That's separate,&lt;br&gt;
future work, not something to fake with a check that can never fire.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bigger picture
&lt;/h2&gt;

&lt;p&gt;The important part isn't the new command. It's that "what changed" is&lt;br&gt;
now a first-class concept. Once comparison exists independently of&lt;br&gt;
policy, other features — history, trend analysis, dashboards, and&lt;br&gt;
explanation — can all build on the same artifact instead of&lt;br&gt;
reimplementing comparison logic.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;v0.5.0&lt;/code&gt; — filesystem history. &lt;code&gt;check --history &amp;lt;path&amp;gt;&lt;/code&gt; as an alternative&lt;br&gt;
to &lt;code&gt;--baseline&lt;/code&gt;: a plain directory of snapshots instead of one file,&lt;br&gt;
aggregated (median of the last N, by default) into a baseline on the fly.&lt;br&gt;
That only becomes possible because comparison is now a standalone&lt;br&gt;
artifact instead of an internal evaluator step — history needs something&lt;br&gt;
stable to compare &lt;em&gt;against&lt;/em&gt;, and now it has one. Full sequencing is in&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate/blob/main/docs/ROADMAP.md" rel="noopener noreferrer"&gt;&lt;code&gt;ROADMAP.md&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt; Bijecta.BenchmarkGate.Tool &lt;span class="nt"&gt;--version&lt;/span&gt; 0.4.0-alpha.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo, README, and the full roadmap:&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate" rel="noopener noreferrer"&gt;github.com/Bijecta/BenchmarkGate&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>benchmarkdotnet</category>
      <category>cicd</category>
    </item>
    <item>
      <title>BenchmarkGate, Part 3: benchmark-gate validate</title>
      <dc:creator>Youness Aamiri</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:27:08 +0000</pubDate>
      <link>https://dev.to/younessaamiri/benchmarkgate-part-3-benchmark-gate-validate-1cfg</link>
      <guid>https://dev.to/younessaamiri/benchmarkgate-part-3-benchmark-gate-validate-1cfg</guid>
      <description>&lt;p&gt;In &lt;a href="https://younessaamiri.dev/posts/why-a-baseline-isnt-enough/" rel="noopener noreferrer"&gt;part two&lt;/a&gt;,&lt;br&gt;
&lt;code&gt;v0.2.0-alpha.1&lt;/code&gt; added a stability gate and a policy file. Both are good&lt;br&gt;
things. Both are also new ways to write a file that's subtly wrong — a&lt;br&gt;
threshold typo, an inverted warning/failure pair, a baseline entry missing&lt;br&gt;
its identity — and until now, the only way to find that out was to run&lt;br&gt;
&lt;code&gt;check&lt;/code&gt; and read whatever exception came back first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;v0.3.0-alpha.1&lt;/code&gt; adds &lt;code&gt;benchmark-gate validate&lt;/code&gt;. It doesn't evaluate&lt;br&gt;
anything. It just tells you, exhaustively, what's wrong with a file —&lt;br&gt;
before that file ever gets near a real &lt;code&gt;check&lt;/code&gt; run.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem with fail-fast
&lt;/h2&gt;

&lt;p&gt;Here's &lt;code&gt;policy.json&lt;/code&gt; with two unrelated mistakes in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minimumMeasurements"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maximumCoefficientOfVariation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metrics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"meanNanoseconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lower-is-better"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"warningPercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"failurePercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;minimumMeasurements: 0&lt;/code&gt; is invalid. &lt;code&gt;warningPercent&lt;/code&gt; equal to&lt;br&gt;
&lt;code&gt;failurePercent&lt;/code&gt; is also invalid. Before this release, &lt;code&gt;PolicyFile.Load&lt;/code&gt;&lt;br&gt;
threw on the first problem it found — you'd fix &lt;code&gt;minimumMeasurements&lt;/code&gt;,&lt;br&gt;
re-run, and &lt;em&gt;then&lt;/em&gt; find out about the threshold. Two mistakes, two&lt;br&gt;
round-trips.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;validate&lt;/code&gt; finds both in one pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ benchmark-gate validate --policy bad-policy.json
bad-policy.json
  ERROR BGV105 /stability/minimumMeasurements: Value must be greater than zero; actual value was 0.
  ERROR BGV117 /metrics/meanNanoseconds: Metric 'meanNanoseconds' has warningPercent (10) &amp;gt;= failurePercent (10). warningPercent must be strictly less than failurePercent for the policy to be meaningful.

$ echo $LASTEXITCODE
12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both errors, one invocation, no guessing what else might be wrong once&lt;br&gt;
you fix the first thing.&lt;/p&gt;
&lt;h2&gt;
  
  
  One command, three file types, together
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmark-gate validate &lt;span class="nt"&gt;--policy&lt;/span&gt; policy.json
benchmark-gate validate &lt;span class="nt"&gt;--baseline&lt;/span&gt; baseline.json
benchmark-gate validate &lt;span class="nt"&gt;--results&lt;/span&gt; ./BenchmarkDotNet.Artifacts/results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;All three flags are optional individually, but at least one is required,&lt;br&gt;
and you can combine them — validate your whole input surface in one call&lt;br&gt;
before wiring anything into CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ benchmark-gate validate --policy bad-policy.json --baseline bad-baseline.json
bad-policy.json
  ERROR BGV105 /stability/minimumMeasurements: Value must be greater than zero; actual value was 0.
  ERROR BGV117 /metrics/meanNanoseconds: Metric 'meanNanoseconds' has warningPercent (10) &amp;gt;= failurePercent (10). warningPercent must be strictly less than failurePercent for the policy to be meaningful.

bad-baseline.json
  ERROR BGV203 /benchmarks/1: Duplicate benchmark identity 'MyApp.Bench.Sort|job=Default'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two independently broken files, two different validators (&lt;code&gt;PolicyValidator&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;SnapshotValidator&lt;/code&gt;), one invocation — each grouped under its own&lt;br&gt;
source heading, with a blank line separating artifacts so a multi-file&lt;br&gt;
run stays scannable instead of turning into a wall of undifferentiated&lt;br&gt;
error text.&lt;/p&gt;

&lt;p&gt;Console output groups by source file. Errors print in red when you're&lt;br&gt;
looking at a real terminal; piped or redirected output stays plain, so&lt;br&gt;
scripts and CI logs get deterministic text.&lt;/p&gt;
&lt;h2&gt;
  
  
  Structured output for machines
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--json&lt;/code&gt; writes a versioned report covering every artifact you validated&lt;br&gt;
in one call, instead of just printing to the console. Here's a baseline&lt;br&gt;
with a duplicate benchmark entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmark-gate validate &lt;span class="nt"&gt;--baseline&lt;/span&gt; bad-baseline.json &lt;span class="nt"&gt;--json&lt;/span&gt; validation-report.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isValid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errorCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"warningCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"artifacts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Baseline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bad-baseline.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"isValid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"errorCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"warningCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"diagnostics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BGV203"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Duplicate benchmark identity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/benchmarks/1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Duplicate benchmark identity 'MyApp.Bench.Sort|job=Default'."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Top-level &lt;code&gt;isValid&lt;/code&gt;/&lt;code&gt;errorCount&lt;/code&gt;/&lt;code&gt;warningCount&lt;/code&gt; let a CI step decide&lt;br&gt;
pass/fail without walking every artifact; each artifact still carries its&lt;br&gt;
own counts and full diagnostic list for anything that wants the detail —&lt;br&gt;
a future &lt;code&gt;benchmark-gate explain BGV203&lt;/code&gt; command, an IDE annotation, a&lt;br&gt;
Markdown summary, whatever ends up consuming this.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the checks actually live
&lt;/h2&gt;

&lt;p&gt;This is the part I think is more interesting than the command itself:&lt;br&gt;
&lt;code&gt;validate&lt;/code&gt; doesn't contain a single validation rule. Every check it runs&lt;br&gt;
already existed — I extracted it from &lt;code&gt;check&lt;/code&gt;'s own load path, rather than&lt;br&gt;
writing a second implementation next to the first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PolicyFile.Load&lt;/code&gt; used to do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;warningPercent&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;failurePercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PolicyFileException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"warningPercent must be strictly less than failurePercent..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail fast, one exception, done. That check now lives in &lt;code&gt;PolicyValidator&lt;/code&gt;,&lt;br&gt;
Core-side, returning a &lt;code&gt;ValidationDiagnostic&lt;/code&gt; instead of throwing — and&lt;br&gt;
&lt;code&gt;PolicyFile.Load&lt;/code&gt; calls that same validator, same as it always did, just&lt;br&gt;
collecting every finding instead of stopping at the first one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;GatePolicy&lt;/span&gt; &lt;span class="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PolicyValidator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;PolicyFileException&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromValidationResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PolicyCompiler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompileValidated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;validate&lt;/code&gt; calls the same &lt;code&gt;PolicyValidator.Validate&lt;/code&gt; and just... returns&lt;br&gt;
what it finds, without compiling anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ValidationResult&lt;/span&gt; &lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PolicyValidator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same rule, same code path, two different callers with two different&lt;br&gt;
needs. &lt;code&gt;check&lt;/code&gt; needs a compiled, guaranteed-valid &lt;code&gt;GatePolicy&lt;/code&gt; or nothing.&lt;br&gt;
&lt;code&gt;validate&lt;/code&gt; needs the findings themselves — including ones &lt;code&gt;check&lt;/code&gt; would&lt;br&gt;
never show you, because a file with only warnings still loads fine as far&lt;br&gt;
as &lt;code&gt;check&lt;/code&gt; is concerned.&lt;/p&gt;

&lt;p&gt;The same split applies to baseline files (&lt;code&gt;SnapshotValidator&lt;/code&gt;, which is&lt;br&gt;
what caught the duplicate identity above) and to BenchmarkDotNet result&lt;br&gt;
JSON (&lt;code&gt;ObservationValidator&lt;/code&gt;, plus a second &lt;code&gt;ObservationSetValidator&lt;/code&gt; for&lt;br&gt;
catching the &lt;em&gt;same&lt;/em&gt; benchmark identity duplicated across two different&lt;br&gt;
result files in a directory — a genuinely different scope from&lt;br&gt;
"duplicated within one file," and worth its own diagnostic).&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnostic codes are not implementation detail
&lt;/h2&gt;

&lt;p&gt;Every finding has a stable, documented code — &lt;code&gt;BGV1xx&lt;/code&gt; for policy,&lt;br&gt;
&lt;code&gt;BGV2xx&lt;/code&gt; for baseline, &lt;code&gt;BGV3xx&lt;/code&gt; for BenchmarkDotNet input. The codes are&lt;br&gt;
numbered by what they mean, not by the order I happened to write the&lt;br&gt;
checks in — &lt;code&gt;BGV100&lt;/code&gt;/&lt;code&gt;BGV101&lt;/code&gt; are "missing schema version" and&lt;br&gt;
"unsupported schema version" as two separate codes, not one, because a&lt;br&gt;
missing field and an explicitly-wrong value have different fixes. Once a&lt;br&gt;
code ships, it means what it means; a future version can add codes, never&lt;br&gt;
repurpose one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What v0.3.0 didn't fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;BdnParameterStringParser&lt;/code&gt; — the thing that turns&lt;br&gt;
&lt;code&gt;"N=1000000,Distribution=Canonical"&lt;/code&gt; into a parameter dictionary — still&lt;br&gt;
silently drops a malformed fragment (&lt;code&gt;"N1000000"&lt;/code&gt;, no &lt;code&gt;=&lt;/code&gt;) instead of&lt;br&gt;
reporting it. I know, because I went looking for exactly this kind of gap&lt;br&gt;
while building the observation validator, and found it already documented&lt;br&gt;
in a two-year-old code comment as "a future version could surface this as&lt;br&gt;
a diagnostic."&lt;/p&gt;

&lt;p&gt;I didn't fix it here, on purpose. By the time any validator runs, the&lt;br&gt;
parser has already thrown the malformed fragment away — there's no&lt;br&gt;
evidence left to build a diagnostic from. Fixing it means changing what&lt;br&gt;
the parser &lt;em&gt;returns&lt;/em&gt;, not adding a check on top of what it currently&lt;br&gt;
discards. That's real, separate, tracked work, not a validate-command&lt;br&gt;
diagnostic I could bolt on this release.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;v0.4.0&lt;/code&gt; — &lt;code&gt;benchmark-gate compare&lt;/code&gt;. Pulls the delta calculation out of&lt;br&gt;
the evaluator: &lt;code&gt;compare&lt;/code&gt; describes what changed between a baseline and a&lt;br&gt;
current run, independent of any policy; &lt;code&gt;check&lt;/code&gt; applies policy to that&lt;br&gt;
same comparison instead of computing it inline. Full sequencing is in&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate/blob/main/docs/ROADMAP.md" rel="noopener noreferrer"&gt;&lt;code&gt;ROADMAP.md&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt; Bijecta.BenchmarkGate.Tool &lt;span class="nt"&gt;--version&lt;/span&gt; 0.3.0-alpha.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo, README, and the full roadmap are at&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate" rel="noopener noreferrer"&gt;github.com/Bijecta/BenchmarkGate&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>benchmarkdotnet</category>
      <category>cicd</category>
    </item>
    <item>
      <title>BenchmarkGate, Part 2: Why a Baseline Isn't Enough</title>
      <dc:creator>Youness Aamiri</dc:creator>
      <pubDate>Wed, 29 Jul 2026 09:04:36 +0000</pubDate>
      <link>https://dev.to/younessaamiri/benchmarkgate-part-2-why-a-baseline-isnt-enough-3ndi</link>
      <guid>https://dev.to/younessaamiri/benchmarkgate-part-2-why-a-baseline-isnt-enough-3ndi</guid>
      <description>&lt;p&gt;In &lt;a href="https://younessaamiri.dev/posts/why-a-baseline-isnt-enough/" rel="noopener noreferrer"&gt;part one&lt;/a&gt;, I introduced&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate" rel="noopener noreferrer"&gt;BenchmarkGate&lt;/a&gt; — a local-first&lt;br&gt;
performance regression gate for BenchmarkDotNet. &lt;code&gt;v0.1.0-alpha.1&lt;/code&gt; could do&lt;br&gt;
one thing: compare a benchmark's mean time against a committed baseline,&lt;br&gt;
using a single global threshold passed on the command line.&lt;/p&gt;

&lt;p&gt;That was enough to catch a regression. It wasn't enough to trust the tool.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;v0.2.0-alpha.1&lt;/code&gt; is live now, and this is the part of the story where the&lt;br&gt;
tool stopped being "diff two numbers" and started being a gate you can&lt;br&gt;
actually build a CI contract on.&lt;/p&gt;
&lt;h2&gt;
  
  
  The run that made the case for itself
&lt;/h2&gt;

&lt;p&gt;Here's real output from &lt;code&gt;check&lt;/code&gt;, run against CedarRecon's benchmark suite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Benchmark Gate — CedarRecon&lt;/span&gt;

&lt;span class="gs"&gt;**Overall: ❓ Unstable**&lt;/span&gt;

| Total | Improved | Passed | Warning | Regressed | Missing | New | Unstable |
|---|---|---|---|---|---|---|---|
| 27    | 0        | 6      | 0       | 0         | 0       | 0   | 21       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;21 of 27 benchmarks came back &lt;code&gt;Unstable&lt;/code&gt;. Not passed, not failed —&lt;br&gt;
&lt;em&gt;unstable&lt;/em&gt;. Every one of them has the same explanation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Unstable] ClassifierPhaseBenchmark.BuildDictionaries|job=Job-ZDAPPP|N=10000:
Only 3 measurements were taken, below the configured minimum of 5.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those benchmarks ran with only 3 iterations — not enough for the tool to&lt;br&gt;
trust what it was looking at. A naive diff-the-means tool would have&lt;br&gt;
happily compared 3-sample noise against a baseline and reported a&lt;br&gt;
confident &lt;code&gt;Passed&lt;/code&gt; or &lt;code&gt;Regressed&lt;/code&gt;. BenchmarkGate refused. That refusal&lt;br&gt;
&lt;em&gt;is&lt;/em&gt; the feature.&lt;/p&gt;

&lt;p&gt;The other 6 benchmarks, from a different BenchmarkDotNet job with a real&lt;br&gt;
iteration count, evaluated cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Benchmark                                                          | Metric          | Baseline    | Current     | Delta  | Status |
|---|---|---|---|---|---|
| ExceptionClassifierBenchmark.DictionaryClassifier|job=Job-SNYTAA|N=10000 | meanNanoseconds | 4.946 ms | 4.946 ms | +0.00% | Passed |
| ExceptionClassifierBenchmark.IndexedClassifier|job=Job-SNYTAA|N=1000000 | meanNanoseconds | 472.444 ms | 472.444 ms | +0.00% | Passed |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same suite, same &lt;code&gt;check&lt;/code&gt; invocation, two entirely different verdicts —&lt;br&gt;
because the tool actually looked at &lt;em&gt;how&lt;/em&gt; each result was produced before&lt;br&gt;
deciding whether it meant anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  What changed since v0.1
&lt;/h2&gt;

&lt;p&gt;v0.1 had exactly one lever: &lt;code&gt;--threshold-percent&lt;/code&gt;, applied to mean time,&lt;br&gt;
globally, with no way to say "trust this benchmark less" or "allocation&lt;br&gt;
matters here too." v0.2 replaces that with three things.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. A policy file, not a flag
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmark-gate check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--results&lt;/span&gt; ./BenchmarkDotNet.Artifacts/results &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--baseline&lt;/span&gt; ./benchmarks/baseline.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy&lt;/span&gt; ./benchmarks/policy.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;policy.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"minimumMeasurements"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maximumCoefficientOfVariation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metrics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"meanNanoseconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lower-is-better"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"warningPercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"failurePercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"minimumAbsoluteChange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a committed, reviewable file — a change to your performance budget&lt;br&gt;
shows up in a pull-request diff, same as any other code change. That was&lt;br&gt;
true in spirit in v0.1; in v0.2 it's actually true, because there's a real&lt;br&gt;
document to diff instead of a CLI flag buried in a CI YAML file.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. A stability gate that runs before anything else
&lt;/h3&gt;

&lt;p&gt;Every benchmark is checked against &lt;code&gt;stability&lt;/code&gt; &lt;em&gt;first&lt;/em&gt; — measurement count,&lt;br&gt;
coefficient of variation — before a single metric comparison happens. Fail&lt;br&gt;
that check, and the benchmark is &lt;code&gt;Unstable&lt;/code&gt;, full stop. No metric verdict&lt;br&gt;
gets attached to a sample the tool doesn't trust.&lt;/p&gt;

&lt;p&gt;This is what produced the 21 &lt;code&gt;Unstable&lt;/code&gt; results above. It's also, I'd&lt;br&gt;
argue, the most important thing v0.2 added — not because "unstable" is an&lt;br&gt;
exciting status, but because &lt;em&gt;not&lt;/em&gt; having it means every noisy CI runner&lt;br&gt;
eventually produces a false regression, and false regressions are how&lt;br&gt;
people learn to ignore the gate entirely.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Warning, not just pass/fail
&lt;/h3&gt;

&lt;p&gt;A metric crossing &lt;code&gt;warningPercent&lt;/code&gt; but not &lt;code&gt;failurePercent&lt;/code&gt; is &lt;code&gt;Warning&lt;/code&gt; —&lt;br&gt;
visible in every report, but it only affects the process exit code if you&lt;br&gt;
explicitly ask for that with &lt;code&gt;--fail-on-warning&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmark-gate check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--results&lt;/span&gt; ./BenchmarkDotNet.Artifacts/results &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--baseline&lt;/span&gt; ./benchmarks/baseline.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy&lt;/span&gt; ./benchmarks/policy.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fail-on-warning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the flag: a suite with only warnings exits &lt;code&gt;0&lt;/code&gt;, but the Markdown,&lt;br&gt;
JSON, and console reports all still show the underlying &lt;code&gt;Warning&lt;/code&gt; status —&lt;br&gt;
they never silently upgrade it to &lt;code&gt;Regressed&lt;/code&gt; just because the exit code&lt;br&gt;
happened to be zero. With the flag: the same suite exits non-zero, and&lt;br&gt;
every report format — including a new JUnit XML output for CI test-result&lt;br&gt;
UIs — agrees.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;benchmark-gate check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--results&lt;/span&gt; ./BenchmarkDotNet.Artifacts/results &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--baseline&lt;/span&gt; ./benchmarks/baseline.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy&lt;/span&gt; ./benchmarks/policy.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--markdown&lt;/span&gt; ./report.md &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; ./decision.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--junit&lt;/span&gt; ./junit.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That consistency — CLI exit code, JSON's &lt;code&gt;exitCode&lt;/code&gt; field, and JUnit's&lt;br&gt;
&lt;code&gt;&amp;lt;failure&amp;gt;&lt;/code&gt; elements all telling the same story — mattered enough that I&lt;br&gt;
wrote tests specifically to pin it down, not just eyeballed it once and&lt;br&gt;
moved on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Multi-metric, not just mean time
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;policy.json&lt;/code&gt; isn't hardcoded to mean time. Allocation tracking is a&lt;br&gt;
second first-class metric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"metrics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meanNanoseconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lower-is-better"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"warningPercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"failurePercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allocatedBytesPerOperation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lower-is-better"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"warningPercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"failurePercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"minimumAbsoluteChange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your benchmarks run with &lt;code&gt;[MemoryDiagnoser]&lt;/code&gt;, both metrics get&lt;br&gt;
evaluated independently, and a benchmark's overall status is whichever&lt;br&gt;
metric did worst — a mean-time pass doesn't hide an allocation&lt;br&gt;
regression. Under the hood this isn't two hardcoded fields; it's an&lt;br&gt;
extensible metric dictionary, so a future metric (throughput, GC&lt;br&gt;
collections, whatever) is a policy entry, not a schema migration.&lt;/p&gt;
&lt;h2&gt;
  
  
  What v0.2 cost: two breaking changes
&lt;/h2&gt;

&lt;p&gt;Being pre-1.0 meant I could make the calls that actually made the tool&lt;br&gt;
better instead of carrying compatibility baggage for an audience that&lt;br&gt;
doesn't exist yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Baseline schema bumped v1 → v2.&lt;/strong&gt; The old single &lt;code&gt;meanNanoseconds&lt;/code&gt;
field couldn't hold a metrics dictionary. Old baselines are rejected
outright with a message pointing at &lt;code&gt;capture&lt;/code&gt; — no migration shim, no
silent reinterpretation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--threshold-percent&lt;/code&gt;/&lt;code&gt;--minimum-absolute-change-ns&lt;/code&gt; are gone.&lt;/strong&gt;
&lt;code&gt;--policy&lt;/code&gt; replaces them entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are documented, both are deliberate, and both happened before anyone&lt;br&gt;
but me depended on this tool. That's the whole point of shipping early&lt;br&gt;
alphas.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Two commands are next, in order, per the actual roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;v0.3.0&lt;/code&gt; — &lt;code&gt;benchmark-gate validate&lt;/code&gt;.&lt;/strong&gt; Validates a policy, baseline,
or BenchmarkDotNet input file on its own — catches malformed data
(an inverted warning/failure threshold, a missing required field, an
unsupported schema version) before it ever reaches &lt;code&gt;check&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;v0.4.0&lt;/code&gt; — &lt;code&gt;benchmark-gate compare&lt;/code&gt;.&lt;/strong&gt; Pulls the delta calculation out
of the evaluator entirely: &lt;code&gt;compare&lt;/code&gt; describes what changed between a
baseline and a current run, independent of any policy; &lt;code&gt;check&lt;/code&gt; then
applies policy to that same comparison instead of computing it inline.
That split is what makes everything after it — history, and eventually
explaining &lt;em&gt;why&lt;/em&gt; something regressed — possible without re-plumbing the
evaluator again.
Further out, once &lt;code&gt;compare&lt;/code&gt; exists: history-aware baselines (&lt;code&gt;v0.5.0&lt;/code&gt;),
which is also where environment compatibility checking lives — filtering
out comparisons across incompatible machines/runtimes before they can
produce a misleading result. That's real and coming, just not the
immediate next step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Separately, and not version-gated to any of the above: &lt;strong&gt;net8.0&lt;br&gt;
multi-targeting&lt;/strong&gt;, so the tool installs on machines with only the .NET 8&lt;br&gt;
LTS runtime, not just .NET 10.&lt;/p&gt;

&lt;p&gt;Full sequencing, including what's deliberately parked past v1.0.0&lt;br&gt;
(diagnostic explanation, EventPipe integration), is in the repo's&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate/blob/main/docs/ROADMAP.md" rel="noopener noreferrer"&gt;&lt;code&gt;ROADMAP.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt; Bijecta.BenchmarkGate.Tool &lt;span class="nt"&gt;--version&lt;/span&gt; 0.2.0-alpha.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo, README, and the full roadmap are at&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate" rel="noopener noreferrer"&gt;github.com/Bijecta/BenchmarkGate&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>benchmarkdotnet</category>
      <category>cicd</category>
    </item>
    <item>
      <title>BenchmarkDotNet Measures Performance—But Who Enforces the Budget?</title>
      <dc:creator>Youness Aamiri</dc:creator>
      <pubDate>Fri, 24 Jul 2026 11:13:13 +0000</pubDate>
      <link>https://dev.to/younessaamiri/benchmarkdotnet-measures-performance-but-who-enforces-the-budget-1am2</link>
      <guid>https://dev.to/younessaamiri/benchmarkdotnet-measures-performance-but-who-enforces-the-budget-1am2</guid>
      <description>&lt;p&gt;You add &lt;code&gt;[MemoryDiagnoser]&lt;/code&gt; and &lt;code&gt;[Benchmark]&lt;/code&gt; to a class, run &lt;code&gt;dotnet run -c&lt;br&gt;
Release&lt;/code&gt;, and BenchmarkDotNet hands you back a beautifully formatted table:&lt;br&gt;
mean, median, standard deviation, allocated bytes per operation. It's one of&lt;br&gt;
the best measurement tools in any ecosystem.&lt;/p&gt;

&lt;p&gt;And then what?&lt;/p&gt;

&lt;p&gt;You read the numbers. You compare them, in your head, to what you remember&lt;br&gt;
from last week. Maybe you screenshot the table and paste it into a Slack&lt;br&gt;
message. Maybe you don't, because nothing regressed &lt;em&gt;today&lt;/em&gt;, so there's&lt;br&gt;
nothing to say.&lt;/p&gt;

&lt;p&gt;That's the gap. BenchmarkDotNet is a measurement tool. It has no opinion&lt;br&gt;
about whether 4.5ms is acceptable or a regression. It doesn't remember what&lt;br&gt;
the number was yesterday. It doesn't fail your build. It doesn't stop a pull&lt;br&gt;
request. Every one of those decisions is left as an exercise for the reader&lt;br&gt;
— which in practice means it doesn't happen, until someone notices&lt;br&gt;
production got slower and goes spelunking through git blame.&lt;/p&gt;

&lt;h2&gt;
  
  
  A budget is not a measurement
&lt;/h2&gt;

&lt;p&gt;The distinction I kept running into while working on&lt;br&gt;
&lt;a href="https://github.com/AamiriYouness/CedarRecon" rel="noopener noreferrer"&gt;CedarRecon&lt;/a&gt;, a reconciliation&lt;br&gt;
engine where I'm doing a lot of comparative benchmarking (dictionary-based&lt;br&gt;
vs. indexed classification strategies, that kind of thing): a &lt;em&gt;measurement&lt;/em&gt;&lt;br&gt;
tells you what happened. A &lt;em&gt;budget&lt;/em&gt; is a policy — a committed, reviewable&lt;br&gt;
statement that says "this operation must not take more than X, and here's&lt;br&gt;
what we do if it does."&lt;/p&gt;

&lt;p&gt;Those are different artifacts with different lifecycles. A measurement is&lt;br&gt;
disposable — you take it, you look at it, you move on. A budget needs to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Committed&lt;/strong&gt; — visible in a pull request diff, not a number someone
remembers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewable&lt;/strong&gt; — a change to the budget is itself a decision someone signs
off on, the same way you'd review a change to a config file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforced&lt;/strong&gt; — a CI failure, not a Slack message someone might read&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;BenchmarkDotNet gives you excellent measurements. It was never trying to be&lt;br&gt;
the second thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just eyeball it in CI logs?
&lt;/h2&gt;

&lt;p&gt;The honest answer is: you can, for a while, on a small project with one or&lt;br&gt;
two people watching closely. It stops working the moment either of those&lt;br&gt;
conditions changes.&lt;/p&gt;

&lt;p&gt;I found this out concretely, not hypothetically. While dogfooding an early&lt;br&gt;
build of a tool I've been writing specifically to close this gap (more on&lt;br&gt;
that below), I ran a real benchmark suite from CedarRecon, deliberately&lt;br&gt;
introduced a regression into one method to confirm the tool would catch it&lt;br&gt;
— and it did, correctly, twice. But on the same run, an &lt;em&gt;unrelated&lt;/em&gt; method I&lt;br&gt;
hadn't touched showed a &lt;strong&gt;28% regression&lt;/strong&gt; at the largest input size.&lt;/p&gt;

&lt;p&gt;If I'd been eyeballing a CI log, that's the kind of number that either gets&lt;br&gt;
ignored ("probably just noise, I'm busy") or gets chased for an hour before&lt;br&gt;
someone realizes the machine was probably running warm. What actually&lt;br&gt;
happened: I reran &lt;em&gt;just that benchmark&lt;/em&gt; in isolation, and it came back&lt;br&gt;
clean — comfortably within a few percent of baseline, no regression at all.&lt;br&gt;
It was noise. A shared CI runner, or a laptop that hadn't idled down between&lt;br&gt;
runs, produced one bad sample.&lt;/p&gt;

&lt;p&gt;That's not a knock on BenchmarkDotNet — its statistics are good. It's a&lt;br&gt;
demonstration of why "look at the number and decide" doesn't scale as a&lt;br&gt;
&lt;em&gt;process&lt;/em&gt;, even when the underlying measurement tool is solid. You need&lt;br&gt;
something that applies a consistent rule every time, and — just as&lt;br&gt;
importantly — something that treats a single suspicious result as a&lt;br&gt;
hypothesis to confirm, not a verdict to act on immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a performance contract actually needs
&lt;/h2&gt;

&lt;p&gt;Turning "here are some numbers" into "here is an enforced budget" means a&lt;br&gt;
few concrete things have to exist that BenchmarkDotNet doesn't try to&lt;br&gt;
provide:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A committed baseline.&lt;/strong&gt; Not a number in someone's head — a file in the&lt;br&gt;
repo. JSON, reviewable, diffable. When a baseline changes, that's a&lt;br&gt;
deliberate act someone did on purpose, visible in the PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A threshold policy.&lt;/strong&gt; How much regression is acceptable before it's a&lt;br&gt;
failure? 5%? 15%? Does a tiny absolute change (1ns → 1.2ns) count, even&lt;br&gt;
though it's "20%"? These are decisions, and they should live in&lt;br&gt;
configuration, not in someone's judgment call at 4pm on a Friday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pass/fail exit code.&lt;/strong&gt; The single most useful thing a CI step can&lt;br&gt;
produce. Not a report someone might read — a build that goes red.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No SaaS, no hosted database.&lt;/strong&gt; This one's a deliberate design choice more&lt;br&gt;
than a hard requirement, but it matters to me: baselines and policies as&lt;br&gt;
plain repo-local JSON mean no external account, no network dependency to&lt;br&gt;
evaluate a benchmark, and — critically — the same command produces the same&lt;br&gt;
decision locally and in CI. Nothing hidden behind a dashboard only CI can&lt;br&gt;
reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is going
&lt;/h2&gt;

&lt;p&gt;That's the shape of the tool I've been building —&lt;br&gt;
&lt;a href="https://github.com/Bijecta/BenchmarkGate" rel="noopener noreferrer"&gt;BenchmarkGate&lt;/a&gt;,&lt;br&gt;
a local-first performance regression gate designed specifically for&lt;br&gt;
BenchmarkDotNet output. It doesn't run your benchmarks. It doesn't replace&lt;br&gt;
BenchmarkDotNet's statistics. It reads the JSON BenchmarkDotNet already&lt;br&gt;
produces, compares it against a committed baseline under a policy you&lt;br&gt;
control, and returns an exit code CI can act on — plus a Markdown summary&lt;br&gt;
that explains &lt;em&gt;why&lt;/em&gt;, not just &lt;em&gt;that&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;v0.1.0-alpha.1&lt;/code&gt; is live now, built and dogfooded against CedarRecon's real&lt;br&gt;
benchmark suite — including that 28% false alarm, caught and correctly&lt;br&gt;
dismissed by rerunning rather than reacting.&lt;/p&gt;

&lt;p&gt;Next up: the architecture — normalized benchmark identities, why baselines&lt;br&gt;
and policies are deliberately separate documents, and the exit-code&lt;br&gt;
contract that makes this usable as an actual CI gate and not just a fancier&lt;br&gt;
report.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>opensource</category>
      <category>cicd</category>
    </item>
    <item>
      <title>From dictionary to columnar: how we made a .NET financial reconciliation engine 2x faster</title>
      <dc:creator>Youness Aamiri</dc:creator>
      <pubDate>Tue, 14 Jul 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/younessaamiri/from-dictionary-to-columnar-how-we-made-a-net-financial-reconciliation-engine-2x-faster-2o67</link>
      <guid>https://dev.to/younessaamiri/from-dictionary-to-columnar-how-we-made-a-net-financial-reconciliation-engine-2x-faster-2o67</guid>
      <description>&lt;p&gt;When you're building financial reconciliation software, correctness is the&lt;br&gt;
first requirement. Performance is the second. At small scale the two rarely&lt;br&gt;
conflict. At a million transactions per run, they start to fight each other —&lt;br&gt;
and the data structures you chose at the beginning of the project start to&lt;br&gt;
look like liabilities.&lt;/p&gt;

&lt;p&gt;This is the story of how we took CedarRecon's exception classification engine&lt;br&gt;
from a dictionary-based implementation running at 1,368ms per million&lt;br&gt;
transactions to a columnar engine running at 646ms — a 2.1x improvement —&lt;br&gt;
and what we learned along the way, including four approaches that made things&lt;br&gt;
worse, not better.&lt;/p&gt;


&lt;h2&gt;
  
  
  What Exception Classification Does
&lt;/h2&gt;

&lt;p&gt;Reconciliation engines do two things: match transactions and classify the ones&lt;br&gt;
that couldn't be matched. Matching is interesting in its own right (see our&lt;br&gt;
earlier post on the hash-based matching engine), but classification is what&lt;br&gt;
produces the output that compliance teams actually act on.&lt;/p&gt;

&lt;p&gt;The classifier takes three inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unmatched source transactions (what we sent)&lt;/li&gt;
&lt;li&gt;Unmatched target transactions (what we received)&lt;/li&gt;
&lt;li&gt;Matched pairs (transactions already resolved by the matching engine)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And assigns every unmatched transaction one of eight discrepancy types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DuplicateInSource     — multiple source rows share the same reference
DuplicateInTarget     — multiple target rows share the same reference
SplitPayment          — one source, multiple targets for the same reference
ConsolidatedPayment   — multiple sources, one target for the same reference
AmountMismatch        — closest target has a different amount
DateMismatch          — closest target has the same amount, different date
MissingInTarget       — no target exists for this source
MissingInSource       — no source claimed this target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These eight types are assigned in a strict cascade — earlier phases take&lt;br&gt;
priority and a row classified in phase 1 is never reclassified in phase 6.&lt;/p&gt;

&lt;p&gt;The classification is a pure function: same inputs, same outputs, every time.&lt;br&gt;
That constraint turns out to be important for how we structured the&lt;br&gt;
optimization work.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Starting Point: Dictionary-Based Classification
&lt;/h2&gt;

&lt;p&gt;The original implementation built four dictionaries from the input data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sourceByRef&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unmatchedSource&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NormalizedReference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDictionary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;targetByRef&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unmatchedTarget&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NormalizedReference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDictionary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;matchedSourceLegCounts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;matchedPairs&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NormalizedReference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDictionary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;matchedTargetLegCounts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;matchedPairs&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NormalizedReference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDictionary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the 8-phase cascade ran lookups against those dictionaries. Every&lt;br&gt;
lookup hashed a &lt;code&gt;NormalizedReference.Value&lt;/code&gt; string — O(string length) per&lt;br&gt;
lookup, called millions of times across the cascade at N=1M.&lt;/p&gt;

&lt;p&gt;The benchmark numbers were not good:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;ms/1K rows&lt;/th&gt;
&lt;th&gt;Growth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;42ms&lt;/td&gt;
&lt;td&gt;4.2ms/1K&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;632ms&lt;/td&gt;
&lt;td&gt;6.3ms/1K&lt;/td&gt;
&lt;td&gt;1.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;8,400ms&lt;/td&gt;
&lt;td&gt;8.4ms/1K&lt;/td&gt;
&lt;td&gt;2.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Super-linear scaling at 2.0x from N=10K to N=1M. At N=1M, 8.4 seconds per&lt;br&gt;
classification run. For a batch reconciliation system running overnight,&lt;br&gt;
that's acceptable. For anything approaching real-time, it isn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82n5nz0ef46fr8mmxauh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82n5nz0ef46fr8mmxauh.png" alt="Dictionary classifier scaling from 4.2ms/1K at N=10K to 8.4ms/1K at N=1M" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We set a benchmark discipline before touching anything: every optimization&lt;br&gt;
claim must be backed by BenchmarkDotNet evidence, hypotheses that turn out&lt;br&gt;
wrong get documented as negative results, and we never batch multiple changes&lt;br&gt;
into one unbenchmarked commit. That last rule saved us several times.&lt;/p&gt;


&lt;h2&gt;
  
  
  First Wins: DictionaryBuilder and Parallel Batching
&lt;/h2&gt;
&lt;h3&gt;
  
  
  DictionaryBuilder
&lt;/h3&gt;

&lt;p&gt;The first obvious improvement was the dictionary construction itself.&lt;br&gt;
&lt;code&gt;GroupBy().ToDictionary()&lt;/code&gt; makes two passes and allocates intermediate&lt;br&gt;
&lt;code&gt;IGrouping&lt;/code&gt; objects. A single-pass approach using&lt;br&gt;
&lt;code&gt;CollectionsMarshal.GetValueRefOrAddDefault&lt;/code&gt; builds the same structure in&lt;br&gt;
one pass with roughly half the allocations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dict&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="n"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;CollectionsMarshal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetValueRefOrAddDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NormalizedReference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;list&lt;/span&gt; &lt;span class="p"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Transaction&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: 2.4–3.7x faster dictionary construction, ~50% less allocation,&lt;br&gt;
and the super-linear curve for this specific phase flattened to roughly&lt;br&gt;
linear. This was the clearest win of the entire investigation, and it cost&lt;br&gt;
about 20 lines of code.&lt;/p&gt;
&lt;h3&gt;
  
  
  Parallel Batching
&lt;/h3&gt;

&lt;p&gt;The cascade's more expensive phases (Split/Consolidated, Mismatch) could&lt;br&gt;
run in parallel across reference groups. We added &lt;code&gt;Parallel.ForEach&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;ConcurrentBag&lt;/code&gt; collection and a merge step, with a configurable&lt;br&gt;
&lt;code&gt;ParallelThreshold&lt;/code&gt; (default 10,000) below which parallel overhead isn't&lt;br&gt;
worth it.&lt;/p&gt;

&lt;p&gt;Combined effect of both fixes: &lt;strong&gt;2.02x faster at N=1M&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But the whole-method scaling curve still climbed — 0.816ms/1K at N=10K&lt;br&gt;
rising to 1.196ms/1K at N=1M, a 1.46x growth ratio. The dictionary&lt;br&gt;
structure itself was still the problem.&lt;/p&gt;


&lt;h2&gt;
  
  
  Dead End #1: FrozenDictionary
&lt;/h2&gt;

&lt;p&gt;The steepest-growing phase was &lt;code&gt;SplitConsolidatedPhases&lt;/code&gt; — 3.06x growth&lt;br&gt;
from N=10K to N=1M. The obvious hypothesis: &lt;code&gt;FrozenDictionary&amp;lt;TKey, TValue&amp;gt;&lt;/code&gt;,&lt;br&gt;
introduced in .NET 8, offers faster lookups than &lt;code&gt;Dictionary&amp;lt;TKey, TValue&amp;gt;&lt;/code&gt;&lt;br&gt;
for read-only scenarios. We'd build the dictionaries once and freeze them&lt;br&gt;
before the cascade.&lt;/p&gt;

&lt;p&gt;The benchmark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;Dictionary&lt;/th&gt;
&lt;th&gt;FrozenDictionary&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;1.21 µs/1K&lt;/td&gt;
&lt;td&gt;0.95 µs/1K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−22%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;1.87 µs/1K&lt;/td&gt;
&lt;td&gt;2.23 µs/1K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+19%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;3.09 µs/1K&lt;/td&gt;
&lt;td&gt;4.35 µs/1K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+41%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;FrozenDictionary&lt;/code&gt; won at N=10K and lost badly at N=100K and N=1M — the&lt;br&gt;
opposite of what we needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4vv2iw9o42sl7rcjc19.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4vv2iw9o42sl7rcjc19.png" alt="FrozenDictionary beats Dictionary by 22% at N=10K but loses by 41% at N=1M" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lesson: the problem wasn't lookup speed. It was scan pattern. At large&lt;br&gt;
N, the dictionary's bucket array and the heap-allocated &lt;code&gt;List&amp;lt;Transaction&amp;gt;&lt;/code&gt;&lt;br&gt;
objects it pointed to were scattered across memory with no spatial locality.&lt;br&gt;
Every "lookup" was really a cache miss chain: hash → bucket → list pointer&lt;br&gt;
→ list contents, none of it co-located. A faster hash function doesn't fix&lt;br&gt;
a cache miss problem.&lt;/p&gt;

&lt;p&gt;This distinction — lookup speed vs access pattern — shaped everything that&lt;br&gt;
came after.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Indexed Engine: Sorting Instead of Hashing
&lt;/h2&gt;

&lt;p&gt;If hashing was the wrong model, what was the right one? For sequential&lt;br&gt;
access over a known key set, sorted arrays.&lt;/p&gt;

&lt;p&gt;A sorted array of transactions, sorted by normalized reference, has all&lt;br&gt;
transactions for the same reference in a contiguous block. Iterating over&lt;br&gt;
them is a sequential memory access — exactly the pattern CPU prefetchers&lt;br&gt;
are built for. A dictionary's entries, by contrast, are accessed in hash&lt;br&gt;
order, which for large tables approximates random access.&lt;/p&gt;
&lt;h3&gt;
  
  
  ReferenceInterner
&lt;/h3&gt;

&lt;p&gt;The first building block: map reference strings to dense sequential integers&lt;br&gt;
(0, 1, 2, ...) once, upfront, so every subsequent operation works on ints&lt;br&gt;
instead of strings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReferenceInterner&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;GetOrAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After interning, every lookup in the cascade is an array index operation&lt;br&gt;
on a dense integer — O(1), no hashing, no string comparison. The string&lt;br&gt;
dictionary pays its cost once at build time and never appears on the hot&lt;br&gt;
path again.&lt;/p&gt;
&lt;h3&gt;
  
  
  RefGroup
&lt;/h3&gt;

&lt;p&gt;Instead of &lt;code&gt;Dictionary&amp;lt;string, List&amp;lt;Transaction&amp;gt;&amp;gt;&lt;/code&gt;, we built a &lt;code&gt;RefGroup&lt;/code&gt;&lt;br&gt;
struct that points into sorted arrays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;RefGroup&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ReferenceId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;SourceStart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// first position in sorted source array&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;SourceCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// number of source rows for this reference&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TargetStart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TargetCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MatchedSourceCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// legs already matched&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MatchedTargetCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TotalSourceLegs&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SourceCount&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;MatchedSourceCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TotalTargetLegs&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TargetCount&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;MatchedTargetCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One flat &lt;code&gt;RefGroup[]&lt;/code&gt; array, one flat source &lt;code&gt;IndexedTransaction[]&lt;/code&gt; array,&lt;br&gt;
one flat target &lt;code&gt;IndexedTransaction[]&lt;/code&gt; array. All sorted by &lt;code&gt;ReferenceId&lt;/code&gt;.&lt;br&gt;
All contiguous in memory. All accessible via array index, never via hash&lt;br&gt;
lookup.&lt;/p&gt;

&lt;p&gt;The cascade phases became sequential scans over &lt;code&gt;RefGroup[]&lt;/code&gt;, with inner&lt;br&gt;
loops over the contiguous row ranges each group described. No dictionaries,&lt;br&gt;
no hash lookups, no pointer chasing.&lt;/p&gt;
&lt;h3&gt;
  
  
  First indexed-engine results
&lt;/h3&gt;

&lt;p&gt;Phase benchmark, &lt;code&gt;SplitConsolidatedScan&lt;/code&gt; (the worst-scaling phase):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;Dictionary µs/1K&lt;/th&gt;
&lt;th&gt;Indexed µs/1K&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;1.27&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;td&gt;−35%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;1.87&lt;/td&gt;
&lt;td&gt;0.64&lt;/td&gt;
&lt;td&gt;−66%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;3.09&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−71%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Growth ratio: 2.43x → 1.11x. Nearly flat. The cache-locality hypothesis&lt;br&gt;
was confirmed.&lt;/p&gt;

&lt;p&gt;Whole-method at N=1M: &lt;strong&gt;0.52x vs dictionary&lt;/strong&gt; (1.92x faster).&lt;/p&gt;

&lt;p&gt;But a new problem had appeared. &lt;code&gt;MismatchScan&lt;/code&gt; — which had previously been&lt;br&gt;
cheap — was now the most expensive scan phase and was showing 1.6x growth:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;MismatchScan µs/1K&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;7.76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;10.79&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;12.57&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We had fixed the scan pattern problem and exposed a different problem: the&lt;br&gt;
arithmetic inside the inner loop.&lt;/p&gt;


&lt;h2&gt;
  
  
  Item 1: decimal → long (The Instruction-Count Insight)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MismatchScan&lt;/code&gt;'s inner loop found the closest unmatched target by amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetRows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ti&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sourceRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Amount&lt;/code&gt; was &lt;code&gt;decimal&lt;/code&gt;. On .NET, &lt;code&gt;decimal&lt;/code&gt; arithmetic is software-emulated —&lt;br&gt;
there is no native CPU instruction for it. &lt;code&gt;Math.Abs&lt;/code&gt; on a &lt;code&gt;decimal&lt;/code&gt; is a&lt;br&gt;
method call that executes multiple instructions. At N=1M with the mismatch&lt;br&gt;
phase touching every source row that reached it, this added up.&lt;/p&gt;

&lt;p&gt;The fix: store amounts as &lt;code&gt;long AmountMinor&lt;/code&gt; (scaled by 10^4) at index-build&lt;br&gt;
time. &lt;code&gt;Math.Abs&lt;/code&gt; on a &lt;code&gt;long&lt;/code&gt; is a single CPU instruction.&lt;/p&gt;

&lt;p&gt;Scale factor choice: 10^4, not 10^2. Most currencies use 2 decimal places,&lt;br&gt;
but ISO 4217 includes currencies with 3 (KWD, BHD, OMR). Scaling by 10^2&lt;br&gt;
would silently truncate those — a data loss bug that would only surface on&lt;br&gt;
specific currency combinations. 10^4 is a strict superset of every realistic&lt;br&gt;
precision in this domain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetBatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AmountMinor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ti&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sourceAmountMinor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;Before µs/1K&lt;/th&gt;
&lt;th&gt;After µs/1K&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;7.76&lt;/td&gt;
&lt;td&gt;5.20&lt;/td&gt;
&lt;td&gt;−33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;10.79&lt;/td&gt;
&lt;td&gt;6.86&lt;/td&gt;
&lt;td&gt;−36%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;12.57&lt;/td&gt;
&lt;td&gt;6.34&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−50%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Growth ratio: 1.6x → 1.22x. Half the cost at N=1M, nearly flat scaling.&lt;br&gt;
Same fact class as the FrozenDictionary lesson — understanding &lt;em&gt;what kind&lt;/em&gt;&lt;br&gt;
of work is expensive (software-emulated arithmetic vs native instructions)&lt;br&gt;
matters more than optimizing at the wrong level.&lt;/p&gt;


&lt;h2&gt;
  
  
  Dead Ends #2, #3, #4: Three More Negative Results
&lt;/h2&gt;

&lt;p&gt;Before reaching the columnar design, we tested three more approaches that&lt;br&gt;
didn't work. Documenting them is as important as documenting what did.&lt;/p&gt;
&lt;h3&gt;
  
  
  Dead End #2: BuildGroups allocation
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis&lt;/strong&gt;: &lt;code&gt;List&amp;lt;RefGroup&amp;gt;&lt;/code&gt; + &lt;code&gt;[.. groups]&lt;/code&gt; spread in &lt;code&gt;BuildGroups&lt;/code&gt;&lt;br&gt;
allocated two arrays — the list's backing array and a second array from&lt;br&gt;
the spread. Replacing with a pre-sized &lt;code&gt;RefGroup[]&lt;/code&gt; + count cursor would&lt;br&gt;
eliminate the second allocation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: &lt;code&gt;Allocated&lt;/code&gt; column unchanged at every N. The realistic dataset&lt;br&gt;
always has some matched-only references (contributing to &lt;code&gt;internedCount&lt;/code&gt;&lt;br&gt;
but not to &lt;code&gt;count&lt;/code&gt;), so &lt;code&gt;count &amp;lt; internedCount&lt;/code&gt; on every run, meaning&lt;br&gt;
&lt;code&gt;Array.Resize&lt;/code&gt; fired just as often as the old spread. The "double&lt;br&gt;
allocation" only exists when &lt;code&gt;count == internedCount&lt;/code&gt; exactly — a case&lt;br&gt;
the realistic dataset never produces.&lt;/p&gt;

&lt;p&gt;Mean time improved slightly (~17% at N=100K) but inconsistently. The change&lt;br&gt;
was kept (not worse) but the hypothesis was wrong.&lt;/p&gt;
&lt;h3&gt;
  
  
  Dead End #3: ReferenceInterner reverse list
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis&lt;/strong&gt;: &lt;code&gt;ReferenceInterner&lt;/code&gt; built a &lt;code&gt;List&amp;lt;string&amp;gt;&lt;/code&gt; for reverse&lt;br&gt;
lookup (&lt;code&gt;GetValue&lt;/code&gt;). Since &lt;code&gt;GetValue&lt;/code&gt; is diagnostics-only and never called&lt;br&gt;
during classification, making it optional should reduce allocation at no&lt;br&gt;
time cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: Allocation down 10–16%. Time consistently &lt;strong&gt;+5–34% worse&lt;/strong&gt; at&lt;br&gt;
all N.&lt;/p&gt;

&lt;p&gt;This is also where we learned that 3 benchmark iterations is not enough for&lt;br&gt;
effect sizes in the 5–35% range. The first run (3 iterations, noisy) showed&lt;br&gt;
an apparent win on both allocation and time. A rerun with 15 iterations&lt;br&gt;
(tighter error bars) revealed the time regression clearly. We reverted the&lt;br&gt;
change and adopted &lt;code&gt;warmupCount: 3, iterationCount: 15&lt;/code&gt; as the standing&lt;br&gt;
benchmark convention from that point forward.&lt;/p&gt;
&lt;h3&gt;
  
  
  Dead End #4: Index-sort (sort int[] instead of structs)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis&lt;/strong&gt;: &lt;code&gt;Array.Sort&lt;/code&gt; on an &lt;code&gt;IndexedTransaction[]&lt;/code&gt; moves whole&lt;br&gt;
~20-byte structs on every swap. Sorting a parallel &lt;code&gt;int[] rowOrder&lt;/code&gt; by&lt;br&gt;
&lt;code&gt;ReferenceId&lt;/code&gt; would move only 4-byte ints — smaller element, cheaper sort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: The sort did get cheaper. But every scan phase then needed one&lt;br&gt;
extra array dereference to read a row: &lt;code&gt;rows[rowOrder[i]]&lt;/code&gt; instead of&lt;br&gt;
&lt;code&gt;rows[i]&lt;/code&gt;. At N=1M, that extra dereference for every row read in every&lt;br&gt;
phase cost more than the sort savings.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase (N=1M)&lt;/th&gt;
&lt;th&gt;Struct-sort µs/1K&lt;/th&gt;
&lt;th&gt;Index-sort µs/1K&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SplitConsolidatedScan&lt;/td&gt;
&lt;td&gt;2.81&lt;/td&gt;
&lt;td&gt;4.53&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+61%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuplicateScan&lt;/td&gt;
&lt;td&gt;5.55&lt;/td&gt;
&lt;td&gt;7.34&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+32%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern from FrozenDictionary reappeared: indirection costs more than&lt;br&gt;
it saves at production scale. Cache misses from pointer chasing dominate&lt;br&gt;
any algorithmic savings from working with smaller elements.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Columnar Leap: Struct-of-Arrays
&lt;/h2&gt;

&lt;p&gt;The index-sort investigation clarified the real problem. Even after&lt;br&gt;
compacting &lt;code&gt;IndexedTransaction&lt;/code&gt; to ~20 bytes, array-of-structs still had&lt;br&gt;
a structural inefficiency: every cache line loaded for any phase contained&lt;br&gt;
all fields, even ones that phase never read.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DuplicateScan&lt;/code&gt; only checks whether multiple rows share the same reference.&lt;br&gt;
It never looks at amounts or dates. But in an array-of-structs layout, the&lt;br&gt;
cache line containing a row's &lt;code&gt;ReferenceId&lt;/code&gt; also contains &lt;code&gt;AmountMinor&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;DayNumber&lt;/code&gt;. That's wasted bandwidth at every cache line boundary.&lt;/p&gt;

&lt;p&gt;Struct-of-arrays separates the columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ColumnarTransactionBatch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;  &lt;span class="n"&gt;OriginalIndex&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;  &lt;span class="n"&gt;MatchKeyId&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;AmountMinor&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;  &lt;span class="n"&gt;DayNumber&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;ProcessingState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DuplicateScan&lt;/code&gt; touches only &lt;code&gt;ProcessingState&lt;/code&gt;. &lt;code&gt;MismatchScan&lt;/code&gt; touches only&lt;br&gt;
&lt;code&gt;AmountMinor&lt;/code&gt;, &lt;code&gt;DayNumber&lt;/code&gt;, and &lt;code&gt;ProcessingState&lt;/code&gt;. &lt;code&gt;MatchKeyId&lt;/code&gt; is read&lt;br&gt;
only during index build. When a phase iterates over &lt;code&gt;AmountMinor&lt;/code&gt;, every&lt;br&gt;
byte in every cache line is an amount value — 100% useful data.&lt;/p&gt;
&lt;h3&gt;
  
  
  ProcessingState: the generic execution buffer
&lt;/h3&gt;

&lt;p&gt;One design decision worth explaining: &lt;code&gt;ProcessingState&lt;/code&gt; is a &lt;code&gt;byte[]&lt;/code&gt;, not&lt;br&gt;
a &lt;code&gt;ClassificationState[]&lt;/code&gt;. The enum exists and works fine for external&lt;br&gt;
APIs, but on the hot path we use byte constants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClassificationStateBytes&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="n"&gt;None&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="n"&gt;DuplicateInSource&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="n"&gt;MissingInSource&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason: &lt;code&gt;ProcessingState&lt;/code&gt; is an execution buffer, not a typed domain&lt;br&gt;
field. The same array can be reused by a future matching operator with&lt;br&gt;
completely different byte semantics — no reallocation, just a different&lt;br&gt;
interpretation. This is how database execution engines handle per-row status:&lt;br&gt;
one generic buffer, operator-local meaning. Translate to stable domain types&lt;br&gt;
(&lt;code&gt;DiscrepancyType&lt;/code&gt;) before exposing results; the buffer stays internal.&lt;/p&gt;
&lt;h3&gt;
  
  
  Columnar scan phase results (N=1M)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Array-of-structs µs/1K&lt;/th&gt;
&lt;th&gt;Columnar µs/1K&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MismatchScan&lt;/td&gt;
&lt;td&gt;6.34&lt;/td&gt;
&lt;td&gt;4.54&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−28%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MissingSweep&lt;/td&gt;
&lt;td&gt;2.96&lt;/td&gt;
&lt;td&gt;1.45&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−51%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SplitConsolidatedScan&lt;/td&gt;
&lt;td&gt;2.81&lt;/td&gt;
&lt;td&gt;3.45&lt;/td&gt;
&lt;td&gt;+23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuplicateScan&lt;/td&gt;
&lt;td&gt;5.55&lt;/td&gt;
&lt;td&gt;5.12&lt;/td&gt;
&lt;td&gt;−8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most phases improved. &lt;code&gt;SplitConsolidatedScan&lt;/code&gt; regressed — unexpected, but&lt;br&gt;
it's a phase that barely touches column data and is dominated by &lt;code&gt;RefGroup&lt;/code&gt;&lt;br&gt;
iteration, so the columnar layout didn't help it as much.&lt;/p&gt;
&lt;h3&gt;
  
  
  The IndexBuild regression
&lt;/h3&gt;

&lt;p&gt;There was a catch. The initial columnar builder used sort-then-scatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sort a parallel int[] by MatchKeyId&lt;/span&gt;
&lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tmpMatchKeyId&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;CompareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmpMatchKeyId&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;span class="c1"&gt;// Scatter each column&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
    &lt;span class="n"&gt;originalIndex&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmpOriginalIndex&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sortOrder&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;  &lt;span class="c1"&gt;// random read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At N=1M, &lt;code&gt;tmpOriginalIndex[sortOrder[i]]&lt;/code&gt; is a random read — &lt;code&gt;sortOrder[i]&lt;/code&gt;&lt;br&gt;
is an arbitrary position in a large array that doesn't fit in cache. Same&lt;br&gt;
problem as the index-sort approach in dead end #4, just in the build phase&lt;br&gt;
instead of the scan phases.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IndexBuild&lt;/code&gt; at N=1M: +18% worse than array-of-structs.&lt;/p&gt;

&lt;p&gt;The whole-method benchmark was still positive (0.50x vs dictionary, 2x&lt;br&gt;
faster) because scan-phase wins outweighed the build regression. But we&lt;br&gt;
had a known problem.&lt;/p&gt;


&lt;h2&gt;
  
  
  Histogram Sort: The Final Piece
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MatchKeyId&lt;/code&gt; is a dense integer — it's an interned reference ID assigned&lt;br&gt;
sequentially from 0. Dense integer keys are the ideal case for counting&lt;br&gt;
sort (also called histogram sort).&lt;/p&gt;

&lt;p&gt;Instead of a comparison sort (O(n log n)) and a random-read scatter, we do:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass 1&lt;/strong&gt;: Count rows per key in one sequential scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tmpMatchKeyId&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]++;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pass 2&lt;/strong&gt;: Prefix sum to get start offsets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;keyCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;starts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;running&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pass 3&lt;/strong&gt;: Place rows using a cursor per key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;starts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// copy of starts&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmpMatchKeyId&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]++;&lt;/span&gt;    &lt;span class="c1"&gt;// cursor advances sequentially within each key&lt;/span&gt;
    &lt;span class="n"&gt;finalAmountMinor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmpAmountMinor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="c1"&gt;// ... other columns&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reads from &lt;code&gt;tmp*&lt;/code&gt; arrays are sequential (we iterate input order).&lt;br&gt;
The writes to &lt;code&gt;final*&lt;/code&gt; arrays are contiguous within each key bucket&lt;br&gt;
(&lt;code&gt;cursor[key]++&lt;/code&gt; means consecutive rows for the same key go to consecutive&lt;br&gt;
positions). No random reads anywhere.&lt;/p&gt;

&lt;p&gt;Total: O(n + k) instead of O(n log n). For typical reconciliation data&lt;br&gt;
with high reference reuse, k ≪ n and this is effectively O(n).&lt;/p&gt;
&lt;h3&gt;
  
  
  The merge-join disappears
&lt;/h3&gt;

&lt;p&gt;There's a bonus: &lt;code&gt;RefGroup[]&lt;/code&gt; construction used to require a merge-join&lt;br&gt;
walk over both sorted arrays to discover group boundaries. With histogram&lt;br&gt;
sort, &lt;code&gt;starts[k]&lt;/code&gt; and &lt;code&gt;counts[k]&lt;/code&gt; are already exactly &lt;code&gt;SourceStart&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;SourceCount&lt;/code&gt; for every reference &lt;code&gt;k&lt;/code&gt;. &lt;code&gt;RefGroup[]&lt;/code&gt; is built in a single&lt;br&gt;
O(k) pass over the key space, with no traversal of row arrays at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;internedCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sourceCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;targetCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sourceCount&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;targetCount&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;++]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;RefGroup&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ReferenceId&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;SourceStart&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Starts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;SourceCount&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sourceCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TargetStart&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Starts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;TargetCount&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;targetCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Histogram sort results
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;IndexBuild&lt;/code&gt; benchmark (µs/1K rows):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;Array-of-structs&lt;/th&gt;
&lt;th&gt;Scatter sort&lt;/th&gt;
&lt;th&gt;Histogram&lt;/th&gt;
&lt;th&gt;vs AoS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;241&lt;/td&gt;
&lt;td&gt;212&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;139&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−42%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;448&lt;/td&gt;
&lt;td&gt;322&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;206&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−54%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;395&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;296&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−25%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The N=1M regression (+18% with scatter) became a −25% improvement with&lt;br&gt;
histogram. No trade-offs, no reversals at any N.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygxuua2nlt4b229c196y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygxuua2nlt4b229c196y.png" alt="IndexBuild: histogram sort beats both array-of-structs and scatter sort at every N" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Results
&lt;/h2&gt;

&lt;p&gt;Whole-method benchmark, DictionaryClassifier vs ColumnarClassifier:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;Dictionary&lt;/th&gt;
&lt;th&gt;Columnar+Histogram&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;8.06 ms&lt;/td&gt;
&lt;td&gt;2.52 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.31x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;108.6 ms&lt;/td&gt;
&lt;td&gt;51.8 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.48x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;1,368 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;646 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.47x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The columnar engine with histogram sort is 2.1x faster than the&lt;br&gt;
dictionary engine at N=1M.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26tmzk8qwe77b05xbwnz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26tmzk8qwe77b05xbwnz.png" alt="Dictionary vs columnar+histogram at 10K, 100K, and 1M rows, log scale" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full journey for &lt;code&gt;MismatchScan&lt;/code&gt; specifically — from the original&lt;br&gt;
dictionary baseline through every optimization:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;N=1M µs/1K&lt;/th&gt;
&lt;th&gt;vs original&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dictionary engine&lt;/td&gt;
&lt;td&gt;12.57&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexed engine (item 1, decimal→long)&lt;/td&gt;
&lt;td&gt;6.34&lt;/td&gt;
&lt;td&gt;−50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columnar engine (item 6, AoS→SoA)&lt;/td&gt;
&lt;td&gt;4.54&lt;/td&gt;
&lt;td&gt;−64%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Know which kind of slowness you have.&lt;/strong&gt; FrozenDictionary failed because&lt;br&gt;
the problem was cache miss patterns, not lookup speed. Index-sort failed&lt;br&gt;
because the problem was read indirection cost, not sort element size. Every&lt;br&gt;
time we correctly identified the bottleneck type, the fix worked. Every time&lt;br&gt;
we guessed, it didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Benchmark discipline is non-negotiable.&lt;/strong&gt; Three of our negative results&lt;br&gt;
(FrozenDictionary, index-sort, scatter sort) would have been hard to&lt;br&gt;
predict analytically. The fourth (reverse list removal) showed an apparent&lt;br&gt;
win with 3 iterations that reversed with 15 iterations. Intuition about&lt;br&gt;
what should be faster is unreliable enough that "benchmark it" isn't just&lt;br&gt;
good practice — it's the only reliable epistemology for performance&lt;br&gt;
engineering at this level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Document the dead ends.&lt;/strong&gt; Every negative result in this investigation&lt;br&gt;
tells you something about the system that a positive result doesn't.&lt;br&gt;
FrozenDictionary's failure taught us the problem was scan pattern, not&lt;br&gt;
lookup speed — which directly led to the sorted-array design. Index-sort's&lt;br&gt;
failure taught us that indirection costs more than element size saves at&lt;br&gt;
scale — which directly influenced the histogram sort design. If we'd&lt;br&gt;
hidden those results, we'd have lost the reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Dense integer keys change the game.&lt;/strong&gt; Once &lt;code&gt;ReferenceInterner&lt;/code&gt; existed&lt;br&gt;
— once every string key was a dense sequential integer — a whole class of&lt;br&gt;
algorithms became available. Counting sort. Direct array indexing. RefGroup&lt;br&gt;
construction from prefix sums. The interning step was inexpensive (one&lt;br&gt;
dictionary lookup per distinct reference, upfront) and unlocked everything&lt;br&gt;
that came after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The database execution model scales.&lt;/strong&gt; &lt;code&gt;ProcessingState&lt;/code&gt; as a generic&lt;br&gt;
byte buffer with operator-local semantics, &lt;code&gt;ColumnarTransactionBatch&lt;/code&gt; as a&lt;br&gt;
shared execution context, refgroup-based range access rather than&lt;br&gt;
per-row object navigation — these patterns exist in database engines for&lt;br&gt;
the same reasons they work here. Financial reconciliation data at scale&lt;br&gt;
has more in common with a database's internal representation than with a&lt;br&gt;
collection of domain objects.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The engine still has known costs worth addressing. The temporary arrays in&lt;br&gt;
&lt;code&gt;ColumnarIndexBuilder&lt;/code&gt; (~20 bytes/row) are the remaining source of&lt;br&gt;
super-linear growth at N=1M — eliminating them would require a two-pass&lt;br&gt;
encode (first pass counts, second pass places directly into final arrays).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ProcessingState&lt;/code&gt; design also sets up a future build strategy selector:&lt;br&gt;
when the matching engine and quality gates share the same&lt;br&gt;
&lt;code&gt;ColumnarTransactionBatch&lt;/code&gt;, those operators may have different key&lt;br&gt;
characteristics. Dense keys get histogram sort. Sparse integer keys get&lt;br&gt;
radix sort. Arbitrary comparison keys fall back to struct-sort. The&lt;br&gt;
selector doesn't exist yet — it will when the second operator needs it.&lt;/p&gt;

&lt;p&gt;CedarRecon is open source. The full benchmark suite, all negative results,&lt;br&gt;
and the complete investigation history are in the repository.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>cedarrecon</category>
    </item>
  </channel>
</rss>
