Skip to content

Commit a5e12e4

Browse files
dougqhclaude
andcommitted
Store known span tags densely in TagMap by tag-id
Known tags (keyOf resolves to a stored id) are held in insertion-ordered parallel arrays (knownIds/knownValues) with NO per-tag Entry object — the allocation lever. Lazily allocated on the first known-tag write; custom tags stay in the hash buckets. Disjoint by construction (known-ness is global), so read-through shadow checks stay within-region and the bucket path is unchanged. - KnownTagCodec (id encoding + resolver) + hand-written KnownTags (keyOf substrate over StringIndex). Off-by-default: dormant until a resolver registers, so production is byte-identical. - CoreTracer flips it live behind `-Ddd.trace.dense.tags.enabled` for A/B. - Sizing is a generous fixed stopgap (KNOWN_INIT_CAP=12, the per-type max); exact per-type sizing comes with the tag registry. Reconciled onto the level-split stack (fold + read-through + StringIndex); built on the folded final-class TagMap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1d8fd80 commit a5e12e4

8 files changed

Lines changed: 1729 additions & 62 deletions

File tree

dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import datadog.trace.api.EndpointTracker;
3838
import datadog.trace.api.IdGenerationStrategy;
3939
import datadog.trace.api.InstrumenterConfig;
40+
import datadog.trace.api.KnownTags;
4041
import datadog.trace.api.Pair;
4142
import datadog.trace.api.TagMap;
4243
import datadog.trace.api.TraceConfig;
@@ -653,6 +654,14 @@ private CoreTracer(
653654
// preload this enum to avoid triggering classloading on the hot path
654655
TraceCollector.PublishState.values();
655656

657+
// Dense known-tag store (experimental, OFF by default): registering the KnownTagCodec resolver
658+
// flips the dense store live so known tags store without a per-tag Entry. Gated by a system
659+
// property for A/B benchmarking; when off, keyOf stays a no-op and tag storage is byte-identical
660+
// to today. Promote to a Config flag if this becomes a permanent rollout.
661+
if (Boolean.getBoolean("dd.trace.dense.tags.enabled")) {
662+
KnownTags.init();
663+
}
664+
656665
if (reportInTracerFlare) {
657666
TracerFlare.addReporter(this);
658667
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package datadog.trace.api;
2+
3+
import datadog.trace.bootstrap.instrumentation.api.Tags;
4+
import java.util.concurrent.TimeUnit;
5+
import org.openjdk.jmh.annotations.Benchmark;
6+
import org.openjdk.jmh.annotations.BenchmarkMode;
7+
import org.openjdk.jmh.annotations.Fork;
8+
import org.openjdk.jmh.annotations.Level;
9+
import org.openjdk.jmh.annotations.Measurement;
10+
import org.openjdk.jmh.annotations.Mode;
11+
import org.openjdk.jmh.annotations.OutputTimeUnit;
12+
import org.openjdk.jmh.annotations.Param;
13+
import org.openjdk.jmh.annotations.Scope;
14+
import org.openjdk.jmh.annotations.Setup;
15+
import org.openjdk.jmh.annotations.State;
16+
import org.openjdk.jmh.annotations.Threads;
17+
import org.openjdk.jmh.annotations.Warmup;
18+
import org.openjdk.jmh.infra.Blackhole;
19+
20+
/**
21+
* Deterministic allocation A/B for the dense known-tag store, using the REAL {@link KnownTags}
22+
* resolver (a {@code StringIndex} probe + a constant-returning {@code switch} — allocation-free,
23+
* exactly like production). An earlier synthetic prefix resolver allocated in {@code keyOf}
24+
* (substring) and {@code nameOf} (concat), contaminating the dense arm; this measures the store,
25+
* not the resolver.
26+
*
27+
* <p>Models how a real span's tags route: {@code today} = all custom (what ships now — every tag
28+
* buckets, since nothing is registered as known), {@code dense} = the same tag count with a
29+
* realistic fraction routed to the dense store (real known tag names) and the rest custom. Run with
30+
* {@code -prof gc}; the {@code gc.alloc.rate.norm} (B/op) delta at the same {@code tagCount} is
31+
* what enabling the dense store does to a real span's per-build allocation.
32+
*
33+
* <p><b>Results — buildMap, JDK 17 (Zulu 17.0.7, Apple Silicon), {@code -prof gc -f 1 -wi 2 -i 3},
34+
* 2026-07-08.</b> Allocation is deterministic (±0.001 B/op); throughput on this run is NOT
35+
* trustworthy (single fork, short) — read B/op only.
36+
*
37+
* <pre>{@code
38+
* scenario tagCount=7 tagCount=12
39+
* today 408 B/op 704 B/op
40+
* dense 376 B/op 416 B/op
41+
* allKnown 176 B/op 400 B/op
42+
* }</pre>
43+
*
44+
* <p>Gate met: {@code dense < today} at both counts (the over-provision artifact is gone). The
45+
* Entry-less win scales with the known-tag fraction — ~8% at 7 tags (~70% known), ~41% at 12;
46+
* {@code allKnown} (the codegen endgame / read-through parent shape) reaches ~57% at 7.
47+
*
48+
* <p><b>Serialize paths (same run, B/op).</b> {@code buildAndSerialize} (alloc-free {@code forEach}
49+
* flyweight) adds a flat +16 B/op over {@code buildMap} in every scenario (7: 392, 12: 432 dense).
50+
* {@code buildAndSerializeViaIterator} — the {@code EntryReader} enhanced-for modeling the count
51+
* pre-pass at {@code TraceMapperV0_4:95} — adds a CONSTANT per-call cost (+56 custom / +80 dense,
52+
* identical at 7 and 12 tags): that flat-vs-tagCount signature is the {@code EntryReaderIterator}
53+
* OBJECT, NOT per-tag Entry — the iterator reuses a dense flyweight (TagMap:2182/2652). So the
54+
* dense win SURVIVES serialization; the only nit is {@code iterator()} allocating one Iterator per
55+
* call, which {@code forEach} avoids and which can be recycled away.
56+
*/
57+
@State(Scope.Benchmark)
58+
@BenchmarkMode(Mode.Throughput)
59+
@OutputTimeUnit(TimeUnit.SECONDS)
60+
@Warmup(iterations = 2, time = 2)
61+
@Measurement(iterations = 3, time = 2)
62+
@Fork(1)
63+
@Threads(1)
64+
public class DenseStoreAllocBenchmark {
65+
66+
// Real stored (dense-routed) tag names — a realistic web/db span's known set.
67+
static final String[] KNOWN =
68+
new String[] {
69+
DDTags.BASE_SERVICE,
70+
Tags.VERSION,
71+
Tags.COMPONENT,
72+
Tags.SPAN_KIND,
73+
Tags.HTTP_METHOD,
74+
Tags.HTTP_ROUTE,
75+
Tags.DB_TYPE,
76+
Tags.DB_INSTANCE,
77+
Tags.PEER_HOSTNAME,
78+
Tags.DB_USER,
79+
DDTags.LANGUAGE_TAG_KEY,
80+
Tags.PEER_PORT,
81+
};
82+
83+
// today = all custom (all bucket, what ships now); dense = ~70% known + custom (a real span);
84+
// allKnown = 100% known (the trace-tier read-through parent's shape — exercises lazy buckets).
85+
@Param({"today", "dense", "allKnown"})
86+
String scenario;
87+
88+
@Param({"7", "12"})
89+
int tagCount;
90+
91+
private String[] keys;
92+
private String[] values;
93+
94+
@Setup(Level.Trial)
95+
public void setup() {
96+
KnownTags.init(); // registers the real (allocation-free) resolver
97+
int knownCount;
98+
if ("allKnown".equals(scenario)) {
99+
knownCount = tagCount; // 100% known (<= KNOWN.length)
100+
} else if ("dense".equals(scenario)) {
101+
knownCount = (tagCount * 7) / 10; // ~70% known + custom
102+
} else {
103+
knownCount = 0; // today: all custom (all bucket)
104+
}
105+
this.keys = new String[tagCount];
106+
this.values = new String[tagCount];
107+
for (int i = 0; i < tagCount; i++) {
108+
this.keys[i] = i < knownCount ? KNOWN[i] : "custom.tag." + i;
109+
this.values[i] = "value-" + i;
110+
}
111+
}
112+
113+
@Benchmark
114+
public TagMap buildMap() {
115+
TagMap m = TagMap.create(16);
116+
for (int i = 0; i < tagCount; i++) {
117+
m.set(keys[i], values[i]);
118+
}
119+
return m;
120+
}
121+
122+
@Benchmark
123+
public void buildAndSerialize(Blackhole bh) {
124+
TagMap m = TagMap.create(16);
125+
for (int i = 0; i < tagCount; i++) {
126+
m.set(keys[i], values[i]);
127+
}
128+
// forEach: the alloc-free flyweight emit for dense
129+
m.forEach(reader -> bh.consume(reader.objectValue()));
130+
bh.consume(m);
131+
}
132+
133+
@Benchmark
134+
public void buildAndSerializeViaIterator(Blackhole bh) {
135+
TagMap m = TagMap.create(16);
136+
for (int i = 0; i < tagCount; i++) {
137+
m.set(keys[i], values[i]);
138+
}
139+
// models the REAL serializer's count pre-pass (TraceMapperV0_4:95). The EntryReader iterator
140+
// uses a reused dense flyweight (NO per-tag Entry alloc — TagMap:2182/2652), so the dense win
141+
// SURVIVES; the only extra cost vs forEach is the EntryReaderIterator object itself (a fixed
142+
// per-call cost, constant across tagCount — not per-tag). forEach avoids even that.
143+
for (TagMap.EntryReader reader : m) {
144+
bh.consume(reader.objectValue());
145+
}
146+
bh.consume(m);
147+
}
148+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package datadog.trace.api;
2+
3+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
4+
5+
/**
6+
* Registry for generated tag ID ↔ name resolution. The code generator populates this at tracer init
7+
* via {@link #register(Resolver)}. Once registered, HotSpot CHA devirtualizes and inlines the
8+
* resolver's switch, making {@link #nameOf}/{@link #keyOf} effectively zero-overhead.
9+
*/
10+
public final class KnownTagCodec {
11+
// Plain (non-volatile) fast-path flag: false until a resolver is ever registered. A plain read is
12+
// free and hoistable, unlike a volatile read of `resolver` (costly on weak memory models such as
13+
// ARM). A stale `false` is benign — callers treat the tag as unknown and use the hash buckets,
14+
// which is correct, just unoptimized; the next read after publication takes the slot path.
15+
private static boolean active;
16+
17+
private static volatile Resolver resolver;
18+
19+
/** Fast-path gate: true once a resolver has been registered. */
20+
public static boolean isActive() {
21+
return active;
22+
}
23+
24+
/*
25+
* tagId bit layout: [63 intercepted] [62-48 globalSerial (15 bits)] [47-32 fieldPos]
26+
* [31-0 nameHash]. Bit 63 (the sign bit) marks a tag the tag interceptor must see, so the check
27+
* is a single {@code tagId < 0}. globalSerial is globally unique per known tag; fieldPos is its
28+
* slot in the global positional layout (TagMap.knownEntries index); nameHash is
29+
* TagMap.Entry#_hash(name) and is layout-independent. Unknown (string-only) tags have the upper
30+
* 32 bits zero. NOTE: TagMap.Entry decodes nameHash inline as (int) tagId on its hot path, so the
31+
* low-32 encoding here must stay in sync with that.
32+
*/
33+
public static int globalSerial(long tagId) {
34+
return (int) ((tagId >>> 48) & 0x7FFF);
35+
}
36+
37+
/**
38+
* Flag bit (the sign bit) marking a tag the tag interceptor must process — reserved/"virtual"
39+
* tags AND intercepted-but-stored tags (e.g. http.method, which the interceptor side-effects and
40+
* also stores). Encoded in the id so {@code DDSpanContext.setTag(long)} can route with a single
41+
* sign test ({@link #isIntercepted}) instead of resolving the name. Non-intercepted tags (peer.*,
42+
* base.service, …) leave it clear and take the fast store path. Must agree with the interceptor's
43+
* name-based {@code needsIntercept} for every assigned id.
44+
*/
45+
public static final long INTERCEPTED = Long.MIN_VALUE; // 1L << 63
46+
47+
/** True if the tagId is flagged for tag-interceptor processing. */
48+
public static boolean isIntercepted(long tagId) {
49+
return tagId < 0L;
50+
}
51+
52+
/** Returns the tagId with the {@link #INTERCEPTED} flag set. */
53+
public static long intercepted(long tagId) {
54+
return tagId | INTERCEPTED;
55+
}
56+
57+
public static int fieldPos(long tagId) {
58+
return (int) ((tagId >>> 32) & 0xFFFF);
59+
}
60+
61+
public static int nameHash(long tagId) {
62+
return (int) tagId;
63+
}
64+
65+
/**
66+
* globalSerial partition. {@code [1, FIRST_STORED_SERIAL)} is reserved for "virtual" tags that
67+
* are specially handled (redirected to span fields or processed by the tag interceptor) and are
68+
* NOT stored in the TagMap — these are hand-assigned in tracer core. {@code [FIRST_STORED_SERIAL,
69+
* ..]} is for generated convention tags that ARE stored (slotted/bucketed). {@code globalSerial
70+
* == 0} means unknown / string-only. Both core and the code generator must agree on this
71+
* boundary.
72+
*/
73+
public static final int FIRST_STORED_SERIAL = 256;
74+
75+
/** True if the tagId names a reserved "virtual"/specially-handled tag (not stored in the map). */
76+
public static boolean isReserved(long tagId) {
77+
int globalSerial = globalSerial(tagId);
78+
return globalSerial > 0 && globalSerial < FIRST_STORED_SERIAL;
79+
}
80+
81+
/** True if the tagId names a generated, map-stored (slotted/bucketed) tag. */
82+
public static boolean isStored(long tagId) {
83+
return globalSerial(tagId) >= FIRST_STORED_SERIAL;
84+
}
85+
86+
/**
87+
* Sentinel {@code fieldPos} meaning "no positional slot". It is the maximum value the 16-bit
88+
* fieldPos field can hold, so it always compares {@code >= slotCount()} and routes to the hash
89+
* buckets rather than the fast positional array. Two kinds of tagId use it:
90+
*
91+
* <ul>
92+
* <li>Reserved/virtual tags ({@code globalSerial < FIRST_STORED_SERIAL}) — not stored at all;
93+
* the sentinel just guarantees an incidental store never lands in a slot.
94+
* <li>Unslotted stored tags ({@code globalSerial >= FIRST_STORED_SERIAL}) — "low-priority" tags
95+
* that get a stable id (and so {@code keyOf}/{@code nameOf} unification with their string
96+
* form) but are deliberately not given a slot, so they live in the buckets and don't widen
97+
* {@code knownEntries[]} for every span. {@code getEntry(long)} for these resolves the name
98+
* and rehashes — the cost of not owning a slot.
99+
* </ul>
100+
*/
101+
public static final int NO_SLOT = 0xFFFF;
102+
103+
/**
104+
* True if the tagId names a stored tag that deliberately has no positional slot (bucket-only).
105+
*/
106+
public static boolean isUnslotted(long tagId) {
107+
return isStored(tagId) && fieldPos(tagId) == NO_SLOT;
108+
}
109+
110+
/**
111+
* Builds a tagId from its parts: {@code globalSerial} (globally unique per known tag), {@code
112+
* fieldPos} (the tag's slot within its span type's positional table), and the tag {@code name}
113+
* (whose hash is computed via the same function the runtime uses, so the low 32 bits match {@link
114+
* TagMap.Entry#hash()}). Inverse of {@link #globalSerial}/{@link #fieldPos}/{@link #nameHash}.
115+
* Intended for the code generator and tests.
116+
*/
117+
public static long tagId(int globalSerial, int fieldPos, String name) {
118+
long nameHash = TagMap.Entry._hash(name) & 0xFFFFFFFFL;
119+
return ((long) globalSerial << 48) | ((long) (fieldPos & 0xFFFF) << 32) | nameHash;
120+
}
121+
122+
/**
123+
* Builds a tagId with no positional slot ({@code fieldPos == }{@link #NO_SLOT}). Use for reserved
124+
* "virtual" tags and for "low-priority" stored tags that get a stable id but are intentionally
125+
* kept out of the fast slot array (they route to the hash buckets). See {@link #NO_SLOT}.
126+
*/
127+
public static long tagId(int globalSerial, String name) {
128+
return tagId(globalSerial, NO_SLOT, name);
129+
}
130+
131+
// Number of positional slots in the global layout = (max stored fieldPos) + 1, declared by the
132+
// registered provider. Captured once at registration and read as a dynamic constant; TagMap sizes
133+
// its knownEntries array to exactly this rather than a hardcoded max. 0 when no resolver.
134+
private static int slotCount;
135+
136+
/** Slot count of the registered provider (max stored fieldPos + 1); 0 if none. */
137+
public static int slotCount() {
138+
return slotCount;
139+
}
140+
141+
public interface Resolver {
142+
String nameOf(long tagId);
143+
144+
long keyOf(String name);
145+
146+
/** Number of positional slots this provider uses: (max stored fieldPos) + 1. */
147+
int slotCount();
148+
}
149+
150+
@SuppressFBWarnings(
151+
value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE",
152+
justification =
153+
"active/slotCount are plain by design: written once at tracer-init registration (before"
154+
+ " any span processing) and read plain on the hot path. A stale read is benign — the"
155+
+ " tag is treated as unknown and takes the hash-bucket path — so plain reads are"
156+
+ " deliberately preferred over a costly volatile read on weak memory models.")
157+
public static void register(Resolver resolver) {
158+
KnownTagCodec.resolver = resolver; // volatile write publishes the resolver
159+
KnownTagCodec.slotCount = (resolver != null) ? resolver.slotCount() : 0;
160+
KnownTagCodec.active =
161+
(resolver != null); // plain write; readers re-read resolver volatile anyway
162+
}
163+
164+
public static String nameOf(long tagId) {
165+
if (!active) return null;
166+
Resolver r = resolver;
167+
return r != null ? r.nameOf(tagId) : null;
168+
}
169+
170+
public static long keyOf(String name) {
171+
if (!active) return 0L;
172+
Resolver r = resolver;
173+
return r != null ? r.keyOf(name) : 0L;
174+
}
175+
176+
private KnownTagCodec() {}
177+
}

0 commit comments

Comments
 (0)