Skip to content

Lazy-allocate error latency histogram on AggregateEntry - #11478

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 7 commits into
masterfrom
dougqh/lazy-error-latencies
Jun 3, 2026
Merged

Lazy-allocate error latency histogram on AggregateEntry#11478
gh-worker-dd-mergequeue-cf854d[bot] merged 7 commits into
masterfrom
dougqh/lazy-error-latencies

Conversation

@dougqh

@dougqhdougqh commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Defer errorLatencies histogram allocation until the first error is recorded on an entry. Most entries never see an error in their lifetime; previously each one carried a ~60-80 byte empty DDSketchHistogram for life.
  • Across a full 2048-entry table, saves ~150 KB if 95% of entries never error (the typical case).
  • SerializingMetricWriter caches the serialized form of an empty histogram (~17 bytes) and emits those cached bytes when an entry's errorLatencies is null, so the wire format is byte-identical to before.

Background

Extracted from #11389, where the same change was bundled with cardinality- and peer-tag-related work. This PR is just the lazy-errorLatencies piece; it sits between #11382 and #11387 so it can ship without depending on the cardinality machinery in #11387.

Trade-off

Entries that do see an error retain the histogram across clear() (cleared, not nulled). An always-erroring entry allocates exactly once. Same total allocation as before for that path.

Throughput benchmarks

This is a heap-footprint change, not a CPU one — the consumer's hot path is unchanged. The bench suite was re-run anyway as a sanity check to confirm no throughput regression vs the #11382 base. Same machine state and JMH config as the rest of the stack's runs (8 producer threads, 2×15s warmup + 5×15s, 1 fork, throughput mode).

Bench (ops/s)v1.62.0master#11382this PR (#11478)
Adversarial444,290 ± 1,616,93714,276,351 ± 1,091,13832,556,300 ± 4,321,49030,609,314 ± 6,944,664
HighCardinalityResource4,854,335 ± 1,214,2338,168,005 ± 3,493,71635,739,452 ± 2,556,68434,552,088 ± 4,687,212
HighCardinalityPeer6,902,209 ± 368,64110,110,142 ± 3,380,59437,638,634 ± 6,673,33735,491,425 ± 4,970,576

#11478 vs #11382 is within the per-run error bar on every bench (0.94×–0.97×) — statistically indistinguishable. The CPU-side hot path didn't change: recordOneDuration now calls errorLatenciesForWrite() instead of reading a final field, but that's a single-field-load-and-branch on every entry's first error and a direct field load thereafter, which the JIT inlines flat. aggregateDropped counts are also in line with #11382, confirming the lazy field doesn't perturb the table-cap behavior.

The actual win — the ~150 KB heap reclamation at full table cap when 95% of entries never error — isn't observable in a throughput bench. It would show up in jol-based per-entry footprint inspection (one fewer histogram per entry) or in a long-running profile of allocated-bytes-per-cycle (errorLatencies allocation amortizes from "one per unique key" to "one per unique error-emitting key").

Test plan

  • :dd-trace-core:test — metrics tests pass
  • No behavior change to the client-stats wire payload

🤖 Generated with Claude Code

@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Pipelines

Fix all issues with BitsAI

⚠️ Warnings

🚦 5 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-java | java-startup-parallel-check-slo-breaches View in DatadogGitLab

See error Failed to generate Markdown threshold comparison report due to missing scenarios for comparison.

DataDog/apm-reliability/dd-trace-java | java-startup-parallel-generate-slos View in DatadogGitLab

See error Failed to change directory to 'artifacts'. Directory does not exist.

DataDog/apm-reliability/dd-trace-java | java-startup-parallel-upload-to-bp-api View in DatadogGitLab

See error Execution of Verify files exist failed. Cannot access '/go/src/github.com/DataDog/apm-reliability/dd-trace-java/artifacts/candidate-*.converted.json': No such file or directory.

View all 5 failed jobs.

Useful? React with 👍 / 👎

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

@dd-octo-sts

dd-octo-stsBot commented May 27, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

SuiteStatus
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
ScenarioCandidatemasterΔ (95% CI of mean)
startup:insecure-bank:iast:Agent14.01 s14.00 s[-1.0%; +1.2%] (no difference)
startup:insecure-bank:tracing:Agent12.99 s12.93 s[-0.3%; +1.4%] (no difference)
startup:petclinic:appsec:Agent15.75 s16.44 s[-12.4%; +4.0%] (unstable)
startup:petclinic:iast:Agent16.57 s16.66 s[-1.8%; +0.7%] (no difference)
startup:petclinic:profiling:Agent15.53 s16.49 s[-14.2%; +2.6%] (unstable)
startup:petclinic:tracing:Agent15.91 s15.84 s[-1.1%; +2.0%] (no difference)

Commit:d99e9029 · 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 marked this pull request as ready for review May 27, 2026 19:25
@dougqh
dougqh requested a review from a team as a code ownerMay 27, 2026 19:25
@dougqh
dougqh requested a review from amarzialiMay 27, 2026 19:25
@dd-octo-stsdd-octo-stsBot added the tag: ai generated Largely based on code generated by an AI or LLM label May 27, 2026
}
}

private byte[] emptyHistogramBytesCache;

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.

this can be final and initialised early? I don't think it will make a big difference. Also the field should be placed up among the other fields and not among methods for clarity

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Addressed — field moved up to the instance fields block. Kept lazy rather than final/eager: Histogram.newHistogram() requires the Histograms factory to be registered, and SerializingMetricWriter is constructed during tracer startup before that registration completes, so eager init would throw. Added an inline comment on the field explaining this. The single-writer invariant (aggregator thread only) means no synchronization is needed either.

(Reply by Claude Sonnet 4.6)

@dougqhdougqhJun 1, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Another way that we could address the cycle is by placing this field inside a helper class. That would allow us to make the field final and let the reference be constant propagated.

Base automatically changed from dougqh/optimize-metric-key to masterMay 29, 2026 15:46
Each AggregateEntry allocated two DDSketchHistograms in its constructor
(ok + error latencies). DDSketchHistogram wraps a DDSketch + lazy store,
roughly 60-80 bytes per histogram even when empty. Most spans aren't
errors, so most entries' errorLatencies sit empty for life.
Now the field starts null. recordOneDuration lazy-allocates on the first
error; if no error ever lands on the entry, it stays null and ~80 bytes
of empty-histogram overhead are reclaimed. Across a full 2048-entry
table that's ~150 KB if 95% of entries never error -- the typical case.
For the wire format, SerializingMetricWriter caches the serialized form
of an empty histogram (~17 bytes) on first use and writes those cached
bytes when an entry's errorLatencies is null. The cache is per-writer
(not a global static) so each writer instance picks up the Histograms
factory state at the time of its first report, avoiding races with test
setup that registers the DDSketch factory at varying points.
Trade-off: entries that DO see an error retain the histogram across
clear() (just cleared, not nulled), so always-erroring entries allocate
exactly once. Same total allocation as before for that case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dougqh
dougqhforce-pushed the dougqh/lazy-error-latencies branch from f2ee559 to 0c658ddCompareJune 1, 2026 15:30
@dd-octo-sts

dd-octo-stsBot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Hi! 👋 Thanks for your pull request! 🎉

To help us review it, please make sure to:

  • Add at least one type, and one component or instrumentation label to the pull request

If you need help, please check our contributing guidelines.

dougqhand others added 3 commits June 1, 2026 13:08
…ports
- Move emptyHistogramBytesCache up to the instance fields block
- Import java.nio.ByteBuffer and datadog.metrics.api.Histogram; drop FQNs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dougqh

Copy link
Copy Markdown
ContributorAuthor

Addressed @amarziali's two comments:

  1. Field placement / eager init: emptyHistogramBytesCache moved up to the instance fields block. Kept lazy (not final/eager) because Histogram.newHistogram() requires the Histograms factory to be registered, and SerializingMetricWriter is constructed during tracer startup before that registration completes — eager init would throw. Added an inline comment explaining this reasoning. The single-writer invariant (aggregator thread only) means no synchronization is needed on the field either.

  2. FQNs: Imported java.nio.ByteBuffer and datadog.metrics.api.Histogram; replaced FQNs in the method body.

(Comment written by Claude Sonnet 4.6)

@dougqh

Copy link
Copy Markdown
ContributorAuthor

I want to collect benchmarking data before merging this. I'm still working on that.

@dougqh

Copy link
Copy Markdown
ContributorAuthor

Benchmark results (2026-06-02, clean-reboot machine)

JMH — 3-fork, no competing processes

Benchmarkold master (≈1.62)master 1.64.0#11478
HighCardinalityPeer7.13M39.15M ± 1.59M39.61M ± 1.03M
HighCardinalityResource8.49M37.50M ± 1.41M40.30M ± 2.25M

#11478 at parity with master. Both ~5× over the pre-#11382 baseline.

Single-endpoint petclinic (n=3, 3-min cooling)

Heap1.62#11478
96m6,636–7,1096,588–6,905
192m8,735 (warm run)10,153–10,268 (warm runs)
256m10,048–11,30910,254–10,373

Multi-endpoint — 32 threads, 10 endpoints (n=2, 3-min cooling)

Heap1.62master/#11500#11478
96m3,714 / 3,4873,672 / 7653,586 / 3,418
192m8,402 / 7,5928,324 / 1,8398,400 / 8,073
256m3,791 / 5,3883,477 / 4,1588,764 / 9,125

All three agents are at parity on 96m and 192m. 256m results are sensitive to sequential thermal load — the lower numbers for 1.62 and master reflect a warmer machine at that point in the sweep, not a design difference. #11478's clean 256m numbers reflect more cooling time between sweeps.

No regression from the lazy histogram change across any benchmark dimension.

(Results by Claude Sonnet 4.6)

Precompute Arrays.hashCode(peerTagSchema.names) once at schema construction
and read it from the field on the AggregateEntry.hashOf hot path instead of
recomputing per publish. The schema is shared across many publishes; the
per-publish recomputation was a top aggregator-thread sample in the 64m CPU
profile. Identified by the 64m JFR profile alongside the park/unpark change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dougqh

Copy link
Copy Markdown
ContributorAuthor

Added one follow-on optimization cherry-picked from dougqh/css-ring-buffer: cache PeerTagSchema.namesHash (commit 07bb401a21).

AggregateEntry.hashOf(snapshot) was calling Arrays.hashCode(peerTagSchema.names) on every snapshot publish — O(n) over the peer tag name array. Since PeerTagSchema is immutable after construction, the hash is now computed once in the constructor and stored as namesHash. The call site becomes a single field read.

Also tightens the peerTagValues hashing gate: the scratch buffer is reusable across publishes and may carry stale contents when no peer tags fired; it now only contributes to the hash when peerTagSchema != null, matching the matches() contract.

(Note by Claude Sonnet 4.6)

dougqh added a commit that referenced this pull request Jun 2, 2026
Picks up two changes from the base branch:
- Lazy-allocate error latency histogram on AggregateEntry (#11478)
- Cache PeerTagSchema.namesHash to avoid recomputing Arrays.hashCode on
every snapshot publish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dougqhdougqh added comp: metrics Metrics tag: performance Performance related changes type: feature Enhancements and improvements labels Jun 3, 2026
@dougqh
dougqh enabled auto-merge June 3, 2026 17:45
@dougqh
dougqh added this pull request to the merge queueJun 3, 2026
@dd-octo-sts

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351Bot commented Jun 3, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-06-03 18:42:04 UTC ℹ️ Start processing command /merge


2026-06-03 18:42:20 UTC ℹ️ MergeQueue: waiting for PR to be ready

This pull request is not mergeable according to GitHub. Common reasons include pending required checks, missing approvals, or merge conflicts — but it could also be blocked by other repository rules or settings.
It will be added to the queue as soon as checks pass and/or get approvals. View in MergeQueue UI.
Note: if you pushed new commits since the last approval, you may need additional approval.
You can remove it from the waiting list with /remove command.


2026-06-03 18:43:22 UTC ℹ️ MergeQueue: merge request added to the queue

The expected merge time in master is approximately 1h (p90).


2026-06-03 19:59:08 UTC ℹ️ MergeQueue: This merge request was merged

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Jun 3, 2026
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854dBot merged commit 3284485 into masterJun 3, 2026
572 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854dBot deleted the dougqh/lazy-error-latencies branch June 3, 2026 19:59
@github-actionsgithub-actionsBot added this to the 1.64.0 milestone Jun 3, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: metricsMetricstag: ai generatedLargely based on code generated by an AI or LLMtag: performancePerformance related changestype: featureEnhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dougqh@amarziali