Skip to content

Proper event tracing for ICSharpCode.Decompiler and ICSharpCode.ILSpyX#3904

Merged
christophwille merged 4 commits into
masterfrom
event-tracing
Jul 22, 2026
Merged

Proper event tracing for ICSharpCode.Decompiler and ICSharpCode.ILSpyX#3904
christophwille merged 4 commits into
masterfrom
event-tracing

Conversation

@christophwille

@christophwille christophwille commented Jul 22, 2026

Copy link
Copy Markdown
Member

Reworks the event tracing that #2519 started into proper, cross-platform performance instrumentation for both ICSharpCode.Decompiler and ICSharpCode.ILSpyX.

Accepted plan

  • Redesign the ICSharpCode.Decompiler provider freely (the five flat events from Add ETW for Performance Measuring #2519 are replaced; back-compat was explicitly waived): Start/Stop event pairs instead of one-shot "took N ms" events, keyword gating, and coverage of the stages that actually cost time - type system initialization, assembly resolution probing, whole-project decompilation, and per-transform timing of the IL/AST pipelines (Verbose, since PR Add ETW for Performance Measuring #2519's [Conditional("STEP")] Stepper only exists in Debug builds).
  • Add a new ICSharpCode.ILSpyX provider for the frontend-support costs: assembly loading (which IFileLoader won), reference resolution (with outcome), snapshot lookup builds, per-module search, analyzer scope scans, bundle/zip extraction, and PDB loading.
  • EventSource only, no new dependencies: EventSource is in-box for netstandard2.0 and flows over both ETW (PerfView) and EventPipe (dotnet-trace), so the same instrumentation works on Windows, Linux and macOS. ActivitySource was considered and rejected because it would add the System.Diagnostics.DiagnosticSource package to the dependency-free core library.

Start/Stop pairs mean trace viewers derive durations and nesting from event timestamps - call sites do not time themselves, except for the high-volume single-shot events (per-transform, per-extracted-entry) which carry an explicit elapsedMs.

High-level overview

ICSharpCode.Decompiler (keywords Decompilation, TypeSystem, AssemblyResolver, ProjectDecompiler, Transforms):

Events Instrumented at
DecompileTypeStart/Stop, DecompileMemberStart/Stop (token, member kind, IL body size) the five DoDecompile overloads in CSharpDecompiler
TypeSystemInitStart/Stop (resolved reference count) DecompilerTypeSystem.InitializeAsync
AssemblyResolveStart/Stop (resolved path, success) UniversalAssemblyResolver.FindAssemblyFile
ProjectDecompilationStart/Stop, ProjectFileStart/Stop WholeProjectDecompiler (incl. the parallel per-file loop)
ILTransformExecuted, AstTransformExecuted (Verbose) the transform loops in ILFunction.RunTransforms / CSharpDecompiler.RunTransforms

ICSharpCode.ILSpyX (keywords AssemblyLoad, Resolver, Search, Analyzers, Packages, DebugInfo):

Events Instrumented at
AssemblyLoadStart/Stop (winning loader, success) LoadedAssembly.LoadAsync
AssemblyResolveStart/Stop (outcome: found-in-list / loaded-from-disk / similar-match / not-found) MyAssemblyResolver.ResolveAsync
SnapshotLookupBuildStart/Stop AssemblyListSnapshot lookup construction (the first-resolve cascade)
SearchModuleStart/Stop RunningSearch.RunSearch (the app-side per-assembly loop)
AnalyzerScopeStart/Stop (modules in scope) AnalyzerScope.GetModulesInScope (span wraps the lazy enumeration)
PackageOpened, PackageEntryExtracted (Verbose) LoadedPackage bundle/zip open and per-entry decompression
DebugInfoLoadStart/Stop (provider kind) LoadedAssembly.LoadDebugInfo

Are the IsEnabled guards necessary?

Short answer: a check must exist somewhere, but not at the call sites - WriteEvent already no-ops internally when the provider is disabled, so the branch itself buys nothing. What a naive "just write the event" pattern would actually pay for with tracing off:

  1. Payload allocation: FullName builds a new string per call - tens of thousands of allocations on a whole-module decompilation.
  2. params object[] marshaling: events with 4+ mixed args have no exact WriteEvent overload, so C# allocates the args array and boxes the ints before the internal enabled-check can skip them. This is the classic EventSource trap, and the same category of always-on overhead Add ETW for Performance Measuring #2519 had (it allocated a Stopwatch plus FullName per member unconditionally).
  3. Timestamps in the hottest loop: per-transform timing needs two Stopwatch.GetTimestamp() (QPC) calls per transform, ~40 transforms x every method - millions of calls on a large assembly, for Verbose data that is almost never collected.

Because readability matters more than guard micro-plumbing, the guards were refactored into the providers using the dotnet/runtime pattern: strongly-typed [NonEvent] overloads (e.g. DecompileMemberStart(IEntity, DecompiledMemberKind)) check IsEnabled() before extracting any payload. Call sites read like plain logging - no bool trace locals, no wrapper/Core method splits kept purely for tracing. The only surviving call-site guards are the cached bool in the two RunTransforms loops and the package-entry extraction paths (case 3 above), each one variable in one method. Along the way the Search/SearchCore template split was reverted (the span moved to the single call site in RunningSearch) and the bundle/zip Start/Stop span collapsed to a single PackageOpened completion event.

Net effect: disabled tracing costs one branch and zero allocations everywhere, and the instrumentation noise is concentrated in the two *EventSource.cs files.

Validating traces

doc/CollectingEventTraces.md (added in this PR) has the per-platform instructions - dotnet-trace attach/launch on all platforms, PerfView/ETW as the Windows alternative, plus the keyword/level reference for enabling subsets. Verified end-to-end by collecting a dotnet-trace EventPipe session of ilspycmd whole-project-decompiling an assembly: all Decompiler event types (including the Verbose per-transform ones) appear in the resulting .nettrace.

Testing

  • Strict manifest validation (EventSource.GenerateManifest(..., Strict)) plus a fire-every-event test per provider that asserts no EventSourceMessage schema errors - all 28 events covered.
  • Behavioral tests for every event pair against the real call sites: decompiling a sample type (type/member/transform events), constructing a DecompilerTypeSystem (type system + resolver events), whole-project-decompiling a Roslyn-compiled throwaway assembly (project events), loading/resolving through AssemblyList (load/resolve/snapshot events), PDB loading, AnalyzerScope enumeration, zip open/extraction, and a headless-UI search run (SearchModule events via SearchPaneModel).
  • Full suites green: ICSharpCode.Decompiler.Tests (3734 passed) and ILSpy.Tests (1051 passed), 0 failures.

🤖 Generated with Claude Code

…ting

The events introduced in #2519 timed only the five per-entity DoDecompile
overloads, allocated a Stopwatch and the member's FullName even when no
trace session was attached, and reported whole milliseconds, which rounds
almost every member to zero. Flat one-shot events also gave PerfView and
dotnet-trace no way to show durations or nesting, and the actually
expensive stages (type system initialization, assembly resolution probing,
the IL/AST transform pipelines, whole-project decompilation) were not
instrumented at all.

Start/Stop event pairs let trace viewers derive duration and nesting from
event timestamps, keywords let a session enable only the areas of
interest, and every call site is gated on IsEnabled() so tracing costs a
branch when disabled. Per-transform events are Verbose because of their
volume; unlike the STEP/Stepper mechanism they work in Release builds.
EventSource is in-box for netstandard2.0 and flows over both ETW and
EventPipe, so this stays cross-platform with no new dependency.

Assisted-by: Claude:claude-fable-5:Claude Code
… tracing

ILSpyX had no instrumentation, yet most UI-visible latency bottoms out
here: lazy assembly loads, the first-resolve cascade that metadata-loads
every assembly in a list snapshot, per-module search strategy runs,
analyzer scope scans over all assemblies and their references, bundle/zip
entry extraction, and PDB loading. The provider mirrors the
ICSharpCode.Decompiler design: Start/Stop pairs, keyword gating, and
IsEnabled() guards at every call site; per-entry package extraction is
Verbose because of its volume.

AbstractSearchStrategy.Search is now a non-virtual template method that
wraps the span around a new protected SearchCore, so derived strategies
cannot bypass the instrumentation.

Assisted-by: Claude:claude-fable-5:Claude Code
@christophwille

Copy link
Copy Markdown
Member Author

Triggering the enhancement of tracing was this blog article: https://devblogs.microsoft.com/performance-diagnostics/etw-mcp-intro/

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR redesigns and expands performance event tracing across the ILSpy stack by introducing proper cross-platform EventSource-based instrumentation for both ICSharpCode.Decompiler (core decompilation pipeline) and ICSharpCode.ILSpyX (frontend-support operations), plus documentation and test coverage to validate event schemas and call-site behavior.

Changes:

  • Reworks ICSharpCode.Decompiler tracing into keyword-gated Start/Stop spans for decompilation stages, type system init, assembly resolution, whole-project decompilation, and Verbose per-transform timings.
  • Adds a new ICSharpCode.ILSpyX provider for assembly loading/resolution, snapshot lookup builds, search, analyzer scope enumeration, package open/extraction, and debug-info loading.
  • Adds strict manifest/schema validation tests for both providers and documentation for collecting traces via EventPipe/ETW.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
ILSpy/Search/RunningSearch.cs Adds per-module search Start/Stop tracing via the ILSpyX provider.
ILSpy.Tests/Instrumentation/ILSpyXEventSourceTests.cs Adds provider manifest/schema validation and behavioral tests for ILSpyX events.
ICSharpCode.ILSpyX/LoadedPackage.cs Emits package open/extraction events (with Verbose gating for per-entry timing).
ICSharpCode.ILSpyX/LoadedAssembly.cs Instruments assembly load, debug-info load, and reference resolution with outcome reporting.
ICSharpCode.ILSpyX/Instrumentation/ILSpyXEventSource.cs Introduces the ICSharpCode.ILSpyX EventSource provider, keywords, and NonEvent helpers.
ICSharpCode.ILSpyX/AssemblyListSnapshot.cs Adds snapshot lookup build Start/Stop spans around lookup construction.
ICSharpCode.ILSpyX/Analyzers/AnalyzerScope.cs Wraps lazy scope enumeration in an analyzer span when tracing is enabled.
ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs Adds type system init Start/Stop span including a resolved-reference count payload.
ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs Instruments assembly probing in FindAssemblyFile with Start/Stop events (non-VSADDIN).
ICSharpCode.Decompiler/Instrumentation/DecompilerEventSource.cs Major redesign of the decompiler provider: keywords, spans, and per-transform events + NonEvent guards.
ICSharpCode.Decompiler/IL/Instructions/ILFunction.cs Adds Verbose per-IL-transform timing with cached tracing gate.
ICSharpCode.Decompiler/CSharp/ProjectDecompiler/WholeProjectDecompiler.cs Adds whole-project and per-file Start/Stop spans and counts.
ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs Adds per-type/per-member spans and Verbose per-AST-transform timing.
ICSharpCode.Decompiler.Tests/Instrumentation/DecompilerEventSourceTests.cs Adds provider manifest/schema validation and behavioral tests for decompiler events.
docs/CollectingEventTraces.md Documents provider keywords/levels and how to collect traces with dotnet-trace/PerfView.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Init(mainModuleWithOptions, referencedAssembliesWithOptions);
}
this.mainModule = (MetadataModule)base.MainModule;
return referencedAssemblies.Count;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 28606d8. InitializeCoreAsync now returns referencedAssembliesWithOptions.Count, i.e. the final reference set passed to Init() (distinct assemblies after the same-name/version dedup, plus resolved non-assembly modules), and the XML doc was updated to match. The raw resolve fan-out is still observable per reference via the AssemblyResolveStart/Stop events, so no information is lost.

Comment on lines +121 to +123
[Test]
public void FiringEveryEventProducesNoEventSourceErrors()
{

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 28606d8. Rather than serializing the test (which would not help against the non-test ILSpy components emitting events), every string payload the test fires now carries a unique "SchemaSmokeTest." marker and the count assertions filter on it, so concurrent decompilations in parallel fixtures cannot affect the counts. The behavioral tests already filtered by identifiable payloads.

Comment on lines +87 to +89
[Test]
public void FiringEveryEventProducesNoEventSourceErrors()
{

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 28606d8, same approach as the decompiler-side test: string payloads carry a unique "SchemaSmokeTest." marker that the count assertions filter on. SnapshotLookupBuildStart/Stop have no string payload, so those two use a sentinel assemblyCount (987654) as the filter instead.

@christophwille

christophwille commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Good context - the keyword/level split in both providers is designed exactly for that kind of workflow: an ETW/EventPipe session can enable just the area under investigation (e.g. AssemblyResolver only, or Transforms at Verbose when hunting a slow transform) instead of drinking from the firehose. doc/CollectingEventTraces.md lists the masks.

Collecting from the two EventSource providers differs by tooling, not by
platform: dotnet-trace/EventPipe works the same everywhere, with ETW/PerfView
as the Windows-only alternative. The doc lists the keyword masks and levels
so sessions can enable only the areas of interest.

Assisted-by: Claude:claude-fable-5:Claude Code
TypeSystemInitStop now reports the size of the final reference set passed
to Init() instead of the raw resolve count, which included same-name
duplicates that the version dedup later drops. The schema smoke tests
asserted exact global event counts, but the providers are process-wide
and the decompiler fixtures run in parallel, so unrelated decompilations
could inflate the counts; every string payload now carries a unique
marker (and the string-less snapshot events a sentinel count) that the
assertions filter on.

Assisted-by: Claude:claude-fable-5:Claude Code
@christophwille
christophwille merged commit 2e617a3 into master Jul 22, 2026
13 checks passed
@christophwille
christophwille deleted the event-tracing branch July 22, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants