Skip to content

Extend type-profile pollution to the remaining map/set benchmarks (perf toolbox - benchmark) - #12298

Open
dougqh wants to merge 37 commits into
masterfrom
dougqh/benchmarkutils-map-set-pollution
Open

dougqh wants to merge 37 commits into
masterfrom
dougqh/benchmarkutils-map-set-pollution

Conversation

@dougqh

@dougqh dougqh commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Stacked on #12294. Wires BenchmarkUtils.polluteHashDispatch() into the rest of this module's HashMap/HashSet/ConcurrentHashMap-comparing JMH benchmarks — until now only ImmutableSetBenchmark/ImmutableMapBenchmark had type-profile pollution applied, so every other file's isolated single-key-type usage left those JDK collections' shared internal hashCode()/equals() dispatch call sites artificially monomorphic, understating their real per-call cost.

  • BenchmarkUtils: polluteHashDispatch() now also warms a scratch ConcurrentHashMap (a separate class from HashMap/HashSet, with its own dispatch call sites — pollinating HashSet alone doesn't reach it). Added populateTypeProfileMap/populateTypeProfileMutableMap as the Map counterparts to the existing Collection-based helpers, for callers that need finer control.
  • Wired into: SingleThreadedMapBenchmark, SingleThreadedSetBenchmark, ThreadSafeMapBenchmark, HashtableD1Benchmark, HashtableD2Benchmark, CaseInsensitiveMapBenchmark, TagMapAccessBenchmark. FlatHashtableIteratorBenchmark is intentionally untouched — it only exercises the project's own FlatHashtable/HashStrategy, never java.util.HashMap/HashSet/ConcurrentHashMap.
  • Reran the full suite for all 7 files (JDK 8, Apple M1) and folded pollution-corrected numbers + updated key-findings into each file's Javadoc, following the pattern already used in 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 reached ImmutableSetBenchmark/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/D2 and FlatHashtable, which don't touch java.util.HashMap/HashSet dispatch 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; only ImmutableSetBenchmark/ImmutableMapBenchmark (from #12294) had pollution produce a real qualitative shift.

ThreadSafeMapBenchmark.get_concSkipListMap moved ~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
  • Full JMH suite run for all 7 affected files (JDK 8, Apple M1, @Fork(2)/@Threads(8) per file's existing config) — results folded into each file's Javadoc

🤖 Generated with Claude Code

@dougqh dougqh added tag: ai generated Largely based on code generated by an AI or LLM comp: core Tracer core type: refactoring tag: no release notes Changes to exclude from release notes labels Aug 26, 2026
@datadog-datadog-prod-us1-2

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Java Benchmark SLOs — Performance SLO warning (near threshold)

Suite Status
Startup 🟡 warning

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.96 s 14.04 s [-1.3%; +0.1%] (no difference)
startup:insecure-bank:tracing:Agent 13.03 s 13.04 s [-0.7%; +0.6%] (no difference)
startup:petclinic:appsec:Agent 17.04 s 16.25 s [+0.6%; +9.1%] (maybe worse)
startup:petclinic:iast:Agent 16.94 s 16.91 s [-0.7%; +1.2%] (no difference)
startup:petclinic:profiling:Agent 16.67 s 16.56 s [-0.4%; +1.7%] (no difference)
startup:petclinic:sca:Agent 16.94 s 16.80 s [+0.1%; +1.7%] (maybe worse)
startup:petclinic:tracing:Agent 15.67 s 16.07 s [-6.6%; +1.7%] (no difference)

Commit: 1ce8fec1 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@dougqh dougqh changed the title Extend type-profile pollution to the remaining map/set benchmarks Extend type-profile pollution to the remaining map/set benchmarks (perf toolbox - benchmark) Sep 15, 2026
dougqh and others added 7 commits September 22, 2026 14:33
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.
@dougqh
dougqh force-pushed the dougqh/benchmarkutils-map-set-pollution branch from 8bbe287 to 7f6889d Compare September 22, 2026 19:05
@dougqh
dougqh marked this pull request as ready for review September 22, 2026 19:06
@dougqh
dougqh requested a review from a team as a code owner September 22, 2026 19:06
@dougqh
dougqh requested review from bric3 and removed request for a team September 22, 2026 19:06

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/HashtableD1Benchmark.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java Outdated

@datadog-datadog-prod-us1-2 datadog-datadog-prod-us1-2 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

The new ConcurrentHashMap setup reuses each inserted key for get(). The identity check then bypasses equals() and leaves the measured lookup call site monomorphic.

Open Bits AI session

🤖 Bits Code Review · Commit 7f6889d · @DataDog review to ask questions

Comment thread internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java Outdated
dougqh and others added 2 commits September 22, 2026 16:11
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>
Base automatically changed from dougqh/stringindex-immutable-set-benchmark to master September 22, 2026 21:17

@datadog-datadog-prod-us1-2 datadog-datadog-prod-us1-2 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

The setup gives hash structures a megamorphic profile but leaves sorted structures and MapN monomorphic. The Java 8 tables also label fallback HashMap and HashSet results as MapN and SetN.

Open Bits AI session

🤖 Bits Code Review · Commit 56a31d9 · @DataDog review to ask questions

Comment thread internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java Outdated
dougqh and others added 4 commits September 22, 2026 18:27
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 bric3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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”.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD1Benchmark.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/ThreadSafeMapD2Benchmark.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java
Comment thread internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java
Comment thread internal-api/src/jmh/java/datadog/trace/util/SingleThreadedSetBenchmark.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/BenchmarkUtils.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java Outdated
Comment thread internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java Outdated
@dougqh

dougqh commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

My general approach here is to recreate the common case which also happens to be the worst case -- megamorphic call site.
Admittedly, I don't think the current pollution process is sufficient. I need to exercise the call sites more during pollution to make this more predictable.

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.
So once we get into a pessimistic (e.g. megamorphic) state, we're generally safe in assuming it will stay there.
And given that my aim is to exercise the worst case behavior, I think that's want we want in our benchmarks.

I was concerned that the pollution process was insufficient, but let it be after spot checking with PrintInlining.
Clearly, I need to update it. While that's a bit annoying, I also think it helps demonstrate why having a utility that encapsulates the process is important.
Once we get it right, we'll remove a subtle source of benchmarking errors going forward.

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>
dougqh added a commit that referenced this pull request Sep 23, 2026
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>
dougqh and others added 9 commits September 24, 2026 20:17
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants