From cc9165788a721c0de7071e863d2bd6a8106fb406 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 28 Sep 2026 19:45:20 -0400 Subject: [PATCH 1/6] Add JMH benchmark exploring a generic guarded-decode/breaker pattern Follow-up to the Kafka header Base64-decode guard fix. Compares the hand-specialized GuardedBase64Decode/Breaker shape against a generic ParseHandler + Breaker abstraction (construction-time vs. call-time strategy composition) to see what devirtualization costs, if any, a reusable version of this pattern would carry. Motivated by APMLP-1513's children (APMLP-1760, APMLP-1769, APMLP-1772, APMLP-1783), which show enough independent exception/parsing-cost cases to justify considering a shared primitive rather than one-off copies. Co-Authored-By: Claude Sonnet 5 --- .../trace/api/FunctionsBase64Benchmark.java | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java diff --git a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java new file mode 100644 index 00000000000..6ff6fa86e6f --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java @@ -0,0 +1,364 @@ +package datadog.trace.api; + +import static datadog.trace.api.Functions.BASE64_DECODE; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.function.Function; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Compares {@link Functions#BASE64_DECODE} on valid input (no exception) against malformed input, + * where every call throws and catches an {@link IllegalArgumentException}. Motivated by a customer + * seeing ~180K/day of this exact throw from Kafka header extraction (non-Base64 header values from + * a mixed producer). Point is to see whether HotSpot's fast-throw stack-trace omission actually + * kicks in for this call site under sustained repeated throws, or whether the caught path pays for + * a full stack trace fill-in every time. + */ +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@State(Scope.Benchmark) +public class FunctionsBase64Benchmark { + + static final byte[] VALID = + Base64.getEncoder().encode("x-datadog-trace-id=1234567890".getBytes(StandardCharsets.UTF_8)); + + static final byte[] INVALID = "not-valid-base64!@#".getBytes(StandardCharsets.UTF_8); + + private static final boolean[] BASE64_ALPHABET = buildAlphabetTable(); + + private static boolean[] buildAlphabetTable() { + boolean[] table = new boolean[256]; + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + for (int i = 0; i < alphabet.length(); i++) { + table[alphabet.charAt(i)] = true; + } + return table; + } + + // Branch-free per byte on purpose: a data-dependent early exit helps the rare "bad byte early" + // case but hurts the common valid case, which always scans the whole buffer anyway. + private static boolean looksLikeBase64(byte[] bytes) { + if (bytes.length == 0 || (bytes.length & 3) != 0) { + return false; + } + boolean valid = true; + for (byte b : bytes) { + valid &= BASE64_ALPHABET[b & 0xFF]; + } + return valid; + } + + static final Function PRECHECK_BASE64_DECODE = + bytes -> looksLikeBase64(bytes) ? BASE64_DECODE.apply(bytes) : null; + + private static final int CLOSE_THRESHOLD = 20; + + /** + * Thrown only when the cheap alphabet-scan precheck already knows the input can't be valid + * Base64, so we never call into {@link Base64}'s decoder (or pay for its exception's stack-trace + * capture) at all. Deliberately extends {@link IllegalArgumentException} so it's a drop-in for + * existing catch sites around the real decoder; callers should not rely on a stack trace being + * present for this specific instance — a real decode failure still throws the JDK's own + * exception, unmodified, with its own message and stack trace. + */ + static final class FastFailBase64Exception extends IllegalArgumentException { + private static final String MESSAGE = "Header value is not valid Base64"; + + FastFailBase64Exception() { + super(MESSAGE); + } + + // IllegalArgumentException has no writableStackTrace-suppressing constructor of its own, so + // skip the stack walk here instead. + @Override + public synchronized Throwable fillInStackTrace() { + return this; + } + } + + // Single-word countdown: 0 == closed (no precheck), >0 == guarded, counting down to close. + // Plain int on purpose: this is advisory hysteresis, not correctness-critical state, so a lost + // update or a stale read across threads just means one extra precheck or one extra exception. + static final class Breaker { + int state; + + String decode(byte[] bytes) { + if (state > 0 && !looksLikeBase64(bytes)) { + return null; + } + String result = BASE64_DECODE.apply(bytes); + if (result == null) { + state = CLOSE_THRESHOLD; + } else if (state > 0) { + state--; + } + return result; + } + + // Same hysteresis, but preserves throw-based failure semantics: a precheck-known failure + // throws our stack-trace-free stand-in, a real decode failure lets the JDK's own + // IllegalArgumentException (with its own message and stack trace) propagate untouched. + String decodeOrThrow(byte[] bytes) { + if (state > 0 && !looksLikeBase64(bytes)) { + throw new FastFailBase64Exception(); + } + try { + String result = new String(Base64.getDecoder().decode(bytes), StandardCharsets.UTF_8); + if (state > 0) { + state--; + } + return result; + } catch (IllegalArgumentException e) { + state = CLOSE_THRESHOLD; + throw e; + } + } + } + + final Breaker breakerForValid = new Breaker(); + final Breaker breakerForInvalid = new Breaker(); + final Breaker breakerThrowingForValid = new Breaker(); + final Breaker breakerThrowingForInvalid = new Breaker(); + + /** + * Hand-written specialization above vs. a generic {@code ParseHandler}/{@code GenericBreaker} + * pair below, to see what the CHA-based devirtualization actually costs relative to a single + * concrete, non-generic class with everything inlined by hand. + */ + abstract static class ParseHandler { + abstract boolean isDefinitelyInvalid(TInput input); + + abstract TOutput parse(TInput input); + + abstract TException createFastFailException(); + } + + static final ParseHandler BASE64_PARSE_HANDLER = + new ParseHandler() { + @Override + boolean isDefinitelyInvalid(byte[] input) { + return !looksLikeBase64(input); + } + + @Override + String parse(byte[] input) { + return new String(Base64.getDecoder().decode(input), StandardCharsets.UTF_8); + } + + @Override + IllegalArgumentException createFastFailException() { + return new FastFailBase64Exception(); + } + }; + + // Strategy composed at construction time (stored as a field). Contrasted below with + // GenericBreakerParam, which takes the same strategy at call time instead — comparing them is + // the actual point, since a field forces C2 to also prove the receiver is constant before it + // can fold through to a concrete ParseHandler, while a call-time argument doesn't. + static final class GenericBreakerCtor { + private final ParseHandler handler; + int state; + + GenericBreakerCtor(ParseHandler handler) { + this.handler = handler; + } + + TOutput decode(TInput input) { + if (state > 0 && handler.isDefinitelyInvalid(input)) { + throw handler.createFastFailException(); + } + try { + TOutput result = handler.parse(input); + if (state > 0) { + state--; + } + return result; + } catch (RuntimeException e) { + state = CLOSE_THRESHOLD; + throw e; + } + } + } + + // Same hysteresis, but the strategy is passed at call time rather than stored in a field. + static final class GenericBreakerParam { + int state; + + TOutput decode(TInput input, ParseHandler handler) { + if (state > 0 && handler.isDefinitelyInvalid(input)) { + throw handler.createFastFailException(); + } + try { + TOutput result = handler.parse(input); + if (state > 0) { + state--; + } + return result; + } catch (RuntimeException e) { + state = CLOSE_THRESHOLD; + throw e; + } + } + } + + final GenericBreakerCtor genericBreakerForValid = + new GenericBreakerCtor<>(BASE64_PARSE_HANDLER); + final GenericBreakerCtor genericBreakerForInvalid = + new GenericBreakerCtor<>(BASE64_PARSE_HANDLER); + + final GenericBreakerParam genericBreakerParamForValid = + new GenericBreakerParam<>(); + final GenericBreakerParam + genericBreakerParamForInvalid = new GenericBreakerParam<>(); + + @Benchmark + public void breakerValid(Blackhole bh) { + bh.consume(breakerForValid.decode(VALID)); + } + + @Benchmark + public void breakerInvalid(Blackhole bh) { + bh.consume(breakerForInvalid.decode(INVALID)); + } + + @Benchmark + public void breakerThrowingValid(Blackhole bh) { + bh.consume(breakerThrowingForValid.decodeOrThrow(VALID)); + } + + @Benchmark + public void breakerThrowingInvalid(Blackhole bh) { + try { + bh.consume(breakerThrowingForInvalid.decodeOrThrow(INVALID)); + } catch (IllegalArgumentException e) { + bh.consume(e); + } + } + + @Benchmark + public void genericBreakerValid(Blackhole bh) { + bh.consume(genericBreakerForValid.decode(VALID)); + } + + @Benchmark + public void genericBreakerInvalid(Blackhole bh) { + try { + bh.consume(genericBreakerForInvalid.decode(INVALID)); + } catch (IllegalArgumentException e) { + bh.consume(e); + } + } + + @Benchmark + public void genericBreakerParamValid(Blackhole bh) { + bh.consume(genericBreakerParamForValid.decode(VALID, BASE64_PARSE_HANDLER)); + } + + @Benchmark + public void genericBreakerParamInvalid(Blackhole bh) { + try { + bh.consume(genericBreakerParamForInvalid.decode(INVALID, BASE64_PARSE_HANDLER)); + } catch (IllegalArgumentException e) { + bh.consume(e); + } + } + + @Benchmark + public void valid(Blackhole bh) { + bh.consume(BASE64_DECODE.apply(VALID)); + } + + @Benchmark + public void invalid(Blackhole bh) { + bh.consume(BASE64_DECODE.apply(INVALID)); + } + + @Benchmark + public void precheckValid(Blackhole bh) { + bh.consume(PRECHECK_BASE64_DECODE.apply(VALID)); + } + + @Benchmark + public void precheckInvalid(Blackhole bh) { + bh.consume(PRECHECK_BASE64_DECODE.apply(INVALID)); + } + + // Deterministic stream: one INVALID every invalidEveryN calls, VALID otherwise. Each mix + // benchmark gets its own counter/breaker so the three strategies see the identical pattern. + @State(Scope.Thread) + public static class Mix { + + @Param({"2", "10", "100", "1000", "10000"}) + int invalidEveryN; + + int counter; + final Breaker breaker = new Breaker(); + final Breaker throwingBreaker = new Breaker(); + final GenericBreakerCtor genericBreaker = + new GenericBreakerCtor<>(BASE64_PARSE_HANDLER); + final GenericBreakerParam genericBreakerParam = + new GenericBreakerParam<>(); + + byte[] next() { + if (++counter >= invalidEveryN) { + counter = 0; + return INVALID; + } + return VALID; + } + } + + @Benchmark + public void mixUnguarded(Mix mix, Blackhole bh) { + bh.consume(BASE64_DECODE.apply(mix.next())); + } + + @Benchmark + public void mixGuarded(Mix mix, Blackhole bh) { + bh.consume(PRECHECK_BASE64_DECODE.apply(mix.next())); + } + + @Benchmark + public void mixBreaker(Mix mix, Blackhole bh) { + bh.consume(mix.breaker.decode(mix.next())); + } + + @Benchmark + public void mixBreakerThrowing(Mix mix, Blackhole bh) { + byte[] bytes = mix.next(); + try { + bh.consume(mix.throwingBreaker.decodeOrThrow(bytes)); + } catch (IllegalArgumentException e) { + bh.consume(e); + } + } + + @Benchmark + public void mixGenericBreaker(Mix mix, Blackhole bh) { + byte[] bytes = mix.next(); + try { + bh.consume(mix.genericBreaker.decode(bytes)); + } catch (IllegalArgumentException e) { + bh.consume(e); + } + } + + @Benchmark + public void mixGenericBreakerParam(Mix mix, Blackhole bh) { + byte[] bytes = mix.next(); + try { + bh.consume(mix.genericBreakerParam.decode(bytes, BASE64_PARSE_HANDLER)); + } catch (IllegalArgumentException e) { + bh.consume(e); + } + } +} From 8c2b4ec9a1543cecf6c2b6eea534dcb9692888e3 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 16:56:54 -0400 Subject: [PATCH 2/6] Rework the Base64 guard benchmark around an abstract DynamicLatch Replace the ParseHandler plus GenericBreakerCtor/GenericBreakerParam pair with one abstract DynamicLatch sketch, local to the benchmark, whose subclass is the strategy: an optimistic parse, a cheap correct pre-check and a stackless failure. That removes the constructor-versus-call-time question, since there is no separate strategy object. The latch has two flavors: get lets the failure flow to the caller (throwing a stackless stand-in while engaged), and tryGetOrNull converts it to null and builds no exception at all while engaged. Latches are static final fields of a named final subclass. The hand-written Breaker stays as the specialized baseline, and a setup self-check fails fast if the sketch misbehaves. Co-Authored-By: Claude Sonnet 5.5 --- .../trace/api/FunctionsBase64Benchmark.java | 223 +++++++++++------- 1 file changed, 134 insertions(+), 89 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java index 6ff6fa86e6f..4d8a315a96f 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java @@ -10,6 +10,7 @@ import org.openjdk.jmh.annotations.Measurement; 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.Warmup; import org.openjdk.jmh.infra.Blackhole; @@ -21,6 +22,17 @@ * a mixed producer). Point is to see whether HotSpot's fast-throw stack-trace omission actually * kicks in for this call site under sustained repeated throws, or whether the caught path pays for * a full stack trace fill-in every time. + * + *

It also measures a reusable form of the guard. {@link DynamicLatch} is a local sketch of the + * shape discussed in APMLP-1884: an abstract class whose subclass is the strategy (an + * optimistic parse, a cheap correct pre-check, and a stackless failure), held in a {@code static + * final}. It replaces an earlier pair of generic variants that stored the strategy in a field or + * took it at call time, which are not needed once the subclass carries the hooks. It has two public + * flavors: {@link DynamicLatch#get} lets the failure flow to the caller (throwing a stackless + * stand-in while engaged), and {@link DynamicLatch#tryGetOrNull} converts it to {@code null} and + * never builds an exception at all while engaged. + * + *

The hand-written {@link Breaker} is the specialized baseline the latch is compared against. */ @Fork(2) @Warmup(iterations = 3, time = 1) @@ -69,6 +81,9 @@ private static boolean looksLikeBase64(byte[] bytes) { * existing catch sites around the real decoder; callers should not rely on a stack trace being * present for this specific instance — a real decode failure still throws the JDK's own * exception, unmodified, with its own message and stack trace. + * + *

A new instance is built per failure, never shared, so suppression cannot accumulate on it. A + * shared instance would also have to disable suppression and forbid {@code initCause}. */ static final class FastFailBase64Exception extends IllegalArgumentException { private static final String MESSAGE = "Header value is not valid Base64"; @@ -124,101 +139,144 @@ String decodeOrThrow(byte[] bytes) { } } - final Breaker breakerForValid = new Breaker(); - final Breaker breakerForInvalid = new Breaker(); - final Breaker breakerThrowingForValid = new Breaker(); - final Breaker breakerThrowingForInvalid = new Breaker(); - /** - * Hand-written specialization above vs. a generic {@code ParseHandler}/{@code GenericBreaker} - * pair below, to see what the CHA-based devirtualization actually costs relative to a single - * concrete, non-generic class with everything inlined by hand. + * Local sketch of a {@code DynamicLatch}: the subclass supplies the strategy, so there is no + * separate handler object and no question of storing it in a field versus passing it per call. + * + *

Two states with hysteresis: closed (optimistic path) and engaged (guarded path). A failure + * of the declared type engages it, and {@link #closeAfter()} consecutive successes disengage it. + * It counts calls, not time, never rejects a call, and keeps plain racy state: a stale read costs + * one more pre-check or one more exception, never a wrong result. + * + *

Three hooks, all cheap to state: {@link #parse}, {@link #isDefinitelyInvalid} and {@link + * #stacklessFailure}. The last is needed only by {@link #get}; {@link #tryGetOrNull} converts the + * failure to {@code null} and never builds an exception while engaged. + * + * @param input type + * @param result type + * @param the failure this latch reacts to (unchecked here, to keep the sketch small) */ - abstract static class ParseHandler { - abstract boolean isDefinitelyInvalid(TInput input); - - abstract TOutput parse(TInput input); + abstract static class DynamicLatch { + private final Class failureType; + private int state; - abstract TException createFastFailException(); - } + DynamicLatch(Class failureType) { + this.failureType = failureType; + } - static final ParseHandler BASE64_PARSE_HANDLER = - new ParseHandler() { - @Override - boolean isDefinitelyInvalid(byte[] input) { - return !looksLikeBase64(input); - } + /** The optimistic parse. May throw {@code X} for bad input. */ + abstract O parse(I input); - @Override - String parse(byte[] input) { - return new String(Base64.getDecoder().decode(input), StandardCharsets.UTF_8); - } + /** A cheap, correct pre-check: true only if the input is definitely invalid. Never throws. */ + abstract boolean isDefinitelyInvalid(I input); - @Override - IllegalArgumentException createFastFailException() { - return new FastFailBase64Exception(); - } - }; - - // Strategy composed at construction time (stored as a field). Contrasted below with - // GenericBreakerParam, which takes the same strategy at call time instead — comparing them is - // the actual point, since a field forces C2 to also prove the receiver is constant before it - // can fold through to a concrete ParseHandler, while a call-time argument doesn't. - static final class GenericBreakerCtor { - private final ParseHandler handler; - int state; + /** A failure to throw while engaged. Must carry no stack trace, and must not be shared. */ + abstract X stacklessFailure(I input); - GenericBreakerCtor(ParseHandler handler) { - this.handler = handler; + /** How many consecutive successes disengage the latch. */ + int closeAfter() { + return CLOSE_THRESHOLD; } - TOutput decode(TInput input) { - if (state > 0 && handler.isDefinitelyInvalid(input)) { - throw handler.createFastFailException(); + /** Flow-through: the caller sees the failure, but while engaged it costs no stack trace. */ + final O get(I input) { + if (state > 0 && isDefinitelyInvalid(input)) { + throw stacklessFailure(input); } try { - TOutput result = handler.parse(input); + O result = parse(input); if (state > 0) { state--; } return result; } catch (RuntimeException e) { - state = CLOSE_THRESHOLD; + if (failureType.isInstance(e)) { + state = closeAfter(); + } throw e; } } - } - - // Same hysteresis, but the strategy is passed at call time rather than stored in a field. - static final class GenericBreakerParam { - int state; - TOutput decode(TInput input, ParseHandler handler) { - if (state > 0 && handler.isDefinitelyInvalid(input)) { - throw handler.createFastFailException(); + /** Converting: {@code null} for bad input. While engaged, no exception is built at all. */ + final O tryGetOrNull(I input) { + if (state > 0 && isDefinitelyInvalid(input)) { + return null; } try { - TOutput result = handler.parse(input); + O result = parse(input); if (state > 0) { state--; } return result; } catch (RuntimeException e) { - state = CLOSE_THRESHOLD; - throw e; + if (!failureType.isInstance(e)) { + throw e; + } + state = closeAfter(); + return null; } } } - final GenericBreakerCtor genericBreakerForValid = - new GenericBreakerCtor<>(BASE64_PARSE_HANDLER); - final GenericBreakerCtor genericBreakerForInvalid = - new GenericBreakerCtor<>(BASE64_PARSE_HANDLER); + /** The Base64 strategy. A named final class, so a field of this type has an exact type. */ + static final class Base64Latch extends DynamicLatch { + Base64Latch() { + super(IllegalArgumentException.class); + } + + @Override + String parse(byte[] input) { + return new String(Base64.getDecoder().decode(input), StandardCharsets.UTF_8); + } + + @Override + boolean isDefinitelyInvalid(byte[] input) { + return !looksLikeBase64(input); + } + + @Override + IllegalArgumentException stacklessFailure(byte[] input) { + return new FastFailBase64Exception(); + } + } + + final Breaker breakerForValid = new Breaker(); + final Breaker breakerForInvalid = new Breaker(); + final Breaker breakerThrowingForValid = new Breaker(); + final Breaker breakerThrowingForInvalid = new Breaker(); - final GenericBreakerParam genericBreakerParamForValid = - new GenericBreakerParam<>(); - final GenericBreakerParam - genericBreakerParamForInvalid = new GenericBreakerParam<>(); + // One latch per arm, each a static final of the exact type, as a call site would hold it. + static final Base64Latch LATCH_FLOW_VALID = new Base64Latch(); + static final Base64Latch LATCH_FLOW_INVALID = new Base64Latch(); + static final Base64Latch LATCH_CONVERT_VALID = new Base64Latch(); + static final Base64Latch LATCH_CONVERT_INVALID = new Base64Latch(); + static final Base64Latch LATCH_FLOW_MIX = new Base64Latch(); + static final Base64Latch LATCH_CONVERT_MIX = new Base64Latch(); + + /** Fails fast if the sketch does not behave as the benchmark assumes. */ + @Setup + public void checkTheSketchBehaves() { + Base64Latch latch = new Base64Latch(); + String expected = new String(Base64.getDecoder().decode(VALID), StandardCharsets.UTF_8); + if (!expected.equals(latch.get(VALID)) || !expected.equals(latch.tryGetOrNull(VALID))) { + throw new IllegalStateException("a valid value must decode"); + } + if (latch.tryGetOrNull(INVALID) != null) { + throw new IllegalStateException("bad input must convert to null"); + } + // the failure above engaged it: the flow-through flavor must now fail without a stack trace + try { + latch.get(INVALID); + throw new IllegalStateException("bad input must throw"); + } catch (FastFailBase64Exception e) { + if (e.getStackTrace().length != 0) { + throw new IllegalStateException("the stand-in must carry no stack trace"); + } + } + if (!expected.equals(latch.get(VALID))) { + throw new IllegalStateException("good input must still decode while engaged"); + } + } @Benchmark public void breakerValid(Blackhole bh) { @@ -245,31 +303,27 @@ public void breakerThrowingInvalid(Blackhole bh) { } @Benchmark - public void genericBreakerValid(Blackhole bh) { - bh.consume(genericBreakerForValid.decode(VALID)); + public void latchFlowValid(Blackhole bh) { + bh.consume(LATCH_FLOW_VALID.get(VALID)); } @Benchmark - public void genericBreakerInvalid(Blackhole bh) { + public void latchFlowInvalid(Blackhole bh) { try { - bh.consume(genericBreakerForInvalid.decode(INVALID)); + bh.consume(LATCH_FLOW_INVALID.get(INVALID)); } catch (IllegalArgumentException e) { bh.consume(e); } } @Benchmark - public void genericBreakerParamValid(Blackhole bh) { - bh.consume(genericBreakerParamForValid.decode(VALID, BASE64_PARSE_HANDLER)); + public void latchConvertValid(Blackhole bh) { + bh.consume(LATCH_CONVERT_VALID.tryGetOrNull(VALID)); } @Benchmark - public void genericBreakerParamInvalid(Blackhole bh) { - try { - bh.consume(genericBreakerParamForInvalid.decode(INVALID, BASE64_PARSE_HANDLER)); - } catch (IllegalArgumentException e) { - bh.consume(e); - } + public void latchConvertInvalid(Blackhole bh) { + bh.consume(LATCH_CONVERT_INVALID.tryGetOrNull(INVALID)); } @Benchmark @@ -293,7 +347,7 @@ public void precheckInvalid(Blackhole bh) { } // Deterministic stream: one INVALID every invalidEveryN calls, VALID otherwise. Each mix - // benchmark gets its own counter/breaker so the three strategies see the identical pattern. + // benchmark gets its own counter and state so the strategies see the identical pattern. @State(Scope.Thread) public static class Mix { @@ -303,10 +357,6 @@ public static class Mix { int counter; final Breaker breaker = new Breaker(); final Breaker throwingBreaker = new Breaker(); - final GenericBreakerCtor genericBreaker = - new GenericBreakerCtor<>(BASE64_PARSE_HANDLER); - final GenericBreakerParam genericBreakerParam = - new GenericBreakerParam<>(); byte[] next() { if (++counter >= invalidEveryN) { @@ -343,22 +393,17 @@ public void mixBreakerThrowing(Mix mix, Blackhole bh) { } @Benchmark - public void mixGenericBreaker(Mix mix, Blackhole bh) { + public void mixLatchFlow(Mix mix, Blackhole bh) { byte[] bytes = mix.next(); try { - bh.consume(mix.genericBreaker.decode(bytes)); + bh.consume(LATCH_FLOW_MIX.get(bytes)); } catch (IllegalArgumentException e) { bh.consume(e); } } @Benchmark - public void mixGenericBreakerParam(Mix mix, Blackhole bh) { - byte[] bytes = mix.next(); - try { - bh.consume(mix.genericBreakerParam.decode(bytes, BASE64_PARSE_HANDLER)); - } catch (IllegalArgumentException e) { - bh.consume(e); - } + public void mixLatchConvert(Mix mix, Blackhole bh) { + bh.consume(LATCH_CONVERT_MIX.tryGetOrNull(mix.next())); } } From 0894b84aba59234199add6030bb5ccadb1ad73db Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 30 Sep 2026 17:07:34 -0400 Subject: [PATCH 3/6] Name the DynamicLatch hooks handle and isKnownToFail Rename the optimistic hook parse to handle, so it reads the same across the Latch family, and the pre-check isDefinitelyInvalid to isKnownToFail, which is not specific to parsers. Naming only. Co-Authored-By: Claude Sonnet 5.5 --- .../trace/api/FunctionsBase64Benchmark.java | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java index 4d8a315a96f..45e9d90c56d 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java @@ -148,7 +148,7 @@ String decodeOrThrow(byte[] bytes) { * It counts calls, not time, never rejects a call, and keeps plain racy state: a stale read costs * one more pre-check or one more exception, never a wrong result. * - *

Three hooks, all cheap to state: {@link #parse}, {@link #isDefinitelyInvalid} and {@link + *

Three hooks, all cheap to state: {@link #handle}, {@link #isKnownToFail} and {@link * #stacklessFailure}. The last is needed only by {@link #get}; {@link #tryGetOrNull} converts the * failure to {@code null} and never builds an exception while engaged. * @@ -164,11 +164,15 @@ abstract static class DynamicLatch { this.failureType = failureType; } - /** The optimistic parse. May throw {@code X} for bad input. */ - abstract O parse(I input); + /** + * The operation, optimistically (for a parser, the parse). May throw {@code X} for bad input. + */ + abstract O handle(I input); - /** A cheap, correct pre-check: true only if the input is definitely invalid. Never throws. */ - abstract boolean isDefinitelyInvalid(I input); + /** + * A cheap, correct pre-check: true only if {@link #handle} would definitely fail. Never throws. + */ + abstract boolean isKnownToFail(I input); /** A failure to throw while engaged. Must carry no stack trace, and must not be shared. */ abstract X stacklessFailure(I input); @@ -180,11 +184,11 @@ int closeAfter() { /** Flow-through: the caller sees the failure, but while engaged it costs no stack trace. */ final O get(I input) { - if (state > 0 && isDefinitelyInvalid(input)) { + if (state > 0 && isKnownToFail(input)) { throw stacklessFailure(input); } try { - O result = parse(input); + O result = handle(input); if (state > 0) { state--; } @@ -199,11 +203,11 @@ final O get(I input) { /** Converting: {@code null} for bad input. While engaged, no exception is built at all. */ final O tryGetOrNull(I input) { - if (state > 0 && isDefinitelyInvalid(input)) { + if (state > 0 && isKnownToFail(input)) { return null; } try { - O result = parse(input); + O result = handle(input); if (state > 0) { state--; } @@ -225,12 +229,12 @@ static final class Base64Latch extends DynamicLatch Date: Wed, 30 Sep 2026 21:23:49 -0400 Subject: [PATCH 4/6] Add benchmark results for the Base64 guard and the DynamicLatch sketch Record a five-fork run on Zulu 17 (M1) in the benchmark's Javadoc: the single-input arms and the mixed-input arms at five invalid rates. The sketch matches the hand-written breaker; the adaptive form is a tradeoff against always pre-checking at high invalid rates. Co-Authored-By: Claude Sonnet 5.5 --- .../trace/api/FunctionsBase64Benchmark.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java index 45e9d90c56d..773f627a39c 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java @@ -33,6 +33,41 @@ * never builds an exception at all while engaged. * *

The hand-written {@link Breaker} is the specialized baseline the latch is compared against. + * + *

Results, one run: Zulu 17.0.7 (HotSpot), MacBook M1, single thread, 5 forks, on a laptop with + * normal background activity (load about 4 to 7). JDK 8 and x86 are not measured. Error margins are + * below 4%. + * + *

+ * ns/op                                valid input  invalid input
+ * unguarded (status quo)                      30.5          905.7
+ * always pre-check                            71.3           2.75
+ * hand-written Breaker, converting            31.2           2.73
+ * hand-written Breaker, throwing              31.4           11.6
+ * DynamicLatch.tryGetOrNull                   31.1           2.78
+ * DynamicLatch.get                            31.2           11.5
+ *
+ * Mix, ns/op, one invalid input in every N
+ *      N  unguarded  pre-check  Breaker  throwing  latch.get  tryGetOrNull
+ *      2      467.1       36.6     47.7      49.9       51.9          47.3
+ *     10      118.4       64.9     71.0      71.3       72.1          70.0
+ *    100       41.9       70.0     46.1      46.4       47.9          45.9
+ *   1000       41.6       70.3     44.7      45.7       45.1          45.6
+ *  10000       34.3       70.2     36.2      35.3       39.6          35.5
+ * 
+ * + * The sketch matches the hand-written breaker: within 0.2 ns in the single-input arms, and within + * about 4 ns in the mixed ones (the largest gap is flow-through at one in 10,000, 39.6 ns against + * 35.3 ns). While engaged, the converting flavor costs about 2.8 ns where the status quo costs + * about 906 ns; the flow-through flavor costs about 11.5 ns, the price of building a stackless + * exception. Always pre-checking more than doubles the cost of valid input (71 ns against 30 ns), + * which is what the adaptive form avoids. + * + *

It is a tradeoff, not a free win. With one invalid input in 100 or rarer, the guard costs 1 to + * 4 ns over doing nothing and is about 24 to 34 ns cheaper than always pre-checking. With a high + * rate (one in 2 or one in 10), always pre-checking is faster than the adaptive guard (36.6 ns + * against about 47 to 52 ns at one in 2, and 65 ns against about 70 to 72 ns at one in 10). The + * cause was not investigated. */ @Fork(2) @Warmup(iterations = 3, time = 1) From 0bf54a671ef3c3514a5ab2b65d0058804c787199 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 2 Oct 2026 08:45:08 -0400 Subject: [PATCH 5/6] Rename the DynamicLatch sketch to AdaptiveLatch and add a fallback Brings the sketch in line with Latch and ClassLatch: tryGetOrNull now returns fallback(input), null unless overridden, for a failed or known-to-fail input. Co-Authored-By: Claude Opus 5.5 --- .../trace/api/FunctionsBase64Benchmark.java | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java index 773f627a39c..8a6ff6dcf89 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java @@ -23,13 +23,13 @@ * kicks in for this call site under sustained repeated throws, or whether the caught path pays for * a full stack trace fill-in every time. * - *

It also measures a reusable form of the guard. {@link DynamicLatch} is a local sketch of the + *

It also measures a reusable form of the guard. {@link AdaptiveLatch} is a local sketch of the * shape discussed in APMLP-1884: an abstract class whose subclass is the strategy (an * optimistic parse, a cheap correct pre-check, and a stackless failure), held in a {@code static * final}. It replaces an earlier pair of generic variants that stored the strategy in a field or * took it at call time, which are not needed once the subclass carries the hooks. It has two public - * flavors: {@link DynamicLatch#get} lets the failure flow to the caller (throwing a stackless - * stand-in while engaged), and {@link DynamicLatch#tryGetOrNull} converts it to {@code null} and + * flavors: {@link AdaptiveLatch#get} lets the failure flow to the caller (throwing a stackless + * stand-in while engaged), and {@link AdaptiveLatch#tryGetOrNull} converts it to {@code null} and * never builds an exception at all while engaged. * *

The hand-written {@link Breaker} is the specialized baseline the latch is compared against. @@ -44,8 +44,8 @@ * always pre-check 71.3 2.75 * hand-written Breaker, converting 31.2 2.73 * hand-written Breaker, throwing 31.4 11.6 - * DynamicLatch.tryGetOrNull 31.1 2.78 - * DynamicLatch.get 31.2 11.5 + * AdaptiveLatch.tryGetOrNull 31.1 2.78 + * AdaptiveLatch.get 31.2 11.5 * * Mix, ns/op, one invalid input in every N * N unguarded pre-check Breaker throwing latch.get tryGetOrNull @@ -61,7 +61,9 @@ * 35.3 ns). While engaged, the converting flavor costs about 2.8 ns where the status quo costs * about 906 ns; the flow-through flavor costs about 11.5 ns, the price of building a stackless * exception. Always pre-checking more than doubles the cost of valid input (71 ns against 30 ns), - * which is what the adaptive form avoids. + * which is what the adaptive form avoids. (Measured before {@code fallback} was added and the + * sketch renamed from {@code DynamicLatch}; the default {@code null} fallback is expected to inline + * away, but has not been re-measured.) * *

It is a tradeoff, not a free win. With one invalid input in 100 or rarer, the guard costs 1 to * 4 ns over doing nothing and is about 24 to 34 ns cheaper than always pre-checking. With a high @@ -175,7 +177,7 @@ String decodeOrThrow(byte[] bytes) { } /** - * Local sketch of a {@code DynamicLatch}: the subclass supplies the strategy, so there is no + * Local sketch of a {@code AdaptiveLatch}: the subclass supplies the strategy, so there is no * separate handler object and no question of storing it in a field versus passing it per call. * *

Two states with hysteresis: closed (optimistic path) and engaged (guarded path). A failure @@ -185,17 +187,18 @@ String decodeOrThrow(byte[] bytes) { * *

Three hooks, all cheap to state: {@link #handle}, {@link #isKnownToFail} and {@link * #stacklessFailure}. The last is needed only by {@link #get}; {@link #tryGetOrNull} converts the - * failure to {@code null} and never builds an exception while engaged. + * failure to {@link #fallback} and never builds an exception while engaged. {@link #fallback} is + * {@code null} unless overridden, as on {@code Latch} and {@code ClassLatch}. * * @param input type * @param result type * @param the failure this latch reacts to (unchecked here, to keep the sketch small) */ - abstract static class DynamicLatch { + abstract static class AdaptiveLatch { private final Class failureType; private int state; - DynamicLatch(Class failureType) { + AdaptiveLatch(Class failureType) { this.failureType = failureType; } @@ -212,6 +215,11 @@ abstract static class DynamicLatch { /** A failure to throw while engaged. Must carry no stack trace, and must not be shared. */ abstract X stacklessFailure(I input); + /** What a failed or known-to-fail input yields from {@link #tryGetOrNull}. */ + O fallback(I input) { + return null; + } + /** How many consecutive successes disengage the latch. */ int closeAfter() { return CLOSE_THRESHOLD; @@ -236,10 +244,10 @@ final O get(I input) { } } - /** Converting: {@code null} for bad input. While engaged, no exception is built at all. */ + /** Converting: {@link #fallback} for bad input. While engaged, no exception is built at all. */ final O tryGetOrNull(I input) { if (state > 0 && isKnownToFail(input)) { - return null; + return fallback(input); } try { O result = handle(input); @@ -252,13 +260,13 @@ final O tryGetOrNull(I input) { throw e; } state = closeAfter(); - return null; + return fallback(input); } } } /** The Base64 strategy. A named final class, so a field of this type has an exact type. */ - static final class Base64Latch extends DynamicLatch { + static final class Base64Latch extends AdaptiveLatch { Base64Latch() { super(IllegalArgumentException.class); } From 59c56f5e35171f05b1fb83690573f1ac8359285e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 2 Oct 2026 09:52:53 -0400 Subject: [PATCH 6/6] Align AdaptiveLatch names with Latch and ClassLatch handle becomes apply and tryGetOrNull becomes tryApply. Also re-aligns the results table after the AdaptiveLatch rename. Co-Authored-By: Claude Opus 5.5 --- .../trace/api/FunctionsBase64Benchmark.java | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java index 8a6ff6dcf89..a909e74d500 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java @@ -29,8 +29,8 @@ * final}. It replaces an earlier pair of generic variants that stored the strategy in a field or * took it at call time, which are not needed once the subclass carries the hooks. It has two public * flavors: {@link AdaptiveLatch#get} lets the failure flow to the caller (throwing a stackless - * stand-in while engaged), and {@link AdaptiveLatch#tryGetOrNull} converts it to {@code null} and - * never builds an exception at all while engaged. + * stand-in while engaged), and {@link AdaptiveLatch#tryApply} converts it to {@code null} and never + * builds an exception at all while engaged. * *

The hand-written {@link Breaker} is the specialized baseline the latch is compared against. * @@ -44,11 +44,11 @@ * always pre-check 71.3 2.75 * hand-written Breaker, converting 31.2 2.73 * hand-written Breaker, throwing 31.4 11.6 - * AdaptiveLatch.tryGetOrNull 31.1 2.78 - * AdaptiveLatch.get 31.2 11.5 + * AdaptiveLatch.tryApply 31.1 2.78 + * AdaptiveLatch.get 31.2 11.5 * * Mix, ns/op, one invalid input in every N - * N unguarded pre-check Breaker throwing latch.get tryGetOrNull + * N unguarded pre-check Breaker throwing latch.get tryApply * 2 467.1 36.6 47.7 49.9 51.9 47.3 * 10 118.4 64.9 71.0 71.3 72.1 70.0 * 100 41.9 70.0 46.1 46.4 47.9 45.9 @@ -61,9 +61,9 @@ * 35.3 ns). While engaged, the converting flavor costs about 2.8 ns where the status quo costs * about 906 ns; the flow-through flavor costs about 11.5 ns, the price of building a stackless * exception. Always pre-checking more than doubles the cost of valid input (71 ns against 30 ns), - * which is what the adaptive form avoids. (Measured before {@code fallback} was added and the - * sketch renamed from {@code DynamicLatch}; the default {@code null} fallback is expected to inline - * away, but has not been re-measured.) + * which is what the adaptive form avoids. (Measured before {@code fallback} was added, while the + * sketch was {@code DynamicLatch} and {@code tryApply} was {@code tryGetOrNull}; the default {@code + * null} fallback is expected to inline away, but has not been re-measured.) * *

It is a tradeoff, not a free win. With one invalid input in 100 or rarer, the guard costs 1 to * 4 ns over doing nothing and is about 24 to 34 ns cheaper than always pre-checking. With a high @@ -185,8 +185,8 @@ String decodeOrThrow(byte[] bytes) { * It counts calls, not time, never rejects a call, and keeps plain racy state: a stale read costs * one more pre-check or one more exception, never a wrong result. * - *

Three hooks, all cheap to state: {@link #handle}, {@link #isKnownToFail} and {@link - * #stacklessFailure}. The last is needed only by {@link #get}; {@link #tryGetOrNull} converts the + *

Three hooks, all cheap to state: {@link #apply}, {@link #isKnownToFail} and {@link + * #stacklessFailure}. The last is needed only by {@link #get}; {@link #tryApply} converts the * failure to {@link #fallback} and never builds an exception while engaged. {@link #fallback} is * {@code null} unless overridden, as on {@code Latch} and {@code ClassLatch}. * @@ -205,17 +205,17 @@ abstract static class AdaptiveLatch { /** * The operation, optimistically (for a parser, the parse). May throw {@code X} for bad input. */ - abstract O handle(I input); + abstract O apply(I input); /** - * A cheap, correct pre-check: true only if {@link #handle} would definitely fail. Never throws. + * A cheap, correct pre-check: true only if {@link #apply} would definitely fail. Never throws. */ abstract boolean isKnownToFail(I input); /** A failure to throw while engaged. Must carry no stack trace, and must not be shared. */ abstract X stacklessFailure(I input); - /** What a failed or known-to-fail input yields from {@link #tryGetOrNull}. */ + /** What a failed or known-to-fail input yields from {@link #tryApply}. */ O fallback(I input) { return null; } @@ -231,7 +231,7 @@ final O get(I input) { throw stacklessFailure(input); } try { - O result = handle(input); + O result = apply(input); if (state > 0) { state--; } @@ -245,12 +245,12 @@ final O get(I input) { } /** Converting: {@link #fallback} for bad input. While engaged, no exception is built at all. */ - final O tryGetOrNull(I input) { + final O tryApply(I input) { if (state > 0 && isKnownToFail(input)) { return fallback(input); } try { - O result = handle(input); + O result = apply(input); if (state > 0) { state--; } @@ -272,7 +272,7 @@ static final class Base64Latch extends AdaptiveLatch