Repository navigation
Conversation
The generator now assigns each required/conditional/recommended tag a slot in id bits [47-32] by graph coloring: tags resolved onto one concrete span type, or both trace-level, get distinct slots; tags that never co-occur -- such as a tag declared per direction -- may share one. The rest get NO_SLOT and stay hash-stored. The current conventions need 18 slots, within the one occupancy long the dense store will use. KnownTagCodec gains slotOf, hasSlot, slotCount, and the slot layout constants. Nothing reads the slot yet; the dense store does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Selects TagMap's dense store for slotted known tags. Read once into a static final so the JIT folds the dense branches away when it is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A known tag with a dense-store slot is held in insertion-ordered parallel arrays (knownIds/knownValues) with no per-tag Entry; custom tags and known tags without a slot stay in the hash buckets. One occupancy long (1L << slot) lets a definitely-absent tag skip the scan; the scan stays authoritative, so two tags sharing a slot in one map still work. Whether a tag routes dense depends only on its id and the flag, so the two regions are disjoint and read-through shadows within a region (parentDenseVisible mirrors parentEntryVisible). Iteration and forEach emit dense values through a reused flyweight; point reads materialize an Entry. Buckets start on a shared empty array and are copied on the first bucket write. Ported from #12045 and #12046 onto today's TagMap: name canonicalization, id-keyed setters, and per-direction shared-name lookups all route through the same chokepoints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TagMapDenseForkedTest covers dense routing against the generated registry: slotted vs unslotted vs custom tags, id-keyed and OpenTelemetry-name writes, two tags sharing a slot in one map, iteration/forEach/entrySet (retain-safe entries), the shared empty buckets, copy/clear/putAll/ledger/freeze, and read-through (inheritance, shadowing, tombstones, multi-level chains). TagMapDenseFuzzForkedTest replays random actions against a HashMap oracle over slotted, unslotted, and custom keys. Both fork, since the flag is read once per JVM. DenseStoreAllocBenchmark: with the store on, a 12-tag map of slotted tags allocates 232 B/op instead of 856; custom tags are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putKnownValue built an Entry for the value it replaced, which set(long, ...) then discarded; the 0 B/op on overwrite held only because C2's escape analysis removed it. Split the store: storeKnownValue/storeDenseLocal return the raw prior value (dense values are never null) and build nothing, and only putDenseLocal -- the getAndSet path, which must return the prior entry -- materializes one. With -XX:-DoEscapeAnalysis, SetTagBenchmark's dense overwrite drops from 48 to 0 B/op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Internals.setKnown built an Entry and sent it through putEntry, whose dense routing then built another for the replaced value: two throwaway allocations on every span id write once the span moved to internals(). Check for a dense id first and store straight into the dense arrays, as set(long, ...) does. SetTagBenchmark with the store on: 0 B/op, and 16 B/op (just the Internals view) with -XX:-DoEscapeAnalysis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the dense store on, every known tag now lives in the dense arrays -- denseIdOf means 'is a known id' -- so the id-keyed setters (set(long, ...) and Internals.setKnown) never reach the hash path: with the flag a static final, putEntry isn't compiled into them at all. The public set(long, ...) checks isKnown itself, since the dense path skips requireKnownName, and the dense write now applies the shared-name rule (an id write supersedes a value held under the shared name). The generator colors every tag resolved onto a modeled span type, whatever its requirement level (24 slots today). Only tags on no span type -- the overlay's routing-only keys and the CI tags of the unmodeled test type -- keep NO_SLOT; a long shift maps NO_SLOT to bit 63, which slots now stay below, so slotBit needs no branch and those tags share that presence bit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peer.port set by name before a span has a direction used to be stored as a custom tag, so every id write of a direction's tag had to remove it from the hash buckets. That pulled removeLocal into DDSpanContext.setTag(long, int) and pushed it past C2's InlineSmallCode limit. The registry now adds an undirected tag per shared Datadog name (PEER_PORT_UNDIRECTED_ID). It is serialed after every declared tag and colored into the same slot as its directional tags. Each directional id carries the undirected serial at bits [30-16], so undirectedIdOf folds for a constant id. In the dense store, a direction's write supersedes the undirected tag by relabeling it in place during the scan it already does. The hash path removes it by id. The span-level supersede calls are gone. keyOf(String) still resolves a shared name to 0. The span's by-name setters rely on that to take the directional path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A map holds each known tag at most once, so a full dense store grows straight to KnownTagCodec.serialLimit() entries instead of doubling. Maps start on shared empty arrays, so one full-capacity test covers a map's first dense tag too. The first allocation, growth, tombstone clearing and the frozen-map exception are now separate methods. This change did not measurably shrink the compiled DDSpanContext.setTag in SetTagMixBenchmark. In both the reused-span and fresh-span profiles, the int overload's growth branch is already an uncommon trap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restore the factory design from before #11678 and #11967. TagMap becomes an abstract class with two final subclasses: HashTagMap (dense store off) and DenseTagMap (dense store on). TagMapFactory chooses one from the flag. EMPTY and every factory method go through the factory, so a JVM loads only one implementation and class hierarchy analysis devirtualizes TagMap calls. A forked test per mode checks that the other implementation never loads. This is rollout scaffolding, as LegacyTagMap was: the hash-only map stays one flag away until the dense store is proven, and is then folded away. The commit is structural only. The logic stays in TagMap; the next commit moves the dense state into DenseTagMap. The public no-arg constructor is removed; TagMap.create() is the intended way to make a map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TagMap now holds only the hash-bucket store, read-through and views. DenseTagMap holds the dense fields and helpers. It overrides the routing methods (putEntry, getLocalEntry, removeLocal, the set(long, ...) overloads, forEach and the bulk copies) to handle known ids and calls super for custom tags. Shared walks over the parent chain reach dense state through per-level hooks (knownCount, countDenseVisibleIn, forEachDenseVisibleIn, readDenseIfVisible); in HashTagMap those return zero or do nothing. DenseStore.ENABLED is now read only by TagMapFactory. Internals.setKnown goes through setKnownTrusted, which skips the frozen check because the core's span maps are never frozen. Every other rule (tombstones, the shared-name supersede, routing) is unchanged. A new Internals.getKnownValue reads a known tag's value with the same semantics as getEntry(id).objectValue(), but builds no Entry for a local dense hit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DDSpanContext's primitive setTag(long, ...) overloads boxed the value twice in their compiled code: once to hand it to the interceptor, through setBox, and again inside Internals.setKnown when storing it. They now box once, and pass the same box to the interceptor and to new boxed Internals.setKnown overloads. HashTagMap builds the tag's primitive-typed Entry around that box, so an intercepted tag that is stored keeps its primitive type rather than becoming ANY. DenseTagMap stores the box as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry's full constructor and the Internals and Ledger constructors were
private, so javac (targeting Java 8, without nestmates) gave each one a
synthetic access constructor taking the never-loaded class TagMap$1. C2
refuses to inline a call whose signature names an unloaded class
("unloaded signature classes"). So the Internals view the core creates
for each id write could not be scalar-replaced, and every Entry built
through that constructor went through a real call. Making the
constructors package-private removes the accessors and TagMap$1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry's *2Prim helpers and requireKnownName, and TagMap.parentEntryVisible, were private but used across nested classes, so javac generated access$ methods for them. Each one adds a level of inlining depth, and the *2Prim helpers are on HashTagMap's typed id-write path. They are now package-private. TagMapSyntheticAccessorTest fails if any class in the TagMap family gains a synthetic accessor again. It is the prototype for the @NoSyntheticAccess contract (APMLP-1976). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putAllIntoEmptyMap copied the source's dense arrays at their full capacity. A source that had grown to the bound would then hand every copy, including each span's starting tags, a full-bound array. The copy now allocates the source's count, with the initial capacity as a floor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the per-operation sizing hint from #12056 onto the split TagMap and the current FlatHashtable.D1 API. SizingHintTable keeps two fixed 512-operation lanes, one for entry spans and one for child spans, each with a capped overflow hint for once it is full. CoreTracer resolves the hint when it builds a span context. DenseTagMap presizes its arrays from it, and DDSpanContext records the span's known-tag count when the span is serialized. Each hint is a racy best-effort max: a stale or lost update only mis-sizes an array. This changes two things from #12056. A fresh hint is seeded at 4, not 1: the hint only grows, so a large seed would lock small spans into oversized arrays. And dense growth now doubles, bounded by serialLimit(), instead of jumping straight to the bound, so outgrowing a small seed costs one doubling. With the dense store off, hintFor folds to null, its tables are never loaded, and span creation does no lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spans that are never serialized, such as those dropped by sampling, never fed their known-tag count back. Their operation's hint stayed at the seed, and each such span grew its dense arrays from 4 slots upward. The span now records its size at finish too. Recording at serialization still captures the tags added then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putAllIntoEmptyMap always allocated new dense arrays. A span map presized from its SizingHint therefore threw its hinted arrays away when its starting tags were copied in (CoreTracer -> setAllTags -> putAll), and allocated 12-slot arrays instead: two allocations, the wrong size. The copy now goes into the map's own arrays when they fit, and allocates only when they don't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpanCreationByIdBenchmark repeats SpanCreationBenchmark's tagged span shapes (web server, JDBC client, and their SpanPrototype variants) but sets each tag by its KnownTags id instead of its name. Same values, threads and forks, so each arm compares directly with its namesake. peer.port uses its directional id. On Zulu 17, with 3 forks and debug logging off, setting tags by id matched setting them by name within the error bars, in both throughput and allocation, with the dense store on and off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Box-once (5e600f7) made every DDSpanContext.setTag(long, <primitive>) box first and pass the box down. With the dense store off, HashTagMap then kept that box in the tag's Entry, although the serializers read the primitive. That was a retained allocation the hash path never had, for any value outside the box cache (most status codes and ports, nearly all longs, every float and double). The box escapes into interceptTag, which is not inlined, so C2 cannot remove it. TagMap.Internals.STORES_BOXES, a static final that is true only for the dense store, chooses between the two shapes, and the check folds. The dense store keeps box-once. The hash store goes back to boxing only on the interception path and storing the primitive otherwise. SetTagBenchmark gains a knownIntTag_byId_uncached arm, since the existing arms only use cached values and could not see the allocation. Also fixes stale documentation found by the techdebt review: - an orphaned comment describing the removed setBox; - comments that still said known tags without a slot are stored by hash (every known tag is dense now); - comments that said sizing hints tune only at serialization (they also tune when a span finishes). SizingHintTable.OVERFLOW_SEED now reads DenseTagMap.KNOWN_INIT_CAP instead of repeating its value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🟢 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. |
Brings master (#12715 merged, Spotless 8.10.4), #12817, and #12731's direction-neutral peer.port id. The dense store moves onto that model: - peer.port is one tag with one id, so the undirected id (e7a8721), the UNDIRECTED and SHARED_NAME bits, and the dense store's in-place relabel are gone. Bit 0 is #12731's HAS_SCOPED_ALIAS. - Slot coloring is re-applied on #12731's one-tag-per-name registry: a name declared per direction is one vertex. - Tests and benchmarks use PEER_PORT_ID; the undirected-model tests are dropped in favor of #12731's SpanTags and directional span tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings master, including #12814, and the carve-bit reordering (HAS_SCOPED_ALIAS, INTERCEPTED, LEVEL_TRACE: bits 0, 1, 2 in order). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolveSplitByTagsPrecedence checked language through the custom-tag overload, which only matches names that resolve to no known tag. language is known, so the check was always false and a prototype's language was never re-applied over another split-by-tags candidate the prototype set. Check and read it by LANGUAGE_ID, as for component. Found by Codex, via a review comment from AlexeyKuznetsov-DD. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tag-registry generator now assigns each known tag a slot in id bits [47-32] by graph coloring: each concrete span type's resolved tags, and the trace-level tags, form a clique and get distinct slots. So 1L << slotOf(id) is an exact presence bit for a span's tags, and one long holds them all. Tags on no modeled span type get NO_SLOT, which a long shift maps onto bit 63; real slots are capped below it. KnownTagCodec gains SLOT_SHIFT, NO_SLOT, slotOf, hasSlot and slotCount. Nothing in the tracer reads slots yet: this is split out of the dense store (#12856) so the store, the span builder's tag ledger and a map-level routing summary can each build on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ForEachContextBenchmark is a local scratch benchmark that the previous merge picked up; it isn't part of this PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| void theTagMapFamilyHasNoSyntheticAccessors() { | ||
| List<Class<?>> classes = new ArrayList<>(); | ||
| for (Class<?> root : Arrays.asList(TagMap.class)) { | ||
| for (Class<?> root : |
There was a problem hiding this comment.
To Claude - another PR is adding a general mechanism for this -- maybe we should adopt that here, too.
There was a problem hiding this comment.
Agreed. #12862's SyntheticAccessors.assertNone is more thorough: it also finds anonymous and local classes. When it lands, I'll switch in #12817, where TagMapSyntheticAccessorTest started, to @NoSyntheticAccessors on TagMap, HashTagMap, DenseTagMap and the factories. Each top-level class is its own nest, so each gets a one-line test. Leaving this open until then.
CoreTracer was its only caller outside datadog.trace.api. It now looks a hint up through SizingHint.forOperation, on the type it already holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each primitive setTag(long, ...) repeated the same three branches. They now box only when the box is needed -- the store keeps boxes (STORES_BOXES, folded) or the tag may be intercepted -- and pass it to one shared setKnownBox; otherwise they store the primitive. That keeps a single boxing site per setter and the hash store's unboxed plain path. setTag(long, int) drops from 195 to 76 bytecode bytes. SetTagBenchmark's int and peer.port arms are unchanged in time and allocation with the dense store off and on, and C2 still inlines the setter at its call sites (no too-big refusals in either version). The typed boxed Internals.setKnown and setKnownTrusted overloads, used only by the old setters, go: an intercepted-then-stored primitive on the hash store is now an ANY entry, which resolves to the same type on read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| * Initial capacity of the dense arrays when no {@link SizingHint} presized them; the largest span | ||
| * type resolves about this many tags. | ||
| */ | ||
| static final int KNOWN_INIT_CAP = 12; |
There was a problem hiding this comment.
To Claude - I don't think that's true anymore -- maybe we can source this value from KnownTagsCodec.
TagMapFactory computed INSTANCE in its own static initializer from a subclass's INSTANCE. Initializing a factory subclass first initializes TagMapFactory, which then read that subclass's INSTANCE while it was still null; TagMap's initializer later failed on it, poisoning TagMap for the JVM's life. And a factory that built its empty map in its own initializer started TagMap's initializer mid-way, so TagMap.EMPTY could read a still-null cached empty map. - The JVM's factory moves to a holder, TagMapFactory.Default.INSTANCE; TagMapFactory has no static state. - TagMap.EMPTY is built fresh by newEmpty(). The JVM's factory returns TagMap.EMPTY from empty(); only the other store (tests) caches its own. Nothing in production initializes a factory before TagMap today; the parameterized tests did. TagMapFactoryInitOrderForkedTest initializes both factories first, in a fresh JVM: it fails before this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dense-store flag picks one TagMap implementation per JVM, so TagMapTest, TagMapReadThroughTest, TagMapFuzzTest and TagMapSetByIdTest only ever exercised HashTagMap. A TagMapType test enum (HASH, DENSE) builds maps through each store's own factory, as TagMapType once did for the optimized and legacy maps, and those suites now run against both (TagMapTest 112 -> 215 tests, ReadThrough 51 -> 102). - copy() now creates through the map's own factory (TagMap.factory()), so a copy keeps its implementation instead of taking the JVM's default store. - createFromParent's read-through checks take an explicit factory (package-private), which TagMapType uses. - 22 of TagMapDenseForkedTest's 24 tests didn't need the flag; they move to DenseTagMapTest in the normal test JVM. The forked test keeps the two that only the flag decides: that HashTagMap never loads, and that a ledger builds a dense map. - TagMapFuzzTest's PutAllTagMap builds its source through the test map's own store, so the two never mix stores. No test fails for one store and not the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6015cbb routed createFromParent(TagMap, SizingHint) through the new factory-taking overload, so on the span-creation path the factory was a parameter instead of a static final constant receiver, behind an extra call level. With the dense store off, SpanCreationBenchmark allocated 24 more bytes per span (jdbcClientSpan 1,472 -> 1,496 B, webServerSpan 1,840 -> 1,864 B). The production path calls TagMapFactory.Default.INSTANCE.create(...) directly again; both overloads share the read-through checks in readThroughParent. Both arms are back to 1,472 and 1,840 B (webServerSpan in 6 of 6 forks; one earlier 2-fork run still had one fork at 1,864). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tag-registry generator now assigns each known tag a slot in id bits [47-32] by graph coloring: each concrete span type's resolved tags, and the trace-level tags, form a clique and get distinct slots. So 1L << slotOf(id) is an exact presence bit for a span's tags, and one long holds them all. Tags on no modeled span type get NO_SLOT, which a long shift maps onto bit 63; real slots are capped below it. KnownTagCodec gains SLOT_SHIFT, NO_SLOT, slotOf, hasSlot and slotCount. Nothing in the tracer reads slots yet: this is split out of the dense store (#12856) so the store, the span builder's tag ledger and a map-level routing summary can each build on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- TagMap.setResolved now calls set(tagId, ...) for a known id, as set(String, ...) already does, so every name-keyed span write takes the id path. Same result on today's store; a store that keeps known tags without an Entry per tag no longer allocates one here. - TagMap gains a package-private getKnownValue(long), exposed to the core as Internals.getKnownValue. DirectionalTags' id reads and KnownTagCodec.directionOf use it instead of getEntry(id).objectValue(). Both pulled forward from the dense store work (#12856) so it overrides them rather than reworking these call sites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tag-registry generator now assigns each known tag a slot in id bits [47-32] by graph coloring: each concrete span type's resolved tags, and the trace-level tags, form a clique and get distinct slots. So 1L << slotOf(id) is an exact presence bit for a span's tags, and one long holds them all. Tags on no modeled span type get NO_SLOT, which a long shift maps onto bit 63; real slots are capped below it. KnownTagCodec gains SLOT_SHIFT, NO_SLOT, slotOf, hasSlot and slotCount. Nothing in the tracer reads slots yet: this is split out of the dense store (#12856) so the store, the span builder's tag ledger and a map-level routing summary can each build on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What Does This Do
Adds an experimental dense store for known span tags, behind
trace.experimental.dense.tags.enabled(off by default). With the flag on, known tags are no longer bucketEntryobjects. Their values sit in insertion-ordered parallel arrays (knownIds/knownValues) with no per-tagEntry. Custom tags stay in the hash buckets.Stacked on #12731 (direction resolution) and #12882 (slot coloring), and merges both. Retarget to master once they merge; until then their commits also show in this diff. This PR replaces the old dense-store drafts, which were built before the build-logic tag registry: #12045, #12046, #12047, #12056 and #11901.
Reading guide
The split is mechanical.
TagMapbecomes abstract, with two final implementations:HashTagMap(flag off) is today's map.DenseTagMap(flag on) is the hybrid.TagMapFactorypicks one from the flag. This is rollout scaffolding, asLegacyTagMapwas: the hash-only map stays one flag away until the dense store is proven, and is folded away afterwards.EMPTYand every factory method go through the factory, so a JVM loads only one implementation and class hierarchy analysis devirtualizes everyTagMapcall. A forked test per mode checks that the other implementation never loads.DenseTagMapcan be read on its own, as one unit of about 600 lines. The base class reaches dense state only through a small set of hooks:knownCount,countDenseVisibleIn,forEachDenseVisibleIn,readDenseIfVisible, and the routing overrides.Slots come from the registry (Color known tags into co-occurrence slots in their ids (phase 3a - groundwork - dense tag storage) #12882). The tag-registry generator colors tags that occur together on a span type into distinct slots; that's reviewed in Color known tags into co-occurrence slots in their ids (phase 3a - groundwork - dense tag storage) #12882, not here. Every known tag is dense. Tags on no modeled span type share occupancy bit 63. Presence is one
long; the scan is authoritative, so tags that share a slot can coexist.Per-operation sizing.
SizingHintTable(onFlatHashtable.D1) keeps a hint per operation, for entry spans and child spans separately, each lane capped at 512 operations. Each hint is a racy best-effort maximum: it presizes a span's dense arrays and tunes up as spans finish.Direction needs nothing here.
peer.portis one tag with one id (Resolve direction-scoped OpenTelemetry names at the OpenTelemetry boundary (otlp - tag registry - phase 3) #12731), so it takes one slot. Only its OpenTelemetry name depends on the span's direction, and that is resolved when the span is read or serialized, not in the store.Changes on the default path (flag off)
Internals.setKnownskips the frozen check: the core's span maps are never frozen.HashTagMap,DenseTagMapand the factories.HashTagMap(−24 B per map).Motivation
Known tags are most of a span's tags, and each one costs an
Entry. Storing them densely by id removes that allocation and makes setting a constant id cheap. This is part of the tag-id work, whose goal is that instrumentation sets tags byKnownTags.X_ID.Measurements
Zulu 17, Apple M1 Max, JMH
-prof gc, 3–4 forks, logging off. The details are in the commit messages.SetTagBenchmarkknown tag by idSpanCreationBenchmark.webServerSpanSpanCreationBenchmark.jdbcClientSpanbareStartSpan(4 known tags)Additional Notes
Entryfrom dense values; a raw-value read path is next.setTagis overInlineSmallCodeon a fresh-span profile. It is on par withsetTag(name), which is acceptable for now.DenseStoreAllocBenchmarkSpanCreationByIdBenchmark, a by-id counterpart toSpanCreationBenchmarkSetTagBenchmark.knownIntTag_byId_uncached, an uncached value so boxing is visible🤖 Generated with Claude Code