diff --git a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8Benchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8Benchmark.java
index 69631eba694..89e6c075a5a 100644
--- a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8Benchmark.java
+++ b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8Benchmark.java
@@ -6,6 +6,8 @@
import datadog.communication.serialization.GenerationalUtf8Cache;
import datadog.communication.serialization.SimpleUtf8Cache;
+import datadog.trace.api.cache.DDCache;
+import datadog.trace.api.cache.DDCaches;
import java.nio.charset.StandardCharsets;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
@@ -23,6 +25,11 @@
* gc" to check bytes / op. Since {@link String#getBytes(java.nio.charset.Charset)} is intrinsified
* the caches typically perform worse throughput wise; the benefit of the caches is to reduce
* allocation.
+ *
+ *
{@link DDCache} (general-purpose, no eviction quality control, no recalibration) is included
+ * as a baseline "naive cache" arm alongside the purpose-built {@link SimpleUtf8Cache} / {@link
+ * GenerationalUtf8Cache}: it has no seen-once filtering, so every miss -- including one-time
+ * "custom" values -- immediately allocates and stores an entry, unlike the other two.
*/
@BenchmarkMode(Mode.Throughput)
public class Utf8Benchmark {
@@ -49,6 +56,14 @@ public static final byte[] tagUtf8_w_cache() {
return tag.getBytes(StandardCharsets.UTF_8);
}
+ static final DDCache DDCACHE_TAG_CACHE = DDCaches.newFixedSizeCache(128);
+
+ @Benchmark
+ public static final byte[] tagUtf8_w_ddcache() {
+ String tag = nextTag();
+ return DDCACHE_TAG_CACHE.computeIfAbsent(tag, t -> t.getBytes(StandardCharsets.UTF_8));
+ }
+
@Benchmark
public static final void valueUtf8_baseline(Blackhole bh) {
for (int i = 0; i < NUM_LOOKUPS; ++i) {
@@ -92,6 +107,23 @@ public static final void valueUtf8_cache_simple(Blackhole bh) {
}
}
+ // Matches GENERATIONAL_VALUE_CACHE's combined eden+tenured capacity (64 + 128).
+ static final DDCache DDCACHE_VALUE_CACHE = DDCaches.newFixedSizeCache(192);
+
+ @Benchmark
+ public static final void valueUtf8_cache_ddcache(Blackhole bh) {
+ DDCache valueCache = DDCACHE_VALUE_CACHE;
+ // No recalibrate() call: DDCache has no decay/eviction-quality bookkeeping to drive.
+
+ for (int i = 0; i < NUM_LOOKUPS; ++i) {
+ String tag = nextTag();
+ String value = nextValue(tag);
+
+ byte[] lookup = valueCache.computeIfAbsent(value, v -> v.getBytes(StandardCharsets.UTF_8));
+ bh.consume(lookup);
+ }
+ }
+
@Benchmark
public static final void valueUtf8_nocache(Blackhole bh) {
for (int i = 0; i < NUM_LOOKUPS; ++i) {
diff --git a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8CacheSizeBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8CacheSizeBenchmark.java
new file mode 100644
index 00000000000..8ffd37a6159
--- /dev/null
+++ b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8CacheSizeBenchmark.java
@@ -0,0 +1,153 @@
+package datadog.trace.common.writer.ddagent;
+
+import static datadog.trace.common.writer.ddagent.Utf8Workload.NUM_LOOKUPS;
+import static datadog.trace.common.writer.ddagent.Utf8Workload.nextTag;
+import static datadog.trace.common.writer.ddagent.Utf8Workload.nextValue;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import datadog.communication.serialization.EncodingCache;
+import datadog.communication.serialization.GenerationalUtf8Cache;
+import datadog.communication.serialization.SimpleUtf8Cache;
+import datadog.trace.api.cache.DDCache;
+import datadog.trace.api.cache.DDCaches;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Mode;
+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.infra.Blackhole;
+
+/**
+ * Sweeps cache capacity for all three caches against the same {@link Utf8Workload}, since the fixed
+ * sizes in {@link Utf8Benchmark} (128, 192) only show each cache at one operating point. The
+ * capacities picked -- 10, 100, 1000 -- roughly bracket the range actually used in production (a
+ * handful of near-static tag names up to {@link SimpleUtf8Cache#MAX_CAPACITY}), so this shows how
+ * each design degrades as the workload's ~180-entry stable set plus unbounded "custom" churn
+ * outgrows or comfortably fits the budget.
+ *
+ * Best paired with {@code -prof gc}: the interesting signal is allocation (entries created,
+ * bytes/op), not throughput -- see {@link Utf8Benchmark}'s note on why raw ops/s undersells the
+ * caches relative to the intrinsified {@code getBytes} baseline.
+ *
+ *
{@code NOCACHE} is the uncached baseline (always calls {@code getBytes} directly) -- it
+ * ignores {@code capacity}, so it's redundant across the three capacity values, but keeping it in
+ * the same parameter sweep makes it trivial to eyeball against every row of the cached variants.
+ *
+ *
Results (capacity sweep, {@code -prof gc}, normalized)
+ *
+ * Throughput in ops/s, allocation in bytes/op, GC rate normalized to counts/sec ({@code
+ * gc.count}/{@code gc.time} are cumulative sums over the whole run, so raw values aren't comparable
+ * across runs with different fork counts without dividing by total measurement time).
+ *
+ *
+ * capacity kind ops/s alloc B/op alloc vs. NOCACHE alloc MB/s GC/s GC time %
+ * 10 NOCACHE 2007.9 1,247,228 -- 2388.3 3.98 0.22%
+ * 10 DDCACHE 1315.7 1,447,111 +16.0% (worse) 1815.7 3.03 0.17%
+ * 10 SIMPLE 1401.6 1,305,537 +4.7% (worse) 1745.1 2.91 0.16%
+ * 10 GENERATIONAL 1205.6 1,318,938 +5.7% (worse) 1516.4 2.53 0.15%
+ * 100 NOCACHE 2003.6 1,247,238 -- 2383.1 3.96 0.22%
+ * 100 DDCACHE 1397.4 1,248,407 +0.1% (breakeven) 1663.7 2.77 0.16%
+ * 100 SIMPLE 1451.3 1,161,541 -6.9% 1607.6 2.68 0.15%
+ * 100 GENERATIONAL 1422.4 1,046,133 -16.1% 1419.0 2.36 0.13%
+ * 1000 NOCACHE 1966.6 1,247,231 -- 2339.1 3.88 0.22%
+ * 1000 DDCACHE 1648.9 1,034,356 -17.1% 1626.6 2.72 0.20%
+ * 1000 SIMPLE 1641.3 974,316 -21.9% 1525.1 2.54 0.14%
+ * 1000 GENERATIONAL 1709.5 961,062 -23.0% 1566.8 2.61 0.15%
+ *
+ *
+ * Key findings
+ *
+ *
+ * - All three caches lose on raw throughput to the uncached baseline at every capacity tested.
+ * {@link java.lang.String#getBytes(java.nio.charset.Charset)} is intrinsified, so a cache
+ * lookup is strictly more expensive per-op than just re-encoding -- these caches only pay off
+ * on allocation, not throughput. Any cache meant to reduce app-thread allocation has to beat
+ * a TLAB bump-pointer allocation, which is a very cheap operation to beat.
+ *
- Allocation reduction is real but smaller than naively expected -- at capacity 1000 all
+ * three caches reduce allocation by only ~17-23%, not the ~90% you'd guess from hit rate
+ * alone. Root cause: {@link Utf8Workload#nextStandardValue}/{@link
+ * Utf8Workload#nextCustomValue} build each value via {@code tag + int} string concatenation
+ * on every lookup, which allocates a fresh {@code String} regardless of whether the
+ * subsequent UTF-8 encoding is cached. For ASCII-compact strings, {@code getBytes(UTF_8)} is
+ * itself just a copy of the existing byte array, so a cache hit only saves that one array
+ * copy -- not the string-construction cost. This structurally caps the max possible
+ * allocation win for any UTF8 cache on this workload.
+ *
- {@code DDCACHE} has a consistent allocation disadvantage vs. {@code SIMPLE}/{@code
+ * GENERATIONAL} (worst at capacity 10, where it's the only arm to allocate more than
+ * {@code NOCACHE}), from its generic {@code Pair.of(key, value)} wrapper allocated on every
+ * stored miss ({@code FixedSizeCache.produceAndStoreValue}). {@code SIMPLE}/{@code
+ * GENERATIONAL} avoid this on one-off values via a marker/bloom-style gate ({@code
+ * Caching.mark}) that defers the wrapper allocation to a value's second observed
+ * touch -- same immutable-wrapper-for-lock-freedom pattern as {@code DDCACHE}, but the
+ * wrapper cost is paid only for values that actually recur. The marker is advisory, not
+ * authoritative: a wrong mark only costs a redundant allocation on the next touch, it can
+ * never cause the cache to return incorrect data.
+ *
- {@code SIMPLE} and {@code GENERATIONAL} are close to equal at capacity 100, with {@code
+ * GENERATIONAL} pulling ahead at capacity 1000 -- worth checking what capacity these caches
+ * actually run at in production before assuming {@code SIMPLE}'s extra complexity over {@code
+ * GENERATIONAL} is still earning its keep.
+ *
+ */
+@BenchmarkMode(Mode.Throughput)
+@State(Scope.Thread)
+public class Utf8CacheSizeBenchmark {
+
+ public enum CacheKind {
+ NOCACHE,
+ DDCACHE,
+ SIMPLE,
+ GENERATIONAL
+ }
+
+ @Param({"10", "100", "1000"})
+ int capacity;
+
+ @Param({"NOCACHE", "DDCACHE", "SIMPLE", "GENERATIONAL"})
+ CacheKind cacheKind;
+
+ EncodingCache cache;
+
+ // Only Simple/Generational have anything to recalibrate; DDCache is a no-op here.
+ Runnable recalibrate;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ switch (cacheKind) {
+ case NOCACHE:
+ cache = value -> value.toString().getBytes(UTF_8);
+ recalibrate = () -> {};
+ break;
+ case DDCACHE:
+ DDCache ddCache = DDCaches.newFixedSizeCache(capacity);
+ cache = value -> ddCache.computeIfAbsent(value.toString(), v -> v.getBytes(UTF_8));
+ recalibrate = () -> {};
+ break;
+ case SIMPLE:
+ SimpleUtf8Cache simpleCache = new SimpleUtf8Cache(capacity);
+ cache = simpleCache;
+ recalibrate = simpleCache::recalibrate;
+ break;
+ case GENERATIONAL:
+ GenerationalUtf8Cache generationalCache = new GenerationalUtf8Cache(capacity);
+ cache = generationalCache;
+ recalibrate = generationalCache::recalibrate;
+ break;
+ }
+ }
+
+ @Benchmark
+ public void lookup(Blackhole bh) {
+ // Single thread drives recalibrate inline, at a transaction boundary -- see Utf8Benchmark.
+ recalibrate.run();
+
+ for (int i = 0; i < NUM_LOOKUPS; ++i) {
+ String tag = nextTag();
+ String value = nextValue(tag);
+
+ bh.consume(cache.encode(value));
+ }
+ }
+}
diff --git a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8ConcurrentBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8ConcurrentBenchmark.java
index 51080906bec..3135909a96d 100644
--- a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8ConcurrentBenchmark.java
+++ b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/Utf8ConcurrentBenchmark.java
@@ -6,6 +6,9 @@
import datadog.communication.serialization.GenerationalUtf8Cache;
import datadog.communication.serialization.SimpleUtf8Cache;
+import datadog.trace.api.cache.DDCache;
+import datadog.trace.api.cache.DDCaches;
+import java.nio.charset.StandardCharsets;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Group;
@@ -29,12 +32,17 @@
*
* The recalibrate thread runs continuously, which is deliberately more aggressive than a real
* periodic cadence — it maximizes contention so this doubles as a concurrency guardrail.
+ *
+ *
{@link DDCache} has no recalibrate step, so its group uses 8 lookup threads (no dedicated
+ * recalibrate thread) against the same shared cache -- a pure contention/throughput comparison
+ * rather than a recalibrate-race guardrail.
*/
@BenchmarkMode(Mode.Throughput)
@State(Scope.Group)
public class Utf8ConcurrentBenchmark {
static final GenerationalUtf8Cache VALUE_CACHE = new GenerationalUtf8Cache(64, 128);
static final SimpleUtf8Cache SIMPLE_VALUE_CACHE = new SimpleUtf8Cache(128);
+ static final DDCache DDCACHE_VALUE_CACHE = DDCaches.newFixedSizeCache(192);
@Benchmark
@Group("generational")
@@ -69,4 +77,16 @@ public void simple_lookup(Blackhole bh) {
public void simple_recalibrate() {
SIMPLE_VALUE_CACHE.recalibrate();
}
+
+ @Benchmark
+ @Group("ddcache")
+ @GroupThreads(8)
+ public void ddcache_lookup(Blackhole bh) {
+ for (int i = 0; i < NUM_LOOKUPS; ++i) {
+ String tag = nextTag();
+ String value = nextValue(tag);
+ bh.consume(
+ DDCACHE_VALUE_CACHE.computeIfAbsent(value, v -> v.getBytes(StandardCharsets.UTF_8)));
+ }
+ }
}