A catalog import often has a simple contract: for every incoming SKU, find its product, multiply the price by the quantity, and preserve the input order. A small lookup inside that loop can dominate the work when it scans the same catalog repeatedly.
This article adapts the ReArch Labs catalog-import experiment published on 15 October 2026. It explains the existing source and recorded results; no new benchmark was run for this article. The workload is synthetic, not a customer migration.
Start with the result that must stay the same
The fixture contains 10,000 unique product SKUs and import batches of 1,000, 10,000 and 50,000 rows. Every row where i % 17 == 0 has an unknown SKU. A matched row produces PriceCents * Quantity; an unknown SKU produces -1. Results retain the row order. Prices use integer cents, and SKU comparisons are ordinal and case-sensitive.
These details matter before changing the collection. An index that silently changes matching rules or output order has changed the job, even if its stopwatch result looks better.
The original loop uses List.Find. This excerpt preserves the implementation; the surrounding harness is omitted:
var outputs = new long[rows.Length];
for (int i = 0; i < rows.Length; i++)
{
ImportRow row = rows[i];
Product? product = catalog.Find(candidate => candidate.Sku == row.Sku);
outputs[i] = product is null ? -1 : product.PriceCents * row.Quantity;
}
Find walks the list until it reaches a match. Repeating that search for every row repeats the catalog traversal; the predicate in this implementation also captures row. Microsoft documents List.Find as a linear search.
The dictionary arm builds an index once per batch and then performs key lookups. These are corresponding excerpts from the same source, with formatting condensed:
var dictionary = new Dictionary<string, Product>(catalog.Count, StringComparer.Ordinal);
foreach (Product product in catalog)
dictionary.Add(product.Sku, product);
var outputs = new long[rows.Length];
for (int i = 0; i < rows.Length; i++)
{
ImportRow row = rows[i];
outputs[i] = dictionary.TryGetValue(row.Sku, out Product? product)
? product.PriceCents * row.Quantity
: -1;
}
This changes both the lookup structure and the capturing-predicate allocation pattern. The result does not isolate the cost of either change. The fixture also has unique catalog keys; it does not establish a duplicate-SKU policy for a real importer.
Put index construction inside the stopwatch
A cached dictionary comparison would answer a different question. Here, every batch builds a new dictionary or frozen dictionary. The timer includes that construction, the row matching and the output array allocation. The published harness separately records index construction time, but does not subtract it from the total.
| Measurement choice | Recorded setup |
|---|---|
| Implementations |
List.Find, Dictionary, FrozenDictionary
|
| Runtime | The same .NET 10.0.13 runtime for all three |
| Repetitions | 2 warmups and 10 measured runs per implementation and size |
| Execution order | Fixed seeded shuffle in one process; 18 warmups and 90 measured runs |
| Environment | Shared Apple M3 development machine; Docker Linux arm64; 1 CPU quota; 768 MiB memory limit |
| JIT setting | DOTNET_TieredCompilation=0 |
| Outside the timer | Input generation, explicit pre-run full GC and output hashing |
| Outside the workload | CSV parsing, database writes and network calls |
Elapsed time comes from Stopwatch. Process CPU time uses Linux CLOCK_PROCESS_CPUTIME_ID. Managed allocation is the difference between two GC.GetTotalAllocatedBytes(true) readings; it is not peak memory or RSS.
All 108 outputs, including warmups, are checked against a separate Python reference implementation. The checks cover output count, missing-SKU count, sum in cents and SHA-256 of the ordered signed little-endian 64-bit results. “Separate reference” describes the checker's implementation, not an independent third-party audit.
Read the small batches as well as the headline
These are elapsed-time medians in milliseconds from the published raw CSV, excluding warmups. Each cell contains ten measurements; values are rounded to three decimal places.
| Import rows | List.Find | Dictionary | FrozenDictionary |
|---|---|---|---|
| 1,000 | 14.216 | 0.210 | 0.897 |
| 10,000 | 143.355 | 0.466 | 1.059 |
| 50,000 | 708.219 | 1.506 | 1.961 |
The prespecified primary point was 50,000 rows: about 708.22 ms for the original implementation and 1.51 ms for the dictionary implementation, including construction. That is an observation about this fixture and environment, not a runtime-upgrade speed claim. Dictionary is available on older .NET versions too.
The frozen collection is useful as a control because a newer API does not settle the choice. At 50,000 rows, median construction was about 0.183 ms for Dictionary and 0.923 ms for FrozenDictionary. For this rebuild-per-batch workload, the latter's total median was higher. A long-lived catalog reused across many batches would have a different cost balance; this experiment did not measure it. Microsoft's FrozenDictionary documentation discusses the tradeoff between construction cost and repeated reads.
Small batches also expose the fixed allocation cost: an index can allocate more managed memory than the original list loop at the smallest size. The full summary retains those results and the observed minimum/maximum timings. A median alone does not describe latency variation.
Reproduce the experiment, then change one question
The published source directory and README include the C# program, Python harness and tests. From that directory, with Python 3 and Docker available, the documented procedure is:
python3 -m unittest discover -s . -p 'test_harness.py' -v
docker pull --platform linux/arm64 mcr.microsoft.com/dotnet/sdk:10.0
python3 run.py --output /tmp/rearch-dotnet-catalog
The tag can move. To target the recorded image, use the exact digest from the published manifest for the pull and the harness's --image option. Choose a new output directory; the harness does not reuse an existing one. These are reproduction instructions, not a claim that they were executed while preparing this article.
Before applying the change to an application, define duplicate-key handling, case sensitivity and missing-product behavior. Verify the same ordered outputs, then measure with the application's actual catalog size, miss rate and index lifetime. If the user-visible wait includes file parsing or database writes, measure that full path too.
The recorded runs share one process and a development host; they are not independent-process trials on an isolated production machine. Disabled tiered compilation and pre-run GC also differ from many application settings. The experiment establishes a bounded result for an in-memory matching job. It does not measure production throughput, security, cloud cost or the benefit of upgrading .NET itself.
Prepared with AI tools; source code and measurement data are linked above.
Top comments (1)
The dictionary arm already has a duplicate-SKU policy, it's just implicit:
Dictionary.Addthrows on a repeated key, so one bad catalog row fails the batch.TryAddmakes it first wins. And if CSV parsing joins the timer, .NET 9'sGetAlternateLookup<ReadOnlySpan<char>>lets you look up from the parsed span without a string per row.