From afed54bdd01632225a45f9e2d179162e3167d711 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 10 Jul 2026 13:33:13 -0400 Subject: [PATCH 1/2] Add front-half span-creation JMH benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds application-thread (front-half) allocation benchmarks that ground the TagMap 2.0 / SpanPrototype work against the real span lifecycle: - SpanCreationBenchmark (front-A): single span create -> tag -> finish. Bare baseline (startSpan vs buildSpan), two known-tag shapes set after start — web-server (7 tags) and JDBC/DB client (9 tags) — plus a builder-tag-path arm (withTag before start, the OTel-bridge shape) to track how the startSpan / buildSpan lineages diverge across releases. - TraceAssemblyBenchmark (front-B): web-shape root + N children (childCount 1/5/20), exercising the per-span baseline-tag copy that map-to-map copy (TagMap 1.0) and the trace/span tag split (level-split) target. - DropWriter: shared no-op Writer so finish() excludes serialization / agent I/O, isolating front-half allocation for -prof gc. Deliberately drift-stable (v1.53->master API) so the same file can be grafted onto old release tags for a historical allocation curve. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/core/DropWriter.java | 31 ++++ .../trace/core/SpanCreationBenchmark.java | 169 ++++++++++++++++++ .../trace/core/TraceAssemblyBenchmark.java | 96 ++++++++++ 3 files changed, 296 insertions(+) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/DropWriter.java create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/TraceAssemblyBenchmark.java 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 trace) {} + + @Override + public void start() {} + + @Override + public boolean flush() { + return true; + } + + @Override + public void close() {} + + @Override + public void incrementDropCounts(int spanCount) {} +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java new file mode 100644 index 00000000000..74dc916c317 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java @@ -0,0 +1,169 @@ +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.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; + +/** + * Grounding benchmark for the full span-creation lifecycle: create -> (set tags) -> finish. + * + *

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/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(); + } +} From cfa94f1e9caaf460ea10146ba096ec06749023d3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 10 Jul 2026 15:15:20 -0400 Subject: [PATCH 2/2] Add virtual-thread span-creation benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs startSpan create+finish on a virtual thread — the regime the platform-thread SpanCreationBenchmark is blind to. startSpan's thread-local SpanBuilder reuse (#9537) is disabled on virtual threads, so a builder is allocated per span there; the builder bypass (#9998) removes it. This bench is where that difference shows. Starts the virtual thread via reflection (Thread.startVirtualThread) so the jmh source set still compiles on pre-21 toolchains; requires a 21+ JDK at run time. The per-op vthread spawn/join cost is constant across tracer versions, so it cancels in the version-over-version delta. Co-Authored-By: Claude Opus 4.8 --- .../SpanCreationVirtualThreadBenchmark.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationVirtualThreadBenchmark.java 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(); + } +}