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: + * + *

*/ // @Fork(5): get_tracerImmutableMap* (MapN reached via interface dispatch) is JIT-bimodal at fewer // forks — 5 @@ -71,12 +115,31 @@ static void fill(Map map) { } } + // StringIndex as a string->int map: an open-addressed index plus a slot-aligned int[] of values + // (VALUES[indexOf(key)]). support_* reads via static final arrays (JIT folds the refs to + // constants); stringIndex_* goes through the instance wrapper. Both share one placement -- + // StringIndex.of and Support.create place identically -- so SI_VALUES aligns with either. + static final int[] SI_HASHES; + static final String[] SI_NAMES; + static final int[] SI_VALUES; + + static { + StringIndex.Data data = StringIndex.Support.create(INSERTION_KEYS); + SI_HASHES = data.hashes; + SI_NAMES = data.names; + SI_VALUES = new int[SI_HASHES.length]; + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, INSERTION_KEYS[i])] = i; + } + } + // Built once, never mutated -- safe to share across the reader threads. HashMap hashMap; LinkedHashMap linkedHashMap; TreeMap treeMap; TagMap tagMap; Map tracerImmutableMap; + StringIndex stringIndex; @Setup(Level.Trial) public void setUp() { @@ -92,6 +155,7 @@ public void setUp() { } // JDK compact immutable map (MapN on Java 10+); the agent's actual fixed-map representation. tracerImmutableMap = CollectionUtils.tryMakeImmutableMap(hashMap); + stringIndex = StringIndex.of(INSERTION_KEYS); } /** Per-thread lookup cursor so each reader thread cycles keys independently. */ @@ -199,4 +263,25 @@ public void iterate_tracerImmutableMap(Blackhole blackhole) { blackhole.consume(entry.getValue()); } } + + @Benchmark + public int stringIndex_get(Cursor cursor) { + return SI_VALUES[stringIndex.indexOf(cursor.nextKey())]; + } + + @Benchmark + public int stringIndex_get_sameKey(Cursor cursor) { + return SI_VALUES[stringIndex.indexOf(cursor.nextKey(INSERTION_KEYS))]; + } + + @Benchmark + public int support_get(Cursor cursor) { + return SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey())]; + } + + @Benchmark + public int support_get_sameKey(Cursor cursor) { + return SI_VALUES[ + StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey(INSERTION_KEYS))]; + } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java index 54b27604f3d..478fb4852d2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -35,34 +35,59 @@ * ({@code ImmutableCollections.SetN}), which is what the agent actually uses for fixed config * sets. Java 10+; falls back to {@code HashSet} pre-10. The realistic baseline for any * flat/immutable set comparison. + *
  • {@code stringIndex} — {@link StringIndex#contains} on the instance wrapper (one field load + * to reach the placed arrays, then an open-addressed probe). + *
  • {@code support} — the same probe via {@link StringIndex.Support#indexOf} over {@code static + * final} arrays, so the JIT folds the refs to constants and there is nothing to dereference + * (the hot path StringIndex recommends). The {@code stringIndex}/{@code support} pair shows + * the indirection cost of the wrapper. * * *

    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: * *

      - *
    • {@code HashSet} is fastest; {@link java.util.Set#copyOf} ({@code SetN}) trails by only ~10% - * on hit and ~7% on miss — and it's the compact, array-backed form the agent already uses for - * fixed config sets, so it's a strong default when the set is immutable. - *
    • {@code array} / {@code sortedArray} / {@code treeSet} cluster at ~0.6–0.9B — they scan, - * binary-search, or tree-walk per lookup, so they trail the hashed structures, most visibly - * on the miss path. + *
    • The static {@code Support} path is the fastest membership structure — it beats {@code + * HashSet} on both hit and miss, and crushes the scan/search/tree forms. + *
    • {@code stringIndex} (the instance wrapper) trails {@code Support} by the field-load + * indirection (~10% on hit), landing near {@code HashSet}. Off the hot path that cost is + * fine; on it, prefer {@code Support}. + *
    • {@link java.util.Set#copyOf} ({@code SetN}, the compact array-backed form the agent uses + * for fixed config sets) is ~1.2x behind {@code Support} on hit — but it is the most + * compact (~27% smaller than StringIndex per the JOL test): StringIndex pays for its + * cached {@code int[]} hashes + the 2x-oversized table. So StringIndex's edge over {@code + * SetN} is speed + the {@code indexOf}->parallel-array capability, NOT footprint; vs + * {@code HashSet} it wins on both. + *
    • {@code array} / {@code sortedArray} / {@code treeSet} scan, binary-search, or tree-walk per + * lookup, so they trail the hashed structures, most visibly on miss. *
    + * + *

    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 hashSet; TreeSet treeSet; Set tracerImmutableSet; + StringIndex stringIndex; @Setup(Level.Trial) public void setUp() { @@ -99,6 +138,7 @@ public void setUp() { hashSet = new HashSet<>(Arrays.asList(STRINGS)); treeSet = new TreeSet<>(Arrays.asList(STRINGS)); tracerImmutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(STRINGS)); + stringIndex = StringIndex.of(STRINGS); } /** Per-thread lookup cursor so each reader thread cycles keys independently. */ @@ -184,4 +224,24 @@ public boolean tracerImmutableSet_hit(Cursor cursor) { public boolean tracerImmutableSet_miss(Cursor cursor) { return tracerImmutableSet.contains(cursor.nextMiss()); } + + @Benchmark + public boolean stringIndex_hit(Cursor cursor) { + return stringIndex.contains(cursor.nextHit()); + } + + @Benchmark + public boolean stringIndex_miss(Cursor cursor) { + return stringIndex.contains(cursor.nextMiss()); + } + + @Benchmark + public boolean support_hit(Cursor cursor) { + return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextHit()) >= 0; + } + + @Benchmark + public boolean support_miss(Cursor cursor) { + return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextMiss()) >= 0; + } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java new file mode 100644 index 00000000000..916cfe70059 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -0,0 +1,299 @@ +package datadog.trace.util; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.CompilerControl; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * The third {@link StringIndex} use case: replacing a {@code switch} over interned {@code String} + * literals that maps a key to a small {@code int} id (exactly what {@code TagInterceptor} does to + * decide whether/how to intercept a tag). Both forms resolve a key to an id, 0 == "not found". + * + *

    Compared: + * + *

      + *
    • {@code switch} — a hand-written {@code switch(key)} over the literals ({@code hashCode} + * switch + {@code equals}), {@code default} returns 0. + *
    • {@code stringIndex} — {@code IDS[Support.indexOf(HASHES, NAMES, key)]} over {@code static + * final} arrays (a miss returns 0), the folded-constant hot path. + *
    + * + *

    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%): + * + *

    {@code
    + * key             switch (inl / noinl)   stringIndex (inl / noinl)
    + * const            2778 / 2769            2047 / 2035
    + * hit  (runtime)   1161 / 1166            2147 / 2152
    + * miss             2083 / 2050            2546 / 2539
    + * }
    + * + *

    Two takeaways: + * + *

      + *
    • The string switch only matches StringIndex in the constant-key corner + * (~2.7B): there the JIT specializes the switch to the single known key — and the {@code + * const} arms show it does so even across a {@code DONT_INLINE} boundary (profile-driven, not + * const-prop-through-inline). Production tags are runtime-varied, so that corner never + * occurs. + *
    • In the realistic regime — a runtime, varied hit key, exactly {@code TagInterceptor} + * — the switch falls to ~1.16B while StringIndex holds ~2.15B (~1.85x). StringIndex is + * flat (~2.0–2.5B) across inline/not-inline and key shape: its throughput doesn't + * depend on the JIT's inlining decisions, which is the whole point. (Misses short-circuit for + * both; StringIndex still ~1.2x.) + *
    + * + *

    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: + * + *

      + *
    • {@link Support} — static algorithm over raw arrays. Keep the arrays in your own + * (ideally {@code static final}) fields and the JIT folds the refs to constants. The fastest + * path; nothing to dereference. + *
    • {@link Data} — a build-time carrier for the placed {@code {hashes, names}} returned + * by {@link Support#create}. Pull its fields into your own and discard it. + *
    • The {@code StringIndex} instance ({@link #of}) — a convenience wrapper holding the + * arrays; {@link #indexOf}/{@link #contains} delegate to {@link Support}. Costs an + * instance-field load per call (the indirection the static path removes) — fine off the hot + * path. + *
    + * + *

    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 T[] mapValues(Class type, Function fn) { + return Support.mapValues(this.names, type, fn); + } + + /** Slot-aligned {@code int[]} of values; absent slots stay 0. See {@link #mapValues}. */ + public int[] mapIntValues(ToIntFunction fn) { + return Support.mapIntValues(this.names, fn); + } + + /** Slot-aligned {@code long[]} of values; absent slots stay 0. See {@link #mapValues}. */ + public long[] mapLongValues(ToLongFunction fn) { + return Support.mapLongValues(this.names, fn); + } + + // --- lookup: resolve a key and read its parallel value in one call --- + + /** {@code data[indexOf(key)]}, or {@code null} when {@code key} is absent. */ + public T lookup(T[] data, String key) { + return Support.lookup(this.hashes, this.names, data, key); + } + + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public T lookupOrDefault(T[] data, String key, T defaultValue) { + return Support.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); + } + + /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ + public int lookup(int[] data, String key) { + return Support.lookup(this.hashes, this.names, data, key); + } + + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public int lookupOrDefault(int[] data, String key, int defaultValue) { + return Support.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); + } + + /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ + public long lookup(long[] data, String key) { + return Support.lookup(this.hashes, this.names, data, key); + } + + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public long lookupOrDefault(long[] data, String key, long defaultValue) { + return Support.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); + } + + /** Build-time carrier. Pull the fields into your own (static final) fields; don't keep this. */ + public static final class Data { + public final int[] hashes; + public final String[] names; + + Data(int[] hashes, String[] names) { + this.hashes = hashes; + this.names = names; + } + } + + /** + * Static algorithm over raw arrays. Query helpers take raw arrays, never a Data or a StringIndex. + */ + public static final class Support { + private Support() {} + + /** Spread of String.hashCode; 0 reserved as the empty sentinel. */ + public static int hash(String name) { + int h = name.hashCode(); // cached on String -> field load + return h == 0 ? 0xDD06 : h ^ (h >>> 16); + } + + /** Power-of-two size, 2x-oversized so load factor stays <= 0.5. */ + public static int tableSizeFor(int n) { + int size = 1; + while (size <= n) { + size <<= 1; + } + return size << 1; + } + + /** Build the placed table. Returns a Data carrier; pull its arrays into your own fields. */ + public static Data create(String... names) { + int size = tableSizeFor(names.length); + int[] hashes = new int[size]; + String[] placed = new String[size]; + for (String name : names) { + put(hashes, placed, name, hash(name)); + } + return new Data(hashes, placed); + } + + /** + * Slot-aligned {@code T[]} over placed {@code names}: {@code out[slot] = fn(name)} per name, + * {@code null} elsewhere. {@code type} is the array element type (generic-array allocation). + */ + @SuppressWarnings("unchecked") + public static T[] mapValues(String[] names, Class type, Function fn) { + T[] out = (T[]) Array.newInstance(type, names.length); + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.apply(name); + } + } + return out; + } + + /** + * Slot-aligned {@code int[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. + */ + public static int[] mapIntValues(String[] names, ToIntFunction fn) { + int[] out = new int[names.length]; + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.applyAsInt(name); + } + } + return out; + } + + /** + * Slot-aligned {@code long[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. + */ + public static long[] mapLongValues(String[] names, ToLongFunction fn) { + long[] out = new long[names.length]; + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.applyAsLong(name); + } + } + return out; + } + + /** Build-time placement. Returns the slot. */ + public static int put(int[] hashes, String[] names, String name, int h) { + final int mask = hashes.length - 1; + int i = h & mask; + for (int probes = 0; probes <= mask; probes++, i = (i + 1) & mask) { + if (hashes[i] == 0) { + hashes[i] = h; + names[i] = name; + return i; + } + if (hashes[i] == h && eq(names[i], name)) { + return i; // already present + } + } + throw new IllegalStateException("table full"); // impossible at LF <= 0.5 + } + + /** Probe; returns the slot or -1. Raw arrays — no Data, no instance. */ + public static int indexOf(int[] hashes, String[] names, String name, int h) { + final int mask = hashes.length - 1; + int i = h & mask; + for (int probes = 0; probes <= mask; probes++, i = (i + 1) & mask) { + int sh = hashes[i]; + if (sh == 0) { + return -1; + } + if (sh == h && eq(names[i], name)) { + return i; + } + } + return -1; + } + + public static int indexOf(int[] hashes, String[] names, String name) { + return indexOf(hashes, names, name, hash(name)); + } + + /** {@code data[indexOf(...)]}, or {@code null} when {@code key} is absent. */ + public static T lookup(int[] hashes, String[] names, T[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : null; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static T lookupOrDefault( + int[] hashes, String[] names, T[] data, String key, T defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + + /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ + public static int lookup(int[] hashes, String[] names, int[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : 0; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static int lookupOrDefault( + int[] hashes, String[] names, int[] data, String key, int defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + + /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ + public static long lookup(int[] hashes, String[] names, long[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : 0L; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static long lookupOrDefault( + int[] hashes, String[] names, long[] data, String key, long defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + + // `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) { + return a == b || a.equals(b); // interned literals hit the == fast path + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java new file mode 100644 index 00000000000..9a3b1db2571 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java @@ -0,0 +1,88 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; +import org.openjdk.jol.info.GraphLayout; + +/** + * Retained-footprint comparison (JOL) for {@link StringIndex} vs the JDK set representations, over + * a fixed read-only string set. Footprint is deterministic, so this is safe to run under load + * (unlike the throughput benchmarks). + * + *

    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
    + * 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 copy = CollectionUtils.tryMakeImmutableSet(Arrays.asList(el)); + long copyOf = bytes(copy); + long stringIndex = bytes(StringIndex.of(el)); + + System.out.printf( + "%-6d %12d %12d %12d %12d %12d%n", n, array, hashSet, treeSet, copyOf, stringIndex); + System.out.printf( + "%-6s %12s %12d %12d %12d %12d%n", + "", "", hashSet - array, treeSet - array, copyOf - array, stringIndex - array); + + // Robust cross-JVM invariant: no per-element Node objects -> lighter than HashSet. + assertTrue( + stringIndex < hashSet, "StringIndex should retain fewer bytes than HashSet at n=" + n); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java new file mode 100644 index 00000000000..3fcdb729d7d --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java @@ -0,0 +1,171 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.util.StringIndex.Data; +import datadog.trace.util.StringIndex.Support; +import org.junit.jupiter.api.Test; + +class StringIndexTest { + + @Test + void hash_spread_and_zeroSentinel() { + // "".hashCode() == 0 -> remapped to the non-zero sentinel so 0 can mean "empty slot" + assertEquals(0xDD06, Support.hash("")); + + int raw = "foo".hashCode(); + assertEquals(raw ^ (raw >>> 16), Support.hash("foo")); + assertNotEquals(0, Support.hash("foo")); + } + + @Test + void tableSizeFor_isPow2_andOversized() { + assertEquals(2, Support.tableSizeFor(0)); + assertEquals(4, Support.tableSizeFor(1)); + assertEquals(8, Support.tableSizeFor(3)); + assertEquals(16, Support.tableSizeFor(4)); + } + + @Test + void instance_contains_internedAndCopy_andMiss() { + StringIndex set = StringIndex.of("foo", "bar", "baz"); + + assertEquals(8, set.slots()); // 3 names -> tableSizeFor(3) == 8 + + assertTrue(set.contains("foo")); // interned literal -> == fast path in eq + assertTrue(set.contains(new String("bar"))); // non-interned -> .equals path + assertFalse(set.contains("nope")); + + assertTrue(set.indexOf("baz") >= 0); + assertEquals(-1, set.indexOf("nope")); + } + + @Test + void support_create_then_indexOf() { + Data d = Support.create("x", "y"); + + int slot = Support.indexOf(d.hashes, d.names, "x"); // 3-arg overload computes the hash + assertTrue(slot >= 0); + assertEquals("x", d.names[slot]); + + assertEquals(-1, Support.indexOf(d.hashes, d.names, "q")); + } + + /** Controlled hashes force collision, linear-probe wraparound, and the already-present path. */ + @Test + void put_and_indexOf_collisionAndWraparound() { + int[] hashes = new int[4]; // mask = 3 + String[] names = new String[4]; + + assertEquals(3, Support.put(hashes, names, "a", 7)); // 7 & 3 == 3 + assertEquals(0, Support.put(hashes, names, "b", 7)); // collides at 3, probes (3+1)&3 == 0 + assertEquals(3, Support.put(hashes, names, "a", 7)); // already present -> existing slot + + assertEquals(3, Support.indexOf(hashes, names, "a", 7)); // direct hit + assertEquals(0, Support.indexOf(hashes, names, "b", 7)); // hit after collision + wraparound + assertEquals( + -1, Support.indexOf(hashes, names, "c", 7)); // miss after probing 3 -> 0 -> 1(empty) + assertEquals(-1, Support.indexOf(hashes, names, "z", 6)); // 6 & 3 == 2, empty -> immediate miss + } + + @Test + void put_throwsWhenFull() { + int[] hashes = new int[2]; // mask = 1 + String[] names = new String[2]; + + Support.put(hashes, names, "a", 4); // 4 & 1 == 0 + Support.put(hashes, names, "b", 5); // 5 & 1 == 1 + + // both slots occupied, no match -> probe exhausts -> throw + assertThrows(IllegalStateException.class, () -> Support.put(hashes, names, "c", 6)); + } + + /** The documented usage: build a StringIndex, attach a parallel payload indexed by slot. */ + @Test + void parallelPayloadBySlot() { + String[] names = {"a", "b", "c"}; + Data d = Support.create(names); + + long[] ids = new long[d.names.length]; + for (int j = 0; j < names.length; j++) { + ids[Support.indexOf(d.hashes, d.names, names[j])] = j + 1L; + } + + assertEquals(1L, ids[Support.indexOf(d.hashes, d.names, "a")]); + assertEquals(2L, ids[Support.indexOf(d.hashes, d.names, "b")]); + assertEquals(3L, ids[Support.indexOf(d.hashes, d.names, "c")]); + } + + @Test + void mapIntValues_slotAligned_andLookup() { + StringIndex idx = StringIndex.of("a", "b", "c"); + // 1-based ids; 0 stays the empty-slot / not-found sentinel. + int[] ids = idx.mapIntValues(s -> s.charAt(0) - 'a' + 1); + assertEquals(idx.slots(), ids.length); // sized to the table, not the name count + + assertEquals(1, idx.lookup(ids, "a")); + assertEquals(2, idx.lookup(ids, "b")); + assertEquals(3, idx.lookup(ids, "c")); + assertEquals(0, idx.lookup(ids, "z")); // miss -> 0 + assertEquals(-1, idx.lookupOrDefault(ids, "z", -1)); // miss -> supplied default + } + + @Test + void mapLongValues_slotAligned_andLookup() { + Data d = Support.create("a", "b", "c"); + long[] vals = Support.mapLongValues(d.names, s -> s.charAt(0) - 'a' + 1L); + + assertEquals(1L, Support.lookup(d.hashes, d.names, vals, "a")); + assertEquals(3L, Support.lookup(d.hashes, d.names, vals, "c")); + assertEquals(0L, Support.lookup(d.hashes, d.names, vals, "z")); // miss -> 0 + assertEquals(-1L, Support.lookupOrDefault(d.hashes, d.names, vals, "z", -1L)); + } + + @Test + void mapValues_objects_typedArray_andLookup() { + StringIndex idx = StringIndex.of("a", "bb", "ccc"); + Integer[] lengths = idx.mapValues(Integer.class, String::length); + + // Class drives a real Integer[], not an Object[]. + assertEquals(Integer[].class, lengths.getClass()); + + assertEquals(Integer.valueOf(1), idx.lookup(lengths, "a")); + assertEquals(Integer.valueOf(3), idx.lookup(lengths, "ccc")); + assertNull(idx.lookup(lengths, "z")); // miss -> null + assertEquals(Integer.valueOf(-1), idx.lookupOrDefault(lengths, "z", -1)); + } + + @Test + void support_mapValues_objects_sizedToSlots_emptyStayNull() { + Data d = Support.create("a", "b", "c"); + String[] tagged = Support.mapValues(d.names, String.class, s -> s + "!"); + + assertEquals(d.names.length, tagged.length); // sized to the table + int nonNull = 0; + for (String s : tagged) { + if (s != null) { + nonNull++; + } + } + assertEquals(3, nonNull); // only the placed names map; unfilled slots stay null + + assertEquals("a!", Support.lookup(d.hashes, d.names, tagged, "a")); + assertEquals("dflt", Support.lookupOrDefault(d.hashes, d.names, tagged, "z", "dflt")); + } + + @Test + void instance_lookup_delegatesToSupportArrays() { + StringIndex idx = StringIndex.of("x", "y"); + int[] ids = idx.mapIntValues(s -> "x".equals(s) ? 7 : 9); + + assertEquals(7, idx.lookup(ids, "x")); + assertEquals(9, idx.lookup(ids, "y")); + assertEquals(0, idx.lookup(ids, "missing")); + assertEquals(42, idx.lookupOrDefault(ids, "missing", 42)); + } +}