Speed up the Counter.increment() hot path - #1277
Closed
michaelbraun wants to merge 3 commits into
Closed
Conversation
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.
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>
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? |
This was referenced Aug 28, 2026
Contributor
Author
|
Closing in favor of split PRs |
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.
Summary
StepDouble/StepLong.rollCounton 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.SwapMeter's use ofunderlying.hasExpired()(a wall clock read on every update) into a plain volatile counter thatAbstractRegistrybumps whenever it removes a meter, keepinghasExpired()itself unchanged so destructive callers likePolledMeterstill only see real expiry.Test plan
StepRollCountDifferentialTestdrives the newrollCountagainst 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.StepRollCountConcurrencyTestexercises concurrent rollovers to confirm the CAS guard prevents double-reset data loss.SwapMeterExpiryReportingTestandAtlasHeldReferenceExpiryTestpin down that routine meter removal is not conflated withhasExpired(), that held references still recover after a meter is swept, that updates through a stale reference are not lost, and that an update through aCompositeRegistryreference still lands after a sub registry sweeps the meter.CounterIncrementisolates the cost of each layer of the update path (clock read, rollCount, CAS, registry lookup)../gradlew :spectator-api:test :spectator-reg-atlas:testpasses.Note on the
SwapMetersemanticsThe re-resolve trigger changes from
underlying.hasExpired()to a removal counter, so it is worthbeing 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 throughgetOrCreate, which is acomputeIfAbsenton the meter map and doesnot 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
AbstractRegistrythe oldhasExpired()check also reduced to exactly
underlying.hasExpired(), since its registry levelVERSIONis aconstant
() -> 0Lboth 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.
AtlasHeldReferenceExpiryTestcovers that path.
The one case that genuinely differs is
CompositeRegistry, which still uses the old constructor anda 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 wrappersbelonging to the same registry.
updatesThroughACompositeSurviveASubRegistrySweeppins that down.lookupCostabove 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'smainwith only theCounterIncrementbenchmark 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.swapCounterSwapCounter.increment()through a held referenceperThreadtimerRecordTimer.record(), which pays therollCountcost across four step valuesatlasCounterSwapMeterindirection/expiry check stripped offbatchedBatchUpdateramortizing the CAS over a batch of 1000rollingStepDoublestepDoublerollCount+ CAS only, clock read hoisted outwallTimelookupCostThe two changes contribute very unevenly, and it seems worth being explicit about that rather than crediting the whole gain to both:
SwapMeterchange is the dominant win:swapCounter,perThread, andtimerRecordall go throughSwapMeter.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.perThreadmatchingswapCounterconfirms it is a per-call path cost and not cache-line contention on the shared counter.rollCountdivision removal is real but much smaller.atlasCounterbypassesSwapMeterand so isolates this half of the PR: +11.8%. OnstepDoublethe 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.rollingStepDoubleis 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.lookupCostregresses ~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.wallTimeis unchanged to within 0.002%, which is a useful check that the two runs saw the same machine conditions.