Skip to content

Add FlatHashtable (perf toolbox) - #11980

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 28 commits into
masterfrom
dougqh/flat-hashtable
Aug 21, 2026
Merged

Add FlatHashtable (perf toolbox)#11980
gh-worker-dd-mergequeue-cf854d[bot] merged 28 commits into
masterfrom
dougqh/flat-hashtable

Conversation

@dougqh

@dougqh dougqh commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Adds a light weight map-like FlatHashtable suitable for a variety of use cases
FlatHashtable is often a suitable replacement for both HashMap and TreeMap.

Unlike Hashtable, can work with arbitrary types not just its own Entry subclasses

Motivation

Provide a safer, faster, lighter alternative to regular JDK Map-s. Unlike JDK Map-s, FlatHashtable is 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...

  • case-insensitive map
  • simple bounded lock free cache

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 Entry objects, and with custom hashing/matching). The narrower members are triggered specialists: StringIndex (expert dense name→ID interner), a future TreeTable (ordered), ConcurrentHashtable (concurrent writers); simple immutable cases stay on Set.copyOf/Map.copyOf.

Bounded by construction (a safety primitive, not just a fast one)

create takes 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 regular Map'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/insert cap (a full table degrades to recompute-on-miss with bounded memory, no reallocation); growth is an explicit resize/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 final singleton 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 to key.hashCode() (override only for custom hashing, e.g. case-insensitive). A @FunctionalInterface.
  • HashStrategy<E> — entry side (insert/iterator/resize): hashOf(entry). For Entry-based tables it's the cached Entry.hash, so those get strategy-free overloads.
  • EntryStrategy<E,K> — both, the abstract base a do-everything user extends.
  • CaseInsensitiveStringStrategy<E> — seals hashKey to the new, allocation-free Strings.caseInsensitiveHashCode (consistent with String.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 plain hashCode; hashKey(key) must stay consistent with hashOf(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 so Entry iteration inlines hashOf — plain Iterator<E> API unchanged).

Benchmarks (JMH, Zulu 21)

  • Map comparisons (SingleThreadedMapBenchmark/ThreadSafeMapBenchmark): FlatHashtable vs HashMap/TreeMap/ConcurrentHashMap. A CHA-defeat rig + -XX:+PrintInlining confirms the strategy calls devirtualize structurally (constant INSTANCE), not via a CHA/type-profile bet. Plain get runs in the billions of ops/s (cached String.hashCode, single probe).
  • CaseInsensitiveMapBenchmark: the CI FlatHashtable is ~2× TreeMap at the same zero allocation and matches HashMap's throughput without HashMap's per-lookup toLowerCase garbage. (All three create arms do equal work; LOW_LOAD_FACTOR is a wash — the char-fold dominates.)
  • FlatHashtableIteratorBenchmark: shows the iterator specialization is robust@Setup poisons the shared hashOf type 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 / @StrategyConsumer markers). Real callers land separately (tick-tock).

🤖 Generated with Claude Code

@dougqh dougqh added comp: core Tracer core tag: no release notes Changes to exclude from release notes type: refactoring tag: ai generated Largely based on code generated by an AI or LLM labels Jul 16, 2026
@datadog-prod-us1-6

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Jul 16, 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 13.99 s 13.87 s [+0.0%; +1.7%] (maybe worse)
startup:insecure-bank:tracing:Agent 12.90 s 13.05 s [-1.8%; -0.5%] (maybe better)
startup:petclinic:appsec:Agent 17.06 s 16.81 s [+0.6%; +2.3%] (maybe worse)
startup:petclinic:iast:Agent 17.01 s 16.99 s [-0.6%; +0.8%] (no difference)
startup:petclinic:profiling:Agent 16.74 s 16.93 s [-2.4%; +0.2%] (no difference)
startup:petclinic:sca:Agent 16.84 s 16.66 s [-0.0%; +2.1%] (no difference)
startup:petclinic:tracing:Agent 16.14 s 16.07 s [-0.4%; +1.2%] (no difference)

Commit: e7eb628a · 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.

@dougqh
dougqh force-pushed the dougqh/flat-hashtable branch from 8e3862c to 2d79fe8 Compare July 17, 2026 15:18
@dougqh
dougqh changed the base branch from master to dougqh/strategy-annotation July 17, 2026 15:18
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
@dougqh
dougqh force-pushed the dougqh/flat-hashtable branch from 2d79fe8 to 07425af Compare July 17, 2026 16:25
Comment thread internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java Outdated
Comment thread internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java Outdated
Comment thread internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java Outdated
@dougqh
dougqh force-pushed the dougqh/strategy-annotation branch from 4a05183 to fbb0085 Compare July 17, 2026 16:28
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
@dougqh
dougqh force-pushed the dougqh/flat-hashtable branch from 07425af to b9c8860 Compare July 17, 2026 16:31
@dougqh
dougqh force-pushed the dougqh/strategy-annotation branch from fbb0085 to da64961 Compare July 17, 2026 16:36
@dougqh
dougqh force-pushed the dougqh/flat-hashtable branch 2 times, most recently from 5cb9a38 to f39ac32 Compare July 17, 2026 16:50
@dougqh
dougqh marked this pull request as ready for review July 20, 2026 12:15
@dougqh
dougqh requested a review from a team as a code owner July 20, 2026 12:15
@dougqh
dougqh requested a review from amarziali July 20, 2026 12:15
@dougqh dougqh changed the title Add FlatHashtable open-addressed find-or-create collection Add FlatHashtable Jul 20, 2026
@dougqh dougqh changed the title Add FlatHashtable Add FlatHashtable (perf toolbox) Jul 29, 2026
dougqh and others added 3 commits July 29, 2026 12:43
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>
@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Jul 29, 2026

Copy link
Copy Markdown

View session in Datadog

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>
Comment thread internal-api/src/main/java/datadog/trace/util/Strings.java
dougqh and others added 2 commits August 19, 2026 11:08
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>
@PerfectSlayer
PerfectSlayer requested review from a team and removed request for a team August 20, 2026 07:16

@datadog-prod-us1-6 datadog-prod-us1-6 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.

Datadog Autotest: FAIL

The FlatHashtable creation benchmark does not apply duplicate updates that the HashMap and TreeMap benchmarks apply. This difference makes the creation results invalid.

Open Bits AI session

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

Comment thread internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java Outdated

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

Review via Claude (automated review). Verified against head 3586400f on 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.

Comment thread internal-api/src/main/java/datadog/trace/util/Strings.java
Comment thread internal-api/src/main/java/datadog/trace/util/Strings.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
Comment thread internal-api/src/main/java/datadog/trace/util/FlatHashtable.java
Comment thread internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java Outdated
dougqh and others added 6 commits August 20, 2026 11:44
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>
@dougqh
dougqh requested a review from amarziali August 21, 2026 13:04

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

Thanks for having addressed the main point. I did not find any other outstanding blocking points.

@dougqh

dougqh commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for having addressed the main point. I did not find any other outstanding blocking points.

@amarziali Thanks for the thorough review.

@dougqh
dougqh added this pull request to the merge queue Aug 21, 2026
@dd-octo-sts

dd-octo-sts Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 21, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-21 21:19:16 UTC ℹ️ Start processing command /merge


2026-08-21 21:19:22 UTC ℹ️ MergeQueue: pull request added to the queue

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


2026-08-21 22:23:51 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 Aug 21, 2026
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 710bee5 into master Aug 21, 2026
590 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the dougqh/flat-hashtable branch August 21, 2026 22:23
@github-actions github-actions Bot added this to the 1.66.0 milestone Aug 21, 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: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants