Skip to content

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

Open
dougqh wants to merge 11 commits into
masterfrom
dougqh/accumulator-primitive
Open

Add Accumulator: a striped long counter primitive as an alternative to LongAdder#12351
dougqh wants to merge 11 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 long[]-per-stripe 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 the same lock guarding its writers, so nothing can land in the gap.

  • 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 for the birthday-paradox reasoning and the before/after benchmark numbers).
  • JMH benchmark (AccumulatorBenchmark) compares against LongAdder, a ConcurrentHashMap.computeIfAbsent anti-pattern, and a per-counter-locked LongAdder alternative, at low/high contention, with real measured numbers and honest interpretation in the javadoc.
  • JOL footprint test (AccumulatorFootprintTest) compares retained bytes against N LongAdders fresh vs. under real contention.

No caller wired in yet — this is the toolkit primitive from APMLP-1779.

Motivation

The realistic alternative to this primitive is N separate LongAdder fields, one per counter. That has two problems this PR 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.
  • Ergonomics: one enum declares the schema, one create()/accumulateAndReset() call site instead of N, and update() gives atomic multi-counter mutation that a pile of independent LongAdders can't.

Migrating a real caller (e.g. OtlpTelemetry or PayloadDispatcherImpl, both confirmed to use the sumThenReset() pattern per APMLP-1780) is the natural follow-up.

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 :internal-api:test --tests "datadog.trace.util.Accumulator*" — unit + footprint tests pass
    • ./gradlew :internal-api:jmhJar builds; JMH benchmark run manually with real numbers captured in javadoc
    • ./gradlew :internal-api:spotlessCheck clean
    • /techdebt and /perf-review run over branch changes — no findings

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.69 s [-0.8%; +0.6%] (no difference)
startup:insecure-bank:tracing:Agent 13.53 s 13.68 s [-1.8%; -0.3%] (maybe better)
startup:petclinic:appsec:Agent 17.52 s 17.44 s [-0.6%; +1.5%] (no difference)
startup:petclinic:iast:Agent 17.52 s 16.99 s [-1.3%; +7.5%] (no difference)
startup:petclinic:profiling:Agent 17.31 s 17.43 s [-1.7%; +0.4%] (no difference)
startup:petclinic:sca:Agent 17.43 s 17.32 s [-0.6%; +1.8%] (no difference)
startup:petclinic:tracing:Agent 16.64 s 16.19 s [-1.5%; +6.9%] (no difference)

Commit: 3484ed7f · 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/test/java/datadog/trace/util/AccumulatorTest.java
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 and others added 2 commits September 1, 2026 13:42
- Update call sites to Accumulator.EmbeddingSupport.*, and add a test
  covering the new typed Accumulator<E> wrapper.
- Fix a race in concurrentAccumulateAndDuringWritesNeverExceedsWritten:
  join the background drainer via Future.get() before the final drain
  and assertion, instead of racing it.
- Assert accumulatorBytes < contendedAdderBytes in contendedFootprint --
  the actual claim the test exists to back up, not just that the
  LongAdder side didn't shrink.
- Correct the CHM benchmark's javadoc: the benchmark-scoped map means
  only the first warmup invocation allocates under the bin lock; every
  sampled op hits an already-warmed computeIfAbsent lookup.
- Add a @Group-based accumulatorMixed-write/accumulatorMixed-drain pair
  modeling "many writers, one rare drainer," alongside the existing
  @threads(MAX) benchmark kept as a documented worst-case upper bound.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iews

Restores enum-ordinal type checking inside update()'s critical section
and on accumulateAndReset()'s drained result, closing the last gap left
by the EmbeddingSupport split. Stripe is constructed fresh under the
held lock and is expected to be scalar-replaced by escape analysis for
well-behaved (small, non-capturing, non-escaping) mutators; Counts
wraps the already-drained array and is a real but infrequent
per-drain allocation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dougqh
dougqh requested a review from amarziali September 1, 2026 18:28
dougqh and others added 3 commits September 1, 2026 14:36
…port

Pairs typedIncrement/typedUpdate/typedAccumulateAndReset against their
EmbeddingSupport equivalents so the wrapper's cost is directly visible:
inc/update should track the raw calls closely (Stripe is designed to
scalar-replace), while accumulateAndReset is expected to run measurably
slower by roughly one small allocation per drain (Counts escapes by
design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mark javadoc

Confirms the wrapper's cost is not measurable: typedIncrement/typedUpdate
track EmbeddingSupport within noise (the Stripe scalar-replaces as
designed), and typedAccumulateAndReset tracks the raw drain within noise
at low contention (the Counts allocation doesn't show up at this
granularity).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d count

The benchmark calls EmbeddingSupport.accumulateAndReset, not
accumulateAnd -- rename to match. Also cap contendedFootprint's thread
count at 16: uncapped availableProcessors() on a high-core build agent
spins up one thread per core, all busy-spinning for two seconds, which
can dominate the host during a parallel test run for no added signal
over a fixed small contention level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dougqh

dougqh commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Agreed, and this is now in there: accumulatorMixed_write/accumulatorMixed_drain in AccumulatorBenchmark model exactly that -- a @Group benchmark with 4 writer threads calling inc and 1 dedicated thread calling accumulateAndReset, rather than every thread doing both. The pre-existing accumulatorAccumulateAndReset_highContention (every thread increments and drains) is kept too, but now documented as a deliberately pessimistic worst-case upper bound rather than the realistic case -- accumulatorMixed_* is the one that reflects actual usage. See commit 175154e.

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