Skip to content

Allocation-free tags on the metrics hot path - #645

Merged
jodydonetti merged 4 commits into
ZiggyCreatures:mainfrom
GordeySt:perf/metrics-tags-no-alloc
Sep 21, 2026
Merged

jodydonetti merged 4 commits into
ZiggyCreatures:mainfrom
GordeySt:perf/metrics-tags-no-alloc

Conversation

@GordeySt

Copy link
Copy Markdown
Contributor

Hi @jodydonetti!

While reading through the diagnostics code I noticed that every instrumented operation allocates its tags array from scratch, even though the only tag actually emitted is the cache name, which is fixed for the lifetime of a cache instance.

I measured this on a memory hit with a MeterListener attached and it adds up to 320 bytes per operation, which decomposes exactly as 2 x 40 B (no extra tags) + 2 x 120 B (extra tag: two arrays plus one boxed bool).

And most importantly, without metrics the memory read path is completely allocation-free. Attaching a listener is what turns a 0 B operation into a 320 B one. It is also silent, the callers only pay it once they turn metrics on and are least likely to be looking for a regression in their own cache layer.

So basically my suggested changes here are:

  1. GetCommonTags(...) -> KeyValuePair[] becomes GetCommonTag(...) -> KeyValuePair and AddWithCommonTags splits into a no-extra-tag and one-extra-tag overload.
  2. A small Tags.Tag(name, bool) helper returns a pre-boxed true / false, which removes the remaining 24 B per extra-tag call site. None of the hot call sites needed editing, since overload resolution picks the non-params form on its own. The only call site that changed is OnRemoveByTag, which was building its array explicitly and becomes a straight if/else.

Benchmarks:

BenchmarkDotNet 0.15.8, .NET 10.0.7, X64 RyuJIT x86-64-v3, in-process emit toolchain, Windows 10.0.19045.

Before

Op No listener Listener attached Metrics overhead
TryGet 132.0 ns / 0 B 226.2 ns / 320 B +94 ns, +320 B
GetOrDefault 131.4 ns / 0 B 223.2 ns / 320 B +92 ns, +320 B
GetOrSet (hit) 165.8 ns / 88 B 261.4 ns / 408 B +96 ns, +320 B
GetOrSetAsync (hit) 242.6 ns / 160 B 347.2 ns / 480 B +105 ns, +320 B
Set 210.6 ns / 184 B 242.7 ns / 264 B +32 ns, +80 B

After

Op No listener Listener attached Metrics overhead
TryGet 138.5 ns / 0 B 157.5 ns / 0 B +19 ns, +0 B
GetOrDefault 131.1 ns / 0 B 159.7 ns / 0 B +29 ns, +0 B
GetOrSet (hit) 164.2 ns / 88 B 195.0 ns / 88 B +31 ns, +0 B
GetOrSetAsync (hit) 243.1 ns / 160 B 281.1 ns / 160 B +38 ns, +0 B
Set 209.9 ns / 184 B 214.9 ns / 184 B +5 ns, +0 B

I added MetricsEndToEndBenchmark to the benchmarks project so these numbers are reproducible. Happy to drop it if you would rather keep the benchmark project focused on the comparison benchmarks.

The params array meant every call site allocated, in exchange for a capability nothing uses: of the 43 call sites, 30 pass no extra tags and 13 pass exactly one, none pass more. Replacing it with two explicit overloads keeps headroom rather than removing it: Counter<T>.Add accepts up to three individual tags, so a future second extra tag is still allocation-free via a three-line overload. Only past three tags would need an array (or other optimization approaches) again.

@jodydonetti

Copy link
Copy Markdown
Collaborator

Hi @GordeySt , thank you for this PR!

I'll take a look at it in the next few days and will report back to you, but I can already see it looks great.

@jodydonetti jodydonetti self-assigned this Sep 14, 2026
@jodydonetti jodydonetti added the enhancement New feature or request label Sep 14, 2026
@jodydonetti jodydonetti added this to the v2.9.0 milestone Sep 14, 2026

@jodydonetti jodydonetti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@jodydonetti
jodydonetti merged commit 447c31c into ZiggyCreatures:main Sep 21, 2026
@jodydonetti

Copy link
Copy Markdown
Collaborator

Hi all, I just publihsed v2.9.0 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants