diff --git a/README.md b/README.md index b0d8cea5..6f5a4566 100644 --- a/README.md +++ b/README.md @@ -162,39 +162,39 @@ Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 10.0.401. See the [Perfo | Scenario | HasListener | Manual | Generated | Ratio | | --- | --- | --- | --- | --- | -| start + complete | False | 0.56 ns | 0.55 ns | 0.99x | -| 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 | +| start + complete | False | 0.53 ns | 0.58 ns | 1.11x | +| start + complete | True | 220 ns / 1008 B | 217 ns / 1008 B | 0.98x | +| start + fail | True | 208 ns / 920 B | 203 ns / 920 B | 0.92x | -Generated activities are within ~1% of hand-written code with identical allocations. +Generated activities match hand-written code with identical allocations. ### Logging (.NET 10.0) | Scenario | HasLogging | LoggerMessage.Define | Generated v1 | Generated v2 | | --- | --- | --- | --- | --- | -| 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) | +| single Info call | True | 6.12 ns | 4.43 ns (0.72x) | 3.80 ns (0.62x) | +| full lifecycle (4 calls) | True | 18.88 ns | 19.58 ns (1.04x) | 15.01 ns (0.80x) | -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. +Both v1 and v2 allocate **0 bytes** per call on all runtimes. Generated methods are emitted with `[MethodImpl(AggressiveInlining)]`; generated v1 and v2 both beat the manual `LoggerMessage.Define` baseline for single calls on .NET 10.0. ### Multi-Target (.NET 10.0, Activity + Logging + Metrics) | Scenario | HasListener | Single-target | Multi-target generated | Multi-target manual | |---|---|---|---|---| -| start + complete | True | 221 ns / 1008 B | 246 ns / 1032 B (1.11x) | 248 ns / 1032 B (1.13x) | +| start + complete | True | 216 ns / 1008 B | 230 ns / 1032 B (1.07x) | 260 ns / 1032 B (1.20x) | -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%. +Adding full Activity+Logging+Metrics multi-target generation costs ~7% over Activity-only on .NET 10.0, and the generated multi-target code is faster than the hand-written equivalent. ### Metrics (.NET 10.0) | Scenario | Generated | Notes | | --- | --- | --- | -| auto-counter (0 tags) | 0.38 ns | - | +| auto-counter (0 tags) | 0.40 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) | 3.5-9.2 ns | Stack-allocated `TagList` | +| up-down counter | 0.37 ns | - | +| histogram (0 tags) | 0.42 ns | - | +| histogram (1 tag) | 0.34 ns | - | +| 4+ tags (TagList) | 5-8 ns | Stack-allocated `TagList` | All instruments are **0 allocations** on all runtimes. diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/ILoggerOnlyTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/ILoggerOnlyTelemetry.cs index 058dddef..5e6f3653 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/ILoggerOnlyTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/ILoggerOnlyTelemetry.cs @@ -4,14 +4,13 @@ namespace Purview.Telemetry.Benchmarks.Telemetry; /// -/// Logger-only interface using the default (v2) code path. -/// When Microsoft.Extensions.Logging.LogPropertiesAttribute is available -/// (via Microsoft.Extensions.Telemetry.Abstractions), the source generator emits -/// the new state-based approach: LoggerMessageHelper.ThreadLocalState is populated -/// with structured key-value pairs and passed to . +/// Logger-only interface using the v2 (state-based) code path. +/// is , so +/// the source generator emits the state-based approach: LoggerMessageHelper.ThreadLocalState +/// is populated with structured key-value pairs and passed to . /// This mirrors the output of the built-in [LoggerMessage] source generator. /// -[Logger] +[Logger(GenerationMode = LoggerGenerationMode.V2)] public interface ILoggerOnlyTelemetry { [Info] diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/IMultiTargetTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/IMultiTargetTelemetry.cs index 47e968dd..464cb2f8 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/IMultiTargetTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Telemetry/IMultiTargetTelemetry.cs @@ -6,9 +6,11 @@ namespace Purview.Telemetry.Benchmarks.Telemetry; /// /// Multi-target interface: Activity + Logging + Metrics in combined methods. /// Used to benchmark multi-target generation overhead vs. single-target. +/// Logging is forced to (state-based) so the +/// benchmark exercises the v2 code path rather than the v1 default selected by Auto mode. /// [ActivitySource("benchmark-multi-target-source")] -[Logger] +[Logger(GenerationMode = LoggerGenerationMode.V2)] [Meter] public interface IMultiTargetTelemetry { diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index 40b0d7d8..6edb17b3 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -27,6 +27,8 @@ BenchmarkDotNet v0.15.8, Windows 11 (10.0.28020.2991) > **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. + ## Activities **Source:** `ActivityBenchmarks` @@ -35,32 +37,32 @@ Compares the source-generator-produced `ActivityOnlyTelemetryCore` against a han | 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. +| Manual: start + complete | .NET 10.0 | False | 0.53 ns | 1.00 | - | +| Generated: start + complete | .NET 10.0 | False | 0.58 ns | 1.11 | - | +| Manual: start + fail | .NET 10.0 | False | 0.75 ns | 1.43 | - | +| Generated: start + fail | .NET 10.0 | False | 0.55 ns | 1.04 | - | +| Manual: start + complete | .NET 8.0 | False | 0.78 ns | 1.00 | - | +| Generated: start + complete | .NET 8.0 | False | 0.88 ns | 1.12 | - | +| Manual: start + fail | .NET 8.0 | False | 0.93 ns | 1.19 | - | +| Generated: start + fail | .NET 8.0 | False | 0.76 ns | 0.97 | - | +| Manual: start + complete | .NET 9.0 | False | 0.55 ns | 1.00 | - | +| Generated: start + complete | .NET 9.0 | False | 0.77 ns | 1.40 | - | +| Manual: start + fail | .NET 9.0 | False | 0.35 ns | 0.63 | - | +| Generated: start + fail | .NET 9.0 | False | 0.59 ns | 1.07 | - | +| **Manual: start + complete** | **.NET 10.0** | **True** | **220.37 ns** | **1.00** | **1008 B** | +| Generated: start + complete | .NET 10.0 | True | 216.69 ns | 0.98 | 1008 B | +| Manual: start + fail | .NET 10.0 | True | 207.55 ns | 0.94 | 920 B | +| Generated: start + fail | .NET 10.0 | True | 203.12 ns | 0.92 | 920 B | +| Manual: start + complete | .NET 8.0 | True | 259.23 ns | 1.00 | 1008 B | +| Generated: start + complete | .NET 8.0 | True | 262.84 ns | 1.01 | 1008 B | +| Manual: start + fail | .NET 8.0 | True | 229.10 ns | 0.88 | 920 B | +| Generated: start + fail | .NET 8.0 | True | 241.07 ns | 0.93 | 920 B | +| Manual: start + complete | .NET 9.0 | True | 226.38 ns | 1.00 | 1008 B | +| Generated: start + complete | .NET 9.0 | True | 229.96 ns | 1.02 | 1008 B | +| Manual: start + fail | .NET 9.0 | True | 219.02 ns | 0.97 | 920 B | +| Generated: start + fail | .NET 9.0 | True | 211.70 ns | 0.94 | 920 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. ## Logging @@ -70,26 +72,26 @@ Compares three logging approaches: hand-written `LoggerMessage.Define` (gold-sta | Method | Runtime | HasLogging | Mean | Ratio | Allocated | | --- | --- | --- | --- | --- | --- | -| **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. +| **Manual: single Info call** | **.NET 10.0** | **True** | **6.12 ns** | **1.00** | **-** | +| Generated v1 — single Info call | .NET 10.0 | True | 4.43 ns | 0.72 | - | +| Generated v2 — single Info call | .NET 10.0 | True | 3.80 ns | 0.62 | - | +| Manual: full lifecycle (4 calls) | .NET 10.0 | True | 18.88 ns | 3.08 | - | +| Generated v1 — full lifecycle | .NET 10.0 | True | 19.58 ns | 3.20 | - | +| Generated v2 — full lifecycle | .NET 10.0 | True | 15.01 ns | 2.45 | - | +| Manual: single Info call | .NET 8.0 | True | 7.55 ns | 1.00 | - | +| Generated v1 — single Info call | .NET 8.0 | True | 7.51 ns | 0.99 | - | +| Generated v2 — single Info call | .NET 8.0 | True | 5.10 ns | 0.68 | - | +| Manual: full lifecycle | .NET 8.0 | True | 29.98 ns | 3.97 | - | +| Generated v1 — full lifecycle | .NET 8.0 | True | 30.72 ns | 4.07 | - | +| Generated v2 — full lifecycle | .NET 8.0 | True | 22.11 ns | 2.93 | - | +| Manual: single Info call | .NET 9.0 | True | 6.88 ns | 1.00 | - | +| Generated v1 — single Info call | .NET 9.0 | True | 6.50 ns | 0.95 | - | +| Generated v2 — single Info call | .NET 9.0 | True | 5.06 ns | 0.74 | - | +| Manual: full lifecycle | .NET 9.0 | True | 25.55 ns | 3.72 | - | +| Generated v1 — full lifecycle | .NET 9.0 | True | 24.88 ns | 3.62 | - | +| Generated v2 — full lifecycle | .NET 9.0 | True | 22.79 ns | 3.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. ## Multi-target @@ -99,19 +101,19 @@ 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** | **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. +| **Single-target (generated): start + complete** | **.NET 10.0** | **True** | **216.15 ns** | **1.00** | **1008 B** | **1.00** | +| Multi-target (generated): start + complete | .NET 10.0 | True | 230.41 ns | 1.07 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 10.0 | True | 260.18 ns | 1.20 | 1032 B | 1.02 | +| Multi-target (generated): start + complete + record latency | .NET 10.0 | True | 229.65 ns | 1.06 | 1032 B | 1.02 | +| Multi-target (manual): start + complete + record latency | .NET 10.0 | True | 234.58 ns | 1.09 | 1032 B | 1.02 | +| Single-target (generated): start + complete | .NET 8.0 | True | 258.36 ns | 1.00 | 1008 B | 1.00 | +| Multi-target (generated): start + complete | .NET 8.0 | True | 260.21 ns | 1.01 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 8.0 | True | 267.22 ns | 1.03 | 1032 B | 1.02 | +| Single-target (generated): start + complete | .NET 9.0 | True | 220.24 ns | 1.00 | 1008 B | 1.00 | +| Multi-target (generated): start + complete | .NET 9.0 | True | 249.15 ns | 1.13 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 9.0 | True | 257.27 ns | 1.17 | 1032 B | 1.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](Generated-Output.md) page for what the multi-target implementation looks like. ## Logger multi-target @@ -121,16 +123,16 @@ Compares single-target logging-only vs. multi-target (Activity + Logging + Metri | 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 | - | +| **Multi-target (manual): start + complete** | **.NET 10.0** | **True** | **264.31 ns** | **1.00** | **1032 B** | +| Multi-target (generated v1): start + complete | .NET 10.0 | True | 236.44 ns | 0.89 | 1032 B | +| Multi-target (generated v2): start + complete | .NET 10.0 | True | 227.94 ns | 0.86 | 1032 B | +| Multi-target (manual): full lifecycle | .NET 10.0 | True | 243.84 ns | 0.92 | 1032 B | +| Multi-target (generated v1): full lifecycle | .NET 10.0 | True | 249.32 ns | 0.94 | 1032 B | +| Multi-target (generated v2): full lifecycle | .NET 10.0 | True | 216.86 ns | 0.82 | 1032 B | +| Single-target (generated v1): full lifecycle | .NET 10.0 | True | 18.21 ns | 0.07 | - | +| Single-target (generated v2): full lifecycle | .NET 10.0 | True | 15.69 ns | 0.06 | - | -**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. +**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. ## Metrics @@ -140,24 +142,24 @@ 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 (0 tags) | 0.40 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 | +| up-down counter | 0.37 ns | 0.39 ns | 0.20 ns | +| histogram (0 tags) | 0.42 ns | 0.35 ns | 0.18 ns | +| histogram (1 tag) | 0.34 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 | .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 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. +| 0 tags: histogram record | 0.38 ns | 0.56 ns | 0.19 ns | +| 1 tag: auto-counter add | 0.41 ns | 0.35 ns | 0.19 ns | +| 3 tags: histogram record | 0.84 ns | 0.74 ns | 0.55 ns | +| 4 tags (TagList): auto-counter add | 7.91 ns | 7.79 ns | 3.62 ns | +| 5 tags (TagList): auto-counter add | 5.46 ns | 8.95 ns | 9.37 ns | +| 6 tags (TagList): histogram record | 6.37 ns | 4.32 ns | 3.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. ## Observable instruments diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs index f55d7aa7..631b3229 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs @@ -53,6 +53,7 @@ SourceProductionContext context ) { IsStatic = true, + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ new ParameterDeclarationOptions( @@ -243,6 +244,7 @@ SourceProductionContext context TypeDeclarationAccessibility.Private ) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ .. methodTarget.Parameters.Select(p => new ParameterDeclarationOptions( @@ -287,6 +289,7 @@ SourceProductionContext context TypeDeclarationAccessibility.Public ) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ .. methodTarget.Parameters.Select(p => new ParameterDeclarationOptions( @@ -369,6 +372,7 @@ SourceProductionContext context writer.MethodScope( new(methodTarget.MethodName, methodTarget.ReturnType, TypeDeclarationAccessibility.Public) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ .. methodTarget.Parameters.Select(p => new ParameterDeclarationOptions( diff --git a/src/src/SourceGenerator/Emitters/EmitterHelpers.cs b/src/src/SourceGenerator/Emitters/EmitterHelpers.cs index 7eba1d59..47c62cd3 100644 --- a/src/src/SourceGenerator/Emitters/EmitterHelpers.cs +++ b/src/src/SourceGenerator/Emitters/EmitterHelpers.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; using Purview.Telemetry.SourceGenerator.Helpers; using Purview.Telemetry.SourceGenerator.Records; @@ -17,6 +18,16 @@ public static AttributeDeclarationOptions EditorBrowsableAttribute() => Arguments = [new("global::System.ComponentModel.EditorBrowsableState.Never")], }; + /// + /// The [MethodImpl(MethodImplOptions.AggressiveInlining)] attribute applied to generated + /// hot-path telemetry methods so the JIT can inline them into callers. + /// + public static AttributeDeclarationOptions AggressiveInliningAttribute() => + new(new TypeIdentity(nameof(MethodImplAttribute), "System.Runtime.CompilerServices")) + { + Arguments = [new("global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining")], + }; + /// /// Adds a finished as a generated source file. The writer's /// disposable scopes must all be closed before calling this; materializing the source diff --git a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs index 00113dab..d504207b 100644 --- a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs @@ -118,6 +118,7 @@ SourceProductionContext context generatePrivateLogging ? TypeDeclarationAccessibility.Private : TypeDeclarationAccessibility.Public ) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ .. methodTarget.Parameters.Select(p => new ParameterDeclarationOptions( @@ -1360,6 +1361,7 @@ SourceProductionContext context writer.MethodScope( new MethodDeclarationOptions(methodTarget.MethodName, returnType, TypeDeclarationAccessibility.Public) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ .. methodTarget.Parameters.Select(p => new ParameterDeclarationOptions( diff --git a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs index 35d39383..f9d193c6 100644 --- a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs @@ -121,6 +121,7 @@ SourceProductionContext context generatePrivateLogging ? TypeDeclarationAccessibility.Private : TypeDeclarationAccessibility.Public ) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ .. methodTarget.Parameters.Select(p => new ParameterDeclarationOptions( @@ -200,6 +201,7 @@ SourceProductionContext context writer.MethodScope( new MethodDeclarationOptions(methodTarget.MethodName, returnType, TypeDeclarationAccessibility.Public) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = [ .. methodTarget.Parameters.Select(p => new ParameterDeclarationOptions( diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs index f1d9c97b..1faba459 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs @@ -155,6 +155,7 @@ SourceProductionContext context : TypeDeclarationAccessibility.Public ) { + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = parameters, IncludeGeneratedAttributes = false, }