Summary
Internal VS telemetry identifies TaggerMainThreadManager.PerformWorkOnMainThreadAsync as a top allocation hotspot on the main thread during editor tag recomputation. The method allocates a TaskCompletionSource<TaggerUIData?> on every call, even when already on the main thread where the action executes synchronously. With multiple active taggers per document (classification, outlining, diagnostics, inheritance margin, etc.), each calling this method on every recompute cycle, the per-call allocation overhead (88-128 bytes) contributes to GC pressure on the UI thread during typing.
Current behavior
The method unconditionally allocates a new TaskCompletionSource<TaggerUIData?>() before checking IsOnMainThread. On the main-thread path, the action is executed synchronously and the TCS result is set immediately, but the allocation has already occurred.
Proposed fix
Split into a non-async fast path and an async slow path:
- Fast path (on main thread): Run the action directly, return the result as a synchronous
ValueTask — zero heap allocations.
- Slow path (off main thread): Unchanged behavior — allocate TCS and enqueue for batched execution.
The method is intentionally kept non-async so the compiler doesn't generate a state machine for the fast path (async elision pattern, matching GetCompilationSlowAsync elsewhere in the repo).
Benchmark results
| Method |
Before Mean |
After Mean |
CPU Reduction |
Before Alloc |
After Alloc |
Alloc Reduction |
| ReturnsNull |
601.9 ns |
345.2 ns |
-43% |
88 B |
0 B |
-100% |
| ReturnsValue |
789.7 ns |
544.1 ns |
-31% |
128 B |
40 B |
-69% |
Benchmark: TaggerMainThreadManagerBenchmarks in src/Tools/IdeBenchmarks, exercising PerformWorkOnMainThreadAsync on the main thread with [CpuUsageDiagnoser].
Summary
Internal VS telemetry identifies
TaggerMainThreadManager.PerformWorkOnMainThreadAsyncas a top allocation hotspot on the main thread during editor tag recomputation. The method allocates aTaskCompletionSource<TaggerUIData?>on every call, even when already on the main thread where the action executes synchronously. With multiple active taggers per document (classification, outlining, diagnostics, inheritance margin, etc.), each calling this method on every recompute cycle, the per-call allocation overhead (88-128 bytes) contributes to GC pressure on the UI thread during typing.Current behavior
The method unconditionally allocates a
new TaskCompletionSource<TaggerUIData?>()before checkingIsOnMainThread. On the main-thread path, the action is executed synchronously and the TCS result is set immediately, but the allocation has already occurred.Proposed fix
Split into a non-async fast path and an async slow path:
ValueTask— zero heap allocations.The method is intentionally kept non-async so the compiler doesn't generate a state machine for the fast path (async elision pattern, matching
GetCompilationSlowAsyncelsewhere in the repo).Benchmark results
Benchmark:
TaggerMainThreadManagerBenchmarksinsrc/Tools/IdeBenchmarks, exercisingPerformWorkOnMainThreadAsyncon the main thread with[CpuUsageDiagnoser].