diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt
index c88b895281b..2085b062049 100644
--- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt
+++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt
@@ -96,6 +96,7 @@ object KnownTagsEmitter {
appendLine(" public static final String ${otelNameC(t.ddName)} = \"${escape(t.otelName)}\";")
}
append("// makeTagId(serial=${t.serial})")
+ append(if (t.slot == TagRegistry.NO_SLOT) " + no slot" else " + slot=${t.slot}")
if (t.traceLevel) append(" + trace-level")
if (t.intercepted) append(" + intercepted")
if (t.scopedOtelNames.isNotEmpty()) append(" + scoped-alias")
@@ -114,6 +115,9 @@ object KnownTagsEmitter {
// Every known tag's serial is below SERIAL_LIMIT; serial 0 is no tag.
val serialLimit = (reg.tags.maxOfOrNull { it.serial } ?: 0) + 1
appendLine(" public static final int SERIAL_LIMIT = $serialLimit;")
+ // Slots in use: every slotted tag's slot is below SLOT_COUNT.
+ val slotCount = (reg.tags.filter { it.slot != TagRegistry.NO_SLOT }.maxOfOrNull { it.slot } ?: -1) + 1
+ appendLine(" public static final int SLOT_COUNT = $slotCount;")
// OpenTelemetry name -> canonical tag name. Validation ensures aliases are distinct from all
// canonical names. Sort by OTel name to keep output deterministic.
diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt
index 1a46bea5a4d..12dec86f681 100644
--- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt
+++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt
@@ -2,12 +2,19 @@ package datadog.buildlogic.tagRegistry
/**
* Assigns tag ids from a parsed [TagConventions]. The id encoding mirrors KnownTagCodec: [63-48
- * serial][47-32 reserved][31-0 flags].
+ * serial][47-32 slot][31-0 flags].
*
- *
An id is a globally unique serial plus two classification bits: trace-level and intercepted. It
- * carries no storage-layout coordinate -- bits [47-32] are held vacant for the co-occurrence slot
- * that the dense tag store assigns by graph coloring, which lands with the dense store itself.
- * Nothing here needs to know how (or whether) a tag is stored.
+ *
An id is a globally unique serial, a slot, and two classification bits: trace-level and
+ * intercepted.
+ *
+ *
The slot comes from GRAPH COLORING the tag co-occurrence graph. Each concrete span type's
+ * resolved tags appear together on one span, so they form a clique and need distinct slots; the
+ * trace-level tier is its own clique (it lives in a separate TagMap). Tags that never share a clique
+ * may share a slot, so the slot count stays bounded by the largest clique, small enough for one
+ * occupancy `long`. Every tag resolved onto a span type, or trace-level, is slotted whatever its
+ * requirement level; a tag on no modeled span type (the tracer overlay's routing-only keys, tags of
+ * unmodeled span types) gets [NO_SLOT]. Slots are for per-span structures that want an exact,
+ * compact presence word: the dense tag store (DenseTagMap), and in time a span builder's tag ledger.
*
*
The intercepted bit marks a tag the tracer may route on the set-path (to a span field or a
* sampling directive instead of tag storage), as listed by the tracer overlay. A setter called with
@@ -26,6 +33,8 @@ class TagRegistry private constructor(val tags: List) {
val serial: Int,
val traceLevel: Boolean,
val intercepted: Boolean,
+ /** The tag's slot, or [NO_SLOT] for a tag on no modeled span type. */
+ val slot: Int,
val id: Long,
/** The tag's OpenTelemetry name in every direction; null when not renamed, or renamed per direction. */
val otelName: String? = null,
@@ -43,6 +52,10 @@ class TagRegistry private constructor(val tags: List) {
const val INTERCEPTED = 1L shl 1 // low-32 carve bit 1; mirrors KnownTagCodec.INTERCEPTED
const val LEVEL_TRACE = 1L shl 2 // low-32 carve bit 2; mirrors KnownTagCodec.LEVEL_TRACE
+ const val SLOT_SHIFT = 32 // slot window [47-32]; mirrors KnownTagCodec.SLOT_SHIFT
+ const val NO_SLOT = 0xFFFF // all-ones slot: no modeled span type; mirrors KnownTagCodec.NO_SLOT
+ const val MAX_SLOTS = 63 // fits one occupancy long; bit 63 is left for unslotted tags to share
+
/**
* Serials stay below 2^15, so bit 63 of an id is never set: every id is positive, clear of 0
* (unknown) and of KnownTagCodec's negative lookup sentinels.
@@ -50,17 +63,24 @@ class TagRegistry private constructor(val tags: List) {
const val SERIAL_CAPACITY = 1 shl 15
/**
- * Mirrors KnownTagCodec.makeTagId(serial) + intercepted() + traceLevel() -- must stay in sync.
+ * Mirrors KnownTagCodec's layout -- must stay in sync: serial at [63-48], slot at [47-32],
* HAS_SCOPED_ALIAS at bit 0 (the tag has an OpenTelemetry name in some directions only),
- * INTERCEPTED at bit 1, LEVEL_TRACE at bit 2, other low bits and the reserved [47-32] window
- * zero. Every id is made here, and the range check makes each one positive by construction.
+ * INTERCEPTED at bit 1, LEVEL_TRACE at bit 2, other low bits zero. Every id is made here, and
+ * the range check makes each one positive by construction.
*/
- fun encode(serial: Int, traceLevel: Boolean, intercepted: Boolean, hasScopedAlias: Boolean = false): Long {
+ fun encode(
+ serial: Int,
+ traceLevel: Boolean,
+ intercepted: Boolean,
+ hasScopedAlias: Boolean = false,
+ slot: Int = NO_SLOT,
+ ): Long {
+ require(slot == NO_SLOT || slot in 0 until MAX_SLOTS) { "slot $slot is outside [0, $MAX_SLOTS)" }
require(serial in FIRST_SERIAL until SERIAL_CAPACITY) {
"serial $serial is outside [$FIRST_SERIAL, $SERIAL_CAPACITY): more tags would make tag ids " +
"negative, where KnownTagCodec's lookup sentinels live"
}
- var id = serial.toLong() shl 48
+ var id = (serial.toLong() shl 48) or (slot.toLong() shl SLOT_SHIFT)
if (traceLevel) id = id or LEVEL_TRACE
if (intercepted) id = id or INTERCEPTED
if (hasScopedAlias) id = id or HAS_SCOPED_ALIAS
@@ -70,6 +90,7 @@ class TagRegistry private constructor(val tags: List) {
fun build(conv: TagConventions): TagRegistry {
val traceLevel = conv.traceLevelTags().map { it.ddName }.toSet()
val renames = conv.otelMappings().groupBy { it.tag.ddName }
+ val slots = colorSlots(conv)
// One tag per Datadog name, in a stable order (by name) so serials -- and therefore ids -- are
// a pure function of the input. A name's declarations per direction merge into its one tag.
@@ -83,6 +104,7 @@ class TagRegistry private constructor(val tags: List) {
val isTraceLevel = first.ddName in traceLevel
val isIntercepted = first.ddName in conv.interceptedNames
val (otelName, scoped) = otelNamesOf(first.ddName, renames[first.ddName].orEmpty())
+ val slot = slots[first.ddName] ?: NO_SLOT
Tag(
first.ddName,
first.type,
@@ -90,7 +112,8 @@ class TagRegistry private constructor(val tags: List) {
serial,
isTraceLevel,
isIntercepted,
- id = encode(serial, isTraceLevel, isIntercepted, scoped.isNotEmpty()),
+ slot,
+ id = encode(serial, isTraceLevel, isIntercepted, scoped.isNotEmpty(), slot),
otelName = otelName,
scopedOtelNames = scoped,
)
@@ -100,6 +123,41 @@ class TagRegistry private constructor(val tags: List) {
return TagRegistry(tags)
}
+ /**
+ * Colors the co-occurrence graph of slotted tags: tags resolved onto one concrete span type, or
+ * both trace-level, must get distinct slots. Greedy, most-constrained first (by clique count,
+ * then name), so the result is a pure function of the conventions. A name declared per direction
+ * is one tag, so it is one vertex.
+ */
+ private fun colorSlots(conv: TagConventions): Map {
+ val cliques = ArrayList>()
+ for (type in conv.concreteTypes()) {
+ cliques.add(conv.resolve(type).map { it.ddName }.toSet())
+ }
+ cliques.add(conv.traceLevelTags().map { it.ddName }.toSet())
+
+ val neighbors = HashMap>()
+ for (clique in cliques) {
+ for (a in clique) {
+ val adjacent = neighbors.getOrPut(a) { HashSet() }
+ for (b in clique) if (a != b) adjacent.add(b)
+ }
+ }
+ val cliqueCount = neighbors.keys.associateWith { tag -> cliques.count { tag in it } }
+ val order = neighbors.keys.sortedWith(compareByDescending { cliqueCount.getValue(it) }.thenBy { it })
+ val slots = HashMap()
+ for (tag in order) {
+ val taken = neighbors.getValue(tag).mapNotNull { slots[it] }.toSet()
+ var slot = 0
+ while (slot in taken) slot++
+ require(slot < MAX_SLOTS) {
+ "coloring needs more than $MAX_SLOTS slots at '$tag'; one occupancy long holds $MAX_SLOTS"
+ }
+ slots[tag] = slot
+ }
+ return slots
+ }
+
/**
* A tag's renames, as one name for every direction or one name per direction. The rename of a
* declaration made per direction applies in that declaration's direction.
diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt
index f2a44a962aa..2477a7b56f4 100644
--- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt
+++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt
@@ -70,16 +70,17 @@ object TagRegistryGenerator {
"""
# Tag id assignment. tags=${reg.tags.size}
- # TAGS serial lvl int id required name
+ # TAGS serial lvl int slot id required name
""".trimIndent()
)
for (t in reg.tags) {
appendLine(
- " %6d %s %s %-18s %-12s %s".format(
+ " %6d %s %s %4s %-18s %-12s %s".format(
Locale.ROOT,
t.serial,
if (t.traceLevel) "T" else "-",
if (t.intercepted) "I" else "-",
+ if (t.slot == TagRegistry.NO_SLOT) "-" else t.slot.toString(),
"0x%016X".format(Locale.ROOT, t.id),
t.required,
t.name
diff --git a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt
index 2169afd1908..0ea513e0ae9 100644
--- a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt
+++ b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt
@@ -558,9 +558,9 @@ class TagRegistryGeneratorTest {
val knownTags = contents(output).getValue("java/datadog/trace/api/KnownTags.java")
assertThat(knownTags)
.contains(
- "COMPONENT_ID = 0x0001000000000000L",
- "RESOURCE_NAME_ID = 0x0002000000000002L",
- "SERVICE_ID = 0x0003000000000002L",
+ "COMPONENT_ID = 0x0001FFFF00000000L",
+ "RESOURCE_NAME_ID = 0x0002FFFF00000002L",
+ "SERVICE_ID = 0x0003FFFF00000002L",
"public static final int SERVICE_SERIAL_NUM = 3;",
"public static final int SERIAL_LIMIT = 4;"
)
@@ -584,6 +584,44 @@ class TagRegistryGeneratorTest {
.withMessageContaining(message)
}
+ @Test
+ fun `tags on one span type get distinct slots and tags that never co-occur share one`() {
+ val yaml = directory.conventionsFile(
+ """
+ trace_level:
+ tags: [{dd-name: env, required: required}]
+ span_types:
+ base:
+ abstract: true
+ tags: [{dd-name: service, required: required}]
+ http.server:
+ extends: base
+ span-kind: server
+ tags: [{dd-name: http.route, required: required}]
+ db.client:
+ extends: base
+ span-kind: client
+ tags: [{dd-name: db.type, required: required}, {dd-name: db.user}]
+ """
+ )
+ val output = File(directory, "generated")
+
+ TagRegistryGenerator.generate(yaml, output)
+
+ // service is on both span types, so it takes slot 0; db.type and db.user share db.client, so
+ // they take distinct slots, and http.route -- never on a span type with either -- reuses one of
+ // them; env is trace-level (its own TagMap) and reuses slot 0. Every level is slotted.
+ assertThat(contents(output).getValue("java/datadog/trace/api/KnownTags.java"))
+ .contains(
+ "DB_TYPE_ID = 0x0001000100000000L",
+ "DB_USER_ID = 0x0002000200000000L",
+ "ENV_ID = 0x0003000000000004L",
+ "HTTP_ROUTE_ID = 0x0004000100000000L",
+ "SERVICE_ID = 0x0005000000000000L",
+ "public static final int SLOT_COUNT = 3;"
+ )
+ }
+
private fun tagConventions(@Language("yaml") yamlText: String) = TagConventions.parse(
ObjectMapper(YAMLFactory()).readValue(
directory.conventionsFile(yamlText),
diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java
index d31137c3aef..2fa5365f935 100644
--- a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java
+++ b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java
@@ -190,5 +190,11 @@ public final class TracerConfig {
public static final String TRACE_ORG_GUARD_STRICT = "trace.org.guard.strict";
public static final String TRACE_ORG_GUARD_TRUSTED_OPMS = "trace.org.guard.trusted.opms";
+ /**
+ * Stores known tags that have a dense-store slot in TagMap's dense store (no per-tag entry)
+ * instead of its hash buckets. Experimental, off by default.
+ */
+ public static final String TRACE_DENSE_TAGS_ENABLED = "trace.experimental.dense.tags.enabled";
+
private TracerConfig() {}
}
diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagBenchmark.java
index dfcbd2266d0..d787ec8db81 100644
--- a/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagBenchmark.java
+++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagBenchmark.java
@@ -125,6 +125,15 @@ public Object knownIntTag_byId() {
return span.setTag(KnownTags.HTTP_RESEND_COUNT_ID, 2);
}
+ /**
+ * {@link #knownIntTag_byId} with a value outside {@code Integer}'s cache, so a box, if the path
+ * makes one, is a real allocation -- as for most status codes and ports.
+ */
+ @Benchmark
+ public Object knownIntTag_byId_uncached() {
+ return span.setTag(KnownTags.HTTP_RESEND_COUNT_ID, 5432);
+ }
+
@Benchmark
public Object knownIntTag_byName() {
return span.setTag(KnownTags.HTTP_RESEND_COUNT_NAME, 2);
diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationByIdBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationByIdBenchmark.java
new file mode 100644
index 00000000000..4066d98fc28
--- /dev/null
+++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationByIdBenchmark.java
@@ -0,0 +1,170 @@
+package datadog.trace.core;
+
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+
+import datadog.trace.api.KnownTags;
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import datadog.trace.bootstrap.instrumentation.api.SpanPrototype;
+import datadog.trace.bootstrap.instrumentation.api.Tags;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * {@link SpanCreationBenchmark}'s tagged span shapes, setting each tag by its {@link KnownTags} id
+ * instead of its name: same tags, values, threads and forks, so each arm here compares directly
+ * with the same-named arm there. The bare spans set no tags, and the builder path has no id-keyed
+ * {@code withTag}, so those arms are not repeated.
+ *
+ *
Read {@code gc.alloc.rate.norm} (B/op) first, as for {@link SpanCreationBenchmark}; throughput
+ * is directional. Run with {@code -prof gc}, and append {@code -DTEST_LOG_LEVEL=warn} to any {@code
+ * -jvmArgsAppend}, which replaces the {@code @Fork} arguments below.
+ */
+@State(Scope.Benchmark)
+@Warmup(iterations = 5)
+@Measurement(iterations = 5)
+@BenchmarkMode(Mode.Throughput)
+@Threads(8)
+@OutputTimeUnit(MICROSECONDS)
+@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn")
+public class SpanCreationByIdBenchmark {
+ private static final String INSTRUMENTATION_NAME = "bench";
+ private static final String SERVER_OPERATION_NAME = "servlet.request";
+ private static final String JDBC_OPERATION_NAME = "database.query";
+
+ // Same values as SpanCreationBenchmark; int values stay inside Integer's cache.
+ private static final String COMPONENT_VALUE = "tomcat-server";
+ private static final String HTTP_METHOD_VALUE = "GET";
+ private static final String HTTP_ROUTE_VALUE = "/owners/{ownerId}";
+ private static final String HTTP_URL_VALUE = "http://localhost:8080/owners/42";
+ private static final int HTTP_STATUS_VALUE = 100;
+ private static final int PEER_PORT_VALUE = 80;
+
+ private static final String DB_COMPONENT_VALUE = "java-jdbc-statement";
+ private static final String DB_TYPE_VALUE = "postgresql";
+ private static final String DB_INSTANCE_VALUE = "petclinic";
+ private static final String DB_USER_VALUE = "app";
+ private static final String DB_OPERATION_VALUE = "SELECT";
+ private static final String DB_STATEMENT_VALUE = "SELECT * FROM owners WHERE id = ?";
+ private static final String DB_PEER_HOSTNAME_VALUE = "db.internal";
+ private static final int DB_PEER_PORT_VALUE = 90;
+
+ CoreTracer tracer;
+ SpanPrototype webProto;
+ SpanPrototype jdbcProto;
+
+ @Setup
+ public void setup(Blackhole blackhole) {
+ this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build();
+ this.webProto =
+ SpanPrototype.builder()
+ .initInstrumentationName(INSTRUMENTATION_NAME)
+ .initOperationName(SERVER_OPERATION_NAME)
+ .initComponentOnly(COMPONENT_VALUE)
+ .initKind(Tags.SPAN_KIND_SERVER)
+ .build();
+ this.jdbcProto =
+ SpanPrototype.builder()
+ .initInstrumentationName(INSTRUMENTATION_NAME)
+ .initOperationName(JDBC_OPERATION_NAME)
+ .initComponentOnly(DB_COMPONENT_VALUE)
+ .initKind(Tags.SPAN_KIND_CLIENT)
+ .initTag(Tags.DB_TYPE, DB_TYPE_VALUE)
+ .build();
+ }
+
+ @TearDown
+ public void tearDown() {
+ this.tracer.close();
+ }
+
+ /** {@link SpanCreationBenchmark#webServerSpan}, by id. */
+ @Benchmark
+ public void webServerSpan() {
+ AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME).start();
+ span.setTag(KnownTags.COMPONENT_ID, COMPONENT_VALUE);
+ span.setTag(KnownTags.SPAN_KIND_ID, Tags.SPAN_KIND_SERVER);
+ span.setTag(KnownTags.HTTP_METHOD_ID, HTTP_METHOD_VALUE);
+ span.setTag(KnownTags.HTTP_ROUTE_ID, HTTP_ROUTE_VALUE);
+ span.setTag(KnownTags.HTTP_URL_ID, HTTP_URL_VALUE);
+ span.setTag(KnownTags.HTTP_STATUS_CODE_ID, HTTP_STATUS_VALUE);
+ span.setTag(KnownTags.PEER_PORT_ID, PEER_PORT_VALUE);
+ span.finish();
+ }
+
+ /** {@link SpanCreationBenchmark#jdbcClientSpan}, by id. */
+ @Benchmark
+ public void jdbcClientSpan() {
+ AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, JDBC_OPERATION_NAME).start();
+ span.setTag(KnownTags.COMPONENT_ID, DB_COMPONENT_VALUE);
+ span.setTag(KnownTags.SPAN_KIND_ID, Tags.SPAN_KIND_CLIENT);
+ span.setTag(KnownTags.DB_TYPE_ID, DB_TYPE_VALUE);
+ span.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE_VALUE);
+ span.setTag(KnownTags.DB_USER_ID, DB_USER_VALUE);
+ span.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION_VALUE);
+ span.setTag(KnownTags.DB_STATEMENT_ID, DB_STATEMENT_VALUE);
+ span.setTag(KnownTags.PEER_HOSTNAME_ID, DB_PEER_HOSTNAME_VALUE);
+ span.setTag(KnownTags.PEER_PORT_ID, DB_PEER_PORT_VALUE);
+ span.finish();
+ }
+
+ /** {@link SpanCreationBenchmark#webServerSpanViaPrototype}, by id. */
+ @Benchmark
+ public void webServerSpanViaPrototype() {
+ AgentSpan span = tracer.buildSpan(webProto, null).start();
+ span.setTag(KnownTags.HTTP_METHOD_ID, HTTP_METHOD_VALUE);
+ span.setTag(KnownTags.HTTP_ROUTE_ID, HTTP_ROUTE_VALUE);
+ span.setTag(KnownTags.HTTP_URL_ID, HTTP_URL_VALUE);
+ span.setTag(KnownTags.HTTP_STATUS_CODE_ID, HTTP_STATUS_VALUE);
+ span.setTag(KnownTags.PEER_PORT_ID, PEER_PORT_VALUE);
+ span.finish();
+ }
+
+ /** {@link SpanCreationBenchmark#jdbcClientSpanViaPrototype}, by id. */
+ @Benchmark
+ public void jdbcClientSpanViaPrototype() {
+ AgentSpan span = tracer.buildSpan(jdbcProto, null).start();
+ span.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE_VALUE);
+ span.setTag(KnownTags.DB_USER_ID, DB_USER_VALUE);
+ span.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION_VALUE);
+ span.setTag(KnownTags.DB_STATEMENT_ID, DB_STATEMENT_VALUE);
+ span.setTag(KnownTags.PEER_HOSTNAME_ID, DB_PEER_HOSTNAME_VALUE);
+ span.setTag(KnownTags.PEER_PORT_ID, DB_PEER_PORT_VALUE);
+ span.finish();
+ }
+
+ /** {@link SpanCreationBenchmark#webServerSpanViaPrototypeStartSpan}, by id. */
+ @Benchmark
+ public void webServerSpanViaPrototypeStartSpan() {
+ AgentSpan span = tracer.startSpan(webProto, null);
+ span.setTag(KnownTags.HTTP_METHOD_ID, HTTP_METHOD_VALUE);
+ span.setTag(KnownTags.HTTP_ROUTE_ID, HTTP_ROUTE_VALUE);
+ span.setTag(KnownTags.HTTP_URL_ID, HTTP_URL_VALUE);
+ span.setTag(KnownTags.HTTP_STATUS_CODE_ID, HTTP_STATUS_VALUE);
+ span.setTag(KnownTags.PEER_PORT_ID, PEER_PORT_VALUE);
+ span.finish();
+ }
+
+ /** {@link SpanCreationBenchmark#jdbcClientSpanViaPrototypeStartSpan}, by id. */
+ @Benchmark
+ public void jdbcClientSpanViaPrototypeStartSpan() {
+ AgentSpan span = tracer.startSpan(jdbcProto, null);
+ span.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE_VALUE);
+ span.setTag(KnownTags.DB_USER_ID, DB_USER_VALUE);
+ span.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION_VALUE);
+ span.setTag(KnownTags.DB_STATEMENT_ID, DB_STATEMENT_VALUE);
+ span.setTag(KnownTags.PEER_HOSTNAME_ID, DB_PEER_HOSTNAME_VALUE);
+ span.setTag(KnownTags.PEER_PORT_ID, DB_PEER_PORT_VALUE);
+ span.finish();
+ }
+}
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 8f52b49052c..4acca3f17b3 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
@@ -42,6 +42,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;
@@ -2328,6 +2329,17 @@ protected static final DDSpanContext buildSpanContext(
requestContextDataIast = builderRequestContextDataIast;
}
+ // Per-operation dense-store sizing: an entry (local-root) span carries the trace-metadata /
+ // enriching tags a child doesn't, so pick the lane by whether we have a local parent. A
+ // resolved hint sizes the span's TagMap and self-tunes as spans finish; null (dense store
+ // off, or no operation name) falls back to the generic default capacity. forOperation tests
+ // the dense-store flag first, so with it off -- the default -- this constant-folds to null
+ // and
+ // the default span-creation path does no lookup (do no harm).
+ final SizingHint sizingHint =
+ SizingHint.forOperation(
+ operationName, !(resolvedParentSpanContext instanceof DDSpanContext));
+
// some attributes are inherited from the parent
context =
new DDSpanContext(
@@ -2356,7 +2368,8 @@ protected static final DDSpanContext buildSpanContext(
tracer.profilingContextIntegration,
tracer.injectBaggageAsTags,
tracer.injectLinksAsTags,
- mergedTracerTagsNeedsIntercept ? null : mergedTracerTags);
+ mergedTracerTagsNeedsIntercept ? null : mergedTracerTags,
+ 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/DDSpan.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java
index e15a7ed49cb..de5333e21db 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java
@@ -178,6 +178,8 @@ private void finishAndAddToTrace(final long durationNano) {
wrapper.onSpanFinished();
}
this.metrics.onSpanFinished();
+ // every finished span tunes its operation's sizing hint, serialized or not
+ context.recordDenseSize();
TraceCollector.PublishState publishState = context.getTraceCollector().onPublish(this);
log.debug("Finished span ({}): {}", publishState, this);
} else {
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 cfe876c1edb..5daf6ba2c2d 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
@@ -18,6 +18,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.SpanTags;
import datadog.trace.api.TagMap;
import datadog.trace.api.cache.DDCache;
@@ -58,6 +59,7 @@
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -180,6 +182,15 @@ public class DDSpanContext
*/
private final TagMap unsafeTags;
+ /**
+ * Per-operation sizing hint (from SizingHintTable, keyed by operation name) this span's dense
+ * store was sized from, if any. Held so the span can feed its dense-store size back when it
+ * finishes and when it is serialized (recordDenseSize) -- the self-tuning loop that lets the
+ * reused hint converge to the operation's real known-tag high-water mark. Null when unsized,
+ * which is always the case 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;
@@ -418,6 +429,64 @@ 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,
+ tagsSize,
+ 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 int tagsSize,
+ 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;
@@ -448,8 +517,9 @@ public DDSpanContext(
final int capacity = Math.max((tagsSize <= 0 ? 3 : (tagsSize + 1)) * 4 / 3, 8);
this.unsafeTags =
readThroughParent != null
- ? TagMap.createFromParent(readThroughParent)
- : TagMap.create(capacity);
+ ? TagMap.createFromParent(readThroughParent, sizingHint)
+ : sizingHint != null ? TagMap.create(sizingHint) : TagMap.create(capacity);
+ this.sizingHint = sizingHint;
// must set this before setting the service and resource names below
this.profilingContextIntegration = profilingContextIntegration;
@@ -1269,8 +1339,8 @@ public void setTag(final long tagId, final boolean value) {
if (!KnownTagCodec.isKnown(tagId)) {
return;
}
- if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
- this.setBox(tagId, value);
+ if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
+ setKnownBox(tagId, value); // the one boxing site
} else {
synchronized (unsafeTags) {
unsafeTags.internals().setKnown(tagId, value);
@@ -1282,8 +1352,8 @@ public void setTag(final long tagId, final int value) {
if (!KnownTagCodec.isKnown(tagId)) {
return;
}
- if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
- this.setBox(tagId, value);
+ if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
+ setKnownBox(tagId, value); // the one boxing site
} else {
synchronized (unsafeTags) {
unsafeTags.internals().setKnown(tagId, value);
@@ -1295,8 +1365,8 @@ public void setTag(final long tagId, final long value) {
if (!KnownTagCodec.isKnown(tagId)) {
return;
}
- if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
- this.setBox(tagId, value);
+ if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
+ setKnownBox(tagId, value); // the one boxing site
} else {
synchronized (unsafeTags) {
unsafeTags.internals().setKnown(tagId, value);
@@ -1308,8 +1378,8 @@ public void setTag(final long tagId, final float value) {
if (!KnownTagCodec.isKnown(tagId)) {
return;
}
- if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
- this.setBox(tagId, value);
+ if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
+ setKnownBox(tagId, value); // the one boxing site
} else {
synchronized (unsafeTags) {
unsafeTags.internals().setKnown(tagId, value);
@@ -1321,8 +1391,8 @@ public void setTag(final long tagId, final double value) {
if (!KnownTagCodec.isKnown(tagId)) {
return;
}
- if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
- this.setBox(tagId, value);
+ if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) {
+ setKnownBox(tagId, value); // the one boxing site
} else {
synchronized (unsafeTags) {
unsafeTags.internals().setKnown(tagId, value);
@@ -1330,6 +1400,21 @@ public void setTag(final long tagId, final double value) {
}
}
+ /**
+ * The boxed path of the primitive id setters, which box at most once: always for the dense store,
+ * which keeps the box ({@code STORES_BOXES} is a static final, so the test folds), and for a tag
+ * that may be intercepted, since the interceptor takes an Object. The hash store's plain path
+ * stores the primitive unboxed. One shared path keeps each setter's compiled code small.
+ */
+ private void setKnownBox(long tagId, Object box) {
+ if (!tagInterceptor.needsIntercept(tagId, routedTagFlags)
+ || !tagInterceptor.interceptTag(this, tagId, box)) {
+ synchronized (unsafeTags) {
+ unsafeTags.internals().setKnown(tagId, box);
+ }
+ }
+ }
+
public void setTag(TagMap.EntryReader entry) {
if (entry == null) {
return;
@@ -1343,26 +1428,6 @@ public void setTag(TagMap.EntryReader entry) {
}
}
- /*
- * Used when the interceptor's precheck says the tag may be intercepted, so boxing is unavoidable
- *
- * Either because the tagInterceptor needs to be fully checked (which requires boxing)
- * In that case, a box has already been created so it makes sense to pass the box
- * onto TagMap, since optimized TagMap will cache the box
- *
- * -- OR --
- *
- * The TagMap isn't optimized and will need to box the primitive regardless of
- * tag interception
- */
- private void setBox(long tagId, Object box) {
- if (!tagInterceptor.interceptTag(this, tagId, box)) {
- synchronized (unsafeTags) {
- unsafeTags.internals().setKnown(tagId, box);
- }
- }
- }
-
public void setTag(final String tag, final boolean value) {
if (null == tag) {
return;
@@ -1624,6 +1689,21 @@ void earlyProcessTags(AppendableSpanLinks links) {
* @param restrictedSpan The span to read links and events from.
* @param options The {@link CoreSpan} options describing what the serialization protocol needs.
*/
+ /**
+ * Feeds this span's known-tag count back into its operation's {@link SizingHint}, so later spans
+ * of the operation are presized. Called when the span finishes, so every span tunes its hint even
+ * if it is never serialized (dropped by sampling, say), and again at serialization, after the
+ * serialization-time tags are added, so the hint also covers the span's final footprint. At
+ * finish the {@code knownCount} read is unlocked and may be stale; the hint is a best-effort racy
+ * max anyway (see {@link TagMap#recordSize}). With the dense store off the hint is always null,
+ * so this is a single null check.
+ */
+ void recordDenseSize() {
+ if (sizingHint != null) {
+ unsafeTags.recordSize(sizingHint);
+ }
+ }
+
void processTagsAndBaggage(
final MetadataConsumer consumer, int longRunningVersion, DDSpan restrictedSpan, int options) {
boolean injectLinksAsTags = this.injectLinksAsTags && (options & STRUCTURED_LINKS) == 0;
@@ -1665,6 +1745,10 @@ void processTagsAndBaggage(
}
}
+ // Self-tune the per-operation sizing hint now that every tag (incl. the just-appended
+ // serialization-time tags) is present -- the terminal point shared by all finish modes.
+ recordDenseSize();
+
// Baggage
Map baggageItemsWithPropagationTags;
if (this.injectBaggageAsTags) {
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 a836404f0fb..861dc87d6f1 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
@@ -2,6 +2,7 @@
import static datadog.trace.api.DDTags.ANALYTICS_SAMPLE_RATE;
import static datadog.trace.api.KnownTags.COMPONENT_ID;
+import static datadog.trace.api.KnownTags.LANGUAGE_ID;
import static datadog.trace.api.KnownTags.SERVLET_CONTEXT_ID;
import static datadog.trace.api.sampling.PrioritySampling.USER_DROP;
import static datadog.trace.bootstrap.instrumentation.api.ServiceNameSources.SPLIT_BY_SERVLET_CONTEXT;
@@ -361,10 +362,10 @@ public void resolveSplitByTagsPrecedence(DDSpanContext span, TagMap prototypeTag
return;
}
}
- if (isSplitServiceTag(DDTags.LANGUAGE_TAG_KEY)) {
- Object language = prototypeTags.getObject(DDTags.LANGUAGE_TAG_KEY);
+ if (isSplitServiceTag(LANGUAGE_ID)) {
+ TagMap.Entry language = prototypeTags.getEntry(LANGUAGE_ID);
if (language != null) {
- splitService(span, language);
+ splitService(span, language.objectValue());
}
}
}
diff --git a/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java
index b01b188d512..e6d088a99dc 100644
--- a/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java
+++ b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java
@@ -376,6 +376,36 @@ void splitByTagsDecoratorComponentOverwritesCandidateSetAtConstruction() {
assertEquals("my-component", span.getServiceName());
}
+ /**
+ * Without a {@code component}, a prototype's {@code language} is re-applied after the prototype's
+ * tags are intercepted, so it wins over another split-by-tags candidate the prototype sets.
+ * Called directly: the prototype's map is intercepted in hash order, so a full application can't
+ * pin the order that needs this.
+ */
+ @Test
+ void splitByTagsLanguageIsReappliedWithoutAComponent() {
+ TagInterceptor interceptor =
+ new TagInterceptor(
+ true,
+ "my-service",
+ new HashSet<>(Arrays.asList("tenant", DDTags.LANGUAGE_TAG_KEY)),
+ new RuleFlags(),
+ false);
+ CoreTracer tracer =
+ tracerBuilder().writer(new LoggingWriter()).tagInterceptor(interceptor).build();
+ AgentSpan span = tracer.buildSpan("datadog", "some span").start();
+ span.setTag("tenant", "acme"); // as if the prototype's tenant was intercepted last
+ assertEquals("acme", span.getServiceName());
+
+ TagMap prototypeTags = TagMap.create();
+ prototypeTags.set(DDTags.LANGUAGE_TAG_KEY, DDTags.LANGUAGE_TAG_VALUE);
+ prototypeTags.set("tenant", "acme");
+ interceptor.resolveSplitByTagsPrecedence((DDSpanContext) span.spanContext(), prototypeTags);
+ span.finish();
+
+ assertEquals(DDTags.LANGUAGE_TAG_VALUE, span.getServiceName());
+ }
+
/** Separate tag writes stay last-writer-wins, whichever split-by-tags candidate they set. */
@Test
void splitByTagsSeparateWritesAreLastWriterWins() {
diff --git a/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java
new file mode 100644
index 00000000000..d9906a27606
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java
@@ -0,0 +1,150 @@
+package datadog.trace.api;
+
+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;
+
+/**
+ * Allocation per span-sized TagMap with the dense store enabled. Run with {@code -prof gc} and read
+ * {@code gc.alloc.rate.norm} (B/op): allocation is deterministic, throughput here is not tuned.
+ *
+ *
Inputs: {@code tagCount} tags per map, drawn from the slotted known tags of an HTTP server
+ * span (dense) and custom names (bucketed). {@code scenario} sets the mix: {@code custom} (all
+ * bucketed -- the baseline), {@code mixed} (about 70% slotted), {@code allSlotted}. {@code
+ * buildMapById} sets the slotted tags by id, the path instrumentation is moving to.
+ *
+ *
The flag is per JVM, so this class forks with it on. For the store-off baseline, run with
+ * {@code -jvmArgsAppend -Ddd.trace.experimental.dense.tags.enabled=false}.
+ *
+ *
Results, B/op ({@code buildMap}; {@code buildMapById} matched it in every case), Zulu 17.0.7,
+ * Apple Silicon, {@code -prof gc -f 1 -wi 1 -i 2}, 2026-10-09:
+ *
+ *
Custom tags are unchanged. With the store on, a map of up to 12 slotted tags costs the same
+ * (the dense arrays start at 12), and {@code buildAndForEach} adds a flat 16 B/op (one reused
+ * flyweight), not a per-tag Entry.
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Warmup(iterations = 2, time = 2)
+@Measurement(iterations = 3, time = 2)
+@Fork(
+ value = 1,
+ jvmArgsAppend = {"-Ddd.trace.experimental.dense.tags.enabled=true"})
+@Threads(1)
+public class DenseStoreAllocBenchmark {
+ static final long[] SLOTTED_IDS = {
+ KnownTags.COMPONENT_ID,
+ KnownTags.SPAN_KIND_ID,
+ KnownTags.SERVICE_ID,
+ KnownTags.HTTP_METHOD_ID,
+ KnownTags.HTTP_URL_ID,
+ KnownTags.HTTP_ROUTE_ID,
+ KnownTags.HTTP_STATUS_CODE_ID,
+ KnownTags.HTTP_HOSTNAME_ID,
+ KnownTags.HTTP_USERAGENT_ID,
+ KnownTags.NETWORK_PROTOCOL_VERSION_ID,
+ KnownTags.DD_INTEGRATION_ID,
+ KnownTags.ERROR_TYPE_ID,
+ };
+
+ @Param({"custom", "mixed", "allSlotted"})
+ String scenario;
+
+ @Param({"7", "12"})
+ int tagCount;
+
+ private String[] names;
+ private long[] ids; // 0 for a custom tag
+ private String[] values;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ for (long id : SLOTTED_IDS) {
+ if (!KnownTagCodec.hasSlot(id)) {
+ throw new IllegalStateException(KnownTagCodec.nameOf(id) + " has no dense-store slot");
+ }
+ }
+ int slotted;
+ if ("allSlotted".equals(scenario)) {
+ slotted = tagCount;
+ } else if ("mixed".equals(scenario)) {
+ slotted = (tagCount * 7) / 10;
+ } else {
+ slotted = 0;
+ }
+ this.names = new String[tagCount];
+ this.ids = new long[tagCount];
+ this.values = new String[tagCount];
+ for (int i = 0; i < tagCount; i++) {
+ if (i < slotted) {
+ this.ids[i] = SLOTTED_IDS[i];
+ this.names[i] = KnownTagCodec.nameOf(SLOTTED_IDS[i]);
+ } else {
+ this.names[i] = "custom.tag." + i;
+ }
+ this.values[i] = "value-" + i;
+ }
+ }
+
+ @Benchmark
+ public TagMap buildMap() {
+ TagMap m = TagMap.create();
+ for (int i = 0; i < tagCount; i++) {
+ m.set(names[i], values[i]);
+ }
+ return m;
+ }
+
+ @Benchmark
+ public TagMap buildMapById() {
+ TagMap m = TagMap.create();
+ for (int i = 0; i < tagCount; i++) {
+ long id = ids[i];
+ if (id != 0) {
+ m.set(id, values[i]);
+ } else {
+ m.set(names[i], values[i]);
+ }
+ }
+ return m;
+ }
+
+ @Benchmark
+ public void buildAndForEach(Blackhole bh) {
+ TagMap m = buildMap();
+ m.forEach(reader -> bh.consume(reader.objectValue()));
+ bh.consume(m);
+ }
+
+ @Benchmark
+ public void buildAndIterate(Blackhole bh) {
+ TagMap m = buildMap();
+ for (TagMap.EntryReader reader : m) {
+ bh.consume(reader.objectValue());
+ }
+ bh.consume(m);
+ }
+}
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 3471750c347..bd1329223a9 100644
--- a/internal-api/src/main/java/datadog/trace/api/Config.java
+++ b/internal-api/src/main/java/datadog/trace/api/Config.java
@@ -703,6 +703,7 @@
import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_PAYLOAD_TAGGING_SERVICES;
import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING;
import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING;
+import static datadog.trace.api.config.TracerConfig.TRACE_DENSE_TAGS_ENABLED;
import static datadog.trace.api.config.TracerConfig.TRACE_EXPERIMENTAL_FEATURES_ENABLED;
import static datadog.trace.api.config.TracerConfig.TRACE_GIT_METADATA_ENABLED;
import static datadog.trace.api.config.TracerConfig.TRACE_HTTP_CLIENT_ERROR_STATUSES;
@@ -1462,6 +1463,7 @@ public static String getHostName() {
private final boolean jdkSocketEnabled;
private final boolean spanBuilderReuseEnabled;
+ private final boolean traceDenseTagsEnabled;
private final int tagNameUtf8CacheSize;
private final int tagValueUtf8CacheSize;
private final int stackTraceLengthLimit;
@@ -3453,6 +3455,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.tagNameUtf8CacheSize =
Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0);
this.tagValueUtf8CacheSize =
@@ -5430,6 +5433,10 @@ public boolean isJdkSocketEnabled() {
return jdkSocketEnabled;
}
+ public boolean isTraceDenseTagsEnabled() {
+ return traceDenseTagsEnabled;
+ }
+
public boolean isSpanBuilderReuseEnabled() {
return spanBuilderReuseEnabled;
}
diff --git a/internal-api/src/main/java/datadog/trace/api/DenseTagMap.java b/internal-api/src/main/java/datadog/trace/api/DenseTagMap.java
new file mode 100644
index 00000000000..0698e139bd4
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/DenseTagMap.java
@@ -0,0 +1,620 @@
+package datadog.trace.api;
+
+import datadog.trace.api.function.TriConsumer;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import javax.annotation.Nonnull;
+
+/**
+ * The dense/hash hybrid {@link TagMap}: known tags are stored densely, by id, with no per-tag
+ * {@link TagMap.Entry}; custom tags stay in the buckets. Used when the dense store is on; see
+ * {@link TagMapFactory}.
+ *
+ *
Each routing method handles a dense id here and defers everything else to {@link TagMap}'s
+ * hash-only behavior. Every map in a JVM is of the one implementation, so a parent is always a
+ * {@code DenseTagMap} too.
+ */
+final class DenseTagMap extends TagMap {
+
+ /*
+ * Dense known-tag store (experimental; see TagMap.DenseStore.ENABLED, default off).
+ *
+ * With the flag on, every known tag is NOT stored as a bucket Entry. Its value lives in the
+ * insertion-ordered parallel arrays knownIds/knownValues instead, with no per-tag Entry object:
+ * that is the allocation win. The registry colors the tags of each span type into distinct slots
+ * (KnownTagCodec.slotOf); a known tag on no modeled span type has NO_SLOT, which shares occupancy
+ * bit 63. Custom tags stay in the buckets, exactly as before.
+ *
+ * Presence: knownOccupancy holds 1L << slot for every dense id ever added. A clear bit means the
+ * tag is definitely absent, so an append or a miss skips the scan. A set bit only means "maybe":
+ * the knownIds scan is authoritative, so two tags sharing a slot in one map (legal -- tags from
+ * span types that never co-occur can still both be set) still work. Bits are never cleared on
+ * remove; a stale bit costs a scan, never a wrong answer. knownTraceLevel records whether any
+ * trace-level id was added (trace and span tags reuse slot numbers), with the same superset rule.
+ *
+ * Disjointness: whether an id routes dense depends only on the id and the global flag, so in a
+ * given JVM a tag is always dense or always bucketed -- never both, in any map. Read-through
+ * therefore shadows within a region: an ancestor's dense value can only be shadowed by a nearer
+ * level's dense value of the same id (parentDenseVisible), an ancestor's bucket entry only by a
+ * nearer bucket entry (parentEntryVisible, unchanged). Tombstones (removedFromParent) are keyed by
+ * canonical name and apply to both regions.
+ *
+ * size counts bucket entries only; knownCount counts dense entries; the local total is
+ * size + knownCount. Buckets start on the shared EMPTY_BUCKETS and are copied on the first bucket write, so an all-dense map allocates no bucket array.
+ *
+ * Iteration (iterator(), forEach) emits a dense entry through a reused EntryReadingHelper
+ * flyweight: valid only until the next element, so call .entry() to retain one. Point reads
+ * (getEntry) materialize a fresh Entry.
+ */
+
+ /** Shared all-null buckets (length 16) that maps start on. */
+ private static final Object[] EMPTY_BUCKETS = new Object[1 << 4];
+
+ /**
+ * Initial capacity of the dense arrays when no {@link SizingHint} presized them; the largest span
+ * type resolves about this many tags.
+ */
+ static final int KNOWN_INIT_CAP = 12;
+
+ /**
+ * Shared empty dense arrays a map starts on (and returns to on clear), so that one full-capacity
+ * test covers a map's first dense tag too. Never written: a full map spills to its own arrays.
+ */
+ private static final long[] EMPTY_KNOWN_IDS = new long[0];
+
+ private static final Object[] EMPTY_KNOWN_VALUES = new Object[0];
+
+ private long[] knownIds = EMPTY_KNOWN_IDS;
+ private Object[] knownValues = EMPTY_KNOWN_VALUES;
+ private int knownCount;
+ private long knownOccupancy;
+ private boolean knownTraceLevel;
+
+ @Override
+ TagMapFactory factory() {
+ return DenseTagMapFactory.INSTANCE;
+ }
+
+ DenseTagMap(TagMap parent) {
+ // start on the shared empty buckets; the first bucket write copies them
+ super(parent, EMPTY_BUCKETS);
+ }
+
+ /**
+ * Like {@link #DenseTagMap(TagMap)}, with the dense arrays presized from {@code hint} (see {@link
+ * SizingHintTable}). A {@code null} hint, or one not yet seeded (a racy reader can see 0), keeps
+ * the shared empty arrays, so the first dense tag allocates at {@link #KNOWN_INIT_CAP}.
+ */
+ DenseTagMap(TagMap parent, SizingHint hint) {
+ super(parent, EMPTY_BUCKETS);
+ if (hint != null) {
+ int cap = hint.size; // read the racy field once
+ if (cap > 0) {
+ this.knownIds = new long[cap];
+ this.knownValues = new Object[cap];
+ }
+ }
+ }
+
+ DenseTagMap(Object[] buckets, int size) {
+ super(buckets, size);
+ }
+
+ @Override
+ int knownCount() {
+ return this.knownCount;
+ }
+
+ @Override
+ public void recordSize(SizingHint hint) {
+ // Capped hints (the heterogeneous shared overflow) are fixed -- don't let unlike sharers grow
+ // them. Best-effort racy max (see TagMap#recordSize); benign plain-int write.
+ if (hint != null && !hint.capped) {
+ int count = this.knownCount;
+ if (count > hint.size) {
+ hint.size = count;
+ }
+ }
+ }
+
+ @Override
+ int countDenseVisibleIn(TagMap ancestor) {
+ DenseTagMap dense = (DenseTagMap) ancestor;
+ long[] ancestorIds = dense.knownIds;
+ int count = 0;
+ for (int i = 0; i < dense.knownCount; ++i) {
+ if (this.parentDenseVisible(ancestorIds[i], dense)) count++;
+ }
+ return count;
+ }
+
+ @Override
+ Entry getLocalEntry(String tag, long tagHash) {
+ long denseId = denseIdOf(tagHash);
+ if (denseId != 0) {
+ int i = this.knownIndexOf(denseId);
+ return i < 0 ? null : Entry.newAnyEntry(denseId, this.knownValues[i]);
+ }
+ return super.getLocalEntry(tag, tagHash);
+ }
+
+ @Override
+ Object getKnownValue(long tagId) {
+ int i = this.knownIndexOf(tagId);
+ return i >= 0 ? this.knownValues[i] : super.getKnownValue(tagId);
+ }
+
+ /** The buckets to write to, copying the shared empty buckets on the first write. */
+ @Override
+ Object[] writableBuckets() {
+ Object[] b = this.buckets;
+ if (b == EMPTY_BUCKETS) {
+ b = new Object[1 << 4];
+ this.buckets = b;
+ }
+ return b;
+ }
+
+ @Override
+ Entry putEntry(@Nonnull Entry newEntry) {
+ long denseId = denseIdOf(newEntry.tagHash);
+ if (denseId != 0) {
+ return this.putDenseLocal(denseId, newEntry.objectValue());
+ }
+ return super.putEntry(newEntry);
+ }
+
+ @Override
+ public void set(long tagId, @Nonnull Object value) {
+ this.storeDenseLocal(requireKnownId(tagId), value);
+ }
+
+ @Override
+ public void set(long tagId, @Nonnull CharSequence value) {
+ this.storeDenseLocal(requireKnownId(tagId), value);
+ }
+
+ @Override
+ public void set(long tagId, boolean value) {
+ this.storeDenseLocal(requireKnownId(tagId), Boolean.valueOf(value));
+ }
+
+ @Override
+ public void set(long tagId, int value) {
+ this.storeDenseLocal(requireKnownId(tagId), Integer.valueOf(value));
+ }
+
+ @Override
+ public void set(long tagId, long value) {
+ this.storeDenseLocal(requireKnownId(tagId), Long.valueOf(value));
+ }
+
+ @Override
+ public void set(long tagId, float value) {
+ this.storeDenseLocal(requireKnownId(tagId), Float.valueOf(value));
+ }
+
+ @Override
+ public void set(long tagId, double value) {
+ this.storeDenseLocal(requireKnownId(tagId), Double.valueOf(value));
+ }
+
+ @Override
+ void setKnownTrusted(long tagId, @Nonnull Object value) {
+ this.storeDenseTrusted(tagId, value);
+ }
+
+ @Override
+ void setKnownTrusted(long tagId, @Nonnull CharSequence value) {
+ this.storeDenseTrusted(tagId, value);
+ }
+
+ @Override
+ void setKnownTrusted(long tagId, boolean value) {
+ this.storeDenseTrusted(tagId, Boolean.valueOf(value));
+ }
+
+ @Override
+ void setKnownTrusted(long tagId, int value) {
+ this.storeDenseTrusted(tagId, Integer.valueOf(value));
+ }
+
+ @Override
+ void setKnownTrusted(long tagId, long value) {
+ this.storeDenseTrusted(tagId, Long.valueOf(value));
+ }
+
+ @Override
+ void setKnownTrusted(long tagId, float value) {
+ this.storeDenseTrusted(tagId, Float.valueOf(value));
+ }
+
+ @Override
+ void setKnownTrusted(long tagId, double value) {
+ this.storeDenseTrusted(tagId, Double.valueOf(value));
+ }
+
+ @Override
+ void putAllMerge(TagMap that) {
+ DenseTagMap dense = (DenseTagMap) that;
+ // incoming dense values clobber existing ones, as incoming bucket entries do
+ for (int i = 0; i < dense.knownCount; ++i) {
+ this.storeKnownValue(dense.knownIds[i], dense.knownValues[i]);
+ }
+ super.putAllMerge(that);
+ }
+
+ @Override
+ void putAllIntoEmptyMap(TagMap that) {
+ DenseTagMap dense = (DenseTagMap) that;
+ if (dense.knownCount > 0) {
+ // dense values are immutable objects, so the copy can share them. Into this map's own arrays
+ // when they already fit -- a map presized from a SizingHint -- else sized to what the source
+ // holds, not its capacity, so a source that grew to the bound doesn't hand every copy a
+ // full-bound array.
+ int count = dense.knownCount;
+ if (this.knownIds.length < count) {
+ int cap = Math.max(count, KNOWN_INIT_CAP);
+ this.knownIds = new long[cap];
+ this.knownValues = new Object[cap];
+ }
+ System.arraycopy(dense.knownIds, 0, this.knownIds, 0, count);
+ System.arraycopy(dense.knownValues, 0, this.knownValues, 0, count);
+ this.knownCount = dense.knownCount;
+ this.knownOccupancy = dense.knownOccupancy;
+ this.knownTraceLevel = dense.knownTraceLevel;
+ }
+ if (that.size == 0) {
+ return; // no bucket entries; stay on the shared empty buckets
+ }
+ super.putAllIntoEmptyMap(that);
+ }
+
+ @Override
+ public void fillMap(Map super String, Object> map) {
+ super.fillMap(map);
+ if (this.parent == null) { // with a parent, super walked the visible union, dense included
+ for (int i = 0; i < this.knownCount; ++i) {
+ map.put(KnownTagCodec.nameOf(this.knownIds[i]), this.knownValues[i]);
+ }
+ }
+ }
+
+ @Override
+ public void fillStringMap(Map super String, ? super String> stringMap) {
+ super.fillStringMap(stringMap);
+ if (this.parent == null) { // see fillMap
+ for (int i = 0; i < this.knownCount; ++i) {
+ stringMap.put(
+ KnownTagCodec.nameOf(this.knownIds[i]),
+ TagValueConversions.toString(this.knownValues[i]));
+ }
+ }
+ }
+
+ @Override
+ Entry removeLocal(String tag, long tagHash) {
+ long denseId = denseIdOf(tagHash);
+ if (denseId != 0) {
+ return this.removeKnown(denseId);
+ }
+ return super.removeLocal(tag, tagHash);
+ }
+
+ @Override
+ public void forEach(Consumer super TagMap.EntryReader> consumer) {
+ if (this.knownCount > 0) {
+ // dense values go through one reused flyweight -- no Entry per tag
+ EntryReadingHelper reader = new EntryReadingHelper();
+ for (int i = 0; i < this.knownCount; ++i) {
+ reader.set(this.knownIds[i], this.knownValues[i]);
+ consumer.accept(reader);
+ }
+ }
+ super.forEach(consumer);
+ }
+
+ @Override
+ public void forEach(T thisObj, BiConsumer consumer) {
+ if (this.knownCount > 0) {
+ EntryReadingHelper reader = new EntryReadingHelper();
+ for (int i = 0; i < this.knownCount; ++i) {
+ reader.set(this.knownIds[i], this.knownValues[i]);
+ consumer.accept(thisObj, reader);
+ }
+ }
+ super.forEach(thisObj, consumer);
+ }
+
+ @Override
+ public void forEach(
+ T thisObj, U otherObj, TriConsumer consumer) {
+ if (this.knownCount > 0) {
+ EntryReadingHelper reader = new EntryReadingHelper();
+ for (int i = 0; i < this.knownCount; ++i) {
+ reader.set(this.knownIds[i], this.knownValues[i]);
+ consumer.accept(thisObj, otherObj, reader);
+ }
+ }
+ super.forEach(thisObj, otherObj, consumer);
+ }
+
+ @Override
+ void forEachDenseVisibleIn(TagMap ancestor, Consumer super TagMap.EntryReader> consumer) {
+ DenseTagMap dense = (DenseTagMap) ancestor;
+ EntryReadingHelper reader = null;
+ for (int i = 0; i < dense.knownCount; ++i) {
+ long denseId = dense.knownIds[i];
+ if (this.parentDenseVisible(denseId, dense)) {
+ if (reader == null) reader = new EntryReadingHelper();
+ reader.set(denseId, dense.knownValues[i]);
+ consumer.accept(reader);
+ }
+ }
+ }
+
+ @Override
+ void forEachDenseVisibleIn(
+ TagMap ancestor, T thisObj, BiConsumer consumer) {
+ DenseTagMap dense = (DenseTagMap) ancestor;
+ EntryReadingHelper reader = null;
+ for (int i = 0; i < dense.knownCount; ++i) {
+ long denseId = dense.knownIds[i];
+ if (this.parentDenseVisible(denseId, dense)) {
+ if (reader == null) reader = new EntryReadingHelper();
+ reader.set(denseId, dense.knownValues[i]);
+ consumer.accept(thisObj, reader);
+ }
+ }
+ }
+
+ @Override
+ void forEachDenseVisibleIn(
+ TagMap ancestor,
+ T thisObj,
+ U otherObj,
+ TriConsumer consumer) {
+ DenseTagMap dense = (DenseTagMap) ancestor;
+ EntryReadingHelper reader = null;
+ for (int i = 0; i < dense.knownCount; ++i) {
+ long denseId = dense.knownIds[i];
+ if (this.parentDenseVisible(denseId, dense)) {
+ if (reader == null) reader = new EntryReadingHelper();
+ reader.set(denseId, dense.knownValues[i]);
+ consumer.accept(thisObj, otherObj, reader);
+ }
+ }
+ }
+
+ @Override
+ boolean readDenseIfVisible(TagMap level, int index, EntryReadingHelper reader) {
+ DenseTagMap dense = (DenseTagMap) level;
+ long denseId = dense.knownIds[index];
+ if (dense == this || this.parentDenseVisible(denseId, dense)) {
+ reader.set(denseId, dense.knownValues[index]);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ void clearLocal() {
+ this.buckets = EMPTY_BUCKETS; // never write to the shared empty buckets
+ this.size = 0;
+ this.knownIds = EMPTY_KNOWN_IDS;
+ this.knownValues = EMPTY_KNOWN_VALUES;
+ this.knownCount = 0;
+ this.knownOccupancy = 0L;
+ this.knownTraceLevel = false;
+ }
+
+ @Override
+ void checkIntegrity() {
+ // dense store: every id slotted and unique, its slot bit set, the count within bounds
+ if (this.knownCount > 0) {
+ if (this.knownIds == null || this.knownCount > this.knownIds.length) {
+ throw new IllegalStateException("incorrect known count");
+ }
+ for (int i = 0; i < this.knownCount; ++i) {
+ long denseId = this.knownIds[i];
+ if (denseIdOf(denseId) == 0) {
+ throw new IllegalStateException("dense id that is not a known tag");
+ }
+ if ((this.knownOccupancy & slotBit(denseId)) == 0) {
+ throw new IllegalStateException("dense id missing from occupancy");
+ }
+ for (int j = i + 1; j < this.knownCount; ++j) {
+ if (this.knownIds[j] == denseId) {
+ throw new IllegalStateException("duplicate dense id");
+ }
+ }
+ }
+ }
+ // disjointness: no bucket entry may be one that routes dense
+ for (EntryReader entry : this) {
+ if (entry instanceof Entry && denseIdOf(((Entry) entry).tagHash) != 0) {
+ throw new IllegalStateException("dense-routed tag stored in a bucket: " + entry.tag());
+ }
+ }
+ super.checkIntegrity();
+ }
+
+ @Override
+ String toInternalString() {
+ StringBuilder ledger = new StringBuilder(super.toInternalString());
+ for (int i = 0; i < this.knownCount; ++i) {
+ ledger
+ .append("[dense ")
+ .append(i)
+ .append("] = {")
+ .append(KnownTagCodec.nameOf(this.knownIds[i]))
+ .append('=')
+ .append(this.knownValues[i])
+ .append("}\n");
+ }
+ return ledger.toString();
+ }
+
+ /**
+ * The id to store densely for {@code tagHash} (an Entry's tag hash, or a tag id), or 0 for a
+ * custom tag, which stays in the buckets. Every known tag is dense, slotted or not, so an
+ * id-keyed write never reaches the hash path.
+ */
+ private static long denseIdOf(long tagHash) {
+ // a known tag's hash is its id (upper 32 bits nonzero); a custom tag's upper 32 bits are zero
+ return (tagHash >>> 32) != 0 ? tagHash : 0L;
+ }
+
+ /**
+ * A dense id's occupancy bit. The bit only rules a tag out quickly; the scan decides, so ids may
+ * share one. No branch for a tag without a slot: a long shift uses only the low 6 bits of its
+ * count, so {@code NO_SLOT} (0xFFFF) lands on bit 63, which the generator reserves (real slots
+ * stay below 63) and every unslotted tag shares.
+ */
+ static long slotBit(long denseId) {
+ return 1L << KnownTagCodec.slotOf(denseId);
+ }
+
+ /**
+ * Index of {@code denseId} in the dense arrays, or -1; skips the scan when its slot bit is clear.
+ */
+ private int knownIndexOf(long denseId) {
+ if ((this.knownOccupancy & slotBit(denseId)) == 0) {
+ return -1; // definitely absent
+ }
+ long[] ids = this.knownIds;
+ for (int i = 0; i < this.knownCount; ++i) {
+ if (ids[i] == denseId) return i;
+ }
+ return -1;
+ }
+
+ /**
+ * Stores a dense value, replacing it in place when present or appending it, and returns the prior
+ * value, or null when there was none. Dense values are never null. Builds no Entry, so a caller
+ * that discards the result allocates nothing, whether or not the JIT inlines this.
+ */
+ private Object storeKnownValue(long denseId, Object value) {
+ int i = this.knownIndexOf(denseId);
+ if (i >= 0) {
+ Object prior = this.knownValues[i];
+ this.knownValues[i] = value;
+ return prior;
+ }
+ int idx = this.knownCount;
+ if (idx == this.knownIds.length) {
+ this.spillKnown();
+ }
+ this.knownIds[idx] = denseId;
+ this.knownValues[idx] = value;
+ this.knownCount = idx + 1;
+ this.knownOccupancy |= slotBit(denseId);
+ this.knownTraceLevel |= KnownTagCodec.isTraceLevel(denseId);
+ return null;
+ }
+
+ /**
+ * {@code tagId}, when it names a known tag; the checked id-keyed setters reject anything else.
+ */
+ private static long requireKnownId(long tagId) {
+ if (!KnownTagCodec.isKnown(tagId)) {
+ throw new IllegalArgumentException("not a known tag id: " + Long.toHexString(tagId));
+ }
+ return tagId;
+ }
+
+ /**
+ * Makes room for one more dense tag. A map's first dense tag on the shared empty arrays allocates
+ * its own at {@link #KNOWN_INIT_CAP} -- a plain allocation, since on fresh unhinted spans this
+ * runs once per span and is compiled inline. A map presized from a {@link SizingHint} skips that
+ * step. A full map grows in {@link #growKnown}.
+ */
+ private void spillKnown() {
+ if (this.knownIds.length == 0) {
+ this.knownIds = new long[KNOWN_INIT_CAP];
+ this.knownValues = new Object[KNOWN_INIT_CAP];
+ } else {
+ this.growKnown();
+ }
+ }
+
+ /**
+ * Grows full dense arrays by doubling, never below one more slot than they hold, and bounded by
+ * {@link KnownTagCodec#serialLimit()}: a map holds each known tag at most once, so that many
+ * entries always suffice. Doubling rather than jumping straight to the bound keeps a span that
+ * outgrows a small {@link SizingHint} seed from paying for a full-bound array; the hint then
+ * tunes up so later spans of the operation are presized. Rare, so the copying stays out of the
+ * write.
+ */
+ private void growKnown() {
+ int length = this.knownIds.length;
+ int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1);
+ this.knownIds = Arrays.copyOf(this.knownIds, newCap);
+ this.knownValues = Arrays.copyOf(this.knownValues, newCap);
+ }
+
+ /** Clears a read-through tombstone for a dense tag being set; out of line, as it is rare. */
+ private void clearDenseTombstone(long denseId) {
+ this.removedFromParent.remove(KnownTagCodec.nameOf(denseId));
+ }
+
+ /**
+ * Local dense write: clears any tombstone for the tag, then stores the value, returning the prior
+ * value or null. The {@code set(long, ...)} methods use this; it builds no Entry.
+ */
+ private Object storeDenseLocal(long denseId, Object value) {
+ this.checkWriteAccess();
+ return this.storeDenseTrusted(denseId, value);
+ }
+
+ /** {@link #storeDenseLocal} without the frozen check, for trusted writes. */
+ private Object storeDenseTrusted(long denseId, Object value) {
+ if (this.removedFromParent != null) {
+ this.clearDenseTombstone(denseId);
+ }
+ return this.storeKnownValue(denseId, value);
+ }
+
+ /** {@link #storeDenseLocal}, returning the prior value as an Entry, per the Map contract. */
+ private Entry putDenseLocal(long denseId, Object value) {
+ Object prior = this.storeDenseLocal(denseId, value);
+ return prior == null ? null : Entry.newAnyEntry(denseId, prior);
+ }
+
+ /** Removes a dense value (swap with the last), returning it as an Entry, or null. */
+ private Entry removeKnown(long denseId) {
+ int i = this.knownIndexOf(denseId);
+ if (i < 0) return null;
+ Object prior = this.knownValues[i];
+ int last = --this.knownCount;
+ this.knownIds[i] = this.knownIds[last];
+ this.knownValues[i] = this.knownValues[last];
+ this.knownIds[last] = 0L;
+ this.knownValues[last] = null;
+ // knownOccupancy is not cleared: another present id may share the slot bit.
+ return Entry.newAnyEntry(denseId, prior);
+ }
+
+ /**
+ * Whether an ancestor's dense value for {@code denseId} (held at {@code fromAncestor}) is visible
+ * from this leaf: not shadowed by a nearer level's dense value of the same id, and not tombstoned
+ * by a nearer level. The dense counterpart of {@code parentEntryVisible}; by disjointness no
+ * bucket check is needed. A nearer level with no trace-level ids cannot shadow a trace-level one.
+ */
+ private boolean parentDenseVisible(long denseId, TagMap fromAncestor) {
+ boolean traceLevel = KnownTagCodec.isTraceLevel(denseId);
+ String tag = null; // resolved lazily, only if a nearer level carries tombstones
+ for (DenseTagMap nearer = this; nearer != fromAncestor; nearer = (DenseTagMap) nearer.parent) {
+ if ((!traceLevel || nearer.knownTraceLevel) && nearer.knownIndexOf(denseId) >= 0) {
+ return false; // shadowed by a nearer dense value
+ }
+ if (nearer.removedFromParent != null) {
+ if (tag == null) tag = KnownTagCodec.nameOf(denseId);
+ if (nearer.removedFromParent.contains(tag)) return false; // tombstoned by a nearer level
+ }
+ }
+ return true;
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/HashTagMap.java b/internal-api/src/main/java/datadog/trace/api/HashTagMap.java
new file mode 100644
index 00000000000..aef9ad8b459
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/HashTagMap.java
@@ -0,0 +1,20 @@
+package datadog.trace.api;
+
+/**
+ * The hash-only {@link TagMap}: every tag, known or custom, is an {@link TagMap.Entry} in the
+ * buckets. Used when the dense store is off; see {@link TagMapFactory}.
+ */
+final class HashTagMap extends TagMap {
+ HashTagMap(TagMap parent) {
+ super(parent, new Object[1 << 4]);
+ }
+
+ HashTagMap(Object[] buckets, int size) {
+ super(buckets, size);
+ }
+
+ @Override
+ TagMapFactory factory() {
+ return HashTagMapFactory.INSTANCE;
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java
index c307b38ae88..62a751ecc3f 100644
--- a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java
+++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java
@@ -5,20 +5,26 @@
* the ID layout and namespace naming policy.
*
*
Datadog and OpenTelemetry names can resolve to the same ID. {@link #canonicalTagName(String)}
- * provides the Datadog key used by {@link TagMap}; the namespace readers choose output names. The
- * ID does not determine whether a tag is intercepted or where it is stored.
+ * provides the Datadog key used by {@link TagMap}; the namespace readers choose output names. An ID
+ * marks a tag that may be intercepted ({@link #INTERCEPTED}), but {@code TagInterceptor} decides
+ * whether to consume the value. The ID says nothing about where the tag is stored.
*/
public final class KnownTagCodec {
/*
- * tagId bit layout: [63-48 serialNum (16 bits, 15 used: bit 63 is kept clear)] [47-32 reserved,
- * zero] [31-0 flags]. Keeping bit 63 clear makes every id positive, so 0 (unknown) and the
- * negative lookup sentinels below can never be ids; the generator enforces it. serialNum is
- * globally unique per known tag and is the whole of the tag's identity — nameOf/
- * openTelemetryNameOf switch on it, and the generator emits each id as a literal. Bits [47-32]
- * are RESERVED and always zero here: they are the window the dense tag store uses for its
- * co-occurrence slot coordinate, which arrives with that store. Of the low 32 flag bits, bit 2 is
- * the trace/span LEVEL bit (set ⟹ trace-level), and bit 1 is the INTERCEPTED bit. Unknown (string-only) custom tags are NOT known ids — {@code keyOf} returns 0 for
- * them.
+ * tagId bit layout: [63-48 serialNum (16 bits, 15 used: bit 63 is kept clear)] [47-32 slot]
+ * [31-0 flags]. Keeping bit 63 clear makes every id positive, so 0 (unknown) and the negative
+ * lookup sentinels below can never be ids; the generator enforces it. serialNum is globally
+ * unique per known tag and is the whole of the tag's identity — nameOf/openTelemetryNameOf key on
+ * it, and the generator emits each id as a literal. Bits [47-32] hold the tag's slot ({@link
+ * #slotOf}), assigned by graph coloring so tags on one span type never share a slot, or {@link
+ * #NO_SLOT} for a tag on no modeled span type. Of the low 32 flag bits, bit 2 is the trace/span
+ * LEVEL bit (set ⟹ trace-level), bit 1 is the INTERCEPTED bit, and bit 0 is the
+ * HAS_SCOPED_ALIAS bit. Unknown (string-only) custom tags are NOT known ids — {@code keyOf}
+ * returns 0 for them.
+ *
+ *
There is one id per Datadog name. A tag the conventions declare once per direction, such as
+ * {@code peer.port}, is still one tag: only its OpenTelemetry name depends on the span's
+ * direction, so direction applies when a tag is named, never when it is stored.
*
*
The INTERCEPTED bit marks a tag TagInterceptor may route on the set-path, to a span field or
* a sampling directive instead of tag storage. It is a hint, not a decision: the interceptor's
@@ -73,16 +79,42 @@ public static boolean hasScopedAlias(long tagId) {
return (tagId & HAS_SCOPED_ALIAS) != 0L;
}
+ /** Bit position of the slot: bits [47-32] of an id. */
+ public static final int SLOT_SHIFT = 32;
+
+ /** The slot of a known tag on no modeled span type. */
+ public static final int NO_SLOT = 0xFFFF;
+
+ /**
+ * The tag's slot, or {@link #NO_SLOT}. The generator colors tags that appear together on a span
+ * type with distinct slots, so a span's slotted tags never collide: {@code 1L << slotOf(id)} is
+ * an exact presence bit for them. Every tag without a slot shares bit 63, which no real slot
+ * uses.
+ */
+ public static int slotOf(long tagId) {
+ return (int) (tagId >>> SLOT_SHIFT) & 0xFFFF;
+ }
+
+ /** True if the tag has a slot, below {@link #slotCount()}. */
+ public static boolean hasSlot(long tagId) {
+ return slotOf(tagId) != NO_SLOT;
+ }
+
+ /** Slots in use: every slotted tag's slot is below this. */
+ public static int slotCount() {
+ return KnownTags.SLOT_COUNT;
+ }
+
/** Returns the tagId with the {@link #LEVEL_TRACE} flag set. */
public static long traceLevel(long tagId) {
return tagId | LEVEL_TRACE;
}
/**
- * Builds a tagId from its {@code serialNum} (globally unique per known tag). The reserved [47-32]
- * window and the low 32 bits are zero, so the id is fully determined by the serial — the
- * generator emits it as a literal. Inverse of {@link #serialNum}. Intended for the code generator
- * and tests.
+ * Builds an id carrying only {@code serialNum}: slot 0 and no flags, so it equals a tag's real id
+ * only when that tag has neither. Enough for serial-keyed lookups such as {@link #nameOf};
+ * compare against a {@code KnownTags} constant, never against this. Inverse of {@link
+ * #serialNum}. Intended for tests and serial-keyed lookups.
*/
public static long makeTagId(int serialNum) {
return (long) serialNum << 48;
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..5189eaaa4c5
--- /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 TagMap} reads {@link #size} to size a fresh dense store and writes
+ * it back (best-effort max, see {@link TagMap#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
+ // TagMap#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 TagMap 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;
+
+ /**
+ * A fresh, uncapped hint for {@code operationName}, seeded at {@link SizingHintTable#SEED_SIZE}.
+ */
+ /**
+ * 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);
+ }
+
+ 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..84bb2199a90
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java
@@ -0,0 +1,101 @@
+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 TagMap} 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 TagMap.DenseStore#ENABLED}); 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, DenseTagMap'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 = DenseTagMap.KNOWN_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 (!TagMap.DenseStore.ENABLED) {
+ 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;
+ // get() then getOrCreate() would probe twice on a miss; getOrCreate() already returns a hit
+ // directly, and returns null only when the fixed lane is full and the key is absent.
+ final SizingHint hint = lane.getOrCreate(key, SizingHint::new);
+ if (hint != null) {
+ return hint;
+ }
+ return entrySpan ? Lanes.ENTRY_OVERFLOW : Lanes.CHILD_OVERFLOW;
+ }
+}
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 9553fb2a8f2..79388d0707f 100644
--- a/internal-api/src/main/java/datadog/trace/api/TagMap.java
+++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java
@@ -48,12 +48,12 @@
*
adaptive collision
*
*/
-public final class TagMap implements Map, Iterable {
+public abstract class TagMap implements Map, Iterable {
/** Immutable empty TagMap - similar to {@link Collections#emptyMap()} */
// Frozen view over a length-1 array: bucket masking needs a power-of-two array length (size 0
- // would fail with ArrayIndexOutOfBoundsException, size 1 works), and the private constructor
- // reads no statics, so this is safe to build directly during TagMap's .
- public static final TagMap EMPTY = new TagMap(new Object[1], 0);
+ // would fail with ArrayIndexOutOfBoundsException, size 1 works). Built by the factory, so it is
+ // the one TagMap implementation this JVM loads; the constructor reads no TagMap statics.
+ public static final TagMap EMPTY = TagMapFactory.Default.INSTANCE.newEmpty();
// Sentinel for an ID not yet resolved by the reused EntryReadingHelper. Zero denotes an unknown
// tag, so it cannot represent "not computed".
@@ -76,11 +76,20 @@ public static final TagMap fromMapImmutable(@Nonnull Map map) {
}
public static final TagMap create() {
- return new TagMap();
+ return TagMapFactory.Default.INSTANCE.create(null);
}
public static final TagMap create(int size) {
- return new TagMap();
+ return TagMapFactory.Default.INSTANCE.create(null);
+ }
+
+ /**
+ * Creates a mutable TagMap whose dense store is sized per-operation from {@code hint} (see {@link
+ * SizingHintTable}). A {@code null} hint behaves exactly like {@link #create()}; the hash-only
+ * implementation ignores the hint.
+ */
+ public static final TagMap create(@Nullable SizingHint hint) {
+ return TagMapFactory.Default.INSTANCE.create(null, hint);
}
/**
@@ -98,6 +107,34 @@ public static final TagMap create(int size) {
* local miss/removal for no benefit.
*/
public static final TagMap createFromParent(TagMap parent) {
+ return createFromParent(parent, null);
+ }
+
+ /**
+ * Read-through variant of {@link #create(SizingHint)}: reads through to {@code parent} on local
+ * misses AND sizes the LOCAL dense store from {@code hint}. The two are orthogonal — the parent
+ * supplies inherited reads, while locally-set known tags live in this map's own dense array,
+ * whose initial capacity the hint tunes. So the common child / shared-parent path (a non-null
+ * read-through parent) still gets per-operation sizing; without this the local store would fall
+ * back to the fixed default. A {@code null} hint behaves exactly like {@link
+ * #createFromParent(TagMap)}.
+ */
+ public static final TagMap createFromParent(TagMap parent, @Nullable SizingHint hint) {
+ // Calls the JVM's factory directly, a static final constant receiver, rather than through the
+ // factory-taking overload below: a factory parameter costs the span-creation path an inlining
+ // level and the constant receiver.
+ return TagMapFactory.Default.INSTANCE.create(readThroughParent(parent), hint);
+ }
+
+ /**
+ * {@link #createFromParent(TagMap, SizingHint)} through {@code factory}; tests pick the store.
+ */
+ static TagMap createFromParent(TagMapFactory factory, TagMap parent, @Nullable SizingHint hint) {
+ return factory.create(readThroughParent(parent), hint);
+ }
+
+ /** {@code parent} checked for read-through: it must be frozen, and an empty one is dropped. */
+ private static TagMap readThroughParent(TagMap parent) {
if (parent != null) {
if (!parent.frozen) {
throw new IllegalStateException("read-through parent must be frozen");
@@ -107,12 +144,18 @@ public static final TagMap createFromParent(TagMap parent) {
// parent keeps isEmpty()'s no-tombstone fast path valid -- an attached parent always
// contributes at least one visible entry.
if (parent.isEmpty()) {
- parent = null;
+ return null;
}
}
- return new TagMap(parent);
+ return parent;
}
+ /**
+ * The factory for this map's own implementation, so that instance methods that make a new map
+ * ({@link #copy}) keep its type rather than taking the JVM's default store.
+ */
+ abstract TagMapFactory factory();
+
/** Creates a new TagMap.Ledger */
public static final Ledger ledger() {
return new Ledger();
@@ -1206,8 +1249,13 @@ public EntryChange next() {
* removed from the collision chain.
*/
- final Object[] buckets;
- private int size;
+ /** Holder for the dense-store flag, read once from Config so the JIT folds it. */
+ static final class DenseStore {
+ static final boolean ENABLED = Config.get().isTraceDenseTagsEnabled();
+ }
+
+ Object[] buckets;
+ int size;
private boolean frozen;
/**
@@ -1229,27 +1277,24 @@ public EntryChange next() {
* and rare, so it costs a lazy allocation on removal rather than complicating the hot bucket
* code.
*/
- private Set removedFromParent;
-
- public TagMap() {
- this((TagMap) null);
- }
+ Set removedFromParent;
/**
* Fresh mutable map that reads through to {@code parent} (may be null). The parent is set here at
* construction and never re-pointed (only detached to null by {@link #clear()}), so read-through
- * optimizations can treat it as fixed.
+ * optimizations can treat it as fixed. {@code buckets} is the starting bucket array (a power of
+ * two in length); a subclass may pass a shared empty one that {@link #writableBuckets} copies.
*/
- private TagMap(TagMap parent) {
+ TagMap(TagMap parent, Object[] buckets) {
// needs to be a power of 2 for bucket masking calculation to work as intended
- this.buckets = new Object[1 << 4];
+ this.buckets = buckets;
this.size = 0;
this.frozen = false;
this.parent = parent;
}
/** Used for inexpensive immutable */
- private TagMap(Object[] buckets, int size) {
+ TagMap(Object[] buckets, int size) {
this.buckets = buckets;
this.size = size;
this.frozen = true;
@@ -1259,8 +1304,9 @@ private TagMap(Object[] buckets, int size) {
@Override
public int size() {
// Exact (Map contract). Under read-through resolves the union; prefer estimateSize() for hints.
+ int local = this.size + this.knownCount(); // buckets + dense
TagMap parent = this.parent;
- return parent == null ? this.size : this.size + this.visibleParentCount();
+ return parent == null ? local : local + this.visibleParentCount();
}
/**
@@ -1270,6 +1316,7 @@ public int size() {
private int visibleParentCount() {
int count = 0;
for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ count += this.countDenseVisibleIn(ancestor);
Object[] parentBuckets = ancestor.buckets;
for (int i = 0; i < parentBuckets.length; ++i) {
Object parentBucket = parentBuckets[i];
@@ -1293,7 +1340,7 @@ private int visibleParentCount() {
@Override
public boolean isEmpty() {
// Exact (Map contract). Under read-through resolves the parent; prefer isDefinitelyEmpty().
- if (this.size != 0) {
+ if (this.size != 0 || this.knownCount() != 0) {
return false;
}
TagMap parent = this.parent;
@@ -1307,14 +1354,14 @@ public boolean isEmpty() {
// entry -> this map is non-empty.
return false;
}
- // size == 0 with tombstones (rare): empty iff every visible ancestor entry is tombstoned
+ // no local entries but tombstones (rare): empty iff every visible ancestor entry is tombstoned
return this.visibleParentCount() == 0;
}
public boolean isDefinitelyEmpty() {
// Cheap: empty iff no level in the chain holds a local entry (ignores shadowing/tombstones).
for (TagMap level = this; level != null; level = level.parent) {
- if (level.size != 0) {
+ if (level.size != 0 || level.knownCount() != 0) {
return false;
}
}
@@ -1322,14 +1369,44 @@ public boolean isDefinitelyEmpty() {
}
public int estimateSize() {
- // Upper bound: sum of every level's local size, ignoring read-through shadowing/removals.
+ // Upper bound: sum of every level's local size (buckets + dense), ignoring read-through
+ // shadowing/removals.
int total = 0;
for (TagMap level = this; level != null; level = level.parent) {
- total += level.size;
+ total += level.size + level.knownCount();
}
return total;
}
+ /** The number of dense values held locally; 0 for a map without a dense store. */
+ int knownCount() {
+ return 0;
+ }
+
+ /**
+ * Feeds this map's final dense-entry count back to {@code hint} (call at a terminal point, e.g.
+ * serialization), so future maps of the same operation are sized correctly. A no-op for a map
+ * without a dense store, and for a {@code null} hint.
+ *
+ *
Best-effort max, not a strict monotonic max: the read-compare-write is unsynchronized, so
+ * two spans of the same operation finishing concurrently can both read the same old size and the
+ * later write can lower it (a 10-tag span then a 5-tag span settles at 5). And the size is a
+ * plain field, so a fresh update isn't promptly visible to span-creation threads. Both are
+ * deliberately tolerated: a stale or lowered hint only mis-sizes a later span's dense array (one
+ * extra grow-copy), never corrupts tag data — so it stays lock- and atomic-free, and the next
+ * heavy span of the operation nudges it back up. A CAS/atomic max would buy exactness at the cost
+ * of contention on this terminal path, which isn't worth it for a sizing hint.
+ */
+ public void recordSize(@Nullable SizingHint hint) {}
+
+ /**
+ * The number of {@code ancestor}'s dense values visible from this leaf; 0 for a map without a
+ * dense store.
+ */
+ int countDenseVisibleIn(TagMap ancestor) {
+ return 0;
+ }
+
@Deprecated
@Override
public Object get(Object tag) {
@@ -1468,8 +1545,8 @@ private Entry getEntry(String canonicalTag, long tagHash) {
return parent.getEntry(canonicalTag, tagHash);
}
- /** Looks up an entry in this map's own buckets only — no read-through to the parent. */
- private Entry getLocalEntry(String tag, long tagHash) {
+ /** Looks up an entry in this map's own storage only — no read-through to the parent. */
+ Entry getLocalEntry(String tag, long tagHash) {
Object[] thisBuckets = this.buckets;
int hash = Entry.bucketHash(tagHash);
return findInBucket(thisBuckets[hash & (thisBuckets.length - 1)], hash, tag);
@@ -1517,6 +1594,11 @@ boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) {
return true;
}
+ /** The buckets to write to; a subclass starting on shared buckets copies them here. */
+ Object[] writableBuckets() {
+ return this.buckets;
+ }
+
@Deprecated
@Override
public Object put(@Nonnull String tag, Object value) {
@@ -1711,6 +1793,14 @@ public Internals internals() {
*/
@NoEscape
public static final class Internals {
+ /**
+ * Whether the store keeps a known tag's value as an object: true for the dense store. A caller
+ * holding a primitive then boxes it once and passes the box down (to the interceptor and the
+ * store alike); otherwise the hash store keeps the primitive, and the caller should not box. A
+ * static final, so the check folds.
+ */
+ public static final boolean STORES_BOXES = DenseStore.ENABLED;
+
private final TagMap map;
Internals(TagMap map) {
@@ -1719,39 +1809,87 @@ public static final class Internals {
/*
* Sets a known tag by id without validating it, unlike TagMap.set(long, ...): the caller has
- * already checked KnownTagCodec.isKnown(tagId).
+ * already checked KnownTagCodec.isKnown(tagId), and the span's map is never frozen, so the write
+ * skips the frozen check. With the dense store on, the tag goes straight to the dense arrays, so
+ * no Entry is built.
*/
public void setKnown(long tagId, @Nonnull Object value) {
- this.map.putEntry(new Entry(tagId, null, Entry.ANY, 0L, value));
+ this.map.setKnownTrusted(tagId, value);
}
public void setKnown(long tagId, @Nonnull CharSequence value) {
- this.map.putEntry(new Entry(tagId, null, EntryReader.OBJECT, 0L, value));
+ this.map.setKnownTrusted(tagId, value);
}
public void setKnown(long tagId, boolean value) {
- this.map.putEntry(
- new Entry(
- tagId, null, EntryReader.BOOLEAN, Entry.boolean2Prim(value), Boolean.valueOf(value)));
+ this.map.setKnownTrusted(tagId, value);
}
public void setKnown(long tagId, int value) {
- this.map.putEntry(new Entry(tagId, null, EntryReader.INT, Entry.int2Prim(value), null));
+ this.map.setKnownTrusted(tagId, value);
}
public void setKnown(long tagId, long value) {
- this.map.putEntry(new Entry(tagId, null, EntryReader.LONG, Entry.long2Prim(value), null));
+ this.map.setKnownTrusted(tagId, value);
}
public void setKnown(long tagId, float value) {
- this.map.putEntry(new Entry(tagId, null, EntryReader.FLOAT, Entry.float2Prim(value), null));
+ this.map.setKnownTrusted(tagId, value);
}
public void setKnown(long tagId, double value) {
- this.map.putEntry(new Entry(tagId, null, EntryReader.DOUBLE, Entry.double2Prim(value), null));
+ this.map.setKnownTrusted(tagId, value);
+ }
+
+ /**
+ * The value of a known tag, boxed if necessary, or null: {@code getEntry(tagId)}'s value,
+ * including read-through, without building an Entry for a local dense value.
+ */
+ public Object getKnownValue(long tagId) {
+ return this.map.getKnownValue(tagId);
}
}
+ /*
+ * Trusted, unchecked known-tag writes behind Internals.setKnown: no id validation and no frozen
+ * check. A subclass with a dense store overrides them to write the dense arrays.
+ */
+ void setKnownTrusted(long tagId, @Nonnull Object value) {
+ this.putEntryLocal(new Entry(tagId, null, Entry.ANY, 0L, value));
+ }
+
+ void setKnownTrusted(long tagId, @Nonnull CharSequence value) {
+ this.putEntryLocal(new Entry(tagId, null, EntryReader.OBJECT, 0L, value));
+ }
+
+ void setKnownTrusted(long tagId, boolean value) {
+ this.putEntryLocal(
+ new Entry(
+ tagId, null, EntryReader.BOOLEAN, Entry.boolean2Prim(value), Boolean.valueOf(value)));
+ }
+
+ void setKnownTrusted(long tagId, int value) {
+ this.putEntryLocal(new Entry(tagId, null, EntryReader.INT, Entry.int2Prim(value), null));
+ }
+
+ void setKnownTrusted(long tagId, long value) {
+ this.putEntryLocal(new Entry(tagId, null, EntryReader.LONG, Entry.long2Prim(value), null));
+ }
+
+ void setKnownTrusted(long tagId, float value) {
+ this.putEntryLocal(new Entry(tagId, null, EntryReader.FLOAT, Entry.float2Prim(value), null));
+ }
+
+ void setKnownTrusted(long tagId, double value) {
+ this.putEntryLocal(new Entry(tagId, null, EntryReader.DOUBLE, Entry.double2Prim(value), null));
+ }
+
+ /** {@code getEntry(tagId)}'s value, or null; see {@link Internals#getKnownValue}. */
+ Object getKnownValue(long tagId) {
+ Entry entry = this.getEntry(tagId);
+ return entry == null ? null : entry.objectValue();
+ }
+
public void set(long tagId, @Nonnull Object value) {
this.putEntry(Entry.newAnyEntry(tagId, value));
}
@@ -1816,14 +1954,18 @@ public Entry getAndSet(@Nullable Entry newEntry) {
*/
Entry putEntry(@Nonnull Entry newEntry) {
this.checkWriteAccess();
+ return this.putEntryLocal(newEntry);
+ }
+ /** {@link #putEntry} without the frozen check, for trusted writes. */
+ private Entry putEntryLocal(@Nonnull Entry newEntry) {
// Re-setting a key clears any read-through tombstone for it (the new value overrides the
// removal). Gated on the lazy field, so this is a no-op for the common no-tombstone case.
if (this.removedFromParent != null) {
this.removedFromParent.remove(newEntry.tag);
}
- Object[] thisBuckets = this.buckets;
+ Object[] thisBuckets = this.writableBuckets();
int newHash = newEntry.hash();
int bucketIndex = newHash & (thisBuckets.length - 1);
@@ -1948,15 +2090,20 @@ private void putAllOptimizedMap(TagMap that) {
that.forEach(this, (self, entry) -> self.set(entry));
return;
}
- if (this.size == 0) {
+ // empty must consider both regions: a map holding only dense values has size == 0
+ if (this.size == 0 && this.knownCount() == 0) {
this.putAllIntoEmptyMap(that);
} else {
this.putAllMerge(that);
}
}
- private void putAllMerge(TagMap that) {
- Object[] thisBuckets = this.buckets;
+ void putAllMerge(TagMap that) {
+ if (that.size == 0) {
+ return; // no bucket entries to merge; leaves shared empty buckets shared
+ }
+
+ Object[] thisBuckets = this.writableBuckets();
Object[] thatBuckets = that.buckets;
// Since TagMap-s don't support expansion, buckets are perfectly aligned
@@ -2072,8 +2219,8 @@ private void putAllMerge(TagMap that) {
/*
* Specially optimized version of putAll for the common case of destination map being empty
*/
- private void putAllIntoEmptyMap(TagMap that) {
- Object[] thisBuckets = this.buckets;
+ void putAllIntoEmptyMap(TagMap that) {
+ Object[] thisBuckets = this.writableBuckets();
Object[] thatBuckets = that.buckets;
// Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check
@@ -2204,8 +2351,8 @@ private Entry getAndRemove(String canonicalTag, long tagHash) {
return localRemoved;
}
- /** Removes an entry from this map's own buckets only — no parent/tombstone handling. */
- private Entry removeLocal(String tag, long tagHash) {
+ /** Removes an entry from this map's own storage only — no parent/tombstone handling. */
+ Entry removeLocal(String tag, long tagHash) {
Object[] thisBuckets = this.buckets;
int hash = Entry.bucketHash(tagHash);
@@ -2246,7 +2393,7 @@ public TagMap copy() {
// Construct with the same (frozen, shared) parent up front — the parent is fixed at
// construction. putAll then clones this map's own (local) buckets + size. The copy stays
// independently mutable (writes land on its local buckets, never the shared parent).
- TagMap copy = new TagMap(this.parent);
+ TagMap copy = this.factory().create(this.parent);
copy.putAllIntoEmptyMap(this);
if (this.removedFromParent != null) {
copy.removedFromParent = new HashSet<>(this.removedFromParent);
@@ -2300,6 +2447,7 @@ private void forEachParent(Consumer super TagMap.EntryReader> consumer) {
// Walk the ancestor chain, nearest first. Each entry is emitted once, by the nearest level that
// defines its key, when not shadowed by a nearer level and not tombstoned.
for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ this.forEachDenseVisibleIn(ancestor, consumer);
Object[] parentBuckets = ancestor.buckets;
for (int i = 0; i < parentBuckets.length; ++i) {
Object parentBucket = parentBuckets[i];
@@ -2346,6 +2494,7 @@ public void forEach(T thisObj, BiConsumer con
private void forEachParent(T thisObj, BiConsumer consumer) {
for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ this.forEachDenseVisibleIn(ancestor, thisObj, consumer);
Object[] parentBuckets = ancestor.buckets;
for (int i = 0; i < parentBuckets.length; ++i) {
Object parentBucket = parentBuckets[i];
@@ -2395,6 +2544,7 @@ public void forEach(
private void forEachParent(
T thisObj, U otherObj, TriConsumer consumer) {
for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ this.forEachDenseVisibleIn(ancestor, thisObj, otherObj, consumer);
Object[] parentBuckets = ancestor.buckets;
for (int i = 0; i < parentBuckets.length; ++i) {
Object parentBucket = parentBuckets[i];
@@ -2418,11 +2568,34 @@ private void forEachParent(
}
}
+ /*
+ * Emit ancestor's dense values visible from this leaf, through a reused flyweight; no-ops for a
+ * map without a dense store.
+ */
+ void forEachDenseVisibleIn(TagMap ancestor, Consumer super TagMap.EntryReader> consumer) {}
+
+ void forEachDenseVisibleIn(
+ TagMap ancestor, T thisObj, BiConsumer consumer) {}
+
+ void forEachDenseVisibleIn(
+ TagMap ancestor,
+ T thisObj,
+ U otherObj,
+ TriConsumer consumer) {}
+
+ /**
+ * Loads {@code level}'s dense value at {@code index} into {@code reader} if it is visible from
+ * this leaf ({@code level} is this map or an ancestor), returning whether it was. Only called for
+ * an index below {@code level.knownCount()}, so never for a map without a dense store.
+ */
+ boolean readDenseIfVisible(TagMap level, int index, EntryReadingHelper reader) {
+ return false;
+ }
+
public void clear() {
this.checkWriteAccess();
- Arrays.fill(this.buckets, null);
- this.size = 0;
+ this.clearLocal();
// clear() removes ALL mappings, including any inherited through read-through. Detaching the
// parent (rather than tombstoning every inherited key) is simpler and cheaper, and leaves an
// empty, parent-less map. Detach is one-way -- the parent is never re-pointed.
@@ -2430,6 +2603,12 @@ public void clear() {
this.removedFromParent = null;
}
+ /** Empties this map's own storage (not the parent link or tombstones), for {@link #clear()}. */
+ void clearLocal() {
+ Arrays.fill(this.buckets, null);
+ this.size = 0;
+ }
+
public TagMap freeze() {
this.frozen = true;
@@ -2441,7 +2620,12 @@ public boolean isFrozen() {
}
public void checkWriteAccess() {
- if (this.frozen) throw new IllegalStateException("TagMap frozen");
+ if (this.frozen) throw frozenError();
+ }
+
+ /** Built out of line, so every write's access check stays a field test and a branch. */
+ private static IllegalStateException frozenError() {
+ return new IllegalStateException("TagMap frozen");
}
void checkIntegrity() {
@@ -2612,7 +2796,13 @@ abstract static class IteratorBase {
private TagMap level;
private Object[] buckets;
- private Entry nextEntry;
+ // A bucket entry is its own retain-safe Entry; a dense value is emitted through the reused
+ // denseReader flyweight, valid only until the next element (call .entry() to retain one).
+ private EntryReader nextEntry;
+ private EntryReadingHelper denseReader;
+
+ // dense cursor for the current level; reset when moving to the next ancestor
+ private int knownIndex = 0;
private int bucketIndex = -1;
@@ -2632,9 +2822,9 @@ public final boolean hasNext() {
return this.nextEntry != null;
}
- final Entry nextEntryOrThrowNoSuchElement() {
+ final EntryReader nextEntryOrThrowNoSuchElement() {
if (this.nextEntry != null) {
- Entry nextEntry = this.nextEntry;
+ EntryReader nextEntry = this.nextEntry;
this.nextEntry = null;
return nextEntry;
}
@@ -2646,9 +2836,9 @@ final Entry nextEntryOrThrowNoSuchElement() {
}
}
- final Entry nextEntryOrNull() {
+ final EntryReader nextEntryOrNull() {
if (this.nextEntry != null) {
- Entry nextEntry = this.nextEntry;
+ EntryReader nextEntry = this.nextEntry;
this.nextEntry = null;
return nextEntry;
}
@@ -2656,8 +2846,20 @@ final Entry nextEntryOrNull() {
return this.hasNext() ? this.nextEntry : null;
}
- private final Entry advance() {
+ private final EntryReader advance() {
while (true) {
+ // the current level's dense values first, then its buckets
+ if (this.knownIndex < this.level.knownCount()) {
+ EntryReadingHelper reader = this.denseReader;
+ if (reader == null) {
+ reader = this.denseReader = new EntryReadingHelper();
+ }
+ if (this.map.readDenseIfVisible(this.level, this.knownIndex++, reader)) {
+ return reader;
+ }
+ continue; // ancestor dense value shadowed/tombstoned -> skip
+ }
+
Entry tagEntry = this.rawAdvance();
if (tagEntry != null) {
// leaf entries emit as-is; ancestor entries only if visible from the leaf -- not shadowed
@@ -2671,6 +2873,7 @@ private final Entry advance() {
// current level exhausted; advance to the next ancestor's buckets (read-through union)
if (this.level.parent != null) {
this.level = this.level.parent;
+ this.knownIndex = 0;
this.buckets = this.level.buckets;
this.bucketIndex = -1;
this.group = null;
@@ -3210,9 +3413,23 @@ public boolean isEmpty() {
@Override
public Iterator> iterator() {
- @SuppressWarnings({"rawtypes", "unchecked"})
- Iterator> iter = (Iterator) this.map.iterator();
- return iter;
+ return new EntriesIterator(this.map);
+ }
+ }
+
+ /**
+ * entrySet() yields retain-safe Map.Entry objects: a bucket entry is returned as is, a dense
+ * value is materialized into a fresh Entry.
+ */
+ static final class EntriesIterator extends IteratorBase
+ implements Iterator> {
+ EntriesIterator(TagMap map) {
+ super(map);
+ }
+
+ @Override
+ public Map.Entry next() {
+ return this.nextEntryOrThrowNoSuchElement().entry();
}
}
@@ -3308,6 +3525,14 @@ void set(String tag, Object value) {
this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access
}
+ /** A dense value: the id is already known, so it needs no lookup. */
+ void set(long tagId, Object value) {
+ this.mapEntry = null;
+ this.tag = KnownTagCodec.nameOf(tagId);
+ this.value = value;
+ this.tagId = tagId;
+ }
+
void set(Map.Entry mapEntry) {
this.mapEntry = mapEntry;
this.tag = mapEntry.getKey();
@@ -3392,7 +3617,10 @@ public Object objectValue() {
@Override
public TagMap.Entry entry() {
- return TagMap.anyEntryFor(this.tag, this.value);
+ long id = this.tagId;
+ return id != TagMap.TAG_ID_NOT_COMPUTED && id != 0
+ ? TagMap.Entry.newAnyEntry(id, this.value)
+ : TagMap.anyEntryFor(this.tag, this.value);
}
@Override
diff --git a/internal-api/src/main/java/datadog/trace/api/TagMapFactory.java b/internal-api/src/main/java/datadog/trace/api/TagMapFactory.java
new file mode 100644
index 00000000000..5d333731972
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/TagMapFactory.java
@@ -0,0 +1,108 @@
+package datadog.trace.api;
+
+/**
+ * Builds {@link TagMap}s of the one implementation this JVM uses: {@link DenseTagMap} when the
+ * dense store is on, {@link HashTagMap} otherwise. Rollout scaffolding, like the earlier
+ * legacy/optimized split: the hash-only map stays one flag away until the dense store is proven,
+ * and is then folded away.
+ *
+ *
A class, not an interface, and its implementations are only reached through {@link
+ * Default#INSTANCE}: the factory not chosen is loaded (to check the field's type) but never linked,
+ * so its methods are never verified and its map class is never loaded. With one {@code TagMap}
+ * subclass loaded, class hierarchy analysis devirtualizes every call on a {@code TagMap} without a
+ * guard.
+ *
+ *
Class initialization must work whichever of TagMap and the factories initializes first: an
+ * error in a static initializer poisons that class for the JVM's life, and a read of a class still
+ * initializing on the same thread silently sees null. So this class has no static state
+ * (initializing a subclass initializes it first, before the subclass's own {@code INSTANCE} is
+ * set), and {@link TagMap#EMPTY} is built fresh by {@link #newEmpty} rather than read from a
+ * factory's cache.
+ */
+abstract class TagMapFactory {
+ /** The JVM's store, chosen once by the dense-store flag. */
+ static final class Default {
+ static final TagMapFactory INSTANCE =
+ TagMap.DenseStore.ENABLED ? DenseTagMapFactory.INSTANCE : HashTagMapFactory.INSTANCE;
+ }
+
+ /** A fresh mutable map that reads through to {@code parent} (may be null). */
+ abstract TagMap create(TagMap parent);
+
+ /**
+ * A fresh mutable map that reads through to {@code parent} (may be null), with its dense store
+ * presized from {@code hint} (may be null; ignored by the hash-only map).
+ */
+ abstract TagMap create(TagMap parent, SizingHint hint);
+
+ /** A new frozen empty map of this store; {@link TagMap#EMPTY} is built with it. */
+ abstract TagMap newEmpty();
+
+ /**
+ * The shared frozen empty map: {@link TagMap#EMPTY} for the JVM's store, or one of this store's
+ * own for the other (only tests build maps of the store the JVM didn't choose).
+ */
+ abstract TagMap empty();
+}
+
+final class HashTagMapFactory extends TagMapFactory {
+ static final HashTagMapFactory INSTANCE = new HashTagMapFactory();
+
+ /** This store's own empty map, when it isn't the JVM's store; see {@link #empty}. */
+ static final class OwnEmpty {
+ static final TagMap EMPTY = INSTANCE.newEmpty();
+ }
+
+ private HashTagMapFactory() {}
+
+ @Override
+ TagMap create(TagMap parent) {
+ return new HashTagMap(parent);
+ }
+
+ @Override
+ TagMap create(TagMap parent, SizingHint hint) {
+ return new HashTagMap(parent); // no dense store to size
+ }
+
+ @Override
+ TagMap newEmpty() {
+ return new HashTagMap(new Object[1], 0);
+ }
+
+ @Override
+ TagMap empty() {
+ return this == Default.INSTANCE ? TagMap.EMPTY : OwnEmpty.EMPTY;
+ }
+}
+
+final class DenseTagMapFactory extends TagMapFactory {
+ static final DenseTagMapFactory INSTANCE = new DenseTagMapFactory();
+
+ /** This store's own empty map, when it isn't the JVM's store; see {@link #empty}. */
+ static final class OwnEmpty {
+ static final TagMap EMPTY = INSTANCE.newEmpty();
+ }
+
+ private DenseTagMapFactory() {}
+
+ @Override
+ TagMap create(TagMap parent) {
+ return new DenseTagMap(parent);
+ }
+
+ @Override
+ TagMap create(TagMap parent, SizingHint hint) {
+ return new DenseTagMap(parent, hint);
+ }
+
+ @Override
+ TagMap newEmpty() {
+ return new DenseTagMap(new Object[1], 0);
+ }
+
+ @Override
+ TagMap empty() {
+ return this == Default.INSTANCE ? TagMap.EMPTY : OwnEmpty.EMPTY;
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/api/DenseTagMapTest.java b/internal-api/src/test/java/datadog/trace/api/DenseTagMapTest.java
new file mode 100644
index 00000000000..4eba5e9ae5d
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/api/DenseTagMapTest.java
@@ -0,0 +1,501 @@
+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.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * TagMap's dense store, built through {@link TagMapType#DENSE} so it runs in the normal test JVM
+ * whatever the dense-store flag says. The shared TagMap suites also run against it; this covers
+ * what is specific to the dense store. {@link TagMapDenseForkedTest} covers what needs the flag on.
+ *
+ *