Store known span tags densely in TagMap by tag-id (phase 2)#11814
Store known span tags densely in TagMap by tag-id (phase 2)#11814dougqh wants to merge 10 commits into
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. |
27d42d4 to
cd8b73a
Compare
Directional span-creation benchmark: dense off vs on (this branch)Toggle A/B on this branch ( Config:
Honest read: dense in isolation is alloc-negative here (~+0.5–1.5%)This is a local valley, not the destination — and the cause is instructive:
Reconciles with the macro From Claude: directional A/B run during the dense reconcile onto the level-split stack; posting the honest before/after and the coupled-gating that gets us out of the valley. 🤖 Generated with Claude Code |
cd8b73a to
a5e12e4
Compare
…scan) A per-map long bitmask (knownBloom) over the dense store: a set bit means a tagId MAY be present (scan to confirm), a clear bit means DEFINITELY absent — so the common per-build insert skips the linear knownIndexOf scan and appends in O(1). Crude position->bit map (fieldPos & 63); a collision-minimizing per-type coloring later only raises the hit rate — correctness never depends on it because the scan stays authoritative. Superset semantics: set on add, never cleared on remove (a stale bit costs a scan, never a wrong answer). Alloc-neutral (one long field, no extra allocation); the win is insertion CPU, moving the dense store toward HashMap insertion parity without the scan. Reconciled onto the folded-class dense store (#11814). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
🎯 Code Coverage (details) 🔗 Commit SHA: 0c0d740 | Docs | Datadog PR Page | Give us feedback! |
a5e12e4 to
6c70848
Compare
…scan) A per-map long bitmask (knownBloom) over the dense store: a set bit means a tagId MAY be present (scan to confirm), a clear bit means DEFINITELY absent — so the common per-build insert skips the linear knownIndexOf scan and appends in O(1). Crude position->bit map (fieldPos & 63); a collision-minimizing per-type coloring later only raises the hit rate — correctness never depends on it because the scan stays authoritative. Superset semantics: set on add, never cleared on remove (a stale bit costs a scan, never a wrong answer). Alloc-neutral (one long field, no extra allocation); the win is insertion CPU, moving the dense store toward HashMap insertion parity without the scan. Reconciled onto the folded-class dense store (#11814). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TagMap was an interface with a single implementation, OptimizedTagMap. The split was vestigial scaffolding from when a second (HashMap-backed) impl existed; with one impl it is false generalization. Collapse them into one `public final class TagMap`: - The interface's abstract method declarations are removed; OptimizedTagMap's bodies become TagMap's methods. - Nested types that were implicitly `public static` in the interface (EntryChange, EntryRemoval, EntryReader, Entry, Ledger) are now written out explicitly as `public static`. - Static factories (create/fromMap/ledger/...) and the EMPTY constant become explicit `public static` members; the EmptyHolder lazy-init note is updated now that there is no interface<->impl class-init cycle. - putAll(TagMap) loses its `instanceof` dispatch (always true once there is one class) and calls the fast path directly. No behavior change; motivation is code simplicity, not performance (a single final class is monomorphic by construction, but CHA already devirtualized the sole impl). Public API is preserved, so callers are unchanged; the 3 tests that referenced OptimizedTagMap now reference TagMap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Post-fold tidy, all TagMap-scoped: - Remove EmptyHolder: with one class there is no interface<->impl class-init cycle to break, and the private constructor reads no statics, so EMPTY is a direct `new TagMap(new Object[1], 0)` initializer. - Static factories (create/fromMap/ledger/...) are now `public static final` (not expressible on the old interface). - assertSize/assertNotEmpty/assertEmpty/checkIntegrity test helpers dropped their now-always-true `instanceof TagMap` guard + redundant cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The interface -> final-class fold removed the TagMap interface decls, which carried the @Nullable/@nonnull param annotations from #11963. Re-home them onto the now-concrete methods: @nonnull tag keys and strict-setter values, @nullable on the set(EntryReader)/getAndSet(Entry) sinks (+ the getAndSet contract javadoc). The null-tolerance behavior was already preserved by the fold; this restores the self-describing contract on the write surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review: these factories NPE on a null map (map.size()/putAll), so the input is non-null by contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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). Off-by-default: dormant until a resolver registers, so production is byte-identical. - CoreTracer flips it live behind `-Ddd.trace.dense.tags.enabled` for A/B. - 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>
6c70848 to
0c0d740
Compare
…scan) A per-map long bitmask (knownBloom) over the dense store: a set bit means a tagId MAY be present (scan to confirm), a clear bit means DEFINITELY absent — so the common per-build insert skips the linear knownIndexOf scan and appends in O(1). Crude position->bit map (fieldPos & 63); a collision-minimizing per-type coloring later only raises the hit rate — correctness never depends on it because the scan stays authoritative. Superset semantics: set on add, never cleared on remove (a stale bit costs a scan, never a wrong answer). Alloc-neutral (one long field, no extra allocation); the win is insertion CPU, moving the dense store toward HashMap insertion parity without the scan. Reconciled onto the folded-class dense store (#11814). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Draft — reviewable; lands after the next release. Both the read-through and dense pieces stay behind experimental flags (off by default, flipped on internally to bake), so it's safe to hold across a release.
This branch is stacked on the level-split PRs — the read-through mechanism (#11789) and the
mergedTracerTagsconsumer wiring (#11932) land first; this PR adds the dense known-tag store on top. (The read-through/consumer commits that used to ride on this branch have been split out into those PRs.)Dense known-tag store (this PR's focus)
Known tags (those
KnownTags.keyOfresolves to a stored id) store in dense parallel arrays (knownIds/knownValues, insertion-ordered, cap-8 ×2) with no per-tagEntry— the #1 tracer allocator. Coexists with the hash buckets (arbitrary tags); disjoint by global known-ness, so read-through shadow checks stay within-region.KnownTags(id constants + registry) +KnownTagCodec(tagId encoding + resolver), with the open-addressedkeyOftable viaStringIndexTagMap(set/get/remove/forEach/size/copy/putAll/clear/iterate)EMPTY_BUCKETSsentinel + copy-on-write; all-known maps allocate zero bucketsEntryReaderIterator— alloc-free dense reads on the serialize path;entrySet()keeps real retain-safeEntrydd.trace.dense.tags.enabled(experimental system property; → promote to a Config flag before ship)Validation
-prof gc(real resolver, realistic mixed span): per-build alloc −8% (7 tags) → −41% (12 tags); all-known / trace-tier shape −57% / −43%.TagMapDenseForkedTest(real registry + read-through union),TagMapDenseFuzzForkedTest(3 key regimes, HashMap oracle +checkIntegrity),DenseStoreAllocBenchmark.The CPU upside (skip
keyOfby setting tags viasetTag(KnownTags.X_ID)) arrives via incremental instrumentation migration over time — no big-bang.🤖 Generated with Claude Code