diff --git a/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java new file mode 100644 index 00000000000..78ee062e1ec --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/MaybeUsagePatternsBenchmark.java @@ -0,0 +1,191 @@ +package datadog.trace.util; + +import java.util.function.BiConsumer; +import java.util.function.ObjLongConsumer; +import javax.annotation.Nullable; +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.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A do/don't guide for using {@link Maybe}, not a research instrument like {@code + * datadog.trace.util.escape.EscapeShapeBenchmark} (which this class's arms are built on top of). + * Read {@code gc.alloc.rate.norm} — the "good" arm in each pair is expected to read 0 B/op on every + * JDK the way {@code EscapeShapeBenchmark}'s {@code singleSite}/{@code passedToInlinedStrategy} + * arms do; the paired "bad" arm exists to make the regression visible rather than theoretical. Run + * as + * + *
+ * ./gradlew :internal-api:jmh -Pjmh.includes=MaybeUsagePatternsBenchmark -Pjmh.profilers=gc -PtestJvm=17 + *+ * + * This is the intended backing example for a perf-review check like "EA-dependent elision on a hot + * path where a structural alternative exists at parity → prefer the deterministic form" + * (APMLP-1799's J12 note): both pairs below have a same-cost deterministic form available, so + * reviewing a real diff against these arms is a matter of asking "which arm does this call site + * look like," not re-deriving the escape-analysis argument each time. + * + *
The boxed-context pair is the sharper illustration of that J12 phrase than it first looks
+ * like. {@code badBoxedContextUpdateInlined} was expected to allocate the boxed {@code Long}
+ * and, measured here, does not -- with the whole {@code update} call inlined, C2 scalar-replaces
+ * the box the same as it would any other short-lived object. That is exactly the "EA-dependent"
+ * half of J12's phrase: {@link Maybe#update(long, ObjLongConsumer)} has no box to
+ * eliminate in the first place, so it reads 0 B/op regardless of whether the mutator lambda's own
+ * inlining holds; the generic-context form's 0 B/op is contingent on that specific inlining, which
+ * {@code badBoxedContextUpdateUninlined} demonstrates by taking it away via the same {@code
+ * -XX:CompileCommand=dontinline} technique {@code EscapeShapeBenchmark} uses for its {@code
+ * UninlinedStrategy} arm. This is narrower than immunity to every inlining failure: if the
+ * producing method or the {@code update} call itself fails to inline -- a different boundary,
+ * exercised by {@code EscapeShapeBenchmark}'s {@code passedToUninlinedStrategy} arm (24 B/op) --
+ * the {@code Maybe} wrapper itself becomes a real allocation for either overload.
+ */
+@Fork(
+ value = 2,
+ jvmArgsAppend = {
+ "-XX:CompileCommand=dontinline,datadog.trace.util.MaybeUsagePatternsBenchmark$UninlinedBoxedAdder::accept"
+ })
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(java.util.concurrent.TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class MaybeUsagePatternsBenchmark {
+
+ static final class Widget {
+ long count;
+ }
+
+ /** A non-capturing updater, as {@link Maybe#update(long, ObjLongConsumer)} expects. */
+ static final ObjLongConsumer The underlying idea, before the terminology: the compiler can sometimes prove a short-lived
+ * object never needs to outlive the method that created it, and when it can, it skips putting that
+ * object on the heap at all -- it keeps the object's fields as plain local values instead. Three
+ * terms for that recur below. Escape analysis (EA) is the compiler's proof step -- showing
+ * an allocated object's lifetime is confined to the method (or thread) that created it, i.e. it
+ * never escapes into a field, a return value visible outside, or a call the compiler cannot see
+ * into. Scalar replacement is what C2 (HotSpot's JIT) does once that proof holds: the object
+ * itself disappears, and its individual fields live in registers or on the stack instead, so no
+ * heap allocation happens -- the arms below that read 0 B/op are exactly the ones EA proved safe.
+ * {@code ReduceAllocationMerges} (JDK-8287061) extends that same proof to one harder case:
+ * an if/else (or similar branch) where each side allocates its own object -- say {@code x = new
+ * Foo()} in one branch and {@code x = new Bar()} in the other -- and the code after the branch
+ * reads {@code x} without knowing which allocation actually ran. Before JDK 21, C2 could not
+ * scalar-replace either allocation once they were merged like this, even if each individually would
+ * have qualified on its own; {@code ReduceAllocationMerges} is what lets it do so starting at JDK
+ * 21, which is why a few rows below only drop to 0 starting at JDK 21/25 rather than on every JDK.
+ * All of this is specific to HotSpot's C2 JIT; none of it has been checked against OpenJ9 or
+ * GraalVM, which use different compilers with different heuristics and may not scalar-replace the
+ * same shapes.
+ *
+ * Every arm consumes the object's fields rather than the object. Handing the reference
+ * to a {@link Blackhole} would make it escape by construction and every row would read the same.
+ *
+ * Bytes per operation, one machine, {@code -Pjmh.forks=1}. A 16-byte object allocated on half
+ * the operations reads as 8. Columns are the JDK the fork ran on, which is not necessarily
+ * the JDK on the shell's path — take it from JMH's own {@code # VM version} line.
+ *
+ * JDK 8 column measured 2026-08-27 (Zulu 8.72.0.17, this machine, {@code -Pjmh.fork=1}): every
+ * arm lands on the same B/op as the 17/25 columns it was checked against, including {@code
+ * mergeWithNull} staying at 8 rather than following JDK 25's drop to 0 — the {@code
+ * ReduceAllocationMerges} relaxation is JDK 21+ only, so 8's floor for this shape is the older,
+ * unconditional one.
+ *
+ * What the two measured columns say so far:
+ *
+ * Deliberately shaped against the {@code Optional}-style merge-with-singleton pitfall: {@link
+ * #of} is the only allocation site, always allocates (never returns a shared instance), and holds a
+ * plain nullable field. See {@code EscapeShapeBenchmark}'s {@code phiWithStatic} arm for why an
+ * {@code EMPTY} singleton would cost 8 B/op on every JDK measured, including 25.
+ *
+ * This shape -- one allocation site, a plain nullable field, no singleton merge -- scalar-
+ * replaces on ordinary escape analysis, on JDK 8/11/17/25, no JDK-21+ {@code
+ * ReduceAllocationMerges} needed (see {@code EscapeShapeBenchmark}). The discipline required of a
+ * caller is that the wrapping method itself construct a {@code Maybe} at exactly one call site (fed
+ * by a plain nullable local merged through ordinary branches, or by delegating to an
+ * already-nullable-returning method) rather than once per {@code return} statement -- multiple
+ * construction sites inline into a multi-producer phi that fails scalar replacement on JDK
+ * 8/11/17/21 (measured 16 B/op, {@code MaybeUsagePatternsBenchmark#badMultiConstructionSite}) once
+ * the refusal branch is reachable. On JDK 25, {@code ReduceAllocationMerges} collapses this
+ * specific shape -- two branches allocating the same final type with identical field layout -- back
+ * down to 0 B/op; do not rely on that JDK-25-only behavior, since it is exactly the kind of
+ * EA-dependent elision that can regress silently the moment the two branches stop being trivially
+ * mergeable (e.g. one branch gains extra state). See {@code EscapeShapeBenchmark}'s {@code
+ * phiOfTwoAllocations} arm, which uses two distinct interface implementations rather than one
+ * concrete type and therefore fails to scalar-replace on every JDK including 25 -- a different,
+ * stronger failure mode than the one demonstrated here.
+ */
+public final class Maybe Unlike the single-arg {@link #of}, {@code fn} here is typically a capturing lambda
+ * -- it closes over whatever local arguments the caller's method has in scope, so a fresh lambda
+ * instance is created on every invocation (capturing lambdas are never cached the way a
+ * non-capturing lambda's singleton instance commonly is) -- which makes it a second heap-object
+ * candidate distinct from the {@code Maybe} itself. That freshly-allocated capturing lambda still
+ * scalar-replaces as reliably as a plain delegating method call does, for the shape actually
+ * measured (JDK 8/11/17/25): a monomorphic receiver and a {@code fn} that is applied exactly once
+ * and does not itself escape (e.g. by being stored or passed further). If {@code fn} itself
+ * captures something that must be freshly allocated per call (e.g. a non-singleton creator), that
+ * allocation is real regardless of what happens to the lambda wrapping it.
+ */
+ @Nonnull
+ public static Deliberately the only primitive-context overload. An {@code int}/{@code
+ * double}/{@code boolean} sibling was tried and reverted: Java's overload resolution can pick
+ * cleanly between a primitive overload and the generic {@link #update(Object, BiConsumer)} form
+ * for a reference-typed argument (boxing is only considered once no non-boxing candidate
+ * applies), but that guarantee does not extend to a second primitive overload -- {@code update(1,
+ * lambda)} is ambiguous between {@code int} and {@code long} even with no {@code double} overload
+ * in the picture, because {@link ObjIntConsumer} and {@link ObjLongConsumer} are unrelated
+ * interfaces and JLS 15.12.2.5's most-specific-method rule requires every parameter position to
+ * agree, not just the numeric one. Confirmed by direct compilation, not just JLS reading: an
+ * inline lambda call breaks as soon as a second primitive overload exists. A plain {@code int}
+ * argument still widens to {@code long} for free at this single overload -- callers are not
+ * required to have a {@code long} in hand.
+ */
+ public void update(long context, ObjLongConsumer super T> mutator) {
+ if (value != null) {
+ mutator.accept(value, context);
+ }
+ }
+
+ public void ifPresentOrElse(Consumer super T> action, Runnable emptyAction) {
+ if (value != null) {
+ action.accept(value);
+ } else {
+ emptyAction.run();
+ }
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/MaybeTest.java b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java
new file mode 100644
index 00000000000..1799c4a2157
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java
@@ -0,0 +1,99 @@
+package datadog.trace.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class MaybeTest {
+
+ static final class Widget {
+ long count;
+ }
+
+ @Test
+ public void ofPresent() {
+ Maybe./gradlew :internal-api:jmh -Pjmh.includes=EscapeShape -Pjmh.profilers=gc -PtestJvm=17
+ *
+ *
+ * and read the same rows across {@code -PtestJvm} 8, 11, 17, 21 and 25. The point is the matrix of
+ * shape against JDK, so that "will this allocate" stops being a question two people answer from
+ * memory. Nothing here is specific to any one caller: the arms model a generic two-outcome wrapper
+ * (something that is either present with a value or absent) and carry over unchanged to {@code
+ * Maybe}, because the shapes under test are about the compiler's allocation behavior, not about
+ * what the wrapped value represents.
+ *
+ *
+ * shape JDK 8 JDK 11 JDK 17 JDK 21 JDK 25 what it isolates
+ * singleSite 0 ? 0 ? 0 the floor
+ * flagOnOneAllocation 0 ? 0 ? 0 outcome in a field
+ * closedInFinally 0 ? 0 ? 0 try/finally
+ * closedInFinallyWithThrow 0 ? 0 ? ? ... with the handler taken
+ * flagOnOneAllocationClosedInFinally 0 ? 0 ? 0 flag field, whole
+ * passedToInlinedStrategy 0 ? 0 ? 0 @Strategy boundary
+ * backingMonomorphic 0 ? 0 ? 0 one backing
+ * backingBimorphic 0 ? 0 ? 0 two backings
+ * mergeWithNull 8 ? 8 ? 0 merge with null
+ * mergeWithStatic 8 ? 8 ? 8 merge with a singleton
+ * mergeWithStaticClosedInFinally 8 ? 8 ? 8 ... the same, whole
+ * mergeOfTwoAllocations 16 ? 16 ? 16 merge of two allocations
+ * passedToUninlinedStrategy 24 ? 24 ? 24 the same boundary, uninlined
+ * backingMegamorphic 24 ? 24 ? 24 three backings
+ *
+ *
+ *
+ *
+ */
+@Fork(
+ value = 2,
+ jvmArgsAppend = {
+ "-XX:CompileCommand=dontinline,datadog.trace.util.escape.EscapeShapeBenchmark$UninlinedStrategy::apply"
+ })
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class EscapeShapeBenchmark {
+
+ /**
+ * Minimal two-method interface -- a value to read and a close to call -- standing in for any
+ * short-lived object more complex than a single field.
+ */
+ interface Outcome {
+ int value();
+
+ void close();
+ }
+
+ static final class SingleAllocation implements Outcome {
+ private final int seed;
+
+ SingleAllocation(int seed) {
+ this.seed = seed;
+ }
+
+ @Override
+ public int value() {
+ return seed + 1;
+ }
+
+ @Override
+ public void close() {}
+ }
+
+ /** A second allocation site, for the merge that C2 has some chance with. */
+ static final class AlternateAllocation implements Outcome {
+ private final int seed;
+
+ AlternateAllocation(int seed) {
+ this.seed = seed;
+ }
+
+ @Override
+ public int value() {
+ return seed + 2;
+ }
+
+ @Override
+ public void close() {}
+ }
+
+ /** The absent outcome, reachable from a static, so the merge it takes part in is not local. */
+ static final Outcome STATIC_SINGLETON =
+ new Outcome() {
+ @Override
+ public int value() {
+ return 0;
+ }
+
+ @Override
+ public void close() {}
+ };
+
+ /** One allocation site carrying the outcome in a field: the shape that survives. */
+ static final class FlaggedAllocation {
+ private final boolean present;
+ private final int seed;
+
+ FlaggedAllocation(boolean present, int seed) {
+ this.present = present;
+ this.seed = seed;
+ }
+
+ int value() {
+ return present ? seed + 1 : 0;
+ }
+
+ void close() {}
+ }
+
+ /**
+ * A non-capturing strategy held in a static final field of concrete type, as {@code @Strategy}
+ * requires.
+ */
+ interface OutcomeStrategy {
+ int apply(FlaggedAllocation cell);
+ }
+
+ static final OutcomeStrategy INLINED = FlaggedAllocation::value;
+
+ /**
+ * Kept out of line by the {@code CompileCommand} in {@link Fork}, not by {@link CompilerControl}:
+ * JMH's processor only collects that annotation from {@code @Benchmark} methods, so putting it
+ * here emits no hint at all and the arm silently becomes a duplicate of the inlined one. Check
+ * the timing against {@code passedToInlinedStrategy} before believing this row — a call that
+ * really did not inline cannot cost the same as no call.
+ */
+ static final class UninlinedStrategy implements OutcomeStrategy {
+ @Override
+ public int apply(FlaggedAllocation cell) {
+ return cell.value();
+ }
+ }
+
+ static final OutcomeStrategy UNINLINED = new UninlinedStrategy();
+
+ /**
+ * The template-method shape: a final method on a base type calling out to an abstract one, with
+ * the object under test riding along as the argument. How many concrete subclasses are loaded is
+ * the whole experiment — C2 inlines a monomorphic call outright and a bimorphic one behind a type
+ * guard, but gives up at three, and a call it does not inline turns its argument into an escape.
+ */
+ abstract static class Backing {
+ final int admit(FlaggedAllocation cell) {
+ return store(cell);
+ }
+
+ abstract int store(FlaggedAllocation cell);
+ }
+
+ static final class ArrayBacking extends Backing {
+ @Override
+ int store(FlaggedAllocation cell) {
+ return cell.value();
+ }
+ }
+
+ static final class LinkedBacking extends Backing {
+ @Override
+ int store(FlaggedAllocation cell) {
+ return cell.value() + 1;
+ }
+ }
+
+ static final class ThirdBacking extends Backing {
+ @Override
+ int store(FlaggedAllocation cell) {
+ return cell.value() + 2;
+ }
+ }
+
+ // All three the same length, so the index arithmetic and the bounds check are identical and the
+ // only difference between the arms is how many types reach the call site.
+ //
+ // Unexplained: the monomorphic arm times slower than the bimorphic one (2.14 against 1.26 ns on
+ // 17), and equalising the lengths did not change it, so it is not the index arithmetic. Both
+ // eliminate their allocation, which is what this matrix is for, so the timing oddity does not
+ // touch any conclusion drawn here — but do not quote these two timings against each other until
+ // someone has read the assembly.
+ private final Backing[] one = {new ArrayBacking(), new ArrayBacking(), new ArrayBacking()};
+ private final Backing[] two = {new ArrayBacking(), new LinkedBacking(), new ArrayBacking()};
+ private final Backing[] three = {new ArrayBacking(), new LinkedBacking(), new ThirdBacking()};
+
+ // The three arms below are deliberately copy-pasted rather than sharing a helper. A shared helper
+ // would carry one profile for all three call sites, so the megamorphic arm would poison the other
+ // two and the matrix would report the same answer three times.
+
+ @Benchmark
+ public void backingMonomorphic(Blackhole bh) {
+ Backing backing = one[(counter++ & 0x7fffffff) % one.length];
+ FlaggedAllocation cell = new FlaggedAllocation(true, counter);
+ bh.consume(backing.admit(cell));
+ }
+
+ @Benchmark
+ public void backingBimorphic(Blackhole bh) {
+ Backing backing = two[(counter++ & 0x7fffffff) % two.length];
+ FlaggedAllocation cell = new FlaggedAllocation(true, counter);
+ bh.consume(backing.admit(cell));
+ }
+
+ @Benchmark
+ public void backingMegamorphic(Blackhole bh) {
+ Backing backing = three[(counter++ & 0x7fffffff) % three.length];
+ FlaggedAllocation cell = new FlaggedAllocation(true, counter);
+ bh.consume(backing.admit(cell));
+ }
+
+ /**
+ * Alternates so both sides of every branch are taken and the profile is honest. A branch C2 never
+ * sees taken becomes an uncommon trap, which would quietly turn the merge arms into single-site
+ * arms and make the whole matrix a lie.
+ */
+ private int counter;
+
+ private boolean alternate() {
+ return (counter++ & 1) == 0;
+ }
+
+ @Benchmark
+ public void singleSite(Blackhole bh) {
+ SingleAllocation cell = new SingleAllocation(counter++);
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void mergeOfTwoAllocations(Blackhole bh) {
+ Outcome cell = alternate() ? new SingleAllocation(counter) : new AlternateAllocation(counter);
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void mergeWithStatic(Blackhole bh) {
+ Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON;
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void mergeWithNull(Blackhole bh) {
+ SingleAllocation cell = alternate() ? new SingleAllocation(counter) : null;
+ bh.consume(cell == null ? 0 : cell.value());
+ }
+
+ @Benchmark
+ public void flagOnOneAllocation(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ bh.consume(cell.value());
+ }
+
+ @Benchmark
+ public void closedInFinally(Blackhole bh) {
+ SingleAllocation cell = new SingleAllocation(counter++);
+ try {
+ bh.consume(cell.value());
+ } finally {
+ cell.close();
+ }
+ }
+
+ /** Preallocated and stackless, so the arm measures control flow rather than fillInStackTrace. */
+ static final class Failure extends RuntimeException {
+ static final Failure INSTANCE = new Failure();
+
+ private Failure() {
+ super("failure", null, false, false);
+ }
+ }
+
+ /**
+ * The same try/finally, with the handler actually taken often enough to be compiled rather than
+ * left as an uncommon trap. This is the case {@link #closedInFinally} does not cover: there, C2
+ * has never seen the exception path, so there is no code for the object to be live into.
+ */
+ @Benchmark
+ public void closedInFinallyWithThrow(Blackhole bh) {
+ SingleAllocation cell = new SingleAllocation(counter++);
+ try {
+ if ((counter & 15) == 0) {
+ throw Failure.INSTANCE;
+ }
+ bh.consume(cell.value());
+ } catch (Failure failure) {
+ bh.consume(cell.value() + 1);
+ } finally {
+ cell.close();
+ }
+ }
+
+ /** The Optional-style shape, whole: a singleton for one outcome, under try/finally. */
+ @Benchmark
+ public void mergeWithStaticClosedInFinally(Blackhole bh) {
+ Outcome cell = alternate() ? new SingleAllocation(counter) : STATIC_SINGLETON;
+ try {
+ bh.consume(cell.value());
+ } finally {
+ cell.close();
+ }
+ }
+
+ /** The single-site shape, whole: one allocation carrying a flag, under try/finally. */
+ @Benchmark
+ public void flagOnOneAllocationClosedInFinally(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ try {
+ bh.consume(cell.value());
+ } finally {
+ cell.close();
+ }
+ }
+
+ /**
+ * A non-escaping object handed across a call boundary the strategy discipline keeps inlinable.
+ */
+ @Benchmark
+ public void passedToInlinedStrategy(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ bh.consume(INLINED.apply(cell));
+ }
+
+ /**
+ * The same, with only the inlining taken away. Whatever this costs is what the discipline buys.
+ */
+ @Benchmark
+ public void passedToUninlinedStrategy(Blackhole bh) {
+ FlaggedAllocation cell = new FlaggedAllocation(alternate(), counter);
+ bh.consume(UNINLINED.apply(cell));
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java
new file mode 100644
index 00000000000..5c0a1fb0499
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java
@@ -0,0 +1,142 @@
+package datadog.trace.util;
+
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.ObjIntConsumer;
+import java.util.function.ObjLongConsumer;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Candidate return shape for fallible operations (e.g. a table's capacity-refusing {@code
+ * tryGetOrCreate}), evaluating whether it can be allocation-free under escape analysis.
+ *
+ *