Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fef452c
Add TagMap read-through read mechanism (single parent)
dougqh Jun 29, 2026
197e952
Make TagMap.copy() preserve read-through structure
dougqh Jun 29, 2026
369aba8
Keep off-path forEach byte-identical by extracting the parent walk
dougqh Jun 29, 2026
6d408a5
Keep config version out of the trace-level tag bundle
dougqh Jun 29, 2026
e2113e9
Wire mergedTracerTags as a read-through parent at span build
dougqh Jun 29, 2026
02ef063
Add TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc)
dougqh Jun 29, 2026
34a1a34
Add StringIndex (keyOf substrate) — ported from dougqh/tagset
dougqh Jun 30, 2026
ae058dd
Add keyOf substrate: KnownTags encoding + KnownTagIds registry
dougqh Jun 30, 2026
4577fe6
Add dense known-tag store to OptimizedTagMap (insertion-ordered)
dougqh Jun 30, 2026
3de3321
Add dense-store fuzz test (custom resolver, 3 key regimes)
dougqh Jun 30, 2026
9a2756e
Add off-by-default toggle to flip the dense store live
dougqh Jun 30, 2026
3aa649b
Route known tags to dense before Entry construction + alloc benchmark
dougqh Jun 30, 2026
dd6e022
Lazily allocate buckets via a shared empty sentinel (COW)
dougqh Jun 30, 2026
1c0a96e
Flyweight dense entries in TagMap.iterator() (alloc-free reads)
dougqh Jun 30, 2026
0b08937
Rename KnownTagIds to KnownTags; move resolver to KnownTagCodec
dougqh Jul 8, 2026
2c3fad7
Suffix known-tag id constants with _ID
dougqh Jul 8, 2026
27d42d4
Persist -prof gc results in benchmark headers; fix stale iterator com…
dougqh Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import datadog.trace.api.EndpointTracker;
import datadog.trace.api.IdGenerationStrategy;
import datadog.trace.api.InstrumenterConfig;
import datadog.trace.api.KnownTags;
import datadog.trace.api.Pair;
import datadog.trace.api.TagMap;
import datadog.trace.api.TraceConfig;
Expand Down Expand Up @@ -653,6 +654,14 @@ private CoreTracer(
// preload this enum to avoid triggering classloading on the hot path
TraceCollector.PublishState.values();

// Dense known-tag store (experimental, OFF by default): registering the KnownTagCodec resolver
// flips the dense store live so known tags store without a per-tag Entry. Gated by a system
// property for A/B benchmarking; when off, keyOf stays a no-op and tag storage is byte-identical
// to today. Promote to a Config flag if this becomes a permanent rollout.
if (Boolean.getBoolean("dd.trace.dense.tags.enabled")) {
KnownTags.init();
}

if (reportInTracerFlare) {
TracerFlare.addReporter(this);
}
Expand Down Expand Up @@ -2195,13 +2204,27 @@ protected static final DDSpanContext buildSpanContext(
// By setting the tags on the context we apply decorators to any tags that have been set via
// the builder. This is the order that the tags were added previously, but maybe the `tags`
// set in the builder should come last, so that they override other tags.
context.setAllTags(mergedTracerTags, mergedTracerTagsNeedsIntercept);
//
// mergedTracerTags is trace-level shared state and the precedence floor (everything below
// overrides it). When it carries no interceptable tags, attach it as a read-through PARENT
// (shared by reference, no per-span copy) instead of copying its entries into the span. When
// it does need interception, fall back to copying (the interceptor's per-span side-effects
// can't be shared by reference).
if (mergedTracerTagsNeedsIntercept) {
context.setAllTags(mergedTracerTags, true);
} else {
context.parentTags(mergedTracerTags);
}
context.setAllTags(tagLedger);
context.setAllTags(coreTags, coreTagsNeedsIntercept);
context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept);
context.setAllTags(contextualTags);
// remove version here since will be done later on the postProcessor.
// it will allow knowing if it will be set manually or not
// Version is added later by the postProcessor (InternalTagsAdder), only if not already set
// during the request. Config version is kept out of the trace-level bundle (see
// withTracerTags), so this removal now only wipes a version set via the span builder —
// keeping
// the existing semantics where a builder-set version is replaced by the config version. Under
// read-through this is a cheap local removal (version isn't in the parent, so no tombstone).
context.removeTag(Tags.VERSION);
return context;
}
Expand Down Expand Up @@ -2432,6 +2455,13 @@ static TagMap withTracerTags(
Map<String, ?> userSpanTags, Config config, TraceConfig traceConfig) {
final TagMap result = TagMap.create(userSpanTags.size() + 5);
result.putAll(userSpanTags);
// Version is conditionally managed by InternalTagsAdder (added only when service == DD_SERVICE
// and not set during the request), so keep it OUT of the trace-level bundle. This matters under
// read-through: the bundle becomes a shared parent, and a per-span removeTag(VERSION) on a key
// that lived in the parent would mint a per-span tombstone. With version excluded here, the
// per-span removeTag (retained, to wipe a builder-set version) is a cheap local op, never a
// tombstone. Behavior is unchanged: version was applied-then-removed at build today.
result.remove(Tags.VERSION);
if (null != config) { // static
if (!config.getEnv().isEmpty()) {
result.set("env", config.getEnv());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,21 @@ void setAllTags(final TagMap map, boolean needsIntercept) {
}
}

/**
* Attaches {@code parent} as a read-through parent of this span's tags instead of copying its
* entries in (level-split phase 1). The parent must be frozen and free of interceptable tags —
* the caller gates on {@code !needsIntercept}, since read-through bypasses the per-span
* interceptor side-effects that {@link #setAllTags(TagMap, boolean)} applies.
*/
void parentTags(final TagMap parent) {
if (parent == null || parent.isEmpty()) {
return;
}
synchronized (unsafeTags) {
unsafeTags.withParent(parent);
}
}

void setAllTags(final TagMap.Ledger ledger) {
if (ledger == null) {
return;
Expand Down
1 change: 1 addition & 0 deletions internal-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ dependencies {
api("com.datadoghq:dd-javac-plugin-client:0.2.2")

testImplementation("org.snakeyaml:snakeyaml-engine:2.9")
testImplementation("org.openjdk.jol:jol-core:0.17") // StringIndexFootprintTest object-layout measurement
testImplementation(project(":utils:test-utils"))
testImplementation(libs.bundles.junit5)
testImplementation("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package datadog.trace.api;

import datadog.trace.bootstrap.instrumentation.api.Tags;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;

/**
* Deterministic allocation A/B for the dense known-tag store, using the REAL {@link KnownTags}
* resolver (a {@code StringIndex} probe + a constant-returning {@code switch} — allocation-free,
* exactly like production). An earlier synthetic prefix resolver allocated in {@code keyOf}
* (substring) and {@code nameOf} (concat), contaminating the dense arm; this measures the store,
* not the resolver.
*
* <p>Models how a real span's tags route: {@code today} = all custom (what ships now — every tag
* buckets, since nothing is registered as known), {@code dense} = the same tag count with a
* realistic fraction routed to the dense store (real known tag names) and the rest custom. Run with
* {@code -prof gc}; the {@code gc.alloc.rate.norm} (B/op) delta at the same {@code tagCount} is
* what enabling the dense store does to a real span's per-build allocation.
*
* <p><b>Results — buildMap, JDK 17 (Zulu 17.0.7, Apple Silicon), {@code -prof gc -f 1 -wi 2 -i 3},
* 2026-07-08.</b> Allocation is deterministic (±0.001 B/op); throughput on this run is NOT
* trustworthy (single fork, short) — read B/op only.
*
* <pre>{@code
* scenario tagCount=7 tagCount=12
* today 408 B/op 704 B/op
* dense 376 B/op 416 B/op
* allKnown 176 B/op 400 B/op
* }</pre>
*
* <p>Gate met: {@code dense < today} at both counts (the over-provision artifact is gone). The
* Entry-less win scales with the known-tag fraction — ~8% at 7 tags (~70% known), ~41% at 12;
* {@code allKnown} (the codegen endgame / read-through parent shape) reaches ~57% at 7.
*
* <p><b>Serialize paths (same run, B/op).</b> {@code buildAndSerialize} (alloc-free {@code forEach}
* flyweight) adds a flat +16 B/op over {@code buildMap} in every scenario (7: 392, 12: 432 dense).
* {@code buildAndSerializeViaIterator} — the {@code EntryReader} enhanced-for modeling the count
* pre-pass at {@code TraceMapperV0_4:95} — adds a CONSTANT per-call cost (+56 custom / +80 dense,
* identical at 7 and 12 tags): that flat-vs-tagCount signature is the {@code EntryReaderIterator}
* OBJECT, NOT per-tag Entry — the iterator reuses a dense flyweight (TagMap:2182/2652). So the
* dense win SURVIVES serialization; the only nit is {@code iterator()} allocating one Iterator per
* call, which {@code forEach} avoids and which can be recycled away.
*/
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 2, time = 2)
@Measurement(iterations = 3, time = 2)
@Fork(1)
@Threads(1)
public class DenseStoreAllocBenchmark {

// Real stored (dense-routed) tag names — a realistic web/db span's known set.
static final String[] KNOWN =
new String[] {
DDTags.BASE_SERVICE,
Tags.VERSION,
Tags.COMPONENT,
Tags.SPAN_KIND,
Tags.HTTP_METHOD,
Tags.HTTP_ROUTE,
Tags.DB_TYPE,
Tags.DB_INSTANCE,
Tags.PEER_HOSTNAME,
Tags.DB_USER,
DDTags.LANGUAGE_TAG_KEY,
Tags.PEER_PORT,
};

// today = all custom (all bucket, what ships now); dense = ~70% known + custom (a real span);
// allKnown = 100% known (the trace-tier read-through parent's shape — exercises lazy buckets).
@Param({"today", "dense", "allKnown"})
String scenario;

@Param({"7", "12"})
int tagCount;

private String[] keys;
private String[] values;

@Setup(Level.Trial)
public void setup() {
KnownTags.init(); // registers the real (allocation-free) resolver
int knownCount;
if ("allKnown".equals(scenario)) {
knownCount = tagCount; // 100% known (<= KNOWN.length)
} else if ("dense".equals(scenario)) {
knownCount = (tagCount * 7) / 10; // ~70% known + custom
} else {
knownCount = 0; // today: all custom (all bucket)
}
this.keys = new String[tagCount];
this.values = new String[tagCount];
for (int i = 0; i < tagCount; i++) {
this.keys[i] = i < knownCount ? KNOWN[i] : "custom.tag." + i;
this.values[i] = "value-" + i;
}
}

@Benchmark
public TagMap buildMap() {
TagMap m = TagMap.create(16);
for (int i = 0; i < tagCount; i++) {
m.set(keys[i], values[i]);
}
return m;
}

@Benchmark
public void buildAndSerialize(Blackhole bh) {
TagMap m = TagMap.create(16);
for (int i = 0; i < tagCount; i++) {
m.set(keys[i], values[i]);
}
// forEach: the alloc-free flyweight emit for dense
m.forEach(reader -> bh.consume(reader.objectValue()));
bh.consume(m);
}

@Benchmark
public void buildAndSerializeViaIterator(Blackhole bh) {
TagMap m = TagMap.create(16);
for (int i = 0; i < tagCount; i++) {
m.set(keys[i], values[i]);
}
// models the REAL serializer's count pre-pass (TraceMapperV0_4:95). The EntryReader iterator
// uses a reused dense flyweight (NO per-tag Entry alloc — TagMap:2182/2652), so the dense win
// SURVIVES; the only extra cost vs forEach is the EntryReaderIterator object itself (a fixed
// per-call cost, constant across tagCount — not per-tag). forEach avoids even that.
for (TagMap.EntryReader reader : m) {
bh.consume(reader.objectValue());
}
bh.consume(m);
}
}
Loading