.NET 8 Performance Edition

4/13/2023

Update 18.04.2023: Thanks to David Guida I added the following things:

  • .NET 6 as reference
  • Some collection operations on List<T>

As with every .NET release, Microsoft improves the performance of the runtime and guess what: This release is no exception to this. In this blog post, I want to go through some of the improvements made so far (.NET 8 preview 3).

Now a small word or two of disclaimer here: This is a preview release. A lot of things can change until the expected release in November 2023. My basis for the blog post is preview 3 of the .NET 8 release. Second, performance is very subjective and I take it out of context. I don't have a real-world application to benchmark against. I'm just looking at the performance of some code snippets. The sole purpose of this blog post is to show you what you can expect from the .NET 8 release without changing your code. Free gains 😉

If you want to do this on your own, head over to the .NET site and download the preview as well as the nightly builds from BenchmarkDotNet. Then you can use the following code to run benchmarks with different .NET versions:

[SimpleJob(RuntimeMoniker.Net70, baseline: true)]
[SimpleJob(RuntimeMoniker.Net80)]

Keep in mind that your project file has to target both versions as well:

<TargetFrameworks>net7.0;net8.0</TargetFrameworks>

I grouped the benchmarks into different categories. As always, I will link the source code at the end of the blog post.

## Strings In the first category, I want to look at the performance of strings. Here are the scenarios:

private readonly List<int> _numbers = Enumerable.Range(0, 10000).Select(s => Random.Shared.Next()).ToList();
private readonly string _text1 = "Hello woRld! ThIs is a test.It CoUlD be a lot longer, but it'S not. Or is it?";
private readonly string _text2 = "Hello world! This is a test.it could be a lot longer, but it's not. Or is it?";

[Benchmark]
public bool EqualsOrdinalIgnoreCase() => _text1.Equals(_text2, StringComparison.OrdinalIgnoreCase);

[Benchmark]
public bool EqualsOrdinal() => _text1.Equals(_text2, StringComparison.Ordinal);

[Benchmark]
public bool ContainsOrdinalIgnoreCase() => _text1.Contains("Or is it?", StringComparison.OrdinalIgnoreCase);

[Benchmark]
public bool ContainsOrdinal() => _text1.Contains("Or is it?", StringComparison.Ordinal);

[Benchmark]
public bool StartsWithOrdinalIgnoreCase() => _text1.StartsWith("Hello World", StringComparison.OrdinalIgnoreCase);

[Benchmark]
public bool StartsWithOrdinal() => _text1.StartsWith("Hello World", StringComparison.Ordinal);

Results:

| Method                      | Runtime  |       Mean |     Error |    StdDev | Ratio |
| --------------------------- | -------- | ---------: | --------: | --------: | ----: |
| EqualsOrdinalIgnoreCase     | .NET 6.0 |  26.985 ns | 0.0261 ns | 0.0218 ns |  1.05 |
| EqualsOrdinalIgnoreCase     | .NET 7.0 |  25.722 ns | 0.0404 ns | 0.0378 ns |  1.00 |
| EqualsOrdinalIgnoreCase     | .NET 8.0 |  15.109 ns | 0.0103 ns | 0.0081 ns |  0.59 |
|                             |          |            |           |           |       |
| EqualsOrdinal               | .NET 6.0 |   3.246 ns | 0.0021 ns | 0.0020 ns |  1.08 |
| EqualsOrdinal               | .NET 7.0 |   3.010 ns | 0.0771 ns | 0.0792 ns |  1.00 |
| EqualsOrdinal               | .NET 8.0 |   2.901 ns | 0.0031 ns | 0.0026 ns |  0.96 |
|                             |          |            |           |           |       |
| ContainsOrdinalIgnoreCase   | .NET 6.0 | 144.409 ns | 0.0489 ns | 0.0457 ns |  2.53 |
| ContainsOrdinalIgnoreCase   | .NET 7.0 |  57.050 ns | 0.3221 ns | 0.2855 ns |  1.00 |
| ContainsOrdinalIgnoreCase   | .NET 8.0 |  49.044 ns | 0.1855 ns | 0.1735 ns |  0.86 |
|                             |          |            |           |           |       |
| ContainsOrdinal             | .NET 6.0 |  17.295 ns | 0.0142 ns | 0.0133 ns |  1.04 |
| ContainsOrdinal             | .NET 7.0 |  16.692 ns | 0.2782 ns | 0.2602 ns |  1.00 |
| ContainsOrdinal             | .NET 8.0 |  12.539 ns | 0.0412 ns | 0.0386 ns |  0.75 |
|                             |          |            |           |           |       |
| StartsWithOrdinalIgnoreCase | .NET 6.0 |   6.306 ns | 0.0032 ns | 0.0028 ns |  1.13 |
| StartsWithOrdinalIgnoreCase | .NET 7.0 |   5.600 ns | 0.0043 ns | 0.0036 ns |  1.00 |
| StartsWithOrdinalIgnoreCase | .NET 8.0 |   5.450 ns | 0.0625 ns | 0.0554 ns |  0.97 |
|                             |          |            |           |           |       |
| StartsWithOrdinal           | .NET 6.0 |   2.621 ns | 0.0017 ns | 0.0014 ns |  1.15 |
| StartsWithOrdinal           | .NET 7.0 |   2.277 ns | 0.0049 ns | 0.0046 ns |  1.00 |
| StartsWithOrdinal           | .NET 8.0 |   2.344 ns | 0.0010 ns | 0.0008 ns |  1.03 |

Almost across the board there is an improvement.

Update:

A significant update was brought to the StringBuilder class, especially the Replace function!

[Benchmark]
public string ReplaceStringBuilder() => new StringBuilder(_text1).Replace("Hello", "Goodbye").ToString();

Results:

| Method               | Runtime  |       Mean |     Error |    StdDev | Ratio |
| -------------------- | -------- | ---------: | --------: | --------: | ----: |
| ReplaceStringBuilder | .NET 6.0 | 322.476 ns | 1.4724 ns | 1.3052 ns |  1.24 |
| ReplaceStringBuilder | .NET 7.0 | 259.370 ns | 0.2351 ns | 0.2084 ns |  1.00 |
| ReplaceStringBuilder | .NET 8.0 |  88.420 ns | 1.1176 ns | 0.9907 ns |  0.34 |

Enum's

Enums got a small overhaul in .NET 8. Basically Stephen Toub explains it here:

This rewrites Enum to change how it stores the values data. Rather than having a non-generic EnumInfo that stores a ulong[] array of all values, there’s a generic EnumInfo that stores a TUnderlyingType[]. Then based on the enum’s type, every entry point maps to an underlying TUnderlyingType and invokes a generic method with that TUnderlyingType, e.g. Enum.IsDefined(…) and Enum.IsDefined(typeof(TEnum), …) will look up the TUnderlyingValue and then invoke Enum.IsDefinedPrimitive(typeof(TEnum)). In this way, a) we store an array strongly typed to the underlying value rather than storing the worst case ulong[], and b) we share implementations across generic and non-generic entrypoints while not having full generic specialization for every TEnum; worst case, we have only one generic specialization per underlying type, of which only 8 are expressible in C#. The generic entrypoints are able to do the mapping very efficiently, thanks to the recently added enum-based intrinsics. The non-generic entrypoints use the same switches on TypeCode/CorElementType they do today when doing e.g. ToUInt64.

[Benchmark]
public DayOfWeek EnumParse() => Enum.Parse<DayOfWeek>("Saturday");

[Benchmark]
public DayOfWeek[] EnumGetValues() => Enum.GetValues<DayOfWeek>();

[Benchmark]
public string[] EnumGetNames() => Enum.GetNames<DayOfWeek>();

Results:

| Method        | Runtime  |       Mean |     Error |    StdDev | Ratio |
| ------------- | -------- | ---------: | --------: | --------: | ----: |
| EnumParse     | .NET 6.0 |  71.326 ns | 0.2383 ns | 0.2229 ns |  2.45 |
| EnumParse     | .NET 7.0 |  29.035 ns | 0.0965 ns | 0.0806 ns |  1.00 |
| EnumParse     | .NET 8.0 |  20.328 ns | 0.1206 ns | 0.1128 ns |  0.70 |
|               |          |            |           |           |       |
| EnumGetValues | .NET 6.0 | 702.492 ns | 0.3677 ns | 0.3440 ns |  2.22 |
| EnumGetValues | .NET 7.0 | 316.622 ns | 0.9014 ns | 0.7991 ns |  1.00 |
| EnumGetValues | .NET 8.0 |  24.072 ns | 0.1872 ns | 0.1564 ns |  0.08 |
|               |          |            |           |           |       |
| EnumGetNames  | .NET 6.0 |  40.587 ns | 0.0675 ns | 0.0599 ns |  2.71 |
| EnumGetNames  | .NET 7.0 |  14.962 ns | 0.0410 ns | 0.0320 ns |  1.00 |
| EnumGetNames  | .NET 8.0 |  11.387 ns | 0.0481 ns | 0.0450 ns |  0.76 |

Especially Enum.GetValues is a lot faster.

LINQ

There are smaller gains with LINQ, especially with value types like int or double.

[Benchmark]
public int LinqMin() => _numbers.Min();

[Benchmark]
public int LinqMax() => _numbers.Max();

Results:

| Method  | Runtime  |          Mean |      Error |     StdDev | Ratio |
| ------- | -------- | ------------: | ---------: | ---------: | ----: |
| LinqMin | .NET 6.0 | 46,774.213 ns | 45.0277 ns | 37.6002 ns | 26.64 |
| LinqMin | .NET 7.0 |  1,755.564 ns |  1.1466 ns |  1.0165 ns |  1.00 |
| LinqMin | .NET 8.0 |  1,538.527 ns | 13.7024 ns | 12.8172 ns |  0.88 |
|         |          |               |            |            |       |
| LinqMax | .NET 6.0 | 46,798.109 ns | 41.9510 ns | 37.1885 ns | 26.05 |
| LinqMax | .NET 7.0 |  1,795.466 ns | 24.5572 ns | 22.9709 ns |  1.00 |
| LinqMax | .NET 8.0 |  1,521.561 ns |  1.9681 ns |  1.7447 ns |  0.85 |

Reflection

Last but not least, there are some improvements in reflection.

[Benchmark]
public int ReflectionCreateInstance() => (int)Activator.CreateInstance(typeof(int));

[Benchmark]
public string InvokeMethodViaReflection()
{
    var method = typeof(string).GetMethod("ToLowerInvariant", BindingFlags.Public | BindingFlags.Instance);
    return (string)method.Invoke(_text1, null);
}

Results:

| Method                    | Runtime  |       Mean |     Error |    StdDev | Ratio |
| ------------------------- | -------- | ---------: | --------: | --------: | ----: |
| ReflectionCreateInstance  | .NET 6.0 |   8.200 ns | 0.0156 ns | 0.0130 ns |  1.28 |
| ReflectionCreateInstance  | .NET 7.0 |   6.387 ns | 0.0398 ns | 0.0372 ns |  1.00 |
| ReflectionCreateInstance  | .NET 8.0 |   5.256 ns | 0.1230 ns | 0.1599 ns |  0.82 |
|                           |          |            |           |           |       |
| InvokeMethodViaReflection | .NET 6.0 | 137.043 ns | 0.1203 ns | 0.1005 ns |  2.28 |
| InvokeMethodViaReflection | .NET 7.0 |  60.079 ns | 0.1238 ns | 0.1034 ns |  1.00 |
| InvokeMethodViaReflection | .NET 8.0 |  52.395 ns | 0.5071 ns | 0.4743 ns |  0.87 |

Lists

In the last category we will have a look at the performance of lists.

[Benchmark]
public List<int> ListAdd10000()
{
    var list = new List<int>();
    for (var i = 0; i < 10_000; i++)
    {
        list.Add(i);
    }

    return list;
}

[Benchmark]
public int HashSetLookup()
{
    var entriesFound = 0;
    for (var i = 0; i < 10000; i++)
    {
        if (_hashSet.Contains(i))
            entriesFound++;
    }

    return entriesFound;
}

[Benchmark]
public int DictionaryKeyLookup()
{
    var entriesFound = 0;
    for (var i = 0; i < 10000; i++)
    {
        if (_dictionary.ContainsKey(i))
            entriesFound++;
    }

    return entriesFound;
}

[Benchmark]
public int DictionaryValueLookup()
{
    var entriesFound = 0;
    for (var i = 0; i < 10000; i++)
    {
        if (_dictionary.ContainsValue(i))
            entriesFound++;
    }

    return entriesFound;
}

Results:

|                Method |  Runtime |         Mean |      Error |     StdDev | Ratio |
|---------------------- |--------- |-------------:|-----------:|-----------:|------:|
|           ListAdd1000 | .NET 6.0 |     15.65 us |   0.029 us |   0.023 us |  1.41 |
|           ListAdd1000 | .NET 7.0 |     11.12 us |   0.184 us |   0.164 us |  1.00 |
|           ListAdd1000 | .NET 8.0 |     10.90 us |   0.027 us |   0.022 us |  0.98 |
|                       |          |              |            |            |       |
|         HashSetLookup | .NET 6.0 |     33.61 us |   0.112 us |   0.093 us |  1.11 |
|         HashSetLookup | .NET 7.0 |     30.23 us |   0.361 us |   0.338 us |  1.00 |
|         HashSetLookup | .NET 8.0 |     30.14 us |   0.032 us |   0.030 us |  1.00 |
|                       |          |              |            |            |       |
|   DictionaryKeyLookup | .NET 6.0 |     31.90 us |   0.030 us |   0.024 us |  1.06 |
|   DictionaryKeyLookup | .NET 7.0 |     30.15 us |   0.045 us |   0.040 us |  1.00 |
|   DictionaryKeyLookup | .NET 8.0 |     31.17 us |   0.046 us |   0.038 us |  1.03 |
|                       |          |              |            |            |       |
| DictionaryValueLookup | .NET 6.0 | 35,144.45 us |  30.810 us |  25.727 us |  1.12 |
| DictionaryValueLookup | .NET 7.0 | 31,517.14 us | 157.415 us | 147.246 us |  1.00 |
| DictionaryValueLookup | .NET 8.0 | 31,373.10 us |  52.731 us |  44.033 us |  1.00 |

Conclusion

There we go, a quick overview of the performance improvements in .NET 8 with smaller and even some bigger improvements!

Source Code

  • Source code to this blog post: here
  • All my sample code is hosted in this repository: here

bUnit v2 - The Blazor unit testing library vNext

Next to the big release of .NET 8, we also released the first preview bUnit v2. This release is a major release, with a lot of new features and improvements. In this post, I will highlight some of the most important changes. This includes new features but also some breaking changes.

Keyed Services in the IServiceProvider in .NET 8 preview 7

The .NET 8 preview 7 will bring another exciting feature some of you probably awaiting for a long time: Keyed services.

.NET 9 LINQ Performance Edition

As with almost every edition of .NET, the team has been working on improving performance. In this blog post, we will see some improvements to the related tickets and benchmarks.

An error has occurred. This application may no longer respond until reloaded. Reload x