Skip to content

JIT: Support general inlining in runtime async - #131538

Draft
jakobbotsch wants to merge 46 commits into
dotnet:mainfrom
jakobbotsch:async-inlining
Draft

JIT: Support general inlining in runtime async#131538
jakobbotsch wants to merge 46 commits into
dotnet:mainfrom
jakobbotsch:async-inlining

Conversation

@jakobbotsch

@jakobbotsch jakobbotsch commented Jul 29, 2026

Copy link
Copy Markdown
Member

Built on #128152 and #131342.

Currently just validating. I am hoping to get this in .NET 11, perhaps disabled by default, depending on how validation goes.

Fix #127865

jakobbotsch and others added 30 commits May 13, 2026 17:13
Invariant nodes and LCL_VARs do not need to be lifted across async
calls.
Consolidate the duplicated non-null assert in FinishContextHandlingAndSuspensionWithHelper into a single assert covering all three well-known args.

Update the stale comment in InsertFinishContextHandlingCall that still described the resumed placeholder as being replaced by a 'continuation != null' computation; it is now replaced by the already-computed resumed value.

No functional change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d86cad55-abc0-42d6-8369-bc6bc619326f
Value numbering can fold the AsyncResumedUse argument to a constant when the resumed indicator is provably zero at the call, for example when two awaits sit on mutually exclusive paths. IsReusableSuspension only accepted locals, so it bailed out with 'argument is too complex' and gave up on merging the suspension points.

That produced a duplicated CORINFO_HELP_ALLOC_CONTINUATION sequence, an extra resumption block and an extra readonly data slot. In smoke_tests.nativeaot this showed up as +59 bytes (17.15%) on Generics+TestAsyncGVMScenarios:AsyncGvm1[System.__Canon] and +57 bytes (16.52%) on RunAsync.

Accept invariant nodes as well, matching the predicate already used for invariantResumed in CreateResumptionsAndSuspensions. Invariant nodes are constants, LCL_ADDR or FTN_ADDR, so comparing them and removing them from the call is safe. Both methods now improve by 15 bytes relative to the base instead of regressing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d86cad55-abc0-42d6-8369-bc6bc619326f
…ning

# Conflicts:
#	src/coreclr/inc/jiteeversionguid.h
#	src/coreclr/jit/async.cpp
#	src/coreclr/jit/async.h
#	src/coreclr/tools/superpmi/superpmi-shared/methodcontext.h
#	src/tests/async/pgo/pgo.csproj
StoreAsyncAwaiter removed the awaiter node from the call block after it had
already been spliced into the suspension block's LIR range, corrupting the
range and tripping 'prev == m_lastNode'. Remove the node from the call block
before building the store instead, and remove the FIELD_LIST node itself in
the multi-field case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Folds in dotnet#127800. Renames GT_CONTINUATION_FIELD_OFFSET to
GT_CONTINUATION_MEMBER_OFFSET to match the node actually implemented.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
…ontext to the JIT

General runtime async inlining needs to check, when an inlined callee logically
returns to its caller after having been resumed, whether it is already running
in the continuation context the caller's continuation captured, and to restore
that continuation's ExecutionContext.

IsOnRightContext mirrors the 'can inline' conditions in
RuntimeAsyncTaskContinuation.QueueIfNecessary; the two must be kept in sync.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Suspends and resumes in a specified continuation context, rather than one
captured at the suspension point. General runtime async inlining uses this when
an inlined callee logically returns to its caller after having been resumed and
IsOnRightContext reports the current context does not match the one the caller's
continuation captured.

It suspends on an already completed task so that the dispatcher re-dispatches
immediately. That dispatch runs with canInline: false, so it always posts or
schedules onto the requested context instead of running inline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Extends ContinuationMember with slots for the ExecutionContext, continuation
context and continuation flags that an inlined async frame captures when it
logically returns to its caller.

These are keyed by inline depth rather than by individual inline frame. Two
frames at the same depth can share storage because their live ranges cannot
overlap: the value is written at a frame's first suspension and consumed by that
same frame's post-inline IR, and one frame at a given depth must be left before
another can be entered. This bounds the extra continuation members by the
maximum inline depth instead of by the number of inlined async frames.

No functional change yet; nothing requests these members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
…exts

Adds the JitAsyncInlining config knob, off by default, which lifts the
CALLEE_AWAIT restriction and allows inlining async callees that may suspend.

When an await in an inlinee may suspend it no longer inherits context handling
from the inlining call. Instead it is treated exactly like an await in a
non-inlined method and picks up the inlinee's own resumed/ExecutionContext/
SynchronizationContext locals, which the inlinee's own SaveAsyncContexts phase
already creates. The cheap paths (async versions of synchronous methods and tail
awaits) keep inheriting from the caller and are unchanged.

With the knob off behavior is unchanged: the restructured gate reports the same
CALLEE_AWAIT observation, and inlinees do not get their own context args.

The knob is not yet functional when enabled; the post-inline IR and the nested
capture chain at suspensions are still missing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
…al async inlining

SaveAsyncContexts now records whether the body contains any async call. For an
inlinee without one the resumed indicator is provably always false, so the
post-inline async frame IR can be skipped entirely, keeping the cheap shape for
what is by far the common case.

Also bails out of general async inlining in OSR methods. An OSR method is
entered with the tier0 method's continuation, whose layout differs from the one
the OSR method computes, so an inlined frame's members cannot be read from it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
…Context

RestoreExecutionContext takes the Thread whose contexts were captured on method
entry. The restore an inlined async frame needs runs only after a resumption, so
it must target the thread we were resumed on instead.

Adds RestoreInlinedFrameExecutionContext, which reads the current thread itself,
and points the JIT-EE handle at it. This also keeps the JIT side to a single
argument.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
SaveAsyncContexts runs for inlinees too, and JIT_FLAG_OSR is not cleared for an
inlinee, so opts.IsOSR() is true while importing an inlinee of an OSR method.
The OSR-specific handling was therefore applied to inlinees, where all three
parts of it are wrong:

- the resumed indicator was initialized from the continuation argument, but an
  inlinee has not resumed just because the OSR method was entered via a
  resumption
- CaptureContexts was skipped, leaving the inlinee's context locals unassigned
  for its own RestoreContexts
- the inlinee's fresh temps were marked lvIsOSRLocal, though they have no home
  in the tier0 frame

An inlinee inside an OSR method starts a fresh logical frame, so it needs the
same treatment as in a non-OSR method.

With this, general async inlining works in OSR methods: an inlinee's resumed
indicator can only become true at a resumption point belonging to that inlinee's
own async calls, which are resumption points of the OSR method's continuation
layout, so the continuation is never the tier0 one where it is read.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
…y region

Suspending inside a protected region additionally requires catching the
exception and rethrowing it around the post-inline handling, per the design
document. That is not implemented, so bail out for now.

Inlinees with EH are only inlined when compInlineMethodsWithEH is enabled, so
this is reachable but not the common case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
…caller

When an inlined async callee is resumed inside its own body, the async
infrastructure only restores the context of the suspension point itself. The
work it would otherwise have done when the callee's frame returned to its caller
now has to be performed by the JIT:

    if (resumed_F)
    {
        RestoreInlinedFrameExecutionContext(continuation.ExecutionContextFor<caller>);
        if (!IsOnRightContext(continuation.ContinuationContextFor<caller>,
                              continuation.FlagsFor<caller>))
        {
            await SwitchContext(...);
        }
        resumed_caller = true;
    }

This is emitted as a real control flow diamond at the join point all of the
inlinee's returns converge on, alongside the other post-inline IR. IsOnRightContext
stays visible in the IR and is an inline candidate, so it needs to be a statement
root with its value consumed through a GT_RET_EXPR.

Two bugs this exposed are fixed here as well:

- Continuation members were registered on whichever Compiler instance was
  importing, so an awaiter inside an inlinee got an index into the inlinee's
  vector while its offset node was resolved against the root's layout. Members
  describe the root method's continuation, so they are now always registered
  there.
- GT_CONTINUATION_MEMBER_OFFSET was always bashed to a TYP_INT constant. Offsets
  used in address arithmetic are TYP_I_IMPL, so the node's type is now preserved.

Also records each inlined frame's async locals on its InlineContext, since the
inlinee's Compiler is discarded once it has been spliced in.

The nested capture chain at suspensions is still missing, so enabling
JitAsyncInlining is not yet correct.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
When an async call inside an inlined frame suspends, every enclosing frame that
has not yet resumed needs its contexts captured into the continuation and
restored onto the thread, as if its physical frame had returned. The IR for that
is created by the async transformation, long after the optimizer has run, so
those values have to be kept live and GC reported at the call until then.

That is exactly what the existing per-frame context args do, so the enclosing
frames just add more of them: one set of AsyncResumedUse/AsyncExecutionContext/
AsyncSynchronizationContext per frame, appended outward when the inlinee is
spliced in. These are pseudo-args, so duplicates take no registers and are
expanded out later. The innermost frame stays first.

Only uses are needed for the enclosing frames. AsyncResumedDef stays
innermost-only, since the caller's indicator is set by the post-inline IR rather
than by a resumption point.

Adjusts the two places that assumed these args were unique: IsReusableSuspension
now requires all of them to agree, and HandleReusedSuspension removes all of
them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Completes general runtime async inlining. When an async call inside an inlined
frame suspends, every enclosing frame that has not yet resumed must capture the
contexts it would hand to its caller and restore its caller's contexts onto the
thread, as if its physical frame had returned.

A frame having resumed implies its caller has too, so the frames can be walked
outward as a straight line rather than as the nested conditionals of the design
document: once one frame has resumed, the helpers for it and every frame outside
it no-op. Each suspension jumps to a tail block that does this, shared by all
suspensions in the same frame.

Adds AsyncHelpers.CaptureInlinedFrameTransition for the capture, which no-ops
when the frame has already resumed.

The chain of frames is recorded on AsyncCallInfo rather than being recovered
from the call's context args, since optimizations may rewrite the arg nodes; the
args remain purely to keep the values live and GC reported until here.

Also fixes impInheritAsyncContextsFromInliner, which propagated only the
innermost frame's contexts to an inlined callee's async calls. An async call
carrying a frame chain can itself be an inline candidate, and dropping the rest
of the chain silently lost the handling for every frame outside the immediate
one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
The worked examples from the design document. Inlining removes a call site and a
callee, and with them the context handling the async infrastructure would
otherwise perform at each frame boundary, so these pin down the observable
results: each frame resumes on the synchronization context its own continuation
captured, and sees the AsyncLocal values from the ExecutionContext it captured.

The callees are marked AggressiveInlining because the profitability heuristic
otherwise rejects them, and the test runs with tiering disabled, since at tier 0
nothing is inlined and the tests would not exercise the feature at all.

Verified to exercise a three frame chain, and to fail if the frame transition IR
is removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Copilot AI review requested due to automatic review settings July 29, 2026 17:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 91 out of 91 changed files in this pull request and generated 1 comment.

Comment thread src/coreclr/jit/fginline.cpp Outdated
Comment on lines +2640 to +2649
// TODO-Async: when this call suspends, the frames enclosing the caller need their
// context handling run too. Those come from the enclosing frame chain, which is added
// to async calls as additional pseudo-args.
call->gtArgs.PushFront(this, NewCallArg::Primitive(gtNewLclVarNode(syncCtx, TYP_REF))
.WellKnown(WellKnownArg::AsyncSynchronizationContext));
call->gtArgs.PushFront(this, NewCallArg::Primitive(gtNewLclVarNode(execCtx, TYP_REF))
.WellKnown(WellKnownArg::AsyncExecutionContext));
call->gtArgs.PushFront(this, NewCallArg::Primitive(gtNewLclVarNode(resumed, TYP_INT))
.WellKnown(WellKnownArg::AsyncResumedUse));
}
jakobbotsch and others added 3 commits July 30, 2026 11:25
# Conflicts:
#	src/coreclr/jit/async.cpp
#	src/coreclr/jit/async.h
#	src/coreclr/jit/compiler.h
#	src/coreclr/jit/importercalls.cpp
The context pseudo-args on an async call are the source of truth for what the
suspension stores. The whole optimizer runs between adding them and the async
transformation, so those nodes can be rewritten: 'resumed' indicators in
particular get const folded, and contexts can be copy propagated to other
locals. Reconstructing the values from the locals recorded at inline time
ignored all of that and stored whatever the original locals happened to hold.

Take the actual nodes off the call instead, spilling to a temp only when a node
is neither invariant nor a local, exactly as the existing shared finish-context
handling does when it needs to reference an arg from another block.

The nodes also become the identity used for sharing a tail, so suspensions share
one only when they agree on every value. That is now observable: with two awaits
in the same inlined frame, the first has its resumed indicator folded to the
constant zero while the second still reads the local, and they correctly get
separate tails. Sharing them would have stored the wrong value at one of them.

This removes the need for the side table, so AsyncCallInfo::InlineFrameLocals
and the AsyncFrameLocals type go away. InlineContext keeps its frame locals,
which are still the right source when creating the args during inlining.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Each suspension now gets its own tail. Sharing required the suspensions to agree
on every value the tail stores, which in practice they do not: the first await in
a frame typically has its resumed indicator folded to a constant while later ones
still read the local, so the tails were never actually shared.

Left as a TODO; it is an optimization that saves cold code and can come back
separately.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
@jakobbotsch jakobbotsch added this to the 11.0.0 milestone Jul 30, 2026
@JulieLeeMSFT JulieLeeMSFT added the Priority:1 Work that is critical for the release, but we could probably ship without label Jul 30, 2026
Copilot AI review requested due to automatic review settings July 30, 2026 17:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 84 out of 84 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/coreclr/jit/fginline.cpp:2853

  • SwitchContext is an async helper that always suspends. In the resumption path, resumedCaller is only set in doneBlock, which executes after the SwitchContext call. If SwitchContext suspends (the expected behavior), suspension processing will observe resumedCaller == 0, which can cause the switch-call suspension to be treated like a first-time (non-resumed) suspension for the caller frame.
    // switchBlock: get back onto the continuation context the caller's continuation
    // captured. This is an await, so it may suspend.
    {
        GenTreeCall* const switchCall = gtNewUserCallNode(asyncInfo->switchContextMethHnd, TYP_VOID);
        switchCall->gtArgs.PushFront(this,

jakobbotsch and others added 8 commits July 31, 2026 14:04
The post-inline IR for an inlined async frame returning to its caller was a full
diamond: an ExecutionContext restore, an IsOnRightContext call with its own
conditional, and a SwitchContext await, spread over four blocks and relying on
inlining the two helpers back in.

Only the resumed check is worth having as IR. It is what guards the synchronous
path, which is the one to optimize for; anything past it has already suspended
and resumed at least once, so a call is cheap there. Fold IsOnRightContext and
SwitchContext into a single AsyncHelpers.RestoreInlinedFrameContinuationContext
await that does the check internally, leaving two blocks and no inline
candidates.

The ExecutionContext restore stays a separate non-async call. It cannot move
into the same helper: a runtime async method restores the contexts it captured
on entry when it returns, which would undo it on the non-suspending path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
RestoreInlinedFrameExecutionContext and RestoreInlinedFrameContinuationContext
become one RestoreInlinedFrameContexts await. The ExecutionContext restore can
live in the async helper after all: it is a manually marked async method rather
than the async variant of a task returning one, so CORINFO_ASYNC_SAVE_CONTEXTS
is not set for it and there is no save/restore around its body to undo the
restore on the way out.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Split the suspension out into a tail awaited SwitchToContinuationContext so that
what is left is just the ExecutionContext restore and the continuation context
check. Take the thread from the cached one in the await state rather than a
second TLS lookup, and inline IsOnRightContext into its only caller.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
The check for whether we are already on the right continuation context is what
guards the suspension, so it is worth having in the caller. Mark the call so the
inliner can pull it in, and force it with AggressiveInlining since the helper is
over the IL size budget.

This also needs the call to carry an AsyncResumedDef like every other async
call. Without it, inlining a body containing an async call trips the use/def
parity assert in impInheritAsyncContextsFromInliner, which propagates both to
the inlinee's own async calls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Whether to inline it should be the JIT's call based on PGO, not forced here.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Those args model what a frame captures and restores when a suspension unwinds
through it. None of that applies here: we only run at all because the frame we
are returning out of was resumed, so every frame in the chain already has a
continuation holding what it captured when it first suspended, and a suspension
here goes straight back to the dispatcher, which restores the thread's contexts
itself.

This also drops the AsyncResumedDef added along with the inline candidate
marking; with no args at all the use/def parity in
impInheritAsyncContextsFromInliner holds trivially and the inlinee's own async
calls correctly get no context args either.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
The comparisons are reference comparisons either way, so the castclass just
costs IL. That matters here: it brings the method from 130 to 120 IL bytes,
under the 128 byte inlining limit, so the JIT can now actually inline it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
The chain was built from a side table of context locals recorded on InlineContext.
That table is populated when a frame is spliced into its caller, which happens
after the frame's own inlinees have already been processed, so an inner frame
never saw the frames between it and the root and silently dropped their
transitions.

Copy the context args off the call being inlined instead. They describe exactly
the chain the inlinee ends up in, and they compose: a frame picks up its
caller's chain when it is spliced in, and every call in it, including ones that
came from its own inlinees, is extended again when that caller is inlined. The
first set on the call belongs to the frame it suspends in and is consumed by
that frame's own handling, so it is skipped unless it is all there is.

This also drops the assumption that the root method is always a logical async
frame. It is not when it does no context handling itself, for example when it is
the async version of a synchronous method; there is then nothing to hand back
and no transitions to run. InlineContext keeps only a bit saying whether the
frame does context handling, set while the frame is compiled rather than when it
is spliced in, so that depths are right for its own inlinees.

Fixes an assert compiling System.IO.ConnectedStreams.BidirectionalStreamBufferStream.ReadAsync.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed3e552-b907-4815-9683-56a7fed2cdf3
Copilot AI review requested due to automatic review settings July 31, 2026 14:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 84 out of 84 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/coreclr/jit/jitconfigvalues.h:627

  • PR description suggests this may need to ship disabled-by-default during validation, but the new config switch enables general runtime async inlining by default (value 1). If this is meant to be an opt-in feature initially, consider defaulting to 0 and enabling via DOTNET_JitAsyncInlining=1 during testing/validation.
// Enable general inlining of runtime async calls, i.e. inlining of async
// callees that may suspend. When zero, only the restricted cases are inlined:
// callees without any awaits, async versions of synchronous methods, and tail
// awaits.
RELEASE_CONFIG_INTEGER(JitAsyncInlining, "JitAsyncInlining", 1)

src/tests/async/custom-struct-awaiters/custom-struct-awaiters.cs:19

  • Using Task.Wait() in a test entrypoint tends to wrap failures in AggregateException and can introduce deadlock hazards if a SynchronizationContext ever gets involved. Prefer GetAwaiter().GetResult() for synchronous blocking in tests.
    src/tests/async/inlined-frame-contexts/inlined-frame-contexts.cs:52
  • ThreadPool.QueueUserWorkItem flows ExecutionContext, which can mask bugs in runtime-async ExecutionContext capture/restore (the callback may already run under the captured context). Suppressing flow here makes the test more directly validate the JIT/runtime context handling it is intended to pin down.
    src/tests/async/osr-inlined-contexts/osr-inlined-contexts.cs:8
  • BumpAsync is marked async but contains no await, which triggers CS1998. Several existing runtime-async tests suppress this warning explicitly; doing the same here avoids build breaks when warnings are treated as errors.

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

Labels

area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI Priority:1 Work that is critical for the release, but we could probably ship without

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JIT: Tracking issue for general runtime async inlining

3 participants