DEV Community

Mert C Balcı for ReArch Labs

Posted on

Replacing repeated List.Find calls in a .NET catalog import: measure the index build too

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mehdimohseni82 profile image
Mehdi Mohseni •

The dictionary arm already has a duplicate-SKU policy, it's just implicit: Dictionary.Add throws on a repeated key, so one bad catalog row fails the batch. TryAdd makes it first wins. And if CSV parsing joins the timer, .NET 9's GetAlternateLookup<ReadOnlySpan<char>> lets you look up from the parsed span without a string per row.