.NET 9 — Exception handling performance

Comparing exception handling performance over time

Assuming everything goes as planed, by the end of 2024 Microsoft should release .NET 9, which is the next major version of their most popular development framework.

It will bring a lot of new features (C# 13 is one of them) but also a lot of performance improvements, which have been a major focus ever since Microsoft created the first version of .NET Core.

Even if an application doesn’t rely on exceptions to hard stop process flows, usually very important for high demanding applications, it certainly connects to a lot of external systems, like databases, message buses, caches and even HTTP endpoints, which may cause exceptions at any moment.

In this article I’m going to test how exception handling performance improved over the years by comparing how faster is .NET 9 over .NET 8.

I’m going to use the well known C# library BenchmarkDotNet to run the tests and the environment will be the following:

1
2
3
4
5
6
7
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3447/23H2/2023Update/SunValley3)
AMD Ryzen 7 3700X, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.100-preview.3.24204.13
[Host] : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
.NET 8.0 : .NET 8.0.4 (8.0.424.16909), X64 RyuJIT AVX2
.NET 9.0 : .NET 9.0.0 (9.0.24.17209), X64 RyuJIT AVX2
.NET Framework 4.8.1 : .NET Framework 4.8.1 (4.8.9232.0), X64 RyuJIT VectorSize=256

Performance test

To test the performance of exception handling I’m going to define two simple scenarios:

  1. Ignore exception — the simplest possible scenario to measure the performance of throwing and catching an exception;
  2. Convert to string — the most common scenario when working with logging frameworks which usually use the ToString method to get a text representation containing both the message and stack trace;

Because asynchronous code with async/await has become very common and it directly affects the complexity of stack trace information, I’m also going to test both scenarios asynchronously.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
[SimpleJob(RuntimeMoniker.Net481)]
[SimpleJob(RuntimeMoniker.Net80, baseline: true)]
[SimpleJob(RuntimeMoniker.Net90)]
[MemoryDiagnoser]
public class ThrowExceptionPerfTest
{
[Benchmark]
public void Catch_Ignore()
{
try
{
ThrowException();
}
catch
{

}
}

[Benchmark]
public string Catch_ToString()
{
try
{
ThrowException();
return null;
}
catch (Exception e)
{
return e.ToString();
}
}

private static void ThrowException() => throw new Exception("test exception");

[Benchmark]
public async ValueTask Catch_Ignore_Async()
{
try
{
await ThrowExceptionAsync();
}
catch
{

}
}

[Benchmark]
public async ValueTask<string> Catch_ToString_Async()
{
try
{
await ThrowExceptionAsync();
return null;
}
catch (Exception e)
{
return e.ToString();
}
}

private static async ValueTask ThrowExceptionAsync()
{
await Task.Yield();

throw new Exception("test exception");
}
}

Performance results

I’m going to use .NET 8 as the baseline since it is the most recent and performant version currently available, but I’m also including .NET Framework 4.8.1 so we can also compare how .NET in general have improved over the years.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| Method               | Runtime              | Mean      | Error     | StdDev    | Ratio | RatioSD | Gen0   | Gen1   | Allocated | Alloc Ratio |
|--------------------- |--------------------- |----------:|----------:|----------:|------:|--------:|-------:|-------:|----------:|------------:|
| Catch_Ignore | .NET 8.0 | 6.130 us | 0.0738 us | 0.0654 us | 1.00 | 0.00 | 0.0381 | - | 344 B | 1.00 |
| Catch_Ignore | .NET 9.0 | 3.059 us | 0.0219 us | 0.0183 us | 0.50 | 0.01 | 0.0381 | - | 344 B | 1.00 |
| Catch_Ignore | .NET Framework 4.8.1 | 7.178 us | 0.0358 us | 0.0299 us | 1.17 | 0.01 | 0.0687 | - | 433 B | 1.26 |
| | | | | | | | | | | |
| Catch_ToString | .NET 8.0 | 11.472 us | 0.0854 us | 0.0798 us | 1.00 | 0.00 | 0.6409 | - | 5384 B | 1.00 |
| Catch_ToString | .NET 9.0 | 7.849 us | 0.1512 us | 0.1263 us | 0.68 | 0.01 | 0.6409 | - | 5384 B | 1.00 |
| Catch_ToString | .NET Framework 4.8.1 | 14.393 us | 0.1046 us | 0.0874 us | 1.25 | 0.01 | 1.0376 | - | 6604 B | 1.23 |
| | | | | | | | | | | |
| Catch_Ignore_Async | .NET 8.0 | 21.662 us | 0.4302 us | 1.0056 us | 1.00 | 0.00 | 0.1221 | - | 1248 B | 1.00 |
| Catch_Ignore_Async | .NET 9.0 | 11.690 us | 0.1647 us | 0.1375 us | 0.54 | 0.03 | 0.1373 | - | 1243 B | 1.00 |
| Catch_Ignore_Async | .NET Framework 4.8.1 | 23.139 us | 0.0875 us | 0.0775 us | 1.06 | 0.06 | 0.2441 | - | 1663 B | 1.33 |
| | | | | | | | | | | |
| Catch_ToString_Async | .NET 8.0 | 44.680 us | 0.7175 us | 0.5991 us | 1.00 | 0.00 | 1.0986 | - | 10025 B | 1.00 |
| Catch_ToString_Async | .NET 9.0 | 36.583 us | 0.7125 us | 0.7623 us | 0.82 | 0.02 | 1.0986 | - | 10105 B | 1.01 |
| Catch_ToString_Async | .NET Framework 4.8.1 | 45.369 us | 0.7617 us | 1.0924 us | 1.01 | 0.02 | 1.8921 | 0.0610 | 11999 B | 1.20 |

Let’s analyze each result individualy.

Catch and ignore

1
2
3
4
5
| Runtime              | Mean      | Error     | StdDev    | Ratio | RatioSD |
|--------------------- |----------:|----------:|----------:|------:|--------:|
| .NET 8.0 | 6.130 us | 0.0738 us | 0.0654 us | 1.00 | 0.00 |
| .NET 9.0 | 3.059 us | 0.0219 us | 0.0183 us | 0.50 | 0.01 |
| .NET Framework 4.8.1 | 7.178 us | 0.0358 us | 0.0299 us | 1.17 | 0.01 |

The simplest scenario shows .NET 9 with a 50% performance improvement over .NET 8, which was already 15% faster that .NET Framework 4.8.1.

Who’s the winner? .NET 9

Catch and convert to string

1
2
3
4
5
| Runtime              | Mean      | Error     | StdDev    | Ratio | RatioSD |
|--------------------- |----------:|----------:|----------:|------:|--------:|
| .NET 8.0 | 11.472 us | 0.0854 us | 0.0798 us | 1.00 | 0.00 |
| .NET 9.0 | 7.849 us | 0.1512 us | 0.1263 us | 0.68 | 0.01 |
| .NET Framework 4.8.1 | 14.393 us | 0.1046 us | 0.0874 us | 1.25 | 0.01 |

In this scenario, using the ToString method is 32% faster in .NET 9 than .NET 8, and about 45% faster than .NET Framework 4.8.1.

Who’s the winner? .NET 9

Catch and ignore (async)

1
2
3
4
5
| Runtime              | Mean      | Error     | StdDev    | Ratio | RatioSD |
|--------------------- |----------:|----------:|----------:|------:|--------:|
| .NET 8.0 | 21.662 us | 0.4302 us | 1.0056 us | 1.00 | 0.00 |
| .NET 9.0 | 11.690 us | 0.1647 us | 0.1375 us | 0.54 | 0.03 |
| .NET Framework 4.8.1 | 23.139 us | 0.0875 us | 0.0775 us | 1.06 | 0.06 |

In the simplest scenario, but with an asynchronous implementation, .NET 9 is 46% faster than .NET 8 and 50% faster than .NET Framework 4.8.1.

Who’s the winner? .NET 9

Catch and convert to string (async)

1
2
3
4
5
| Runtime              | Mean      | Error     | StdDev    | Ratio | RatioSD |
|--------------------- |----------:|----------:|----------:|------:|--------:|
| .NET 8.0 | 44.680 us | 0.7175 us | 0.5991 us | 1.00 | 0.00 |
| .NET 9.0 | 36.583 us | 0.7125 us | 0.7623 us | 0.82 | 0.02 |
| .NET Framework 4.8.1 | 45.369 us | 0.7617 us | 1.0924 us | 1.01 | 0.02 |

The catch and use the ToString method asynchronous implementation is 18% faster in .NET 9 than .NET 8 and 20% faster than .NET framework 4.8.1.

Who’s the winner? .NET 9

Conclusion

In this article we compared .NET 9, .NET 8 and .NET Framework 4.8.1 performance when throwing and handling exceptions, for both synchronous and asynchronous code, and concluded the future .NET release will come with significant improvements.

This will certainly be important for high demanding systems that usually integrate with a lot of external systems, like message queues, databases or HTTP endpoints. Even in less complex scenarios it is good to know a simple change from .NET 8 or older versions to .NET 9 will increase the overall performance.

Microsoft has been stepping in the right direction ever since they released .NET Core, and let’s hope they keep going.