Skip to content

Memoize tag-based Scope/Counter/Gauge/Timer lookups - #179

Open
th0114nd wants to merge 2 commits into
masterfrom
worktree-gostats-taghash
Open

Memoize tag-based Scope/Counter/Gauge/Timer lookups#179
th0114nd wants to merge 2 commits into
masterfrom
worktree-gostats-taghash

Conversation

@th0114nd

@th0114nd th0114nd commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Profiling of a downstream service showed gostats' own scope/tag/counter key
computation (joinScopes, TagSet.MergeTags, TagSet.Serialize/
SerializeTags) accounting for a large share of allocations - even on calls
that are guaranteed cache hits against gostats' own metric registry. The
underlying Counter/Gauge/Timer object is already deduplicated by name+tags,
but the lookup key for that dedup is rebuilt from scratch on every single
call: join the scope path, merge tag sets, sort, serialize to a string.

This adds a scopeCache to subScope and statStore that memoizes
Scope/ScopeWithTags and every New*WithTags call on a 128-bit,
order-independent hash of (name, tags). On a cache hit, the call returns
the previously-created handle directly - no join, no merge, no sort, no
serialize.

  • Cache entries are never evicted: gostats already retains one
    Counter/Gauge/Timer per unique serialized name+tags forever, so this
    cache's growth is bounded the same way - it isn't a new source of
    unbounded memory growth.
  • The cache key is a 128-bit hash with no stored verification copy. At
    128 bits, the birthday-bound collision probability (n²/2^129) stays
    negligible for any cardinality a real service would produce, so a hash
    match is trusted directly. An earlier revision stored the caller's
    original tags map alongside each entry to confirm a 64-bit hash hit before
    trusting it - that copy cost ~200+ bytes per entry (several times the size
    of the object it was guarding) and is no longer needed at 128 bits.
  • NewPerInstanceCounter/Gauge/Timer/MilliTimer are deliberately left
    uncached for now - a candidate follow-up once verified safe.

Benchmark results

Synthetic trace simulating a service that holds a long-lived root Scope
but re-derives nested scopes and tagged metric handles inline on every call
(see the benchmark code in stacked PR #180). Compared via benchstat,
main vs. this change, n=6:

Benchmark time (main → this PR) allocs/op (main → this PR) B/op (main → this PR)
SyntheticTrace 10.4µs → 4.3µs (-58.8%) 48 → 18 (-62.5%) 4.94Ki → 2.95Ki (-40.2%)
SyntheticTraceParallel 3.7µs → 1.8µs (-50.8%) 48 → 18 (-62.5%) 4.94Ki → 2.95Ki (-40.2%)
SyntheticTracePooled* 9.9µs → 3.5µs (-65.0%) 30 → 0 (-100%) 1.98Ki → 0 (-100%)
SyntheticTracePooledParallel* 2.2µs → 1.0µs (-57.1%) 30 → 0 (-100%) 1.98Ki → 0 (-100%)
SyntheticTracePerInstance (control, uncached path) ~0% (noise) unchanged unchanged

* "Pooled" reuses the caller's map[string]string tag sets across calls
instead of allocating a fresh literal each time, isolating gostats' own
internal allocations from caller-side map-literal cost - this is where the
change goes all the way to 0 allocations, 0 bytes per call on a repeat
(scope, name, tags) lookup. The PerInstance control - the one call path
intentionally left uncached - shows no change, confirming the improvement
is attributable to the cached paths and not some unrelated speedup.

All results statistically significant (p=0.002, benchstat).

Test plan

  • go build ./..., go vet ./..., gofmt -l . clean
  • go test ./... -race -count=1 - 133 tests passing
  • New tests: internal/tags/tags_test.go (hash order-independence, differing
    inputs, empty-key/value handling), stats_cache_test.go (cache-hit
    identity across distinct-but-equal tag maps, cache-miss on different tags,
    flush-output correctness unchanged, race-checked concurrent access)
  • Benchmark methodology and results reproduced in stacked PR Add synthetic-trace allocation benchmark for tag caching #180

th0114nd and others added 2 commits July 29, 2026 14:08
subScope.NewCounterWithTags (and its Gauge/Timer/MilliTimer/ScopeWithTags
siblings), plus their statStore-root equivalents, re-derive the full
joinScopes/MergeTags/Serialize (or SerializeTags) path on every single call,
even when the result is guaranteed to be the same object gostats already
handed back last time - a caller that holds a Scope and re-emits the same
name+tags repeatedly pays this cost every time.

Add a scopeCache to subScope and statStore that memoizes each of these calls
keyed on an order-independent hash of (name, tags) (internal/tags.HashNameTags),
confirming the stored name+tags on a hit (internal/tags.TagsEqual) before
trusting it, so a hash collision falls through to a recompute instead of
returning the wrong object. The underlying counters/gauges/timers sync.Maps
already retain one object per unique serialized name+tags forever, so this
cache's unbounded growth is no different from what gostats already commits to.

PerInstance* methods are left uncached, same as their existing MergeTags
counterparts weren't touched here either - only the WithTags variants that
were previously rebuilding the full key on every call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior design stored the caller's original name+tags alongside each
cached value so a hit could be confirmed before being trusted, guarding
against a 64-bit hash collision. Since scopeCache never evicts, that
verification copy - a map[string]string, which costs on the order of
200+ bytes even for one or two entries, several times the size of the
Counter/Gauge/Timer object it's guarding - is retained forever for every
distinct metric a process ever emits. For a long-running service with real
tag cardinality, that's a meaningful, avoidable memory overhead.

Widen the hash to 128 bits (two independent FNV-1a passes over name+tags,
computed together in a single pass over the tags map) and drop the stored
copy entirely. At 128 bits the birthday-bound collision probability
(n^2/2^129) stays negligible for any cardinality a real service would
produce, so a hash match can be trusted directly - internal/tags.TagsEqual
and the single-hash HashNameTags are removed as no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@th0114nd th0114nd changed the title worktree gostats taghash Memoize tag-based Scope/Counter/Gauge/Timer lookups Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant