Allocation-free tags on the metrics hot path - #645
Merged
jodydonetti merged 4 commits intoSep 21, 2026
Merged
Conversation
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. |
Collaborator
|
Hi all, I just publihsed |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
MeterListenerattached 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:
GetCommonTags(...) -> KeyValuePair[]becomesGetCommonTag(...) -> KeyValuePairandAddWithCommonTagssplits into a no-extra-tag and one-extra-tag overload.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 isOnRemoveByTag, 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
TryGetGetOrDefaultGetOrSet(hit)GetOrSetAsync(hit)SetAfter
TryGetGetOrDefaultGetOrSet(hit)GetOrSetAsync(hit)SetI added
MetricsEndToEndBenchmarkto 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>.Addaccepts 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.