The classic ValueTask optimization
In high-performance .NET development, we often use ValueTask to avoid heap allocations
when an asynchronous operation completes synchronously (e.g., from a memory cache).
However, even when a method completes synchronously, an async ValueTask method still incurs some
overhead because the compiler must set up the asynchronous state machine.
To squeeze out every nanosecond of performance, developers have long relied on a classic optimization: splitting the method into a synchronous fast-path wrapper and an asynchronous slow-path helper.
Consider this standard implementation versus the split optimized implementation:
using System.Runtime.CompilerServices; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; namespace Beskar.Networking.Benchmarks.Common; [SimpleJob(RuntimeMoniker.HostProcess, launchCount: 1, warmupCount: 2)] [MemoryDiagnoser] public class ValueTaskBenchmarks { private const int Mask = 0x3FF; private bool[] _cacheHitPattern = null!; private int _index; [Params(0, 25, 50, 75, 100)] public int SyncPercentage { get; set; } [GlobalSetup] public void Setup() { _cacheHitPattern = new bool[1024]; for (var i = 0; i < 1024; i++) { _cacheHitPattern[i] = (i % 100) < SyncPercentage; } } [Benchmark] public async ValueTask<int> StandardAsyncValueTask() { var isHit = _cacheHitPattern[_index++ & Mask]; return await GetValueStandardAsync(isHit); } [Benchmark] public ValueTask<int> OptimizedValueTask() { var isHit = _cacheHitPattern[_index++ & Mask]; if (isHit) { return ValueTask.FromResult(42); } return GetValueOptimizedAsync(); } [MethodImpl(MethodImplOptions.NoInlining)] private async ValueTask<int> GetValueStandardAsync(bool isHit) { if (isHit) { return 42; } await Task.Yield(); return 42; } [MethodImpl(MethodImplOptions.NoInlining)] private async ValueTask<int> GetValueOptimizedAsync() { await Task.Yield(); return 42; } }
In .NET 10.0, the optimized approach does exactly what we expect: it bypasses
the state machine setup entirely when isHit is true, returning a pre-constructed
synchronous ValueTask via ValueTask.FromResult(42).
Measuring performance in .NET 10.0
Let's look at the benchmarks under .NET 10.0:
Runtime=.NET 10.0 LaunchCount=1 WarmupCount=2
| Method | SyncPercentage | Mean | Error | StdDev | Median | Gen0 | Allocated |
|----------------------- |--------------- |-----------:|----------:|-----------:|-----------:|-------:|----------:|
| StandardAsyncValueTask | 0 | 507.226 ns | 9.2749 ns | 10.3090 ns | 503.893 ns | 0.0038 | 222 B |
| OptimizedValueTask | 0 | 492.457 ns | 7.3778 ns | 6.9012 ns | 491.572 ns | 0.0019 | 104 B |
| StandardAsyncValueTask | 25 | 374.620 ns | 3.3229 ns | 2.7748 ns | 375.165 ns | 0.0029 | 162 B |
| OptimizedValueTask | 25 | 354.458 ns | 3.6797 ns | 3.2619 ns | 354.641 ns | 0.0014 | 76 B |
| StandardAsyncValueTask | 50 | 256.692 ns | 4.1552 ns | 3.6835 ns | 255.347 ns | 0.0019 | 108 B |
| OptimizedValueTask | 50 | 319.017 ns | 7.0536 ns | 20.1243 ns | 319.479 ns | 0.0010 | 51 B |
| StandardAsyncValueTask | 75 | 160.875 ns | 3.2362 ns | 3.1784 ns | 159.733 ns | 0.0010 | 53 B |
| OptimizedValueTask | 75 | 137.021 ns | 6.2172 ns | 18.3314 ns | 126.698 ns | 0.0005 | 25 B |
| StandardAsyncValueTask | 100 | 14.277 ns | 0.3059 ns | 0.7842 ns | 14.560 ns | - | - |
| OptimizedValueTask | 100 | 1.582 ns | 0.0348 ns | 0.0308 ns | 1.579 ns | - | - |At SyncPercentage = 100 (always completing synchronously), the OptimizedValueTask is nearly 9x faster
than StandardAsyncValueTask (1.582 ns vs 14.277 ns) and performs zero allocations. Even on the 100% asynchronous
path (SyncPercentage = 0), the optimized pattern is slightly faster and allocates less than half the heap memory
(104 B vs 222 B) because the outer benchmark method doesn't need to spin up its own compiler state machine.
Enter .NET 11 Runtime Async
Disclaimer: The features and performance characteristics discussed here are based on the .NET 11.0.0-preview.5 compiler and runtime. As an experimental preview feature, implementation details, optimization paths, and behavior may change before the final stable release.
In .NET 11, the runtime team is introducing an experimental optimization known as Runtime Async (also referred to as JIT-implemented async methods).
Historically, the C# compiler transformed async/await methods at compile-time into complex state
machine structures (IAsyncStateMachine). Under Runtime Async, the responsibility shifts from compile-time
rewriting to the runtime JIT. The compiler emits cleaner, unwritten Intermediate Language (IL) decorated
with [MethodImpl(MethodImplOptions.Async)], and the JIT handles stack suspension, frame allocation,
and resumption natively.
Let's look at the benchmarks run on .NET 11.0.0-preview.5:
Runtime=11.0.0-preview.5.26302.115 LaunchCount=1 WarmupCount=2
| Method | SyncPercentage | Mean | Error | StdDev | Gen0 | Allocated |
|----------------------- |--------------- |-----------:|----------:|----------:|-------:|----------:|
| StandardAsyncValueTask | 0 | 170.743 ns | 1.8055 ns | 1.6005 ns | 0.0033 | 168 B |
| OptimizedValueTask | 0 | 352.297 ns | 3.8004 ns | 3.3689 ns | 0.0067 | 342 B |
| StandardAsyncValueTask | 25 | 150.347 ns | 0.6093 ns | 0.5088 ns | 0.0024 | 123 B |
| OptimizedValueTask | 25 | 263.793 ns | 1.2434 ns | 1.0383 ns | 0.0048 | 251 B |
| StandardAsyncValueTask | 50 | 99.046 ns | 0.4761 ns | 0.3976 ns | 0.0015 | 82 B |
| OptimizedValueTask | 50 | 180.598 ns | 1.7667 ns | 1.4752 ns | 0.0033 | 171 B |
| StandardAsyncValueTask | 75 | 50.930 ns | 0.1352 ns | 0.1056 ns | 0.0008 | 41 B |
| OptimizedValueTask | 75 | 94.923 ns | 0.6548 ns | 0.5468 ns | 0.0017 | 84 B |
| StandardAsyncValueTask | 100 | 3.306 ns | 0.0488 ns | 0.0381 ns | - | - |
| OptimizedValueTask | 100 | 7.360 ns | 0.0502 ns | 0.0469 ns | - | - |The tables have completely turned:
- The Async Path (
SyncPercentage = 0):StandardAsyncValueTaskis now twice as fast (170 ns vs 352 ns) and allocates half the memory (168 B vs 342 B) compared to the "optimized" version. - The Sync Path (
SyncPercentage = 100): Even on the pure synchronous fast-path, the standard method is more than twice as fast (3.3 ns vs 7.3 ns) than the hand-optimized split pattern!
Our "optimization" has become a severe deoptimization across the board. To understand why, we have to look under the hood at the compiled IL.
Under the hood: C# compilation comparison
To see what is happening, let's compare how the C# compiler lowers these methods under the two different execution models.
.NET 11 Lowered C# (Runtime Async)
Under .NET 11's Runtime Async, the compiler outputs simplified methods marked
with MethodImplOptions.Async and utilizes native runtime helpers (like AsyncHelpers)
rather than generating state machine structs:
public class ValueTaskBenchmarks { private const int Mask = 1023; [Nullable(1)] private bool[] _cacheHitPattern = null; private int _index; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private int <SyncPercentage>k__BackingField; public int SyncPercentage { [CompilerGenerated] get { return <SyncPercentage>k__BackingField; } [CompilerGenerated] set { <SyncPercentage>k__BackingField = value; } } public void Setup() { _cacheHitPattern = new bool[1024]; int num = 0; while (num < 1024) { _cacheHitPattern[num] = num % 100 < SyncPercentage; num++; } } [MethodImpl(MethodImplOptions.Async)] public ValueTask<int> StandardAsyncValueTask() { //IL_0033: Expected O, but got I4 bool isHit = _cacheHitPattern[_index++ & 0x3FF]; int num = AsyncHelpers.Await(GetValueStandardAsync(isHit)); int num2 = num; return (ValueTask<int>)num2; } public ValueTask<int> OptimizedValueTask() { if (_cacheHitPattern[_index++ & 0x3FF]) { return ValueTask.FromResult(42); } return GetValueOptimizedAsync(); } [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.Async)] private ValueTask<int> GetValueStandardAsync(bool isHit) { //IL_0039: Expected O, but got I4 int num; if (isHit) { num = 42; } else { YieldAwaitable.YieldAwaiter awaiter = Task.Yield().GetAwaiter(); if (!awaiter.IsCompleted) { AsyncHelpers.UnsafeAwaitAwaiter(awaiter); } awaiter.GetResult(); num = 42; } return (ValueTask<int>)num; } [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.Async)] private ValueTask<int> GetValueOptimizedAsync() { //IL_002e: Expected O, but got I4 YieldAwaitable.YieldAwaiter awaiter = Task.Yield().GetAwaiter(); if (!awaiter.IsCompleted) { AsyncHelpers.UnsafeAwaitAwaiter(awaiter); } awaiter.GetResult(); int num = 42; return (ValueTask<int>)num; } }
Traditional Lowered C# (State Machine Model)
Before .NET 11, the compiler generated nested classes or structs implementing IAsyncStateMachine
to manage local variables and resumption points:
public class ValueTaskBenchmarks { [CompilerGenerated] private sealed class <GetValueOptimizedAsync>d__11 : IAsyncStateMachine { public int <>1__state; public AsyncValueTaskMethodBuilder<int> <>t__builder; public ValueTaskBenchmarks <>4__this; private YieldAwaitable.YieldAwaiter <>u__1; private void MoveNext() { int num = <>1__state; int result; try { YieldAwaitable.YieldAwaiter awaiter; if (num != 0) { awaiter = Task.Yield().GetAwaiter(); if (!awaiter.IsCompleted) { num = (<>1__state = 0); <>u__1 = awaiter; <GetValueOptimizedAsync>d__11 stateMachine = this; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } } else { awaiter = <>u__1; <>u__1 = default(YieldAwaitable.YieldAwaiter); num = (<>1__state = -1); } awaiter.GetResult(); result = 42; } catch (Exception exception) { <>1__state = -2; <>t__builder.SetException(exception); return; } <>1__state = -2; <>t__builder.SetResult(result); } void IAsyncStateMachine.MoveNext() { this.MoveNext(); } [DebuggerHidden] private void SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine) { } void IAsyncStateMachine.SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine) { this.SetStateMachine(stateMachine); } } [CompilerGenerated] private sealed class <GetValueStandardAsync>d__10 : IAsyncStateMachine { public int <>1__state; public AsyncValueTaskMethodBuilder<int> <>t__builder; public bool isHit; public ValueTaskBenchmarks <>4__this; private YieldAwaitable.YieldAwaiter <>u__1; private void MoveNext() { int num = <>1__state; int result; try { YieldAwaitable.YieldAwaiter awaiter; if (num == 0) { awaiter = <>u__1; <>u__1 = default(YieldAwaitable.YieldAwaiter); num = (<>1__state = -1); goto IL_007b; } if (!isHit) { awaiter = Task.Yield().GetAwaiter(); if (!awaiter.IsCompleted) { num = (<>1__state = 0); <>u__1 = awaiter; <GetValueStandardAsync>d__10 stateMachine = this; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } goto IL_007b; } result = 42; goto end_IL_0007; IL_007b: awaiter.GetResult(); result = 42; end_IL_0007:; } catch (Exception exception) { <>1__state = -2; <>t__builder.SetException(exception); return; } <>1__state = -2; <>t__builder.SetResult(result); } void IAsyncStateMachine.MoveNext() { this.MoveNext(); } [DebuggerHidden] private void SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine) { } void IAsyncStateMachine.SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine) { this.SetStateMachine(stateMachine); } } [CompilerGenerated] private sealed class <StandardAsyncValueTask>d__8 : IAsyncStateMachine { public int <>1__state; public AsyncValueTaskMethodBuilder<int> <>t__builder; public ValueTaskBenchmarks <>4__this; private bool <isHit>5__1; private int <>s__2; private ValueTaskAwaiter<int> <>u__1; private void MoveNext() { int num = <>1__state; int result; try { ValueTaskAwaiter<int> awaiter; if (num != 0) { bool[] cacheHitPattern = <>4__this._cacheHitPattern; ValueTaskBenchmarks valueTaskBenchmarks = <>4__this; int index = <>4__this._index; valueTaskBenchmarks._index = index + 1; <isHit>5__1 = cacheHitPattern[index & 0x3FF]; awaiter = <>4__this.GetValueStandardAsync(<isHit>5__1).GetAwaiter(); if (!awaiter.IsCompleted) { num = (<>1__state = 0); <>u__1 = awaiter; <StandardAsyncValueTask>d__8 stateMachine = this; <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } } else { awaiter = <>u__1; <>u__1 = default(ValueTaskAwaiter<int>); num = (<>1__state = -1); } <>s__2 = awaiter.GetResult(); result = <>s__2; } catch (Exception exception) { <>1__state = -2; <>t__builder.SetException(exception); return; } <>1__state = -2; <>t__builder.SetResult(result); } void IAsyncStateMachine.MoveNext() { this.MoveNext(); } [DebuggerHidden] private void SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine) { } void IAsyncStateMachine.SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine) { this.SetStateMachine(stateMachine); } } private const int Mask = 1023; [Nullable(1)] private bool[] _cacheHitPattern = null; private int _index; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private int <SyncPercentage>k__BackingField; public int SyncPercentage { [CompilerGenerated] get { return <SyncPercentage>k__BackingField; } [CompilerGenerated] set { <SyncPercentage>k__BackingField = value; } } public void Setup() { _cacheHitPattern = new bool[1024]; int num = 0; while (num < 1024) { _cacheHitPattern[num] = num % 100 < SyncPercentage; num++; } } [AsyncStateMachine(typeof(<StandardAsyncValueTask>d__8))] [DebuggerStepThrough] public ValueTask<int> StandardAsyncValueTask() { <StandardAsyncValueTask>d__8 stateMachine = new <StandardAsyncValueTask>d__8(); stateMachine.<>t__builder = AsyncValueTaskMethodBuilder<int>.Create(); stateMachine.<>4__this = this; stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task; } public ValueTask<int> OptimizedValueTask() { if (_cacheHitPattern[_index++ & 0x3FF]) { return ValueTask.FromResult(42); } return GetValueOptimizedAsync(); } [MethodImpl(MethodImplOptions.NoInlining)] [AsyncStateMachine(typeof(<GetValueStandardAsync>d__10))] [DebuggerStepThrough] private ValueTask<int> GetValueStandardAsync(bool isHit) { <GetValueStandardAsync>d__10 stateMachine = new <GetValueStandardAsync>d__10(); stateMachine.<>t__builder = AsyncValueTaskMethodBuilder<int>.Create(); stateMachine.<>4__this = this; stateMachine.isHit = isHit; stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task; } [MethodImpl(MethodImplOptions.NoInlining)] [AsyncStateMachine(typeof(<GetValueOptimizedAsync>d__11))] [DebuggerStepThrough] private ValueTask<int> GetValueOptimizedAsync() { <GetValueOptimizedAsync>d__11 stateMachine = new <GetValueOptimizedAsync>d__11(); stateMachine.<>t__builder = AsyncValueTaskMethodBuilder<int>.Create(); stateMachine.<>4__this = this; stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task; } }
Why the optimization backfires
There are three primary reasons why this split-method optimization pattern causes a performance regression in the Runtime Async model:
1. Async-to-Async Native Cohesion
When two native async methods call each other (e.g., StandardAsyncValueTask() calling GetValueStandardAsync()),
both exist entirely within the runtime's native async context. The JIT compiler understands
this relationship and can generate highly optimized transitions.
When a call remains synchronous, the runtime does not construct task wrappers or
state tracking objects; the JIT can optimize the execution to pass variables directly
via CPU registers or lightweight stack slots. This makes StandardAsyncValueTask extremely cheap (3.306 ns)
on synchronous hits.
2. The Synchronous Boundary Penalty
In OptimizedValueTask(), the benchmark method is a standard synchronous method returning a ValueTask<int>.
Because it is not marked as a native async method, the runtime-async JIT optimizations cannot span across it.
When the synchronous path is missed and it has to call GetValueOptimizedAsync(), the execution crosses
a synchronous-to-asynchronous boundary. The runtime is forced to allocate and set up a heavier task boundary
block to return a ValueTask<int> that the synchronous caller can consume.
This boundary crossing doubles the execution time and allocation overhead on the asynchronous
path (352 ns and 342 B allocated vs 170 ns and 168 B allocated).
3. Struct Construction vs JIT Register Return
On a 100% synchronous hit, the optimized method executes:
return ValueTask.FromResult(42);
Even though this does not allocate on the heap, it still forces the creation of a
full ValueTask<int> struct on the stack, filling its fields (result and task source pointer)
and returning it to the caller.
Under Runtime Async, the standard async method StandardAsyncValueTask and its
target GetValueStandardAsync are compiled together as native async operations.
Because of this, on a synchronous hit, the JIT completely avoids constructing the
intermediate ValueTask<int> struct at the machine code level. Instead,
it extracts the returned scalar 42 directly in a register.
This direct register return is why the standard method runs in 3.3 ns compared
to the optimized method's 7.3 ns.
Takeaways for high-performance C#
The rise of Runtime Async in .NET 11 highlights a recurring theme in compiler and runtime optimization: micro-optimizations based on compiler implementation details eventually age into bottlenecks.
By writing explicit, split-method wrappers to dodge state machines, we created code
that is harder to read, harder to maintain, and now significantly slower in modern runtimes.
The .NET 11 JIT has been optimized to compile standard, idiomatic C# async/await code into native machinery.
For your hot paths in .NET 11:
- Prefer idiomatic C#
async/awaitcode over manual state machine avoidance. - Profile and measure your performance patterns using preview runtimes to catch regressions early.
- Trust the runtime to optimize async-to-async paths. Let the JIT compiler do its job.