<?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>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/AamiriYouness/Cedar.BenchmarkGate" rel="noopener noreferrer"&gt;Cedar.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>
