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.

An unhandled error has occurred. Reload 🗙