Uh oh!
There was an error while loading. Please reload this page.
Add TagMap read-through support - trace / span tag split mechanism (phase 1a) - #11789
Conversation
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
dougqh
commented
Jun 29, 2026
Inlining gate — off-path ( |
getEntry | decision | |
|---|---|---|
| master | monolith, 90 B | inline (hot) |
| branch | getEntry 51 B → getLocalEntry 24 B → findInBucket 45 B | all inline (hot) |
Same fully-inlined read — the branch inlines a 120 B chain of tiny methods where master inlines a 90 B monolith (+30 B for the parent != null check + framing, all far under FreqInlineSize 325). Cold-site "callee is too large" is identical on both (> the 35 B cold cutoff). So the split is inlining-neutral.
forEach: the read-through parent loop was extracted to per-variant forEachParent(...) (called only when parent != null), so off-path forEach is back to ~90 B (it had grown to 242 B inline) and compiles as its own loop unit exactly as before; the parent-loop code is out of line and dead when parent == null.
Writes: descoped — setTag → TagInterceptor → getAndSet is already a non-inlinable big method due to the interceptor cascade, so a write-side inlining measurement is confounded. The one gated removedFromParent != null field-check is noise against that; meaningful write-path inlining waits on interceptor retirement.
Not yet measured: the with-parent hot path (read-through active) — there's no benchmark with a parent attached yet. That belongs on the consumer PR's span-level -prof gc benchmark, which also demonstrates the per-span allocation win.
dougqh
commented
Jun 30, 2026
Measured read-through win (quiet box, firmed)
The headline is the shape, not a single number:
Reliability: alloc deterministic (±0.001 over 25 samples); throughput tight (±2–5M). Honest attribution / scope: the alloc delta is bucket structure ( |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
3a0a318 to
e692601Compare…plit phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…plit phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6abe29d to
0737ca6Comparee692601 to
b58bcc0Compare…plit phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bef20fa to
50cf53dCompareHi! 👋 Thanks for your pull request! 🎉 To help us review it, please make sure to:
If you need help, please check our contributing guidelines. |
There was a problem hiding this comment.
More details
This PR adds read-through support to TagMap, enabling span-level maps to read through a frozen parent (trace-level) map on local misses. The mechanism is inert when no parent is attached (parent == null), ensuring no behavioral change for existing code. Comprehensive testing verifies read-through correctness, tombstone handling, iterator deduplication, and equivalence to flat-map semantics. No production bugs detected.
🤖 Datadog Autotest · Commit 50cf53d · What is Autotest? · Any feedback? Reach out in #autotest
ae64f2a to
5cd8289Compare…plit phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5cd8289 to
ab56044CompareA fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ab56044 to
64c42e6Compare…plit phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…plit phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…mall Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
A live read-through parent is never definitely-empty: createFromParent drops an empty parent, copy() only forwards an already-vetted parent, and the parent is frozen so it can't become empty. So the size==0 / no-tombstones branch of isEmpty() always returned false via the parent chain walk — collapse it to a documented `return false`. Addresses mcculls' review comment on #11789. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dougqh
commented
Jul 25, 2026
Span-creation allocation benchmark — do-no-harm gateThis PR adds the read-through mechanism to
|
| Arm | before | after | Δ B/op | Δ% |
|---|---|---|---|---|
| bareStartSpan | 904.0 ±0.0 | 897.1 ±12.5 | −6.9 | −0.8% |
| bareBuildSpan | 924.3 ±47.9 | 930.7 ±49.2 | +6.4 | +0.7% |
| jdbcClientSpan | 1290.7 ±29.2 | 1298.7 ±29.2 | +8.0 | +0.6% |
| webServerSpan | 1586.7 ±41.7 | 1618.7 ±41.7 | +32.0 | +2.0% |
| webServerSpanViaBuilder | 1688.0 ±12.5 | 1730.7 ±41.7 | +42.7 | +2.5% |
Verdict: flat within noise — do-no-harm pass. Every delta sits inside overlapping confidence intervals; none is a statistically defensible regression.
Two reasons it reads as variance, not a real cost:
- Not systematic. A real cost would come from
TagMapgaining ~2 ref fields (~16 B/op) and would show on every TagMap-allocating arm. Instead the bare arms are flat (one dropped), and the tagged deltas don't scale with tag count (jdbc sets more tags than web but moved less: +8 vs +32). - Wide error bars = per-fork inlining bimodality — what
@Fork(3)exists to expose (bareBuildis ±48 on both sides;viaBuilder's error swung 12→42 between runs). The point estimates ride fork-to-fork JIT variance.
Throughput (directional only, laptop): mostly up on the after side (+1.6%…+6.5%), one arm down — no regression signal.
Methodology
SpanCreationBenchmark trimmed to the drift-stable arms (no SpanPrototype, which doesn't exist at these commits). @Threads(8) @Fork(3) @Warmup(5) @Measurement(5), -prof gc, finished against a no-op DropWriter so only front-half (create/tag/finish) allocation is measured. Read gc.alloc.rate.norm (deterministic) as the signal; throughput is thermal/bimodality-fragile.
/merge |
View all feedbacks in Devflow UI.
The expected merge time in
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…plit) (phase 1a) (#11932) Add TagMap read-through support (level-split phase 1 mechanism) A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Wire mergedTracerTags as a read-through parent at span build (level-split phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Mark parent field @VisibleForTesting and size the tombstone HashSet small Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Collapse redundant isDefinitelyEmpty() call in isEmpty() A live read-through parent is never definitely-empty: createFromParent drops an empty parent, copy() only forwards an already-vetted parent, and the parent is frozen so it can't become empty. So the size==0 / no-tombstones branch of isEmpty() always returned false via the parent chain walk — collapse it to a documented `return false`. Addresses mcculls' review comment on #11789. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Merge branch 'dougqh/tagmap-read-through' into dougqh/tagmap-read-through-consumer Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'dougqh/tagmap-read-through' into dougqh/tagmap-read-through-consumer Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'dougqh/tagmap-read-through' into dougqh/tagmap-read-through-consumer Make TagMap fillMap/fillStringMap read-through aware Both bulk-fill helpers walked only this.buckets, so a parent-backed TagMap materialized through them dropped tags visible only via the parent chain and ignored shadowing/tombstones -- unlike putAll, forEach, and iteration, which already honor the read-through union. Branch to the visible-union walk (forEach) when a parent is attached, mirroring putAllOptimizedMap; keep the untouched local-only loop for the common no-parent case so it pays zero overhead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Drop observationally-empty read-through parents (exact isEmpty) createFromParent used isDefinitelyEmpty(), which only checks each level's local size and ignores shadowing/tombstones. With multi-level read-through, a frozen intermediate can be observationally empty (no local entries, every inherited key tombstoned) yet report isDefinitelyEmpty() == false because a farther ancestor still holds entries. Attaching such a parent then let a child take isEmpty()'s no-tombstone fast path and return false while size(), lookup, and iteration all report empty -- a Map-contract violation. Use exact isEmpty() so semantically empty parents are dropped, which also restores the invariant isEmpty()'s fast path relies on: an attached parent always contributes at least one visible entry. The hot path is unaffected -- isEmpty() short-circuits on size != 0, only walking tombstones in the rare all-tombstoned case this fix targets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Merge remote-tracking branch 'origin/master' into dougqh/tagmap-read-through-consumer # Conflicts: # internal-api/src/main/java/datadog/trace/api/TagMap.java # internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
What Does This Do
Adds read-through support to TagMap - currently unused
A TagMap can now be constructed via createFromParent(TagMap) to resolve map queries against a frozen parent TagMap. The plan is to use this to separate trace-level tags from span-level tags without any semantic changes.
Motivation
When applied to the tracer core will enable lighter and faster span construction by removing some map-to-map copying.
Additional Notes
From Claude...
Pure mechanism — inert until a consumer attaches a parent. No map has a parent yet (
parent == nullfor every existing map), so there is no behavior change off the read-through path. The consumer wiring lands in a stacked follow-up.A child
TagMapreferences a frozen parent (createFromParent) and reads through to it on a local miss, while local entries shadow the parent. This is the enabler for level-split phase 1 — a span will stop copying the shared trace-level tags (mergedTracerTags) down into every span; reads route through instead.Design
mergedTracerTags). Written so generalizing to multiple flattened parents is additive (the bulk walk is already bucket-aligned, the degenerate single-parent case of the multi-parent merge).removedFromParentside-set, not inline tombstones. Inline-in-buckets kept breaking on the bare-Entry-vs-BucketGroupduality (re-type, per-group bitfield + single-Entry gap, two bitfields — all awkward). The side-set is shape-agnostic, keepsEntry/BucketGroupcompletely untouched, and the lazy null field doubles as the gate. Tombstones are rare (only when a parent-exposed key is removed).Set/BloomFilter.forEach(×3) stays alloc-free;IteratorBasedoes a two-phase local-then-parent walk soiterator/entrySet/keySet/values/streamall emit the deduped union.What's covered
getEntryfall-through (the wholeget*/containsKeyfamily inherits it).isDefinitelyEmpty()+estimateSize()added as cheap conservative/upper-bound variants (mirroringLedger);isEmpty()/size()stay exact (Mapcontract) and resolve the union.remove()returns the prior visible value (Map contract holds via read-through).forEach/iterators/collection views all emit the deduped, first-occurrence-wins union, skipping shadowed/tombstoned parent entries.copy()preserves read-through (shares the frozen parent + copies tombstones) — was dropping both.TagMapReadThroughTestcovers the read path, removal/tombstoning, the deduped bulk union, andcopy()preservation; the fullTagMap*suite stays green (inert whenparent == null).Deferred / follow-ups
get/setacross theparent != nullbranch — the perf-gate before marking this ready.!needsIntercept-gatedmergedTracerTags → createFromParentwiring. Includes theremoveTag(VERSION)cleanup — config version moves out of the read-through bundle so the existingInternalTagsAdderconditional-add works unchanged and no per-span tombstone is minted (the apply-then-remove dance is vestigial; read-through keeps config-in-parent / manual-in-local). Plus an audit of any other per-span parent-key removals and of read-through maps as a merge/clone source (onlycopy()handled here).tag: ai generated· pure mechanism, no behavior change until a consumer attaches a parent.🤖 Generated with Claude Code