Performance Improvements in .NET 11
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 benchmarksReplace the contents of the generated benchmarks.csproj with the following, which multi-targets both versions so that BenchmarkDotNet can build for each:
Exe
net11.0;net10.0
preview
enable
enable
true
true
10.0.12
11.0.0-rc.1.26425.128
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.0which 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 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(); // inlineableor 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 typeor 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 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 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. 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.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, 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 s_readOnlyStatic = [1, 2, 3, 4, 5];
private readonly IEnumerable _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.Equals, used in the following benchmark by EqualityComparer.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.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 |
ArgumentNullException.ThrowIfNull method accepts an object value. That means when you have a method like this:
static void Test(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 value);
}
private sealed class Processor : IProcessor
{
public int SizeOf(T value) => Unsafe.SizeOf();
}
}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() 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 |
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 s_first = s_target.GetValue;
private static readonly Func s_second = s_target.GetValue;
[Benchmark]
public Func 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, ValueTask, or ValueTask (or, in the rare case, a custom task-like type). For example, consider this tiny method:
static async Task 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(d__0))]
static Task ReadLengthAsync(Stream stream, CancellationToken cancellationToken)
{
d__0 stateMachine = default;
stateMachine.builder = AsyncTaskMethodBuilder.Create();
stateMachine.state = -1;
stateMachine.stream = stream;
stateMachine.cancellationToken = cancellationToken;
stateMachine.builder.Start(ref stateMachine);
return stateMachine.builder.Task;
}
struct d__0 : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder builder;
public Stream stream;
public CancellationToken cancellationToken;
private TaskAwaiter awaiter;
public void MoveNext()
{
int result;
try
{
TaskAwaiter 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:
net11.0
$(Features);runtime-async=on
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-returning async methods, each awaiting the next, and compiles the same source once with compiler lowering and once with runtime async:
Exe
net11.0
SizeProbe
enable
enable
$(Features);runtime-async=on
// 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 Layer0() => await Layer1();
private static async Task Layer1() => await Layer2();
private static async Task Layer2() => await Layer3();
private static async Task Layer3() => await Layer4();
private static async Task Layer4() => await Layer5();
private static async Task Layer5() => await Layer6();
private static async Task Layer6() => await Layer7();
private static async Task Layer7() => await Layer8();
private static async Task Layer8() => await Layer9();
private static async Task Layer9()
{
await Task.Yield();
return 42;
}
}| Lowering | SizeProbe.dll | Ratio |
|---|---|---|
| Compiler | 10,752 bytes | 1.00 |
| Runtime async | 5,632 bytes | 0.52 |
static async Task 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 CallerAsync() cil managed async
{
call class System.Threading.Tasks.Task`1 CalleeAsync()
call int32 System.Runtime.CompilerServices.AsyncHelpers::Await(
class System.Threading.Tasks.Task`1)
ret
}There is no generated d__0 type, no IAsyncStateMachine, no MoveNext, no AsyncTaskMethodBuilder, 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 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 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. 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. 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 A(bool yield) => await B(yield);
static async Task B(bool yield) => await C(yield);
static async Task 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 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 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 s_completed = Task.FromResult(42);
[Benchmark(Baseline = true), BenchmarkCategory("Completed")]
public Task ClassicCompleted() => ClassicCompletedOuter();
[Benchmark, BenchmarkCategory("Completed")]
public Task RuntimeCompleted() => RuntimeCompletedOuter();
[Benchmark(Baseline = true), BenchmarkCategory("Yielding")]
public Task ClassicYielding() => ClassicYieldingOuter();
[Benchmark, BenchmarkCategory("Yielding")]
public Task RuntimeYielding() => RuntimeYieldingOuter();
[RuntimeAsyncMethodGeneration(false)]
private static async Task ClassicCompletedOuter() => await ClassicCompletedInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task ClassicCompletedInner() => await s_completed;
private static async Task RuntimeCompletedOuter() => await RuntimeCompletedInner();
private static async Task RuntimeCompletedInner() => await s_completed;
[RuntimeAsyncMethodGeneration(false)]
private static async Task ClassicYieldingOuter() => await ClassicYieldingInner();
[RuntimeAsyncMethodGeneration(false)]
private static async Task ClassicYieldingInner()
{
await Task.Yield();
return 42;
}
private static async Task RuntimeYieldingOuter() => await RuntimeYieldingInner();
private static async Task 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 |
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 task)
{
try
{
return task.GetAwaiter().GetResult();
}
catch (InvalidOperationException)
{
return -1;
}
}
[RuntimeAsyncMethodGeneration(false)]
private async Task ClassicThrowAsync(int depth)
{
if (depth == 0)
{
if (Yield) await Task.Yield();
throw new InvalidOperationException("uh oh");
}
return await ClassicThrowAsync(depth - 1);
}
private async Task 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 |
Task, Task, ValueTask, and ValueTask 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, the existing reasons to use the type still apply. A ValueTask can carry a result directly, wrap a Task, or refer to an IValueTaskSource. 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. Does that mean everyone should stop using ValueTask? 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 elementThe 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, 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.Lengthis never negative, so it has a range of[0, Array.MaxLength]. - On the false edge of
num == 0, we know thatnum != 0, so the range is narrowed now to[1, Array.MaxLength]. - Similarly, on the false edge of
num == 1, we know thatnum != 1, so the range is narrowed now to[2, Array.MaxLength]. - We then access
name[0]andname[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 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 60There 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 building it toward patterns that occur in real workloads. One such pattern, often seen in libraries like format decoders, builds a table index with bitwise operations on a byte, for example ((b & 0x03) << 4) | ((b & 0xf0) >> 4). Each masked piece has a tiny upper bound, so the OR of those pieces is always in [0..63], safely in range for e.g. a Base64 alphabet table. Until dotnet/runtime#122263, the JIT often failed to prove that combined bound and left a bounds check on the index. Existing range-check code understood the upper bounds produced by bitwise AND and shifts, but not OR; the change lets the JIT combine the known bounds of both OR operands and remove the remaining array check.
// 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 readonly byte[] _input = new byte[4096];
[GlobalSetup]
public void Setup() => new Random(42).NextBytes(_input);
[Benchmark]
public int Base64LikeIndex() => Sum(_input);
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Sum(ReadOnlySpan input)
{
int sum = 0;
foreach (byte b in input)
{
int index = ((b & 0x03) << 4) | ((b & 0xF0) >> 4);
sum += "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="u8[index];
}
return sum;
}
}The .NET 10 assembly checks the computed index against the 65-byte lookup table on every iteration. In .NET 11, range analysis proves the index is at most 63, so both the comparison and the branch to the range-check failure helper disappear:
; x64
M01_L00:
movzx r9d, byte ptr [rdx+r8]
mov r11d, r9d
and r11d, 3
shl r11d, 4
and r9d, 0F0
sar r9d, 4
or r9d, r11d
- cmp r9d, 41
- jae short M01_L02
movzx r9d, byte ptr [r10+r9]
add eax, r9d
inc r8d
cmp r8d, ecx
jl short M01_L00
-M01_L02:
- call CORINFO_HELP_RNGCHKFAIL
- int 3
-
-; Total bytes of code 95
+; Total bytes of code 79As another example, dotnet/runtime#125056 improves the handling of guards like (uint)i < span.Length that are pervasive in performance-sensitive code. Consider this 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int[] _data = Enumerable.Range(0, 512).ToArray();
[Benchmark]
public int RunMany()
{
int sum = 0;
for (int i = 0; i < _data.Length; i++)
sum += Test(_data, i);
return sum;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Test(Span span, int i)
{
if ((uint)i < (uint)span.Length)
{
if (i != 0)
return span[i - 1] + span[i];
return span[i];
}
return 0;
}
}Because the comparison is unsigned, (uint)i would be a large positive number if i were negative, making it impossible for (uint)i < (uint)span.Length to be true (since a span's length is never negative, (uint)span.Length is at most int.MaxValue). Inside the true branch, i is therefore in [0, span.Length - 1]. Previously, the JIT wasn't always recording the lower bound i >= 0 when it processed the (uint)i < span.Length assertion, and that could leave bounds checks on expressions like i - 1 in place. The fix adds the [0, int.MaxValue - 1] lower bound deduction for the index variable upon entering the true arm of a (uint)i < span.Length check. Combined with the existing range tracking for the upper bound, this gives the JIT a complete picture of i's range inside the guarded block.
; Arm64
--- .NET 10
+++ .NET 11
@@ -8,10 +8,8 @@
cbz w2, G_M000_IG05
G_M000_IG03:
- sub w3, w2, #1
- cmp w3, w1
- bhs G_M000_IG09
- ldr w1, [x0, w3, UXTW #2]
+ sub w1, w2, #1
+ ldr w1, [x0, w1, UXTW #2]
ldr w0, [x0, w2, UXTW #2]
add w0, w1, w0
@@ -33,8 +31,4 @@
ldp fp, lr, [sp], #0x10
ret lr
-G_M000_IG09:
- bl CORINFO_HELP_RNGCHKFAIL
- brk #0
-
-; Total bytes of code 84
+; Total bytes of code 68Bounds check elision is generally based on forms of range analysis, where the JIT needs to prove that a given index is guaranteed to be within the range of the data structure. But the same range analysis-based facts can prove that other checks are unnecessary. For example, once the JIT knows that an integer is in [0..100], it can prove both that converting it to byte can't lose data and that multiplying it by 10 can't overflow. dotnet/runtime#124147 enables the JIT to use such facts to avoid unnecessary branches as part of checked operations. When range analysis proves that the operands are in ranges whose result can't overflow, making checked a nop, the backend can now emit plain add/multiply/subtract instructions, without the jump to failure, as in the following 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 readonly int[] _array = new int[99];
[Benchmark]
public int ArrayLengthPlusConstant() => AddToLength(_array);
[MethodImpl(MethodImplOptions.NoInlining)]
private static int AddToLength(int[] array) => checked(array.Length + 10);
[Benchmark]
public int GuardedLengthTimesConstant() => Multiply(_array);
[MethodImpl(MethodImplOptions.NoInlining)]
private static int Multiply(Span span)
{
if (span.Length >= 100) return 0;
return checked(span.Length * 10);
}
}; Arm64
--- .NET 10
+++ .NET 11
G_M000_IG02:
cmp w1, #100
bge G_M000_IG05
G_M000_IG03:
mov w0, #10
- smull x0, w1, w0
- lsr x2, x0, #32
- cmp w2, w0, ASR #31
+ mul w0, w1, w0
- bne G_M000_IG07
G_M000_IG04:
ldp fp, lr, [sp], #0x10
ret lr
-G_M000_IG07:
- bl CORINFO_HELP_OVERFLOW
- brk #0
-
-; Total bytes of code 64
+; Total bytes of code 44That makes the change broadly applicable: any time you write checked arithmetic on quantities that are inherently bounded, such as collection counts, lengths, or indices constrained by prior comparisons, the JIT now has a chance to prove at compile time that the overflow can't happen and thus eliminate the run-time check entirely. Building on that range-check work, dotnet/runtime#124184 teaches the JIT to eliminate "narrowing casts" under the same kinds of guards:
// 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 uint _value = 100;
[Benchmark]
public byte GuardedNarrowingCast() => Narrow(_value);
[MethodImpl(MethodImplOptions.NoInlining)]
private static byte Narrow(uint value)
{
if (value > 100) return 0;
return checked((byte)value);
}
}; Arm64
--- .NET 10
+++ .NET 11
@@ -4,23 +4,10 @@
G_M000_IG02:
cmp w0, #100
- bhi G_M000_IG04
- cmp w0, #255
- bhi G_M000_IG06
+ csel w0, w0, wzr, ls
G_M000_IG03:
ldp fp, lr, [sp], #0x10
ret lr
-G_M000_IG04:
- mov w0, wzr
-
-G_M000_IG05:
- ldp fp, lr, [sp], #0x10
- ret lr
-
-G_M000_IG06:
- bl CORINFO_HELP_OVERFLOW
- brk #0
-
-; Total bytes of code 52
+; Total bytes of code 24Such use of checked is common in serialization and protocol code where you validate a value's range prior to truncating it. In this benchmark I've used checked explicitly, but the more common form is with the whole project compiled with true in the .csproj, such that this checked becomes implicit. After the change, the range analysis sees that value is in the range [0, 100], knows byte fits values up to 255, and elides the check. dotnet/runtime#128620 further teaches range analysis the possible results of leading-zero count, trailing-zero count, and population count instructions. Those results are often used to index small lookup tables... knowing their bounds lets the JIT remove the bounds check.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly int[] s_lookup =
Enumerable.Range(0, 33).Select(i => i * i).ToArray();
private uint[] _values;
[GlobalSetup]
public void Setup()
{
Random rng = new(42);
_values = Enumerable.Range(0, 1024).Select(i => (uint)rng.Next(1, int.MaxValue)).ToArray();
}
[Benchmark]
public int SumLookupByLeadingZeroCount()
{
int sum = 0;
foreach (var v in _values)
sum += s_lookup[BitOperations.LeadingZeroCount(v)];
return sum;
}
}The lookup improves because the JIT now knows LeadingZeroCount(uint) is between 0 and 32 and can remove the bounds check.
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| SumLookupByLeadingZeroCount | .NET 10.0 | 516.1 ns | 1.00 |
| SumLookupByLeadingZeroCount | .NET 11.0 | 438.5 ns | 0.85 |
int value = array[i];the JIT could theoretically clone that in order to avoid the implicit bounds check, e.g.
int value;
if ((uint)i < array.Length)
{
// no bounds check emitted by JIT, e.g.
value = Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), i);
}
else
{
// bounds check emitted
value = array[i];
}That particular code looks silly, as we're just trading an implicit bounds check for an explicit one. It becomes less silly when the JIT is able to elide multiple bounds checks with a single branch, e.g.
int sum;
if (4 < array.Length)
{
// zero bounds checks
ref int startRef = ref MemoryMarshal.GetArrayDataReference(array);
sum =
startRef +
Unsafe.Add(ref startRef, 1) +
Unsafe.Add(ref startRef, 2) +
Unsafe.Add(ref startRef, 3);
}
else
{
// potentially four bounds checks
sum =
array[0] +
array[1] +
array[2] +
array[3];
}Such optimizations are already handled in the JIT, via its optRangeCheckCloning phase. It groups bounds checks from a basic block, emits one guard for the largest required range, and duplicates the affected code into a fast path where the individual checks can be removed and a fallback path where they remain. However, one long-standing limitation of range-check cloning is that it refused to process the last statement of any "terminator" block, a block that ends with a jump or return instruction. For a method like:
static int ArrayAccess(int[] abcd) => abcd[0] + abcd[1] + abcd[2] + abcd[3];all four array accesses live in the return statement, the last statement of a return block, so nothing got cloned and the hot path retained four separate bounds checks. In .NET 11, dotnet/runtime#124705 removes that restriction, making the return statement eligible for range-check cloning and allowing a single fast-path guard to cover all four accesses. But even without range-check cloning, there's really no reason such accesses should require four bounds checks: the JIT should be able to see that the array or span needs to have a length of at least 4 and guard all accesses by that single check. If there were intervening operations that had side effects, the JIT would need to maintain order of operations, at least enough to maintain the observable behavior of those effects, but that's not the case here. With dotnet/runtime#127439 in .NET 11, the JIT will now coalesce those checks within a basic block, strengthening the first check to the largest constant index and removing the rest.
// 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);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly int[] _values = Enumerable.Range(0, 16).ToArray();
[Benchmark]
public int Sum16()
{
int[] values = _values;
return
values[0] + values[1] + values[2] + values[3] +
values[4] + values[5] + values[6] + values[7] +
values[8] + values[9] + values[10] + values[11] +
values[12] + values[13] + values[14] + values[15];
}
}In previous releases, you'd sometimes see a proactive developer doing a similar optimization manually, e.g. reordering the accesses in an example like that to put the largest read first. That's no longer necessary.
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Sum16 | .NET 10.0 | 2.958 ns | 1.00 |
| Sum16 | .NET 11.0 | 1.828 ns | 0.62 |
dotnet/runtime#127488, which actually targets explicitly-implemented bounds checks (rather than the implicit ones we've been discussing) and targets code that reads a fixed-size value from the end of a span, such as BinaryPrimitives.ReadInt32BigEndian(span.Slice(span.Length - 4)) behind a span.Length >= 4 guard, e.g.
if (span.Length >= 4)
{
// Parse an int from the end of the span
... = ReadInt32BigEndian(span.Slice(span.Length - 4));
...
}There shouldn't be any additional bounds checking required here. However, Span.Slice begins with:
if ((uint)start > (uint)_length)
ThrowHelper.ThrowArgumentOutOfRangeException();and ReadInt32BigEndian begins with:
if (sizeof(T) > source.Length)
ThrowHelper.ThrowArgumentOutOfRangeException();so even though our span.Length >= 4 check should have been sufficient, we're still ending up with two additional checks. To address that, the JIT needed two things. First, it needed to be able to identify that x - (x + a) is the same as -a. Without this identity, length - (length - 4) is just an opaque subtraction of two expressions with no obvious constant result. With the identity, the JIT can recognize the inner expression (length - 4) as length + (-4), apply x - (x + a) == -a with x == length and a == -4, and end up with -(-4) == 4. Now ReadInt32BigEndian's check against 4 becomes 4 >= 4, which the JIT can trivially see is true. Second, Slice(start) must establish that start is between zero and the span's length. When start is length - 4, the existing length >= 4 guard proves the result is non-negative, while subtracting a positive constant means the result can't exceed length. The improved range analysis connects that guard to the subtraction and removes Slice's check. Both fixes together mean the above example now elides both extra bounds checks. That's useful in particular for libraries like parsers, network protocol implementations, and cryptographic code, all of which frequently on hot paths do things like "read the last N bytes of a buffer."
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Binary;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly byte[] _buffer = new byte[64];
[Benchmark]
public int ReadLastInt32() => ReadLastInt32(_buffer);
[MethodImpl(MethodImplOptions.NoInlining)]
private static int ReadLastInt32(ReadOnlySpan span)
{
if (span.Length >= sizeof(int))
{
return BinaryPrimitives.ReadInt32BigEndian(span.Slice(span.Length - sizeof(int)));
}
return -1;
}
}In .NET 10, the helper is 73 bytes and includes both additional checks and their throw paths:
; x64
cmp ecx,4
jl RETURN_MINUS_ONE
lea edx,[rcx-4]
cmp edx,ecx
ja THROW_SLICE
mov r8d,edx
add rax,r8
sub ecx,edx
cmp ecx,4
jl THROW_READ
movbe eax,[rax]In .NET 11, the helper is 28 bytes, and only the original length guard remains:
; x64
cmp ecx,4
jl RETURN_MINUS_ONE
add ecx,-4
add rax,rcx
movbe eax,[rax]dotnet/runtime#122040 and dotnet/runtime#127117 similarly help to remove bounds checks involving span.Slice. Vectorized loops often work through a span a chunk at a time, slicing off the elements they've already processed. The JIT hasn't always been able to keep track of how those progressively smaller slices relate to the original span, so it could end up checking the same limits again on each iteration. These changes improve that tracking, enabling more of those repeated checks to be removed.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly int[] _data = Enumerable.Repeat(1, 1_024).ToArray();
[Benchmark]
public Vector256 CreateFromSlice() => CreateFromSlice(_data);
[Benchmark]
public int SumSliced() => SumSliced(_data);
[MethodImpl(MethodImplOptions.NoInlining)]
private static Vector256 CreateFromSlice(Span values)
{
if (values.Length < 16)
return default;
return Vector256.Create(values.Slice(8));
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int SumSliced(ReadOnlySpan data)
{
Vector128 sum = default;
while (data.Length >= Vector128.Count)
{
sum += Vector128.Create(data);
data = data.Slice(Vector128.Count);
}
int result = Vector128.Sum(sum);
foreach (int value in data)
result += value;
return result;
}
}In .NET 10, the loop condition proves that at least one vector remains, but the construction of the vector from the current span performs the same check again. .NET 11 retains the length relationship, so the loop body begins directly with the vector addition:
; x64, vector loop
-cmp esi, 4
-jl THROW_ARGUMENT_OUT_OF_RANGE
-vpaddd xmm6, xmm6, [rbx]
-add rbx, 10
-add esi, 0FFFFFFFC
-cmp esi, 4
+vpaddd xmm0, xmm0, [rax]
+add rax, 10
+add ecx, 0FFFFFFFC
+cmp ecx, 4
jge LOOPWe saw earlier how range-check cloning enables duplicating a sequence of instructions in order to eliminate bounds checks. "Loop cloning" extends that to a whole loop. Consider a loop that processes the first count elements of an array:
for (int i = 0; i < count; i++)
sum += values[i];The test i < count doesn't by itself prove that i < values.Length, so by default the compilation would need a bounds check in the body, which would mean a bounds check for every values[i] access. Loop cloning gives the JIT another option. Instead of generating the equivalent of:
for (int i = 0; i < count; i++)
sum += values[i]; // bounds check!it can generate the equivalent of:
if ((uint)count <= (uint)values.Length)
{
// no bounds checks
ref int startRef = ref MemoryMarshal.GetArrayDataReference(values);
for (int i = 0; i < count; i++)
{
sum += Unsafe.Add(ref startRef, i);
}
}
else
{
// bounds check per iteration
for (int i = 0; i < count; i++)
{
sum += values[i];
}
}For the common case where the iteration is in bounds, execution proceeds through a cloned loop with no per-iteration bounds checks, whereas the original checked loop remains as the fallback that preserves exceptional behavior for invalid inputs. The normal path pays for one guard and avoids a check on every iteration, but that comes at the expense of duplicating code. The JIT therefore needs to apply the optimization selectively. The JIT has long employed loop cloning, but it didn't always kick in even in cases it seemed applicable. The previous example showed loop cloning with < in the iteration condition. For whatever reason, however, some developers used !=, and loop cloning didn't apply (I'm guessing they used != because they thought it was more efficient, and they actually end up deoptimizing). Thanks to dotnet/runtime#129268, in .NET 11 != is now also handled, as long as specific conditions are met, such as the stride being exactly 1 or -1, e.g. i++ qualifies, while i += 2 doesn't. dotnet/runtime#129303 also improves loops that terminate with i != bound, giving the JIT a tighter understanding of the values i can take and allowing it to remove some bounds checks even when it can't clone the whole loop. Lookahead in arrays and spans is another recurring pattern, especially in parsers. dotnet/runtime#124242 and dotnet/runtime#125235 recognize conditions such as (uint)(i + 2) < (uint)span.Length and use that relation to remove the follow-on checks for span[i + 1] and span[i + 2]. Consider this benchmark:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.Linq;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly string _text = string.Concat(Enumerable.Repeat("%FE", 128));
[Benchmark]
public bool ContainsPercentFF()
{
ReadOnlySpan span = _text;
for (int i = 0; i < span.Length; i++)
{
if (span[i] == '%' &&
(uint)(i + 2) < (uint)span.Length &&
span[i + 1] == 'F' &&
span[i + 2] == 'F')
{
return true;
}
}
return false;
}
}| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| ContainsPercentFF | .NET 10.0 | 232.1 ns | 1.00 |
| ContainsPercentFF | .NET 11.0 | 194.9 ns | 0.84 |
- dotnet/runtime#121640 helps in a situation where once an access using a chosen index has been checked, a later access to the same array at that index need not be checked again.
- dotnet/runtime#121683 enables the JIT to trace an array's length through calculations performed earlier in the method, exposing more redundant checks, including some involving index-from-end expressions.
- dotnet/runtime#124387 and dotnet/runtime#130326 teach the optimizer to rely on a span's length always being non-negative.
- dotnet/runtime#124571 improves sequences of index-from-end accesses: once an access like
arr[^4]establishes that the array has at least four elements, the JIT reuses that information for nearby accesses such asarr[^3]. - dotnet/runtime#129101 improves how the JIT combines and carries forward the possible ranges of arithmetic expressions, including expressions involving bitwise OR and unsigned division. Those tighter ranges can show that more values are non-negative or within bounds.
Bounds-check elimination is only one payoff from understanding a loop's structure. The JIT analyzes induction variables (values like loop counters that change predictably each iteration) and puts loops into standard forms so that later optimizations can reason about them. .NET 11 broadens the range of loops for which that works:
- dotnet/runtime#122184 recognizes another representation of a 32-to-64-bit zero extension. That lets pointer loops using expressions such as
data[(uint)i]replace the repeated index extension and address calculation with a pointer increment. - dotnet/runtime#119537 follows simple control-flow predecessors when finding an induction variable's initialization and zero-trip test, while dotnet/runtime#128303 gives loops with multiple backedges a single canonical latch block.
- dotnet/runtime#128532 makes loop cloning tolerate more statements around the update and test.
- dotnet/runtime#129309 extends cloning to more span loops with non-unit strides and offset limits.
- dotnet/runtime#129349 handles large strides in array loops with an explicit safety guard rather than rejecting them outright.
- dotnet/runtime#129472 allows loop inversion to spend more of its budget on likely cloning candidates.
- dotnet/runtime#130205 removes comparisons that are redundant given the induction variable's known range.
- dotnet/runtime#131362 corrects profile weights after inversion changes a loop's exit.
Much of this work wasn't motivated by contrived benchmarks containing nothing but array indexing as I'm prone to use in these posts. Rather, many of the improvements stemmed from an ongoing audit of unsafe code throughout the .NET libraries, part of a broader effort to improve memory safety in .NET. .NET and C# are memory safe, but as with other memory safe languages like Rust, it provides escape hatches that enable turning off the guardrails provided by the compiler and runtime. This effort is about reducing where and when developers feel compelled to use those escape hatches, since every occurrence is an opportunity for increased risk. Unsafe code was often introduced years earlier to manually avoid bounds checks, typically by walking a buffer with pointers, byrefs, or Unsafe.Add. Sometimes the audit found that the unsafe code was no longer needed and could simply be removed. Sometimes a "safe" rewrite (meaning not using unsafe and friends) was already just as fast or even faster. And sometimes the rewrite exposed an optimization the JIT was missing, in which case the answer was to improve the JIT and then rewrite the library code to use normal, bounds-checked C#. Several of the optimizations discussed in this section are the result of exactly that feedback loop. dotnet/runtime#127429 is a particularly nice example. The vectorized implementation of Enumerable.Sum used MemoryMarshal.GetReference, Vector.LoadUnsafe, and Unsafe.Add to walk its input without bounds checks. With the span-slicing improvements described earlier, it could instead use Vector.Create(span), span.Slice(...), and a foreach for the tail. That's easier to reason about, removes the unchecked indexing, and ended up being faster. dotnet/runtime#114757 similarly replaced an unsafe pointer-based header-name accessor with a generic ReadOnlySpan implementation without loss of performance. Similarly, dotnet/runtime#121270 removed more unsafe code from Uri parsing and actually improved performance of the cited code measurably. There's a useful "go do" here for libraries outside of dotnet/runtime, as well. Unsafe code written to work around the JIT is a snapshot of what the JIT could do at the time that code was written. If you own code that has hand-written pointer or Unsafe-based loops whose purpose is to avoid bounds checks, it's worth rewriting them with safe, bounds-checked C# and measuring again on .NET 11. Chances are, you'll find the gap at this point is either non-existent or small enough that it's not worth the increased maintenance and risk for managing the safety yourself. And if the revised version is still slower, that's a great opportunity for you to share a repro in the dotnet/runtime repo, hopefully serving as inspiration for one of the first performance improvements to go into the JIT for .NET 12. unsafe code is still necessary for scenarios like interop, but performance alone shouldn't be a permanent reason to eschew all the valuable guardrails .NET provides. The C# 15 memory-safety preview pushes in the same direction and is part and parcel of this effort. Historically, C# has largely equated pointers with unsafe code: simply declaring or manipulating a pointer generally required an unsafe context, even if the code never accessed the memory to which it points. In the preview, pointer plumbing such as declaring a pointer, taking an address with &, using fixed, converting stackalloc to a pointer, and applying sizeof to an unmanaged type no longer requires an unsafe context. Operations that actually access the pointed-to memory, including *p, p->member, and p[i], still do. C# 15 also adds an unsafe(expression) form, analogous to checked(expression), so an unsafe context can cover one precise expression rather than a larger statement block. Those changes are the first preview slice of a larger, multi-release unsafe evolution. The end goal is to make unsafe regions smaller, make their assumptions visible through the call graph, and make them easier for reviewers and tools to find. Pairing that with a JIT that makes idiomatic safe code fast removes a lot of the historical pressure to use unsafe code in the first place.
Assertion Propagation
As discussed earlier, the JIT continually learns facts while compiling a method: a value equals a constant, a reference isn't null, an integer falls within a particular range, and so on. "Assertion propagation" carries those facts forward so they can simplify later code. "Value numbering" complements it by letting the JIT recognize when two expressions compute the same value, even if they appear in different places or use different variables. Together, these mechanisms enable optimizations such as removing redundant null and bounds checks, folding conditions to constants, and reusing repeated computations. .NET 11 improves assertion propagation primarily by fixing places where useful facts were either never recorded or weren't recognized later. For example, reading an array's length normally carries an implicit null-check: if the array reference is null, the read must throw. Once global assertion propagation already knows the reference is non-null, however, we should be able to avoid the implicit null check. In .NET 11, dotnet/runtime#124291 takes care of that for Array.Length:
// 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 readonly int[] _values = new int[1024];
[Benchmark]
public void DeadLength() => Test(_values);
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Test(int[]? values)
{
if (values is not null)
_ = values.Length;
}
}The .NET 10 code still tests the reference and reads the length. In .NET 11, the guard proves the read can't throw, and since its result isn't used, the access disappears:
; Arm64
--- .NET 10
+++ .NET 11
G_M000_IG01:
stp fp, lr, [sp, #-0x10]!
mov fp, sp
G_M000_IG02:
- cbz x0, G_M000_IG04
-
-G_M000_IG03:
- ldr wzr, [x0, #0x08]
-
-G_M000_IG04:
ldp fp, lr, [sp], #0x10
ret lr
-; Total bytes of code 24
+; Total bytes of code 16dotnet/runtime#119474 improves the starting point for integer range analysis. The JIT now uses facts inherent in a value itself, e.g. a constant has one exact value, while a value converted to byte, for example, must be between 0 and 255. That can eliminate bounds checks and conditions even when no preceding if explicitly established the range. dotnet/runtime#124415 further refines this handling of casts, combining what is known about both the source value and the destination type to derive the tightest useful range. Those improvements derive ranges from facts inherent in a value, but ranges can also come from control flow. After if ((uint)x < 10), for example, the JIT knows that x is between 0 and 9 on the true path, which may be enough to remove a later comparison or array bounds check. dotnet/runtime#123624 derives tighter ranges from assertions and casts, including proving that some comparisons are always true or false. dotnet/runtime#129390 preserves range information more accurately when control-flow paths merge. Other changes make better use of the ranges once known. dotnet/runtime#129354 traces values back through their definitions to fold more span- and slice-related comparisons, and dotnet/runtime#126917 uses narrowed ranges to remove more relational branches. dotnet/runtime#124711 teaches the JIT to learn implicit facts from operations that have already completed successfully. For example:
- Creating an array proves its requested length wasn't negative.
- A reference-array store may need a runtime covariance check, because a value typed as
object[]can actually refer to astring[]; the helper that performs that type check also validates the index, so if it returns successfully, the index was in range. - Integer division or modulo proves the divisor wasn't zero.
And so on. Those facts can then remove redundant checks and conditions later in the method.
// 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);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly object?[] _objArr = new object?[8];
private readonly object _value = new();
[Benchmark]
public object? CovariantArrayStore()
{
object?[] objArr = _objArr;
objArr[3] = _value;
return objArr[2];
}
}A successful store to element 3 proves that particular array has at least four elements; since an array's length can't change, the subsequent read of element 2 doesn't need another bounds check.
| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| CovariantArrayStore | .NET 10.0 | 3.565 ns | 1.00 |
| CovariantArrayStore | .NET 11.0 | 2.985 ns | 0.84 |
dotnet/runtime#128522 simplifies how the global assertion pass identifies values, making it less likely to miss a fact learned earlier. One practical impact of this is better propagation of a static string's known length, which can turn a general string comparison into a fixed-size vectorized comparison. dotnet/runtime#127810 improves null-check elimination where control flow merges. With ??=, which is a very common operator used for lazy initialization, the resulting value is non-null whether it came from the existing field or from the newly allocated object. The JIT now combines the facts from both paths and recognizes that the subsequent call doesn't need another null check.
// 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
{
Inner? _inner;
[Benchmark]
[Arguments(42)]
public int Invoke(int n) => (_inner ??= new()).Increment(n);
private sealed class Inner
{
[MethodImpl(MethodImplOptions.NoInlining)]
public int Increment(int n) => n + 1;
}
}The generated code consequently loses the null check on the merged value:
; x64
M00_L00:
mov edx, esi
- cmp [rcx], ecx
call qword ptr ; Inner.Increment(Int32)
-; Total bytes of code 75
+; Total bytes of code 73dotnet/runtime#128701 removes similarly redundant null checks from copies of structs that contain object references. Such copies use a runtime helper so the garbage collector is correctly notified about the reference writes, but lowering had been adding probes for both source and destination without preserving whether either address could actually fault. It now emits only the probes that are needed.
// 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private FourRefs _src = new()
{
A = new(),
B = new(),
C = new(),
D = new()
};
private FourRefs _dst;
[Benchmark]
public void BulkStructCopy() => _dst = _src;
private struct FourRefs
{
public object? A;
public object? B;
public object? C;
public object? D;
}
}Both _src and _dst are fields of the same object, so after probing the source address has established that the object isn't null, probing the destination address can't provide any additional information. .NET 11 removes that second probe:
; Arm64
G_M000_IG02:
add x1, x0, #8
ldrsb wzr, [x1]
add x0, x0, #40
- ldrsb wzr, [x0]
movz x2, ...
ldr x3, [x2]
mov x2, #32
blr x3 // CORINFO_HELP_BULK_WRITEBARRIER
-; Total bytes of code 56
+; Total bytes of code 52Additionally, dotnet/runtime#125215 lets the JIT retain and efficiently find more assertions in larger methods, increasing the opportunities for the same kinds of simplification. And dotnet/runtime#129312 removes unnecessary temporary variables when the same simple field address is used multiple times, enabling more efficient loads and stores.
Simplification
Assertion propagation is largely about proving things to help the generated code. Once the JIT knows enough about an operation's inputs, it can often replace the operation with something simpler and cheaper. "Constant folding" is a fancy way of saying the compiler does work once so it doesn't need to be repeated at run time. If the compiler has everything it needs to compute an answer when building, it can bake that answer in to the generated code and avoid needing the code to re-compute it. That answer can then be further used by other computations at build time, potentially folding further. The C# compiler handles constant folding expressions composed entirely of language constants, while the JIT compiler can go further after inlining and after learning things about values and control flow. The JIT already does a ton of folding, and as with every release, it goes further in .NET 11. One straightforward example is the offset of a field within a struct. dotnet/runtime#122297 recognizes more cases where two addresses refer to the same struct and replaces their difference with the known field offset. Here, the second int field begins four bytes into the struct:
// 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 unsafe class Benchmarks
{
private struct MyStruct
{
public int A;
public int Field;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static nint OffsetOfFieldInline()
{
MyStruct dummy;
return (nint)((byte*)&dummy.Field - (byte*)&dummy);
}
[Benchmark]
[Arguments(1_000)]
public nint OffsetOfFieldLoop(int n)
{
nint sum = 0;
for (int i = 0; i < n; i++)
sum += OffsetOfFieldInline();
return sum;
}
}Without the fold, the loop repeatedly computes the field offset. With the fold, each iteration simply adds the constant 4.
; Arm64
--- .NET 10
+++ .NET 11
@@ -1,7 +1,6 @@
G_M000_IG01:
- stp fp, lr, [sp, #-0x20]!
+ stp fp, lr, [sp, #-0x10]!
mov fp, sp
- str xzr, [fp, #0x18]
G_M000_IG02:
mov x0, xzr
@@ -9,22 +8,18 @@
ble G_M000_IG05
G_M000_IG03:
- add x2, fp, #0x1C
- add x3, fp, #24
- sub x2, x2, x3
align [0 bytes for IG04]
align [0 bytes]
align [0 bytes]
align [0 bytes]
G_M000_IG04:
- str xzr, [fp, #0x18]
- add x0, x2, x0
+ add x0, x0, #4
sub w1, w1, #1
cbnz w1, G_M000_IG04
G_M000_IG05:
- ldp fp, lr, [sp], #0x20
+ ldp fp, lr, [sp], #0x10
ret lr
-; Total bytes of code 60
+; Total bytes of code 40dotnet/runtime#121985 from @hez2010 enables the JIT to evaluate SequenceEqual at compile time when both inputs are known. SequenceEqual normally walks two sequences element by element, stopping at the first mismatch. But if inlining exposes both sequences as constants, there's nothing useful left to do at run time: the JIT can compare them while compiling and replace the whole operation with a constant true or false. This intrinsic underpins APIs including MemoryExtensions.SequenceEqual, ReadOnlySpan.SequenceEqual, and string.Equals.
// 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static string AlphaLower => "abcdefghijklmnopqrstuvwxyz";
private static string AlphaUpper => "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
[Benchmark]
public bool CompareEqual() => AlphaLower.Equals(AlphaLower);
[Benchmark]
public bool CompareDistinct() => AlphaLower.Equals(AlphaUpper);
}Because these properties aren't const, the C# compiler can't evaluate the comparisons. The JIT, however, can see the string literals after inlining. It now folds comparisons of the same input whose contents are available at the time of compilation. CompareDistinct therefore becomes a constant false.
; x64
--- .NET 10
+++ .NET 11
-mov rax,LOWER_STRING
-mov rcx,UPPER_STRING
-add rax,0C
-vmovups ymm0,[rax]
-vmovups ymm1,[rax+14]
-vmovups ymm2,[rcx]
-vpxor ymm0,ymm2,ymm0
-vpxor ymm1,ymm1,[rcx+14]
-vpor ymm0,ymm1,ymm0
-vptest ymm0,ymm0
-sete al
-movzx eax,al
-vzeroupper
+xor eax,eax
ret
-; Total bytes of code 65
+; Total bytes of code 3Folding an operation is only the first step, though. The result can then simplify later code, even when it's a vector. dotnet/runtime#127124 extends assertion propagation to 128-bit integer vector constants. If a branch establishes that a vector is zero, uses of that vector within the branch can now be replaced with zero and simplified just like scalar values.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int _selector;
[MethodImpl(MethodImplOptions.NoInlining)]
private Vector128 Compute() => _selector == 0 ? Vector128.Zero : Vector128.Create(7);
[Benchmark]
public int AndNotIfZero()
{
Vector128 v = Compute();
if (v == Vector128.Zero)
{
Vector128 masked = Vector128.AndNot(v, Vector128.Create(0x00FF00FF));
return masked[0];
}
return -1;
}
}In the benchmark's zero branch, the JIT can now fold away the mask creation, AndNot, and lane extraction, reducing the Arm64 method from 68 bytes to 56 bytes. This currently applies to integer vectors up to 128 bits (floating-point equality has additional NaN and signed-zero semantics that prevent the same reasoning at present).
; Arm64
--- .NET 10
+++ .NET 11
@@ -11,14 +11,11 @@
umaxp v16.4s, v0.4s, v0.4s
umov x0, v16.d[0]
movn w1, #0
- movi v16.8h, #0xFF, LSL #8
- and v16.4s, v0.4s, v16.4s
- smov x2, v16.s[0]
cmp x0, #0
- csel w0, w1, w2, ne
+ cinc w0, w1, eq
G_M000_IG03:
ldp fp, lr, [sp], #0x10
ret lr
-; Total bytes of code 68
+; Total bytes of code 56Two backend cleanups take advantage of simpler expressions. dotnet/runtime#124332 from @jonathandavies-arm removes an unnecessary negation when Arm64 code compares a negated value with zero. And dotnet/runtime#124642 from @yykkibbb lets short-circuit Boolean returns fold even when inlining has left unused writes in the same block; those stores previously obscured the simple Boolean expression from the optimizer. Branches offer another opportunity for simplification. Modern processors work on several instructions at different stages at the same time. When a processor encounters a conditional branch, it predicts which path will be taken so that it can continue fetching and executing instructions speculatively. A correct prediction hides much of the branch's cost. A misprediction throws away that speculative work, redirects instruction fetch to the correct path, and refills the processor's execution pipeline. That can make the predictability of a branch as important as the work in either branch. The JIT can sometimes avoid that variability, particularly inside small hot loops, by replacing a branch with a conditional move instruction or by recognizing that several branches describe one simpler condition. This isn't always profitable: branchless code may evaluate work that a predictable branch would skip, making the branching code less expensive in the majority case. But it can be valuable for small, data-dependent choices. dotnet/runtime#124567 recognizes zero-based equality chains, e.g. value == 0 || value == 1 || value == 2. Such chains can be replaced with an unsigned range check, e.g. (uint)value <= 2, producing a branchless result. The unsigned comparison also handles negative inputs: when interpreted as unsigned, any negative int is larger than the upper bound.
// 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);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int _value = 2;
[Benchmark]
public bool IsLetterCategory() =>
_value == 0 ||
_value == 1 ||
_value == 2 ||
_value == 3 ||
_value == 4;
}The .NET 10 JIT already combines the first four comparisons, but still needs a branch and a separate comparison for 4:
; x64
mov ecx,[rcx+8]
cmp ecx,3
ja CHECK_FOUR
mov eax,1
ret
CHECK_FOUR:
cmp ecx,4
sete al
movzx eax,al
ret.NET 11 recognizes the whole chain as one unsigned range check, reducing the method from 24 bytes to 13:
; x64
mov eax,[rcx+8]
cmp eax,5
setb al
movzx eax,al
retdotnet/runtime#128524 from @BoyBaykiller extends the same optimization to contiguous ranges that don't start at zero. For example, x == 3 || x == 4 || x == 5 can become (uint)(x - 3) <= 2. Casts can obscure an equally simple comparison. dotnet/runtime#128091 from @BoyBaykiller broadens cast-comparison optimization to equality and inequality. In this benchmark, converting a uint to ulong adds no information needed to compare it with uint.MaxValue, so the JIT can keep the comparison at 32 bits:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Linq;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly int[] _values = Enumerable.Range(0, 128).ToArray();
[Benchmark]
public int CastEquality()
{
int matches = 0;
foreach (int value in _values)
if ((ulong)(uint)value == uint.MaxValue)
matches++;
return matches;
}
}The widening cast disappears, reducing the Arm64 method from 80 bytes to 76 bytes.
; Arm64
--- .NET 10
+++ .NET 11
@@ -18,8 +18,7 @@
G_M000_IG04:
ldr w3, [x0]
- mov x4, #0xFFFFFFFF
- cmp x3, x4
+ cmn w3, #1
beq G_M000_IG08
G_M000_IG05:
@@ -38,4 +37,4 @@
add w1, w1, #1
b G_M000_IG05
-; Total bytes of code 80
+; Total bytes of code 76The examples thus far simplify individual comparisons. dotnet/runtime#127181 also combines multiple comparisons in the same expression. For example, (x >= c) && (x <= c) can only be true when x == c; corresponding OR forms can be simplified similarly. Once the JIT can reason about one comparison in terms of another, it can apply the same idea across branches. dotnet/runtime#126587 removes an earlier test when a later, stronger test subsumes it. For example, if (x > 0) if (x > 1) needs only the x > 1 test, as reaching the nested body with x > 1 necessarily also means x > 0. Rather than simply removing a test, the JIT can sometimes use the outcome of an earlier branch to choose the destination of a later one. This is known as "jump threading": the JIT threads a control-flow path through the intervening jumps directly to its eventual destination. For example, consider:
int value = condition ? 1 : 2;
if (value == 1)
{
One();
}
else
{
Two();
}The path where condition is true can go directly to One, while the false path can go directly to Two, eliminating the second test, effectively:
int value;
if (condition)
{
value = 1;
One();
}
else
{
value = 2;
Two();
}dotnet/runtime#126812 lets this continue through more places where paths rejoin, and dotnet/runtime#127103 ensures the rewritten values remain correct in more of those cases. dotnet/runtime#127950 carries relationships between values further, so facts like a > 10 and b > a can simplify later branches or bounds. The same reasoning can apply to type information. dotnet/runtime#128500 combines the known types of instances arriving from multiple paths; if every value derives from the tested base type, the JIT can remove the is test after the paths merge. And dotnet/runtime#127434 from @hez2010 lets redundant-branch elimination look through empty jump blocks. Such a block contains no work of its own and exists only to redirect control elsewhere, but it could still hide the relationship between two conditions from the optimizer. Consider this 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static object? s_sink;
private int _x = 20;
private int _y = 30;
private bool _flag = true;
private int _count = 5;
[Benchmark]
public bool TransitiveComparison() => TransitiveComparison(_x, _y);
[Benchmark]
public bool MergedTypeCheck() => MergedTypeCheck(_flag);
[Benchmark]
public int NestedThresholds() => NestedThresholds(_count);
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool TransitiveComparison(int x, int y)
{
if (x > 10 && x < 100 && y > x)
return y > 0;
return false;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool MergedTypeCheck(bool flag)
{
object shape = flag ? new Circle() : new Rectangle();
s_sink = shape;
return shape is Shape;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int NestedThresholds(int count)
{
if (count > 1)
if (count > 2)
if (count > 3)
if (count > 4)
return 1;
return 3;
}
private abstract class Shape;
private sealed class Circle : Shape;
private sealed class Rectangle : Shape;
}In TransitiveComparison, reaching y > 0 means the JIT already knows that x > 10 and y > x, which together prove that y is positive. The final comparison disappears, reducing the Arm64 method from 40 bytes to 36 bytes:
; Arm64
--- .NET 10
+++ .NET 11
cmp w1, w0
ccmp w2, w3, c, gt
- ccmp w1, #0, nzc, ls
- cset x0, gt
+ cset x0, ls
-; Total bytes of code 40
+; Total bytes of code 36In MergedTypeCheck, each path creates a different concrete type, but both derive from Shape. .NET 11 keeps the allocations and the store that make the example observable, but replaces the is helper call and its result test with the constant true, reducing the method from 112 bytes to 88 bytes:
; Arm64
--- .NET 10
+++ .NET 11
bl CORINFO_HELP_ASSIGN_REF
- movz x0, #0xEA30
- movk x0, #0x4EB LSL #16
- movk x0, #0x7FFF LSL #32
- bl CORINFO_HELP_ISINSTANCEOFCLASS
- cmp x0, #0
- cset x0, ne
+ mov w0, #1
-; Total bytes of code 112
+; Total bytes of code 88For NestedThresholds, reaching the return 1 requires count to be greater than all four constants, which is equivalent to just count > 4. Once redundant-branch elimination can see through the empty jump blocks left behind while simplifying the nested conditions, the other three comparisons disappear:
; Arm64
--- .NET 10
+++ .NET 11
mov w1, #3
mov w2, #1
- cmp w0, #1
- ccmp w0, #2, nzc, gt
- ccmp w0, #3, nzc, gt
- ccmp w0, #4, nzc, gt
+ cmp w0, #4
csel w0, w1, w2, le
-; Total bytes of code 44
+; Total bytes of code 32Removing a redundant branch is ideal; why do work when it's provably unnecessary? Often, however, the branch is necessary, as both outcomes are possible (or at least not provably impossible). In such cases, the JIT may still be able to avoid branching via specialized instructions that bake the choice into the instruction. "If-conversion" replaces a small if/else with a conditional-move instruction or another branchless form when both alternatives are cheap. The JIT has been able to do this for several releases, and improves in .NET 11. dotnet/runtime#124738 from @BoyBaykiller recognizes an earlier default assignment as the implicit else, so bool x = false; if (cond) x = true; can become the same branchless form as an explicit else. dotnet/runtime#127915 from @BoyBaykiller handles the opposite cleanup, removing a conditional selection when both outcomes are the same constant while preserving any side effects from evaluating the condition. dotnet/runtime#128533 from @BoyBaykiller also helps these Boolean optimizations meet in the middle by normalizing power-of-two bit tests. A power of two has exactly one bit set, in which case (A & bit) == bit is equivalent to (A & bit) != 0; putting both forms into the same canonical representation makes them easier to combine with surrounding conditions. All three improvements are visible in the following benchmarks:
// 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 _left = 1;
private int _right = 2;
private double _double = 0.0;
private int _bits = 4;
[Benchmark]
public bool ImplicitElse() => ImplicitElse(_left, _right);
[Benchmark]
public bool IsDefaultValue() => IsDefaultValue(_double);
[Benchmark]
public bool HasEitherBit() => HasEitherBit(_bits);
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool ImplicitElse(int left, int right)
{
bool leftIsSmaller = false;
if (left < right)
leftIsSmaller = true;
return leftIsSmaller;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool IsDefaultValue(double value) => 0.0.Equals(value);
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool HasEitherBit(int value) =>
((value & 4) == 4) || ((value & 8) == 8);
}For ImplicitElse, .NET 10 already avoids a branch, but it still materializes both Boolean values and selects between them. In .NET 11, the method becomes just the comparison and a cset, shrinking from 36 bytes to 24 bytes:
; Arm64
--- .NET 10
+++ .NET 11
- mov w2, wzr
- mov w3, #1
cmp w0, w1
- csel w2, w2, w3, ge
- mov w0, w2
+ cset x0, lt
-; Total bytes of code 36
+; Total bytes of code 240.0.Equals(value) needs to account for NaN, but because the left operand is zero, the case where both operands are NaN can never apply. Removing the conditional selection for that case leaves one floating-point comparison and one cset, reducing IsDefaultValue from 40 bytes to 24 bytes:
; Arm64
--- .NET 10
+++ .NET 11
fcmp d0, #0.0
- beq G_M000_IG04
-
-G_M000_IG03:
- fcmp d0, d0
- csel w0, wzr, wzr, eq
- b G_M000_IG05
-
-G_M000_IG04:
- mov w0, #1
-
-G_M000_IG05:
+ cset x0, eq
+
+G_M000_IG03:
ldp fp, lr, [sp], #0x10
ret lr
-; Total bytes of code 40
+; Total bytes of code 24Finally, normalizing both power-of-two comparisons lets the JIT combine their results. The short-circuit branch in HasEitherBit is replaced by two masks and an or, reducing the method from 40 bytes to 36 bytes:
; Arm64
--- .NET 10
+++ .NET 11
- tbz w0, #2, G_M000_IG05
-
-G_M000_IG03:
- mov w0, #1
-
-G_M000_IG04:
- ldp fp, lr, [sp], #0x10
- ret lr
-
-G_M000_IG05:
- tst w0, #8
+ and w1, w0, #4
+ and w0, w0, #8
+ orr w0, w1, w0
+ cmp w0, #0
cset x0, ne
-G_M000_IG06:
+G_M000_IG03:
ldp fp, lr, [sp], #0x10
ret lr
-; Total bytes of code 40
+; Total bytes of code 36Not every simplification depends on broader control-flow reasoning. "Peephole optimizations" instead replace a short, recognizable pattern with an equivalent cheaper one. Each may save only an instruction or expose a form that another optimization understands, but these patterns can occur very frequently on hot paths throughout generated code. For example, dotnet/runtime#126529 from @BoyBaykiller recognizes that 255 - x for a byte is equivalent to x ^ 255: both simply flip all eight bits, but the latter can remove an instruction if it's able to replace a negation and add with an xor. Similarly, -1 - x can turn into the equivalent of ~x.
// 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly byte[] _data = new byte[4096];
[GlobalSetup]
public void Setup() => new Random(42).NextBytes(_data);
[Benchmark]
public int InvertBytes()
{
int sum = 0;
foreach (byte b in _data) sum += 255 - b;
return sum;
}
}In .NET 11, the loop loses a separate negate and add:
; Arm64
--- .NET 10
+++ .NET 11
@@ -19,9 +19,8 @@
G_M000_IG04:
ldrb w4, [x0, w2, UXTW]
- neg w4, w4
+ eor w4, w4, #255
add w1, w4, w1
- add w1, w1, #255
add w2, w2, #1
cmp w3, w2
bgt G_M000_IG04
@@ -33,4 +32,4 @@
ldp fp, lr, [sp], #0x10
ret lr
-; Total bytes of code 76
+; Total bytes of code 72dotnet/runtime#129361 removes another unnecessary instruction when comparing an sbyte with a constant that fits in eight bits. The JIT can compare the byte directly, with no sign extension:
// 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 sbyte _value = -65;
[Benchmark]
public bool IsLow() => IsLow(_value);
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool IsLow(sbyte value) => value < -64;
}The optimized codegen then compares the byte directly, removing the movsx sign-extension instruction (though the JIT still retains it in the few comparison forms that require a full-width sign bit for correctness).
; x64
--- .NET 10
+++ .NET 11
-movsx rax,cl
-cmp eax,0FFFFFFC0
+cmp cl,0C0
setl al
movzx eax,al
ret
-; Total bytes of code 14
+; Total bytes of code 10dotnet/runtime#125180 from @saucecontrol improves non-overflowing float and double conversions to long and ulong on x86 machines with AVX-512 or AVX10.2. These casts have defined behavior for NaN and out-of-range values, so older code used a helper to preserve those semantics. The newer instruction set lets the JIT keep the normal path inline and register-based, avoiding the helper call; machines that don't support these instructions retain the existing fallback.
// Run with 32-bit x86 dotnet on a machine with AVX-512 or AVX10.2:
// 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private float _single = 123_456.75f;
private double _double = 123_456.75;
[Benchmark] public long SingleToInt64() => (long)_single;
[Benchmark] public ulong SingleToUInt64() => (ulong)_single;
[Benchmark] public long DoubleToInt64() => (long)_double;
[Benchmark] public ulong DoubleToUInt64() => (ulong)_double;
}| Method | Runtime | Mean | Ratio | Code Size |
|---|---|---|---|---|
| SingleToInt64 | .NET 10.0 | 5.103 ns | 1.00 | 31 B |
| SingleToInt64 | .NET 11.0 | 2.093 ns | 0.41 | 54 B |
| SingleToUInt64 | .NET 10.0 | 4.810 ns | 1.00 | 31 B |
| SingleToUInt64 | .NET 11.0 | 1.366 ns | 0.28 | 34 B |
| DoubleToInt64 | .NET 10.0 | 4.834 ns | 1.00 | 31 B |
| DoubleToInt64 | .NET 11.0 | 2.101 ns | 0.43 | 54 B |
| DoubleToUInt64 | .NET 10.0 | 4.663 ns | 1.00 | 31 B |
| DoubleToUInt64 | .NET 11.0 | 1.363 ns | 0.29 | 34 B |
Vectorization
SIMD, or "single instruction, multiple data", is the concept of one instruction applying the same operation to several values at once. A "scalar" add, for example, might combine one pair of 32-bit integers, while a 128-bit SIMD add can combine "vectors" of four pairs in the same instruction; 256- and 512-bit variants can handle vectors of eight and sixteen pairs, respectively. When the iterations of an operation are independent, "vectorizing" a loop can therefore replace several scalar iterations with one, improving the throughput of the loop significantly. .NET exposes portable (they work on any machine) variable-width vector type Vector (which can represent different counts of T depending on the current hardware), fixed-width Vector64 through Vector512 types (which always represent the same count of T), and architecture-specific intrinsics (performing operations on such vector types which the JIT then maps to the right underlying hardware instructions). Each element in a vector is often referred to as a "lane". Because the JIT recognizes these operations directly, it can fold constants, select instructions, and remove unsupported paths without treating them as normal method calls. A variety of PRs in .NET 11 improve AVX-512 broadcasting and masking. Embedded broadcasting lets an instruction load a single scalar value and replicate it across all vector lanes, avoiding the need to materialize a full-width vector constant in memory to feed into the instruction. For example, this bitwise AND instruction:
; x64
vpandd zmm0, zmm1, dword ptr [reloc @RWD00] {1to16}can replace this one:
; x64
vpandd zmm0, zmm1, zmmword ptr [reloc @RWD00]storing only 4 bytes in the read-only data section rather than 64. Because the broadcast is handled as part of the load, there's no additional instruction-level latency; the primary benefit is reduced data size and cache footprint. Embedded masking similarly lets an instruction update only a subset of the lanes. A mask is one bit per vector lane, where each bit indicates whether and how the operation should affect the corresponding lane. Without embedded masking, code often needs to compute every lane and then blend that result with the old value, so folding the mask into the operation can remove both the separate blend and a zero-vector setup. dotnet/runtime#117700 from @saucecontrol improves broadcast selection when an intrinsic's natural element size differs from its managed vector type. VNNI, the Vector Neural Network Instructions used for small-integer multiply-accumulate operations, and bitwise operations can now use the smallest valid repeated constant, avoiding a full-vector load.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
// Requires AVX-VNNI and AVX-512F.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Vector128 _bytes = Vector128.Create((byte)1);
private readonly Vector128 _u64 = Vector128.Create(1UL);
private readonly Vector128 _u32 = Vector128.Create(1U);
private readonly Vector512 _v512 = Vector512.Create(1);
private int _n = 42;
[Benchmark]
public Vector128 VnniBroadcast() =>
AvxVnni.MultiplyWideningAndAdd(
Vector128.Zero, _bytes, Vector128.One);
[Benchmark]
public Vector128 MaskAnd() =>
Vector128.ConditionalSelect(
Vector128.GreaterThan(_u32, Vector128.Zero),
(_u64 & Vector128.One.AsUInt64()).AsUInt32(),
Vector128.Zero);
[Benchmark]
public Vector512 BlendMaskAllOnes() =>
Avx512F.BlendVariable(
Vector512.Create(_n),
_v512,
Vector512.Create(-1));
[Benchmark]
public Vector512 MultiInsert() =>
Vector512.ConditionalSelect(
Vector512.Create(0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0),
_v512,
Vector512.Create(_n));
[Benchmark]
public Vector512 MultiInsertZero() =>
Avx512F.BlendVariable(
_v512,
Vector512.Zero,
Vector512.Create(0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0));
}In .NET 11, this results in 12 fewer bytes in the read-only data section, and 12 fewer bytes of constant-pool cache footprint.
; x64
-C4E279503500000000 vpdpbusd xmm6, xmm0, xmmword ptr [reloc @RWD00]
+62F27D18503500000000 vpdpbusd xmm6, xmm0, dword ptr [reloc @RWD00] {1to4}
-RWD00 dq 0101010101010101h, 0101010101010101h
+RWD00 dd 01010101hThe fix also impacts embedded masking. For example, with MaskAnd previously, the AND used a qword broadcast, {1to2}, and a separate blend then moved the masked result, meaning two instructions. Now that Vector128.One can be broadcast at dword granularity, the mask's element size and the AND's element size agree, unlocking using the single merged-masked form. This pattern shows up throughout vectorized algorithms that do lots of bitwise manipulation and hashing, including implementations in System.Numerics.Tensors, System.IO.Hashing, and System.Private.CoreLib.
; x64
- vpandq xmm0, xmm0, qword ptr [reloc @RWD00] {1to2}
- vpblendmd xmm0 {k1}{z}, xmm0, xmm0
+ vpandd xmm0 {k1}{z}, xmm0, dword ptr [reloc @RWD00] {1to4}
; Code: 45 → 39 bytes; data: 8 bytes → 4 bytesThat MaskAnd example starts as an AND followed by a blend, an operation that chooses independently for each vector lane whether to take its value from one input or the other, with the JIT able to fold those two operations together. Similar opportunities arise with blends more generally. Sometimes the mask or one of the inputs makes the choice trivial, e.g. an all-ones mask always selects the same input, so the blend is just a move. If one input is zero, it can often become an AND or ANDN. AVX-512 provides more options still, as constant masks and zeroing can be encoded directly in the instruction. dotnet/runtime#123146 from @saucecontrol makes these simplifications consistently across the portable and hardware-specific APIs. A blend with an all-ones mask provides a particularly clear example:
// Run on x64 with AVX-512:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Vector512 _values = Vector512.Create(1);
private int _n = 42;
[GlobalSetup]
public void Setup()
{
if (!Avx512F.IsSupported)
throw new PlatformNotSupportedException();
}
[Benchmark]
public Vector512 BlendMaskAllOnes() =>
Avx512F.BlendVariable(Vector512.Create(_n), _values, Vector512.Create(-1));
}The generated code no longer needs to create the first input, load the mask, or perform the blend. It simply loads the input the all-ones mask would always select:
; x64
-vpbroadcastd zmm0, dword ptr [rcx+8]
-kmovq k1, qword ptr [RWD00]
-vpblendmd zmm0 {k1}, zmm0, [rcx+48]
+vmovups zmm0, [rcx+48]
vmovups [rdx], zmm0
mov rax, rdx
vzeroupper
ret
; 39 bytes → 23 bytesIntrinsics
An intrinsic is a managed API that the JIT recognizes and special-cases. Often that special-casing involves actually replacing calls to the method with custom code that's behaviorally equivalent but better in some way (faster, smaller, etc.) As an example, dotnet/runtime#128678 improves recognition of generic-math calls to IBinaryNumber.Log2. The method computes the base-2 logarithm of an integer, equivalent to the index of the number's highest set bit; for example, Log2(16) is 4. Previously, the JIT's normalized integer type lost the signedness needed to import the operation directly as an intrinsic. Inlining the managed implementation could still produce the same optimized code, but when inlining didn't happen, the managed call remained. In .NET 11, the JIT consults the precise type and imports the operation directly: unsigned and non-negative signed inputs can become leading-zero-count or bit-scan arithmetic, while a negative signed value retains the managed fallback and its exact exception behavior. Sometimes the JIT has a perfectly good intrinsic lowering but doesn't recognize a call that should use it. Enum.Equals from a generic T : Enum context was a good example. Even though both arguments to the generic helper are strongly typed as T, an enum doesn't provide an Equals(T) method; it inherits the virtual Enum.Equals(object) implementation. The second argument therefore needs to be boxed to pass it as object. The receiver is invoked with a constrained virtual call, but because the concrete enum doesn't override the method itself, it too needs to be boxed to invoke the implementation on System.Enum. Thus, what looks like a strongly-typed comparison can end up allocating two boxes and making a virtual call. In .NET 11, dotnet/runtime#122779 eliminates this overhead by teaching the JIT to recognize the call and fold it to a direct comparison of the enum's underlying integer values. For 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);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private static readonly StringComparison[] s_values =
{
StringComparison.Ordinal, StringComparison.OrdinalIgnoreCase,
StringComparison.CurrentCulture, StringComparison.CurrentCultureIgnoreCase,
StringComparison.InvariantCulture, StringComparison.Ordinal,
};
[Benchmark]
public int CountOrdinal_Generic()
{
int count = 0;
foreach (var v in s_values)
if (EqualsGeneric(v, StringComparison.Ordinal))
count++;
return count;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool EqualsGeneric(T a, T b) where T : Enum => a.Equals(b);
}Once the JIT knows the callee is Enum.Equals and knows the exact enum type, it asks the runtime for the underlying integer type and replaces the virtual call with a direct comparison. That in turn makes both box/unbox pairs redundant, and the generated code contains neither allocation. For the six comparisons performed here, .NET 10 creates twelve boxes, totaling 288 bytes. In .NET 11, the helper becomes just the integer comparison, eliminating both the allocations and the virtual dispatch.
| Method | Runtime | Mean | Ratio | Allocated |
|---|---|---|---|---|
| CountOrdinal_Generic | .NET 10.0 | 59.24 ns | 1.00 | 288 B |
| CountOrdinal_Generic | .NET 11.0 | 10.01 ns | 0.17 | - |
Enum.Equals to use typed comparisons. With the JIT now handling it, including in NativeAOT's own use of the JIT (NativeAOT uses the JIT ahead of time rather than just in time), dotnet/runtime#123086 deletes that rewriting and its supporting machinery. dotnet/runtime#127329 improves the Vector256.Sum and Vector512.Sum intrinsics. The JIT now performs most of the reduction at full width and combines the per-lane results at the end, avoiding the extracts and duplicate shuffle sequences needed when splitting wide vectors into 128-bit pieces. And dotnet/runtime#127402 extends vector-constant propagation from 128-bit vectors to Vector256 and Vector512. Code that compares a wide vector with a known sentinel can now simplify subsequent uses just as narrower vectors already could. The following benchmark exemplifies both:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
// Requires AVX2 for the assembly shown below.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Vector256 _floats =
Vector256.Create(1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f);
private int _selector;
[Benchmark]
public float Sum() => Vector256.Sum(_floats);
[Benchmark]
public int TransformWhenKnown()
{
Vector256 value = GetVector();
if (value == Vector256.Create(0, 1, 2, 3, 4, 5, 6, 7))
return (value + Vector256.Create(10)).GetElement(6);
return -1;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private Vector256 GetVector() =>
_selector == 0 ?
Vector256.Create(0, 1, 2, 3, 4, 5, 6, 7) :
Vector256.Create(7);
}For Sum, .NET 10 separately reduces each 128-bit half and then adds the two scalar results. In .NET 11, the permutes and adds operate on both halves in parallel as 256-bit instructions, after which only the two already-reduced halves need to be combined:
; x64
vmovups ymm0, [rcx+28]
-vmovaps ymm1, ymm0
-vpermilps xmm2, xmm1, 0B1
-vaddps xmm1, xmm2, xmm1
-vpermilps xmm2, xmm1, 4E
-vaddps xmm1, xmm2, xmm1
-vextractf128 xmm0, ymm0, 1
-vpermilps xmm2, xmm0, 0B1
-vaddps xmm0, xmm2, xmm0
-vpermilps xmm2, xmm0, 4E
-vaddps xmm0, xmm2, xmm0
-vaddss xmm0, xmm1, xmm0
+vpermilps ymm1, ymm0, 0B1
+vaddps ymm0, ymm1, ymm0
+vpermilps ymm1, ymm0, 4E
+vaddps ymm0, ymm1, ymm0
+vextractf128 xmm1, ymm0, 1
+vaddps xmm0, xmm1, xmm0
; 63 bytes → 39 bytesTransformWhenKnown uses a deliberately non-repeating constant across its eight lanes. On the branch where the comparison succeeds, .NET 11 can replace value with that constant, fold the vector addition, and determine that element 6 is 16. The vpaddd, extraction, second 32-byte constant, and associated control flow all disappear:
; x64
-cmp eax, 0FFFFFFFF
-jne M00_L00
-vmovups ymm0, [rsp+20]
-vpaddd ymm0, ymm0, [RWD32]
-vextracti128 xmm0, ymm0, 1
-vpextrd eax, xmm0, 2
-vzeroupper
-add rsp, 58
-ret
-
-M00_L00:
-mov eax, 0FFFFFFFF
+mov ecx, 0FFFFFFFF
+mov edx, 10
+cmp eax, 0FFFFFFFF
+mov eax, edx
+cmovne eax, ecx
vzeroupper
add rsp, 58
ret
; 85 bytes → 59 bytesOne of the goals of .NET is that you can write code once and have it run anywhere, optimized for whatever that "anywhere" has to offer. For vectorization, that means providing portable operations whenever the intent is common across instruction sets, while retaining architecture-specific APIs for algorithms that really do need to target a particular machine. Whenever possible, we want to enable developers to express their algorithms using the portable APIs, and each release of .NET fills additional gaps there. Including .NET 11. dotnet/runtime#129627 from @hez2010 adds portable APIs for constructing common lane sequences (e.g. [1, 2, 4, 8] or [a, b, a, b]), concatenating half-vectors (the lower halves of [a, b, c, d] and [w, x, y, z] producing [a, b, w, x]), interleaving ([a, b] and [x, y] producing [a, x, b, y]), de-interleaving ([a, x, b, y] producing [a, b] and [x, y]), and reversal ([a, b, c, d] producing [d, c, b, a]), along with their JIT intrinsification. These operations were already expressible, but only verbosely and only if you knew which hardware instruction to reach for, e.g. writing Zip by hand meant targeting a platform-specific API like AdvSimd.Arm64.ZipLow. The new APIs let the code state the transformation and leave instruction selection to the JIT. Once the intrinsic operation has been recognized, the backend still needs to keep it in a useful vector form while assigning registers and selecting instructions. Vector values are structs, and the JIT will often apply "struct promotion," tracking a struct's fields as independent locals so that each can be optimized separately. That's useful for ordinary structs, but counterproductive when a value is meant to remain in a vector or mask register: splitting it can introduce extra moves and obscure what should be a single whole-value store, particularly after inlining introduces more local stores. dotnet/runtime#128013 consistently marks SIMD and mask stores as intrinsic-related across platforms, including 32-bit x86 and x64 mask stores, so those locals remain intact. dotnet/runtime#129563 extends that principle to user-defined structs that are bitcast to SIMD types. This trades away struct promotion for those locals, but enables the JIT to preserve their vector representation. This matters for user-defined numerical types that store the same data as a hardware vector but expose named fields or domain-specific operations. The following Vector2Double is laid out as two adjacent double values, so it can be bitcast to Vector128, operated on with SIMD, and bitcast back:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
public struct Vector2Double(double x, double y)
{
public double X = x;
public double Y = y;
public static Vector2Double operator +(Vector2Double left, Vector2Double right)
{
Vector128 simdLeft = Unsafe.BitCast>(left);
Vector128 simdRight = Unsafe.BitCast>(right);
return Unsafe.BitCast, Vector2Double>(simdLeft + simdRight);
}
}
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Vector2Double _a = new(1.0, 2.0);
private readonly Vector2Double _b = new(3.0, 4.0);
private readonly Vector2Double _c = new(5.0, 6.0);
[Benchmark]
public Vector2Double Add() => Add(_a, _b, _c);
[MethodImpl(MethodImplOptions.NoInlining)]
private static Vector2Double Add(Vector2Double a, Vector2Double b, Vector2Double c) =>
a + b + c;
}In .NET 10, promotion of the intermediate struct sends the first SIMD result through two stack locations before the second addition. .NET 11 keeps that value in xmm0, reducing the helper from 52 bytes to 22 bytes:
; x64
-sub rsp, 28
vmovups xmm0, [rdx]
vaddpd xmm0, xmm0, [r8]
-vmovaps [rsp], xmm0
-vmovups xmm0, [rsp]
-vmovups [rsp+18], xmm0
-vmovups xmm0, [rsp+18]
vaddpd xmm0, xmm0, [r9]
vmovups [rcx], xmm0
mov rax, rcx
-add rsp, 28
ret
; 52 bytes → 22 bytesdotnet/runtime#128350 gives the xarch register allocator more freedom around fused multiply-add (FMA) and AVX-512 ternary-logic operations. These instructions can read and overwrite operands in several equivalent arrangements; choosing the arrangement that already matches the surrounding registers avoids otherwise necessary moves. Generic vector code introduces another wrinkle. Operators like Vector128.operator == return bool, so the return type doesn't reveal the vector's element type. The JIT instead needs to obtain that type from the operands in order to select the right comparison instruction. In some generic contexts, including helpers built on the internal ISimdVector abstraction, the JIT was consulting the wrong type information and failed to import the operator as an intrinsic. It then executed the managed fallback, which compares the lanes individually. dotnet/runtime#130086 marks these operators so their element type is taken from the first argument. As an example, the generic helpers used internally by ordinal-ignore-case string comparer benefit from this.
// 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);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly string _lower = new('a', 256);
private readonly string _upper = new('A', 256);
[Benchmark]
public bool OrdinalIgnoreCase() => string.Equals(_lower, _upper, StringComparison.OrdinalIgnoreCase);
}| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| OrdinalIgnoreCase | .NET 10.0 | 27.794 ns | 1.00 |
| OrdinalIgnoreCase | .NET 11.0 | 21.861 ns | 0.79 |
dotnet/runtime#124114 from @saucecontrol improves 32-bit x86 without AVX-512, where converting uint to float or double previously required a runtime helper. Older x86 conversion instructions accept signed integers, and half of the uint range doesn't fit in a signed 32-bit value, which is why the helper existed. The JIT now emits an inline vector-instruction sequence that handles the high bit explicitly, avoiding the call and its register and stack overhead.
// Run with 32-bit x86 dotnet and AVX-512 disabled (DOTNET_EnableAVX512=0)
// 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private uint _value = 0xF123_4567;
[Benchmark] public float UInt32ToSingle() => _value;
[Benchmark] public double UInt32ToDouble() => _value;
}| Method | Runtime | Mean | Ratio | Code Size |
|---|---|---|---|---|
| UInt32ToSingle | .NET 10.0 | 4.752 ns | 1.00 | 37 B |
| UInt32ToSingle | .NET 11.0 | 2.403 ns | 0.51 | 43 B |
| UInt32ToDouble | .NET 10.0 | 4.727 ns | 1.00 | 39 B |
| UInt32ToDouble | .NET 11.0 | 2.402 ns | 0.51 | 41 B |
dotnet/runtime#124804 from @alexcovington adds the AVX-512 Bit Matrix Multiply APIs. A binary matrix treats each bit as an element and combines rows and columns with bitwise operations, not integer multiplication. The instructions are useful in areas such as error correction and CRC computation. Each replaces a much longer sequence of shifts, masks, and exclusive-ORs. And dotnet/runtime#128365 from @jamesburton adds AvxVnni.V512, extending the AVX-VNNI APIs from 256-bit to 512-bit operands so the small-integer dot products used by quantized machine-learning models can process 64 bytes per operation instead of 32. dotnet/runtime#126062 from @saucecontrol also avoids converting a vector selector into an AVX-512 mask register when the eventual operation still needs the vector form. In such cases, the older-looking vector blend is actually shorter and uses fewer resources:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Vector128 _v1 = Vector128.Create(-1.0f, 2.0f, -3.0f, 4.0f);
private readonly Vector128 _v2 = Vector128.Create(10.0f);
[GlobalSetup]
public void Setup()
{
if (!Sse41.IsSupported)
throw new PlatformNotSupportedException();
}
[Benchmark]
public Vector128 AddToNegative() =>
Sse41.BlendVariable(_v1, _v1 + _v2, _v1);
}In .NET 11, you get the simpler vblendvps form that avoids an unnecessary k-register operation.
; x64
vmovups xmm0, [rcx+8]
- vpmovd2m k1, xmm0
- vaddps xmm0 {k1}, xmm0, [rcx+18]
+ vaddps xmm1, xmm0, [rcx+18]
+ vblendvps xmm0, xmm0, xmm1, xmm0
vmovups [rdx], xmm0
; 29 bytes → 24 bytesThe masked EVEX form looks more modern, but when the mask originates from a vector anyway, the vector-blend sequence is five bytes shorter and avoids writing a mask register. There are only 8 k-registers, and some microarchitectures have port contention for instructions that write them. A compiler's cost model assigns estimates to operations and instructions, such as their execution cost or throughput and their impact on code size, and uses those estimates to choose between otherwise legal transformations or instruction sequences. Wrong estimates can still produce semantically correct code, just slower or larger code. With dotnet/runtime#127048, which updates the JIT's xarch floating-point and SIMD cost model, the JIT's cost model reflects modern instruction throughput and encoded size, replacing old x87 assumptions and a flat cost for every intrinsic. That leads to better decisions about common-subexpression elimination and loop unrolling, particularly for 512-bit operations. dotnet/runtime#130422 folds a vector lane extraction followed by WithElement into one insertps that reads the source lane directly. Code such as destination.WithElement(0, source.GetElement(2)) conceptually extracts a scalar and then inserts it elsewhere. insertps, however, has an immediate operand whose bits select both the source lane and destination lane. The JIT can therefore pass the original source vector to the instruction and encode lane 2 in that immediate, instead of first shuffling lane 2 into the scalar position and then inserting it. Three more xarch changes tighten public SIMD operations on the hardware where they apply. dotnet/runtime#125666 from @alexcovington replaces the dedicated AVX dot-product instruction with a multiply, add, and permute reduction that has better throughput on contemporary cores:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using System.Numerics;
using System.Runtime.Intrinsics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Plane _plane = new(new Vector3(1.0f, 2.0f, 3.0f), 4.0f);
private readonly Vector4 _vector4 = new(5.0f, 6.0f, 7.0f, 8.0f);
private readonly Quaternion _quaternion1 = new(1.0f, 2.0f, 3.0f, 4.0f);
private readonly Quaternion _quaternion2 = new(5.0f, 6.0f, 7.0f, 8.0f);
private readonly Vector128 _vector1 = Vector128.Create(1.0f, 2.0f, 3.0f, 4.0f);
private readonly Vector128 _vector2 = Vector128.Create(5.0f, 6.0f, 7.0f, 8.0f);
[Benchmark]
public float PlaneDot() => Plane.Dot(_plane, _vector4);
[Benchmark]
public float QuaternionDot() => Quaternion.Dot(_quaternion1, _quaternion2);
[Benchmark]
public float Vector128Dot() => Vector128.Dot(_vector1, _vector2);
}| Method | Runtime | Mean | Ratio | Code Size |
|---|---|---|---|---|
| PlaneDot | .NET 10.0 | 2.616 ns | 1.00 | 13 B |
| PlaneDot | .NET 11.0 | 1.365 ns | 0.52 | 31 B |
| QuaternionDot | .NET 10.0 | 2.640 ns | 1.00 | 13 B |
| QuaternionDot | .NET 11.0 | 1.326 ns | 0.50 | 31 B |
| Vector128Dot | .NET 10.0 | 2.597 ns | 1.00 | 13 B |
| Vector128Dot | .NET 11.0 | 1.366 ns | 0.53 | 31 B |
dotnet/runtime#126348 from @saucecontrol instead separates the even and odd bytes with masks and shifts, performs two 16-bit multiplications over the full vector width, and recombines the low bytes:
// Run on x64 with AVX-512:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using System.Runtime.Intrinsics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Vector512 _left = Vector512.Create((byte)17);
private readonly Vector512 _right = Vector512.Create((byte)19);
[Benchmark]
public Vector512 Multiply() => _left * _right;
}| Method | Runtime | Mean | Ratio | Code Size |
|---|---|---|---|---|
| Multiply | .NET 10.0 | 3.752 ns | 1.00 | 114 B |
| Multiply | .NET 11.0 | 2.174 ns | 0.58 | 73 B |
; x64
vmovups zmm0, [rcx+8]
-vmovaps zmm1, zmm0
-vpmovzxbw zmm1, ymm1
-vmovups zmm2, [rcx+48]
-vmovaps zmm3, zmm2
-vpmovzxbw zmm3, ymm3
-vpmullw zmm1, zmm3, zmm1
-vpmovwb ymm1, zmm1
-vextracti32x8 ymm0, zmm0, 1
-vpmovzxbw zmm0, ymm0
-vextracti32x8 ymm2, zmm2, 1
-vpmovzxbw zmm2, ymm2
-vpmullw zmm0, zmm2, zmm0
-vpmovwb ymm0, zmm0
-vinserti32x8 zmm0, zmm1, ymm0, 1
+vmovups zmm1, [rcx+48]
+vpmullw zmm2, zmm0, zmm1
+vpsrlw zmm0, zmm0, 8
+vpandd zmm1, zmm1, dword bcst [RWD00]
+vpmullw zmm0, zmm1, zmm0
+vpternlogd zmm0, zmm2, dword bcst [RWD04], 0F8
vmovups [rdx], zmm0
; 114 bytes → 73 bytesdotnet/runtime#127094 lets scalar conversions between Half and float use F16C's vcvtps2ph and vcvtph2ps instructions when AVX2 is enabled:
// Run on x64 with AVX2 enabled:
// 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private Half _half = (Half)123.5f;
private float _single = 123.5f;
[Benchmark] public float HalfToSingle() => (float)_half;
[Benchmark] public Half SingleToHalf() => (Half)_single;
}| Method | Runtime | Mean | Ratio | Code Size |
|---|---|---|---|---|
| HalfToSingle | .NET 10.0 | 2.506 ns | 1.00 | 104 B |
| HalfToSingle | .NET 11.0 | 1.380 ns | 0.55 | 14 B |
| SingleToHalf | .NET 10.0 | 2.598 ns | 1.00 | 134 B |
| SingleToHalf | .NET 11.0 | 1.351 ns | 0.52 | 19 B |
dotnet/runtime#127536 from @Ruihan-Yin completes support for APX, Intel's Advanced Performance Extensions. In addition to expanding the general-purpose register set, APX adds forms of many instructions that don't overwrite the processor's condition flags. That gives the register allocator and instruction scheduler more freedom to keep values and pending conditions alive at the same time. Its CTEST and CFCMOV instructions can also represent chained conditions without branches and replace some compare-with-zero forms with shorter encodings. Applications don't need to call APX-specific APIs to benefit; when the hardware and operating system expose APX, the JIT is able to utilize the additional instructions automatically. On Arm64, the work in .NET 11 spans both conventional code generation and continued support for SVE (Scalable Vector Extension). Unlike 128-bit AdvSimd vectors, an SVE vector doesn't have one width fixed by the instruction set; each processor chooses a supported width, and the same compiled loop uses predicate masks to operate on however many elements fit. That makes SVE well suited to loops whose trip counts are not exact multiples of a particular vector size. dotnet/runtime#121986 improves zeroing for larger stack allocations on Arm64. The JIT can store two zeroed 128-bit vector registers at a time, doubling the amount cleared by each instruction:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics.Arm;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
[Benchmark] public void Stackalloc512() => Consume(stackalloc byte[512]);
[Benchmark] public void Stackalloc1024() => Consume(stackalloc byte[1024]);
[Benchmark] public void Stackalloc16384() => Consume(stackalloc byte[16384]);
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Consume(Span x) { }
}| Method | Runtime | Mean | Ratio |
|---|---|---|---|
| Stackalloc512 | .NET 10.0 | 13.65 ns | 1.00 |
| Stackalloc512 | .NET 11.0 | 9.557 ns | 0.70 |
| Stackalloc1024 | .NET 10.0 | 25.35 ns | 1.00 |
| Stackalloc1024 | .NET 11.0 | 14.332 ns | 0.57 |
| Stackalloc16384 | .NET 10.0 | 312.97 ns | 1.00 |
| Stackalloc16384 | .NET 11.0 | 162.656 ns | 0.52 |
dotnet/runtime#119758 from @jonathandavies-arm lets a comparison with zero consume condition flags set as a side effect of the preceding arithmetic or logical instruction, avoiding a separate cmp. dotnet/runtime#123138 from @jonathandavies-arm recognizes bit-extraction idioms such as (value >> 6) & 0x3F and maps them to the dedicated ubfx instruction. And dotnet/runtime#123546 from @jonathandavies-arm removes a non-overflowing int-to-long widening cast when the result is immediately truncated to a smaller integer type.
// 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);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD", "left", "right", "value")]
public class Benchmarks
{
[Benchmark]
[Arguments(-1, 2)]
public bool CompareWithZero(int left, int right) => (left & right) <= 0;
[Benchmark]
[Arguments(0x7F65_4321)]
public int ExtractBits(int value) => (value >> 6) & 0x3F;
[Benchmark]
[Arguments(0x1122_3344)]
public sbyte TruncateAfterWidening(int value) => (sbyte)(long)value;
}Each example removes one instruction. CompareWithZero changes and to its flag-setting ands form and drops the subsequent cmp; ExtractBits replaces a shift and mask with ubfx; and TruncateAfterWidening drops the sxtw that widened the value to 64 bits only for sxtb to immediately truncate it again:
; Arm64
; CompareWithZero: 28 bytes → 24 bytes
- and w0, w1, w2
- cmp w0, #0
+ ands w0, w1, w2
cset x0, le
; ExtractBits: 24 bytes → 20 bytes
- asr w0, w1, #6
- and w0, w0, #63
+ ubfx w0, w1, #6, #6
; TruncateAfterWidening: 24 bytes → 20 bytes
- sxtw x0, w1
- sxtb w0, w0
+ sxtb w0, w1Instruction selection also improves where values move between registers and memory. In .NET 11, dotnet/runtime#126803 changes ToScalar on a vector of 64-bit integers to use fmov Xd, Dn rather than the lane-extract instruction umov; in both cases lane zero moves to a general-purpose register, but fmov is the more direct form. For ReadyToRun code, dotnet/runtime#129589 folds relocatable indirection-cell loads from adrp + add + ldr into adrp + ldr #:lo12:, removing the separate address addition. And dotnet/runtime#129932 re-enables ldp/stp formation for negative unscaled offsets, letting two adjacent loads or stores become one paired instruction. The first and third changes are easy to see with small methods:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private Vector128 _vector = Vector128.Create(42L, 84L);
private nint[] _storage = new nint[8];
[Benchmark]
public long ToScalar() => ToScalarCore(_vector);
[Benchmark]
public void ClearPrevious() => ClearPreviousCore(ref _storage[4]);
[MethodImpl(MethodImplOptions.NoInlining)]
private static long ToScalarCore(Vector128 value) => value.ToScalar();
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ClearPreviousCore(ref nint value)
{
Unsafe.Add(ref value, -1) = 0;
Unsafe.Add(ref value, -2) = 0;
Unsafe.Add(ref value, -3) = 0;
Unsafe.Add(ref value, -4) = 0;
}
}The ToScalar change is a direct instruction substitution, while the negative-offset stores collapse from four instructions to two, reducing the helper from 32 bytes to 24 bytes:
; Arm64
; ToScalarCore
- umov x0, v0.d[0]
+ fmov x0, d0
; ClearPreviousCore
- str xzr, [x0, #-0x08]
- str xzr, [x0, #-0x10]
- str xzr, [x0, #-0x18]
- str xzr, [x0, #-0x20]
+ stp xzr, xzr, [x0, #-0x10]
+ stp xzr, xzr, [x0, #-0x20]Bit-counting operations benefit as well. PopCount counts the one bits in a value, while TrailingZeroCount counts the zero bits below its least-significant one bit. dotnet/runtime#128677 imports both as dedicated Arm64 intrinsics, making their intent visible to later optimization. On processors with the FEAT_CSSC extension, dotnet/runtime#130332 can then lower them directly to the scalar cnt and ctz instructions. Comparison masks are another place where spelling out the intent enables much better code. Portable SIMD code often compares vectors, calls ExtractMostSignificantBits, and then asks whether any lane matched, counts matching lanes, or finds the first or last match. dotnet/runtime#129688 from @jonathandavies-arm recognizes those consumers on Arm64 and avoids materializing the full scalar mask: it can horizontally reduce the vector mask directly.
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private Vector128 _value = Vector128.Create(1, -2, 3, -4);
[Benchmark]
public bool AnyLessThan() => AnyLessThanCore(_value, 0);
[Benchmark]
public int CountLessThan() => CountLessThanCore(_value, 0);
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool AnyLessThanCore(Vector128 value, int limit) =>
Vector128.LessThan(value, Vector128.Create(limit))
.ExtractMostSignificantBits() != 0;
[MethodImpl(MethodImplOptions.NoInlining)]
private static int CountLessThanCore(Vector128 value, int limit) =>
BitOperations.PopCount(
Vector128.LessThan(value, Vector128.Create(limit))
.ExtractMostSignificantBits());
}In .NET 10, both helpers first pack the most-significant bit from every comparison lane into a scalar. .NET 11 instead keeps the comparison as a vector.
; Arm64
; AnyLessThanCore
cmgt v16.4s, v16.4s, v0.4s
- movi v17.4s, #0x80, LSL #24
- and v16.4s, v16.4s, v17.4s
- ldr q17, [@RWD00]
- ushl v16.4s, v16.4s, v17.4s
- addv s16, v16.4s
- smov x0, v16.s[0]
+ umaxv s16, v16.4s
+ umov w0, v16.s[0]
cmp w0, #0
cset x0, ne
; CountLessThanCore
cmgt v16.4s, v16.4s, v0.4s
- movi v17.4s, #0x80, LSL #24
- and v16.4s, v16.4s, v17.4s
- ldr q17, [@RWD00]
- ushl v16.4s, v16.4s, v17.4s
- addv s16, v16.4s
- movi v17.2s, #0
- smov x0, v16.s[0]
- ins v17.s[0], w0
- cnt v16.8b, v17.8b
- addv b16, v16.8b
- umov w0, v16.b[0]
+ ushr v16.4s, v16.4s, #31
+ addv s16, v16.4s
+ umov w0, v16.s[0]On the SVE and SVE2 side, dotnet/runtime#129852 from @snickolls-arm removes the old 128-bit size ceiling for Vector on Arm64 and lets the runtime size the type from the process's actual SVE vector length. (Scalable Vector remains experimental and disabled by default in .NET 11, so this expands what the experimental mode can do; it doesn't speed up the default Vector configuration.) The public intrinsic surface also grows. In .NET 11, dotnet/runtime#118957 from @SwapnilGaikwad exposes odd-lane floating-point conversions; "odd lane" here means converting elements 1, 3, 5, and so on, which is useful when widening or narrowing interleaved data. dotnet/runtime#123890 from @ylpoonlg and dotnet/runtime#123892 from @ylpoonlg add non-temporal gather loads and scatter stores, which read from or write to multiple non-contiguous addresses (the "gather" part) while hinting that the data need not remain in cache (the "non-temporal" part). Other changes improve the predicates that make scalable loops work. dotnet/runtime#127538 adds hardware-generated predicate masks for more loop and memory-access patterns, while dotnet/runtime#126398 from @ylpoonlg reduces setup moves for masked operations. And dotnet/runtime#128326 from @snickolls-arm improves how SVE masks flow through the JIT, allowing zeroing forms of instructions to replace separate constant setup. dotnet/runtime#127520 from @a74nh enables scalable vector and mask constants, and dotnet/runtime#128148 from @snickolls-arm uses vector stores to initialize scalable vector locals, replacing scalar loops.
Register Allocation
Generated code constantly moves values between the CPU's limited set of fast registers and temporary stack slots. Register allocation in a compiler decides which values stay in registers and which are "spilled" to the stack; avoiding one spill can remove both the store and the later reload. Some small structs are passed with multiple fields packed into one register. In .NET 11, dotnet/runtime#112740 lets the JIT extract those fields directly, avoiding a "spill" to a temporary stack slot followed by a reload of each field:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Drawing;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private readonly Memory[] _memories = CreateMemories();
private static Memory[] CreateMemories()
{
Random rng = new(42);
var memories = new Memory[4096];
for (int i = 0; i < memories.Length; i++)
memories[i] = new int[rng.Next(0, 20)];
return memories;
}
[Method