Skip to content

Performance

Kieron Lanning edited this page Apr 7, 2026 · 2 revisions

Performance Benchmarks

Full cross-runtime benchmark results for the Purview Telemetry Source Generator.

Results are generated by BenchmarkDotNet from the benchmark project in the benchmarks/ directory. To reproduce them:

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.

See README.md § Performance for a condensed summary of the key .NET 10.0 numbers.


Environment

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8117/25H2/2025Update/HudsonValley2)
13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores
.NET SDK 10.0.201
  [Host]    : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3
  .NET 10.0 : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3
  .NET 8.0  : .NET 8.0.25 (8.0.25, 8.0.2526.11203), X64 RyuJIT x86-64-v3
  .NET 9.0  : .NET 9.0.14 (9.0.14, 9.0.1426.11910), X64 RyuJIT x86-64-v3

Note: .NET Framework 4.7/4.8 targets did not produce results in this run and are excluded from the tables below.


Activities

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).

Method Runtime HasListener Mean Ratio Allocated Alloc Ratio
Manual: start + complete .NET 10.0 False 0.56 ns 1.00 - NA
Generated: start + complete .NET 10.0 False 0.55 ns 0.99 - NA
Manual: start + fail .NET 10.0 False 0.72 ns 1.29 - NA
Generated: start + fail .NET 10.0 False 0.52 ns 0.93 - NA
Manual: start + complete .NET 8.0 False 0.71 ns 1.00 - NA
Generated: start + complete .NET 8.0 False 0.71 ns 1.01 - NA
Manual: start + fail .NET 8.0 False 0.91 ns 1.30 - NA
Generated: start + fail .NET 8.0 False 0.90 ns 1.28 - NA
Manual: start + complete .NET 9.0 False 0.54 ns 1.00 - NA
Generated: start + complete .NET 9.0 False 0.55 ns 1.01 - NA
Manual: start + fail .NET 9.0 False 0.73 ns 1.36 - NA
Generated: start + fail .NET 9.0 False 0.70 ns 1.30 - NA
Manual: start + complete .NET 10.0 True 217.75 ns 1.00 1008 B 1.00
Generated: start + complete .NET 10.0 True 204.03 ns 0.94 1008 B 1.00
Manual: start + fail .NET 10.0 True 198.43 ns 0.91 920 B 0.91
Generated: start + fail .NET 10.0 True 189.26 ns 0.87 920 B 0.91
Manual: start + complete .NET 8.0 True 241.11 ns 1.00 1008 B 1.00
Generated: start + complete .NET 8.0 True 250.49 ns 1.04 1008 B 1.00
Manual: start + fail .NET 8.0 True 223.87 ns 0.93 920 B 0.91
Generated: start + fail .NET 8.0 True 222.14 ns 0.92 920 B 0.91
Manual: start + complete .NET 9.0 True 216.84 ns 1.00 1008 B 1.00
Generated: start + complete .NET 9.0 True 214.30 ns 0.99 1008 B 1.00
Manual: start + fail .NET 9.0 True 200.43 ns 0.92 920 B 0.91
Generated: start + fail .NET 9.0 True 222.14 ns 1.03 920 B 0.91

Interpretation: Generated activities match or outperform hand-written code and allocate identically across all tested runtimes.


Logging

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).

Method Runtime HasLogging Mean Ratio Allocated
Manual: LoggerMessage.Define — single Info .NET 10.0 False 0.21 ns 1.01 -
Generated v1 — single Info .NET 10.0 False 0.18 ns 0.89 -
Generated v2 — single Info .NET 10.0 False 0.21 ns 1.00 -
Manual: LoggerMessage.Define — full lifecycle .NET 10.0 False 0.37 ns 1.80 -
Generated v1 — full lifecycle .NET 10.0 False 0.75 ns 3.63 -
Generated v2 — full lifecycle .NET 10.0 False 0.76 ns 3.71 -
Manual: LoggerMessage.Define — single Info .NET 8.0 False 0.18 ns 1.00 -
Generated v1 — single Info .NET 8.0 False 0.39 ns 2.15 -
Generated v2 — single Info .NET 8.0 False 0.37 ns 2.04 -
Manual: LoggerMessage.Define — full lifecycle .NET 8.0 False 0.92 ns 5.12 -
Generated v1 — full lifecycle .NET 8.0 False 1.28 ns 7.13 -
Generated v2 — full lifecycle .NET 8.0 False 1.34 ns 7.44 -
Manual: LoggerMessage.Define — single Info .NET 9.0 False 0.18 ns 1.00 -
Generated v1 — single Info .NET 9.0 False 0.18 ns 0.99 -
Generated v2 — single Info .NET 9.0 False 0.17 ns 0.91 -
Manual: LoggerMessage.Define — full lifecycle .NET 9.0 False 0.54 ns 2.99 -
Generated v1 — full lifecycle .NET 9.0 False 1.49 ns 8.23 -
Generated v2 — full lifecycle .NET 9.0 False 1.47 ns 8.08 -
Manual: LoggerMessage.Define — single Info .NET 10.0 True 4.29 ns 1.00 -
Generated v1 — single Info .NET 10.0 True 4.24 ns 0.99 -
Generated v2 — single Info .NET 10.0 True 4.20 ns 0.98 -
Manual: LoggerMessage.Define — full lifecycle .NET 10.0 True 17.73 ns 4.13 -
Generated v1 — full lifecycle .NET 10.0 True 19.52 ns 4.55 -
Generated v2 — full lifecycle .NET 10.0 True 18.81 ns 4.38 -
Manual: LoggerMessage.Define — single Info .NET 8.0 True 7.57 ns 1.00 -
Generated v1 — single Info .NET 8.0 True 7.34 ns 0.97 -
Generated v2 — single Info .NET 8.0 True 7.26 ns 0.96 -
Manual: LoggerMessage.Define — full lifecycle .NET 8.0 True 28.73 ns 3.79 -
Generated v1 — full lifecycle .NET 8.0 True 29.90 ns 3.95 -
Generated v2 — full lifecycle .NET 8.0 True 29.85 ns 3.94 -
Manual: LoggerMessage.Define — single Info .NET 9.0 True 6.10 ns 1.00 -
Generated v1 — single Info .NET 9.0 True 6.22 ns 1.02 -
Generated v2 — single Info .NET 9.0 True 6.25 ns 1.03 -
Manual: LoggerMessage.Define — full lifecycle .NET 9.0 True 23.88 ns 3.92 -
Generated v1 — full lifecycle .NET 9.0 True 24.55 ns 4.03 -
Generated v2 — full lifecycle .NET 9.0 True 24.75 ns 4.06 -

Interpretation: Generated v1 and v2 both allocate zero bytes across all runtimes. On .NET 10.0 with logging active, v1 (4.24 ns) and v2 (4.20 ns) are within ~1-2% of the manual LoggerMessage.Define baseline (4.29 ns). On .NET 8.0 and .NET 9.0, generated code is indistinguishable from hand-written code.


Logger Multi-Target

Source: LoggerMultiTargetBenchmarks

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

Method Runtime HasListener Mean Ratio Allocated Alloc Ratio
Multi-target (manual): start + complete .NET 10.0 False 11.87 ns 1.00 24 B 1.00
Multi-target (generated v1): start + complete .NET 10.0 False 27.85 ns 2.35 24 B 1.00
Multi-target (generated v2): start + complete .NET 10.0 False 26.67 ns 2.25 24 B 1.00
Multi-target (manual): full lifecycle .NET 10.0 False 23.67 ns 1.99 24 B 1.00
Multi-target (generated v1): full lifecycle .NET 10.0 False 18.29 ns 1.54 24 B 1.00
Multi-target (generated v2): full lifecycle .NET 10.0 False 12.27 ns 1.03 24 B 1.00
Single-target (generated v1): full lifecycle .NET 10.0 False 17.84 ns 1.50 - 0.00
Single-target (generated v2): full lifecycle .NET 10.0 False 17.66 ns 1.49 - 0.00
Multi-target (manual): start + complete .NET 8.0 False 17.55 ns 1.00 24 B 1.00
Multi-target (generated v1): start + complete .NET 8.0 False 18.64 ns 1.06 24 B 1.00
Multi-target (generated v2): start + complete .NET 8.0 False 18.81 ns 1.07 24 B 1.00
Multi-target (manual): full lifecycle .NET 8.0 False 15.90 ns 0.91 24 B 1.00
Multi-target (generated v1): full lifecycle .NET 8.0 False 17.38 ns 0.99 24 B 1.00
Multi-target (generated v2): full lifecycle .NET 8.0 False 16.92 ns 0.96 24 B 1.00
Single-target (generated v1): full lifecycle .NET 8.0 False 31.09 ns 1.77 - 0.00
Single-target (generated v2): full lifecycle .NET 8.0 False 31.25 ns 1.78 - 0.00
Multi-target (manual): start + complete .NET 9.0 False 14.51 ns 1.00 24 B 1.00
Multi-target (generated v1): start + complete .NET 9.0 False 14.67 ns 1.01 24 B 1.00
Multi-target (generated v2): start + complete .NET 9.0 False 14.60 ns 1.01 24 B 1.00
Multi-target (manual): full lifecycle .NET 9.0 False 14.34 ns 0.99 24 B 1.00
Multi-target (generated v1): full lifecycle .NET 9.0 False 14.66 ns 1.01 24 B 1.00
Multi-target (generated v2): full lifecycle .NET 9.0 False 14.34 ns 0.99 24 B 1.00
Single-target (generated v1): full lifecycle .NET 9.0 False 24.22 ns 1.67 - 0.00
Single-target (generated v2): full lifecycle .NET 9.0 False 24.32 ns 1.68 - 0.00
Multi-target (manual): start + complete .NET 10.0 True 243.70 ns 1.00 1032 B 1.00
Multi-target (generated v1): start + complete .NET 10.0 True 226.39 ns 0.93 1032 B 1.00
Multi-target (generated v2): start + complete .NET 10.0 True 228.01 ns 0.94 1032 B 1.00
Multi-target (manual): full lifecycle .NET 10.0 True 219.11 ns 0.90 1032 B 1.00
Multi-target (generated v1): full lifecycle .NET 10.0 True 220.89 ns 0.91 1032 B 1.00
Multi-target (generated v2): full lifecycle .NET 10.0 True 220.03 ns 0.90 1032 B 1.00
Single-target (generated v1): full lifecycle .NET 10.0 True 19.27 ns 0.08 - 0.00
Single-target (generated v2): full lifecycle .NET 10.0 True 17.73 ns 0.07 - 0.00
Multi-target (manual): start + complete .NET 8.0 True 259.01 ns 1.00 1032 B 1.00
Multi-target (generated v1): start + complete .NET 8.0 True 265.32 ns 1.02 1032 B 1.00
Multi-target (generated v2): start + complete .NET 8.0 True 263.96 ns 1.02 1032 B 1.00
Multi-target (manual): full lifecycle .NET 8.0 True 252.01 ns 0.97 1032 B 1.00
Multi-target (generated v1): full lifecycle .NET 8.0 True 261.70 ns 1.01 1032 B 1.00
Multi-target (generated v2): full lifecycle .NET 8.0 True 252.52 ns 0.98 1032 B 1.00
Single-target (generated v1): full lifecycle .NET 8.0 True 31.17 ns 0.12 - 0.00
Single-target (generated v2): full lifecycle .NET 8.0 True 30.51 ns 0.12 - 0.00
Multi-target (manual): start + complete .NET 9.0 True 238.41 ns 1.00 1032 B 1.00
Multi-target (generated v1): start + complete .NET 9.0 True 243.77 ns 1.02 1032 B 1.00
Multi-target (generated v2): start + complete .NET 9.0 True 240.78 ns 1.01 1032 B 1.00
Multi-target (manual): full lifecycle .NET 9.0 True 234.18 ns 0.98 1032 B 1.00
Multi-target (generated v1): full lifecycle .NET 9.0 True 233.81 ns 0.98 1032 B 1.00
Multi-target (generated v2): full lifecycle .NET 9.0 True 243.42 ns 1.02 1032 B 1.00
Single-target (generated v1): full lifecycle .NET 9.0 True 27.69 ns 0.12 - 0.00
Single-target (generated v2): full lifecycle .NET 9.0 True 26.07 ns 0.11 - 0.00

Interpretation: Generated v1 and v2 both allocate identically to hand-written multi-target code (24 B no-listener, 1032 B with-listener active). When a listener is active, all three multi-target implementations are within ~7% of each other on .NET 10.0. The Activity-creation cost dominates when a listener is active — single-target logging-only (~18 ns) accounts for only a small fraction of the total multi-target cost (~230 ns).


Multi-Target vs. Single-Target

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.

Method Runtime HasListener Mean Ratio Allocated Alloc Ratio
Single-target (generated): start + complete .NET 10.0 False 0.55 ns 1.00 - NA
Multi-target (generated): start + complete .NET 10.0 False 11.46 ns 20.88 24 B NA
Multi-target (manual): start + complete .NET 10.0 False 11.76 ns 21.43 24 B NA
Multi-target (generated): start + complete + record latency .NET 10.0 False 12.16 ns 22.15 24 B NA
Multi-target (manual): start + complete + record latency .NET 10.0 False 11.83 ns 21.55 24 B NA
Single-target (generated): start + complete .NET 8.0 False 0.73 ns 1.00 - NA
Multi-target (generated): start + complete .NET 8.0 False 16.07 ns 21.92 24 B NA
Multi-target (manual): start + complete .NET 8.0 False 16.45 ns 22.43 24 B NA
Multi-target (generated): start + complete + record latency .NET 8.0 False 16.90 ns 23.05 24 B NA
Multi-target (manual): start + complete + record latency .NET 8.0 False 15.48 ns 21.11 24 B NA
Single-target (generated): start + complete .NET 9.0 False 0.55 ns 1.00 - NA
Multi-target (generated): start + complete .NET 9.0 False 14.31 ns 26.13 24 B NA
Multi-target (manual): start + complete .NET 9.0 False 13.96 ns 25.50 24 B NA
Multi-target (generated): start + complete + record latency .NET 9.0 False 15.13 ns 27.63 24 B NA
Multi-target (manual): start + complete + record latency .NET 9.0 False 15.00 ns 27.39 24 B NA
Single-target (generated): start + complete .NET 10.0 True 203.33 ns 1.00 1008 B 1.00
Multi-target (generated): start + complete .NET 10.0 True 229.91 ns 1.13 1032 B 1.02
Multi-target (manual): start + complete .NET 10.0 True 233.48 ns 1.15 1032 B 1.02
Multi-target (generated): start + complete + record latency .NET 10.0 True 224.27 ns 1.10 1032 B 1.02
Multi-target (manual): start + complete + record latency .NET 10.0 True 217.24 ns 1.07 1032 B 1.02
Single-target (generated): start + complete .NET 8.0 True 242.29 ns 1.00 1008 B 1.00
Multi-target (generated): start + complete .NET 8.0 True 257.99 ns 1.06 1032 B 1.02
Multi-target (manual): start + complete .NET 8.0 True 254.46 ns 1.05 1032 B 1.02
Multi-target (generated): start + complete + record latency .NET 8.0 True 253.35 ns 1.05 1032 B 1.02
Multi-target (manual): start + complete + record latency .NET 8.0 True 261.46 ns 1.08 1032 B 1.02
Single-target (generated): start + complete .NET 9.0 True 219.20 ns 1.00 1008 B 1.00
Multi-target (generated): start + complete .NET 9.0 True 237.42 ns 1.08 1032 B 1.02
Multi-target (manual): start + complete .NET 9.0 True 242.88 ns 1.11 1032 B 1.02
Multi-target (generated): start + complete + record latency .NET 9.0 True 235.98 ns 1.08 1032 B 1.02
Multi-target (manual): start + complete + record latency .NET 9.0 True 221.28 ns 1.01 1032 B 1.02

Interpretation: When an Activity listener is active (production path), multi-target generation adds ~13% overhead over single-target Activity-only on .NET 10.0, reflecting the real cost of the extra log call and metric increment — not generated-code overhead. The generated multi-target code matches hand-written multi-target code closely across all runtimes.


Metrics — TagList Threshold

Source: TagListBenchmarks

Demonstrates the source generator's tag-count optimization: methods with fewer than 4 tags pass them directly as inline KeyValuePair parameters (no heap allocation), while methods with 4 or more tags use a stack-allocated TagList struct to batch them.

Method Runtime Mean Ratio Allocated
0 tags: histogram record .NET 10.0 0.34 ns 1.00 -
1 tag: auto-counter add .NET 10.0 0.37 ns 1.09 -
3 tags: histogram record .NET 10.0 0.87 ns 2.53 -
4 tags (TagList): auto-counter add .NET 10.0 4.19 ns 12.22 -
5 tags (TagList): auto-counter add .NET 10.0 5.18 ns 15.08 -
6 tags (TagList): histogram record .NET 10.0 6.51 ns 18.97 -
0 tags: histogram record .NET 8.0 0.54 ns 1.00 -
1 tag: auto-counter add .NET 8.0 0.37 ns 0.68 -
3 tags: histogram record .NET 8.0 0.73 ns 1.34 -
4 tags (TagList): auto-counter add .NET 8.0 3.78 ns 6.94 -
5 tags (TagList): auto-counter add .NET 8.0 4.19 ns 7.70 -
6 tags (TagList): histogram record .NET 8.0 4.17 ns 7.67 -
0 tags: histogram record .NET 9.0 0.19 ns 1.00 -
1 tag: auto-counter add .NET 9.0 0.36 ns 1.87 -
3 tags: histogram record .NET 9.0 0.68 ns 3.55 -
4 tags (TagList): auto-counter add .NET 9.0 3.62 ns 18.93 -
5 tags (TagList): auto-counter add .NET 9.0 3.78 ns 19.76 -
6 tags (TagList): histogram record .NET 9.0 3.88 ns 20.27 -

Interpretation: All metrics recording is allocation-free regardless of tag count. The TagList path (>=4 tags) costs 12-19x more in CPU time than the inline path on .NET 10.0; both remain in single-digit-nanosecond range.


Metrics — Generated vs. Manual

Source: MetricsBenchmarks

Compares the source-generator-produced implementation against hand-written raw .NET metrics API calls for Counter, UpDownCounter, and Histogram.

Note: On .NET 10.0, the JIT can eliminate unobserved metric calls (no active MeterListener) almost entirely, reducing manual baselines to sub-picosecond noise. Because these near-zero baselines are statistically unreliable, BenchmarkDotNet reports ? for all .NET 10.0 ratios in this section. The absolute nanosecond values remain meaningful — generated instruments cost ~0.35–0.37 ns each. On .NET 8.0 and .NET 9.0, all calls are in the same sub-nanosecond range with no allocations.

Method Runtime Mean Ratio Allocated
Manual: auto-counter (0 tags) .NET 10.0 0.006 ns ? -
Generated: auto-counter (0 tags) .NET 10.0 0.37 ns ? -
Manual: auto-counter (1 tag) .NET 10.0 0.17 ns ? -
Generated: auto-counter (1 tag) .NET 10.0 0.37 ns ? -
Manual: up-down counter .NET 10.0 0.003 ns ? -
Generated: up-down counter .NET 10.0 0.35 ns ? -
Manual: histogram (0 tags) .NET 10.0 0.006 ns ? -
Generated: histogram (0 tags) .NET 10.0 0.36 ns ? -
Manual: histogram (1 tag) .NET 10.0 0.17 ns ? -
Generated: histogram (1 tag) .NET 10.0 0.36 ns ? -
Manual: auto-counter (0 tags) .NET 8.0 0.37 ns 1.00 -
Generated: auto-counter (0 tags) .NET 8.0 0.37 ns 0.99 -
Manual: auto-counter (1 tag) .NET 8.0 0.37 ns 1.01 -
Generated: auto-counter (1 tag) .NET 8.0 0.36 ns 0.98 -
Manual: up-down counter .NET 8.0 0.36 ns 0.97 -
Generated: up-down counter .NET 8.0 0.54 ns 1.48 -
Manual: histogram (0 tags) .NET 8.0 0.37 ns 1.00 -
Generated: histogram (0 tags) .NET 8.0 0.38 ns 1.03 -
Manual: histogram (1 tag) .NET 8.0 0.36 ns 0.98 -
Generated: histogram (1 tag) .NET 8.0 0.55 ns 1.49 -
Manual: auto-counter (0 tags) .NET 9.0 0.37 ns 1.00 -
Generated: auto-counter (0 tags) .NET 9.0 0.18 ns 0.49 -
Manual: auto-counter (1 tag) .NET 9.0 0.36 ns 1.00 -
Generated: auto-counter (1 tag) .NET 9.0 0.36 ns 0.98 -
Manual: up-down counter .NET 9.0 0.35 ns 0.95 -
Generated: up-down counter .NET 9.0 0.18 ns 0.50 -
Manual: histogram (0 tags) .NET 9.0 0.18 ns 0.50 -
Generated: histogram (0 tags) .NET 9.0 0.17 ns 0.48 -
Manual: histogram (1 tag) .NET 9.0 0.37 ns 1.03 -
Generated: histogram (1 tag) .NET 9.0 0.19 ns 0.52 -

Interpretation: On .NET 10.0, generated instruments measure ~0.35-0.37 ns per call — the manual baselines are near-zero noise because the JIT eliminates unlistened calls, so no meaningful ratio can be computed. On .NET 8.0 and .NET 9.0, generated and manual instruments are within ~25% of each other, with 0 allocations across all runtimes and instrument types.


Observable Instruments

ObservableCounter, ObservableGauge, and ObservableUpDownCounter are not benchmarked because they have no per-operation hot path to compare.

These instruments register a callback once at construction time (via meter.CreateObservableCounter(name, callback)) and are polled by the metrics collection pipeline. The source generator produces the identical CreateObservable* call that you would write by hand — there is no wrapper layer on the measurement path. Benchmarking the one-time registration call would not reflect production performance.


Raw Results

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

Clone this wiki locally