Skip to content

Commit b294856

Browse files
dougqhclaude
andcommitted
Add per-operation self-tuning dense-store sizing (SizingHint)
Sizes each span's dense TagMap store from a per-operation hint instead of a generic default, then feeds the actual known-tag count back on finish so the hint converges to the operation's real high-water mark. - FlatHashtable: open-addressed find-or-create over self-contained entries; static-polymorphism via a concrete-typed Helper singleton so the JIT devirtualizes/inlines hash/matches/create per call site. - SizingHint / SizingHelper: opaque per-operation hint + its String-key helper. - SizingHintTable: process-wide, pure-static two-lane (entry vs child) registry keyed by operation name; fixed-capacity + deliberately lock-free/racy (a lost update or double-mint only mis-sizes an array, never corrupts tag data). - CoreTracer.buildSpanContext resolves the hint by operationName + entry-ness and hands it to DDSpanContext, which sizes its TagMap and records the size back via DDSpan.finishAndAddToTrace. - Tests: FlatHashtableTest, SizingHintTableTest (incl. self-tuning + content-keying). - Benchmarks: FlatHashtableBenchmark; DenseStoreAllocBenchmark buildMapSized arm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e6800b8 commit b294856

13 files changed

Lines changed: 782 additions & 7 deletions

File tree

dd-trace-core/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ jmh {
134134
if (project.hasProperty('jmh.profilers')) {
135135
profilers = project.property('jmh.profilers').tokenize(',')
136136
}
137+
if (project.hasProperty('jmh.jvmArgs')) {
138+
jvmArgsAppend = project.property('jmh.jvmArgs').tokenize(' ')
139+
}
137140
if (project.hasProperty('testJvm')) {
138141
def testJvmSpec = new TestJvmSpec(project)
139142
jvm = testJvmSpec.javaTestLauncher.map { it.executablePath.asFile.absolutePath }

dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
import datadog.trace.api.InstrumenterConfig;
4343
import datadog.trace.api.KnownTags;
4444
import datadog.trace.api.Pair;
45+
import datadog.trace.api.SizingHint;
46+
import datadog.trace.api.SizingHintTable;
4547
import datadog.trace.api.TagMap;
4648
import datadog.trace.api.TraceConfig;
4749
import datadog.trace.api.civisibility.config.BazelMode;
@@ -659,7 +661,8 @@ private CoreTracer(
659661

660662
// Dense known-tag store (experimental, OFF by default): registering the KnownTagCodec resolver
661663
// flips the dense store live so known tags store without a per-tag Entry. Gated by a system
662-
// property for A/B benchmarking; when off, keyOf stays a no-op and tag storage is byte-identical
664+
// property for A/B benchmarking; when off, keyOf stays a no-op and tag storage is
665+
// byte-identical
663666
// to today. Promote to a Config flag if this becomes a permanent rollout.
664667
if (Boolean.getBoolean("dd.trace.dense.tags.enabled")) {
665668
KnownTags.init();
@@ -2189,6 +2192,14 @@ protected static final DDSpanContext buildSpanContext(
21892192
requestContextDataIast = builderRequestContextDataIast;
21902193
}
21912194

2195+
// Per-type dense-store sizing: an entry (local-root) span carries the trace-metadata /
2196+
// enriching tags a child doesn't, so pick the lane by whether we have a local parent. A
2197+
// resolved hint sizes the span's TagMap and self-tunes on finish; null (no/unkeyable
2198+
// operation
2199+
// name) falls back to the generic default capacity.
2200+
final boolean entrySpan = !(resolvedParentSpanContext instanceof DDSpanContext);
2201+
final SizingHint sizingHint = SizingHintTable.hintFor(operationName, entrySpan);
2202+
21922203
// some attributes are inherited from the parent
21932204
context =
21942205
new DDSpanContext(
@@ -2217,7 +2228,8 @@ protected static final DDSpanContext buildSpanContext(
22172228
tracer.profilingContextIntegration,
22182229
tracer.injectBaggageAsTags,
22192230
tracer.injectLinksAsTags,
2220-
mergedTracerTagsNeedsIntercept ? null : mergedTracerTags);
2231+
mergedTracerTagsNeedsIntercept ? null : mergedTracerTags,
2232+
sizingHint);
22212233

22222234
// By setting the tags on the context we apply decorators to any tags that have been set via
22232235
// the builder. This is the order that the tags were added previously, but maybe the `tags`

dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ public boolean isFinished() {
157157
private void finishAndAddToTrace(final long durationNano) {
158158
// ensure a min duration of 1
159159
if (DURATION_NANO_UPDATER.compareAndSet(this, 0, Math.max(1, durationNano))) {
160+
context.recordDenseSize();
160161
setLongRunningVersion(-this.longRunningVersion);
161162
SpanWrapper wrapper = getWrapper();
162163
if (wrapper != null) {

dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import datadog.trace.api.DDTraceId;
1313
import datadog.trace.api.Functions;
1414
import datadog.trace.api.ProcessTags;
15+
import datadog.trace.api.SizingHint;
1516
import datadog.trace.api.TagMap;
1617
import datadog.trace.api.cache.DDCache;
1718
import datadog.trace.api.cache.DDCaches;
@@ -138,6 +139,13 @@ public class DDSpanContext
138139
*/
139140
private final TagMap unsafeTags;
140141

142+
// Per-type sizing hint (e.g. a SpanPrototype) this span was created from, if any. Held so the
143+
// span
144+
// can feed its final dense-store size back on finish (recordSize) -- the self-tuning loop that
145+
// lets
146+
// the reused hint converge to the type's real known-tag high-water mark. Null when unsized.
147+
private final SizingHint sizingHint;
148+
141149
/** The service name is required, otherwise the span are dropped by the agent */
142150
private volatile String serviceName;
143151

@@ -244,6 +252,7 @@ public DDSpanContext(
244252
ProfilingContextIntegration.NoOp.INSTANCE,
245253
true,
246254
true,
255+
null,
247256
null);
248257
}
249258

@@ -295,6 +304,7 @@ public DDSpanContext(
295304
ProfilingContextIntegration.NoOp.INSTANCE,
296305
injectBaggageAsTags,
297306
injectLinksAsTags,
307+
null,
298308
null);
299309
}
300310

@@ -351,6 +361,7 @@ public DDSpanContext(
351361
profilingContextIntegration,
352362
injectBaggageAsTags,
353363
injectLinksAsTags,
364+
null,
354365
null);
355366
}
356367

@@ -380,7 +391,8 @@ public DDSpanContext(
380391
final ProfilingContextIntegration profilingContextIntegration,
381392
final boolean injectBaggageAsTags,
382393
final boolean injectLinksAsTags,
383-
final TagMap readThroughParent) {
394+
final TagMap readThroughParent,
395+
final SizingHint sizingHint) {
384396

385397
assert traceCollector != null;
386398
this.traceCollector = traceCollector;
@@ -412,7 +424,8 @@ public DDSpanContext(
412424
this.unsafeTags =
413425
readThroughParent != null
414426
? TagMap.createFromParent(readThroughParent)
415-
: TagMap.create(capacity);
427+
: sizingHint != null ? TagMap.create(sizingHint) : TagMap.create(capacity);
428+
this.sizingHint = sizingHint;
416429

417430
// must set this before setting the service and resource names below
418431
this.profilingContextIntegration = profilingContextIntegration;
@@ -963,6 +976,22 @@ public void setTag(final String tag, final String value) {
963976
}
964977
}
965978

979+
/**
980+
* Feeds this span's final dense-store size back into the sizing hint it was created from (if
981+
* any). Called once at span finish; lets a reused hint (e.g. a per-type SpanPrototype) self-tune
982+
* to the type's observed known-tag high-water mark, so subsequent spans of the type are sized
983+
* correctly.
984+
*/
985+
void recordDenseSize() {
986+
if (sizingHint != null) {
987+
// Intentionally unsynchronized: recordSize is benign-racy by design (monotonic-max on a plain
988+
// int), and a stale knownCount read only ever costs one extra growth on a later span. The
989+
// lock
990+
// would add nothing but contention on the finish path.
991+
unsafeTags.recordSize(sizingHint);
992+
}
993+
}
994+
966995
public void setTag(TagMap.EntryReader entry) {
967996
if (entry == null) {
968997
return;

internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ public class DenseStoreAllocBenchmark {
9090

9191
private String[] keys;
9292
private String[] values;
93+
// Per-type sizing hint seeded to this scenario's known-tag count -- what a SpanPrototype
94+
// supplies.
95+
private SizingHint prototype;
9396

9497
@Setup(Level.Trial)
9598
public void setup() {
@@ -108,8 +111,11 @@ public void setup() {
108111
this.keys[i] = i < knownCount ? KNOWN[i] : "custom.tag." + i;
109112
this.values[i] = "value-" + i;
110113
}
114+
// Size the dense store to the known-tag count (the "end state" per-type sizing).
115+
this.prototype = new SizingHint("bench", 0, Math.max(knownCount, 1));
111116
}
112117

118+
/** Current: generic default dense capacity (KNOWN_INIT_CAP=12) -- over-provisions small types. */
113119
@Benchmark
114120
public TagMap buildMap() {
115121
TagMap m = TagMap.create(16);
@@ -119,6 +125,18 @@ public TagMap buildMap() {
119125
return m;
120126
}
121127

128+
/**
129+
* End state: dense store sized per-type via a SpanPrototype (SizingHint) -- no over-provision.
130+
*/
131+
@Benchmark
132+
public TagMap buildMapSized() {
133+
TagMap m = TagMap.create(prototype);
134+
for (int i = 0; i < tagCount; i++) {
135+
m.set(keys[i], values[i]);
136+
}
137+
return m;
138+
}
139+
122140
@Benchmark
123141
public void buildAndSerialize(Blackhole bh) {
124142
TagMap m = TagMap.create(16);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package datadog.trace.util;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
import java.util.concurrent.TimeUnit;
6+
import org.openjdk.jmh.annotations.Benchmark;
7+
import org.openjdk.jmh.annotations.BenchmarkMode;
8+
import org.openjdk.jmh.annotations.Fork;
9+
import org.openjdk.jmh.annotations.Measurement;
10+
import org.openjdk.jmh.annotations.Mode;
11+
import org.openjdk.jmh.annotations.OutputTimeUnit;
12+
import org.openjdk.jmh.annotations.Scope;
13+
import org.openjdk.jmh.annotations.Setup;
14+
import org.openjdk.jmh.annotations.State;
15+
import org.openjdk.jmh.annotations.Threads;
16+
import org.openjdk.jmh.annotations.Warmup;
17+
import org.openjdk.jmh.infra.Blackhole;
18+
19+
/**
20+
* Directional: is the {@link FlatHashtable} hit-path lookup (what a span pays per create, all hits
21+
* after warmup) cheap vs a {@link HashMap}? Concrete-typed {@code static final} helper so the
22+
* static-poly specialization is in play. One op = one pass over the op-name set. Single-threaded,
23+
* short — a rough signal, not a verdict (real numbers on the box).
24+
*/
25+
@State(Scope.Thread)
26+
@BenchmarkMode(Mode.AverageTime)
27+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
28+
@Warmup(iterations = 3, time = 1)
29+
@Measurement(iterations = 3, time = 1)
30+
@Fork(1)
31+
@Threads(1)
32+
public class FlatHashtableBenchmark {
33+
34+
static final class StrHelper extends FlatHashtable.StringHelper<String> {
35+
@Override
36+
public boolean matches(String key, String value) {
37+
return key == value || key.equals(value);
38+
}
39+
40+
@Override
41+
public String create(String key) {
42+
return key; // store the key itself as the (self-identifying) entry
43+
}
44+
}
45+
46+
private static final StrHelper HELPER = new StrHelper();
47+
48+
private static final String[] KEYS = {
49+
"servlet.request",
50+
"database.query",
51+
"http.request",
52+
"grpc.client",
53+
"kafka.produce",
54+
"kafka.consume",
55+
"jdbc.query",
56+
"spring.handler",
57+
"servlet.forward",
58+
"okhttp.request"
59+
};
60+
61+
private String[] table;
62+
private Map<String, String> map;
63+
64+
@Setup
65+
public void setup() {
66+
table = FlatHashtable.create(String.class, KEYS.length);
67+
map = new HashMap<>(KEYS.length * 2);
68+
for (String k : KEYS) {
69+
FlatHashtable.getOrCreate(table, k, HELPER);
70+
map.put(k, k);
71+
}
72+
}
73+
74+
/** FlatHashtable all-hit lookups (concrete helper → specialized). */
75+
@Benchmark
76+
public void flatGet(Blackhole bh) {
77+
for (String k : KEYS) {
78+
bh.consume(FlatHashtable.get(table, k, HELPER));
79+
}
80+
}
81+
82+
/** Steady-state span-create shape: get-then-getOrCreate, all hits. */
83+
@Benchmark
84+
public void flatGetOrCreate(Blackhole bh) {
85+
for (String k : KEYS) {
86+
bh.consume(FlatHashtable.getOrCreate(table, k, HELPER));
87+
}
88+
}
89+
90+
/** Baseline: HashMap lookups over the same keys. */
91+
@Benchmark
92+
public void hashMapGet(Blackhole bh) {
93+
for (String k : KEYS) {
94+
bh.consume(map.get(k));
95+
}
96+
}
97+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package datadog.trace.api;
2+
3+
import datadog.trace.util.FlatHashtable;
4+
5+
/**
6+
* {@link FlatHashtable} policy for the per-operation {@link SizingHint} table: keys by operation
7+
* name, entries are {@code SizingHint}s carrying that name plus its cached spread hash. Stateless —
8+
* held by {@link SizingHintTable} as a concrete-typed {@code static final} singleton so {@code
9+
* FlatHashtable.get}/{@code getOrCreate} specialize (devirtualize + inline) at the call site.
10+
*
11+
* <p>Extends {@link FlatHashtable.StringHelper}, which seals the spread {@code hash}; this class
12+
* only supplies {@code matches} and {@code create}. Both use the inherited {@code hash} so the
13+
* cached {@link SizingHint#labelHash} is always the same spread the probe used.
14+
*/
15+
final class SizingHelper extends FlatHashtable.StringHelper<SizingHint> {
16+
@Override
17+
public boolean matches(String key, SizingHint value) {
18+
// int gate on the cached hash before equals; op-names are usually interned literals, so `==` is
19+
// the common hit.
20+
return value.labelHash == hash(key) && (key == value.label || key.equals(value.label));
21+
}
22+
23+
@Override
24+
public SizingHint create(String key) {
25+
return new SizingHint(key, hash(key), SizingHintTable.SEED_SIZE);
26+
}
27+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package datadog.trace.api;
2+
3+
/**
4+
* Opaque per-operation dense-store sizing hint, and a self-contained {@link
5+
* datadog.trace.util.FlatHashtable} slot: it carries everything the probe compares ({@link #label}
6+
* + cached {@link #labelHash}) plus the tuned payload ({@link #size}). Holding key, hash, and value
7+
* in ONE object behind ONE array slot is deliberate — entry publication is a single reference
8+
* store, so a reader sees {@code null} or a complete entry (never a torn one), and the {@code
9+
* final} identity fields are visible even under racy publication (JMM final-field guarantee). That
10+
* sidesteps the memory-ordering / visibility problems parallel key/hash/value arrays would create,
11+
* no volatile or atomics.
12+
*
13+
* <p>Opaque to everything outside {@code datadog.trace.api}: no public members. {@link TagMap}
14+
* reads {@link #size} to size a fresh dense store and writes it back (monotonic-max) at a terminal
15+
* point; {@code SizingHelper} mints and compares by {@link #label}/{@link #labelHash}. Callers only
16+
* ever hold the reference.
17+
*
18+
* <p>{@link #labelHash} is supplied by the helper (a single spread source — {@code
19+
* FlatHashtable.StringHelper.hash}) so the cached gate always matches the probe hash.
20+
*/
21+
public final class SizingHint {
22+
// Identity: final => safely published under a racy single-reference store. `label` is the
23+
// operation
24+
// name (typically an interned literal, so the `==` fast-path usually hits). `labelHash` is the
25+
// helper's spread hash, cached to gate `equals` with an int compare during probing.
26+
final String label;
27+
final int labelHash;
28+
29+
// Payload: the tuned dense-store size. Plain racy int, updated monotonic-max — a stale/lost read
30+
// only mis-sizes an array (over/under-provision), never corrupts tag data, so no synchronization.
31+
int size;
32+
33+
// When true, {@code size} is fixed and recordSize won't grow it. For the shared default /
34+
// overflow
35+
// hint (a HETEROGENEOUS catch-all for operation-less / over-budget spans): self-tuning it via
36+
// monotonic-max would converge to the max across unlike sharers and over-provision the lean ones.
37+
final boolean capped;
38+
39+
SizingHint(String label, int labelHash, int seedSize) {
40+
this(label, labelHash, seedSize, false);
41+
}
42+
43+
SizingHint(String label, int labelHash, int seedSize, boolean capped) {
44+
this.label = label;
45+
this.labelHash = labelHash;
46+
this.size = seedSize;
47+
this.capped = capped;
48+
}
49+
}

0 commit comments

Comments
 (0)