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..a909e74d500 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/api/FunctionsBase64Benchmark.java @@ -0,0 +1,456 @@ +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.Setup; +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. + * + *
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 AdaptiveLatch#get} lets the failure flow to the caller (throwing a stackless + * 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. + * + *
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 + * 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 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 + * 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. (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
+ * 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)
+@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 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";
+
+ 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;
+ }
+ }
+ }
+
+ /**
+ * 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
+ * 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 #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}.
+ *
+ * @param input type
+ * @param