Skip to content

Store known span tags densely in TagMap by tag-id (phase 2) - #12045

Draft
dougqh wants to merge 6 commits into
masterfrom
dougqh/dense-store-v2
Draft

Store known span tags densely in TagMap by tag-id (phase 2)#12045
dougqh wants to merge 6 commits into
masterfrom
dougqh/dense-store-v2

Conversation

@dougqh

@dougqh dougqh commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What

Adds a dense store for known span tags in TagMap: tags resolved to a stable tag-id via KnownTagCodec.keyOf are held in insertion-ordered parallel arrays (knownIds/knownValues) with no per-tag Entry object — eliminating the TagMap$Entry allocation that macro JFR profiling flagged as the #1 tracer allocation lever.

  • Name resolution and dense routing are decoupled. A KnownTagCodec is always installed — the real resolver via KnownTags.register() at tracer init, or a lazily-locked empty NoKnownTagCodec null-object if nothing registers. So keyOf/nameOf are always available, but whether known tags then take the dense storage path is a separate decision.
  • Dense routing is gated behind a const-folded static final DENSE_STORE (trace.experimental.dense.tags.enabled, default off). When off, HotSpot dead-code-eliminates the keyOf call and the dense branches, so the default path is byte-identical to the bucket-only store — no new work on the hot path.
  • Iteration/serialization emit dense entries through a reused flyweight (EntryReadingHelper) — no per-entry Entry alloc on the read/serialize path either.
  • Read-through is chain-aware: dense entries participate in the multi-level ancestor union via parentDenseVisible (mirrors parentEntryVisible), nearest-level-wins with tombstone/shadow checks. Disjointness (known tags never bucket) keeps the two stores independent by construction.

Why

Removes TagMap$Entry allocation for known tags (the macro alloc win — see the tracer-overhead JFR profiling). CPU is neutral/parity; the headline is allocation on the app thread.

Decoupling name-resolution from dense-routing lets the codec register unconditionally (needed by later PRs in the stack — e.g. OpenTelemetry name resolution) without forcing every tag through the dense store: the store stays opt-in and off-by-default while resolution becomes always-on.

Stack

Sits on dougqh/tagset (StringIndex / #11660 base). Supersedes the old dense PR #11814.

Test

TagMapDenseForkedTest, TagMapDenseFuzzForkedTest (dense on), KnownTagsTest + default-off TagMapTest/TagMapFuzzTest green; spotbugsMain + spotlessJavaCheck clean.

🤖 Generated with Claude Code

dougqh and others added 5 commits July 21, 2026 13:06
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>
…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>
StringIndex is a compact open-addressed string→index structure (the keyOf
substrate the dense tag store builds on): parallel hash/name arrays, linear
probing, on par with HashSet on lookup at a smaller footprint. Includes unit
tests, a footprint test (jol), and comparison benchmarks (vs HashSet/switch).

No TagMap changes — standalone util. Rebased onto the level-split stack
(consumer #11932) as the layer dense-store sits on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… gate

Re-applies the coverage fix dropped by the branch rebase/restack.
jacocoTestCoverageVerification flags StringIndex at 0.7 instruction
coverage (min 0.8): the instance long[] API (mapLongValues / lookup /
lookupOrDefault) and the Support.numSlots(int[]) static were never
exercised. Add two tests covering them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… capacityFor

Two consistency changes in light of the FlatHashtable strategy family:

- Rename Support -> EmbeddingSupport. StringIndex is the LightMap/object-side
  member (a real object with static factories), and its static-over-raw-arrays
  tier is exactly the "embed the backing arrays in your own fields" role that
  LightMap.EmbeddingSupport names.
- Replace tableSizeFor with capacityFor(n[, loadFactor]) + DEFAULT_LOAD_FACTOR /
  LOW_LOAD_FACTOR, mirroring FlatHashtable's sizing (duplicated for now; the two
  branches are independent, to be unified when the family converges). This also
  tightens the sizing: the old `while (size <= n)` over-allocated 2x at power-of-
  two counts (capacityFor(16) is now 32, was 64) while still targeting load
  factor <= 0.5. capacityFor(0) stays valid (StringIndex allows the empty set).

Updates StringIndexTest (sizing expectations + a rejects test) and the three
benchmarks referencing the tier. Behavior unchanged except the tighter default
table size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh dougqh added comp: core Tracer core 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 Jul 23, 2026
@datadog-official

datadog-official Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Bits has a CI fix ready

🟢 Investigated · 🟢 Fix prepared · ⚪ Validation skipped · 🟠 Ready

CoreTracer.java contained an overlong dense-tag initialization comment that violated the Java formatter. Wrapped the comment without changing runtime behavior.

Commit fix to this PR


View in Datadog | Reviewed commit d758efe · Any feedback? Reach out in #deveng-pr-agent

@datadog-official

datadog-official Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
Patch Coverage: 76.42%
Overall Coverage: 57.42% (-1.24%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 6948f86 | Docs | View more details | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Jul 23, 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.04 s 14.02 s [-0.7%; +0.9%] (no difference)
startup:insecure-bank:tracing:Agent 13.03 s 13.07 s [-1.2%; +0.6%] (no difference)
startup:petclinic:appsec:Agent 17.53 s 17.43 s [-0.3%; +1.4%] (no difference)
startup:petclinic:iast:Agent 17.37 s 17.39 s [-0.8%; +0.7%] (no difference)
startup:petclinic:profiling:Agent 17.17 s 17.28 s [-1.7%; +0.4%] (no difference)
startup:petclinic:sca:Agent 17.47 s 17.45 s [-0.7%; +0.9%] (no difference)
startup:petclinic:tracing:Agent 16.59 s 16.73 s [-1.8%; +0.1%] (no difference)

Commit: 6948f86d · 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 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d758efe1d9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +664 to +665
if (Boolean.getBoolean("dd.trace.dense.tags.enabled")) {
KnownTags.init();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Initialize dense routing before constructing tag maps

When dd.trace.dense.tags.enabled is true for the first tracer, CoreTracerBuilder.config() has already constructed and frozen defaultSpanTags and localRootSpanTags while the codec was inactive, so known names such as env are stored in buckets before this call activates dense routing. Subsequent lookups search only the dense region for those names, and parent shadow checks assume a known tag can never be bucketed; consequently point reads/removals can miss configured tags and a local dense override can be serialized alongside the bucketed parent value. Initialize the codec before these maps are built, or migrate/fall back to pre-registration bucket entries.

Useful? React with 👍 / 👎.

Comment on lines +1415 to +1417
private Object knownRawValue(long tagId) {
int i = this.knownIndexOf(tagId);
return i < 0 ? null : this.knownValues[i];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Distinguish a stored null value from absence

With dense routing active, Map.put(knownTag, null) appends an entry and increments knownCount, but this lookup returns null for both an absent ID and a present null value, after which getLocalEntry treats the mapping as absent. The map then reports the entry in size() and iteration while containsKey returns false, unlike the previous bucket implementation and the Map contract; presence must be determined from the index rather than the stored value.

Useful? React with 👍 / 👎.

Comment on lines +2593 to +2597
EntryReadingHelper reader = this.denseReader;
if (reader == null) {
reader = this.denseReader = new EntryReadingHelper();
}
reader.set(KnownTagCodec.nameOf(tagId), value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return stable readers from the public iterator

When a caller retains elements from TagMap.iterator() or TagMap.stream()—for example with stream().collect(toList())—every dense element is represented by this same mutable EntryReadingHelper. Advancing the iterator rewrites previously returned elements, so a collected list contains repeated aliases representing the final dense tag instead of the map's entries; bucket entries were stable before this change. Public iteration must return stable readers, or the reusable flyweight must be confined to a separately documented immediate-consumption traversal API.

Useful? React with 👍 / 👎.

* — {@code keyOf}/{@code nameOf} depend only on globalSerial + name, not on the (dormant)
* positional layout.
*/
class KnownTagsTest {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Isolate the codec-registration test in a forked JVM

This test class references KnownTags and permanently registers the global resolver, but its name does not match the build's *ForkedTest* isolation convention, so it runs in the shared test JVM and changes dense routing for every later test in that worker. That makes bucket-only coverage and failures order-dependent; rename it with the ForkedTest suffix so registration cannot leak between test classes.

AGENTS.md reference: AGENTS.md:L63-L63

Useful? React with 👍 / 👎.

@dougqh
dougqh force-pushed the dougqh/dense-store-v2 branch from d758efe to 992dc14 Compare August 18, 2026 20:14
@dougqh

dougqh commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Known tags (keyOf resolves to a stored id) are held in insertion-ordered
parallel arrays (knownIds/knownValues) with NO per-tag Entry object — the
allocation lever. Lazily allocated on the first known-tag write; custom tags
stay in the hash buckets. Disjoint by construction (known-ness is global), so
read-through shadow checks stay within-region and the bucket path is unchanged.

- KnownTagCodec (id encoding + resolver) + hand-written KnownTags (keyOf
  substrate over StringIndex).
- The KnownTagCodec is ALWAYS present: CoreTracer registers the real resolver
  unconditionally at init (so keyOf/nameOf name resolution is always live —
  the OTel-name mapping later in the stack depends on this), and on first use
  with nothing registered the codec lazily installs an empty NoKnownTagCodec.
  Either way it locks after first use, so a map can never be built half-bucketed
  then half-dense by a late registration.
- Dense ROUTING is decoupled from resolution and gated separately by the
  const-folded KnownTagCodec.DENSE_STORE, captured from the new
  trace.experimental.dense.tags.enabled Config flag (off by default). TagMap
  computes keyOf only under this gate, so when off the dense branches
  dead-code-eliminate and tag storage is byte-identical to the bucket store.
- Sizing is a generous fixed stopgap (KNOWN_INIT_CAP=12, the per-type max);
  exact per-type sizing comes with the tag registry.

Reconciled onto the level-split stack (fold + read-through + StringIndex);
built on the folded final-class TagMap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 992dc142b4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1929 to +1930
for (int i = 0; i < this.knownCount; ++i) {
map.put(KnownTagCodec.nameOf(this.knownIds[i]), this.knownValues[i]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include inherited entries in map exports

When a TagMap is created with createFromParent, fillMap walks only the child's local buckets and dense arrays, so tags visible solely through the parent are silently omitted even though get, size, iteration, and the collection views expose them. This makes exporting a read-through map differ from exporting an equivalent flat map; traverse the visible parent union as forEach does. fillStringMap has the same omission.

Useful? React with 👍 / 👎.

@dougqh
dougqh force-pushed the dougqh/dense-store-v2 branch from 992dc14 to 6948f86 Compare August 19, 2026 12:45
@dougqh

dougqh commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6948f86d96

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1317 to +1318
Object known = this.knownRawValue(id);
return known == null ? null : Entry.newAnyEntry(tag, known);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid materializing entries for dense point reads

perf: When dense routing is enabled, every getObject, getString, or containsKey call for a present known tag allocates a new Entry here. This is reachable on the per-span finish path—for example, HttpEndpointPostProcessor reads http.method, http.route, and http.url—while the added allocation benchmark only exercises iteration, whose flyweight avoids this branch. Provide direct dense-value access for point reads and verify the finish path with an allocation profile.

AGENTS.md reference: AGENTS.md:L77-L81

Useful? React with 👍 / 👎.

Comment on lines 1507 to +1510
public void set(@Nonnull String tag, int value) {
this.getAndSet(Entry.newIntEntry(tag, value));
long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L;
if (KnownTagCodec.isStored(id)) {
this.putKnownLocal(id, tag, Integer.valueOf(value));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve primitive storage for known numeric tags

perf: With dense routing enabled, setting a known numeric tag now boxes the primitive before storage; for example, BaseDecorator.onPeerConnection sets the known peer.port tag on client spans, and ports outside the Integer cache can allocate an Integer per span. The bucket path previously retained the value in Entry.rawPrim without boxing, and the new JMH benchmark uses only String values, so it does not measure this regression. Preserve an unboxed representation or add a representative numeric allocation benchmark before enabling this path.

AGENTS.md reference: AGENTS.md:L77-L81

Useful? React with 👍 / 👎.

private final boolean jdkSocketEnabled;

private final boolean spanBuilderReuseEnabled;
private final boolean traceDenseTagsEnabled;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the dense-tags flag in Config.toString

The new configuration is stored and exposed through a getter but is omitted from Config.toString(), contrary to the repository's configuration checklist. As a result, startup diagnostics and tracer-flare configuration dumps cannot show whether this experimental storage mode was requested, making failures specific to dense routing substantially harder to identify; add traceDenseTagsEnabled to the serialized configuration state.

AGENTS.md reference: AGENTS.md:L37-L43

Useful? React with 👍 / 👎.

Base automatically changed from dougqh/tagset to master August 21, 2026 15:40
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 type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant