From 670c5c8ed2d1f6d1269ac34a6eabd24415121b02 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 16 Sep 2026 20:28:28 +0100 Subject: [PATCH] docs: refresh performance benchmarks --- README.md | 31 ++++--- docs/wiki/Performance.md | 180 +++++++++++++++++++++++---------------- 2 files changed, 121 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index a1b5bb33..b0d8cea5 100644 --- a/README.md +++ b/README.md @@ -156,48 +156,47 @@ The [.NET Aspire Sample](https://github.com/purview-dev/telemetry-sourcegenerato ## Performance -Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 10.0.201. See the [Performance](https://github.com/purview-dev/telemetry-sourcegenerator/wiki/Performance) wiki page for full cross-runtime results. +Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 10.0.401. See the [Performance](https://github.com/purview-dev/telemetry-sourcegenerator/wiki/Performance) wiki page for full cross-runtime results. ### Activities (.NET 10.0) | Scenario | HasListener | Manual | Generated | Ratio | | --- | --- | --- | --- | --- | | start + complete | False | 0.56 ns | 0.55 ns | 0.99x | -| start + complete | True | 218 ns / 1008 B | 204 ns / 1008 B | 0.94x | -| start + fail | True | 198 ns / 920 B | 189 ns / 920 B | 0.87x | +| start + complete | True | 212 ns / 1008 B | 213 ns / 1008 B | 1.01x | +| start + fail | True | 206 ns / 920 B | 208 ns / 920 B | 0.98x | -Generated activities match or outperform hand-written code with identical allocations. +Generated activities are within ~1% of hand-written code with identical allocations. ### Logging (.NET 10.0) | Scenario | HasLogging | LoggerMessage.Define | Generated v1 | Generated v2 | | --- | --- | --- | --- | --- | -| single Info call | False | 0.21 ns | 0.18 ns | 0.21 ns | -| single Info call | True | 4.29 ns | 4.24 ns (0.99x) | 4.20 ns (0.98x) | -| full lifecycle (4 calls) | True | 17.73 ns | 19.52 ns (1.10x) | 18.81 ns (1.06x) | +| single Info call | True | 4.59 ns | 7.35 ns (1.60x) | 4.57 ns (1.00x) | +| full lifecycle (4 calls) | True | 16.77 ns | 18.76 ns (1.12x) | 18.58 ns (1.11x) | -Both v1 and v2 allocate **0 bytes** per call on all runtimes. Both generated variants are within ~2% of the manual baseline for single calls; full lifecycle cost is within ~10%. +Both v1 and v2 allocate **0 bytes** per call on all runtimes. Generated v2 (state-based) matches the manual baseline for single calls on .NET 10.0; v1 (LoggerMessage.Define pattern) is slower for single calls on this runtime. ### Multi-Target (.NET 10.0, Activity + Logging + Metrics) | Scenario | HasListener | Single-target | Multi-target generated | Multi-target manual | |---|---|---|---|---| -| start + complete | True | 203 ns / 1008 B | 230 ns / 1032 B (1.13x) | 233 ns / 1032 B (1.15x) | +| start + complete | True | 221 ns / 1008 B | 246 ns / 1032 B (1.11x) | 248 ns / 1032 B (1.13x) | -Adding full Activity+Logging+Metrics multi-target generation costs ~13% over Activity-only on .NET 10.0 — matching hand-written multi-target code within 2%. +Adding full Activity+Logging+Metrics multi-target generation costs ~11% over Activity-only on .NET 10.0 — matching hand-written multi-target code within ~1%. ### Metrics (.NET 10.0) | Scenario | Generated | Notes | | --- | --- | --- | -| auto-counter (0 tags) | 0.37 ns | - | -| auto-counter (1 tag) | 0.37 ns | - | -| up-down counter | 0.35 ns | - | -| histogram (0 tags) | 0.36 ns | - | +| auto-counter (0 tags) | 0.38 ns | - | +| auto-counter (1 tag) | 0.35 ns | - | +| up-down counter | 0.39 ns | - | +| histogram (0 tags) | 0.37 ns | - | | histogram (1 tag) | 0.36 ns | - | -| 4+ tags (TagList) | 4-7 ns | Stack-allocated `TagList` | +| 4+ tags (TagList) | 3.5-9.2 ns | Stack-allocated `TagList` | -All instruments are **0 allocations**. Manual baselines are JIT-eliminated on .NET 10.0 (no active listener), so absolute times are shown. On .NET 8/9, generated and manual are within ~25%. +All instruments are **0 allocations** on all runtimes. ## v4 Breaking Changes diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index 9828e359..40b0d7d8 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -12,20 +12,20 @@ dotnet run --project benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.B Results are written to `BenchmarkDotNet.Artifacts/results/` as `*-report-github.md`, `*.csv`, and `*.html`. > [!IMPORTANT] -> BenchmarkDotNet numbers are only meaningful from **Release** builds. The tables below reflect the latest published run. Regenerate them with `just benchmark-docs` before relying on them for a specific machine/SDK combination. +> BenchmarkDotNet numbers are only meaningful from **Release** builds. Run the suite fresh (`just benchmark-docs`) before relying on the tables below for a specific machine/SDK combination. ## Environment ``` -BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8117/25H2/2025Update) +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.201 - .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 +.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:** .NET Framework targets did not produce results in this run and are excluded from the tables below. +> **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. ## Activities @@ -33,34 +33,34 @@ BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8117/25H2/2025Update) 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. +| Method | Runtime | HasListener | Mean | Ratio | Allocated | +| --- | --- | --- | --- | --- | --- | +| Manual: start + complete | .NET 10.0 | False | 0.56 ns | 1.00 | - | +| Generated: start + complete | .NET 10.0 | False | 0.55 ns | 0.99 | - | +| Manual: start + fail | .NET 10.0 | False | 0.73 ns | 1.32 | - | +| Generated: start + fail | .NET 10.0 | False | 0.56 ns | 1.00 | - | +| Manual: start + complete | .NET 8.0 | False | 0.72 ns | 1.00 | - | +| Generated: start + complete | .NET 8.0 | False | 0.94 ns | 1.31 | - | +| Manual: start + fail | .NET 8.0 | False | 0.92 ns | 1.29 | - | +| Generated: start + fail | .NET 8.0 | False | 0.72 ns | 1.00 | - | +| Manual: start + complete | .NET 9.0 | False | 0.57 ns | 1.00 | - | +| Generated: start + complete | .NET 9.0 | False | 0.74 ns | 1.29 | - | +| Manual: start + fail | .NET 9.0 | False | 0.38 ns | 0.66 | - | +| Generated: start + fail | .NET 9.0 | False | 0.91 ns | 1.60 | - | +| **Manual: start + complete** | **.NET 10.0** | **True** | **211.68 ns** | **1.00** | **1008 B** | +| Generated: start + complete | .NET 10.0 | True | 213.33 ns | 1.01 | 1008 B | +| Manual: start + fail | .NET 10.0 | True | 205.59 ns | 0.97 | 920 B | +| Generated: start + fail | .NET 10.0 | True | 207.58 ns | 0.98 | 920 B | +| Manual: start + complete | .NET 8.0 | True | 244.55 ns | 1.00 | 1008 B | +| Generated: start + complete | .NET 8.0 | True | 257.92 ns | 1.05 | 1008 B | +| Manual: start + fail | .NET 8.0 | True | 235.83 ns | 0.96 | 920 B | +| Generated: start + fail | .NET 8.0 | True | 234.55 ns | 0.96 | 920 B | +| Manual: start + complete | .NET 9.0 | True | 221.63 ns | 1.00 | 1008 B | +| Generated: start + complete | .NET 9.0 | True | 221.03 ns | 1.00 | 1008 B | +| Manual: start + fail | .NET 9.0 | True | 206.32 ns | 0.93 | 920 B | +| Generated: start + fail | .NET 9.0 | True | 203.48 ns | 0.92 | 920 B | + +**Interpretation:** Generated activities are within ~5% of hand-written code 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. ## Logging @@ -70,29 +70,26 @@ Compares three logging approaches: hand-written `LoggerMessage.Define` (gold-sta | 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 — 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). +| **Manual: single Info call** | **.NET 10.0** | **True** | **4.59 ns** | **1.00** | **-** | +| Generated v1 — single Info call | .NET 10.0 | True | 7.35 ns | 1.60 | - | +| Generated v2 — single Info call | .NET 10.0 | True | 4.57 ns | 1.00 | - | +| Manual: full lifecycle (4 calls) | .NET 10.0 | True | 16.77 ns | 3.66 | - | +| Generated v1 — full lifecycle | .NET 10.0 | True | 18.76 ns | 4.09 | - | +| Generated v2 — full lifecycle | .NET 10.0 | True | 18.58 ns | 4.05 | - | +| Manual: single Info call | .NET 8.0 | True | 7.40 ns | 1.00 | - | +| Generated v1 — single Info call | .NET 8.0 | True | 7.61 ns | 1.03 | - | +| Generated v2 — single Info call | .NET 8.0 | True | 7.38 ns | 1.00 | - | +| Manual: full lifecycle | .NET 8.0 | True | 29.68 ns | 4.01 | - | +| Generated v1 — full lifecycle | .NET 8.0 | True | 30.96 ns | 4.18 | - | +| Generated v2 — full lifecycle | .NET 8.0 | True | 30.36 ns | 4.10 | - | +| Manual: single Info call | .NET 9.0 | True | 8.19 ns | 1.00 | - | +| Generated v1 — single Info call | .NET 9.0 | True | 7.24 ns | 0.88 | - | +| Generated v2 — single Info call | .NET 9.0 | True | 6.95 ns | 0.85 | - | +| Manual: full lifecycle | .NET 9.0 | True | 25.42 ns | 3.10 | - | +| Generated v1 — full lifecycle | .NET 9.0 | True | 25.99 ns | 3.17 | - | +| Generated v2 — full lifecycle | .NET 9.0 | True | 26.61 ns | 3.25 | - | + +**Interpretation:** Generated v1 and v2 both allocate **zero bytes** per call across all runtimes. On .NET 10.0, v2 (state-based) matches hand-written `LoggerMessage.Define` (4.57 vs 4.59 ns single call; 18.58 vs 16.77 ns full lifecycle); on .NET 9.0 the generated variants are faster than the manual baseline. The full-lifecycle cost is dominated by the four `IsEnabled` checks and message-formatting paths that all three implementations share. ## Multi-target @@ -102,30 +99,65 @@ Measures the overhead of emitting Activity + Logging + Metrics from a single met | Method | Runtime | HasListener | Mean | Ratio | Allocated | Alloc Ratio | | --- | --- | --- | --- | --- | --- | --- | -| **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 10.0** | **True** | **220.82 ns** | **1.00** | **1008 B** | **1.00** | +| Multi-target (generated): start + complete | .NET 10.0 | True | 245.55 ns | 1.11 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 10.0 | True | 248.35 ns | 1.13 | 1032 B | 1.02 | +| Multi-target (generated): start + complete + record latency | .NET 10.0 | True | 227.38 ns | 1.03 | 1032 B | 1.02 | +| Multi-target (manual): start + complete + record latency | .NET 10.0 | True | 213.89 ns | 0.97 | 1032 B | 1.02 | +| Single-target (generated): start + complete | .NET 8.0 | True | 242.55 ns | 1.00 | 1008 B | 1.00 | +| Multi-target (generated): start + complete | .NET 8.0 | True | 262.91 ns | 1.08 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 8.0 | True | 273.61 ns | 1.13 | 1032 B | 1.02 | +| Single-target (generated): start + complete | .NET 9.0 | True | 218.89 ns | 1.00 | 1008 B | 1.00 | +| Multi-target (generated): start + complete | .NET 9.0 | True | 242.07 ns | 1.11 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 9.0 | True | 233.33 ns | 1.07 | 1032 B | 1.02 | + +**Interpretation:** When an Activity listener is active (production path), multi-target generation adds ~11% 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 matches hand-written multi-target code within ~1%. See the [Generated Output](Generated-Output.md) page for what the multi-target implementation looks like. + +## Logger multi-target + +**Source:** `LoggerMultiTargetBenchmarks` -**Interpretation:** When an Activity listener is active (production path), multi-target generation adds ~13% overhead over single-target Activity-only on .NET 10.0, matching hand-written multi-target code within ~2%. See the [Generated Output](Generated-Output.md) page for what the multi-target implementation looks like. +Compares single-target logging-only vs. multi-target (Activity + Logging + Metrics) generated code with a hand-written multi-target baseline. + +| Method | Runtime | HasListener | Mean | Ratio | Allocated | +| --- | --- | --- | --- | --- | --- | +| **Multi-target (manual): start + complete** | **.NET 10.0** | **True** | **272.34 ns** | **1.00** | **1032 B** | +| Multi-target (generated v1): start + complete | .NET 10.0 | True | 260.98 ns | 0.96 | 1032 B | +| Multi-target (generated v2): start + complete | .NET 10.0 | True | 264.47 ns | 0.97 | 1032 B | +| Multi-target (manual): full lifecycle | .NET 10.0 | True | 240.42 ns | 0.88 | 1032 B | +| Multi-target (generated v1): full lifecycle | .NET 10.0 | True | 241.50 ns | 0.89 | 1032 B | +| Multi-target (generated v2): full lifecycle | .NET 10.0 | True | 239.78 ns | 0.88 | 1032 B | +| Single-target (generated v1): full lifecycle | .NET 10.0 | True | 19.86 ns | 0.07 | - | +| Single-target (generated v2): full lifecycle | .NET 10.0 | True | 18.24 ns | 0.07 | - | + +**Interpretation:** Generated and manual multi-target implementations are within ~4% of each other on .NET 10.0, allocating identically (1032 B with a listener active). Logging-only accounts for a small fraction (~19 ns) of the multi-target cost (~260 ns); Activity creation dominates when a listener is active. ## Metrics **Source:** `MetricsBenchmarks` and `TagListBenchmarks` -All instruments are **0 allocations**. Manual baselines are JIT-eliminated on .NET 10.0 (no active listener), so absolute times are shown for generated instruments; on .NET 8/9 generated and manual are within ~25%. +All instruments are **0 allocations** on every runtime. + +| Scenario | Generated (.NET 10.0) | Generated (.NET 8.0) | Generated (.NET 9.0) | +| --- | --- | --- | --- | +| auto-counter (0 tags) | 0.38 ns | 0.55 ns | 0.21 ns | +| auto-counter (1 tag) | 0.35 ns | 0.56 ns | 0.30 ns | +| up-down counter | 0.39 ns | 0.39 ns | 0.20 ns | +| histogram (0 tags) | 0.37 ns | 0.35 ns | 0.18 ns | +| histogram (1 tag) | 0.36 ns | 0.36 ns | 0.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`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist) struct. -| Scenario | Generated | Notes | -| --- | --- | --- | -| auto-counter (0 tags) | 0.37 ns | - | -| auto-counter (1 tag) | 0.37 ns | - | -| up-down counter | 0.35 ns | - | -| histogram (0 tags) | 0.36 ns | - | -| histogram (1 tag) | 0.36 ns | - | -| 4+ tags (TagList) | 4–7 ns | Stack-allocated `TagList` | +| Scenario | .NET 10.0 | .NET 8.0 | .NET 9.0 | +| --- | --- | --- | --- | +| 0 tags: histogram record | 0.37 ns | 0.56 ns | 0.19 ns | +| 1 tag: auto-counter add | 0.37 ns | 0.36 ns | 0.19 ns | +| 3 tags: histogram record | 0.55 ns | 0.74 ns | 0.55 ns | +| 4 tags (TagList): auto-counter add | 3.52 ns | 7.79 ns | 3.62 ns | +| 5 tags (TagList): auto-counter add | 9.22 ns | 8.95 ns | 9.37 ns | +| 6 tags (TagList): histogram record | 5.57 ns | 4.32 ns | 3.99 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`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist) struct. The TagList path costs 12–19× more CPU than the inline path on .NET 10.0, but both remain in the single-digit-nanosecond range with zero allocations. +The TagList path costs 9–25× more CPU than the inline path on .NET 10.0, but both remain in the single-digit-nanosecond range with zero allocations. ## Observable instruments