diff --git a/.agents/skills/perf-review/references/checks.md b/.agents/skills/perf-review/references/checks.md index abcfbcf073a..229ce363f48 100644 --- a/.agents/skills/perf-review/references/checks.md +++ b/.agents/skills/perf-review/references/checks.md @@ -51,7 +51,9 @@ 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: pollute Map/Set dispatch before trusting a JMH comparison** *(methodology check on a submitted benchmark, not a production hot-path finding — refines the flag-as-measure posture in J2)*. A JMH benchmark comparing `HashMap`/`HashSet`/`ConcurrentHashMap` (or `TreeMap`/`TreeSet`/`ConcurrentSkipListMap`, or the JDK's immutable `Map.of`/`Set.of` `MapN`/`SetN`) against another collection, that only ever looks up one key class for the whole run, leaves that collection's internal `hashCode()`/`equals()` (or `compareTo`) dispatch site artificially monomorphic — a state production rarely reaches, since those call sites are shared JVM-wide across every key type the whole process uses. An unpolluted benchmark can inflate the affected structure's throughput and flip or exaggerate a comparison (this is exactly what PR #12298 found and fixed: synchronized `HashMap` collapsed ~70% once its dispatch was made megamorphic). Before trusting such a benchmark's numbers, check its `@Setup` calls `datadog.trace.util.BenchmarkUtils.polluteHashDispatch()` (covers `HashSet`/`HashMap`/`ConcurrentHashMap`/`SetN`/`MapN` `equals()` dispatch and `TreeMap`/`TreeSet`/`ConcurrentSkipListMap` `compareTo` dispatch) before the measured operations run. **flag-with-confidence** when a new or edited Map/Set JMH benchmark in this module omits it — fix: call `BenchmarkUtils.polluteHashDispatch()` in `@Setup(Level.Trial)`/`Level.Iteration`, ahead of the benchmarked calls. + +- **Toolkit availability — cite only what exists.** Available today: `Strings`, `SubSequence`, `HashingUtils`, `Hashtable`, `BenchmarkUtils.polluteHashDispatch()`/`polluteCompareToDispatch()` 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..2f77a78b22a 100644 --- a/.agents/skills/perf-review/references/guide.md +++ b/.agents/skills/perf-review/references/guide.md @@ -161,6 +161,15 @@ 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 that never pollute dispatch — treat as unverified.** A benchmark that +looks up only one key class for its whole run leaves the collection's internal +`hashCode()`/`equals()` (or `compareTo` for sorted maps/sets) call site artificially +monomorphic — production hits that same shared call site with whatever key types the whole +process uses, so it's realistically almost always megamorphic. An unpolluted benchmark can +overstate a structure's throughput and flip a comparison (PR #12298: a synchronized `HashMap` +collapsed ~70% once pollution was added). Check that the benchmark's `@Setup` calls +`datadog.trace.util.BenchmarkUtils.polluteHashDispatch()` before trusting its numbers. + **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..dae425c6b66 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 and is + * genuinely megamorphic — which is the condition {@code warmUpHashDispatch} reproduces here. 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 + * megamorphic 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..72758640d4d 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,268 @@ 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 org.openjdk.jmh.annotations.CompilerControl; +import org.openjdk.jmh.infra.Blackhole; -/** Shared setup helpers for JMH benchmarks in this module. */ +/** + * Shared setup helpers for JMH benchmarks in this module. + * + *

These exist to compensate for forking. JMH runs each benchmark method in a fresh JVM, which is + * right for measurement -- one arm cannot contaminate another's numbers -- but it means every arm + * is compiled inside a process that has only ever executed that one code path, with that one arm's + * key types and that one arm's branch outcomes. Production is the reverse: a single JVM runs + * everything. So the harness hands C2 a maximally specialized view of the world, and the + * compilation that results can be better than anything achievable in a real application. Put + * another way, JMH forks to eliminate cross-benchmark profile pollution, but profile pollution + * is the production condition. + * + *

That surfaces three ways. This class addresses the first two partially and the third not at + * all: + * + *

+ * + *

{@link Blackhole} is orthogonal to all of this. It stops the JIT proving a result is dead, and + * says nothing about whether the surrounding code was compiled realistically -- a benchmark can + * consume every result through a {@code Blackhole} and still measure a devirtualized fast path that + * cannot occur in production. + * + *

One-sided branch profiles also interact with escape analysis in a way that can silently + * flatter a benchmark. When a branch is never taken during profiling, C2 prunes it as an {@code + * unstable_if} uncommon trap; if that pruned branch held the only store of an object, the object + * becomes provably non-escaping and escape analysis scalar-replaces an allocation that production + * would keep. The case measured in this module is {@code map.get(key)} followed by a guarded {@code + * computeIfAbsent(key, ...)}: with every key pre-installed by {@code @Setup} the absent branch + * never runs, so a composite key costs nothing at all. A real cache records its population-phase + * misses in that same branch profile -- MDO counters accumulate from interpretation onward and are + * never reset -- so the profile is two-sided, nothing is pruned, and the key is allocated on every + * lookup. See {@code ThreadSafeMapD2Benchmark} for the measurement, and {@code + * HashtableD2Benchmark} for the contrasting shape, where {@code merge} keeps the present/absent + * decision inside the callee and leaves no caller-visible branch to prune. + * + *

The more complete approach is to exercise every benchmark arm during setup, across each of its + * outcomes, so that each fork's profiles reflect the whole class rather than the one arm it is + * about to measure. That is what restores the mix the fork removed. + * + *

Such a helper should drive the arms in a shuffled order rather than a fixed round-robin, since + * compilation can trigger part-way through a warmup and would otherwise see whichever arm dominates + * that point in the sequence; a regular pattern also gives the hardware branch predictor an + * unrealistically easy time. Seeding the shuffle keeps it irregular but reproducible. A {@code + * warmUpArms} along those lines is planned as a follow-on, because adopting it changes benchmark + * setup and requires re-measuring whatever adopts it. + */ 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; + + /** + * 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. A call site's recorded type profile isn't threatened by which key + * type happens to dominate traffic during or after warmup -- once HotSpot has recorded multiple + * receiver types there, it stays megamorphic regardless of later call frequency. The risk this + * class guards against is a statically deducible receiver type bypassing the profile entirely: + * loading extra classes here 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) { + 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. + * + *

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

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

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") // 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 9cbbddcf299..39c6bb169f8 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; @@ -22,36 +24,38 @@ * 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 - * 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. + *

Takeaways. FlatHashtable is ~1.8x the (previously recommended) TreeMap at the same zero + * allocation, but — see the revised takeaway below — trails HashMap's look-up throughput; its case + * is the allocation win (no per-look-up folded String, which drives the multi-threaded GC pressure + * HashMap pays), 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. * - *

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

Java 17 results (MacBook M1, {@code @Fork(5)}, {@code @Threads(8)}) with the front-loaded + * {@link BenchmarkUtils#warmUpHashDispatch} pollution design (M ops/s): * - * 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
+ * create_baseline        25.2    create_flatHashtable    15.3
+ * create_hashMap          7.3    create_treeMap           8.4
  *
- * 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       2760.8   lookup_flatHashtable    380.3
+ * lookup_flatHashtable_lowLoad  430.7  lookup_hashMap    488.5
+ * lookup_treeMap         214.7
+ * }
* - * 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 - * + *

{@code lookup_flatHashtable}/{@code lookup_hashMap}/{@code lookup_treeMap} carry error bars of + * ~13-17% of their means at {@code @Fork(5)} (down from 44-66% at {@code @Fork(2)}, which wasn't + * decisive) — tight enough that {@code hashMap}'s lead over {@code flatHashtable} (488.5 vs 380.3, + * ~28%) is a real, if not perfectly clean-cut, result rather than noise. + * + *

Takeaway, revised. {@code HashMap} keyed on {@code toLowerCase()} is faster than {@code + * FlatHashtable} for this lookup shape, not merely comparable to it as earlier (noisier) runs + * suggested. {@code FlatHashtable} still wins on allocation — it is the zero-allocation option, and + * that stays true regardless of the throughput ordering — but the throughput case for it over + * {@code HashMap} on case-insensitive lookups does not hold up under this rerun. {@code TreeMap} + * remains the slowest of the three at every fork count measured. */ @Fork(2) @Warmup(iterations = 2) @@ -101,6 +105,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..645d717c79e 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 852 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,58 @@ * 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 plus one concrete candidate: {@code warmUpHashDispatch} itself + * allocates heavily before measurement starts, which can shift GC state for the whole trial. + * Neither was measured. 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). ops/us, 8 threads: + * + *

    {@code
    + * Benchmark            ops/us            B/op   gc.count
    + * add_hashMap        1517.5 ± 242.9      32.0       1820
    + * add_hashtable      1302.1 ± 403.9      40.0       1933
    + * update_hashMap      686.0 ± 140.4      24.0        852
    + * update_hashtable   2770.7 ± 169.4       ~0          ~0
    + * iterate_hashMap      19.8 ±   0.6      40.0         55
    + * iterate_hashtable    79.6 ±   9.6       ~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 single run (so the cross-JDK Blackhole-mode confound doesn't apply to the ratios), + * {@code update_hashtable} wins by ~4.0x — 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} leads on the means + * (1517.5 vs 1302.1), though both error bars are wide enough to overlap, so treat that one as + * undecided rather than a HashMap win. 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) @@ -101,6 +161,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..a61d0c86a2e 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 420 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,70 @@ * 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 plus one concrete candidate: {@code + * warmUpHashDispatch} itself allocates heavily before measurement starts, which can shift GC state + * for the whole trial. Neither was measured. 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. + * + *

    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        1009.6 ± 190.2      56.0       1819
    + * add_hashtable      1018.9 ± 235.0      40.0       1589
    + * update_hashMap      463.0 ±  95.7      48.0       1064
    + * update_hashtable   2492.7 ±  51.9       ~0          ~0
    + * iterate_hashMap      19.8 ±   0.3      40.0         60
    + * iterate_hashtable    72.0 ±   1.6       ~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.4x — 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 463.0). {@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 now a tie (1018.9 vs 1009.6, comfortably inside both error bars), 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 +178,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 +217,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..58edce8873c 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,43 @@ * {@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). *
    */ // @Fork(5): get_tracerImmutableMap* (MapN reached via interface dispatch) is JIT-bimodal at fewer @@ -139,8 +144,9 @@ static void fill(Map map) { @Setup(Level.Trial) public void setUp() { - BenchmarkUtils.polluteHashDispatch(); - + // Superseded by Cursor#warmUpPollution's heavier front-load below -- this single call isn't + // enough on its own to drive HotSpot's tiered compiler through both C1 and C2 on the shared + // hash-dispatch call sites (see BenchmarkUtils#warmUpHashDispatch). hashMap = new HashMap<>(); fill(hashMap); linkedHashMap = new LinkedHashMap<>(); @@ -161,6 +167,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..fcc2f5209cd 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,9 @@ static String[] newMisses() { @Setup(Level.Trial) public void setUp() { - BenchmarkUtils.polluteHashDispatch(); - + // Superseded by Cursor#warmUpPollution's heavier front-load below -- this single call isn't + // enough on its own to drive HotSpot's tiered compiler through both C1 and C2 on the shared + // hash-dispatch call sites (see BenchmarkUtils#warmUpHashDispatch). array = STRINGS; sortedArray = Arrays.copyOf(STRINGS, STRINGS.length); Arrays.sort(sortedArray); @@ -164,6 +159,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..51ade692ce6 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,41 @@ * 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
    + *
    + * 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 +224,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..d4a1d85d1bb 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,38 @@ * 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            2593.6 ± 116.7        ~0
    + * get_concurrentHashMap              1830.7 ±  31.5        ~0
    + * get_concurrentSkipListMap           202.7 ±  75.6        ~0
    + * get_synchronizedHashMap               9.2 ±   0.8        ~0
      *
    - * getOrCreate_concurrentHashtable       1450   ops/us
    - * getOrCreate_concurrentHashMap         1125   ops/us
    - * getOrCreate_synchronizedHashMap         31   ops/us
    + * getOrCreate_concurrentHashtable    2580.6 ±  28.3        ~0
    + * getOrCreate_concurrentHashMap      1823.9 ±  50.2        ~0
    + * getOrCreate_synchronizedHashMap       9.3 ±   0.7        ~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 + *
    • {@code ConcurrentHashtable} is ~40% faster than {@code ConcurrentHashMap} on {@code get} + * (2593.6 vs 1830.7 ops/us); avoids the hash-to-segment translation CHM pays even on its fast * path. *
    • {@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 (±75.6 on a 202.7 mean), so + * treat that multiple as approximate. + *
    • Synchronized {@code HashMap} is roughly 280× slower than {@code ConcurrentHashtable} (9.2 + * vs 2593.6 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 +131,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..921e4fd4fd2 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,60 @@ *

    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                         2730.0 ±  33.4        ~0
    + * get_concurrentHashtable             2653.0 ±  81.6        ~0
    + * get_concurrentHashMap               1665.0 ±  24.2        ~0
    + * get_concurrentSkipListMap            187.3 ±  40.1        ~0
    + * get_synchronizedHashMap                9.3 ±   0.4        ~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                 2597.4 ± 221.5        ~0
    + * getOrCreate_concurrentHashMap       1647.1 ±  75.5        ~0
    + * getOrCreate_concurrentHashtable     1397.0 ±  46.5        ~0
    + * getOrCreate_concurrentSkipListMap    179.1 ±  40.0        ~0
    + * getOrCreate_synchronizedHashMap        9.3 ±   0.4        ~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. + * 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} (2730.0 + * and 2653.0 ops/us), ~60% ahead of {@code ConcurrentHashMap} (1665.0). 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} (1647.1) overtakes + * {@code ConcurrentHashtable} (1397.0), whose own {@code getOrCreate} is roughly half its + * {@code get}. {@code Support} holds up (2597.4). 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 (±40.1 on a 187.3 mean). + *
    • Synchronized {@code HashMap} is roughly 290× slower than the fastest options (9.3 vs 2730.0 + * 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 +234,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);