.NET 8 — Frozen Collections

Henrique Siebert Domareski
6 min readJan 15, 2024

Frozen Collections is a new .NET 8 feature that can be used to create Dictionaries and Sets for faster read operations when you don’t need to make changes after the creation. In this article, I present how to work with these collections and demonstrate the performance difference when compared with other collections.

The new System.Collections.Frozen namespace includes the collection types FrozenDictionary<TKey,TValue> and FrozenSet<T>, these types are optimized for fast lookup operations. They take a bit more time during the creation, but the read operations are faster when compared with a Dictionary or a Set.

FrozenDictionary and FrozenSet

A FrozenDictionary and FrozenSet provides an immutable, read-only dictionaryand set, optimized for fast lookup and enumeration. It is optimized for cases when you only need to create a dictionary/set once, and will not need to change keys or values, and it is frequently used at the run time.

They are ideal for cases when the dictionary/set is created once (potentially at the startup of the application) and used throughout the remainder of the life of the app.

To create a FrozenDictionary you can use the ToFrozenDictionary method:

FrozenDictionary<int, int> frozenDictionary = 
Enumerable.Range(0, 10).ToFrozenDictionary(key => key);

To create a FrozenSet you can use the ToFrozenSet method:

FrozenSet<int> frozenSet = Enumerable.Range(0, 10).ToFrozenSet();

Benchmark

For demonstration purposes, I create a series of methods that do three different operations for different collection types: Create the collection, execute the TryGetValue method, and execute a Lookup operation, and to run the benchmark, I used the BenchmarkDotNet package.

To execute the benchmarks you need to set the Visual Studio to Release and execute the project, or use the following commands via the command line (When running via the command line, the results will be stored in a folder named BenchmarkDotNet.Artifacts):

// Build:
dotnet build -c Release .\FrozenCollections

// Run:
dotnet FrozenCollections\bin\Release\net8.0\FrozenCollections.dll

In the benchmark result you can see the following information:

  • Method: the name of the benchmarked method.
  • Mean: Arithmetic mean of all measurements.
  • Error: Half of 99.9% confidence interval.
  • StdDev: Standard deviation of all measurements. A lower standard deviation indicates more consistent results.
  • Rank: the rank performance position (from fastest to slowest).

For the benchmark results, smaller values are better.

Create benchmark

In the class below you can find the methods that create a series of collection types:

[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[MemoryDiagnoser(false)]
[RankColumn(NumeralSystem.Arabic)]
[MarkdownExporter]
public class CreateBenchmark
{
private const int itemsCount = 10_000;

[Benchmark]
public void CreateDictionary()
{
Dictionary<int, int> dictionary = Enumerable.Range(0, itemsCount).ToDictionary(key => key);
}

[Benchmark]
public void CreateImmutableDictionary()
{
ImmutableDictionary<int, int> dictionary = Enumerable.Range(0, itemsCount).ToImmutableDictionary(key => key);
}

[Benchmark]
public void CreateFrozenDictionary()
{
FrozenDictionary<int, int> frozenDictionary = Enumerable.Range(0, itemsCount).ToFrozenDictionary(key => key);
}

[Benchmark]
public void CreateList()
{
List<int> list = Enumerable.Range(0, itemsCount).ToList();
}

[Benchmark]
public void CreateImmutableList()
{
ImmutableList<int> list = Enumerable.Range(0, itemsCount).ToImmutableList();
}

[Benchmark]
public void CreateHashSet()
{
HashSet<int> hashSet = Enumerable.Range(0, itemsCount).ToHashSet();
}

[Benchmark]
public void CreateImmutableHashSet()
{
ImmutableHashSet<int> hashSet = Enumerable.Range(0, itemsCount).ToImmutableHashSet();
}

[Benchmark]
public void CreateFrozenSet()
{
FrozenSet<int> frozenSet = Enumerable.Range(0, itemsCount).ToFrozenSet();
}
}

This is the benchmark result:

| Method                    | Mean        | Error     | StdDev    | Rank |
|-------------------------- |------------:|----------:|----------:|-----:|
| CreateList | 141.0 μs | 2.41 μs | 2.78 μs | 1 |
| CreateDictionary | 842.7 μs | 12.07 μs | 9.43 μs | 2 |
| CreateHashSet | 1,038.0 μs | 20.64 μs | 40.25 μs | 3 |
| CreateFrozenSet | 1,811.1 μs | 20.12 μs | 16.80 μs | 4 |
| CreateImmutableList | 2,706.9 μs | 50.81 μs | 92.90 μs | 5 |
| CreateFrozenDictionary | 2,708.1 μs | 53.33 μs | 121.47 μs | 5 |
| CreateImmutableHashSet | 19,103.4 μs | 370.51 μs | 576.84 μs | 6 |
| CreateImmutableDictionary | 21,426.4 μs | 422.12 μs | 935.38 μs | 7 |

The creation of a FrozenDictionary and a FrozenSet are slower when compared with the creation of other collections, but as we are going to see in the next methods, the reading operation will be faster.

TryGetValue benchmark

In the class below, you can find a series of collection types being initialized as private properties, with 100.000 items. Each class method executes the TryGetValue method, searching for the key 500:

[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[RankColumn(NumeralSystem.Arabic)]
[MarkdownExporter]
public class TryGetValueBenchmark
{
private const int itemsCount = 100_000;
private const int keyToFind = 500;

private Dictionary<int, int> _dictionary = Enumerable.Range(0, itemsCount).ToDictionary(key => key);
private ImmutableDictionary<int, int> _immutableDictionary = Enumerable.Range(0, itemsCount).ToImmutableDictionary(key => key);

private HashSet<int> _hashSet = Enumerable.Range(0, itemsCount).ToHashSet();
private ImmutableHashSet<int> _immutableHashSet = Enumerable.Range(0, itemsCount).ToImmutableHashSet();

private FrozenDictionary<int, int> _frozenDictionary = Enumerable.Range(0, itemsCount).ToFrozenDictionary(key => key);
private FrozenSet<int> _frozenSet = Enumerable.Range(0, itemsCount).ToFrozenSet();

[Benchmark]
public void TryGetValueDictionary()
{
_dictionary.TryGetValue(keyToFind, out _);
}

[Benchmark]
public void TryGetValueImmutableDictionary()
{
_immutableDictionary.TryGetValue(keyToFind, out _);
}

[Benchmark]
public void TryGetValueFrozenDictionary()
{
_frozenDictionary.TryGetValue(keyToFind, out _);
}

[Benchmark]
public void TryGetValueHashSet()
{
_hashSet.TryGetValue(keyToFind, out _);
}

[Benchmark]
public void TryGetValueImmutableHashSet()
{
_immutableHashSet.TryGetValue(keyToFind, out _);
}

[Benchmark]
public void TryGetValueFrozenSet()
{
_frozenSet.TryGetValue(keyToFind, out _);
}
}

This is the benchmark result:

| Method                         | Mean      | Error     | StdDev    | Rank |
|------------------------------- |----------:|----------:|----------:|-----:|
| TryGetValueFrozenDictionary | 1.622 ns | 0.0592 ns | 0.0904 ns | 1 |
| TryGetValueFrozenSet | 2.905 ns | 0.0833 ns | 0.2251 ns | 2 |
| TryGetValueDictionary | 3.429 ns | 0.0925 ns | 0.0909 ns | 3 |
| TryGetValueHashSet | 3.723 ns | 0.1010 ns | 0.2259 ns | 4 |
| TryGetValueImmutableHashSet | 17.261 ns | 0.3619 ns | 0.6145 ns | 5 |
| TryGetValueImmutableDictionary | 17.841 ns | 0.3823 ns | 0.7894 ns | 6 |

Executing the TryGetValue in a FrozenDictionary and in a FrozenSet were the fastest operations when compared with these other collections.

Lookup Benchmark:

In the class below, you can find a series of collection types being initialized as private properties, with 100.000 items. On each class method, there is a loop (using a for), and for each iteration the method ContainsKey is executed:


[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[RankColumn(NumeralSystem.Arabic)]
[MarkdownExporter]
public class LookupBenchmark
{
private const int itemsCount = 100_000;
private const int iterations = 1_000;

private Dictionary<int, int> _dictionary = Enumerable.Range(0, itemsCount).ToDictionary(key => key);
private ImmutableDictionary<int, int> _immutableDictionary = Enumerable.Range(0, itemsCount).ToImmutableDictionary(key => key);

private List<int> _list = Enumerable.Range(0, itemsCount).ToList();
private ImmutableList<int> _immutableList = Enumerable.Range(0, itemsCount).ToImmutableList();

private HashSet<int> _hashSet = Enumerable.Range(0, itemsCount).ToHashSet();
private ImmutableHashSet<int> _immutableHashSet = Enumerable.Range(0, itemsCount).ToImmutableHashSet();

private FrozenDictionary<int, int> _frozenDictionary = Enumerable.Range(0, itemsCount).ToFrozenDictionary(key => key);
private FrozenSet<int> _frozenSet = Enumerable.Range(0, itemsCount).ToFrozenSet();

[Benchmark]
public void LookupDictionary()
{
for (int i = 0; i < iterations; i++)
_ = _dictionary.ContainsKey(i);
}

[Benchmark]
public void LookupImmutableDictionary()
{
for (int i = 0; i < iterations; i++)
_ = _immutableDictionary.ContainsKey(i);
}

[Benchmark]
public void LookupFrozenDictionary()
{
for (var i = 0; i < iterations; i++)
_ = _frozenDictionary.ContainsKey(i);
}

[Benchmark]
public void LookupList()
{
for (int i = 0; i < iterations; i++)
_ = _list.Contains(i);
}

[Benchmark]
public void LookupImmutableList()
{
for (int i = 0; i < iterations; i++)
_ = _immutableList.Contains(i);
}

[Benchmark]
public void LookupHashSet()
{
for (var i = 0; i < iterations; i++)
_ = _hashSet.Contains(i);
}

[Benchmark]
public void LookupImmutableHashSet()
{
for (var i = 0; i < iterations; i++)
_ = _immutableHashSet.Contains(i);
}

[Benchmark]
public void LookupFrozenSet()
{
for (var i = 0; i < iterations; i++)
_ = _frozenSet.Contains(i);
}
}

This is the benchmark result:

| Method                    | Mean         | Error      | StdDev     | Rank |
|-------------------------- |-------------:|-----------:|-----------:|-----:|
| LookupFrozenSet | 2.016 μs | 0.0401 μs | 0.0763 μs | 1 |
| LookupFrozenDictionary | 2.280 μs | 0.0438 μs | 0.0875 μs | 2 |
| LookupHashSet | 3.437 μs | 0.0681 μs | 0.1060 μs | 3 |
| LookupDictionary | 3.647 μs | 0.0609 μs | 0.0540 μs | 4 |
| LookupList | 24.507 μs | 0.4882 μs | 0.7455 μs | 5 |
| LookupImmutableHashSet | 30.181 μs | 0.5236 μs | 0.4898 μs | 6 |
| LookupImmutableDictionary | 35.969 μs | 0.7154 μs | 1.4122 μs | 7 |
| LookupImmutableList | 1,413.501 μs | 27.7182 μs | 47.8125 μs | 8 |

As expected, the lookup in a FrozendDictionary and in a FrozenSet were the fastest when compared with the other collections.

Conclusion

Frozen Collections are collections optimized for situations where you have collections that will be frequently accessed, and you do not need to change the keys and values after creating. These collections are a bit slower during the creation, but reading operations are faster.

This is the link for the project in GitHub: https://github.com/henriquesd/FrozenCollections

If you like this demo, I kindly ask you to give a ⭐️ in the repository.

Thanks for reading!

--

--