Skip to content

Add JMH benchmarks comparing DDCache, SimpleUtf8Cache, GenerationalUtf8Cache - #12538

Draft
dougqh wants to merge 2 commits into
masterfrom
dougqh/utf8-cache-benchmarks
Draft

dougqh wants to merge 2 commits into
masterfrom
dougqh/utf8-cache-benchmarks

Conversation

@dougqh

@dougqh dougqh commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Adds a JMH benchmark harness comparing the three UTF-8-encoding cache strategies available for tag-value encoding in trace serialization: the general-purpose DDCache, and the purpose-built SimpleUtf8Cache / GenerationalUtf8Cache.

  • Utf8CacheSizeBenchmark (new): sweeps cache capacity (10 / 100 / 1000) across NOCACHE / DDCACHE / SIMPLE / GENERATIONAL against the same synthetic tag/value workload, run with -prof gc to capture allocation alongside throughput.
  • Utf8Benchmark / Utf8ConcurrentBenchmark: added a DDCache arm alongside the existing Simple/Generational arms, plus doc comments clarifying the single-threaded-recalibrate contract these benchmarks assume (matches how the caches are actually driven in the writer today).

No production code changes — benchmark-only.

Motivation

We have three different caching implementations doing effectively the same job (string → UTF-8 bytes) with no head-to-head data on how they compare across capacities, or against not caching at all. This gives us that data so future decisions about which cache to use/keep can be evidence-based rather than assumed.

Results (capacity sweep, -prof gc, normalized)

Throughput in ops/s, allocation in bytes/op, GC rate normalized to counts/sec (raw gc.count/gc.time are cumulative sums over the run and aren't comparable across different fork/iteration counts without this normalization).

capacity kind ops/s alloc B/op alloc vs. NOCACHE alloc MB/s GC/s GC time %
10 NOCACHE 2007.9 1,247,228 2388.3 3.98 0.22%
10 DDCACHE 1315.7 1,447,111 +16.0% (worse) 1815.7 3.03 0.17%
10 SIMPLE 1401.6 1,305,537 +4.7% (worse) 1745.1 2.91 0.16%
10 GENERATIONAL 1205.6 1,318,938 +5.7% (worse) 1516.4 2.53 0.15%
100 NOCACHE 2003.6 1,247,238 2383.1 3.96 0.22%
100 DDCACHE 1397.4 1,248,407 +0.1% (~breakeven) 1663.7 2.77 0.16%
100 SIMPLE 1451.3 1,161,541 −6.9% 1607.6 2.68 0.15%
100 GENERATIONAL 1422.4 1,046,133 −16.1% 1419.0 2.36 0.13%
1000 NOCACHE 1966.6 1,247,231 2339.1 3.88 0.22%
1000 DDCACHE 1648.9 1,034,356 −17.1% 1626.6 2.72 0.20%
1000 SIMPLE 1641.3 974,316 −21.9% 1525.1 2.54 0.14%
1000 GENERATIONAL 1709.5 961,062 −23.0% 1566.8 2.61 0.15%

GC/s and GC time % are gc.count/gc.time normalized by total measurement time (NOCACHE: 1 fork × 5 × 10s = 50s; the three caches: 5 forks × 5 × 10s = 250s) — the raw JMH sums aren't comparable across runs with different fork counts without this.

Key findings

  • All three caches lose on raw throughput to the uncached baseline at every capacity tested. String.getBytes(UTF_8) is intrinsified, so a cache lookup is strictly more expensive per-op than just re-encoding — these caches only pay off on allocation, not throughput. This confirms the "bump-pointer bar": any cache meant to reduce app-thread allocation has to beat a TLAB bump-pointer allocation, which is a very cheap operation to beat.
  • Allocation reduction is real but smaller than naively expected — at capacity 1000 all three caches reduce allocation by only ~17-23%, not the ~90% you'd guess from hit rate alone. Root cause: the synthetic workload builds each value via tag + int string concatenation on every lookup (Utf8Workload.nextStandardValue/nextCustomValue), which allocates a fresh String regardless of whether the subsequent UTF-8 encoding is cached. For ASCII-compact strings, getBytes(UTF_8) is itself just a copy of the existing byte array, so a cache hit only saves that one array copy — not the string-construction cost. This structurally caps the max possible allocation win for any UTF8 cache on this workload.
  • DDCache has a small but consistent allocation disadvantage vs. Simple/Generational, from its generic Pair.of(key, value) wrapper allocated on every stored miss (FixedSizeCache.produceAndStoreValue). Simple/Generational avoid this on one-off values via a marker/bloom-style gate (Caching.mark) that defers the wrapper allocation (CacheEntry) to a value's second observed touch — same immutable-wrapper-for-lock-freedom pattern as DDCache, but with the wrapper cost paid only for values that actually recur. The marker itself is advisory rather than authoritative: a wrong mark only costs a redundant allocation on the next touch, it can never cause the cache to return incorrect data, which is what keeps the design simple to reason about.
  • DDCache's disadvantage at very small capacity (10) is smaller than expected, since at that capacity all caches thrash heavily regardless of strategy.
  • Simple and Generational are close to equal at capacity 100, with Generational pulling ahead at capacity 1000 — worth a follow-up look at what capacity the caches actually run at in production before considering whether Simple's extra complexity over Generational is still earning its keep.

Additional Notes

Opened as a standalone side task, decoupled from #12474 (the @ForegroundSafe/@BackgroundOnly annotation-checker PR).

Contributor Checklist

  • Format the title according to the contribution guidelines
  • Assign the type: and (comp: or inst:) labels in addition to any other useful labels
  • Avoid using close, fix, or any linking keywords when referencing an issue
  • Update the CODEOWNERS file on source file addition, migration, or deletion (n/a — no new module/package)
  • Update public documentation with any new configuration flags or behaviors (n/a — benchmark-only, no config/behavior change)
  • Once approved, use merge queue to merge the PR

Test plan

  • Ran Utf8CacheSizeBenchmark locally (./gradlew :dd-trace-core:jmh -Pjmh.include=Utf8CacheSizeBenchmark -Pjmh.profilers=gc) across all capacity/kind combinations plus a NOCACHE-only baseline run.
  • Benchmark-only change; no functional code touched, so no other automated test coverage applies.

🤖 Generated with Claude Code

…f8Cache

Adds Utf8CacheSizeBenchmark, which sweeps cache capacity (10/100/1000)
across all three UTF8-caching strategies plus a NOCACHE baseline, to
compare their throughput and allocation profiles at different operating
points. Also updates Utf8Benchmark and Utf8ConcurrentBenchmark with a
DDCache arm alongside the existing Simple/Generational arms, and
doc comments explaining the single-threaded vs. concurrent recalibrate
contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dougqh dougqh added comp: core Tracer core tag: performance Performance related changes tag: no release notes Changes to exclude from release notes type: refactoring tag: ai generated Largely based on code generated by an AI or LLM labels Sep 16, 2026
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dd-octo-sts

dd-octo-sts Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

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 14.07 s 14.07 s [-0.9%; +0.9%] (no difference)
startup:insecure-bank:tracing:Agent 13.00 s 13.03 s [-1.1%; +0.6%] (no difference)
startup:petclinic:appsec:Agent 16.42 s 16.40 s [-6.1%; +6.3%] (unstable)
startup:petclinic:iast:Agent 16.90 s 16.53 s [-2.0%; +6.4%] (no difference)
startup:petclinic:profiling:Agent 16.65 s 16.69 s [-1.2%; +0.7%] (no difference)
startup:petclinic:sca:Agent 16.88 s 16.57 s [+0.7%; +3.0%] (maybe worse)
startup:petclinic:tracing:Agent 15.97 s 16.17 s [-2.2%; -0.2%] (maybe better)

Commit: 7e256d7e · 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.

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 tag: performance Performance related changes type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant