Skip to content

Add Accumulator: a striped long counter primitive as an alternative to LongAdder (perf toolbox) - #12351

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 38 commits into
masterfrom
dougqh/accumulator-primitive
Sep 22, 2026
Merged

gh-worker-dd-mergequeue-cf854d[bot] merged 38 commits into
masterfrom
dougqh/accumulator-primitive

Conversation

@dougqh

@dougqh dougqh commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Adds Accumulator, an enum-keyed, striped counter primitive that closes LongAdder.sumThenReset()'s documented non-atomic reset hazard: increments landing between sum and zero are silently and permanently lost with LongAdder; Accumulator.accumulateAndReset combines and resets each stripe under a single atomic getAndSet per 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-free AtomicLongArray-per-stripe striping, one inc/add write path, accumulateAndReset() (destructive drain) and sum() (non-destructive peek), typed Counts<E> views (get, plus, from). Lives in products/metrics/metrics-api (moved from internal-api, next to Counter), not internal-api — see AccumulatorVsCounterBenchmark's javadoc for the rule of thumb on when to reach for this over the existing Counter.
  • Accumulator.RunningTotal — an opt-in, lock-guarded composition of a periodic destructive drain() (for reporting) and a non-destructive live() (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, and StatsDCountReporter owns an Accumulator plus the periodic drain-and-report cycle, retrying whatever a client exception fails to deliver on the next flush rather than discarding it.
  • Stripe count is oversized to ~2x available cores (floor 4) rather than exactly core count, to reduce stripe-collision probability under contention (see Accumulator.stripeCount() javadoc).
  • JMH benchmarks: AccumulatorBenchmark (vs. LongAdder, a CHM-of-AtomicLong anti-pattern, and an unstriped AtomicLongArray, at low/high contention) and AccumulatorVsCounterBenchmark (a decision-support comparison against metrics-api's existing Counter, establishing when each is the right call).
  • JOL footprint test (AccumulatorFootprintTest) compares retained bytes against N LongAdders 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 current AtomicLongArray design 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-object LongAdder fan-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-tracked LongAdder fields and the previousCounts/countIndex diffing ceremony Flush used 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 LongAdder fields, one per counter, each independently diffed against a hand-tracked previous value. That has problems this stack fixes:

  • Correctness: 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.
  • Ergonomics: one enum declares the schema; one Accumulator/StatsDCountReporter replaces N independent LongAdder fields plus hand-rolled diffing against a previousCounts array.
  • A live diagnostic read that doesn't race the periodic drain: RunningTotal gives summary()-style callers a value that's always live and never inconsistent with a concurrent Flush, without either call blocking the other beyond one short critical section.
  • Reporting resilience: StatsDCountReporter retries whatever a statsd.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 contended LongAdders once measured under the load it will actually see in production — see the JOL footprint test.
  • Test plan:
    • ./gradlew :products:metrics:metrics-api:test — unit, footprint, and StatsDCountReporter tests pass
    • ./gradlew :products:metrics:metrics-api:jmhJar builds; JMH benchmarks (AccumulatorBenchmark, AccumulatorVsCounterBenchmark) run manually with real numbers captured in javadoc
    • ./gradlew :products:metrics:metrics-api:spotlessCheck clean
    • /techdebt and /perf-review run over branch changes — no findings
    • Real-caller trial (Migrate TracerHealthMetrics onto the Accumulator primitive (perf toolbox adoption) #12383, TracerHealthMetrics) exercised against the existing HealthMetricsTest/MetricsReliabilityTest regression suites with zero test edits needed

Contributor Checklist

  • Format the title according to the contribution guidelines
  • Assign the type: and (comp: or inst:) labels in addition to any other useful labels (none set yet)
  • Avoid using close, fix, or any linking keywords when referencing an issue
  • Update the CODEOWNERS file on source file addition, migration, or deletion
  • Update public documentation with any new configuration flags or behaviors
  • Once approved, use merge queue to merge the PR

Jira ticket: APMLP-1779

dougqh and others added 4 commits August 31, 2026 12:16
…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>
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
@datadog-datadog-prod-us1

This comment has been minimized.

Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
@dd-octo-sts

dd-octo-sts Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 14.67 s 14.67 s [-0.8%; +0.8%] (no difference)
startup:insecure-bank:tracing:Agent 13.65 s 13.67 s [-1.0%; +0.7%] (no difference)
startup:petclinic:appsec:Agent 17.71 s 17.62 s [-0.3%; +1.3%] (no difference)
startup:petclinic:iast:Agent 17.58 s 17.63 s [-0.8%; +0.3%] (no difference)
startup:petclinic:profiling:Agent 17.39 s 17.15 s [+0.3%; +2.5%] (maybe worse)
startup:petclinic:sca:Agent 17.52 s 17.53 s [-0.9%; +0.7%] (no difference)
startup:petclinic:tracing:Agent 16.60 s 16.75 s [-1.9%; +0.2%] (no difference)

Commit: 4be76af1 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
@dougqh dougqh added type: feature Enhancements and improvements comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes labels Aug 31, 2026
@dougqh
dougqh marked this pull request as ready for review August 31, 2026 21:00
@dougqh
dougqh requested a review from a team as a code owner August 31, 2026 21:00
@dougqh
dougqh requested review from amarziali and removed request for a team August 31, 2026 21:00
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>

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: FAIL

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.

Open Bits AI session

🤖 Datadog Autotest · Commit fc2f55c · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

Comment thread internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java Outdated
Comment thread internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java Outdated
Comment thread internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java Outdated
@mcculls

mcculls commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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 amarziali left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The raw long[][] API does not bind storage to its enum schema. A different enum can be passed to inc/add and silently update the wrong ordinal. Exposing the arrays also makes the synchronization protocol conventional rather than enforceable.
  2. 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 LongAdder instances 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.

Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Accumulator.java Outdated
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>
@dougqh
dougqh requested review from bric3 and mcculls and removed request for a team September 10, 2026 15:30

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: FAIL

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.

Open Bits AI session

🤖 Datadog Autotest · Commit 8e6e624 · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

Comment thread products/metrics/metrics-api/src/main/java/datadog/metrics/api/Accumulator.java Outdated
@dougqh

dougqh commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

@amarziali Following up here since this is where the actual change landed: I moved Accumulator from internal-api into metrics-api (this PR), right next to Counter. This addresses the module-layering concern you raised on #12383 -- StatsDCountReporter's Accumulator.Counts overload no longer reaches into internal-api from metrics-api, it's a same-module reference now.

I'm not entirely sure how we originally decided what belongs in metrics-api vs. internal-api, but this placement feels consistent with Counter already living there.

Making that move also made it clear that Counter is the fairer comparison point for Accumulator than the synthetic alternatives (LongAdder, CHM, etc.) already in AccumulatorBenchmark -- Counter's real implementation (StatsDCounter) is the thing Accumulator actually displaces on a hot path. So I added AccumulatorVsCounterBenchmark alongside it.

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>
@dougqh

dougqh commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Added two small things here that came out of the compensating-retry work on the stacked TracerHealthMetrics migration (#12383):

  • @ThreadSafe on Accumulator and RunningTotal — both were already safe for concurrent use, just not documented as such.
  • Counts.from(fromIndex) — zeroes every entry before the given index, so a caller that only partially delivered a drained batch downstream (e.g. a statsd client throwing partway through a report loop) can represent "what's left to retry" without re-counting the part that already made it. StatsDCountReporter.flush() uses this to retry undelivered counters on the next flush cycle instead of dropping them.

Both covered by new tests in AccumulatorTest.

…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>
Comment thread products/metrics/metrics-api/src/main/java/datadog/metrics/api/Accumulator.java Outdated
dougqh and others added 5 commits September 10, 2026 18:17
…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>
@dougqh dougqh mentioned this pull request Sep 11, 2026
6 of 9 tasks
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>

@mcculls mcculls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@dougqh dougqh changed the title Add Accumulator: a striped long counter primitive as an alternative to LongAdder Add Accumulator: a striped long counter primitive as an alternative to LongAdder (perf toolbox) Sep 15, 2026
…primitive-local

# Conflicts:
#	products/metrics/metrics-api/build.gradle.kts
@dougqh
dougqh added this pull request to the merge queue Sep 22, 2026
@dd-octo-sts

dd-octo-sts Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-09-22 13:37:32 UTC ℹ️ Start processing command /merge


2026-09-22 13:37:36 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 1h (p90).


2026-09-22 14:55:02 UTC ℹ️ MergeQueue: This merge request was merged

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 22, 2026
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit be7c7c5 into master Sep 22, 2026
606 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the dougqh/accumulator-primitive branch September 22, 2026 14:55
@github-actions github-actions Bot added this to the 1.67.0 milestone Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants