Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
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);
}
}
177 changes: 177 additions & 0 deletions internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package datadog.trace.api;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

/**
* Registry for generated tag ID ↔ name resolution. The code generator populates this at tracer init
* via {@link #register(Resolver)}. Once registered, HotSpot CHA devirtualizes and inlines the
* resolver's switch, making {@link #nameOf}/{@link #keyOf} effectively zero-overhead.
*/
public final class KnownTagCodec {
// Plain (non-volatile) fast-path flag: false until a resolver is ever registered. A plain read is
// free and hoistable, unlike a volatile read of `resolver` (costly on weak memory models such as
// ARM). A stale `false` is benign — callers treat the tag as unknown and use the hash buckets,
// which is correct, just unoptimized; the next read after publication takes the slot path.
private static boolean active;

private static volatile Resolver resolver;

/** Fast-path gate: true once a resolver has been registered. */
public static boolean isActive() {
return active;
}

/*
* tagId bit layout: [63 intercepted] [62-48 globalSerial (15 bits)] [47-32 fieldPos]
* [31-0 nameHash]. Bit 63 (the sign bit) marks a tag the tag interceptor must see, so the check
* is a single {@code tagId < 0}. globalSerial is globally unique per known tag; fieldPos is its
* slot in the global positional layout (TagMap.knownEntries index); nameHash is
* TagMap.Entry#_hash(name) and is layout-independent. Unknown (string-only) tags have the upper
* 32 bits zero. NOTE: TagMap.Entry decodes nameHash inline as (int) tagId on its hot path, so the
* low-32 encoding here must stay in sync with that.
*/
public static int globalSerial(long tagId) {
return (int) ((tagId >>> 48) & 0x7FFF);
}

/**
* Flag bit (the sign bit) marking a tag the tag interceptor must process — reserved/"virtual"
* tags AND intercepted-but-stored tags (e.g. http.method, which the interceptor side-effects and
* also stores). Encoded in the id so {@code DDSpanContext.setTag(long)} can route with a single
* sign test ({@link #isIntercepted}) instead of resolving the name. Non-intercepted tags (peer.*,
* base.service, …) leave it clear and take the fast store path. Must agree with the interceptor's
* name-based {@code needsIntercept} for every assigned id.
*/
public static final long INTERCEPTED = Long.MIN_VALUE; // 1L << 63

/** True if the tagId is flagged for tag-interceptor processing. */
public static boolean isIntercepted(long tagId) {
return tagId < 0L;
}

/** Returns the tagId with the {@link #INTERCEPTED} flag set. */
public static long intercepted(long tagId) {
return tagId | INTERCEPTED;
}

public static int fieldPos(long tagId) {
return (int) ((tagId >>> 32) & 0xFFFF);
}

public static int nameHash(long tagId) {
return (int) tagId;
}

/**
* globalSerial partition. {@code [1, FIRST_STORED_SERIAL)} is reserved for "virtual" tags that
* are specially handled (redirected to span fields or processed by the tag interceptor) and are
* NOT stored in the TagMap — these are hand-assigned in tracer core. {@code [FIRST_STORED_SERIAL,
* ..]} is for generated convention tags that ARE stored (slotted/bucketed). {@code globalSerial
* == 0} means unknown / string-only. Both core and the code generator must agree on this
* boundary.
*/
public static final int FIRST_STORED_SERIAL = 256;

/** True if the tagId names a reserved "virtual"/specially-handled tag (not stored in the map). */
public static boolean isReserved(long tagId) {
int globalSerial = globalSerial(tagId);
return globalSerial > 0 && globalSerial < FIRST_STORED_SERIAL;
}

/** True if the tagId names a generated, map-stored (slotted/bucketed) tag. */
public static boolean isStored(long tagId) {
return globalSerial(tagId) >= FIRST_STORED_SERIAL;
}

/**
* Sentinel {@code fieldPos} meaning "no positional slot". It is the maximum value the 16-bit
* fieldPos field can hold, so it always compares {@code >= slotCount()} and routes to the hash
* buckets rather than the fast positional array. Two kinds of tagId use it:
*
* <ul>
* <li>Reserved/virtual tags ({@code globalSerial < FIRST_STORED_SERIAL}) — not stored at all;
* the sentinel just guarantees an incidental store never lands in a slot.
* <li>Unslotted stored tags ({@code globalSerial >= FIRST_STORED_SERIAL}) — "low-priority" tags
* that get a stable id (and so {@code keyOf}/{@code nameOf} unification with their string
* form) but are deliberately not given a slot, so they live in the buckets and don't widen
* {@code knownEntries[]} for every span. {@code getEntry(long)} for these resolves the name
* and rehashes — the cost of not owning a slot.
* </ul>
*/
public static final int NO_SLOT = 0xFFFF;

/**
* True if the tagId names a stored tag that deliberately has no positional slot (bucket-only).
*/
public static boolean isUnslotted(long tagId) {
return isStored(tagId) && fieldPos(tagId) == NO_SLOT;
}

/**
* Builds a tagId from its parts: {@code globalSerial} (globally unique per known tag), {@code
* fieldPos} (the tag's slot within its span type's positional table), and the tag {@code name}
* (whose hash is computed via the same function the runtime uses, so the low 32 bits match {@link
* TagMap.Entry#hash()}). Inverse of {@link #globalSerial}/{@link #fieldPos}/{@link #nameHash}.
* Intended for the code generator and tests.
*/
public static long tagId(int globalSerial, int fieldPos, String name) {
long nameHash = TagMap.Entry._hash(name) & 0xFFFFFFFFL;
return ((long) globalSerial << 48) | ((long) (fieldPos & 0xFFFF) << 32) | nameHash;
}

/**
* Builds a tagId with no positional slot ({@code fieldPos == }{@link #NO_SLOT}). Use for reserved
* "virtual" tags and for "low-priority" stored tags that get a stable id but are intentionally
* kept out of the fast slot array (they route to the hash buckets). See {@link #NO_SLOT}.
*/
public static long tagId(int globalSerial, String name) {
return tagId(globalSerial, NO_SLOT, name);
}

// Number of positional slots in the global layout = (max stored fieldPos) + 1, declared by the
// registered provider. Captured once at registration and read as a dynamic constant; TagMap sizes
// its knownEntries array to exactly this rather than a hardcoded max. 0 when no resolver.
private static int slotCount;

/** Slot count of the registered provider (max stored fieldPos + 1); 0 if none. */
public static int slotCount() {
return slotCount;
}

public interface Resolver {
String nameOf(long tagId);

long keyOf(String name);

/** Number of positional slots this provider uses: (max stored fieldPos) + 1. */
int slotCount();
}

@SuppressFBWarnings(
value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE",
justification =
"active/slotCount are plain by design: written once at tracer-init registration (before"
+ " any span processing) and read plain on the hot path. A stale read is benign — the"
+ " tag is treated as unknown and takes the hash-bucket path — so plain reads are"
+ " deliberately preferred over a costly volatile read on weak memory models.")
public static void register(Resolver resolver) {
KnownTagCodec.resolver = resolver; // volatile write publishes the resolver
KnownTagCodec.slotCount = (resolver != null) ? resolver.slotCount() : 0;
KnownTagCodec.active =
(resolver != null); // plain write; readers re-read resolver volatile anyway
}

public static String nameOf(long tagId) {
if (!active) return null;
Resolver r = resolver;
return r != null ? r.nameOf(tagId) : null;
}

public static long keyOf(String name) {
if (!active) return 0L;
Resolver r = resolver;
return r != null ? r.keyOf(name) : 0L;
}

private KnownTagCodec() {}
}
Loading