Skip to content

Conversation

@jtschuster
Copy link
Member

Updates the AsyncResumptionStub to emit IL. The implementation was ported from the runtime's getAsyncResumptionStub

CORINFO_METHOD_HANDLE CEEJitInfo::getAsyncResumptionStub(void** entryPoint)
{
CONTRACTL{
THROWS;
GC_TRIGGERS;
MODE_PREEMPTIVE;
} CONTRACTL_END;
MethodDesc* md = m_pMethodBeingCompiled;
LoaderAllocator* loaderAlloc = md->GetLoaderAllocator();
AllocMemTracker amTracker;
Signature stubSig = BuildResumptionStubSignature(md->GetLoaderAllocator(), &amTracker);
MetaSig msig(md);
Signature calliSig = BuildResumptionStubCalliSignature(msig, md->GetMethodTable(), md->GetLoaderAllocator(), &amTracker);
SigTypeContext emptyCtx;
ILStubLinker sl(md->GetModule(), stubSig, &emptyCtx, NULL, ILSTUB_LINKER_FLAG_NONE);
ILCodeStream* pCode = sl.NewCodeStream(ILStubLinker::kDispatch);
int numArgs = 0;
if (msig.HasThis())
{
if (md->GetMethodTable()->IsValueType())
{
pCode->EmitLDC(0);
pCode->EmitCONV_U();
}
else
{
pCode->EmitLDNULL();
}
numArgs++;
}
#ifndef TARGET_X86
if (msig.HasGenericContextArg())
{
pCode->EmitLDC(0);
numArgs++;
}
// Continuation
pCode->EmitLDARG(0);
numArgs++;
#endif
msig.Reset();
CorElementType ty;
while ((ty = msig.NextArg()) != ELEMENT_TYPE_END)
{
TypeHandle tyHnd = msig.GetLastTypeHandleThrowing();
DWORD loc = pCode->NewLocal(LocalDesc(tyHnd));
pCode->EmitLDLOCA(loc);
pCode->EmitINITOBJ(pCode->GetToken(tyHnd));
pCode->EmitLDLOC(loc);
numArgs++;
}
#ifdef TARGET_X86
// Continuation
pCode->EmitLDARG(0);
if (msig.HasGenericContextArg())
{
pCode->EmitLDC(0);
numArgs++;
}
numArgs++;
#endif
#ifdef FEATURE_TIERED_COMPILATION
// Resumption stubs are uniquely coupled to the code version (since the
// continuation is), so we need to make sure we always keep calling the
// same version here.
PrepareCodeConfig* config = GetThread()->GetCurrentPrepareCodeConfig();
NativeCodeVersion ncv = config->GetCodeVersion();
if (ncv.GetOptimizationTier() == NativeCodeVersion::OptimizationTier1OSR)
{
#ifdef FEATURE_ON_STACK_REPLACEMENT
// The OSR version needs to resume in the tier0 version. The tier0
// version will handle setting up the frame that the OSR version
// expects and then delegating back into the OSR version (knowing to do
// so through information stored in the continuation).
_ASSERTE(m_pPatchpointInfoFromRuntime != NULL);
pCode->EmitLDC((DWORD_PTR)m_pPatchpointInfoFromRuntime->GetTier0EntryPoint());
#else // !FEATURE_ON_STACK_REPLACEMENT
_ASSERTE(!"Unexpected optimization tier with OSR disabled");
#endif // FEATURE_ON_STACK_REPLACEMENT
}
else
#endif // FEATURE_TIERED_COMPILATION
{
{
m_finalCodeAddressSlot = (PCODE*)amTracker.Track(m_pMethodBeingCompiled->GetLoaderAllocator()->GetHighFrequencyHeap()->AllocMem(S_SIZE_T(sizeof(PCODE))));
}
pCode->EmitLDC((DWORD_PTR)m_finalCodeAddressSlot);
pCode->EmitLDIND_I();
}
pCode->EmitCALLI(pCode->GetSigToken(calliSig.GetRawSig(), calliSig.GetRawSigLen()), numArgs, msig.IsReturnTypeVoid() ? 0 : 1);
DWORD resultLoc = UINT_MAX;
TypeHandle resultTypeHnd;
if (!msig.IsReturnTypeVoid())
{
resultTypeHnd = msig.GetRetTypeHandleThrowing();
resultLoc = pCode->NewLocal(LocalDesc(resultTypeHnd));
pCode->EmitSTLOC(resultLoc);
}
TypeHandle continuationTypeHnd = CoreLibBinder::GetClass(CLASS__CONTINUATION);
DWORD newContinuationLoc = pCode->NewLocal(LocalDesc(continuationTypeHnd));
pCode->EmitCALL(METHOD__STUBHELPERS__ASYNC_CALL_CONTINUATION, 0, 1);
pCode->EmitSTLOC(newContinuationLoc);
if (!msig.IsReturnTypeVoid())
{
ILCodeLabel* doneResult = pCode->NewCodeLabel();
pCode->EmitLDLOC(newContinuationLoc);
pCode->EmitBRTRUE(doneResult);
pCode->EmitLDARG(1); // resultLoc
pCode->EmitLDLOC(resultLoc);
pCode->EmitSTOBJ(pCode->GetToken(resultTypeHnd));
pCode->EmitLabel(doneResult);
}
pCode->EmitLDLOC(newContinuationLoc);
pCode->EmitRET();
MethodDesc* result =
ILStubCache::CreateAndLinkNewILStubMethodDesc(
md->GetLoaderAllocator(),
md->GetLoaderModule()->GetILStubCache()->GetOrCreateStubMethodTable(md->GetLoaderModule()),
ILSTUB_ASYNC_RESUME,
md->GetModule(),
stubSig.GetRawSig(), stubSig.GetRawSigLen(),
&emptyCtx,
&sl);
amTracker.SuppressRelease();
ILStubResolver *pResolver = result->AsDynamicMethodDesc()->GetILStubResolver();
pResolver->SetStubTargetMethodDesc(m_pMethodBeingCompiled);
const char* optimizationTierName = "UnknownTier";
#ifdef FEATURE_TIERED_COMPILATION
switch (ncv.GetOptimizationTier())
{
case NativeCodeVersion::OptimizationTier0: optimizationTierName = "Tier0"; break;
case NativeCodeVersion::OptimizationTier1: optimizationTierName = "Tier1"; break;
case NativeCodeVersion::OptimizationTier1OSR: optimizationTierName = "Tier1OSR"; break;
case NativeCodeVersion::OptimizationTierOptimized: optimizationTierName = "Optimized"; break;
case NativeCodeVersion::OptimizationTier0Instrumented: optimizationTierName = "Tier0Instrumented"; break;
case NativeCodeVersion::OptimizationTier1Instrumented: optimizationTierName = "Tier1Instrumented"; break;
default: break;
}
#endif // FEATURE_TIERED_COMPILATION
#ifdef _DEBUG
LPCUTF8 methodName = m_pMethodBeingCompiled->GetName();
size_t stubNameLen = STRING_LENGTH("IL_STUB_AsyncResume__");
stubNameLen += strlen(methodName);
stubNameLen += strlen(optimizationTierName);
stubNameLen++; // "\n"
AllocMemTracker amTrackerName;
char* allocedMem = (char*)amTrackerName.Track(m_pMethodBeingCompiled->GetLoaderAllocator()->GetLowFrequencyHeap()->AllocMem(S_SIZE_T(stubNameLen)));
sprintf_s(allocedMem, stubNameLen, "IL_STUB_AsyncResume_%s_%s", m_pMethodBeingCompiled->GetName(), optimizationTierName);
result->AsDynamicMethodDesc()->SetMethodName((LPCUTF8)allocedMem);
amTrackerName.SuppressRelease();
LOG((LF_STUBS, LL_INFO1000, "ASYNC: Resumption stub %s created\n", allocedMem));
sl.LogILStub(CORJIT_FLAGS());
#endif
*entryPoint = (void*)result->GetMultiCallableAddrOfCode();
return CORINFO_METHOD_HANDLE(result);
}

@jtschuster jtschuster self-assigned this Nov 7, 2025
Copilot AI review requested due to automatic review settings November 7, 2025 21:48
@github-actions github-actions bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Nov 7, 2025

// Ported from jitinterface.cpp CEEJitInfo::getAsyncResumptionStub
// Emitted IL:
// if (!_owningMethod.Signature.IsStatic)
Copy link
Member

Choose a reason for hiding this comment

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

I do not think this comment useful. You can easily tell what the IL is from the actual emitter below. It can only get out of sync overtime.

@am11 am11 added area-NativeAOT-coreclr and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Nov 7, 2025
@dotnet-policy-service
Copy link
Contributor

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

{
private readonly MethodDesc _owningMethod;
private MethodSignature _signature;
private MethodIL _methodIL;
Copy link
Member

Choose a reason for hiding this comment

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

We do not store IL on the ILStubMethods. There is a cache in the ILProvider.

ilStream.EmitStLoc(resultLocal);
}

MethodDesc asyncCallContinuation = Context.SystemModule.GetKnownType("System.StubHelpers"u8, "StubHelpers"u8)
Copy link
Member

Choose a reason for hiding this comment

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

Can we move this to AsyncHelpers so that we do not have to add StubHelpers.cs to NativeAOT?

StubHelpers in CoreCLR is everything-in-the-kitchen-sink type. It is not pretty.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR implements the async resumption stub for native AOT compilation by porting the CoreCLR VM implementation from jitinterface.cpp to C#. The stub is used to resume async methods after suspension.

  • Replaces the placeholder ThrowNotSupportedException implementation with a complete IL emission that matches CoreCLR's getAsyncResumptionStub
  • Adds proper caching of the generated IL and comparison logic for the stub method
  • Updates the constructor assertion to use a simplified IsAsyncCall() method
Comments suppressed due to low confidence (2)

src/coreclr/tools/Common/TypeSystem/IL/Stubs/AsyncResumptionStub.cs:148

  • This CompareToImpl method duplicates the existing implementation in AsyncResumptionStub.Sorting.cs (lines 13-16). Both implementations are identical and will cause a compilation error due to duplicate method definitions in the same partial class.
    src/coreclr/tools/Common/TypeSystem/IL/Stubs/AsyncResumptionStub.cs:127
  • The brtrue instruction requires an address on the stack, but EmitLdLoca loads the address of the local variable. This should be EmitLdLoc instead to load the value of the continuation for the branch condition. The CoreCLR reference implementation uses EmitLDLOC (line 14772 in jitinterface.cpp).

public override MethodIL EmitIL()
{
return _methodIL ?? InitializeMethodIL();
}
Copy link

Copilot AI Nov 7, 2025

Choose a reason for hiding this comment

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

The logic for returnsVoid is inverted. It should be == instead of !=. The variable is true when the return type is NOT void, which means the variable name and logic are contradictory. This will cause incorrect IL generation - the result handling code will execute when the method returns void and will be skipped when it doesn't return void.

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 36
protected override int ClassCode => unchecked((int)0xa91ac565);

Copy link

Copilot AI Nov 7, 2025

Choose a reason for hiding this comment

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

This ClassCode value conflicts with the existing value in AsyncResumptionStub.Sorting.cs (0x773ab1). Both implementations define ClassCode for the same partial class, creating a duplicate definition that will cause a compilation error.

Suggested change
protected override int ClassCode => unchecked((int)0xa91ac565);

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +79
MethodDesc asyncCallContinuation = Context.SystemModule.GetKnownType("System.Runtime.CompilerServices"u8, "AsyncHelpers"u8)
.GetKnownMethod("AsyncCallContinuation"u8, null);
Copy link
Member

Choose a reason for hiding this comment

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

For this to work you need to update JIT and the location in CoreCLR's corelib too.

(Cc @eduardo-vp since it's used in his PR too and I told him to look into getAsyncResumptionStub next. Since this one is now taken, #121443 is adding yet another IL thunk, so Eduardo, you could take that one.)

#endif
[Intrinsic]
[BypassReadyToRun]
internal static Continuation? AsyncCallContinuation() => throw new UnreachableException(); // Unconditionally expanded intrinsic
Copy link
Member

Choose a reason for hiding this comment

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

The JIT and VM needs to be update for the new location.

#endif
[Intrinsic]
[BypassReadyToRun]
internal static Continuation? AsyncCallContinuation() => throw new UnreachableException(); // Unconditionally expanded intrinsic
Copy link
Member

Choose a reason for hiding this comment

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

AsyncHelpers.CoreCLR.cs would be a better place for this for the time being. Moving it there is going to fix the build break as well.

Copy link
Member

Choose a reason for hiding this comment

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

(near AsyncSuspend method in that file)

@jtschuster
Copy link
Member Author

Created #121458 to keep the corelib changes in a separate commit.

ilStream.EmitLdLoc(local);
}
ilStream.Emit(ILOpcode.ldftn, ilEmitter.NewToken(_owningMethod));
ilStream.Emit(ILOpcode.calli, ilEmitter.NewToken(this.Signature));
Copy link
Member

Choose a reason for hiding this comment

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

is this.Signature the signature of the stub or the method that we are resuming?

Copy link
Member Author

Choose a reason for hiding this comment

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

this.Signature is the stub signature. This should be changed to _owningMethod.Signature.

}

return emitter.Link(this);
foreach (var param in _owningMethod.Signature)
Copy link
Member

@VSadov VSadov Nov 8, 2025

Choose a reason for hiding this comment

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

On the JIT side here, between this and other arguments, we also put:

  • generic context arg, if present.
    It is set to 0, and will not be used by the calee. Continuation has captured the real value and that will be used instead. But we need to match what the calee expects, so we need to push 0 for the context.
  • continuation.
    it is our arg0. Per the ABI of Async* calls it goes right before the other arguments.
    (unless this is x86)

On x86, as usual, there is a difference and the hidden args go after formal ones and in reverse order.

--
*Note - the resume stub is an ordinary function, but what we are resuming is an Async method, thus we shuffle the continuation from our arg0 to its predefined/hidden position in the calee signature.

ilStream.Emit(ILOpcode.initobj, ilEmitter.NewToken(param));
ilStream.EmitLdLoc(local);
}
ilStream.Emit(ILOpcode.ldftn, ilEmitter.NewToken(_owningMethod));
Copy link
Member

@VSadov VSadov Nov 8, 2025

Choose a reason for hiding this comment

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

Not sure. Maybe this is correct in NAOT, but ldftn is a bit suspicious.

If this is a metadata token for the method that we are resuming, you may end up calling the Task-returning variant.
In the JIT counterpart we load the actual address of the JITed target method.
As in:

        pCode->EmitLDC((DWORD_PTR)m_finalCodeAddressSlot);
        pCode->EmitLDIND_I();

cc: @MichalStrehovsky - will ldftn do the right thing here?

Copy link
Member

@jkotas jkotas Nov 8, 2025

Choose a reason for hiding this comment

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

This should be emitted as a regular call and the JIT/EE interface and/or the JIT itself should make sure that the call does the right thing.

(Once we figure out what to do here, we may want to switch the JIT counterpart to the same plan.)

Copy link
Member

Choose a reason for hiding this comment

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

Yes, this should be a regular call. The call will be to a thing with RuntimeAsync calling convention; we can make tokens for that and we will make such token here. We probably don't need to pass generic context explicitly either.

But the question is what this will do in the JIT. My expectation is that it will take similar paths as a AsyncExplicitImpl method call.

Copy link
Member

Choose a reason for hiding this comment

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

But the question is what this will do in the JIT. My expectation is that it will take similar paths as a AsyncExplicitImpl method call.

Runtime async method calls pass a null continuation in the JIT. This needs to pass an actual continuation.

Copy link
Member

Choose a reason for hiding this comment

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

"Resume" in a way is the opposite of "Call". In Call we pass formal arguments, generic context, etc.., but continuation is null. In Resume only continuation is what is not default/null.

Doing CALLI with a special-crafted signature is the technique to do a low level "unsafe cast" through call conventions in IL. I have seen it in other places (like instantiating stubs?).

Copy link
Member

Choose a reason for hiding this comment

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

My expectation is that it will take similar paths as a AsyncExplicitImpl method call.

Note - what we are resuming may actually be AsyncExplicitImpl.

Anything with Async call conv may need resuming. Whether it has a variant with non-async call convention is unimportant here. Just the part is that it is Async (i.e. emitted as a state machine, has async call conv).

Copy link
Member

@jkotas jkotas Nov 8, 2025

Choose a reason for hiding this comment

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

I have seen it in other places (like instantiating stubs?).

I do not think that this trick is used anywhere in AOT compilers (just looked through all Emit(ILOpcode.calli - none of them look like that).

AsyncCallContinuation() intrinsic gets the async continuation from the last call. Can we have a counterpart that sets the async continuation for the next call?

We can try going the ldftn route, but I expect that it will hit problems with generics and the generated code won't be the best at the end if we manage to make it work. Managed function pointers in NAOT are tagged pointers. Regular calli of a managed function pointer compiles into if (ptr & tag_bit) { ptr[0](ptr[1] /* instantiating arg */, regular args); } else { ptr(regular args) }. We would probably need to suppress this for this ldftn + calli pair.

Copy link
Member

@VSadov VSadov Nov 8, 2025

Choose a reason for hiding this comment

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

Could CALLI trick work the same as in JIT case if we clear the tag after LDFTN?

(This all assumes that we can know if the calee/resumee has a generic context parameter and can push 0 to its position)

Copy link
Member

Choose a reason for hiding this comment

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

As another wild thought - I think this IL is never exposed outside of internals of VM/JIT/AOT. Perhaps we could use an IL prefix to tag a call as a resuming call?

Ex:

// load continuation 
   ldarg 0     
// the prefix means "consume one arg as a continuation argument, pass everything else as default"
// could be applied to calli as well
// callee must have async callconv
   resume.              
   call  <token>     

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants