Skip to content

Commit cca1b81

Browse files
dougqhclaude
andcommitted
Add id-keyed TagMap.set(long) + insertion-comparison benchmark
set(long id, Object value) is the id-keyed insertion path: the caller passes a resolved KnownTags id, so it skips the keyOf name resolution the set(String, ...) methods pay and stores densely. The id must be a stored known-tag id; custom names have no id and use the name-keyed setters. The name is resolved lazily only to clear a read-through tombstone (rare). Adds TagMapInsertionComparisonBenchmark (dense vs HashMap insertion, -prof gc) — the isolated micro where the dense store + bloom insertion win shows, and the home for the id-keyed vs string-keyed comparison. Reconciled onto the tag registry (#11961). Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 956f2c9 commit cca1b81

2 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package datadog.trace.api;
2+
3+
import static java.util.concurrent.TimeUnit.SECONDS;
4+
5+
import datadog.trace.bootstrap.instrumentation.api.Tags;
6+
import java.util.HashMap;
7+
import java.util.Map;
8+
import org.openjdk.jmh.annotations.Benchmark;
9+
import org.openjdk.jmh.annotations.BenchmarkMode;
10+
import org.openjdk.jmh.annotations.Fork;
11+
import org.openjdk.jmh.annotations.Level;
12+
import org.openjdk.jmh.annotations.Measurement;
13+
import org.openjdk.jmh.annotations.Mode;
14+
import org.openjdk.jmh.annotations.OutputTimeUnit;
15+
import org.openjdk.jmh.annotations.Param;
16+
import org.openjdk.jmh.annotations.Scope;
17+
import org.openjdk.jmh.annotations.Setup;
18+
import org.openjdk.jmh.annotations.State;
19+
import org.openjdk.jmh.annotations.Threads;
20+
import org.openjdk.jmh.annotations.Warmup;
21+
22+
/**
23+
* Insertion comparison for the deck's "how do we do vs HashMap / TagMap 1.0" and "id vs name"
24+
* claims (slides 3 / 7 / 8). Same tag count, four ways:
25+
*
26+
* <ul>
27+
* <li><b>hashMap</b> — {@code HashMap.put(name, value)}. The baseline every ratio is quoted
28+
* against (so slides reconcile across runs).
29+
* <li><b>tagMapByName</b> — {@code TagMap.set(name, value)} with known names: {@code keyOf} hit +
30+
* dense store. This is the 2.0 name path. (Run on <i>master</i>, the same arm is true 1.0 —
31+
* master's {@code set(name)} has no {@code keyOf} — so master-vs-branch on this arm, both
32+
* normalized to hashMap, is the 2.0-vs-1.0 comparison.)
33+
* <li><b>tagMapById</b> — {@code TagMap.set(id, value)} with pre-resolved {@code KnownTags} ids:
34+
* dense store, NO {@code keyOf}. The 2.0 id path. tagMapById-vs-tagMapByName is the {@code
35+
* keyOf} tax that instrumentation recovers by migrating to ids (slide 7/8).
36+
* <li><b>tagMapCustom</b> — {@code set(customName, value)}: {@code keyOf} miss + bucket + Entry
37+
* (the unknown/custom-tag path).
38+
* </ul>
39+
*
40+
* <p>Run with {@code -prof gc} for the allocation columns (deterministic). The throughput columns
41+
* are thermal-fragile — quote them only from a quiet machine, and take the id-vs-name and
42+
* vs-HashMap ratios rather than absolute ops/s. True 1.0 (no {@code keyOf}) is not on this branch;
43+
* see {@code tagMapByName} above for the master-run recipe.
44+
*
45+
* <p><b>Results — WITH the bloom-filter fast-path (dense-store → bloom → id stack), JDK 17 (Zulu
46+
* 17.0.7, Apple Silicon, idle box), {@code -prof gc -f 5 -wi 5 -i 5}, 2026-07-09.</b> Alloc is
47+
* deterministic (quotable); throughput was measured on a quiet box (25 iters, tight error bars),
48+
* trustworthy for the ratios — except {@code tagMapById@7} carries ~6% fork-to-fork variance (bloom
49+
* fast-path inlining nondeterminism; check PrintInlining before quoting 0.99x as hard parity).
50+
*
51+
* <pre>{@code
52+
* alloc B/op (7 / 12) thrpt vs hashMap (7 / 12)
53+
* hashMap 352 / 512 1.00x / 1.00x
54+
* tagMapById 184 / 408 0.99x / 0.63x
55+
* tagMapByName 184 / 408 0.66x / 0.50x
56+
* tagMapCustom 416 / 712 0.59x / 0.46x
57+
* }</pre>
58+
*
59+
* <p>Three takeaways. (1) <b>id and name insertion allocate identically</b> (184/408) — the
60+
* id-vs-name advantage is CPU (skipping {@code keyOf}), not allocation. Dense allocs ~half of
61+
* HashMap at 7 tags (~20% less at 12) and beats the bucket/Entry path ({@code tagMapCustom})
62+
* everywhere; the bloom cost +8 B/op (one {@code long} field). (2) <b>the bloom brings id insertion
63+
* to HashMap parity at typical counts</b> — 0.99x at 7 tags (was 0.91x pre-bloom), 0.63x at 12 (was
64+
* 0.54x). Not a beat: the crude {@code fieldPos & 63} mapping collides more as tags grow, so some
65+
* appends still scan; per-type graph-coloring is the lever to push 12 toward parity. (3) <b>id
66+
* clearly beats the bucket/1.0 path</b> — 1.4–1.7x ({@code tagMapById} vs {@code tagMapCustom});
67+
* pin exact 2.0-vs-1.0 with a master run (true 1.0 has no {@code keyOf}).
68+
*/
69+
@State(Scope.Benchmark)
70+
@BenchmarkMode(Mode.Throughput)
71+
@OutputTimeUnit(SECONDS)
72+
@Warmup(iterations = 5, time = 2)
73+
@Measurement(iterations = 5, time = 2)
74+
@Fork(3)
75+
@Threads(8)
76+
public class TagMapInsertionComparisonBenchmark {
77+
78+
// A realistic web/db span's known tag set (same list as DenseStoreAllocBenchmark).
79+
static final String[] KNOWN =
80+
new String[] {
81+
DDTags.BASE_SERVICE,
82+
Tags.VERSION,
83+
Tags.COMPONENT,
84+
Tags.SPAN_KIND,
85+
Tags.HTTP_METHOD,
86+
Tags.HTTP_ROUTE,
87+
Tags.DB_TYPE,
88+
Tags.DB_INSTANCE,
89+
Tags.PEER_HOSTNAME,
90+
Tags.DB_USER,
91+
DDTags.LANGUAGE_TAG_KEY,
92+
Tags.PEER_PORT,
93+
};
94+
95+
@Param({"7", "12"})
96+
int tagCount;
97+
98+
private String[] knownNames;
99+
private long[] knownIds;
100+
private String[] customNames;
101+
private String[] values;
102+
103+
@Setup(Level.Trial)
104+
public void setup() {
105+
KnownTags.init(); // register the real (allocation-free) resolver
106+
this.knownNames = new String[tagCount];
107+
this.knownIds = new long[tagCount];
108+
this.customNames = new String[tagCount];
109+
this.values = new String[tagCount];
110+
for (int i = 0; i < tagCount; i++) {
111+
this.knownNames[i] = KNOWN[i];
112+
this.knownIds[i] =
113+
KnownTagCodec.keyOf(KNOWN[i]); // resolve name -> id once (as codegen would)
114+
this.customNames[i] = "custom.tag." + i;
115+
this.values[i] = "value-" + i;
116+
}
117+
}
118+
119+
@Benchmark
120+
public Map<String, Object> hashMap() {
121+
final Map<String, Object> m = new HashMap<>(16);
122+
for (int i = 0; i < tagCount; i++) {
123+
m.put(knownNames[i], values[i]);
124+
}
125+
return m;
126+
}
127+
128+
@Benchmark
129+
public TagMap tagMapByName() {
130+
final TagMap m = TagMap.create(16);
131+
for (int i = 0; i < tagCount; i++) {
132+
m.set(knownNames[i], values[i]);
133+
}
134+
return m;
135+
}
136+
137+
@Benchmark
138+
public TagMap tagMapById() {
139+
final TagMap m = TagMap.create(16);
140+
for (int i = 0; i < tagCount; i++) {
141+
m.set(knownIds[i], values[i]);
142+
}
143+
return m;
144+
}
145+
146+
@Benchmark
147+
public TagMap tagMapCustom() {
148+
final TagMap m = TagMap.create(16);
149+
for (int i = 0; i < tagCount; i++) {
150+
m.set(customNames[i], values[i]);
151+
}
152+
return m;
153+
}
154+
}

‎internal-api/src/main/java/datadog/trace/api/TagMap.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,6 +1639,23 @@ public void set(@Nonnull String tag, double value) {
16391639
}
16401640
}
16411641

1642+
/**
1643+
* Sets a known tag by its resolved id (a {@code KnownTags.*} constant), storing the value
1644+
* densely. This is the id-keyed insertion path: it skips the {@code keyOf} name resolution the
1645+
* {@code set(String, ...)} methods pay. The id MUST be a stored known-tag id (see {@link
1646+
* datadog.trace.api.KnownTagCodec#isStored}); custom/unknown names have no id and must use the
1647+
* name-keyed setters.
1648+
*/
1649+
public void set(long id, Object value) {
1650+
// id-keyed insertion: the id is already resolved, so skip keyOf and store densely. The name is
1651+
// needed only to clear a read-through tombstone (rare), so resolve it lazily in that case.
1652+
this.checkWriteAccess();
1653+
if (this.removedFromParent != null) {
1654+
this.removedFromParent.remove(KnownTagCodec.nameOf(id));
1655+
}
1656+
this.putKnownValue(id, value);
1657+
}
1658+
16421659
/**
16431660
* Places an Entry directly into the map, avoiding a new Entry allocation. Null-tolerant: a null
16441661
* {@code newEntry} is a no-op returning null, so an Entry producer (e.g. {@link

0 commit comments

Comments
 (0)