.NET 10 Performance Edition

6/16/2025
16 minute read

As every year, the dotnet team adjusted the runtime and libraries to improve performance. So let's go over some of the highlights. I will link the corresponding blog post or GitHub issue for each item, so you can read more about it if you are interested.

And of course: You will find the benchmarks at the end of the article.


Disclaimer: As with every benchmark article I have, I advise you to not use these benchmarks as a reason to change your code. They are here to show you what the runtime is capable of and how it can improve performance in certain scenarios. Always measure your own code and see if it benefits from these changes.

The code shown here is in isolated and does not represent real-world scenarios. The results may vary based on the specific use case, environment, and other factors.

Also this is the reference output of benchmarkdotnet for my setup:

BenchmarkDotNet v0.15.0, macOS Sequoia 15.5 (24F74) [Darwin 24.5.0]
Apple M2 Pro, 1 CPU, 12 logical and 12 physical cores
.NET SDK 10.0.100-preview.5.25277.114
  [Host]    : .NET 9.0.5 (9.0.525.21509), Arm64 RyuJIT AdvSIMD
  .NET 10.0 : .NET 10.0.0 (10.0.25.27814), Arm64 RyuJIT AdvSIMD
  .NET 9.0  : .NET 9.0.5 (9.0.525.21509), Arm64 RyuJIT AdvSIMD

Virtualization and Inlining

Stackalloc of arrays

Imagine the following code:

public int StackallocOfArrays()
{
    int[] numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
    var sum = 0;

    for (var i = 0; i < numbers.Length; i++)
        sum += numbers[i];

    return sum;
}

We have an array of integers that will end up on the heap, as all arrays do. But if the JIT can determine that the array is small enough and will not outlive the scope of the method, it can allocate the array on the stack instead.

The results are nice:

| Method                 | Job       | Runtime   | Mean          | Error      | StdDev     | Ratio | Gen0   | Allocated | Alloc Ratio |
|----------------------- |---------- |---------- |--------------:|-----------:|-----------:|------:|-------:|----------:|------------:|
| StackallocOfArrays     | .NET 10.0 | .NET 10.0 |      3.921 ns |  0.0245 ns |  0.0217 ns |  0.51 |      - |         - |        0.00 |
| StackallocOfArrays     | .NET 9.0  | .NET 9.0  |      7.703 ns |  0.0329 ns |  0.0308 ns |  1.00 | 0.0086 |      72 B |        1.00 |

Of course the allocations are gone, as the array is now allocated on the stack. The performance is also improved.

See more: https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/runtime#stack-allocation

Delegate escape analysis

When you create a delegate, the JIT can analyze if the delegate will escape the method scope. What does that mean? Normally, when you create a delegate, it captures the context in which it was created. It does so by creating a class that holds the state of the delegate. That is important for example if you return the delegate and execute it later. Then you need to hold the state of the variables that were in scope when the delegate was created. But if the delegate does not escape the method scope, the JIT can optimize it by stackallocating the object.

public int DelegateEscapeAnalysis()
{
    var sum = 0;
    Action<int> action = i => sum += i;

    foreach (var number in Numbers)
        action(number);

    return sum;
}

Results:

| Method                 | Job       | Runtime   | Mean          | Error      | StdDev     | Ratio | Gen0   | Allocated | Alloc Ratio |
|----------------------- |---------- |---------- |--------------:|-----------:|-----------:|------:|-------:|----------:|------------:|
| DelegateEscapeAnalysis | .NET 10.0 | .NET 10.0 |  6,292.884 ns |  9.5450 ns |  8.9284 ns |  0.33 |      - |      24 B |        0.27 |
| DelegateEscapeAnalysis | .NET 9.0  | .NET 9.0  | 18,983.377 ns | 74.6878 ns | 66.2088 ns |  1.00 |      - |      88 B |        1.00 |

That is quite the improvement. 3x faster and over 3x less memory allocated.

See more: https://github.com/dotnet/core/blob/main/release-notes/10.0/preview/preview5/runtime.md#escape-analysis-for-delegates

LINQ

Our all time favorite: LINQ. Also here we have a few improvements. In recent additions more and more SIMD operations were used under the hood to speed up LINQ operations. If you don't know what SIMD is, I have a full blown article about it: LINQ on steroids with SIMD and also some conference talks.

Anyway, let's have a look at:

public class LinqTests
{
    private static readonly IReadOnlyCollection<int> Numbers = Enumerable.Range(0, 20000).ToArray();
    private static readonly IReadOnlyCollection<float> NumbersFloats = Enumerable.Range(0, 20000).Select(s => (float)s).ToArray();
    
    [Benchmark]
    public int EvenCountInteger()
    {
        return Numbers.Count(n => n % 2 == 0);
    }
    
    [Benchmark]
    public IReadOnlyCollection<int> EvenCountIntegerToList()
    {
        return Numbers.Where(n => n % 2 == 0).ToList();
    }
    
    [Benchmark]
    public int EvenCountFloat()
    {
        return NumbersFloats.Count(n => n % 2 == 0);
    }
}

We can see that we have a few LINQ operations here. Count often utilizes Where (or better the WhereIterator) under the hood, so we can also see the performance of that. I added the float version because many operations like Sum have special cases for things like int to use SIMD. But not for data types like float or double. Here are the results for the benchmarks:

| Method                 | Job       | Runtime   | Mean       | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|----------------------- |---------- |---------- |-----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| EvenCountInteger       | .NET 10.0 | .NET 10.0 |   7.234 us | 0.1429 us | 0.1468 us |  0.47 |    0.01 |      - |         - |          NA |
| EvenCountInteger       | .NET 9.0  | .NET 9.0  |  15.421 us | 0.1352 us | 0.1265 us |  1.00 |    0.01 |      - |         - |          NA |
|                        |           |           |            |           |           |       |         |        |           |             |
| EvenCountIntegerToList | .NET 10.0 | .NET 10.0 |  14.025 us | 0.2689 us | 0.2383 us |  0.79 |    0.01 | 4.7760 |   40104 B |        1.00 |
| EvenCountIntegerToList | .NET 9.0  | .NET 9.0  |  17.645 us | 0.1694 us | 0.1584 us |  1.00 |    0.01 | 4.7607 |   40104 B |        1.00 |
|                        |           |           |            |           |           |       |         |        |           |             |
| EvenCountFloat         | .NET 10.0 | .NET 10.0 | 262.528 us | 2.2539 us | 1.9980 us |  1.00 |    0.01 |      - |         - |          NA |
| EvenCountFloat         | .NET 9.0  | .NET 9.0  | 263.203 us | 3.5460 us | 3.1434 us |  1.00 |    0.02 |      - |         - |          NA |

We can see a nice improvement for the Count operation on integers. The Where operation is not that much faster, because it gets "shadowed" by the ToList operation.

As expected, and kind of like the Sum operation, the float operations are not faster. The JIT does not use SIMD for float or double operations in LINQ.

Small hint here: My target architecture is arm64 with a MacBook Pro M2 (that has support for Vector128 operations). If you run this on x64 or x86, the results may vary heavily.

System.Text.Json improvements

The System.Text.Json library has seen some improvements as well. That is a common theme for years now, and it is always great to see smaller improvements!

public class SystemTextJsonTests
{
    private const string Json = """
                                {
                                    "Name": "Steven Giesel",
                                    "Age": 34,
                                    "IsEmployed": true,
                                    "Skills": ["C#", "Blazor", "ASP.NET Core"],
                                    "Address": {
                                        "Street": "1725 Slough Avenue",
                                        "Suite": "Suite 200",
                                        "City": "Scranton",
                                        "State": "PA",
                                        "ZipCode": "18505"
                                    },
                                    "PhoneNumbers": [
                                        {
                                            "Type": "Home",
                                            "Number": "123-456-7890"
                                        },
                                        {
                                            "Type": "Work",
                                            "Number": "098-765-4321"
                                        }
                                    ]
                                }
                                """;
    private readonly Person person = new()
    {
        Name = "Steven Giesel",
        Age = 34,
        IsEmployed = true,
        Skills = ["C#", "Blazor", "ASP.NET Core"],
        Address = new Address
        {
            Street = "1725 Slough Avenue",
            Suite = "Suite 200",
            City = "Scranton",
            State = "PA",
            ZipCode = "18505"
        },
        PhoneNumbers =
        [
            new PhoneNumber { Type = "Home", Number = "123-456-7890" },
            new PhoneNumber { Type = "Work", Number = "098-765-4321" }
        ]
    };

    [Benchmark]
    public Person DeserializePerson()
    {
        return System.Text.Json.JsonSerializer.Deserialize<Person>(Json)!;
    }

    [Benchmark]
    public string SerializePerson()
    {
        return System.Text.Json.JsonSerializer.Serialize(person);
    }

    public class Person
    {
        public string Name { get; set; } = string.Empty;
        public int Age { get; set; }
        public bool IsEmployed { get; set; }
        public List<string> Skills { get; set; } = [];
        public Address Address { get; set; } = new();
        public List<PhoneNumber> PhoneNumbers { get; set; } = [];
    }

    public class Address
    {
        public string Street { get; set; } = string.Empty;
        public string Suite { get; set; } = string.Empty;
        public string City { get; set; } = string.Empty;
        public string State { get; set; } = string.Empty;
        public string ZipCode { get; set; } = string.Empty;
    }

    public class PhoneNumber
    {
        public string Type { get; set; } = string.Empty;
        public string Number { get; set; } = string.Empty;
    }
}

Here are the results for the benchmarks:

| Method            | Job       | Runtime   | Mean       | Error    | StdDev   | Ratio | Gen0   | Allocated | Alloc Ratio |
|------------------ |---------- |---------- |-----------:|---------:|---------:|------:|-------:|----------:|------------:|
| DeserializePerson | .NET 10.0 | .NET 10.0 | 1,353.9 ns | 11.16 ns |  8.71 ns |  0.92 | 0.1755 |    1480 B |        1.00 |
| DeserializePerson | .NET 9.0  | .NET 9.0  | 1,476.7 ns | 15.12 ns | 14.14 ns |  1.00 | 0.1755 |    1480 B |        1.00 |
|                   |           |           |            |          |          |       |        |           |             |
| SerializePerson   | .NET 10.0 | .NET 10.0 |   527.7 ns |  8.94 ns |  8.37 ns |  0.90 | 0.1116 |     936 B |        1.00 |
| SerializePerson   | .NET 9.0  | .NET 9.0  |   584.1 ns |  1.24 ns |  0.97 ns |  1.00 | 0.1116 |     936 B |        1.00 |

10% faster deserialization and serialization - I'll take that! Of course that greatly benefits ASP.NET.

ASP.NET Benchmarks

Some time ago, I wrote an article ("Comparing the performance between the Minimal API and classic Controllers") showcasing the performance between regular Controllers and the Minimal API in dotnet. I took now the liberty of re-running the test for the minimal API (see the source code either at the end of this article or the linked article):

| Method                             | Job       | Runtime   | Mean     | Error     | StdDev    | Gen0     | Gen1    | Allocated |
|----------------------------------- |---------- |---------- |---------:|----------:|----------:|---------:|--------:|----------:|
| GetTwoTimes100InParallelMinimalApi | .NET 10.0 | .NET 10.0 | 2.971 ms | 0.0266 ms | 0.0296 ms | 164.0625 | 54.6875 |   1.29 MB |
| GetTwoTimes100InParallelMinimalApi | .NET 9.0  | .NET 9.0  | 3.207 ms | 0.0562 ms | 0.0984 ms | 164.0625 | 54.6875 |   1.29 MB |

Resources

  • Source code for this post can be found here
  • Majority of my blog post examples can be found here
An error has occurred. This application may no longer respond until reloaded. Reload x