2 hours ago
Before television shows like The Office and Parks and Recreation cemented the mockumentary in the minds of millions, there was Christopher Guest. He didn’t invent the genre, but he’s widely recognized as one of its most influential practitioners, and for my money, there’s none better. I’ve watched Waiting for Guffman and Best in Show more times than I can count. But the one that has stuck with me the most, the one I quote at the slightest provocation, is This Is Spinal Tap.
If you’ve seen it you already know where this is going (and if you haven’t, you now have weekend plans). The film is a fictional documentary about an aging English rock band named Spinal Tap, whose members are everything we picture when we picture over-the-top rock stars. In one of its more memorable scenes, the guitarist (Nigel) gives the filmmaker (Marty) a tour of his most prized gear, in particular showing off an amplifier unlike any other: its dials don’t stop at ten. That leads to what might be the single most quoted exchange in the entire movie:
Nigel: “You see, most blokes, you know, will be playing at ten. You’re on ten here, all the way up, all the way up, all the way up, you’re on ten on your guitar. Where can you go from there? Where?”
Marty: “I don’t know.”
Nigel: “Nowhere. Exactly. What we do is, if we need that extra push over the cliff, you know what we do?”
Marty: “Put it up to eleven?”
Nigel: “Eleven. Exactly. One louder.”
This is .NET 11. It’s one louder, with another year’s worth of performance work
having gone into making the runtime and libraries that much faster. Of course, the premise of Nigel’s special amplifier is ludicrous, as is exemplified in the subsequent few lines of dialog:
Marty: “Why don’t you just make ten louder and make ten be the top number and make that a little louder?”
Nigel: (pauses) “…these go to eleven.”
In contrast, .NET 11 is actually one higher, one louder. The sections that follow are full of real improvements. A bounds check removed, an allocation that no longer happens, a lock that isn’t taken, a loop that runs in fewer cycles than it did a year ago, a comparison folded to a constant here, a redundant check hoisted out of a loop there, a couple of instructions fused into one, a syscall sidestepped, an array copy handed off to SIMD, and on and on. That’s how real performance work goes, accumulating gain after gain, each compounding on the last, until the whole thing is measurably, provably louder. And so, in this post, as I’ve done in past years with .NET 10, .NET 9, .NET 8, .NET 7, .NET 6, .NET 5, .NET Core 3.0, .NET Core 2.1, and .NET Core 2.0 before it, we’ll take an unhurried tour through hundreds of them.
This is a long one. It’s meant to be. Grab your hot beverage of choice, settle in, and let’s turn it up.
Benchmarking Setup
As in previous years, the post is chock full of micro-benchmarks that demonstrate the individual improvements. Almost all of them use BenchmarkDotNet, and each is written to be self-contained so you can try it out yourself.
Start by ensuring you have both .NET 10 and .NET 11 installed (most of the benchmarks compare the same code running on both versions) and create a new console project in a fresh benchmarks
directory:
dotnet new console -o benchmarks
cd benchmarks
Replace the contents of the generated benchmarks.csproj
with the following, which multi-targets both versions so that BenchmarkDotNet can build for each:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net11.0;net10.0</TargetFrameworks>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ServerGarbageCollection>true</ServerGarbageCollection>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.12</SystemPackageVersion>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net11.0'">11.0.0-rc.1.26425.128</SystemPackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.1" />
<PackageReference Include="System.IO.Hashing" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Runtime.Caching" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Numerics.Tensors" Version="$(SystemPackageVersion)" />
</ItemGroup>
</Project>
For a given benchmark to test, copy its complete contents over everything in Program.cs
and then run it. Each benchmark includes as a comment at the top the exact command to use. In most cases, it’s:
dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
which builds in Release and runs the benchmark against both .NET 10 and .NET 11, emitting a side-by-side comparison. The other common form, used when a benchmark is comparing two coding approaches on a single runtime (rather than the same code across two runtimes) is:
dotnet run -c Release -f net11.0 --filter "*"
The usual disclaimer applies: these are micro-benchmarks, many measuring operations so short that a blink would miss them. Your results will vary with your hardware, OS, runtime configuration, what else your machine happens to be doing at that exact moment, and whether Mercury is in retrograde.
Every line of managed code ultimately ends up at the just-in-time compiler, so let’s start there.
JIT
Of all the places to improve .NET’s performance, few have as broad an impact as the just-in-time (JIT) compiler. C#, F#, and Visual Basic are typically compiled first to intermediate language (IL), and the JIT ultimately turns that IL into the native instructions the CPU executes. A JIT improvement can therefore benefit application and library code wherever the optimized pattern occurs, often with no source changes or recompilation of the application itself. Even removing a single instruction or proving one check unnecessary can add up when the code is on a very hot path.
Deabstraction
We as developers love our abstractions. They let us write clean, reusable, object-oriented code, but we don’t want to pay for every abstraction at run time. The runtime can often undo an abstraction when it proves the effects aren’t observable. It can look at a virtual call and determine which concrete method it’ll invoke, look at a heap allocation and recognize that the object never leaves the current stack frame, or look at an interface cast and reuse a type fact already established earlier in the method. This process is called “deabstraction.” .NET has improved steadily in this area for years, and that continues in .NET 11.
Every time you write interface
in C#, you’re creating a contract, a promise that any type implementing that interface can be substituted for any other. That flexibility is enormously valuable because, for example, it’s what lets us write IEnumerable<T>
and have it work equally well over arrays, lists, other collections, LINQ, custom iterators, and so on. But the CPU doesn’t know anything about these contracts; it just knows how to execute instructions. Turning “call whatever method this interface reference points to” into actual machine instructions requires special machinery. Consider this example:
// dotnet run -c Release -f net11.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private Animal _animal = Environment.TickCount >= 0 ? new Dog() : new Cat();
[Benchmark]
public int Speak() => _animal.Speak();
public abstract class Animal
{
public abstract int Speak();
}
private sealed class Dog : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 1;
}
private sealed class Cat : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 2;
}
}
At compile time, all else equal, the JIT doesn’t know whether _animal
is a Dog
or a Cat
. It generates code that loads the instance’s “method table pointer” (its object type handle), sometimes called a “vtable pointer”, stored at the beginning of every .NET object, indexes into the method table at the known slot for Speak
, and calls the function pointer found there:
; x64
mov rcx, [rcx+8] ; load _animal
mov rax, [rcx] ; load method table
mov rax, [rax+40] ; load vtable chunk
call qword ptr [rax+20]
For this one call to Speak
, we pay three dependent memory dereferences and an indirect call because the processor doesn’t know for certain in advance where the call is going (it might guess, or “speculatively execute”, but it has to be prepared for the possibility it was wrong), and because the call target is indirect, the JIT can’t inline the callee. Whatever Speak
does, its code can’t be folded into the calling method.
That’s a performance problem. Those indirections have overhead, but the bigger cost is the lost opportunity to inline. Inlining not only saves function call overhead, more importantly it opens the callee’s code up to the same optimizations that are operating on the caller, such as constant propagation, dead code elimination, bounds check elimination, further devirtualization, etc. That means a series of small virtual calls that each look innocent can, when devirtualized and inlined, collapse into a handful of instructions that would be unrecognizable and way cheaper when compared to the original source code. Without inlining, each callee is an opaque box; with it, the JIT can see through the layers.
We as .NET developers constantly rely on the JIT’s sophisticated heuristics for inlining that weigh the IL size of the callee, the exact work the callee is performing, the call frequency of the method, the expected benefit from constant arguments, and dozens of other factors. For virtual calls, the JIT needs to know what the actual target of the call will be; it needs to “devirtualize”. In some cases, it can determine that statically, where it has exact-type knowledge. For example, if the JIT can prove that animal
is always a Dog
, whether because it was just allocated with new Dog()
:
Animal animal = GetSomeAnimal();
animal.Speak();
...
static Animal GetSomeAnimal() => new Dog(); // inlineable
or because the variable’s type is a sealed class:
Dog animal = GetSomeAnimal();
animal.Speak();
...
sealed class Dog { ... } // impossible for `animal` to be anything other than a `Dog`
or with NativeAOT and whole-program compilation, if it sees that Animal
is abstract and the only type in the whole application that derives from Animal
is Dog
:
Animal animal = GetSomeAnimal();
animal.Speak();
...
abstract class Animal { ... }
class Dog : Animal { ... } // no other such derived type
or other such validation, it can emit a call to Dog.Speak()
directly, and the inliner can take its shot.
But for other cases where it can’t prove this with static analysis, the JIT turns to profile-guided optimization (PGO). PGO sounds fancy, but it’s conceptually simple. With “tiered compilation”, when a method is first invoked, it can be compiled “just in time” with few-to-no optimizations (this is referred to as Tier 0). The JIT can include in this compilation additional probes (think “printf debugging”) that let it track a bunch of interesting information about the nature of the code, recording what actually happens when it runs: which branches are taken, what are the concrete types that show up at virtual call sites or cast attempts, and so on. If the method is invoked enough or loops enough times, the runtime can ask the JIT to produce a new optimized version (referred to as Tier 1). That compilation can then factor in all of the learnings gathered as part of that profiling.
The JIT, of course, still needs to generate code that’s always correct. Even if a dynamic profile says animal
was Dog
100% of the time, that doesn’t guarantee it’ll always be Dog
in the future; it could be that the first 1000 calls passed in a Dog
but the 1001st call is going to pass in Dolphin
. How can the JIT incorporate this learning then? By emitting a run-time check. The Dog
path can get a direct call, which may then be inlinable, and the other path keeps the original virtual call as the fallback. The speed comes from making the common case tiny, while correctness comes from leaving the uncommon case intact.
// Approximately what the JIT generates
if (animal?.GetType() == typeof(Dog))
{
((Dog)animal).Speak(); // devirtualized, inlinable
}
else
{
animal.Speak(); // original virtual call, hopefully rare
}
This “guess and verify” pattern, called “guarded devirtualization” (GDV), accounts for many of the biggest throughput wins in real workloads. It’s applicable not only to virtual dispatch but also to interface dispatch, which also happens to be a bit more expensive than virtual dispatch because a type can implement any number of interfaces and that means the interface slots don’t simply map to fixed vtable positions.
Deabstraction can also make object creation more efficient when it reveals what kind of object is involved. In general, objects in .NET are allocated on the garbage collected heap, tracked by the garbage collector (GC), and collected when no longer reachable. Heap allocation is typically fast, often effectively just bumping a pointer. However, when there’s not enough space available to bump the pointer, it can get much more expensive, including needing to incur a garbage collection. Every allocated object also effectively incurs the amortized cost of all collections, as every allocated object eventually needs to be cleaned up.
“Escape analysis” is the compiler technique that lets us ask whether this object ever “escapes” the current method. If an object reference to a newly allocated object provably doesn’t escape, then the JIT can more efficiently allocate it. It needn’t store it on the GC heap, because nothing could possibly need to reference that object again, so it can instead allocate the object on the stack, making both allocation and cleanup essentially free. Stack allocation is even faster than heap bump-pointer allocation; it’s just decrementing the stack pointer, which is typically already in a register. And more importantly it means zero GC impact, because the stack frame is freed atomically on function return.
The JIT’s been progressively expanding escape analysis over the past several .NET releases, with .NET 9 and 10 seeing significant investments in stack-allocating delegates and closures, Nullable<T>
temporaries, and small helper objects. The key theme is that every false positive escape, every time the JIT incorrectly concludes an object may escape when it really doesn’t, represents a heap allocation that could have been avoided, and we want to whittle away at that false positive list. In .NET 11, the JIT trims that list in several ways.
We’ll start with nullable boxing. Consider this benchmark:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int? _nullableNull;
private int? _nullableValue = 42;
[Benchmark]
public object? BoxNullableNull() => (object?)_nullableNull;
[Benchmark]
public object? BoxNullableValue() => (object?)_nullableValue;
[Benchmark]
public string? FormatNullableInt() => Format(_nullableValue);
private static string? Format<T>(T value)
{
if (value is IFormattable formattable)
return formattable.ToString(null, null);
return null;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
BoxNullableNull
.NET 10.0
2.095 ns
1.00
–
–
BoxNullableNull
.NET 11.0
1.764 ns
0.84
–
–
BoxNullableValue
.NET 10.0
9.213 ns
1.00
24 B
1.00
BoxNullableValue
.NET 11.0
4.126 ns
0.45
24 B
1.00
FormatNullableInt
.NET 10.0
9.583 ns
1.00
24 B
1.00
FormatNullableInt
.NET 11.0
1.987 ns
0.21
–
0
dotnet/runtime#122167 expands nullable boxing inside the JIT, exposing the temporary box to escape analysis; previously, a runtime helper hid it. For a null
input, there’s no allocation on either version, because nothing gets boxed. And on both versions, BoxNullableValue
returns the boxed object, meaning the object escapes, so the 24-byte allocation remains. However, for FormatNullableInt
, the JIT in .NET 11 can now see that the temporary 24-byte box doesn’t escape and eliminates that heap allocation entirely.
Escape analysis improved further for enumerators, through a mechanism called Conditional Escape Analysis (CEA). Support for CEA was introduced in .NET 10, but .NET 11 extends the set of patterns that this analysis can safely recognize. The existing escape analysis asks whether a reference created by an allocation can flow somewhere the JIT can no longer track, such as an unknown call. If it can, the object must remain on the heap. That analysis is necessarily conservative and largely flow-insensitive: if an object might be passed to an interface call on any path, it doesn’t try to prove that the path containing that call is mutually exclusive with the path containing the allocation.
Unfortunately, that’s exactly what GDV produces when it optimizes a foreach
over an IEnumerable<T>
. As noted earlier, GDV turns an interface call into a type check with two branches: a fast branch for the likely collection type and a fallback branch containing the original interface call. Devirtualization and inlining along the fast branch will often reveal an enumerator allocation for the collection type, while later enumerator guards retain fallback calls such as IEnumerator<T>.MoveNext
. The existing analysis sees those calls and concludes that the locally allocated enumerator might escape. CEA instead records the relationship between the fast-path allocation and the enumerator local tested by the later guards. If every apparent escape occurs only behind a failed type check, the JIT can clone the region into a hot version where those checks are known to succeed. In that clone, the object can’t reach the fallback calls, so it can be stack-allocated and often promoted into separate scalar locals. The original region remains as the general slow path.
One case .NET 10 didn’t handle, though, was a GetEnumerator()
implementation that returns the result of another GetEnumerator()
call. A collection expression converted to IEnumerable<int>
, for example, uses a compiler-generated read-only-array wrapper with exactly this structure: the wrapper’s GetEnumerator()
delegates to the underlying array’s GetEnumerator
. With dotnet/runtime#122946, the JIT in .NET 11 handles this “chaining”:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly IEnumerable<int> s_readOnlyStatic = [1, 2, 3, 4, 5];
private readonly IEnumerable<int> _readOnlyInstance = [1, 2, 3, 4, 5];
[Benchmark]
public int ReadOnlyStatic()
{
int sum = 0;
foreach (int item in s_readOnlyStatic) sum += item;
return sum;
}
[Benchmark]
public int ReadOnlyInstance()
{
int sum = 0;
foreach (int item in _readOnlyInstance) sum += item;
return sum;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
ReadOnlyStatic
.NET 10.0
2.665 ns
1.00
–
–
ReadOnlyStatic
.NET 11.0
2.666 ns
1.00
–
–
ReadOnlyInstance
.NET 10.0
13.874 ns
1.00
32 B
1.00
ReadOnlyInstance
.NET 11.0
2.674 ns
0.19
–
0
ReadOnlyStatic
, whose static readonly
field the JIT can effectively treat as a constant, was already optimized in .NET 10. In .NET 11, the instance-field case also loses its 32-byte enumerator allocation and converges on the same throughput.
dotnet/runtime#121918 from @MichalPetryka fixes another way an address could unnecessarily make an object appear to escape. The IL constrained.
prefix lets one generic callvirt
sequence work for both value types and reference types: it can avoid boxing a value type, while for a reference type it dereferences the receiver and performs normal virtual dispatch. ObjectEqualityComparer<T>.Equals
, used in the following benchmark by EqualityComparer<T>.Default
, contains such a call to value.Equals(other)
. The receiver was represented as an indirect read through the address of a local. Merely taking that address marked the local as exposed, preventing the newly allocated Value
from being considered for stack allocation. The receiver is now represented as a direct value load instead, and the 24-byte heap allocation disappears.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Generic;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Value s_other = new(42);
[Benchmark]
public bool Equals() => EqualityComparer<Value>.Default.Equals(new Value(42), s_other);
private sealed class Value(int value)
{
private readonly int _value = value;
public override bool Equals(object? obj) => obj is Value other && _value == other._value;
public override int GetHashCode() => _value;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
Equals
.NET 10.0
3.874 ns
1.00
24 B
1.00
Equals
.NET 11.0
1.786 ns
0.46
–
0
While CEA can move a non-escaping object off the GC heap, sometimes the JIT can go further and prove an allocation need not exist at all. Generic code provides a common source of such opportunities through boxing. For example, the ArgumentNullException.ThrowIfNull
method accepts an object value
. That means when you have a method like this:
static void Test<T>(T value)
{
ArgumentNullException.ThrowIfNull(value);
...
}
when T
is constrained to a non-nullable struct, boxing is incurred, in order to pass value
as object
. ThrowIfNull
here is a nop if value
is non-null
(since the method is simply if (value is null) Throw();
), and previous releases successfully optimized away that boxing in optimized code. However, in Tier 0, that optimization wasn’t applied, and ThrowIfNull
would end up allocating. While this wouldn’t negatively impact steady-state throughput, it would lead to annoying noise in profiling, as well as additional overhead during startup, where such use wasn’t yet promoted out of Tier 0. In .NET 11, dotnet/runtime#129392 adds support for this in Tier 0 as well.
On the virtual-dispatch side, multiple PRs contribute to improving generic virtual methods (GVMs). dotnet/runtime#120866 from @hez2010 stops eagerly spilling ldvirtftn
call targets into a temporary, and lets generic virtual target resolution move ahead of argument setup when legal. dotnet/runtime#122023 from @hez2010 then enables the JIT to devirtualize non-shared GVMs, carrying the generic context needed to turn the indirect dispatch into a direct, and potentially inlineable, call. And dotnet/runtime#128702 from @hez2010 extends that support to shared GVMs and default interface implementations that require an instantiating stub. These optimizations can increase total code size when the newly direct calls are inlined, but that’s generally the desired trade: more of the actual work becomes visible to the optimizer. Consider the following benchmark:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Benchmark]
public int NonShared() => ((IProcessor)new Processor()).SizeOf(42);
[Benchmark]
public int Shared() => ((IProcessor)new Processor()).SizeOf("hello");
private interface IProcessor
{
int SizeOf<T>(T value);
}
private sealed class Processor : IProcessor
{
public int SizeOf<T>(T value) => Unsafe.SizeOf<T>();
}
}
Casting a freshly allocated Processor
to IProcessor
incurs an interface generic virtual call in the IL, but the JIT is now able to see the receiver’s exact type, even in the shared string
case, such that .NET 11 devirtualizes and inlines both calls. That in turn exposes Unsafe.SizeOf<T>()
as a constant and proves that the short-lived Processor
doesn’t need to be allocated at all.
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
NonShared
.NET 10.0
6.678 ns
1.00
24 B
1.00
NonShared
.NET 11.0
1.764 ns
0.26
–
0
Shared
.NET 10.0
7.166 ns
1.00
24 B
1.00
Shared
.NET 11.0
1.764 ns
0.25
–
0
Building on that, dotnet/runtime#123183 from @hez2010 enables ReadyToRun compilation to resolve and devirtualize more non-shared generic virtual calls that would otherwise remain indirect, and dotnet/runtime#130202 from @hez2010 extends that support to NativeAOT. NativeAOT represents some generic virtual targets as “fat pointers” (pointers that are more than just an address, typically an address and associated metadata, and that in this case carry both a code address and generic context); by deferring that transformation until after exact-type devirtualization has had a chance to run, the JIT can turn an interface call site with a single known target to a non-shared GVM into a direct call that may then be inlined.
Type information also needs to survive the transformations the JIT performs internally. If the JIT spills a reference expression into a temporary while restructuring a tree, losing the expression’s exact class information can turn a call that was devirtualizable back into an opaque virtual call. That’s what happens here in .NET 10: Value
gets boxed and SetValue
is invoked through IValue
. dotnet/runtime#128485 from @hez2010 preserves the class handle and exactness on the temporary. With that information still available, .NET 11 devirtualizes and inlines the call, eliminating the box and its 24-byte allocation.
Separately, dotnet/runtime#127433 relaxes the inliner’s budget heuristics for callees on [Intrinsic]
types like Span
and Vector
. These types intentionally expose many small, composable methods that serve as gateways to JIT-recognized operations. If a wrapper remains as a call, the caller pays the call overhead and optimizations around it see an opaque boundary. If it inlines, the importer can replace its body with an intrinsic node and optimize that node together with the surrounding indexing, bounds checks, and vector operations. Giving such wrappers more favorable budgeting therefore keeps more of them inlineable and exposes more of the actual operation to the rest of the optimizer.
One of the core abstraction-enabling mechanisms in .NET is delegates: they let us pass around objects representing functions to be invoked, carrying with them associated required state. Deabstraction enables avoiding paying for the overheads associated with delegates in some cases. For the rest, we still want those delegates to be as cheap as possible. dotnet/runtime#99200 from
@MichalPetryka simplifies CoreCLR’s delegate
representation, removing one pointer-sized field from every delegate object.
That saves 8 bytes per delegate in a 64-bit CoreCLR process.
dotnet/runtime#129304 from
@MichalPetryka improves Native AOT’s
delegate layout separately by reordering its existing four fields so related
values are adjacent. The updated layouts also give equality and hash-code
operations more direct access to the method identity they need.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Target s_target = new();
private static readonly Func<int> s_first = s_target.GetValue;
private static readonly Func<int> s_second = s_target.GetValue;
[Benchmark]
public Func<int> ClosedInstance() => s_target.GetValue;
[Benchmark]
public bool DelegateEquals() => s_first.Equals(s_second);
[Benchmark]
public int DelegateGetHashCode() => s_first.GetHashCode();
private sealed class Target
{
public int GetValue() => 42;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
ClosedInstance
.NET 10.0
7.395 ns
1.00
64 B
1.00
ClosedInstance
.NET 11.0
6.844 ns
0.93
56 B
0.88
DelegateEquals
.NET 10.0
3.254 ns
1.00
–
–
DelegateEquals
.NET 11.0
2.215 ns
0.68
–
–
DelegateGetHashCode
.NET 10.0
5.623 ns
1.00
–
–
DelegateGetHashCode
.NET 11.0
3.741 ns
0.67
–
–
dotnet/runtime#129410 from
@MichalPetryka follows up on the CoreCLR
layout by placing the target object and method pointer next to each other.
Those are commonly consumed together during invocation, and the adjacency
enables paired loads on architectures such as Arm64.
Runtime Async
For more than a decade, async
and await
have let us write asynchronous code that looks remarkably similar to synchronous code: we can put a try
/catch
around an await
, use local variables on either side of it, return a value and generally reason about the method in source order. When execution reaches an await
for something that isn’t yet complete, however, the method can’t simply leave its current stack frame in place and wait for the operation to finish. The thread needs to be freed up to do other work, while the work after the await
, including whatever local state it will need later, must survive somewhere. In C#, the compiler has traditionally been responsible for transforming the method into a representation that enables that continuation.
I went into the history and mechanics of that transformation in How async/await really works. The very short version is that the compiler traditionally replaces an async
method with a small entry method and a generated state machine whose MoveNext
method contains the transformed user code. Parameters, locals that need to survive an incomplete await, spilled expression values, awaiters, the current state number, and a method builder all become fields on a heap-allocated object. The generated MoveNext
method runs the user’s code until an awaiter reports that it isn’t yet complete. It stores enough information to know where and with what values to resume, registers MoveNext
as the continuation, and returns. When the operation completes, MoveNext
is invoked again, jumps to the right location based on the saved state number (think goto
and a label), retrieves the result from a value-producing awaiter, and continues. If every awaiter is already complete, MoveNext
can run all the way through synchronously. When the method completes or throws, the builder publishes the result, cancellation, or exception through the returned Task
, Task<T>
, ValueTask
, or ValueTask<T>
(or, in the rare case, a custom task-like type).
For example, consider this tiny method:
static async Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
return bytesRead;
}
While the code that gets generated for this changes over time and differs between debug and release builds, the lowering by the C# compiler has looked something like this:
[AsyncStateMachine(typeof(<ReadLengthAsync>d__0))]
static Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
<ReadLengthAsync>d__0 stateMachine = default;
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.state = -1;
stateMachine.stream = stream;
stateMachine.cancellationToken = cancellationToken;
stateMachine.builder.Start(ref stateMachine);
return stateMachine.builder.Task;
}
struct <ReadLengthAsync>d__0 : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<int> builder;
public Stream stream;
public CancellationToken cancellationToken;
private TaskAwaiter<int> awaiter;
public void MoveNext()
{
int result;
try
{
TaskAwaiter<int> localAwaiter;
if (state != 0)
{
byte[] buffer = new byte[4096];
localAwaiter = stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).GetAwaiter();
if (!localAwaiter.IsCompleted)
{
state = 0;
awaiter = localAwaiter;
builder.AwaitUnsafeOnCompleted(ref localAwaiter, ref this);
return;
}
}
else
{
localAwaiter = awaiter;
awaiter = default;
state = -1;
}
result = localAwaiter.GetResult();
}
catch (Exception e)
{
state = -2;
builder.SetException(e);
return;
}
state = -2;
builder.SetResult(result);
}
}
That’s quite a lot of generated code for three lines of C#. The compiler has to make decisions before the program runs about the state-machine layout, which values might need to survive, how many awaiter fields are required, and how all the suspension points fit into one MoveNext
dispatch. The runtime and JIT have optimized the resulting pattern heavily over the years, including combining the task, state machine, continuation, and ExecutionContext
into a single allocation, but by the time the JIT sees the IL, the transformation has already happened, leaving it with a very complicated system to try to optimize.
.NET 11 introduces a new way to split that responsibility, a reimplementation of the async
/await
infrastructure referred to as “runtime async”. Rather than the C# compiler being responsible for the transformation, the JIT is. The C# compiler emits a much smaller suspension-aware IL contract for each eligible async
method and marks the method as async
in metadata. The runtime and JIT then do the work that depends on runtime knowledge: creating the externally visible Task
or ValueTask
, recognizing direct async calls, deciding which values are actually alive at each suspension point, laying out continuation objects, and generating the control flow that suspends and resumes the method. Effectively, the transformation moves from C# to the runtime, where more information is available to optimize it.
The programming model hasn’t changed. This is still C# async
/await
; await
still obeys the awaiter pattern, exceptions and cancellation still surface through the returned task-like object, ConfigureAwait
still has its usual meaning, synchronous completion is still synchronous completion, and on and on. An explicit goal for the feature has been 100% behavioral compatibility: whether an async
method is lowered by the language compiler or by the runtime is an implementation detail, and any observable semantic difference is a bug.
In .NET 11, application code opts in with a compiler feature switch:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
Note that there’s no new C# syntax involved, so LangVersion=preview
isn’t required, nor is EnablePreviewFeatures
. While this is opt-in at the application layer, most of the in-box shared framework is already built this way for .NET 11. The async
/await
performance goal for .NET 11 is parity with .NET 10, and in general runtime async is already as good as or better than the older implementation in many important paths. It isn’t yet fully optimized, though, and there are known cases where it still produces less efficient code. I’d encourage you to experiment in .NET 11 with opting-in your applications and services; just make sure to measure. My hope is that it’ll be on by default starting in .NET 12.
Moving the transformation from the C# compiler to the runtime has the added benefit of reducing binary size. As noted, the traditional lowering emits an entry method, a generated state-machine type, fields for captured state, and a MoveNext
body, for every async method. Runtime async leaves a much smaller method body for the runtime to transform. The following tiny app contains ten Task<int>
-returning async methods, each awaiting the next, and compiles the same source once with compiler lowering and once with runtime async:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<AssemblyName>SizeProbe</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Features Condition="'$(RuntimeAsync)' == 'true'">$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
// dotnet build -c Release -p:RuntimeAsync=false -o classic --no-incremental; dotnet build -c Release -p:RuntimeAsync=true -o runtime --no-incremental; Get-Item .\classic\SizeProbe.dll, .\runtime\SizeProbe.dll | Select-Object Directory, Length
Console.WriteLine(await Benchmarks.Layer0());
public class Benchmarks
{
public static async Task<int> Layer0() => await Layer1();
private static async Task<int> Layer1() => await Layer2();
private static async Task<int> Layer2() => await Layer3();
private static async Task<int> Layer3() => await Layer4();
private static async Task<int> Layer4() => await Layer5();
private static async Task<int> Layer5() => await Layer6();
private static async Task<int> Layer6() => await Layer7();
private static async Task<int> Layer7() => await Layer8();
private static async Task<int> Layer8() => await Layer9();
private static async Task<int> Layer9()
{
await Task.Yield();
return 42;
}
}
Lowering
SizeProbe.dll
Ratio
Compiler
10,752 bytes
1.00
Runtime async
5,632 bytes
0.52
For a method such as:
static async Task<int> CallerAsync() => await CalleeAsync();
with runtime async enabled, the C# compiler generates IL like the following:
; MSIL
.method private hidebysig static
class System.Threading.Tasks.Task`1<int32> CallerAsync() cil managed async
{
call class System.Threading.Tasks.Task`1<int32> CalleeAsync()
call int32 System.Runtime.CompilerServices.AsyncHelpers::Await<int32>(
class System.Threading.Tasks.Task`1<int32>)
ret
}
There is no generated <CallerAsync>d__0
type, no IAsyncStateMachine
, no MoveNext
, no AsyncTaskMethodBuilder<int>
, and no AsyncStateMachineAttribute
. Previously, async
on a C# method evaporated at compile time. Now, the method has a new MethodImpl
async
bit, represented in IL assembly syntax by that async
modifier, and the body calls helpers in System.Runtime.CompilerServices.AsyncHelpers
.
At first glance the ret
looks impossible because the declared signature returns Task<int>
while the value on the IL evaluation stack is an int
. This clearly isn’t a normal calling convention. The VM can give a Task
-returning method two related identities, or MethodDescs, where one has the normal signature the rest of managed code sees, Task<int> CallerAsync()
. The other is the AsyncCall variant, which effectively returns int
and has an implicit channel for a continuation. Both refer to the same logical method and metadata token, but they have different calling conventions and different jobs. If regular managed code invokes CallerAsync
, the VM-generated outer thunk preserves the public contract and returns a Task<int>
. If another runtime async method directly awaits it, the JIT can instead call the AsyncCall variant and receive the result directly when the call completes synchronously, or a continuation when it suspends. In other words, it can hand back the T
directly and avoid allocating a Task<T>
.
That pairing works in both directions. For a method compiled with runtime async, the AsyncCall variant owns the generated (newly compact) IL while the public Task
-returning entry point is an adapter thunk; for a traditionally compiled method, the public method owns its usual IL while the VM can create an AsyncCall adapter around it. That means runtime async code remains able to await existing libraries and code compiled by older compilers, a critical capability for our goal of 100% compat. The largest wins naturally appear as more of an async call chain is compiled with runtime async.
This is where the JIT gets an opportunity that simply didn’t exist when every boundary was already expressed as a task and a generated state machine. Suppose A
awaits B
, which awaits C
:
static async Task<int> A(bool yield) => await B(yield);
static async Task<int> B(bool yield) => await C(yield);
static async Task<int> C(bool yield)
{
if (yield)
await Task.Yield();
return 42;
}
Traditionally, each method has its own compiler-generated state machine and its own task-like result. C
suspends and eventually completes its task, which wakes B
‘s state machine; B
then completes its task, which wakes A
‘s state machine; and A
completes the root task observed by the caller. There has been an enormous amount of work done over the years to reduce the costs of those objects and transitions.
With runtime async, the importer recognizes the adjacent pattern of “call a Task-returning method, then await that task.” In the simple case it can call the callee’s AsyncCall variant instead. When yield
is false and C
completes synchronously, the int
flows back through B
and A
as a plain value, and only the outermost boundary needs to turn it into the Task<int>
promised to the original caller. When yield
is true and C
suspends, the runtime links continuation state for the chain and eventually resumes it without requiring an intermediate Task<int>
at every directly fused edge. The Task
contract hasn’t vanished, it just moved to the place where a Task
is actually needed.
Runtime async doesn’t make every asynchronous operation allocation-free, though. Rather, it gives the JIT enough information to avoid materializing some task objects that existed only to carry a result from one async method directly into the next. If a consumer stores the task in a collection, manually hooks up a continuation, or otherwise observes the task as an object, that object is still needed. The optimization is about not paying for boundaries that aren’t observably boundaries.
The impact is already visible with just two layers:
// dotnet run -c Release -f net11.0 --filter "*"
// The project also needs the `runtime-async=on` feature switch set.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false)]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Task<int> s_completed = Task.FromResult(42);
[Benchmark(Baseline = true), BenchmarkCategory("Completed")]
public Task<int> ClassicCompleted() => ClassicCompletedOuter();
[Benchmark, BenchmarkCategory("Completed")]
public Task<int> RuntimeCompleted() => RuntimeCompletedOuter();
[Benchmark(Baseline = true), BenchmarkCategory("Yielding")]
public Task<int> ClassicYielding() => ClassicYieldingOuter();
[Benchmark, BenchmarkCategory("Yielding")]
public Task<int> RuntimeYielding() => RuntimeYieldingOuter();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedOuter() => await ClassicCompletedInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedInner() => await s_completed;
private static async Task<int> RuntimeCompletedOuter() => await RuntimeCompletedInner();
private static async Task<int> RuntimeCompletedInner() => await s_completed;
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingOuter() => await ClassicYieldingInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingInner()
{
await Task.Yield();
return 42;
}
private static async Task<int> RuntimeYieldingOuter() => await RuntimeYieldingInner();
private static async Task<int> RuntimeYieldingInner()
{
await Task.Yield();
return 42;
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}
Method
Mean
Ratio
Allocated
Alloc Ratio
ClassicCompleted
21.221 ns
1.00
144 B
1.00
RuntimeCompleted
6.151 ns
0.29
0 B
0.00
ClassicYielding
254.139 ns
1.00
248 B
1.00
RuntimeYielding
116.927 ns
0.46
168 B
0.68
The synchronously completing chain is more than 3x faster and avoids both
intermediate task allocations. Even after a real suspension, the same
two-layer chain takes less than half the time and allocates 80 fewer bytes.
Exception handling amplifies the difference. Again consider an async method A
calling an async method B
calling an async method C
. The transformation generated by the C# compiler of each method results in a try
/catch
block around the whole body of the MoveNext
method so that any unhandled exception can be stored into the returned Task
. Let’s say code in C
throws an unhandled exception. That’s then caught by this manufactured catch
block and stored into the Task
returned to B
. The awaiter in B
then retrieves that exception from the Task
object and throws it. It’s then caught by B
‘s generated catch and stored into its Task
. And so on. An exception crossing ten such async helpers can therefore be thrown, caught, and stored ten times even though none of the source methods has an explicit handler. That is super expensive. But runtime async doesn’t need to re-enter a pass-through frame with no handler. On the synchronous path the exception unwinds through the fused calls normally, and after a real suspension, one dispatch-loop catch walks past continuation records that have no handler and faults the observable root task once.
The following benchmark measures both a fully synchronous throw and an exception after one real Task.Yield
suspension. It uses a compiler-recognized per-method escape hatch (RuntimeAsyncMethodGeneration
) so that the classic and runtime async methods run in the same process on the same .NET 11 runtime and differ only in how the compiler lowers them. (Note that this attribute is experimental and isn’t a public API exposed from the core libraries; as with other attributes known to the C# compiler, it recognizes them by name and signature.)
// dotnet run -c Release -f net11.0 --filter "*"
// The project also needs the `runtime-async=on` feature switch set.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Params(1, 10, 30)]
public int Depth;
[Params(false, true)]
public bool Yield;
[Benchmark(Baseline = true)]
public int Classic() => Invoke(ClassicThrowAsync(Depth));
[Benchmark]
public int Runtime() => Invoke(RuntimeThrowAsync(Depth));
private static int Invoke(Task<int> task)
{
try
{
return task.GetAwaiter().GetResult();
}
catch (InvalidOperationException)
{
return -1;
}
}
[RuntimeAsyncMethodGeneration(false)]
private async Task<int> ClassicThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await ClassicThrowAsync(depth - 1);
}
private async Task<int> RuntimeThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await RuntimeThrowAsync(depth - 1);
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}
Depth
Yield
Method
Mean
Ratio
Allocated
Alloc Ratio
1
False
Classic
4.727 μs
1.00
1.6 KB
1.00
1
False
Runtime
3.558 μs
0.75
1.16 KB
0.72
1
True
Classic
6.308 μs
1.00
1.68 KB
1.00
1
True
Runtime
8.211 μs
1.30
1.42 KB
0.85
10
False
Classic
19.469 μs
1.00
15.13 KB
1.00
10
False
Runtime
5.885 μs
0.30
2.13 KB
0.14
10
True
Classic
24.923 μs
1.00
15.63 KB
1.00
10
True
Runtime
6.122 μs
0.25
2.88 KB
0.18
30
False
Classic
51.302 μs
1.00
84.2 KB
1.00
30
False
Runtime
10.721 μs
0.21
5.71 KB
0.07
30
True
Classic
65.974 μs
1.00
85.53 KB
1.00
30
True
Runtime
11.254 μs
0.17
7.72 KB
0.09
Runtime async supports Task
, Task<T>
, ValueTask
, and ValueTask<T>
as method return types, but as of today it doesn’t support async void
, async iterators, or arbitrary custom task-like return types with custom builders; those continue to use the traditional compiler transformation. For ValueTask<T>
, the existing reasons to use the type still apply. A ValueTask<T>
can carry a result directly, wrap a Task<T>
, or refer to an IValueTaskSource<T>
. That’s made it useful for APIs where synchronous completion is common enough that avoiding a Task
allocation outweighs the larger return value and the more restrictive consumption rules, or where asynchronous completion can have its costs amortized via a reusable backing object. Runtime async then addresses some of the scenarios that would have led developers to use ValueTask<T>
. Does that mean everyone should stop using ValueTask<T>
? No. Choosing Task
versus ValueTask
remains an API design decision based on completion patterns, allocation sensitivity, call frequency, and how consumers need to use the result. Write the return type that makes sense for the API, then let the compiler, VM, and JIT optimize it as best they can.
Workloads with many layers of small async methods can benefit the most from runtime
async, because those layers are exactly where intermediate tasks and state
machines often accumulate. Shared framework code, for example, is full of this
pattern: a public method validates arguments and awaits a private helper, which
awaits a transport helper, which awaits an operating-system operation.
Application services similarly compose authentication, retry, logging,
serialization, and I/O helpers. Runtime async can make the source-level
decomposition cheaper without asking the developer to flatten the code into
one giant method in order to avoid “implementation detail” costs.
The work required to reach this point has been extensive. A GitHub search of the runtime async tracking label on September 14, 2026 returned 235 pull requests, far too many for me to enumerate one by one. So I won’t try; you can peruse that label in your spare time. The work is also not only about direct performance improvements but also about
improvements to diagnostics and performance tooling that help you to make better
use of async in your own code. When an async method
suspends, its physical thread stack unwinds. That method’s continuation might later run
on a different thread whose physical stack begins in the thread pool, with the
methods that led to the original await
nowhere to be found. A sampling
CPU profiler can see where the processor is spending time, but without additional
information, it can’t reliably connect those traces back through the logical async
call chain, making it hard to answer questions about what async call paths were actually costing.
Profiling tools like the async profiler in Visual Studio have traditionally reconstructed those chains from
events emitted by Task
‘s infrastructure, but async-heavy applications can generate enormous volumes of
those very chatty events. The resulting overhead easily perturbs the workload being measured, making
it all but unusable in production. dotnet/runtime#127238 added a
new lightweight async-profiler event stream for .NET 11 and runtime async. Rather than sending every small
transition through the eventing system as its own full event, the runtime
writes compact records into per-thread buffers, delta-encoding timestamps and
instruction pointers and flushing the data in batches. It also puts a small
identifiable wrapper frame into the physical stack when invoking a
continuation. A profiler can use that frame as an anchor, joining ordinary CPU
samples to the logical async call stack represented by the event stream. In some measurements,
this new approach added less than 1% overhead and shrank the traced data by an order of magnitude.
dotnet/runtime#129043 and a few follow-up PRs extended
the same approach to the compiler-generated state machines used by existing
async code. Thus this
isn’t useful only to applications that opt into runtime async; tooling gets one
consistent representation across both implementations.
What should you as a developer do differently with runtime async in the picture? Mostly nothing. Keep writing asynchronous code the way you want it to read, and break a large operation into helpers when that makes the code clearer. Use Task
by default and choose ValueTask
where its API and usage tradeoffs genuinely fit. And don’t contort source code to remove a clean await
just because today’s implementation might allocate an intermediate Task
. The lowering strategy should “just work” as an implementation detail, preserve behavior, and make existing source get better as the runtime improves.
Bounds Checks
C# is a memory-safe language. Accesses to arrays, strings, and spans are guaranteed by the runtime to be in-bounds; if you try to access someArray[i]
, someString[i]
, or someSpan[i]
with an index less than 0 or greater than or equal to the length of the array/string/span, you’ll get an exception, not silently corrupted memory or a process crash. The runtime guarantees that all permitted accesses are within bounds, and that means it needs to be able to prove the access is in bounds. The main method the JIT has for achieving that is by injecting code that performs a bounds check, as if instead of:
int[] array = ...;
int value = array[i];
you’d written:
int[] array = ...;
if ((uint)i >= array.Length) throw new IndexOutOfRangeException();
int value = array[i];
At the assembly level, a bounds check looks something like:
; x64
cmp ecx, dword ptr [rax+8] ; compare index with array length
jae THROW ; unsigned index >= length
mov edx, dword ptr [rax+rcx*4+16] ; load the element
The JIT could just inject such code on every access and call it a day, but such code adds overhead, so the JIT works to elide those checks and that overhead wherever it can prove the index is valid. Proving an index is valid means the JIT needs to be able to see from other evidence that it couldn’t possibly be out of bounds.
The quintessential example of that is a for
loop over the full contents of an array or span:
for (int i = 0; i < array.Length; i++)
{
Use(array[i]);
}
The JIT recognizes from this idiom that, within the loop body, i
is guaranteed to be in the range [0, array.Length)
, and avoids emitting the bounds check for the array[i]
access. The JIT has long handled this particular case. Other cases, not so much. Bounds-check elimination has improved in virtually every .NET release; more recent releases added range propagation for derived expressions (.NET 7
and .NET 8
saw significant improvements here), SSA-based reasoning (.NET 9
), and better handling of Span<T>
, whose length sits in a field rather than an object header, complicating tracking. Each year, the developers contributing to the JIT find new patterns that were being missed, that show up in the wild, and that are fixable. .NET 11 improves several such patterns.
Range analysis in the JIT tracks intervals for each variable, an upper bound and a lower bound. For example, taking the true branch of x < 5
gives the range for x
in that branch an upper bound of 4 while taking the true branch of x > 2
makes the lower bound 3. What about x != 5
? On the true edge, we know x
isn’t 5, and if the current range for x
is [5, 10]
, then we know the range must actually be [6, 10]
… the lower bound can be tightened because the only value at the lower end is excluded. Similarly, if the range is [0, 5]
, an x != 5
assertion tells us the range is actually the narrower [0, 4]
. Or, at least, that’s what you’d hope it would do. The JIT had this relevant comment:
// We have a != assertion, but it doesn't tell us much about the interval. So just skip it.
continue;
In .NET 11, dotnet/runtime#121273 replaces that logic with productive reasoning. It checks whether the excluded constant is at either edge of the currently tracked range, adding in the new insights if so. C# list patterns, introduced in C# 11, generate just such comparison sequences. For example, the pattern name is [] or [':'] or [':', not ':', ..]
lowers to something like this:
if (name != null)
{
int num = name.Length;
if (num == 0) return true;
if (num == 1)
{
if (name[0] == ':') return true;
}
else if (name[0] == ':' && name[1] != ':')
{
return true;
}
return false;
}
Range analysis then proceeds with something like this:
We know that Array.Length
is never negative, so it has a range of [0, Array.MaxLength]
.
On the false edge of num == 0
, we know that num != 0
, so the range is narrowed now to [1, Array.MaxLength]
.
Similarly, on the false edge of num == 1
, we know that num != 1
, so the range is narrowed now to [2, Array.MaxLength]
.
We then access name[0]
and name[1]
, both of which are guaranteed in bounds based on the lower bound of 2 that was established.
Without the != constant
tightening, that narrowing wouldn’t happen, and the bounds checks in step 4 couldn’t be elided. Thankfully, they now can be in .NET 11. Consider this example:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private string[] _inputs = ["", ":", ":x", "abc", ":ab", "x", "ab:cd"];
[Benchmark]
public int ClassifyAll()
{
int total = 0;
foreach (string s in _inputs) total += Classify(s);
return total;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Classify(ReadOnlySpan<char> name) =>
name switch
{
[] => 0,
[':'] => 1,
[':', not ':', ..] => 10 + name[0] + name[1],
_ => 3
};
}
In .NET 10, we can see the call to CORINFO_HELP_RNGCHKFAIL
at the bottom of the method. That’s the tell-tale sign there was at least one bounds check in the method. With .NET 11, that sign is removed.
; Arm64
--- .NET 10
+++ .NET 11
@@ -10,17 +10,15 @@
beq G_M000_IG08
G_M000_IG04:
- ldrh w2, [x0]
- cmp w2, #58
+ ldrh w1, [x0]
+ cmp w1, #58
bne G_M000_IG06
G_M000_IG05:
- cmp w1, #1
- bls G_M000_IG11
ldrh w0, [x0, #0x02]
cmp w0, #58
beq G_M000_IG06
- add w0, w2, w0
+ add w0, w1, w0
add w0, w0, #10
b G_M000_IG07
@@ -44,8 +42,4 @@
mov w0, wzr
b G_M000_IG07
-G_M000_IG11:
- bl CORINFO_HELP_RNGCHKFAIL
- brk #0
-
-; Total bytes of code 112
+; Total bytes of code 96
“Assertion” machinery in the JIT propagates learned facts (like the aforementioned range information) between “basic blocks” (a sequence of instructions with one entry point, one exit point, and no branches into or out of the middle of it), so information established in block A flows to block B if A “dominates” B (meaning the only way to get to B is through A). But what about facts established earlier within the same block? That’s the gap that dotnet/runtime#121527 addresses. Consider this code:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int[] _arr = new int[512];
[Benchmark]
public int RunMany()
{
int touched = 0;
for (int i = 0; i < _arr.Length - 2; i++)
{
Test(_arr, i);
touched++;
}
return touched;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Test(int[] arr, int i)
{
arr[i] = 0; // 1: establishes 'i >= 0 && i < arr.Length'
i++; // 2: same block
if (i < arr.Length) arr[i] = 0; // 3: proven safe from 1's assertion
}
}
Statements 1, 2, and 3 are all in the same basic block, up to the conditional; after statement 1 executes, if we reach statement 2, the bounds check on statement 1 passed, we know i >= 0
and i < arr.Length
, and after statement 2, i
becomes i + 1
. After the if
guard i < arr.Length
we know the incremented i
is still within bounds. But when the range check pass in the .NET 10 JIT examined statement 3’s bounds check, it saw the assertions propagated from predecessor blocks. Since the assertion from statement 1 is generated within the current block, the range check couldn’t see it. The PR fixed it to walk the current block’s tree in execution order, accumulating assertions as it went. When we reach statement 3’s bounds check, we’ve already walked past statement 1 and picked up its i >= 0 && i < arr.Length
assertion.
; Arm64
--- .NET 10
+++ .NET 11
@@ -13,8 +13,6 @@
ble G_M000_IG04
G_M000_IG03:
- cmp w1, w2
- bhs G_M000_IG05
str wzr, [x0, w1, UXTW #2]
G_M000_IG04:
@@ -25,4 +23,4 @@
bl CORINFO_HELP_RNGCHKFAIL
brk #0
-; Total bytes of code 68
+; Total bytes of code 60
There are almost an infinite number of things the JIT could look for and special-case. But every special case requires code, maintenance, and, most importantly, compilation time. A “just-in-time” compiler typically runs while the application is running, so the JIT itself must be optimized and spend its limited budget only where there’s a likely payoff. That pushes the developers
This article was shortened because the original exceeded the forum post limit.
If you’ve seen it you already know where this is going (and if you haven’t, you now have weekend plans). The film is a fictional documentary about an aging English rock band named Spinal Tap, whose members are everything we picture when we picture over-the-top rock stars. In one of its more memorable scenes, the guitarist (Nigel) gives the filmmaker (Marty) a tour of his most prized gear, in particular showing off an amplifier unlike any other: its dials don’t stop at ten. That leads to what might be the single most quoted exchange in the entire movie:
Nigel: “You see, most blokes, you know, will be playing at ten. You’re on ten here, all the way up, all the way up, all the way up, you’re on ten on your guitar. Where can you go from there? Where?”
Marty: “I don’t know.”
Nigel: “Nowhere. Exactly. What we do is, if we need that extra push over the cliff, you know what we do?”
Marty: “Put it up to eleven?”
Nigel: “Eleven. Exactly. One louder.”
This is .NET 11. It’s one louder, with another year’s worth of performance work
having gone into making the runtime and libraries that much faster. Of course, the premise of Nigel’s special amplifier is ludicrous, as is exemplified in the subsequent few lines of dialog:
Marty: “Why don’t you just make ten louder and make ten be the top number and make that a little louder?”
Nigel: (pauses) “…these go to eleven.”
In contrast, .NET 11 is actually one higher, one louder. The sections that follow are full of real improvements. A bounds check removed, an allocation that no longer happens, a lock that isn’t taken, a loop that runs in fewer cycles than it did a year ago, a comparison folded to a constant here, a redundant check hoisted out of a loop there, a couple of instructions fused into one, a syscall sidestepped, an array copy handed off to SIMD, and on and on. That’s how real performance work goes, accumulating gain after gain, each compounding on the last, until the whole thing is measurably, provably louder. And so, in this post, as I’ve done in past years with .NET 10, .NET 9, .NET 8, .NET 7, .NET 6, .NET 5, .NET Core 3.0, .NET Core 2.1, and .NET Core 2.0 before it, we’ll take an unhurried tour through hundreds of them.
This is a long one. It’s meant to be. Grab your hot beverage of choice, settle in, and let’s turn it up.
Benchmarking Setup
As in previous years, the post is chock full of micro-benchmarks that demonstrate the individual improvements. Almost all of them use BenchmarkDotNet, and each is written to be self-contained so you can try it out yourself.
Start by ensuring you have both .NET 10 and .NET 11 installed (most of the benchmarks compare the same code running on both versions) and create a new console project in a fresh benchmarks
directory:
dotnet new console -o benchmarks
cd benchmarks
Replace the contents of the generated benchmarks.csproj
with the following, which multi-targets both versions so that BenchmarkDotNet can build for each:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net11.0;net10.0</TargetFrameworks>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ServerGarbageCollection>true</ServerGarbageCollection>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.12</SystemPackageVersion>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net11.0'">11.0.0-rc.1.26425.128</SystemPackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.1" />
<PackageReference Include="System.IO.Hashing" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Runtime.Caching" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Numerics.Tensors" Version="$(SystemPackageVersion)" />
</ItemGroup>
</Project>
For a given benchmark to test, copy its complete contents over everything in Program.cs
and then run it. Each benchmark includes as a comment at the top the exact command to use. In most cases, it’s:
dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
which builds in Release and runs the benchmark against both .NET 10 and .NET 11, emitting a side-by-side comparison. The other common form, used when a benchmark is comparing two coding approaches on a single runtime (rather than the same code across two runtimes) is:
dotnet run -c Release -f net11.0 --filter "*"
The usual disclaimer applies: these are micro-benchmarks, many measuring operations so short that a blink would miss them. Your results will vary with your hardware, OS, runtime configuration, what else your machine happens to be doing at that exact moment, and whether Mercury is in retrograde.
Every line of managed code ultimately ends up at the just-in-time compiler, so let’s start there.
JIT
Of all the places to improve .NET’s performance, few have as broad an impact as the just-in-time (JIT) compiler. C#, F#, and Visual Basic are typically compiled first to intermediate language (IL), and the JIT ultimately turns that IL into the native instructions the CPU executes. A JIT improvement can therefore benefit application and library code wherever the optimized pattern occurs, often with no source changes or recompilation of the application itself. Even removing a single instruction or proving one check unnecessary can add up when the code is on a very hot path.
Deabstraction
We as developers love our abstractions. They let us write clean, reusable, object-oriented code, but we don’t want to pay for every abstraction at run time. The runtime can often undo an abstraction when it proves the effects aren’t observable. It can look at a virtual call and determine which concrete method it’ll invoke, look at a heap allocation and recognize that the object never leaves the current stack frame, or look at an interface cast and reuse a type fact already established earlier in the method. This process is called “deabstraction.” .NET has improved steadily in this area for years, and that continues in .NET 11.
Every time you write interface
in C#, you’re creating a contract, a promise that any type implementing that interface can be substituted for any other. That flexibility is enormously valuable because, for example, it’s what lets us write IEnumerable<T>
and have it work equally well over arrays, lists, other collections, LINQ, custom iterators, and so on. But the CPU doesn’t know anything about these contracts; it just knows how to execute instructions. Turning “call whatever method this interface reference points to” into actual machine instructions requires special machinery. Consider this example:
// dotnet run -c Release -f net11.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private Animal _animal = Environment.TickCount >= 0 ? new Dog() : new Cat();
[Benchmark]
public int Speak() => _animal.Speak();
public abstract class Animal
{
public abstract int Speak();
}
private sealed class Dog : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 1;
}
private sealed class Cat : Animal
{
[MethodImpl(MethodImplOptions.NoInlining)]
public override int Speak() => 2;
}
}
At compile time, all else equal, the JIT doesn’t know whether _animal
is a Dog
or a Cat
. It generates code that loads the instance’s “method table pointer” (its object type handle), sometimes called a “vtable pointer”, stored at the beginning of every .NET object, indexes into the method table at the known slot for Speak
, and calls the function pointer found there:
; x64
mov rcx, [rcx+8] ; load _animal
mov rax, [rcx] ; load method table
mov rax, [rax+40] ; load vtable chunk
call qword ptr [rax+20]
For this one call to Speak
, we pay three dependent memory dereferences and an indirect call because the processor doesn’t know for certain in advance where the call is going (it might guess, or “speculatively execute”, but it has to be prepared for the possibility it was wrong), and because the call target is indirect, the JIT can’t inline the callee. Whatever Speak
does, its code can’t be folded into the calling method.
That’s a performance problem. Those indirections have overhead, but the bigger cost is the lost opportunity to inline. Inlining not only saves function call overhead, more importantly it opens the callee’s code up to the same optimizations that are operating on the caller, such as constant propagation, dead code elimination, bounds check elimination, further devirtualization, etc. That means a series of small virtual calls that each look innocent can, when devirtualized and inlined, collapse into a handful of instructions that would be unrecognizable and way cheaper when compared to the original source code. Without inlining, each callee is an opaque box; with it, the JIT can see through the layers.
We as .NET developers constantly rely on the JIT’s sophisticated heuristics for inlining that weigh the IL size of the callee, the exact work the callee is performing, the call frequency of the method, the expected benefit from constant arguments, and dozens of other factors. For virtual calls, the JIT needs to know what the actual target of the call will be; it needs to “devirtualize”. In some cases, it can determine that statically, where it has exact-type knowledge. For example, if the JIT can prove that animal
is always a Dog
, whether because it was just allocated with new Dog()
:
Animal animal = GetSomeAnimal();
animal.Speak();
...
static Animal GetSomeAnimal() => new Dog(); // inlineable
or because the variable’s type is a sealed class:
Dog animal = GetSomeAnimal();
animal.Speak();
...
sealed class Dog { ... } // impossible for `animal` to be anything other than a `Dog`
or with NativeAOT and whole-program compilation, if it sees that Animal
is abstract and the only type in the whole application that derives from Animal
is Dog
:
Animal animal = GetSomeAnimal();
animal.Speak();
...
abstract class Animal { ... }
class Dog : Animal { ... } // no other such derived type
or other such validation, it can emit a call to Dog.Speak()
directly, and the inliner can take its shot.
But for other cases where it can’t prove this with static analysis, the JIT turns to profile-guided optimization (PGO). PGO sounds fancy, but it’s conceptually simple. With “tiered compilation”, when a method is first invoked, it can be compiled “just in time” with few-to-no optimizations (this is referred to as Tier 0). The JIT can include in this compilation additional probes (think “printf debugging”) that let it track a bunch of interesting information about the nature of the code, recording what actually happens when it runs: which branches are taken, what are the concrete types that show up at virtual call sites or cast attempts, and so on. If the method is invoked enough or loops enough times, the runtime can ask the JIT to produce a new optimized version (referred to as Tier 1). That compilation can then factor in all of the learnings gathered as part of that profiling.
The JIT, of course, still needs to generate code that’s always correct. Even if a dynamic profile says animal
was Dog
100% of the time, that doesn’t guarantee it’ll always be Dog
in the future; it could be that the first 1000 calls passed in a Dog
but the 1001st call is going to pass in Dolphin
. How can the JIT incorporate this learning then? By emitting a run-time check. The Dog
path can get a direct call, which may then be inlinable, and the other path keeps the original virtual call as the fallback. The speed comes from making the common case tiny, while correctness comes from leaving the uncommon case intact.
// Approximately what the JIT generates
if (animal?.GetType() == typeof(Dog))
{
((Dog)animal).Speak(); // devirtualized, inlinable
}
else
{
animal.Speak(); // original virtual call, hopefully rare
}
This “guess and verify” pattern, called “guarded devirtualization” (GDV), accounts for many of the biggest throughput wins in real workloads. It’s applicable not only to virtual dispatch but also to interface dispatch, which also happens to be a bit more expensive than virtual dispatch because a type can implement any number of interfaces and that means the interface slots don’t simply map to fixed vtable positions.
Deabstraction can also make object creation more efficient when it reveals what kind of object is involved. In general, objects in .NET are allocated on the garbage collected heap, tracked by the garbage collector (GC), and collected when no longer reachable. Heap allocation is typically fast, often effectively just bumping a pointer. However, when there’s not enough space available to bump the pointer, it can get much more expensive, including needing to incur a garbage collection. Every allocated object also effectively incurs the amortized cost of all collections, as every allocated object eventually needs to be cleaned up.
“Escape analysis” is the compiler technique that lets us ask whether this object ever “escapes” the current method. If an object reference to a newly allocated object provably doesn’t escape, then the JIT can more efficiently allocate it. It needn’t store it on the GC heap, because nothing could possibly need to reference that object again, so it can instead allocate the object on the stack, making both allocation and cleanup essentially free. Stack allocation is even faster than heap bump-pointer allocation; it’s just decrementing the stack pointer, which is typically already in a register. And more importantly it means zero GC impact, because the stack frame is freed atomically on function return.
The JIT’s been progressively expanding escape analysis over the past several .NET releases, with .NET 9 and 10 seeing significant investments in stack-allocating delegates and closures, Nullable<T>
temporaries, and small helper objects. The key theme is that every false positive escape, every time the JIT incorrectly concludes an object may escape when it really doesn’t, represents a heap allocation that could have been avoided, and we want to whittle away at that false positive list. In .NET 11, the JIT trims that list in several ways.
We’ll start with nullable boxing. Consider this benchmark:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int? _nullableNull;
private int? _nullableValue = 42;
[Benchmark]
public object? BoxNullableNull() => (object?)_nullableNull;
[Benchmark]
public object? BoxNullableValue() => (object?)_nullableValue;
[Benchmark]
public string? FormatNullableInt() => Format(_nullableValue);
private static string? Format<T>(T value)
{
if (value is IFormattable formattable)
return formattable.ToString(null, null);
return null;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
BoxNullableNull
.NET 10.0
2.095 ns
1.00
–
–
BoxNullableNull
.NET 11.0
1.764 ns
0.84
–
–
BoxNullableValue
.NET 10.0
9.213 ns
1.00
24 B
1.00
BoxNullableValue
.NET 11.0
4.126 ns
0.45
24 B
1.00
FormatNullableInt
.NET 10.0
9.583 ns
1.00
24 B
1.00
FormatNullableInt
.NET 11.0
1.987 ns
0.21
–
0
dotnet/runtime#122167 expands nullable boxing inside the JIT, exposing the temporary box to escape analysis; previously, a runtime helper hid it. For a null
input, there’s no allocation on either version, because nothing gets boxed. And on both versions, BoxNullableValue
returns the boxed object, meaning the object escapes, so the 24-byte allocation remains. However, for FormatNullableInt
, the JIT in .NET 11 can now see that the temporary 24-byte box doesn’t escape and eliminates that heap allocation entirely.
Escape analysis improved further for enumerators, through a mechanism called Conditional Escape Analysis (CEA). Support for CEA was introduced in .NET 10, but .NET 11 extends the set of patterns that this analysis can safely recognize. The existing escape analysis asks whether a reference created by an allocation can flow somewhere the JIT can no longer track, such as an unknown call. If it can, the object must remain on the heap. That analysis is necessarily conservative and largely flow-insensitive: if an object might be passed to an interface call on any path, it doesn’t try to prove that the path containing that call is mutually exclusive with the path containing the allocation.
Unfortunately, that’s exactly what GDV produces when it optimizes a foreach
over an IEnumerable<T>
. As noted earlier, GDV turns an interface call into a type check with two branches: a fast branch for the likely collection type and a fallback branch containing the original interface call. Devirtualization and inlining along the fast branch will often reveal an enumerator allocation for the collection type, while later enumerator guards retain fallback calls such as IEnumerator<T>.MoveNext
. The existing analysis sees those calls and concludes that the locally allocated enumerator might escape. CEA instead records the relationship between the fast-path allocation and the enumerator local tested by the later guards. If every apparent escape occurs only behind a failed type check, the JIT can clone the region into a hot version where those checks are known to succeed. In that clone, the object can’t reach the fallback calls, so it can be stack-allocated and often promoted into separate scalar locals. The original region remains as the general slow path.
One case .NET 10 didn’t handle, though, was a GetEnumerator()
implementation that returns the result of another GetEnumerator()
call. A collection expression converted to IEnumerable<int>
, for example, uses a compiler-generated read-only-array wrapper with exactly this structure: the wrapper’s GetEnumerator()
delegates to the underlying array’s GetEnumerator
. With dotnet/runtime#122946, the JIT in .NET 11 handles this “chaining”:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly IEnumerable<int> s_readOnlyStatic = [1, 2, 3, 4, 5];
private readonly IEnumerable<int> _readOnlyInstance = [1, 2, 3, 4, 5];
[Benchmark]
public int ReadOnlyStatic()
{
int sum = 0;
foreach (int item in s_readOnlyStatic) sum += item;
return sum;
}
[Benchmark]
public int ReadOnlyInstance()
{
int sum = 0;
foreach (int item in _readOnlyInstance) sum += item;
return sum;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
ReadOnlyStatic
.NET 10.0
2.665 ns
1.00
–
–
ReadOnlyStatic
.NET 11.0
2.666 ns
1.00
–
–
ReadOnlyInstance
.NET 10.0
13.874 ns
1.00
32 B
1.00
ReadOnlyInstance
.NET 11.0
2.674 ns
0.19
–
0
ReadOnlyStatic
, whose static readonly
field the JIT can effectively treat as a constant, was already optimized in .NET 10. In .NET 11, the instance-field case also loses its 32-byte enumerator allocation and converges on the same throughput.
dotnet/runtime#121918 from @MichalPetryka fixes another way an address could unnecessarily make an object appear to escape. The IL constrained.
prefix lets one generic callvirt
sequence work for both value types and reference types: it can avoid boxing a value type, while for a reference type it dereferences the receiver and performs normal virtual dispatch. ObjectEqualityComparer<T>.Equals
, used in the following benchmark by EqualityComparer<T>.Default
, contains such a call to value.Equals(other)
. The receiver was represented as an indirect read through the address of a local. Merely taking that address marked the local as exposed, preventing the newly allocated Value
from being considered for stack allocation. The receiver is now represented as a direct value load instead, and the 24-byte heap allocation disappears.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Generic;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Value s_other = new(42);
[Benchmark]
public bool Equals() => EqualityComparer<Value>.Default.Equals(new Value(42), s_other);
private sealed class Value(int value)
{
private readonly int _value = value;
public override bool Equals(object? obj) => obj is Value other && _value == other._value;
public override int GetHashCode() => _value;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
Equals
.NET 10.0
3.874 ns
1.00
24 B
1.00
Equals
.NET 11.0
1.786 ns
0.46
–
0
While CEA can move a non-escaping object off the GC heap, sometimes the JIT can go further and prove an allocation need not exist at all. Generic code provides a common source of such opportunities through boxing. For example, the ArgumentNullException.ThrowIfNull
method accepts an object value
. That means when you have a method like this:
static void Test<T>(T value)
{
ArgumentNullException.ThrowIfNull(value);
...
}
when T
is constrained to a non-nullable struct, boxing is incurred, in order to pass value
as object
. ThrowIfNull
here is a nop if value
is non-null
(since the method is simply if (value is null) Throw();
), and previous releases successfully optimized away that boxing in optimized code. However, in Tier 0, that optimization wasn’t applied, and ThrowIfNull
would end up allocating. While this wouldn’t negatively impact steady-state throughput, it would lead to annoying noise in profiling, as well as additional overhead during startup, where such use wasn’t yet promoted out of Tier 0. In .NET 11, dotnet/runtime#129392 adds support for this in Tier 0 as well.
On the virtual-dispatch side, multiple PRs contribute to improving generic virtual methods (GVMs). dotnet/runtime#120866 from @hez2010 stops eagerly spilling ldvirtftn
call targets into a temporary, and lets generic virtual target resolution move ahead of argument setup when legal. dotnet/runtime#122023 from @hez2010 then enables the JIT to devirtualize non-shared GVMs, carrying the generic context needed to turn the indirect dispatch into a direct, and potentially inlineable, call. And dotnet/runtime#128702 from @hez2010 extends that support to shared GVMs and default interface implementations that require an instantiating stub. These optimizations can increase total code size when the newly direct calls are inlined, but that’s generally the desired trade: more of the actual work becomes visible to the optimizer. Consider the following benchmark:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Benchmark]
public int NonShared() => ((IProcessor)new Processor()).SizeOf(42);
[Benchmark]
public int Shared() => ((IProcessor)new Processor()).SizeOf("hello");
private interface IProcessor
{
int SizeOf<T>(T value);
}
private sealed class Processor : IProcessor
{
public int SizeOf<T>(T value) => Unsafe.SizeOf<T>();
}
}
Casting a freshly allocated Processor
to IProcessor
incurs an interface generic virtual call in the IL, but the JIT is now able to see the receiver’s exact type, even in the shared string
case, such that .NET 11 devirtualizes and inlines both calls. That in turn exposes Unsafe.SizeOf<T>()
as a constant and proves that the short-lived Processor
doesn’t need to be allocated at all.
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
NonShared
.NET 10.0
6.678 ns
1.00
24 B
1.00
NonShared
.NET 11.0
1.764 ns
0.26
–
0
Shared
.NET 10.0
7.166 ns
1.00
24 B
1.00
Shared
.NET 11.0
1.764 ns
0.25
–
0
Building on that, dotnet/runtime#123183 from @hez2010 enables ReadyToRun compilation to resolve and devirtualize more non-shared generic virtual calls that would otherwise remain indirect, and dotnet/runtime#130202 from @hez2010 extends that support to NativeAOT. NativeAOT represents some generic virtual targets as “fat pointers” (pointers that are more than just an address, typically an address and associated metadata, and that in this case carry both a code address and generic context); by deferring that transformation until after exact-type devirtualization has had a chance to run, the JIT can turn an interface call site with a single known target to a non-shared GVM into a direct call that may then be inlined.
Type information also needs to survive the transformations the JIT performs internally. If the JIT spills a reference expression into a temporary while restructuring a tree, losing the expression’s exact class information can turn a call that was devirtualizable back into an opaque virtual call. That’s what happens here in .NET 10: Value
gets boxed and SetValue
is invoked through IValue
. dotnet/runtime#128485 from @hez2010 preserves the class handle and exactness on the temporary. With that information still available, .NET 11 devirtualizes and inlines the call, eliminating the box and its 24-byte allocation.
Separately, dotnet/runtime#127433 relaxes the inliner’s budget heuristics for callees on [Intrinsic]
types like Span
and Vector
. These types intentionally expose many small, composable methods that serve as gateways to JIT-recognized operations. If a wrapper remains as a call, the caller pays the call overhead and optimizations around it see an opaque boundary. If it inlines, the importer can replace its body with an intrinsic node and optimize that node together with the surrounding indexing, bounds checks, and vector operations. Giving such wrappers more favorable budgeting therefore keeps more of them inlineable and exposes more of the actual operation to the rest of the optimizer.
One of the core abstraction-enabling mechanisms in .NET is delegates: they let us pass around objects representing functions to be invoked, carrying with them associated required state. Deabstraction enables avoiding paying for the overheads associated with delegates in some cases. For the rest, we still want those delegates to be as cheap as possible. dotnet/runtime#99200 from
@MichalPetryka simplifies CoreCLR’s delegate
representation, removing one pointer-sized field from every delegate object.
That saves 8 bytes per delegate in a 64-bit CoreCLR process.
dotnet/runtime#129304 from
@MichalPetryka improves Native AOT’s
delegate layout separately by reordering its existing four fields so related
values are adjacent. The updated layouts also give equality and hash-code
operations more direct access to the method identity they need.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Target s_target = new();
private static readonly Func<int> s_first = s_target.GetValue;
private static readonly Func<int> s_second = s_target.GetValue;
[Benchmark]
public Func<int> ClosedInstance() => s_target.GetValue;
[Benchmark]
public bool DelegateEquals() => s_first.Equals(s_second);
[Benchmark]
public int DelegateGetHashCode() => s_first.GetHashCode();
private sealed class Target
{
public int GetValue() => 42;
}
}
Method
Runtime
Mean
Ratio
Allocated
Alloc Ratio
ClosedInstance
.NET 10.0
7.395 ns
1.00
64 B
1.00
ClosedInstance
.NET 11.0
6.844 ns
0.93
56 B
0.88
DelegateEquals
.NET 10.0
3.254 ns
1.00
–
–
DelegateEquals
.NET 11.0
2.215 ns
0.68
–
–
DelegateGetHashCode
.NET 10.0
5.623 ns
1.00
–
–
DelegateGetHashCode
.NET 11.0
3.741 ns
0.67
–
–
dotnet/runtime#129410 from
@MichalPetryka follows up on the CoreCLR
layout by placing the target object and method pointer next to each other.
Those are commonly consumed together during invocation, and the adjacency
enables paired loads on architectures such as Arm64.
Runtime Async
For more than a decade, async
and await
have let us write asynchronous code that looks remarkably similar to synchronous code: we can put a try
/catch
around an await
, use local variables on either side of it, return a value and generally reason about the method in source order. When execution reaches an await
for something that isn’t yet complete, however, the method can’t simply leave its current stack frame in place and wait for the operation to finish. The thread needs to be freed up to do other work, while the work after the await
, including whatever local state it will need later, must survive somewhere. In C#, the compiler has traditionally been responsible for transforming the method into a representation that enables that continuation.
I went into the history and mechanics of that transformation in How async/await really works. The very short version is that the compiler traditionally replaces an async
method with a small entry method and a generated state machine whose MoveNext
method contains the transformed user code. Parameters, locals that need to survive an incomplete await, spilled expression values, awaiters, the current state number, and a method builder all become fields on a heap-allocated object. The generated MoveNext
method runs the user’s code until an awaiter reports that it isn’t yet complete. It stores enough information to know where and with what values to resume, registers MoveNext
as the continuation, and returns. When the operation completes, MoveNext
is invoked again, jumps to the right location based on the saved state number (think goto
and a label), retrieves the result from a value-producing awaiter, and continues. If every awaiter is already complete, MoveNext
can run all the way through synchronously. When the method completes or throws, the builder publishes the result, cancellation, or exception through the returned Task
, Task<T>
, ValueTask
, or ValueTask<T>
(or, in the rare case, a custom task-like type).
For example, consider this tiny method:
static async Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
return bytesRead;
}
While the code that gets generated for this changes over time and differs between debug and release builds, the lowering by the C# compiler has looked something like this:
[AsyncStateMachine(typeof(<ReadLengthAsync>d__0))]
static Task<int> ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
<ReadLengthAsync>d__0 stateMachine = default;
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.state = -1;
stateMachine.stream = stream;
stateMachine.cancellationToken = cancellationToken;
stateMachine.builder.Start(ref stateMachine);
return stateMachine.builder.Task;
}
struct <ReadLengthAsync>d__0 : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<int> builder;
public Stream stream;
public CancellationToken cancellationToken;
private TaskAwaiter<int> awaiter;
public void MoveNext()
{
int result;
try
{
TaskAwaiter<int> localAwaiter;
if (state != 0)
{
byte[] buffer = new byte[4096];
localAwaiter = stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).GetAwaiter();
if (!localAwaiter.IsCompleted)
{
state = 0;
awaiter = localAwaiter;
builder.AwaitUnsafeOnCompleted(ref localAwaiter, ref this);
return;
}
}
else
{
localAwaiter = awaiter;
awaiter = default;
state = -1;
}
result = localAwaiter.GetResult();
}
catch (Exception e)
{
state = -2;
builder.SetException(e);
return;
}
state = -2;
builder.SetResult(result);
}
}
That’s quite a lot of generated code for three lines of C#. The compiler has to make decisions before the program runs about the state-machine layout, which values might need to survive, how many awaiter fields are required, and how all the suspension points fit into one MoveNext
dispatch. The runtime and JIT have optimized the resulting pattern heavily over the years, including combining the task, state machine, continuation, and ExecutionContext
into a single allocation, but by the time the JIT sees the IL, the transformation has already happened, leaving it with a very complicated system to try to optimize.
.NET 11 introduces a new way to split that responsibility, a reimplementation of the async
/await
infrastructure referred to as “runtime async”. Rather than the C# compiler being responsible for the transformation, the JIT is. The C# compiler emits a much smaller suspension-aware IL contract for each eligible async
method and marks the method as async
in metadata. The runtime and JIT then do the work that depends on runtime knowledge: creating the externally visible Task
or ValueTask
, recognizing direct async calls, deciding which values are actually alive at each suspension point, laying out continuation objects, and generating the control flow that suspends and resumes the method. Effectively, the transformation moves from C# to the runtime, where more information is available to optimize it.
The programming model hasn’t changed. This is still C# async
/await
; await
still obeys the awaiter pattern, exceptions and cancellation still surface through the returned task-like object, ConfigureAwait
still has its usual meaning, synchronous completion is still synchronous completion, and on and on. An explicit goal for the feature has been 100% behavioral compatibility: whether an async
method is lowered by the language compiler or by the runtime is an implementation detail, and any observable semantic difference is a bug.
In .NET 11, application code opts in with a compiler feature switch:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
Note that there’s no new C# syntax involved, so LangVersion=preview
isn’t required, nor is EnablePreviewFeatures
. While this is opt-in at the application layer, most of the in-box shared framework is already built this way for .NET 11. The async
/await
performance goal for .NET 11 is parity with .NET 10, and in general runtime async is already as good as or better than the older implementation in many important paths. It isn’t yet fully optimized, though, and there are known cases where it still produces less efficient code. I’d encourage you to experiment in .NET 11 with opting-in your applications and services; just make sure to measure. My hope is that it’ll be on by default starting in .NET 12.
Moving the transformation from the C# compiler to the runtime has the added benefit of reducing binary size. As noted, the traditional lowering emits an entry method, a generated state-machine type, fields for captured state, and a MoveNext
body, for every async method. Runtime async leaves a much smaller method body for the runtime to transform. The following tiny app contains ten Task<int>
-returning async methods, each awaiting the next, and compiles the same source once with compiler lowering and once with runtime async:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<AssemblyName>SizeProbe</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Features Condition="'$(RuntimeAsync)' == 'true'">$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
// dotnet build -c Release -p:RuntimeAsync=false -o classic --no-incremental; dotnet build -c Release -p:RuntimeAsync=true -o runtime --no-incremental; Get-Item .\classic\SizeProbe.dll, .\runtime\SizeProbe.dll | Select-Object Directory, Length
Console.WriteLine(await Benchmarks.Layer0());
public class Benchmarks
{
public static async Task<int> Layer0() => await Layer1();
private static async Task<int> Layer1() => await Layer2();
private static async Task<int> Layer2() => await Layer3();
private static async Task<int> Layer3() => await Layer4();
private static async Task<int> Layer4() => await Layer5();
private static async Task<int> Layer5() => await Layer6();
private static async Task<int> Layer6() => await Layer7();
private static async Task<int> Layer7() => await Layer8();
private static async Task<int> Layer8() => await Layer9();
private static async Task<int> Layer9()
{
await Task.Yield();
return 42;
}
}
Lowering
SizeProbe.dll
Ratio
Compiler
10,752 bytes
1.00
Runtime async
5,632 bytes
0.52
For a method such as:
static async Task<int> CallerAsync() => await CalleeAsync();
with runtime async enabled, the C# compiler generates IL like the following:
; MSIL
.method private hidebysig static
class System.Threading.Tasks.Task`1<int32> CallerAsync() cil managed async
{
call class System.Threading.Tasks.Task`1<int32> CalleeAsync()
call int32 System.Runtime.CompilerServices.AsyncHelpers::Await<int32>(
class System.Threading.Tasks.Task`1<int32>)
ret
}
There is no generated <CallerAsync>d__0
type, no IAsyncStateMachine
, no MoveNext
, no AsyncTaskMethodBuilder<int>
, and no AsyncStateMachineAttribute
. Previously, async
on a C# method evaporated at compile time. Now, the method has a new MethodImpl
async
bit, represented in IL assembly syntax by that async
modifier, and the body calls helpers in System.Runtime.CompilerServices.AsyncHelpers
.
At first glance the ret
looks impossible because the declared signature returns Task<int>
while the value on the IL evaluation stack is an int
. This clearly isn’t a normal calling convention. The VM can give a Task
-returning method two related identities, or MethodDescs, where one has the normal signature the rest of managed code sees, Task<int> CallerAsync()
. The other is the AsyncCall variant, which effectively returns int
and has an implicit channel for a continuation. Both refer to the same logical method and metadata token, but they have different calling conventions and different jobs. If regular managed code invokes CallerAsync
, the VM-generated outer thunk preserves the public contract and returns a Task<int>
. If another runtime async method directly awaits it, the JIT can instead call the AsyncCall variant and receive the result directly when the call completes synchronously, or a continuation when it suspends. In other words, it can hand back the T
directly and avoid allocating a Task<T>
.
That pairing works in both directions. For a method compiled with runtime async, the AsyncCall variant owns the generated (newly compact) IL while the public Task
-returning entry point is an adapter thunk; for a traditionally compiled method, the public method owns its usual IL while the VM can create an AsyncCall adapter around it. That means runtime async code remains able to await existing libraries and code compiled by older compilers, a critical capability for our goal of 100% compat. The largest wins naturally appear as more of an async call chain is compiled with runtime async.
This is where the JIT gets an opportunity that simply didn’t exist when every boundary was already expressed as a task and a generated state machine. Suppose A
awaits B
, which awaits C
:
static async Task<int> A(bool yield) => await B(yield);
static async Task<int> B(bool yield) => await C(yield);
static async Task<int> C(bool yield)
{
if (yield)
await Task.Yield();
return 42;
}
Traditionally, each method has its own compiler-generated state machine and its own task-like result. C
suspends and eventually completes its task, which wakes B
‘s state machine; B
then completes its task, which wakes A
‘s state machine; and A
completes the root task observed by the caller. There has been an enormous amount of work done over the years to reduce the costs of those objects and transitions.
With runtime async, the importer recognizes the adjacent pattern of “call a Task-returning method, then await that task.” In the simple case it can call the callee’s AsyncCall variant instead. When yield
is false and C
completes synchronously, the int
flows back through B
and A
as a plain value, and only the outermost boundary needs to turn it into the Task<int>
promised to the original caller. When yield
is true and C
suspends, the runtime links continuation state for the chain and eventually resumes it without requiring an intermediate Task<int>
at every directly fused edge. The Task
contract hasn’t vanished, it just moved to the place where a Task
is actually needed.
Runtime async doesn’t make every asynchronous operation allocation-free, though. Rather, it gives the JIT enough information to avoid materializing some task objects that existed only to carry a result from one async method directly into the next. If a consumer stores the task in a collection, manually hooks up a continuation, or otherwise observes the task as an object, that object is still needed. The optimization is about not paying for boundaries that aren’t observably boundaries.
The impact is already visible with just two layers:
// dotnet run -c Release -f net11.0 --filter "*"
// The project also needs the `runtime-async=on` feature switch set.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false)]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly Task<int> s_completed = Task.FromResult(42);
[Benchmark(Baseline = true), BenchmarkCategory("Completed")]
public Task<int> ClassicCompleted() => ClassicCompletedOuter();
[Benchmark, BenchmarkCategory("Completed")]
public Task<int> RuntimeCompleted() => RuntimeCompletedOuter();
[Benchmark(Baseline = true), BenchmarkCategory("Yielding")]
public Task<int> ClassicYielding() => ClassicYieldingOuter();
[Benchmark, BenchmarkCategory("Yielding")]
public Task<int> RuntimeYielding() => RuntimeYieldingOuter();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedOuter() => await ClassicCompletedInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicCompletedInner() => await s_completed;
private static async Task<int> RuntimeCompletedOuter() => await RuntimeCompletedInner();
private static async Task<int> RuntimeCompletedInner() => await s_completed;
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingOuter() => await ClassicYieldingInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task<int> ClassicYieldingInner()
{
await Task.Yield();
return 42;
}
private static async Task<int> RuntimeYieldingOuter() => await RuntimeYieldingInner();
private static async Task<int> RuntimeYieldingInner()
{
await Task.Yield();
return 42;
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}
Method
Mean
Ratio
Allocated
Alloc Ratio
ClassicCompleted
21.221 ns
1.00
144 B
1.00
RuntimeCompleted
6.151 ns
0.29
0 B
0.00
ClassicYielding
254.139 ns
1.00
248 B
1.00
RuntimeYielding
116.927 ns
0.46
168 B
0.68
The synchronously completing chain is more than 3x faster and avoids both
intermediate task allocations. Even after a real suspension, the same
two-layer chain takes less than half the time and allocates 80 fewer bytes.
Exception handling amplifies the difference. Again consider an async method A
calling an async method B
calling an async method C
. The transformation generated by the C# compiler of each method results in a try
/catch
block around the whole body of the MoveNext
method so that any unhandled exception can be stored into the returned Task
. Let’s say code in C
throws an unhandled exception. That’s then caught by this manufactured catch
block and stored into the Task
returned to B
. The awaiter in B
then retrieves that exception from the Task
object and throws it. It’s then caught by B
‘s generated catch and stored into its Task
. And so on. An exception crossing ten such async helpers can therefore be thrown, caught, and stored ten times even though none of the source methods has an explicit handler. That is super expensive. But runtime async doesn’t need to re-enter a pass-through frame with no handler. On the synchronous path the exception unwinds through the fused calls normally, and after a real suspension, one dispatch-loop catch walks past continuation records that have no handler and faults the observable root task once.
The following benchmark measures both a fully synchronous throw and an exception after one real Task.Yield
suspension. It uses a compiler-recognized per-method escape hatch (RuntimeAsyncMethodGeneration
) so that the classic and runtime async methods run in the same process on the same .NET 11 runtime and differ only in how the compiler lowers them. (Note that this attribute is experimental and isn’t a public API exposed from the core libraries; as with other attributes known to the C# compiler, it recognizes them by name and signature.)
// dotnet run -c Release -f net11.0 --filter "*"
// The project also needs the `runtime-async=on` feature switch set.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Params(1, 10, 30)]
public int Depth;
[Params(false, true)]
public bool Yield;
[Benchmark(Baseline = true)]
public int Classic() => Invoke(ClassicThrowAsync(Depth));
[Benchmark]
public int Runtime() => Invoke(RuntimeThrowAsync(Depth));
private static int Invoke(Task<int> task)
{
try
{
return task.GetAwaiter().GetResult();
}
catch (InvalidOperationException)
{
return -1;
}
}
[RuntimeAsyncMethodGeneration(false)]
private async Task<int> ClassicThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await ClassicThrowAsync(depth - 1);
}
private async Task<int> RuntimeThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await RuntimeThrowAsync(depth - 1);
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method)]
internal sealed class RuntimeAsyncMethodGenerationAttribute(bool runtimeAsync) : Attribute
{
public bool RuntimeAsync => runtimeAsync;
}
}
Depth
Yield
Method
Mean
Ratio
Allocated
Alloc Ratio
1
False
Classic
4.727 μs
1.00
1.6 KB
1.00
1
False
Runtime
3.558 μs
0.75
1.16 KB
0.72
1
True
Classic
6.308 μs
1.00
1.68 KB
1.00
1
True
Runtime
8.211 μs
1.30
1.42 KB
0.85
10
False
Classic
19.469 μs
1.00
15.13 KB
1.00
10
False
Runtime
5.885 μs
0.30
2.13 KB
0.14
10
True
Classic
24.923 μs
1.00
15.63 KB
1.00
10
True
Runtime
6.122 μs
0.25
2.88 KB
0.18
30
False
Classic
51.302 μs
1.00
84.2 KB
1.00
30
False
Runtime
10.721 μs
0.21
5.71 KB
0.07
30
True
Classic
65.974 μs
1.00
85.53 KB
1.00
30
True
Runtime
11.254 μs
0.17
7.72 KB
0.09
Runtime async supports Task
, Task<T>
, ValueTask
, and ValueTask<T>
as method return types, but as of today it doesn’t support async void
, async iterators, or arbitrary custom task-like return types with custom builders; those continue to use the traditional compiler transformation. For ValueTask<T>
, the existing reasons to use the type still apply. A ValueTask<T>
can carry a result directly, wrap a Task<T>
, or refer to an IValueTaskSource<T>
. That’s made it useful for APIs where synchronous completion is common enough that avoiding a Task
allocation outweighs the larger return value and the more restrictive consumption rules, or where asynchronous completion can have its costs amortized via a reusable backing object. Runtime async then addresses some of the scenarios that would have led developers to use ValueTask<T>
. Does that mean everyone should stop using ValueTask<T>
? No. Choosing Task
versus ValueTask
remains an API design decision based on completion patterns, allocation sensitivity, call frequency, and how consumers need to use the result. Write the return type that makes sense for the API, then let the compiler, VM, and JIT optimize it as best they can.
Workloads with many layers of small async methods can benefit the most from runtime
async, because those layers are exactly where intermediate tasks and state
machines often accumulate. Shared framework code, for example, is full of this
pattern: a public method validates arguments and awaits a private helper, which
awaits a transport helper, which awaits an operating-system operation.
Application services similarly compose authentication, retry, logging,
serialization, and I/O helpers. Runtime async can make the source-level
decomposition cheaper without asking the developer to flatten the code into
one giant method in order to avoid “implementation detail” costs.
The work required to reach this point has been extensive. A GitHub search of the runtime async tracking label on September 14, 2026 returned 235 pull requests, far too many for me to enumerate one by one. So I won’t try; you can peruse that label in your spare time. The work is also not only about direct performance improvements but also about
improvements to diagnostics and performance tooling that help you to make better
use of async in your own code. When an async method
suspends, its physical thread stack unwinds. That method’s continuation might later run
on a different thread whose physical stack begins in the thread pool, with the
methods that led to the original await
nowhere to be found. A sampling
CPU profiler can see where the processor is spending time, but without additional
information, it can’t reliably connect those traces back through the logical async
call chain, making it hard to answer questions about what async call paths were actually costing.
Profiling tools like the async profiler in Visual Studio have traditionally reconstructed those chains from
events emitted by Task
‘s infrastructure, but async-heavy applications can generate enormous volumes of
those very chatty events. The resulting overhead easily perturbs the workload being measured, making
it all but unusable in production. dotnet/runtime#127238 added a
new lightweight async-profiler event stream for .NET 11 and runtime async. Rather than sending every small
transition through the eventing system as its own full event, the runtime
writes compact records into per-thread buffers, delta-encoding timestamps and
instruction pointers and flushing the data in batches. It also puts a small
identifiable wrapper frame into the physical stack when invoking a
continuation. A profiler can use that frame as an anchor, joining ordinary CPU
samples to the logical async call stack represented by the event stream. In some measurements,
this new approach added less than 1% overhead and shrank the traced data by an order of magnitude.
dotnet/runtime#129043 and a few follow-up PRs extended
the same approach to the compiler-generated state machines used by existing
async code. Thus this
isn’t useful only to applications that opt into runtime async; tooling gets one
consistent representation across both implementations.
What should you as a developer do differently with runtime async in the picture? Mostly nothing. Keep writing asynchronous code the way you want it to read, and break a large operation into helpers when that makes the code clearer. Use Task
by default and choose ValueTask
where its API and usage tradeoffs genuinely fit. And don’t contort source code to remove a clean await
just because today’s implementation might allocate an intermediate Task
. The lowering strategy should “just work” as an implementation detail, preserve behavior, and make existing source get better as the runtime improves.
Bounds Checks
C# is a memory-safe language. Accesses to arrays, strings, and spans are guaranteed by the runtime to be in-bounds; if you try to access someArray[i]
, someString[i]
, or someSpan[i]
with an index less than 0 or greater than or equal to the length of the array/string/span, you’ll get an exception, not silently corrupted memory or a process crash. The runtime guarantees that all permitted accesses are within bounds, and that means it needs to be able to prove the access is in bounds. The main method the JIT has for achieving that is by injecting code that performs a bounds check, as if instead of:
int[] array = ...;
int value = array[i];
you’d written:
int[] array = ...;
if ((uint)i >= array.Length) throw new IndexOutOfRangeException();
int value = array[i];
At the assembly level, a bounds check looks something like:
; x64
cmp ecx, dword ptr [rax+8] ; compare index with array length
jae THROW ; unsigned index >= length
mov edx, dword ptr [rax+rcx*4+16] ; load the element
The JIT could just inject such code on every access and call it a day, but such code adds overhead, so the JIT works to elide those checks and that overhead wherever it can prove the index is valid. Proving an index is valid means the JIT needs to be able to see from other evidence that it couldn’t possibly be out of bounds.
The quintessential example of that is a for
loop over the full contents of an array or span:
for (int i = 0; i < array.Length; i++)
{
Use(array[i]);
}
The JIT recognizes from this idiom that, within the loop body, i
is guaranteed to be in the range [0, array.Length)
, and avoids emitting the bounds check for the array[i]
access. The JIT has long handled this particular case. Other cases, not so much. Bounds-check elimination has improved in virtually every .NET release; more recent releases added range propagation for derived expressions (.NET 7
and .NET 8
saw significant improvements here), SSA-based reasoning (.NET 9
), and better handling of Span<T>
, whose length sits in a field rather than an object header, complicating tracking. Each year, the developers contributing to the JIT find new patterns that were being missed, that show up in the wild, and that are fixable. .NET 11 improves several such patterns.
Range analysis in the JIT tracks intervals for each variable, an upper bound and a lower bound. For example, taking the true branch of x < 5
gives the range for x
in that branch an upper bound of 4 while taking the true branch of x > 2
makes the lower bound 3. What about x != 5
? On the true edge, we know x
isn’t 5, and if the current range for x
is [5, 10]
, then we know the range must actually be [6, 10]
… the lower bound can be tightened because the only value at the lower end is excluded. Similarly, if the range is [0, 5]
, an x != 5
assertion tells us the range is actually the narrower [0, 4]
. Or, at least, that’s what you’d hope it would do. The JIT had this relevant comment:
// We have a != assertion, but it doesn't tell us much about the interval. So just skip it.
continue;
In .NET 11, dotnet/runtime#121273 replaces that logic with productive reasoning. It checks whether the excluded constant is at either edge of the currently tracked range, adding in the new insights if so. C# list patterns, introduced in C# 11, generate just such comparison sequences. For example, the pattern name is [] or [':'] or [':', not ':', ..]
lowers to something like this:
if (name != null)
{
int num = name.Length;
if (num == 0) return true;
if (num == 1)
{
if (name[0] == ':') return true;
}
else if (name[0] == ':' && name[1] != ':')
{
return true;
}
return false;
}
Range analysis then proceeds with something like this:
We know that Array.Length
is never negative, so it has a range of [0, Array.MaxLength]
.
On the false edge of num == 0
, we know that num != 0
, so the range is narrowed now to [1, Array.MaxLength]
.
Similarly, on the false edge of num == 1
, we know that num != 1
, so the range is narrowed now to [2, Array.MaxLength]
.
We then access name[0]
and name[1]
, both of which are guaranteed in bounds based on the lower bound of 2 that was established.
Without the != constant
tightening, that narrowing wouldn’t happen, and the bounds checks in step 4 couldn’t be elided. Thankfully, they now can be in .NET 11. Consider this example:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private string[] _inputs = ["", ":", ":x", "abc", ":ab", "x", "ab:cd"];
[Benchmark]
public int ClassifyAll()
{
int total = 0;
foreach (string s in _inputs) total += Classify(s);
return total;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Classify(ReadOnlySpan<char> name) =>
name switch
{
[] => 0,
[':'] => 1,
[':', not ':', ..] => 10 + name[0] + name[1],
_ => 3
};
}
In .NET 10, we can see the call to CORINFO_HELP_RNGCHKFAIL
at the bottom of the method. That’s the tell-tale sign there was at least one bounds check in the method. With .NET 11, that sign is removed.
; Arm64
--- .NET 10
+++ .NET 11
@@ -10,17 +10,15 @@
beq G_M000_IG08
G_M000_IG04:
- ldrh w2, [x0]
- cmp w2, #58
+ ldrh w1, [x0]
+ cmp w1, #58
bne G_M000_IG06
G_M000_IG05:
- cmp w1, #1
- bls G_M000_IG11
ldrh w0, [x0, #0x02]
cmp w0, #58
beq G_M000_IG06
- add w0, w2, w0
+ add w0, w1, w0
add w0, w0, #10
b G_M000_IG07
@@ -44,8 +42,4 @@
mov w0, wzr
b G_M000_IG07
-G_M000_IG11:
- bl CORINFO_HELP_RNGCHKFAIL
- brk #0
-
-; Total bytes of code 112
+; Total bytes of code 96
“Assertion” machinery in the JIT propagates learned facts (like the aforementioned range information) between “basic blocks” (a sequence of instructions with one entry point, one exit point, and no branches into or out of the middle of it), so information established in block A flows to block B if A “dominates” B (meaning the only way to get to B is through A). But what about facts established earlier within the same block? That’s the gap that dotnet/runtime#121527 addresses. Consider this code:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int[] _arr = new int[512];
[Benchmark]
public int RunMany()
{
int touched = 0;
for (int i = 0; i < _arr.Length - 2; i++)
{
Test(_arr, i);
touched++;
}
return touched;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Test(int[] arr, int i)
{
arr[i] = 0; // 1: establishes 'i >= 0 && i < arr.Length'
i++; // 2: same block
if (i < arr.Length) arr[i] = 0; // 3: proven safe from 1's assertion
}
}
Statements 1, 2, and 3 are all in the same basic block, up to the conditional; after statement 1 executes, if we reach statement 2, the bounds check on statement 1 passed, we know i >= 0
and i < arr.Length
, and after statement 2, i
becomes i + 1
. After the if
guard i < arr.Length
we know the incremented i
is still within bounds. But when the range check pass in the .NET 10 JIT examined statement 3’s bounds check, it saw the assertions propagated from predecessor blocks. Since the assertion from statement 1 is generated within the current block, the range check couldn’t see it. The PR fixed it to walk the current block’s tree in execution order, accumulating assertions as it went. When we reach statement 3’s bounds check, we’ve already walked past statement 1 and picked up its i >= 0 && i < arr.Length
assertion.
; Arm64
--- .NET 10
+++ .NET 11
@@ -13,8 +13,6 @@
ble G_M000_IG04
G_M000_IG03:
- cmp w1, w2
- bhs G_M000_IG05
str wzr, [x0, w1, UXTW #2]
G_M000_IG04:
@@ -25,4 +23,4 @@
bl CORINFO_HELP_RNGCHKFAIL
brk #0
-; Total bytes of code 68
+; Total bytes of code 60
There are almost an infinite number of things the JIT could look for and special-case. But every special case requires code, maintenance, and, most importantly, compilation time. A “just-in-time” compiler typically runs while the application is running, so the JIT itself must be optimized and spend its limited budget only where there’s a likely payoff. That pushes the developers
This article was shortened because the original exceeded the forum post limit.

