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.
+ */
+@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.
+ *
+ * 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..6e9b7c803f8
--- /dev/null
+++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceAssemblyBenchmark.java
@@ -0,0 +1,96 @@
+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.
+ */
+@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();
+ }
+}