[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[release/11.0-preview4] High-performance EventSource runtime async profiler. - #127585

Closed
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4
Closed

[release/11.0-preview4] High-performance EventSource runtime async profiler.#127585
hoyosjs wants to merge 31 commits into
dotnet:release/11.0-preview4from
hoyosjs:juhoyosa/backport-127238-11p4

Conversation

@hoyosjs

@hoyosjshoyosjs commented Apr 29, 2026

Copy link
Copy Markdown
Member

Motivation

TPL includes support to capture compiler async (AsyncV1) events to stitch together async callstacks in tools like PerfView, VS .NET Async Profiler, and Application Insights Profiler.

The challenge using TPL events profiling async-heavy workloads is that they are verbose and produce a lot of data, creating too much overhead on the profiled process and skewing measurements.

Each TPL event is written into ETW/EventPipe/UserEvents causing latency (kernel call) as well as additional data (~100-byte header). Even a small event without a stack takes 200–500 ns to emit at 100+ bytes. TPL tracking of async execution generates heavy traffic on the eventing subsystem, increasing the risk of dropping events.

TPL overhead (synthetic benchmark)

Async resume/suspend rateThroughput dropETL size (20 s)Dropped events
1M/s>75%severe (requires enlarged ETW buffers)
100K/s~45%3+ GBhigh (requires enlarged ETW buffers)
10K/s~10%moderate

TPL depends on a complete chain of events to recreate async callstacks — losing any events makes post-processing unreliable.

There have been ideas for quite some time to look into a more lightweight approach to track async method execution, making it possible to recreate async callstacks for sync callstacks captured by external tools like OS CPU samplers and profilers.

With the introduction of runtime async (AsyncV2), it was decided to revisit this and see what we could do to improve the profiler experience of async code. The async profiler is not tied to runtime async methods (AsyncV2), so it will be able to handle compiler async methods (AsyncV1) as well, but this PR focuses on AsyncV2. Follow-up PRs will add AsyncV1 support, making it possible to use the new async profiler to collect both AsyncV1 and AsyncV2 async callstacks.


Design

NOTE: All formats introduced by this PR are currently considered internal and can be changed without notice.

This PR adds AsyncProfilerBufferedEventSource — a high-performance EventSource for async method profiling that uses per-thread buffered event emission with centralized flush coordination.

Core architecture

  • Per-thread event buffers with lock-free acquire/release for zero-contention writes on the hot path.
  • Delta timestamp encoding using compressed variable-length integers (LEB128 + zigzag), reducing per-event timestamp overhead from 8 bytes to typically 1–2 bytes under load.
  • Delta IP encoding using compressed variable-length integers, reducing bytes per frame IP.
  • Centralized AsyncThreadContextCache with background flush timer for idle and dead thread buffer reclamation.
  • Continuation wrapper table for compact async callstack representation, mapping runtime IPs to table indices — enables matching sync callstacks captured by OS CPU profilers through the resume async callstack event.

Event types

Events cover the full async lifecycle:

CategoryEvents
Async contextcreate, resume, suspend, complete
Async methodresume, complete
Exception unwindunhandled, handled
Async callstackscreate, resume, suspend

Buffer management

  • Configurable buffer size via DOTNET_AsyncProfilerEventSource_EventBufferSize (default 16 KB − 256 bytes).
  • Optimized buffer serialization with low overhead.
  • SyncPoint mechanism for coordinated config changes across writer threads.
  • BlockContext flag for safe flush-thread access to live thread buffers with 100 ms spin timeout to prevent flush thread stalls.

Integration

  • Async profiler wired into AsyncInstrumentation alongside the async debugger.
  • Cross-runtime design: EventSource + AsyncProfiler with CoreCLR-specific parts in a dedicated source file.

Test coverage

Comprehensive test suite (AsyncProfilerTests) validating event correctness, buffer serialization, delta encoding, callstack capture, config changes, and multi-threaded stress scenarios.


Performance results

Overhead comparison: async profiler vs. TPL

Async resume/suspend rateTPL overheadAsync profiler overheadImprovement
1M/s>75%~20%~4×
100K/s~45%<1%~40×
10K/s~10%<0.3% (noise)~30×

Note: The 1M/s scenario is extreme — the benchmark does virtually no work, only exercising the internal async dispatch loop. Any real user code in the async methods will quickly reduce the relative overhead.

Data volume

ETL file size is down ~10× for scenarios capturing data to recreate async callstacks. For the 1M/s scenario over 20 seconds:

ETL sizeDropped events
TPL3+ GBmany (default ETW settings)
Async profiler~330 MBnone (default ETW settings)

VS CPU profiling visibility

Running the 1M/s scenario under VS CPU profiling, none of the async profiler methods stand out — most are in the 0.01–0.03% self-CPU range with very low sample counts. The instrumented DispatchContinuations function shows ~2% overhead compared to the uninstrumented version. Even in very heavy async workloads, the async profiler does not pollute VS CPU profiling output.


Continuation wrapper optimization

My initial ambition was to track the async callstack on any thread at any point using a single event including the resumed async callstack executed through the dispatch loop. Initially that strategy hit issues due to ambiguity mapping methods between sync callstacks collected by the OS CPU sampler and the resumed async callstack active at that time.

This can be solved using CompleteAsyncMethod events to recreate async callstacks at any point in time. CompleteAsyncMethod is just a signal event consuming a couple of bytes in the event buffer, but it introduces ~30–40 ns per completed method. The major cost is capturing the QPC (~15–20 ns, platform-dependent); the rest is raw memcpy + delta encoding. Reading the timestamp directly via CPU instruction could bring this down to ~10 ns total — a potential future optimization (would require a JIT intrinsic).

Instead of continuing to optimize CompleteAsyncMethod, I revisited the original problem: if we inject an anchor in the sync callstack captured by external tools, we can use it to tie into the async callstack emitted via the resume async callstack event. Since we control the dispatch loop running each continuation, it's possible to call through an indexed wrapper that places enough information in the sync callstack to identify the current resumed continuation.

With up to 255 frames in an async callstack, the pre-generated wrappers are capped at 32 and recycled, emitting a reset event into the stream to signal reuse to parsers. This mechanism makes it possible to recreate any async callstack tied to sync callstacks captured by external tools using a single event (ResumeAsyncCallstack).

Calling through the wrapper costs ~5 ns per method resume — compared to ~30–40 ns for CompleteAsyncMethod plus increased output size, this ended up as a very successful optimization.


Timestamp correlation

All async profiler events are emitted using existing EventSource infrastructure, meaning it's possible to listen on the event stream using in-proc EventListeners, ICorProfiler, as well as external ETW/EventPipe/UserEvent clients.

When events are used to recreate async callstacks for other events capturing sync callstacks emitted into the same event subsystem, all events share the same timestamp infrastructure. If events are emitted using different timestamp infrastructure not in sync, timestamps need to be re-synchronized and adjusted before use.

Each buffered event uses the machine's QPC infrastructure (Stopwatch.GetTimestamp), and each event buffer includes the timestamp of first and last event. The metadata event emitted at the beginning of the stream includes a reference QPC + QPC frequency + reference UTC time in ticks, making it possible to convert all buffered events to wall clock time. Every minute, a clock sync event is emitted into the stream re-syncing QPC and UTC time.


Future work

  • AsyncV1 support will come as follow-up PR(s).

lateralusXand others added 30 commits April 29, 2026 14:42
Commit adds AsyncProfilerBufferedEventSource - a high-performance
EventSource for async method profiling that uses per-thread buffered
event emission with centralized flush coordination.
Key design:
- Per-thread event buffers with lock-free acquire/release for
zero-contention writes on the hot path.
- Delta timestamp encoding using compressed variable-length integers,
reducing per-event timestamp overhead from 8 bytes to typically
1-2 bytes under load.
- Delta IP encoding using compressed variable length integers,
reducing bytes used per frame IP.
- Variable-length compressed integers using LEB128 and zigzag encoding.
- Centralized AsyncThreadContextCache with background flush timer for
idle and dead thread buffer reclamation.
- Continuation wrapper table for compact async callstack representation,
mapping runtime IPs to table indices. Makes it possible to match
sync callstacks captured by OS CPU profiler with resume async
callstack event.
Event types cover the full async lifecycle:
- async context: create/resume/suspend/complete.
- async method: resume/complete.
- exception unwind: unhandled/handled.
- async callstacks: create/resume/suspend.
Buffer management:
- Configurable buffer size via DOTNET_AsyncProfilerBufferedEventSource_EventBufferSize
(default 16KB - 256 bytes).
- Optimized buffer serialization methods, low overhead serializing events.
- SyncPoint mechanism for coordinated config changes across writer threads.
- BlockContext flag for safe flush-thread access to live thread buffers with
100ms spin timeout to prevent flush thread stalls.
Integration:
- Async profiler wired into AsyncInstrumentation alongside the async debugger.
- EventSource + AsyncProfiler, cross runtime support. Runtime specific
parts implemented in a CoreCLR specific source file.
- Mono stub for potential future platform support (AsyncV1).
Includes comprehensive test coverage (AsyncProfilerTests) validating event
correctness, buffer serialization, delta encoding, callstack capture,
config changes, and multi-threaded stress scenarios.
* Fix 0 length room for callstack.
* Have AsyncEventHeader return header start index.
* Add qpc and utc time to metadata to make time conversion possible.
* Harden buffer allocation failure.
* AI review feedback.
* Adjust tests.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 29, 2026 21:46
@hoyosjshoyosjs changed the title [reelase/11.0-preview4] High-performance EventSource runtime async profiler.[release/11.0-preview4] High-performance EventSource runtime async profiler.Apr 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-runtime-compilerservices
See info in area-owners.md if you want to be subscribed.

CopilotAI 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 introduces a new high-performance runtime async profiler pipeline based on a buffered EventSource, integrates it into the CoreCLR runtime-async (AsyncV2) dispatch path, and adds a comprehensive validation test suite for the emitted buffered event format and behaviors.

Changes:

  • Adds AsyncProfilerEventSource + AsyncProfiler implementation in System.Private.CoreLib to emit per-thread buffered async lifecycle/callstack events with centralized flushing.
  • Integrates async-profiler instrumentation into CoreCLR’s runtime-async dispatcher (including continuation-wrapper IP table support for correlating with native CPU samples).
  • Adds/updates tests in System.Threading.Tasks.Tests to validate event buffer format, delta encoding, callstack semantics, timer flush behavior, and instrumentation flag synchronization.

Reviewed changes

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

Show a summary per file
FileDescription
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csprojAdds the new AsyncProfilerTests.cs compilation unit (non-mono).
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/RuntimeAsyncTests.csUpdates debugger attach/detach simulation to use the new “Synchronize” bit behavior; removes interpreter ActiveIssue skips.
src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.csAdds extensive tests for async-profiler buffered event emission, parsing, ordering, flushing, and wrapper IP metadata.
src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TplEventSource.csRemoves prior async-instrumentation flag toggling from OnEventCommand.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfilerEventSource.csAdds the new EventSource that publishes buffered async-profiler payloads and handles flush/config commands.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.csAdds the managed async-profiler core: buffering, serialization, config, and thread-context cache/flush coordination.
src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncInstrumentation.csReworks flag synchronization semantics (renames “Uninitialized” to “Synchronize” and changes sync logic).
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsIncludes new async-profiler sources and conditions out async instrumentation for Mono builds.
src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.csExcludes the new async-profiler EventSource from “no EventSources running” assertions.
src/coreclr/System.Private.CoreLib/System.Private.CoreLib.csprojAdds CoreCLR-specific async-profiler implementation file to the build.
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.csAdds CoreCLR-specific profiler pieces: runtime callstack capture and continuation-wrapper table (32 wrappers).
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.csWires async-profiler hooks into runtime-async dispatch and extends AsyncDispatcherInfo to carry profiler state.
src/coreclr/nativeaot/System.Private.CoreLib/src/System.Private.CoreLib.csprojAdds CoreCLR async-profiler CoreCLR-partition source to NativeAOT corelib build.

Comment on lines +1935 to +1944
Type cwType = typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);

for (int i = 0; i < wrapperIPs.Length; i++)
{
string expectedName = $"Continuation_Wrapper_{i}";
MethodInfo method = cwType.GetMethod(expectedName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.True(method is not null, $"Expected method '{expectedName}' to exist on ContinuationWrapper type");

System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Assembly.GetType(...) and Type.GetMethod(...) return nullable types. Assigning them to non-nullable Type/MethodInfo will trigger nullable warnings (and can become build breaks if warnings are treated as errors). Consider declaring these as Type? / MethodInfo? (or using null-forgiving only after the null check) so the nullability matches the APIs and the subsequent Assert.NotNull / Assert.True(method is not null, ...) validations.

Suggested change
TypecwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfomethod=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
Type?cwType=typeof(object).Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper");
Assert.NotNull(cwType);
for(inti=0;i<wrapperIPs.Length;i++)
{
stringexpectedName=$"Continuation_Wrapper_{i}";
MethodInfo?method=cwType.GetMethod(expectedName,BindingFlags.NonPublic|BindingFlags.Static);
Assert.True(methodis not null,$"Expected method '{expectedName}' to exist on ContinuationWrapper type");
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method!.MethodHandle);

Copilot uses AI. Check for mistakes.
CopilotAI review requested due to automatic review settings April 29, 2026 22:10
@hoyosjs
hoyosjsforce-pushed the juhoyosa/backport-127238-11p4 branch from 09f97c6 to 9b868c5CompareApril 29, 2026 22:10

CopilotAI 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 12 out of 13 changed files in this pull request and generated 2 comments.

ActiveEventKeywords = 0;
if (logLevel == EventLevel.LogAlways || logLevel >= EventLevel.Informational)
{
ActiveEventKeywords = eventKeywords;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

Config.Update copies eventKeywords directly into ActiveEventKeywords. In EventSource, a matchAnyKeyword value of 0 is a special case meaning “all keywords”, but with the current logic ActiveEventKeywords == 0 causes IsEnabled.AnyAsyncEvents / per-event keyword checks to evaluate false and disables the profiler even when the EventSource is enabled without an explicit keyword mask. Consider normalizing eventKeywords == 0 to AsyncEventKeywords (or otherwise honoring the EventSource ‘0 means all’ semantics) before computing ActiveEventKeywords / updating flags.

Suggested change
ActiveEventKeywords=eventKeywords;
ActiveEventKeywords=eventKeywords==0?AsyncEventKeywords:eventKeywords;

Copilot uses AI. Check for mistakes.
Comment on lines +1084 to +1085
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
long lastEventWriteTimestamp = context.LastEventTimestamp;

CopilotAIApr 29, 2026

Copy link

Choose a reason for hiding this comment

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

FlushCore reads context.LastEventTimestamp without any atomic/volatile read. On 32-bit platforms, long reads can tear, which could lead to spurious large values (skipping needed flushes indefinitely) or small values (premature flush/reclaim). Since this is a background path, consider using Volatile.Read(ref context.LastEventTimestamp) (and Volatile.Write where it’s updated, if needed) to ensure atomicity across architectures.

Suggested change
// Read LastEventTimestamp without atomics, could cause teared reads but not critical.
longlastEventWriteTimestamp=context.LastEventTimestamp;
// Read LastEventTimestamp atomically to avoid torn 64-bit reads on 32-bit platforms.
longlastEventWriteTimestamp=Volatile.Read(refcontext.LastEventTimestamp);

Copilot uses AI. Check for mistakes.
@hoyosjs

Copy link
Copy Markdown
MemberAuthor

Backport of #127238 - but decided this is too risky for p4

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@hoyosjs@lateralusX