Conversation
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
force-pushed
the
feat/client-side-stats-hashtable-api
branch
from
August 27, 2026 00:47
2f6f714 to
8435b63
Compare
5 tasks
…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(...).
5 tasks
… 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
force-pushed
the
feat/client-side-stats-hashtable-api
branch
from
August 28, 2026 18:54
6bc6632 to
ab7ec53
Compare
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Does This Do?
Migrates client-side statistics off the deprecated
Hashtable.Supportfacade 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
AggregateTablegoes from 164 to 132 lines, and from four pieces of hand-managed state to one:The biggest deletion is
evictOneStaleand 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: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 inclear, and the iterator loop inexpungeStaleAggregates.STALEis astatic 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:
removeMatchingneeded an explicitHashtable.<AggregateEntry>type witness, becauseTEntryhad nothing to infer from.Predicate<? super Entry>, forcingentry -> ((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.Supportany 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 fromAggregateTableany more🤖 Generated with Claude Code