Skip to content

Interpreter block-count PGO for WebAssembly CoreCLR - #132721

Merged
pavelsavara merged 32 commits into
dotnet:mainfrom
pavelsavara:wasm_collect_PGO
Sep 25, 2026
Merged

pavelsavara merged 32 commits into
dotnet:mainfrom
pavelsavara:wasm_collect_PGO

Conversation

@pavelsavara

@pavelsavara pavelsavara commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Instruments the CoreCLR interpreter with block-count PGO probes on WebAssembly and adds a JavaScript trigger to collect the profile over EventPipe, so dotnet-pgo can produce an .mibc for R2R precompilation. This is the profile production side of PGO-on-WebAssembly; consumption (crossgen2 on WASM) is tracked separately.

This targets the single-threaded browser/WASI interpreter (the offline PGO-collection config, PERFTRACING_DISABLE_THREADS); the feature is compiled out on multithreaded WASM.

Part of #130524. Implements #130517 and #130518.

Instrumentation (#130517)

  • New INTOP_PGO_COUNT interpreter opcode (single-threaded browser/WASI only) that increments a native uint32_t counter allocated via allocPgoInstrumentationBySchema, so counters outlive the EventPipe session and wrap as the profile format expects.
  • InterpCompiler::InstrumentBlockCounts emits BasicBlockIntCount probes at block heads only — method entry plus branch/switch/loop targets, restricted to the original IL range (m_ILCodeSizeFromILHeader, so synthetic finally/epilog IL for synchronized/async methods is skipped) — gated by DOTNET_InterpPgo with an optional DOTNET_InterpPgoMethods method filter.
  • The alloc*/get* PGO interface methods move to the shared CEECodeGenInfo base so the JIT and interpreter share one implementation; the tiering gate is relaxed for the interpreter, target-scoped to browser/WASI.
  • FEATURE_PGO is enabled for WASM independently.
  • The instrumentation (opcode, probe emission, enable flags) is gated on PERFTRACING_DISABLE_THREADS, so multithreaded (WasmEnableThreads) builds never emit the counter and can't race on the increment.

Flush over EventPipe

  • Accumulated counts are emitted to JitInstrumentationDataVerbose events on EventPipe session stop via a new ep_rt_session_stopping hook. CoreCLR calls PgoManager::EmitInstrumentationDataToEventPipe() (Mono and NativeAOT are no-ops). The hook runs before the EventPipe lock is taken, since emitting events re-enters the write path; the stopping session's keyword mask is captured under the lock in stop_session and passed to the hook (ep_rt_session_stopping(id, session_mask)), so the runtime tests the keyword without dereferencing a session a concurrent stop could free.
  • EventPipe emission is separated from the text-file export. EmitInstrumentationDataToEventPipe() only fires the events; the DOTNET_WritePGOData text dump stays in WritePgoData(), driven solely by the process-shutdown path — an on-demand trace collection never writes the text file.
  • Shutdown coordination: WritePgoData() emits to EventPipe only under !PERFTRACING_DISABLE_THREADS. On single-threaded WASM the session-stopping hook is the sole EventPipe emitter, so a method is never delivered twice into a session EventPipe stops during shutdown (which dotnet-pgo rejects as a duplicate chunk after a method's final chunk); threaded desktop still emits at shutdown as before.
  • The emission is routed into only the stopping session: the flushing thread is briefly marked as a rundown thread bound to that session, so events go through ep_session_write_event instead of broadcasting to every enabled session (the same mechanism EventPipe uses for method/assembly rundown at teardown).
  • On multithreaded WASM the flush is compiled out; a PORTABILITY_ASSERT, gated on the stopping session's JitInstrumentationData keyword, flags a genuine PGO-collection attempt on that unsupported config without tripping on unrelated (CPU/GC/counters) sessions.

JS trigger (#130518)

  • collectPgoTrace() diagnostic client (js://pgo) starts a trace with the JitInstrumentationData keyword — mask aligned to the IBC keyword set dotnet-pgo consumes — and auto-downloads the .nettrace after a default 10s window. The stop timer only stops the session it started.
  • Collection is one-shot per process: because the interpreter counters are cumulative and flushed once, a second collectPgoTrace is rejected rather than re-emitting cumulative data that dotnet-pgo would drop as a restarted chunk sequence. Restart the app to collect again.

Notes

  • Method identity in the events is MVID + metadata token + IL offset, which is engine-independent, so no PerfMap is required for instrumentation PGO.
  • dotnet-pgo must reference the IL-trimmed linked/*.dll (whose MVID matches the running app), not the untrimmed runtime pack.
  • Docs added to src/mono/wasm/features.md.

Validation

  • Browser CoreCLR interpreter builds clean.
  • End-to-end verified: browser run → .nettrace with JitInstrumentationDataVerbose events → dotnet-pgo → valid .mibc.

Note

This PR description was drafted with GitHub Copilot.

@azure-pipelines

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

@pavelsavara pavelsavara added this to the 12.0.0 milestone Aug 24, 2026
@pavelsavara pavelsavara added arch-wasm WebAssembly architecture os-browser Browser variant of arch-wasm labels Aug 24, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.

Comment thread src/coreclr/vm/jitinterface.cpp Outdated
@pavelsavara

pavelsavara commented Aug 26, 2026 •

Copy link
Copy Markdown
Member Author

Blazor WASM PGO profile/trace https://gist.github.com/pavelsavara/70de5d2c5a7575f35eba0a72fc9e0abb

@pavelsavara

Copy link
Copy Markdown
Member Author
image

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 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.

🟡 Changes recommended

Unresolved moderate findings affect counter correctness, session-specific flushing, trace collection, and end-to-end validation.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds CoreCLR WebAssembly interpreter block-count PGO instrumentation and browser-side EventPipe trace collection for dotnet-pgo/R2R workflows.

Changes:

  • Adds WASM interpreter probes, shared PGO allocation, and configuration.
  • Adds EventPipe flushing and collectPgoTrace().
  • Adds documentation, build integration, and end-to-end validation.
File summaries
File Reviewed change / final review note
src/native/libs/System.Native.Browser/diagnostics/types.ts Adds the PGO EventPipe keyword.
src/native/libs/System.Native.Browser/diagnostics/index.ts Exposes the PGO collector.
src/native/libs/System.Native.Browser/diagnostics/dotnet-pgo-trace.ts Implements timed trace collection. moderate (1 vote): stale timers can stop a later session; associate the timer with its original session.
src/native/libs/System.Native.Browser/diagnostics/diagnostic-server-js.ts Supports startup js://pgo tracing. moderate (1 vote): add coverage for startup registration and downloaded traces.
src/native/libs/System.Native.Browser/diagnostics/client-commands.ts Defines the PGO EventPipe command.
src/native/libs/Common/JavaScript/types/public-api.ts Declares the diagnostics API.
src/native/libs/Common/JavaScript/loader/dotnet.d.ts Updates loader typings.
src/native/eventpipe/ep.c Invokes the session-stopping hook. moderate (1 vote): session-agnostic flushing broadcasts duplicate PGO chunks; make flushing session-aware or only flush when appropriate.
src/native/eventpipe/ep-rt.h Declares the lifecycle hook. nit (3 votes): correct the inaccurate EventPipe-lock contract comment.
src/mono/wasm/Wasm.Build.Tests/Wasm.Build.Tests.csproj Includes dotnet-pgo in test payloads.
src/mono/wasm/Wasm.Build.Tests/Blazor/EventPipeDiagnosticsTests.cs Adds end-to-end PGO validation. moderate (2 votes): use the trimmed linker directory. moderate (3 votes): assert BasicBlockIntCount data, not only method presence.
src/mono/wasm/features.md Documents WASM PGO usage. nit (1 vote): align DLL identity guidance with the tool’s actual CodeView/PDB GUID validation.
src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets Integrates the browser CoreCLR build settings.
src/mono/mono/eventpipe/ep-rt-mono.h Adds the Mono no-op lifecycle hook.
src/coreclr/vm/pgo.h Declares PGO instrumentation flushing.
src/coreclr/vm/pgo.cpp Flushes accumulated instrumentation data.
src/coreclr/vm/jitinterface.h Exposes shared PGO interface methods.
src/coreclr/vm/jitinterface.cpp Shares PGO allocation with the interpreter. moderate (1 vote): limit the tiering-gate relaxation to the interpreter callback.
src/coreclr/vm/interpexec.cpp Executes PGO counter probes. moderate (1 vote): threaded builds can race on the counter; use synchronized counters or exclude them. moderate (2 votes): use the unsigned counter type to avoid signed overflow and match the schema.
src/coreclr/vm/eventing/eventpipe/ep-rt-coreclr.h Adds the CoreCLR lifecycle hook declaration.
src/coreclr/vm/eventing/eventpipe/ep-rt-coreclr.cpp Connects EventPipe stopping to PGO flushing. moderate (2 votes): prevent duplicate chunks when sessions overlap.
src/coreclr/nativeaot/Runtime/eventpipe/ep-rt-aot.h Adds the AOT no-op hook.
src/coreclr/interpreter/interpconfigvalues.h Defines interpreter PGO settings.
src/coreclr/interpreter/inc/intops.def Adds the PGO counter opcode.
src/coreclr/interpreter/eeinterp.cpp Initializes interpreter PGO instrumentation.
src/coreclr/interpreter/compiler.h Stores interpreter instrumentation state and helpers.
src/coreclr/interpreter/compiler.cpp Emits block-head probes. moderate (1 vote): increment the unsigned BasicBlockIntCount counter with an unsigned type.
src/coreclr/inc/clrconfigvalues.h Adds interpreter PGO configuration.
src/coreclr/clrfeatures.cmake Enables PGO for WASM.
Review details

Suppressed comments (7)

src/coreclr/interpreter/compiler.cpp:8757

  • BasicBlockIntCount is an unsigned four-byte counter (see corjit.h/PgoFormat.cs), but this executes a signed int32_t increment. A hot interpreted method can eventually overflow INT32_MAX, which is undefined behavior in C++, and the access does not match the schema's unsigned representation. Use a uint32_t* (or an equivalent unsigned increment) here.
        int32_t *pCounter = (int32_t*)(pInstrumentationData + pSchema[i].Offset);

src/coreclr/vm/interpexec.cpp:2071

  • INTOP_PGO_COUNT is compiled for threaded browser/WASI builds too: WasmEnableThreads=true removes PERFTRACING_DISABLE_THREADS, while this opcode is guarded only by the target. Multiple workers can race on this read-modify-write, and session stopping can read the same counter concurrently, so counts can be lost or undefined. Use an atomic/interlocked counter with a synchronized snapshot, or explicitly exclude threaded builds.
                    (*(int32_t*)pMethod->pDataItems[ip[1]])++;

src/coreclr/vm/jitinterface.cpp:13095

  • CEECodeGenInfo is the common base of both CEEJitInfo and CInterpreterJitInfo, so this condition also relaxes the JIT's tiering-eligibility gate whenever DOTNET_InterpPGO=1. Any JIT PGO phase can then allocate instrumentation for non-tiering-eligible methods, and a later JIT schema can replace an interpreter schema for the same method in PgoManager. Keep the relaxation limited to the interpreter callback rather than this shared implementation.
    // Only try instrumenting tiering-eligible methods, unless interpreter PGO is enabled, in
    // which case we instrument every method for offline profile collection.
    MethodDesc* pMD = (MethodDesc*)ftnHnd;
    if (pMD->IsEligibleForTieredCompilation() || InterpreterPgoInstrumentationEnabled())
    {

src/mono/wasm/features.md:471

  • The conversion tool currently validates CodeView/PDB GUIDs (src/coreclr/tools/dotnet-pgo/Program.cs:1304-1322) and explicitly notes that it does not match MVIDs (:1340). This documentation therefore attributes Dll mismatch to an MVID check that dotnet-pgo does not perform; please align the guidance with the actual identity check (or update the tool and docs together) so users do not diagnose the wrong cause.
`--reference` must point at assemblies whose **MVID** matches the modules recorded in the trace, otherwise
`dotnet-pgo` reports `Dll mismatch ...` (or `Unknown ModuleID` for the affected methods). On browser/wasm
the assemblies loaded by the runtime are the **IL-trimmed** ones: `PublishTrimmed`/ILLink rewrites each
assembly and **generates a fresh MVID**, then those trimmed DLLs are converted to the fingerprinted
`*.wasm` files in `_framework` (webcil preserves the MVID byte-for-byte). So the trace records the
**trimmed** MVIDs, which do **not** match the untrimmed assemblies in the runtime pack

src/native/eventpipe/ep.c:808

  • ep_rt_session_stopping() is called for every stop_session(id), but the hook has no session ID and WritePgoData() uses the normal EventPipe write path. Those events are broadcast to every still-live session, so stopping an unrelated or earlier diagnostic session flushes the complete PGO dataset into this trace; the later PGO-session stop flushes it again. dotnet-pgo rejects a new chunk after a method's final chunk and drops that method, making traces unreliable when sessions overlap. Make the hook session-aware/target the write, or flush only once when the final relevant session stops.
		// Give the runtime a chance to emit any pending end-of-session data (e.g. block-count PGO)
		// into the still-live session. This must run before taking the EventPipe lock: emitting events
		// re-enters the write path, which requires the lock not be held.
		ep_rt_session_stopping ();

src/native/libs/System.Native.Browser/diagnostics/diagnostic-server-js.ts:179

  • The existing PGO test invokes collectPgoTrace from an already-running page, so it does not exercise this new js://pgo startup registration. A failure in createDiagConnectionJs or the startup=true setup would leave the documented pre-managed-code capture broken while the test still passes. Add a startup-port case that verifies the downloaded trace.
        if (scenarioName.startsWith("js://pgo")) {
            collectPgoTrace({}, true);

src/native/libs/System.Native.Browser/diagnostics/dotnet-pgo-trace.ts:32

  • The timeout callback is detached from the session it was created for and stops whatever session is currently in the global pgoSession. If the first session closes early and a second trace starts before the first timeout fires, the stale timer will stop the second trace prematurely. Capture/check the original session before sending the stop command.
        Module.safeSetTimeout(() => {
            stopPgoTrace();
        }, 1000 * durationSeconds);
  • Files reviewed: 28/29 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment thread src/coreclr/vm/eventing/eventpipe/ep-rt-coreclr.cpp Outdated
Comment thread src/coreclr/vm/interpexec.cpp Outdated
Comment thread src/mono/wasm/Wasm.Build.Tests/Blazor/EventPipeDiagnosticsTests.cs
Comment thread src/mono/wasm/Wasm.Build.Tests/Blazor/EventPipeDiagnosticsTests.cs Outdated
Comment thread src/native/eventpipe/ep-rt.h Outdated
Per EventPipe-owner feedback: bind the stopping session as the current thread's rundown session inside a new single-threaded-only session_stopping helper in ep.c (save/restore the previous binding under the config lock), and add a shared ep_event_is_enabled_for_current_thread helper. The CoreCLR session-stopping hook now just gates on that helper and emits, with session routing and validation owned by EventPipe; the hook reverts to a single session_id parameter. Rename PgoManager::EmitInstrumentationDataToEventPipe to LogInstrumentationData to match LogMethodInstrumentationData.
Copilot AI review requested due to automatic review settings September 21, 2026 11:52

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.

Comment thread src/native/eventpipe/ep.c
Per review feedback, probe the same points as the sampling profiler - method entry and targets of backward branches - instead of every branch/switch/leave target, using a bit set on the target basic block in EmitBranch. Counters remain exact (bumped every execution, not sampled), giving exact method invocation and loop trip counts. Acyclic branch structure is deliberately left unprofiled: mapping interpreter blocks onto the JIT's is approximate, and block-count schemas get no flow reconstruction in the consumer, so that precision is deferred to the planned R2R-side instrumentation. Removes the per-instruction branch-target scan.
Copilot AI review requested due to automatic review settings September 21, 2026 14:55
@pavelsavara

Copy link
Copy Markdown
Member Author

Thanks, this was the input that settled the design.

My plan is that block-level precision comes from a follow-up that instruments R2R code itself, where the profile maps back onto the same IR that consumes it — no interpreter→JIT block mapping involved. For the interpreter (this PR) I went with the simplification @BrzVlad suggested: probe only method entry and loop heads (targets of backward branches — the same points the WASM sampling profiler uses), with exact counters rather than samples.

That's a direct response to your point. The JIT is the consumer here (this feeds crossgen2 for R2R), and looking at the consumption path confirms your concern: fgIncorporateBlockCounts does a plain IL-offset match, so a block with no schema entry never gets setBBProfileWeight; all the reconstruction lives in fgIncorporateEdgeCounts/EfficientEdgeCountReconstructor, and fgIncorporateProfileData prefers edge counts when both exist. So the sparse block schema I had (entry + every branch/switch/leave target) would leave hot fall-through arms and EH entries unweighted while looking like a block profile. Rather than try to match the JIT's block boundaries, or replicate its spanning-tree edge scheme from the interpreter, I narrowed it to what's genuinely reliable: exact method invocation counts and loop trip counts.

Once R2R instrumentation lands, the interpreter-only residue is methods that can't be R2R-compiled at all — crossgen2 never compiles those, so their coarse counts are never used for codegen. And the cross-method signals (ExclusiveWeight, WeightedCallData) come from dotnet-pgo's sampling/call-graph channel, not from this schema, so they can't skew R2R-compatible methods.

On provenance: crossgen2 hardcodes PgoSource.Static for anything read from a .mibc (CorInfoImpl.cs) and MethodProfileData has no source field, so there's no channel to mark this as lower-fidelity today. Static is already conservative — untrusted by fgHaveTrustedProfileWeights, insufficient below 1000 entry weight — which is the behavior we want. Making it explicit via PgoSource::Sampling would be additive and non-breaking; I'd propose it alongside the R2R instrumentation work.

Agreed on class histograms and value profiles for GDV — out of scope here, but worth doing once the collection pipeline is established.

Note

Reply drafted with GitHub Copilot.

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.

Copilot review overview

🟡 Changes recommended

Unresolved moderate issues affect WASI support, probe coverage, threaded collection behavior, configuration, and test packaging.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
Resolved since last review (2)

Comment thread src/coreclr/interpreter/compiler.cpp
The hook no longer takes a session id: the caller validates the session and binds the current thread to it, so the runtime identifies the target via ep_event_is_enabled_for_current_thread. Use the ep_thread_set_as_rundown_thread wrapper for both bind and restore, and unbind on the error path so a failed restore-lock acquisition cannot leave the thread scoped to a session.
Comment thread src/coreclr/interpreter/compiler.cpp Outdated
The isBackwardBranchTarget bit was only set in EmitBranch, so loop heads reached via CEE_SWITCH (which links targets directly) or EmitLeave (which calls EmitBranchToBB directly) received no PGO probe and their execution counts were absent from the profile. Mark both. For leave, mark before the finally-call-island redirection so the bit lands on the real IL block rather than an island that shares its IL offset.
Per review feedback, drop the browser/WASI single-threaded ifdefs around the interpreter block-count instrumentation so the feature can be exercised on desktop, where it is much easier to debug. This includes the VM-side InterpreterPgoInstrumentationEnabled gate, without which allocPgoInstrumentationBySchema returns E_NOTIMPL and no probes are emitted off-WASM.

The feature stays opt-in behind DOTNET_InterpPGO, and the counter increment is now interlocked so concurrent executions of an instrumented method do not lose counts. The EventPipe session-stopping flush remains single-threaded-only; on other platforms the counters are collected through the existing DOTNET_WritePGOData text export at shutdown.
Copilot AI review requested due to automatic review settings September 21, 2026 17:06

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.

Copilot review overview

🔵 Needs a closer look

Unresolved moderate findings remain around target gating, counter atomicity, R2R handling, WASI support, and test output staging.

Review effort: Lite
Findings: None

Resolved since last review (1)

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

EventPipe related changes LGTM!

@pavelsavara

Copy link
Copy Markdown
Member Author

I think this is ready to merge, is there more feedback ?

@pavelsavara

Copy link
Copy Markdown
Member Author

/ba-g unrelated infra issues

@pavelsavara
pavelsavara merged commit e19e409 into dotnet:main Sep 25, 2026
149 of 153 checks passed
@pavelsavara
pavelsavara deleted the wasm_collect_PGO branch September 25, 2026 06:36
lewing added a commit that referenced this pull request Sep 25, 2026
#132721 duplicated the composite and container-format errors into
_WasmCoreClrSelectR2RDirectories. That brought back the composite
rejection this PR removes and failed PublishRunAllPagesComposite on CI.
Keep the checks in _WasmCoreClrValidateReadyToRun, which runs first, and
move the new WasmPerformanceInstrumentation check there.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-VM-coreclr os-browser Browser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants