Skip to content

Recalibrate the UTF8 caches every N spans instead of per span#11945

Open
dougqh wants to merge 6 commits into
masterfrom
dougqh/utf8-recalibrate-interval
Open

Recalibrate the UTF8 caches every N spans instead of per span#11945
dougqh wants to merge 6 commits into
masterfrom
dougqh/utf8-recalibrate-interval

Conversation

@dougqh

@dougqh dougqh commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Reduces the CPU cost of the UTF8 caches
The caches were designed to be recalibrated periodically, but they don't need to recalibrate on every span

Motivation

Reduces the biggest CPU cost in the background threads
GenerationalUtf8Cache.recalibrate was 18.3% of background thread CPU activity

Additional Notes

From Claude...

TraceMapperV0_4 recalibrated the UTF8 caches (TAG_CACHE, VALUE_CACHE) on every span. recalibrate() is O(cacheSize) — it decays every entry's score, purges cold ones, and retunes the promotion threshold — so driven per-span it became the largest single consumer of serializer-thread CPU.

This gates it to once every RECALIBRATE_SPAN_INTERVAL (512) spans, via a span counter and a pure, unit-tested shouldRecalibrate() predicate.

Evidence

Baseline profile — Spring PetClinic multi-endpoint under JMeter, JFR execution sampling, background (dd-trace-processor) thread:

  18.3%  GenerationalUtf8Cache.recalibrate   ← #1 hotspot on the serializer thread
  15.1%  FlushingBuffer.put
  10.9%  StringLatin1.hashCode
  ...

Measured A/B — same-base build (the only diff is this commit), same workload, JFR:

dd-trace-processor recalibrate share:
  baseline (per-span):   348 / 1898 samples =  18.3%   ← #1
  change  (every 512):     3 / 1775 samples =   0.2%   ← eliminated
  • recalibrate drops out of the serializer's hot methods entirely.
  • The serializer thread gets ~6.5% lighter (1898 → 1775 samples) while serving ~3% more requests — more spans serialized for less CPU.
  • Throughput moved +3% (3.10M → 3.20M JMeter samples), but that's directional only: one uncounterbalanced run on a roomy -Xmx1g box, which is the regime where freed background CPU is least likely to convert. On a core-bound or CPU-limited-container host, more of it should land as throughput.

Why it's safe

  • Cache correctness is unaffectedrecalibrate only decays/retunes; it never changes the bytes getUtf8 returns.
  • Cache effectiveness is preserved — the caches adapt to shifts in tag-value cardinality on a timescale far longer than a single span, so recalibrating every 512 spans still re-tunes in a fraction of a second at real span rates. Per-span was never the intended cadence.

Impact

A background serializer-thread CPU reduction, alloc-neutral. It converts to application-thread throughput/latency under core contention (busy hosts, CPU-limited containers where the cgroup quota throttles the whole process) and protects GC/JIT headroom where cores are spare.

🤖 Generated with Claude Code

TraceMapperV0_4 called TAG_CACHE.recalibrate()/VALUE_CACHE.recalibrate() on
every span. recalibrate() is O(cacheSize) (decay every entry, purge cold ones,
retune the promotion threshold), so per-span it dominated serializer-thread CPU
— a PetClinic multi-endpoint JFR put GenerationalUtf8Cache.recalibrate at ~17%
of the dd-trace-processor thread's samples, its #1 hotspot.

Per-span was never the intended cadence: the caches adapt to shifts in tag-value
cardinality on a timescale far longer than one span, so recalibrating once every
RECALIBRATE_SPAN_INTERVAL (512) spans preserves their effectiveness while cutting
recalibrate calls ~500x. Gated via a span counter and a pure, unit-tested
shouldRecalibrate() predicate.

Correctness of the caches is unaffected (recalibrate only decays/retunes; it
never changes returned bytes). The win is CPU on the background serializer
thread, which converts to application-thread throughput/latency under core
contention (busy boxes, CPU-limited containers) and protects GC/JIT headroom
elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh dougqh added comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM type: refactoring labels Jul 14, 2026
@datadog-official

datadog-official Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
Patch Coverage: 20.00%
Overall Coverage: 57.15% (-0.03%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 996c42e | Docs | Datadog PR Page | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Jul 14, 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.02 s 13.92 s [+0.1%; +1.4%] (maybe worse)
startup:insecure-bank:tracing:Agent 12.94 s 13.03 s [-1.3%; -0.1%] (maybe better)
startup:petclinic:appsec:Agent 16.86 s 16.56 s [+0.8%; +2.9%] (maybe worse)
startup:petclinic:iast:Agent 16.93 s 16.98 s [-1.0%; +0.3%] (no difference)
startup:petclinic:profiling:Agent 16.71 s 16.85 s [-1.9%; +0.3%] (no difference)
startup:petclinic:sca:Agent 16.93 s 16.78 s [-0.1%; +1.9%] (no difference)
startup:petclinic:tracing:Agent 16.10 s 15.60 s [-1.2%; +7.7%] (no difference)

Commit: 996c42e6 · 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 and others added 2 commits July 14, 2026 09:37
…che guard

- Trim the RECALIBRATE_SPAN_INTERVAL comment to explain intent (control
  recalibration frequency) without the profiling numbers (those live in the PR).
- Move the span-counter increment inside shouldRecalibrate() so the gate is
  self-contained.
- Only consult shouldRecalibrate() when a cache is actually present, so the
  cache-off config pays nothing (and the static-final guard folds away when
  caches are enabled).
- Test now asserts the window-invariant (any 2*interval window crosses exactly
  two boundaries), since shouldRecalibrate() is now stateful.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Allocate SPAN_COUNTER only when a cache exists, and use its presence as the
"recalibration enabled" gate inside shouldRecalibrate(). This drops the explicit
(TAG_CACHE != null || VALUE_CACHE != null) guard from the call site — accept()
now just checks shouldRecalibrate() — while keeping the cache-off config free
(static-final null folds the whole block away).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh dougqh added the tag: performance Performance related changes label Jul 14, 2026
@dougqh dougqh marked this pull request as ready for review July 14, 2026 14:00
@dougqh dougqh requested a review from a team as a code owner July 14, 2026 14:00
@dougqh dougqh requested a review from amarziali July 14, 2026 14:00

@datadog-official datadog-official 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.

Datadog Autotest: PASS

More details

Cache recalibration interval logic is sound. The PR gates recalibrate() calls to once per 512 spans via an atomic counter and power-of-two bitmask, reducing serializer-thread CPU from 18.3% to 0.2% without affecting cache correctness—recalibrate() only tunes efficiency, never changes the bytes returned. Thread-safe atomic increment, correct bitmask arithmetic, proper null-guard when caches are disabled, and comprehensive unit tests all validate the change.

Was this helpful? React 👍 or 👎

📊 Validated against 5 scenarios · Open Bits AI session

🤖 Datadog Autotest · Commit ea5f842 · What is Autotest? · Any feedback? Reach out in #autotest

…n firstSpanWritten

firstSpanWritten is only touched on the single serializer thread. The new static
AtomicLong recalibration counter makes SpotBugs treat this class as multithreaded,
which retroactively flags the pre-existing non-volatile boolean. Suppress it, matching
the same pattern already used in TracerHealthMetrics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review: the span counter drives a recalibration cadence, not an exact
count, and the serializer is single-threaded, so AtomicLong is unnecessary
-- a plain long suffices (a benign race would at most nudge when recalibrate
fires). A static-final boolean gates it so the JIT still folds the whole
block away when both caches are off. Also drops the now-useless
firstSpanWritten SpotBugs suppression: with the AtomicLong gone the class is
no longer flagged as multithreaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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: performance Performance related changes type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants