diff --git a/.agents/skills/perf-review/references/checks.md b/.agents/skills/perf-review/references/checks.md index 5cb5d81411e..d8db4751879 100644 --- a/.agents/skills/perf-review/references/checks.md +++ b/.agents/skills/perf-review/references/checks.md @@ -52,7 +52,8 @@ Refines the universal checks with JVM mechanics. Quarantined here, for the Java - **J9 — `Objects.hash(...)` varargs / boxing hash on a hot path → `HashingUtils`** *(refines #1)*. The allocation is specific to the **varargs/boxing forms**: `Objects.hash(a, b, …)` allocates an `Object[]` per call and **boxes every primitive** arg; same for boxing primitives into a `new Object[]{…}` (or `Arrays.hashCode` over such an array). Per-span tag/key building, or a hot value object's `hashCode()` built this way, → a guaranteed per-call allocation + boxing. Fix: `datadog.trace.util.HashingUtils` — primitive `hash(long/int/boolean/char/…)` overloads (no boxing), `hash(Object,Object)` and `hash(int,int)` combiners (no array); for >2 fields fold pairwise through `hash(int,int)` (there is no varargs form, by design). flag-with-confidence for the varargs/boxing form — SEV-2/3. **Do NOT flag allocation-free combines** — a hand-rolled `31*h + Long.hashCode(x)` / `31*h + intField`, or `Arrays.hashCode` over an *existing primitive array*, allocates nothing (`HashingUtils` is itself 31-based); flagging them would recommend replacing already-correct code. - **J10 — hot-path `String.format` / string munging → `Strings` (+ `SubSequence`)** *(refines #2)*. `String.format` parses the format string, boxes its args, and allocates on every call — never on a hot path; hand-rolled case-conversion, class/resource-name munging, blank-checks, and truncation recomputed per call qualify too. Fix: `datadog.trace.util.Strings` — allocation-aware `replace`/`truncate(CharSequence)`/`isBlank`/`getResourceName`/`getClassName`/…; for **transient substring compares** prefer a `SubSequence` view (J7); for plain assembly, direct concatenation beats `format`. flag-with-confidence for `String.format` on a hot path; flag-as-measure for borderline munging — SEV-2/3. - **J11 — composite / multi-dimensional key maps on a hot path → `Hashtable` / `ConcurrentHashtable`** *(refines #1, #3)*. `Map>` nesting, or a `HashMap` keyed by a composite key (client-side stats, per-`(service, operation, …)` aggregation), allocates nested maps + `Entry` objects + boxes keys on the hot aggregation path. Fix: `datadog.trace.util.Hashtable` (single-threaded, composite-key D1/D2 tables — landed) or `datadog.trace.util.ConcurrentHashtable` (lock-free concurrent, **coming**) — positional composite keys, fewer allocations. flag-as-measure — SEV-2/3. -- **Toolkit availability — cite only what exists.** Available today: `Strings`, `SubSequence`, `HashingUtils`, `Hashtable` (all `datadog.trace.util`), `RE2J` (`com.google.re2j`). Coming (name as "coming", don't imply it's present): `ConcurrentHashtable`, `StringIndex` (immutable string set/map), `UTF8BytesString.Cache` (recurring-string interner), wider `IntegerCache` (http-status/port boxing), `DDCache` inlining. **J7–J11 route an *existing* #1/#2/#3 finding to a reusable fix — they are not new flag-triggers. Don't raise a finding you wouldn't have raised anyway; the posture (precision, silent-when-unsure, findings-cap-scales-with-diff — see `SKILL.md`) is unchanged.** +- **J12 — Benchmark validity: is the dispatch under test actually profile-dependent?** *(methodology check on a submitted benchmark, not a production hot-path finding — refines the flag-as-measure posture in J2)*. JMH forks a fresh JVM per benchmark method, so each arm is compiled in a process that has only ever executed that one arm — leaving shared `hashCode()`/`equals()`/`compareTo` call sites unrealistically clean compared with production, where erasure makes them serve every map in the application. This only affects the measurement when the receiver type is **not statically deducible at the benchmark's own call site**: if the key is a final class (`String`) or an exact type from an allocation, C2 devirtualizes on that proof and never consults the profile, so pollution is a no-op that only adds setup allocation. Note that one key class per benchmark is normal and correct — a production `Map` call site is monotyped too; variety at the caller is not what makes the internal site megamorphic. **flag-as-measure** when a Map/Set JMH benchmark's measured operation dispatches on a non-final key type, or reaches a strategy/comparator interface with several loaded implementors, and its `@Setup` does not pollute — fix: call `BenchmarkUtils.warmUpHashDispatch(Blackhole)` from `@Setup(Level.Trial)` (trial level, not per-iteration: it runs 50,000 allocation-heavy passes). A one-off measurement made while working on #12298 (not recorded in a committed benchmark) showed both sides — `FlatHashtableIteratorBenchmark`'s four-implementor strategy dispatch fell 37.9 → 18.1 M ops/s when its profile was poisoned, while the `String`-keyed map benchmarks in the same module were unaffected, with LogCompilation confirming the dispatch resolved statically. +- **Toolkit availability — cite only what exists.** Available today: `Strings`, `SubSequence`, `HashingUtils`, `Hashtable`, `BenchmarkUtils.warmUpHashDispatch(Blackhole)` for JMH setup (all `datadog.trace.util`), `RE2J` (`com.google.re2j`). Coming (name as "coming", don't imply it's present): `ConcurrentHashtable`, `StringIndex` (immutable string set/map), `UTF8BytesString.Cache` (recurring-string interner), wider `IntegerCache` (http-status/port boxing), `DDCache` inlining. **J7–J11 route an *existing* #1/#2/#3 finding to a reusable fix — they are not new flag-triggers. Don't raise a finding you wouldn't have raised anyway; the posture (precision, silent-when-unsure, findings-cap-scales-with-diff — see `SKILL.md`) is unchanged.** - **J5 — Cardinality-sensitive aggregator** *(domain-specialized #3)*. Some structures are invisible to the generic "unbounded collection" check because the risk is *cardinality*, not raw size: a config- or user-driven value (tag key, resource name, HTTP URL) feeding a **cardinality-sensitive aggregator** (e.g. the conflating metrics aggregator — each unique label combination = one aggregate; a `maxAggregates` cap bounds OOM but high-cardinality input *thrashes* it: constant eviction, garbled metrics). flag-with-confidence when config/user-driven values reach an aggregator with a per-key budget — **SEV-1** (same class as unbounded memory: correctness + heap impact). Fix: bound the source cardinality before it enters the aggregator, or use sentinel substitution for over-cap values. This surfaced on merged production code more than once in back-test calibration — the capstone pattern where the bot's value concentrates. ## Instrumentation (ByteBuddy Advice) idioms — dd-trace-java-specific fixes diff --git a/.agents/skills/perf-review/references/guide.md b/.agents/skills/perf-review/references/guide.md index 6f847ef39d0..3c2b9aaf721 100644 --- a/.agents/skills/perf-review/references/guide.md +++ b/.agents/skills/perf-review/references/guide.md @@ -161,6 +161,27 @@ escape analysis eliminates *local* short-lived allocations, but only when the ob Stored in a map, returned, captured by a lambda, or passed to a non-inlined virtual call: it escapes, and it's real. +**Map/Set JMH benchmarks whose dispatch is actually reachable — treat as unverified.** JMH forks a +fresh JVM per benchmark method, so each arm is compiled in a process that has only ever seen that +one arm's key types. A collection's internal `hashCode()`/`equals()` (or `compareTo`) call site is +shared JVM-wide in production and hit with every key type the process uses, so the benchmark's +version of it is unrealistically clean. + +But this only matters when the receiver type is **not statically deducible at the benchmark's own +call site**. If the key is a final class (`String`) or an exact type from an allocation, C2 +devirtualizes on that proof and never consults the profile — pollution is then a no-op, and +demanding it only adds setup allocation. Having one key class per benchmark is normal and correct; +a real `Map` call site is monotyped too. Erasure is what makes the *internal* site +megamorphic, not variety at the caller. + +A one-off measurement made while working on #12298 (not recorded in a committed benchmark) showed +both sides: `FlatHashtableIteratorBenchmark`'s strategy dispatch fell 37.9 → 18.1 +M ops/s (2.1x) when its profile was poisoned, because the strategy is an interface with four loaded +implementors; the `String`-keyed map benchmarks in the same module were unaffected, and +LogCompilation confirmed the dispatch was resolved statically. Check that `@Setup` calls +`datadog.trace.util.BenchmarkUtils.warmUpHashDispatch(Blackhole)` when the dispatch is genuinely +profile-dependent. + **EA claims for scope/wrapper objects spanning I/O — treat as unverified.** A microbenchmark tight-loop can show zero allocation for a scope or wrapper object because C2 inlines through everything and scalar-replaces it. In production, scopes almost always wrap I/O — and C2 cannot 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..4d0c4e3cb7a 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; @@ -56,6 +57,45 @@ * 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 the same machine and JDK (Zulu 17.0.7, {@code @Fork(2)}, {@code @Threads(8)}) with a + * new top-level {@code @Setup(Level.Trial)} calling {@link BenchmarkUtils#warmUpHashDispatch} (this + * file had none before): + * + *

{@code
+ * Benchmark                      Score (M ops/s)    Error
+ * getEntry                              97.00      ± 2.37
+ * getObject                             97.70      ± 0.88
+ * insert                                52.67      ± 1.28
+ * insert_hashMap                        69.57      ± 0.65
+ * insert_hashMap_builderStyle           29.51      ± 0.50
+ * insert_via_ledger                     37.15      ± 0.78
+ * }
+ * + *

Pollution left these paths materially unchanged, but not for the same reason on both sides. + * {@code TagMap} is typed to {@code String} keys throughout ({@link TagMap#getEntry(String)}, + * {@link TagMap.Ledger#set(String, Object)}) and compares against a {@code String}-declared field, + * so it has no {@code Object}-typed dispatch site to pollute — it is insulated by construction, + * whatever the JIT decides. {@code HashMap} does have such sites: {@code getNode} calls {@code + * hashCode()}/{@code equals()} on an {@code Object}-declared key. Erasure makes that one bytecode + * index serve every map in the application, so in production it sees many receiver types — the + * condition {@code warmUpHashDispatch} approximates here by broadening that profile. Even so, + * {@code get}/{@code put} inline into a caller holding a statically exact {@code String}, so C2 + * sharpens the argument and devirtualizes without consulting that profile. That is a realistic + * condition rather than an artifact — plenty of production call sites do hand HashMap a statically + * known key type, and it legitimately gets that benefit. Where the key type is not deducible, the + * broadened profile governs HashMap and still does not reach TagMap. + * + *

Consistent with that, most entries move by a couple of percent with overlapping intervals, and + * the larger moves go up, which pollution cannot cause. + * + *

The exception is {@code insert_via_ledger}, down about 10% (41.17 to 37.15) with + * non-overlapping intervals. That is the most allocation-heavy path measured, and {@code + * warmUpHashDispatch} itself allocates heavily before measurement starts, so setup churn shifting + * GC state for the trial is a likelier cause than dispatch. Not investigated further. + * + *

Both tables are the same machine and JDK, but the baseline above ran at {@code Cnt 5} against + * {@code Cnt 10} here, and on a different day, so small differences carry no weight. */ @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) @@ -95,6 +135,11 @@ public class TagMapAccessBenchmark { } } + @Setup(Level.Trial) + public void setUp(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + /** * 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)}. 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 4aa5ea7556a..42c294e0469 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java +++ b/internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java @@ -1,59 +1,263 @@ package datadog.trace.util; import java.util.Arrays; -import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.atomic.AtomicBoolean; +import org.openjdk.jmh.annotations.CompilerControl; +import org.openjdk.jmh.infra.Blackhole; -/** Shared setup helpers for JMH benchmarks in this module. */ +/** + * Prepares shared collection code for benchmarks that run in separate JMH forks. + * + *

Forking isolates benchmark methods, so their receiver types, loaded implementations and branch + * outcomes can differ from those in an application. This helper broadens receiver-type profiles and + * loads decoy implementations; it does not reproduce a production workload or guarantee a + * particular compiled dispatch shape. + * + *

    + *
  • Receiver-type profiles record the key classes seen at shared collection call sites. {@link + * #warmUpHashDispatch(Blackhole)} exercises those sites with several classes. + *
  • Class-hierarchy analysis uses loaded implementations to resolve virtual calls. Extra decoy + * classes can prevent an assumption that only one implementation exists. + *
  • Branch profiles record which paths execute. A pre-populated, hit-only benchmark may never + * exercise insertion; this helper does not broaden those profiles. + *
+ * + *

{@link Blackhole} prevents unused results from being eliminated, but does not make the + * surrounding type and branch profiles representative of application traffic. + * + *

C2 may replace an unobserved miss branch with an uncommon trap, leaving miss handling outside + * the optimized path. If that branch was the only path retaining a temporary key, escape analysis + * can then eliminate the allocation on hits. {@code ThreadSafeMapD2Benchmark} reports this for a + * guarded {@code computeIfAbsent}; {@code HashtableD2Benchmark} reports a retained wrapper for + * {@code merge}. Different traffic may change those decisions, so application allocation costs + * require representative misses and compiler or allocation profiling. + * + *

A planned follow-up will exercise each benchmark variant and its relevant outcomes in a + * reproducible shuffled order. Compilation can start during setup, so a fixed order may favour + * whichever variants run first. Adopting that setup requires remeasuring affected benchmarks. + */ public final class BenchmarkUtils { private BenchmarkUtils() {} - private static final Object[] DEFAULT_DECOY_KEYS = { - "decoy", 1, 1L, 1.0d, Boolean.TRUE, new Object() - }; + private static final Object[] DECOY_KEYS = {"decoy", 1, 1L, 1.0d, Boolean.TRUE, new Object()}; /** - * Makes the internal {@code hashCode()} and {@code equals()} call sites of common hash-based - * collections megamorphic before measurement. - * - *

HotSpot records receiver classes at each virtual call site. A benchmark using only {@code - * String} keys leaves the sites inside these shared implementations monomorphic, allowing C2 to - * devirtualize and inline them. Production uses many key types, so the same sites are often - * megamorphic and retain virtual dispatch. Exercising several key classes avoids reporting - * unrealistically fast collection lookups. + * Number of pollution passes {@link #warmUpHashDispatch} runs before a trial starts. Driving + * enough calls at {@code Level.Trial} -- entirely before JMH's warmup, let alone measurement, + * starts -- gets HotSpot's tiered compiler through both C1 (tier 3, full profiling; default + * invocation threshold in the hundreds) and C2 (tier 4; default thresholds in the thousands) on + * the shared hash-dispatch call sites while decoy types are still part of their profile, so the + * resulting compiled code keeps a genuine multi-receiver-type guard for the rest of the trial -- + * recompilation of already-C2-compiled code isn't triggered merely by one receiver type going + * quiet, only by hitting an actual uncommon trap or similar deopt event, so it doesn't need + * refreshing once compiled this way. This iteration count was picked by watching {@code + * -XX:+PrintCompilation}/{@code -XX:+PrintInlining} during development and confirming both tiers + * compile well before it's reached, with margin for ambient JVM-wide traffic on these shared JDK + * call sites; it isn't verified at runtime (there's no portable, non-test-only JDK API for "is + * this method at tier N"). */ - public static void polluteHashDispatch() { - polluteHashDispatch(DEFAULT_DECOY_KEYS); + private static final int WARM_UP_ITERATIONS = 50_000; + + /** + * Guards {@link #warmUpHashDispatch} so the passes run once per JVM rather than once per caller. + * Receiver profiles live in the per-method MDO and are JVM-global, so a single pass is sufficient + * no matter how many threads or state classes ask for it. Most benchmarks here wire the call into + * a {@code @State(Scope.Thread)} setup, which JMH runs once per thread -- without this guard, + * {@code @Threads(8)} would perform eight times the pollution work, and the allocation it + * generates before measurement is not free. + */ + private static final AtomicBoolean WARMED_UP = new AtomicBoolean(); + + /** + * Exercises shared collection methods with several key classes before benchmark warmup. + * + *

HotSpot records receiver types at bytecode call sites, not per collection instance. Scratch + * collections therefore contribute to the internal profiles used when compiling benchmark + * lookups. Loading extra classes alone can defeat class-hierarchy analysis (optimization based on + * the loaded implementations), but changing a receiver profile requires method calls. + * + *

{@link HashSet} uses {@link HashMap}'s lookup code, also used by {@code LinkedHashMap}. + * {@link ConcurrentHashMap}, including its key-set views, has separate internal call sites. The + * immutable set and map implementations selected by {@link CollectionUtils} are exercised + * separately too; on older JDKs these helpers fall back to mutable collections. + * + *

{@link #polluteCompareToDispatch(Blackhole)} also exercises natural-order lookups in {@link + * TreeSet}, {@link TreeMap}, and {@link ConcurrentSkipListMap}; {@link + * #polluteComparatorDispatch(Blackhole)} exercises the same three collections' separate {@link + * Comparator}-based call sites. + * + *

The benchmark's own collection call sites are not invoked here and remain free to specialize + * for their receiver types. This broadens the shared internal profiles; it does not guarantee + * that C2 treats them as megamorphic. Receiver counts still matter: C2 can inline both receivers + * of a bimorphic profile, or a dominant receiver behind a type guard, and the benchmark's own + * warmup traffic keeps adding to those counts until the compile happens -- so a single key type + * can come to dominate an internal site this method touched. Only call sites confirmed in the + * compiled benchmark (e.g. via {@code -XX:+PrintInlining} or {@code LogCompilation}) should be + * described as megamorphic. Loading extra classes here also defeats class-hierarchy analysis + * (optimization based on which implementations are actually loaded), and {@link + * #distinctEqualCopy}'s {@code DONT_INLINE} stops the JIT from tracing a decoy key's type back to + * its origin through static inference. + */ + public static void warmUpHashDispatch(Blackhole bh) { + if (!WARMED_UP.compareAndSet(false, true)) { + return; + } + for (int i = 0; i < WARM_UP_ITERATIONS; ++i) { + polluteHashDispatch(bh); + polluteCompareToDispatch(bh); + polluteComparatorDispatch(bh); + } } - public static void polluteHashDispatch(Object... decoyKeys) { - populateTypeProfileMutable(new HashSet<>(), decoyKeys); - populateTypeProfile(CollectionUtils.tryMakeImmutableSet(Arrays.asList(decoyKeys)), decoyKeys); + private static void polluteHashDispatch(Blackhole bh) { + HashSet hashSet = new HashSet<>(); + ConcurrentHashMap concurrentHashMap = new ConcurrentHashMap<>(); + Map mapCopySource = new HashMap<>(); + for (Object key : DECOY_KEYS) { + hashSet.add(distinctEqualCopy(key)); + bh.consume(hashSet.contains(distinctEqualCopy(key))); + + concurrentHashMap.put(distinctEqualCopy(key), key); + bh.consume(concurrentHashMap.get(distinctEqualCopy(key))); + + mapCopySource.put(key, key); + } + bh.consume(hashSet); + bh.consume(concurrentHashMap); + + Set immutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(DECOY_KEYS)); + Map immutableMap = CollectionUtils.tryMakeImmutableMap(mapCopySource); + for (Object key : DECOY_KEYS) { + bh.consume(immutableSet.contains(distinctEqualCopy(key))); + bh.consume(immutableMap.get(distinctEqualCopy(key))); + } + bh.consume(immutableSet); + bh.consume(immutableMap); } /** - * Exercises {@code contains()} with the default decoy keys. + * Exercises natural-order {@code compareTo} calls in {@link TreeSet}, {@link TreeMap}, and {@link + * ConcurrentSkipListMap} with the default decoy keys. * - *

Use the collection implementation under test; the instance itself may be a scratch object. - * HotSpot stores receiver-type profiles at bytecode call sites, so every instance executing that - * implementation contributes to the same internal {@code hashCode()} and {@code equals()} - * profiles. The collection may be mutable or immutable. + *

Each key type uses a separate collection because the decoys are not mutually comparable. The + * instances still execute the same internal call sites and contribute to their receiver profiles. + * {@link #polluteComparatorDispatch(Blackhole)} is the counterpart for the separate call sites + * these collections use when constructed with an explicit {@link java.util.Comparator}. + * + *

Keys that do not implement {@link Comparable}, including the plain {@code Object} decoy, are + * skipped. */ - public static void populateTypeProfile(Collection populated) { - populateTypeProfile(populated, DEFAULT_DECOY_KEYS); + private static void polluteCompareToDispatch(Blackhole bh) { + for (Object key : DECOY_KEYS) { + if (!(key instanceof Comparable)) { + continue; + } + + TreeSet treeSet = new TreeSet<>(); + treeSet.add(key); + bh.consume(treeSet.contains(distinctEqualCopy(key))); + bh.consume(treeSet); + + TreeMap treeMap = new TreeMap<>(); + treeMap.put(key, key); + bh.consume(treeMap.get(distinctEqualCopy(key))); + bh.consume(treeMap); + + ConcurrentSkipListMap skipListMap = new ConcurrentSkipListMap<>(); + skipListMap.put(key, key); + bh.consume(skipListMap.get(distinctEqualCopy(key))); + bh.consume(skipListMap); + } } - public static void populateTypeProfile(Collection populated, Object... decoyKeys) { - for (Object key : decoyKeys) { - populated.contains(key); + /** + * Exercises {@link Comparator}-based {@code compare} calls in {@link TreeSet}, {@link TreeMap}, + * and {@link ConcurrentSkipListMap} with the default decoy keys. + * + *

Constructing these collections with an explicit {@code Comparator} routes lookups through + * internal call sites distinct from the no-arg, natural-ordering constructors {@link + * #polluteCompareToDispatch(Blackhole)} exercises -- pollution there does not carry over here. + * The comparator itself delegates to {@link Comparable#compareTo}, so this pass also keeps that + * method's call site (invoked from inside the comparator, not from the collection directly) warm + * across the same key types. + * + *

Keys that do not implement {@link Comparable}, including the plain {@code Object} decoy, are + * skipped. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void polluteComparatorDispatch(Blackhole bh) { + Comparator comparator = (a, b) -> ((Comparable) a).compareTo(b); + for (Object key : DECOY_KEYS) { + if (!(key instanceof Comparable)) { + continue; + } + + TreeSet treeSet = new TreeSet<>(comparator); + treeSet.add(key); + bh.consume(treeSet.contains(distinctEqualCopy(key))); + bh.consume(treeSet); + + TreeMap treeMap = new TreeMap<>(comparator); + treeMap.put(key, key); + bh.consume(treeMap.get(distinctEqualCopy(key))); + bh.consume(treeMap); + + ConcurrentSkipListMap skipListMap = new ConcurrentSkipListMap<>(comparator); + skipListMap.put(key, key); + bh.consume(skipListMap.get(distinctEqualCopy(key))); + bh.consume(skipListMap); } } - /** Exercises {@code add()} and {@code contains()} on a mutable collection. */ - public static void populateTypeProfileMutable(Collection scratch, Object... decoyKeys) { - for (Object key : decoyKeys) { - scratch.add(key); - scratch.contains(key); + /** + * Returns a distinct instance that's {@code .equals()} to {@code key} but never {@code ==} it, so + * the lookup that follows can't take {@code HashMap}/{@code ConcurrentHashMap}'s internal {@code + * key == storedKey || key.equals(storedKey)} identity fast path and skip calling {@code equals()} + * -- which is exactly the dispatch this class exists to pollute. {@code Object}'s own {@code + * equals()} is identity, so a decoy of that type has no distinct-but-equal instance to make and + * is returned as-is. The lookup then matches on identity: same result, but {@code equals()} is + * never invoked, so the {@code Object} decoy contributes a {@code hashCode()} receiver sample + * (the hash is computed before the identity check) and no {@code equals()} one. + * + *

{@code DONT_INLINE} makes this call boundary an optimization black box: without it, once a + * caller like {@link #polluteHashDispatch} is inlined into a tight loop, the JIT can trace a + * decoy key's concrete type straight back through this method to the literal it originated from + * and devirtualize {@code hashCode()}/{@code equals()} via static type inference alone -- + * bypassing the shared, runtime type profile this class exists to pollute, no matter how many + * distinct instances or types are pushed through it. Keeping this a real, non-inlined call forces + * every caller to go through actual dispatch -- a type-erasing wormhole, the mirror image of + * {@link Blackhole}'s value-erasing one. + * + * @param key the decoy key to copy + * @return a distinct-but-equal copy, or {@code key} itself if it has no distinct-but-equal form + */ + @CompilerControl(CompilerControl.Mode.DONT_INLINE) + @SuppressWarnings({ + "deprecation", + "removal" + }) // boxed-type constructors: only way to force a non-cached instance + private static Object distinctEqualCopy(Object key) { + if (key instanceof String) { + return new String((String) key); + } else if (key instanceof Integer) { + return new Integer((Integer) key); + } else if (key instanceof Long) { + return new Long((Long) key); + } else if (key instanceof Double) { + return new Double((Double) key); + } else if (key instanceof Boolean) { + return new Boolean((Boolean) key); + } else { + return key; } } } 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 27ffba35fd5..6d9e695d6da 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/CaseInsensitiveMapBenchmark.java @@ -1,13 +1,15 @@ package datadog.trace.util; import java.util.HashMap; +import java.util.Random; import java.util.TreeMap; -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.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; @@ -22,38 +24,45 @@ * allocation-free (case folded inside hash/matches), value stored unboxed * * - *

Takeaways. FlatHashtable is ~2x the (previously recommended) TreeMap at the same zero - * allocation, and matches HashMap's look-up throughput without HashMap's per-look-up folded - * String (which drives the multi-threaded GC pressure). The case-insensitive hash is the + *

Takeaways. FlatHashtable is ~1.8-2x the (previously recommended) TreeMap at the same + * zero allocation, but trails HashMap's look-up throughput by ~20-30%; its case is the allocation + * win (no per-look-up folded String), not a throughput win. The case-insensitive hash is the * consistent-for-all-inputs two-way fold ({@link * datadog.trace.util.Strings#caseInsensitiveHashCode} — see its note); a cheaper ASCII-only fold * would recover a few percent for header-name-only hot paths, deliberately not the default. {@code - * LOW_LOAD_FACTOR} makes no difference here (the fold, not the probe count, dominates), so the - * default 0.5 is used. + * LOW_LOAD_FACTOR} makes no consistent difference here, so the default 0.5 is used. * - *

Numbers below: MacBook M1, Zulu 21, per-thread lookup index, @Fork(5). - * 1 thread + *

Java 17 results (Zulu 17.0.7, MacBook M1, {@code @Fork(5)}, {@code @Threads(8)}, {@code -prof + * gc}) with the front-loaded {@link BenchmarkUtils#warmUpHashDispatch} pollution design and the + * {@code CHA_DEFEAT} decoys: * - * Benchmark Mode Cnt Score Error Units - * create_flatHashtable thrpt 15 2158595.7 ± 73576.7 ops/s - * create_hashMap thrpt 15 944890.9 ± 34398.7 ops/s - * create_treeMap thrpt 15 1285085.3 ± 133648.6 ops/s + *

{@code
+ * Benchmark                      M ops/s           B/op
+ * create_baseline                  25.5 ±   0.4    1152
+ * create_flatHashtable             14.4 ±   0.7    1693
+ * create_hashMap                    7.5 ±   0.3    2320
+ * create_treeMap                    7.9 ±   1.0    1840
  *
- * lookup_flatHashtable                         thrpt   15    75350287.4 ±  4128577.5  ops/s
- * lookup_flatHashtable_lowLoad                 thrpt   15    77127204.7 ±  2546322.0  ops/s
- * lookup_hashMap                               thrpt   15    76615721.1 ±  4615488.0  ops/s
- * lookup_treeMap                               thrpt   15    45777645.5 ±  4551223.1  ops/s
- *  
- * 8 threads (with -prof gc; alloc = gc.alloc.rate.norm)
+ * lookup_baseline                2772.0 ±  23.5      ~0
+ * lookup_flatHashtable            385.1 ±  51.5      ~0
+ * lookup_flatHashtable_lowLoad    425.5 ±   6.0      ~0
+ * lookup_hashMap                  502.4 ±   7.9    22.5
+ * lookup_treeMap                  214.6 ±  11.8      ~0
+ * }
* - * Benchmark Mode Cnt Score Error Units alloc - * lookup_flatHashtable thrpt 15 537007985.7 ± 21864181.3 ops/s ~0 B/op - * lookup_flatHashtable_lowLoad thrpt 15 540434673.5 ± 20451984.4 ops/s ~0 B/op - * 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 - * + *

Every arm cycles through the same fixed-seed {@code LOOKUP_KEYS} sequence, so all of them see + * the same upper/lower-case and hit/miss mix. {@code lookup_hashMap} is steady across all five + * forks (483-509 per iteration). {@code lookup_flatHashtable}'s wide interval comes from one fork + * that compiled slow (~295 throughout, against ~380-420 for the other four); excluding it, the gap + * to {@code hashMap} is ~20%, and including it ~30%. Either way HashMap leads on throughput. + * + *

{@code lookup_hashMap} is the only lookup arm that allocates: 22.5 B/op for the folded + * {@code String}, which is the allocation win claimed above. {@code toLowerCase()} returns {@code + * this} when a string is already lower-case, so the figure reflects this key set's upper-case share + * rather than a fixed per-lookup cost; with the seeded keys it is deterministic (±0.001), where an + * earlier unseeded run varied ±4.6. {@code TreeMap} remains the slowest look-up. */ -@Fork(2) +@Fork(5) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) @@ -77,10 +86,13 @@ static T init(Supplier supplier) { return upperPrefixes; }); + // Fixed seed so every arm, in every fork, sees the same case and hit/miss mix -- the upper-case + // share drives toLowerCase() allocation and suffix NUM_SUFFIXES is a miss, so an unseeded draw + // would give each arm a different workload. static final String[] LOOKUP_KEYS = init( () -> { - ThreadLocalRandom curRandom = ThreadLocalRandom.current(); + Random curRandom = new Random(42); String[] keys = new String[32]; for (int i = 0; i < keys.length; ++i) { @@ -101,6 +113,14 @@ static T init(Supplier supplier) { // masking exactly the differences this benchmark compares. int lookupIndex = 0; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + 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..19b35f862d3 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,18 @@ *

  • 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). D1 mutates a primitive counter in the existing + * entry; the HashMap path boxes a {@code Long} on every {@code merge}. Measured with {@code -prof + * gc} on Zulu 17, {@code update_hashMap} allocates 24.000 ± 0.001 B/op — exactly one boxed {@code + * Long} (12-byte header plus an 8-byte value, aligned to 24) — against ≈0 B/op for {@code + * update_hashtable}, and 801 collections over the run against none. The GC pressure is measured + * rather than inferred from throughput. 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, and the error bars exceed + * the means, so no precise comparison is possible there. 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 @@ -54,6 +62,81 @@ * 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#warmUpHashDispatch} 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}. 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 warmUpHashDispatch} + * cannot reach them regardless of key-type overlap. It is equally a no-op for {@code *_hashMap}: + * the keys come from {@code SOURCE_KEYS}, a {@code String[]}, and {@code String} is final, so C2 + * sharpens the {@code Object}-declared key to an exact type and devirtualizes {@code + * hashCode()}/{@code equals()} without consulting the polluted profile. Pollution therefore cannot + * explain a drop on either side. The JDK and machine were held constant, so what remains is + * uncontrolled run-to-run variation. + * + *

    The other candidate is that {@code warmUpHashDispatch}'s own heavy pre-measurement allocation + * shifts GC state for the trial. Pollution used to run once per thread, so at {@code @Threads(8)} + * it did eight times the work; it now runs once per JVM. Re-measuring across that 8x reduction + * moved nothing: {@code update_hashMap} 686.0 to 689.6, {@code update_hashtable} 2770.7 to 2796.3, + * {@code iterate_hashMap} 19.83 to 19.84, all well inside their intervals. So the amount of + * setup allocation isn't a factor at this scale; whether a single pass affects the trial at all was + * not tested, since both runs include one. The relative conclusion (D1 dominates {@code + * update}, is roughly comparable on {@code add}, ties on {@code iterate}) is unchanged throughout. + * + *

    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). ops/us, 8 threads: + * + *

    {@code
    + * Benchmark            ops/us            B/op   gc.count
    + * add_hashMap        1658.6 ± 190.5      32.0       1839
    + * add_hashtable      1358.0 ± 213.6      40.0       1710
    + * update_hashMap      689.6 ± 140.8      24.0        801
    + * update_hashtable   2796.3 ±  79.7       ~0          ~0
    + * iterate_hashMap      19.8 ±   0.3      40.0         76
    + * iterate_hashtable    81.5 ±   2.5       ~0          ~0
    + * }
    + * + *

    Allocation is measured with {@code -prof gc} and decomposes exactly: 24 B/op for {@code + * update_hashMap} is one boxed {@code Long}; 32 B/op for {@code add_hashMap} is one {@code + * HashMap.Node}, with no box because {@code (long) i} for {@code i < 128} hits the {@code + * Long.valueOf} cache; 40 B/op for {@code add_hashtable} is the {@code D1Counter} entry. The + * hashtable's {@code update} and {@code iterate} paths allocate nothing at all, which is the point + * of the design. + * + *

    Within this run, {@code update_hashtable} wins by ~4.0x and {@code iterate_hashtable} by + * ~4.1x. {@code add_hashMap} leads on the means (1658.6 vs 1358.0), but the error bars overlap, so + * treat {@code add} as undecided rather than a HashMap win. + * + *

    The {@code update} ratio depends heavily on the collector, so it is not a fixed property of + * the two structures. A controlled follow-up on the same Zulu 17 build, with the Blackhole mode + * forced to {@code FULL_DONTINLINE} for every configuration (separate invocations, so allow ~20% + * run-to-run spread): + * + *

    {@code
    + * Configuration              update_hashMap   update_hashtable   ratio
    + * G1 (default)                522.4 ±  42.1     1408.0 ±  90.0    2.7x
    + * G1, -XX:-EliminateAutoBox   536.5 ±  24.5     1689.6 ±  46.2    3.1x
    + * -XX:+UseParallelGC          129.2 ±  36.8     1634.2 ±  19.3   12.6x
    + * }
    + * + *

    Disabling autobox elimination changes nothing for {@code update_hashMap} (still 24 B/op): the + * {@code Long} escapes into the map, so there is no box to eliminate. Switching to ParallelGC cuts + * {@code update_hashMap} ~4x while leaving the allocation-free {@code update_hashtable} where it + * was, so the collector governs how expensive the per-update box is. The mechanism isn't isolated: + * total {@code gc.time} is similar under both collectors (1106 vs 1226 ms) despite ParallelGC + * running ~3x as many collections, so reported pause time alone doesn't account for the gap. The + * forced Blackhole mode also compresses the ratios relative to the table above (2.7x vs 4.0x on + * {@code update}, 1.5x vs 4.1x on {@code iterate}), since every arm pays a non-inlined call per + * {@code consume}; the default-mode table is the one to quote. + * + *

    Net takeaway: {@code Hashtable} is a strong substitute for {@code HashMap} for simple + * counter/tally use cases with a primitive value. Avoiding the per-update boxing allocation wins + * under G1, and wins by a much larger margin on a throughput collector. */ @Fork(2) @Warmup(iterations = 2) @@ -101,6 +184,14 @@ public static class D1State { int cursor; final BhD1Consumer consumer = new BhD1Consumer(); + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + @Setup(Level.Iteration) public void setUp() { table = new Hashtable.D1<>(CAPACITY); 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..52042fcbce3 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,27 @@ *

    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. + * Measured with {@code -prof gc} on Zulu 17, {@code update_hashMap} allocates 48.000 ± 0.001 B/op, + * which decomposes exactly: 24 for the boxed {@code Long} and 24 for the {@code Key2} wrapper + * (12-byte header, two references, one {@code int}). {@code update_hashtable} allocates ≈0 B/op. + * Collections over the run were 890 against none. + * + *

    That 48 also answers a common assumption: escape analysis does not eliminate the + * temporary {@code Key2}, even though every key in this benchmark is already present and the + * wrapper is discarded immediately. C2 assigns one escape state per allocation site rather than per + * path, and {@code merge} stores the key into a {@code Node} on the absent branch, so the + * allocation is GlobalEscape for the whole compiled method. Eliminating it would require + * specializing the present and absent cases separately, which HotSpot does not do. (The wrapper's + * hash uses {@link HashingUtils#hash(Object, Object)} rather than {@code Objects.hash}, whose + * varargs array would otherwise add a third 24-byte allocation per lookup and handicap the + * baseline.) + * + *

    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 @@ -58,6 +75,72 @@ * 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#warmUpHashDispatch} 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). 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. + * It is equally a no-op for {@code *_hashMap}: {@code Key2} is a final class and every lookup key + * is built at the call site with {@code new Key2(...)}, so C2 has an exact type either way and + * devirtualizes {@code hashCode()}/{@code equals()} without consulting the polluted profile. + * Pollution therefore cannot explain a move on either side. With the JDK and machine held constant, + * what remains is uncontrolled run-to-run variation. The comparison in {@link HashtableD1Benchmark} + * reduced setup from eight passes to one without a detectable throughput change. Both runs still + * included a pass, so its effect on GC state or throughput remains untested. Treat these two runs + * as not directly comparable on absolute numbers. The relative conclusion (D2 dominates + * {@code update}, leads {@code add} on JDK 8 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. + * ops/us, 8 threads: + * + *

    {@code
    + * Benchmark            ops/us            B/op   gc.count
    + * add_hashMap         987.3 ± 322.9      56.0       1797
    + * add_hashtable      1163.6 ± 143.5      40.0       1709
    + * update_hashMap      427.9 ±  64.0      48.0        890
    + * update_hashtable   2346.8 ±  31.7       ~0          ~0
    + * iterate_hashMap      19.8 ±   0.5      40.0         76
    + * iterate_hashtable    71.7 ±   2.1       ~0          ~0
    + * }
    + * + *

    These numbers postdate the switch from {@code Objects.hash} to {@link + * HashingUtils#hash(Object, Object)} in {@code Key2}, which removed a varargs {@code Object[]} per + * key construction. Earlier tables in this file predate it and had the HashMap baseline carrying + * that extra 24 B/op. + * + *

    Allocation decomposes exactly. {@code add_hashMap} at 56 B/op is a {@code Key2} (24) plus a + * {@code HashMap.Node} (32), with no box because {@code (long) i} for {@code i < 128} hits the + * {@code Long.valueOf} cache. {@code update_hashMap} at 48 B/op is a boxed {@code Long} (24) plus + * the {@code Key2} (24) — see the escape-analysis note above. Both hashtable paths that avoid the + * wrapper allocate nothing on {@code update} and {@code iterate}. + * + *

    {@code update_hashtable} wins by ~5.5x — down from ~26x on JDK 8, and also down from the + * ~11.6x this file previously reported. The difference is the {@code Objects.hash} fix: removing + * that varargs {@code Object[]} took 24 B/op off {@code update_hashMap} and roughly doubled its + * throughput (196.7 to 427.9). {@code iterate_hashtable} wins ~3.6x, flipping 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 a tie (1163.6 vs 987.3; the hashtable leads on the means but {@code + * add_hashMap}'s interval is ±322.9, so the two overlap), where this file previously claimed a + * ~1.8x hashtable win. That claim was an artifact of the varargs allocation in the old {@code Key2} + * constructor; with it gone the two are indistinguishable on the insert path, which makes sense — + * both allocate one entry per insert. + * + *

    Net takeaway, consistent with {@link HashtableD1Benchmark}: {@code Hashtable} is a strong + * substitute for {@code HashMap} for counter/tally use cases with a primitive value, where avoiding + * the per-update boxing pays off even on a JVM with much better allocation handling than JDK 8 had. + * For D2 specifically, avoiding the composite-key wrapper pays off on {@code update} and {@code + * iterate} — but not on {@code add}, contrary to what this file said before the baseline was fixed. */ @Fork(2) @Warmup(iterations = 2) @@ -97,7 +180,7 @@ static final class Key2 { Key2(String k1, Integer k2) { this.k1 = k1; this.k2 = k2; - this.hash = Objects.hash(k1, k2); + this.hash = HashingUtils.hash(k1, k2); } @Override @@ -136,6 +219,14 @@ public static class D2State { int cursor; final BhD2Consumer consumer = new BhD2Consumer(); + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + @Setup(Level.Iteration) public void setUp() { table = new Hashtable.D2<>(CAPACITY); 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 7f21e08656f..0037de6abd2 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -44,38 +44,49 @@ * {@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. * - *

    {@link BenchmarkUtils#polluteHashDispatch()} prepares shared {@code hashCode()} and {@code + *

    {@link BenchmarkUtils#warmUpHashDispatch} prepares shared {@code hashCode()} and {@code * equals()} call sites before measurement. This avoids giving hash-based structures an * unrealistically monomorphic type profile. * *

    A virtual call site is monomorphic when it has observed one receiver class, * polymorphic when it has observed a small set, and megamorphic when no small, stable - * set dominates. HotSpot can usually devirtualize and inline the monomorphic case, sometimes a - * small polymorphic one; megamorphic sites generally retain virtual dispatch. + * set dominates. HotSpot keeps one such profile per bytecode index, shared across every inlining + * context, and consults it as a fallback: the profile says what to speculate when the receiver type + * cannot be deduced. A megamorphic profile therefore does not always imply virtual dispatch in + * compiled code. Wherever a particular context yields a proof of the receiver type — a final class, + * an exact type from an allocation or a constant, or an argument sharpened by inlining — C2 + * devirtualizes and inlines regardless of how polluted the profile is. Pollution only reaches the + * contexts that have no such proof. * - *

    Results on an Apple M1 with Java 8u382, {@code @Fork(5)}, and {@code @Threads(8)} (M ops/s): + *

    Java 17 results on an Apple M1 with the front-loaded {@link BenchmarkUtils#warmUpHashDispatch} + * pollution design, {@code @Fork(5)}, {@code @Threads(8)} (M ops/s): * *

    {@code
      * 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       -        -
    + * hashMap                1240.9  1827.4  109.95        -
    + * linkedHashMap          1259.5     -    139.86        -
    + * treeMap                 665.0     -    139.49        -
    + * tagMap                 1200.3  1420.9  100.66     146.69
    + * tracerImmutableMap     1067.3  1395.9  137.93        -   (MapN)
    + * stringIndex             1452.4  1864.8       -        -
    + * stringIndex_embedded    1605.2  2063.5       -        -
      * }
    * *

    In this run: * *

      - *
    • The embedded StringIndex has the fastest {@code get}; the instance wrapper is second. - *
    • Both StringIndex variants outperform the map-based alternatives for distinct and identical - * key instances. - *
    • {@code TreeMap.get} varies widely across forks (roughly 230-610 M ops/s), so its mean is - * less stable than the other results. - *
    • {@code TagMap.forEach} is about 10% faster than its iterator (121 vs. 109 M ops/s). Its - * advantage widens as TagMap's entry model grows. + *
    • The embedded StringIndex has the fastest {@code get}; the instance wrapper is second — both + * StringIndex variants outperform the map-based alternatives for distinct and identical key + * instances. + *
    • {@code stringIndex} is also slightly better than {@code hashMap} when used as an immutable + * map (1452.4 vs 1240.9 on {@code get}). + *
    • {@code TagMap.forEach} is about 46% faster than its iterator (146.69 vs. 100.66 M ops/s), + * and {@code -prof gc} supplies the mechanism: {@code iterate_tagMap} allocates 40 B/op while + * {@code iterate_tagMap_forEach} allocates ≈0. {@code iterate_hashMap} is also 40 B/op, + * whereas {@code iterate_linkedHashMap}, {@code iterate_treeMap} and {@code + * iterate_tracerImmutableMap} allocate nothing. Every {@code get} arm is allocation-free, + * including the distinct-key variants. Allocation was profiled in a later run than the + * throughput table above, so the two are not a matched pair on absolute numbers. *
    */ // @Fork(5): get_tracerImmutableMap* (MapN reached via interface dispatch) is JIT-bimodal at fewer @@ -139,8 +150,6 @@ static void fill(Map map) { @Setup(Level.Trial) public void setUp() { - BenchmarkUtils.polluteHashDispatch(); - hashMap = new HashMap<>(); fill(hashMap); linkedHashMap = new LinkedHashMap<>(); @@ -161,6 +170,14 @@ public void setUp() { public static class Cursor { int index = 0; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + String nextKey() { return nextKey(EQUAL_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 b0977dc5ecb..e6ef49b7eff 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -13,6 +13,7 @@ import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; /** * Membership over a small, fixed, read-only string set shared across threads — split into hit and @@ -47,50 +48,43 @@ *

    Lookup variants: * *

      - *
    • {@code hit} uses the same interned strings that were inserted, exercising the identity fast - * path. - *
    • {@code hitFresh} uses equal, non-interned strings, avoiding the identity fast path. It is - * measured only for the hash-based structures. - *
    • {@code miss} uses non-interned strings that are not in the set. + *
    • {@code hit} reuses the inserted interned strings, exercising identity fast paths where + * available. + *
    • {@code hitFresh} reuses equal, non-interned copies created before measurement, avoiding + * identity matches without allocating per lookup; only hash-based structures have this case. + *
    • {@code miss} reuses non-interned strings that are not in the set. *
    * - *

    Results on an Apple M1 with Java 8u382, {@link BenchmarkUtils#polluteHashDispatch()} enabled, - * {@code @Fork(5)}, and {@code @Threads(8)} (M ops/s): + *

    For hash-based lookups, warmup populates the reused strings' cached hashes; these cases do not + * measure repeated string hashing from characters. + * + *

    Java 17 results on an Apple M1 with the front-loaded {@link BenchmarkUtils#warmUpHashDispatch} + * pollution design, {@code @Fork(5)}, {@code @Threads(8)} (M ops/s): * *

    {@code
      * Structure                    hit   hitFresh    miss
    - * stringIndex_embedded (static) 2098      1563    2030
    - * hashSet                       1723      1276    1823
    - * stringIndex (inst)            1883      1184 *  1700 *
    - * tracerImmutableSet            1632      1232    1625    (SetN)
    - * array                          854         -     495
    - * sortedArray                    713         -     613
    - * treeSet                        646         -     544
    + * stringIndex_embedded        2231.9   1663.2   2207.7
    + * hashSet                     2172.8   1359.2   2252.9
    + * tracerImmutableSet          2045.4   1394.9   1711.8
    + * stringIndex (inst)          2037.6   1505.7   2060.7
    + * array                        967.4        -    611.0
    + * sortedArray                  691.4        -    598.6
    + * treeSet                      657.2        -    607.2
      * }
    * *

    In this run: * *

      - *
    • The embedded {@code StringIndex} is fastest for all three lookup variants. - *
    • The {@code StringIndex} wrapper beats {@code HashSet} for interned hits. Its fresh-hit and - * miss results are bimodal and have lower means than {@code HashSet}; prefer the embedded - * form when these paths matter. - *
    • {@code SetN} is slower than the embedded form but about 27% smaller. StringIndex trades - * that space for speed and support for slot-aligned payload arrays. - *
    • Fresh hits are slower than misses for each hash-based structure: a matching distinct string - * reaches {@code equals()}, while a miss can stop on a hash mismatch. + *
    • {@code stringIndex} is slightly better than {@code hashSet} on {@code hitFresh}, but not + * uniformly ahead across all three lookup variants the way earlier runs suggested; the two + * trade the lead depending on the variant. + *
    • The embedded {@code StringIndex} form remains at or near the front for all three lookup + * variants, and is now about as fast as the instance wrapper rather than clearly ahead of it. + *
    • {@code tracerImmutableSet} ({@code SetN}) is competitive with the hash-based structures on + * {@code hit}/{@code hitFresh} but falls behind on {@code miss}. *
    - * - *

    Caveat — the instance {@code stringIndex} miss is bimodal across forks (confirmed at - * {@code @Fork(10)}: 6 forks fast, 4 slow, nothing between). ~60% of forks compile to a fast mode - * (~2000, ≈ {@code stringIndex_embedded_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 - * EmbeddingSupport} path const-folds those refs and is never bimodal ({@code - * stringIndex_embedded_miss} ±0.3%). Prefer {@code EmbeddingSupport} where miss latency matters. */ -@Fork(5) // 5 forks settle the bimodal stringIndex_miss / interface-dispatch arms (see header) +@Fork(5) // extra forks needed historically to settle bimodal JIT behavior on some arms @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) @@ -146,8 +140,6 @@ static String[] newMisses() { @Setup(Level.Trial) public void setUp() { - BenchmarkUtils.polluteHashDispatch(); - array = STRINGS; sortedArray = Arrays.copyOf(STRINGS, STRINGS.length); Arrays.sort(sortedArray); @@ -164,6 +156,14 @@ public static class Cursor { int hitFreshIndex = 0; int missIndex = 0; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + String nextHit() { int i = hitIndex + 1; if (i >= STRINGS.length) { 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..bd3c0e83cc6 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,42 @@ * 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.) + * + *

    Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}) with the front-loaded {@link + * BenchmarkUtils#warmUpHashDispatch} pollution design (M ops/s): + * + *

    {@code
    + * create_flatHashtable          183.4   create_hashMap                 104.7
    + * create_hashMap_sized          110.3   create_linkedHashMap            57.4
    + * create_synchronizedHashMap     52.0   create_tagMap                  103.6
    + * create_tagMap_via_ledger       76.2   create_treeMap                  34.6
    + *
    + * clone_tagMap                  301.8   clone_treeMap                  101.5
    + * clone_hashMap                  61.2   clone_synchronizedHashMap       54.9
    + * clone_linkedHashMap            50.8   (clone B/op: tagMap 208, hashMap 488,
    + *                                        linkedHashMap 536, treeMap 480)
    + *
    + * get_flatHashtable             1583.7  get_hashMap                    1352.1
    + * get_synchronizedHashMap        843.2
    + *
    + * iterate_flatHashtable          182.6  iterate_hashMap                 106.3
    + * iterate_synchronizedHashMap     93.2
    + * }
    + * + *

    Key findings: + * + *

      + *
    • {@code flatHashtable} dominates {@code create}, {@code get}, and {@code iterate} — the + * unboxed, self-contained entry and comparison-free insert pay off, consistent with every + * other FlatHashtable comparison in this module. + *
    • {@code tagMap} clone (301.8M) is ~4.9x {@code hashMap} clone (61.2M) — the same story + * {@link datadog.trace.api.TagMapAccessBenchmark} reports, by design ({@code TagMap} clone is + * a purpose-built fast path). + *
    • The uncontended synchronization tax is visible even with no contention, consistent with + * Java 15+'s biased locking being disabled by default (JEP 374): {@code get_hashMap} + * (1352.1M) → {@code get_synchronizedHashMap} (843.2M) is a ~38% hit, and {@code iterate} + * (106.3M → 93.2M) is ~12%. + *
    */ @Fork(2) @Warmup(iterations = 2) @@ -189,8 +225,13 @@ static IntEntry[] newFilledFlat() { IntEntry[] flatTable; int index = 0; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. @Setup(Level.Trial) - public void setUp() { + public void setUp(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + 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..c1ba965702d 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java @@ -30,31 +30,31 @@ * 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. * - *

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

    Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}) with the front-loaded {@link + * BenchmarkUtils#warmUpHashDispatch} pollution design (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)
    + * contains_hashSet            1376.4
    + * contains_synchronizedSet     814.5    (~41% slower — the uncontended sync tax)
    + * iterate_hashSet               94.6
    + * iterate_synchronizedSet       91.3    (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
    + * create_hashSet          94.4   clone_hashSet          48.4
    + * create_hashSet_sized    95.9   clone_synchronizedSet  44.0
    + * create_linkedHashSet    41.3   clone_linkedHashSet    42.7
    + * create_synchronizedSet  41.7   clone_treeSet          86.1
    + * create_treeSet          32.7
      * }
    * *

    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: {@code contains} is ~41% + * slower synchronized (1376.4 → 814.5M ops/s), consistent with Java 15+'s biased locking + * being disabled by default (JEP 374). {@code iterate}'s tax stays small (~3.5%): one monitor + * acquire amortized over the walk. + *
    • {@code TreeSet} stays the slowest structure to build but is, notably, the fastest to clone + * (86.1M) — worth a closer look if that gap turns out to matter elsewhere. *
    */ @Fork(2) @@ -92,8 +92,13 @@ static void fill(Set set) { LinkedHashSet linkedHashSet; int index = 0; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. @Setup(Level.Trial) - public void setUp() { + public void setUp(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + 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/ThreadSafeMapCounterBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java index bc8bc07b9f5..0449d30c54d 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapCounterBenchmark.java @@ -18,6 +18,7 @@ import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; /** * Measures lookup followed by an atomic counter increment in a shared, pre-populated table. Models @@ -53,6 +54,28 @@ * embedding the counter directly in the entry — one object instead of two, with no throughput * penalty. * + * + *

    Rerun with {@link BenchmarkUtils#warmUpHashDispatch} wired into {@code SharedState.setUp()}, + * same JDK 17 and machine as the table above. Same-JDK removes one variable, but separate JMH + * invocations aren't a controlled A/B -- forks of a single benchmark method run back-to-back, while + * the two tables here come from separate {@code ./gradlew jmh} invocations, so a systematic + * difference between them (thermal state, background load, where the JIT happened to land) isn't + * distinguishable from a pollution effect. Pollution itself is best-effort -- it raises the odds a + * shared call site is megamorphic going into measurement, not a guarantee -- so treat this rerun as + * illustrative, not as an isolated measurement of the pollution mechanism's effect: + * + *

    {@code
    + * Benchmark                          Score   Units
    + * increment_longAdder                  205   ops/us
    + * increment_atomicLong                  72   ops/us
    + * increment_concurrentHashtable         68   ops/us
    + * }
    + * + *

    {@code ConcurrentHashtable} and {@code AtomicLong} are still within 6% of each other (68 vs 72 + * ops/us), unchanged from above. {@code LongAdder}'s score jumped to 205 ops/us, but its error bar + * ({@code ±429}) is more than double its own mean -- unusable at this fork count, and not evidence + * of a real pollution effect. It equally cannot confirm the earlier within-15% comparison: that + * finding rests on the first table's own data, and this run neither supports nor refutes it. */ @Fork(2) @Warmup(iterations = 2) @@ -98,6 +121,14 @@ public static class SharedState { ConcurrentHashMap atomicLongMap; ConcurrentHashMap longAdderMap; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + @Setup(Level.Iteration) public void setUp() { table = ConcurrentHashtable.D1.createBounded(CounterEntry.class, CAPACITY); diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java index 43d9b849f48..46f4b65b0a3 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java @@ -19,6 +19,7 @@ import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; /** * Measures steady-state single-key lookups in a shared, pre-populated table. @@ -36,30 +37,37 @@ * its {@code _sameKey} vs default variants). See {@link ThreadSafeMapD2Benchmark} for composite * keys. * - *

    Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + *

    Java 17 results with the front-loaded {@link BenchmarkUtils#warmUpHashDispatch} pollution + * design ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys; ops/us): * *

    {@code
    - * Benchmark                             Score   Units
    - * get_concurrentHashtable               1583   ops/us
    - * get_concurrentHashMap                 1145   ops/us
    - * get_concurrentSkipListMap              170   ops/us
    - * get_synchronizedHashMap                 33   ops/us
    + * Benchmark                              ops/us          B/op
    + * get_concurrentHashtable            2647.5 ±  74.6        ~0
    + * get_concurrentHashMap              1837.1 ±  44.0        ~0
    + * get_concurrentSkipListMap           203.4 ±  36.3        ~0
    + * get_synchronizedHashMap               9.3 ±   0.4        ~0
      *
    - * getOrCreate_concurrentHashtable       1450   ops/us
    - * getOrCreate_concurrentHashMap         1125   ops/us
    - * getOrCreate_synchronizedHashMap         31   ops/us
    + * getOrCreate_concurrentHashtable    2589.7 ±  60.9        ~0
    + * getOrCreate_concurrentHashMap      1842.2 ±  33.1        ~0
    + * getOrCreate_synchronizedHashMap       9.3 ±   0.8        ~0
      * }
    * + *

    Allocation is measured with {@code -prof gc}. Every arm is allocation-free: the keys are + * pre-installed {@code String}s reused on each lookup, so nothing is constructed per operation. + * *

    Key findings: * *

      - *
    • {@code ConcurrentHashtable} is ~38% faster than {@code ConcurrentHashMap} on {@code get} - * (1583 vs 1145 ops/us); avoids the hash-to-segment translation CHM pays even on its fast - * path. + *
    • {@code ConcurrentHashtable} is ~44% faster than {@code ConcurrentHashMap} on {@code get} + * (2647.5 vs 1837.1 ops/us). *
    • {@code ConcurrentSkipListMap} is ~9× slower than {@code ConcurrentHashMap} — tree traversal - * cost is high even under lock-free CAS. - *
    • Synchronized {@code HashMap} is ~47× slower than {@code ConcurrentHashtable}; the global - * lock serializes all 8 threads. + * cost is high even under lock-free CAS. Its error bar is wide (±36.3 on a 203.4 mean), so + * treat that multiple as approximate. + *
    • Synchronized {@code HashMap} is roughly 280× slower than {@code ConcurrentHashtable} (9.3 + * vs 2647.5 ops/us) — lock contention across eight threads on a single monitor, which is what + * this benchmark isolates. Type-profile pollution is not a factor: the keys are {@code + * String}, a final class, so these lookups devirtualize by exact type and never consult the + * polluted profile. *
    • {@code getOrCreate} is near-identical to {@code get} because all keys are pre-populated — * the lock branch is never taken during measurement. *
    @@ -122,6 +130,14 @@ public void setUp() { public static class ThreadState { int cursor; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + int next() { int i = cursor; cursor = (i + 1) & (N_KEYS - 1); diff --git a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java index 57506a12230..0739e487627 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java @@ -20,6 +20,7 @@ import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.Threads; import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; /** * Measures steady-state composite-key lookups in a shared, pre-populated table. @@ -39,37 +40,62 @@ *

    Lookups reuse the key-part instances installed during setup, taking the identity fast path for * their object comparisons. See {@link ThreadSafeMapD1Benchmark} for single-key lookups. * - *

    Java 17 results ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys): + *

    Java 17 results with the front-loaded {@link BenchmarkUtils#warmUpHashDispatch} pollution + * design ({@code @Fork(2)}, {@code @Threads(8)}, 64 pre-populated keys; ops/us): * *

    {@code
    - * Benchmark                              Score   Units
    - * get_concurrentHashtable                1452   ops/us
    - * get_support                            1450   ops/us
    - * get_concurrentHashMap                   777   ops/us
    - * get_concurrentSkipListMap               146   ops/us
    - * get_synchronizedHashMap                  27   ops/us
    + * Benchmark                               ops/us          B/op
    + * get_support                         2731.5 ±  48.2        ~0
    + * get_concurrentHashtable             2545.5 ± 322.8        ~0
    + * get_concurrentHashMap               1668.2 ±  34.2        ~0
    + * get_concurrentSkipListMap            182.2 ±  43.8        ~0
    + * get_synchronizedHashMap                9.4 ±   0.5        ~0
      *
    - * getOrCreate_support                    1379   ops/us
    - * getOrCreate_concurrentHashtable        1119   ops/us
    - * getOrCreate_concurrentHashMap           769   ops/us
    - * getOrCreate_concurrentSkipListMap       151   ops/us
    - * getOrCreate_synchronizedHashMap          28   ops/us
    + * getOrCreate_support                 2497.8 ±  73.0        ~0
    + * getOrCreate_concurrentHashMap       1655.5 ±  62.4        ~0
    + * getOrCreate_concurrentHashtable     1397.3 ±  72.7        ~0
    + * getOrCreate_concurrentSkipListMap    178.2 ±  31.7        ~0
    + * getOrCreate_synchronizedHashMap        8.7 ±   2.4      12.0
      * }
    * + *

    The {@link Key2} wrapper is not allocated in the measured code. Every map arm reports + * ≈0 B/op under {@code -prof gc}, despite the source constructing a {@code Key2} per lookup. The + * lone exception is {@code getOrCreate_synchronizedHashMap} at 12 B/op, whose throughput interval + * (±2.4 on an 8.7 mean) is too wide to read anything into; not investigated. LogCompilation + * confirms the mechanism: C2 emits {@code eliminate_allocation} for {@code Key2}, because the + * never-taken {@code computeIfAbsent} branch is pruned as {@code unstable_if}, which removes the + * only store of the key and leaves it provably non-escaping. + * + *

    That is a property of this workload, not of the code, and it would not survive in production. + * Pruning is possible only because {@code @Setup} installs every key before warmup, so the absent + * branch is never recorded. A real cache records its population-phase misses in the same branch + * profile — MDO counters accumulate from interpretation onward and are never reset — giving a + * two-sided profile, no pruning, and a {@code Key2} allocated on every lookup. Treat the {@code + * ConcurrentHashMap} and {@code ConcurrentSkipListMap} numbers here as an upper bound that a + * production miss rate would erode. See {@code HashtableD2Benchmark}, where the same wrapper is + * not eliminated because {@code merge} keeps the present/absent decision inside the callee, + * leaving no caller-visible branch to prune. + * *

    Key findings: * *

      - *
    • {@code ConcurrentHashtable} and {@code Support} are neck-and-neck on {@code get} (1452 vs - * 1450 ops/us); both avoid the {@link Key2} wrapper allocation that {@code ConcurrentHashMap} - * requires on every lookup. - *
    • {@code ConcurrentHashMap} is ~2× slower than {@code ConcurrentHashtable} on {@code get} - * (777 vs 1452 ops/us) — the {@link Key2} allocation plus two-level hash lookup adds up. - *
    • {@code Support} shows slightly higher {@code getOrCreate} throughput than {@code D2} (1379 - * vs 1119 ops/us) because its primitive {@code int} K2 field avoids boxing inside the entry - * match on the write-path re-check. - *
    • {@code ConcurrentSkipListMap} is ~5× slower than {@code ConcurrentHashMap} due to tree - * traversal; the two-traversal {@code getOrCreate} pattern adds further overhead on misses. - *
    • Synchronized {@code HashMap} is ~50× slower than {@code ConcurrentHashtable}. + *
    • {@code Support} and {@code ConcurrentHashtable} are the two fastest on {@code get} (2731.5 + * and 2545.5 ops/us), 53-64% ahead of {@code ConcurrentHashMap} (1668.2). Since the {@code + * Key2} allocation is eliminated in all three (see above), that lead is the two-level hash + * lookup rather than allocation. + *
    • On {@code getOrCreate} the ordering inverts: {@code ConcurrentHashMap} (1655.5) overtakes + * {@code ConcurrentHashtable} (1397.3), whose own {@code getOrCreate} is roughly half its + * {@code get}. {@code Support} holds up (2497.8). Not root-caused here — the write-path + * re-check is the obvious suspect, but it is not measured. + *
    • {@code Support} edges {@code D2} on both paths, consistent with its primitive {@code int} + * K2 field avoiding boxing inside the entry match on the write-path re-check. + *
    • {@code ConcurrentSkipListMap} is ~9× slower than {@code ConcurrentHashMap} due to tree + * traversal, though its error bar is wide (±43.8 on a 182.2 mean). + *
    • Synchronized {@code HashMap} is roughly 290× slower than the fastest options (9.4 vs 2731.5 + * ops/us) — lock contention across eight threads on a single monitor, the same magnitude seen + * in {@link ThreadSafeMapD1Benchmark}. Type-profile pollution is not a factor: {@code Key2} + * is a final class built at the call site, so C2 has an exact type and devirtualizes without + * consulting the polluted profile. *
    */ @Fork(2) @@ -210,6 +236,14 @@ public void setUp() { public static class ThreadState { int cursor; + // Front-load pollution once per trial, entirely before JMH's warmup starts: JMH + // injects the Blackhole straight into this setup method, so no per-benchmark + // scratch state is needed. + @Setup(Level.Trial) + public void warmUpPollution(Blackhole bh) { + BenchmarkUtils.warmUpHashDispatch(bh); + } + int next() { int i = cursor; cursor = (i + 1) & (N_KEYS - 1); @@ -307,8 +341,8 @@ public Long getOrCreate_concurrentHashMap(SharedState s, ThreadState t) { } /** - * get-first pattern for ConcurrentSkipListMap — manual get-then-putIfAbsent since CSLM has no - * computeIfAbsent. Two traversals on miss; one on hit. + * Get-first pattern for {@link ConcurrentSkipListMap}: hits use one {@code get}; misses also call + * {@code putIfAbsent} to handle concurrent insertion. */ @Benchmark public Long getOrCreate_concurrentSkipListMap(SharedState s, ThreadState t) {