From 2e312fadb3115e14805aabc0c76928864e969ff4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 14:40:56 -0400 Subject: [PATCH 01/21] Add EmbeddingSupport.contains(hashes, names, name) helper Mirrors StringIndex#contains for the static-arrays path, so callers of the embedded/parallel-array form don't need to spell out indexOf(...) >= 0 themselves. Co-Authored-By: Claude Sonnet 5 --- .../src/main/java/datadog/trace/util/StringIndex.java | 5 +++++ .../test/java/datadog/trace/util/StringIndexTest.java | 10 ++++++++++ 2 files changed, 15 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 868558e0c38..1358c56f425 100644 --- a/internal-api/src/main/java/datadog/trace/util/StringIndex.java +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -334,6 +334,11 @@ public static int indexOf(int[] hashes, String[] names, String name) { return indexOf(hashes, names, name, hash(name)); } + /** {@code indexOf(hashes, names, name) >= 0}. Mirrors {@link StringIndex#contains}. */ + public static boolean contains(int[] hashes, String[] names, String name) { + return indexOf(hashes, names, name) >= 0; + } + /** Number of slots — the length to size parallel payload arrays to. */ public static int numSlots(int[] hashes) { return hashes.length; 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 46fa68d040e..966d0d0d4ae 100644 --- a/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java @@ -81,6 +81,16 @@ void support_create_then_indexOf() { assertEquals(-1, EmbeddingSupport.indexOf(d.hashes, d.names, "q")); } + @Test + void support_contains_internedAndCopy_andMiss() { + Data d = EmbeddingSupport.create("foo", "bar", "baz"); + + assertTrue( + EmbeddingSupport.contains(d.hashes, d.names, "foo")); // interned literal -> == fast path + assertTrue(EmbeddingSupport.contains(d.hashes, d.names, new String("bar"))); // non-interned + assertFalse(EmbeddingSupport.contains(d.hashes, d.names, "nope")); + } + /** Controlled hashes force collision, linear-probe wraparound, and the already-present path. */ @Test void put_and_indexOf_collisionAndWraparound() { From 760ad2c8ab35e9be891e57d675162f2f283b7eba Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 14:41:14 -0400 Subject: [PATCH 02/21] Add hitFresh scenario and hash-dispatch pollution to ImmutableSetBenchmark Reusing the same interned/cached-hash key instances for both building a structure and measuring hit lookups against it understates real hit cost -- String#equals's == fast path and String#hashCode's cached result pay nothing extra for a key already touched during setUp. The new hitFresh scenario looks up separate, never-touched String instances instead, alongside the existing hit (interned literal) and miss (already representative) scenarios. Also pulls in BenchmarkUtils#polluteHashDispatch so the shared hashCode/equals call sites are already megamorphic before the StringSet arms measure their own lookups, matching production where those call sites are hit by every hash-based structure in the JVM. Replaced the javadoc's stale, partial-run tables/findings with a full hit/hitFresh/miss re-run across all six structures together (Fork(5), Threads(8)) -- confirms hitFresh is the slowest of the three scenarios for every hash-based structure, not merely slower than hit as previously guessed from partial data. Co-Authored-By: Claude Sonnet 5 --- .../datadog/trace/util/BenchmarkUtils.java | 59 ++++++++ .../trace/util/ImmutableSetBenchmark.java | 126 ++++++++++++++---- 2 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java diff --git a/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java b/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java new file mode 100644 index 00000000000..56ae1c3ebc1 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java @@ -0,0 +1,59 @@ +package datadog.trace.util; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** Shared setup helpers for JMH benchmarks in this module. */ +public final class BenchmarkUtils { + private BenchmarkUtils() {} + + private static final Object[] DEFAULT_DECOY_KEYS = { + "decoy", 1, 1L, 1.0d, Boolean.TRUE, new Object() + }; + + /** + * Exercises {@link HashSet}/{@link java.util.HashMap} and the tracer's {@link + * CollectionUtils#tryMakeImmutableSet} immutable sets with several distinct key classes, so their + * internal {@code hashCode()}/{@code equals()} dispatch -- a call site shared JVM-wide by every + * instance of that structure in the process, regardless of which specific instance or call site + * invokes {@code add}/{@code contains} -- is already megamorphic before a benchmark measures + * lookups against a single key type. + * + *

This matches production: those shared internal call sites are hit by every hash-based + * structure in the JVM across whatever key types the whole application uses, so they're + * realistically almost always megamorphic. An isolated benchmark that only ever looks up one key + * type (e.g. {@code String}) would otherwise leave them artificially monomorphic for the entire + * run, understating real dispatch cost. + * + *

Deliberately does not touch the benchmark's own {@code contains}/{@code add} call sites -- + * those are realistically free to specialize per caller, the way a genuinely hot, narrowly-typed + * call site would in production. + * + *

Not to be confused with the CHA-defeat decoys in {@code SingleThreadedMapBenchmark}/{@code + * ThreadSafeMapBenchmark} ({@code KeyStrategy} implementors referenced only so they're loaded, + * never invoked): that technique denies class-hierarchy analysis a single-implementor bet for a + * narrow, dd-trace-java-owned interface, and works by class-loading alone. It doesn't apply here + * -- {@code Object.hashCode()}/{@code equals()} already have countless implementors loaded in any + * real JVM, so a single-implementor CHA bet was never available for them. What gates their + * dispatch is the interpreter's per-call-site type profile, which only invocation can pollute -- + * hence this helper actually calls {@code add}/{@code contains}, rather than just loading + * classes. + */ + public static void polluteHashDispatch() { + polluteHashDispatch(DEFAULT_DECOY_KEYS); + } + + public static void polluteHashDispatch(Object... decoyKeys) { + HashSet scratchHashSet = new HashSet<>(); + for (Object key : decoyKeys) { + scratchHashSet.add(key); + scratchHashSet.contains(key); + } + + Set scratchImmutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(decoyKeys)); + for (Object key : decoyKeys) { + scratchImmutableSet.contains(key); + } + } +} 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 f0604e6ddd4..66d3c5feb6c 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -44,37 +44,69 @@ * indirection cost of the wrapper. * * - *

Lookups are interned (the {@code ==} fast path where a structure has one); misses are short - * and never present. + *

Hit lookups come in two flavors, because reusing the exact same key instances for both + * building a structure and measuring lookups against it is its own validity bug, independent of + * hash-dispatch pollution: {@link String#equals} takes an {@code ==} fast path, and {@link + * String#hashCode()} caches its result in a field on first call, so a key instance that was already + * inserted (or previously looked up) pays neither real cost again. * - *

JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s = - * millions): + *

    + *
  • {@code hit} -- looks up the same interned {@link #STRINGS} literals used to build every + * structure. This is realistic and worth keeping (fixed header/config-key lookups commonly + * are interned literals), but identity with the stored element is inherent to interning, not + * a choice this benchmark makes -- two equal literals are always the same instance. + *
  • {@code hitFresh} -- looks up {@link #FRESH_STRINGS}, separate {@code new String(..)} + * instances never touched by {@link #setUp}, so each carries an uncached hash and forces a + * real {@code equals()} beyond the {@code ==} check. Models keys arriving from + * parsing/concatenation/deserialization rather than literals. Only meaningful for the + * hash-based structures ({@code hashSet}, {@code tracerImmutableSet}, {@code stringIndex}, + * {@code stringIndex_embedded}); not measured for {@code array}/{@code sortedArray}/{@code + * treeSet}. + *
+ * + *

Misses are already representative of both effects for free: {@link #MISSES} is built via + * concatenation (never interned) and never touched by {@link #setUp}. + * + *

Full re-run, all six structures across {@code hit}/{@code hitFresh}/{@code miss} together, + * with {@link BenchmarkUtils#polluteHashDispatch()} in effect (Apple M1, Java 8u382 -- the + * repo-default {@code jmh} test launcher, no {@code -PtestJvm} override --, {@code @Fork(5)}, + * {@code @Threads(8)}; M ops/s = millions): * *

{@code
- * Structure                           hit    miss
- * stringIndex_embedded (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
+ * Structure                    hit   hitFresh    miss
+ * stringIndex_embedded (static) 2098      1563    2030    (fastest hit and miss)
+ * hashSet                       1723      1276    1823
+ * stringIndex (inst)            1883      1184 *  1700 *  (* bimodal -- see caveat)
+ * tracerImmutableSet            1632      1232    1625    (Set.copyOf / SetN)
+ * array                          854         -     495
+ * sortedArray                    713         -     613
+ * treeSet                        646         -     544
  * }
* *

Key findings: * *

    - *
  • The static {@code EmbeddingSupport} path is the fastest — it beats {@code HashSet} on hit - * and miss and crushes the scan/search/tree forms. + *
  • The static {@code EmbeddingSupport} path is the fastest on both hit and miss -- it beats + * {@code HashSet} on both and crushes the scan/search/tree forms. *
  • {@code stringIndex} (the instance wrapper) trails {@code EmbeddingSupport} by the - * field-load indirection (~10% on hit), landing near {@code HashSet} — fine off the hot path, - * prefer {@code EmbeddingSupport} on it. - *
  • {@link java.util.Set#copyOf} ({@code SetN}, the agent's compact fixed-set form) is ~1.2x - * behind {@code EmbeddingSupport} on hit but the most compact (~27% smaller — no - * cached hashes, no 2x table). So StringIndex's edge over {@code SetN} is speed + the {@code - * indexOf}->parallel-array capability, not footprint; over {@code HashSet} it wins both. - *
  • {@code array} / {@code sortedArray} / {@code treeSet} trail the hashed structures, most on + * field-load indirection, but on this run actually leads {@code HashSet} on hit; miss is + * noisy (see bimodal caveat below) and not a reliable comparison point. Prefer {@code + * EmbeddingSupport} on the hot path regardless. + *
  • {@link java.util.Set#copyOf} ({@code SetN}, the agent's compact fixed-set form) trails + * {@code EmbeddingSupport} on every scenario but remains the most compact (~27% + * smaller -- no cached hashes, no 2x table). So StringIndex's edge over {@code SetN} is speed + * + the {@code indexOf}->parallel-array capability, not footprint. + *
  • {@code array} / {@code sortedArray} / {@code treeSet} trail every hashed structure, most on * miss. + *
  • {@code hitFresh} is the slowest of the three scenarios for every hash-based + * structure -- clearly below both {@code hit} and {@code miss}, not merely below {@code hit} + * as previously guessed. This makes sense once the two failure shapes are compared: a miss + * usually short-circuits on the first hash mismatch during probing and rarely reaches {@code + * equals()}, while a {@code hitFresh} lookup must probe until it finds the match and pay a + * real, uncached {@code equals()} there -- so it is not simply "the honest version of hit", + * it exercises a genuinely more expensive path than either {@code hit} (cached hash + {@code + * ==}) or {@code miss} (hash-only rejection). Superseded an earlier partial-data guess that + * {@code hitFresh} would land at the same cost as {@code miss}. *
* *

Caveat — the instance {@code stringIndex} miss is bimodal across forks (confirmed at @@ -100,6 +132,21 @@ public class ImmutableSetBenchmark { /** Distinct String instances that are never present, for the miss path. */ static final String[] MISSES = newMisses(); + /** + * Equal-content, non-interned, never-before-hashed copies of {@link #STRINGS}, built once here + * and never touched by {@link #setUp} -- so a lookup against them can't ride the {@code ==} fast + * path or a hash cached during set construction. See {@code hitFresh} in the class javadoc. + */ + static final String[] FRESH_STRINGS = newFreshStrings(); + + static String[] newFreshStrings() { + String[] fresh = new String[STRINGS.length]; + for (int i = 0; i < STRINGS.length; ++i) { + fresh[i] = new String(STRINGS[i]); + } + return fresh; + } + static String[] newMisses() { String[] misses = new String[STRINGS.length * 4]; for (int i = 0; i < misses.length; ++i) { @@ -131,6 +178,8 @@ static String[] newMisses() { @Setup(Level.Trial) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + array = STRINGS; sortedArray = Arrays.copyOf(STRINGS, STRINGS.length); Arrays.sort(sortedArray); @@ -144,6 +193,7 @@ public void setUp() { @State(Scope.Thread) public static class Cursor { int hitIndex = 0; + int hitFreshIndex = 0; int missIndex = 0; String nextHit() { @@ -155,6 +205,16 @@ String nextHit() { return STRINGS[i]; } + /** See {@code hitFresh} in the class javadoc. */ + String nextHitFresh() { + int i = hitFreshIndex + 1; + if (i >= FRESH_STRINGS.length) { + i = 0; + } + hitFreshIndex = i; + return FRESH_STRINGS[i]; + } + String nextMiss() { int i = missIndex + 1; if (i >= MISSES.length) { @@ -199,6 +259,11 @@ public boolean hashSet_hit(Cursor cursor) { return hashSet.contains(cursor.nextHit()); } + @Benchmark + public boolean hashSet_hitFresh(Cursor cursor) { + return hashSet.contains(cursor.nextHitFresh()); + } + @Benchmark public boolean hashSet_miss(Cursor cursor) { return hashSet.contains(cursor.nextMiss()); @@ -219,6 +284,11 @@ public boolean tracerImmutableSet_hit(Cursor cursor) { return tracerImmutableSet.contains(cursor.nextHit()); } + @Benchmark + public boolean tracerImmutableSet_hitFresh(Cursor cursor) { + return tracerImmutableSet.contains(cursor.nextHitFresh()); + } + @Benchmark public boolean tracerImmutableSet_miss(Cursor cursor) { return tracerImmutableSet.contains(cursor.nextMiss()); @@ -229,6 +299,11 @@ public boolean stringIndex_hit(Cursor cursor) { return stringIndex.contains(cursor.nextHit()); } + @Benchmark + public boolean stringIndex_hitFresh(Cursor cursor) { + return stringIndex.contains(cursor.nextHitFresh()); + } + @Benchmark public boolean stringIndex_miss(Cursor cursor) { return stringIndex.contains(cursor.nextMiss()); @@ -236,11 +311,16 @@ public boolean stringIndex_miss(Cursor cursor) { @Benchmark public boolean stringIndex_embedded_hit(Cursor cursor) { - return StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextHit()) >= 0; + return StringIndex.EmbeddingSupport.contains(SI_HASHES, SI_NAMES, cursor.nextHit()); + } + + @Benchmark + public boolean stringIndex_embedded_hitFresh(Cursor cursor) { + return StringIndex.EmbeddingSupport.contains(SI_HASHES, SI_NAMES, cursor.nextHitFresh()); } @Benchmark public boolean stringIndex_embedded_miss(Cursor cursor) { - return StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextMiss()) >= 0; + return StringIndex.EmbeddingSupport.contains(SI_HASHES, SI_NAMES, cursor.nextMiss()); } } From 7de549a65d5c140dd632da7c194d457822a79898 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 20:10:42 -0400 Subject: [PATCH 03/21] Restructure StringIndex put/indexOf around a single induction variable Recompute the slot index each iteration as (h + probes) & mask instead of carrying a separately-incremented cursor, so the loop has one canonical induction variable for the JIT to reason about. --- .../src/main/java/datadog/trace/util/StringIndex.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 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 1358c56f425..66644e0fecf 100644 --- a/internal-api/src/main/java/datadog/trace/util/StringIndex.java +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -74,7 +74,7 @@ public int indexOf(String name) { } public boolean contains(String name) { - return indexOf(name) >= 0; + return EmbeddingSupport.indexOf(this.hashes, this.names, name) >= 0; } /** Table size — allocate parallel payload arrays of this length. */ @@ -288,8 +288,8 @@ public static long[] mapLongValues(String[] names, ToLongFunction fn) { */ 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) { + for (int probes = 0; probes <= mask; probes++) { + int i = (h + probes) & mask; if (hashes[i] == 0) { hashes[i] = h; names[i] = name; @@ -313,8 +313,8 @@ static int put(int[] hashes, String[] names, String name, int h) { */ 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) { + for (int probes = 0; probes <= mask; probes++) { + int i = (h + probes) & mask; int sh = hashes[i]; if (sh == 0) { return -1; From f6751513138a390a186e48592ca5aced3f396e17 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 20:10:52 -0400 Subject: [PATCH 04/21] Split BenchmarkUtils type-profile pollution into add-driving and contains-only helpers populateTypeProfile() drives only contains(), so it works against both mutable and immutable collections (including the collection instance under test itself); populateTypeProfileMutable() keeps the old add()+contains() behavior for callers that need add() dispatch polluted too. --- .../datadog/trace/util/BenchmarkUtils.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java b/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java index 56ae1c3ebc1..06cd5324b86 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java +++ b/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java @@ -1,8 +1,8 @@ package datadog.trace.util; import java.util.Arrays; +import java.util.Collection; import java.util.HashSet; -import java.util.Set; /** Shared setup helpers for JMH benchmarks in this module. */ public final class BenchmarkUtils { @@ -45,15 +45,35 @@ public static void polluteHashDispatch() { } public static void polluteHashDispatch(Object... decoyKeys) { - HashSet scratchHashSet = new HashSet<>(); + populateTypeProfileMutable(new HashSet<>(), decoyKeys); + populateTypeProfile(CollectionUtils.tryMakeImmutableSet(Arrays.asList(decoyKeys)), decoyKeys); + } + + /** + * The entry point most benchmarks should reach for: pass the same kind of collection instance + * under test (or an equivalent scratch instance). Works for both mutable and immutable + * collections since it only drives {@code contains()} -- the operation every {@link + * java.util.Set} supports, and the one these lookup benchmarks actually measure. + */ + public static void populateTypeProfile(Collection populated) { + populateTypeProfile(populated, DEFAULT_DECOY_KEYS); + } + + public static void populateTypeProfile(Collection populated, Object... decoyKeys) { for (Object key : decoyKeys) { - scratchHashSet.add(key); - scratchHashSet.contains(key); + populated.contains(key); } + } - Set scratchImmutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(decoyKeys)); + /** + * Lower-level control: also drives {@code add()} dispatch, so {@code scratch} must genuinely + * support mutation, and lets the caller pick the decoy keys. Reach for this only when {@code + * add()} dispatch matters too, or the default decoys aren't the right shape. + */ + public static void populateTypeProfileMutable(Collection scratch, Object... decoyKeys) { for (Object key : decoyKeys) { - scratchImmutableSet.contains(key); + scratch.add(key); + scratch.contains(key); } } } From 9f40da9ec818f41e35a5f906f1d0477359a5df42 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 20:11:00 -0400 Subject: [PATCH 05/21] Scope StringIndex-as-Set guidance to hit-dominated access patterns The instance wrapper reliably beats HashSet on hit (its actual design case) but not on miss/hitFresh, where both are bimodal across forks and the mean already sits at or below HashSet's steady figure. Point miss/fresh-key-heavy callers at EmbeddingSupport directly instead of assuming the wrapper is strictly better than a plain Set. --- .../datadog/trace/util/ImmutableSetBenchmark.java | 12 +++++++++--- 1 file changed, 9 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 66d3c5feb6c..ce5e7dc5ee2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -89,9 +89,15 @@ *
  • The static {@code EmbeddingSupport} path is the fastest on both hit and miss -- it beats * {@code HashSet} on both and crushes the scan/search/tree forms. *
  • {@code stringIndex} (the instance wrapper) trails {@code EmbeddingSupport} by the - * field-load indirection, but on this run actually leads {@code HashSet} on hit; miss is - * noisy (see bimodal caveat below) and not a reliable comparison point. Prefer {@code - * EmbeddingSupport} on the hot path regardless. + * field-load indirection. It reliably beats {@code HashSet} on {@code hit} (its actual design + * case: repeated lookups of a known, fixed name set). On {@code miss} and {@code hitFresh} it + * is not a reliable win -- both are bimodal across forks (see caveat below) and the + * mean in each case already sits at or below {@code HashSet}'s steady figure. For miss- or + * fresh-key-heavy membership use, prefer {@link StringIndex.EmbeddingSupport} directly rather + * than assuming the wrapper is strictly better than a plain {@code Set}. This doesn't apply + * to {@code StringIndex}'s parallel-value (map) use case ({@code mapValues}/{@code lookup}) + * -- that win comes from avoiding boxing and node overhead entirely and is unaffected by any + * of this. *
  • {@link java.util.Set#copyOf} ({@code SetN}, the agent's compact fixed-set form) trails * {@code EmbeddingSupport} on every scenario but remains the most compact (~27% * smaller -- no cached hashes, no 2x table). So StringIndex's edge over {@code SetN} is speed From b55310a4a14fb6c78b5de91e95b7694e00f2adc0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 20:11:09 -0400 Subject: [PATCH 06/21] Pollute type dispatch in ImmutableMapBenchmark and refresh results Object.hashCode()/equals() are JVM-wide shared call sites hit by every hash-based structure in the process; leaving them monomorphic for a single-key-type run understates real dispatch cost, same reasoning as ImmutableSetBenchmark. Rerun and update the javadoc results table with pollution in effect -- StringIndex's get win over HashMap/TagMap/MapN holds up unconditionally here, unlike the access-pattern-dependent Set case. --- .../trace/util/ImmutableMapBenchmark.java | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 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 39aaf82183c..5826e92e10c 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -44,40 +44,50 @@ * {@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. * - *

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

    {@link BenchmarkUtils#polluteHashDispatch()} runs in {@link #setUp}, for the same reason as + * {@link ImmutableSetBenchmark}: {@code Object.hashCode()}/{@code equals()} are JVM-wide shared + * call sites, hit by every hash-based structure in the process (this benchmark's {@code HashMap}, + * {@code LinkedHashMap}, and {@code Map.copyOf}/{@code MapN} all dispatch through them for their + * {@code String} keys) — realistically almost always megamorphic, so leaving them monomorphic for + * the whole run would understate real dispatch cost. * - *

    {@code
    - * Structure                           get sameKey
    - * stringIndex_embedded (static)      1498    2081    (fastest)
    - * stringIndex (inst)                 1363    1900
    - * hashMap                            1216    1850
    - * linkedHashMap                      1214       -
    - * tagMap                             1167    1386
    - * tracerImmutableMap                 1049    1364    (MapN)
    - * treeMap                             656       -
    - * }
    - * - *

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

    JDK 8 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}, with {@link + * BenchmarkUtils#polluteHashDispatch()} in effect; 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
    - * tagMap.forEach        148    (fastest)
    - * linkedHashMap         136
    - * tracerImmutableMap    135    (MapN)
    - * treeMap               134
    - * hashMap               104
    - * tagMap (iterator)      96
    + * Structure                get sameKey iterate iterate_forEach
    + * hashMap                 1202    1438     120        -
    + * linkedHashMap           1097       -     127        -
    + * treeMap                  487       -     121        -
    + * tagMap                  1005    1235     109       121
    + * tracerImmutableMap      1052    1249     123        -   (MapN)
    + * stringIndex             1366    1724       -        -
    + * stringIndex_embedded    1479    1846       -        -   (fastest get)
      * }
    * *

    Key findings: * *

      - *
    • StringIndex-as-map ({@code EmbeddingSupport}) 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 + *
    • StringIndex-as-map is a reliable {@code get} win, and it holds up under type-profile + * pollution: {@code stringIndex_embedded} (the {@code static final}-array form) and {@code + * stringIndex} (the instance wrapper) both beat every {@code Map} here by a wide margin, on + * both the {@code equals()} and identity-fast-path lookups. Unlike {@link + * ImmutableSetBenchmark}'s {@code hitFresh} case, there's no bimodality here — this win is + * unconditional, not access-pattern-dependent. + *
    • This table still compares boxed {@code Integer} values ({@code hashMap}/{@code + * linkedHashMap}/{@code treeMap}/{@code tracerImmutableMap} all return {@code Integer}; + * {@code tagMap}/{@code stringIndex} happen to expose primitive {@code int} accessors, but + * that's not exercised as a differentiator here). StringIndex's edge should widen further + * against a {@code Map} once the comparison is against genuinely autoboxed + * reads on both sides — not yet measured. + *
    • {@code treeMap}'s {@code get} has a wide error bar (±129, one fork's measurement iterations + * dropped to ~230-300, the rest sit around 490-610) — a known {@code TreeMap} comparison + * characteristic (uses {@code compareTo} not {@code hashCode}/{@code equals}, so it's + * unaffected by dispatch pollution), not the same warmup-race bimodality investigated in + * {@link ImmutableSetBenchmark}. + *
    • {@code TagMap.forEach} (121) beats its own {@code iterator} (109) by ~10%: 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 @@ -145,6 +155,8 @@ static void fill(Map map) { @Setup(Level.Trial) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + hashMap = new HashMap<>(); fill(hashMap); linkedHashMap = new LinkedHashMap<>(); From ea15440bcf0a356f98f4dc286289ab40fe3d1558 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 20:28:48 -0400 Subject: [PATCH 07/21] Extend BenchmarkUtils with ConcurrentHashMap and Map-dispatch pollution polluteHashDispatch() only warmed the shared HashSet/HashMap call site; ConcurrentHashMap has its own, unrelated hashCode()/equals() dispatch sites and needs a dedicated scratch instance. Also add populateTypeProfileMap/populateTypeProfileMutableMap as the Map counterparts to the existing Collection-based helpers, for benchmarks that need finer-grained control than polluteHashDispatch() gives. --- .../datadog/trace/util/BenchmarkUtils.java | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java b/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java index 06cd5324b86..e785757a515 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java +++ b/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java @@ -3,6 +3,8 @@ import java.util.Arrays; import java.util.Collection; import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** Shared setup helpers for JMH benchmarks in this module. */ public final class BenchmarkUtils { @@ -13,12 +15,12 @@ private BenchmarkUtils() {} }; /** - * Exercises {@link HashSet}/{@link java.util.HashMap} and the tracer's {@link - * CollectionUtils#tryMakeImmutableSet} immutable sets with several distinct key classes, so their - * internal {@code hashCode()}/{@code equals()} dispatch -- a call site shared JVM-wide by every - * instance of that structure in the process, regardless of which specific instance or call site - * invokes {@code add}/{@code contains} -- is already megamorphic before a benchmark measures - * lookups against a single key type. + * Exercises {@link HashSet}/{@link java.util.HashMap}, the tracer's {@link + * CollectionUtils#tryMakeImmutableSet} immutable sets, and {@link ConcurrentHashMap} with several + * distinct key classes, so each structure's internal {@code hashCode()}/{@code equals()} dispatch + * -- a call site shared JVM-wide by every instance of that structure in the process, regardless + * of which specific instance or call site invokes {@code add}/{@code contains}/{@code get} -- is + * already megamorphic before a benchmark measures lookups against a single key type. * *

      This matches production: those shared internal call sites are hit by every hash-based * structure in the JVM across whatever key types the whole application uses, so they're @@ -26,9 +28,18 @@ private BenchmarkUtils() {} * type (e.g. {@code String}) would otherwise leave them artificially monomorphic for the entire * run, understating real dispatch cost. * - *

      Deliberately does not touch the benchmark's own {@code contains}/{@code add} call sites -- - * those are realistically free to specialize per caller, the way a genuinely hot, narrowly-typed - * call site would in production. + *

      {@code HashSet} is backed by {@code HashMap} in the JDK, so polluting it also covers plain + * {@code HashMap} and {@code LinkedHashMap} (which extends {@code HashMap}) -- they share the + * same internal dispatch call site. {@code ConcurrentHashMap} does not: it's an unrelated class + * with its own {@code hashCode()}/{@code equals()} call sites, so it needs its own scratch + * instance (also covers {@code ConcurrentHashMap#newKeySet()}, which is backed by a {@code + * ConcurrentHashMap}). Structures that dispatch on {@code compareTo} instead ({@code TreeMap}, + * {@code TreeSet}, {@code ConcurrentSkipListMap}) aren't affected by any of this and don't need + * pollution. + * + *

      Deliberately does not touch the benchmark's own {@code contains}/{@code add}/{@code get} + * call sites -- those are realistically free to specialize per caller, the way a genuinely hot, + * narrowly-typed call site would in production. * *

      Not to be confused with the CHA-defeat decoys in {@code SingleThreadedMapBenchmark}/{@code * ThreadSafeMapBenchmark} ({@code KeyStrategy} implementors referenced only so they're loaded, @@ -37,8 +48,8 @@ private BenchmarkUtils() {} * -- {@code Object.hashCode()}/{@code equals()} already have countless implementors loaded in any * real JVM, so a single-implementor CHA bet was never available for them. What gates their * dispatch is the interpreter's per-call-site type profile, which only invocation can pollute -- - * hence this helper actually calls {@code add}/{@code contains}, rather than just loading - * classes. + * hence this helper actually calls {@code add}/{@code contains}/{@code get}, rather than just + * loading classes. */ public static void polluteHashDispatch() { polluteHashDispatch(DEFAULT_DECOY_KEYS); @@ -47,6 +58,7 @@ public static void polluteHashDispatch() { public static void polluteHashDispatch(Object... decoyKeys) { populateTypeProfileMutable(new HashSet<>(), decoyKeys); populateTypeProfile(CollectionUtils.tryMakeImmutableSet(Arrays.asList(decoyKeys)), decoyKeys); + populateTypeProfileMutableMap(new ConcurrentHashMap<>(), decoyKeys); } /** @@ -76,4 +88,31 @@ public static void populateTypeProfileMutable(Collection scratch, Object scratch.contains(key); } } + + /** + * {@link Map} counterpart to {@link #populateTypeProfile(Collection)}: pass the map instance + * under test (or an equivalent scratch instance) to drive its {@code get()} dispatch. Safe + * against immutable maps too, since it only calls {@code get()}. + */ + public static void populateTypeProfileMap(Map populated) { + populateTypeProfileMap(populated, DEFAULT_DECOY_KEYS); + } + + public static void populateTypeProfileMap(Map populated, Object... decoyKeys) { + for (Object key : decoyKeys) { + populated.get(key); + } + } + + /** + * Lower-level control, {@link Map} counterpart to {@link #populateTypeProfileMutable}: also + * drives {@code put()} dispatch, so {@code scratch} must genuinely support mutation. + */ + public static void populateTypeProfileMutableMap( + Map scratch, Object... decoyKeys) { + for (Object key : decoyKeys) { + scratch.put(key, key); + scratch.get(key); + } + } } From f54f1dc6a482e03c234e63c27d37868d1ecfa77c Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 20:29:00 -0400 Subject: [PATCH 08/21] Wire BenchmarkUtils.polluteHashDispatch into the remaining map/set benchmarks Applies the type-profile-pollution fix already used in ImmutableSetBenchmark/ImmutableMapBenchmark to the rest of the HashMap/HashSet/ConcurrentHashMap-comparing benchmarks in this module: SingleThreadedMapBenchmark, SingleThreadedSetBenchmark, ThreadSafeMapBenchmark, HashtableD1Benchmark, HashtableD2Benchmark, CaseInsensitiveMapBenchmark, and TagMapAccessBenchmark. Without this, each file's isolated single-key-type usage left the JDK collections' shared internal hashCode()/equals() dispatch call sites artificially monomorphic, understating their real per-call cost. FlatHashtableIteratorBenchmark is intentionally untouched -- it only exercises the project's own FlatHashtable/HashStrategy, not java.util.HashMap/HashSet/ConcurrentHashMap. --- .../jmh/java/datadog/trace/api/TagMapAccessBenchmark.java | 6 ++++++ .../datadog/trace/util/CaseInsensitiveMapBenchmark.java | 7 +++++++ .../jmh/java/datadog/trace/util/HashtableD1Benchmark.java | 2 ++ .../jmh/java/datadog/trace/util/HashtableD2Benchmark.java | 2 ++ .../datadog/trace/util/SingleThreadedMapBenchmark.java | 2 ++ .../datadog/trace/util/SingleThreadedSetBenchmark.java | 2 ++ .../java/datadog/trace/util/ThreadSafeMapBenchmark.java | 7 +++++++ 7 files changed, 28 insertions(+) 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 de33c957e9a..28d14e44718 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,6 @@ package datadog.trace.api; +import datadog.trace.util.BenchmarkUtils; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -99,6 +100,11 @@ public class TagMapAccessBenchmark { * 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)}. */ + @Setup(Level.Trial) + public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + } + @State(Scope.Thread) public static class ReadMap { TagMap map; diff --git a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java index 9cbbddcf299..e99080539b7 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -6,8 +6,10 @@ import java.util.function.Supplier; 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; @@ -101,6 +103,11 @@ static T init(Supplier supplier) { // masking exactly the differences this benchmark compares. int lookupIndex = 0; + @Setup(Level.Trial) + public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + } + String nextLookupKey() { int localIndex = ++lookupIndex; if (localIndex >= LOOKUP_KEYS.length) { diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java index f8ba7177e88..b8c8988d93a 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -103,6 +103,8 @@ public static class D1State { @Setup(Level.Iteration) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + table = new Hashtable.D1<>(CAPACITY); hashMap = new HashMap<>(CAPACITY); keys = SOURCE_KEYS; diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java index 6f46a702005..55258135180 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -138,6 +138,8 @@ public static class D2State { @Setup(Level.Iteration) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + table = new Hashtable.D2<>(CAPACITY); hashMap = new HashMap<>(CAPACITY); k1s = SOURCE_K1; 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 cb792cc1ca9..02295a46edb 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -191,6 +191,8 @@ static IntEntry[] newFilledFlat() { @Setup(Level.Trial) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + hashMap = new HashMap<>(); fill(hashMap); synchronizedHashMap = Collections.synchronizedMap(new HashMap<>(hashMap)); 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 e145e6bbe8b..1825771f852 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java @@ -94,6 +94,8 @@ static void fill(Set set) { @Setup(Level.Trial) public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + hashSet = new HashSet<>(Arrays.asList(ELEMENTS)); synchronizedSet = Collections.synchronizedSet(new HashSet<>(hashSet)); treeSet = new TreeSet<>(Arrays.asList(ELEMENTS)); diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java index 63ccb734e9c..4d319e118d8 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java @@ -8,8 +8,10 @@ import java.util.function.Supplier; 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; @@ -93,6 +95,11 @@ static T init(Supplier supplier) { // (e.g. FlatHashtable's lock-free probe), hiding exactly the differences this benchmark compares. int lookupIndex = 0; + @Setup(Level.Trial) + public void setUp() { + BenchmarkUtils.polluteHashDispatch(); + } + String nextLookupKey() { return nextLookupKey(EQUAL_KEYS); } From 1c83f2b2aca253b98633354b23eb2f1c6cc30456 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 22:38:30 -0400 Subject: [PATCH 09/21] Record pollution-corrected results in SingleThreadedMapBenchmark --- .../util/SingleThreadedMapBenchmark.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 02295a46edb..26753867d18 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedMapBenchmark.java @@ -50,6 +50,48 @@ * 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.) + * + *

      JDK 8 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}, with {@link + * BenchmarkUtils#polluteHashDispatch()} in effect; M ops/s): + * + *

      {@code
      + * create_hashMap                 79   create_hashMap_sized     38*
      + * create_synchronizedHashMap    8.6   create_treeMap            5*
      + * create_linkedHashMap            8*  create_tagMap            10
      + * create_tagMap_via_ledger        9   create_flatHashtable    190
      + *
      + * clone_hashMap                  68*  clone_synchronizedHashMap 58
      + * clone_treeMap                 100   clone_linkedHashMap       94
      + * clone_tagMap                  249
      + *
      + * get_hashMap                   164   get_synchronizedHashMap   67
      + * get_flatHashtable              196
      + *
      + * iterate_hashMap                119  iterate_synchronizedHashMap 81
      + * iterate_flatHashtable           14*
      + * }
      + * + *

      * = error bar as wide as (or wider than) the mean at {@code @Fork(2)} — treat these as + * directional, not decisive; a {@code @Fork(5)} rerun would tighten them (see {@code + * ThreadSafeMapBenchmark}'s Javadoc for the same caveat pattern). The construction benchmarks are + * consistently the noisy ones; the read/clone/iterate benchmarks are comparatively tight. + * + *

      Key findings: + * + *

        + *
      • {@code flatHashtable} dominates both {@code create} (190M) and {@code get} (196M) — the + * unboxed, self-contained entry and comparison-free insert pay off, consistent with every + * other FlatHashtable comparison in this module. + *
      • {@code tagMap} clone (249M) is ~3.7x {@code hashMap} clone (68M) — the same story {@link + * datadog.trace.api.TagMapAccessBenchmark} reports from an earlier (unpolluted, Java 17) run + * at ~4.6x; the ratio survives pollution and a different JDK, even though the absolute + * numbers aren't directly comparable across those two runs. + *
      • The uncontended synchronization tax is large here even though this run is on JDK 8, where + * biased locking is enabled by default: {@code get_hashMap} (164M) → {@code + * get_synchronizedHashMap} (67M) is a ~59% hit, and {@code iterate} (119M → 81M) is ~32%. + * That's a bigger tax than the "biased locking should make uncontended locking nearly free" + * story predicts — not root-caused here, left as an open question rather than papered over. + *
      */ @Fork(2) @Warmup(iterations = 2) From dcfc9e74dd4caee5b3685e5b4b3a4f7ed8a5aee4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 22:38:32 -0400 Subject: [PATCH 10/21] Record pollution-corrected results in SingleThreadedSetBenchmark --- .../util/SingleThreadedSetBenchmark.java | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) 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 1825771f852..2b19e3ac48f 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java @@ -45,16 +45,36 @@ * create_treeSet 36 * } * + *

      JDK 8 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}, with {@link + * BenchmarkUtils#polluteHashDispatch()} in effect; M ops/s): + * + *

      {@code
      + * contains_hashSet            1421
      + * contains_synchronizedSet     746    (~48% slower — the uncontended sync tax)
      + * iterate_hashSet              134
      + * iterate_synchronizedSet      129    (one monitor acquire amortized over the walk)
      + *
      + * create_hashSet         67    clone_hashSet          56
      + * create_hashSet_sized   83*   clone_synchronizedSet  48*
      + * create_linkedHashSet   63    clone_linkedHashSet    56
      + * create_synchronizedSet 71*   clone_treeSet          77
      + * create_treeSet         38
      + * }
      + * + *

      * = error bar over half the mean at {@code @Fork(2)} — directional only. + * *

      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.) + *
      • Uncontended synchronization tax holds up under pollution and on a different JDK: + * {@code contains} is ~48% slower synchronized (1421 → 746M ops/s on JDK 8, vs. ~37% on Java + * 17) — same story, somewhat larger tax. {@code iterate}'s tax stays small either way (~4% + * here): one monitor acquire amortized over the walk. + *
      • Type-profile pollution didn't change the qualitative story from the original Java 17 run — + * {@code contains_hashSet} and {@code iterate_hashSet} land in the same range (1291 vs 1421M, + * 91 vs 134M) rather than collapsing, unlike {@link ImmutableSetBenchmark}'s {@code hitFresh} + * case. Construction numbers remain the noisiest (several {@code @Fork(2)} error bars exceed + * half the mean); {@code TreeSet} stays the slowest to build across both runs. *
      */ @Fork(2) From aa889afcdd1b34ddf8562bb6ef74bcf8d0867993 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 22:38:33 -0400 Subject: [PATCH 11/21] Record pollution-corrected results in ThreadSafeMapBenchmark --- .../trace/util/ThreadSafeMapBenchmark.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java index 4d319e118d8..53a009597c5 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapBenchmark.java @@ -64,6 +64,29 @@ * ThreadSafeMapBenchmark.get_hashMap_synchronized thrpt 6 20123419.604 ± 4858466.787 ops/s * ThreadSafeMapBenchmark.get_hashMap_volatile thrpt 6 286024211.995 ± 114449056.603 ops/s * + * + *

      Rerun on JDK 8 with a new {@code @Setup(Level.Trial)} calling {@link + * BenchmarkUtils#polluteHashDispatch()} (not present before this change — this file had no + * {@code @Setup} at all). Not directly comparable to the Java 21 numbers above (different JDK, and + * this is the first run with pollution), so treat this as its own baseline rather than a delta: + * + *

      {@code
      + * create_concHashMap            47   create_concSkipListMap  17
      + * create_hashMap                130  create_hashMap_synchronized 92*
      + * create_flatHashtable          178
      + *
      + * get_concHashMap                977  get_concSkipListMap    309
      + * get_flatHashtable             1654  get_hashMap_synchronized 26
      + * get_hashMap_volatile          1257
      + * }
      + * + *

      * = error bar over half the mean at {@code @Fork(2)} — directional only. + * + *

      {@code get_concSkipListMap} (309M) is ~11x the Java 21 measurement above (27M) — far more than + * a JDK swap plausibly explains for an O(log n), {@code compareTo}-dispatched structure that this + * pollution change doesn't touch. Flagging this as an open anomaly rather than a finding: don't + * treat it as "ConcurrentSkipListMap got faster" without a controlled re-run isolating the JDK + * variable. */ @Fork(2) @Warmup(iterations = 2) From e34f87c80768573ada5bf94594081e17c28388a8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 22:38:34 -0400 Subject: [PATCH 12/21] Record pollution rerun results in HashtableD1Benchmark --- .../java/datadog/trace/util/HashtableD1Benchmark.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java index b8c8988d93a..8653ac46fb7 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -54,6 +54,16 @@ * HashtableD1Benchmark.iterate_hashMap thrpt 6 20.043 ± 0.752 ops/us * HashtableD1Benchmark.iterate_hashtable thrpt 6 22.208 ± 0.956 ops/us * + * + *

      Rerun with {@link BenchmarkUtils#polluteHashDispatch()} added to {@code D1State.setUp()} (same + * machine/JVM/config): every number moved down somewhat (add_hashMap 188→101, update_hashtable + * 1810→1465, iterate_hashtable 22→17 ops/us), including {@code *_hashtable}, which doesn't touch + * {@code java.util.HashMap}/{@code HashSet} at all and so shouldn't be affected by this pollution + * mechanism. That points to session-to-session machine variance (not controlled for here) rather + * than a genuine pollution effect for this particular file — unlike {@code + * ImmutableSetBenchmark}/{@code ImmutableMapBenchmark}, where pollution measurably changed the + * comparison. The relative conclusion (D1 dominates {@code update}, is roughly comparable on + * {@code add}, ties on {@code iterate}) is unchanged either way. */ @Fork(2) @Warmup(iterations = 2) From d3c3595b1f0d30a9e6554f9783ef9c2b8d79f2e4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 22:38:35 -0400 Subject: [PATCH 13/21] Record pollution rerun results in HashtableD2Benchmark --- .../java/datadog/trace/util/HashtableD2Benchmark.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java index 55258135180..c2abfa6726c 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -58,6 +58,17 @@ * HashtableD2Benchmark.iterate_hashMap thrpt 6 19.508 ± 0.760 ops/us * HashtableD2Benchmark.iterate_hashtable thrpt 6 16.968 ± 0.371 ops/us * + * + *

      Rerun with {@link BenchmarkUtils#polluteHashDispatch()} added to {@code D2State.setUp()} (same + * machine/JVM/config): results were noisy and inconsistent with a clean pollution story — + * add_hashMap actually rose (77→103), while add_hashtable fell sharply (217→118, error bars wider + * than the mean both times); update_hashtable fell (1446→1225) and both iterate numbers fell + * (19.5→15.4, 17.0→13.1) despite {@code *_hashtable} not touching {@code java.util.HashMap}/{@code + * HashSet} dispatch at all. As with {@link HashtableD1Benchmark}, this looks like uncontrolled + * machine variance between the two sessions rather than a genuine pollution effect here — treat + * these two runs as not directly comparable. The relative conclusion (D2 dominates {@code + * update}, wins {@code add} by avoiding the {@code Key2} allocation, ties on {@code iterate}) is + * unchanged either way. */ @Fork(2) @Warmup(iterations = 2) From b0c6cf41c96be85ae17bdf2a6fc882b336394679 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 22:38:36 -0400 Subject: [PATCH 14/21] Record pollution rerun results in CaseInsensitiveMapBenchmark --- .../util/CaseInsensitiveMapBenchmark.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java index e99080539b7..0368f4fd516 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -54,6 +54,30 @@ * lookup_hashMap thrpt 15 441875038.1 ± 110408182.2 ops/s 24.0 B/op (129 GCs) * lookup_treeMap thrpt 15 251195415.1 ± 14662568.3 ops/s ~0 B/op * + * + *

      Rerun on JDK 8 with {@link BenchmarkUtils#polluteHashDispatch()} added to a new + * {@code @Setup(Level.Trial)} (this file had none before), at this file's actual {@code @Fork(2)} + * (the numbers above are from an ad hoc higher-fork run; not directly comparable). M ops/s, 8 + * threads: + * + *

      {@code
      + * create_baseline        26    create_flatHashtable   13
      + * create_hashMap          9    create_treeMap          7
      + *
      + * lookup_baseline      2618    lookup_flatHashtable  415
      + * lookup_flatHashtable_lowLoad 415  lookup_hashMap    367*
      + * lookup_treeMap        209
      + * }
      + * + *

      * = error bar over a third of the mean at {@code @Fork(2)} — directional only. + * + *

      All four {@code lookup_*} numbers sit 17-23% below the table above (415 vs 537 flatHashtable, + * 367 vs 442 hashMap, 209 vs 251 treeMap) despite {@code flatHashtable} and {@code treeMap} using + * neither {@code java.util.HashMap} nor {@code hashCode()}/{@code equals()} dispatch — so this drop + * isn't attributable to pollution. Combined with the same pattern in {@link HashtableD1Benchmark} + * and {@link HashtableD2Benchmark}, this looks like session-to-session machine variance (different + * JDK, different run) rather than a real regression. The relative ranking — {@code + * flatHashtable} > {@code hashMap} > {@code treeMap} — is unchanged. */ @Fork(2) @Warmup(iterations = 2) From d61d4aec7f650371fcb710d13af286cdc5065e85 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 25 Aug 2026 22:38:37 -0400 Subject: [PATCH 15/21] Record pollution rerun results in TagMapAccessBenchmark --- .../trace/api/TagMapAccessBenchmark.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 28d14e44718..d373a6a5e18 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java @@ -57,6 +57,28 @@ * TagMapAccessBenchmark.insert_hashMap_builderStyle thrpt 5 28057827.189 ± 1359655.664 ops/s * TagMapAccessBenchmark.insert_via_ledger thrpt 5 41169656.095 ± 773264.754 ops/s * + * + *

      Rerun on JDK 8 with a new top-level {@code @Setup(Level.Trial)} calling {@link + * BenchmarkUtils#polluteHashDispatch()} (this file had none before). M ops/s, 8 threads: + * + *

      {@code
      + * getEntry                        83   getObject                    87
      + * insert                          37   insert_hashMap                48
      + * insert_hashMap_builderStyle     20   insert_via_ledger             37*
      + * }
      + * + *

      * = error bar about a quarter of the mean at {@code @Fork(2)} — directional only. + * + *

      Every number here is 9-29% below the Java 17 table above, with no clear split between the + * TagMap paths and the HashMap paths this pollution should affect — the same broad slowdown pattern + * seen across {@link datadog.trace.util.HashtableD1Benchmark}, {@link + * datadog.trace.util.HashtableD2Benchmark}, and {@link + * datadog.trace.util.CaseInsensitiveMapBenchmark} in the same session, so treat it as + * session-to-session machine/JDK variance rather than a pollution-driven regression. The relative + * story survives: {@code insert_hashMap} (48M) still beats {@code insert} (37M) for plain + * insertion, and {@code insert_via_ledger} (37M) still clearly beats the HashMap builder-style path + * (20M); {@code insert_via_ledger} landing roughly level with {@code insert} here (vs. clearly + * behind it in the table above) is within that path's own wide error bar, not a new finding. */ @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) From 82d1adb2c19904e9fba603f973032322d9b949c8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 07:56:21 -0400 Subject: [PATCH 16/21] Correct HashtableD1Benchmark's pollution-immunity rationale D1.Entry.hash/matches are call sites private to Hashtable.java, distinct from java.util.HashMap/HashSet's own dispatch sites, so pollution literally cannot reach them (not just unlikely to). Since JDK and machine were held constant in this rerun, attribute the observed drop to same-session run-to-run noise, not a JDK effect (that explanation applies elsewhere, where the JDK actually changed). --- .../trace/util/HashtableD1Benchmark.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java index 8653ac46fb7..587eaad3da2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -57,13 +57,18 @@ * *

      Rerun with {@link BenchmarkUtils#polluteHashDispatch()} added to {@code D1State.setUp()} (same * machine/JVM/config): every number moved down somewhat (add_hashMap 188→101, update_hashtable - * 1810→1465, iterate_hashtable 22→17 ops/us), including {@code *_hashtable}, which doesn't touch - * {@code java.util.HashMap}/{@code HashSet} at all and so shouldn't be affected by this pollution - * mechanism. That points to session-to-session machine variance (not controlled for here) rather - * than a genuine pollution effect for this particular file — unlike {@code - * ImmutableSetBenchmark}/{@code ImmutableMapBenchmark}, where pollution measurably changed the - * comparison. The relative conclusion (D1 dominates {@code update}, is roughly comparable on - * {@code add}, ties on {@code iterate}) is unchanged either way. + * 1810→1465, iterate_hashtable 22→17 ops/us), including {@code *_hashtable}. That's expected to be + * a no-op for {@code *_hashtable}: {@link Hashtable.D1.Entry#hash} and {@link + * Hashtable.D1.Entry#matches} are call sites private to {@code Hashtable.java}, structurally + * distinct from {@code java.util.HashMap}/{@code HashSet}'s internal {@code hashCode()}/{@code + * equals()} call sites — JIT type profiles are keyed per call site, so {@code + * polluteHashDispatch()} cannot reach them regardless of key-type overlap. Since the JDK and + * machine were held constant across this rerun (unlike the JDK 8-vs-17 comparisons in {@link + * datadog.trace.util.CaseInsensitiveMapBenchmark} and {@link + * datadog.trace.api.TagMapAccessBenchmark}), the drop here is same-session run-to-run noise + * (thermal/power, not controlled for) rather than either a pollution effect or a JDK effect. The + * relative conclusion (D1 dominates {@code update}, is roughly comparable on {@code add}, + * ties on {@code iterate}) is unchanged either way. */ @Fork(2) @Warmup(iterations = 2) From 3d6d044227ba868d3b5164386884a470793a3f6f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 07:56:30 -0400 Subject: [PATCH 17/21] Correct HashtableD2Benchmark's pollution-immunity rationale Same fix as HashtableD1Benchmark: D2.Entry.hash/matches are call sites private to Hashtable.java, structurally distinct from java.util.HashMap dispatch, so pollution cannot reach them regardless of key-type overlap; the observed drop with JDK/machine held constant is same-session noise, not a JDK effect. --- .../datadog/trace/util/HashtableD2Benchmark.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java index c2abfa6726c..7c9e412d479 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -63,12 +63,15 @@ * machine/JVM/config): results were noisy and inconsistent with a clean pollution story — * add_hashMap actually rose (77→103), while add_hashtable fell sharply (217→118, error bars wider * than the mean both times); update_hashtable fell (1446→1225) and both iterate numbers fell - * (19.5→15.4, 17.0→13.1) despite {@code *_hashtable} not touching {@code java.util.HashMap}/{@code - * HashSet} dispatch at all. As with {@link HashtableD1Benchmark}, this looks like uncontrolled - * machine variance between the two sessions rather than a genuine pollution effect here — treat - * these two runs as not directly comparable. The relative conclusion (D2 dominates {@code - * update}, wins {@code add} by avoiding the {@code Key2} allocation, ties on {@code iterate}) is - * unchanged either way. + * (19.5→15.4, 17.0→13.1). As with {@link HashtableD1Benchmark}, {@code *_hashtable} is expected to + * be a no-op here: {@link Hashtable.D2.Entry#hash} and {@link Hashtable.D2.Entry#matches} are call + * sites private to {@code Hashtable.java}, structurally distinct from {@code + * java.util.HashMap}/{@code HashSet}'s internal dispatch call sites — pollution cannot reach them. + * With the JDK and machine held constant across this rerun, the drop is same-session run-to-run + * noise (thermal/power, not controlled for) rather than a genuine pollution effect. Treat these two + * runs as not directly comparable on absolute numbers. The relative conclusion (D2 dominates + * {@code update}, wins {@code add} by avoiding the {@code Key2} allocation, ties on {@code + * iterate}) is unchanged either way. */ @Fork(2) @Warmup(iterations = 2) From 229822498a6d5e6c9c85439ceea934a5edf5d3b8 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 07:56:37 -0400 Subject: [PATCH 18/21] Attribute CaseInsensitiveMapBenchmark's rerun slowdown to JDK 8 on ARM64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original table is Zulu 21; this rerun is JDK 8, whose C2 backend for Apple Silicon is far less mature than JDK 17+'s. That JDK gap explains a broad-based slowdown across every entry on its own, including flatHashtable/treeMap which don't touch java.util.HashMap dispatch — a more precise explanation than generic "machine variance." --- .../datadog/trace/util/CaseInsensitiveMapBenchmark.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java index 0368f4fd516..62a48976691 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -74,10 +74,11 @@ *

      All four {@code lookup_*} numbers sit 17-23% below the table above (415 vs 537 flatHashtable, * 367 vs 442 hashMap, 209 vs 251 treeMap) despite {@code flatHashtable} and {@code treeMap} using * neither {@code java.util.HashMap} nor {@code hashCode()}/{@code equals()} dispatch — so this drop - * isn't attributable to pollution. Combined with the same pattern in {@link HashtableD1Benchmark} - * and {@link HashtableD2Benchmark}, this looks like session-to-session machine variance (different - * JDK, different run) rather than a real regression. The relative ranking — {@code - * flatHashtable} > {@code hashMap} > {@code treeMap} — is unchanged. + * isn't attributable to pollution. The likelier explanation: the table above is Zulu 21, this rerun + * is JDK 8, and JDK 8's C2 backend for Apple Silicon (AArch64) is far less mature than JDK 17+'s — + * a broad-based slowdown across every entry, pollution-affected or not, is expected from that JDK + * gap alone on this machine. The relative ranking — {@code flatHashtable} > {@code hashMap} + * > {@code treeMap} — is unchanged. */ @Fork(2) @Warmup(iterations = 2) From 226dd76f153a159246551bdb9a52659464d782b0 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 07:56:45 -0400 Subject: [PATCH 19/21] Attribute TagMapAccessBenchmark's rerun slowdown to JDK 8 on ARM64 Same fix as CaseInsensitiveMapBenchmark: the original table is Java 17, this rerun is JDK 8, and JDK 8's weaker Apple Silicon C2 codegen explains the across-the-board drop on its own. Distinguish this from HashtableD1Benchmark/HashtableD2Benchmark, where the JDK was held constant and the drop is same-session noise instead. --- .../trace/api/TagMapAccessBenchmark.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 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 d373a6a5e18..3cd021a65b4 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java @@ -70,15 +70,18 @@ *

      * = error bar about a quarter of the mean at {@code @Fork(2)} — directional only. * *

      Every number here is 9-29% below the Java 17 table above, with no clear split between the - * TagMap paths and the HashMap paths this pollution should affect — the same broad slowdown pattern - * seen across {@link datadog.trace.util.HashtableD1Benchmark}, {@link - * datadog.trace.util.HashtableD2Benchmark}, and {@link - * datadog.trace.util.CaseInsensitiveMapBenchmark} in the same session, so treat it as - * session-to-session machine/JDK variance rather than a pollution-driven regression. The relative - * story survives: {@code insert_hashMap} (48M) still beats {@code insert} (37M) for plain - * insertion, and {@code insert_via_ledger} (37M) still clearly beats the HashMap builder-style path - * (20M); {@code insert_via_ledger} landing roughly level with {@code insert} here (vs. clearly - * behind it in the table above) is within that path's own wide error bar, not a new finding. + * TagMap paths and the HashMap paths this pollution should affect. The table above is Java 17; this + * rerun is JDK 8, whose C2 backend for Apple Silicon (AArch64) is far less mature than JDK 17+'s — + * a broad-based slowdown across every entry is expected from that JDK gap alone, independent of + * pollution — the same JDK-crossing explanation applies to {@link + * datadog.trace.util.CaseInsensitiveMapBenchmark}'s rerun. ({@link + * datadog.trace.util.HashtableD1Benchmark} and {@link datadog.trace.util.HashtableD2Benchmark} saw + * a similar broad drop despite holding the JDK constant — that one is same-session run-to-run + * noise, not a JDK effect.) The relative story survives: {@code insert_hashMap} (48M) still beats + * {@code insert} (37M) for plain insertion, and {@code insert_via_ledger} (37M) still clearly beats + * the HashMap builder-style path (20M); {@code insert_via_ledger} landing roughly level with {@code + * insert} here (vs. clearly behind it in the table above) is within that path's own wide error bar, + * not a new finding. */ @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) From dbed8a194fac816a3601fdca555cadbb1c44b101 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 09:26:23 -0400 Subject: [PATCH 20/21] Record Java 17 rerun for HashtableD1Benchmark Controlled rerun (same machine, JDK swapped to Zulu 17.0.7) shows update_hashtable still wins decisively (~4.2x, down from ~14x on JDK 8) and iterate_hashtable flips from a wash to a ~4x win, while add_hashMap edges ahead slightly. Java 17's much better allocator/GC narrows but doesn't erase Hashtable's win on the allocation-heavy paths -- net takeaway: Hashtable is a strong HashMap substitute for simple counter/tally cases with a primitive value. Absolute numbers aren't comparable to the JDK 8 table: JMH auto-selects the cheap "compiler" Blackhole mode on 17 but not on 8, so within-run ratios are the only trustworthy comparison here. --- .../trace/util/HashtableD1Benchmark.java | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java index 587eaad3da2..9581a8db520 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java @@ -38,10 +38,14 @@ *

    • iterate — walk every entry and consume its key + value. * * - *

      Update is where Hashtable dominates: D1 is ~14x faster, because the HashMap path - * allocates per call (a {@code Long}) and the resulting GC pressure throttles throughput under - * multiple threads. Add is roughly comparable (both allocate one entry per insert). - * Iterate is essentially a wash — both are bucket walks. + *

      Update is where Hashtable dominates: D1 is ~14x faster on JDK 8 (see the Java 17 rerun + * below for a narrower but still decisive margin), because the HashMap path allocates per call (a + * {@code Long}) and the resulting GC pressure throttles throughput under multiple threads. This is + * the headline case for {@code Hashtable}: a simple counter/tally with a primitive value is exactly + * where HashMap's autoboxing tax bites hardest, and {@code Hashtable.D1} sidesteps it entirely by + * mutating a field on the retrieved entry in place. Add is roughly comparable (both allocate + * one entry per insert). Iterate is essentially a wash on JDK 8, though not on Java 17 (see + * below). * MacBook M1 8 threads (Java 8) * * Benchmark Mode Cnt Score Error Units @@ -69,6 +73,29 @@ * (thermal/power, not controlled for) rather than either a pollution effect or a JDK effect. The * relative conclusion (D1 dominates {@code update}, is roughly comparable on {@code add}, * ties on {@code iterate}) is unchanged either way. + * + *

      Separately rerun on Zulu 17.0.7 (native AArch64, same machine, pollution wiring unchanged; JMH + * auto-detected the cheap "compiler" Blackhole mode here, unlike JDK 8, so absolute numbers below + * are not comparable to the JDK 8 tables above — see {@code HashtableD2Benchmark}'s javadoc for the + * full caveat). M ops/us, 8 threads: + * + *

      {@code
      + * add_hashMap        1502.6   add_hashtable      1377.3
      + * update_hashMap      644.2   update_hashtable   2706.5
      + * iterate_hashMap      19.3   iterate_hashtable    78.0
      + * }
      + * + *

      Within this single run (so the cross-JDK Blackhole-mode confound doesn't apply to the ratios), + * {@code update_hashtable} still wins by ~4.2x — down from ~14x on JDK 8, because Java 17's + * allocator/GC absorbs {@code update_hashMap}'s per-call {@code Long} boxing far better than JDK 8 + * did (update_hashMap itself got ~5x faster; update_hashtable only ~1.5x faster). {@code + * iterate_hashtable} also now clearly wins (~4.0x), flipping from JDK 8's "wash" — HashMap's {@code + * entrySet()} iterator does more per-entry work than a modern JIT's allocation improvements erase. + * {@code add} is the one case that flips the other way: {@code add_hashMap} edges out {@code + * add_hashtable} slightly (1502.6 vs 1377.3). Net takeaway: {@code Hashtable} is a strong + * substitute for {@code HashMap} particularly for simple counter/tally use cases with a primitive + * value, where avoiding the per-update boxing allocation pays off even on a JVM with much better + * allocation handling than JDK 8 had. */ @Fork(2) @Warmup(iterations = 2) From 8bbe287f783197cd8d397963eb73c0247d9cff64 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 26 Aug 2026 09:26:32 -0400 Subject: [PATCH 21/21] Record Java 17 rerun for HashtableD2Benchmark Same controlled rerun as HashtableD1Benchmark. update_hashtable wins ~11.6x (down from ~26x on JDK 8); unlike JDK 8, Hashtable now also wins clearly on add (~1.8x) and iterate (~3.4x, up from a wash) -- Java 17's allocator/GC narrows the update margin but doesn't flip any operation in HashMap's favor for D2. Same Blackhole-mode caveat on absolute numbers as D1; within-run ratios are what's trustworthy here. --- .../trace/util/HashtableD2Benchmark.java | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java index 7c9e412d479..49357ab9a17 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/HashtableD2Benchmark.java @@ -42,10 +42,13 @@ *

      The D2 variants additionally pay for a composite-key wrapper allocation in the HashMap path * (Java has no built-in tuple-as-key) — D2 sidesteps it by taking both key parts directly. * - *

      Update is where Hashtable dominates: D2 is ~26x faster, because the HashMap path - * allocates per call (a {@code Long}, plus a {@code Key2}) and the resulting GC pressure throttles - * throughput under multiple threads. Add is ~3x faster for D2 (Hashtable sidesteps the - * {@code Key2} allocation). Iterate is essentially a wash — both are bucket walks. + *

      Update is where Hashtable dominates: D2 is ~26x faster on JDK 8 (see the Java 17 rerun + * below for a narrower but still decisive margin), because the HashMap path allocates per call (a + * {@code Long}, plus a {@code Key2}) and the resulting GC pressure throttles throughput under + * multiple threads. Like D1, this is the headline case for {@code Hashtable}: a simple + * counter/tally with a primitive value is exactly where HashMap's autoboxing tax bites hardest. + * Add is ~3x faster for D2 (Hashtable sidesteps the {@code Key2} allocation). Iterate + * is essentially a wash on JDK 8, though not on Java 17 (see below). * MacBook M1 8 threads (Java 8) * * Benchmark Mode Cnt Score Error Units @@ -72,6 +75,32 @@ * runs as not directly comparable on absolute numbers. The relative conclusion (D2 dominates * {@code update}, wins {@code add} by avoiding the {@code Key2} allocation, ties on {@code * iterate}) is unchanged either way. + * + *

      Separately rerun on Zulu 17.0.7 (native AArch64, same machine, pollution wiring unchanged). + * JMH auto-detected the cheap "compiler" Blackhole mode on Java 17 (its log explicitly warns that + * Blackhole-mode differences between JVMs can swing results significantly), which JDK 8 cannot use + * — so absolute numbers below are not comparable to the JDK 8 tables above; only within-run + * ratios are, since both benchmark methods in a given run get identical Blackhole treatment. M + * ops/us, 8 threads: + * + *

      {@code
      + * add_hashMap         656.7   add_hashtable     1185.5
      + * update_hashMap      196.7   update_hashtable  2292.2
      + * iterate_hashMap      20.5   iterate_hashtable   69.2
      + * }
      + * + *

      {@code update_hashtable} still wins decisively (~11.6x, down from ~26x on JDK 8 — Java 17's + * allocator/GC absorbs {@code update_hashMap}'s per-call {@code Long}+{@code Key2} boxing far + * better than JDK 8 did: update_hashMap got ~3.5x faster, update_hashtable only ~1.6x faster). + * Unlike JDK 8, Hashtable now wins clearly on every operation: {@code add_hashtable} wins + * ~1.8x (vs. JDK 8's ~3x — HashMap's {@code Key2} allocation also got relatively cheaper), and + * {@code iterate_hashtable} flips from JDK 8's wash to a ~3.4x win (HashMap's {@code entrySet()} + * iterator does more per-entry work than a modern JIT's allocation improvements erase). Net + * takeaway, consistent with {@link HashtableD1Benchmark}: {@code Hashtable} is a strong substitute + * for {@code HashMap} particularly for simple counter/tally use cases with a primitive value, where + * avoiding the per-update boxing allocation pays off even on a JVM with much better allocation + * handling than JDK 8 had — and for D2 specifically, avoiding the composite-key wrapper allocation + * pays off across the board, not just on {@code update}. */ @Fork(2) @Warmup(iterations = 2)