From 9e76978e195441b929c512f7671e4e1dc3278c18 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 17 Jun 2026 07:03:04 -0400 Subject: [PATCH 01/34] Add TagSet: a generic open-addressed string set (+ benchmarks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TagSet (datadog.trace.util) — a flat, allocation-free, open-addressed string set: Support (static algorithm over raw int[] hashes / String[] names — the hot path, folds to constants when held in static finals), Data (build-time carrier), and a convenience instance wrapper. 2x-oversized (load factor <= 0.5), linear probe, interned-== fast path, 0 = empty sentinel. Consumers attach a parallel payload array indexed by the returned slot. Benchmarks: SetBenchmark (membership vs HashSet/array/sortedArray/treeSet) and KeyOfBenchmark (name->id resolution vs HashMap and a hand-written switch). TagSetTest covers hashing, probing/wraparound, table-full, and parallel payload. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/KeyOfBenchmark.java | 253 ++++++++++++++++++ .../java/datadog/trace/util/SetBenchmark.java | 170 +++++++++--- .../main/java/datadog/trace/util/TagSet.java | 142 ++++++++++ .../java/datadog/trace/util/TagSetTest.java | 102 +++++++ 4 files changed, 623 insertions(+), 44 deletions(-) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java create mode 100644 internal-api/src/main/java/datadog/trace/util/TagSet.java create mode 100644 internal-api/src/test/java/datadog/trace/util/TagSetTest.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java new file mode 100644 index 00000000000..aee20e94755 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java @@ -0,0 +1,253 @@ +package datadog.trace.util; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; +import org.openjdk.jmh.annotations.Benchmark; +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; + +/** + * name -> id resolution shootout (the {@code keyOf} path), built on the generic {@link TagSet}. + * + * + * + *

Two term flavors: interned (realistic — instrumentation passes string literals → the + * {@code ==} fast path in eq) and copies (non-interned → forces {@code String.equals}). + * Terms are hit-dominated. + * Apple M1 Max (10 core) - 8 threads (per-thread state) - 2 forks - Java 8 (Zulu 8.0.382) + * + * Benchmark Mode Cnt Score Error Units + * KeyOfBenchmark.aa_baseline_termSelection thrpt 6 2743246161.5 ± 29519843.7 ops/s + * KeyOfBenchmark.tagSet thrpt 6 2275407420.3 ± 35217527.6 ops/s + * KeyOfBenchmark.tagSet_throughClass thrpt 6 2036161909.9 ± 49813775.7 ops/s + * KeyOfBenchmark.hashMap thrpt 6 1889985340.4 ± 46434121.2 ops/s + * KeyOfBenchmark.switch_ thrpt 6 1132557957.9 ±128775728.2 ops/s + * // copies (non-interned): tagSet 1843M, tagSet_throughClass 1708M, hashMap 1593M, switch_ 1137M + * + * + *

+ */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@Threads(8) +@State(Scope.Thread) +public class KeyOfBenchmark { + static final long UNKNOWN = 0L; + + static final String[] NAMES_IN = { + "span.type", "component", "span.kind", "db.type", "db.instance", "db.statement", + "peer.hostname", "peer.port", "http.method", "http.route", "http.status_code", "http.url", + "error", "resource", "service", "operation" + }; + + /** ids parallel to NAMES_IN — id == index+1, matched across all contenders. */ + static final long[] IDS_IN = + init( + () -> { + long[] ids = new long[NAMES_IN.length]; + for (int j = 0; j < ids.length; j++) { + ids[j] = j + 1L; + } + return ids; + }); + + // fastest path: build once, pull into static final so the refs fold + static final int[] HASHES; + static final String[] NAMES; + static final long[] IDS; + + static { + TagSet.Data data = TagSet.Support.create(NAMES_IN); + long[] ids = new long[data.names.length]; + for (int j = 0; j < NAMES_IN.length; j++) { + ids[TagSet.Support.indexOf(data.hashes, data.names, NAMES_IN[j])] = IDS_IN[j]; + } + HASHES = data.hashes; + NAMES = data.names; + IDS = ids; + } + + static final Map HASH_MAP = + init( + () -> { + Map m = new HashMap<>(NAMES_IN.length * 2); + for (int j = 0; j < NAMES_IN.length; j++) { + m.put(NAMES_IN[j], IDS_IN[j]); + } + return m; + }); + + /** Convenience instance — the through-the-class path (instance-field loads vs folded statics). */ + static final TagSet TAG_SET = TagSet.of(NAMES_IN); + + // hit-dominated, two misses; interned and non-interned copies + static final String[] TERMS = { + "span.type", "component", "span.kind", "db.type", "db.instance", "db.statement", + "peer.hostname", "peer.port", "http.method", "http.route", "http.status_code", "http.url", + "error", "resource", "service", "operation", "unknown.tag", "custom.attr" + }; + + static final String[] TERM_COPIES = + init( + () -> { + String[] copies = new String[TERMS.length]; + for (int i = 0; i < TERMS.length; i++) { + copies[i] = new String(TERMS[i]); // defeat interning + } + return copies; + }); + + int termIndex = 0; // per-thread (Scope.Thread) — no shared-counter contention under @Threads(8) + + String nextTerm() { + int i = termIndex + 1; + if (i >= TERMS.length) { + i = 0; + } + termIndex = i; + return TERMS[i]; + } + + String nextTermCopy() { + int i = termIndex + 1; + if (i >= TERM_COPIES.length) { + i = 0; + } + termIndex = i; + return TERM_COPIES[i]; + } + + static T init(Supplier supplier) { + return supplier.get(); + } + + // ---- resolvers ---- + static long tagSetKeyOf(String t) { + int slot = TagSet.Support.indexOf(HASHES, NAMES, t); // folded static-final refs + return slot < 0 ? UNKNOWN : IDS[slot]; + } + + static long tagSetThroughClassKeyOf(String t) { + int slot = TAG_SET.indexOf(t); // instance-field load of hashes/names + return slot < 0 ? UNKNOWN : IDS[slot]; + } + + static long hashMapKeyOf(String t) { + Long v = HASH_MAP.get(t); + return v == null ? UNKNOWN : v.longValue(); + } + + static long switchKeyOf(String t) { + switch (t) { + case "span.type": + return 1L; + case "component": + return 2L; + case "span.kind": + return 3L; + case "db.type": + return 4L; + case "db.instance": + return 5L; + case "db.statement": + return 6L; + case "peer.hostname": + return 7L; + case "peer.port": + return 8L; + case "http.method": + return 9L; + case "http.route": + return 10L; + case "http.status_code": + return 11L; + case "http.url": + return 12L; + case "error": + return 13L; + case "resource": + return 14L; + case "service": + return 15L; + case "operation": + return 16L; + default: + return UNKNOWN; + } + } + + // ---- interned terms (realistic) ---- + @Benchmark + public String aa_baseline_termSelection() { + return nextTerm(); + } + + @Benchmark + public long tagSet() { + return tagSetKeyOf(nextTerm()); + } + + @Benchmark + public long tagSet_throughClass() { + return tagSetThroughClassKeyOf(nextTerm()); + } + + @Benchmark + public long hashMap() { + return hashMapKeyOf(nextTerm()); + } + + @Benchmark + public long switch_() { + return switchKeyOf(nextTerm()); + } + + // ---- non-interned copies (forces equals) ---- + @Benchmark + public String aa_baseline_termSelectionCopy() { + return nextTermCopy(); + } + + @Benchmark + public long tagSet_copy() { + return tagSetKeyOf(nextTermCopy()); + } + + @Benchmark + public long tagSet_throughClass_copy() { + return tagSetThroughClassKeyOf(nextTermCopy()); + } + + @Benchmark + public long hashMap_copy() { + return hashMapKeyOf(nextTermCopy()); + } + + @Benchmark + public long switch_copy() { + return switchKeyOf(nextTermCopy()); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java index 144e4748400..eec1bbee95e 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java @@ -1,44 +1,62 @@ package datadog.trace.util; import java.util.Arrays; -import java.util.Collections; import java.util.HashSet; import java.util.TreeSet; -import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; import org.openjdk.jmh.annotations.Benchmark; 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; /** - * + * Ways to represent a small set of strings and test membership, split into hit and miss lookups + * (different cost shapes per structure). Lookups are interned (the {@code ==} fast path); misses + * are short and never present. Per-thread state ({@code @State(Scope.Thread)}) keeps the rotation + * counter off the shared-write path under {@code @Threads(8)} — an earlier shared-counter version + * capped the fast structures at a ~1.4B contention ceiling (since superseded by the numbers below). * *
    - * Benchmark showing possible ways to represent and check if a set includes an elememt... - *
  • (RECOMMENDED) HashSet - on par with TreeSet - idiomatic - *
  • (RECOMMENDED) TreeMap - on par with HashSet - better solution if custom comparator is - * needed (see CaseInsensitiveMapBenchmark) - *
  • array - slower than HashSet - *
  • sortedArray - slowest - slower than array for common case of small arrays + *
  • tagSetSupport (static) is the fastest membership path — 2336M hit / 2170M miss. It + * beats the TagSet instance ({@code tagSet_*}) by ~7% (hit) to ~12% (miss): the instance pays + * an instance-field load of hashes/names, while {@code Support.indexOf} over {@code static + * final} arrays lets the refs fold to constants. Matches KeyOfBenchmark's ~12% static-vs- + * instance gap. So when the set is fixed, pull {@code Data} into your own static finals. + *
  • vs HashSet — the static path is ~12% faster on hit and ~par on miss. But HashSet was + * noisy here (±22% error) while TagSet was tight (±2-7%), so TagSet also wins on + * predictability — and is allocation-free and positional-capable. + *
  • array / sortedArray / treeSet cluster ~0.65-1.0B — they compare/scan per element, so they + * slow on miss (hit early-exits; miss does the full scan / binary descent / tree walk). + * TreeSet is NOT uniquely slowest — worth it only for a custom comparator (case-insensitive, + * dodging {@code toLowerCase}), not speed. *
* * - * MacBook M1 - 8 threads - Java 21 - * 1/3 not found rate + * Apple M1 Max (10 core) - 8 threads (per-thread state) - 2 forks - Java 8 (Zulu 8.0.382) * - * Benchmark Mode Cnt Score Error Units - * SetBenchmark.contains_array thrpt 6 645561886.327 ± 100781717.494 ops/s - * SetBenchmark.contains_hashSet thrpt 6 1536236680.235 ± 114966961.506 ops/s - * SetBenchmark.contains_sortedArray thrpt 6 571476939.441 ± 21334620.460 ops/s - * SetBenchmark.contains_treeSet thrpt 6 1557663759.411 ± 95343683.124 ops/s + * Benchmark Mode Cnt Score Error Units + * SetBenchmark.array_hit thrpt 6 995578895.732 ± 73709080.997 ops/s + * SetBenchmark.array_miss thrpt 6 649860848.470 ± 32489300.626 ops/s + * SetBenchmark.hashSet_hit thrpt 6 2081738804.271 ± 464349157.190 ops/s + * SetBenchmark.hashSet_miss thrpt 6 2136501411.026 ± 474132929.024 ops/s + * SetBenchmark.sortedArray_hit thrpt 6 837595967.794 ± 113538780.712 ops/s + * SetBenchmark.sortedArray_miss thrpt 6 692064118.699 ± 25752553.077 ops/s + * SetBenchmark.tagSet_hit thrpt 6 2184722734.028 ± 61054981.099 ops/s + * SetBenchmark.tagSet_miss thrpt 6 1933588009.009 ± 159869680.982 ops/s + * SetBenchmark.tagSetSupport_hit thrpt 6 2335685599.706 ± 52460762.937 ops/s + * SetBenchmark.tagSetSupport_miss thrpt 6 2169715463.018 ± 141321499.862 ops/s + * SetBenchmark.treeSet_hit thrpt 6 798251906.675 ± 39041398.413 ops/s + * SetBenchmark.treeSet_miss thrpt 6 667078954.487 ± 56517120.187 ops/s * */ @Fork(2) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) +@State(Scope.Thread) public class SetBenchmark { static final String[] STRINGS = new String[] { @@ -60,45 +78,60 @@ static T init(Supplier supplier) { return supplier.get(); } - static final String[] LOOKUPS = + /** Present in the set (interned). */ + static final String[] HITS = STRINGS; + + /** Never present. */ + static final String[] MISSES = init( () -> { - String[] lookups = Arrays.copyOf(STRINGS, STRINGS.length * 10); - - for (int i = 0; i < STRINGS.length; ++i) { - lookups[STRINGS.length + i] = new String(STRINGS[i]); + String[] misses = new String[STRINGS.length * 4]; + for (int i = 0; i < misses.length; ++i) { + misses[i] = "dne-" + i; } - - // 2 / 3 of the key look-ups miss the set - for (int i = STRINGS.length * 2; i < lookups.length; ++i) { - lookups[i] = "dne-" + ThreadLocalRandom.current().nextInt(); - } - - Collections.shuffle(Arrays.asList(lookups)); - return lookups; + return misses; }); - static int sharedLookupIndex = 0; + int hitIndex = 0; // per-thread (Scope.Thread) — no shared-counter contention under @Threads(8) + int missIndex = 0; - static String nextString() { - int localIndex = ++sharedLookupIndex; - if (localIndex >= LOOKUPS.length) { - sharedLookupIndex = localIndex = 0; + String nextHit() { + int i = hitIndex + 1; + if (i >= HITS.length) { + i = 0; } - return LOOKUPS[localIndex]; + hitIndex = i; + return HITS[i]; + } + + String nextMiss() { + int i = missIndex + 1; + if (i >= MISSES.length) { + i = 0; + } + missIndex = i; + return MISSES[i]; } static final String[] ARRAY = STRINGS; - @Benchmark - public boolean contains_array() { - String needle = nextString(); + static boolean arrayContains(String needle) { for (String str : ARRAY) { if (needle.equals(str)) return true; } return false; } + @Benchmark + public boolean array_hit() { + return arrayContains(nextHit()); + } + + @Benchmark + public boolean array_miss() { + return arrayContains(nextMiss()); + } + static final String[] SORTED_ARRAY = init( () -> { @@ -108,21 +141,70 @@ public boolean contains_array() { }); @Benchmark - public boolean contains_sortedArray() { - return (Arrays.binarySearch(SORTED_ARRAY, nextString()) != -1); + public boolean sortedArray_hit() { + return Arrays.binarySearch(SORTED_ARRAY, nextHit()) >= 0; + } + + @Benchmark + public boolean sortedArray_miss() { + return Arrays.binarySearch(SORTED_ARRAY, nextMiss()) >= 0; } static final HashSet HASH_SET = new HashSet<>(Arrays.asList(STRINGS)); @Benchmark - public boolean contains_hashSet() { - return HASH_SET.contains(nextString()); + public boolean hashSet_hit() { + return HASH_SET.contains(nextHit()); + } + + @Benchmark + public boolean hashSet_miss() { + return HASH_SET.contains(nextMiss()); } static final TreeSet TREE_SET = new TreeSet<>(Arrays.asList(STRINGS)); @Benchmark - public boolean contains_treeSet() { - return HASH_SET.contains(nextString()); + public boolean treeSet_hit() { + return TREE_SET.contains(nextHit()); + } + + @Benchmark + public boolean treeSet_miss() { + return TREE_SET.contains(nextMiss()); + } + + static final TagSet TAG_SET = TagSet.of(STRINGS); + + @Benchmark + public boolean tagSet_hit() { + return TAG_SET.contains(nextHit()); + } + + @Benchmark + public boolean tagSet_miss() { + return TAG_SET.contains(nextMiss()); + } + + // The static Support path: hashes/names built once into static-final arrays (refs fold to + // constants) and probed directly via Support.indexOf -- vs tagSet_* above, which loads them + // through a TagSet instance. Mirrors KeyOfBenchmark's tagSet (static) vs tagSet_throughClass. + static final int[] SUPPORT_HASHES; + static final String[] SUPPORT_NAMES; + + static { + TagSet.Data data = TagSet.Support.create(STRINGS); + SUPPORT_HASHES = data.hashes; + SUPPORT_NAMES = data.names; + } + + @Benchmark + public boolean tagSetSupport_hit() { + return TagSet.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextHit()) >= 0; + } + + @Benchmark + public boolean tagSetSupport_miss() { + return TagSet.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextMiss()) >= 0; } } diff --git a/internal-api/src/main/java/datadog/trace/util/TagSet.java b/internal-api/src/main/java/datadog/trace/util/TagSet.java new file mode 100644 index 00000000000..570f3c7e003 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/TagSet.java @@ -0,0 +1,142 @@ +package datadog.trace.util; + +/** + * 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 TagSet} 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. + * + *

Slot 0-value is the empty sentinel: {@link Support#hash} never returns 0, so {@code hashes[i] + * == 0} unambiguously means an empty slot. + */ +public final class TagSet { + private final int[] hashes; + private final String[] names; + public final int slots; // == hashes.length + + private TagSet(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 TagSet of(String... names) { + Data data = Support.create(names); + return new TagSet(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; + } + + /** 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 TagSet. */ + 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); + } + + /** 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)); + } + + // `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/TagSetTest.java b/internal-api/src/test/java/datadog/trace/util/TagSetTest.java new file mode 100644 index 00000000000..e5ca5ff41e5 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/TagSetTest.java @@ -0,0 +1,102 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.util.TagSet.Data; +import datadog.trace.util.TagSet.Support; +import org.junit.jupiter.api.Test; + +class TagSetTest { + + @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() { + TagSet set = TagSet.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 TagSet, 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")]); + } +} From 39c91c05c39f111c8ee06e81edabe32bbd7fc07b Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 17 Jun 2026 07:14:05 -0400 Subject: [PATCH 02/34] Drop KeyOfBenchmark from the TagSet PR (it's a keyOf-integration benchmark, not a generic-set one) Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/KeyOfBenchmark.java | 253 ------------------ 1 file changed, 253 deletions(-) delete mode 100644 internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java deleted file mode 100644 index aee20e94755..00000000000 --- a/internal-api/src/jmh/java/datadog/trace/util/KeyOfBenchmark.java +++ /dev/null @@ -1,253 +0,0 @@ -package datadog.trace.util; - -import java.util.HashMap; -import java.util.Map; -import java.util.function.Supplier; -import org.openjdk.jmh.annotations.Benchmark; -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; - -/** - * name -> id resolution shootout (the {@code keyOf} path), built on the generic {@link TagSet}. - * - *

    - *
  • tagSet — {@code TagSet.Support.indexOf} over static-final {@code int[] - * hashes} / {@code String[] names} (refs fold to constants) + a parallel {@code long[] ids}. - *
  • tagSet_throughClass — same, but via a {@code TagSet} instance (an - * instance-field load of hashes/names) — isolates the wrapper indirection vs static fields. - *
  • hashMap — {@code HashMap} (boxes the value). - *
  • switch — hand-written string {@code switch} (the thing keyOf replaces). At 16 cases - * it inlines fine; the at-scale degradation (hundreds of cases over FreqInlineSize) shows up - * against the real generated keyOf, not here. - *
- * - *

Two term flavors: interned (realistic — instrumentation passes string literals → the - * {@code ==} fast path in eq) and copies (non-interned → forces {@code String.equals}). - * Terms are hit-dominated. - * Apple M1 Max (10 core) - 8 threads (per-thread state) - 2 forks - Java 8 (Zulu 8.0.382) - * - * Benchmark Mode Cnt Score Error Units - * KeyOfBenchmark.aa_baseline_termSelection thrpt 6 2743246161.5 ± 29519843.7 ops/s - * KeyOfBenchmark.tagSet thrpt 6 2275407420.3 ± 35217527.6 ops/s - * KeyOfBenchmark.tagSet_throughClass thrpt 6 2036161909.9 ± 49813775.7 ops/s - * KeyOfBenchmark.hashMap thrpt 6 1889985340.4 ± 46434121.2 ops/s - * KeyOfBenchmark.switch_ thrpt 6 1132557957.9 ±128775728.2 ops/s - * // copies (non-interned): tagSet 1843M, tagSet_throughClass 1708M, hashMap 1593M, switch_ 1137M - * - * - *

    - *
  • tagSet ~2x the switch (2275M vs 1133M) at only 16 cases — the gap widens toward the - * generated hundreds, where the switch exceeds the inline budget. The keyOf swap's win. - *
  • tagSet ~20% over HashMap (2275M vs 1890M). - *
  • static ~12% over the instance (tagSet 2275M vs tagSet_throughClass 2036M) — folded - * static-final arrays beat the instance-field load; pull {@code Data} into your own statics. - *
  • The switch is interning-insensitive (1133≈1137, dispatch-bound); hash contenders gain - * ~16-19% interned via the {@code ==} fast path. - *
- */ -@Fork(2) -@Warmup(iterations = 2) -@Measurement(iterations = 3) -@Threads(8) -@State(Scope.Thread) -public class KeyOfBenchmark { - static final long UNKNOWN = 0L; - - static final String[] NAMES_IN = { - "span.type", "component", "span.kind", "db.type", "db.instance", "db.statement", - "peer.hostname", "peer.port", "http.method", "http.route", "http.status_code", "http.url", - "error", "resource", "service", "operation" - }; - - /** ids parallel to NAMES_IN — id == index+1, matched across all contenders. */ - static final long[] IDS_IN = - init( - () -> { - long[] ids = new long[NAMES_IN.length]; - for (int j = 0; j < ids.length; j++) { - ids[j] = j + 1L; - } - return ids; - }); - - // fastest path: build once, pull into static final so the refs fold - static final int[] HASHES; - static final String[] NAMES; - static final long[] IDS; - - static { - TagSet.Data data = TagSet.Support.create(NAMES_IN); - long[] ids = new long[data.names.length]; - for (int j = 0; j < NAMES_IN.length; j++) { - ids[TagSet.Support.indexOf(data.hashes, data.names, NAMES_IN[j])] = IDS_IN[j]; - } - HASHES = data.hashes; - NAMES = data.names; - IDS = ids; - } - - static final Map HASH_MAP = - init( - () -> { - Map m = new HashMap<>(NAMES_IN.length * 2); - for (int j = 0; j < NAMES_IN.length; j++) { - m.put(NAMES_IN[j], IDS_IN[j]); - } - return m; - }); - - /** Convenience instance — the through-the-class path (instance-field loads vs folded statics). */ - static final TagSet TAG_SET = TagSet.of(NAMES_IN); - - // hit-dominated, two misses; interned and non-interned copies - static final String[] TERMS = { - "span.type", "component", "span.kind", "db.type", "db.instance", "db.statement", - "peer.hostname", "peer.port", "http.method", "http.route", "http.status_code", "http.url", - "error", "resource", "service", "operation", "unknown.tag", "custom.attr" - }; - - static final String[] TERM_COPIES = - init( - () -> { - String[] copies = new String[TERMS.length]; - for (int i = 0; i < TERMS.length; i++) { - copies[i] = new String(TERMS[i]); // defeat interning - } - return copies; - }); - - int termIndex = 0; // per-thread (Scope.Thread) — no shared-counter contention under @Threads(8) - - String nextTerm() { - int i = termIndex + 1; - if (i >= TERMS.length) { - i = 0; - } - termIndex = i; - return TERMS[i]; - } - - String nextTermCopy() { - int i = termIndex + 1; - if (i >= TERM_COPIES.length) { - i = 0; - } - termIndex = i; - return TERM_COPIES[i]; - } - - static T init(Supplier supplier) { - return supplier.get(); - } - - // ---- resolvers ---- - static long tagSetKeyOf(String t) { - int slot = TagSet.Support.indexOf(HASHES, NAMES, t); // folded static-final refs - return slot < 0 ? UNKNOWN : IDS[slot]; - } - - static long tagSetThroughClassKeyOf(String t) { - int slot = TAG_SET.indexOf(t); // instance-field load of hashes/names - return slot < 0 ? UNKNOWN : IDS[slot]; - } - - static long hashMapKeyOf(String t) { - Long v = HASH_MAP.get(t); - return v == null ? UNKNOWN : v.longValue(); - } - - static long switchKeyOf(String t) { - switch (t) { - case "span.type": - return 1L; - case "component": - return 2L; - case "span.kind": - return 3L; - case "db.type": - return 4L; - case "db.instance": - return 5L; - case "db.statement": - return 6L; - case "peer.hostname": - return 7L; - case "peer.port": - return 8L; - case "http.method": - return 9L; - case "http.route": - return 10L; - case "http.status_code": - return 11L; - case "http.url": - return 12L; - case "error": - return 13L; - case "resource": - return 14L; - case "service": - return 15L; - case "operation": - return 16L; - default: - return UNKNOWN; - } - } - - // ---- interned terms (realistic) ---- - @Benchmark - public String aa_baseline_termSelection() { - return nextTerm(); - } - - @Benchmark - public long tagSet() { - return tagSetKeyOf(nextTerm()); - } - - @Benchmark - public long tagSet_throughClass() { - return tagSetThroughClassKeyOf(nextTerm()); - } - - @Benchmark - public long hashMap() { - return hashMapKeyOf(nextTerm()); - } - - @Benchmark - public long switch_() { - return switchKeyOf(nextTerm()); - } - - // ---- non-interned copies (forces equals) ---- - @Benchmark - public String aa_baseline_termSelectionCopy() { - return nextTermCopy(); - } - - @Benchmark - public long tagSet_copy() { - return tagSetKeyOf(nextTermCopy()); - } - - @Benchmark - public long tagSet_throughClass_copy() { - return tagSetThroughClassKeyOf(nextTermCopy()); - } - - @Benchmark - public long hashMap_copy() { - return hashMapKeyOf(nextTermCopy()); - } - - @Benchmark - public long switch_copy() { - return switchKeyOf(nextTermCopy()); - } -} From 66ee8ed3a4e80fe0fadeccfff71ff33de0c57183 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 17 Jun 2026 07:38:44 -0400 Subject: [PATCH 03/34] Add TagSet + parallel int[] fixed-map option to UnsynchronizedMapBenchmark Adds get_tagSetMap / get_tagSetMap_sameKey as a build-once, read-only map option in the map menu: keys in a TagSet, values in a parallel int[] (no boxing), get is Support.indexOf plus one array load. Fastest get in the benchmark and the tightest error bars; results captured in the javadoc. Co-Authored-By: Claude Opus 4.8 --- .../util/UnsynchronizedMapBenchmark.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java index 42fec2b98e0..c93d8819680 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java @@ -24,6 +24,10 @@ *
  • TreeMap - better for custom Comparators - case-insensitive Maps (see * CaseInsensitiveMapBenchmark) *
  • LinkedHashMap - only when insertion order is needed + *
  • TagSet + parallel value array - for a FIXED (build-once, read-only) map whose key set is + * known up front: the keys go in a {@link TagSet} and the values in a parallel array indexed + * by the slot {@code indexOf} returns. Fastest get, no per-lookup allocation, no node chasing + * - but it can't change after construction (see get_tagSetMap). * * *

    TagMap is the preferred way to store tags. @@ -91,6 +95,23 @@ * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 422407681.630 ± 19493455.109 ops/s * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 392884747.896 ± 80190674.417 ops/s * + * + *

    A TagSet + parallel int[] used as a fixed (build-once) map is the fastest get here -- ahead of + * HashMap in both the rotating-key and same-key cases -- and the most predictable (±1-2% vs + * HashMap's ~5% and TagMap's ~12%). It pays no boxing (int[] values), chases no node, and allocates + * nothing per lookup. It only applies when the key set is fixed at construction. + * Fixed-map get comparison incl. TagSet + parallel int[] (Apple M1 Max, 8 threads, Java 8 Zulu 8.0.382) + * + * Benchmark Mode Cnt Score Error Units + * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1086382687.356 ± 52784044.910 ops/s + * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 1254600761.612 ± 33664831.344 ops/s + * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1055047178.664 ± 44355203.950 ops/s + * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 906953688.631 ± 112749682.884 ops/s + * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 1055399886.424 ± 162974805.646 ops/s + * UnsynchronizedMapBenchmark.get_tagSetMap thrpt 6 1168134502.026 ± 23922240.061 ops/s + * UnsynchronizedMapBenchmark.get_tagSetMap_sameKey thrpt 6 1379540570.008 ± 15177042.759 ops/s + * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 590656771.941 ± 63177685.351 ops/s + * */ @Fork(2) @Warmup(iterations = 2) @@ -305,4 +326,39 @@ public void iterate_tagMap_forEach(Blackhole blackhole) { public TagMap clone_tagMap() { return TAG_MAP.copy(); } + + // TagSet + a parallel value array used as a FIXED (build-once, read-only) map. The keys are known + // up front, so they go in a TagSet and the values in a plain array indexed by the slot that + // Support.indexOf returns. There is no node, no Entry, and no per-lookup allocation -- a get is a + // hash probe (with an interned == fast path) plus one array load. Pull the Data into your own + // static finals (as below) so the hashes/names refs fold to constants -- same static-vs-instance + // win SetBenchmark and KeyOfBenchmark measure. The values live in a parallel int[] -- no boxing, + // the same primitive-value advantage TagMap.getInt has over a HashMap, whose + // value type structurally forces the box. The trade-off: it cannot change after construction. + static final int[] TAG_SET_HASHES; + static final String[] TAG_SET_NAMES; + static final int[] TAG_SET_VALUES; + + static { + TagSet.Data data = TagSet.Support.create(INSERTION_KEYS); + int[] values = new int[data.names.length]; + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + values[TagSet.Support.indexOf(data.hashes, data.names, INSERTION_KEYS[i])] = i; + } + TAG_SET_HASHES = data.hashes; + TAG_SET_NAMES = data.names; + TAG_SET_VALUES = values; + } + + @Benchmark + public int get_tagSetMap() { + int slot = TagSet.Support.indexOf(TAG_SET_HASHES, TAG_SET_NAMES, nextLookupKey()); + return slot < 0 ? -1 : TAG_SET_VALUES[slot]; + } + + @Benchmark + public int get_tagSetMap_sameKey() { + int slot = TagSet.Support.indexOf(TAG_SET_HASHES, TAG_SET_NAMES, nextLookupKey(INSERTION_KEYS)); + return slot < 0 ? -1 : TAG_SET_VALUES[slot]; + } } From 4d493ed2b4539ece9b11e6bb009d0ef60a54330f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 17 Jun 2026 09:25:06 -0400 Subject: [PATCH 04/34] Move UnsynchronizedMapBenchmark lookup rotation to @State(Scope.Thread) The shared static sharedLookupIndex was written by every thread under @Threads(8), so its cache-line ping-pong capped the get_* benchmarks near a ~1.4B ops/s contention ceiling -- the same artifact SetBenchmark fixed by moving its rotation counter to per-thread @State(Scope.Thread). Apply the same fix here; off the ceiling the get_* numbers rise and the differences between map options stop being compressed. Replaces the two stale, unlabeled Java-21 result blocks (and the interim contention-capped get block) with a single env-stamped table from one run on a modern, representative JVM (Apple M1 Max, macOS 26.4.1, Zulu 17.0.7+7-LTS, 8 threads, 2 forks). Header conclusions re-checked against these numbers: the fixed TagSet map leads HashMap ~30%/~50% (rotating/same-key), forEach matches the fastest map iterators, and create's HashMap-vs-LinkedHashMap edge is ~1.4x. Co-Authored-By: Claude Opus 4.8 --- .../util/UnsynchronizedMapBenchmark.java | 120 +++++++----------- 1 file changed, 48 insertions(+), 72 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java index c93d8819680..55ce4fa1745 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java @@ -9,6 +9,8 @@ import org.openjdk.jmh.annotations.Benchmark; 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; import org.openjdk.jmh.infra.Blackhole; @@ -18,7 +20,9 @@ * *

      * Benchmark comparing different Map-s... - *
    • (RECOMMENDED) HashMap - for fastest lookups - (not typically needed for tags) + *
    • (RECOMMENDED) HashMap - fastest lookups among general-purpose (mutable) maps - (not + * typically needed for tags; a fixed TagSet map below is faster still when the keys are + * known) *
    • (RECOMMENDED) TagMap - for storing tags - especially if copying between maps or using * builders *
    • TreeMap - better for custom Comparators - case-insensitive Maps (see @@ -34,89 +38,60 @@ * *

      TagMap excels at storing primitives, copying between TagMap instances, and builder idioms. * - *

      Iterator traversal with TagMap is relatively slow, but TagMap#forEach is on par (and slightly) - * faster than traditional map entry iteration. + *

      Iterator traversal with TagMap is relatively slow, but TagMap#forEach matches the fastest map + * iterators (LinkedHashMap/TreeMap) and far outpaces HashMap entry-set iteration. * *

      HashMap & LinkedHashMap perform equally well on get operations. * - *

      HashMap is 2x faster throughput-wise to create and has less memory overhead because there's no - * linked list to capture insertion order. + *

      HashMap is ~1.4x faster throughput-wise to create than LinkedHashMap and has less memory + * overhead because there's no linked list to capture insertion order. * *

      TreeMap is useful when a custom Comparator is needed -- see CaseInsensitiveMapBenchmark * *

      HashMap & TagMap also perform exceedingly well in cases where the exact same object is used - * for put & get operations. e.g. when using String literals or Class literals as keys - * MacBook M1 1 thread (Java 21) + * for put & get operations. e.g. when using String literals or Class literals as keys. * - * Benchmark Mode Cnt Score Error Units - * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 12482267.775 ± 236852.198 ops/s - * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 12414187.888 ± 224418.265 ops/s - * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 49638156.234 ± 2972608.986 ops/s - * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 16201216.086 ± 619985.352 ops/s - * - * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 22534042.260 ± 819970.046 ops/s - * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 21871270.375 ± 893842.109 ops/s - * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 12905731.242 ± 8930007.156 ops/s - * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 15794277.380 ± 6069426.265 ops/s - * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 4711961.814 ± 48582.934 ops/s - * - * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 212201631.841 ± 6223069.782 ops/s - * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 392053406.085 ± 3938305.125 ops/s - * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 210734968.352 ± 3627805.282 ops/s - * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 201864656.534 ± 4596147.771 ops/s - * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 256311645.716 ± 13315886.308 ops/s - * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 94606404.423 ± 806879.890 ops/s - * - * MacBook M1 with 8 threads (Java 21) + *

      A TagSet + parallel int[] used as a fixed (build-once) map is the fastest get here -- ~30% + * ahead of HashMap on the rotating-key path and ~50% ahead on the same-key path, where it sustains + * 5.4B ops/s at the tightest error in the table (±1.3%). It pays no boxing (int[] values), chases + * no node, and allocates nothing per lookup. It only applies when the key set is fixed at + * construction. + * Apple M1 Max (10 core), macOS 26.4.1 -- 8 threads (per-thread state), 2 forks -- Java 17 (Zulu 17.42.19, 17.0.7+7-LTS) * * Benchmark Mode Cnt Score Error Units - * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 89645484.526 ± 6546683.185 ops/s - * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 78233577.417 ± 7204526.742 ops/s - * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 315228772.058 ± 20689692.104 ops/s - * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 102416350.341 ± 7258040.561 ops/s - * - * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 150462966.692 ± 11243713.572 ops/s - * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 111213025.138 ± 4593366.916 ops/s - * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 80882399.133 ± 19567359.487 ops/s - * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 93026443.634 ± 11831456.794 ops/s - * UnsynchronizedMapBenchmark.create_tagMap_via_ledger thrpt 6 70769351.353 ± 3821543.185 ops/s - * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 32737595.187 ± 2638992.844 ops/s + * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 69903436.959 ± 8570506.651 ops/s + * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 85857078.271 ± 6500998.510 ops/s + * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 293226423.495 ± 40922964.340 ops/s + * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 95345978.653 ± 19359076.478 ops/s * - * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1154522356.093 ± 116525174.735 ops/s - * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 1760800709.734 ± 33551896.166 ops/s - * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1191208257.933 ± 49810465.132 ops/s - * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 933455574.646 ± 154146815.295 ops/s - * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 1138764608.359 ± 88352911.617 ops/s - * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 490872723.682 ± 87017311.892 ops/s - * - * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 351222668.708 ± 35242914.752 ops/s - * UnsynchronizedMapBenchmark.iterate_linkedHashMap thrpt 6 406635839.285 ± 55990655.235 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap thrpt 6 185264584.604 ± 15137886.028 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 422407681.630 ± 19493455.109 ops/s - * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 392884747.896 ± 80190674.417 ops/s - * + * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 154092654.362 ± 4062613.480 ops/s + * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 146583930.032 ± 4457830.615 ops/s + * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 111282881.273 ± 11735323.503 ops/s + * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 92286566.881 ± 16770930.695 ops/s + * UnsynchronizedMapBenchmark.create_tagMap_via_ledger thrpt 6 71399936.094 ± 8077504.597 ops/s + * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 35930407.162 ± 590070.611 ops/s * - *

      A TagSet + parallel int[] used as a fixed (build-once) map is the fastest get here -- ahead of - * HashMap in both the rotating-key and same-key cases -- and the most predictable (±1-2% vs - * HashMap's ~5% and TagMap's ~12%). It pays no boxing (int[] values), chases no node, and allocates - * nothing per lookup. It only applies when the key set is fixed at construction. - * Fixed-map get comparison incl. TagSet + parallel int[] (Apple M1 Max, 8 threads, Java 8 Zulu 8.0.382) + * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1897568575.023 ± 47092787.708 ops/s + * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 3544716454.416 ± 192397957.653 ops/s + * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1871397460.306 ± 51848940.996 ops/s + * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 1706422514.145 ± 91472057.777 ops/s + * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 2205821374.441 ± 108659512.329 ops/s + * UnsynchronizedMapBenchmark.get_tagSetMap thrpt 6 2448917198.752 ± 105399021.596 ops/s + * UnsynchronizedMapBenchmark.get_tagSetMap_sameKey thrpt 6 5358887465.195 ± 70196881.552 ops/s + * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 704782343.575 ± 52129796.457 ops/s * - * Benchmark Mode Cnt Score Error Units - * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1086382687.356 ± 52784044.910 ops/s - * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 1254600761.612 ± 33664831.344 ops/s - * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1055047178.664 ± 44355203.950 ops/s - * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 906953688.631 ± 112749682.884 ops/s - * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 1055399886.424 ± 162974805.646 ops/s - * UnsynchronizedMapBenchmark.get_tagSetMap thrpt 6 1168134502.026 ± 23922240.061 ops/s - * UnsynchronizedMapBenchmark.get_tagSetMap_sameKey thrpt 6 1379540570.008 ± 15177042.759 ops/s - * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 590656771.941 ± 63177685.351 ops/s + * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 132215205.201 ± 9079485.505 ops/s + * UnsynchronizedMapBenchmark.iterate_linkedHashMap thrpt 6 343848177.356 ± 13084730.321 ops/s + * UnsynchronizedMapBenchmark.iterate_tagMap thrpt 6 140210161.690 ± 13645098.317 ops/s + * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 354398629.295 ± 5906534.357 ops/s + * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 344428565.523 ± 11100737.787 ops/s * */ @Fork(2) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) +@State(Scope.Thread) public class UnsynchronizedMapBenchmark { static final String[] INSERTION_KEYS = { "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3" @@ -132,18 +107,19 @@ public class UnsynchronizedMapBenchmark { return keys; }); - static int sharedLookupIndex = 0; + int lookupIndex = 0; // per-thread (Scope.Thread) — no shared-counter contention under @Threads(8) - static String nextLookupKey() { + String nextLookupKey() { return nextLookupKey(EQUAL_KEYS); } - static String nextLookupKey(String[] keys) { - int localIndex = ++sharedLookupIndex; - if (localIndex >= keys.length) { - sharedLookupIndex = localIndex = 0; + String nextLookupKey(String[] keys) { + int i = lookupIndex + 1; + if (i >= keys.length) { + i = 0; } - return keys[localIndex]; + lookupIndex = i; + return keys[i]; } static T init(Supplier supplier) { From 94830b2ff869ec43232beb5d206bb415c9ee5906 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 18 Jun 2026 12:27:25 -0400 Subject: [PATCH 05/34] Add threading-correct TagMap access microbenchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throughput microbenchmark for TagMap insert/getObject/getEntry over a representative HTTP-server tag set. All mutable state (the read map) lives in @State(Scope.Thread) so @Threads(8) runs measure TagMap rather than cross-thread contention on a shared map/counter/flyweight — the flaw that made earlier TagMap benchmarks misleading. Run config is baked into annotations (the me.champeau.jmh plugin ignores -Pjmh.* flags). Co-Authored-By: Claude Opus 4.8 --- .../trace/api/TagMapAccessBenchmark.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java new file mode 100644 index 00000000000..ec3ef8ffd0f --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java @@ -0,0 +1,109 @@ +package datadog.trace.api; + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Throughput microbenchmark for the core {@link TagMap} access paths: insert, raw-value read, and + * Entry read, over a representative HTTP-server-ish tag set. + * + *

      Threading correctness. Runs at {@code @Threads(8)}. All shared state is + * immutable ({@link #NAMES}/{@link #VALUES}); every bit of mutable state lives in a + * {@code @State(Scope.Thread)} holder so threads never contend on a shared map, index, or reader + * flyweight. Earlier TagMap benchmarks shared a cross-thread counter/index, which turned the result + * into a contention measurement rather than a TagMap measurement — this layout avoids that. Indices + * are plain per-invocation locals. + * + *

      Run configuration is baked into annotations rather than relying on {@code -Pjmh.*} flags + * (which the {@code me.champeau.jmh} plugin ignores). + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Fork(1) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@Threads(8) +@State(Scope.Benchmark) +public class TagMapAccessBenchmark { + // a representative HTTP-server-ish tag set (immutable -> safe to share across threads) + static final String[] NAMES = { + "http.request.method", + "http.response.status_code", + "http.route", + "url.path", + "url.scheme", + "server.address", + "server.port", + "client.address", + "network.protocol.version", + "user_agent.original", + "span.kind", + "component", + "language", + "error", + "resource.name", + "service.name", + "operation.name", + "env", + }; + + static final Object[] VALUES = new Object[NAMES.length]; + + static { + for (int i = 0; i < NAMES.length; ++i) { + VALUES[i] = "value-" + i; + } + } + + /** + * Pre-populated read map, PER-THREAD ({@code Scope.Thread}): each thread owns its own map so + * reads don't contend on shared mutable state under {@code @Threads(8)}. + */ + @State(Scope.Thread) + public static class ReadMap { + TagMap map; + + @Setup(Level.Trial) + public void build() { + this.map = TagMap.create(); + for (int i = 0; i < NAMES.length; ++i) { + this.map.set(NAMES[i], VALUES[i]); + } + } + } + + @Benchmark + public TagMap insert() { + TagMap map = TagMap.create(); + for (int i = 0; i < NAMES.length; ++i) { + map.set(NAMES[i], VALUES[i]); + } + return map; + } + + @Benchmark + public void getObject(ReadMap rm, Blackhole bh) { + for (int i = 0; i < NAMES.length; ++i) { + bh.consume(rm.map.getObject(NAMES[i])); + } + } + + @Benchmark + public void getEntry(ReadMap rm, Blackhole bh) { + for (int i = 0; i < NAMES.length; ++i) { + bh.consume(rm.map.getEntry(NAMES[i]).objectValue()); + } + } +} From 54979cb26100555313e18245648dbec846ef76c6 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 16:58:29 -0400 Subject: [PATCH 06/34] Fix data race in UnsynchronizedMapBenchmark scaffolding index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sharedLookupIndex was a plain static int incremented by all 8 JMH threads without synchronization — a data race that turned the get benchmarks into a contention measurement rather than a map measurement. Move the index to @State(Scope.Thread) so each thread has its own cursor, matching the approach used in TagMapAccessBenchmark. Co-Authored-By: Claude Sonnet 4.6 --- .../util/UnsynchronizedMapBenchmark.java | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java index 42fec2b98e0..7f9723d850f 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java @@ -9,6 +9,8 @@ import org.openjdk.jmh.annotations.Benchmark; 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; import org.openjdk.jmh.infra.Blackhole; @@ -111,18 +113,18 @@ public class UnsynchronizedMapBenchmark { return keys; }); - static int sharedLookupIndex = 0; + @State(Scope.Thread) + public static class BenchmarkState { + int index = 0; - static String nextLookupKey() { - return nextLookupKey(EQUAL_KEYS); - } + String nextLookupKey() { + return nextLookupKey(EQUAL_KEYS); + } - static String nextLookupKey(String[] keys) { - int localIndex = ++sharedLookupIndex; - if (localIndex >= keys.length) { - sharedLookupIndex = localIndex = 0; + String nextLookupKey(String[] keys) { + if (++index >= keys.length) index = 0; + return keys[index]; } - return keys[localIndex]; } static T init(Supplier supplier) { @@ -154,8 +156,8 @@ public Map create_hashMap_sized() { static final HashMap HASH_MAP = _create_hashMap(); @Benchmark - public Integer get_hashMap() { - return HASH_MAP.get(nextLookupKey()); + public Integer get_hashMap(BenchmarkState state) { + return HASH_MAP.get(state.nextLookupKey()); } @Benchmark @@ -167,8 +169,8 @@ public void iterate_hashMap(Blackhole blackhole) { } @Benchmark - public Integer get_hashMap_sameKey() { - return HASH_MAP.get(nextLookupKey(INSERTION_KEYS)); + public Integer get_hashMap_sameKey(BenchmarkState state) { + return HASH_MAP.get(state.nextLookupKey(INSERTION_KEYS)); } @Benchmark @@ -198,8 +200,8 @@ public TreeMap create_treeMap() { static final TreeMap TREE_MAP = _create_treeMap(); @Benchmark - public Integer get_treeMap() { - return TREE_MAP.get(nextLookupKey()); + public Integer get_treeMap(BenchmarkState state) { + return TREE_MAP.get(state.nextLookupKey()); } @Benchmark @@ -231,8 +233,8 @@ public LinkedHashMap create_linkedHashMap() { static final LinkedHashMap LINKED_HASH_MAP = _create_linkedHashMap(); @Benchmark - public Integer get_linkedHashMap() { - return LINKED_HASH_MAP.get(nextLookupKey()); + public Integer get_linkedHashMap(BenchmarkState state) { + return LINKED_HASH_MAP.get(state.nextLookupKey()); } @Benchmark @@ -273,13 +275,13 @@ public TagMap create_tagMap_via_ledger() { static final TagMap TAG_MAP = _create_tagMap(); @Benchmark - public int get_tagMap() { - return TAG_MAP.getInt(nextLookupKey()); + public int get_tagMap(BenchmarkState state) { + return TAG_MAP.getInt(state.nextLookupKey()); } @Benchmark - public int get_tagMap_sameKey() { - return TAG_MAP.getInt(nextLookupKey(INSERTION_KEYS)); + public int get_tagMap_sameKey(BenchmarkState state) { + return TAG_MAP.getInt(state.nextLookupKey(INSERTION_KEYS)); } @Benchmark From 0ecfa48f2a4b6e754d3c311a7b574c645bc21eeb Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 19:37:33 -0400 Subject: [PATCH 07/34] Wire -Pjmh.includes and -PtestJvm into internal-api JMH config Without this, -Pjmh.includes is silently ignored by the me.champeau.jmh plugin, requiring a full fat-jar build to run a single benchmark. -PtestJvm was also ignored for JMH execution, defaulting to the Gradle daemon JVM regardless of the requested version. Co-Authored-By: Claude Sonnet 4.6 --- internal-api/build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index fc95dd9e1f1..826d972f95b 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -1,3 +1,4 @@ +import datadog.gradle.plugin.testJvmConstraints.TestJvmSpec import de.thetaphi.forbiddenapis.gradle.CheckForbiddenApis import groovy.lang.Closure @@ -282,4 +283,13 @@ dependencies { jmh { jmhVersion = libs.versions.jmh.get() duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE + + if (project.hasProperty("jmh.includes")) { + includes.add(project.property("jmh.includes") as String) + } + + if (project.hasProperty("testJvm")) { + val testJvmSpec = TestJvmSpec(project) + jvm.set(testJvmSpec.javaTestLauncher.map { it.executablePath.asFile.absolutePath }) + } } From 9dc91221f4109a1027f467b1894369fb15e19992 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 20:38:30 -0400 Subject: [PATCH 08/34] Update UnsynchronizedMapBenchmark results with Java 17 numbers Re-run after fixing the shared-index data race, on Java 17 with correct per-thread scaffolding state. Co-Authored-By: Claude Sonnet 4.6 --- .../util/UnsynchronizedMapBenchmark.java | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java index 7f9723d850f..2062350f4b5 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java @@ -65,33 +65,33 @@ * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 256311645.716 ± 13315886.308 ops/s * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 94606404.423 ± 806879.890 ops/s * - * MacBook M1 with 8 threads (Java 21) + * MacBook M1 with 8 threads (Java 17) * * Benchmark Mode Cnt Score Error Units - * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 89645484.526 ± 6546683.185 ops/s - * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 78233577.417 ± 7204526.742 ops/s - * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 315228772.058 ± 20689692.104 ops/s - * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 102416350.341 ± 7258040.561 ops/s + * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 64114261.922 ± 2714625.721 ops/s + * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 58377772.312 ± 1886458.903 ops/s + * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 294689216.889 ± 2109031.601 ops/s + * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 95734227.366 ± 4991257.908 ops/s * - * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 150462966.692 ± 11243713.572 ops/s - * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 111213025.138 ± 4593366.916 ops/s - * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 80882399.133 ± 19567359.487 ops/s - * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 93026443.634 ± 11831456.794 ops/s - * UnsynchronizedMapBenchmark.create_tagMap_via_ledger thrpt 6 70769351.353 ± 3821543.185 ops/s - * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 32737595.187 ± 2638992.844 ops/s + * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 138186878.397 ± 8061849.083 ops/s + * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 134663715.019 ± 3647180.727 ops/s + * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 100174160.624 ± 15100786.744 ops/s + * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 100393577.047 ± 31239670.187 ops/s + * UnsynchronizedMapBenchmark.create_tagMap_via_ledger thrpt 6 66617980.502 ± 7150404.185 ops/s + * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 35675941.185 ± 1336913.277 ops/s * - * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1154522356.093 ± 116525174.735 ops/s - * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 1760800709.734 ± 33551896.166 ops/s - * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1191208257.933 ± 49810465.132 ops/s - * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 933455574.646 ± 154146815.295 ops/s - * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 1138764608.359 ± 88352911.617 ops/s - * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 490872723.682 ± 87017311.892 ops/s + * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1188970326.797 ± 12769138.695 ops/s + * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 1775131917.376 ± 36253403.407 ops/s + * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1116040635.574 ± 171675606.827 ops/s + * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 1096185164.767 ± 86073700.284 ops/s + * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 1421384314.806 ± 41585905.864 ops/s + * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 609161698.455 ± 52856650.957 ops/s * - * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 351222668.708 ± 35242914.752 ops/s - * UnsynchronizedMapBenchmark.iterate_linkedHashMap thrpt 6 406635839.285 ± 55990655.235 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap thrpt 6 185264584.604 ± 15137886.028 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 422407681.630 ± 19493455.109 ops/s - * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 392884747.896 ± 80190674.417 ops/s + * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 105299838.607 ± 2351744.650 ops/s + * UnsynchronizedMapBenchmark.iterate_linkedHashMap thrpt 6 128496701.159 ± 4733810.851 ops/s + * UnsynchronizedMapBenchmark.iterate_tagMap thrpt 6 93444577.025 ± 3013254.073 ops/s + * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 147636746.282 ± 13994246.571 ops/s + * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 128732613.579 ± 4947877.215 ops/s * */ @Fork(2) From a2b6a2f88ca460e22100addb5544cc414195571a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 20:44:21 -0400 Subject: [PATCH 09/34] Update UnsynchronizedMapBenchmark prose to match Java 17 results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '2x faster construction' claim was stale — Java 17 numbers show ~40%. Also clarifies that LinkedHashMap's cost is purely at construction; gets and iteration are equivalent to HashMap. Co-Authored-By: Claude Sonnet 4.6 --- .../datadog/trace/util/UnsynchronizedMapBenchmark.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java index 2062350f4b5..7763b05cc28 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java @@ -35,10 +35,9 @@ *

      Iterator traversal with TagMap is relatively slow, but TagMap#forEach is on par (and slightly) * faster than traditional map entry iteration. * - *

      HashMap & LinkedHashMap perform equally well on get operations. - * - *

      HashMap is 2x faster throughput-wise to create and has less memory overhead because there's no - * linked list to capture insertion order. + *

      HashMap & LinkedHashMap perform equally well on get and iterate operations — the cost of + * LinkedHashMap is paid entirely at construction (~40% slower) and in memory (doubly-linked list + * overhead per entry). Do not use LinkedHashMap unless insertion-order iteration is required. * *

      TreeMap is useful when a custom Comparator is needed -- see CaseInsensitiveMapBenchmark * From cd883e1a22321c2fde6a739f290133232a6938e7 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 21:32:37 -0400 Subject: [PATCH 10/34] Add builder-style insert benchmarks and update results in TagMapAccessBenchmark Co-Authored-By: Claude Sonnet 4.6 --- .../trace/api/TagMapAccessBenchmark.java | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java index ec3ef8ffd0f..3b08e8ce8d6 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java @@ -1,5 +1,7 @@ package datadog.trace.api; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; @@ -16,8 +18,9 @@ import org.openjdk.jmh.infra.Blackhole; /** - * Throughput microbenchmark for the core {@link TagMap} access paths: insert, raw-value read, and - * Entry read, over a representative HTTP-server-ish tag set. + * Throughput microbenchmark for the core {@link TagMap} access paths — insert (direct, via Ledger, + * and HashMap variants), raw-value read, and Entry read — over a representative HTTP-server-ish tag + * set. * *

      Threading correctness. Runs at {@code @Threads(8)}. All shared state is * immutable ({@link #NAMES}/{@link #VALUES}); every bit of mutable state lives in a @@ -28,6 +31,31 @@ * *

      Run configuration is baked into annotations rather than relying on {@code -Pjmh.*} flags * (which the {@code me.champeau.jmh} plugin ignores). + * + *

      Key findings (MacBook M1, 8 threads, Java 17): + * + *

        + *
      • get: TagMap ({@code getObject}/{@code getEntry} ~96M ops/s) is essentially on par + * with HashMap — the slight difference is noise. + *
      • insert: Direct {@code HashMap} put (65M) is faster than {@code TagMap} (52M) for + * plain insertion. However, if a builder pattern is required, {@code TagMap.Ledger} (41M) + * handily beats {@code HashMap} builder style — staging map + defensive copy (28M) — because + * it avoids the second allocation and second fill pass. + *
      • clone: See {@link UnsynchronizedMapBenchmark} — TagMap clone is ~4.6x faster than + * HashMap clone (295M vs 64M ops/s), which dominates span lifecycle costs. + *
      + * + * + * MacBook M1 with 8 threads (Java 17) + * + * Benchmark Mode Cnt Score Error Units + * TagMapAccessBenchmark.getEntry thrpt 5 95559437.524 ± 1381678.908 ops/s + * TagMapAccessBenchmark.getObject thrpt 5 95980166.452 ± 2217719.560 ops/s + * TagMapAccessBenchmark.insert thrpt 5 52523529.023 ± 1816998.150 ops/s + * TagMapAccessBenchmark.insert_hashMap thrpt 5 65344306.574 ± 4013136.530 ops/s + * TagMapAccessBenchmark.insert_hashMap_builderStyle thrpt 5 28057827.189 ± 1359655.664 ops/s + * TagMapAccessBenchmark.insert_via_ledger thrpt 5 41169656.095 ± 773264.754 ops/s + * */ @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) @@ -93,6 +121,37 @@ public TagMap insert() { return map; } + @Benchmark + public TagMap insert_via_ledger() { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < NAMES.length; ++i) { + ledger.set(NAMES[i], VALUES[i]); + } + return ledger.build(); + } + + @Benchmark + public Map insert_hashMap() { + HashMap map = new HashMap<>(); + for (int i = 0; i < NAMES.length; ++i) { + map.put(NAMES[i], VALUES[i]); + } + return map; + } + + /** + * Models the builder idiom for HashMap: accumulate into a staging map, then defensively copy. Two + * allocations, two fill passes — the honest cost of a HashMap-based builder pattern. + */ + @Benchmark + public Map insert_hashMap_builderStyle() { + HashMap staging = new HashMap<>(); + for (int i = 0; i < NAMES.length; ++i) { + staging.put(NAMES[i], VALUES[i]); + } + return new HashMap<>(staging); + } + @Benchmark public void getObject(ReadMap rm, Blackhole bh) { for (int i = 0; i < NAMES.length; ++i) { From 36a711e6242b63035f769b4ce521796da5f17e82 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 14:27:07 -0400 Subject: [PATCH 11/34] Use @Fork(2) in TagMapAccessBenchmark; drop JMH gradle wiring leak Align TagMapAccessBenchmark with UnsynchronizedMapBenchmark at @Fork(2) for steadier numbers (results to be refreshed on the next run). Also revert the internal-api/build.gradle.kts -Pjmh.includes / -PtestJvm wiring, which belongs in its dedicated PR (#11703), not here. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal-api/build.gradle.kts | 10 ---------- .../java/datadog/trace/api/TagMapAccessBenchmark.java | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index c6e03a03883..305dead4459 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -1,4 +1,3 @@ -import datadog.gradle.plugin.testJvmConstraints.TestJvmSpec import de.thetaphi.forbiddenapis.gradle.CheckForbiddenApis import groovy.lang.Closure @@ -275,13 +274,4 @@ dependencies { jmh { jmhVersion = libs.versions.jmh.get() duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE - - if (project.hasProperty("jmh.includes")) { - includes.add(project.property("jmh.includes") as String) - } - - if (project.hasProperty("testJvm")) { - val testJvmSpec = TestJvmSpec(project) - jvm.set(testJvmSpec.javaTestLauncher.map { it.executablePath.asFile.absolutePath }) - } } diff --git a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java index 3b08e8ce8d6..970a855c347 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java @@ -59,7 +59,7 @@ */ @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) -@Fork(1) +@Fork(2) @Warmup(iterations = 3) @Measurement(iterations = 5) @Threads(8) From 78a786e2ade8a911208b2273f6e1eb43644045b5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 15:19:22 -0400 Subject: [PATCH 12/34] Split map benchmarks into ImmutableMapBenchmark and SingleThreadedMapBenchmark Replace UnsynchronizedMapBenchmark with two classes that each pick the correct threading model for their use case (the @State scope can't vary by @Param, so one class can't host both): - ImmutableMapBenchmark: precomputed/read-mostly maps shared across threads (@State(Scope.Benchmark)) -- sharing is correct since read-only. get/iterate across HashMap, LinkedHashMap, TreeMap, TagMap. - SingleThreadedMapBenchmark: per-thread mutable lifecycle (@State(Scope.Thread)). create/clone + reads. Adds a Collections.synchronizedMap case to measure the uncontended synchronization tax (per-thread => bias never revoked); the unsynchronized HashMap get/iterate are the in-harness baseline. The biased- locking effect shows when comparing across JVM versions at stock flags. Also fixes a latent bug in the old iterate_linkedHashMap, which iterated TREE_MAP. Stale result blocks dropped; numbers pending a fresh multi-JVM run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trace/util/ImmutableMapBenchmark.java | 169 ++++++++++ .../util/SingleThreadedMapBenchmark.java | 222 +++++++++++++ .../util/UnsynchronizedMapBenchmark.java | 309 ------------------ 3 files changed, 391 insertions(+), 309 deletions(-) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java create mode 100644 internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java delete mode 100644 internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java new file mode 100644 index 00000000000..d5f3f8dc4be --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -0,0 +1,169 @@ +package datadog.trace.util; + +import datadog.trace.api.TagMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Read-side benchmark for precomputed, immutable / read-mostly maps that are shared across + * threads. Models the use case where a map is built once and then only read — often published and + * read concurrently by many threads. + * + *

      Because nothing mutates after construction, a single shared instance ({@link Scope#Benchmark}) + * read by all {@code @Threads} is realistic and contention-free. This is the read-mostly + * counterpart to the per-thread mutable {@link SingleThreadedMapBenchmark} and the contended {@code + * ConcurrentHashtable} / {@code ThreadSafeMap} suites. + * + *

      Compares {@code get} + {@code iterate} across {@link HashMap}, {@link LinkedHashMap}, {@link + * TreeMap}, and {@link TagMap}. Lookups use {@code EQUAL_KEYS} (distinct String instances) to + * exercise {@code equals()} rather than identity; {@code *_sameKey} variants reuse the original key + * instances to show the identity fast path. (Results pending a fresh multi-JVM run.) + */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@Threads(8) +@State(Scope.Benchmark) +public class ImmutableMapBenchmark { + static final String[] INSERTION_KEYS = { + "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3" + }; + + // Distinct String instances (not the literals used to build the maps) so lookups exercise + // equals(), not identity -- the realistic case for keys arriving from parsing/decoding. + static final String[] EQUAL_KEYS = newEqualKeys(); + + static String[] newEqualKeys() { + String[] keys = new String[INSERTION_KEYS.length]; + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + keys[i] = new String(INSERTION_KEYS[i]); + } + return keys; + } + + static void fill(Map map) { + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + map.put(INSERTION_KEYS[i], i); + } + } + + // Built once, never mutated -- safe to share across the reader threads. + HashMap hashMap; + LinkedHashMap linkedHashMap; + TreeMap treeMap; + TagMap tagMap; + + @Setup(Level.Trial) + public void setUp() { + hashMap = new HashMap<>(); + fill(hashMap); + linkedHashMap = new LinkedHashMap<>(); + fill(linkedHashMap); + treeMap = new TreeMap<>(); + fill(treeMap); + tagMap = TagMap.create(); + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + tagMap.set(INSERTION_KEYS[i], i); // primitive support + } + } + + /** Per-thread lookup cursor so each reader thread cycles keys independently. */ + @State(Scope.Thread) + public static class Cursor { + int index = 0; + + String nextKey() { + return nextKey(EQUAL_KEYS); + } + + String nextKey(String[] keys) { + if (++index >= keys.length) index = 0; + return keys[index]; + } + } + + @Benchmark + public Integer get_hashMap(Cursor cursor) { + return hashMap.get(cursor.nextKey()); + } + + @Benchmark + public Integer get_hashMap_sameKey(Cursor cursor) { + return hashMap.get(cursor.nextKey(INSERTION_KEYS)); + } + + @Benchmark + public void iterate_hashMap(Blackhole blackhole) { + for (Map.Entry entry : hashMap.entrySet()) { + blackhole.consume(entry.getKey()); + blackhole.consume(entry.getValue()); + } + } + + @Benchmark + public Integer get_linkedHashMap(Cursor cursor) { + return linkedHashMap.get(cursor.nextKey()); + } + + @Benchmark + public void iterate_linkedHashMap(Blackhole blackhole) { + for (Map.Entry entry : linkedHashMap.entrySet()) { + blackhole.consume(entry.getKey()); + blackhole.consume(entry.getValue()); + } + } + + @Benchmark + public Integer get_treeMap(Cursor cursor) { + return treeMap.get(cursor.nextKey()); + } + + @Benchmark + public void iterate_treeMap(Blackhole blackhole) { + for (Map.Entry entry : treeMap.entrySet()) { + blackhole.consume(entry.getKey()); + blackhole.consume(entry.getValue()); + } + } + + @Benchmark + public int get_tagMap(Cursor cursor) { + return tagMap.getInt(cursor.nextKey()); + } + + @Benchmark + public int get_tagMap_sameKey(Cursor cursor) { + return tagMap.getInt(cursor.nextKey(INSERTION_KEYS)); + } + + @Benchmark + public void iterate_tagMap(Blackhole blackhole) { + for (TagMap.EntryReader entry : tagMap) { + blackhole.consume(entry.tag()); + blackhole.consume(entry.intValue()); + } + } + + @Benchmark + public void iterate_tagMap_forEach(Blackhole blackhole) { + // Taking advantage of passthrough of contextObj to avoid capturing lambda + tagMap.forEach( + blackhole, + (bh, entry) -> { + bh.consume(entry.tag()); + bh.consume(entry.intValue()); + }); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java new file mode 100644 index 00000000000..d446ec4b7a7 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -0,0 +1,222 @@ +package datadog.trace.util; + +import datadog.trace.api.TagMap; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Benchmark for single-threaded (uncontended) map usage: each thread builds, mutates, reads, and + * discards its own maps. Models the common tracer pattern of assembling a short-lived map + * (e.g. span tags) on a single thread. + * + *

      State is per-thread ({@link Scope#Thread}) so no map is ever shared — the read-mostly shared + * case lives in {@link ImmutableMapBenchmark}, and the contended case in the {@code + * ConcurrentHashtable} / {@code ThreadSafeMap} suites. Running at {@code @Threads(8)} keeps + * allocation / GC interactions visible without introducing lock contention. + * + *

      Comparing different Map types: + * + *

        + *
      • (RECOMMENDED) HashMap — fastest general-purpose lookups + *
      • (RECOMMENDED) TagMap — preferred for storing tags; excels at primitives, copying, and + * builder idioms + *
      • TreeMap — when a custom Comparator is needed (see CaseInsensitiveMapBenchmark) + *
      • LinkedHashMap — only when insertion-order iteration is required; cost is paid at + * construction and in per-entry memory + *
      + * + *

      Uncontended synchronization tax. A {@link Collections#synchronizedMap} case is included + * to measure what synchronization costs when there is no contention: because each thread + * owns its synchronized map, the monitor is only ever locked by one thread. On JVMs with biased + * locking (Java ≤ 11 by default) repeated same-thread locking should be nearly free; on Java 15+ + * (biased locking disabled by default, JEP 374) it pays the full uncontended CAS. The + * unsynchronized {@code hashMap} {@code get}/{@code iterate} methods are the in-harness baseline; + * the tax is the delta to the {@code synchronizedHashMap} equivalents. Comparing across JVM + * versions at stock flags shows the biased-locking effect. (Results pending a fresh multi-JVM run.) + */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@Threads(8) +@State(Scope.Thread) +public class SingleThreadedMapBenchmark { + static final String[] INSERTION_KEYS = { + "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3" + }; + + // Distinct String instances so lookups exercise equals(), not identity. + static final String[] EQUAL_KEYS = newEqualKeys(); + + static String[] newEqualKeys() { + String[] keys = new String[INSERTION_KEYS.length]; + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + keys[i] = new String(INSERTION_KEYS[i]); + } + return keys; + } + + static void fill(Map map) { + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + map.put(INSERTION_KEYS[i], i); + } + } + + static TagMap fillTagMap(TagMap map) { + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + map.set(INSERTION_KEYS[i], i); // primitive support + } + return map; + } + + // Per-thread prebuilt maps for the read + clone benchmarks (built once per trial, per thread). + HashMap hashMap; + Map synchronizedHashMap; + TreeMap treeMap; + LinkedHashMap linkedHashMap; + TagMap tagMap; + int index = 0; + + @Setup(Level.Trial) + public void setUp() { + hashMap = new HashMap<>(); + fill(hashMap); + synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(hashMap)); + treeMap = new TreeMap<>(); + fill(treeMap); + linkedHashMap = new LinkedHashMap<>(); + fill(linkedHashMap); + tagMap = fillTagMap(TagMap.create()); + } + + String nextLookupKey() { + if (++index >= EQUAL_KEYS.length) index = 0; + return EQUAL_KEYS[index]; + } + + // ---- construction: build cost + allocation ---- + + @Benchmark + public Map create_hashMap() { + HashMap map = new HashMap<>(); + fill(map); + return map; + } + + @Benchmark + public Map create_hashMap_sized() { + // Sizing is preferable for large maps, but in practice most of our maps fall within the + // default. + HashMap map = new HashMap<>(INSERTION_KEYS.length); + fill(map); + return map; + } + + @Benchmark + public Map create_synchronizedHashMap() { + Map map = Collections.synchronizedMap(new HashMap<>()); + fill(map); + return map; + } + + @Benchmark + public TreeMap create_treeMap() { + TreeMap map = new TreeMap<>(); + fill(map); + return map; + } + + @Benchmark + public LinkedHashMap create_linkedHashMap() { + LinkedHashMap map = new LinkedHashMap<>(); + fill(map); + return map; + } + + @Benchmark + public TagMap create_tagMap() { + return fillTagMap(TagMap.create()); + } + + @Benchmark + public TagMap create_tagMap_via_ledger() { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + ledger.set(INSERTION_KEYS[i], i); // primitive support + } + return ledger.build(); + } + + // ---- copy ---- + + @Benchmark + public Map clone_hashMap() { + return new HashMap<>(hashMap); + } + + @Benchmark + public Map clone_synchronizedHashMap() { + return Collections.synchronizedMap(new HashMap<>(hashMap)); + } + + @Benchmark + public TreeMap clone_treeMap() { + TreeMap map = new TreeMap<>(); + map.putAll(treeMap); + return map; + } + + @Benchmark + public LinkedHashMap clone_linkedHashMap() { + return new LinkedHashMap<>(linkedHashMap); + } + + @Benchmark + public TagMap clone_tagMap() { + return tagMap.copy(); + } + + // ---- read: unsynchronized baseline vs uncontended synchronized (biased-locking story) ---- + + @Benchmark + public Integer get_hashMap() { + return hashMap.get(nextLookupKey()); + } + + @Benchmark + public Integer get_synchronizedHashMap() { + return synchronizedHashMap.get(nextLookupKey()); + } + + @Benchmark + public void iterate_hashMap(Blackhole blackhole) { + for (Map.Entry entry : hashMap.entrySet()) { + blackhole.consume(entry.getKey()); + blackhole.consume(entry.getValue()); + } + } + + @Benchmark + public void iterate_synchronizedHashMap(Blackhole blackhole) { + // Collections.synchronizedMap requires the caller to synchronize during iteration; this is the + // correct usage and measures one (uncontended) monitor acquire around the traversal. + synchronized (synchronizedHashMap) { + for (Map.Entry entry : synchronizedHashMap.entrySet()) { + blackhole.consume(entry.getKey()); + blackhole.consume(entry.getValue()); + } + } + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java deleted file mode 100644 index 7763b05cc28..00000000000 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ /dev/null @@ -1,309 +0,0 @@ -package datadog.trace.util; - -import datadog.trace.api.TagMap; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.TreeMap; -import java.util.function.Supplier; -import org.openjdk.jmh.annotations.Benchmark; -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; -import org.openjdk.jmh.infra.Blackhole; - -/** - * - * - *

        - * Benchmark comparing different Map-s... - *
      • (RECOMMENDED) HashMap - for fastest lookups - (not typically needed for tags) - *
      • (RECOMMENDED) TagMap - for storing tags - especially if copying between maps or using - * builders - *
      • TreeMap - better for custom Comparators - case-insensitive Maps (see - * CaseInsensitiveMapBenchmark) - *
      • LinkedHashMap - only when insertion order is needed - *
      - * - *

      TagMap is the preferred way to store tags. - * - *

      TagMap excels at storing primitives, copying between TagMap instances, and builder idioms. - * - *

      Iterator traversal with TagMap is relatively slow, but TagMap#forEach is on par (and slightly) - * faster than traditional map entry iteration. - * - *

      HashMap & LinkedHashMap perform equally well on get and iterate operations — the cost of - * LinkedHashMap is paid entirely at construction (~40% slower) and in memory (doubly-linked list - * overhead per entry). Do not use LinkedHashMap unless insertion-order iteration is required. - * - *

      TreeMap is useful when a custom Comparator is needed -- see CaseInsensitiveMapBenchmark - * - *

      HashMap & TagMap also perform exceedingly well in cases where the exact same object is used - * for put & get operations. e.g. when using String literals or Class literals as keys - * MacBook M1 1 thread (Java 21) - * - * Benchmark Mode Cnt Score Error Units - * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 12482267.775 ± 236852.198 ops/s - * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 12414187.888 ± 224418.265 ops/s - * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 49638156.234 ± 2972608.986 ops/s - * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 16201216.086 ± 619985.352 ops/s - * - * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 22534042.260 ± 819970.046 ops/s - * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 21871270.375 ± 893842.109 ops/s - * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 12905731.242 ± 8930007.156 ops/s - * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 15794277.380 ± 6069426.265 ops/s - * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 4711961.814 ± 48582.934 ops/s - * - * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 212201631.841 ± 6223069.782 ops/s - * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 392053406.085 ± 3938305.125 ops/s - * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 210734968.352 ± 3627805.282 ops/s - * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 201864656.534 ± 4596147.771 ops/s - * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 256311645.716 ± 13315886.308 ops/s - * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 94606404.423 ± 806879.890 ops/s - * - * MacBook M1 with 8 threads (Java 17) - * - * Benchmark Mode Cnt Score Error Units - * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 64114261.922 ± 2714625.721 ops/s - * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 58377772.312 ± 1886458.903 ops/s - * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 294689216.889 ± 2109031.601 ops/s - * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 95734227.366 ± 4991257.908 ops/s - * - * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 138186878.397 ± 8061849.083 ops/s - * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 134663715.019 ± 3647180.727 ops/s - * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 100174160.624 ± 15100786.744 ops/s - * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 100393577.047 ± 31239670.187 ops/s - * UnsynchronizedMapBenchmark.create_tagMap_via_ledger thrpt 6 66617980.502 ± 7150404.185 ops/s - * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 35675941.185 ± 1336913.277 ops/s - * - * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1188970326.797 ± 12769138.695 ops/s - * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 1775131917.376 ± 36253403.407 ops/s - * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1116040635.574 ± 171675606.827 ops/s - * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 1096185164.767 ± 86073700.284 ops/s - * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 1421384314.806 ± 41585905.864 ops/s - * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 609161698.455 ± 52856650.957 ops/s - * - * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 105299838.607 ± 2351744.650 ops/s - * UnsynchronizedMapBenchmark.iterate_linkedHashMap thrpt 6 128496701.159 ± 4733810.851 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap thrpt 6 93444577.025 ± 3013254.073 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 147636746.282 ± 13994246.571 ops/s - * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 128732613.579 ± 4947877.215 ops/s - * - */ -@Fork(2) -@Warmup(iterations = 2) -@Measurement(iterations = 3) -@Threads(8) -public class UnsynchronizedMapBenchmark { - static final String[] INSERTION_KEYS = { - "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3" - }; - - static final String[] EQUAL_KEYS = - init( - () -> { - String[] keys = new String[INSERTION_KEYS.length]; - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - keys[i] = new String(INSERTION_KEYS[i]); - } - return keys; - }); - - @State(Scope.Thread) - public static class BenchmarkState { - int index = 0; - - String nextLookupKey() { - return nextLookupKey(EQUAL_KEYS); - } - - String nextLookupKey(String[] keys) { - if (++index >= keys.length) index = 0; - return keys[index]; - } - } - - static T init(Supplier supplier) { - return supplier.get(); - } - - static void fill(Map map) { - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - map.put(INSERTION_KEYS[i], i); - } - } - - static HashMap _create_hashMap() { - HashMap map = new HashMap<>(); - fill(map); - return map; - } - - @Benchmark - public Map create_hashMap() { - return _create_hashMap(); - } - - @Benchmark - public Map create_hashMap_sized() { - return _create_hashMap_sized(); - } - - static final HashMap HASH_MAP = _create_hashMap(); - - @Benchmark - public Integer get_hashMap(BenchmarkState state) { - return HASH_MAP.get(state.nextLookupKey()); - } - - @Benchmark - public void iterate_hashMap(Blackhole blackhole) { - for (Map.Entry entry : HASH_MAP.entrySet()) { - blackhole.consume(entry.getKey()); - blackhole.consume(entry.getValue()); - } - } - - @Benchmark - public Integer get_hashMap_sameKey(BenchmarkState state) { - return HASH_MAP.get(state.nextLookupKey(INSERTION_KEYS)); - } - - @Benchmark - public Map clone_hashMap() { - return new HashMap<>(HASH_MAP); - } - - static Map _create_hashMap_sized() { - // Sizing is preferable for large maps, but in practice, most of our maps typically fall within - // the default - HashMap map = new HashMap<>(INSERTION_KEYS.length); - fill(map); - return map; - } - - static TreeMap _create_treeMap() { - TreeMap map = new TreeMap<>(); - fill(map); - return map; - } - - @Benchmark - public TreeMap create_treeMap() { - return _create_treeMap(); - } - - static final TreeMap TREE_MAP = _create_treeMap(); - - @Benchmark - public Integer get_treeMap(BenchmarkState state) { - return TREE_MAP.get(state.nextLookupKey()); - } - - @Benchmark - public void iterate_treeMap(Blackhole blackhole) { - for (Map.Entry entry : TREE_MAP.entrySet()) { - blackhole.consume(entry.getKey()); - blackhole.consume(entry.getValue()); - } - } - - @Benchmark - public TreeMap clone_treeMap() { - TreeMap map = new TreeMap<>(); - map.putAll(TREE_MAP); - return map; - } - - static LinkedHashMap _create_linkedHashMap() { - LinkedHashMap map = new LinkedHashMap<>(); - fill(map); - return map; - } - - @Benchmark - public LinkedHashMap create_linkedHashMap() { - return _create_linkedHashMap(); - } - - static final LinkedHashMap LINKED_HASH_MAP = _create_linkedHashMap(); - - @Benchmark - public Integer get_linkedHashMap(BenchmarkState state) { - return LINKED_HASH_MAP.get(state.nextLookupKey()); - } - - @Benchmark - public void iterate_linkedHashMap(Blackhole blackhole) { - for (Map.Entry entry : TREE_MAP.entrySet()) { - blackhole.consume(entry.getKey()); - blackhole.consume(entry.getValue()); - } - } - - @Benchmark - public LinkedHashMap clone_linkedHashMap() { - return new LinkedHashMap<>(LINKED_HASH_MAP); - } - - static TagMap _create_tagMap() { - TagMap map = TagMap.create(); - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - map.set(INSERTION_KEYS[i], i); // taking advantage of primitive support - } - return map; - } - - @Benchmark - public TagMap create_tagMap() { - return _create_tagMap(); - } - - @Benchmark - public TagMap create_tagMap_via_ledger() { - TagMap.Ledger ledger = TagMap.ledger(); - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - ledger.set(INSERTION_KEYS[i], i); // taking advantage of primitive support - } - return ledger.build(); - } - - static final TagMap TAG_MAP = _create_tagMap(); - - @Benchmark - public int get_tagMap(BenchmarkState state) { - return TAG_MAP.getInt(state.nextLookupKey()); - } - - @Benchmark - public int get_tagMap_sameKey(BenchmarkState state) { - return TAG_MAP.getInt(state.nextLookupKey(INSERTION_KEYS)); - } - - @Benchmark - public void iterate_tagMap(Blackhole blackhole) { - for (TagMap.EntryReader entry : TAG_MAP) { - blackhole.consume(entry.tag()); - blackhole.consume(entry.intValue()); - } - } - - @Benchmark - public void iterate_tagMap_forEach(Blackhole blackhole) { - // Taking advantage of passthrough of contextObj to avoid capturing lambda - TAG_MAP.forEach( - blackhole, - (bh, entry) -> { - bh.consume(entry.tag()); - bh.consume(entry.intValue()); - }); - } - - @Benchmark - public TagMap clone_tagMap() { - return TAG_MAP.copy(); - } -} From 0b1b9bd6474b1db8279a2cc68c099999c77a6213 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 16:49:42 -0400 Subject: [PATCH 13/34] Rename TagSet to StringIndex The structure is more general than tags: a flat, open-addressed String set whose indexOf returns a stable dense slot for parallel value arrays. "Tag" was just one prospective consumer. Renames the class, test, and benchmark references (Support/Data nested types and the SetBenchmark / map-benchmark usages); leaves the unrelated propagation TagSetChanges in dd-trace-core alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/datadog/trace/util/SetBenchmark.java | 46 ++++++++------- .../util/UnsynchronizedMapBenchmark.java | 58 ++++++++++--------- .../util/{TagSet.java => StringIndex.java} | 14 +++-- .../{TagSetTest.java => StringIndexTest.java} | 10 ++-- 4 files changed, 68 insertions(+), 60 deletions(-) rename internal-api/src/main/java/datadog/trace/util/{TagSet.java => StringIndex.java} (91%) rename internal-api/src/test/java/datadog/trace/util/{TagSetTest.java => StringIndexTest.java} (92%) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java index eec1bbee95e..29b2bd62628 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java @@ -20,13 +20,14 @@ * capped the fast structures at a ~1.4B contention ceiling (since superseded by the numbers below). * *

        - *
      • tagSetSupport (static) is the fastest membership path — 2336M hit / 2170M miss. It - * beats the TagSet instance ({@code tagSet_*}) by ~7% (hit) to ~12% (miss): the instance pays - * an instance-field load of hashes/names, while {@code Support.indexOf} over {@code static - * final} arrays lets the refs fold to constants. Matches KeyOfBenchmark's ~12% static-vs- - * instance gap. So when the set is fixed, pull {@code Data} into your own static finals. + *
      • stringIndexSupport (static) is the fastest membership path — 2336M hit / 2170M miss. + * It beats the StringIndex instance ({@code stringIndex_*}) by ~7% (hit) to ~12% (miss): the + * instance pays an instance-field load of hashes/names, while {@code Support.indexOf} over + * {@code static final} arrays lets the refs fold to constants. Matches KeyOfBenchmark's ~12% + * static-vs- instance gap. So when the set is fixed, pull {@code Data} into your own static + * finals. *
      • vs HashSet — the static path is ~12% faster on hit and ~par on miss. But HashSet was - * noisy here (±22% error) while TagSet was tight (±2-7%), so TagSet also wins on + * noisy here (±22% error) while StringIndex was tight (±2-7%), so StringIndex also wins on * predictability — and is allocation-free and positional-capable. *
      • array / sortedArray / treeSet cluster ~0.65-1.0B — they compare/scan per element, so they * slow on miss (hit early-exits; miss does the full scan / binary descent / tree walk). @@ -44,10 +45,10 @@ * SetBenchmark.hashSet_miss thrpt 6 2136501411.026 ± 474132929.024 ops/s * SetBenchmark.sortedArray_hit thrpt 6 837595967.794 ± 113538780.712 ops/s * SetBenchmark.sortedArray_miss thrpt 6 692064118.699 ± 25752553.077 ops/s - * SetBenchmark.tagSet_hit thrpt 6 2184722734.028 ± 61054981.099 ops/s - * SetBenchmark.tagSet_miss thrpt 6 1933588009.009 ± 159869680.982 ops/s - * SetBenchmark.tagSetSupport_hit thrpt 6 2335685599.706 ± 52460762.937 ops/s - * SetBenchmark.tagSetSupport_miss thrpt 6 2169715463.018 ± 141321499.862 ops/s + * SetBenchmark.stringIndex_hit thrpt 6 2184722734.028 ± 61054981.099 ops/s + * SetBenchmark.stringIndex_miss thrpt 6 1933588009.009 ± 159869680.982 ops/s + * SetBenchmark.stringIndexSupport_hit thrpt 6 2335685599.706 ± 52460762.937 ops/s + * SetBenchmark.stringIndexSupport_miss thrpt 6 2169715463.018 ± 141321499.862 ops/s * SetBenchmark.treeSet_hit thrpt 6 798251906.675 ± 39041398.413 ops/s * SetBenchmark.treeSet_miss thrpt 6 667078954.487 ± 56517120.187 ops/s * @@ -174,37 +175,38 @@ public boolean treeSet_miss() { return TREE_SET.contains(nextMiss()); } - static final TagSet TAG_SET = TagSet.of(STRINGS); + static final StringIndex STRING_INDEX = StringIndex.of(STRINGS); @Benchmark - public boolean tagSet_hit() { - return TAG_SET.contains(nextHit()); + public boolean stringIndex_hit() { + return STRING_INDEX.contains(nextHit()); } @Benchmark - public boolean tagSet_miss() { - return TAG_SET.contains(nextMiss()); + public boolean stringIndex_miss() { + return STRING_INDEX.contains(nextMiss()); } // The static Support path: hashes/names built once into static-final arrays (refs fold to - // constants) and probed directly via Support.indexOf -- vs tagSet_* above, which loads them - // through a TagSet instance. Mirrors KeyOfBenchmark's tagSet (static) vs tagSet_throughClass. + // constants) and probed directly via Support.indexOf -- vs stringIndex_* above, which loads them + // through a StringIndex instance. Mirrors KeyOfBenchmark's stringIndex (static) vs + // stringIndex_throughClass. static final int[] SUPPORT_HASHES; static final String[] SUPPORT_NAMES; static { - TagSet.Data data = TagSet.Support.create(STRINGS); + StringIndex.Data data = StringIndex.Support.create(STRINGS); SUPPORT_HASHES = data.hashes; SUPPORT_NAMES = data.names; } @Benchmark - public boolean tagSetSupport_hit() { - return TagSet.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextHit()) >= 0; + public boolean stringIndexSupport_hit() { + return StringIndex.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextHit()) >= 0; } @Benchmark - public boolean tagSetSupport_miss() { - return TagSet.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextMiss()) >= 0; + public boolean stringIndexSupport_miss() { + return StringIndex.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextMiss()) >= 0; } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java index 55ce4fa1745..fbbd62cbbc2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java @@ -21,17 +21,17 @@ *
          * Benchmark comparing different Map-s... *
        • (RECOMMENDED) HashMap - fastest lookups among general-purpose (mutable) maps - (not - * typically needed for tags; a fixed TagSet map below is faster still when the keys are + * typically needed for tags; a fixed StringIndex map below is faster still when the keys are * known) *
        • (RECOMMENDED) TagMap - for storing tags - especially if copying between maps or using * builders *
        • TreeMap - better for custom Comparators - case-insensitive Maps (see * CaseInsensitiveMapBenchmark) *
        • LinkedHashMap - only when insertion order is needed - *
        • TagSet + parallel value array - for a FIXED (build-once, read-only) map whose key set is - * known up front: the keys go in a {@link TagSet} and the values in a parallel array indexed - * by the slot {@code indexOf} returns. Fastest get, no per-lookup allocation, no node chasing - * - but it can't change after construction (see get_tagSetMap). + *
        • StringIndex + parallel value array - for a FIXED (build-once, read-only) map whose key set + * is known up front: the keys go in a {@link StringIndex} and the values in a parallel array + * indexed by the slot {@code indexOf} returns. Fastest get, no per-lookup allocation, no node + * chasing - but it can't change after construction (see get_stringIndexMap). *
        * *

        TagMap is the preferred way to store tags. @@ -51,10 +51,10 @@ *

        HashMap & TagMap also perform exceedingly well in cases where the exact same object is used * for put & get operations. e.g. when using String literals or Class literals as keys. * - *

        A TagSet + parallel int[] used as a fixed (build-once) map is the fastest get here -- ~30% - * ahead of HashMap on the rotating-key path and ~50% ahead on the same-key path, where it sustains - * 5.4B ops/s at the tightest error in the table (±1.3%). It pays no boxing (int[] values), chases - * no node, and allocates nothing per lookup. It only applies when the key set is fixed at + *

        A StringIndex + parallel int[] used as a fixed (build-once) map is the fastest get here -- + * ~30% ahead of HashMap on the rotating-key path and ~50% ahead on the same-key path, where it + * sustains 5.4B ops/s at the tightest error in the table (±1.3%). It pays no boxing (int[] values), + * chases no node, and allocates nothing per lookup. It only applies when the key set is fixed at * construction. * Apple M1 Max (10 core), macOS 26.4.1 -- 8 threads (per-thread state), 2 forks -- Java 17 (Zulu 17.42.19, 17.0.7+7-LTS) * @@ -76,8 +76,8 @@ * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1871397460.306 ± 51848940.996 ops/s * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 1706422514.145 ± 91472057.777 ops/s * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 2205821374.441 ± 108659512.329 ops/s - * UnsynchronizedMapBenchmark.get_tagSetMap thrpt 6 2448917198.752 ± 105399021.596 ops/s - * UnsynchronizedMapBenchmark.get_tagSetMap_sameKey thrpt 6 5358887465.195 ± 70196881.552 ops/s + * UnsynchronizedMapBenchmark.get_stringIndexMap thrpt 6 2448917198.752 ± 105399021.596 ops/s + * UnsynchronizedMapBenchmark.get_stringIndexMap_sameKey thrpt 6 5358887465.195 ± 70196881.552 ops/s * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 704782343.575 ± 52129796.457 ops/s * * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 132215205.201 ± 9079485.505 ops/s @@ -303,38 +303,42 @@ public TagMap clone_tagMap() { return TAG_MAP.copy(); } - // TagSet + a parallel value array used as a FIXED (build-once, read-only) map. The keys are known - // up front, so they go in a TagSet and the values in a plain array indexed by the slot that + // StringIndex + a parallel value array used as a FIXED (build-once, read-only) map. The keys are + // known + // up front, so they go in a StringIndex and the values in a plain array indexed by the slot that // Support.indexOf returns. There is no node, no Entry, and no per-lookup allocation -- a get is a // hash probe (with an interned == fast path) plus one array load. Pull the Data into your own // static finals (as below) so the hashes/names refs fold to constants -- same static-vs-instance // win SetBenchmark and KeyOfBenchmark measure. The values live in a parallel int[] -- no boxing, // the same primitive-value advantage TagMap.getInt has over a HashMap, whose // value type structurally forces the box. The trade-off: it cannot change after construction. - static final int[] TAG_SET_HASHES; - static final String[] TAG_SET_NAMES; - static final int[] TAG_SET_VALUES; + static final int[] STRING_INDEX_HASHES; + static final String[] STRING_INDEX_NAMES; + static final int[] STRING_INDEX_VALUES; static { - TagSet.Data data = TagSet.Support.create(INSERTION_KEYS); + StringIndex.Data data = StringIndex.Support.create(INSERTION_KEYS); int[] values = new int[data.names.length]; for (int i = 0; i < INSERTION_KEYS.length; ++i) { - values[TagSet.Support.indexOf(data.hashes, data.names, INSERTION_KEYS[i])] = i; + values[StringIndex.Support.indexOf(data.hashes, data.names, INSERTION_KEYS[i])] = i; } - TAG_SET_HASHES = data.hashes; - TAG_SET_NAMES = data.names; - TAG_SET_VALUES = values; + STRING_INDEX_HASHES = data.hashes; + STRING_INDEX_NAMES = data.names; + STRING_INDEX_VALUES = values; } @Benchmark - public int get_tagSetMap() { - int slot = TagSet.Support.indexOf(TAG_SET_HASHES, TAG_SET_NAMES, nextLookupKey()); - return slot < 0 ? -1 : TAG_SET_VALUES[slot]; + public int get_stringIndexMap() { + int slot = + StringIndex.Support.indexOf(STRING_INDEX_HASHES, STRING_INDEX_NAMES, nextLookupKey()); + return slot < 0 ? -1 : STRING_INDEX_VALUES[slot]; } @Benchmark - public int get_tagSetMap_sameKey() { - int slot = TagSet.Support.indexOf(TAG_SET_HASHES, TAG_SET_NAMES, nextLookupKey(INSERTION_KEYS)); - return slot < 0 ? -1 : TAG_SET_VALUES[slot]; + public int get_stringIndexMap_sameKey() { + int slot = + StringIndex.Support.indexOf( + STRING_INDEX_HASHES, STRING_INDEX_NAMES, nextLookupKey(INSERTION_KEYS)); + return slot < 0 ? -1 : STRING_INDEX_VALUES[slot]; } } diff --git a/internal-api/src/main/java/datadog/trace/util/TagSet.java b/internal-api/src/main/java/datadog/trace/util/StringIndex.java similarity index 91% rename from internal-api/src/main/java/datadog/trace/util/TagSet.java rename to internal-api/src/main/java/datadog/trace/util/StringIndex.java index 570f3c7e003..3b6980ff492 100644 --- a/internal-api/src/main/java/datadog/trace/util/TagSet.java +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -11,7 +11,7 @@ * 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 TagSet} instance ({@link #of}) — a convenience wrapper holding the + *
      • 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. @@ -23,12 +23,12 @@ *

        Slot 0-value is the empty sentinel: {@link Support#hash} never returns 0, so {@code hashes[i] * == 0} unambiguously means an empty slot. */ -public final class TagSet { +public final class StringIndex { private final int[] hashes; private final String[] names; public final int slots; // == hashes.length - private TagSet(int[] hashes, String[] names) { + private StringIndex(int[] hashes, String[] names) { this.hashes = hashes; this.names = names; this.slots = hashes.length; @@ -37,9 +37,9 @@ private TagSet(int[] hashes, String[] names) { /** * Convenience instance — wraps the placed arrays. For the hot path prefer raw {@link Support}. */ - public static TagSet of(String... names) { + public static StringIndex of(String... names) { Data data = Support.create(names); - return new TagSet(data.hashes, data.names); + return new StringIndex(data.hashes, data.names); } /** Slot of {@code name}, or -1. Delegates to {@link Support} on the instance's arrays. */ @@ -67,7 +67,9 @@ public static final class Data { } } - /** Static algorithm over raw arrays. Query helpers take raw arrays, never a Data or a TagSet. */ + /** + * Static algorithm over raw arrays. Query helpers take raw arrays, never a Data or a StringIndex. + */ public static final class Support { private Support() {} diff --git a/internal-api/src/test/java/datadog/trace/util/TagSetTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java similarity index 92% rename from internal-api/src/test/java/datadog/trace/util/TagSetTest.java rename to internal-api/src/test/java/datadog/trace/util/StringIndexTest.java index e5ca5ff41e5..60c0d9998ea 100644 --- a/internal-api/src/test/java/datadog/trace/util/TagSetTest.java +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java @@ -6,11 +6,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import datadog.trace.util.TagSet.Data; -import datadog.trace.util.TagSet.Support; +import datadog.trace.util.StringIndex.Data; +import datadog.trace.util.StringIndex.Support; import org.junit.jupiter.api.Test; -class TagSetTest { +class StringIndexTest { @Test void hash_spread_and_zeroSentinel() { @@ -32,7 +32,7 @@ void tableSizeFor_isPow2_andOversized() { @Test void instance_contains_internedAndCopy_andMiss() { - TagSet set = TagSet.of("foo", "bar", "baz"); + StringIndex set = StringIndex.of("foo", "bar", "baz"); assertEquals(8, set.slots()); // 3 names -> tableSizeFor(3) == 8 @@ -84,7 +84,7 @@ void put_throwsWhenFull() { assertThrows(IllegalStateException.class, () -> Support.put(hashes, names, "c", 6)); } - /** The documented usage: build a TagSet, attach a parallel payload indexed by slot. */ + /** The documented usage: build a StringIndex, attach a parallel payload indexed by slot. */ @Test void parallelPayloadBySlot() { String[] names = {"a", "b", "c"}; From 24a9fdccdb49a77327f337b84ee2a8263226c73e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 17:46:22 -0400 Subject: [PATCH 14/34] Overhaul set benchmarks: split Immutable / SingleThreaded, add Set.copyOf Mirror the map-benchmark overhaul for sets. Replace the single SetBenchmark (shared mutable counter under @Threads(8); contains_treeSet bug that queried HASH_SET) with two classes that each pick the right threading model: - ImmutableSetBenchmark: fixed read-only membership shared across threads (@State(Scope.Benchmark)); array / sortedArray / HashSet / TreeSet / Set.copyOf (the JDK compact SetN the agent actually uses for config sets, via CollectionUtils.tryMakeImmutableSet). hit/miss split, per-thread cursor. - SingleThreadedSetBenchmark: per-thread mutable lifecycle (@State(Scope.Thread)); create/clone + contains/iterate, plus a Collections.synchronizedSet case for the uncontended synchronization tax (per-thread => bias never revoked; biased-locking story across JVMs). StringIndex rows fold in later. Result blocks empty pending a fresh multi-JVM run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trace/util/ImmutableSetBenchmark.java | 165 +++++++++++++++++ .../java/datadog/trace/util/SetBenchmark.java | 128 ------------- .../util/SingleThreadedSetBenchmark.java | 172 ++++++++++++++++++ 3 files changed, 337 insertions(+), 128 deletions(-) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java delete mode 100644 internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java create mode 100644 internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java new file mode 100644 index 00000000000..8dc45eed908 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -0,0 +1,165 @@ +package datadog.trace.util; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.TreeSet; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Membership over a small, fixed, read-only string set shared across threads — split into hit and + * miss lookups (different cost shapes per structure). + * + *

        The set is built once and only read, so a single shared instance ({@link Scope#Benchmark}) + * read by all {@code @Threads} is realistic and contention-free. This is the read-mostly + * counterpart to the per-thread mutable {@link SingleThreadedSetBenchmark}, and mirrors {@link + * ImmutableMapBenchmark} on the set side. Sets in the tracer skew strongly toward this fixed, + * read-only shape. + * + *

        Strategies compared: + * + *

          + *
        • {@code array} / {@code sortedArray} — linear scan / binary search; slow on miss. + *
        • {@link HashSet} — idiomatic, fast; node-based, allocates per element. + *
        • {@link TreeSet} — comparator-ordered; worth it only for a custom comparator, not speed. + *
        • {@link java.util.Set#copyOf} (via {@link CollectionUtils#tryMakeImmutableSet}) — the JDK's + * compact, array-backed immutable set ({@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. + *
        + * + *

        Lookups are interned (the {@code ==} fast path where a structure has one); misses are short + * and never present. (Results pending a fresh multi-JVM run — {@code Set.copyOf} only materializes + * the compact form on Java 10+.) + */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@Threads(8) +@State(Scope.Benchmark) +public class ImmutableSetBenchmark { + static final String[] STRINGS = { + "foo", "bar", "baz", "quux", "hello", "world", + "service", "queryString", "lorem", "ipsum", "dolem", "sit" + }; + + /** Distinct String instances that are never present, for the miss path. */ + static final String[] MISSES = newMisses(); + + static String[] newMisses() { + String[] misses = new String[STRINGS.length * 4]; + for (int i = 0; i < misses.length; ++i) { + misses[i] = "dne-" + i; + } + return misses; + } + + // Built once, never mutated -- safe to share across the reader threads. + String[] array; + String[] sortedArray; + HashSet hashSet; + TreeSet treeSet; + Set copyOfSet; + + @Setup(Level.Trial) + public void setUp() { + array = STRINGS; + sortedArray = Arrays.copyOf(STRINGS, STRINGS.length); + Arrays.sort(sortedArray); + hashSet = new HashSet<>(Arrays.asList(STRINGS)); + treeSet = new TreeSet<>(Arrays.asList(STRINGS)); + copyOfSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(STRINGS)); + } + + /** Per-thread lookup cursor so each reader thread cycles keys independently. */ + @State(Scope.Thread) + public static class Cursor { + int hitIndex = 0; + int missIndex = 0; + + String nextHit() { + int i = hitIndex + 1; + if (i >= STRINGS.length) { + i = 0; + } + hitIndex = i; + return STRINGS[i]; + } + + String nextMiss() { + int i = missIndex + 1; + if (i >= MISSES.length) { + i = 0; + } + missIndex = i; + return MISSES[i]; + } + } + + static boolean arrayContains(String[] array, String needle) { + for (String s : array) { + if (needle.equals(s)) { + return true; + } + } + return false; + } + + @Benchmark + public boolean array_hit(Cursor cursor) { + return arrayContains(array, cursor.nextHit()); + } + + @Benchmark + public boolean array_miss(Cursor cursor) { + return arrayContains(array, cursor.nextMiss()); + } + + @Benchmark + public boolean sortedArray_hit(Cursor cursor) { + return Arrays.binarySearch(sortedArray, cursor.nextHit()) >= 0; + } + + @Benchmark + public boolean sortedArray_miss(Cursor cursor) { + return Arrays.binarySearch(sortedArray, cursor.nextMiss()) >= 0; + } + + @Benchmark + public boolean hashSet_hit(Cursor cursor) { + return hashSet.contains(cursor.nextHit()); + } + + @Benchmark + public boolean hashSet_miss(Cursor cursor) { + return hashSet.contains(cursor.nextMiss()); + } + + @Benchmark + public boolean treeSet_hit(Cursor cursor) { + return treeSet.contains(cursor.nextHit()); + } + + @Benchmark + public boolean treeSet_miss(Cursor cursor) { + return treeSet.contains(cursor.nextMiss()); + } + + @Benchmark + public boolean copyOf_hit(Cursor cursor) { + return copyOfSet.contains(cursor.nextHit()); + } + + @Benchmark + public boolean copyOf_miss(Cursor cursor) { + return copyOfSet.contains(cursor.nextMiss()); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java deleted file mode 100644 index 144e4748400..00000000000 --- a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java +++ /dev/null @@ -1,128 +0,0 @@ -package datadog.trace.util; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.TreeSet; -import java.util.concurrent.ThreadLocalRandom; -import java.util.function.Supplier; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Threads; -import org.openjdk.jmh.annotations.Warmup; - -/** - * - * - *

          - * Benchmark showing possible ways to represent and check if a set includes an elememt... - *
        • (RECOMMENDED) HashSet - on par with TreeSet - idiomatic - *
        • (RECOMMENDED) TreeMap - on par with HashSet - better solution if custom comparator is - * needed (see CaseInsensitiveMapBenchmark) - *
        • array - slower than HashSet - *
        • sortedArray - slowest - slower than array for common case of small arrays - *
        - * - * - * MacBook M1 - 8 threads - Java 21 - * 1/3 not found rate - * - * Benchmark Mode Cnt Score Error Units - * SetBenchmark.contains_array thrpt 6 645561886.327 ± 100781717.494 ops/s - * SetBenchmark.contains_hashSet thrpt 6 1536236680.235 ± 114966961.506 ops/s - * SetBenchmark.contains_sortedArray thrpt 6 571476939.441 ± 21334620.460 ops/s - * SetBenchmark.contains_treeSet thrpt 6 1557663759.411 ± 95343683.124 ops/s - * - */ -@Fork(2) -@Warmup(iterations = 2) -@Measurement(iterations = 3) -@Threads(8) -public class SetBenchmark { - static final String[] STRINGS = - new String[] { - "foo", - "bar", - "baz", - "quux", - "hello", - "world", - "service", - "queryString", - "lorem", - "ipsum", - "dolem", - "sit" - }; - - static T init(Supplier supplier) { - return supplier.get(); - } - - static final String[] LOOKUPS = - init( - () -> { - String[] lookups = Arrays.copyOf(STRINGS, STRINGS.length * 10); - - for (int i = 0; i < STRINGS.length; ++i) { - lookups[STRINGS.length + i] = new String(STRINGS[i]); - } - - // 2 / 3 of the key look-ups miss the set - for (int i = STRINGS.length * 2; i < lookups.length; ++i) { - lookups[i] = "dne-" + ThreadLocalRandom.current().nextInt(); - } - - Collections.shuffle(Arrays.asList(lookups)); - return lookups; - }); - - static int sharedLookupIndex = 0; - - static String nextString() { - int localIndex = ++sharedLookupIndex; - if (localIndex >= LOOKUPS.length) { - sharedLookupIndex = localIndex = 0; - } - return LOOKUPS[localIndex]; - } - - static final String[] ARRAY = STRINGS; - - @Benchmark - public boolean contains_array() { - String needle = nextString(); - for (String str : ARRAY) { - if (needle.equals(str)) return true; - } - return false; - } - - static final String[] SORTED_ARRAY = - init( - () -> { - String[] sorted = Arrays.copyOf(STRINGS, STRINGS.length); - Arrays.sort(sorted); - return sorted; - }); - - @Benchmark - public boolean contains_sortedArray() { - return (Arrays.binarySearch(SORTED_ARRAY, nextString()) != -1); - } - - static final HashSet HASH_SET = new HashSet<>(Arrays.asList(STRINGS)); - - @Benchmark - public boolean contains_hashSet() { - return HASH_SET.contains(nextString()); - } - - static final TreeSet TREE_SET = new TreeSet<>(Arrays.asList(STRINGS)); - - @Benchmark - public boolean contains_treeSet() { - return HASH_SET.contains(nextString()); - } -} diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java new file mode 100644 index 00000000000..b90fbbeb288 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java @@ -0,0 +1,172 @@ +package datadog.trace.util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.TreeSet; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Single-threaded (uncontended) set usage: each thread builds, reads, and discards its own + * sets. Per-thread state ({@link Scope#Thread}); mirrors {@link SingleThreadedMapBenchmark} on the + * set side. Running at {@code @Threads(8)} keeps allocation / GC interactions visible without lock + * contention. + * + *

        Sets in the tracer skew read-only/fixed (see {@link ImmutableSetBenchmark}); this covers the + * mutable-lifecycle case for completeness and — via {@link Collections#synchronizedSet} — the + * uncontended synchronization tax. Because each thread owns its synchronized set, the + * monitor is only ever locked by one thread: biased locking ≈ free on Java ≤ 11, full uncontended + * CAS on Java 15+ (biased locking disabled by default, JEP 374). The unsynchronized {@code hashSet} + * {@code contains}/{@code iterate} methods are the in-harness baseline; the tax is the delta. + * (Results pending a fresh multi-JVM run.) + */ +@Fork(2) +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@Threads(8) +@State(Scope.Thread) +public class SingleThreadedSetBenchmark { + static final String[] ELEMENTS = { + "foo", "bar", "baz", "quux", "hello", "world", + "service", "queryString", "lorem", "ipsum", "dolem", "sit" + }; + + // Distinct String instances so lookups exercise equals(), not identity. + static final String[] EQUAL_ELEMENTS = newEqualElements(); + + static String[] newEqualElements() { + String[] copies = new String[ELEMENTS.length]; + for (int i = 0; i < ELEMENTS.length; ++i) { + copies[i] = new String(ELEMENTS[i]); + } + return copies; + } + + static void fill(Set set) { + for (String s : ELEMENTS) { + set.add(s); + } + } + + // Per-thread prebuilt sets for the read + clone benchmarks (built once per trial, per thread). + HashSet hashSet; + Set synchronizedSet; + TreeSet treeSet; + LinkedHashSet linkedHashSet; + int index = 0; + + @Setup(Level.Trial) + public void setUp() { + hashSet = new HashSet<>(Arrays.asList(ELEMENTS)); + synchronizedSet = Collections.synchronizedSet(new HashSet<>(hashSet)); + treeSet = new TreeSet<>(Arrays.asList(ELEMENTS)); + linkedHashSet = new LinkedHashSet<>(Arrays.asList(ELEMENTS)); + } + + String nextLookup() { + if (++index >= EQUAL_ELEMENTS.length) { + index = 0; + } + return EQUAL_ELEMENTS[index]; + } + + // ---- construction: build cost + allocation ---- + + @Benchmark + public Set create_hashSet() { + HashSet set = new HashSet<>(); + fill(set); + return set; + } + + @Benchmark + public Set create_hashSet_sized() { + HashSet set = new HashSet<>(ELEMENTS.length); + fill(set); + return set; + } + + @Benchmark + public Set create_synchronizedSet() { + Set set = Collections.synchronizedSet(new HashSet<>()); + fill(set); + return set; + } + + @Benchmark + public Set create_treeSet() { + TreeSet set = new TreeSet<>(); + fill(set); + return set; + } + + @Benchmark + public Set create_linkedHashSet() { + LinkedHashSet set = new LinkedHashSet<>(); + fill(set); + return set; + } + + // ---- copy ---- + + @Benchmark + public Set clone_hashSet() { + return new HashSet<>(hashSet); + } + + @Benchmark + public Set clone_synchronizedSet() { + return Collections.synchronizedSet(new HashSet<>(hashSet)); + } + + @Benchmark + public Set clone_treeSet() { + return new TreeSet<>(treeSet); + } + + @Benchmark + public Set clone_linkedHashSet() { + return new LinkedHashSet<>(linkedHashSet); + } + + // ---- read: unsynchronized baseline vs uncontended synchronized (biased-locking story) ---- + + @Benchmark + public boolean contains_hashSet() { + return hashSet.contains(nextLookup()); + } + + @Benchmark + public boolean contains_synchronizedSet() { + return synchronizedSet.contains(nextLookup()); + } + + @Benchmark + public void iterate_hashSet(Blackhole blackhole) { + for (String s : hashSet) { + blackhole.consume(s); + } + } + + @Benchmark + public void iterate_synchronizedSet(Blackhole blackhole) { + // Collections.synchronizedSet requires the caller to synchronize during iteration; this is the + // correct usage and measures one (uncontended) monitor acquire around the traversal. + synchronized (synchronizedSet) { + for (String s : synchronizedSet) { + blackhole.consume(s); + } + } + } +} From d9167621a5d5f0c4f38d53ea1ca4c36927db77bd Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 17:47:38 -0400 Subject: [PATCH 15/34] Drop benchmarks from StringIndex PR; revert SetBenchmark/UnsynchronizedMapBenchmark StringIndex's benchmark integration is moving to the dedicated benchmark PRs (set overhaul #11721, map overhaul #11679) and will be folded in there later. Revert both benchmark files to master so this PR is purely the StringIndex data structure + tests. Avoids the #11679/#11721 deletions-vs-edits conflicts too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/datadog/trace/util/SetBenchmark.java | 172 +++++------------- .../util/UnsynchronizedMapBenchmark.java | 146 ++++++--------- 2 files changed, 99 insertions(+), 219 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java index 29b2bd62628..144e4748400 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SetBenchmark.java @@ -1,63 +1,44 @@ package datadog.trace.util; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.TreeSet; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; import org.openjdk.jmh.annotations.Benchmark; 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; /** - * Ways to represent a small set of strings and test membership, split into hit and miss lookups - * (different cost shapes per structure). Lookups are interned (the {@code ==} fast path); misses - * are short and never present. Per-thread state ({@code @State(Scope.Thread)}) keeps the rotation - * counter off the shared-write path under {@code @Threads(8)} — an earlier shared-counter version - * capped the fast structures at a ~1.4B contention ceiling (since superseded by the numbers below). + * * *

          - *
        • stringIndexSupport (static) is the fastest membership path — 2336M hit / 2170M miss. - * It beats the StringIndex instance ({@code stringIndex_*}) by ~7% (hit) to ~12% (miss): the - * instance pays an instance-field load of hashes/names, while {@code Support.indexOf} over - * {@code static final} arrays lets the refs fold to constants. Matches KeyOfBenchmark's ~12% - * static-vs- instance gap. So when the set is fixed, pull {@code Data} into your own static - * finals. - *
        • vs HashSet — the static path is ~12% faster on hit and ~par on miss. But HashSet was - * noisy here (±22% error) while StringIndex was tight (±2-7%), so StringIndex also wins on - * predictability — and is allocation-free and positional-capable. - *
        • array / sortedArray / treeSet cluster ~0.65-1.0B — they compare/scan per element, so they - * slow on miss (hit early-exits; miss does the full scan / binary descent / tree walk). - * TreeSet is NOT uniquely slowest — worth it only for a custom comparator (case-insensitive, - * dodging {@code toLowerCase}), not speed. + * Benchmark showing possible ways to represent and check if a set includes an elememt... + *
        • (RECOMMENDED) HashSet - on par with TreeSet - idiomatic + *
        • (RECOMMENDED) TreeMap - on par with HashSet - better solution if custom comparator is + * needed (see CaseInsensitiveMapBenchmark) + *
        • array - slower than HashSet + *
        • sortedArray - slowest - slower than array for common case of small arrays *
        * * - * Apple M1 Max (10 core) - 8 threads (per-thread state) - 2 forks - Java 8 (Zulu 8.0.382) + * MacBook M1 - 8 threads - Java 21 + * 1/3 not found rate * - * Benchmark Mode Cnt Score Error Units - * SetBenchmark.array_hit thrpt 6 995578895.732 ± 73709080.997 ops/s - * SetBenchmark.array_miss thrpt 6 649860848.470 ± 32489300.626 ops/s - * SetBenchmark.hashSet_hit thrpt 6 2081738804.271 ± 464349157.190 ops/s - * SetBenchmark.hashSet_miss thrpt 6 2136501411.026 ± 474132929.024 ops/s - * SetBenchmark.sortedArray_hit thrpt 6 837595967.794 ± 113538780.712 ops/s - * SetBenchmark.sortedArray_miss thrpt 6 692064118.699 ± 25752553.077 ops/s - * SetBenchmark.stringIndex_hit thrpt 6 2184722734.028 ± 61054981.099 ops/s - * SetBenchmark.stringIndex_miss thrpt 6 1933588009.009 ± 159869680.982 ops/s - * SetBenchmark.stringIndexSupport_hit thrpt 6 2335685599.706 ± 52460762.937 ops/s - * SetBenchmark.stringIndexSupport_miss thrpt 6 2169715463.018 ± 141321499.862 ops/s - * SetBenchmark.treeSet_hit thrpt 6 798251906.675 ± 39041398.413 ops/s - * SetBenchmark.treeSet_miss thrpt 6 667078954.487 ± 56517120.187 ops/s + * Benchmark Mode Cnt Score Error Units + * SetBenchmark.contains_array thrpt 6 645561886.327 ± 100781717.494 ops/s + * SetBenchmark.contains_hashSet thrpt 6 1536236680.235 ± 114966961.506 ops/s + * SetBenchmark.contains_sortedArray thrpt 6 571476939.441 ± 21334620.460 ops/s + * SetBenchmark.contains_treeSet thrpt 6 1557663759.411 ± 95343683.124 ops/s * */ @Fork(2) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) -@State(Scope.Thread) public class SetBenchmark { static final String[] STRINGS = new String[] { @@ -79,60 +60,45 @@ static T init(Supplier supplier) { return supplier.get(); } - /** Present in the set (interned). */ - static final String[] HITS = STRINGS; - - /** Never present. */ - static final String[] MISSES = + static final String[] LOOKUPS = init( () -> { - String[] misses = new String[STRINGS.length * 4]; - for (int i = 0; i < misses.length; ++i) { - misses[i] = "dne-" + i; + String[] lookups = Arrays.copyOf(STRINGS, STRINGS.length * 10); + + for (int i = 0; i < STRINGS.length; ++i) { + lookups[STRINGS.length + i] = new String(STRINGS[i]); + } + + // 2 / 3 of the key look-ups miss the set + for (int i = STRINGS.length * 2; i < lookups.length; ++i) { + lookups[i] = "dne-" + ThreadLocalRandom.current().nextInt(); } - return misses; - }); - int hitIndex = 0; // per-thread (Scope.Thread) — no shared-counter contention under @Threads(8) - int missIndex = 0; + Collections.shuffle(Arrays.asList(lookups)); + return lookups; + }); - String nextHit() { - int i = hitIndex + 1; - if (i >= HITS.length) { - i = 0; - } - hitIndex = i; - return HITS[i]; - } + static int sharedLookupIndex = 0; - String nextMiss() { - int i = missIndex + 1; - if (i >= MISSES.length) { - i = 0; + static String nextString() { + int localIndex = ++sharedLookupIndex; + if (localIndex >= LOOKUPS.length) { + sharedLookupIndex = localIndex = 0; } - missIndex = i; - return MISSES[i]; + return LOOKUPS[localIndex]; } static final String[] ARRAY = STRINGS; - static boolean arrayContains(String needle) { + @Benchmark + public boolean contains_array() { + String needle = nextString(); for (String str : ARRAY) { if (needle.equals(str)) return true; } return false; } - @Benchmark - public boolean array_hit() { - return arrayContains(nextHit()); - } - - @Benchmark - public boolean array_miss() { - return arrayContains(nextMiss()); - } - static final String[] SORTED_ARRAY = init( () -> { @@ -142,71 +108,21 @@ public boolean array_miss() { }); @Benchmark - public boolean sortedArray_hit() { - return Arrays.binarySearch(SORTED_ARRAY, nextHit()) >= 0; - } - - @Benchmark - public boolean sortedArray_miss() { - return Arrays.binarySearch(SORTED_ARRAY, nextMiss()) >= 0; + public boolean contains_sortedArray() { + return (Arrays.binarySearch(SORTED_ARRAY, nextString()) != -1); } static final HashSet HASH_SET = new HashSet<>(Arrays.asList(STRINGS)); @Benchmark - public boolean hashSet_hit() { - return HASH_SET.contains(nextHit()); - } - - @Benchmark - public boolean hashSet_miss() { - return HASH_SET.contains(nextMiss()); + public boolean contains_hashSet() { + return HASH_SET.contains(nextString()); } static final TreeSet TREE_SET = new TreeSet<>(Arrays.asList(STRINGS)); @Benchmark - public boolean treeSet_hit() { - return TREE_SET.contains(nextHit()); - } - - @Benchmark - public boolean treeSet_miss() { - return TREE_SET.contains(nextMiss()); - } - - static final StringIndex STRING_INDEX = StringIndex.of(STRINGS); - - @Benchmark - public boolean stringIndex_hit() { - return STRING_INDEX.contains(nextHit()); - } - - @Benchmark - public boolean stringIndex_miss() { - return STRING_INDEX.contains(nextMiss()); - } - - // The static Support path: hashes/names built once into static-final arrays (refs fold to - // constants) and probed directly via Support.indexOf -- vs stringIndex_* above, which loads them - // through a StringIndex instance. Mirrors KeyOfBenchmark's stringIndex (static) vs - // stringIndex_throughClass. - static final int[] SUPPORT_HASHES; - static final String[] SUPPORT_NAMES; - - static { - StringIndex.Data data = StringIndex.Support.create(STRINGS); - SUPPORT_HASHES = data.hashes; - SUPPORT_NAMES = data.names; - } - - @Benchmark - public boolean stringIndexSupport_hit() { - return StringIndex.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextHit()) >= 0; - } - - @Benchmark - public boolean stringIndexSupport_miss() { - return StringIndex.Support.indexOf(SUPPORT_HASHES, SUPPORT_NAMES, nextMiss()) >= 0; + public boolean contains_treeSet() { + return HASH_SET.contains(nextString()); } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java index fbbd62cbbc2..42fec2b98e0 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/UnsynchronizedMapBenchmark.java @@ -9,8 +9,6 @@ import org.openjdk.jmh.annotations.Benchmark; 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; import org.openjdk.jmh.infra.Blackhole; @@ -20,78 +18,84 @@ * *
          * Benchmark comparing different Map-s... - *
        • (RECOMMENDED) HashMap - fastest lookups among general-purpose (mutable) maps - (not - * typically needed for tags; a fixed StringIndex map below is faster still when the keys are - * known) + *
        • (RECOMMENDED) HashMap - for fastest lookups - (not typically needed for tags) *
        • (RECOMMENDED) TagMap - for storing tags - especially if copying between maps or using * builders *
        • TreeMap - better for custom Comparators - case-insensitive Maps (see * CaseInsensitiveMapBenchmark) *
        • LinkedHashMap - only when insertion order is needed - *
        • StringIndex + parallel value array - for a FIXED (build-once, read-only) map whose key set - * is known up front: the keys go in a {@link StringIndex} and the values in a parallel array - * indexed by the slot {@code indexOf} returns. Fastest get, no per-lookup allocation, no node - * chasing - but it can't change after construction (see get_stringIndexMap). *
        * *

        TagMap is the preferred way to store tags. * *

        TagMap excels at storing primitives, copying between TagMap instances, and builder idioms. * - *

        Iterator traversal with TagMap is relatively slow, but TagMap#forEach matches the fastest map - * iterators (LinkedHashMap/TreeMap) and far outpaces HashMap entry-set iteration. + *

        Iterator traversal with TagMap is relatively slow, but TagMap#forEach is on par (and slightly) + * faster than traditional map entry iteration. * *

        HashMap & LinkedHashMap perform equally well on get operations. * - *

        HashMap is ~1.4x faster throughput-wise to create than LinkedHashMap and has less memory - * overhead because there's no linked list to capture insertion order. + *

        HashMap is 2x faster throughput-wise to create and has less memory overhead because there's no + * linked list to capture insertion order. * *

        TreeMap is useful when a custom Comparator is needed -- see CaseInsensitiveMapBenchmark * *

        HashMap & TagMap also perform exceedingly well in cases where the exact same object is used - * for put & get operations. e.g. when using String literals or Class literals as keys. + * for put & get operations. e.g. when using String literals or Class literals as keys + * MacBook M1 1 thread (Java 21) * - *

        A StringIndex + parallel int[] used as a fixed (build-once) map is the fastest get here -- - * ~30% ahead of HashMap on the rotating-key path and ~50% ahead on the same-key path, where it - * sustains 5.4B ops/s at the tightest error in the table (±1.3%). It pays no boxing (int[] values), - * chases no node, and allocates nothing per lookup. It only applies when the key set is fixed at - * construction. - * Apple M1 Max (10 core), macOS 26.4.1 -- 8 threads (per-thread state), 2 forks -- Java 17 (Zulu 17.42.19, 17.0.7+7-LTS) + * Benchmark Mode Cnt Score Error Units + * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 12482267.775 ± 236852.198 ops/s + * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 12414187.888 ± 224418.265 ops/s + * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 49638156.234 ± 2972608.986 ops/s + * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 16201216.086 ± 619985.352 ops/s + * + * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 22534042.260 ± 819970.046 ops/s + * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 21871270.375 ± 893842.109 ops/s + * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 12905731.242 ± 8930007.156 ops/s + * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 15794277.380 ± 6069426.265 ops/s + * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 4711961.814 ± 48582.934 ops/s + * + * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 212201631.841 ± 6223069.782 ops/s + * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 392053406.085 ± 3938305.125 ops/s + * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 210734968.352 ± 3627805.282 ops/s + * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 201864656.534 ± 4596147.771 ops/s + * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 256311645.716 ± 13315886.308 ops/s + * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 94606404.423 ± 806879.890 ops/s + * + * MacBook M1 with 8 threads (Java 21) * * Benchmark Mode Cnt Score Error Units - * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 69903436.959 ± 8570506.651 ops/s - * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 85857078.271 ± 6500998.510 ops/s - * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 293226423.495 ± 40922964.340 ops/s - * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 95345978.653 ± 19359076.478 ops/s + * UnsynchronizedMapBenchmark.clone_hashMap thrpt 6 89645484.526 ± 6546683.185 ops/s + * UnsynchronizedMapBenchmark.clone_linkedHashMap thrpt 6 78233577.417 ± 7204526.742 ops/s + * UnsynchronizedMapBenchmark.clone_tagMap thrpt 6 315228772.058 ± 20689692.104 ops/s + * UnsynchronizedMapBenchmark.clone_treeMap thrpt 6 102416350.341 ± 7258040.561 ops/s * - * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 154092654.362 ± 4062613.480 ops/s - * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 146583930.032 ± 4457830.615 ops/s - * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 111282881.273 ± 11735323.503 ops/s - * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 92286566.881 ± 16770930.695 ops/s - * UnsynchronizedMapBenchmark.create_tagMap_via_ledger thrpt 6 71399936.094 ± 8077504.597 ops/s - * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 35930407.162 ± 590070.611 ops/s + * UnsynchronizedMapBenchmark.create_hashMap thrpt 6 150462966.692 ± 11243713.572 ops/s + * UnsynchronizedMapBenchmark.create_hashMap_sized thrpt 6 111213025.138 ± 4593366.916 ops/s + * UnsynchronizedMapBenchmark.create_linkedHashMap thrpt 6 80882399.133 ± 19567359.487 ops/s + * UnsynchronizedMapBenchmark.create_tagMap thrpt 6 93026443.634 ± 11831456.794 ops/s + * UnsynchronizedMapBenchmark.create_tagMap_via_ledger thrpt 6 70769351.353 ± 3821543.185 ops/s + * UnsynchronizedMapBenchmark.create_treeMap thrpt 6 32737595.187 ± 2638992.844 ops/s * - * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1897568575.023 ± 47092787.708 ops/s - * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 3544716454.416 ± 192397957.653 ops/s - * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1871397460.306 ± 51848940.996 ops/s - * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 1706422514.145 ± 91472057.777 ops/s - * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 2205821374.441 ± 108659512.329 ops/s - * UnsynchronizedMapBenchmark.get_stringIndexMap thrpt 6 2448917198.752 ± 105399021.596 ops/s - * UnsynchronizedMapBenchmark.get_stringIndexMap_sameKey thrpt 6 5358887465.195 ± 70196881.552 ops/s - * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 704782343.575 ± 52129796.457 ops/s + * UnsynchronizedMapBenchmark.get_hashMap thrpt 6 1154522356.093 ± 116525174.735 ops/s + * UnsynchronizedMapBenchmark.get_hashMap_sameKey thrpt 6 1760800709.734 ± 33551896.166 ops/s + * UnsynchronizedMapBenchmark.get_linkedHashMap thrpt 6 1191208257.933 ± 49810465.132 ops/s + * UnsynchronizedMapBenchmark.get_tagMap thrpt 6 933455574.646 ± 154146815.295 ops/s + * UnsynchronizedMapBenchmark.get_tagMap_sameKey thrpt 6 1138764608.359 ± 88352911.617 ops/s + * UnsynchronizedMapBenchmark.get_treeMap thrpt 6 490872723.682 ± 87017311.892 ops/s * - * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 132215205.201 ± 9079485.505 ops/s - * UnsynchronizedMapBenchmark.iterate_linkedHashMap thrpt 6 343848177.356 ± 13084730.321 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap thrpt 6 140210161.690 ± 13645098.317 ops/s - * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 354398629.295 ± 5906534.357 ops/s - * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 344428565.523 ± 11100737.787 ops/s + * UnsynchronizedMapBenchmark.iterate_hashMap thrpt 6 351222668.708 ± 35242914.752 ops/s + * UnsynchronizedMapBenchmark.iterate_linkedHashMap thrpt 6 406635839.285 ± 55990655.235 ops/s + * UnsynchronizedMapBenchmark.iterate_tagMap thrpt 6 185264584.604 ± 15137886.028 ops/s + * UnsynchronizedMapBenchmark.iterate_tagMap_forEach thrpt 6 422407681.630 ± 19493455.109 ops/s + * UnsynchronizedMapBenchmark.iterate_treeMap thrpt 6 392884747.896 ± 80190674.417 ops/s * */ @Fork(2) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) -@State(Scope.Thread) public class UnsynchronizedMapBenchmark { static final String[] INSERTION_KEYS = { "foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3" @@ -107,19 +111,18 @@ public class UnsynchronizedMapBenchmark { return keys; }); - int lookupIndex = 0; // per-thread (Scope.Thread) — no shared-counter contention under @Threads(8) + static int sharedLookupIndex = 0; - String nextLookupKey() { + static String nextLookupKey() { return nextLookupKey(EQUAL_KEYS); } - String nextLookupKey(String[] keys) { - int i = lookupIndex + 1; - if (i >= keys.length) { - i = 0; + static String nextLookupKey(String[] keys) { + int localIndex = ++sharedLookupIndex; + if (localIndex >= keys.length) { + sharedLookupIndex = localIndex = 0; } - lookupIndex = i; - return keys[i]; + return keys[localIndex]; } static T init(Supplier supplier) { @@ -302,43 +305,4 @@ public void iterate_tagMap_forEach(Blackhole blackhole) { public TagMap clone_tagMap() { return TAG_MAP.copy(); } - - // StringIndex + a parallel value array used as a FIXED (build-once, read-only) map. The keys are - // known - // up front, so they go in a StringIndex and the values in a plain array indexed by the slot that - // Support.indexOf returns. There is no node, no Entry, and no per-lookup allocation -- a get is a - // hash probe (with an interned == fast path) plus one array load. Pull the Data into your own - // static finals (as below) so the hashes/names refs fold to constants -- same static-vs-instance - // win SetBenchmark and KeyOfBenchmark measure. The values live in a parallel int[] -- no boxing, - // the same primitive-value advantage TagMap.getInt has over a HashMap, whose - // value type structurally forces the box. The trade-off: it cannot change after construction. - static final int[] STRING_INDEX_HASHES; - static final String[] STRING_INDEX_NAMES; - static final int[] STRING_INDEX_VALUES; - - static { - StringIndex.Data data = StringIndex.Support.create(INSERTION_KEYS); - int[] values = new int[data.names.length]; - for (int i = 0; i < INSERTION_KEYS.length; ++i) { - values[StringIndex.Support.indexOf(data.hashes, data.names, INSERTION_KEYS[i])] = i; - } - STRING_INDEX_HASHES = data.hashes; - STRING_INDEX_NAMES = data.names; - STRING_INDEX_VALUES = values; - } - - @Benchmark - public int get_stringIndexMap() { - int slot = - StringIndex.Support.indexOf(STRING_INDEX_HASHES, STRING_INDEX_NAMES, nextLookupKey()); - return slot < 0 ? -1 : STRING_INDEX_VALUES[slot]; - } - - @Benchmark - public int get_stringIndexMap_sameKey() { - int slot = - StringIndex.Support.indexOf( - STRING_INDEX_HASHES, STRING_INDEX_NAMES, nextLookupKey(INSERTION_KEYS)); - return slot < 0 ? -1 : STRING_INDEX_VALUES[slot]; - } } From 613952d40952e3b8908bc555c6271cf4f6abc740 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 18:03:55 -0400 Subject: [PATCH 16/34] Add Map.copyOf case to ImmutableMapBenchmark; fix dangling Javadoc link - Add a Map.copyOf case (via CollectionUtils.tryMakeImmutableMap -> JDK MapN) to ImmutableMapBenchmark: get / get_sameKey / iterate. MapN is the agent's actual fixed-config-map representation and the honest immutable-map baseline. - Fix TagMapAccessBenchmark's @link to the deleted UnsynchronizedMapBenchmark -> SingleThreadedMapBenchmark (which now holds the clone cases). - Note that interned (_sameKey) lookups are the common tracer case (keys are typically interned tag-name constants). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trace/api/TagMapAccessBenchmark.java | 4 +-- .../trace/util/ImmutableMapBenchmark.java | 35 +++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java index 970a855c347..de33c957e9a 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java @@ -41,8 +41,8 @@ * plain insertion. However, if a builder pattern is required, {@code TagMap.Ledger} (41M) * handily beats {@code HashMap} builder style — staging map + defensive copy (28M) — because * it avoids the second allocation and second fill pass. - *

      • clone: See {@link UnsynchronizedMapBenchmark} — TagMap clone is ~4.6x faster than - * HashMap clone (295M vs 64M ops/s), which dominates span lifecycle costs. + *
      • clone: See {@link datadog.trace.util.SingleThreadedMapBenchmark} — TagMap clone is + * ~4.6x faster than HashMap clone (295M vs 64M ops/s), which dominates span lifecycle costs. *
      * * 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 d5f3f8dc4be..f460a834185 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -27,9 +27,17 @@ * ConcurrentHashtable} / {@code ThreadSafeMap} suites. * *

      Compares {@code get} + {@code iterate} across {@link HashMap}, {@link LinkedHashMap}, {@link - * TreeMap}, and {@link TagMap}. Lookups use {@code EQUAL_KEYS} (distinct String instances) to - * exercise {@code equals()} rather than identity; {@code *_sameKey} variants reuse the original key - * instances to show the identity fast path. (Results pending a fresh multi-JVM run.) + * TreeMap}, {@link TagMap}, and {@link java.util.Map#copyOf} (via {@link + * CollectionUtils#tryMakeImmutableMap} — the JDK's compact, array-backed {@code + * ImmutableCollections.MapN}, which is what the agent actually uses for fixed config maps; Java + * 10+, falls back to the input map pre-10). {@code Map.copyOf}/{@code MapN} is the honest + * immutable-map baseline, not {@code HashMap}. + * + *

      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+.) */ @Fork(2) @Warmup(iterations = 2) @@ -64,6 +72,7 @@ static void fill(Map map) { LinkedHashMap linkedHashMap; TreeMap treeMap; TagMap tagMap; + Map copyOfMap; @Setup(Level.Trial) public void setUp() { @@ -77,6 +86,8 @@ public void setUp() { for (int i = 0; i < INSERTION_KEYS.length; ++i) { tagMap.set(INSERTION_KEYS[i], i); // primitive support } + // JDK compact immutable map (MapN on Java 10+); the agent's actual fixed-map representation. + copyOfMap = CollectionUtils.tryMakeImmutableMap(hashMap); } /** Per-thread lookup cursor so each reader thread cycles keys independently. */ @@ -166,4 +177,22 @@ public void iterate_tagMap_forEach(Blackhole blackhole) { bh.consume(entry.intValue()); }); } + + @Benchmark + public Integer get_copyOf(Cursor cursor) { + return copyOfMap.get(cursor.nextKey()); + } + + @Benchmark + public Integer get_copyOf_sameKey(Cursor cursor) { + return copyOfMap.get(cursor.nextKey(INSERTION_KEYS)); + } + + @Benchmark + public void iterate_copyOf(Blackhole blackhole) { + for (Map.Entry entry : copyOfMap.entrySet()) { + blackhole.consume(entry.getKey()); + blackhole.consume(entry.getValue()); + } + } } From 5a14e62e10ad9138bb90c6643ff5111b39a43145 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 24 Jun 2026 07:15:12 -0400 Subject: [PATCH 17/34] Add Java 17 results to set benchmark Javadocs ImmutableSetBenchmark: HashSet fastest; Set.copyOf (SetN) ~10% behind on hit, the compact form the agent uses for fixed config sets. SingleThreadedSetBenchmark: uncontended synchronizedSet tax ~37% on contains (biased locking off, Java 17), near-zero on iterate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trace/util/ImmutableSetBenchmark.java | 25 +++++++++++++++-- .../util/SingleThreadedSetBenchmark.java | 28 ++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) 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 8dc45eed908..13312c6c576 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -37,8 +37,29 @@ *

    * *

    Lookups are interned (the {@code ==} fast path where a structure has one); misses are short - * and never present. (Results pending a fresh multi-JVM run — {@code Set.copyOf} only materializes - * the compact form on Java 10+.) + * and never present. + * + *

    Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}; M ops/s = millions): + * + *

    {@code
    + * Structure              hit     miss
    + * hashSet               2159     1751    (fastest)
    + * copyOf (SetN)         1946     1633
    + * array                  926      584
    + * sortedArray            664      588
    + * treeSet                642      593
    + * }
    + * + *

    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. + *
    */ @Fork(2) @Warmup(iterations = 2) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java index b90fbbeb288..f9e9b69179a 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java @@ -29,7 +29,33 @@ * monitor is only ever locked by one thread: biased locking ≈ free on Java ≤ 11, full uncontended * CAS on Java 15+ (biased locking disabled by default, JEP 374). The unsynchronized {@code hashSet} * {@code contains}/{@code iterate} methods are the in-harness baseline; the tax is the delta. - * (Results pending a fresh multi-JVM run.) + * + *

    Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}; M ops/s = millions): + * + *

    {@code
    + * contains_hashSet            1291
    + * contains_synchronizedSet     808    (~37% slower — the uncontended sync tax)
    + * iterate_hashSet              91
    + * iterate_synchronizedSet      90    (one monitor acquire amortized over the walk)
    + *
    + * create_hashSet         81    clone_hashSet          48
    + * create_hashSet_sized   78    clone_synchronizedSet  47
    + * create_linkedHashSet   61    clone_linkedHashSet    59
    + * create_synchronizedSet 41    clone_treeSet          83
    + * create_treeSet         36
    + * }
    + * + *

    Key findings: + * + *

      + *
    • Uncontended synchronization tax on {@code contains} is ~37% (1291 → 808M ops/s) even + * with no contention and biased locking disabled (Java 17, JEP 374) — the full per-lock CAS + * cost. On {@code iterate} it nearly vanishes: a single monitor acquire amortized over the + * traversal. + *
    • Construction: {@code TreeSet} is the slowest to build (~36M); the {@code synchronizedSet} + * wrapper adds a modest cost over plain {@code HashSet}. (Allocation-path numbers carry more + * run-to-run variance than the read paths.) + *
    */ @Fork(2) @Warmup(iterations = 2) From 227fcc061d0b32d96d6e136d943947f6310c0ea2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 24 Jun 2026 09:18:49 -0400 Subject: [PATCH 18/34] Add JOL retained-footprint test for StringIndex vs JDK sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measures StringIndex vs HashSet/TreeSet/Set.copyOf(SetN)/array via JOL (deterministic, load-insensitive). StringIndex is ~9% lighter than HashSet (no per-element Node objects — asserted), but SetN is the most compact by a wide margin: StringIndex pays for its cached int[] hashes + 2x-oversized String[]. So StringIndex's edge over SetN is speed + the indexOf/parallel-array capability, not footprint. Adds jol-core:0.17 test dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal-api/build.gradle.kts | 1 + .../trace/util/StringIndexFootprintTest.java | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index fc95dd9e1f1..024805e0958 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -277,6 +277,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/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); + } + } +} From af1957085cac9f2d79714e74465e9ea2272a88e3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 24 Jun 2026 09:58:32 -0400 Subject: [PATCH 19/34] Note StringIndex's footprint-for-throughput tradeoff in class Javadoc Make the design intent explicit: 2x-oversized table + cached hashes buy short probes at a memory cost; prefer Set.copyOf (SetN) for compact membership, reach for StringIndex for the indexOf->parallel-array capability or the static path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/java/datadog/trace/util/StringIndex.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/util/StringIndex.java b/internal-api/src/main/java/datadog/trace/util/StringIndex.java index 3b6980ff492..886ac125951 100644 --- a/internal-api/src/main/java/datadog/trace/util/StringIndex.java +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -22,6 +22,13 @@ * *

    Slot 0-value is the empty sentinel: {@link Support#hash} never returns 0, so {@code hashes[i] * == 0} unambiguously means an empty slot. + * + *

    Trades footprint for throughput: the 2x-oversized table (load factor ≤ 0.5) and cached + * {@code int[]} hashes keep probe chains short and gate {@code equals()}, but cost more memory than + * a tightly-packed set. Prefer {@link java.util.Set#copyOf} (the JDK's compact {@code SetN}) when + * you only need membership; reach for {@code StringIndex} for the {@code + * indexOf}->parallel-array (name→id) capability or the hot, allocation-free static {@link + * Support} path. */ public final class StringIndex { private final int[] hashes; From 41e9777024d517fbfaab27272ff4db0d296529d2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 24 Jun 2026 10:30:22 -0400 Subject: [PATCH 20/34] Correct StringIndex Javadoc: 2x sizing is for build simplicity, not throughput The 2x oversizing (load factor <= 0.5) exists so build-time placement always finds a free slot and never rehashes/resizes -- short probes are a side effect, not the goal. Note that a higher load factor with construction-time rehashing would close the footprint gap vs SetN if footprint ever outweighs simplicity. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/datadog/trace/util/StringIndex.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/util/StringIndex.java b/internal-api/src/main/java/datadog/trace/util/StringIndex.java index 886ac125951..46411a50fe4 100644 --- a/internal-api/src/main/java/datadog/trace/util/StringIndex.java +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -23,12 +23,15 @@ *

    Slot 0-value is the empty sentinel: {@link Support#hash} never returns 0, so {@code hashes[i] * == 0} unambiguously means an empty slot. * - *

    Trades footprint for throughput: the 2x-oversized table (load factor ≤ 0.5) and cached - * {@code int[]} hashes keep probe chains short and gate {@code equals()}, but cost more memory than - * a tightly-packed set. Prefer {@link java.util.Set#copyOf} (the JDK's compact {@code SetN}) when - * you only need membership; reach for {@code StringIndex} for the {@code - * indexOf}->parallel-array (name→id) capability or the hot, allocation-free static {@link - * Support} path. + *

    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; From 1d57b3f5ed55f438fe2952429f88f7e75071fea3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 24 Jun 2026 13:49:07 -0400 Subject: [PATCH 21/34] Fix clone_synchronizedHashMap to clone the synchronized map (not the plain one) Per Codex review: clone_synchronizedHashMap copied `hashMap`, unlike the other clone_* methods which copy their own structure. Copy `synchronizedHashMap` so it faithfully measures cloning the synchronized variant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java index d446ec4b7a7..11572aa923d 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -168,7 +168,7 @@ public Map clone_hashMap() { @Benchmark public Map clone_synchronizedHashMap() { - return Collections.synchronizedMap(new HashMap<>(hashMap)); + return Collections.synchronizedMap(new HashMap<>(synchronizedHashMap)); } @Benchmark From 0c8f18039390cf75830bb881532790394dc39f2c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 24 Jun 2026 13:49:10 -0400 Subject: [PATCH 22/34] Use @Fork(5) for ImmutableMapBenchmark (JIT-bimodal copyOf path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_copyOf* reaches MapN via interface dispatch and is bimodal across forks: get_copyOf_sameKey measured 1034M ±928M (90%) at @Fork(2) vs 1346M ±24M (1.8%) at @Fork(5). 5 forks resolves the two-clocks artifact. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jmh/java/datadog/trace/util/ImmutableMapBenchmark.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 f460a834185..470421987f1 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -39,7 +39,9 @@ * (Results pending a fresh multi-JVM run — {@code Map.copyOf} only materializes the compact form on * Java 10+.) */ -@Fork(2) +// @Fork(5): get_copyOf* (MapN reached via interface dispatch) is JIT-bimodal at fewer forks — 5 +// forks resolves it (get_copyOf_sameKey measured ±90% at @Fork(2) -> ±1.8% at @Fork(5)). +@Fork(5) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) From 78075dc478c716c1185767dc79f87c3ae6e93e1f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 24 Jun 2026 13:52:08 -0400 Subject: [PATCH 23/34] Rename copyOf -> tracerImmutableSet in ImmutableSetBenchmark Per bric3 review: copyOf named the mechanism; tracerImmutableSet names the role (the agent's fixed-config-set representation, i.e. Set.copyOf / SetN). Prose keeps the Set#copyOf reference for the mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trace/util/ImmutableSetBenchmark.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 13312c6c576..54b27604f3d 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -30,10 +30,11 @@ *

  • {@code array} / {@code sortedArray} — linear scan / binary search; slow on miss. *
  • {@link HashSet} — idiomatic, fast; node-based, allocates per element. *
  • {@link TreeSet} — comparator-ordered; worth it only for a custom comparator, not speed. - *
  • {@link java.util.Set#copyOf} (via {@link CollectionUtils#tryMakeImmutableSet}) — the JDK's - * compact, array-backed immutable set ({@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 tracerImmutableSet} — {@link java.util.Set#copyOf} (via {@link + * CollectionUtils#tryMakeImmutableSet}), the JDK's compact, array-backed immutable set + * ({@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. * * *

    Lookups are interned (the {@code ==} fast path where a structure has one); misses are short @@ -44,7 +45,7 @@ *

    {@code
      * Structure              hit     miss
      * hashSet               2159     1751    (fastest)
    - * copyOf (SetN)         1946     1633
    + * tracerImmutableSet    1946     1633    (Set.copyOf / SetN)
      * array                  926      584
      * sortedArray            664      588
      * treeSet                642      593
    @@ -88,7 +89,7 @@ static String[] newMisses() {
       String[] sortedArray;
       HashSet hashSet;
       TreeSet treeSet;
    -  Set copyOfSet;
    +  Set tracerImmutableSet;
     
       @Setup(Level.Trial)
       public void setUp() {
    @@ -97,7 +98,7 @@ public void setUp() {
         Arrays.sort(sortedArray);
         hashSet = new HashSet<>(Arrays.asList(STRINGS));
         treeSet = new TreeSet<>(Arrays.asList(STRINGS));
    -    copyOfSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(STRINGS));
    +    tracerImmutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(STRINGS));
       }
     
       /** Per-thread lookup cursor so each reader thread cycles keys independently. */
    @@ -175,12 +176,12 @@ public boolean treeSet_miss(Cursor cursor) {
       }
     
       @Benchmark
    -  public boolean copyOf_hit(Cursor cursor) {
    -    return copyOfSet.contains(cursor.nextHit());
    +  public boolean tracerImmutableSet_hit(Cursor cursor) {
    +    return tracerImmutableSet.contains(cursor.nextHit());
       }
     
       @Benchmark
    -  public boolean copyOf_miss(Cursor cursor) {
    -    return copyOfSet.contains(cursor.nextMiss());
    +  public boolean tracerImmutableSet_miss(Cursor cursor) {
    +    return tracerImmutableSet.contains(cursor.nextMiss());
       }
     }
    
    From e1a6de340c66a24b4c461c5d09a2f69a94044735 Mon Sep 17 00:00:00 2001
    From: Douglas Q Hawkins 
    Date: Wed, 24 Jun 2026 13:52:11 -0400
    Subject: [PATCH 24/34] Fix clone_synchronizedSet to clone the synchronized set
     (not the plain one)
    
    Per Codex review: it copied `hashSet`, unlike the other clone_* methods which
    copy their own structure. Copy `synchronizedSet` so it faithfully measures
    cloning the synchronized variant.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    ---
     .../jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java
    index f9e9b69179a..e145e6bbe8b 100644
    --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java
    +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java
    @@ -153,7 +153,7 @@ public Set clone_hashSet() {
     
       @Benchmark
       public Set clone_synchronizedSet() {
    -    return Collections.synchronizedSet(new HashSet<>(hashSet));
    +    return Collections.synchronizedSet(new HashSet<>(synchronizedSet));
       }
     
       @Benchmark
    
    From 60bcf7b9a0db6cbd9aea0c00287b28d4865b59c9 Mon Sep 17 00:00:00 2001
    From: Douglas Q Hawkins 
    Date: Thu, 25 Jun 2026 08:39:07 -0400
    Subject: [PATCH 25/34] Add StringIndex benchmark arms across its three use
     cases
    
    - ImmutableSetBenchmark: stringIndex_* (instance contains) + support_* (static
      Support.indexOf over folded arrays) -- the immutable-set use case.
    - ImmutableMapBenchmark: stringIndex_get*/support_get* (VALUES[indexOf]) -- the
      string->int immutable-map use case.
    - StringIndexSwitchBenchmark (new): switch vs StringIndex, each inlined and
      not-inlined (@CompilerControl) -- the switch-replacement use case, exercising
      the non-inlined regime TagInterceptor actually runs in.
    
    Both modes everywhere (the StringIndex object and Support used directly) so the
    instance-wrapper indirection cost is visible. Results pending the @Fork(5) run.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     .../trace/util/ImmutableMapBenchmark.java     |  47 ++++
     .../trace/util/ImmutableSetBenchmark.java     |  41 +++
     .../util/StringIndexSwitchBenchmark.java      | 238 ++++++++++++++++++
     3 files changed, 326 insertions(+)
     create mode 100644 internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java
    
    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 470421987f1..2de15a2f739 100644
    --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java
    +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java
    @@ -33,6 +33,12 @@
      * 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. @@ -69,12 +75,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 copyOfMap; + StringIndex stringIndex; @Setup(Level.Trial) public void setUp() { @@ -90,6 +115,7 @@ public void setUp() { } // JDK compact immutable map (MapN on Java 10+); the agent's actual fixed-map representation. copyOfMap = CollectionUtils.tryMakeImmutableMap(hashMap); + stringIndex = StringIndex.of(INSERTION_KEYS); } /** Per-thread lookup cursor so each reader thread cycles keys independently. */ @@ -197,4 +223,25 @@ public void iterate_copyOf(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..3f8a33442e0 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -35,6 +35,12 @@ * ({@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 @@ -84,12 +90,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 +119,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 +205,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..3e9bd6019e1 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -0,0 +1,238 @@ +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. + *
    + * + *

    The axis that matters: inlining. A prior investigation found the {@code TagInterceptor} + * switch wasn't being inlined / constant-propagated into its hot caller, so it ran as a real call. + * Each form is therefore measured both ways via {@link CompilerControl}: {@code _inlined} (the + * lookup is inlined into the measured loop) and {@code _noinline} (the lookup is a real call — + * {@code TagInterceptor}'s actual regime). The teaching point is that the switch can look + * competitive when inlined but loses ground when it isn't, while the StringIndex {@code Support} + * path stays flat — so the win is largest exactly where it's needed. + * + *

    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). + */ +@Fork(2) +@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" + }; + + /** 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()); + } +} From afc65638202307f641cd3f82125320e9c8c4001f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 25 Jun 2026 09:38:13 -0400 Subject: [PATCH 26/34] Add preliminary @Fork(2) StringIndex benchmark results to Javadocs JDK 17 @Fork(2) spot (directional; @Fork(5) confirm pending): - switch replacement: StringIndex ~1.8x hit / ~1.5x miss vs a String switch. - immutable set: static Support fastest (beats HashSet + SetN); instance ~SetN. - immutable map: StringIndex beats HashMap (~1.2x) and Map.copyOf/MapN (~1.5x). Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ImmutableMapBenchmark.java | 17 +++++++++++++++-- .../trace/util/ImmutableSetBenchmark.java | 16 ++++++++++++++++ .../trace/util/StringIndexSwitchBenchmark.java | 17 +++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) 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 2de15a2f739..dd1a816c516 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -42,8 +42,21 @@ *

    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+.) + * + *

    StringIndex-as-map arms — preliminary JDK 17 {@code @Fork(2)} spot (a proper {@code @Fork(5)} + * run is pending; treat as directional); M ops/s: + * + *

    {@code
    + *                     distinct   interned(==)
    + * support (static)      797        1197
    + * stringIndex (inst)    785        1087
    + * hashMap               702         977
    + * copyOf (MapN)         607         814
    + * }
    + * + * StringIndex (both modes) beats {@code HashMap} (~1.2x) and {@code Map.copyOf}/{@code MapN} + * (~1.5x) on the interned path — and {@code MapN} only materializes the compact form on Java 10+. + * The static {@code Support} path edges the instance wrapper by ~10% on the interned path. */ // @Fork(5): get_copyOf* (MapN reached via interface dispatch) is JIT-bimodal at fewer forks — 5 // forks resolves it (get_copyOf_sameKey measured ±90% at @Fork(2) -> ±1.8% at @Fork(5)). 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 3f8a33442e0..c080d2454de 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -67,6 +67,22 @@ * binary-search, or tree-walk per lookup, so they trail the hashed structures, most visibly * on the miss path. * + * + *

    StringIndex arms — preliminary JDK 17 {@code @Fork(2)} spot (a proper {@code @Fork(5)} run is + * pending; treat as directional). Absolute scale differs from the table above (separate run), so + * read the within-block comparison; M ops/s: + * + *

    {@code
    + * Structure             hit      miss
    + * support (static)     1389     1288    (fastest)
    + * hashSet              1293     1163
    + * tracerImmutableSet   1201      890    (SetN)
    + * stringIndex (inst)   1180     1119
    + * }
    + * + * The static {@code Support} path is the fastest membership structure here — it beats {@code + * HashSet} and {@code SetN}, most on miss; the instance wrapper costs ~15–18% (landing near {@code + * SetN}). So StringIndex's set edge is the static path; the wrapper is roughly SetN-class. */ @Fork(2) @Warmup(iterations = 2) diff --git a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java index 3e9bd6019e1..10ff42a56e6 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -38,6 +38,23 @@ * 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). + * + *

    Preliminary JDK 17 numbers (Apple M1, {@code @Fork(2)} spot — a proper {@code @Fork(5)} run is + * pending; the errors are wide, treat as directional); M ops/s: + * + *

    {@code
    + *                            hit     miss
    + * switch (inlined)           608    1052
    + * switch (not-inlined)       617     904
    + * stringIndex (inlined)     1099    1371
    + * stringIndex (not-inlined) 1115    1510
    + * }
    + * + * StringIndex resolves ~1.8x faster than the switch on hit and ~1.5x on miss, and the win holds + * whether or not the lookup inlines — the switch's hit path pays a full {@code equals} after the + * hashCode switch, while StringIndex gates {@code equals} behind a cached-hash check and usually + * hits the {@code ==} fast path. (At {@code @Fork(2)} the inline-vs-not axis is within noise; the + * StringIndex-vs-switch gap clears it. This is the use case behind {@code TagInterceptor}.) */ @Fork(2) @Warmup(iterations = 2) From a903c3a414d3fede1ae6775d28ee322b6ea5dbc4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 25 Jun 2026 10:26:13 -0400 Subject: [PATCH 27/34] Add StringIndex value-mapping and lookup surface API Dual (instance + Support) by design: - mapValues(Class, Function) -> T[] (object; Class for generic-array alloc), mapIntValues(ToIntFunction) -> int[], mapLongValues(ToLongFunction) -> long[]. Object keeps the bare name; primitives are mapValues (int/long overloads of a single name would be ambiguous for int-returning refs). - lookup / lookupOrDefault overloaded by data array type (int[]/long[]/T[]): one call for slot-resolve + parallel-array read (miss -> 0/0L/null or the supplied default). Skips other primitive types for now. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/StringIndex.java | 145 +++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/internal-api/src/main/java/datadog/trace/util/StringIndex.java b/internal-api/src/main/java/datadog/trace/util/StringIndex.java index 46411a50fe4..ec16b51dd3e 100644 --- a/internal-api/src/main/java/datadog/trace/util/StringIndex.java +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -1,5 +1,10 @@ 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. * @@ -18,7 +23,9 @@ * * *

    Consumers attach their own parallel payload arrays (ids, values, ...) sized to {@link #slots} - * and indexed by the slot {@code indexOf} returns. + * 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. @@ -66,6 +73,59 @@ 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; @@ -109,6 +169,50 @@ public static Data create(String... names) { 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; @@ -146,6 +250,45 @@ 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 From 2d4d779bd8cbc544bcb53da3fdf4597d6fe3fe0d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 25 Jun 2026 10:37:16 -0400 Subject: [PATCH 28/34] Test StringIndex value-mapping and lookup API Covers mapIntValues/mapLongValues/mapValues(Class,Fn) slot-alignment, the typed T[] from Class, empty-slot null/0 handling, and lookup/lookupOrDefault hit + miss across int/long/object (instance and Support tiers). Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/StringIndexTest.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java index 60c0d9998ea..3fcdb729d7d 100644 --- a/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java @@ -3,6 +3,7 @@ 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; @@ -99,4 +100,72 @@ void parallelPayloadBySlot() { 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)); + } } From 2ea9ae5de7e2fe1bd241d872ca867c377e17a050 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 25 Jun 2026 16:39:14 -0400 Subject: [PATCH 29/34] Add constant-propagated switch arms to StringIndexSwitchBenchmark A const-key (compile-time constant) switch arm shows the string switch's ceiling: inlined, the JIT folds the switch away; not-inlined, the constant can't cross the call boundary so the switch runs in full. Paired with the StringIndex const arms, this draws the full landscape -- the switch only matches StringIndex when BOTH inlined and const-propagated, neither of which holds in TagInterceptor's real regime (runtime tag, non-inlined call). Co-Authored-By: Claude Opus 4.8 --- .../util/StringIndexSwitchBenchmark.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java index 10ff42a56e6..6dbdb95045e 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -67,6 +67,14 @@ public class StringIndexSwitchBenchmark { "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(); @@ -252,4 +260,27 @@ public int stringIndex_hit_noinline(Cursor cursor) { 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); + } } From ecf3643c9c641b2b40f6a70302d11a60e596dac1 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 25 Jun 2026 17:53:56 -0400 Subject: [PATCH 30/34] Refresh StringIndexSwitchBenchmark results: clean @Fork(5), const-key landscape JDK 17 quiet @Fork(5). The const-key arms show the switch only matches StringIndex when specialized to a single key (~2.7B, even across a DONT_INLINE boundary -- profile-driven); in the realistic runtime-varied-key regime (TagInterceptor) the switch is ~1.16B vs StringIndex ~2.19B (~1.9x), and StringIndex is flat across inline/not-inline and key shape. Co-Authored-By: Claude Opus 4.8 --- .../util/StringIndexSwitchBenchmark.java | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java index 6dbdb95045e..3b48b899d62 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -39,22 +39,33 @@ * never present. Run via {@code -Pjmh.includes=StringIndexSwitchBenchmark} (add {@code -prof gc} — * should be ~0 B/op both ways; this proves throughput, not allocation). * - *

    Preliminary JDK 17 numbers (Apple M1, {@code @Fork(2)} spot — a proper {@code @Fork(5)} run is - * pending; the errors are wide, treat as directional); M ops/s: + *

    JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s, + * ±1–5%): * *

    {@code
    - *                            hit     miss
    - * switch (inlined)           608    1052
    - * switch (not-inlined)       617     904
    - * stringIndex (inlined)     1099    1371
    - * stringIndex (not-inlined) 1115    1510
    + * key             switch (inl / noinl)   stringIndex (inl / noinl)
    + * const            2735 / 2720            2045 / 2043
    + * hit  (runtime)   1172 / 1156            2197 / 2178
    + * miss             2068 / 2029            2525 / 2528
      * }
    * - * StringIndex resolves ~1.8x faster than the switch on hit and ~1.5x on miss, and the win holds - * whether or not the lookup inlines — the switch's hit path pays a full {@code equals} after the - * hashCode switch, while StringIndex gates {@code equals} behind a cached-hash check and usually - * hits the {@code ==} fast path. (At {@code @Fork(2)} the inline-vs-not axis is within noise; the - * StringIndex-vs-switch gap clears it. This is the use case behind {@code TagInterceptor}.) + *

    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.19B (~1.9x). 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(2) @Warmup(iterations = 2) From d6378a4b782a4eda4cd1d06ec174cb7521d72ca3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 25 Jun 2026 17:55:10 -0400 Subject: [PATCH 31/34] Reconcile StringIndexSwitchBenchmark intro with results (key-constancy, not inlining) The clean @Fork(5) data showed inline-vs-not is ~flat; the dominant axis is key-constancy. Reframe the intro to match (it had claimed inlining was the axis). Co-Authored-By: Claude Opus 4.8 --- .../trace/util/StringIndexSwitchBenchmark.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java index 3b48b899d62..1b40ce7137d 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -23,13 +23,15 @@ * final} arrays (a miss returns 0), the folded-constant hot path. * * - *

    The axis that matters: inlining. A prior investigation found the {@code TagInterceptor} - * switch wasn't being inlined / constant-propagated into its hot caller, so it ran as a real call. - * Each form is therefore measured both ways via {@link CompilerControl}: {@code _inlined} (the - * lookup is inlined into the measured loop) and {@code _noinline} (the lookup is a real call — - * {@code TagInterceptor}'s actual regime). The teaching point is that the switch can look - * competitive when inlined but loses ground when it isn't, while the StringIndex {@code Support} - * path stays flat — so the win is largest exactly where it's needed. + *

    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. From daca028ef9c8704114c910efd28df238230af738 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 26 Jun 2026 08:07:53 -0400 Subject: [PATCH 32/34] Finalize StringIndex benchmark Javadocs with quiet @Fork(5) numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the directional @Fork(2) placeholders with clean JDK17 @Fork(5), @Threads(8) results (3h quiet-machine run): - ImmutableSetBenchmark: merge the two @Fork(2) blocks into one @Fork(5) table (support fastest, beats HashSet on hit+miss; SetN compact-but-slower); bump @Fork(2)->@Fork(5); document the instance stringIndex_miss bimodality (±27% even at 5 forks; support_miss tight). - ImmutableMapBenchmark: full get + sameKey + iterate tables (support fastest get; tagMap.forEach ~1.5x its iterator); annotation already @Fork(5). - StringIndexSwitchBenchmark: refresh to this run's numbers (StringIndex ~1.85x the switch on a runtime hit key); align @Fork(2)->@Fork(5) with the documented numbers. Numbers only / benchmark config; no production code change. Co-Authored-By: Claude Opus 4.8 --- .../trace/util/ImmutableMapBenchmark.java | 45 +++++++++++---- .../trace/util/ImmutableSetBenchmark.java | 55 +++++++++---------- .../util/StringIndexSwitchBenchmark.java | 10 ++-- 3 files changed, 67 insertions(+), 43 deletions(-) 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 dd1a816c516..84dd8fd9651 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -43,20 +43,45 @@ * {@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. * - *

    StringIndex-as-map arms — preliminary JDK 17 {@code @Fork(2)} spot (a proper {@code @Fork(5)} - * run is pending; treat as directional); M ops/s: + *

    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
    - *                     distinct   interned(==)
    - * support (static)      797        1197
    - * stringIndex (inst)    785        1087
    - * hashMap               702         977
    - * copyOf (MapN)         607         814
    + * Structure              get    sameKey
    + * support (static)      1498     2081    (fastest)
    + * stringIndex (inst)    1363     1900
    + * hashMap               1216     1850
    + * linkedHashMap         1214       -
    + * tagMap                1167     1386
    + * copyOf (MapN)         1049     1364
    + * treeMap                656       -
      * }
    * - * StringIndex (both modes) beats {@code HashMap} (~1.2x) and {@code Map.copyOf}/{@code MapN} - * (~1.5x) on the interned path — and {@code MapN} only materializes the compact form on Java 10+. - * The static {@code Support} path edges the instance wrapper by ~10% on the interned path. + *

    {@code iterate} (full traversal): + * + *

    {@code
    + * tagMap.forEach        148    (fastest)
    + * linkedHashMap         136
    + * copyOf (MapN)         135
    + * treeMap               134
    + * hashMap               104
    + * tagMap (iterator)      96
    + * }
    + * + *

    Key findings: + * + *

      + *
    • StringIndex-as-map ({@code Support}) is the fastest {@code get} — beating {@code HashMap} + * and {@code Map.copyOf}/{@code MapN}, most on the interned path; the instance wrapper trails + * it by ~10%. (vs {@code MapN} the edge is speed + the slot/parallel-array capability, not + * footprint — see {@link ImmutableSetBenchmark}.) + *
    • {@code TagMap.forEach} (148) beats its own {@code iterator} (96) by ~1.5x: TagMap's + * structure makes a faithful external {@code Iterator} expensive (externalized cursor + + * skip-empty + per-call re-entry + the iterator allocation) — all of which internal {@code + * forEach} avoids. Traverse TagMap via {@code forEach}, never its iterator; that gap only + * widens as TagMap's entry model grows. + *
    */ // @Fork(5): get_copyOf* (MapN reached via interface dispatch) is JIT-bimodal at fewer forks — 5 // forks resolves it (get_copyOf_sameKey measured ±90% at @Fork(2) -> ±1.8% at @Fork(5)). 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 c080d2454de..3ee07398d22 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -46,45 +46,44 @@ *

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

    StringIndex arms — preliminary JDK 17 {@code @Fork(2)} spot (a proper {@code @Fork(5)} run is - * pending; treat as directional). Absolute scale differs from the table above (separate run), so - * read the within-block comparison; M ops/s: - * - *

    {@code
    - * Structure             hit      miss
    - * support (static)     1389     1288    (fastest)
    - * hashSet              1293     1163
    - * tracerImmutableSet   1201      890    (SetN)
    - * stringIndex (inst)   1180     1119
    - * }
    - * - * The static {@code Support} path is the fastest membership structure here — it beats {@code - * HashSet} and {@code SetN}, most on miss; the instance wrapper costs ~15–18% (landing near {@code - * SetN}). So StringIndex's set edge is the static path; the wrapper is roughly SetN-class. + *

    Caveat — {@code stringIndex} miss is bimodal: even at {@code @Fork(5)} it measured ±27% + * (the instance miss path; static {@code support_miss} is tight, ±0.3%). The wrapper's + * field-load indirection interacts with the miss branch to split C2 across forks — read that one + * number as approximate, and use {@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) diff --git a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java index 1b40ce7137d..916cfe70059 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -46,9 +46,9 @@ * *

    {@code
      * key             switch (inl / noinl)   stringIndex (inl / noinl)
    - * const            2735 / 2720            2045 / 2043
    - * hit  (runtime)   1172 / 1156            2197 / 2178
    - * miss             2068 / 2029            2525 / 2528
    + * const            2778 / 2769            2047 / 2035
    + * hit  (runtime)   1161 / 1166            2147 / 2152
    + * miss             2083 / 2050            2546 / 2539
      * }
    * *

    Two takeaways: @@ -60,7 +60,7 @@ * 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.19B (~1.9x). StringIndex is + * — 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.) @@ -69,7 +69,7 @@ *

    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(2) +@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) From ba9a3ec1e52e9e15674e817178e080a7f329c857 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 26 Jun 2026 08:39:09 -0400 Subject: [PATCH 33/34] Sharpen stringIndex_miss caveat with the confirmed @Fork(10) bimodality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Fork(10) on the single arm: 6 forks fast (~2000 ≈ support_miss), 4 slow (~1070), nothing between -> two-clocks, not noise. Cause: C2 hoists the instance field-loads out of the miss-path probe loop only in the fast mode; the static Support path const-folds them and is never bimodal. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/util/ImmutableSetBenchmark.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 3ee07398d22..478fb4852d2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -78,10 +78,14 @@ * lookup, so they trail the hashed structures, most visibly on miss. * * - *

    Caveat — {@code stringIndex} miss is bimodal: even at {@code @Fork(5)} it measured ±27% - * (the instance miss path; static {@code support_miss} is tight, ±0.3%). The wrapper's - * field-load indirection interacts with the miss branch to split C2 across forks — read that one - * number as approximate, and use {@code Support} where miss latency matters. + *

    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(5) // 5 forks settle the bimodal stringIndex_miss / interface-dispatch arms (see header) @Warmup(iterations = 2) From 767af3de1e2e7bc68a47eaa04f86f5291f2d84a8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 30 Jun 2026 16:14:17 -0400 Subject: [PATCH 34/34] spotless: reflow grafted StringIndex Javadoc in ImmutableMapBenchmark Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/util/ImmutableMapBenchmark.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 4f05daf709d..46fabf7a312 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -34,10 +34,10 @@ * 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. + * 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