Conversation
This comment has been minimized.
This comment has been minimized.
🟡 Java Benchmark SLOs — Performance SLO warning (near threshold)
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. |
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.
8bbe287 to
7f6889d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f6889d388
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
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>
There was a problem hiding this comment.
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
bric3
left a comment
There was a problem hiding this comment.
Does this achieve its stated purpose?
-
I wonder, are we able to verify that the compiled lookups remain megamorphic after warmup?
-
If the scope is only setup coverage, what evidence supports presenting these results as representative of production workloads?
| * loading classes. | ||
| */ | ||
| public static void polluteHashDispatch() { | ||
| polluteHashDispatch(DEFAULT_DECOY_KEYS); |
There was a problem hiding this comment.
issue: Calling each decoy once doesn't keep the lookup profile megamorphic through warmup.
On acd9bd09, ImmutableSetBenchmark.hashSet_hitFresh with -XX:+PrintInlining still inlined String.hashCode() and String.equals() on Corretto 17: over 99.8% of receiver samples were String. Corretto 8 also specialized them. I'd check the final compiled lookup before calling these results “pollution-corrected”.
There was a problem hiding this comment.
Currently I don't know how that can be automated, but we could look at the JIT inlining decisions, I think this -Pjmh.jvmArgsAppend=-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining
There was a problem hiding this comment.
Yes, you are right that String::hashCode is still getting inlined into HashMap. I was expecting that, but I was not clear about it.
What I’m trying to do is exercise HashMap, etc in a way that is realistic for a customer system.
In a customer system, I expect there to be maps that use a variety of key types, so I’m trying to simulate that.
My aim is to avoid the speculative optimizations which are based on runtime state that are unlikely to occur in a real system,
but there are places where devirtualization and inlining can occur without relying on speculation & profiling.
For invocations, those speculative optimizations are based on the loaded class hierarchy analysis and the call site’s TypeProfile.
Here, I’m specifically aiming to pre-populate / pollute the TypeProfile.
In spite of this, in many cases, C2 will still be able to devirtualize and inline calls based off of static type reasoning.
This usually occurs when the call is on a parameter coming into a method rather than a reference stored inside the Map.
In the provided example, I believe that is what is happening. The collections are typically written to call equals & hashCode on the incoming parameter,
so C2 can take advantage of type-based reasoning.
ImmutableSetBenchmark::hashSet_hitFresh inline (hot)
Cursor::nextHitFresh inline (hot)
HashSet::contains inline (hot)
HashMap::containsKey inline (hot)
HashMap::getNode inline (hot)
HashMap::hash → String::hashCode → StringLatin1::hashCode inline (hot)
String::equals ×2 inline (hot)
Unfortunately, PrintInlining doesn’t clearly indicate how the decision was made. To make sure, I’ll switch to checking LogCompilation which is more detailed.
It is an XML-ish format, so not as nice to read, but it does provide the detail that we want.
There was a problem hiding this comment.
As promised, I had Claude dig deeper into LogCompilation. LogCompilation does show that the “pollution” mechanism is largely having the desired effect.
Admittedly, this approach is really just a “best effort” at making our benchmarks more realistic. I think it is safe to say that some type profile pollution is more realistic than no type profile pollution.
So in that regard, I think this is working as intended, but I won’t be surprised if further adjustments are needed.
Claude’s analysis…
LogCompilation shows the real invocation histogram, not just a compiled decision — it's on the <call> element itself (receiver/receiver_count, receiver2/receiver2_count), frozen at whatever moment the interpreter/C1 handed the profile to C2. <predicted_call> is just the consequence: C2's guard, generated from that snapshot.
Digging into HashMap.getNode's own early compile, the same method body has two structurally identical equals call sites fed by different code paths, and pollution landed on them completely differently:
- One call site: 2780 Integer calls vs. 2778 String calls — genuinely ~50/50. C2 looked at that and declined to speculate at all —
inline_fail reason='virtual call', real vtable dispatch, no guard. - The other: 3024 Long calls vs. 1 String call. C2 still emits a bimorphic-shaped guard pair (because its policy covers the top-2 recorded types whenever more than one shows up at all), but the underlying traffic was never actually balanced there.
So even within one method, in one compile, pollution's effectiveness at a given call site depends on which code path happened to route which key types through it during the warmup burst — it's not uniform, and it's not something that "decays" from a shared profile so much as something that was never evenly distributed to begin with.
There was a problem hiding this comment.
Re-ran everything with -prof gc on Zulu 17, plus a LogCompilation pass. Conclusions moved, and the first two are mine to retract.
Retracted. HashtableD2 claimed a ~1.8x hashtable win on add and ~11.6x on update. Both were artifacts of Objects.hash in the Key2 constructor — it's varargs, so it allocated an Object[] per key construction and the HashMap baseline was carrying 24 B/op that nothing in the design required. Switched to HashingUtils.hash, which exists in this repo precisely to avoid that. update_hashMap roughly doubled (196.7 → 463.0), so update is ~5.4x and add is a tie (1018.9 vs 1009.6). The contention ratios in both ThreadSafeMap files were also computed off stale tables — they're ~280-290x, not ~150x.
Your PrintInlining run is what started all of this, and it turned out to be showing something more interesting than pollution failing. Chasing it led to measuring Key2, and the result splits cleanly by call shape:
| arm | shape | alloc |
|---|---|---|
HashtableD2.update_hashMap |
merge(new Key2(...), ...) |
48 B/op |
ThreadSafeMapD2.get_* / getOrCreate_* |
get, then guarded computeIfAbsent |
0 B/op |
LogCompilation shows eliminate_allocation for Key2 in the second case. The never-taken computeIfAbsent branch is pruned as an unstable_if uncommon trap, which removes the only store of the key and leaves it provably non-escaping, so EA scalar-replaces it. With merge the present/absent decision lives inside the callee, so there's no caller-visible branch to prune and the wrapper survives.
But that elimination is manufactured by the benchmark. It only works because @Setup installs every key before warmup, so the absent branch is never recorded. A real cache records its population-phase misses in the same 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 per lookup. Those ConcurrentHashMap numbers are an upper bound; it's stated in the javadoc.
The general lesson is about forking. JMH runs each benchmark method in a fresh JVM — 29 methods, 29 VM invoker lines in our log. Right for measurement, but it means every arm is compiled in a process that has only ever seen that one code path, that one arm's key types, and that one arm's branch outcomes. Production is the reverse. So JMH forks to eliminate cross-benchmark profile pollution, while profile pollution is the production condition. Receiver profiles collapse, CHA gets a thin hierarchy, and branch profiles go one-sided — and BenchmarkUtils only addresses the first two.
Filed as APMLP-1870: a warmUpArms(Blackhole, Arm...) that drives every arm in shuffled order during setup, with reflective discovery so coverage can't silently drift.
Your call on sequencing: I can pull that into this PR, which means better numbers but a bigger diff and another re-measure, or land it as a follow-on with its first adopter and a before/after. Genuinely happy either way — you're the one reading it.
|
My general approach here is to recreate the common case which also happens to be the worst case -- megamorphic call site. From what you found, it sounds like some of the benchmark surpassed the TypeProfileMajorReceiver limit. In a real system, I don't think that's not likely to happen, so I want to adjust the benchmark accordingly. As for whether, we can verify that a callsite stays megamorphic. Generally speaking for most optimizations, C2 starts in an optimistic state and then reaches a pessimistic state and then stops. I was concerned that the pollution process was insufficient, but let it be after spot checking with PrintInlining. Once I've updated the pollution process, I'll re-run the benchmarks and make sure the conclusions still hold. |
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>
Rebasing this branch reintroduced BenchmarkUtils pollution wiring that had already landed independently on the base branch, producing two identically-named @setup(Level.Trial) setUp() methods in TagMapAccessBenchmark (a silent auto-merge, not a flagged conflict). 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>
…llution' into dougqh/benchmarkutils-map-set-pollution
Several benchmark javadocs attributed cost to "megamorphic hashCode()/equals() dispatch" that could not have occurred. A megamorphic profile does not always imply virtual dispatch: an exact static type is a proof that outranks the profile, and these keys are statically exact, so C2 devirtualizes without consulting the polluted profile at all. - Add polluteComparatorDispatch so Comparator-based TreeSet/TreeMap/ ConcurrentSkipListMap sites are exercised alongside the natural-ordering ones, which do not share their call sites. - Document the devirtualization model once, in ImmutableMapBenchmark, instead of re-deriving it per file. - Drop the unsupported megamorphic-dispatch attributions in HashtableD1, HashtableD2 and TagMapAccess. String is final and Key2 is a final class built at the call site, so both sharpen to an exact type. - Rewrite TagMapAccess's rerun section against same-JDK Zulu 17 data rather than a cross-JDK comparison, removing that confound entirely. - Replace Objects.hash with HashingUtils.hash in Key2. Objects.hash is varargs and allocated an Object[] per lookup, handicapping the HashMap baseline by 24 B/op. - Fold measured -prof gc results into HashtableD1 and HashtableD2: 24 B/op for D1's boxed Long against zero for the hashtable, and 48 B/op for D2 (Long plus Key2), confirming escape analysis does not eliminate the composite key. HashtableD2's throughput tables still need refreshing from a multi-fork run, since the Key2 change alters that baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An identity match yields the same lookup result but never invokes equals(), so the Object decoy contributes a hashCode() receiver sample and no equals() one. The previous wording called the two indistinguishable, which is backwards for the profiling purpose this class exists to serve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both ThreadSafeMap benchmarks claimed that under pollution, megamorphic hashCode()/equals() dispatch dominated the synchronized HashMap cost "far more than lock contention" -- which also asserted that each benchmark failed to measure the thing it was built to measure. The arithmetic rules that out: get_synchronizedHashMap runs at 9.2 ops/us (D1) and 8.8 (D2), roughly 110 ns per op aggregate across eight threads. A megamorphic dispatch costs single-digit nanoseconds; a contended monitor with eight threads parking and unparking does not. Separately, the dispatch is not megamorphic in compiled code anyway: D1 keys are String, a final class, and D2's Key2 is a final class built at the call site, so C2 sharpens to an exact type and devirtualizes without consulting the profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Full Zulu 17 run across the four affected classes with -prof gc, so every table now carries allocation per op alongside throughput. - HashtableD2: the Objects.hash fix roughly doubled update_hashMap (196.7 to 463.0), so the update margin is ~5.4x rather than the ~11.6x claimed, and add is now a tie (1018.9 vs 1009.6) rather than a ~1.8x hashtable win. Both prior figures were artifacts of the varargs allocation in the old Key2 constructor. - ThreadSafeMapD1/D2: refreshed tables moved substantially, so the synchronized HashMap contention ratios are ~280-290x, not the ~150x computed from the older numbers a few commits ago. - ThreadSafeMapD2: document that Key2 is scalar-replaced in every map arm (0 B/op), confirmed by LogCompilation showing eliminate_allocation once the never-taken computeIfAbsent branch is pruned as unstable_if. Flag that this depends on a one-sided branch profile the benchmark manufactures by populating in @setup; a production miss rate would restore the allocation, so those numbers are an upper bound. - HashtableD1: the collection count in prose was from the single-fork spot check and disagreed with its own table; now both read 852. Allocation figures decompose exactly, which is a useful property to keep: 24 B/op is one boxed Long, 32 is a HashMap.Node, 48 is Long plus Key2, and 56 is Key2 plus Node with no box because (long) i for i < 128 hits the Long.valueOf cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A one-sided branch profile lets C2 prune the untaken branch as unstable_if, and when that branch held the only store of an object, escape analysis then scalar-replaces an allocation production would keep. Measured here as get-then-guarded-computeIfAbsent, where pre-installing every key in @setup makes a composite key free; a real cache records population-phase misses in the same profile and allocates on every lookup. Also notes what this utility does not cover: branch profiles, and the ordering dependency from JMH running arms sequentially. Flags warmUpArms as the planned follow-on rather than adding unadopted API here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects a claim added in the previous commit. I had written that arms run sequentially and the first shapes profiles the rest inherit; that is wrong. JMH forks a fresh JVM per benchmark method -- verified in our own run log, 29 benchmark methods and 29 VM invokers -- so no profile carries between arms. The real problem is isolation, not ordering, and it is the root cause of all three issues this class deals with. A fresh JVM per arm means C2 sees only that arm's key types, only that arm's branch outcomes, and only the classes that arm loads. So receiver profiles collapse, branch profiles go one-sided, and CHA gets an artificially thin hierarchy. JMH forks to eliminate cross-benchmark profile pollution, but profile pollution is the production condition. Restructured the class javadoc to lead with that and enumerate the three consequences, demoting Blackhole to the orthogonal note it is. Also records that a future warmUpArms should shuffle arm order with a fixed seed, since compilation can trigger part-way through a fixed round-robin and a regular pattern is unrealistically easy on the hardware branch predictor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What Does This Do
Stacked on #12294. Wires
BenchmarkUtils.polluteHashDispatch()into the rest of this module'sHashMap/HashSet/ConcurrentHashMap-comparing JMH benchmarks — until now onlyImmutableSetBenchmark/ImmutableMapBenchmarkhad type-profile pollution applied, so every other file's isolated single-key-type usage left those JDK collections' shared internalhashCode()/equals()dispatch call sites artificially monomorphic, understating their real per-call cost.BenchmarkUtils:polluteHashDispatch()now also warms a scratchConcurrentHashMap(a separate class fromHashMap/HashSet, with its own dispatch call sites — pollinatingHashSetalone doesn't reach it). AddedpopulateTypeProfileMap/populateTypeProfileMutableMapas theMapcounterparts to the existingCollection-based helpers, for callers that need finer control.SingleThreadedMapBenchmark,SingleThreadedSetBenchmark,ThreadSafeMapBenchmark,HashtableD1Benchmark,HashtableD2Benchmark,CaseInsensitiveMapBenchmark,TagMapAccessBenchmark.FlatHashtableIteratorBenchmarkis intentionally untouched — it only exercises the project's ownFlatHashtable/HashStrategy, neverjava.util.HashMap/HashSet/ConcurrentHashMap.ImmutableSetBenchmark/ImmutableMapBenchmark.Motivation
#12294 showed that leaving JDK collections' shared
hashCode()/equals()call sites monomorphic (single key type per benchmark) understates their real production cost by ~20%, since in a real system those call sites are shared and megamorphic. That pollution fix only reachedImmutableSetBenchmark/ImmutableMapBenchmark; this PR extends the same treatment to the module's other map/set benchmarks so their numbers are consistent and realistic too.Additional Notes
Honest caveat on this rerun: this session's rerun landed on a noticeably slower baseline across several files — including numbers for the project's own
Hashtable.D1/D2andFlatHashtable, which don't touchjava.util.HashMap/HashSetdispatch at all and so shouldn't be affected by this pollution change. That points to session-to-session machine variance (the run spanned ~2 hours; power source changed partway through) rather than a genuine pollution effect for those files. Each affected file's Javadoc calls this out explicitly rather than presenting the new numbers as clean deltas. The relative conclusions in every file (which structure wins which operation) are unchanged either way; onlyImmutableSetBenchmark/ImmutableMapBenchmark(from #12294) had pollution produce a real qualitative shift.ThreadSafeMapBenchmark.get_concSkipListMapmoved ~11x from its prior Java 21 measurement — flagged as an open anomaly in its Javadoc, not asserted as a real finding.Test plan
./gradlew :internal-api:compileJmhJava— compiles clean./gradlew :internal-api:spotlessApply— formatting clean@Fork(2)/@Threads(8)per file's existing config) — results folded into each file's Javadoc🤖 Generated with Claude Code