Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
98b34e3
Add EmbeddingSupport.contains(hashes, names, name) helper
dougqh Aug 25, 2026
a440151
Add hitFresh scenario and hash-dispatch pollution to ImmutableSetBenc…
dougqh Aug 25, 2026
ae7207a
Restructure StringIndex put/indexOf around a single induction variable
dougqh Aug 26, 2026
69e5509
Split BenchmarkUtils type-profile pollution into add-driving and cont…
dougqh Aug 26, 2026
cccabcd
Scope StringIndex-as-Set guidance to hit-dominated access patterns
dougqh Aug 26, 2026
1efce43
Pollute type dispatch in ImmutableMapBenchmark and refresh results
dougqh Aug 26, 2026
786bd68
Tighten benchmark javadoc wording per review
dougqh Sep 1, 2026
b5e9007
Extend BenchmarkUtils with ConcurrentHashMap and Map-dispatch pollution
dougqh Aug 26, 2026
f2b6e86
Wire BenchmarkUtils.polluteHashDispatch into the remaining map/set be…
dougqh Aug 26, 2026
dd3eba5
Record pollution-corrected results in SingleThreadedMapBenchmark
dougqh Aug 26, 2026
8c60545
Record pollution-corrected results in SingleThreadedSetBenchmark
dougqh Aug 26, 2026
0aea116
Record pollution rerun results in HashtableD1Benchmark
dougqh Aug 26, 2026
fe50efd
Record pollution rerun results in HashtableD2Benchmark
dougqh Aug 26, 2026
b683585
Record pollution rerun results in CaseInsensitiveMapBenchmark
dougqh Aug 26, 2026
c514c05
Record pollution rerun results in TagMapAccessBenchmark
dougqh Aug 26, 2026
6749897
Correct HashtableD1Benchmark's pollution-immunity rationale
dougqh Aug 26, 2026
35c3591
Correct HashtableD2Benchmark's pollution-immunity rationale
dougqh Aug 26, 2026
f6e3ca8
Attribute CaseInsensitiveMapBenchmark's rerun slowdown to JDK 8 on ARM64
dougqh Aug 26, 2026
e6175c5
Attribute TagMapAccessBenchmark's rerun slowdown to JDK 8 on ARM64
dougqh Aug 26, 2026
7f9fbf1
Record Java 17 rerun for HashtableD1Benchmark
dougqh Aug 26, 2026
7f6889d
Record Java 17 rerun for HashtableD2Benchmark
dougqh Aug 26, 2026
3d9b93a
Address bot review findings on benchmark pollution helper and javadoc
dougqh Sep 22, 2026
56a31d9
Record JDK 8 rerun results with fixed pollution helper for ThreadSafe…
dougqh Sep 22, 2026
acd9bd0
Replace JDK 8 rerun with clean same-JDK pollution comparison
dougqh Sep 22, 2026
ecbdd4f
Pollute compareTo and MapN dispatch in BenchmarkUtils
dougqh Sep 23, 2026
2c0117e
Add a perf-review rubric check for Map/Set JMH benchmark dispatch pol…
dougqh Sep 23, 2026
30c5de2
Merge remote-tracking branch 'origin/master' into dougqh/benchmarkuti…
dougqh Sep 23, 2026
71a383e
Re-pollute hash/compareTo dispatch every invocation, not once per trial
dougqh Sep 23, 2026
b1c09ce
Merge remote-tracking branch 'origin/master' into dougqh/benchmarkuti…
dougqh Sep 25, 2026
74435ac
Simplify pollution warm-up to a single scratch-free entry point
dougqh Sep 25, 2026
67516de
Merge remote-tracking branch 'origin/dougqh/benchmarkutils-map-set-po…
dougqh Sep 25, 2026
165dfc0
Correct benchmark dispatch claims and measure allocation directly
dougqh Sep 25, 2026
3878379
Correct the Object decoy's contribution in distinctEqualCopy javadoc
dougqh Sep 25, 2026
99ca351
Attribute the synchronized HashMap gap to lock contention, not dispatch
dougqh Sep 25, 2026
19c00ff
Refresh benchmark tables from a -prof gc run and retract stale claims
dougqh Sep 25, 2026
492066b
Document the branch-profile / escape-analysis interaction
dougqh Sep 25, 2026
1ce8fec
Reframe BenchmarkUtils around fork isolation as the root cause
dougqh Sep 25, 2026
59c58eb
Fix perf-review J12 check and clean up pollution-migration leftovers
dougqh Sep 25, 2026
754a5ed
Run hash-dispatch pollution once per JVM instead of once per thread
dougqh Sep 25, 2026
d6c6284
Refresh tables from the once-per-JVM run and rule out setup-allocatio…
dougqh Sep 28, 2026
49a25ac
Correct dispatch and API claims in benchmark javadocs
dougqh Oct 6, 2026
7adb401
Narrow HashtableD1Benchmark's setup-allocation finding
dougqh Oct 6, 2026
f41429c
Seed CaseInsensitiveMapBenchmark's lookup keys and refresh results
dougqh Oct 6, 2026
45cede4
Merge remote-tracking branch 'origin/master' into dougqh/benchmarkuti…
dougqh Oct 6, 2026
8493096
Attribute HashtableD1Benchmark's update ratio to the collector, not t…
dougqh Oct 6, 2026
a74b563
Refresh CaseInsensitiveMapBenchmark on the merged CHA-decoy code
dougqh Oct 6, 2026
184fbfd
Add BenchmarkUtils.warmUpArms to restore cross-arm profile pollution
dougqh Sep 25, 2026
c7b01bf
Rename Arm/arm/warmUpArms to Bench/bench/warmUp, collapse to boxing-t…
dougqh Sep 25, 2026
ec7bfa7
Add WarmUpEscapeAnalysisDemoBenchmark as evidence that BenchmarkUtils…
dougqh Sep 25, 2026
bf02bd7
Correct BenchmarkUtils.warmUp and demo benchmark javadocs
dougqh Oct 6, 2026
325b6f2
Count superclass @Benchmark methods in warmUp's coverage check
dougqh Oct 8, 2026
84615b7
Run the benchmark class's own @Setup in reflective warmUp
dougqh Oct 8, 2026
a883d9d
Reuse datadog.trace.api.function's TriFunction and TriConsumer in warmUp
dougqh Oct 8, 2026
5c80c7f
Drop the demo's scratch keys after warmUp and refresh on JDK 17
dougqh Oct 8, 2026
d97f076
Attribute the FlatHashtable dispatch numbers to a one-off measurement
dougqh Oct 13, 2026
85c926a
Apply review wording to BenchmarkUtils and HashtableD2Benchmark javadoc
dougqh Oct 13, 2026
3367947
Merge branch 'master' into dougqh/benchmarkutils-map-set-pollution
dougqh Oct 13, 2026
c498d60
Merge branch 'dougqh/benchmarkutils-map-set-pollution' into dougqh/be…
dougqh Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/perf-review/references/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ Refines the universal checks with JVM mechanics. Quarantined here, for the Java
- **J9 — `Objects.hash(...)` varargs / boxing hash on a hot path → `HashingUtils`** *(refines #1)*. The allocation is specific to the **varargs/boxing forms**: `Objects.hash(a, b, …)` allocates an `Object[]` per call and **boxes every primitive** arg; same for boxing primitives into a `new Object[]{…}` (or `Arrays.hashCode` over such an array). Per-span tag/key building, or a hot value object's `hashCode()` built this way, → a guaranteed per-call allocation + boxing. Fix: `datadog.trace.util.HashingUtils` — primitive `hash(long/int/boolean/char/…)` overloads (no boxing), `hash(Object,Object)` and `hash(int,int)` combiners (no array); for >2 fields fold pairwise through `hash(int,int)` (there is no varargs form, by design). flag-with-confidence for the varargs/boxing form — SEV-2/3. **Do NOT flag allocation-free combines** — a hand-rolled `31*h + Long.hashCode(x)` / `31*h + intField`, or `Arrays.hashCode` over an *existing primitive array*, allocates nothing (`HashingUtils` is itself 31-based); flagging them would recommend replacing already-correct code.
- **J10 — hot-path `String.format` / string munging → `Strings` (+ `SubSequence`)** *(refines #2)*. `String.format` parses the format string, boxes its args, and allocates on every call — never on a hot path; hand-rolled case-conversion, class/resource-name munging, blank-checks, and truncation recomputed per call qualify too. Fix: `datadog.trace.util.Strings` — allocation-aware `replace`/`truncate(CharSequence)`/`isBlank`/`getResourceName`/`getClassName`/…; for **transient substring compares** prefer a `SubSequence` view (J7); for plain assembly, direct concatenation beats `format`. flag-with-confidence for `String.format` on a hot path; flag-as-measure for borderline munging — SEV-2/3.
- **J11 — composite / multi-dimensional key maps on a hot path → `Hashtable` / `ConcurrentHashtable`** *(refines #1, #3)*. `Map<A, Map<B,V>>` nesting, or a `HashMap` keyed by a composite key (client-side stats, per-`(service, operation, …)` aggregation), allocates nested maps + `Entry` objects + boxes keys on the hot aggregation path. Fix: `datadog.trace.util.Hashtable` (single-threaded, composite-key D1/D2 tables — landed) or `datadog.trace.util.ConcurrentHashtable` (lock-free concurrent, **coming**) — positional composite keys, fewer allocations. flag-as-measure — SEV-2/3.
- **Toolkit availability — cite only what exists.** Available today: `Strings`, `SubSequence`, `HashingUtils`, `Hashtable` (all `datadog.trace.util`), `RE2J` (`com.google.re2j`). Coming (name as "coming", don't imply it's present): `ConcurrentHashtable`, `StringIndex` (immutable string set/map), `UTF8BytesString.Cache` (recurring-string interner), wider `IntegerCache` (http-status/port boxing), `DDCache` inlining. **J7–J11 route an *existing* #1/#2/#3 finding to a reusable fix — they are not new flag-triggers. Don't raise a finding you wouldn't have raised anyway; the posture (precision, silent-when-unsure, findings-cap-scales-with-diff — see `SKILL.md`) is unchanged.**
- **J12 — Benchmark validity: is the dispatch under test actually profile-dependent?** *(methodology check on a submitted benchmark, not a production hot-path finding — refines the flag-as-measure posture in J2)*. JMH forks a fresh JVM per benchmark method, so each arm is compiled in a process that has only ever executed that one arm — leaving shared `hashCode()`/`equals()`/`compareTo` call sites unrealistically clean compared with production, where erasure makes them serve every map in the application. This only affects the measurement when the receiver type is **not statically deducible at the benchmark's own call site**: if the key is a final class (`String`) or an exact type from an allocation, C2 devirtualizes on that proof and never consults the profile, so pollution is a no-op that only adds setup allocation. Note that one key class per benchmark is normal and correct — a production `Map<String, V>` call site is monotyped too; variety at the caller is not what makes the internal site megamorphic. **flag-as-measure** when a Map/Set JMH benchmark's measured operation dispatches on a non-final key type, or reaches a strategy/comparator interface with several loaded implementors, and its `@Setup` does not pollute — fix: call `BenchmarkUtils.warmUpHashDispatch(Blackhole)` from `@Setup(Level.Trial)` (trial level, not per-iteration: it runs 50,000 allocation-heavy passes). A one-off measurement made while working on #12298 (not recorded in a committed benchmark) showed both sides — `FlatHashtableIteratorBenchmark`'s four-implementor strategy dispatch fell 37.9 → 18.1 M ops/s when its profile was poisoned, while the `String`-keyed map benchmarks in the same module were unaffected, with LogCompilation confirming the dispatch resolved statically.
- **Toolkit availability — cite only what exists.** Available today: `Strings`, `SubSequence`, `HashingUtils`, `Hashtable`, `BenchmarkUtils.warmUpHashDispatch(Blackhole)` for JMH setup (all `datadog.trace.util`), `RE2J` (`com.google.re2j`). Coming (name as "coming", don't imply it's present): `ConcurrentHashtable`, `StringIndex` (immutable string set/map), `UTF8BytesString.Cache` (recurring-string interner), wider `IntegerCache` (http-status/port boxing), `DDCache` inlining. **J7–J11 route an *existing* #1/#2/#3 finding to a reusable fix — they are not new flag-triggers. Don't raise a finding you wouldn't have raised anyway; the posture (precision, silent-when-unsure, findings-cap-scales-with-diff — see `SKILL.md`) is unchanged.**
- **J5 — Cardinality-sensitive aggregator** *(domain-specialized #3)*. Some structures are invisible to the generic "unbounded collection" check because the risk is *cardinality*, not raw size: a config- or user-driven value (tag key, resource name, HTTP URL) feeding a **cardinality-sensitive aggregator** (e.g. the conflating metrics aggregator — each unique label combination = one aggregate; a `maxAggregates` cap bounds OOM but high-cardinality input *thrashes* it: constant eviction, garbled metrics). flag-with-confidence when config/user-driven values reach an aggregator with a per-key budget — **SEV-1** (same class as unbounded memory: correctness + heap impact). Fix: bound the source cardinality before it enters the aggregator, or use sentinel substitution for over-cap values. This surfaced on merged production code more than once in back-test calibration — the capstone pattern where the bot's value concentrates.

## Instrumentation (ByteBuddy Advice) idioms — dd-trace-java-specific fixes
Expand Down
21 changes: 21 additions & 0 deletions .agents/skills/perf-review/references/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,27 @@ escape analysis eliminates *local* short-lived allocations, but only when the ob
Stored in a map, returned, captured by a lambda, or passed to a non-inlined virtual call: it
escapes, and it's real.

**Map/Set JMH benchmarks whose dispatch is actually reachable — treat as unverified.** JMH forks a
fresh JVM per benchmark method, so each arm is compiled in a process that has only ever seen that
one arm's key types. A collection's internal `hashCode()`/`equals()` (or `compareTo`) call site is
shared JVM-wide in production and hit with every key type the process uses, so the benchmark's
version of it is unrealistically clean.

But this only matters when the receiver type is **not statically deducible at the benchmark's own
call site**. If the key is a final class (`String`) or an exact type from an allocation, C2
devirtualizes on that proof and never consults the profile — pollution is then a no-op, and
demanding it only adds setup allocation. Having one key class per benchmark is normal and correct;
a real `Map<String, V>` call site is monotyped too. Erasure is what makes the *internal* site
megamorphic, not variety at the caller.

A one-off measurement made while working on #12298 (not recorded in a committed benchmark) showed
both sides: `FlatHashtableIteratorBenchmark`'s strategy dispatch fell 37.9 → 18.1
M ops/s (2.1x) when its profile was poisoned, because the strategy is an interface with four loaded
implementors; the `String`-keyed map benchmarks in the same module were unaffected, and
LogCompilation confirmed the dispatch was resolved statically. Check that `@Setup` calls
`datadog.trace.util.BenchmarkUtils.warmUpHashDispatch(Blackhole)` when the dispatch is genuinely
profile-dependent.

**EA claims for scope/wrapper objects spanning I/O — treat as unverified.** A microbenchmark
tight-loop can show zero allocation for a scope or wrapper object because C2 inlines through
everything and scalar-replaces it. In production, scopes almost always wrap I/O — and C2 cannot
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package datadog.trace.api;

import datadog.trace.util.BenchmarkUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -56,6 +57,45 @@
* TagMapAccessBenchmark.insert_hashMap_builderStyle thrpt 5 28057827.189 ± 1359655.664 ops/s
* TagMapAccessBenchmark.insert_via_ledger thrpt 5 41169656.095 ± 773264.754 ops/s
* </code>
*
* <p>Rerun on the same machine and JDK (Zulu 17.0.7, {@code @Fork(2)}, {@code @Threads(8)}) with a
* new top-level {@code @Setup(Level.Trial)} calling {@link BenchmarkUtils#warmUpHashDispatch} (this
* file had none before):
*
* <pre>{@code
* Benchmark Score (M ops/s) Error
* getEntry 97.00 ± 2.37
* getObject 97.70 ± 0.88
* insert 52.67 ± 1.28
* insert_hashMap 69.57 ± 0.65
* insert_hashMap_builderStyle 29.51 ± 0.50
* insert_via_ledger 37.15 ± 0.78
* }</pre>
*
* <p>Pollution left these paths materially unchanged, but not for the same reason on both sides.
* {@code TagMap} is typed to {@code String} keys throughout ({@link TagMap#getEntry(String)},
* {@link TagMap.Ledger#set(String, Object)}) and compares against a {@code String}-declared field,
* so it has no {@code Object}-typed dispatch site to pollute — it is insulated by construction,
* whatever the JIT decides. {@code HashMap} does have such sites: {@code getNode} calls {@code
* hashCode()}/{@code equals()} on an {@code Object}-declared key. Erasure makes that one bytecode
* index serve every map in the application, so in production it sees many receiver types — the
* condition {@code warmUpHashDispatch} approximates here by broadening that profile. Even so,
* {@code get}/{@code put} inline into a caller holding a statically exact {@code String}, so C2
* sharpens the argument and devirtualizes without consulting that profile. That is a realistic
* condition rather than an artifact — plenty of production call sites do hand HashMap a statically
* known key type, and it legitimately gets that benefit. Where the key type is not deducible, the
* broadened profile governs HashMap and still does not reach TagMap.
*
* <p>Consistent with that, most entries move by a couple of percent with overlapping intervals, and
* the larger moves go up, which pollution cannot cause.
*
* <p>The exception is {@code insert_via_ledger}, down about 10% (41.17 to 37.15) with
* non-overlapping intervals. That is the most allocation-heavy path measured, and {@code
* warmUpHashDispatch} itself allocates heavily before measurement starts, so setup churn shifting
* GC state for the trial is a likelier cause than dispatch. Not investigated further.
*
* <p>Both tables are the same machine and JDK, but the baseline above ran at {@code Cnt 5} against
* {@code Cnt 10} here, and on a different day, so small differences carry no weight.
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
Expand Down Expand Up @@ -95,6 +135,11 @@ public class TagMapAccessBenchmark {
}
}

@Setup(Level.Trial)
public void setUp(Blackhole bh) {
BenchmarkUtils.warmUpHashDispatch(bh);
}

/**
* Pre-populated read map, PER-THREAD ({@code Scope.Thread}): each thread owns its own map so
* reads don't contend on shared mutable state under {@code @Threads(8)}.
Expand Down
Loading
Loading