Skip to content

.NET 11 preview: Tier 0 code for async method chains runs about 2x slower than on .NET 10 #132943

Closed

Description

@wholroyd

Description

This looks like a warm-up performance regression for Tier 0 async code under the default compilation mode. While a chain of classic compiler-generated async methods waits for its Tier 1 recompile, it runs 1.5 to 2.6x slower than the same code does on .NET 10 during the same wait. Tier 1 itself got faster than .NET 10. Code compiled with <Features>runtime-async=on</Features> never has the slow phase at all. Since classic compilation is what nearly all code uses today, the slow window is the first thing existing apps and benchmark suites see on .NET 11. I noticed because the benchmark suite reported a large regression on its first preview 6 run, and again on preview 7, with fixed warmups that sat inside the window where Tier 1 would normally kick in.

using System.Diagnostics;

const int Chunk = 20_000;
const int Chunks = 20;

bool direct = args.Length > 0 && args[0] == "direct";
Func<Task> call = direct ? Driver : HandleRequest;
string label = direct ? "direct " : "layered";

var wall = Stopwatch.StartNew();
for (int c = 1; c <= Chunks; c++)
{
    long ticks = 0;
    for (int i = 0; i < Chunk; i++)
    {
        long t0 = Stopwatch.GetTimestamp();
        await call();
        ticks += Stopwatch.GetTimestamp() - t0;
    }
    double nsPerCall = ticks * (1_000_000_000.0 / Stopwatch.Frequency) / Chunk;
    Console.WriteLine($"{Environment.Version} | {label} | calls {c * Chunk,7:N0} | wall {wall.Elapsed.TotalMilliseconds,6:F0} ms | {nsPerCall,6:F0} ns/call");
}

static async Task HandleRequest() => await ValidateAsync();
static async Task ValidateAsync()  => await ServiceAsync();
static async Task ServiceAsync()   => await RepositoryAsync();
static async Task RepositoryAsync() => await CacheAsync();
static async Task CacheAsync()     => await StorageAsync();
static async Task StorageAsync()   => await Driver();

static Task Driver()
{
    var io = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
    ThreadPool.UnsafeQueueUserWorkItem(static s => s.SetResult(), io, preferLocal: false);
    return io.Task;
}

Create a new project file for the above Program.cs file...

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net10.0;net11.0</TargetFrameworks>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Execute it...

dotnet run -c Release -f net10.0
dotnet run -c Release -f net11.0

Configuration

.NET 10.0.7 vs .NET 11.0.0-preview.7.26381.103 (SDK 11.0.100-preview.7.26381.103). Also present on preview 6. macOS 26.5.2, Apple M5, 10 cores, ARM64.

Regression?

Yes, from .NET 10, for the pre-Tier-1 window it is 1.5 to 2.6x slower than .NET 10 at the same point, and 4 to 5x slower than the preview's own final speed. After Tier 1 the preview is faster than .NET 10, so this is specifically about the code that runs before the recompile. Programs that never get there (short-lived processes, such as my benchmarks) spend their whole life driving with the parking brake on.

Obviously with this finding, I think I'll need to restructure my benchmarks a bit differently to somehow ensure I'm capturing post-optimization numbers only. I still think this requires a call out as others might trip on this change in behavior.

Data

Every number below is the program's own output: a per-call average printed every 20,000 calls, one process per run, no warmup. I ran the whole matrix in one sitting on the same machine with both runtimes under default settings, then each runtime with one tiering setting turned off, then the same three configurations again with runtime-async enabled.

Default settings first, full runs:

.NET 10                                       .NET 11 preview 7
calls  20,000 |    64 ms |  3,167 ns/call     calls  20,000 |   127 ms |  6,326 ns/call
calls  40,000 |   121 ms |  2,654 ns/call     calls  40,000 |   270 ms |  6,965 ns/call
calls  60,000 |   176 ms |  2,736 ns/call     calls  60,000 |   405 ms |  6,694 ns/call
calls  80,000 |   230 ms |  2,660 ns/call     calls  80,000 |   528 ms |  6,163 ns/call
calls 100,000 |   284 ms |  2,711 ns/call     calls 100,000 |   645 ms |  5,821 ns/call
calls 120,000 |   359 ms |  3,696 ns/call     calls 120,000 |   767 ms |  6,083 ns/call
calls 140,000 |   430 ms |  3,567 ns/call     calls 140,000 |   862 ms |  4,731 ns/call
calls 160,000 |   507 ms |  3,833 ns/call     calls 160,000 |   898 ms |  1,731 ns/call
calls 180,000 |   575 ms |  3,362 ns/call     calls 180,000 |   929 ms |  1,541 ns/call
calls 200,000 |   640 ms |  3,209 ns/call     calls 200,000 |   960 ms |  1,537 ns/call
calls 220,000 |   710 ms |  3,501 ns/call     calls 220,000 |   991 ms |  1,534 ns/call
calls 240,000 |   772 ms |  3,106 ns/call     calls 240,000 | 1,022 ms |  1,535 ns/call
calls 260,000 |   835 ms |  3,129 ns/call     calls 260,000 | 1,054 ms |  1,549 ns/call
calls 280,000 |   900 ms |  3,225 ns/call     calls 280,000 | 1,085 ms |  1,551 ns/call
calls 300,000 |   959 ms |  2,922 ns/call     calls 300,000 | 1,117 ms |  1,565 ns/call
calls 320,000 | 1,023 ms |  3,175 ns/call     calls 320,000 | 1,148 ms |  1,563 ns/call
calls 340,000 | 1,083 ms |  2,965 ns/call     calls 340,000 | 1,180 ms |  1,556 ns/call
calls 360,000 | 1,143 ms |  2,997 ns/call     calls 360,000 | 1,211 ms |  1,537 ns/call
calls 380,000 | 1,194 ms |  2,535 ns/call     calls 380,000 | 1,242 ms |  1,526 ns/call
calls 400,000 | 1,242 ms |  2,359 ns/call     calls 400,000 | 1,273 ms |  1,541 ns/call

.NET 10 stays near its final speed the whole way, drifting between 2.4 and 3.8 us. The preview runs its slow phase at 5 to 7 us and then collapses to about 1.5 us. The 4,731 ns chunk is the switch landing partway through a chunk. Across my runs the slow phase reads 5.4 to 7.9 us and the switch lands between 862 and 1,295 ms in a loop like this one. The depth-1 control (-- direct, the completion with no layers above it) is faster on the preview, so the chain is what is slow, not the completion.

Turning the two tiering settings off shows where this comes from. When it comes to dotnet 10, it reads the same under every setting. When it comes to the preview, tiering off removes both phases, and PGO off keeps the slow phase but switches within 400 ms:

DOTNET_TieredCompilation=0
.NET 10                                       .NET 11 preview 7
calls  20,000 |    66 ms |  3,279 ns/call     calls  20,000 |    72 ms |  3,603 ns/call
calls  40,000 |   116 ms |  2,284 ns/call     calls  40,000 |   135 ms |  2,976 ns/call
calls  60,000 |   163 ms |  2,332 ns/call     calls  60,000 |   194 ms |  2,934 ns/call
calls  80,000 |   210 ms |  2,376 ns/call     calls  80,000 |   254 ms |  2,972 ns/call
calls 100,000 |   260 ms |  2,459 ns/call     calls 100,000 |   314 ms |  2,950 ns/call
calls 120,000 |   310 ms |  2,478 ns/call     calls 120,000 |   372 ms |  2,909 ns/call
calls 140,000 |   359 ms |  2,425 ns/call     calls 140,000 |   431 ms |  2,921 ns/call
calls 160,000 |   409 ms |  2,505 ns/call     calls 160,000 |   490 ms |  2,935 ns/call
calls 180,000 |   460 ms |  2,533 ns/call     calls 180,000 |   548 ms |  2,874 ns/call
calls 200,000 |   516 ms |  2,746 ns/call     calls 200,000 |   607 ms |  2,927 ns/call
calls 220,000 |   576 ms |  2,990 ns/call     calls 220,000 |   666 ms |  2,934 ns/call
calls 240,000 |   622 ms |  2,263 ns/call     calls 240,000 |   726 ms |  2,963 ns/call
calls 260,000 |   672 ms |  2,509 ns/call     calls 260,000 |   786 ms |  2,964 ns/call
calls 280,000 |   719 ms |  2,323 ns/call     calls 280,000 |   845 ms |  2,927 ns/call
calls 300,000 |   763 ms |  2,160 ns/call     calls 300,000 |   911 ms |  3,283 ns/call
calls 320,000 |   809 ms |  2,306 ns/call     calls 320,000 |   969 ms |  2,902 ns/call
calls 340,000 |   860 ms |  2,496 ns/call     calls 340,000 | 1,029 ms |  2,940 ns/call
calls 360,000 |   912 ms |  2,598 ns/call     calls 360,000 | 1,088 ms |  2,949 ns/call
calls 380,000 |   953 ms |  2,008 ns/call     calls 380,000 | 1,147 ms |  2,949 ns/call
calls 400,000 | 1,014 ms |  3,062 ns/call     calls 400,000 | 1,207 ms |  2,934 ns/call
DOTNET_TieredPGO=0
.NET 10                                       .NET 11 preview 7
calls  20,000 |    63 ms |  3,120 ns/call     calls  20,000 |   111 ms |  5,505 ns/call
calls  40,000 |   120 ms |  2,670 ns/call     calls  40,000 |   227 ms |  5,546 ns/call
calls  60,000 |   173 ms |  2,635 ns/call     calls  60,000 |   320 ms |  4,638 ns/call
calls  80,000 |   224 ms |  2,544 ns/call     calls  80,000 |   383 ms |  3,133 ns/call
calls 100,000 |   274 ms |  2,451 ns/call     calls 100,000 |   439 ms |  2,775 ns/call
calls 120,000 |   319 ms |  2,258 ns/call     calls 120,000 |   480 ms |  2,049 ns/call
calls 140,000 |   368 ms |  2,425 ns/call     calls 140,000 |   522 ms |  2,069 ns/call
calls 160,000 |   418 ms |  2,493 ns/call     calls 160,000 |   564 ms |  2,077 ns/call
calls 180,000 |   464 ms |  2,283 ns/call     calls 180,000 |   606 ms |  2,073 ns/call
calls 200,000 |   510 ms |  2,274 ns/call     calls 200,000 |   648 ms |  2,079 ns/call
calls 220,000 |   556 ms |  2,277 ns/call     calls 220,000 |   690 ms |  2,056 ns/call
calls 240,000 |   603 ms |  2,300 ns/call     calls 240,000 |   731 ms |  2,066 ns/call
calls 260,000 |   647 ms |  2,206 ns/call     calls 260,000 |   773 ms |  2,069 ns/call
calls 280,000 |   694 ms |  2,306 ns/call     calls 280,000 |   815 ms |  2,051 ns/call
calls 300,000 |   741 ms |  2,352 ns/call     calls 300,000 |   857 ms |  2,078 ns/call
calls 320,000 |   785 ms |  2,189 ns/call     calls 320,000 |   900 ms |  2,162 ns/call
calls 340,000 |   836 ms |  2,512 ns/call     calls 340,000 |   943 ms |  2,094 ns/call
calls 360,000 |   888 ms |  2,587 ns/call     calls 360,000 |   985 ms |  2,090 ns/call
calls 380,000 |   942 ms |  2,681 ns/call     calls 380,000 | 1,027 ms |  2,087 ns/call
calls 400,000 |   996 ms |  2,652 ns/call     calls 400,000 | 1,068 ms |  2,042 ns/call

Then the same matrix with runtime-async enabled, but given it's a new feature with net11.0, thats all we will have to show here. Ideally, this is just showing the numbers using the new runtime for comparison's sake.

dotnet build -c Release -f net11.0 -p:Features=runtime-async=on --no-incremental
dotnet run -c Release -f net11.0 --no-build

With runtime-async on there is no slow phase under any of the three configurations. Every chunk reads between 1.6 and 2.1 us, fast from the first. I stopped these runs at 160,000 calls since there is no later phase to wait for:

.NET 11 preview 7, runtime-async on
                  default          TieredCompilation=0   TieredPGO=0
calls  20,000 |  2,051 ns/call    1,951 ns/call         2,145 ns/call
calls  40,000 |  1,905 ns/call    1,652 ns/call         1,939 ns/call
calls  60,000 |  1,805 ns/call    1,641 ns/call         1,884 ns/call
calls  80,000 |  1,808 ns/call    1,639 ns/call         1,903 ns/call
calls 100,000 |  1,824 ns/call    1,665 ns/call         1,811 ns/call
calls 120,000 |  1,876 ns/call    1,662 ns/call         1,622 ns/call
calls 140,000 |  1,864 ns/call    1,668 ns/call         1,594 ns/call
calls 160,000 |  2,098 ns/call    1,673 ns/call         1,607 ns/call

The whole matrix at a glance:

                             .NET 10              .NET 11 preview 7
default                      drifts 2.4-3.8 us    early 5.4-7.9 us, late ~1.5 us, flip at 862-1,295 ms
DOTNET_TieredCompilation=0   flat, ~2.4 us        flat, ~2.9 us (neither phase)
DOTNET_TieredPGO=0           flat, 2.2-2.7 us     early ~5.5 us, late ~2.1 us, flip within 400 ms
runtime-async on             n/a                  flat, 1.6-2.1 us under all three settings

Two measurement notes for anyone reproducing. Running the calls in bursts with idle gaps does not avoid the slow phase, though a bursty schedule leaves the phase quickly on both runtimes. And BenchmarkDotNet's in-process toolchain inflates the preview's numbers by a further ~1.5x while leaving .NET 10 unchanged, so measure out of process.

Activity

  1. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Aug 30, 2026
  2. dotnet-policy-service commented on Aug 30, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

  3. wholroyd commented on Aug 30, 2026

    @wholroyd
    Author

    Simplified it also into Benchmarks.cs and mentioned it in the initial filing, but forgot to attach it.

  4. added this to the 11.0.0 milestone on Aug 31, 2026
  5. removed
    untriagedNew issue has not been triaged by the area owner
    on Aug 31, 2026
  6. AndyAyersMS commented on Aug 31, 2026

    @AndyAyersMS
    Member

    Thanks for reporting this.

    Looks like this was introduced by #128606, and is fixed in main by #132765. A .NET 11 backport #132908 is still open.

    @VSadov going to reassign this to you.

  7. added and removed
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Aug 31, 2026
  8. dotnet-policy-service commented on Aug 31, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @VSadov
    See info in area-owners.md if you want to be subscribed.

  9. VSadov commented on Sep 10, 2026

    @VSadov
    Member

    I can reproduce this on rc1, but on the resent nt11.0 bits this is fixed
    (the build includes #132908)

    Here is on win-x64 with the repro app, dfault settings (no runtime-async)

    === net10.0 (starts at 5-4 usec/call, settles at 2.5 us/call)

    10.0.12 | layered | calls  20,000 | wall    123 ms |   5374 ns/call
    10.0.12 | layered | calls  40,000 | wall    209 ms |   3879 ns/call
    10.0.12 | layered | calls  60,000 | wall    286 ms |   3800 ns/call
    10.0.12 | layered | calls  80,000 | wall    363 ms |   3830 ns/call
    10.0.12 | layered | calls 100,000 | wall    452 ms |   4400 ns/call
    10.0.12 | layered | calls 120,000 | wall    547 ms |   4673 ns/call
    10.0.12 | layered | calls 140,000 | wall    643 ms |   4771 ns/call
    10.0.12 | layered | calls 160,000 | wall    749 ms |   5244 ns/call
    10.0.12 | layered | calls 180,000 | wall    863 ms |   5634 ns/call
    10.0.12 | layered | calls 200,000 | wall    964 ms |   5009 ns/call
    10.0.12 | layered | calls 220,000 | wall   1070 ms |   5239 ns/call
    10.0.12 | layered | calls 240,000 | wall   1186 ms |   5773 ns/call
    10.0.12 | layered | calls 260,000 | wall   1280 ms |   4664 ns/call
    10.0.12 | layered | calls 280,000 | wall   1366 ms |   4245 ns/call
    10.0.12 | layered | calls 300,000 | wall   1426 ms |   2945 ns/call
    10.0.12 | layered | calls 320,000 | wall   1478 ms |   2536 ns/call
    10.0.12 | layered | calls 340,000 | wall   1531 ms |   2623 ns/call
    10.0.12 | layered | calls 360,000 | wall   1585 ms |   2647 ns/call
    10.0.12 | layered | calls 380,000 | wall   1636 ms |   2511 ns/call
    10.0.12 | layered | calls 400,000 | wall   1689 ms |   2596 ns/call
    10.0.12 | layered | calls 420,000 | wall   1742 ms |   2601 ns/call
    10.0.12 | layered | calls 440,000 | wall   1795 ms |   2600 ns/call
    10.0.12 | layered | calls 460,000 | wall   1847 ms |   2561 ns/call
    10.0.12 | layered | calls 480,000 | wall   1900 ms |   2588 ns/call
    10.0.12 | layered | calls 500,000 | wall   1955 ms |   2715 ns/call
    10.0.12 | layered | calls 520,000 | wall   2008 ms |   2625 ns/call
    10.0.12 | layered | calls 540,000 | wall   2060 ms |   2528 ns/call
    10.0.12 | layered | calls 560,000 | wall   2114 ms |   2670 ns/call
    10.0.12 | layered | calls 580,000 | wall   2166 ms |   2563 ns/call
    10.0.12 | layered | calls 600,000 | wall   2220 ms |   2633 ns/call
    10.0.12 | layered | calls 620,000 | wall   2275 ms |   2722 ns/call
    10.0.12 | layered | calls 640,000 | wall   2330 ms |   2676 ns/call
    10.0.12 | layered | calls 660,000 | wall   2381 ms |   2537 ns/call
    10.0.12 | layered | calls 680,000 | wall   2434 ms |   2604 ns/call
    

    net 11.0 rc1 (starts at 9-8 usec/call, settles at 2 usec/call)

    11.0.0 | layered | calls  20,000 | wall    190 ms |   9392 ns/call
    11.0.0 | layered | calls  40,000 | wall    340 ms |   7234 ns/call
    11.0.0 | layered | calls  60,000 | wall    478 ms |   6843 ns/call
    11.0.0 | layered | calls  80,000 | wall    644 ms |   8240 ns/call
    11.0.0 | layered | calls 100,000 | wall    825 ms |   9009 ns/call
    11.0.0 | layered | calls 120,000 | wall   1004 ms |   8900 ns/call
    11.0.0 | layered | calls 140,000 | wall   1177 ms |   8602 ns/call
    11.0.0 | layered | calls 160,000 | wall   1370 ms |   9567 ns/call
    11.0.0 | layered | calls 180,000 | wall   1570 ms |   9956 ns/call
    11.0.0 | layered | calls 200,000 | wall   1698 ms |   6316 ns/call
    11.0.0 | layered | calls 220,000 | wall   1821 ms |   6097 ns/call
    11.0.0 | layered | calls 240,000 | wall   1908 ms |   4320 ns/call
    11.0.0 | layered | calls 260,000 | wall   1981 ms |   3576 ns/call
    11.0.0 | layered | calls 280,000 | wall   2021 ms |   1979 ns/call
    11.0.0 | layered | calls 300,000 | wall   2060 ms |   1878 ns/call
    11.0.0 | layered | calls 320,000 | wall   2100 ms |   1968 ns/call
    11.0.0 | layered | calls 340,000 | wall   2141 ms |   2021 ns/call
    11.0.0 | layered | calls 360,000 | wall   2181 ms |   1912 ns/call
    11.0.0 | layered | calls 380,000 | wall   2222 ms |   2022 ns/call
    11.0.0 | layered | calls 400,000 | wall   2262 ms |   1971 ns/call
    11.0.0 | layered | calls 420,000 | wall   2302 ms |   1922 ns/call
    11.0.0 | layered | calls 440,000 | wall   2342 ms |   1961 ns/call
    11.0.0 | layered | calls 460,000 | wall   2381 ms |   1877 ns/call
    11.0.0 | layered | calls 480,000 | wall   2420 ms |   1916 ns/call
    

    net 11.0 recent 11.0 branch (starts at 4-3 usec/call, settles at 1 usec/call )

    11.0.0 | layered | calls  20,000 | wall     94 ms |   4584 ns/call
    11.0.0 | layered | calls  40,000 | wall    160 ms |   3005 ns/call
    11.0.0 | layered | calls  60,000 | wall    224 ms |   3181 ns/call
    11.0.0 | layered | calls  80,000 | wall    285 ms |   2998 ns/call
    11.0.0 | layered | calls 100,000 | wall    343 ms |   2833 ns/call
    11.0.0 | layered | calls 120,000 | wall    399 ms |   2732 ns/call
    11.0.0 | layered | calls 140,000 | wall    474 ms |   3736 ns/call
    11.0.0 | layered | calls 160,000 | wall    541 ms |   3283 ns/call
    11.0.0 | layered | calls 180,000 | wall    606 ms |   3200 ns/call
    11.0.0 | layered | calls 200,000 | wall    670 ms |   3153 ns/call
    11.0.0 | layered | calls 220,000 | wall    733 ms |   3073 ns/call
    11.0.0 | layered | calls 240,000 | wall    807 ms |   3697 ns/call
    11.0.0 | layered | calls 260,000 | wall    883 ms |   3745 ns/call
    11.0.0 | layered | calls 280,000 | wall    956 ms |   3571 ns/call
    11.0.0 | layered | calls 300,000 | wall   1024 ms |   3372 ns/call
    11.0.0 | layered | calls 320,000 | wall   1091 ms |   3317 ns/call
    11.0.0 | layered | calls 340,000 | wall   1169 ms |   3817 ns/call
    11.0.0 | layered | calls 360,000 | wall   1256 ms |   4332 ns/call
    11.0.0 | layered | calls 380,000 | wall   1331 ms |   3694 ns/call
    11.0.0 | layered | calls 400,000 | wall   1392 ms |   2992 ns/call
    11.0.0 | layered | calls 420,000 | wall   1443 ms |   2493 ns/call
    11.0.0 | layered | calls 440,000 | wall   1494 ms |   2513 ns/call
    11.0.0 | layered | calls 460,000 | wall   1528 ms |   1667 ns/call
    11.0.0 | layered | calls 480,000 | wall   1552 ms |   1129 ns/call
    11.0.0 | layered | calls 500,000 | wall   1574 ms |   1087 ns/call
    11.0.0 | layered | calls 520,000 | wall   1596 ms |   1050 ns/call
    11.0.0 | layered | calls 540,000 | wall   1619 ms |   1090 ns/call
    11.0.0 | layered | calls 560,000 | wall   1642 ms |   1090 ns/call
    11.0.0 | layered | calls 580,000 | wall   1665 ms |   1131 ns/call
    11.0.0 | layered | calls 600,000 | wall   1689 ms |   1136 ns/call
    
  10. locked and limited conversation to collaborators on Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions