From 30aee5514c7cda33ec5bc9124c34479b3666aa63 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sat, 17 Oct 2026 15:48:03 -0400 Subject: [PATCH 01/11] Give the dense store's write paths one reference store store and getAndPut wrote values[i] on the overwrite path and again in appendSlot on the append path. Under G1 on JDK 17 and earlier C2 expands each reference-store site's write barriers inline, roughly 250 bytes apiece, and these paths inline into every setTag. Both now resolve the index first (appendSlot reserves the slot and returns it) and store the value once; the tombstone path uses reserveSlot the same way. On fresh spans this alone trims appendSlot (2560 -> 2240 bytes of compiled code) and the by-name setters by about 64-128 bytes; most of the remaining size comes from grow() running on every span's first tag, which the next commit addresses with sizing. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/api/DenseIdStore.java | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 8c6d0cdb2c6..8c5941745b6 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -270,11 +270,11 @@ public EntryReader getAndPut(EntryReader entryReader) { this.markLocal(bit, level); this.count++; } - this.values[i] = entryReader.objectValue(); } else { prior = this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; - this.append(serial, bit, level, entryReader.objectValue()); + i = this.appendSlot(serial, bit, level); } + this.values[i] = entryReader.objectValue(); // the one reference store; see store return this.readerOf(tagId, prior); } @@ -304,7 +304,8 @@ public EntryReader remove(long tagId) { this.values[i] = TOMBSTONE; this.count--; } else { - this.appendSlot(serial, TOMBSTONE); + int slot = this.reserveSlot(serial); // first: it may replace this.values + this.values[slot] = TOMBSTONE; } this.markTombstone(bit, level); return this.readerOf(tagId, local != null ? local : inherited); @@ -323,35 +324,43 @@ private Object store(long tagId, Object value) { long bit = slotBit(tagId); int level = levelOf(tagId); int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object prior; if (i >= 0) { - Object prior = this.values[i]; - this.values[i] = value; + prior = this.values[i]; if (prior == TOMBSTONE) { + prior = null; // a tombstone replaced is a removal undone: no prior local value this.markLocal(bit, level); - this.count++; // a tombstone replaced is a removal undone: no prior local value - return null; + this.count++; } - return prior; + } else { + prior = null; + i = this.appendSlot(serial, bit, level); } - this.append(serial, bit, level, value); - return null; + // The one reference store on this path. Under G1 (before JDK 24's late barrier expansion) C2 + // expands each reference-store site's write barriers inline, roughly 250 bytes apiece, so the + // overwrite and append paths share this store rather than each having their own: that keeps + // the setters that inline this small enough to inline into their callers. + this.values[i] = value; + return prior; } - /** Appends a live local tag. */ - private void append(char serial, long bit, int level, Object value) { - this.appendSlot(serial, value); + /** Reserves a slot for a new live local tag, returning its index; the caller stores the value. */ + private int appendSlot(char serial, long bit, int level) { + int i = this.reserveSlot(serial); this.markLocal(bit, level); this.count++; + return i; } - private void appendSlot(char serial, Object value) { + /** Reserves the next slot for {@code serial}, returning its index; its value is left unset. */ + private int reserveSlot(char serial) { int i = this.used; if (i == this.serials.length) { this.grow(); } this.serials[i] = serial; - this.values[i] = value; this.used = i + 1; + return i; } /** Removes slot {@code i}, swapping the last slot into its place. The masks stay as they were. */ From 8c906a49c57b02acf5c875670c960014073bc1ae Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sat, 17 Oct 2026 16:10:31 -0400 Subject: [PATCH 02/11] Presize a span's dense store from a per-operation sizing hint Ports #12856's SizingHint / SizingHintTable onto the store split. The tracer looks up a hint by operation name (entry and child lanes, bounded, racy by design) and the span's DenseIdStore allocates its arrays at the hint's size when it is created. Spans record their size back when their trace is written -- after the early post-processors and the root's sampling decision have added their tags -- and when serialized. Without it every fresh span grew its store on its first tag, so C2 inlined grow() and its two allocations into DenseIdStore.store (2816 B of compiled code), over InlineSmallCode, and every setTag called store out of line ("already compiled into a big method"). With the hints converged grow() is cold, store compiles to 768 B and inlines again: on fresh spans, dense on, JDK 17, setTag(long, int) is 1792 B and setTag(long, CharSequence) 1408 B, under the 2500 B limit. Recording at trace write rather than at span finish matters: a hint taken at finish misses the sampling tags, so every later span grew its store from the hint to twice that. Dense on, single-threaded SpanCreationBenchmark: realistic spans allocate 24-196 B less per span (jdbc -196, web -96); throughput is unchanged except bare spans (~4% slower, the per-span hint lookup). With the dense store off the hint is null and the lookup folds away. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/core/CoreTracer.java | 25 +- .../datadog/trace/core/DDSpanContext.java | 84 ++++++- .../java/datadog/trace/api/DenseIdStore.java | 30 +++ .../main/java/datadog/trace/api/IdStore.java | 16 ++ .../datadog/trace/api/IdStoreFactory.java | 25 +- .../java/datadog/trace/api/SizingHint.java | 56 +++++ .../datadog/trace/api/SizingHintTable.java | 102 ++++++++ .../trace/api/SizingHintTableForkedTest.java | 218 ++++++++++++++++++ .../SizingHintTableHashOnlyForkedTest.java | 59 +++++ 9 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 internal-api/src/main/java/datadog/trace/api/SizingHint.java create mode 100644 internal-api/src/main/java/datadog/trace/api/SizingHintTable.java create mode 100644 internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java create mode 100644 internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 05516c73e6b..264414bb9ad 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -44,6 +44,7 @@ import datadog.trace.api.IdGenerationStrategy; import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.Pair; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; import datadog.trace.api.civisibility.config.BazelMode; @@ -1444,8 +1445,10 @@ void write(final SpanList trace) { writtenTrace.get(i).setTag(APM_ENABLED, 0); } } + recordDenseSizes(writtenTrace); writer.write(writtenTrace); } else { + recordDenseSizes(writtenTrace); // with span streaming this won't work - it needs to be changed // to track an effective sampling rate instead, however, tests // checking that a hard reference on a continuation prevents @@ -1457,6 +1460,17 @@ void write(final SpanList trace) { } } + /** + * Tunes each span's per-operation sizing hint. Done here rather than at span finish: the early + * post-processors and the root's sampling decision add tags after a span finishes, and a hint + * that missed them would leave every later span of the operation growing its store. + */ + private static void recordDenseSizes(List trace) { + for (int i = 0; i < trace.size(); i++) { + trace.get(i).spanContext().recordDenseSize(); + } + } + private List interceptCompleteTrace(SpanList originalTrace) { return interceptCompleteTrace(interceptors, originalTrace); } @@ -2383,6 +2397,14 @@ protected static final DDSpanContext buildSpanContext( requestContextDataIast = builderRequestContextDataIast; } + // Per-operation dense-store sizing: an entry (local-root) span carries tags a child doesn't, + // so + // the lane is chosen by whether there's a local parent. Null with the dense store off -- the + // flag test folds, so the default path does no lookup. + final SizingHint sizingHint = + SizingHint.forOperation( + operationName, !(resolvedParentSpanContext instanceof DDSpanContext)); + // some attributes are inherited from the parent context = new DDSpanContext( @@ -2410,7 +2432,8 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - tagParent); + tagParent, + sizingHint); // By setting the tags on the context we apply decorators to any tags that have been set via // the builder. The `mergedTracerTags` are always applied first (the precedence floor: diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 3f2a420d432..53857691c16 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -20,6 +20,7 @@ import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; @@ -58,6 +59,7 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -184,6 +186,14 @@ public class DDSpanContext @GuardedBy("knownTags") private TagMap tagView; + /** + * The per-operation sizing hint ({@link SizingHint}) this span's dense store was presized from, + * and that it feeds back into when it finishes and when it's serialized ({@link + * #recordDenseSize}), so later spans of the operation are sized right. Null with the dense store + * off. + */ + @Nullable private final SizingHint sizingHint; + /** The service name is required, otherwise the span are dropped by the agent */ private volatile String serviceName; @@ -415,6 +425,62 @@ public DDSpanContext( final boolean injectBaggageAsTags, final boolean injectLinksAsTags, final TagMap readThroughParent) { + this( + traceId, + spanId, + parentId, + parentServiceName, + serviceNameSource, + serviceName, + operationName, + resourceName, + samplingPriority, + origin, + baggageItems, + w3cBaggage, + errorFlag, + spanType, + traceCollector, + requestContextDataAppSec, + requestContextDataIast, + CiVisibilityContextData, + pathwayContext, + disableSamplingMechanismValidation, + propagationTags, + profilingContextIntegration, + injectBaggageAsTags, + injectLinksAsTags, + readThroughParent, + null); + } + + public DDSpanContext( + final DDTraceId traceId, + final long spanId, + final long parentId, + final CharSequence parentServiceName, + final CharSequence serviceNameSource, + final String serviceName, + final CharSequence operationName, + final CharSequence resourceName, + final int samplingPriority, + final CharSequence origin, + final Map baggageItems, + final Baggage w3cBaggage, + final boolean errorFlag, + final CharSequence spanType, + final TraceCollector traceCollector, + final Object requestContextDataAppSec, + final Object requestContextDataIast, + final Object CiVisibilityContextData, + final PathwayContext pathwayContext, + final boolean disableSamplingMechanismValidation, + final PropagationTags propagationTags, + final ProfilingContextIntegration profilingContextIntegration, + final boolean injectBaggageAsTags, + final boolean injectLinksAsTags, + final TagMap readThroughParent, + @Nullable final SizingHint sizingHint) { assert traceCollector != null; this.traceCollector = traceCollector; @@ -442,7 +508,8 @@ public DDSpanContext( // Reads through to the frozen tracer-level tags, if any. TagMap parentTags = TagMap.readThroughParent(readThroughParent); - this.knownTags = IdStore.create(parentTags); + this.knownTags = IdStore.create(parentTags, sizingHint); + this.sizingHint = sizingHint; this.customTags = HashStore.inheritedFrom(parentTags); this.customTagsBorrowed = this.customTags != null; @@ -1692,6 +1759,17 @@ public void setMetaStruct(final String field, final T value) { } } + /** + * Feeds this span's known-tag count back into its operation's {@link SizingHint}, so later spans + * of the operation are presized. A best-effort racy max; with the dense store off the hint is + * null and this does nothing. + */ + void recordDenseSize() { + if (sizingHint != null) { + knownTags.recordSize(sizingHint); + } + } + void earlyProcessTags(AppendableSpanLinks links) { synchronized (knownTags) { TagMap view = tagView(); @@ -1749,6 +1827,10 @@ void processTagsAndBaggage( } } + // Tune the operation's sizing hint now that every tag, serialization-time ones included, is + // present. + recordDenseSize(); + // Baggage Map baggageItemsWithPropagationTags; if (this.injectBaggageAsTags) { diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 8c5941745b6..02c68624538 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; +import javax.annotation.Nullable; /** * The dense {@link IdStore}: known tags in two parallel arrays -- each tag's 16-bit serial and its @@ -187,6 +188,35 @@ public EntryReader next() { this.parent = (DenseIdStore) parent; } + /** + * Like {@link #DenseIdStore(IdStore)}, with the arrays allocated up front at {@code hint}'s size. + * A span's store presized this way never grows on its first tag, so {@link #grow} stays off the + * write path. A null hint, or one not yet seeded (a racy reader can see 0), keeps the shared + * empty arrays. + */ + DenseIdStore(IdStore parent, @Nullable SizingHint hint) { + this(parent); + if (hint != null) { + int cap = hint.size; // read the racy field once + if (cap > 0) { + this.serials = new char[cap]; + this.values = new Object[cap]; + } + } + } + + @Override + public void recordSize(@Nullable SizingHint hint) { + // Capped hints (the shared overflow) are fixed: unlike sharers mustn't grow them. Best-effort + // racy max; a benign plain-int write. + if (hint != null && !hint.capped) { + int used = this.used; + if (used > hint.size) { + hint.size = used; + } + } + } + @Override public EntryReader getEntryReader(long tagId) { return this.readerOf(tagId, this.getValue(tagId)); diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index 4854cfff427..dc8c1729e17 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; +import javax.annotation.Nullable; /** * Known tags keyed by their {@link KnownTags} id. Every method takes an id, or an {@link @@ -82,6 +83,21 @@ public static IdStore create(TagMap parent) { return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore); } + /** + * Like {@link #create(TagMap)}, presized from {@code hint} (see {@link SizingHintTable}); a null + * hint, or a store that isn't sized per span, ignores it. + */ + public static IdStore create(TagMap parent, @Nullable SizingHint hint) { + return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore, hint); + } + + /** + * Feeds this store's size back into {@code hint} -- a best-effort, racy max, so later stores of + * the same operation are presized to it. A capped or null hint, or a store that isn't sized per + * span, ignores it. + */ + public void recordSize(@Nullable SizingHint hint) {} + /** {@code tagId}'s slot bit. */ static long slotBit(long tagId) { return 1L << KnownTagCodec.slotOf(tagId); diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java index 765906c62ec..8a471b36397 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -1,5 +1,7 @@ package datadog.trace.api; +import javax.annotation.Nullable; + /** * Builds the {@link IdStore}s of the one implementation this JVM uses: {@link DenseIdStore} when * the dense store is on, {@link HashIdStore} otherwise. Rollout scaffolding: the hash store stays @@ -16,15 +18,22 @@ abstract class IdStoreFactory { /** The JVM's store, chosen once by the dense-store flag. */ static final class Default { + /** Whether the dense store is on; folds wherever it is tested. */ + static final boolean DENSE = Config.get().isTraceDenseTagsEnabled(); + static final IdStoreFactory INSTANCE = - Config.get().isTraceDenseTagsEnabled() - ? DenseIdStoreFactory.INSTANCE - : HashIdStoreFactory.INSTANCE; + DENSE ? DenseIdStoreFactory.INSTANCE : HashIdStoreFactory.INSTANCE; } /** A fresh store reading through to {@code parent} (may be null). */ abstract IdStore create(IdStore parent); + /** + * A fresh store reading through to {@code parent} (may be null), presized from {@code hint} where + * the store keeps sized arrays; a null hint is the same as {@link #create(IdStore)}. + */ + abstract IdStore create(IdStore parent, @Nullable SizingHint hint); + /** A new empty parentless store, for a frozen map. */ abstract IdStore createEmpty(); } @@ -39,6 +48,11 @@ IdStore create(IdStore parent) { return new HashIdStore(parent); } + @Override + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new HashIdStore(parent); // buckets aren't sized per span; a hint is only for dense + } + @Override IdStore createEmpty() { return HashIdStore.createEmpty(); @@ -55,6 +69,11 @@ IdStore create(IdStore parent) { return new DenseIdStore(parent); } + @Override + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new DenseIdStore(parent, hint); + } + @Override IdStore createEmpty() { return new DenseIdStore(null); diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHint.java b/internal-api/src/main/java/datadog/trace/api/SizingHint.java new file mode 100644 index 00000000000..238c5620178 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHint.java @@ -0,0 +1,56 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; +import javax.annotation.Nullable; + +/** + * Opaque per-operation dense-store sizing hint, and a self-contained {@link FlatHashtable.D1} + * entry: it carries everything the probe compares (the operation name as the {@link + * FlatHashtable.D1.Entry} key, plus the cached hash from {@link FlatHashtable.Entry#hash}) and the + * tuned payload ({@link #size}). Holding key, hash, and value in ONE object behind ONE array slot + * is deliberate — entry publication is a single reference store, so a reader sees {@code null} or a + * complete entry (never a torn one), and the {@code final} identity fields are visible even under + * racy publication (JMM final-field guarantee). That sidesteps the memory-ordering / visibility + * problems parallel key/hash/value arrays would create, no volatile or atomics. + * + *

Opaque to everything outside {@code datadog.trace.api}: its only public member is the lookup, + * {@link #forOperation}. {@link DenseIdStore} reads {@link #size} to size a span's fresh store and + * writes it back (best-effort max, see {@link IdStore#recordSize}) at a terminal point; {@link + * SizingHintTable} mints them. Callers only ever hold the reference. + */ +public final class SizingHint extends FlatHashtable.D1.Entry { + // Payload: the tuned dense-store size. Plain racy int, updated best-effort max (see + // IdStore#recordSize -- not strictly monotonic under concurrent finishes). A stale/lost/lowered + // read only mis-sizes an array (over/under-provision), never corrupts tag data, so no + // synchronization. Not safely published by a racing create either (FlatHashtable class doc): a + // reader may see the default 0, which DenseIdStore treats as "no presize" -- benign. + int size; + + // When true, size is fixed and recordSize won't grow it. For the shared overflow hint (a + // HETEROGENEOUS catch-all for over-budget operations): self-tuning it toward the observed max + // would converge to the max across unlike sharers and over-provision the lean ones. + final boolean capped; + + /** + * The hint for spans of {@code operationName}, in the entry-span or child-span lane; null when + * the dense store is off (constant-folded) or there is no operation name. See {@link + * SizingHintTable#hintFor}. + */ + @Nullable + public static SizingHint forOperation(@Nullable CharSequence operationName, boolean entrySpan) { + return SizingHintTable.hintFor(operationName, entrySpan); + } + + /** + * A fresh, uncapped hint for {@code operationName}, seeded at {@link SizingHintTable#SEED_SIZE}. + */ + SizingHint(String operationName) { + this(operationName, SizingHintTable.SEED_SIZE, false); + } + + SizingHint(String operationName, int seedSize, boolean capped) { + super(operationName); + this.size = seedSize; + this.capped = capped; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java new file mode 100644 index 00000000000..cdbd171b252 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java @@ -0,0 +1,102 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; +import javax.annotation.Nullable; + +/** + * Process-wide, self-tuning registry of per-operation {@link SizingHint}s, keyed by operation name. + * Pure static and package-private: the tracer resolves a hint at span build through {@link + * SizingHint#forOperation} and hands it to the span, which sizes its dense {@link IdStore} from it + * and records the actual size back when it finishes — the hint converges to the operation's real + * known-tag high-water mark, so later spans of that operation size correctly. + * + *

Only meaningful with the dense store on ({@link IdStoreFactory.Default#DENSE}); with it off, + * {@link #hintFor} returns {@code null} before touching anything, so the default span path does no + * lookup and the lane tables are never even allocated (they live in a holder class). + * + *

Two lanes, because a span's dense size depends systematically on whether it is an entry + * (local-root) span or not: entry spans carry the trace-metadata / enriching tags and children + * don't, so the same operation name has two different steady-state sizes. {@link #hintFor} picks + * the lane. + * + *

Bounded + racy by design. Each lane is a fixed-capacity {@link FlatHashtable.D1} (never + * resized) so memory is bounded even under unbounded/dynamic operation names; once a lane's + * cardinality budget is spent, further operations share a capped overflow hint. Insertion and the + * best-effort-max size update are lock-free and deliberately racy (the table is used from many + * threads without synchronization): a lost size-counter update lets a lane slightly overshoot its + * budget (still bounded by the backing array, whose full-table insert just fails), and two threads + * minting the same operation at once may leave a shadowed, unreachable duplicate. Either only + * mis-sizes an array (over/under-provision) for a span or two, never corrupts tag data (see {@link + * FlatHashtable} and {@link SizingHint} for the rationale). + * + *

Keyed by the operation name's {@code String} form. In practice every operation name we see is + * a {@code String} or a {@link datadog.trace.bootstrap.instrumentation.api.UTF8BytesString}, whose + * {@code toString()} just returns its backing field — so the {@code toString()} here is O(1) and + * allocation-free on the hot path. A {@code null} operation name gets no hint (the span falls back + * to the generic default capacity); a missed hint is benign, never wrong. + */ +final class SizingHintTable { + private SizingHintTable() {} + + // Seed for a fresh per-operation hint: a small floor that self-tunes up via best-effort-max + // recordSize. Kept small because the hint only grows -- a large seed would lock small spans into + // oversized arrays forever; a span that outgrows it pays one doubling. + static final int SEED_SIZE = 4; + + // Fixed size for the shared over-budget hint: the untuned default, DenseIdStore's initial + // capacity + // (a compile-time constant, so reading it loads no class). Capped (never grown by recordSize) + // because it's a heterogeneous catch-all -- growing it would over-provision lean sharers to the + // max of an unlike cohort. + static final int OVERFLOW_SEED = DenseIdStore.INIT_CAP; + + // Max distinct operation names per lane that get their own hint before collapsing to the capped + // overflow hint. + static final int CARDINALITY_LIMIT = 512; + + /** + * The lanes, in a holder so they are only allocated once the dense store is on and a span asks + * for a hint. + */ + static final class Lanes { + private Lanes() {} + + // Entry (local-root, enriched) lane and non-entry (child) lane, keyed by operation name. + static final FlatHashtable.D1 ENTRY = + FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT); + static final FlatHashtable.D1 CHILD = + FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT); + + // Shared capped hint each lane returns once its budget is exhausted. + static final SizingHint ENTRY_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + static final SizingHint CHILD_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + } + + /** + * The sizing hint for {@code operationName} in the given lane: the existing one, a freshly-minted + * (seeded) one if the lane has budget, or the shared capped overflow hint if it's full. Returns + * {@code null} when the dense store is off (constant-folded, so the default span path does no + * lookup) and for a {@code null} operation name — the span then uses the generic default capacity + * (operation-less spans aren't reliably similar, so they get no hint). Hits are a single probe; + * the create path is warmup-rare. + */ + @Nullable + static SizingHint hintFor(@Nullable CharSequence operationName, boolean entrySpan) { + if (!IdStoreFactory.Default.DENSE) { + return null; + } + if (operationName == null) { + return null; + } + // O(1) for String / UTF8BytesString (see class doc) + final String key = operationName.toString(); + final FlatHashtable.D1 lane = entrySpan ? Lanes.ENTRY : Lanes.CHILD; + // One probe: returns a hit directly, and null only when the fixed lane is full and the key is + // absent. + final SizingHint hint = lane.tryGetOrCreateOrNull(key, SizingHint::new); + if (hint != null) { + return hint; + } + return entrySpan ? Lanes.ENTRY_OVERFLOW : Lanes.CHILD_OVERFLOW; + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java new file mode 100644 index 00000000000..05d66cb9973 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java @@ -0,0 +1,218 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Registry semantics for {@link SizingHintTable}, and how {@link DenseIdStore} sizes from and tunes + * a {@link SizingHint}. Hints are only minted with the dense store on, and the flag is read once + * into a static final, so this runs in its own JVM ({@code ForkedTest}) with the flag set first. + * + *

The table is process-wide static state; each test uses distinct operation names so tests don't + * interfere. The overflow test fills the child lane, so no other test here uses that lane expecting + * a fresh hint. + */ +class SizingHintTableForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + /** Distinct known tag ids, in a stable order. */ + static final List KNOWN_IDS = new ArrayList<>(); + + @BeforeAll + static void checkSetup() throws IllegalAccessException { + assertTrue(IdStoreFactory.Default.DENSE, "dense store must be on in this JVM"); + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) && !KNOWN_IDS.contains(id)) { + KNOWN_IDS.add(id); + } + } + assertTrue(KNOWN_IDS.size() > 24, "need enough known tags to exercise growth"); + } + + private static IdStore storeFor(SizingHint hint) { + IdStore store = IdStore.create(null, hint); + assertSame(DenseIdStore.class, store.getClass()); + return store; + } + + private static Object field(IdStore store, String name) { + try { + Field f = DenseIdStore.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(store); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private static int capacity(IdStore store) { + char[] serials = (char[]) field(store, "serials"); + Object[] values = (Object[]) field(store, "values"); + assertEquals(serials.length, values.length, "dense arrays stay parallel"); + return serials.length; + } + + /** Sets the first {@code n} known tags. */ + private static void fill(IdStore store, int n) { + for (int i = 0; i < n; ++i) { + store.set(KNOWN_IDS.get(i), "v" + i); + } + assertEquals(n, store.size()); + } + + @Test + void nullOperationNameGetsNoHint() { + assertNull(SizingHintTable.hintFor(null, true)); + assertNull(SizingHintTable.hintFor(null, false)); + } + + @Test + void nonStringCharSequenceIsKeyedByContent() { + // Operation names are commonly UTF8BytesString, not String; a content-equal name must resolve + // to the same hint as its String form (keyed by toString(), which is O(1) for these types). + SizingHint viaString = SizingHintTable.hintFor("registry.utf8", true); + CharSequence utf8 = UTF8BytesString.create("registry.utf8"); + assertNotNull(viaString); + assertSame(viaString, SizingHintTable.hintFor(utf8, true)); + } + + @Test + void freshHintIsSeededAndUncapped() { + SizingHint hint = SizingHintTable.hintFor("registry.fresh", true); + assertNotNull(hint); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + assertFalse(hint.capped); + assertEquals("registry.fresh", hint.key()); + } + + @Test + void sameOperationAndLaneReturnsSameInstance() { + assertSame( + SizingHintTable.hintFor("registry.stable", true), + SizingHintTable.hintFor("registry.stable", true)); + } + + @Test + void entryAndChildLanesAreIndependent() { + SizingHint entry = SizingHintTable.hintFor("registry.twolane", true); + SizingHint child = SizingHintTable.hintFor("registry.twolane", false); + assertNotNull(entry); + assertNotNull(child); + assertNotSame(entry, child, "each lane holds its own hint for the same operation name"); + assertSame(entry, SizingHintTable.hintFor("registry.twolane", true)); + assertSame(child, SizingHintTable.hintFor("registry.twolane", false)); + } + + @Test + void beyondCardinalityBudgetSharesACappedOverflowHint() { + // A hint minted before the lane fills keeps resolving to itself afterwards. + SizingHint early = SizingHintTable.hintFor("registry.flood.early", false); + assertFalse(early.capped); + + // Fill the child lane past its budget; a capped shared hint must appear. + SizingHint firstOverflow = null; + for (int i = 0; i <= SizingHintTable.CARDINALITY_LIMIT && firstOverflow == null; i++) { + SizingHint hint = SizingHintTable.hintFor("registry.flood." + i, false); + assertNotNull(hint); + if (hint.capped) { + firstOverflow = hint; + } + } + assertNotNull(firstOverflow, "lane collapses to a capped overflow hint once full"); + assertEquals(SizingHintTable.OVERFLOW_SEED, firstOverflow.size); + + // Every further over-budget name shares that same capped instance... + SizingHint another = SizingHintTable.hintFor("registry.flood.after", false); + assertTrue(another.capped); + assertSame(firstOverflow, another); + // ...existing hints still resolve (the cap blocks only creation)... + assertSame(early, SizingHintTable.hintFor("registry.flood.early", false)); + // ...and the entry lane has its own budget. + assertFalse(SizingHintTable.hintFor("registry.flood.after", true).capped); + + // A capped hint is never tuned: a heavy span sharing it does not grow it. + IdStore heavy = storeFor(another); + fill(heavy, SizingHintTable.OVERFLOW_SEED + 4); + heavy.recordSize(another); + assertEquals(SizingHintTable.OVERFLOW_SEED, another.size); + } + + @Test + void sizingHintPresizesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.presize", true); + assertEquals( + hint.size, capacity(storeFor(hint)), "presized at the hint's size, before a write"); + + // Without a hint, a store starts on the shared empty arrays. + assertEquals(0, capacity(storeFor(null))); + assertEquals(0, capacity(IdStore.create(null))); + } + + @Test + void sizingHintFeedsAndTunesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.tuning", true); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + + int observed = SizingHintTable.SEED_SIZE + 3; + IdStore store = storeFor(hint); + fill(store, observed); + store.recordSize(hint); + assertEquals(observed, hint.size, "hint self-tunes up to the observed high-water mark"); + + // The next store of the operation is presized to fit, so it never grows. + IdStore next = storeFor(hint); + assertEquals(observed, capacity(next)); + fill(next, observed); + assertEquals(observed, capacity(next)); + + // A smaller later observation does not shrink the hint. + IdStore smaller = storeFor(hint); + fill(smaller, 1); + smaller.recordSize(hint); + assertEquals(observed, hint.size, "recordSize never shrinks the hint"); + + // A null hint is a no-op. + smaller.recordSize(null); + } + + @Test + void fullArraysDoubleUpToTheSerialLimit() { + // Presized small: 4 -> 8 -> 16 + IdStore store = storeFor(new SizingHint("registry.growth")); + fill(store, 4); + assertEquals(4, capacity(store)); + fill(store, 5); + assertEquals(8, capacity(store)); + fill(store, 9); + assertEquals(16, capacity(store)); + + // Unhinted: first allocation at INIT_CAP, then doubling + IdStore unhinted = storeFor(null); + fill(unhinted, 1); + assertEquals(DenseIdStore.INIT_CAP, capacity(unhinted)); + fill(unhinted, DenseIdStore.INIT_CAP + 1); + assertEquals(2 * DenseIdStore.INIT_CAP, capacity(unhinted)); + + // Growth never passes the serial limit, yet always makes room. + IdStore all = storeFor(new SizingHint("registry.growth.all")); + fill(all, KNOWN_IDS.size()); + assertTrue(capacity(all) <= KnownTagCodec.serialLimit()); + assertTrue(capacity(all) >= KNOWN_IDS.size()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java new file mode 100644 index 00000000000..00d4fa67f48 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java @@ -0,0 +1,59 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * With the dense store off, {@link SizingHintTable} hands out no hints and never allocates its + * lanes, and the hash store ignores a hint. Runs in its own JVM so the flag is read as off. + */ +class SizingHintTableHashOnlyForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "false"); + } + + @Test + void noHintWhenTheDenseStoreIsOff() { + assertFalse(IdStoreFactory.Default.DENSE); + assertNull(SizingHintTable.hintFor("registry.off", true)); + assertNull(SizingHintTable.hintFor("registry.off", false)); + assertNull(SizingHintTable.hintFor(null, true)); + assertFalse(isLoaded("datadog.trace.api.SizingHintTable$Lanes")); + } + + @Test + void theHashStoreIgnoresAHint() { + SizingHint hint = new SizingHint("registry.off.direct"); + IdStore store = IdStore.create(null, hint); + store.set(KnownTags.COMPONENT_ID, "okhttp"); + store.set(KnownTags.DD_BASE_SERVICE_ID, "svc"); + store.recordSize(hint); + + assertSame(HashIdStore.class, store.getClass()); + assertEquals(SizingHintTable.SEED_SIZE, hint.size, "recordSize is a no-op on the hash store"); + assertFalse(isLoaded("datadog.trace.api.DenseIdStore")); + } + + /** Whether {@code className} has been loaded by TagMap's class loader, without loading it. */ + private static boolean isLoaded(String className) { + Method findLoadedClass; + try { + findLoadedClass = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class); + findLoadedClass.setAccessible(true); + } catch (RuntimeException | NoSuchMethodException e) { + assumeTrue(false, "cannot inspect loaded classes on this JVM: " + e); + return false; + } + try { + return findLoadedClass.invoke(TagMap.class.getClassLoader(), className) != null; + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} From 44115d2a788af9b14a3424adc02d54cb9fa4cd87 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sat, 17 Oct 2026 17:06:21 -0400 Subject: [PATCH 03/11] Seed a span prototype's known tags in bulk With the dense store, a prototype's known tags are now copied into a span at once instead of one at a time through isPresent, the interceptor and routePut: - IdStore.seedAbsent appends a frozen, parentless, tombstone-free dense source with two array copies when its occupancy words share no bit with the target's live, tombstoned or inherited words, per level: then none of its tags can already be present, so "absent only" holds without a per-tag check. Otherwise it copies nothing and the caller seeds tag by tag. The hash store always declines. - TagInterceptor.canSeedInBulk: no known tag needs the interceptor except span.kind, whose one effect -- the cached kind ordinal -- the seed sets directly. A split-by-tags span.kind counts as a hazard. - The plan (bulk or not, the kind ordinal, and the interceptor it was checked against) is cached on the SpanPrototype on first use, racy and immutable. Custom prototype tags are always seeded one at a time. SpanPrototypeConstructionDenseForkedTest reruns the prototype tests with the dense store on, plus the fallback for a span that already has one of the prototype's tags. The TagStoresTest cases for seedAbsent also caught a bug from the single-store commit (now fixed there): the tombstone path indexed this.values before reserveSlot could replace it. Measured gain is small today: prototypes carry two or three known tags, so single-threaded prototype span creation is within about +-1%. It grows with prototypes that carry more constants. Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/DDSpanContext.java | 96 +++++++++++++++---- .../core/taginterceptor/TagInterceptor.java | 19 ++++ ...nPrototypeConstructionDenseForkedTest.java | 75 +++++++++++++++ .../java/datadog/trace/api/DenseIdStore.java | 46 +++++++++ .../main/java/datadog/trace/api/IdStore.java | 10 ++ .../main/java/datadog/trace/api/TagMap.java | 6 ++ .../instrumentation/api/SpanPrototype.java | 20 ++++ .../java/datadog/trace/api/TagStoresTest.java | 67 +++++++++++++ 8 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 53857691c16..b774e5e50e4 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -722,7 +722,7 @@ public void apply(@Nonnull final SpanPrototype prototype) { setSpanType(spanType); } } - seedAbsentTags(prototype.tags()); + seedPrototypeTags(prototype); if (this.integrationName == null) { final CharSequence integrationName = prototype.integrationName(); if (integrationName != null) { @@ -752,25 +752,87 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { } /** - * Seeds tags that are not already present, routed through the interceptor. Mirrors {@link - * #setAllTags(TagMap, boolean)}'s intercepting path but skips any key already set, so explicit - * tags keep precedence over the prototype's constant defaults. + * How a {@link SpanPrototype}'s tags are seeded into a span, cached on the prototype for the + * interceptor it was checked against ({@link SpanPrototype#seedPlan}). */ - private void seedAbsentTags(final TagMap map) { - if (map == null) { - return; + private static final class SeedPlan { + final TagInterceptor interceptor; + + /** No known tag needs the interceptor beyond span.kind; see TagInterceptor#canSeedInBulk. */ + final boolean bulk; + + /** The ordinal of the prototype's span.kind, or SPAN_KIND_UNSET. */ + final byte kindOrdinal; + + SeedPlan(TagInterceptor interceptor, boolean bulk, byte kindOrdinal) { + this.interceptor = interceptor; + this.bulk = bulk; + this.kindOrdinal = kindOrdinal; } + } + + private SeedPlan seedPlanFor(final SpanPrototype prototype) { + Object cached = prototype.seedPlan(); + if (cached instanceof SeedPlan && ((SeedPlan) cached).interceptor == tagInterceptor) { + return (SeedPlan) cached; + } + TagMap tags = prototype.tags(); + TagMap.Entry kind = tags.getEntry(KnownTags.SPAN_KIND_ID); + SeedPlan plan = + new SeedPlan( + tagInterceptor, + tagInterceptor.canSeedInBulk(tags), + kind == null ? SPAN_KIND_UNSET : spanKindOrdinalOf(kind.stringValue())); + prototype.seedPlan(plan); + return plan; + } + + /** + * Seeds a prototype's tags where the span doesn't already have them, so explicit tags keep + * precedence over the prototype's constant defaults. Two paths: + * + *

    + *
  • Bulk, when no known tag needs the interceptor beyond span.kind and none of them + * can already be present: the known tags are copied in at once ({@link + * IdStore#seedAbsent}), and span.kind's one effect, the cached kind ordinal, is set + * directly. + *
  • Tag by tag otherwise, through the interceptor, mirroring {@link + * #setAllTags(TagMap, boolean)}'s intercepting path but skipping any tag already set. + *
+ * + * Custom tags always go tag by tag. + */ + private void seedPrototypeTags(final SpanPrototype prototype) { + final TagMap tags = prototype.tags(); + final SeedPlan plan = seedPlanFor(prototype); synchronized (knownTags) { - map.forEach( - this, - (ctx, tagEntry) -> { - if (ctx.isPresent(tagEntry)) { - return; - } - if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.routePut(tagEntry); - } - }); + if (plan.bulk && knownTags.seedAbsent(tags.knownStore())) { + if (plan.kindOrdinal != SPAN_KIND_UNSET) { + spanKindOrdinal = plan.kindOrdinal; + } + if (tags.customStore() != null) { + // rare: a prototype with custom tags seeds those one at a time + tags.forEach( + this, + (ctx, tagEntry) -> { + if (tagEntry.tagId() == 0) { + ctx.seedIfAbsent(tagEntry); + } + }); + } + return; + } + tags.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry)); + } + } + + /** Seeds one tag if the span doesn't have it yet. Call under the {@code knownTags} lock. */ + private void seedIfAbsent(final TagMap.EntryReader tagEntry) { + if (isPresent(tagEntry)) { + return; + } + if (!tagInterceptor.interceptTag(this, tagEntry)) { + routePut(tagEntry); } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java index b487d4665b7..d004d46108d 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java @@ -152,6 +152,25 @@ public boolean needsIntercept(TagMap.EntryReader entry) { } /** Whether a custom tag (one with no id) may be routed; only split-by-tags routes them. */ + /** + * Whether a span prototype's tags can be seeded in bulk: none of its known tags needs the + * interceptor except {@code span.kind}, whose only effect is the span's cached kind ordinal, + * which the seed sets directly. Custom tags don't count: they are seeded one at a time. Depends + * only on this interceptor's configuration, so a prototype computes it once per interceptor. + */ + public boolean canSeedInBulk(TagMap tags) { + for (TagMap.EntryReader entry : tags) { + long tagId = entry.tagId(); + if (tagId == 0) { + continue; + } + if (tagId == KnownTags.SPAN_KIND_ID ? isSplitServiceTag(tagId) : needsIntercept(tagId)) { + return false; + } + } + return true; + } + public boolean needsInterceptCustomTag(String customTag) { return isSplitServiceTag(customTag); } diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java new file mode 100644 index 00000000000..9d03f39c78b --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java @@ -0,0 +1,75 @@ +package datadog.trace.core; + +import static datadog.trace.bootstrap.instrumentation.api.Tags.COMPONENT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_CLIENT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_SERVER; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.common.writer.ListWriter; +import org.junit.jupiter.api.Test; + +/** + * {@link SpanPrototypeConstructionTest} with the dense store on, where a prototype's known tags are + * seeded in bulk ({@code DDSpanContext.seedPrototypeTags}); runs in its own JVM because the flag is + * read once. Adds the bulk path's fallback: a span that already holds one of the prototype's tags + * is seeded tag by tag, so its own value wins and the rest still land. + */ +class SpanPrototypeConstructionDenseForkedTest extends SpanPrototypeConstructionTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + @Test + void aTagTheSpanAlreadyHasKeepsItsValueAndTheRestAreSeeded() { + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + try { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_CLIENT) + .initComponentOnly("prototype-component") + .build(); + DDSpan span = (DDSpan) tracer.buildSpan("test-instr", "op").start(); + try { + span.setTag(COMPONENT, "explicit"); + span.spanContext().apply(prototype); + + assertEquals("explicit", span.getTags().get(COMPONENT)); // present: kept + assertEquals(SPAN_KIND_CLIENT, span.getTags().get(SPAN_KIND)); // absent: seeded + assertEquals(SPAN_KIND_CLIENT, span.getSpanKindString()); // and its ordinal + } finally { + span.finish(); + } + } finally { + tracer.close(); + } + } + + @Test + void aPrototypeSeedsEverySpanBuiltFromIt() { + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + try { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_SERVER) + .initComponentOnly("prototype-component") + .build(); + for (int i = 0; i < 3; i++) { // the first span builds the cached plan, the rest reuse it + DDSpan span = (DDSpan) tracer.buildSpan(prototype, null).start(); + try { + assertEquals("prototype-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + } finally { + tracer.close(); + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 02c68624538..5803f8c20ff 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -205,6 +205,52 @@ public EntryReader next() { } } + /** + * The bulk seed: when {@code source} is a parentless dense store with no tombstones and its + * occupancy words share no bit with this store's (live, tombstoned or inherited, per level), none + * of its tags can be present here, so its serials and values are appended with two array copies + * and its occupancy ORed in. The occupancy words over-approximate presence, so a shared bit only + * means "maybe present" and falls back to the caller's tag-by-tag seed. + */ + @Override + public boolean seedAbsent(IdStore source) { + if (!(source instanceof DenseIdStore)) { + return false; + } + DenseIdStore src = (DenseIdStore) source; + int n = src.used; + if (src.parent != null || n != src.count) { + return false; // read-through or tombstones: not a plain list of tags + } + long maybeHere = this.tombstoneMask | this.inheritedMask; + if ((src.spanOccupancy & (this.spanOccupancy | maybeHere)) != 0L + || (src.traceOccupancy & (this.traceOccupancy | maybeHere)) != 0L) { + return false; + } + if (n == 0) { + return true; + } + int used = this.used; + if (used + n > this.serials.length) { + this.growTo(used + n); + } + System.arraycopy(src.serials, 0, this.serials, used, n); + System.arraycopy(src.values, 0, this.values, used, n); + this.used = used + n; + this.count += n; + this.spanOccupancy |= src.spanOccupancy; + this.traceOccupancy |= src.traceOccupancy; + return true; + } + + /** Grows the arrays to hold at least {@code minCapacity} slots. Rare, so kept out of line. */ + private void growTo(int minCapacity) { + int newCap = + Math.max(minCapacity, Math.min(this.serials.length << 1, KnownTagCodec.serialLimit())); + this.serials = Arrays.copyOf(this.serials, newCap); + this.values = Arrays.copyOf(this.values, newCap); + } + @Override public void recordSize(@Nullable SizingHint hint) { // Capped hints (the shared overflow) are fixed: unlike sharers mustn't grow them. Best-effort diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index dc8c1729e17..ea115639d00 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -98,6 +98,16 @@ public static IdStore create(TagMap parent, @Nullable SizingHint hint) { */ public void recordSize(@Nullable SizingHint hint) {} + /** + * Copies every tag of {@code source} in at once if none of them can already be present here -- + * locally, as a tombstone, or inherited -- and returns true; otherwise copies nothing and returns + * false, and the caller seeds tag by tag. A store that can't prove absence in bulk always returns + * false. {@code source} must be frozen. + */ + public boolean seedAbsent(IdStore source) { + return false; + } + /** {@code tagId}'s slot bit. */ static long slotBit(long tagId) { return 1L << KnownTagCodec.slotOf(tagId); diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 1d588cfb98e..0dafc875902 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1302,6 +1302,12 @@ private static Entry entryOf(EntryReader reader) { return reader == null ? null : reader.entry(); } + /** This map's known-tag store. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public IdStore knownStore() { + return this.knownStore; + } + /** This map's custom store: null, borrowed ({@link #isCustomStoreBorrowed}), or its own. */ @Restricted(allowedIn = {"datadog.trace.core"}) public HashStore customStore() { diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java index 24ccd2b39d8..148d0812d5d 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -1,5 +1,6 @@ package datadog.trace.bootstrap.instrumentation.api; +import datadog.apicontract.Restricted; import datadog.trace.api.TagMap; /** @@ -30,6 +31,13 @@ public static Builder builder() { private final CharSequence integrationName; private final TagMap tags; // frozen + /** + * The tracer core's plan for seeding these tags into a span (whether they can be copied in bulk, + * and the span kind they carry), built on first use. Racy by design: the plan is immutable, so a + * reader sees null or a complete plan, and two threads building it at once just build it twice. + */ + private Object seedPlan; + private SpanPrototype(final Builder builder) { this.instrumentationName = builder.instrumentationName; this.operationName = builder.operationName; @@ -64,6 +72,18 @@ public TagMap tags() { return tags; } + /** The tracer core's seed plan for these tags, or null before the first span uses it. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public Object seedPlan() { + return seedPlan; + } + + /** Records the tracer core's seed plan for these tags; see {@link #seedPlan()}. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public void seedPlan(Object plan) { + this.seedPlan = plan; + } + public static final class Builder { private String instrumentationName; private CharSequence operationName; diff --git a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java index 888a7dde3e8..b815247d5dd 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -144,6 +144,73 @@ void aClearSlotBitIsADefiniteMiss() { assertEquals("/a", child.getValue(KnownTags.HTTP_ROUTE_ID)); } + /** A frozen-style source: a parentless dense store holding {@code ids}, each set to its name. */ + private static DenseIdStore denseSource(long... ids) { + DenseIdStore source = new DenseIdStore(null); + for (long id : ids) { + source.set(id, KnownTagCodec.nameOf(id)); + } + return source; + } + + @Test + void seedAbsentCopiesEveryTagWhenNoneMayBePresent() { + DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID); + DenseIdStore target = new DenseIdStore(null); + target.set(KnownTags.HTTP_ROUTE_ID, "/a"); + assertEquals( + 0L, source.spanOccupancy & target.spanOccupancy, "test precondition: disjoint slots"); + + assertTrue(target.seedAbsent(source)); + assertEquals(3, target.size()); + assertEquals("component", target.getValue(KnownTags.COMPONENT_ID)); + assertEquals("span.kind", target.getValue(KnownTags.SPAN_KIND_ID)); + assertEquals("/a", target.getValue(KnownTags.HTTP_ROUTE_ID)); + target.checkIntegrity(); + } + + @Test + void seedAbsentRefusesWhenAnyTagMayBePresent() { + DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID); + DenseIdStore target = new DenseIdStore(null); + target.set(KnownTags.COMPONENT_ID, "explicit"); + + assertFalse(target.seedAbsent(source)); + assertEquals(1, target.size(), "nothing copied"); + assertEquals("explicit", target.getValue(KnownTags.COMPONENT_ID)); + } + + @Test + void seedAbsentRefusesASourceWithAParentOrTombstones() { + DenseIdStore parent = denseSource(KnownTags.COMPONENT_ID); + DenseIdStore child = new DenseIdStore(parent); + child.set(KnownTags.SPAN_KIND_ID, "server"); + assertFalse(new DenseIdStore(null).seedAbsent(child), "read-through source"); + + DenseIdStore tombstoned = new DenseIdStore(parent); + tombstoned.remove(KnownTags.COMPONENT_ID); + assertFalse(new DenseIdStore(null).seedAbsent(tombstoned), "tombstoned source"); + } + + @Test + void seedAbsentGrowsTheTargetToFit() { + DenseIdStore source = + denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID, KnownTags.HTTP_ROUTE_ID); + DenseIdStore target = new DenseIdStore(null); // shared empty arrays: no room yet + + assertTrue(target.seedAbsent(source)); + assertEquals(3, target.size()); + assertEquals("http.route", target.getValue(KnownTags.HTTP_ROUTE_ID)); + target.checkIntegrity(); + } + + @Test + void theHashStoreNeverSeedsInBulk() { + HashIdStore target = new HashIdStore(null); + assertFalse(target.seedAbsent(denseSource(KnownTags.COMPONENT_ID))); + assertFalse(target.seedAbsent(new HashIdStore(null))); + } + @Test void aTraceLevelTagDoesNotDefeatASpanLevelMissInTheSameSlot() { // A local root span holds trace-level and span-level tags in one store, and the two levels are From 8a0ab350112b10d6b1600971076110052f48fbb7 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sat, 17 Oct 2026 17:37:09 -0400 Subject: [PATCH 04/11] Keep the sizing hint on the dense store, not the span The span held its SizingHint in a new DDSpanContext field, so with the dense store off -- the default -- every span carried an always-null reference: DDSpanContext grew from 160 to 168 bytes (+8 B per span in the 8-thread SpanCreationBenchmark, hash store). The hint now lives on the DenseIdStore it presized, and recordSize() takes no argument: the span asks its store, the hash store does nothing. DDSpanContext is back to 160 bytes; DenseIdStore grows from 72 to 80, paid only with the dense store on. Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/DDSpanContext.java | 19 ++++--------------- .../java/datadog/trace/api/DenseIdStore.java | 15 +++++++++++++-- .../main/java/datadog/trace/api/IdStore.java | 8 ++++---- .../trace/api/SizingHintTableForkedTest.java | 10 +++++----- .../SizingHintTableHashOnlyForkedTest.java | 2 +- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index b774e5e50e4..06bc4715afd 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -186,14 +186,6 @@ public class DDSpanContext @GuardedBy("knownTags") private TagMap tagView; - /** - * The per-operation sizing hint ({@link SizingHint}) this span's dense store was presized from, - * and that it feeds back into when it finishes and when it's serialized ({@link - * #recordDenseSize}), so later spans of the operation are sized right. Null with the dense store - * off. - */ - @Nullable private final SizingHint sizingHint; - /** The service name is required, otherwise the span are dropped by the agent */ private volatile String serviceName; @@ -509,7 +501,6 @@ public DDSpanContext( // Reads through to the frozen tracer-level tags, if any. TagMap parentTags = TagMap.readThroughParent(readThroughParent); this.knownTags = IdStore.create(parentTags, sizingHint); - this.sizingHint = sizingHint; this.customTags = HashStore.inheritedFrom(parentTags); this.customTagsBorrowed = this.customTags != null; @@ -1822,14 +1813,12 @@ public void setMetaStruct(final String field, final T value) { } /** - * Feeds this span's known-tag count back into its operation's {@link SizingHint}, so later spans - * of the operation are presized. A best-effort racy max; with the dense store off the hint is - * null and this does nothing. + * Feeds this span's known-tag count back into its operation's {@link SizingHint}, held by its + * dense store, so later spans of the operation are presized. A best-effort racy max; with the + * dense store off this does nothing. */ void recordDenseSize() { - if (sizingHint != null) { - knownTags.recordSize(sizingHint); - } + knownTags.recordSize(); } void earlyProcessTags(AppendableSpanLinks links) { diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 5803f8c20ff..6a14b97a7ba 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -180,12 +180,20 @@ public EntryReader next() { /** The frozen parent's id store, or null. */ final DenseIdStore parent; + /** + * The per-operation hint this store was presized from, and that {@link #recordSize} tunes, or + * null. Held here rather than on the span so a span with the dense store off carries no field for + * it. + */ + @Nullable private final SizingHint hint; + /** The reused flyweight point reads answer with; built on first use. */ private DenseReader reader; DenseIdStore(IdStore parent) { super(parent); this.parent = (DenseIdStore) parent; + this.hint = null; } /** @@ -195,7 +203,9 @@ public EntryReader next() { * empty arrays. */ DenseIdStore(IdStore parent, @Nullable SizingHint hint) { - this(parent); + super(parent); + this.parent = (DenseIdStore) parent; + this.hint = hint; if (hint != null) { int cap = hint.size; // read the racy field once if (cap > 0) { @@ -252,9 +262,10 @@ private void growTo(int minCapacity) { } @Override - public void recordSize(@Nullable SizingHint hint) { + public void recordSize() { // Capped hints (the shared overflow) are fixed: unlike sharers mustn't grow them. Best-effort // racy max; a benign plain-int write. + SizingHint hint = this.hint; if (hint != null && !hint.capped) { int used = this.used; if (used > hint.size) { diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index ea115639d00..3072d6efc06 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -92,11 +92,11 @@ public static IdStore create(TagMap parent, @Nullable SizingHint hint) { } /** - * Feeds this store's size back into {@code hint} -- a best-effort, racy max, so later stores of - * the same operation are presized to it. A capped or null hint, or a store that isn't sized per - * span, ignores it. + * Feeds this store's size back into the hint it was presized from -- a best-effort, racy max, so + * later stores of the same operation are presized to it. A store created without a hint, or with + * a capped one, or that isn't sized per span, does nothing. */ - public void recordSize(@Nullable SizingHint hint) {} + public void recordSize() {} /** * Copies every tag of {@code source} in at once if none of them can already be present here -- diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java index 05d66cb9973..cd62bad0c2b 100644 --- a/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java @@ -149,7 +149,7 @@ void beyondCardinalityBudgetSharesACappedOverflowHint() { // A capped hint is never tuned: a heavy span sharing it does not grow it. IdStore heavy = storeFor(another); fill(heavy, SizingHintTable.OVERFLOW_SEED + 4); - heavy.recordSize(another); + heavy.recordSize(); assertEquals(SizingHintTable.OVERFLOW_SEED, another.size); } @@ -172,7 +172,7 @@ void sizingHintFeedsAndTunesTheDenseStore() { int observed = SizingHintTable.SEED_SIZE + 3; IdStore store = storeFor(hint); fill(store, observed); - store.recordSize(hint); + store.recordSize(); assertEquals(observed, hint.size, "hint self-tunes up to the observed high-water mark"); // The next store of the operation is presized to fit, so it never grows. @@ -184,11 +184,11 @@ void sizingHintFeedsAndTunesTheDenseStore() { // A smaller later observation does not shrink the hint. IdStore smaller = storeFor(hint); fill(smaller, 1); - smaller.recordSize(hint); + smaller.recordSize(); assertEquals(observed, hint.size, "recordSize never shrinks the hint"); - // A null hint is a no-op. - smaller.recordSize(null); + // A store without a hint records nothing. + storeFor(null).recordSize(); } @Test diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java index 00d4fa67f48..c32823b740c 100644 --- a/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java @@ -33,7 +33,7 @@ void theHashStoreIgnoresAHint() { IdStore store = IdStore.create(null, hint); store.set(KnownTags.COMPONENT_ID, "okhttp"); store.set(KnownTags.DD_BASE_SERVICE_ID, "svc"); - store.recordSize(hint); + store.recordSize(); assertSame(HashIdStore.class, store.getClass()); assertEquals(SizingHintTable.SEED_SIZE, hint.size, "recordSize is a no-op on the hash store"); From ef5dadc3e2ea2a95562aa63253b60c17436fdb58 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sat, 17 Oct 2026 23:23:07 -0400 Subject: [PATCH 05/11] Size prototype spans from hints cached on the prototype A span built from a SpanPrototype that uses the prototype's own operation name now takes its sizing hint from the prototype, which resolves it once per lane (entry, child) and caches it, so those spans skip the SizingHintTable lookup. A span given a different operation name still gets that name's hint from the table: hints stay keyed by operation name, so the cache never shares a hint the table wouldn't. SpanPrototypeConstructionDenseForkedTest now turns the dense store on with @WithConfig. Its static System.setProperty was cleared by the test extensions before Config read it, so the class had been running on the hash store (where the prototype tests pass too); theDenseStoreIsOn now guards that. Adds a test for the cached hint. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/core/CoreTracer.java | 13 +++--- ...nPrototypeConstructionDenseForkedTest.java | 38 +++++++++++++++- .../instrumentation/api/SpanPrototype.java | 45 +++++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 264414bb9ad..5e0dc00ee0c 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -2398,12 +2398,15 @@ protected static final DDSpanContext buildSpanContext( } // Per-operation dense-store sizing: an entry (local-root) span carries tags a child doesn't, - // so - // the lane is chosen by whether there's a local parent. Null with the dense store off -- the - // flag test folds, so the default path does no lookup. + // so the lane is chosen by whether there's a local parent. A span built from a prototype uses + // the prototype's hints, keyed by its operation name and cached on it, so an operation name + // given per call still shares one converging hint and skips the table lookup. Null with the + // dense store off -- the flag test folds, so the default path does no lookup. + final boolean entrySpan = !(resolvedParentSpanContext instanceof DDSpanContext); final SizingHint sizingHint = - SizingHint.forOperation( - operationName, !(resolvedParentSpanContext instanceof DDSpanContext)); + spanPrototype != SpanPrototype.NONE + ? spanPrototype.sizingHint(entrySpan, operationName) + : SizingHint.forOperation(operationName, entrySpan); // some attributes are inherited from the parent context = diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java index 9d03f39c78b..7d880dda6ea 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java @@ -5,9 +5,17 @@ import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_CLIENT; import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_SERVER; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import datadog.trace.api.IdStore; +import datadog.trace.api.SizingHint; +import datadog.trace.api.config.TracerConfig; import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.common.writer.ListWriter; +import datadog.trace.test.junit.utils.config.WithConfig; import org.junit.jupiter.api.Test; /** @@ -16,9 +24,14 @@ * read once. Adds the bulk path's fallback: a span that already holds one of the prototype's tags * is seeded tag by tag, so its own value wins and the rest still land. */ +@WithConfig(key = TracerConfig.TRACE_DENSE_TAGS_ENABLED, value = "true") class SpanPrototypeConstructionDenseForkedTest extends SpanPrototypeConstructionTest { - static { - System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + + @Test + void theDenseStoreIsOn() { + // guards the rest of this class: with the flag missed, every test would quietly run on the + // hash store, where the prototype tests pass too + assertEquals("DenseIdStore", IdStore.create(null).getClass().getSimpleName()); } @Test @@ -48,6 +61,27 @@ void aTagTheSpanAlreadyHasKeepsItsValueAndTheRestAreSeeded() { } } + @Test + void aPrototypeCachesTheSizingHintForItsOwnOperationNameOnly() { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("sizing.proto.op") + .build(); + SizingHint own = prototype.sizingHint(true, "sizing.proto.op"); + assertNotNull(own); + assertSame(SizingHint.forOperation("sizing.proto.op", true), own, "keyed like the table"); + assertSame(own, prototype.sizingHint(true, UTF8BytesString.create("sizing.proto.op"))); + + // another operation name gets that name's hint, never the prototype's + SizingHint other = prototype.sizingHint(true, "sizing.proto.other"); + assertSame(SizingHint.forOperation("sizing.proto.other", true), other); + assertNotSame(own, other); + + // the entry and child lanes stay apart + assertNotSame(own, prototype.sizingHint(false, "sizing.proto.op")); + } + @Test void aPrototypeSeedsEverySpanBuiltFromIt() { CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java index 148d0812d5d..830ca3d33be 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -1,7 +1,9 @@ package datadog.trace.bootstrap.instrumentation.api; import datadog.apicontract.Restricted; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; +import javax.annotation.Nullable; /** * A baked-once, frozen descriptor of a span's constant initial state — the per-decorator constants @@ -38,6 +40,16 @@ public static Builder builder() { */ private Object seedPlan; + /** + * The dense-store sizing hints for spans that use this prototype's own operation name, in the + * entry-span and child-span lanes, resolved on first use so later spans skip the table lookup. + * Racy like {@link #seedPlan}: a hint is stable for its operation name, so two threads resolving + * it at once agree. + */ + private SizingHint entryHint; + + private SizingHint childHint; + private SpanPrototype(final Builder builder) { this.instrumentationName = builder.instrumentationName; this.operationName = builder.operationName; @@ -84,6 +96,39 @@ public void seedPlan(Object plan) { this.seedPlan = plan; } + /** + * The sizing hint for a span built from this prototype with {@code operationName}, in the + * entry-span or child-span lane; null with the dense store off. Hints are keyed by operation + * name, like {@link SizingHint#forOperation}: when the span uses the prototype's own operation + * name, the hint is resolved once per lane and cached here, so the span skips the table lookup; a + * span given a different operation name gets that name's hint from the table, so spans of unlike + * operations never share one. + */ + @Restricted(allowedIn = {"datadog.trace.core"}) + @Nullable + public SizingHint sizingHint(boolean entrySpan, @Nullable CharSequence operationName) { + CharSequence own = this.operationName; + if (own == null || !sameName(own, operationName)) { + return SizingHint.forOperation(operationName, entrySpan); + } + SizingHint hint = entrySpan ? this.entryHint : this.childHint; + if (hint == null) { + hint = SizingHint.forOperation(own, entrySpan); + if (entrySpan) { + this.entryHint = hint; + } else { + this.childHint = hint; + } + } + return hint; + } + + /** Whether {@code name} is {@code own}: the same instance, or the same characters. */ + private static boolean sameName(CharSequence own, @Nullable CharSequence name) { + // toString() is O(1) for the String and UTF8BytesString operation names we see + return own == name || (name != null && own.toString().equals(name.toString())); + } + public static final class Builder { private String instrumentationName; private CharSequence operationName; From 8423cac091694def99c9358f355435fe18848ab5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sun, 18 Oct 2026 08:42:20 -0400 Subject: [PATCH 06/11] Add singleSpanBuilder(SpanPrototype) The prototype entry points now mirror the plain ones: startSpan (builder-free), buildSpan (a builder for any number of spans) and singleSpanBuilder, which hands out the thread's reusable builder carrying the prototype, so it doesn't allocate. The builder drops the prototype on reset, so it can't leak into the thread's next span. AgentTracer's default seeds identity only, like buildSpan's. Adds jdbcClientSpanViaPrototypeSingleSpanBuilder to SpanCreationBenchmark, next to the buildSpan and startSpan variants. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/SpanCreationBenchmark.java | 17 ++++++++++++ .../java/datadog/trace/core/CoreTracer.java | 22 ++++++++++++++-- .../core/SpanPrototypeConstructionTest.java | 26 +++++++++++++++++++ .../instrumentation/api/AgentTracer.java | 15 +++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java index c2bcd4a1cfb..3cbaaefd969 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java @@ -272,6 +272,23 @@ public void jdbcClientSpanViaPrototype() { span.finish(); } + /** + * JDBC-client-shaped span via {@code singleSpanBuilder(SpanPrototype, ...)}: the thread's + * reusable builder carries the prototype. Compare against {@link #jdbcClientSpanViaPrototype} (a + * fresh multi-span builder) and {@link #jdbcClientSpanViaPrototypeStartSpan} (no builder). + */ + @Benchmark + public void jdbcClientSpanViaPrototypeSingleSpanBuilder() { + AgentSpan span = tracer.singleSpanBuilder(jdbcProto, null).start(); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + /** * Web-server-shaped span via {@code startSpan(SpanPrototype, ...)} — the builder-free * construction entry (no MultiSpanBuilder allocation), the auto-instrumentation path. Compare diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 5e0dc00ee0c..ea0b0157211 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -1163,8 +1163,26 @@ public CoreSpanBuilder buildSpan( if (operationName == null) { operationName = prototype.operationName(); } - CoreSpanBuilder builder = - createMultiSpanBuilder(prototype.instrumentationName(), operationName); + return seedFrom( + createMultiSpanBuilder(prototype.instrumentationName(), operationName), prototype); + } + + /** + * Like {@link #buildSpan(SpanPrototype, CharSequence)}, for one span only: the thread's reusable + * builder carries the prototype (see {@link #singleSpanBuilder(String, CharSequence)}), which it + * drops when reset for the next span. + */ + @Override + public CoreSpanBuilder singleSpanBuilder( + @Nonnull final SpanPrototype prototype, CharSequence operationName) { + if (operationName == null) { + operationName = prototype.operationName(); + } + return seedFrom(singleSpanBuilder(prototype.instrumentationName(), operationName), prototype); + } + + /** Seeds {@code builder}'s prototype and span type; its tags are seeded when the span starts. */ + private static CoreSpanBuilder seedFrom(CoreSpanBuilder builder, SpanPrototype prototype) { builder.spanPrototype = prototype; if (prototype.spanType() != null) { builder.spanType = prototype.spanType(); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java index 7a3722d77cf..e93e7ef261c 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -88,6 +88,32 @@ void startSpanSeedsPrototype() { } } + @Test + void singleSpanBuilderSeedsPrototype() { + DDSpan span = (DDSpan) tracer.singleSpanBuilder(prototype, null).start(); + try { + assertEquals("proto.op", span.getOperationName().toString()); + assertEquals("web", span.getSpanType()); + assertEquals("test-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void aReusedSingleSpanBuilderDropsThePrototype() { + // the thread's reusable builder carried a prototype for the first span; reset must clear it + tracer.singleSpanBuilder(prototype, null).start().finish(); + DDSpan plain = (DDSpan) tracer.singleSpanBuilder("other-instr", "plain.op").start(); + try { + assertNull(plain.getTags().get(COMPONENT)); + assertNull(plain.getSpanType()); + } finally { + plain.finish(); + } + } + @Test void explicitBuilderTagOverridesPrototypeConstant() { // prototype seeds `component` just before the builder's own tags, so the explicit withTag wins diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 540a4a2481a..adc1be8a63c 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -363,6 +363,21 @@ default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence opera */ SpanBuilder singleSpanBuilder(String instrumentationName, CharSequence spanName); + /** + * Returns a SpanBuilder seeded from a {@link SpanPrototype}, like {@link + * #buildSpan(SpanPrototype, CharSequence)}, that produces one and only one span, so it can be + * cheaper (see {@link #singleSpanBuilder(String, CharSequence)}). {@code operationName} + * overrides the prototype's when non-null. + * + *

This default seeds identity only, like {@link #buildSpan(SpanPrototype, CharSequence)}'s. + */ + default SpanBuilder singleSpanBuilder( + @Nonnull SpanPrototype prototype, CharSequence operationName) { + return singleSpanBuilder( + prototype.instrumentationName(), + operationName != null ? operationName : prototype.operationName()); + } + void close(); /** From 1b538a3f95e2d6d8df349e15953470dc92d2d3a4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sun, 18 Oct 2026 08:42:24 -0400 Subject: [PATCH 07/11] Overwrite a prototype's known tags in bulk after start applyOverwriting -- what BaseDecorator.afterStart does to every span today -- stamped the prototype's tags one at a time through the interceptor. Overwriting a tag that isn't there is just appending it, so when the masks prove none of the prototype's known tags is on the span (the usual case right after startSpan) they are now copied in at once with IdStore.seedAbsent, and span.kind's ordinal is set directly, like the construction-time seed. A conflict falls back to overwriting tag by tag; custom tags always go through the interceptor. Adds tests for stamping after start and for overwriting a tag the span already has (both stores), and webServerSpanRetrofit / jdbcClientSpanRetrofit to SpanCreationBenchmark: startSpan by name, then applyOverwriting, as decorators do. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/SpanCreationBenchmark.java | 34 ++++++++++++++++ .../datadog/trace/core/DDSpanContext.java | 39 ++++++++++++++++++- .../core/SpanPrototypeConstructionTest.java | 28 +++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java index 3cbaaefd969..eba7c661aff 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java @@ -255,6 +255,40 @@ public void webServerSpanViaPrototype() { span.finish(); } + /** + * Web-server-shaped span the way decorators build it today: started by name, then the decorator's + * prototype stamped over it after start ({@code BaseDecorator.afterStart} -> {@code + * applyOverwriting}). The baseline the construction-time prototype paths have to beat. + */ + @Benchmark + public void webServerSpanRetrofit() { + AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME); + span.applyOverwriting(webProto); + span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE); + span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE); + span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE); + span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE); + span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE); + span.finish(); + } + + /** + * JDBC-client-shaped span the way decorators build it today: started by name, then the prototype + * stamped over it after start; see {@link #webServerSpanRetrofit}. + */ + @Benchmark + public void jdbcClientSpanRetrofit() { + AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, JDBC_OPERATION_NAME); + span.applyOverwriting(jdbcProto); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + /** * JDBC/DB-client-shaped span via {@link SpanPrototype}: component, span.kind, and db.type ride * the prototype; the dynamic db.* / peer.* tags are set per-span. Compare against {@link diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 06bc4715afd..d9f9927d974 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -734,7 +734,9 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { setSpanType(spanType); } final TagMap tags = prototype.tags(); - setAllTags(tags, true); + if (!overwriteInBulk(prototype)) { + setAllTags(tags, true); + } tagInterceptor.resolveSplitByTagsPrecedence(this, tags); final CharSequence integrationName = prototype.integrationName(); if (integrationName != null) { @@ -817,6 +819,41 @@ private void seedPrototypeTags(final SpanPrototype prototype) { } } + /** + * The overwriting counterpart of {@link #seedPrototypeTags}'s bulk path: when the masks prove + * none of the prototype's known tags is on the span, overwriting them is just appending them, so + * they are copied in at once ({@link IdStore#seedAbsent}) and span.kind's ordinal set directly. + * Custom tags go through the interceptor one at a time. Returns false, having changed nothing, + * when the prototype needs the interceptor or a tag may already be present; the caller then + * overwrites tag by tag. + */ + private boolean overwriteInBulk(final SpanPrototype prototype) { + final SeedPlan plan = seedPlanFor(prototype); + if (!plan.bulk) { + return false; + } + final TagMap tags = prototype.tags(); + synchronized (knownTags) { + if (!knownTags.seedAbsent(tags.knownStore())) { + return false; + } + if (plan.kindOrdinal != SPAN_KIND_UNSET) { + spanKindOrdinal = plan.kindOrdinal; + } + if (tags.customStore() != null) { + // rare: a prototype with custom tags overwrites those one at a time + tags.forEach( + this, + (ctx, tagEntry) -> { + if (tagEntry.tagId() == 0 && !ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { + ctx.routePut(tagEntry); + } + }); + } + return true; + } + } + /** Seeds one tag if the span doesn't have it yet. Call under the {@code knownTags} lock. */ private void seedIfAbsent(final TagMap.EntryReader tagEntry) { if (isPresent(tagEntry)) { diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java index e93e7ef261c..7929eaace1c 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -114,6 +114,34 @@ void aReusedSingleSpanBuilderDropsThePrototype() { } } + @Test + void applyOverwritingAfterStartStampsThePrototype() { + // the decorator path: start by name, then afterStart stamps the prototype over the span + DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + span.applyOverwriting(prototype); + assertEquals("web", span.getSpanType()); + assertEquals("test-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getTags().get(SPAN_KIND)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void applyOverwritingReplacesATagTheSpanAlreadyHas() { + DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + span.setTag(COMPONENT, "earlier"); + span.applyOverwriting(prototype); + assertEquals("test-component", span.getTags().get(COMPONENT)); // the prototype wins + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + @Test void explicitBuilderTagOverridesPrototypeConstant() { // prototype seeds `component` just before the builder's own tags, so the explicit withTag wins From 94faf016ebd5d05c04de055fa3937d8467150eb2 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sun, 18 Oct 2026 17:11:49 -0400 Subject: [PATCH 08/11] Address techdebt and perf review findings - Flag off, every decorated span took the tag lock twice: applyOverwriting tried the bulk path (the plan said bulk), whose seedAbsent always fails on the hash store, then locked again for its fallback. The seed plan is now bulk only when the store can seed in bulk (IdStore.supportsBulkSeed, true on DenseIdStore), so the default path skips the attempt and its lock. Guarded by a test on both stores. - Published spans record their size when serialized, with every tag present; CoreTracer.write records only the traces it drops, so kept traces no longer walk their spans for it on the application thread. - seedPrototypeTags and overwriteInBulk share seedKnownInBulk; the intercept-or-put step is putIntercepted, also used by setAllTags. - IdStoreFactory.create(parent) and DenseIdStore(parent) delegate to their hinted forms; IdStore.create(TagMap) likewise; grow() delegates to growTo. - The hint lookup is one call: NONE has no operation name, so SpanPrototype.sizingHint already falls through to the table. - Docs: the orphaned needsInterceptCustomTag Javadoc is reattached, apply's stale "will land here" paragraph points to seedPrototypeTags, the hint-less DDSpanContext constructor is marked for tests and benchmarks, and a comment in SizingHintTable is rewrapped. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/core/CoreTracer.java | 25 ++++--- .../datadog/trace/core/DDSpanContext.java | 71 +++++++++++-------- .../core/taginterceptor/TagInterceptor.java | 2 +- .../core/SpanPrototypeConstructionTest.java | 12 ++++ .../java/datadog/trace/api/DenseIdStore.java | 15 ++-- .../main/java/datadog/trace/api/IdStore.java | 10 ++- .../datadog/trace/api/IdStoreFactory.java | 16 ++--- .../datadog/trace/api/SizingHintTable.java | 7 +- 8 files changed, 92 insertions(+), 66 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index ea0b0157211..9c98506a568 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -1463,8 +1463,7 @@ void write(final SpanList trace) { writtenTrace.get(i).setTag(APM_ENABLED, 0); } } - recordDenseSizes(writtenTrace); - writer.write(writtenTrace); + writer.write(writtenTrace); // serialization records these spans' sizes } else { recordDenseSizes(writtenTrace); // with span streaming this won't work - it needs to be changed @@ -1479,9 +1478,11 @@ void write(final SpanList trace) { } /** - * Tunes each span's per-operation sizing hint. Done here rather than at span finish: the early - * post-processors and the root's sampling decision add tags after a span finishes, and a hint - * that missed them would leave every later span of the operation growing its store. + * Tunes each span's per-operation sizing hint, for a trace that is dropped rather than written: + * published spans are recorded when they're serialized, with every tag present. Done here rather + * than at span finish: the early post-processors and the root's sampling decision add tags after + * a span finishes, and a hint that missed them would leave every later span of the operation + * growing its store. */ private static void recordDenseSizes(List trace) { for (int i = 0; i < trace.size(); i++) { @@ -2416,15 +2417,13 @@ protected static final DDSpanContext buildSpanContext( } // Per-operation dense-store sizing: an entry (local-root) span carries tags a child doesn't, - // so the lane is chosen by whether there's a local parent. A span built from a prototype uses - // the prototype's hints, keyed by its operation name and cached on it, so an operation name - // given per call still shares one converging hint and skips the table lookup. Null with the - // dense store off -- the flag test folds, so the default path does no lookup. - final boolean entrySpan = !(resolvedParentSpanContext instanceof DDSpanContext); + // so the lane is chosen by whether there's a local parent. A span using its prototype's own + // operation name gets the hint cached on the prototype; any other span (including NONE's, + // which has no operation name) gets its operation's hint from the table. Null with the dense + // store off -- the flag test folds, so the default path does no lookup. final SizingHint sizingHint = - spanPrototype != SpanPrototype.NONE - ? spanPrototype.sizingHint(entrySpan, operationName) - : SizingHint.forOperation(operationName, entrySpan); + spanPrototype.sizingHint( + !(resolvedParentSpanContext instanceof DDSpanContext), operationName); // some attributes are inherited from the parent context = diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index d9f9927d974..e3bf3d4c06c 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -391,6 +391,10 @@ public DDSpanContext( null); } + /** + * Without a sizing hint: for tests and benchmarks that build contexts directly. The tracer uses + * the constructor that takes one. + */ public DDSpanContext( final DDTraceId traceId, final long spanId, @@ -700,11 +704,8 @@ public void setSpanType(final CharSequence spanType) { * *

This is the construction-time seam ({@code CoreSpanBuilder}) -- see {@link * #applyOverwriting} for the decorator {@code afterStart} seam (via {@link - * DDSpan#applyOverwriting}), which needs different precedence. The context owns the tag map, so - * the eventual cheaper bulk-share path (skipping interception for non-intercepted tags) and the - * identity short-circuit will land here, with the dense store; a tag's intercept status is - * already on its id ({@link KnownTagCodec#isIntercepted}). Until then each constant tag is - * prechecked by the interceptor, identical to the per-tag calls this replaces. + * DDSpan#applyOverwriting}), which needs different precedence. The tags are seeded by {@link + * #seedPrototypeTags}, in bulk where the store and the prototype allow it. */ public void apply(@Nonnull final SpanPrototype prototype) { if (this.spanType == null) { @@ -751,7 +752,11 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { private static final class SeedPlan { final TagInterceptor interceptor; - /** No known tag needs the interceptor beyond span.kind; see TagInterceptor#canSeedInBulk. */ + /** + * The store can seed in bulk (the dense store) and no known tag needs the interceptor beyond + * span.kind (TagInterceptor#canSeedInBulk). False with the dense store off, so the default path + * never tries. + */ final boolean bulk; /** The ordinal of the prototype's span.kind, or SPAN_KIND_UNSET. */ @@ -774,7 +779,7 @@ private SeedPlan seedPlanFor(final SpanPrototype prototype) { SeedPlan plan = new SeedPlan( tagInterceptor, - tagInterceptor.canSeedInBulk(tags), + tags.knownStore().supportsBulkSeed() && tagInterceptor.canSeedInBulk(tags), kind == null ? SPAN_KIND_UNSET : spanKindOrdinalOf(kind.stringValue())); prototype.seedPlan(plan); return plan; @@ -799,10 +804,7 @@ private void seedPrototypeTags(final SpanPrototype prototype) { final TagMap tags = prototype.tags(); final SeedPlan plan = seedPlanFor(prototype); synchronized (knownTags) { - if (plan.bulk && knownTags.seedAbsent(tags.knownStore())) { - if (plan.kindOrdinal != SPAN_KIND_UNSET) { - spanKindOrdinal = plan.kindOrdinal; - } + if (seedKnownInBulk(plan, tags)) { if (tags.customStore() != null) { // rare: a prototype with custom tags seeds those one at a time tags.forEach( @@ -819,6 +821,23 @@ private void seedPrototypeTags(final SpanPrototype prototype) { } } + /** + * The bulk step shared by {@link #seedPrototypeTags} and {@link #overwriteInBulk}: when the plan + * allows it and the masks prove none of the prototype's known tags can already be on the span, + * copies them in at once ({@link IdStore#seedAbsent}) and sets span.kind's ordinal directly, and + * returns true. Otherwise changes nothing and returns false. Call under the {@code knownTags} + * lock; custom tags are the caller's. + */ + private boolean seedKnownInBulk(final SeedPlan plan, final TagMap tags) { + if (!plan.bulk || !knownTags.seedAbsent(tags.knownStore())) { + return false; + } + if (plan.kindOrdinal != SPAN_KIND_UNSET) { + spanKindOrdinal = plan.kindOrdinal; + } + return true; + } + /** * The overwriting counterpart of {@link #seedPrototypeTags}'s bulk path: when the masks prove * none of the prototype's known tags is on the span, overwriting them is just appending them, so @@ -830,23 +849,20 @@ private void seedPrototypeTags(final SpanPrototype prototype) { private boolean overwriteInBulk(final SpanPrototype prototype) { final SeedPlan plan = seedPlanFor(prototype); if (!plan.bulk) { - return false; + return false; // checked before taking the lock: the caller locks again on its fallback } final TagMap tags = prototype.tags(); synchronized (knownTags) { - if (!knownTags.seedAbsent(tags.knownStore())) { + if (!seedKnownInBulk(plan, tags)) { return false; } - if (plan.kindOrdinal != SPAN_KIND_UNSET) { - spanKindOrdinal = plan.kindOrdinal; - } if (tags.customStore() != null) { // rare: a prototype with custom tags overwrites those one at a time tags.forEach( this, (ctx, tagEntry) -> { - if (tagEntry.tagId() == 0 && !ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.routePut(tagEntry); + if (tagEntry.tagId() == 0) { + ctx.putIntercepted(tagEntry); } }); } @@ -856,9 +872,13 @@ private boolean overwriteInBulk(final SpanPrototype prototype) { /** Seeds one tag if the span doesn't have it yet. Call under the {@code knownTags} lock. */ private void seedIfAbsent(final TagMap.EntryReader tagEntry) { - if (isPresent(tagEntry)) { - return; + if (!isPresent(tagEntry)) { + putIntercepted(tagEntry); } + } + + /** Puts one tag, unless the interceptor routes it. Call under the {@code knownTags} lock. */ + private void putIntercepted(final TagMap.EntryReader tagEntry) { if (!tagInterceptor.interceptTag(this, tagEntry)) { routePut(tagEntry); } @@ -1681,13 +1701,7 @@ void setAllTags(final TagMap map, boolean needsIntercept) { // forEach out-performs the iterator of TagMap // Taking advantage of ability to pass through other context arguments // to avoid using a capturing lambda - map.forEach( - this, - (ctx, tagEntry) -> { - if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.routePut(tagEntry); - } - }); + map.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); } else { routePutAll(map); } @@ -1916,7 +1930,8 @@ void processTagsAndBaggage( } // Tune the operation's sizing hint now that every tag, serialization-time ones included, is - // present. + // present. This is where published spans are recorded; CoreTracer.write records only the + // spans it drops. recordDenseSize(); // Baggage diff --git a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java index d004d46108d..dbff87375d3 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java @@ -151,7 +151,6 @@ public boolean needsIntercept(TagMap.EntryReader entry) { return tagId != 0 ? needsIntercept(tagId) : isSplitServiceTag(entry.tag()); } - /** Whether a custom tag (one with no id) may be routed; only split-by-tags routes them. */ /** * Whether a span prototype's tags can be seeded in bulk: none of its known tags needs the * interceptor except {@code span.kind}, whose only effect is the span's cached kind ordinal, @@ -171,6 +170,7 @@ public boolean canSeedInBulk(TagMap tags) { return true; } + /** Whether a custom tag (one with no id) may be routed; only split-by-tags routes them. */ public boolean needsInterceptCustomTag(String customTag) { return isSplitServiceTag(customTag); } diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java index 7929eaace1c..21906df6c6c 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -8,6 +8,7 @@ import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.common.writer.ListWriter; +import java.lang.reflect.Field; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -142,6 +143,17 @@ void applyOverwritingReplacesATagTheSpanAlreadyHas() { } } + @Test + void theSeedPlanTriesTheBulkPathOnlyWhenTheStoreSupportsIt() throws Exception { + // With the hash store (the default) the plan must never be bulk: applyOverwriting would take + // the tag lock for a bulk attempt that always fails, then again for its fallback. + tracer.buildSpan(prototype, null).start().finish(); + Object plan = prototype.seedPlan(); + Field bulk = plan.getClass().getDeclaredField("bulk"); + bulk.setAccessible(true); + assertEquals(prototype.tags().knownStore().supportsBulkSeed(), bulk.getBoolean(plan)); + } + @Test void explicitBuilderTagOverridesPrototypeConstant() { // prototype seeds `component` just before the builder's own tags, so the explicit withTag wins diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 6a14b97a7ba..64becaa40c5 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -191,9 +191,7 @@ public EntryReader next() { private DenseReader reader; DenseIdStore(IdStore parent) { - super(parent); - this.parent = (DenseIdStore) parent; - this.hint = null; + this(parent, null); } /** @@ -222,6 +220,11 @@ public EntryReader next() { * and its occupancy ORed in. The occupancy words over-approximate presence, so a shared bit only * means "maybe present" and falls back to the caller's tag-by-tag seed. */ + @Override + public boolean supportsBulkSeed() { + return true; + } + @Override public boolean seedAbsent(IdStore source) { if (!(source instanceof DenseIdStore)) { @@ -478,11 +481,9 @@ private void grow() { if (length == 0) { this.serials = new char[INIT_CAP]; this.values = new Object[INIT_CAP]; - return; + } else { + this.growTo(length + 1); // doubles, bounded by the serial limit } - int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1); - this.serials = Arrays.copyOf(this.serials, newCap); - this.values = Arrays.copyOf(this.values, newCap); } /** The flyweight over {@code value}, or null when there is none. */ diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index 3072d6efc06..f9e2783d2cd 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -80,7 +80,7 @@ interface Cursor { /** A fresh id store reading through to {@code parent}'s known tags (parent may be null). */ public static IdStore create(TagMap parent) { - return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore); + return create(parent, null); } /** @@ -108,6 +108,14 @@ public boolean seedAbsent(IdStore source) { return false; } + /** + * Whether {@link #seedAbsent} can ever succeed for this kind of store, so a caller can skip it, + * and the lock it needs, when it can't. + */ + public boolean supportsBulkSeed() { + return false; + } + /** {@code tagId}'s slot bit. */ static long slotBit(long tagId) { return 1L << KnownTagCodec.slotOf(tagId); diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java index 8a471b36397..98d3d823f64 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -26,11 +26,13 @@ static final class Default { } /** A fresh store reading through to {@code parent} (may be null). */ - abstract IdStore create(IdStore parent); + final IdStore create(IdStore parent) { + return create(parent, null); + } /** * A fresh store reading through to {@code parent} (may be null), presized from {@code hint} where - * the store keeps sized arrays; a null hint is the same as {@link #create(IdStore)}. + * the store keeps sized arrays; a null hint presizes nothing. */ abstract IdStore create(IdStore parent, @Nullable SizingHint hint); @@ -43,11 +45,6 @@ final class HashIdStoreFactory extends IdStoreFactory { private HashIdStoreFactory() {} - @Override - IdStore create(IdStore parent) { - return new HashIdStore(parent); - } - @Override IdStore create(IdStore parent, @Nullable SizingHint hint) { return new HashIdStore(parent); // buckets aren't sized per span; a hint is only for dense @@ -64,11 +61,6 @@ final class DenseIdStoreFactory extends IdStoreFactory { private DenseIdStoreFactory() {} - @Override - IdStore create(IdStore parent) { - return new DenseIdStore(parent); - } - @Override IdStore create(IdStore parent, @Nullable SizingHint hint) { return new DenseIdStore(parent, hint); diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java index cdbd171b252..555ae95b5a4 100644 --- a/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java +++ b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java @@ -44,10 +44,9 @@ private SizingHintTable() {} static final int SEED_SIZE = 4; // Fixed size for the shared over-budget hint: the untuned default, DenseIdStore's initial - // capacity - // (a compile-time constant, so reading it loads no class). Capped (never grown by recordSize) - // because it's a heterogeneous catch-all -- growing it would over-provision lean sharers to the - // max of an unlike cohort. + // capacity (a compile-time constant, so reading it loads no class). Capped (never grown by + // recordSize) because it's a heterogeneous catch-all -- growing it would over-provision lean + // sharers to the max of an unlike cohort. static final int OVERFLOW_SEED = DenseIdStore.INIT_CAP; // Max distinct operation names per lane that get their own hint before collapsing to the capped From d25eddeae7602406ed98388b6089e85df70390d9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Sun, 18 Oct 2026 17:17:33 -0400 Subject: [PATCH 09/11] Make the stores' forEach public and seed custom prototype tags from them HashStore's and IdStore's forEach overloads are public, so the prototype paths in DDSpanContext walk a prototype's custom store directly instead of iterating its whole map and skipping known tags. Adds a test for a prototype carrying a custom tag, seeded at construction and overwritten after start (both stores). Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/DDSpanContext.java | 22 +++++---------- .../core/SpanPrototypeConstructionTest.java | 27 +++++++++++++++++++ .../java/datadog/trace/api/DenseIdStore.java | 7 ++--- .../java/datadog/trace/api/HashIdStore.java | 7 ++--- .../java/datadog/trace/api/HashStore.java | 7 ++--- .../main/java/datadog/trace/api/IdStore.java | 6 ++--- 6 files changed, 48 insertions(+), 28 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index e3bf3d4c06c..b38815c77b3 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -805,15 +805,10 @@ private void seedPrototypeTags(final SpanPrototype prototype) { final SeedPlan plan = seedPlanFor(prototype); synchronized (knownTags) { if (seedKnownInBulk(plan, tags)) { - if (tags.customStore() != null) { + HashStore custom = tags.customStore(); + if (custom != null) { // rare: a prototype with custom tags seeds those one at a time - tags.forEach( - this, - (ctx, tagEntry) -> { - if (tagEntry.tagId() == 0) { - ctx.seedIfAbsent(tagEntry); - } - }); + custom.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry)); } return; } @@ -856,15 +851,10 @@ private boolean overwriteInBulk(final SpanPrototype prototype) { if (!seedKnownInBulk(plan, tags)) { return false; } - if (tags.customStore() != null) { + HashStore custom = tags.customStore(); + if (custom != null) { // rare: a prototype with custom tags overwrites those one at a time - tags.forEach( - this, - (ctx, tagEntry) -> { - if (tagEntry.tagId() == 0) { - ctx.putIntercepted(tagEntry); - } - }); + custom.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); } return true; } diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java index 21906df6c6c..a2303e3b44a 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -154,6 +154,33 @@ void theSeedPlanTriesTheBulkPathOnlyWhenTheStoreSupportsIt() throws Exception { assertEquals(prototype.tags().knownStore().supportsBulkSeed(), bulk.getBoolean(plan)); } + @Test + void aPrototypesCustomTagIsSeededAndOverwritten() { + SpanPrototype withCustom = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_SERVER) + .initTag("my.custom.constant", "custom-value") + .build(); + DDSpan built = (DDSpan) tracer.buildSpan(withCustom, null).start(); + try { + assertEquals("custom-value", built.getTags().get("my.custom.constant")); + assertEquals(SPAN_KIND_SERVER, built.getSpanKindString()); + } finally { + built.finish(); + } + + DDSpan decorated = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + decorated.setTag("my.custom.constant", "earlier"); + decorated.applyOverwriting(withCustom); + assertEquals("custom-value", decorated.getTags().get("my.custom.constant")); + } finally { + decorated.finish(); + } + } + @Test void explicitBuilderTagOverridesPrototypeConstant() { // prototype seeds `component` just before the builder's own tags, so the explicit withTag wins diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 64becaa40c5..be8219990d2 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -586,7 +586,7 @@ private boolean readIfVisible(DenseIdStore level, int i, DenseReader reader) { } @Override - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { DenseReader reader = new DenseReader(); for (DenseIdStore level = this; level != null; level = level.parent) { for (int i = 0; i < level.used; ++i) { @@ -598,7 +598,7 @@ void forEach(Consumer consumer) { } @Override - void forEach(T thisObj, BiConsumer consumer) { + public void forEach(T thisObj, BiConsumer consumer) { DenseReader reader = new DenseReader(); for (DenseIdStore level = this; level != null; level = level.parent) { for (int i = 0; i < level.used; ++i) { @@ -610,7 +610,8 @@ void forEach(T thisObj, BiConsumer consumer) { } @Override - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { DenseReader reader = new DenseReader(); for (DenseIdStore level = this; level != null; level = level.parent) { for (int i = 0; i < level.used; ++i) { diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java index 7d623249618..5e54fd138bc 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -291,7 +291,7 @@ boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) { } @Override - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { HashSupport.forEach(this.buckets, consumer); if (this.parent != null) { this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); @@ -299,7 +299,7 @@ void forEach(Consumer consumer) { } @Override - void forEach(T thisObj, BiConsumer consumer) { + public void forEach(T thisObj, BiConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); @@ -307,7 +307,8 @@ void forEach(T thisObj, BiConsumer consumer) { } @Override - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, otherObj, consumer); diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java index 8b526d385a0..e142b6e5671 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -263,21 +263,22 @@ boolean parentEntryVisible(Entry parentEntry, HashStore fromAncestor) { } /** Visits the local entries, then the visible inherited ones. */ - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { HashSupport.forEach(this.buckets, consumer); if (this.parent != null) { this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); } } - void forEach(T thisObj, BiConsumer consumer) { + public void forEach(T thisObj, BiConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); } } - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, otherObj, consumer); diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index f9e2783d2cd..b17649c48c7 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -244,11 +244,11 @@ static IllegalArgumentException notKnown(long tagId) { abstract boolean hasParent(); /** Visits the local entries, then the visible inherited ones. */ - abstract void forEach(Consumer consumer); + public abstract void forEach(Consumer consumer); - abstract void forEach(T thisObj, BiConsumer consumer); + public abstract void forEach(T thisObj, BiConsumer consumer); - abstract void forEach( + public abstract void forEach( T thisObj, U otherObj, TriConsumer consumer); /** From 0c628d32180758f82f8aa985da9b83674e99c7af Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 20 Oct 2026 15:00:06 -0400 Subject: [PATCH 10/11] Turn the dense tag store on by default With per-operation sizing hints presizing each span's store, the dense store is the default here, so CI and benchmarks exercise it. Set trace.experimental.dense.tags.enabled=false to use the hash store. Co-Authored-By: Claude Opus 5.5 --- internal-api/src/main/java/datadog/trace/api/Config.java | 2 +- metadata/supported-configurations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index bb0e9803eac..c86955a2749 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -3457,7 +3457,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.spanBuilderReuseEnabled = configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true); - this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, false); + this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, true); this.tagNameUtf8CacheSize = Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0); this.tagValueUtf8CacheSize = diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index a6733fc287e..d11fceadb36 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -5856,7 +5856,7 @@ { "version": "A", "type": "boolean", - "default": "false", + "default": "true", "aliases": [] } ], From bc1f444cdea6d54eea6695d612d2c0962d007488 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 21 Oct 2026 08:05:54 -0400 Subject: [PATCH 11/11] Feed the sizing hints in TraceShapeBenchmark, as serialization would The sizing hints learn when a published trace is serialized, or when a dropped trace is written. DropWriter does neither, so the hints stayed at their initial size and every span was presized small and then grew: the benchmark showed this branch allocating more than its base. SizeRecordingWriter records each span's size before dropping the trace. Bytes per trace with -prof gc, single-threaded: arm hash dense legacy 8920 5720 (-36%) normal 8224 5728 (-30%) newApi 8224 5728 (-30%) Co-Authored-By: Claude Opus 5.5 --- .../trace/core/TraceShapeBenchmark.java | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java index 5556fa26139..f346d02bf2c 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java @@ -8,6 +8,8 @@ import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.common.writer.Writer; +import java.util.List; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; @@ -44,7 +46,8 @@ * outside {@code Integer}'s box cache, so primitive boxing shows up. * *

This copy: 1.68 snapshot (decorators apply a prototype in {@code afterStart}, #12037; peer - * tags by id, #12715). + * tags by id, #12715), with the dense store's per-operation sizing hints, which the writer feeds as + * serialization would. */ @State(Scope.Benchmark) @Warmup(iterations = 5) @@ -103,7 +106,7 @@ public class TraceShapeBenchmark { @Setup public void setup(Blackhole blackhole) { - this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + this.tracer = CoreTracer.builder().writer(new SizeRecordingWriter(blackhole)).build(); // the decorators' prototype chains: Base (type, component) -> Server / Client (kind, language) this.serverProto = SpanPrototype.builder() @@ -267,6 +270,41 @@ public void newApi() { server.finish(); } + /** + * {@link DropWriter}, except that it first records each span's dense-store size, as serialization + * does for a published trace. Without it the per-operation sizing hints never learn here, and + * every span would be presized to the initial hint and then grow. + */ + static final class SizeRecordingWriter implements Writer { + private final Blackhole blackhole; + + SizeRecordingWriter(Blackhole blackhole) { + this.blackhole = blackhole; + } + + @Override + public void write(List trace) { + for (DDSpan span : trace) { + span.spanContext().recordDenseSize(); + } + blackhole.consume(trace); + } + + @Override + public void start() {} + + @Override + public boolean flush() { + return true; + } + + @Override + public void close() {} + + @Override + public void incrementDropCounts(int spanCount) {} + } + /** The advice's exit: activate around the call, then close and finish. */ void finishScoped(AgentSpan span) { ContextScope scope = tracer.activateSpan(span);