Add Accumulator: a striped long counter primitive as an alternative to LongAdder (perf toolbox) - #12351
Conversation
…o LongAdder Enum-keyed long[]-per-stripe storage with cache-line padding, threadId&mask stripe selection, and combine+reset performed atomically under each stripe's own lock -- closing the non-atomic sumThenReset() loss window LongAdder has. Includes a JMH benchmark against LongAdder and the ConcurrentHashMap.computeIfAbsent(AtomicLong::new) anti-pattern. APMLP-1779 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…isions Sizing stripes to exactly availableProcessors() left collisions likely under real contention (birthday-paradox: n(n-1)/(2m) expected colliding pairs), and a collision costs a blocking synchronized wait rather than LongAdder's cheap CAS retry. Doubling the stripe count (floor 4) cuts accumulatorIncrement_highContention from ~0.097 to ~0.040 us/op at the cost of a pricier but far rarer accumulateAnd drain -- the right trade since inc/add run on every call while accumulateAnd runs on a reporting cadence. Benchmark javadoc updated with the re-measured numbers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Accumulator's realistic alternative isn't a single LongAdder but one per counter (there's no multi-counter LongAdder). Fresh instances make LongAdder look ~15x lighter, but that's an artifact of never having grown a Cell[] table under contention. Forcing real concurrent writes shows the opposite: 4 LongAdders under contention (17,560 bytes) end up over 7x heavier than Accumulator's fixed footprint (2,384 bytes), which is paid once at creation and doesn't grow with more contention or more counters, while each contended LongAdder keeps paying independently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tests the hypothesis that a LongAdder-based helper which actually closes the same sumThenReset() reset hazard (one LongAdder per counter, a per-counter lock guarding both increment and drain) would cost about the same as Accumulator. It doesn't -- it's a clean trade-off inversion, not a wash: Accumulator's thread-sharded stripes win ~10x on the increment path, while the per-counter design wins ~24x on drain, but only because this benchmark has a single counter (its drain cost scales with counter count; Accumulator's is fixed at stripe count). Documented as a data point, not adopted -- both designs close the hazard, and the difference is negligible next to real request/span work either way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
Rename accumulateAnd to accumulateAndReset, use ThreadSupport.threadId() instead of the deprecated Thread.getId(), add @GuardedBy annotations on the stripe-locked helpers, add @ParametersAreNonnullByDefault, and trim the javadoc (drop the Hashtable/FlatHashtable mention, the not-yet-built non-additive-counter escape hatch, and the C2-specific vectorization detail; shorten the LongAdder comparison). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The new counter code has no confirmed defect. Its concurrency test can fail because it does not wait for the drain task, and its map benchmark does not measure the stated allocation path.
🤖 Datadog Autotest · Commit fc2f55c · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fc2f55cd81
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
IMHO the benchmarks here should exercise the expected scenario of many threads calling increment, but only one thread periodically summing up the count. Currently they exercise many threads each calling increment and then the same incrementing threads all immediately summing up the count, which is not how it would be used in practice. |
amarziali
left a comment
There was a problem hiding this comment.
Automated review — request changes
The per-stripe synchronization protocol appears sound when all access goes through the provided operations: writers and combine/reset use the same stripe monitor, preventing the LongAdder.sumThenReset() loss window.
I found two blocking issues:
- The raw
long[][]API does not bind storage to its enum schema. A different enum can be passed toinc/addand silently update the wrong ordinal. Exposing the arrays also makes the synchronization protocol conventional rather than enforceable. - The concurrent drain test does not wait for its drainer to finish before asserting. It can fail against a correct implementation and can leave an active infinite task after a timeout.
The performance evidence also needs revision before its conclusions can support this abstraction:
- The drain benchmark creates many concurrent drainers instead of modeling many writers and one rare reporter.
- The footprint experiment expands unlocked
LongAdderinstances rather than measuring the correctness-equivalent locked alternative. - The stripe-collision explanation contains incorrect math and assumes a distribution not produced by
threadId() & mask. - The CHM measurement is pre-warmed and does not exercise the claimed allocation-under-lock path.
There is also an unmeasured deployment risk on JDK 21–23: virtual threads contending on these monitors during a drain may pin carrier threads. This should either be constrained in the contract or evaluated with a virtual-thread workload.
I recommend encapsulating the storage in an enum-bound owning type, repairing the drainer lifecycle test, and reshaping the measurements around the intended production topology before merging. Since there is no production caller in this PR, migrating one intended caller would also help validate the API and workload assumptions.
This was an automated, read-only review of head 89d980c0f829c6df51d22131aa35b760c529fdb4.
The original static, allocation-free Accumulator API let a caller index its long[][] with a different enum than the one it was created for -- compiles, but silently reads/writes the wrong slot. Move that raw API into a nested EmbeddingSupport namespace, and add a top-level Accumulator<E> that owns its storage and binds inc/add/update/ accumulateAndReset to one enum at construction, mirroring StringIndex's own EmbeddingSupport split in this package. Also fix the stripe-count javadoc: with n contending threads and m stripes, doubling m halves the expected number of colliding pairs (n(n-1)/(2m)), not quarters it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
RunningTotal removes existing values before the first report. Counts also exposes shared key state, and the size test can fail on hosts with many processors.
🤖 Datadog Autotest · Commit 8e6e624 · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest
|
@amarziali Following up here since this is where the actual change landed: I moved I'm not entirely sure how we originally decided what belongs in Making that move also made it clear that |
Counts.from(fromIndex) zeroes every entry before the given index, so a caller that partially delivered a drained batch downstream can represent "what's left to retry" without re-counting the part that already made it. Used by StatsDCountReporter's compensation logic in the TracerHealthMetrics migration (dougqh/accumulator-tracerhealthmetrics). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Added two small things here that came out of the compensating-retry work on the stacked
Both covered by new tests in |
…er with retry Owns an Accumulator + RunningTotal for one enum's counters, exposing inc/add/live/flush. flush() drains and reports the delta, retrying whatever a statsd exception left undelivered on the next cycle (Counts.from(i)) instead of dropping it -- without perturbing the cumulative live() total, since the drain already folded the delta into it unconditionally before delivery was attempted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ared array Protects the backing key array every Counts from the same Accumulator shares, without paying for a defensive copy. Also clarifies RunningTotal.of()'s javadoc about its pre-existing-value seeding visibility gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Without a cap, stripeCount() grows unbounded on very-high-core-count hosts. Past that many contending threads the collision-reduction math has already flattened out, so an unbounded table isn't worth the memory cost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Re-ran the Fork(5) suite post-MAX_STRIPES=64 to confirm the write-side win and drain-side parity still hold; numbers moved slightly but the conclusions are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
StatsDClient.NO_OP does nothing, so counterIncrement was measuring bare virtual-dispatch cost instead of StatsDCounter's real per-call cost -- the comparison couldn't answer its own question, and made Counter look cheaper than Accumulator even at high contention. Swap in a LockingStatsDClient that pays a real shared-lock cost per call, mirroring every real StatsDClient's single shared connection. Document the corrected results in the class javadoc. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sumThenReset() isn't the only safe alternative to Accumulator for one counter: a single dedicated differ computing sum() - previous by hand (the pattern pre-migration TracerHealthMetrics actually used) is also lock-free and never loses an update. Add that baseline (longAdderDelta*/ longAdderDeltaMixed*) and rewrite the class javadoc to reframe the existing longAdderGroup* win as the cost of a lock-based guarantee, not of LongAdder itself, and to note that Accumulator's real, decisive evidence is the batch/drain win demonstrated on real code in TracerHealthMetricsBenchmark rather than these single-counter numbers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ative lower bound LockingStatsDClient stands in for a real StatsDClient's shared lock/queue but skips the encoding and queue-offer/socket I/O work that lock also guards in production, so the measured gap is a floor on the real one, not an exact measurement of it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…primitive-local # Conflicts: # products/metrics/metrics-api/build.gradle.kts
|
/merge |
|
View all feedbacks in Devflow UI.
The expected merge time in
|
What Does This Do
Adds
Accumulator, an enum-keyed, striped counter primitive that closesLongAdder.sumThenReset()'s documented non-atomic reset hazard: increments landing between sum and zero are silently and permanently lost withLongAdder;Accumulator.accumulateAndResetcombines and resets each stripe under a single atomicgetAndSetper counter, so nothing can land in the gap.This has grown from a bare primitive into a small toolkit, in response to review and to trial a real caller (see below):
Accumulator— lock-freeAtomicLongArray-per-stripe striping, oneinc/addwrite path,accumulateAndReset()(destructive drain) andsum()(non-destructive peek), typedCounts<E>views (get,plus,from). Lives inproducts/metrics/metrics-api(moved frominternal-api, next toCounter), notinternal-api— seeAccumulatorVsCounterBenchmark's javadoc for the rule of thumb on when to reach for this over the existingCounter.Accumulator.RunningTotal— an opt-in, lock-guarded composition of a periodic destructivedrain()(for reporting) and a non-destructivelive()(for a diagnostic read, e.g.summary()) that share one lock, so a live read can never observe a torn state straddling a concurrent drain. Most callers don't need this (a drain-only reporter, or a live-only diagnostic, needs no coordination); it's provided as a separate type for the one recurring shape that does.StatsDCounterKey/StatsDCountReporter— the statsd-reporting glue: an enum constant carries its own metric name and tags, andStatsDCountReporterowns anAccumulatorplus the periodic drain-and-report cycle, retrying whatever a client exception fails to deliver on the next flush rather than discarding it.Accumulator.stripeCount()javadoc).AccumulatorBenchmark(vs.LongAdder, a CHM-of-AtomicLonganti-pattern, and an unstripedAtomicLongArray, at low/high contention) andAccumulatorVsCounterBenchmark(a decision-support comparison againstmetrics-api's existingCounter, establishing when each is the right call).AccumulatorFootprintTest) compares retained bytes against NLongAdders fresh vs. under real contention.Row-wide atomicity was dropped, deliberately, in the move to lock-free striping. The previous
synchronized-stripe design let several counters be updated together atomically; the currentAtomicLongArraydesign only guarantees atomicity per-counter — two different counters written by the same call are no longer guaranteed to be observed together by a concurrent drain. No caller in this stack (or the real one wired in below) depends on that cross-counter invariant, and the lock-free design wins on both raw throughput and multi-counter "bulk update" call sites despite giving it up (shared-stripe cache locality plus no lock beats per-objectLongAdderfan-out plus a monitor). See the class javadoc for the full reasoning.There is now a real caller: #12383 migrates
TracerHealthMetrics(dd-trace-core) fully onto this stack —Accumulator+RunningTotal+StatsDCounterKey/StatsDCountReporter— replacing ~49 hand-trackedLongAdderfields and thepreviousCounts/countIndexdiffing ceremonyFlushused to need. That PR is stacked on this one and is what settled the design questions below with real numbers instead of argument.Motivation
The realistic alternative to this primitive is N separate
LongAdderfields, one per counter, each independently diffed against a hand-tracked previous value. That has problems this stack fixes:LongAdder#sumThenReset()is documented as not atomic against concurrent updates — an increment landing on a cell after it's summed but before it's zeroed is silently and permanently lost.Accumulator.accumulateAndReset()closes that gap per-counter.Accumulator/StatsDCountReporterreplaces N independentLongAdderfields plus hand-rolled diffing against apreviousCountsarray.RunningTotalgivessummary()-style callers a value that's always live and never inconsistent with a concurrentFlush, without either call blocking the other beyond one short critical section.StatsDCountReporterretries whatever astatsd.count()exception fails to deliver on the next flush, instead of the drained delta being silently discarded for that interval.Additional Notes
Accumulator's fixed up-front cost ends up lighter than contendedLongAdders once measured under the load it will actually see in production — see the JOL footprint test../gradlew :products:metrics:metrics-api:test— unit, footprint, andStatsDCountReportertests pass./gradlew :products:metrics:metrics-api:jmhJarbuilds; JMH benchmarks (AccumulatorBenchmark,AccumulatorVsCounterBenchmark) run manually with real numbers captured in javadoc./gradlew :products:metrics:metrics-api:spotlessCheckclean/techdebtand/perf-reviewrun over branch changes — no findingsTracerHealthMetrics) exercised against the existingHealthMetricsTest/MetricsReliabilityTestregression suites with zero test edits neededContributor Checklist
type:and (comp:orinst:) labels in addition to any other useful labels (none set yet)close,fix, or any linking keywords when referencing an issueJira ticket: APMLP-1779