Skip to content

Add FlatHashtable#11980

Open
dougqh wants to merge 16 commits into
masterfrom
dougqh/flat-hashtable
Open

Add FlatHashtable#11980
dougqh wants to merge 16 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 suitable replacement for both HashMap and TreeMap

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

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

Copy link
Copy Markdown

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 57.33% (+0.04%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 60a69a3 | Docs | Datadog PR Page | Give us feedback!

@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.90 s [-0.2%; +1.4%] (no difference)
startup:insecure-bank:tracing:Agent 12.91 s 13.00 s [-1.3%; +0.0%] (no difference)
startup:petclinic:appsec:Agent 16.95 s 16.43 s [+2.0%; +4.3%] (significantly worse)
startup:petclinic:iast:Agent 16.88 s 16.82 s [-0.3%; +1.1%] (no difference)
startup:petclinic:profiling:Agent 16.01 s 16.86 s [-9.3%; -0.8%] (maybe better)
startup:petclinic:sca:Agent 16.80 s 16.73 s [-0.5%; +1.4%] (no difference)
startup:petclinic:tracing:Agent 16.11 s 15.68 s [-1.5%; +6.9%] (no difference)

Commit: 60a69a39 · 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
…orphism strategies

Marker-only (no enforcement yet): telegraphs the static-polymorphism strategy pattern and gives a future checker targets. @strategy marks strategy types/parameters; @StrategyConsumer marks the higher-order methods that must inline for them to specialize. Contracts live in the javadoc. Applications land in stacked PRs (FlatHashtable first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/strategy-annotation branch from fbb0085 to da64961 Compare July 17, 2026 16:36
dougqh and others added 2 commits July 17, 2026 12:37
…hism helpers

A reusable open-addressed table over self-contained entries (one reference per
slot), designed so callers specialize it via a concrete-typed static-final
Helper that the JIT devirtualizes and inlines (C++-template-style static
polymorphism). Cardinality cap / overflow / size are caller policy; this class
is pure mechanism (capacityFor, create, get, getOrCreate). Includes a
String-key StringHelper that seals a spread hash.

Intended as the shared backing for several open-addressed caches/tables
(per-operation sizing hints, UTF8BytesString caches, etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Force hash collisions via fixed-hash helpers to exercise the linear-probe
paths the original tests missed: probe-past-occupied + match-after-probe (in
both get and getOrCreate), wraparound to the front, and get()'s full-table
wrap-to-null branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/flat-hashtable branch from b9c8860 to 5cb9a38 Compare July 17, 2026 16:44
KeyStrategy (abstract class): hash/matches/hashOf, long hashes for family consistency with Hashtable/ConcurrentHashtable. StringKeyStrategy seals the spread hash; EntryKeyStrategy seals hashOf to a cached Entry.hash (Entry is an optional structure-free base).

CreateStrategy (bespoke @FunctionalInterface): create, cold+per-use, lambda-able. @strategy on both strategy types; @StrategyConsumer on the inlining consumers (get/getOrCreate/insert).

Surface: get/getOrCreate (key-taking) + two insert flavors (Entry-based / KeyStrategy-based) over a shared comparison-free placement core; forEach (+ context variant); hash-filtered read-only iterator (not a StrategyConsumer -- interface-dispatched cold traversal). No removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/flat-hashtable branch from 5cb9a38 to f39ac32 Compare July 17, 2026 16:50
dougqh and others added 9 commits July 17, 2026 13:03
Adds FlatHashtable to SingleThreadedMapBenchmark on the ops it supports (build via comparison-free insert, get, iterate) — its self-contained entry holds the value unboxed vs HashMap<String,Integer>. Fixed-capacity so the table is sized to the key count. Uses the INSTANCE-singleton strategy style (private ctor, lazy class-init).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rison

Adds FlatHashtable to ThreadSafeMapBenchmark: build + concurrent get on a shared, once-published table (lock-free, no volatile) alongside ConcurrentHashMap / volatile-HashMap / synchronizedHashMap. Fixture mirrors SingleThreadedMapBenchmark (self-contained per benchmark).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unter contention)

nextLookupKey used a shared static counter incremented from all @threads(8) — a cache-line-contended race that floors the fastest reads and masks the very differences the benchmark compares (mirrors the per-thread index SingleThreadedMapBenchmark already uses, and the set-benchmark fix in #11721). Maps stay static/shared; only the lookup index goes per-thread via @State(Scope.Thread). Existing Javadoc numbers predate this and need a rerun.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each concrete key strategy carries a static final INSTANCE of its exact type + a private ctor (lazy class-init singleton); call sites use X.INSTANCE, dropping the separate module-level constants. Matches the benchmark fixture and the strategy-class style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…virt

Load two decoy KeyStrategy implementors alongside the real one in both map
benchmarks so KeyStrategy.hash and KeyStrategy.matches each have >=2 concrete
implementors before the hot method compiles. This denies C2 the single-
implementor CHA devirtualization of keyStrat.hash/matches inside get(), so the
steady-state numbers reflect the no-CHA regime rather than a deopt-guarded bet.

Verified with -XX:+PrintInlining (Zulu 21; Java 8/ARM64 is fine for inlining-
decision inspection but not for throughput): with CHA impossible, the concrete
StringKeyStrategy::hash / IntEntryKeyStrategy::matches still inline (hot) and no
type-profile/morphic markers appear on any FlatHashtable/KeyStrategy method. The
devirtualization is therefore exact-type, from the constant INSTANCE propagated
through the inlined get -- the structural monomorphization the @strategy
contract promises, not a CHA or type-profile speculation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tHashtable

Rounds out the FlatHashtable toolbox and adds a case-insensitive map arm to the
comparison benchmark:

- Strings.caseInsensitiveHashCode: a hashCode consistent with
  String.equalsIgnoreCase (same two-way fold as regionMatches(ignoreCase)),
  allocation-free. Reusable primitive; the strategy below composes it.
- FlatHashtable.CaseInsensitiveStringKeyStrategy: case-insensitive sibling of
  StringKeyStrategy, sealing hash to the primitive.
- The table now owns the spread (home()): a golden-ratio Fibonacci mix robust to
  weak/int-derived and full 64-bit hashes alike, so a KeyStrategy returns a plain
  hashCode without pre-mixing. StringKeyStrategy/CI/Entry now carry raw hashes.
- Load-factor control: DEFAULT_LOAD_FACTOR (0.5) / LOW_LOAD_FACTOR (0.25)
  constants plus create(Class,int,float) / capacityFor(int,float); the 2-arg
  forms are kept and delegate to the default.
- resize(...) and resizingInsert(...) (Entry and KeyStrategy flavors): explicit,
  caller-invoked growth for the rare full case, keeping get/insert resize-free on
  the hot path. resizingInsert returns the (possibly new) array either way.

Benchmark: CaseInsensitiveMapBenchmark gains a FlatHashtable arm (dogfooding the
shared strategy + primitive) and a DEFAULT-vs-LOW load-factor pair, and moves the
lookup index per-thread (@State(Scope.Thread)) so the shared counter doesn't floor
the fastest reads. On Zulu 21 the CI FlatHashtable is ~2x the (previously
recommended) TreeMap at the same zero allocation, and matches HashMap's throughput
without HashMap's per-lookup folded-String garbage; LOW_LOAD_FACTOR is a wash for
the fold-dominated CI lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cialize the iterator

Reshape the single KeyStrategy into concern-split roles so each operation asks
only for what it needs, and so the iterator can be specialized without stubbing
out unused methods:

- HashStrategy<E> (hashOf) — entry side: insert / iterator / resize.
- MatchingStrategy<E,K> (matches, + hashKey defaulting to key.hashCode()) — key
  side: get / getOrCreate. A functional interface; override hashKey only for
  custom hashing (e.g. case-insensitive).
- EntryStrategy<E,K> implements both — the do-everything base a full user extends.
- CaseInsensitiveStringStrategy seals hashKey to Strings.caseInsensitiveHashCode;
  StringKeyStrategy/EntryKeyStrategy are gone (the String hash is now the default).

Specialize the iterator via the CacheHelper static-polymorphism move: HashIterator
is an abstract base with final template methods (advanceWith/nextWith) taking the
strategy; StrategyHashIterator holds it in a field (general), EntryHashIterator
feeds the constant Entry-hash singleton so hashOf inlines to entry.hash. The public
iterator(...) API and Iterator<E> return type are unchanged; the call site
specializes by its own monomorphism.

FlatHashtableIteratorBenchmark demonstrates why the specialization is more robust:
@setup poisons the shared hashOf profile with four distinct strategy types, after
which iterate_general (single strategy, looks monomorphic) pays ~2.1x because
HotSpot keeps one per-bci profile shared across all inlining contexts, while
iterate_specialized is immune (constant type-flow, not profile). Unpoisoned the two
tie. Tests and the three map benchmarks updated to the new hierarchy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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
Closes the two jacoco branch gaps the reshape left: resize now runs over a
partially-filled table (exercising the null-slot skip in the rehash loop), and
two iterator tests walk a full colliding table (the match-on-wrapping-slot and
absent-hash walked-whole-table paths in HashIterator.advanceWith). All
FlatHashtable classes are now 100% instruction- and branch-covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh dougqh changed the title Add FlatHashtable open-addressed find-or-create collection Add FlatHashtable Jul 20, 2026

@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: PASS

More details

The FlatHashtable implementation is logically sound across every adversarial scenario tested — full-table iteration with no null sentinel, iterator wrap-around starting at a non-zero home slot, non-ASCII case folding (Turkish ı/İ) staying consistent with equalsIgnoreCase, and resize preserving findability of collided entries. No behavioral regressions or contract violations were found.

Was this helpful? React 👍 or 👎

📊 Validated against 10 scenarios · Open Bits AI session

🤖 Datadog Autotest · Commit ba5e1c6 · What is Autotest? · Any feedback? Reach out in #autotest

@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: ba5e1c6b60

ℹ️ 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/CaseInsensitiveMapBenchmark.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/util/Strings.java
dougqh and others added 2 commits July 20, 2026 11:16
…nsitiveHashCode semantics

- CaseInsensitiveMapBenchmark: _create_flat now mirrors the HashMap/TreeMap builds'
  second loop (8 UPPER_PREFIXES case-insensitive collisions) via getOrCreate — all
  hits, so it does the same probe/match work the maps' overwrite puts do (the
  non-capturing create never fires, nothing allocates). All three create arms now
  run the same 24 ops, so create_* is comparable. Refreshed create numbers
  (create_flatHashtable 2.16M, was an unfair 3.79M measuring only 16 inserts).
- Strings.caseInsensitiveHashCode javadoc: note it folds per-char exactly as
  String.equalsIgnoreCase itself does, so a supplementary case pair (U+10400 /
  U+10428) is treated as distinct by both and the two stay consistent — a
  code-point fold would make the hash inconsistent with equalsIgnoreCase, not more
  correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e + default

Class javadoc: frame fixed capacity as a feature — create() forces you to size
the table (surfacing the cardinality question that, unasked, becomes an
unbounded-growth leak in an embedded agent). It caps rather than churns; growth
is an explicit opt-in. Positions it as the general-purpose default of the family.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Base automatically changed from dougqh/strategy-annotation to master July 20, 2026 20:25

@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: WARN

Executed 12 adversarial scenarios covering capacityFor edge cases, caseInsensitiveHashCode consistency with equalsIgnoreCase across HTTP headers and Unicode (including Turkish-I and German ß), iterator wrap-around on full tables, and mixed-hash probe filtering. One real defect found: capacityFor silently returns Integer.MIN_VALUE (-2^31) for cardinalityLimit >= 536870913 at DEFAULT_LOAD_FACTOR (or >= 268435457 at LOW_LOAD_FACTOR) because Integer.highestOneBit(min-1) << 1 overflows; the caller's subsequent Array.newInstance then throws an opaque NegativeArraySizeException. Fixed with a post-shift guard that throws a clear IllegalArgumentException, and a test case was added to lock in the rejection.

View proposed fix
📊 Validated against 12 scenarios · Open Bits AI session

🤖 Datadog Autotest · Commit 60a69a3 · What is Autotest? · Any feedback? Reach out in #autotest

throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor);
}
int min = (int) Math.ceil(cardinalityLimit / (double) loadFactor);
return Integer.highestOneBit(min - 1) << 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 capacityFor silently returns negative capacity on large cardinalities

Any caller that passes a cardinality > 536870912 (at DEFAULT_LOAD_FACTOR) or > 268435456 (at LOW_LOAD_FACTOR) gets a confusing NegativeArraySizeException from deep inside create(), not a clear argument error. Fixed by adding a post-shift guard.

Assertion details
  • Input: FlatHashtable.capacityFor(536870913, DEFAULT_LOAD_FACTOR) — first cardinality at 0.5 load factor where required capacity (2^31) exceeds Integer.MAX_VALUE
  • Expected: IllegalArgumentException with clear message, or a valid positive power-of-two capacity
  • Actual: Returns Integer.MIN_VALUE (-2147483648); any subsequent Array.newInstance call throws an opaque NegativeArraySizeException rather than a clear precondition violation

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · Any feedback? Reach out in #autotest

Suggested change
return Integer.highestOneBit(min - 1) << 1;
int cap = Integer.highestOneBit(min - 1) << 1;
if (cap <= 0) {
throw new IllegalArgumentException(
"cardinalityLimit " + cardinalityLimit + " at loadFactor " + loadFactor
+ " requires capacity > Integer.MAX_VALUE");
}
return cap;

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