Skip to content

Speed up the Counter.increment() hot path - #1277

Closed
michaelbraun wants to merge 3 commits into
Netflix:mainfrom
michaelbraun:perf/counter-increment-hot-path
Closed

Speed up the Counter.increment() hot path#1277
michaelbraun wants to merge 3 commits into
Netflix:mainfrom
michaelbraun:perf/counter-increment-hot-path

Conversation

@michaelbraun

@michaelbraun michaelbraun commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Removes a division from StepDouble/StepLong.rollCount on every update by caching the end of the current step interval and only computing a new boundary on an actual rollover. Adds a guard so concurrent rollovers cannot double-reset the interval and lose data.
  • Collapses SwapMeter's use of underlying.hasExpired() (a wall clock read on every update) into a plain volatile counter that AbstractRegistry bumps whenever it removes a meter, keeping hasExpired() itself unchanged so destructive callers like PolledMeter still only see real expiry.

Test plan

  • StepRollCountDifferentialTest drives the new rollCount against a copy of the previous implementation over identical timestamp sequences (including exact boundaries, multi-interval gaps, and backwards clock movement) and requires bit-for-bit equal results. Run over step sizes of 1ms, 7ms, 13ms, 1s, 5s and 60s, so a step that rolls on nearly every update and steps that do not divide any round timestamp are covered as well as the aligned 5s case.
  • StepRollCountConcurrencyTest exercises concurrent rollovers to confirm the CAS guard prevents double-reset data loss.
  • SwapMeterExpiryReportingTest and AtlasHeldReferenceExpiryTest pin down that routine meter removal is not conflated with hasExpired(), that held references still recover after a meter is swept, that updates through a stale reference are not lost, and that an update through a CompositeRegistry reference still lands after a sub registry sweeps the meter.
  • New JMH benchmark CounterIncrement isolates the cost of each layer of the update path (clock read, rollCount, CAS, registry lookup).
  • ./gradlew :spectator-api:test :spectator-reg-atlas:test passes.

Note on the SwapMeter semantics

The re-resolve trigger changes from underlying.hasExpired() to a removal counter, so it is worth
being precise about what that does and does not change.

For a meter that is past its TTL but not yet swept, the two are equivalent rather than merely
close: lookup() goes through getOrCreate, which is a computeIfAbsent on the meter map and does
not replace expired entries, so the old code's re-resolve returned the same instance it already
held. The update lands on the same object either way. For AbstractRegistry the old hasExpired()
check also reduced to exactly underlying.hasExpired(), since its registry level VERSION is a
constant () -> 0L both before and after this change.

Once the meter is actually swept, the removal counter moves and the next call re-resolves, which
is if anything more timely than waiting for the TTL to be observed. AtlasHeldReferenceExpiryTest
covers that path.

The one case that genuinely differs is CompositeRegistry, which still uses the old constructor and
a version that only changes when a registry is added or removed — so a sweep inside a sub registry
no longer invalidates the composite's wrapper. Recovery there comes from the sub registry's own
wrapper nested inside the composite meter, which survives because unwrap() only flattens wrappers
belonging to the same registry. updatesThroughACompositeSurviveASubRegistrySweep pins that down.

lookupCost above is the measurable cost of this trade.

Benchmark results

./gradlew :spectator-reg-atlas:jmh (CounterIncrement, JDK 25, 5 forks x (5 x 2s warmup + 10 x 3s measurement) = 50 measurement iterations per benchmark, single thread unless noted, no profilers attached). "Before" is this repo's main with only the CounterIncrement benchmark file itself kept at this PR's version, so both runs measure the exact same call sequences; "after" is this branch. Errors are JMH's 99.9% confidence intervals and include fork-to-fork variance.

Benchmark Before (ops/s) After (ops/s) Change What it isolates
swapCounter 16,927,174 ± 24,465 33,246,268 ± 22,866 +96% Production path: SwapCounter.increment() through a held reference
perThread 16,940,904 ± 2,612 33,234,296 ± 28,377 +96% Same path with no cache-line sharing between threads
timerRecord 15,929,431 ± 99,575 29,838,483 ± 63,708 +87% Timer.record(), which pays the rollCount cost across four step values
atlasCounter 29,727,295 ± 6,455 33,242,211 ± 17,675 +11.8% Same path with the SwapMeter indirection/expiry check stripped off
batched 291,635,390 ± 4,448,067 298,290,827 ± 14,027 +2.3% Per-thread BatchUpdater amortizing the CAS over a batch of 1000
rollingStepDouble 110,969,861 ± 33,206 112,700,011 ± 34,016 +1.6% Worst case: 1ms step, every call rolls the boundary over
stepDouble 226,028,968 ± 392,638 226,497,284 ± 71,016 +0.2% rollCount + CAS only, clock read hoisted out
wallTime 38,979,556 ± 5,407 38,978,659 ± 6,409 0.0% Control: a single wall clock read, for scale
lookupCost 39,803,685 ± 102,233 38,580,497 ± 298,467 -3.1% Cost of re-resolving a meter from the registry

The two changes contribute very unevenly, and it seems worth being explicit about that rather than crediting the whole gain to both:

  • The SwapMeter change is the dominant win: swapCounter, perThread, and timerRecord all go through SwapMeter.get() on every call, and all roughly double. That is consistent with removing a wall clock read (AtlasMeter.hasExpired()'s TTL check) from every update. perThread matching swapCounter confirms it is a per-call path cost and not cache-line contention on the shared counter.
  • The rollCount division removal is real but much smaller. atlasCounter bypasses SwapMeter and so isolates this half of the PR: +11.8%. On stepDouble the effect is inside the noise (+0.2%), which is expected — with the clock read hoisted out, the CAS dominates and the division was never the bottleneck at that granularity.
  • rollingStepDouble is the adversarial case for the change (1ms step, a rollover on essentially every call, so the division is still paid and the cached boundary read is added on top). It comes out +1.6%, i.e. slightly ahead rather than behind, so the cached read does not cost anything measurable even when the fast path never hits.
  • lookupCost regresses ~3.1%, and at these error bars that is a real effect rather than noise. This is the expected trade: constructing a wrapper now samples the removal counter as well and carries one more volatile field. It is paid once per held reference per cleanup pass, against a ~2x saving on every single update, but it is a regression and not worth hiding.
  • wallTime is unchanged to within 0.002%, which is a useful check that the two runs saw the same machine conditions.

Removes a division from StepDouble/StepLong.rollCount on every update by
caching the end of the current step interval and only computing a new
boundary on an actual rollover. Adds a guard so concurrent rollovers cannot
double-reset the interval and lose data.

Also collapses SwapMeter's use of underlying.hasExpired() (a wall clock
read on every update) into a plain volatile counter that AbstractRegistry
bumps whenever it removes a meter, keeping hasExpired() itself unchanged so
destructive callers like PolledMeter still only see real expiry.

Adds differential, concurrency, and expiry-regression tests plus a JMH
benchmark isolating each layer of the update path.
@brharrington brharrington added this to the 1.10.6 milestone Aug 17, 2026
michaelbraun and others added 2 commits August 17, 2026 20:32
Comment only. Keeps the reasoning that is not recoverable from the code
(why the removal signal is separate from the version used by
hasExpired(), why resolveVersion has to be sampled before the lookup)
and drops the restatements of what the code already says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run the differential comparison over several step sizes rather than only
5s: 1ms rolls on nearly every update, and 7 and 13 do not divide any
round timestamp, so they exercise alignment the single step could not.

Add a test that an update through a composite reference still lands
after the sub registry sweeps the meter. The composite's version only
changes when a registry is added or removed, so recovery there comes
from the sub registry's own wrapper nested inside the composite meter,
which depends on unwrap() only flattening wrappers from the same
registry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brharrington brharrington modified the milestones: 1.10.6, 1.10.7 Aug 19, 2026
@brharrington

brharrington commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

I would like to break this up into smaller more focused chunks that are easier to review. To start with, the change to remove division from the StepLong and StepDouble seems pretty self-contained, would you mind creating a new PR with just that change?

@michaelbraun

Copy link
Copy Markdown
Contributor Author

Closing in favor of split PRs

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.

2 participants