Repository navigation
Conversation
Mirrors StringIndex#contains for the static-arrays path, so callers of the embedded/parallel-array form don't need to spell out indexOf(...) >= 0 themselves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hmark Reusing the same interned/cached-hash key instances for both building a structure and measuring hit lookups against it understates real hit cost -- String#equals's == fast path and String#hashCode's cached result pay nothing extra for a key already touched during setUp. The new hitFresh scenario looks up separate, never-touched String instances instead, alongside the existing hit (interned literal) and miss (already representative) scenarios. Also pulls in BenchmarkUtils#polluteHashDispatch so the shared hashCode/equals call sites are already megamorphic before the StringSet arms measure their own lookups, matching production where those call sites are hit by every hash-based structure in the JVM. Replaced the javadoc's stale, partial-run tables/findings with a full hit/hitFresh/miss re-run across all six structures together (Fork(5), Threads(8)) -- confirms hitFresh is the slowest of the three scenarios for every hash-based structure, not merely slower than hit as previously guessed from partial data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recompute the slot index each iteration as (h + probes) & mask instead of carrying a separately-incremented cursor, so the loop has one canonical induction variable for the JIT to reason about.
…ains-only helpers populateTypeProfile() drives only contains(), so it works against both mutable and immutable collections (including the collection instance under test itself); populateTypeProfileMutable() keeps the old add()+contains() behavior for callers that need add() dispatch polluted too.
The instance wrapper reliably beats HashSet on hit (its actual design case) but not on miss/hitFresh, where both are bimodal across forks and the mean already sits at or below HashSet's steady figure. Point miss/fresh-key-heavy callers at EmbeddingSupport directly instead of assuming the wrapper is strictly better than a plain Set.
Object.hashCode()/equals() are JVM-wide shared call sites hit by every hash-based structure in the process; leaving them monomorphic for a single-key-type run understates real dispatch cost, same reasoning as ImmutableSetBenchmark. Rerun and update the javadoc results table with pollution in effect -- StringIndex's get win over HashMap/TagMap/MapN holds up unconditionally here, unlike the access-pattern-dependent Set case.
Apply the reviewer-suggested rewordings for BenchmarkUtils, ImmutableSetBenchmark, ImmutableMapBenchmark, and StringIndex: trim narration and restate a couple of claims (fresh-string hash caching, contains() contract) more precisely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
polluteHashDispatch() only warmed the shared HashSet/HashMap call site; ConcurrentHashMap has its own, unrelated hashCode()/equals() dispatch sites and needs a dedicated scratch instance. Also add populateTypeProfileMap/populateTypeProfileMutableMap as the Map counterparts to the existing Collection-based helpers, for benchmarks that need finer-grained control than polluteHashDispatch() gives.
…nchmarks Applies the type-profile-pollution fix already used in ImmutableSetBenchmark/ImmutableMapBenchmark to the rest of the HashMap/HashSet/ConcurrentHashMap-comparing benchmarks in this module: SingleThreadedMapBenchmark, SingleThreadedSetBenchmark, ThreadSafeMapBenchmark, HashtableD1Benchmark, HashtableD2Benchmark, CaseInsensitiveMapBenchmark, and TagMapAccessBenchmark. Without this, each file's isolated single-key-type usage left the JDK collections' shared internal hashCode()/equals() dispatch call sites artificially monomorphic, understating their real per-call cost. FlatHashtableIteratorBenchmark is intentionally untouched -- it only exercises the project's own FlatHashtable/HashStrategy, not java.util.HashMap/HashSet/ConcurrentHashMap.
D1.Entry.hash/matches are call sites private to Hashtable.java, distinct from java.util.HashMap/HashSet's own dispatch sites, so pollution literally cannot reach them (not just unlikely to). Since JDK and machine were held constant in this rerun, attribute the observed drop to same-session run-to-run noise, not a JDK effect (that explanation applies elsewhere, where the JDK actually changed).
Same fix as HashtableD1Benchmark: D2.Entry.hash/matches are call sites private to Hashtable.java, structurally distinct from java.util.HashMap dispatch, so pollution cannot reach them regardless of key-type overlap; the observed drop with JDK/machine held constant is same-session noise, not a JDK effect.
The original table is Zulu 21; this rerun is JDK 8, whose C2 backend for Apple Silicon is far less mature than JDK 17+'s. That JDK gap explains a broad-based slowdown across every entry on its own, including flatHashtable/treeMap which don't touch java.util.HashMap dispatch — a more precise explanation than generic "machine variance."
Same fix as CaseInsensitiveMapBenchmark: the original table is Java 17, this rerun is JDK 8, and JDK 8's weaker Apple Silicon C2 codegen explains the across-the-board drop on its own. Distinguish this from HashtableD1Benchmark/HashtableD2Benchmark, where the JDK was held constant and the drop is same-session noise instead.
Controlled rerun (same machine, JDK swapped to Zulu 17.0.7) shows update_hashtable still wins decisively (~4.2x, down from ~14x on JDK 8) and iterate_hashtable flips from a wash to a ~4x win, while add_hashMap edges ahead slightly. Java 17's much better allocator/GC narrows but doesn't erase Hashtable's win on the allocation-heavy paths -- net takeaway: Hashtable is a strong HashMap substitute for simple counter/tally cases with a primitive value. Absolute numbers aren't comparable to the JDK 8 table: JMH auto-selects the cheap "compiler" Blackhole mode on 17 but not on 8, so within-run ratios are the only trustworthy comparison here.
Same controlled rerun as HashtableD1Benchmark. update_hashtable wins ~11.6x (down from ~26x on JDK 8); unlike JDK 8, Hashtable now also wins clearly on add (~1.8x) and iterate (~3.4x, up from a wash) -- Java 17's allocator/GC narrows the update margin but doesn't flip any operation in HashMap's favor for D2. Same Blackhole-mode caveat on absolute numbers as D1; within-run ratios are what's trustworthy here.
BenchmarkUtils's contains()/get() probes reused the exact same key instance just added/put, so HashMap/ConcurrentHashMap's internal identity fast path (key == storedKey) short-circuited before equals() was ever invoked -- the helper was polluting hashCode() dispatch but not equals() dispatch, undermining its own purpose (Codex P1, Autotest P2). Added distinctEqualCopy() to probe with an equal-but-not-identical instance instead. Also fixes: mislabeled "M ops/us" throughput unit in the Java 17 tables (HashtableD1/D2Benchmark, should read ops/us like the JDK 8 tables above them), a Javadoc comment in TagMapAccessBenchmark left describing ReadMap after a @setup method was inserted above it, and an invalid cross-JDK comparison in SingleThreadedSetBenchmark's commentary that attributed unchanged numbers to "pollution had no effect" without acknowledging the JDK 8-vs-17 confound. Note: the BenchmarkUtils fix changes what polluteHashDispatch() actually exercises, so the "Java 17 rerun" numbers recorded in ThreadSafeMapD1/D2/ CounterBenchmark's javadoc (added in an earlier commit on this branch) are still pending a fresh rerun against this corrected helper.
…Map benchmarks Reruns ThreadSafeMapD1/D2/CounterBenchmark with BenchmarkUtils.polluteHashDispatch() now fixed (commit 3d9b93a) to avoid the identity fast-path bypass. Documents the fresh JDK 8 numbers alongside the pre-existing Java 17 tables, with the same cross-JDK-comparison caveat used elsewhere in this PR since these reruns aren't on the same JDK as the original tables. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reruns ThreadSafeMapD1/D2/CounterBenchmark on the same Zulu 17 JVM as the original baseline table, superseding the earlier JDK 8 rerun (56a31d9) whose numbers were confounded by a JDK 8 vs 17 optimizer difference on arm64. The clean delta surfaces a real finding the confounded run couldn't: synchronized HashMap throughput collapses by ~67-73% once hash dispatch is polluted, far more than the lock contention these benchmarks were designed to isolate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
polluteHashDispatch() only made hash-based (equals()/hashCode()) dispatch megamorphic, leaving TreeMap/TreeSet/ConcurrentSkipListMap's compareTo dispatch monomorphic and the JDK's immutable Map.copyOf (MapN) lookup site untouched -- both flagged by review as able to artificially favor those structures in benchmark comparisons. Adds polluteCompareToDispatch(), driven from polluteHashDispatch() since every current caller wants both passes: each decoy key type gets its own scratch TreeSet/TreeMap/ConcurrentSkipListMap (natural ordering can't mix incomparable types in one instance, but the JVM's call-site type profile is shared across instances regardless). Also exercises a Map.copyOf-backed MapN with a get() pass, matching the existing Set.copyOf-backed SetN handling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lution Add J12 (checks.md) and a matching guide.md callout: a benchmark that compares Map/Set implementations using only one key class leaves the structure's hashCode()/equals()/compareTo call site monomorphic, overstating its throughput relative to production -- exactly what PR #12298 found (synchronized HashMap collapsed ~70% once dispatch was polluted). Points reviewers at BenchmarkUtils.polluteHashDispatch(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ls-map-set-pollution # Conflicts: # internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java
A one-shot BenchmarkUtils.polluteHashDispatch() call at @setup(Level.Trial) or Level.Iteration) gets drowned out by the benchmark's own real-key traffic before HotSpot compiles the shared internal dispatch call sites, letting them re-specialize to a dominant receiver. Confirmed via -XX:+PrintInlining on ImmutableSetBenchmark.hashSet_hitFresh: HashMap.getNode's key.equals(k) call showed "no static binding" (genuinely virtual) only once pollution moved to @setup(Level.Invocation), unlike the one-shot version. Also fixes a transcription error in ThreadSafeMapD2Benchmark's pollution delta prose: the unpolluted baseline quoted (971/935) didn't match this file's own baseline table (777/769 ops/us). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ls-map-set-pollution
Replace the PollutionScratch/CompareToScratch persistent-scratch-object machinery with a single warmUpHashDispatch(Blackhole) call: each pass now allocates fresh collections and consumes the collection instances directly via the Blackhole to defeat scalar replacement, instead of reusing persistent fields. Removes the now-dead populateTypeProfile*/decoyKeys varargs surface that had no callers, so using this in a new benchmark needs nothing more than one @setup(Level.Trial) method with no scratch object to construct or wire up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Contributor
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
…n churn Pollution now runs once per JVM rather than once per thread, so the nine Scope.Thread benchmarks needed re-measuring. Zulu 17, -prof gc, same machine and fork config as the previous run. The interesting result is a negative one. Three files had been carrying "warmUpHashDispatch allocates heavily before measurement, which can shift GC state for the whole trial" as an explicitly unmeasured candidate for otherwise unexplained drops. The guard produced an 8x reduction in that allocation with everything else held constant, and nothing moved: update_hashMap 686.0 to 689.6, update_hashtable 2770.7 to 2796.3, iterate_hashMap 19.83 to 19.84, get_concurrentHashMap 1830.7 to 1837.1, get_support 2730.0 to 2731.5. All inside their intervals. That candidate is now tested and ruled out rather than left hanging, and HashtableD1 carries the comparison with D2 pointing at it. HashtableD2's update margin lands at ~5.5x and its add arms remain a tie, now with the hashtable ahead on the means but overlapping intervals. One arm moved outside its interval -- D2 update_hashtable, 2492.7 to 2346.8 -- on the zero-allocation path, so a GC explanation runs the wrong way; recorded as run-to-run drift. Refreshed throughput only for the four classes whose config is comparable between the two runs. For CaseInsensitiveMap, ImmutableMap and SingleThreadedMap the existing tables were taken at a different fork count, so only the allocation figures are added -- bytes/op is deterministic and config-insensitive: - CaseInsensitiveMap: lookup_hashMap is the only allocating lookup arm at 25.5 B/op for the folded String, against zero elsewhere. The variance is real signal, since toLowerCase() returns this for already-lower-case input. - ImmutableMap: the forEach-beats-iterator finding now has a mechanism -- iterate_tagMap 40 B/op versus zero for iterate_tagMap_forEach. - SingleThreadedMap: clone_tagMap 208 B/op against clone_hashMap 488. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Describe warmUpHashDispatch as broadening shared profiles rather than making them megamorphic (receiver counts still drive C2's inlining), drop the segment-based explanation for ConcurrentHashMap, and remove the false claim that ConcurrentSkipListMap lacks computeIfAbsent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The once-per-thread vs once-per-JVM comparison shows the amount of pollution work doesn't matter; it can't show whether a single pass affects the trial, since both runs include one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unseeded per-fork key draws gave each arm a different case and hit/miss mix. With a fixed sequence, HashMap's apparent ~28% lookup lead over FlatHashtable disappears (384.7 +/- 81.0 vs 402.6 +/- 11.9), and the folded-String allocation becomes deterministic. Default to @fork(5) to match the documented configuration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls-map-set-pollution
…he JDK Replace the Java 8-to-17 allocator story with a controlled same-JDK comparison (Zulu 17, forced FULL_DONTINLINE Blackhole, -prof gc): disabling EliminateAutoBox changes nothing, while ParallelGC cuts update_hashMap ~4x, matching JDK 8's default-collector number. The mechanism is left unattributed since reported gc.time is similar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous seeded run's hashMap/treeMap arms showed intra-fork swings consistent with external interference. A clean rerun on the merged code (with the CHA_DEFEAT decoys) restores HashMap's ~20-30% lookup lead over FlatHashtable; FlatHashtable's wide interval is one slow-compiling fork. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JMH forks a fresh JVM per @benchmark method, so each fork's C2 compiles against a maximally specialized view: one arm's receiver types, one arm's branch outcomes. warmUpArms drives every registered arm through many shuffled-order passes before measurement, restoring the two-sided branch profile a fork removes. Arm0/Arm1/Arm2 adapters overload on standard java.util.function shapes (Function, ToLongFunction, Predicate, Consumer, their Bi* counterparts, plus TriFunction/TriConsumer for two-@State-parameter arms), so a method reference or state-capturing lambda to an existing @benchmark method coerces in directly, and overload resolution picks the non-boxing primitive interface whenever the referenced method returns an unboxed primitive. Coverage is declared, not inferred: registering fewer arms than the benchmark class's matching-shape @benchmark methods fails loudly at setup time rather than silently warming a subset. Jira: APMLP-1870 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…olerant overloads Drops the primitive-specific overloads (ToLongFunction/Predicate/ ToLongBiFunction/BiPredicate) in favor of a single Function/Consumer pair per arity plus TriFunction/TriConsumer for the arity-driven shapes; a primitive result boxes on the way into Blackhole.consume, which is warmup-only overhead off the measured path. Also adds a fully reflective warmUp(Class<?>, Blackhole) overload that discovers every @benchmark method on a class and drives it without any registration, for benchmarks where one arm per method is enough. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
….warmUp works Canonical case: a computeIfAbsent-guarded composite-key lookup where ThreadSafeMapD2Benchmark's javadoc already documents that a one-sided branch profile lets C2 prune the miss branch as unstable_if and scalar-replace the composite key, understating real allocation. Both @benchmark arms here call the identical getOrCreate bytecode on an always-hit lookup; the only difference is whether warmUp primes that same method with one genuine miss arm, on scratch data, before measurement. Measured effect: 0 B/op with no priming vs 24 B/op (the composite key's real size) with it, ~20% slower -- confirming warmUp restores the production-realistic profile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dougqh
force-pushed
the
dougqh/benchmarkutils-warmup-arms
branch
from
October 6, 2026 17:30
5a5f2b1 to
ec7bfa7
Compare
The class doc still said branch profiles were unaddressed. Also fix claims that didn't match the code: a zero-arg lambda example that can't fit Function<B, ?>, the demo's 'both @benchmark methods' (there is one, with two @PARAM arms), TriFunction's arity, which @setup methods the reflective path skips, and its never-set @PARAM fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JMH runs @setup on a generated Foo_jmhType subclass, so the bench's own class declares no @benchmark methods and the check counted zero, letting under-registration pass silently. Walk the hierarchy, counting an overridden @benchmark method once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nested copies duplicated interfaces of the same shape already in internal-api. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each miss pass inserted a fresh key into a per-thread scratch map that stayed live through measurement, so pollute=true carried ~400k extra entries the false arm didn't. Clear it after warmUp, and replace the JDK 8 table with a Zulu 17 run: 0 vs 24 B/op, ~28% throughput cost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The J12 rubric text said this PR measured FlatHashtableIteratorBenchmark, but that benchmark is not part of the PR; the 37.9 -> 18.1 M ops/s numbers came from an uncommitted experiment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merged
3 tasks done
BenchmarkUtils' class javadoc now keeps the fork, profile and escape-analysis constraints while limiting production claims to what the benchmarks show. HashtableD2Benchmark no longer says the remaining setup pass was ruled out: eight passes vs one does not test its effect. Co-authored-by: bric3 <bric3@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nchmarkutils-warmup-arms # Conflicts: # internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java
dougqh
changed the base branch from
dougqh/benchmarkutils-map-set-pollution
to
master
October 13, 2026 13:06
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Does This Do
Adds
BenchmarkUtils.warmUp, a JMH setup helper that drives every path through a benchmark class (each@Benchmarkmethod, plus each interesting outcome of a multi-outcome method) through many shuffled-order passes before measurement starts.warmUp(bench, [state...], bh, bench(...)...), with arities 0–2 for@Stateparameters.bench(...)is overloaded on a small set ofjava.util.functionshapes (Function/Consumer, theBi*versions, plus the existingdatadog.trace.api.function.TriFunction/TriConsumer), so a lambda or method reference to an existing benchmark method fits directly. Primitive results get boxed on the way into theBlackhole. That cost lands only during warmup, never on the measured path.warmUp(Class<?>, bh)finds every@Benchmarkmethod on the class, builds the benchmark class and its@Stateobjects through their no-arg constructors, runs their no-arg@Setupmethods, and drives one call per method. It needs no setup work, but drives only one outcome per method.WarmUpEscapeAnalysisDemoBenchmark, which shows the effect (see Additional Notes).Motivation
JMH forks a fresh JVM for each
@Benchmarkmethod, so each fork's C2 compiles code that has only ever seen one benchmark's paths. Production is the opposite: one JVM runs everything.warmUpHashDispatch(#12294/#12298) already handles the receiver-type and class-hierarchy side of this. The remaining gap is one-sided branch profiles.That gap can hide real allocations. In
ThreadSafeMapD2Benchmark,@Setuppre-installs every key, so the miss branch ofgetOrCreatenever runs. C2 drops that branch entirely (anunstable_iftrap), the composite key no longer escapes, and escape analysis removes its allocation. The benchmark reports ≈0 B/op for a lookup that allocates a key on every call in a real cache, since a real cache's early misses leave that branch two-sided.Additional Notes
Evidence.
WarmUpEscapeAnalysisDemoBenchmarkruns the samegetOrCreatecode in two arms, always hitting in the measured lookup. The only difference is whetherwarmUpfirst primes that call site once with a genuine miss, using scratch data:pollutefalsetrueZulu 17.0.7, Apple M1, 2 forks, 8 threads,
-prof gc. Priming brings back the composite key's real 24 B/op and its ~28% throughput cost, without changing the measured method or the measured map. The demo clears its scratch map afterwarmUp, so the extra keys inserted on miss passes aren't kept live during measurement.Known limits.
@Benchmarkmethods (including those inherited, since JMH runs@Setupon a generated subclass). It can't verify that every outcome of a multi-outcome method was driven, and registering only the hit path ofgetOrCreateis worse than not callingwarmUpat all.@Setupmethods (on the benchmark class and its state objects), leaves@Paramfields at their Java defaults, and drives only the outcome that its reflectively built state happens to produce.Not yet adopted. No production benchmark uses
warmUpyet. Adopting it inThreadSafeMapD2Benchmarkmeans writing a miss path for absent keys, and every benchmark that adopts it has to be re-measured. That happens in a follow-up.Stacked on #12298.
Contributor Checklist
type:and (comp:orinst:) labels in addition to any other useful labelsclose,fix, or any linking keywords when referencing an issueUse
solvesinstead, and assign the PR milestone to the issueJira ticket: APMLP-1870
🤖 Generated with Claude Code