Skip to content

Use the unified Hashtable API in client-side stats (perf toolbox) - #12312

Draft
dougqh wants to merge 65 commits into
feat/hashtable-api-unificationfrom
feat/client-side-stats-hashtable-api
Draft

dougqh wants to merge 65 commits into
feat/hashtable-api-unificationfrom
feat/client-side-stats-hashtable-api

Conversation

@dougqh

@dougqh dougqh commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Stacked on #12101. Review that first — this PR's diff against it is just AggregateTable.

What Does This Do?

Migrates client-side statistics off the deprecated Hashtable.Support facade and onto the API from #12101, as the first real consumer of it.

This is the "use it to review it" half of that PR. The API was shaped in the abstract; this is what it looks like from the outside.

Result

AggregateTable goes from 164 to 132 lines, and from four pieces of hand-managed state to one:

// before
private final Hashtable.Entry[] buckets;
private final int maxAggregates;
private int size;
private int evictCursor;

// after
private final Hashtable.State<AggregateEntry> state;

The biggest deletion is evictOneStale and its helper — a cursor-resumed two-pass scan over [cursor, length) then [0, cursor), ~25 lines — which collapses into one call that reserves a slot and evicts only if the table is actually full:

if (!Hashtable.tryReserveOrEvict(state, STALE)) {
  return null;                        // full, nothing stale -- drop the datum
}

The caller no longer knows a cursor exists, but still gets its amortization: a sustained eviction stream never re-scans the hot prefix more than twice across N evictions.

Also gone: the manual size-- after every unlink (the manager owns the count now), the three paired resets in clear, and the iterator loop in expungeStaleAggregates.

STALE is a static final Predicate<AggregateEntry> — non-capturing, so eviction allocates nothing, and typed, so it needs no cast.

What this exercised in the API

Two warts found by writing this, both fixed in #12101 rather than worked around here:

  • removeMatching needed an explicit Hashtable.<AggregateEntry> type witness, because TEntry had nothing to infer from.
  • Eviction predicates took Predicate<? super Entry>, forcing entry -> ((AggregateEntry) entry).getHitCount() == 0.

Parameterizing State<TEntry> and having the statics take it fixed both, and made a spine/manager mismatch unconstructible rather than merely discouraged.

Behavior

Unchanged. Same cap, same evict-a-stale-entry-or-drop policy on the miss path, same amortized resumable scan — the scan just lives in the primitive now.

Follow-up this unblocks

No production code references Hashtable.Support any more. The facade holds no logic and can be deleted outright — deliberately left for its own PR so this one stays a behaviour-neutral migration.

Test plan

  • ./gradlew :dd-trace-core:test --tests "datadog.trace.common.metrics.*" — passes unchanged
  • ./gradlew :dd-trace-core:compileJava — no deprecation warnings from AggregateTable any more

🤖 Generated with Claude Code

dougqh and others added 30 commits August 25, 2026 14:40
Mirrors StringIndex#contains for the static-arrays path, so callers
of the embedded/parallel-array form don't need to spell out
indexOf(...) >= 0 themselves.

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

Reusing the same interned/cached-hash key instances for both building
a structure and measuring hit lookups against it understates real hit
cost -- String#equals's == fast path and String#hashCode's cached
result pay nothing extra for a key already touched during setUp. The
new hitFresh scenario looks up separate, never-touched String
instances instead, alongside the existing hit (interned literal) and
miss (already representative) scenarios.

Also pulls in BenchmarkUtils#polluteHashDispatch so the shared
hashCode/equals call sites are already megamorphic before the
StringSet arms measure their own lookups, matching production where
those call sites are hit by every hash-based structure in the JVM.

Replaced the javadoc's stale, partial-run tables/findings with a full
hit/hitFresh/miss re-run across all six structures together (Fork(5),
Threads(8)) -- confirms hitFresh is the slowest of the three scenarios
for every hash-based structure, not merely slower than hit as
previously guessed from partial data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recompute the slot index each iteration as (h + probes) & mask instead
of carrying a separately-incremented cursor, so the loop has one
canonical induction variable for the JIT to reason about.
…ains-only helpers

populateTypeProfile() drives only contains(), so it works against both
mutable and immutable collections (including the collection instance
under test itself); populateTypeProfileMutable() keeps the old
add()+contains() behavior for callers that need add() dispatch
polluted too.
The instance wrapper reliably beats HashSet on hit (its actual design
case) but not on miss/hitFresh, where both are bimodal across forks
and the mean already sits at or below HashSet's steady figure. Point
miss/fresh-key-heavy callers at EmbeddingSupport directly instead of
assuming the wrapper is strictly better than a plain Set.
Object.hashCode()/equals() are JVM-wide shared call sites hit by every
hash-based structure in the process; leaving them monomorphic for a
single-key-type run understates real dispatch cost, same reasoning as
ImmutableSetBenchmark. Rerun and update the javadoc results table with
pollution in effect -- StringIndex's get win over HashMap/TagMap/MapN
holds up unconditionally here, unlike the access-pattern-dependent Set
case.
polluteHashDispatch() only warmed the shared HashSet/HashMap call site;
ConcurrentHashMap has its own, unrelated hashCode()/equals() dispatch
sites and needs a dedicated scratch instance. Also add
populateTypeProfileMap/populateTypeProfileMutableMap as the Map
counterparts to the existing Collection-based helpers, for benchmarks
that need finer-grained control than polluteHashDispatch() gives.
…nchmarks

Applies the type-profile-pollution fix already used in
ImmutableSetBenchmark/ImmutableMapBenchmark to the rest of the
HashMap/HashSet/ConcurrentHashMap-comparing benchmarks in this module:
SingleThreadedMapBenchmark, SingleThreadedSetBenchmark,
ThreadSafeMapBenchmark, HashtableD1Benchmark, HashtableD2Benchmark,
CaseInsensitiveMapBenchmark, and TagMapAccessBenchmark. Without this,
each file's isolated single-key-type usage left the JDK collections'
shared internal hashCode()/equals() dispatch call sites artificially
monomorphic, understating their real per-call cost.

FlatHashtableIteratorBenchmark is intentionally untouched -- it only
exercises the project's own FlatHashtable/HashStrategy, not
java.util.HashMap/HashSet/ConcurrentHashMap.
D1.Entry.hash/matches are call sites private to Hashtable.java, distinct
from java.util.HashMap/HashSet's own dispatch sites, so pollution
literally cannot reach them (not just unlikely to). Since JDK and
machine were held constant in this rerun, attribute the observed drop
to same-session run-to-run noise, not a JDK effect (that explanation
applies elsewhere, where the JDK actually changed).
Same fix as HashtableD1Benchmark: D2.Entry.hash/matches are call sites
private to Hashtable.java, structurally distinct from java.util.HashMap
dispatch, so pollution cannot reach them regardless of key-type
overlap; the observed drop with JDK/machine held constant is
same-session noise, not a JDK effect.
The original table is Zulu 21; this rerun is JDK 8, whose C2 backend
for Apple Silicon is far less mature than JDK 17+'s. That JDK gap
explains a broad-based slowdown across every entry on its own,
including flatHashtable/treeMap which don't touch java.util.HashMap
dispatch — a more precise explanation than generic "machine variance."
Same fix as CaseInsensitiveMapBenchmark: the original table is Java 17,
this rerun is JDK 8, and JDK 8's weaker Apple Silicon C2 codegen
explains the across-the-board drop on its own. Distinguish this from
HashtableD1Benchmark/HashtableD2Benchmark, where the JDK was held
constant and the drop is same-session noise instead.
Controlled rerun (same machine, JDK swapped to Zulu 17.0.7) shows
update_hashtable still wins decisively (~4.2x, down from ~14x on JDK 8)
and iterate_hashtable flips from a wash to a ~4x win, while add_hashMap
edges ahead slightly. Java 17's much better allocator/GC narrows but
doesn't erase Hashtable's win on the allocation-heavy paths -- net
takeaway: Hashtable is a strong HashMap substitute for simple
counter/tally cases with a primitive value.

Absolute numbers aren't comparable to the JDK 8 table: JMH auto-selects
the cheap "compiler" Blackhole mode on 17 but not on 8, so within-run
ratios are the only trustworthy comparison here.
Same controlled rerun as HashtableD1Benchmark. update_hashtable wins
~11.6x (down from ~26x on JDK 8); unlike JDK 8, Hashtable now also
wins clearly on add (~1.8x) and iterate (~3.4x, up from a wash) --
Java 17's allocator/GC narrows the update margin but doesn't flip any
operation in HashMap's favor for D2. Same Blackhole-mode caveat on
absolute numbers as D1; within-run ratios are what's trustworthy here.
Move the static building blocks off the nested Support class onto Hashtable
itself, mirroring ConcurrentHashtable's flat layout, and add
createFixedBuckets(Class, int) factories on Hashtable/D1/D2 for family
symmetry. Support becomes a thin @deprecated facade delegating to the new
statics (retaining the scaled create(int, float)/MAX_RATIO helpers, which have
no blessed equivalent), so client-side-statistics callers keep compiling
untouched. Rename the context type parameter <T> -> <C> on the context-passing
forEach overloads, and add D2.Entry.key1()/key2() accessors to match D1/the
concurrent variant.

No behavior change; pure API relocation + deprecation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point the tests at the relocated static building blocks on Hashtable
(createFixedBuckets, sizeFor, bucketIndex, clear, insertHeadEntry, and
the iterator factories) instead of the now-deprecated Support facade.

Keep a small DeprecatedSupportTests group covering the deprecated-only
scaled create(int, float) + MAX_RATIO, which have no blessed equivalent
and remain in use by client-side statistics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the ConcurrentHashtable fix: an int-typed key hash calling the
overloaded insertHeadEntry(buckets, hash, entry) binds to the int-index
overload instead of widening to long, treating the raw hash as an array
index. Split into insertHeadEntryAt (index-based) and insertHeadEntryFor
(hash-based). Also renames bucket to bucketFor for consistency with
ConcurrentHashtable's naming, even though Hashtable has no competing
int-index overload of bucket today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Capacity is now enforced, not just used to size the bucket array:
insert() returns false, getOrCreate() returns null, and
insertOrReplace() throws once size() reaches the constructor
capacity. A lookup hit is still always returned even at capacity --
only new entries are blocked. Callers wanting their own eviction
policy can drop to Hashtable.Support directly.
getOrCreate() can now return null once TAG_CAPACITY distinct tags
are blocked in a window; record() must null-check it rather than
relying on the table's old unbounded-chaining behavior.
Composers driving the static building blocks directly (e.g. client-side
stats' AggregateTable) currently hand-roll entry-count bookkeeping and
cursor-resumed eviction scans themselves. These give them (and D1/D2, next)
a shared, non-thread-safe primitive for both instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the hand-rolled size/limit int fields with the new shared
SizeTracker -- no behavior change, D1/D2's public API and semantics are
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds unconditional drain (forEach-then-clear-and-reset-size in one
call, plus a context-passing overload) as a static building block on
Hashtable and as instance methods on D1/D2, mirroring
ConcurrentHashtable's drain(Consumer)/drain(context, BiConsumer). The
single-threaded version needs no locking, just a size-tracker reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two questions, at the top of each class, written from that class's side:

  1. Concurrent? -> ConcurrentHashtable, the only thread-safe one.
  2. Otherwise, does the population reset wholesale or evolve? Cleared as
     a unit (per cycle, per request, built-then-discarded) -> the
     open-addressed FlatHashtable, which has no tombstones and so offers
     no removal beyond clearing. Entries coming and going independently
     -> the chained Hashtable, which removes and evicts in place.

Lifetime is the usual shorthand for the second question, and the guide
says where it mis-sorts: a long-lived table that resets on a cycle is a
sequence of short lives and belongs with the short-lived ones. That case
is real -- client-side stats has one table of each shape -- so the guide
describes the two shapes rather than leaving a dev to discover the
exception.

These are the only cross-class references in Hashtable's docs; selection
guidance is the one place a reader needs to know the siblings exist.
Written as {@code} rather than {@link} so nothing dangles at a class
outside this tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Aug 27, 2026
Addresses the review comments on #12312, with the API additions made in
#12101 and percolated here:

  state.sizeManager.size()          -> Hashtable.size(state)
  ... == 0                          -> Hashtable.isEmpty(state)
  bucketFor(state.buckets, hash)    -> bucketFor(state, hash)
  insertHeadEntryFor(state.buckets, ...) -> insertReserved(state, ...)
  forEach(state.buckets, ...)       -> forEach(state, ...)

No reference to state.buckets or state.sizeManager remains -- what State
holds is now its own business. Also drops the field comment that
re-documented the eviction cursor living inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dougqh
dougqh force-pushed the feat/client-side-stats-hashtable-api branch from 2f6f714 to 8435b63 Compare August 27, 2026 00:47
dougqh and others added 6 commits August 27, 2026 12:42
…ller's path

tryGetOrCreate returns null once the table is at capacity, so the natural
read-modify-write spelling

    table.tryGetOrCreate(key, Counter::new).inc();

compiles, tests, and then throws in production under cardinality pressure --
the one condition no unit test covers. Fusing the update keeps that reference
inside the table: at capacity the update is skipped and false is returned.

Delegates to tryGetOrCreate, so the hash is still computed once and there is no
extra work versus doing it by hand. Context-passing overloads take the side-band
value as an argument against a non-capturing BiConsumer, so a counter add does
not allocate a capturing lambda per call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generic context overload boxes on every call, which would make the
counter-accumulate shape allocate where the hand-rolled tryGetOrCreate +
null-check + field-write it replaces did not. ObjLongConsumer closes that
gap for the one shape that motivated tryGetOrUpdate in the first place.

D1 only -- D2 has no caller for it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the 5-fork Java 17 numbers for the State-backed table alongside the
existing tables, and notes that JMH's Blackhole auto-detect picked a
different mode than the previous Java 17 run did on the same JVM build --
so absolute numbers are only comparable within a table.

add_hashtable now loses to HashMap by ~19% rather than being roughly
comparable; update (~3.2x) and iterate (~1.35x) still win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
insertHeadEntryAt has no guard against splicing an Entry into a chain
it is already linked in -- doing so silently produces a self-loop or
a multi-node cycle, which every chain walk in this class (get,
getOrCreate, forEach, and all three iterators) then spins on forever
since none of them detect cycles.
Maybe/MaybeTest/EscapeShapeBenchmark/MaybeUsagePatternsBenchmark, copied
verbatim from the merge-queued PR #12328, so this branch (stacked on
#12101) can add Maybe-returning Hashtable/FlatHashtable methods without
waiting on the queue. Drop this commit's contents in favor of master's
copy once this branch rebases past #12328 landing.
Additive siblings to tryGetOrCreate on Hashtable.D1/D2 and
FlatHashtable.D1/D2, wrapping the existing @Nullable-returning method in
a Maybe rather than changing its signature. Each delegates to the
existing tryGetOrCreate as its sole Maybe#of call site, keeping the
allocation-free shape Maybe's class javadoc requires.

Validates Maybe against a real caller: the client-side-stats PR
(#12312) stacked on top of this one converts CardinalityLimitReporter
to tryGetOrCreateAsMaybe(...).update(...).
dougqh and others added 11 commits August 28, 2026 14:27
… to tryGetOrCreateOrNull

Maybe becomes the primary get-or-create contract on Hashtable.D1/D2 and
FlatHashtable.D1/D2; the raw nullable form survives as an escape hatch under
a less-prominent name. Breaking change is affordable now: CardinalityLimitReporter
is the only production caller and is updated to the renamed OrNull method here
(the fused Maybe-based conversion lands separately in #12312).
Replaces the deprecated Support facade, and the hand-rolled bookkeeping,
with Hashtable.State:

- four fields (buckets, maxAggregates, size, evictCursor) become one
  Hashtable.State<AggregateEntry>
- evictOneStale's cursor-resumed two-pass scan -- the [cursor, length)
  then [0, cursor) walk, plus its helper, ~25 lines -- disappears into
  Hashtable.tryReserveOrEvict, which reserves a slot and only evicts if
  the table is actually full
- expungeStaleAggregates' manual iterator loop becomes evictAll
- clear stops pairing three resets by hand
- the stale test is a static final Predicate<AggregateEntry>, so eviction
  allocates no lambda and needs no cast

Behaviour is unchanged: same cap, same evict-a-stale-entry-or-drop policy
on the miss path, same amortized resumable scan -- that scan just lives
in the primitive now instead of here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The eviction predicate spelled the rule out as hitCount == 0, so the
table had to know how staleness is defined. Moving it onto the entry
leaves the call site reading AggregateEntry::isStale.

That is an unbound instance-method reference, so it still coerces to
Predicate<AggregateEntry> and is still non-capturing -- LambdaMetafactory
links it to one cached instance, same as the lambda it replaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the review comments on #12312, with the API additions made in
#12101 and percolated here:

  state.sizeManager.size()          -> Hashtable.size(state)
  ... == 0                          -> Hashtable.isEmpty(state)
  bucketFor(state.buckets, hash)    -> bucketFor(state, hash)
  insertHeadEntryFor(state.buckets, ...) -> insertReserved(state, ...)
  forEach(state.buckets, ...)       -> forEach(state, ...)

No reference to state.buckets or state.sizeManager remains -- what State
holds is now its own business. Also drops the field comment that
re-documented the eviction cursor living inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Notes why AggregateTable.size() stays exact despite delegating to an
estimate: findOrInsert reserves and links without yielding, so the
reservation window is never observable from outside this class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting evictOneStale left its javadoc behind, where it silently
attached to forEach -- so forEach claimed to unlink stale entries and
linked #evictCursor, a field that no longer exists.

The mechanical half of that text (cursor-resumed two-pass scan, its
amortization) now belongs to Hashtable.tryReserveOrEvict, so it goes.
The domain half is knowledge this class still owns and nothing else
records: why a full table drops the new key instead of evicting an
established one, and why cardinality limiting reduces but does not
eliminate eviction. That moves onto findOrInsert, where the decision is
actually made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing references it any more: this PR moved the last production caller
(AggregateTable) onto the blessed statics, and the facade held no logic
of its own -- every member was a one-line delegate.

Removes 174 lines from Hashtable and the 135-line DeprecatedSupportTests
group, most of which asserted only that a one-liner forwards. The two
members that did have unique behaviour, create(int, float) and
MAX_RATIO, are covered by capacityFor(int, float), which has its own
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the nullable that only ever appears once the tag table is at
capacity -- the shape most likely to ship as a rare production NPE.
The primitive-long overload keeps record() allocation-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… as a method reference

record() now uses tryGetOrCreate(...).update(...) instead of the older
tryGetOrUpdate helper, and the mutator is TagBlockEntry::inc -- an unbound
method reference, non-capturing like the static-final lambda it replaces --
so nothing changes on the allocation front. Added a JMH benchmark as the
acceptance check that steady-state record() stays allocation-free.
Same non-capturing-method-reference reasoning as CardinalityLimitReporter's
ADD_BLOCKED: an unbound method reference is cached the same way a
static final field would be, so the STALE field bought nothing. Also
trims isStale's javadoc now that it no longer needs to justify a
static-field pattern that's gone.
@dougqh
dougqh force-pushed the feat/client-side-stats-hashtable-api branch from 6bc6632 to ab7ec53 Compare August 28, 2026 18:54
dougqh added a commit that referenced this pull request Aug 29, 2026
Additive siblings to tryGetOrCreate on Hashtable.D1/D2 and
FlatHashtable.D1/D2, wrapping the existing @Nullable-returning method in
a Maybe rather than changing its signature. Each delegates to the
existing tryGetOrCreate as its sole Maybe#of call site, keeping the
allocation-free shape Maybe's class javadoc requires.

Validates Maybe against a real caller: the client-side-stats PR
(#12312) stacked on top of this one converts CardinalityLimitReporter
to tryGetOrCreateAsMaybe(...).update(...).
dougqh added a commit that referenced this pull request Aug 29, 2026
… to tryGetOrCreateOrNull

Maybe becomes the primary get-or-create contract on Hashtable.D1/D2 and
FlatHashtable.D1/D2; the raw nullable form survives as an escape hatch under
a less-prominent name. Breaking change is affordable now: CardinalityLimitReporter
is the only production caller and is updated to the renamed OrNull method here
(the fused Maybe-based conversion lands separately in #12312).
@dougqh dougqh changed the title Use the unified Hashtable API in client-side stats Use the unified Hashtable API in client-side stats (perf toolbox) Sep 15, 2026
dougqh added a commit that referenced this pull request Sep 23, 2026
From reviewing the first real consumer (#12312), where each of these was
either reaching into state.buckets or reaching into state.sizeManager to
do something the API should have offered directly:

  size(state) / isEmpty(state)
  bucketFor(state, keyHash)          -- typed, so the chain walk needs no witness
  forEach(state, consumer)           -- and the context-passing overload

Also adds insertReserved(state, keyHash, entry), which links an entry
without touching the count because the caller already holds a
reservation. That is the other half of tryReserveOrEvict, and it is
deliberately a different name from insertHeadEntryFor(State, ...) --
that one reserves as it inserts, so using it after a reservation would
count the entry twice. Splitting them keeps the refuse-before-you-
allocate shape available: reserve, and only build the entry once the
slot is yours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Sep 23, 2026
Additive siblings to tryGetOrCreate on Hashtable.D1/D2 and
FlatHashtable.D1/D2, wrapping the existing @Nullable-returning method in
a Maybe rather than changing its signature. Each delegates to the
existing tryGetOrCreate as its sole Maybe#of call site, keeping the
allocation-free shape Maybe's class javadoc requires.

Validates Maybe against a real caller: the client-side-stats PR
(#12312) stacked on top of this one converts CardinalityLimitReporter
to tryGetOrCreateAsMaybe(...).update(...).
dougqh added a commit that referenced this pull request Sep 23, 2026
… to tryGetOrCreateOrNull

Maybe becomes the primary get-or-create contract on Hashtable.D1/D2 and
FlatHashtable.D1/D2; the raw nullable form survives as an escape hatch under
a less-prominent name. Breaking change is affordable now: CardinalityLimitReporter
is the only production caller and is updated to the renamed OrNull method here
(the fused Maybe-based conversion lands separately in #12312).

This branch has not been deployed

No deployments
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: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant