Remove the division from StepLong/StepDouble rollCount - #1280
Conversation
Every update to a step value called rollCount, which divided the current wall time by the step to get the interval index. step is a non-trusted final instance field, so C2 cannot constant fold it and this compiles to a real idivq on the update path of every counter, timer, gauge and distribution summary. Cache the end of the current interval instead of its index. An update landing inside the current interval is then a comparison against a volatile long, and the division is paid only on an actual rollover. Boundaries are exact multiples of step, so comparing them orders the intervals exactly the way comparing the indices did. StepRollCountDifferentialTest drives the new implementation and a copy of the previous one over identical timestamp sequences across every entry point and requires bit-for-bit equal results, including exact boundaries, multi-interval gaps and backwards clock movement, over step sizes of 1ms, 7ms, 13ms, 1s, 5s and 60s. StepRollCountConcurrencyTest covers the pre-CAS boundary compare, which cannot be exercised single threaded and whose removal loses a completed interval's data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the JIT-internals detail to a single sentence and fix the stale reference to the init position moving forward by one, which is now the boundary moving forward by one step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
toString recomputed the removed lastInitPos with a division purely to keep the old string. The class is documented as an internal detail subject to change, so print the state that actually exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…show The division is long-latency work on a separate port, so on main it masks the rest of the loop. Removing it makes that other work visible, which can read as a regression in a synthetic loop that does strictly less work. Spell that out next to the benchmarks it applies to so the numbers are not quoted without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| ManualClock clock = new ManualClock(); | ||
| StepLong value = new StepLong(0L, clock, STEP); | ||
| final int perThread = 100_000; | ||
| final long now = 5L * STEP; |
There was a problem hiding this comment.
This test appears to be flakey. ManualClock starts at wallTime 0, so nextStepBoundary = STEP = 10000. But every thread calls value.addAndGet(now = 5 * STEP = 50000, 1L). The very first call satisfies now >= nextStepBoundary, so the intended "stays within a single interval" setup actually opens with a rollover. With all 8 threads already released from the barrier and incrementing, a thread that loses the boundary CAS still completes its CURRENT_UPDATER.addAndGet; if that lands before the winner's getAndSet(this, init), the increment is wiped. Hence expected: <800000> but was: <799993>.
Fix is one line before the barrier release:
StepLong value = new StepLong(0L, clock, STEP);
final int perThread = 100_000;
final long now = 5L * STEP;
value.addAndGet(now, 0L); // do the rollover up front, then the test really is in-intervalThere was a problem hiding this comment.
This should be fixed now
There was a problem hiding this comment.
should be fixed now
ManualClock starts at 0, so nextStepBoundary is STEP and the first update at 5 * STEP rolled over while all eight threads were already incrementing. A thread that loses the boundary CAS still completes its addAndGet, and if that landed before the winner's getAndSet the increment was wiped, so the test could observe fewer than the expected total. The "stays within a single interval" premise it documents was never actually established. Perform the rollover up front and assert it happened, so the body of the test is genuinely in-interval and the precondition cannot regress silently. Also drop an unused StepDouble from the Varying benchmark state; only the StepLong variants use it. Reported-by: brharrington Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Private fields do not need javadoc under this checkstyle config, and the explanation of why the boundary is cached does not need repeating in three places. Keep the parts that are not inferable from the code: why the division cannot be folded away, why boundaries order the same way indices did, and why the compare before the CAS is load bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The differential test's negative-timestamp comment claimed the two implementations were compared over that range; with only non-negative starts neither can roll there, so state that instead. Restate the backwards-clock comment in terms of nextStepBoundary rather than the removed lastInit guard. Move the awaitTermination checks out of the finally blocks so they cannot mask a round assertion failure.
Summary
Every update to a step value calls
rollCount, which divided the current wall time by the step size to get the interval index.stepis a non-trusted final instance field, so C2 cannot constant fold it, and this compiles to a realidivqon the update path of every counter, timer, gauge and distribution summary.Cache the end of the current interval instead of its index. An in-interval update then becomes a comparison against a volatile long, and the division is paid only on an actual rollover. Boundaries are exact multiples of
step, so comparing them orders the intervals exactly the way comparing the indices did.No public API or behavior change. The only externally visible difference is
toString, which used to printlastInitPos; it now prints the field that actually exists. Both classes are documented as internal implementation details subject to change.Correctness
StepRollCountDifferentialTestdrives the new implementation and a copy of the previous one over identical timestamp sequences and requires bit-for-bit equal results. It covers every entry point (addAndGet,getCurrent,setCurrent,getAndSet,min,max,poll,pollAsRate), asserting both the current value andtimestamp()after each operation, over step sizes of 1ms, 7ms, 13ms, 1s, 5s and 60s and five starting alignments. The sequences deliberately include exact boundaries, boundary +/- 1, multi-interval gaps and backwards clock movement.StepRollCountConcurrencyTestcovers the boundary compare that precedes the CAS. Without it, two threads that both observe the same boundary would both roll, and the second would resetcurrentand publish the completed interval as zero. That cannot be exercised single threaded.I checked the tests are not vacuous by injecting four bugs; all were caught:
>=weakened to>in the boundary checktimestamp()off by one steppreviousforward./gradlew :spectator-api:buildpasses, including checkstyle, spotbugs and the JDK 17/25/26 test tasks.Benchmark
New
StepValueUpdateJMH benchmark inspectator-api(the change is here, and it needs no registry). JDK 25, 5 forks x (5 x 1s warmup + 10 x 2s measurement) = 50 measurement iterations, single threaded. Errors are 99.9% confidence intervals including fork-to-fork variance. "Before" is this repo'smainwith only the benchmark file itself added, so both runs measure identical call sequences.stepLongAddAndGetWithClockAtlasCounter.adddoesstepDoubleAddAndGetWithClockstepDoublePollstepLongPollvaryingStepLongPollstepLongAddAndGetstepDoubleAddAndGetvaryingStepLongAddAndGetrollingStepLongrollingStepDoublewallTime*WithClockis the shape a real update has, matchingAtlasCounter.add: read the clock, then update. That is the number worth quoting. ThewallTimecontrol matching to 0.02% across the two runs indicates the machine was not drifting between them.The disassembly confirms the mechanism directly:
idivqappears throughout thebeforecompilation of the hot loop and is entirely absent after.On the two apparent regressions
rollingStepLongandrollingStepDoubleuse a 1ms step with a timestamp advancing every call, so every single update rolls over. That is the adversarial case: the division is still paid and the boundary load is added on top. It costs ~6%, and it only applies to a meter rolling over on essentially every update. With the 5s step used in practice a rollover is one update in millions.varyingStepLongAddAndGetat -5.0% is reproducible rather than noise, and it is worth being precise about because it is not a cost of this change. The division is long-latency work on a separate execution port, so onmainit masks whatever else the loop is doing. Measured onmain, that benchmark is flat at 492-510M ops/s whether the timestamp comes from a constant, a counter increment, or an array load (a throwaway variant used to diagnose this, not included here). With the division gone the same three land at 501M, 471M and 453M, tracking the cost of the timestamp source itself. In other words the loop with a trivial timestamp is faster after this change (stepLongAddAndGet, 501.4M vs 493.0M); what the slower variants measure is the benchmark's own timestamp generation emerging from behind the divide.I ruled out the alternatives before settling on that: splitting
rollCountinto a fast path plus a coldrollCountSlowis not responsible (an unsplit build measures the same, 470.7M vs 471.6M); nor is field layout (paddingnextStepBoundaryonto its own cache line, verified in the disassembly to move it from offset0x30to0x70, changes nothing); nor loop alignment or unrolling (-XX:OptoLoopAlignment=32and-XX:LoopUnrollLimit=1leave both builds unmoved).A real caller reads a clock, which costs far more than anything the division could mask, which is why the production-shaped benchmark gains 11%.