Skip to content

[browser][CoreCLR] Enable EventPipe CPU sampling for RyuJIT/R2R on WebAssembly - #132788

Open
pavelsavara wants to merge 16 commits into
dotnet:mainfrom
pavelsavara:browser-coreclr-ep-cpu-sampling-r2r
Open

pavelsavara wants to merge 16 commits into
dotnet:mainfrom
pavelsavara:browser-coreclr-ep-cpu-sampling-r2r

Conversation

@pavelsavara

@pavelsavara pavelsavara commented Aug 26, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds cooperative EventPipe CPU sampling for RyuJIT-generated ReadyToRun code on single-threaded WebAssembly, complementing the existing interpreter sampling support.

On single-threaded WebAssembly there is no sampling thread that can suspend the target thread, so instrumented code triggers sampling cooperatively. This change adds the equivalent of the interpreter's INTOP_PROF_SAMPLEPOINT instrumentation to R2R code.

CPU profiling is supported only for published applications. A dev-loop dotnet build with WasmPerformanceInstrumentation now fails with a clear diagnostic, regardless of whether PublishReadyToRun is enabled, because dev-loop builds use uninstrumented framework R2R images from the runtime pack.

Implementation

  • Adds CORINFO_HELP_WASM_PROF_SAMPLEPOINT and the corresponding ReadyToRun helper.
  • Adds a WASM JIT instrumentation phase that inserts samplepoints at method entry and loop back-edges for methods selected by WasmPerformanceInstrumentation.
  • Emits samplepoints as normal NoGC helper calls through the existing portable-helper path used by WebAssembly R2R code.
  • Anchors stack walking with an InlinedCallFrame derived from the caller's shadow stack pointer, then delegates to the shared EventPipe sampling implementation.
  • Keeps the helper cooperative and avoids constructing the stack-walk frame when no sampling session is active.
  • Registers Wasm virtual-IP ranges when ReadyToRun information is installed so R2R 31 direct calls remain stack-walkable before assembly activation.
  • Keeps the performance-instrumentation hook available to both in-build and packaged crossgen2, while limiting the custom compiler resolver to in-build crossgen2 so workload consumers retain the base SDK resolver.
  • Forwards the normalized instrumentation method filter to crossgen2 while keeping interval=... as the separate DOTNET_EventPipeThreadSamplingRate setting. In particular, all,interval=0 becomes the crossgen2 filter *.
  • Instruments the complete publish closure, including framework assemblies, so R2R traces include both application and framework methods.
  • Rejects WasmPerformanceInstrumentation outside publish with:
    Only published applications are supported for CPU profiling with WasmPerformanceInstrumentation.

Tests

Extended EventPipeDiagnosticsTests to publish, run, capture, and inspect traces from both execution modes:

  • Interpreter, Debug
  • Interpreter, Release
  • ReadyToRun, Debug
  • ReadyToRun, Release

The R2R cases assert that an instrumented application R2R method appears in sampled call stacks.

Added dev-loop validation for both:

  • PublishReadyToRun=false
  • PublishReadyToRun=true

Both configurations assert the publish-only CPU-profiling diagnostic. Obsolete native-defaults coverage that expected instrumentation to succeed during a dev-loop build was removed.

Validation

After merging upstream/main at dca76ff203f:

  • Browser/CoreCLR Debug clr+libs+host: passed with zero warnings and errors.
  • Browser/CoreCLR Release clr+libs+host: passed with zero warnings and errors.
  • WebAssembly SDK pack rebuild: passed with zero warnings and errors.
  • Dev-loop CPU-profiling rejection with R2R disabled and enabled: 2/2 passed.
  • Composite/WebCIL validation: 1/1 passed.
  • Published R2R CPU sampling in Debug and Release: 2/2 passed.
  • Workload Blazor publish in Debug and Release: 2/2 passed.
  • Exact Helix no-workload shape (Release WBT runner, packaged crossgen2 only, in-build crossgen hidden): Debug and Release 2/2 passed.
  • Verified that the R2R regression test fails when the raw all,interval=0 value is forwarded to crossgen2 instead of the normalized * filter.

Related to #130521.

Note

This pull request description was updated with the assistance of GitHub Copilot.

Adds cooperative, self-triggered EventPipe CPU sampling for RyuJIT-generated
R2R (native wasm) code on single-threaded browser/WASI, complementing the
interpreter sampling from dotnet#126324. On single-threaded WASM there is no sampling
thread, so sampling is driven by the instrumented code itself.

- New JIT helper CORINFO_HELP_WASM_PROF_SAMPLEPOINT and R2R helper
  READYTORUN_HELPER_WasmProfSamplepoint (0x119, R2R minor 27.1). Native shim
  JIT_WasmProfSamplepoint in vm/wasm/helpers.cpp (modeled on JIT_PollGC) anchors
  a cooperative managed stack walk via an InlinedCallFrame / INLINED_PINVOKE_FROM_R2R
  derived from the caller's shadow SP, then delegates to the shared
  SamplingProfiler_OnSamplepoint. No GC transition; only walks on an actual sample.
- New GT_WASM_PROF_SAMPLEPOINT node and PHASE_WASM_PROF_INSTRUMENT: emit a
  samplepoint at method entry and every loop back-edge for methods matching the
  WasmPerformanceInstrumentation MethodSet filter (same key and shared skip counter
  as the interpreter). Codegen emits it via genEmitHelperCall with the managed
  (sp, pep) ABI, like the throw helpers.
- crossgen2 maps the helper to its R2R cell; the WebAssembly SDK forwards the
  filter to crossgen (PublishReadyToRunCrossgen2ExtraArgs) and warns that framework
  and BCL frames are absent from samples (the runtime-pack R2R images are not
  instrumented).

v1 uses an unconditional helper call (skip counter inside the helper). The inline
countdown fast path and the browser DevTools flame-chart profiler (PROF_ENTER/LEAVE
plus an EH-dispatcher unwind hook) are deferred; see the PR description TODO.

Validated: browser CoreCLR clr+host build clean; crossgen2 with
WasmPerformanceInstrumentation=* over all of System.Private.CoreLib completes
cleanly and the instrumented R2R image is ~6% larger than baseline (samplepoints
emitted); the SDK warning fires on R2R publish. End-to-end runtime .nettrace
capture is blocked by pre-existing experimental CoreCLR-browser sample-publish
infrastructure issues (see TODO), not by this change.
@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.

pavelsavara and others added 2 commits September 25, 2026 12:37
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Normalize WasmPerformanceInstrumentation before forwarding it to crossgen2 and compile the R2R samplepoint helper under the VM's perf-tracing feature guard. Extend the browser diagnostics tests to cover interpreter and ReadyToRun CPU sampling in Debug and Release.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pavelsavara pavelsavara changed the title [browser][coreCLR] EventPipe CPU sampling for RyuJIT/R2R on WASM [browser][CoreCLR] Enable EventPipe CPU sampling for RyuJIT/R2R on WebAssembly Sep 29, 2026
@pavelsavara

Copy link
Copy Markdown
Member Author
image

pavelsavara and others added 2 commits September 30, 2026 14:00
Reject WasmPerformanceInstrumentation during dev-loop builds regardless of ReadyToRun, and keep CPU profiling coverage on supported publish paths. Remove the obsolete native-defaults expectation that instrumentation succeeds during build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pavelsavara
pavelsavara marked this pull request as ready for review September 30, 2026 13:05
@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.

Comment thread src/coreclr/jit/codegenwasm.cpp
Comment thread src/coreclr/jit/codegenwasm.cpp Outdated
Comment thread src/coreclr/jit/fgwasm.cpp Outdated

@adamperlin adamperlin 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.

Commenting just on the JIT changes, I think we want to re-use fgRunDfs instead of a custom DFS implementation. For discovering the loops, we can use a visitEdge implementation like the following:

auto visitEdge = [&hasCycle](BasicBlock* block, BasicBlock* succ) {
. For the SuccessorEnumerator, we can use a custom implementation that only returns direct (non-EH) successors.

@adamperlin

Copy link
Copy Markdown
Contributor

Also cc @dotnet/jit-contrib for the profile sampling changes.

Pass an explicit scratch argument to the native samplepoint helper instead
of using the portable entry point context, and clarify virtual IP ordering.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread src/coreclr/vm/wasm/helpers.cpp Outdated

@maraf maraf left a comment

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.

Wasm.Build.Tests and msbuild related changes looks good to me

Use fgRunDfs with a direct-successor enumerator to discover loop headers
for WebAssembly profiling instrumentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pavelsavara

Copy link
Copy Markdown
Member Author

@adamperlin Addressed in 7796eed. The profiler phase now uses fgRunDfs with a WasmProfSuccessorEnumerator that returns only direct NumSucc/GetSucc edges. Back edges are identified using preorder numbers plus active-stack membership, matching the pattern you linked.

Validated with the incremental Browser/CoreCLR build and BlazorEventPipeTestWithCpuSamplesReadyToRun in Debug and Release (2 passed, 0 failed, 0 skipped).

Note

This response was generated with GitHub Copilot assistance.

Use the standard Wasm helper wrapper for profiling samplepoints and keep
the crossgen instrumentation target with the ReadyToRun overrides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pavelsavara
pavelsavara requested a review from adamperlin October 1, 2026 08:14
Comment thread src/coreclr/jit/codegenwasm.cpp Outdated
Comment thread src/coreclr/jit/fgwasm.cpp
Represent profiler samplepoints as NoGC helper calls and use the standard
DFS tree to identify loop headers, including loops involving EH flow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve upstream R2R 31 and composite browser support while rebasing the
Wasm profiler helper format change to R2R 31.1. Register Wasm virtual IP
ranges when R2R info is installed so direct R2R calls remain stack-walkable
before assembly activation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@adamperlin adamperlin 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.

The JIT changes here LGTM now!

pavelsavara and others added 5 commits October 8, 2026 12:22
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Always append the CoreCLR browser ReadyToRun override after the .NET SDK
targets so packaged crossgen2 receives performance instrumentation options.
Keep the optional in-build Crossgen2Tasks shim ordered before the override,
and gate the override targets to CoreCLR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep the performance-instrumentation hook available to packaged
crossgen2 builds without replacing the SDK resolver for workload
consumers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve R2R version conflicts at 32.1 and regenerate the JIT/EE
interface identifier while preserving the Wasm sampling helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
for (GenTree* tree : LIR::AsRange(block))
{
if (tree->IsCall())
if (tree->IsCall() && IsPotentialGCSafePoint(tree))

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.

I think this guard skips more than intended. Because the operand-removal VisitOperands(removeUses) lives inside this if, and the fallback removal further down is guarded by if (!tree->IsCall()), the ref/byref operands of a NoGC helper call are now never removed from defs.

One concrete case is CORINFO_HELP_BULK_WRITEBARRIER_SMALL from Lowering::LowerBlockStoreAsGcBulkCopyCall: its destination is typically a non-invariant byref (e.g. ADD(obj, offset)), so it gets pushed into defs and stays there. In a checked JIT that should trip assert(defs.empty()) at the start of the next block; in release it would lead to unnecessary pinned spills at the next GC-safe call. This affects all wasm methods, not only instrumented ones.

As far as I can tell, the samplepoints themselves don't need this change: they're inserted at the start of a block, where no LIR values are live, so they never cause spills anyway. Would it work to either revert this line, or keep operand removal unconditional for all calls and gate only the spilling? Something like:

if (tree->IsCall())
{
    tree->VisitOperands(...removeUses...);

    if (!defs.empty() && IsPotentialGCSafePoint(tree))
    {
        // spill
    }
}

(I found this by reading the code; I haven't reproduced it.)

Note

This comment was generated with GitHub Copilot assistance.

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.

+1, the code here is meant to consume all outstanding refs for the closest parent call, regardless of whether it's a safepoint or not. It's true that we only need to spill not-yet-consumed refs if the tree is a safe point though, so I think it is sound to change the code to what @lewing is suggesting.

GenTree* samplepoint = gtNewHelperCallNode(CORINFO_HELP_WASM_PROF_SAMPLEPOINT, TYP_VOID);
samplepoint = fgMorphCall(samplepoint->AsCall());
gtSetEvalOrder(samplepoint);
LIR::AsRange(block).InsertAtBeginning(LIR::SeqTree(this, samplepoint));

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 methods compiled with opts.IsReversePInvoke() ([UnmanagedCallersOnly]), fgAddReversePInvokeEnterExit has already placed the CORINFO_HELP_JIT_REVERSE_PINVOKE_ENTER call at the start of fgFirstBB during fgAddInternal. So InsertAtBeginning here puts the entry samplepoint before the transition to cooperative mode.

When a sampling session is active, JIT_WasmProfSamplepoint would then push an InlinedCallFrame and call SamplingProfiler_OnSamplepoint (which is MODE_COOPERATIVE) while the thread is still preemptive.

Would it make sense to insert the entry samplepoint after the reverse P/Invoke enter call, or simply skip the entry samplepoint for reverse P/Invoke methods? (I found this by reading the code; I haven't reproduced it.)

Note

This comment was generated with GitHub Copilot assistance.

for (unsigned i = 0; i < dfsTree->GetPostOrderCount(); i++)
{
BasicBlock* const source = dfsTree->GetPostOrder(i);
source->VisitAllSuccs(this, [&](BasicBlock* target) {

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.

VisitAllSuccs also follows the modeled two-pass EH edges (VisitEHEnclosedHandlerSecondPassSuccs in compiler.hpp), which can form cycles that aren't real loops. For example, with a try/finally (e.g. using) nested inside a try { } catch when (...): the inner finally's blocks have an EH successor edge to the outer filter, and the filter has a second-pass edge back to the inner finally's entry.

Depending on the DFS order, either the filter entry or the finally entry becomes a "back-edge target". If it's the filter entry, InsertAtBeginning puts the samplepoint ahead of GT_CATCH_ARG, which needs to stay first (see LIR::Range::FirstNonCatchArgNode). Either way, a non-loop funclet ends up with a samplepoint and an unwindable frame.

Would walking the wasm control-flow graph instead (FgWasm::WasmDfs / WasmSuccessorEnumerator, as fgWasmTransformSccs does) work here? It should still catch loops formed through catch resumption, since those are explicit edges by this point. At minimum, inserting after FirstNonCatchArgNode() would avoid the GT_CATCH_ARG ordering problem. (I found this by reading the code; I haven't reproduced it.)

Note

This comment was generated with GitHub Copilot assistance.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants