Using CollectionsMarshal for faster Dictionary access
Here's some problematic code that most C# developers have 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 uses 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 reference into that location.
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 CollectionsMarshal for a reason. This is a low-level API. It trades a bit of safety for performance. For casual code, the classic TryGetValue pattern is perfectly fine. Reach for this when profiling tells you dictionary access is a bottleneck.
- It only works with Dictionary, not ConcurrentDictionary, not FrozenDictionary, 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.