Skip to content

Commit 1d8fd80

Browse files
dougqhclaude
andcommitted
Add StringIndex: a generic open-addressed string set
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>
1 parent 3b9156e commit 1d8fd80

8 files changed

Lines changed: 1012 additions & 15 deletions

File tree

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ jmh = "1.37"
4242
# Profiling
4343
jmc = "8.1.0"
4444
jafar = "0.16.0"
45+
jol = "0.17"
4546

4647
# Web & Network
4748
jnr-unixsocket = "0.38.25"
@@ -125,6 +126,7 @@ instrument-java = { module = "com.datadoghq:dd-instrument-java", version.ref = "
125126
jmc-common = { module = "org.openjdk.jmc:common", version.ref = "jmc" }
126127
jmc-flightrecorder = { module = "org.openjdk.jmc:flightrecorder", version.ref = "jmc" }
127128
jafar-tools = { module = "io.btrace:jafar-tools", version.ref = "jafar" }
129+
jol-core = { module = "org.openjdk.jol:jol-core", version.ref = "jol" }
128130

129131
# Web & Network
130132
okio = { module = "com.datadoghq.okio:okio", version.ref = "okio" }

internal-api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ dependencies {
281281
testImplementation("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
282282
testImplementation(libs.commons.math)
283283
testImplementation(libs.bundles.mockito)
284+
testImplementation(libs.jol.core)
284285
}
285286

286287
jmh {

internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,55 @@
3333
* 10+, falls back to the input map pre-10). {@code Map.copyOf}/{@code MapN} is the honest
3434
* immutable-map baseline, not {@code HashMap}.
3535
*
36+
* <p>Also compared: {@link StringIndex} used as a string-&gt;int map — an open-addressed index plus
37+
* a slot-aligned {@code int[]} of values ({@code SI_VALUES[indexOf(key)]}). {@code
38+
* stringIndex_get*} goes through the instance wrapper; {@code support_get*} reads via {@code static
39+
* final} arrays (the JIT folds the refs). No {@code iterate} arm — StringIndex is a lookup index,
40+
* not an iteration structure; its map use case is the {@code indexOf}-&gt;parallel-array read.
41+
*
3642
* <p>Lookups use {@code EQUAL_KEYS} (distinct String instances) to exercise {@code equals()};
3743
* {@code *_sameKey} variants reuse the original interned key instances to show the identity fast
3844
* path — which is the common tracer case, since map keys are typically interned tag-name constants.
39-
* (Results pending a fresh multi-JVM run — {@code Map.copyOf} only materializes the compact form on
40-
* Java 10+.)
45+
*
46+
* <p>JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s).
47+
* {@code get} uses distinct keys (exercises {@code equals()}); {@code sameKey} reuses the interned
48+
* key (the {@code ==} fast path — the common tracer case):
49+
*
50+
* <pre>{@code
51+
* Structure get sameKey
52+
* support (static) 1498 2081 (fastest)
53+
* stringIndex (inst) 1363 1900
54+
* hashMap 1216 1850
55+
* linkedHashMap 1214 -
56+
* tagMap 1167 1386
57+
* tracerImmutableMap 1049 1364 (MapN)
58+
* treeMap 656 -
59+
* }</pre>
60+
*
61+
* <p>{@code iterate} (full traversal):
62+
*
63+
* <pre>{@code
64+
* tagMap.forEach 148 (fastest)
65+
* linkedHashMap 136
66+
* tracerImmutableMap 135 (MapN)
67+
* treeMap 134
68+
* hashMap 104
69+
* tagMap (iterator) 96
70+
* }</pre>
71+
*
72+
* <p>Key findings:
73+
*
74+
* <ul>
75+
* <li>StringIndex-as-map ({@code Support}) is the fastest {@code get} — beating {@code HashMap}
76+
* and {@code Map.copyOf}/{@code MapN}, most on the interned path; the instance wrapper trails
77+
* it by ~10%. (vs {@code MapN} the edge is speed + the slot/parallel-array capability, not
78+
* footprint — see {@link ImmutableSetBenchmark}.)
79+
* <li>{@code TagMap.forEach} (148) beats its own {@code iterator} (96) by ~1.5x: TagMap's
80+
* structure makes a faithful external {@code Iterator} expensive (externalized cursor +
81+
* skip-empty + per-call re-entry + the iterator allocation) — all of which internal {@code
82+
* forEach} avoids. Traverse TagMap via {@code forEach}, never its iterator; that gap only
83+
* widens as TagMap's entry model grows.
84+
* </ul>
4185
*/
4286
// @Fork(5): get_tracerImmutableMap* (MapN reached via interface dispatch) is JIT-bimodal at fewer
4387
// forks — 5
@@ -71,12 +115,31 @@ static void fill(Map<String, Integer> map) {
71115
}
72116
}
73117

118+
// StringIndex as a string->int map: an open-addressed index plus a slot-aligned int[] of values
119+
// (VALUES[indexOf(key)]). support_* reads via static final arrays (JIT folds the refs to
120+
// constants); stringIndex_* goes through the instance wrapper. Both share one placement --
121+
// StringIndex.of and Support.create place identically -- so SI_VALUES aligns with either.
122+
static final int[] SI_HASHES;
123+
static final String[] SI_NAMES;
124+
static final int[] SI_VALUES;
125+
126+
static {
127+
StringIndex.Data data = StringIndex.Support.create(INSERTION_KEYS);
128+
SI_HASHES = data.hashes;
129+
SI_NAMES = data.names;
130+
SI_VALUES = new int[SI_HASHES.length];
131+
for (int i = 0; i < INSERTION_KEYS.length; ++i) {
132+
SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, INSERTION_KEYS[i])] = i;
133+
}
134+
}
135+
74136
// Built once, never mutated -- safe to share across the reader threads.
75137
HashMap<String, Integer> hashMap;
76138
LinkedHashMap<String, Integer> linkedHashMap;
77139
TreeMap<String, Integer> treeMap;
78140
TagMap tagMap;
79141
Map<String, Integer> tracerImmutableMap;
142+
StringIndex stringIndex;
80143

81144
@Setup(Level.Trial)
82145
public void setUp() {
@@ -92,6 +155,7 @@ public void setUp() {
92155
}
93156
// JDK compact immutable map (MapN on Java 10+); the agent's actual fixed-map representation.
94157
tracerImmutableMap = CollectionUtils.tryMakeImmutableMap(hashMap);
158+
stringIndex = StringIndex.of(INSERTION_KEYS);
95159
}
96160

97161
/** Per-thread lookup cursor so each reader thread cycles keys independently. */
@@ -199,4 +263,25 @@ public void iterate_tracerImmutableMap(Blackhole blackhole) {
199263
blackhole.consume(entry.getValue());
200264
}
201265
}
266+
267+
@Benchmark
268+
public int stringIndex_get(Cursor cursor) {
269+
return SI_VALUES[stringIndex.indexOf(cursor.nextKey())];
270+
}
271+
272+
@Benchmark
273+
public int stringIndex_get_sameKey(Cursor cursor) {
274+
return SI_VALUES[stringIndex.indexOf(cursor.nextKey(INSERTION_KEYS))];
275+
}
276+
277+
@Benchmark
278+
public int support_get(Cursor cursor) {
279+
return SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey())];
280+
}
281+
282+
@Benchmark
283+
public int support_get_sameKey(Cursor cursor) {
284+
return SI_VALUES[
285+
StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey(INSERTION_KEYS))];
286+
}
202287
}

internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,34 +35,57 @@
3535
* ({@code ImmutableCollections.SetN}), which is what the agent actually uses for fixed config
3636
* sets. Java 10+; falls back to {@code HashSet} pre-10. The realistic baseline for any
3737
* flat/immutable set comparison.
38+
* <li>{@code stringIndex} — {@link StringIndex#contains} on the instance wrapper (one field load
39+
* to reach the placed arrays, then an open-addressed probe).
40+
* <li>{@code support} — the same probe via {@link StringIndex.Support#indexOf} over {@code static
41+
* final} arrays, so the JIT folds the refs to constants and there is nothing to dereference
42+
* (the hot path StringIndex recommends). The {@code stringIndex}/{@code support} pair shows
43+
* the indirection cost of the wrapper.
3844
* </ul>
3945
*
4046
* <p>Lookups are interned (the {@code ==} fast path where a structure has one); misses are short
4147
* and never present.
4248
*
43-
* <p>Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}; M ops/s = millions):
49+
* <p>JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s =
50+
* millions):
4451
*
4552
* <pre>{@code
4653
* Structure hit miss
47-
* hashSet 2159 1751 (fastest)
48-
* tracerImmutableSet 1946 1633 (Set.copyOf / SetN)
49-
* array 926 584
50-
* sortedArray 664 588
51-
* treeSet 642 593
54+
* support (static) 2320 2159 (fastest)
55+
* hashSet 2198 2134
56+
* stringIndex (inst) 2098 1548 * (* miss bimodal -- see caveat)
57+
* tracerImmutableSet 1914 1663 (Set.copyOf / SetN)
58+
* array 941 589
59+
* sortedArray 685 610
60+
* treeSet 657 610
5261
* }</pre>
5362
*
5463
* <p>Key findings:
5564
*
5665
* <ul>
57-
* <li>{@code HashSet} is fastest; {@link java.util.Set#copyOf} ({@code SetN}) trails by only ~10%
58-
* on hit and ~7% on miss — and it's the compact, array-backed form the agent already uses for
59-
* fixed config sets, so it's a strong default when the set is immutable.
60-
* <li>{@code array} / {@code sortedArray} / {@code treeSet} cluster at ~0.6–0.9B — they scan,
61-
* binary-search, or tree-walk per lookup, so they trail the hashed structures, most visibly
62-
* on the miss path.
66+
* <li>The static {@code Support} path is the fastest — it beats {@code HashSet} on hit and miss
67+
* and crushes the scan/search/tree forms.
68+
* <li>{@code stringIndex} (the instance wrapper) trails {@code Support} by the field-load
69+
* indirection (~10% on hit), landing near {@code HashSet} — fine off the hot path, prefer
70+
* {@code Support} on it.
71+
* <li>{@link java.util.Set#copyOf} ({@code SetN}, the agent's compact fixed-set form) is ~1.2x
72+
* behind {@code Support} on hit but the most <i>compact</i> (~27% smaller — no cached hashes,
73+
* no 2x table). So StringIndex's edge over {@code SetN} is speed + the {@code
74+
* indexOf}-&gt;parallel-array capability, not footprint; over {@code HashSet} it wins both.
75+
* <li>{@code array} / {@code sortedArray} / {@code treeSet} trail the hashed structures, most on
76+
* miss.
6377
* </ul>
78+
*
79+
* <p><b>Caveat — the instance {@code stringIndex} miss is bimodal across forks</b> (confirmed at
80+
* {@code @Fork(10)}: 6 forks fast, 4 slow, nothing between). ~60% of forks compile to a fast mode
81+
* (~2000, ≈ {@code support_miss} — the wrapper indirection is then free) and ~40% to a slow mode
82+
* (~1070, ~half); each fork locks one at warmup. So the {@code 1548 ±27%} above is a mode-mix, not
83+
* noise. Cause: C2 hoists the instance field-loads ({@code this.hashes}/{@code names}) out of the
84+
* miss-path probe loop only in the fast mode; the static {@code Support} path const-folds those
85+
* refs and is never bimodal ({@code support_miss} ±0.3%). Prefer {@code Support} where miss latency
86+
* matters.
6487
*/
65-
@Fork(2)
88+
@Fork(5) // 5 forks settle the bimodal stringIndex_miss / interface-dispatch arms (see header)
6689
@Warmup(iterations = 2)
6790
@Measurement(iterations = 3)
6891
@Threads(8)
@@ -84,12 +107,26 @@ static String[] newMisses() {
84107
return misses;
85108
}
86109

110+
// StringIndex static-Support mode: the placed arrays pulled into static final fields, so the JIT
111+
// folds the refs to constants and Support.indexOf has nothing to dereference (the hot path the
112+
// StringIndex class Javadoc recommends). Contrast support_* (these) with stringIndex_* (the
113+
// instance wrapper, one field load) to see the indirection cost.
114+
static final int[] SI_HASHES;
115+
static final String[] SI_NAMES;
116+
117+
static {
118+
StringIndex.Data data = StringIndex.Support.create(STRINGS);
119+
SI_HASHES = data.hashes;
120+
SI_NAMES = data.names;
121+
}
122+
87123
// Built once, never mutated -- safe to share across the reader threads.
88124
String[] array;
89125
String[] sortedArray;
90126
HashSet<String> hashSet;
91127
TreeSet<String> treeSet;
92128
Set<String> tracerImmutableSet;
129+
StringIndex stringIndex;
93130

94131
@Setup(Level.Trial)
95132
public void setUp() {
@@ -99,6 +136,7 @@ public void setUp() {
99136
hashSet = new HashSet<>(Arrays.asList(STRINGS));
100137
treeSet = new TreeSet<>(Arrays.asList(STRINGS));
101138
tracerImmutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(STRINGS));
139+
stringIndex = StringIndex.of(STRINGS);
102140
}
103141

104142
/** Per-thread lookup cursor so each reader thread cycles keys independently. */
@@ -184,4 +222,24 @@ public boolean tracerImmutableSet_hit(Cursor cursor) {
184222
public boolean tracerImmutableSet_miss(Cursor cursor) {
185223
return tracerImmutableSet.contains(cursor.nextMiss());
186224
}
225+
226+
@Benchmark
227+
public boolean stringIndex_hit(Cursor cursor) {
228+
return stringIndex.contains(cursor.nextHit());
229+
}
230+
231+
@Benchmark
232+
public boolean stringIndex_miss(Cursor cursor) {
233+
return stringIndex.contains(cursor.nextMiss());
234+
}
235+
236+
@Benchmark
237+
public boolean support_hit(Cursor cursor) {
238+
return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextHit()) >= 0;
239+
}
240+
241+
@Benchmark
242+
public boolean support_miss(Cursor cursor) {
243+
return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextMiss()) >= 0;
244+
}
187245
}

0 commit comments

Comments
 (0)