Add FlatHashtable (perf toolbox) - #11980
Conversation
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
8e3862c to
2d79fe8
Compare
2d79fe8 to
07425af
Compare
4a05183 to
fbb0085
Compare
07425af to
b9c8860
Compare
fbb0085 to
da64961
Compare
5cb9a38 to
f39ac32
Compare
Mirror Hashtable.D1/D2 as an entry-centric convenience layer over the (untouched) strategy-based static core: nested D1/D2 hold a table + size and delegate placement/growth to the core, while D1.Entry/D2.Entry carry the key(s), matches(), and cached hash. Chosen at construction via distinct factories rather than an ambiguous (Class, int) constructor: - createFixed(maxCapacity) bounded; getOrCreate caps (returns null) - createGrowable(initialCapacity) doubles on demand; never caps No remove (the open-addressed core has no tombstones); true-map needs are left to a future LightMap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover get/getOrCreate/insert/forEach/clear/size, key() accessors, null-key handling (D1 sentinel; D2 null parts), collision-by-equality, fixed capping (getOrCreate->null, insert->false when full), and growable growth past the initial capacity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Bits Code status: ✅ Done Comment @DataDog to request changes |
… gate FlatHashtable$D2$Entry sat at 50% branch coverage — matches() was only ever exercised on the all-match path, so both short-circuit rejections (key1 differs; key1 equal but key2 differs) were untested, and D2.getOrCreate's growable grow() path was likewise unhit. Both are reachable via the instance API; add a forced composite-hash-collision test (constant-hash CollidingKey) and a growable getOrCreate growth test. D2$Entry 50%->100%; D1/D2 branch to 94.4% (the only residual misses are the provably-unreachable full-table wrap guards in get(), which the free-slot invariant prevents). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Large cardinality budgets (>536870912 at DEFAULT_LOAD_FACTOR, >268435456 at LOW_LOAD_FACTOR) overflow the power-of-two rounding to Integer.MIN_VALUE, which previously surfaced as an opaque NegativeArraySizeException from deep inside create(). Throw a clear IllegalArgumentException at the precondition instead. Flagged by Datadog Autotest on #11980. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
The FlatHashtable creation benchmark does not apply duplicate updates that the HashMap and TreeMap benchmarks apply. This difference makes the creation results invalid.
🤖 Datadog Autotest · Commit 3586400 · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest
amarziali
left a comment
There was a problem hiding this comment.
Review via Claude (automated review). Verified against head
3586400fon OpenJDK 25.
Requesting changes — one blocking issue.
Strings.caseInsensitiveHashCode folds per UTF-16 char, but String.equalsIgnoreCase on JDK 9+ reconstructs supplementary code points before folding. I brute-forced every defined code point against its upper/lower/title variants, keeping only pairs where equalsIgnoreCase returns true:
equalsIgnoreCase-equal pairs checked: 4368
current implementation mismatches: 846
code-point fold mismatches: 0
Through CaseInsensitiveStringStrategy, two equal keys get different home slots — get stops at the first empty slot and false-misses, and getOrCreate silently inserts a duplicate. The method's javadoc asserts the opposite invariant, so it needs rewriting alongside the fix. Details and a validated patch are inline.
Five non-blocking comments are also inline (growth overflow, resizingInsert load factor, racy-publication wording, null-key contract, a broken javadoc link) — none of them block.
The probing, wraparound, full-table termination, resize, and iterator-specialization logic is carefully done and well covered, and the fixed-vs-growable split in D1/D2 makes the boundedness policy explicit at call sites.
On JDK 9+, String.equalsIgnoreCase reconstructs supplementary code points before folding case, so a per-char fold hashes some equalsIgnoreCase-equal pairs (e.g. U+10400 / U+10428) differently. Through CaseInsensitiveStringStrategy this was a silent false miss on FlatHashtable.get, not just a wasted collision. The prior per-char fold was deliberate (see cb9b9f6) based on matching equalsIgnoreCase's own JDK 8 behavior; JDK 9 changed that behavior, invalidating the assumption. Verified empirically against JDK 8/17/25. Keeps an ASCII fast path so the common case stays as cheap as the old per-char loop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rflow table.length << 1 wraps to a negative value once length reaches 1 << 30, which previously surfaced as an opaque NegativeArraySizeException from Array.newInstance deep inside resize()/grow(). Extract the doubling into grownLength(int), guarded like capacityFor's existing overflow check, so the failure is a clear IllegalStateException and the arithmetic is testable without allocating a multi-GB array. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Class javadoc previously implied general lock-free safety; clarify that only the slot reference and an entry's final fields get safe-publication guarantees -- non-final payload fields and race-losing entries built by getOrCreate are not safely published, so concurrent creation of entries with meaningful mutable state needs fully-final entries. Also document that MatchingStrategy#hashKey's default NPEs on a null key, unlike D1.Entry which maps null to a hash sentinel -- the raw core takes no position on null keys and a strategy needing them must handle null itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Referenced a nonexistent CaseInsensitiveStringKeyStrategy; the actual type is FlatHashtable.CaseInsensitiveStringStrategy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
resizingInsert only grows when insert reports the table full, since the raw core carries no live-size counter to compare against a load factor. That sits against the class's load-factor argument (DEFAULT_LOAD_FACTOR/ LOW_LOAD_FACTOR), which sizes a fixed table up front rather than driving insert-time growth. Document the distinction: a caller that sizes up front (the expected usage) never sees this; one that grows an under-sized table from scratch via resizingInsert runs fill past where those load-factor figures apply. D1/D2, which do track size, can and do grow at a load factor of their own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… maps' put() getOrCreate only finds the existing entry on a hit and never updates it, so the mirrored collision loop was skipping the value overwrite that HashMap/TreeMap's put() performs -- giving FlatHashtable's create benchmark a false advantage by doing less work than the arms it's compared against. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
amarziali
left a comment
There was a problem hiding this comment.
Thanks for having addressed the main point. I did not find any other outstanding blocking points.
@amarziali Thanks for the thorough review. |
|
/merge |
|
View all feedbacks in Devflow UI.
The expected merge time in
|
What Does This Do
Adds a light weight map-like FlatHashtable suitable for a variety of use cases
FlatHashtableis often a suitable replacement for bothHashMapandTreeMap.Unlike
Hashtable, can work with arbitrary types not just its ownEntrysubclassesMotivation
Provide a safer, faster, lighter alternative to regular JDK
Map-s. Unlike JDKMap-s,FlatHashtableis bounded in size by default, but the caller can still resize if necessary.FlatHashtable's API is also designed to work with static polymorphism patterns to allow for optimal performance while serving as...Additional Notes
From Claude:
A reusable open-addressed, single-array find-or-create table over self-contained entries — one reference per slot, so entry publication is a single reference store and readers see null-or-complete (no torn reads, no
volatile/atomics when the payload makes a stale/lost read benign). Pure mechanism; cardinality cap / overflow / live-size counting are caller policy.Intended as the general-purpose primitive of the flat-collection family — the one to reach for by default in perf-critical core code (works mutable and immutable, with POJO or
Entryobjects, and with custom hashing/matching). The narrower members are triggered specialists:StringIndex(expert dense name→ID interner), a futureTreeTable(ordered),ConcurrentHashtable(concurrent writers); simple immutable cases stay onSet.copyOf/Map.copyOf.Bounded by construction (a safety primitive, not just a fast one)
createtakes a cardinality budget, so you can't build one without deciding how big it may get — the question whose unasked version becomes an unbounded-growth leak in a long-lived agent embedded in someone else's process. A regularMap's auto-resize lets you forget that (fine when you own the heap; the wrong default when you're a guest). The table never grows on its own —get/getOrCreate/insertcap (a full table degrades to recompute-on-miss with bounded memory, no reallocation); growth is an explicitresize/resizingInsert. It defaults to the bounded-footprint posture the agent needs, with unbounded growth an opt-in you must reach for. (Trade pays when a miss is benign — a cache/interner — not a must-hold-everything map.)Static-polymorphism strategies
Split by concern, each held as a concrete-typed
static finalsingleton the JIT devirtualizes and inlines (one algorithm, one monomorphic instantiation per call site):MatchingStrategy<E,K>— key side (get/getOrCreate):matches(entry, key)+hashKey(key)defaulting tokey.hashCode()(override only for custom hashing, e.g. case-insensitive). A@FunctionalInterface.HashStrategy<E>— entry side (insert/iterator/resize):hashOf(entry). ForEntry-based tables it's the cachedEntry.hash, so those get strategy-free overloads.EntryStrategy<E,K>— both, the abstract base a do-everything user extends.CaseInsensitiveStringStrategy<E>— sealshashKeyto the new, allocation-freeStrings.caseInsensitiveHashCode(consistent withString.equalsIgnoreCase).CreateStrategy<E,K>— cold entry minting (non-capturing lambda / ctor-ref).The table owns the spread (
home— a golden-ratio/Fibonacci mix), so a strategy returns a plainhashCode;hashKey(key)must stay consistent withhashOf(entry).Operations
capacityFor/create(typed backing array) with load-factor control —DEFAULT_LOAD_FACTOR(0.5) /LOW_LOAD_FACTOR(0.25) +(…, float)overloads;get/getOrCreate;insert(Entry and strategy flavors);resize/resizingInsert;forEach;iterator(hash-filtered, specialized via the same static-polymorphism move soEntryiteration inlineshashOf— plainIterator<E>API unchanged).Benchmarks (JMH, Zulu 21)
SingleThreadedMapBenchmark/ThreadSafeMapBenchmark): FlatHashtable vs HashMap/TreeMap/ConcurrentHashMap. A CHA-defeat rig +-XX:+PrintInliningconfirms the strategy calls devirtualize structurally (constantINSTANCE), not via a CHA/type-profile bet. Plaingetruns in the billions of ops/s (cachedString.hashCode, single probe).CaseInsensitiveMapBenchmark: the CI FlatHashtable is ~2× TreeMap at the same zero allocation and matches HashMap's throughput without HashMap's per-lookuptoLowerCasegarbage. (All three create arms do equal work;LOW_LOAD_FACTORis a wash — the char-fold dominates.)FlatHashtableIteratorBenchmark: shows the iterator specialization is robust —@Setuppoisons the sharedhashOftype profile, after which the single-strategy general iterator pays 2.1× (18.1M vs 37.9M ops/s, F5), while the specialized iterator's constant type-flow is immune. Unpoisoned the two tie.Stacked on #11984 (the
@Strategy/@StrategyConsumermarkers). Real callers land separately (tick-tock).🤖 Generated with Claude Code