diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java b/dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java new file mode 100644 index 00000000000..727cc00fa57 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java @@ -0,0 +1,31 @@ +package datadog.trace.core; + +import datadog.trace.common.writer.Writer; +import java.util.List; + +/** + * No-op {@link Writer}: drops finished traces so span-creation benchmarks measure only the + * application-thread (front-half) allocation — create, tag, finish, PendingTrace completion — with + * no serialization or agent I/O leaking into the {@code -prof gc} number. + * + *
Drift-stable: implements only the five-method {@link Writer} interface, unchanged
+ * v1.53→master.
+ */
+final class DropWriter implements Writer {
+ @Override
+ public void write(List This is the "micro-ish" reference the TagMap 2.0 / SpanPrototype work is measured against. It
+ * pairs a tag-free baseline with two known-tag shapes — a web-server span (7 tags) and a JDBC/DB
+ * client span (9 tags) — so the dense-store and SpanPrototype allocation wins are actually
+ * exercised (a tag-free or custom-tag benchmark would be dense-neutral and show nothing), and so
+ * the marginal per-tag cost is visible across two realistic tag counts. It also covers the builder
+ * tag path ({@code withTag} before {@code start()}, the OTel-bridge shape) alongside the
+ * set-after-start path, so the startSpan/buildSpan lineages can be tracked as they diverge across
+ * releases.
+ *
+ * Read the allocation columns, not just throughput. Run with {@code -prof gc}: {@code
+ * gc.alloc.rate.norm} (B/op) is deterministic run-to-run and is the primary signal; throughput is
+ * thermal/contention-fragile on a laptop and should be treated as directional. Multi-fork
+ * ({@code @Fork(3)}) guards against per-fork inlining bimodality.
+ *
+ * Deliberately drift-stable so it can be copied onto past release tags and back-checked.
+ * It touches only API that is byte-identical from v1.53.0 to master: {@link
+ * CoreTracer#buildSpan(String, CharSequence)} / {@link CoreTracer#startSpan(String, CharSequence)},
+ * {@code AgentSpan.setTag(String, ...)} / {@code finish()}, the {@link Tags} constants, and the
+ * five-method {@code Writer} interface (implemented as a no-op {@link DropWriter}). If you add to
+ * it, keep it inside that stable surface or grafting it onto old tags for the historical curve will
+ * stop compiling. (Source rebuilds only reach ~v1.53 — older tags hit dead build-time dependencies;
+ * deeper history is a published-jar job.)
+ *
+ * Spans are finished against {@link DropWriter} so the create/tag/finish allocation is isolated
+ * from serialization and agent I/O — those live on a different lever and would otherwise leak into
+ * the {@code -prof gc} number via the writer's background threads.
+ *
+ * Multi-threaded on purpose ({@code @Threads(8)}); some tracer optimizations only show under
+ * contention. Use {@code -t 1} for a single-threaded run.
+ *
+ * Historical allocation (B/op, {@code gc.alloc.rate.norm}), this benchmark grafted onto
+ * each release tag and run {@code @Threads(8) -f3 -wi5 -i5 -prof gc} (measured 2026-07). The ~1.59
+ * drop is the TagMap 1.0 shared-Entry default flip; the ~1.61 drop is the interceptor/links
+ * cluster. Net 1.53 → 1.64 is -20% to -31% per arm.
+ *
+ * Throughput (ops/us) from the same runs — noisier, treat as directional only (laptop
+ * thermals + per-fork inlining bimodality; no Δ% given because there is no reliable trend):
+ *
+ * Why this exists: {@code startSpan}'s thread-local {@code SpanBuilder} reuse (1.55, #9537) is
+ * deliberately disabled on virtual threads (an {@code isVirtualThread} guard — thread-local
+ * caching on numerous short-lived virtual threads is an anti-pattern), so on a virtual thread 1.55
+ * still allocates a builder per {@code startSpan}. The full builder bypass (1.57, #9998) removes
+ * that allocation for everyone, including virtual threads. On platform threads the reuse already
+ * ate the allocation, so 1.57 shows nothing there; on virtual threads it should show as a per-span
+ * allocation drop at 1.57. This bench is where that appears.
+ *
+ * Requires a JDK with virtual threads (21+) at run time. To keep the jmh source set
+ * compilable on older toolchains, the virtual thread is started via reflection ({@code
+ * Thread.startVirtualThread}); {@link #setup()} fails fast on a pre-21 JDK. The per-op vthread
+ * spawn + join cost is constant across tracer versions, so it cancels in the 1.55→1.57 delta (read
+ * the delta, not the absolute B/op).
+ */
+@State(Scope.Benchmark)
+@Warmup(iterations = 5)
+@Measurement(iterations = 5)
+@BenchmarkMode(Mode.Throughput)
+@Threads(4)
+@OutputTimeUnit(MICROSECONDS)
+@Fork(value = 3)
+public class SpanCreationVirtualThreadBenchmark {
+ private static final String INSTRUMENTATION_NAME = "bench";
+ private static final String OPERATION_NAME = "servlet.request";
+
+ CoreTracer tracer;
+ // Thread.startVirtualThread(Runnable) -> Thread, resolved reflectively (JDK 21+).
+ private MethodHandle startVirtualThread;
+ // Reused so no per-op capturing-lambda allocation muddies the measurement.
+ private Runnable spanTask;
+
+ @Setup
+ public void setup() throws Throwable {
+ this.tracer = CoreTracer.builder().writer(new DropWriter()).build();
+ this.startVirtualThread =
+ MethodHandles.publicLookup()
+ .findStatic(
+ Thread.class,
+ "startVirtualThread",
+ MethodType.methodType(Thread.class, Runnable.class));
+ this.spanTask =
+ () -> {
+ AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, OPERATION_NAME);
+ span.finish();
+ };
+ }
+
+ @TearDown
+ public void tearDown() {
+ this.tracer.close();
+ }
+
+ /** create + finish a bare span on a fresh virtual thread; join. */
+ @Benchmark
+ public void bareStartSpanOnVirtualThread() throws Throwable {
+ Thread vthread = (Thread) startVirtualThread.invokeExact(spanTask);
+ vthread.join();
+ }
+}
diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceAssemblyBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceAssemblyBenchmark.java
new file mode 100644
index 00000000000..57872bfdbb4
--- /dev/null
+++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceAssemblyBenchmark.java
@@ -0,0 +1,139 @@
+package datadog.trace.core;
+
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+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.Param;
+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;
+
+/**
+ * Front-half (application-thread) benchmark for multi-span trace assembly: a
+ * web-server-shaped parent plus N children, each finished, whole trace dropped.
+ *
+ * Where {@link SpanCreationBenchmark} measures a single span (front-A), this measures the cost
+ * that scales with trace size (front-B): every span created copies the tracer's baseline /
+ * trace-level tags into its own storage, so an N-span trace pays that copy N+1 times. That per-span
+ * copy is exactly what the map-to-map copy optimization (TagMap 1.0) and the trace/span tag split
+ * (level-split read-through) target — this bench is how their win shows up as it scales, which a
+ * single-span bench cannot show.
+ *
+ * Sweeping {@code childCount} turns the per-child marginal cost into the slope: the level-split
+ * win should flatten it (children stop re-copying trace-level tags).
+ *
+ * Same conventions as {@link SpanCreationBenchmark}: {@link DropWriter} isolates front-half
+ * allocation; read alloc ({@code -prof gc}) as the anchor, throughput as directional; logging must
+ * be forced to WARN or DEBUG-line allocation corrupts the numbers. Drift-stable v1.53→master
+ * ({@code buildSpan}/{@code asChildOf}/{@code setTag}/{@code finish}, {@link Tags}, {@link
+ * datadog.trace.common.writer.Writer}) so it can be grafted onto old tags for the historical curve.
+ *
+ * Historical allocation (B/op, {@code gc.alloc.rate.norm}) per {@code childCount},
+ * grafted onto each release tag, {@code @Threads(8) -f3 -wi5 -i5 -prof gc} (measured 2026-07). The
+ * per-child slope is the level-split target: note the total falls far less than the single-span
+ * arms (net -13% to -20% vs -20% to -31%), because most of an N-span trace's allocation is the
+ * per-child trace-level-tag copy that TagMap 1.0 does not yet remove — the level-split read-through
+ * is what should flatten it.
+ *
+ * Throughput (ops/us) from the same runs — noisier, treat as directional only (laptop
+ * thermals + per-fork inlining bimodality; no Δ% given because there is no reliable trend):
+ *
+ *
+ * ver bareStart bareBuild webServer viaBuilder jdbcClient
+ * 1.53 1330.7 1331.1 2058.7 2434.7 1821.3
+ * 1.54 1423.8 1423.8 2131.4 2491.1 2037.8
+ * 1.55 1308.8 1422.0 2098.1 2477.2 2029.4
+ * 1.56 1322.2 1393.0 2131.5 2464.2 2016.6
+ * 1.57 1321.6 1363.3 2063.3 2410.6 2073.8
+ * 1.58 1312.6 1310.5 2018.0 2442.1 2065.3
+ * 1.59 1174.9 1166.1 1869.0 1987.2 1866.0
+ * 1.60 1180.6 1192.1 1858.9 1963.3 1818.0
+ * 1.61 959.2 951.7 1639.2 1774.1 1619.4
+ * 1.62 948.0 948.7 1674.9 1787.1 1614.1
+ * 1.63 927.0 926.7 1626.8 1737.7 1429.8
+ * 1.64 923.3 958.4 1636.6 1751.1 1421.5
+ * Δ% -30.6 -28.0 -20.5 -28.1 -22.0
+ *
+ *
+ *
+ * ver bareStart bareBuild webServer viaBuilder jdbcClient
+ * 1.53 5.69 5.49 4.00 3.99 5.33
+ * 1.54 4.26 4.25 3.47 3.55 3.15
+ * 1.55 3.87 4.13 3.37 3.52 3.14
+ * 1.56 3.92 4.03 3.79 3.33 3.15
+ * 1.57 4.21 4.11 3.31 3.49 3.37
+ * 1.58 4.16 4.19 3.34 3.52 3.27
+ * 1.59 4.20 4.66 3.43 3.52 3.45
+ * 1.60 5.48 5.66 3.42 3.54 3.77
+ * 1.61 4.17 4.31 3.46 3.58 3.46
+ * 1.62 5.53 5.72 4.20 4.37 4.49
+ * 1.63 6.03 5.97 4.66 5.13 4.49
+ * 1.64 5.37 5.26 4.62 5.29 5.06
+ *
+ */
+@State(Scope.Benchmark)
+@Warmup(iterations = 5)
+@Measurement(iterations = 5)
+@BenchmarkMode(Mode.Throughput)
+@Threads(8)
+@OutputTimeUnit(MICROSECONDS)
+@Fork(value = 3)
+public class SpanCreationBenchmark {
+ private static final String INSTRUMENTATION_NAME = "bench";
+ private static final String OPERATION_NAME = "servlet.request";
+
+ // int tag values are deliberately kept inside Integer's built-in cache (-128..127) so valueOf
+ // returns a shared box and boxing does not allocate — the bench then measures tag storage / path
+ // cost, not incidental boxing (which differs between setTag(int) and the builder's
+ // withTag(Number)).
+
+ // Web-server-shaped known tags — the profile the dense store / SpanPrototype target.
+ 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; // in-cache; value itself is immaterial here
+ private static final int PEER_PORT_VALUE = 80;
+
+ // JDBC/DB-client-shaped known tags — a higher-tag-count shape (9 vs the web shape's 7), matching
+ // what DatabaseClientDecorator + JDBCDecorator set on a statement span.
+ 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; // in-cache; value itself is immaterial here
+
+ CoreTracer tracer;
+
+ @Setup
+ public void setup() {
+ // DropWriter keeps finish() from pulling in serialization / agent I/O, so -prof gc reflects
+ // span creation + tagging + PendingTrace completion only.
+ this.tracer = CoreTracer.builder().writer(new DropWriter()).build();
+ }
+
+ @TearDown
+ public void tearDown() {
+ this.tracer.close();
+ }
+
+ /** Baseline: create + finish a bare span via startSpan, no tags. */
+ @Benchmark
+ public void bareStartSpan() {
+ AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, OPERATION_NAME);
+ span.finish();
+ }
+
+ /** Baseline: create + finish a bare span via the builder path, no tags. */
+ @Benchmark
+ public void bareBuildSpan() {
+ AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, OPERATION_NAME).start();
+ span.finish();
+ }
+
+ /** Web-server-shaped span: create -> set the typical known tags -> finish. */
+ @Benchmark
+ public void webServerSpan() {
+ AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, OPERATION_NAME).start();
+ span.setTag(Tags.COMPONENT, COMPONENT_VALUE);
+ span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER);
+ span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE);
+ span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE);
+ span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE);
+ span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE);
+ span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE);
+ span.finish();
+ }
+
+ /**
+ * Web-server-shaped span via the builder tag path: tags accumulated on the builder with
+ * {@code withTag} and applied at {@code start()}, rather than set on the span afterward. This is
+ * the shape the OTel bridge takes (OTel {@code SpanBuilder.setAttribute} → dd builder), still
+ * live today for manual OTel and OTel-bridge auto-instrumentation. Compare against {@link
+ * #webServerSpan} (same tags, set after start) to track how the startSpan/buildSpan paths diverge
+ * across releases.
+ */
+ @Benchmark
+ public void webServerSpanViaBuilder() {
+ AgentSpan span =
+ tracer
+ .buildSpan(INSTRUMENTATION_NAME, OPERATION_NAME)
+ .withTag(Tags.COMPONENT, COMPONENT_VALUE)
+ .withTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER)
+ .withTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE)
+ .withTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE)
+ .withTag(Tags.HTTP_URL, HTTP_URL_VALUE)
+ .withTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE)
+ .withTag(Tags.PEER_PORT, PEER_PORT_VALUE)
+ .start();
+ span.finish();
+ }
+
+ /** JDBC/DB-client-shaped span: create -> set the typical DB known tags (9) -> finish. */
+ @Benchmark
+ public void jdbcClientSpan() {
+ AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, OPERATION_NAME).start();
+ span.setTag(Tags.COMPONENT, DB_COMPONENT_VALUE);
+ span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT);
+ span.setTag(Tags.DB_TYPE, DB_TYPE_VALUE);
+ span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE);
+ span.setTag(Tags.DB_USER, DB_USER_VALUE);
+ span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE);
+ span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE);
+ span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE);
+ span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE);
+ span.finish();
+ }
+}
diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationVirtualThreadBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationVirtualThreadBenchmark.java
new file mode 100644
index 00000000000..f408b4723e5
--- /dev/null
+++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationVirtualThreadBenchmark.java
@@ -0,0 +1,84 @@
+package datadog.trace.core;
+
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+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;
+
+/**
+ * Runs span creation on a virtual thread — the regime the platform-thread {@link
+ * SpanCreationBenchmark} is blind to.
+ *
+ *
+ * ver trace[1] trace[5] trace[20]
+ * 1.53 2792.0 5701.3 16674.7
+ * 1.54 2874.7 5773.3 16728.0
+ * 1.55 2816.7 5752.0 16733.4
+ * 1.56 2826.7 5762.7 16728.0
+ * 1.57 2746.7 5301.3 15058.7
+ * 1.58 2640.0 5642.7 15072.0
+ * 1.59 2504.0 5376.0 15370.7
+ * 1.60 2519.2 5264.0 14986.7
+ * 1.61 2240.0 4634.7 14258.7
+ * 1.62 2256.2 4650.7 13688.7
+ * 1.63 2238.6 4868.9 14992.0
+ * 1.64 2241.3 4720.5 14592.0
+ * Δ% -19.7 -17.2 -12.5
+ *
+ *
+ *
+ * ver trace[1] trace[5] trace[20]
+ * 1.53 2.428 0.781 0.247
+ * 1.54 2.318 0.840 0.260
+ * 1.55 2.355 0.931 0.254
+ * 1.56 2.515 0.904 0.278
+ * 1.57 2.296 0.953 0.305
+ * 1.58 2.481 1.049 0.301
+ * 1.59 2.285 1.022 0.276
+ * 1.60 2.297 0.980 0.303
+ * 1.61 2.371 0.884 0.258
+ * 1.62 2.341 1.141 0.296
+ * 1.63 2.911 1.039 0.247
+ * 1.64 2.422 0.944 0.253
+ *
+ */
+@State(Scope.Benchmark)
+@Warmup(iterations = 5)
+@Measurement(iterations = 5)
+@BenchmarkMode(Mode.Throughput)
+@Threads(8)
+@OutputTimeUnit(MICROSECONDS)
+@Fork(value = 3)
+public class TraceAssemblyBenchmark {
+ private static final String INSTRUMENTATION_NAME = "bench";
+ private static final String ROOT_OPERATION = "servlet.request";
+ private static final String CHILD_OPERATION = "servlet.handler";
+
+ 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 = 200;
+
+ /** Number of child spans under the root — the axis that turns per-child cost into a slope. */
+ @Param({"1", "5", "20"})
+ int childCount;
+
+ CoreTracer tracer;
+
+ @Setup
+ public void setup() {
+ this.tracer = CoreTracer.builder().writer(new DropWriter()).build();
+ }
+
+ @TearDown
+ public void tearDown() {
+ this.tracer.close();
+ }
+
+ /** Web-server root + {@code childCount} children, each finished; whole trace dropped. */
+ @Benchmark
+ public void webServerTrace() {
+ AgentSpan root = tracer.buildSpan(INSTRUMENTATION_NAME, ROOT_OPERATION).start();
+ root.setTag(Tags.COMPONENT, COMPONENT_VALUE);
+ root.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER);
+ root.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE);
+ root.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE);
+ root.setTag(Tags.HTTP_URL, HTTP_URL_VALUE);
+ root.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE);
+
+ for (int i = 0; i < childCount; i++) {
+ AgentSpan child =
+ tracer.buildSpan(INSTRUMENTATION_NAME, CHILD_OPERATION).asChildOf(root).start();
+ child.setTag(Tags.COMPONENT, COMPONENT_VALUE);
+ child.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_INTERNAL);
+ child.finish();
+ }
+
+ root.finish();
+ }
+}