Skip to content

Commit f3c4c21

Browse files
dougqhclaude
andcommitted
Align StringIndex with the flat-collection family: EmbeddingSupport + capacityFor
Two consistency changes in light of the FlatHashtable strategy family: - Rename Support -> EmbeddingSupport. StringIndex is the LightMap/object-side member (a real object with static factories), and its static-over-raw-arrays tier is exactly the "embed the backing arrays in your own fields" role that LightMap.EmbeddingSupport names. - Replace tableSizeFor with capacityFor(n[, loadFactor]) + DEFAULT_LOAD_FACTOR / LOW_LOAD_FACTOR, mirroring FlatHashtable's sizing (duplicated for now; the two branches are independent, to be unified when the family converges). This also tightens the sizing: the old `while (size <= n)` over-allocated 2x at power-of- two counts (capacityFor(16) is now 32, was 64) while still targeting load factor <= 0.5. capacityFor(0) stays valid (StringIndex allows the empty set). Updates StringIndexTest (sizing expectations + a rejects test) and the three benchmarks referencing the tier. Behavior unchanged except the tighter default table size. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fc89423 commit f3c4c21

5 files changed

Lines changed: 141 additions & 96 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,12 @@ static void fill(Map<String, Integer> map) {
124124
static final int[] SI_VALUES;
125125

126126
static {
127-
StringIndex.Data data = StringIndex.Support.create(INSERTION_KEYS);
127+
StringIndex.Data data = StringIndex.EmbeddingSupport.create(INSERTION_KEYS);
128128
SI_HASHES = data.hashes;
129129
SI_NAMES = data.names;
130130
SI_VALUES = new int[SI_HASHES.length];
131131
for (int i = 0; i < INSERTION_KEYS.length; ++i) {
132-
SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, INSERTION_KEYS[i])] = i;
132+
SI_VALUES[StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, INSERTION_KEYS[i])] = i;
133133
}
134134
}
135135

@@ -276,12 +276,12 @@ public int stringIndex_get_sameKey(Cursor cursor) {
276276

277277
@Benchmark
278278
public int support_get(Cursor cursor) {
279-
return SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey())];
279+
return SI_VALUES[StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey())];
280280
}
281281

282282
@Benchmark
283283
public int support_get_sameKey(Cursor cursor) {
284284
return SI_VALUES[
285-
StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey(INSERTION_KEYS))];
285+
StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey(INSERTION_KEYS))];
286286
}
287287
}

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@
3737
* flat/immutable set comparison.
3838
* <li>{@code stringIndex} — {@link StringIndex#contains} on the instance wrapper (one field load
3939
* 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.
40+
* <li>{@code support} — the same probe via {@link StringIndex.EmbeddingSupport#indexOf} over
41+
* {@code static final} arrays, so the JIT folds the refs to constants and there is nothing to
42+
* dereference (the hot path StringIndex recommends). The {@code stringIndex}/{@code support}
43+
* pair shows the indirection cost of the wrapper.
4444
* </ul>
4545
*
4646
* <p>Lookups are interned (the {@code ==} fast path where a structure has one); misses are short
@@ -115,7 +115,7 @@ static String[] newMisses() {
115115
static final String[] SI_NAMES;
116116

117117
static {
118-
StringIndex.Data data = StringIndex.Support.create(STRINGS);
118+
StringIndex.Data data = StringIndex.EmbeddingSupport.create(STRINGS);
119119
SI_HASHES = data.hashes;
120120
SI_NAMES = data.names;
121121
}
@@ -235,11 +235,11 @@ public boolean stringIndex_miss(Cursor cursor) {
235235

236236
@Benchmark
237237
public boolean support_hit(Cursor cursor) {
238-
return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextHit()) >= 0;
238+
return StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextHit()) >= 0;
239239
}
240240

241241
@Benchmark
242242
public boolean support_miss(Cursor cursor) {
243-
return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextMiss()) >= 0;
243+
return StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextMiss()) >= 0;
244244
}
245245
}

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
* <ul>
2020
* <li>{@code switch} — a hand-written {@code switch(key)} over the literals ({@code hashCode}
2121
* switch + {@code equals}), {@code default} returns 0.
22-
* <li>{@code stringIndex} — {@code IDS[Support.indexOf(HASHES, NAMES, key)]} over {@code static
23-
* final} arrays (a miss returns 0), the folded-constant hot path.
22+
* <li>{@code stringIndex} — {@code IDS[EmbeddingSupport.indexOf(HASHES, NAMES, key)]} over {@code
23+
* static final} arrays (a miss returns 0), the folded-constant hot path.
2424
* </ul>
2525
*
2626
* <p><b>What this measures: two axes.</b> A prior investigation found the {@code TagInterceptor}
@@ -30,8 +30,8 @@
3030
* varied key. The results (below) land the teaching point: the dominant axis is
3131
* <i>key-constancy</i>, not inlining. At steady state the inline-vs-not gap is small for both
3232
* forms; what sinks the switch is a runtime, varied key (it can't specialize), while the
33-
* StringIndex {@code Support} path stays flat across both axes — so the win is largest exactly
34-
* where {@code TagInterceptor} lives.
33+
* StringIndex {@code EmbeddingSupport} path stays flat across both axes — so the win is largest
34+
* exactly where {@code TagInterceptor} lives.
3535
*
3636
* <p>The {@code _inlined} and {@code _noinline} helpers carry duplicate bodies on purpose: that's
3737
* the only way to pin each form's inlining decision independently.
@@ -107,12 +107,13 @@ static String[] newMisses() {
107107
static final int[] IDS;
108108

109109
static {
110-
StringIndex.Data data = StringIndex.Support.create(KEYS);
110+
StringIndex.Data data = StringIndex.EmbeddingSupport.create(KEYS);
111111
HASHES = data.hashes;
112112
NAMES = data.names;
113113
IDS = new int[HASHES.length];
114114
for (int i = 0; i < KEYS.length; ++i) {
115-
IDS[StringIndex.Support.indexOf(HASHES, NAMES, KEYS[i])] = i + 1; // 1-based; 0 = not found
115+
IDS[StringIndex.EmbeddingSupport.indexOf(HASHES, NAMES, KEYS[i])] =
116+
i + 1; // 1-based; 0 = not found
116117
}
117118
}
118119

@@ -224,13 +225,13 @@ static int switchNoInline(String key) {
224225

225226
@CompilerControl(CompilerControl.Mode.INLINE)
226227
static int indexInline(String key) {
227-
int slot = StringIndex.Support.indexOf(HASHES, NAMES, key);
228+
int slot = StringIndex.EmbeddingSupport.indexOf(HASHES, NAMES, key);
228229
return slot >= 0 ? IDS[slot] : 0;
229230
}
230231

231232
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
232233
static int indexNoInline(String key) {
233-
int slot = StringIndex.Support.indexOf(HASHES, NAMES, key);
234+
int slot = StringIndex.EmbeddingSupport.indexOf(HASHES, NAMES, key);
234235
return slot >= 0 ? IDS[slot] : 0;
235236
}
236237

internal-api/src/main/java/datadog/trace/util/StringIndex.java

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
* <p>Two ways to use it, trading convenience for indirection:
1212
*
1313
* <ul>
14-
* <li>{@link Support} — static algorithm over <b>raw arrays</b>. Keep the arrays in your own
15-
* (ideally {@code static final}) fields and the JIT folds the refs to constants. The fastest
16-
* path; nothing to dereference.
14+
* <li>{@link EmbeddingSupport} — static algorithm over <b>raw arrays</b> you <b>embed</b> in your
15+
* own (ideally {@code static final}) fields; the JIT folds the refs to constants. The fastest
16+
* path, nothing to dereference. (Named for the same role as {@code
17+
* LightMap.EmbeddingSupport}: the static ops you reach for when you own the backing arrays
18+
* directly.)
1719
* <li>The {@code StringIndex} <b>instance</b> ({@link #of}) — a convenience wrapper holding the
18-
* arrays; {@link #indexOf}/{@link #contains} delegate to {@link Support}. Costs an
20+
* arrays; {@link #indexOf}/{@link #contains} delegate to {@link EmbeddingSupport}. Costs an
1921
* instance-field load per call (the indirection the static path removes) — fine off the hot
2022
* path.
2123
* </ul>
@@ -25,18 +27,19 @@
2527
* mapIntValues}/{@code mapLongValues} build such an array at construction; {@code lookup}/{@code
2628
* lookupOrDefault} read one back in a single call (slot resolve + array read).
2729
*
28-
* <p>Slot 0-value is the empty sentinel: {@link Support#hash} never returns 0, so {@code hashes[i]
29-
* == 0} unambiguously means an empty slot.
30+
* <p>Slot 0-value is the empty sentinel: {@link EmbeddingSupport#hash} never returns 0, so {@code
31+
* hashes[i] == 0} unambiguously means an empty slot.
3032
*
31-
* <p>Trades memory for simplicity (and, incidentally, speed). The table is 2x-oversized (load
32-
* factor &le; 0.5) so build-time placement always finds a free slot and never has to rehash or
33-
* resize — short probe chains are a welcome side effect, not the design goal. The cached {@code
34-
* int[]} hashes gate {@code equals()}. Both cost memory, so a tightly-packed set is more compact:
35-
* prefer {@link java.util.Set#copyOf} (the JDK's {@code SetN}) when you only need membership, and
36-
* reach for {@code StringIndex} for the {@code indexOf}-&gt;parallel-array (name&rarr;id)
37-
* capability or the hot, allocation-free static {@link Support} path. (If footprint ever matters
38-
* more than build simplicity, a higher load factor with construction-time rehashing would close the
39-
* gap.)
33+
* <p>Trades memory for simplicity (and, incidentally, speed). The table is 2x-oversized ({@link
34+
* EmbeddingSupport#DEFAULT_LOAD_FACTOR} &le; 0.5) so build-time placement always finds a free slot
35+
* and never has to rehash or resize — short probe chains are a welcome side effect, not the design
36+
* goal. The cached {@code int[]} hashes gate {@code equals()}. Both cost memory, so a
37+
* tightly-packed set is more compact: prefer {@link java.util.Set#copyOf} (the JDK's {@code SetN})
38+
* when you only need membership, and reach for {@code StringIndex} for the {@code
39+
* indexOf}-&gt;parallel-array (name&rarr;id) capability or the hot, allocation-free static {@link
40+
* EmbeddingSupport} path. (If footprint matters more than build simplicity, build via {@link
41+
* EmbeddingSupport#capacityFor(int, float)} at a higher load factor — placement still finds a slot
42+
* at any factor &lt; 1, so no rehash is needed.)
4043
*/
4144
public final class StringIndex {
4245
private final int[] hashes;
@@ -48,16 +51,19 @@ private StringIndex(int[] hashes, String[] names) {
4851
}
4952

5053
/**
51-
* Convenience instance — wraps the placed arrays. For the hot path prefer raw {@link Support}.
54+
* Convenience instance — wraps the placed arrays. For the hot path prefer raw {@link
55+
* EmbeddingSupport}.
5256
*/
5357
public static StringIndex of(String... names) {
54-
Data data = Support.create(names);
58+
Data data = EmbeddingSupport.create(names);
5559
return new StringIndex(data.hashes, data.names);
5660
}
5761

58-
/** Slot of {@code name}, or -1. Delegates to {@link Support} on the instance's arrays. */
62+
/**
63+
* Slot of {@code name}, or -1. Delegates to {@link EmbeddingSupport} on the instance's arrays.
64+
*/
5965
public int indexOf(String name) {
60-
return Support.indexOf(this.hashes, this.names, name);
66+
return EmbeddingSupport.indexOf(this.hashes, this.names, name);
6167
}
6268

6369
public boolean contains(String name) {
@@ -77,49 +83,49 @@ public int numSlots() {
7783
* can't allocate a generic array without it). Pair with {@link #lookup(Object[], String)}.
7884
*/
7985
public <T> T[] mapValues(Class<T> type, Function<String, T> fn) {
80-
return Support.mapValues(this.names, type, fn);
86+
return EmbeddingSupport.mapValues(this.names, type, fn);
8187
}
8288

8389
/** Slot-aligned {@code int[]} of values; absent slots stay 0. See {@link #mapValues}. */
8490
public int[] mapIntValues(ToIntFunction<String> fn) {
85-
return Support.mapIntValues(this.names, fn);
91+
return EmbeddingSupport.mapIntValues(this.names, fn);
8692
}
8793

8894
/** Slot-aligned {@code long[]} of values; absent slots stay 0. See {@link #mapValues}. */
8995
public long[] mapLongValues(ToLongFunction<String> fn) {
90-
return Support.mapLongValues(this.names, fn);
96+
return EmbeddingSupport.mapLongValues(this.names, fn);
9197
}
9298

9399
// --- lookup: resolve a key and read its parallel value in one call ---
94100

95101
/** {@code data[indexOf(key)]}, or {@code null} when {@code key} is absent. */
96102
public <T> T lookup(T[] data, String key) {
97-
return Support.lookup(this.hashes, this.names, data, key);
103+
return EmbeddingSupport.lookup(this.hashes, this.names, data, key);
98104
}
99105

100106
/** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */
101107
public <T> T lookupOrDefault(T[] data, String key, T defaultValue) {
102-
return Support.lookupOrDefault(this.hashes, this.names, data, key, defaultValue);
108+
return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue);
103109
}
104110

105111
/** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */
106112
public int lookup(int[] data, String key) {
107-
return Support.lookup(this.hashes, this.names, data, key);
113+
return EmbeddingSupport.lookup(this.hashes, this.names, data, key);
108114
}
109115

110116
/** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */
111117
public int lookupOrDefault(int[] data, String key, int defaultValue) {
112-
return Support.lookupOrDefault(this.hashes, this.names, data, key, defaultValue);
118+
return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue);
113119
}
114120

115121
/** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */
116122
public long lookup(long[] data, String key) {
117-
return Support.lookup(this.hashes, this.names, data, key);
123+
return EmbeddingSupport.lookup(this.hashes, this.names, data, key);
118124
}
119125

120126
/** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */
121127
public long lookupOrDefault(long[] data, String key, long defaultValue) {
122-
return Support.lookupOrDefault(this.hashes, this.names, data, key, defaultValue);
128+
return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue);
123129
}
124130

125131
/** Build-time carrier. Pull the fields into your own (static final) fields; don't keep this. */
@@ -136,27 +142,52 @@ public static final class Data {
136142
/**
137143
* Static algorithm over raw arrays. Query helpers take raw arrays, never a Data or a StringIndex.
138144
*/
139-
public static final class Support {
140-
private Support() {}
145+
public static final class EmbeddingSupport {
146+
private EmbeddingSupport() {}
141147

142148
/** Spread of String.hashCode; 0 reserved as the empty sentinel. */
143149
public static int hash(String name) {
144150
int h = name.hashCode(); // cached on String -> field load
145151
return h == 0 ? 0xDD06 : h ^ (h >>> 16);
146152
}
147153

148-
/** Power-of-two size, 2x-oversized so load factor stays &lt;= 0.5. */
149-
public static int tableSizeFor(int n) {
150-
int size = 1;
151-
while (size <= n) {
152-
size <<= 1;
154+
/**
155+
* Balanced default load factor — target fill {@code <= 0.5} ({@code >= 2x} capacity). (Mirrors
156+
* {@code FlatHashtable.DEFAULT_LOAD_FACTOR}; duplicated while the two are separate PRs, to be
157+
* unified when the flat-collection family converges.)
158+
*/
159+
public static final float DEFAULT_LOAD_FACTOR = 0.5f;
160+
161+
/** Sparse load factor — target fill {@code <= 0.25} ({@code >= 4x} capacity). */
162+
public static final float LOW_LOAD_FACTOR = 0.25f;
163+
164+
/** Power-of-two capacity for {@code n} names at the {@link #DEFAULT_LOAD_FACTOR}. */
165+
public static int capacityFor(int n) {
166+
return capacityFor(n, DEFAULT_LOAD_FACTOR);
167+
}
168+
169+
/**
170+
* Power-of-two capacity for {@code n} names at {@code loadFactor}: the smallest power of two
171+
* {@code >= ceil(n / loadFactor)} (so the achieved fill is {@code <= loadFactor}). {@code n ==
172+
* 0} yields a minimal 2-slot table (StringIndex allows the empty set, unlike FlatHashtable).
173+
*/
174+
public static int capacityFor(int n, float loadFactor) {
175+
if (n < 0) {
176+
throw new IllegalArgumentException("n must be non-negative: " + n);
177+
}
178+
if (!(loadFactor > 0f && loadFactor < 1f)) {
179+
throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor);
180+
}
181+
if (n == 0) {
182+
return 2; // empty set -> minimal table (one always-empty slot suffices, 2 keeps it pow2)
153183
}
154-
return size << 1;
184+
int min = (int) Math.ceil(n / (double) loadFactor);
185+
return Integer.highestOneBit(min - 1) << 1;
155186
}
156187

157188
/** Build the placed table. Returns a Data carrier; pull its arrays into your own fields. */
158189
public static Data create(String... names) {
159-
int size = tableSizeFor(names.length);
190+
int size = capacityFor(names.length);
160191
int[] hashes = new int[size];
161192
String[] placed = new String[size];
162193
for (String name : names) {

0 commit comments

Comments
 (0)