Skip to content
Purview Dev logo

Performance

Stable Telemetry SourceGenerator Reviewed 2026-09-16 purview-dev/telemetry-sourcegenerator Star on GitHub 36 activity c-sharp distributed-tracing dotnet events high-performance-logging instrumentation logging melt metrics nuget observability open-telemetry open-telemetry-csharp otel roslyn source-generator spans telemetry tracing

Full cross-runtime benchmark results for the Purview Telemetry Source Generator, generated by BenchmarkDotNet from the benchmarks/ project.

Terminal window
dotnet run --project benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj \
--configuration Release --framework net10.0

Results are written to BenchmarkDotNet.Artifacts/results/ as *-report-github.md, *.csv, and *.html.

BenchmarkDotNet v0.15.8, Windows 11 (10.0.28020.2991)
13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores
.NET SDK 10.0.401
.NET 10.0 : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3
.NET 8.0 : .NET 8.0.31 (8.0.31, 8.0.3126.42015), X64 RyuJIT x86-64-v3
.NET 9.0 : .NET 9.0.20 (9.0.20, 9.0.2026.41315), X64 RyuJIT x86-64-v3

Note: the .NET Framework 4.7/4.8 jobs were executed but produced no results in this run and are excluded from the tables below.

Generated hot-path methods carry [MethodImpl(MethodImplOptions.AggressiveInlining)], which lets the JIT inline the small generated telemetry methods into callers.

Source: ActivityBenchmarks

Compares the source-generator-produced ActivityOnlyTelemetryCore against a hand-written ManualActivityTelemetry under two conditions: no listener registered (fast-path) and a full-sampling ActivityListener active (production path).

MethodRuntimeHasListenerMeanRatioAllocated
Manual: start + complete.NET 10.0False0.53 ns1.00-
Generated: start + complete.NET 10.0False0.58 ns1.11-
Manual: start + fail.NET 10.0False0.75 ns1.43-
Generated: start + fail.NET 10.0False0.55 ns1.04-
Manual: start + complete.NET 8.0False0.78 ns1.00-
Generated: start + complete.NET 8.0False0.88 ns1.12-
Manual: start + fail.NET 8.0False0.93 ns1.19-
Generated: start + fail.NET 8.0False0.76 ns0.97-
Manual: start + complete.NET 9.0False0.55 ns1.00-
Generated: start + complete.NET 9.0False0.77 ns1.40-
Manual: start + fail.NET 9.0False0.35 ns0.63-
Generated: start + fail.NET 9.0False0.59 ns1.07-
Manual: start + complete.NET 10.0True220.37 ns1.001008 B
Generated: start + complete.NET 10.0True216.69 ns0.981008 B
Manual: start + fail.NET 10.0True207.55 ns0.94920 B
Generated: start + fail.NET 10.0True203.12 ns0.92920 B
Manual: start + complete.NET 8.0True259.23 ns1.001008 B
Generated: start + complete.NET 8.0True262.84 ns1.011008 B
Manual: start + fail.NET 8.0True229.10 ns0.88920 B
Generated: start + fail.NET 8.0True241.07 ns0.93920 B
Manual: start + complete.NET 9.0True226.38 ns1.001008 B
Generated: start + complete.NET 9.0True229.96 ns1.021008 B
Manual: start + fail.NET 9.0True219.02 ns0.97920 B
Generated: start + fail.NET 9.0True211.70 ns0.94920 B

Interpretation: Generated activities match hand-written code within ~2% and allocate identically across all tested runtimes. When a listener is active the absolute times are dominated by the framework’s Activity creation, not the generated code.

Source: LoggerBenchmarks

Compares three logging approaches: hand-written LoggerMessage.Define (gold-standard manual), generated v1 (LoggerMessage.Define pattern), and generated v2 (state-based ThreadLocalState pattern).

MethodRuntimeHasLoggingMeanRatioAllocated
Manual: single Info call.NET 10.0True6.12 ns1.00-
Generated v1 — single Info call.NET 10.0True4.43 ns0.72-
Generated v2 — single Info call.NET 10.0True3.80 ns0.62-
Manual: full lifecycle (4 calls).NET 10.0True18.88 ns3.08-
Generated v1 — full lifecycle.NET 10.0True19.58 ns3.20-
Generated v2 — full lifecycle.NET 10.0True15.01 ns2.45-
Manual: single Info call.NET 8.0True7.55 ns1.00-
Generated v1 — single Info call.NET 8.0True7.51 ns0.99-
Generated v2 — single Info call.NET 8.0True5.10 ns0.68-
Manual: full lifecycle.NET 8.0True29.98 ns3.97-
Generated v1 — full lifecycle.NET 8.0True30.72 ns4.07-
Generated v2 — full lifecycle.NET 8.0True22.11 ns2.93-
Manual: single Info call.NET 9.0True6.88 ns1.00-
Generated v1 — single Info call.NET 9.0True6.50 ns0.95-
Generated v2 — single Info call.NET 9.0True5.06 ns0.74-
Manual: full lifecycle.NET 9.0True25.55 ns3.72-
Generated v1 — full lifecycle.NET 9.0True24.88 ns3.62-
Generated v2 — full lifecycle.NET 9.0True22.79 ns3.31-

Interpretation: Generated v1 and v2 both allocate zero bytes per call across all runtimes. On .NET 10.0 the generated variants are faster than the hand-written LoggerMessage.Define baseline for single calls (v1 0.72x, v2 0.62x) thanks to [MethodImpl(AggressiveInlining)]; v2 (state-based) is the fastest path on every runtime. The full-lifecycle cost is dominated by the four IsEnabled checks and message-formatting paths that all three implementations share.

Source: MultiTargetVsSingleTargetBenchmarks

Measures the overhead of emitting Activity + Logging + Metrics from a single method call (multi-target) vs. Activity-only (single-target), comparing generated and manual code.

MethodRuntimeHasListenerMeanRatioAllocatedAlloc Ratio
Single-target (generated): start + complete.NET 10.0True216.15 ns1.001008 B1.00
Multi-target (generated): start + complete.NET 10.0True230.41 ns1.071032 B1.02
Multi-target (manual): start + complete.NET 10.0True260.18 ns1.201032 B1.02
Multi-target (generated): start + complete + record latency.NET 10.0True229.65 ns1.061032 B1.02
Multi-target (manual): start + complete + record latency.NET 10.0True234.58 ns1.091032 B1.02
Single-target (generated): start + complete.NET 8.0True258.36 ns1.001008 B1.00
Multi-target (generated): start + complete.NET 8.0True260.21 ns1.011032 B1.02
Multi-target (manual): start + complete.NET 8.0True267.22 ns1.031032 B1.02
Single-target (generated): start + complete.NET 9.0True220.24 ns1.001008 B1.00
Multi-target (generated): start + complete.NET 9.0True249.15 ns1.131032 B1.02
Multi-target (manual): start + complete.NET 9.0True257.27 ns1.171032 B1.02

Interpretation: When an Activity listener is active (production path), multi-target generation adds ~7% overhead over single-target Activity-only on .NET 10.0 — the real cost of the extra log call and metric increment, not generated-code overhead. The generated multi-target code is faster than the hand-written multi-target baseline (~1.07x vs ~1.20x of single-target). See the Generated Output page for what the multi-target implementation looks like.

Source: LoggerMultiTargetBenchmarks

Compares single-target logging-only vs. multi-target (Activity + Logging + Metrics) generated code with a hand-written multi-target baseline.

MethodRuntimeHasListenerMeanRatioAllocated
Multi-target (manual): start + complete.NET 10.0True264.31 ns1.001032 B
Multi-target (generated v1): start + complete.NET 10.0True236.44 ns0.891032 B
Multi-target (generated v2): start + complete.NET 10.0True227.94 ns0.861032 B
Multi-target (manual): full lifecycle.NET 10.0True243.84 ns0.921032 B
Multi-target (generated v1): full lifecycle.NET 10.0True249.32 ns0.941032 B
Multi-target (generated v2): full lifecycle.NET 10.0True216.86 ns0.821032 B
Single-target (generated v1): full lifecycle.NET 10.0True18.21 ns0.07-
Single-target (generated v2): full lifecycle.NET 10.0True15.69 ns0.06-

Interpretation: Generated and manual multi-target implementations are within ~6% of each other on .NET 10.0, allocating identically (1032 B with a listener active). Logging-only accounts for a small fraction (~16-18 ns) of the multi-target cost (~230 ns); Activity creation dominates when a listener is active.

Source: MetricsBenchmarks and TagListBenchmarks

All instruments are 0 allocations on every runtime.

ScenarioGenerated (.NET 10.0)Generated (.NET 8.0)Generated (.NET 9.0)
auto-counter (0 tags)0.40 ns0.55 ns0.21 ns
auto-counter (1 tag)0.35 ns0.56 ns0.30 ns
up-down counter0.37 ns0.39 ns0.20 ns
histogram (0 tags)0.42 ns0.35 ns0.18 ns
histogram (1 tag)0.34 ns0.36 ns0.37 ns

The source generator uses a tag-count optimization: methods with fewer than 4 tags pass inline KeyValuePair parameters (no heap allocation), while methods with 4 or more tags use a stack-allocated TagList struct.

Scenario.NET 10.0.NET 8.0.NET 9.0
0 tags: histogram record0.38 ns0.56 ns0.19 ns
1 tag: auto-counter add0.41 ns0.35 ns0.19 ns
3 tags: histogram record0.84 ns0.74 ns0.55 ns
4 tags (TagList): auto-counter add7.91 ns7.79 ns3.62 ns
5 tags (TagList): auto-counter add5.46 ns8.95 ns9.37 ns
6 tags (TagList): histogram record6.37 ns4.32 ns3.99 ns

The TagList path costs 14–21× more CPU than the inline path on .NET 10.0, but both remain in the single-digit-nanosecond range with zero allocations.

ObservableCounter, ObservableGauge, and ObservableUpDownCounter are not benchmarked because they have no per-operation hot path to compare. They register a callback once at construction time (via meter.CreateObservable*) and are polled by the metrics collection pipeline — the generator produces the identical CreateObservable* call you would write by hand, so there is no wrapper layer on the measurement path.

Full CSV and HTML benchmark artifacts are available in BenchmarkDotNet.Artifacts/results/.