A profiler trace put me on a code path I'd never once suspected: a request handler that reads a blob of space-separated tokens and sums a weight for each one it recognizes. The logic is boring and correct. What caught my eye was 7.8 MB of string allocations sitting in the hot loop of something that only ever reads those tokens. I wasn't keeping any of them. I was allocating a key, doing one dictionary lookup, and dropping it on the floor.
The keys were substrings. Every token I sliced out of the blob became its own little string object just so I could hand it to Dictionary.TryGetValue. So I measured what that habit actually costs, and what the span-based alternate lookup buys back.
The setup
The input is one string of 200,000 space-separated tokens, about 1.5 MB. Roughly 70% of them are real keys that live in a 5,000-entry Dictionary<string, long>; the rest are misses. The job walks the blob, pulls out each token, looks it up, and adds the weight. Timings are the median of 9 rounds after a warmup, allocations come from GC.GetAllocatedBytesForCurrentThread, .NET 10, Release build, small Linux container. Not a lab. I care about the ratios.
Here's the version I'd been writing forever:
intstart=0;for(inti=0;i<=input.Length;i++){if(i==input.Length||input[i]==' '){stringtoken=input.Substring(start,i-start);// allocatesif(table.TryGetValue(token,outlongw))sum+=w;start=i+1;}}Substring is the tell. It reads like exactly what I mean, which is why it never gets flagged in review. And for a handful of tokens it's genuinely fine. Here's the bill for 200,000:
[A: Substring + lookup] sum=6,963,210 median=23.4 ms allocated=7,812 KB
Seven and a half megabytes of keys, none of which outlived a single if. On a busy endpoint that's 7.5 MB of garbage per call for the collector to sweep up later, and the collector's bill lands on some other request's latency, which is what makes this kind of thing so annoying to track down.
The alternate lookup
Since .NET 9, Dictionary<TKey, TValue> can hand you an alternate lookup keyed by a different, comparer-compatible type. For a string-keyed dictionary the useful one is ReadOnlySpan<char>. You ask for it once, then look up spans directly — no substring, no allocation:
varlookup=table.GetAlternateLookup<ReadOnlySpan<char>>();ReadOnlySpan<char>span=input;intstart=0;for(inti=0;i<=span.Length;i++){if(i==span.Length||span[i]==' '){ReadOnlySpan<char>token=span.Slice(start,i-start);// no allocif(lookup.TryGetValue(token,outlongw))sum+=w;start=i+1;}}Slice doesn't copy anything — it's a window over the original string's characters. The comparer hashes and compares the span against the stored keys without ever building a temporary string. Same dictionary, same entries, just a second door into it. And the numbers:
[B: span alternate lookup] sum=6,963,210 median=14.9 ms allocated=0 KB
same result: True
allocation ratio A/B: ~200,000x
time ratio A/B: 1.57x
Identical sum, so I didn't quietly change behavior. Zero bytes allocated for the keys. And it came out about 1.57x faster too, which I honestly didn't expect to be that pronounced. I went in for the allocations and the wall-clock win was a bonus, mostly from skipping 140,000 string constructions and the memory traffic they drag along.
The catch nobody mentions
GetAlternateLookup isn't free to reach for. It only works when the dictionary's comparer implements IAlternateEqualityComparer<ReadOnlySpan<char>, string>. The good news is that the comparers you'd actually pick for machine keys already do: StringComparer.Ordinal, StringComparer.OrdinalIgnoreCase, and the default comparer you get from new Dictionary<string, long>() all qualify. A custom IEqualityComparer<string> you wrote yourself will not, and you find out with an exception thrown at the GetAlternateLookup call, not a compile error. So it's a runtime contract, and that's worth a test.
My honest opinion: this is a hot-path tool, not a default. If you're looking up a handful of keys, or the strings already exist as string objects, reach for it and you've added ceremony for nothing. Where it earns its keep is exactly the shape above, where you're carving keys out of a larger buffer (a parser, a tokenizer, a CSV or header scanner, a log processor) and the substring is pure waste because it dies the instant the lookup returns. That's when 7.8 MB quietly turns into zero.
I've started grepping my own parsers for Substring( followed by a TryGetValue on the next line. It's a small pattern, but it shows up more than you'd think once you know its silhouette.
Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/023-dictionary-alternate-lookup
Have you found a spot in your code where the key was already sitting in a buffer you owned? I'd like to hear where it turned up for you.
— still benchmarking things nobody asked me to


Top comments (0)