diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index 6e99abe89d2..8dff777b30f 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -281,6 +281,7 @@ dependencies { testImplementation("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}") testImplementation(libs.commons.math) testImplementation(libs.bundles.mockito) + testImplementation("org.openjdk.jol:jol-core:0.17") } jmh { diff --git a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java index e42a67ec9ea..46fabf7a312 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -33,11 +33,55 @@ * 10+, falls back to the input map pre-10). {@code Map.copyOf}/{@code MapN} is the honest * immutable-map baseline, not {@code HashMap}. * + *
Also compared: {@link StringIndex} used as a string->int map — an open-addressed index plus + * a slot-aligned {@code int[]} of values ({@code SI_VALUES[indexOf(key)]}). {@code + * stringIndex_get*} goes through the instance wrapper; {@code support_get*} reads via {@code static + * final} arrays (the JIT folds the refs). No {@code iterate} arm — StringIndex is a lookup index, + * not an iteration structure; its map use case is the {@code indexOf}->parallel-array read. + * *
Lookups use {@code EQUAL_KEYS} (distinct String instances) to exercise {@code equals()}; * {@code *_sameKey} variants reuse the original interned key instances to show the identity fast * path — which is the common tracer case, since map keys are typically interned tag-name constants. - * (Results pending a fresh multi-JVM run — {@code Map.copyOf} only materializes the compact form on - * Java 10+.) + * + *
JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s). + * {@code get} uses distinct keys (exercises {@code equals()}); {@code sameKey} reuses the interned + * key (the {@code ==} fast path — the common tracer case): + * + *
{@code
+ * Structure get sameKey
+ * support (static) 1498 2081 (fastest)
+ * stringIndex (inst) 1363 1900
+ * hashMap 1216 1850
+ * linkedHashMap 1214 -
+ * tagMap 1167 1386
+ * tracerImmutableMap 1049 1364 (MapN)
+ * treeMap 656 -
+ * }
+ *
+ * {@code iterate} (full traversal): + * + *
{@code
+ * tagMap.forEach 148 (fastest)
+ * linkedHashMap 136
+ * tracerImmutableMap 135 (MapN)
+ * treeMap 134
+ * hashMap 104
+ * tagMap (iterator) 96
+ * }
+ *
+ * Key findings: + * + *
Lookups are interned (the {@code ==} fast path where a structure has one); misses are short * and never present. * - *
Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}; M ops/s = millions): + *
JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s = + * millions): * *
{@code
* Structure hit miss
- * hashSet 2159 1751 (fastest)
- * tracerImmutableSet 1946 1633 (Set.copyOf / SetN)
- * array 926 584
- * sortedArray 664 588
- * treeSet 642 593
+ * support (static) 2320 2159 (fastest)
+ * hashSet 2198 2134
+ * stringIndex (inst) 2098 1548 * (* miss bimodal -- see caveat)
+ * tracerImmutableSet 1914 1663 (Set.copyOf / SetN)
+ * array 941 589
+ * sortedArray 685 610
+ * treeSet 657 610
* }
*
* Key findings: * *
Caveat — the instance {@code stringIndex} miss is bimodal across forks (confirmed at
+ * {@code @Fork(10)}: 6 forks fast, 4 slow, nothing between). ~60% of forks compile to a fast mode
+ * (~2000, ≈ {@code support_miss} — the wrapper indirection is then free) and ~40% to a slow mode
+ * (~1070, ~half); each fork locks one at warmup. So the {@code 1548 ±27%} above is a mode-mix, not
+ * noise. Cause: C2 hoists the instance field-loads ({@code this.hashes}/{@code names}) out of the
+ * miss-path probe loop only in the fast mode; the static {@code Support} path const-folds those
+ * refs and is never bimodal ({@code support_miss} ±0.3%). Prefer {@code Support} where miss latency
+ * matters.
*/
-@Fork(2)
+@Fork(5) // 5 forks settle the bimodal stringIndex_miss / interface-dispatch arms (see header)
@Warmup(iterations = 2)
@Measurement(iterations = 3)
@Threads(8)
@@ -84,12 +109,26 @@ static String[] newMisses() {
return misses;
}
+ // StringIndex static-Support mode: the placed arrays pulled into static final fields, so the JIT
+ // folds the refs to constants and Support.indexOf has nothing to dereference (the hot path the
+ // StringIndex class Javadoc recommends). Contrast support_* (these) with stringIndex_* (the
+ // instance wrapper, one field load) to see the indirection cost.
+ static final int[] SI_HASHES;
+ static final String[] SI_NAMES;
+
+ static {
+ StringIndex.Data data = StringIndex.Support.create(STRINGS);
+ SI_HASHES = data.hashes;
+ SI_NAMES = data.names;
+ }
+
// Built once, never mutated -- safe to share across the reader threads.
String[] array;
String[] sortedArray;
HashSet Compared:
+ *
+ * What this measures: two axes. A prior investigation found the {@code TagInterceptor}
+ * switch wasn't being inlined / specialized into its hot caller. So each form is measured across
+ * (a) inlining — {@code _inlined} vs {@code _noinline} (a real call, {@code TagInterceptor}'s
+ * actual regime) via {@link CompilerControl} — and (b) key shape — a constant key vs a runtime,
+ * varied key. The results (below) land the teaching point: the dominant axis is
+ * key-constancy, not inlining. At steady state the inline-vs-not gap is small for both
+ * forms; what sinks the switch is a runtime, varied key (it can't specialize), while the
+ * StringIndex {@code Support} path stays flat across both axes — so the win is largest exactly
+ * where {@code TagInterceptor} lives.
+ *
+ * The {@code _inlined} and {@code _noinline} helpers carry duplicate bodies on purpose: that's
+ * the only way to pin each form's inlining decision independently.
+ *
+ * {@code @Threads(8)}; read-only, so no store dilutes the signal. Hit keys are the interned
+ * literals (the {@code ==} fast path StringIndex and the switch both get); misses are distinct and
+ * never present. Run via {@code -Pjmh.includes=StringIndexSwitchBenchmark} (add {@code -prof gc} —
+ * should be ~0 B/op both ways; this proves throughput, not allocation).
+ *
+ * JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s,
+ * ±1–5%):
+ *
+ * Two takeaways:
+ *
+ * So the {@code const} arm is the control: it exposes the switch's "fast" as a single-key
+ * specialization artifact — drop the constant and the switch is ~half StringIndex's throughput.
+ */
+@Fork(5) // matches the documented @Fork(5) numbers; the switch's const-key arm is profile-bimodal
+@Warmup(iterations = 2)
+@Measurement(iterations = 3)
+@Threads(8)
+@State(Scope.Benchmark)
+public class StringIndexSwitchBenchmark {
+ static final String[] KEYS = {
+ "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
+ "india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa"
+ };
+
+ // A compile-time-constant hit key. javac inlines it, so the JIT can constant-propagate it into an
+ // inlined switch and fold the whole switch away -- the switch's theoretical ceiling. The const_*
+ // arms pair this with INLINE vs DONT_INLINE to show that ceiling only materializes when the call
+ // ALSO inlines: across a DONT_INLINE boundary the constant can't propagate in, so the switch runs
+ // in full. TagInterceptor's real regime is a runtime tag through a non-inlined call -- neither
+ // holds -- which is why StringIndex wins where it counts.
+ static final String CONST_KEY = "mike";
+
+ /** Distinct String instances that are never present, for the miss path. */
+ static final String[] MISSES = newMisses();
+
+ static String[] newMisses() {
+ String[] misses = new String[KEYS.length * 2];
+ for (int i = 0; i < misses.length; ++i) {
+ misses[i] = "dne-" + i;
+ }
+ return misses;
+ }
+
+ // StringIndex placed arrays + slot-aligned ids, pulled into static final fields so the JIT folds
+ // the refs to constants (the hot path StringIndex recommends). IDS[slot] is the 1-based id;
+ // empty slots stay 0, which doubles as the "not found" sentinel.
+ static final int[] HASHES;
+ static final String[] NAMES;
+ static final int[] IDS;
+
+ static {
+ StringIndex.Data data = StringIndex.Support.create(KEYS);
+ HASHES = data.hashes;
+ NAMES = data.names;
+ IDS = new int[HASHES.length];
+ for (int i = 0; i < KEYS.length; ++i) {
+ IDS[StringIndex.Support.indexOf(HASHES, NAMES, KEYS[i])] = i + 1; // 1-based; 0 = not found
+ }
+ }
+
+ /** Per-thread cursors so threads don't contend on a shared index under {@code @Threads(8)}. */
+ @State(Scope.Thread)
+ public static class Cursor {
+ int hit = 0;
+ int miss = 0;
+
+ String nextHit() {
+ int i = hit + 1;
+ if (i >= KEYS.length) {
+ i = 0;
+ }
+ hit = i;
+ return KEYS[i];
+ }
+
+ String nextMiss() {
+ int i = miss + 1;
+ if (i >= MISSES.length) {
+ i = 0;
+ }
+ miss = i;
+ return MISSES[i];
+ }
+ }
+
+ @CompilerControl(CompilerControl.Mode.INLINE)
+ static int switchInline(String key) {
+ switch (key) {
+ case "alpha":
+ return 1;
+ case "bravo":
+ return 2;
+ case "charlie":
+ return 3;
+ case "delta":
+ return 4;
+ case "echo":
+ return 5;
+ case "foxtrot":
+ return 6;
+ case "golf":
+ return 7;
+ case "hotel":
+ return 8;
+ case "india":
+ return 9;
+ case "juliet":
+ return 10;
+ case "kilo":
+ return 11;
+ case "lima":
+ return 12;
+ case "mike":
+ return 13;
+ case "november":
+ return 14;
+ case "oscar":
+ return 15;
+ case "papa":
+ return 16;
+ default:
+ return 0;
+ }
+ }
+
+ // Duplicate body, pinned non-inlinable -- TagInterceptor's actual call regime.
+ @CompilerControl(CompilerControl.Mode.DONT_INLINE)
+ static int switchNoInline(String key) {
+ switch (key) {
+ case "alpha":
+ return 1;
+ case "bravo":
+ return 2;
+ case "charlie":
+ return 3;
+ case "delta":
+ return 4;
+ case "echo":
+ return 5;
+ case "foxtrot":
+ return 6;
+ case "golf":
+ return 7;
+ case "hotel":
+ return 8;
+ case "india":
+ return 9;
+ case "juliet":
+ return 10;
+ case "kilo":
+ return 11;
+ case "lima":
+ return 12;
+ case "mike":
+ return 13;
+ case "november":
+ return 14;
+ case "oscar":
+ return 15;
+ case "papa":
+ return 16;
+ default:
+ return 0;
+ }
+ }
+
+ @CompilerControl(CompilerControl.Mode.INLINE)
+ static int indexInline(String key) {
+ int slot = StringIndex.Support.indexOf(HASHES, NAMES, key);
+ return slot >= 0 ? IDS[slot] : 0;
+ }
+
+ @CompilerControl(CompilerControl.Mode.DONT_INLINE)
+ static int indexNoInline(String key) {
+ int slot = StringIndex.Support.indexOf(HASHES, NAMES, key);
+ return slot >= 0 ? IDS[slot] : 0;
+ }
+
+ @Benchmark
+ public int switch_hit_inlined(Cursor cursor) {
+ return switchInline(cursor.nextHit());
+ }
+
+ @Benchmark
+ public int switch_miss_inlined(Cursor cursor) {
+ return switchInline(cursor.nextMiss());
+ }
+
+ @Benchmark
+ public int switch_hit_noinline(Cursor cursor) {
+ return switchNoInline(cursor.nextHit());
+ }
+
+ @Benchmark
+ public int switch_miss_noinline(Cursor cursor) {
+ return switchNoInline(cursor.nextMiss());
+ }
+
+ @Benchmark
+ public int stringIndex_hit_inlined(Cursor cursor) {
+ return indexInline(cursor.nextHit());
+ }
+
+ @Benchmark
+ public int stringIndex_miss_inlined(Cursor cursor) {
+ return indexInline(cursor.nextMiss());
+ }
+
+ @Benchmark
+ public int stringIndex_hit_noinline(Cursor cursor) {
+ return indexNoInline(cursor.nextHit());
+ }
+
+ @Benchmark
+ public int stringIndex_miss_noinline(Cursor cursor) {
+ return indexNoInline(cursor.nextMiss());
+ }
+
+ // --- constant key: the switch's best case (const-propagated). Inlined -> folds away; not-inlined
+ // -> the constant can't cross the boundary, so the switch runs in full. ---
+
+ @Benchmark
+ public int switch_const_inlined() {
+ return switchInline(CONST_KEY);
+ }
+
+ @Benchmark
+ public int switch_const_noinline() {
+ return switchNoInline(CONST_KEY);
+ }
+
+ @Benchmark
+ public int stringIndex_const_inlined() {
+ return indexInline(CONST_KEY);
+ }
+
+ @Benchmark
+ public int stringIndex_const_noinline() {
+ return indexNoInline(CONST_KEY);
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/util/StringIndex.java b/internal-api/src/main/java/datadog/trace/util/StringIndex.java
new file mode 100644
index 00000000000..ec16b51dd3e
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java
@@ -0,0 +1,297 @@
+package datadog.trace.util;
+
+import java.lang.reflect.Array;
+import java.util.function.Function;
+import java.util.function.ToIntFunction;
+import java.util.function.ToLongFunction;
+
+/**
+ * Flat open-addressed name set. Generic — it knows only names.
+ *
+ * Three ways to use it, trading convenience for indirection:
+ *
+ * Consumers attach their own parallel payload arrays (ids, values, ...) sized to {@link #slots}
+ * and indexed by the slot {@code indexOf} returns. {@code mapValues}/{@code mapIntValues}/{@code
+ * mapLongValues} build such an array at construction; {@code lookup}/{@code lookupOrDefault} read
+ * one back in a single call (slot resolve + array read).
+ *
+ * Slot 0-value is the empty sentinel: {@link Support#hash} never returns 0, so {@code hashes[i]
+ * == 0} unambiguously means an empty slot.
+ *
+ * Trades memory for simplicity (and, incidentally, speed). The table is 2x-oversized (load
+ * factor ≤ 0.5) so build-time placement always finds a free slot and never has to rehash or
+ * resize — short probe chains are a welcome side effect, not the design goal. The cached {@code
+ * int[]} hashes gate {@code equals()}. Both cost memory, so a tightly-packed set is more compact:
+ * prefer {@link java.util.Set#copyOf} (the JDK's {@code SetN}) when you only need membership, and
+ * reach for {@code StringIndex} for the {@code indexOf}->parallel-array (name→id)
+ * capability or the hot, allocation-free static {@link Support} path. (If footprint ever matters
+ * more than build simplicity, a higher load factor with construction-time rehashing would close the
+ * gap.)
+ */
+public final class StringIndex {
+ private final int[] hashes;
+ private final String[] names;
+ public final int slots; // == hashes.length
+
+ private StringIndex(int[] hashes, String[] names) {
+ this.hashes = hashes;
+ this.names = names;
+ this.slots = hashes.length;
+ }
+
+ /**
+ * Convenience instance — wraps the placed arrays. For the hot path prefer raw {@link Support}.
+ */
+ public static StringIndex of(String... names) {
+ Data data = Support.create(names);
+ return new StringIndex(data.hashes, data.names);
+ }
+
+ /** Slot of {@code name}, or -1. Delegates to {@link Support} on the instance's arrays. */
+ public int indexOf(String name) {
+ return Support.indexOf(this.hashes, this.names, name);
+ }
+
+ public boolean contains(String name) {
+ return indexOf(name) >= 0;
+ }
+
+ /** Table size — allocate parallel payload arrays of this length. */
+ public int slots() {
+ return this.slots;
+ }
+
+ // --- value mapping: build a slot-aligned parallel array (off the hot path) ---
+
+ /**
+ * Builds a slot-aligned {@code T[]} of values: {@code out[indexOf(name)] == fn.apply(name)} for
+ * every indexed name; other slots stay {@code null}. {@code type} is the array element type (Java
+ * can't allocate a generic array without it). Pair with {@link #lookup(Object[], String)}.
+ */
+ public All structures hold the same String instances, so the shared strings cancel out and the
+ * differences reflect structural overhead. We report total retained bytes and the overhead above a
+ * plain {@code String[]} (which is just the strings + a reference array). {@code Set.copyOf} yields
+ * the JDK's compact {@code SetN} only on Java 10+ (it falls back to {@code HashSet} pre-10), so the
+ * copyOf row is only meaningful on a 10+ test JVM.
+ *
+ * The one robust cross-JVM invariant we assert is that {@code StringIndex} is lighter than
+ * {@code HashSet} (no per-element {@code Node} objects). The {@code StringIndex} vs {@code SetN}
+ * comparison is left as reported data rather than an assertion: {@code StringIndex} caches an
+ * {@code int[]} of hashes that {@code SetN} does not, so which one wins on bytes is genuinely worth
+ * measuring.
+ *
+ * Measured retained bytes (Java 17, JOL estimate mode — relative ordering reliable, exact bytes
+ * approximate):
+ *
+ *
+ *
+ *
+ * {@code
+ * key switch (inl / noinl) stringIndex (inl / noinl)
+ * const 2778 / 2769 2047 / 2035
+ * hit (runtime) 1161 / 1166 2147 / 2152
+ * miss 2083 / 2050 2546 / 2539
+ * }
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * {@code
+ * n array hashSet treeSet copyOf stringIndex
+ * 8 496 864 848 552 760
+ * 32 1936 3168 3152 2088 2872
+ * 128 7696 12384 12368 8232 11320
+ * }
+ *
+ * Finding: {@code StringIndex} is ~9% lighter than {@code HashSet}/{@code TreeSet} (no per-element
+ * {@code Node} objects), but {@code Set.copyOf} ({@code SetN}) is the most compact by a wide margin
+ * (~27% under {@code StringIndex} at n=128) — {@code StringIndex} pays for its cached {@code int[]}
+ * hashes and 2x-oversized {@code String[]}. So {@code StringIndex}'s edge over {@code SetN} is
+ * speed and the {@code indexOf}->parallel-array capability, not footprint.
+ */
+class StringIndexFootprintTest {
+
+ static String[] elements(int n) {
+ String[] a = new String[n];
+ for (int i = 0; i < n; ++i) {
+ a[i] = "element-key-" + i;
+ }
+ return a;
+ }
+
+ static long bytes(Object root) {
+ return GraphLayout.parseInstance(root).totalSize();
+ }
+
+ @Test
+ void footprintComparison() {
+ System.out.printf(
+ "%-6s %12s %12s %12s %12s %12s%n",
+ "n", "array", "hashSet", "treeSet", "copyOf", "stringIndex");
+ System.out.printf(
+ "%-6s %12s %12s %12s %12s %12s (overhead above array)%n", "", "", "", "", "", "");
+
+ for (int n : new int[] {8, 32, 128}) {
+ String[] el = elements(n);
+
+ long array = bytes((Object) el); // baseline: strings + reference array
+ long hashSet = bytes(new HashSet<>(Arrays.asList(el)));
+ long treeSet = bytes(new TreeSet<>(Arrays.asList(el)));
+ Set