Skip to content

Reduce runtime async dispatch overhead - #135962

Open
thomhurst wants to merge 1 commit into
dotnet:mainfrom
thomhurst:runtime-async-dispatch-alloc
Open

thomhurst wants to merge 1 commit into
dotnet:mainfrom
thomhurst:runtime-async-dispatch-alloc

Conversation

@thomhurst

@thomhurst thomhurst commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Two small changes to the runtime async dispatcher in AsyncHelpers.CoreCLR.cs.

Changes

1. Create the RuntimeAsyncTask<T> continuation delegate lazily

The RuntimeAsyncTask<T> constructor allocated m_action = DispatchContinuations eagerly, which is one delegate (56 B measured) for every runtime async chain that suspends. The delegate is only used when the task is handed to:

  • a custom awaiter (OnCompleted / UnsafeOnCompleted, e.g. Task.Yield()),
  • an INotifyCompletion notifier,
  • a non-default TaskScheduler (RuntimeAsyncTaskContinuation.QueueIfNecessary).

The common paths (awaiting a Task or an IValueTaskSource-backed ValueTask) never use it. GetContinuationAction() now creates the delegate on first use, which matches how the classic builder handles MoveNextAction (m_action ??= new Action(MoveNext)).

RuntimeAsyncTaskContinuation only has the task as a Task, so it reaches the lazy getter through a new internal IRuntimeAsyncTask interface. That path is only taken for non-default task schedulers.

2. Avoid a thread-static lookup per resumed frame

QueueContinuationFollowUpActionIfNecessary read Thread.CurrentThreadAssumedInitialized on every resumed frame, although the dispatcher already holds the current thread in t_runtimeAsyncAwaitState.CurrentThread. The dispatcher now passes that cached thread in, as RestoreInlinedFrameContexts already does. A Debug.Assert checks that the cached thread is the current thread.

Benchmarks

The benchmark suspends a chain of Depth runtime async frames on a pending TaskCompletionSource task, then completes the task so the chain resumes inline on the same thread. Depth 1 isolates the per-chain cost (change 1). Depth 10 exercises the per-frame resume path (change 2). The project is compiled with runtime-async=on. A sanity check confirms that the suspended task is an AsyncHelpers+RuntimeAsyncTask<T>, and that the Task.Yield and non-default TaskScheduler paths still work on every build.

Benchmark code
[MemoryDiagnoser]
public class SuspendBenchmarks
{
    private const int OperationsPerInvoke = 1000;

    [Params(1, 10)]
    public int Depth;

    [Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
    public void SuspendResumeTask()
    {
        for (int i = 0; i < OperationsPerInvoke; i++)
        {
            var tcs = new TaskCompletionSource();
            Task t = Chain(tcs.Task, Depth);
            tcs.SetResult();
            t.GetAwaiter().GetResult();
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    internal static async Task Chain(Task pending, int depth)
    {
        if (depth <= 1)
        {
            await pending;
        }
        else
        {
            await Chain(pending, depth - 1);
        }
    }
}

All builds are local Release builds of the same commit (bc110ba). Only System.Private.CoreLib.dll (ReadyToRun) differs between them. They were compared with BenchmarkDotNet 0.16.0-preview.2 --corerun.

BenchmarkDotNet v0.16.0-preview.2, Windows 11 (10.0.26200)
12th Gen Intel Core i7-12700K 3.61GHz, 1 CPU, 20 logical and 12 physical cores
Runtime: .NET 12.0.0 (12.0.0-dev), X64 RyuJIT x86-64-v3

To show each change on its own, each change was measured separately:

  • baseline: main
  • A: change 1 only
  • B: changes 1 + 2

Run 1: baseline vs A vs B

Depth Build Mean Error Ratio Allocated Alloc Ratio
1 baseline 76.78 ns 0.961 ns 1.00 272 B 1.00
1 A 72.71 ns 1.081 ns 0.95 216 B 0.79
1 B 72.77 ns 1.445 ns 0.95 216 B 0.79
10 baseline 193.80 ns 3.521 ns 1.00 776 B 1.00
10 A 190.07 ns 3.515 ns 0.98 720 B 0.93
10 B 184.21 ns 2.838 ns 0.95 720 B 0.93

Run 2: A vs B, 3 launches each (to check change 2)

Depth Build Mean Error Median Ratio
1 A 72.80 ns 1.056 ns 72.29 ns 1.00
1 B 72.83 ns 0.726 ns 72.43 ns 1.00
10 A 195.31 ns 2.764 ns 193.73 ns 1.00
10 B 187.42 ns 2.018 ns 185.55 ns 0.96

Summary:

  • Change 1 removes 56 B per suspended chain and makes suspend/resume about 5% faster at depth 1.
  • Change 2 has no visible effect at depth 1, where only one frame resumes. At depth 10 it is about 3–4% faster, consistently in both runs.

EgorBot (PR vs main)

EgorBot ran a wider set of scenarios (snippet):

  • AwaitPendingTask: a chain awaits a pending Task (the case above).
  • AwaitPendingValueTask: the leaf awaits an IValueTaskSource-backed ValueTask (single-reader Channel with synchronous continuations).
  • FanOut16: 16 chains suspended at the same time, then completed.
  • TaskYield: a custom awaiter, which still needs the delegate (regression guard).

All scenarios ran at depth 1 and depth 10.

  • First run (EgorBot/Benchmarks#655, Linux AMD Turin and macOS M1): every allocating scenario allocated 56 B less per chain on both platforms. At depth 1, the PR was 5–7% faster everywhere. A few depth-10 results came out slower with the PR on one platform and faster on the other: Linux AwaitPendingValueTask +2%, Linux FanOut16 +1%, macOS TaskYield +5%.
  • Rerun of those scenarios with --launchCount 3 (EgorBot/Benchmarks#656, Linux AMD Turin and macOS M2): none of the slowdowns reproduced. Every scenario was faster with the PR or the same as main.

Rerun results (time with the PR divided by time on main; lower is better):

Scenario Depth Linux x64 macOS arm64 Allocated (main → PR)
AwaitPendingValueTask 1 0.97 0.92 192 → 136 B
AwaitPendingValueTask 10 0.95 0.90 768 → 712 B
FanOut16 1 0.95 1.00 340 → 284 B
FanOut16 10 0.94 0.96 844 → 788 B
TaskYield 1 0.97 0.97 112 → 112 B
TaskYield 10 0.94 0.98 616 → 616 B

The windows_azure_turin job failed on both requests. The bot's own runtime build hit MSB3027 because an MSBuild process held a lock on Crossgen2Tasks.dll, so no benchmarks ran there. The local numbers above are from Windows x64.

Testing

  • src/tests/async (Release, x64, Windows): 146 passed, 0 failed.
  • build.cmd clr.corelib+clr.nativecorelib -rc release builds cleanly.
  • Not tested locally: the NativeAOT CoreLib build (it compiles the same files) and a Checked runtime, which is where the new Debug.Assert is active.

Diagnostics impact

I checked the readers of m_action in this repo (including cDAC) and in dotnet/diagnostics:

  • SOS threadpool only reads m_action for work items whose type is exactly System.Threading.Tasks.Task, so it does not read it for RuntimeAsyncTask<T>.
  • dumpasync follows continuation chains through m_action only for wrapper and continuation objects. RuntimeAsyncTaskContinuation has no m_action field, and the custom-awaiter path still gets the same delegate.
  • The only visible difference: dumpasync's description of a RuntimeAsyncTask that suspended on a Task or ValueTask no longer has the {Void DispatchContinuations()} suffix.

@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Oct 16, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

@thomhurst

Copy link
Copy Markdown
Contributor Author

@EgorBot -amd -arm -windows_x64

using System.Runtime.CompilerServices;
using System.Threading.Channels;
using BenchmarkDotNet.Attributes;

// Compiled with runtime-async=on, so every async method here is a runtime async method.
[MemoryDiagnoser]
public class RuntimeAsyncDispatch
{
    private const int Ops = 1024; // multiple of 16 so FanOut16 runs exactly Ops chains

    [Params(1, 10)]
    public int Depth;

    private Channel<int> _channel = null!;
    private readonly TaskCompletionSource[] _fanOut = new TaskCompletionSource[16];
    private readonly Task[] _fanOutTasks = new Task[16];

    [GlobalSetup]
    public void Setup()
    {
        // Single reader + synchronous continuations: ReadAsync returns an IValueTaskSource-backed
        // ValueTask, and TryWrite resumes the suspended reader inline on this thread.
        _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
        {
            SingleReader = true,
            SingleWriter = true,
            AllowSynchronousContinuations = true,
        });
    }

    // A chain of `Depth` frames suspends on a pending Task, then resumes inline when it completes.
    [Benchmark(OperationsPerInvoke = Ops)]
    public void AwaitPendingTask()
    {
        for (int i = 0; i < Ops; i++)
        {
            var tcs = new TaskCompletionSource();
            Task t = TaskChain(tcs.Task, Depth);
            tcs.SetResult();
            t.GetAwaiter().GetResult();
        }
    }

    // Same, but the leaf awaits an IValueTaskSource-backed ValueTask.
    [Benchmark(OperationsPerInvoke = Ops)]
    public int AwaitPendingValueTask()
    {
        int sum = 0;
        for (int i = 0; i < Ops; i++)
        {
            ValueTask<int> t = ValueTaskChain(Depth);
            _channel.Writer.TryWrite(i);
            sum += t.GetAwaiter().GetResult();
        }
        return sum;
    }

    // 16 chains suspended at the same time, then completed.
    [Benchmark(OperationsPerInvoke = Ops)]
    public void FanOut16()
    {
        for (int i = 0; i < Ops / 16; i++)
        {
            for (int j = 0; j < 16; j++)
            {
                _fanOut[j] = new TaskCompletionSource();
                _fanOutTasks[j] = TaskChain(_fanOut[j].Task, Depth);
            }

            for (int j = 0; j < 16; j++)
            {
                _fanOut[j].SetResult();
            }

            Task.WaitAll(_fanOutTasks);
        }
    }

    // Custom awaiter path (YieldAwaitable) still needs the continuation delegate: regression guard.
    [Benchmark]
    public async Task TaskYield()
    {
        await YieldChain(Depth);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static async Task TaskChain(Task pending, int depth)
    {
        if (depth <= 1)
        {
            await pending;
        }
        else
        {
            await TaskChain(pending, depth - 1);
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private async ValueTask<int> ValueTaskChain(int depth)
    {
        if (depth <= 1)
        {
            return await _channel.Reader.ReadAsync();
        }

        return await ValueTaskChain(depth - 1);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static async Task YieldChain(int depth)
    {
        if (depth <= 1)
        {
            await Task.Yield();
        }
        else
        {
            await YieldChain(depth - 1);
        }
    }
}

@thomhurst

Copy link
Copy Markdown
Contributor Author

@EgorBot -amd -arm -windows_x64 --filter "AwaitPendingValueTask" "FanOut16" "TaskYield" --launchCount 3

using System.Runtime.CompilerServices;
using System.Threading.Channels;
using BenchmarkDotNet.Attributes;

// Compiled with runtime-async=on, so every async method here is a runtime async method.
[MemoryDiagnoser]
public class RuntimeAsyncDispatch
{
    private const int Ops = 1024; // multiple of 16 so FanOut16 runs exactly Ops chains

    [Params(1, 10)]
    public int Depth;

    private Channel<int> _channel = null!;
    private readonly TaskCompletionSource[] _fanOut = new TaskCompletionSource[16];
    private readonly Task[] _fanOutTasks = new Task[16];

    [GlobalSetup]
    public void Setup()
    {
        // Single reader + synchronous continuations: ReadAsync returns an IValueTaskSource-backed
        // ValueTask, and TryWrite resumes the suspended reader inline on this thread.
        _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
        {
            SingleReader = true,
            SingleWriter = true,
            AllowSynchronousContinuations = true,
        });
    }

    // A chain of `Depth` frames suspends on a pending Task, then resumes inline when it completes.
    [Benchmark(OperationsPerInvoke = Ops)]
    public void AwaitPendingTask()
    {
        for (int i = 0; i < Ops; i++)
        {
            var tcs = new TaskCompletionSource();
            Task t = TaskChain(tcs.Task, Depth);
            tcs.SetResult();
            t.GetAwaiter().GetResult();
        }
    }

    // Same, but the leaf awaits an IValueTaskSource-backed ValueTask.
    [Benchmark(OperationsPerInvoke = Ops)]
    public int AwaitPendingValueTask()
    {
        int sum = 0;
        for (int i = 0; i < Ops; i++)
        {
            ValueTask<int> t = ValueTaskChain(Depth);
            _channel.Writer.TryWrite(i);
            sum += t.GetAwaiter().GetResult();
        }
        return sum;
    }

    // 16 chains suspended at the same time, then completed.
    [Benchmark(OperationsPerInvoke = Ops)]
    public void FanOut16()
    {
        for (int i = 0; i < Ops / 16; i++)
        {
            for (int j = 0; j < 16; j++)
            {
                _fanOut[j] = new TaskCompletionSource();
                _fanOutTasks[j] = TaskChain(_fanOut[j].Task, Depth);
            }

            for (int j = 0; j < 16; j++)
            {
                _fanOut[j].SetResult();
            }

            Task.WaitAll(_fanOutTasks);
        }
    }

    // Custom awaiter path (YieldAwaitable) still needs the continuation delegate: regression guard.
    [Benchmark]
    public async Task TaskYield()
    {
        await YieldChain(Depth);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static async Task TaskChain(Task pending, int depth)
    {
        if (depth <= 1)
        {
            await pending;
        }
        else
        {
            await TaskChain(pending, depth - 1);
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private async ValueTask<int> ValueTaskChain(int depth)
    {
        if (depth <= 1)
        {
            return await _channel.Reader.ReadAsync();
        }

        return await ValueTaskChain(depth - 1);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static async Task YieldChain(int depth)
    {
        if (depth <= 1)
        {
            await Task.Yield();
        }
        else
        {
            await YieldChain(depth - 1);
        }
    }
}

Create the RuntimeAsyncTask continuation delegate lazily. It is only needed
when handing the task to a custom awaiter, a notifier or a non-default
TaskScheduler; Task and IValueTaskSource awaits resume without it. This
saves one delegate allocation per suspended runtime async chain.

Pass the thread cached in t_runtimeAsyncAwaitState to
QueueContinuationFollowUpActionIfNecessary instead of re-reading
Thread.CurrentThreadAssumedInitialized for every resumed frame.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-VM-coreclr community-contribution Indicates that the PR has been added by a community member runtime-async

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants