Skip to content

Fix profiler ELT callbacks for Runtime Async suspension - #131985

Open
tommcdon wants to merge 2 commits into
dotnet:mainfrom
tommcdon:dev/tommcdon/runtime-async-elt-contract
Open

tommcdon wants to merge 2 commits into
dotnet:mainfrom
tommcdon:dev/tommcdon/runtime-async-elt-contract

Conversation

@tommcdon

@tommcdon tommcdon commented Aug 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Emit profiler Leave callbacks when Runtime Async methods suspend, pairing the existing Enter callbacks emitted on initial invocation and resume. This makes ELT callbacks consistently represent physical execution segments.

Preserves the continuation across profiler callbacks on all supported ABIs and adds Windows/Linux regression coverage for Task, Task<long>, and Task<double> methods.

Fixes #122488.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tommcdon tommcdon added this to the 11.0.0 milestone Aug 7, 2026
@tommcdon
tommcdon requested a review from lateralusX August 7, 2026 04:00
@tommcdon tommcdon self-assigned this Aug 7, 2026
Copilot AI review requested due to automatic review settings August 7, 2026 04:00
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

@azure-pipelines

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

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

This PR updates CoreCLR JIT codegen for Runtime Async suspension points to emit profiler Leave callbacks on suspend (to match the existing Enter callbacks that occur on initial invocation and resume), while preserving the async continuation value across the profiler helper call. It also adds a new profiler-based regression test to validate the callback pairing behavior.

Changes:

  • Update CodeGen::genReturnSuspend to call the profiling leave helper on GT_RETURN_SUSPEND, with target-specific handling to preserve the async continuation register across the callback.
  • Add a new native profiler (RuntimeAsyncELTProfiler) that counts and validates enter/leave sequencing for a runtime-async method.
  • Add a new managed profilee (runtimeasyncelt) and wire the new profiler into the native profiler test build/activation.

Reviewed changes

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

Show a summary per file
File Description
src/coreclr/jit/codegencommon.cpp Emit profiling Leave callback on runtime-async suspend while preserving the continuation across the helper call.
src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.h Declares a new native profiler test type tracking enter/leave counts and sequencing.
src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.cpp Implements the enter/leave hooks, target method selection, and pass/fail reporting.
src/tests/profiler/native/CMakeLists.txt Adds the new profiler source to the native profiler test build.
src/tests/profiler/native/classfactory.cpp Registers the new profiler CLSID in the native class factory.
src/tests/profiler/elt/runtimeasyncelt.csproj Adds the new managed profilee project configured for runtime-async.
src/tests/profiler/elt/runtimeasyncelt.cs Managed profilee that exercises runtime-async suspension/resume patterns.

Comment on lines +71 to +74
if (FAILED(hrStatus) || GetFunctionIDName(functionId) != WCHAR("Work"))
{
return S_OK;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 58c682a. The profiler now tracks Work, WorkVoid, and WorkDouble independently and requires exact paired callback counts of 25, 3, and 3 respectively, with balanced depth and no sequence failures. The expanded test passes on Windows x64 and Linux x64.

Comment on lines +7519 to +7569
#ifdef PROFILING_SUPPORTED
if (m_compiler->compIsProfilerHookNeeded())
{
#ifdef TARGET_ARM
// The ARM32 Leave helper preserves REG_PROFILER_RET_SCRATCH, which is also the dedicated
// Runtime Async continuation register. For integer/soft-float returns it preserves R0 by
// moving it through this scratch register; for void and hard-float returns R0 is overwritten
// and the scratch register itself is preserved.
static_assert(REG_ASYNC_CONTINUATION_RET == REG_PROFILER_RET_SCRATCH);
bool r0InUse;
if (m_compiler->info.compRetType == TYP_VOID)
{
r0InUse = false;
}
else if (varTypeIsFloating(m_compiler->info.compRetType) ||
m_compiler->IsHfa(m_compiler->info.compMethodInfo->args.retTypeClass))
{
r0InUse = m_compiler->info.compIsVarArgs || m_compiler->opts.compUseSoftFP;
}
else
{
r0InUse = true;
}
if (r0InUse)
{
inst_Mov(TYP_REF, REG_INTRET, reg, /* canSkip */ true);
gcInfo.gcMarkRegPtrVal(REG_INTRET, TYP_REF);
genProfilingLeaveCallback(CORINFO_HELP_PROF_FCN_LEAVE);
inst_Mov(TYP_REF, REG_ASYNC_CONTINUATION_RET, REG_INTRET, /* canSkip */ true);
gcInfo.gcMarkRegSetNpt(genRegMask(REG_INTRET));
}
else
{
inst_Mov(TYP_REF, REG_ASYNC_CONTINUATION_RET, reg, /* canSkip */ true);
gcInfo.gcMarkRegPtrVal(REG_ASYNC_CONTINUATION_RET, TYP_REF);
genProfilingLeaveCallback(CORINFO_HELP_PROF_FCN_LEAVE);
}
#else
// The Leave helper preserves the normal return register, but may overwrite the dedicated
// Runtime Async continuation register because it is an argument register on supported
// ABIs. Temporarily use the normal return register to carry the continuation across the
// callback, then restore the dedicated register.
inst_Mov(TYP_REF, REG_INTRET, reg, /* canSkip */ true);
gcInfo.gcMarkRegPtrVal(REG_INTRET, TYP_REF);
genProfilingLeaveCallback(CORINFO_HELP_PROF_FCN_LEAVE);
inst_Mov(TYP_REF, REG_ASYNC_CONTINUATION_RET, REG_INTRET, /* canSkip */ true);
gcInfo.gcMarkRegSetNpt(genRegMask(REG_INTRET));
#endif
}
else
#endif

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you factor this into a function? The ifdefs make this quite hard to read now.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 58c682a. I factored the target-specific preservation and Leave emission into genProfilingLeaveCallbackForAsyncSuspend, so genReturnSuspend now only selects the profiled versus unprofiled path.

Comment thread src/coreclr/jit/codegencommon.cpp Outdated
Comment on lines +7557 to +7560
// The Leave helper preserves the normal return register, but may overwrite the dedicated
// Runtime Async continuation register because it is an argument register on supported
// ABIs. Temporarily use the normal return register to carry the continuation across the
// callback, then restore the dedicated register.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are these ELT hooks usually able to look at register state? Does this e.g. break the ability to look at return values from leave callbacks in async functions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A suspension Leave marks the end of a physical execution segment, not logical method completion, so there is no semantic method return value at that callback. The normal return registers may contain non-semantic state there. The final completion path is unchanged and still uses normal return codegen, so its Leave callback continues to expose the actual return value. I added this distinction to the helper comment in 58c682a.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How is the leave hook expected to detect that it is being called while the return register happens to contain a continuation instead of a return value?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this exposes an issue - profilers might not be able to tell the difference between a suspension vs. a synchronous method return. I propose we return CORPROF_E_DATAINCOMPLETE with no return-value range for suspension, and then a synchronous/final return returns S_OK with the real return value. Does that sound reasonable?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, that sounds good. I am not so familiar with this, how do you think we should pass that info through?

FWIW the enter callback similarly will not have arguments available on resumption, so I expect we want to do something similar there.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For Enter, I think we can inspect the Continuation argument, and if it is non-null, GetFunctionEnter3Info would return CORPROF_E_DATAINCOMPLETE.
And for Leave, we can use REG_ASYNC_CONTINUATION_RET to detect suspension and similarly return CORPROF_E_DATAINCOMPLETE if non-null.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For leave, the implementation here does not allow for detecting that with the way it tries to save the continuation across the call to the hook.

@noahfalk noahfalk Aug 11, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The normal return registers may contain non-semantic state there. The final completion path is unchanged and still uses normal return codegen, so its Leave callback continues to expose the actual return value.

I'm guessing this return value doesn't match the type specified in the function's signature? Ie the function declares its return type to be Task<float> but the actual jitted return type is float. I'd suggest the runtime not provide information about the return value for calls using the async ABI. Either the runtime profiler API will misinterpret where to get the return value from, or caller will misinterpret the bytes they get back, or the caller will be hardcoding a bunch of implementation details about how runtime async is implemented that we'd probably rather they didn't rely on.

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.

I ran the test profiler calling GetFunctionLeave3Info (with COR_PRF_ENABLE_FUNCTION_RETVAL) on every Leave of the test methods, on linux-x64, linux-arm64, macOS arm64 and Windows x64 at 58c682a. What the hook sees:

  • Task<long>: each suspension Leave returns S_OK with an 8-byte range holding the continuation pointer. GetClassLayout on its class gives CORPROF_E_DATAINCOMPLETE.
  • Completion: the raw T (6), since the range follows the async variant's signature, as @noahfalk suspected.
  • Task: the length is 0 at both kinds of Leave.
  • Struct T via return buffer: the suspension range starts at the continuation; for a 512-byte struct it is 512 bytes long, while GetObjectSize2 reports that object as 64.

The GC side holds up: under GCStress=0x4/0xC on a Checked linux-x64 runtime the test passed 53 of 53 runs, and without the gcMarkRegPtrVal(REG_INTRET, TYP_REF) here 7 of 53 failed, so the stress does cover that register.

On CORPROF_E_DATAINCOMPLETE: ProfileLeave's Leave2 slow path only asserts S_OK, then passes its uninitialised argumentRange to the hook. That already happens on main whenever RETVAL is off (FRAME_INFO alone takes that path), so I'll send the = {} as its own small PR; it would also cover a DATAINCOMPLETE result here.

One more, specific to Unix x64, in the next 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.

Specific to Unix x64: for Task<double> and Task<float>, the RETVAL query overwrites the continuation this change parks in rax. GetReturnBufferAddr handles R4/R8 by writing pData->rax = pData->flt0, and ProfileLeaveNaked reloads rax from that slot after the callback.

XMM0 held sum (0.0) at the first suspension, so the continuation came back null: WorkDouble never resumed and the program got 0 instead of 2 (Task<float> likewise). With sum starting at 1.0 the continuation became 0x3ff0000000000000 and the caller died with a NullReferenceException. A FunctionLeave2 hook with RETVAL does the same, since the runtime makes this call itself. linux-arm64, macOS arm64 and Windows x64 (whose stub restores rax from its own save) don't show this.

Returning the FP slot avoids it: everything completes, and on main the existing slowpatheltleave FP checks still pass with it. It only matters once rax is live across Leave, which is this change, so it probably belongs here; I also have it as a separate branch if you'd rather keep it out. It stops the corruption only; what a suspension query should return is still the open question above.

     if (ELEMENT_TYPE_R4 == t || ELEMENT_TYPE_R8 == t)
     {
-        pData->rax = pData->flt0;
+        return &(pData->flt0);
     }

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 7, 2026 17:16

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 8 out of 8 changed files in this pull request and generated no new comments.

const char* const TargetDisplayNames[] = {"Work", "WorkVoid", "WorkDouble"};
const int ExpectedCallbackCounts[] = {25, 3, 3};

void STDMETHODCALLTYPE EnterStub(FunctionIDOrClientID functionId, COR_PRF_ELT_INFO)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These callbacks are intended to be __declspec(naked)
https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/profiling/functionenter3withinfo-function#remarks.

They are annoying to implement properly because they are supposed to restore every register they touch, including registers that would normally be volatile that the C/C++ compiler won't preserve. Historically I believe we implemented ELT tests with assembly wrappers.

Even though we document __declspec(naked) as being required I don't know that other profiler implementers write them to that standard or that the JIT assumes implementers write them to that standard. If the JIT actually doesn't expect the callee to preserve all the registers we might want to update the docs.

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.

I tried this out.

For the WithInfo hooks this test registers, SetEnterLeaveFunctionHooks3WithInfo routes the JIT's call through ProfileEnterNaked/ProfileLeaveNaked. Those stubs save the state the JIT relies on and then call the hook as an ordinary function. On linux-x64, Enter and Leave hooks that clear the volatile GPRs and xmm0–7 before returning still pass this test. The existing ELT tests (slowpatheltprofiler, elttransitions, inlining) use plain STDMETHODCALLTYPE hooks the same way.

With SetEnterLeaveFunctionHooks3 the JIT calls the hook directly, and there it matters. Plain C++ hooks there crash on linux-x64, linux-arm64, macOS arm64 and Windows x64. On x64 the RBM_PROFILER_{ENTER,LEAVE,TAILCALL}_TRASH masks name the registers a hook may modify; other targets need their codegen checked too (on ARM, R2 is in the Leave mask but carries the return value). With asm wrappers on linux-x64:

  • Preserving the volatile GPRs and xmm0–15 passes.
  • Zeroing the listed Enter registers (rax, r10, r11, xmm8–15) and Leave/Tailcall registers (rcx, rsi, rdi, r8–r11, xmm2–15) also passes.
  • Zeroing rax after Leave, or rdi after Enter, crashes.

On Unix x64 the Enter hook also gets its argument in r14 (caller SP in r15), so a fast-path FunctionEnter3 can't be a plain C function at all.

This is a small, one-test version of the experiment you sketched in #10727. If it helps the docs side, here's a clr-abi.md update (below). Today it says the parameter is always in RCX/R0. The Unix x64 rows match what I measured; the others come from the JIT source. Happy to open it as a PR.

Proposed "Profiler Hooks" section of docs/design/coreclr/botr/clr-abi.md (as it would read; headings shown smaller here)

New: the two tables, the paragraph after the first one, the reworded AMD64 second-parameter sentence, and the "Registers the call-outs may modify" subsection. The rest is the existing text.


Profiler Hooks

If the JIT gets passed CORJIT_FLG_PROF_ENTERLEAVE, then the JIT might need to insert native entry/exit/tail call probes. To determine for sure, the JIT must call GetProfilingHandle. This API returns as out parameters, the true dynamic boolean indicating if the JIT should actually insert the probes and a parameter to pass to the callbacks (typed as void*), with an optional indirection (used for NGEN). This parameter, and on most targets the caller's stack pointer, are passed to the call-outs in these registers:

Target Enter Leave and Tailcall
Windows x64 RCX (parameter), RDX (caller's SP) RCX, RDX
Unix x64 R14 (parameter), R15 (caller's SP) RDI, RSI
ARM64 X10 (parameter), X11 (caller's SP) X10, X11
ARM R0 (parameter) R0
LoongArch64 T1 (parameter), T2 (caller's SP) T1, T2
RISC-V 64 T0 (parameter), T1 (caller's SP) T0, T1

On Unix x64 the Enter call-out does not use the first argument register, because the method's own incoming arguments are still live in the argument registers at that point. On x64 the other call-outs use the first two argument registers (REG_ARG_0, REG_ARG_1), and Unix Enter uses REG_PROFILER_ENTER_ARG_0/1. The other targets define the registers as REG_PROFILER_*_ARG* in the JIT's target*.h files. On x86 the parameters are passed on the stack (see below).

Outside of the prolog (in a GC interruptible location), the JIT injects a call to CORINFO_HELP_PROF_FCN_ENTER. For AMD64, on Windows all argument registers will be homed into their caller-allocated stack locations (similar to varargs), on Unix all argument registers will be stored in the inner structure. For ARM and ARM64, all arguments are prespilled (again similar to varargs).

After computing the return value and storing it in the correct register, but before any epilog code (including before a possible GS cookie check), the JIT injects a call to CORINFO_HELP_PROF_FCN_LEAVE. For AMD64 this call must preserve the return register: RAX or XMM0 on Windows and RAX and RDX or XMM0 and XMM1 on Unix. For ARM, the return value will be moved from R0 to R2 (if it was in R0), R1, R2, and S0/D0 must be preserved by the callee (longs will be R2, R1 - note the unusual ordering of the registers, floats in S0, doubles in D0, smaller integrals in R2).

TODO: describe ARM64 profile leave conventions.

Before the argument setup (but after any argument side-effects) for any tail calls or jump calls, the JIT injects a call to CORINFO_HELP_PROF_FCN_TAILCALL. Note that it is NOT called for self-recursive tail calls turned into loops.

For ARM tail calls, the JIT actually loads the outgoing arguments first, and then just before the profiler call-out, spills the argument in R0 to another non-volatile register, makes the call (passing the callback parameter in R0), and then restores R0.

For AMD64, all probes receive a second parameter which is the address of the start of the arguments' home location (equivalent to the value of the caller's stack pointer). It is passed in RDX on Windows, and on Unix in R15 for Enter and RSI for Leave and Tailcall (see the table above).

TODO: describe ARM64 tail call convention.

Registers the call-outs may modify

The JIT treats the registers in each call-out's kill set as killed by the call: RBM_PROFILER_ENTER_TRASH, RBM_PROFILER_LEAVE_TRASH and RBM_PROFILER_TAILCALL_TRASH in the JIT's target*.h files. The register allocator and the emitter use these sets. On the targets below these are the registers the call-out may modify, with one exception: on ARM, RBM_PROFILER_LEAVE_TRASH contains R2 only so that the register allocator treats it as killed by the return. R2 holds the saved return value, and the Leave call-out must preserve it (see above). In summary:

Target Enter may modify Leave and Tailcall may modify
Windows x64 all volatile registers (the JIT homes the argument registers before the call and reloads them after it) volatile registers except RAX and XMM0
Unix x64 volatile registers except the integer and floating-point argument registers volatile registers except RAX, RDX, XMM0 and XMM1
ARM64 volatile registers except the argument registers, the return buffer register and FP the same set as Enter
ARM none none (R2 is in the Leave kill set but must be preserved, as described above)
x86 none Leave: none; Tailcall: volatile registers except the argument registers

LoongArch64 and RISC-V 64 define these sets in their target*.h files too; they are not summarized here.

Who has to honour this depends on how the profiler registered its hooks:

  • With SetEnterLeaveFunctionHooks3WithInfo, SetEnterLeaveFunctionHooks2 or SetEnterLeaveFunctionHooks, the call-outs are the runtime's ProfileEnterNaked, ProfileLeaveNaked and ProfileTailcallNaked stubs. They save the state the JIT relies on, then call the profiler's hook as an ordinary function. The profiler's hook does not need to preserve volatile registers.
  • With SetEnterLeaveFunctionHooks3, the JIT calls the profiler's hooks directly. The profiler's hooks must then preserve every register the table above does not list, and receive their parameters in the registers given in the first table; on Unix x64, for example, the Enter hook receives its parameter in R14, not in the first argument register. A C or C++ compiler does not guarantee either of these, so in practice these hooks are assembly stubs.

void STDMETHODCALLTYPE EnterStub(FunctionIDOrClientID functionId, COR_PRF_ELT_INFO)
{
SHUTDOWNGUARD_RETVOID();
RuntimeAsyncELTProfiler::Instance()->Enter(functionId.functionID);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It sounds like we've got some active discussion about how the arguments and return values are going to behave in these callbacks. It would be nice if the test called GetEnter/Leave/Tailcall3Info() and validated that we get the expected data or error result.

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.

I extended the test profiler to enable COR_PRF_ENABLE_FUNCTION_ARGS | COR_PRF_ENABLE_FUNCTION_RETVAL and to call GetFunctionEnter3Info, GetFunctionLeave3Info and GetFunctionTailcall3Info from the hooks. On linux-x64, linux-arm64, macOS arm64 and Windows x64:

  • Initial Enter: one 4-byte range with the real iterations value.
  • Resumption Enter: S_OK, one 4-byte range, value 0.
  • Suspension Leave: S_OK, with the continuation's bits for Task<long>, an empty range for Task, and stale FP bits for Task<double> (more in the other thread).
  • Completion Leave: the raw T: 6, and 2.0 (on linux-x64, 2.0 only with the fix from the other thread).
  • Tail calls: none of the test's async methods reaches the Tailcall hook. The JIT only allows a tail await there, and user code can't express one. Other methods do reach it, and GetFunctionTailcall3Info returns S_OK for them.

The version I'd suggest (diff below) adds a Task<float> method and checks the initial arguments, today's completion values, frame info, and that the targets make no tail calls. At a suspension or resumption it accepts S_OK (with frame info) or CORPROF_E_DATAINCOMPLETE until that contract settles, so there it mainly checks that the query does no harm. If return values end up withheld for async methods, as you suggested in the other thread, the completion checks change to match. Each of its 21 checks, broken on its own, turned it red on linux-x64.

It passes on linux-arm64, macOS arm64 and Windows x64. On linux-x64 it fails at this head, because querying the Task<double> or Task<float> return value overwrites the parked continuation (other thread), and passes with that fixed. Happy to push it as a commit you can cherry-pick, if that's easier.

Proposed test changes: git apply-able diff against 58c682a (runtimeasyncelt.cs, runtimeasynceltprofiler.{cpp,h})
diff --git a/src/tests/profiler/elt/runtimeasyncelt.cs b/src/tests/profiler/elt/runtimeasyncelt.cs
index d601024a494..2cbc6b01778 100644
--- a/src/tests/profiler/elt/runtimeasyncelt.cs
+++ b/src/tests/profiler/elt/runtimeasyncelt.cs
@@ -65,6 +65,17 @@ private static async Task<double> WorkDouble(int iterations)
         return sum;
     }
 
+    private static async Task<float> WorkFloat(int iterations)
+    {
+        float sum = 0;
+        for (int i = 0; i < iterations; i++)
+        {
+            await Task.Yield();
+            sum += i + 0.5f;
+        }
+        return sum;
+    }
+
     private static async Task<int> RunAsync()
     {
         long total = 0;
@@ -75,8 +86,9 @@ private static async Task<int> RunAsync()
 
         await WorkVoid(2);
         double floatingResult = await WorkDouble(2);
+        float singleResult = await WorkFloat(2);
 
-        return total == 30 && floatingResult == 2.0 ? 100 : 1;
+        return total == 30 && floatingResult == 2.0 && singleResult == 2.0f ? 100 : 1;
     }
 
     private static int RunTest() => RunAsync().GetAwaiter().GetResult();
diff --git a/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.cpp b/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.cpp
index 03478556ab0..73379058c19 100644
--- a/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.cpp
+++ b/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.cpp
@@ -5,25 +5,40 @@
 
 namespace
 {
-const WCHAR* const TargetNames[] = {WCHAR("Work"), WCHAR("WorkVoid"), WCHAR("WorkDouble")};
-const char* const TargetDisplayNames[] = {"Work", "WorkVoid", "WorkDouble"};
-const int ExpectedCallbackCounts[] = {25, 3, 3};
+const WCHAR* const TargetNames[] = {WCHAR("Work"), WCHAR("WorkVoid"), WCHAR("WorkDouble"), WCHAR("WorkFloat")};
+const char* const TargetDisplayNames[] = {"Work", "WorkVoid", "WorkDouble", "WorkFloat"};
+const int ExpectedCallbackCounts[] = {25, 3, 3, 3};
 
-void STDMETHODCALLTYPE EnterStub(FunctionIDOrClientID functionId, COR_PRF_ELT_INFO)
+// Callbacks per call: Work(4) is entered once and resumed 4 times; WorkVoid(2), WorkDouble(2) and
+// WorkFloat(2) are entered once and resumed twice. The first Enter of a call is its initial entry, the last
+// Leave its completion; every other Enter is a resumption and every other Leave a suspension.
+const int CallbacksPerCall[] = {5, 3, 3, 3};
+const int ExpectedIterations[] = {4, 2, 2, 2};
+
+void STDMETHODCALLTYPE EnterStub(FunctionIDOrClientID functionId, COR_PRF_ELT_INFO eltInfo)
 {
     SHUTDOWNGUARD_RETVOID();
-    RuntimeAsyncELTProfiler::Instance()->Enter(functionId.functionID);
+    RuntimeAsyncELTProfiler::Instance()->Enter(functionId.functionID, eltInfo);
 }
 
-void STDMETHODCALLTYPE LeaveStub(FunctionIDOrClientID functionId, COR_PRF_ELT_INFO)
+void STDMETHODCALLTYPE LeaveStub(FunctionIDOrClientID functionId, COR_PRF_ELT_INFO eltInfo)
 {
     SHUTDOWNGUARD_RETVOID();
-    RuntimeAsyncELTProfiler::Instance()->Leave(functionId.functionID);
+    RuntimeAsyncELTProfiler::Instance()->Leave(functionId.functionID, eltInfo);
 }
 
-void STDMETHODCALLTYPE TailcallStub(FunctionIDOrClientID, COR_PRF_ELT_INFO)
+void STDMETHODCALLTYPE TailcallStub(FunctionIDOrClientID functionId, COR_PRF_ELT_INFO eltInfo)
 {
     SHUTDOWNGUARD_RETVOID();
+    RuntimeAsyncELTProfiler::Instance()->Tailcall(functionId.functionID, eltInfo);
+}
+
+// The suspension/resumption contract is still being decided, so either a successful query or
+// CORPROF_E_DATAINCOMPLETE is accepted there. A successful query must still report the frame. Whatever it
+// returns, the query must not disturb the method.
+bool IsAcceptableSegmentResult(HRESULT hr, COR_PRF_FRAME_INFO frameInfo)
+{
+    return (hr == S_OK && frameInfo != 0) || hr == CORPROF_E_DATAINCOMPLETE;
 }
 }
 
@@ -31,11 +46,16 @@ RuntimeAsyncELTProfiler* RuntimeAsyncELTProfiler::s_instance = nullptr;
 
 RuntimeAsyncELTProfiler::RuntimeAsyncELTProfiler()
     : Profiler(),
-      _sequenceFailures(0)
+      _sequenceFailures(0),
+      _infoFailures(0),
+      _targetTailcalls(0),
+      _otherTailcalls(0)
 {
     static_assert(sizeof(TargetNames) / sizeof(TargetNames[0]) == TargetCount);
     static_assert(sizeof(TargetDisplayNames) / sizeof(TargetDisplayNames[0]) == TargetCount);
     static_assert(sizeof(ExpectedCallbackCounts) / sizeof(ExpectedCallbackCounts[0]) == TargetCount);
+    static_assert(sizeof(CallbacksPerCall) / sizeof(CallbacksPerCall[0]) == TargetCount);
+    static_assert(sizeof(ExpectedIterations) / sizeof(ExpectedIterations[0]) == TargetCount);
 
     for (int i = 0; i < TargetCount; i++)
     {
@@ -64,7 +84,8 @@ HRESULT RuntimeAsyncELTProfiler::Initialize(IUnknown* pCorProfilerInfoUnk)
     s_instance = this;
 
     hr = pCorProfilerInfo->SetEventMask2(
-        COR_PRF_MONITOR_ENTERLEAVE | COR_PRF_MONITOR_JIT_COMPILATION | COR_PRF_ENABLE_FRAME_INFO,
+        COR_PRF_MONITOR_ENTERLEAVE | COR_PRF_MONITOR_JIT_COMPILATION | COR_PRF_ENABLE_FRAME_INFO |
+            COR_PRF_ENABLE_FUNCTION_ARGS | COR_PRF_ENABLE_FUNCTION_RETVAL,
         0);
     if (FAILED(hr))
     {
@@ -109,7 +130,14 @@ HRESULT RuntimeAsyncELTProfiler::JITCompilationFinished(
     return S_OK;
 }
 
-void RuntimeAsyncELTProfiler::Enter(FunctionID functionId)
+void RuntimeAsyncELTProfiler::Fail(const char* what, int targetIndex, int index, HRESULT hr)
+{
+    _infoFailures++;
+    printf("RuntimeAsyncELTProfiler: FAIL %s %s #%d hr=0x%08x\n", what, TargetDisplayNames[targetIndex], index, (unsigned)hr);
+    fflush(stdout);
+}
+
+void RuntimeAsyncELTProfiler::Enter(FunctionID functionId, COR_PRF_ELT_INFO eltInfo)
 {
     int targetIndex = GetTargetIndex(functionId);
     if (targetIndex < 0)
@@ -117,14 +145,38 @@ void RuntimeAsyncELTProfiler::Enter(FunctionID functionId)
         return;
     }
 
-    _enters[targetIndex]++;
+    int enterIndex = _enters[targetIndex]++;
     if (_depth[targetIndex].fetch_add(1) != 0)
     {
         _sequenceFailures++;
     }
+
+    COR_PRF_FRAME_INFO frameInfo = 0;
+    alignas(8) BYTE argumentBuffer[256];
+    ULONG cbArgumentInfo = sizeof(argumentBuffer);
+    COR_PRF_FUNCTION_ARGUMENT_INFO* argumentInfo = reinterpret_cast<COR_PRF_FUNCTION_ARGUMENT_INFO*>(argumentBuffer);
+    HRESULT hr = pCorProfilerInfo->GetFunctionEnter3Info(functionId, eltInfo, &frameInfo, &cbArgumentInfo, argumentInfo);
+
+    bool initial = (enterIndex % CallbacksPerCall[targetIndex]) == 0;
+    if (!initial)
+    {
+        // Resumption: the original arguments are not passed again.
+        if (!IsAcceptableSegmentResult(hr, frameInfo))
+        {
+            Fail("resumption GetFunctionEnter3Info", targetIndex, enterIndex, hr);
+        }
+        return;
+    }
+
+    // Initial entry: the single int argument must be reported with its real value.
+    if (hr != S_OK || frameInfo == 0 || argumentInfo->numRanges != 1 || argumentInfo->ranges[0].length != sizeof(int) ||
+        *reinterpret_cast<int*>(argumentInfo->ranges[0].startAddress) != ExpectedIterations[targetIndex])
+    {
+        Fail("initial GetFunctionEnter3Info", targetIndex, enterIndex, hr);
+    }
 }
 
-void RuntimeAsyncELTProfiler::Leave(FunctionID functionId)
+void RuntimeAsyncELTProfiler::Leave(FunctionID functionId, COR_PRF_ELT_INFO eltInfo)
 {
     int targetIndex = GetTargetIndex(functionId);
     if (targetIndex < 0)
@@ -132,11 +184,77 @@ void RuntimeAsyncELTProfiler::Leave(FunctionID functionId)
         return;
     }
 
-    _leaves[targetIndex]++;
+    int leaveIndex = _leaves[targetIndex]++;
     if (_depth[targetIndex].fetch_sub(1) != 1)
     {
         _sequenceFailures++;
     }
+
+    COR_PRF_FRAME_INFO frameInfo = 0;
+    COR_PRF_FUNCTION_ARGUMENT_RANGE retvalRange = {};
+    HRESULT hr = pCorProfilerInfo->GetFunctionLeave3Info(functionId, eltInfo, &frameInfo, &retvalRange);
+
+    bool completion = (leaveIndex % CallbacksPerCall[targetIndex]) == CallbacksPerCall[targetIndex] - 1;
+    if (!completion)
+    {
+        // Suspension: there is no method result yet. The query itself must be harmless; the profilee's own
+        // result checks catch a disturbed continuation.
+        if (!IsAcceptableSegmentResult(hr, frameInfo))
+        {
+            Fail("suspension GetFunctionLeave3Info", targetIndex, leaveIndex, hr);
+        }
+        return;
+    }
+
+    // Completion: the range describes the value the async method returns (the T of Task<T>).
+    bool ok = hr == S_OK && frameInfo != 0;
+    switch (targetIndex)
+    {
+    case 0: // Work: long, 0+1+2+3
+        ok = ok && retvalRange.length == sizeof(long long) &&
+             *reinterpret_cast<long long*>(retvalRange.startAddress) == 6;
+        break;
+    case 1: // WorkVoid: no value
+        ok = ok && retvalRange.length == 0;
+        break;
+    case 2: // WorkDouble: double, 0.5 + 1.5
+        ok = ok && retvalRange.length == sizeof(double) &&
+             *reinterpret_cast<double*>(retvalRange.startAddress) == 2.0;
+        break;
+    case 3: // WorkFloat: float, 0.5f + 1.5f
+        ok = ok && retvalRange.length == sizeof(float) &&
+             *reinterpret_cast<float*>(retvalRange.startAddress) == 2.0f;
+        break;
+    }
+    if (!ok)
+    {
+        Fail("completion GetFunctionLeave3Info", targetIndex, leaveIndex, hr);
+    }
+}
+
+void RuntimeAsyncELTProfiler::Tailcall(FunctionID functionId, COR_PRF_ELT_INFO eltInfo)
+{
+    // Runtime-async user methods never tail call: the JIT disqualifies every tail call in an async method
+    // except a tail await, which only CoreLib can express. Other methods do tail call here, so the query is
+    // still exercised.
+    COR_PRF_FRAME_INFO frameInfo = 0;
+    HRESULT hr = pCorProfilerInfo->GetFunctionTailcall3Info(functionId, eltInfo, &frameInfo);
+
+    int targetIndex = GetTargetIndex(functionId);
+    if (targetIndex >= 0)
+    {
+        int tailcallIndex = _targetTailcalls++;
+        Fail("unexpected tail call from", targetIndex, tailcallIndex, hr);
+        return;
+    }
+
+    _otherTailcalls++;
+    if (hr != S_OK || frameInfo == 0)
+    {
+        _infoFailures++;
+        printf("RuntimeAsyncELTProfiler: FAIL GetFunctionTailcall3Info hr=0x%08x\n", (unsigned)hr);
+        fflush(stdout);
+    }
 }
 
 HRESULT RuntimeAsyncELTProfiler::Shutdown()
@@ -144,7 +262,7 @@ HRESULT RuntimeAsyncELTProfiler::Shutdown()
     HRESULT hr = Profiler::Shutdown();
     s_instance = nullptr;
 
-    bool passed = SUCCEEDED(hr) && _sequenceFailures == 0;
+    bool passed = SUCCEEDED(hr) && _sequenceFailures == 0 && _infoFailures == 0 && _targetTailcalls == 0;
     for (int i = 0; i < TargetCount; i++)
     {
         printf("RuntimeAsyncELTProfiler: %s enters=%d leaves=%d depth=%d\n",
@@ -154,7 +272,8 @@ HRESULT RuntimeAsyncELTProfiler::Shutdown()
                   _leaves[i].load() == ExpectedCallbackCounts[i] &&
                   _depth[i].load() == 0;
     }
-    printf("RuntimeAsyncELTProfiler: sequenceFailures=%d\n", _sequenceFailures.load());
+    printf("RuntimeAsyncELTProfiler: sequenceFailures=%d infoFailures=%d targetTailcalls=%d otherTailcalls=%d\n",
+           _sequenceFailures.load(), _infoFailures.load(), _targetTailcalls.load(), _otherTailcalls.load());
     printf(passed ? "PROFILER TEST PASSES\n" : "PROFILER TEST FAILED\n");
 
     fflush(stdout);
diff --git a/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.h b/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.h
index 4f56623a30e..a17c9af6c09 100644
--- a/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.h
+++ b/src/tests/profiler/native/runtimeasyncelt/runtimeasynceltprofiler.h
@@ -16,16 +16,18 @@ public:
     HRESULT STDMETHODCALLTYPE JITCompilationFinished(
         FunctionID functionId, HRESULT hrStatus, BOOL fIsSafeToBlock) override;
 
-    void Enter(FunctionID functionId);
-    void Leave(FunctionID functionId);
+    void Enter(FunctionID functionId, COR_PRF_ELT_INFO eltInfo);
+    void Leave(FunctionID functionId, COR_PRF_ELT_INFO eltInfo);
+    void Tailcall(FunctionID functionId, COR_PRF_ELT_INFO eltInfo);
 
     static RuntimeAsyncELTProfiler* Instance() { return s_instance; }
 
 private:
-    static const int TargetCount = 3;
+    static const int TargetCount = 4;
 
     int GetTargetIndex(FunctionID functionId);
     ModuleID GetModuleId(FunctionID functionId);
+    void Fail(const char* what, int targetIndex, int index, HRESULT hr);
 
     static RuntimeAsyncELTProfiler* s_instance;
     std::atomic<FunctionID> _targets[TargetCount];
@@ -33,4 +35,7 @@ private:
     std::atomic<int> _leaves[TargetCount];
     std::atomic<int> _depth[TargetCount];
     std::atomic<int> _sequenceFailures;
+    std::atomic<int> _infoFailures;
+    std::atomic<int> _targetTailcalls;
+    std::atomic<int> _otherTailcalls;
 };

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Evaluate profiler callback behavior for runtime async methods

5 participants