Ditch the Double Lookup: CollectionsMarshal.GetValueRefOrAddDefault
Here’s a pattern every C# developer has written a hundred times for counting occurrences, aggregating values, or building up a cache:
var wordCounts = new Dictionary<string, int>();
foreach (var word in words){ if (wordCounts.TryGetValue(word, out var count)) wordCounts[word] = count + 1; else wordCounts[word] = 1;}It works. It’s clear. But it hashes the key twice on every iteration, once for TryGetValue and once for the indexer set. On a hot path with millions of entries, that adds up fast.
Since .NET 6, System.Runtime.InteropServices.CollectionsMarshal gives you a way to do this in a single lookup. The GetValueRefOrAddDefault method returns a ref directly into the dictionary’s internal storage:
using System.Runtime.InteropServices;
var wordCounts = new Dictionary<string, int>();
foreach (var word in words){ ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault( wordCounts, word, out bool exists);
// 'count' is a ref to the slot — mutate it in place count++;}That’s it. One hash, one probe, one update. If the key already existed, exists is true and count points at the current value. If it didn’t, the runtime inserts a default entry (0 for int) and count points at that new slot. Either way, count++ does the right thing.
This works beautifully for any “upsert” operation. Building a grouped lookup? Same idea:
var grouped = new Dictionary<string, List<Order>>();
foreach (var order in orders){ ref List<Order>? list = ref CollectionsMarshal.GetValueRefOrAddDefault( grouped, order.Region, out bool exists);
if (!exists) list = new List<Order>();
list!.Add(order);}No TryGetValue. No ContainsKey. No double hashing. Just a direct ref into the slot.
A couple of things to keep in mind:
- Don’t modify the dictionary while holding a ref. Adding or removing keys can resize the internal storage and invalidate your reference. Use the ref, then move on.
- It lives in
CollectionsMarshalfor a reason. This is a low-level API. It trades a bit of safety for performance. For casual code, the classicTryGetValuepattern is perfectly fine. Reach for this when profiling tells you dictionary access is a bottleneck. - It only works with
Dictionary<TKey, TValue>, notConcurrentDictionary, notFrozenDictionary, not custom implementations.
For hot-path aggregation, counting, grouping, or caching, CollectionsMarshal.GetValueRefOrAddDefault is a one-line upgrade that cuts your hashing work in half.