Add StringIndex: a generic open-addressed string set#11660
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. |
- Consume the StringIndex surface API now in #11660: FIXED_HANDLER_IDS via Support.mapIntValues; handlerId resolves via Support.lookup (id, or 0 on miss -- the not-intercepted sentinel, so no separate slot check). - Make TagInterceptor final (nothing extends it; aids JIT devirtualization). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 767af3de1e
ℹ️ 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".
| } | ||
|
|
||
| // `a` is a stored name on an occupied slot (never null); `b` is a non-null query. | ||
| private static boolean eq(String a, String b) { |
There was a problem hiding this comment.
I wonder how it would compare to a simple call toString.equals that already has the same shortcut check and likely gets inlined.
There was a problem hiding this comment.
Yeah, for the most part, I'd expect it to optimize the same in this case.
Since the JIT knows both are Strings, the equals call can devirtualized and inlined. The only real complicating factor is null handling.
There's an implied a != null before the a.equals call, but that's speculatively eliminated by the JIT.
At this point, I honestly cannot remember. I put most of this together a long time in a prototype. I'm only merging it now because I'll soon have a real use case for it.
There was a problem hiding this comment.
I had Claude run a detailed comparison...
Good question — I benchmarked it rather than guess. JMH on Zulu 21 (HotSpot C2), guarded (a == b || a.equals(b)) vs plain (a.equals(b)), across identity-hit / equal-but-distinct-instance / miss, with equals both inlined and forced non-inlined. Quick directional pass (-f 2):
Inlined — the monomorphic String case, i.e. this code path:
| scenario | guarded | plain |
|---|---|---|
| interned hit | 0.56 | 0.67 |
| distinct hit | 1.79 | 1.78 |
| miss | 1.11 | 1.17 |
→ a wash. distinct-hit and miss are dead even; the interned-hit "edge" is 0.1 ns, inside -f 2 noise. So you're right: when String.equals inlines (which it does for a mono String receiver), its own leading this == anObject already provides the identity fast-path, and the explicit guard adds nothing.
The guard only separates when equals can't inline: on an identity hit it saves ~1.6 ns by short-circuiting the call (0.57 vs 2.17 ns), but it costs ~0.3 ns on miss/distinct (a wasted == before the inevitable call). So it's a narrow bet — it pays only if equals doesn't inline and identity hits dominate.
Bottom line: in this path (mono String, equals inlines) the guard is neither win nor loss — fine to keep as cheap belt-and-suspenders, equally fine to drop for simplicity. The "interned literals hit the == fast path" comment is accurate, but inlined equals already gives that.
(Quick -f 2 pass; happy to firm up with -f 5 if useful.)
— Claude (Claude Code)
There was a problem hiding this comment.
Based on the benchmark above, I decided to drop the reference check.
From Claude...
Decided to drop it — done in 9b6c445. Two reasons:
- The benchmark above showed the
a == bguard is a wash whenequalsinlines, which is this path's regime. - The
eq()helper was itself an extra call in the hot probe, eating intoMaxInlineDepth. Inlining.equals()directly at the probe flattensindexOf → equalsby a level, which makesequalsmore likely to inline — i.e. the exact regime where the guard is moot.
So both the guard and the helper are gone; the probe is now hashes[i] == h && names[i].equals(name). Thanks for the nudge.
— Claude (Claude Code)
bric3
left a comment
There was a problem hiding this comment.
Note, in general the generated comments, are not that readable. So I'd rather have human crafted comments.
E.g this way of aligning words is difficult to process, one sentence would be ok, but since all comments are like that :
* <p>Slot 0-value is the empty sentinel: {@link Support#hash} never returns 0, so {@code hashes[i]
* == 0} unambiguously means an empty slot.
StringIndex: a generic open-addressed string set
There was a problem hiding this comment.
More details
All 166 adversarial scenarios executed against the new StringIndex implementation pass cleanly — empty-string key (remapped hash 0xDD06), empty index, duplicate deduplication, full wraparound probe chains crossing different-hash occupants, parallel long[]/int[]/T[] payloads with realistic tracer tag sets, non-interned string lookups, and typed-array return from mapValues. The open-addressing algorithm (zero sentinel, linear probe, LF ≤ 0.5) is internally consistent and handles every edge case the diff introduces.
📊 Validated against 166 scenarios · Open Bits AI session
🤖 Datadog Autotest · Commit 1d8fd80 · What is Autotest? · Any feedback? Reach out in #autotest
3b9156e to
d2aa48f
Compare
StringIndex is a compact open-addressed string→index structure (the keyOf substrate the dense tag store builds on): parallel hash/name arrays, linear probing, on par with HashSet on lookup at a smaller footprint. Includes unit tests, a footprint test (jol), and comparison benchmarks (vs HashSet/switch). No TagMap changes — standalone util. Rebased onto the level-split stack (consumer #11932) as the layer dense-store sits on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What Does This Do
Adds
StringIndexan open-indexed immutable hash set -- that can also work in conjunction with a separate data array as an immutable map.In terms of ops/sec,
StringIndexperforms on par or better thanHashSetandHashMapwhile consuming less memory.In terms of ops/sec
StringIndexout performsSet.ofbut consumes more memory.StringIndexexcels in situations where map values are primitive types or multiple map values are needed for the same keys.Motivation
Data structure that can be reused throughout dd-trace-java that helps reduce tracer overhead - throughput impact and memory footprint
Additional Notes
StringIndex(datadog.trace.util, alongside the customHashtable) — a flat, allocation-free, open-addressed string set / index:Support— the static algorithm over rawint[] hashes/String[] names. Held instatic finalfields the refs fold to constants (the hot path).Data— a build-time carrier{int[] hashes, String[] names}(pull into your own fields).of/contains/indexOf).2×-oversized (load factor ≤ 0.5), linear probe + wraparound, hash gates
equals, interned==fast path,0= empty sentinel. Generic — no payload baked in; it just knows names. The headline capability isindexOf, which assigns each known string a stable dense slot; consumers attach a parallel array (e.g.long[] ids) indexed by that slot. Membership (contains) falls out asindexOf >= 0.(Renamed from
TagSet— the structure is more general than tags; a fixed name→id / membership index is just one of its uses.)Benchmarks
The motivating use is a fast, declared name→id table — a "pit of success" alternative to hand-rolled string
switches and per-name caches — but the structure is general. Benchmarks (Apple M1, JDK 17,@Fork(5),@Threads(8); M ops/s):ImmutableSetBenchmark(membership, hit): staticSupport2320 ≳HashSet2198 > instanceStringIndex2098 >Set.copyOf/SetN1914, and ~2.5–3.5× thearray/sortedArray/treeSetforms. The foldedSupportpath is the fastest membership structure (~6% overHashSet); the instance wrapper costs ~10% (a field load), landing nearHashSet.ImmutableMapBenchmark(name→valueget): staticSupport1498 (interned key 2081) > instance 1363 >HashMap1216 >Map.copyOf/MapN1049 — StringIndex-as-map is the fastestget, and a parallellong[]/int[]avoids the boxing aHashMap<String, Long>pays.StringIndexSwitchBenchmark(name→id vs a hand-written stringswitch): on a runtime, varied key — the realistic regime — StringIndex is ~1.85× the switch (2147 vs 1161) and flat across inline/key-shape; the switch only matches it when the key is a compile-time constant (the JIT const-folds the switch away), which production keys never are.Footprint (
StringIndexFootprintTest, JOL): ~9% lighter thanHashSet(no per-elementNodes) but ~27% heavier thanSet.copyOf/SetN(it carries the cachedint[]hashes + a 2×-oversized table) — so vs the JDK compact immutables the edge is speed + theindexOf→parallel-array capability, not footprint.StringIndexTestcovers hashing/zero-sentinel, probe + wraparound, table-full, and the parallel-payload usage.🤖 Generated with Claude Code