Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
94830b2
Add threading-correct TagMap access microbenchmark
dougqh Jun 18, 2026
54979cb
Fix data race in UnsynchronizedMapBenchmark scaffolding index
dougqh Jun 22, 2026
0ecfa48
Wire -Pjmh.includes and -PtestJvm into internal-api JMH config
dougqh Jun 22, 2026
9dc9122
Update UnsynchronizedMapBenchmark results with Java 17 numbers
dougqh Jun 23, 2026
a2b6a2f
Update UnsynchronizedMapBenchmark prose to match Java 17 results
dougqh Jun 23, 2026
cd883e1
Add builder-style insert benchmarks and update results in TagMapAcces…
dougqh Jun 23, 2026
02d08f0
Merge branch 'master' into dougqh/tagmap-access-benchmark
dougqh Jun 23, 2026
36a711e
Use @Fork(2) in TagMapAccessBenchmark; drop JMH gradle wiring leak
dougqh Jun 23, 2026
78a786e
Split map benchmarks into ImmutableMapBenchmark and SingleThreadedMap…
dougqh Jun 23, 2026
613952d
Add Map.copyOf case to ImmutableMapBenchmark; fix dangling Javadoc link
dougqh Jun 23, 2026
a9329e0
Merge remote-tracking branch 'origin/master' into dougqh/tagmap-acces…
dougqh Jun 23, 2026
1d57b3f
Fix clone_synchronizedHashMap to clone the synchronized map (not the …
dougqh Jun 24, 2026
0c8f180
Use @Fork(5) for ImmutableMapBenchmark (JIT-bimodal copyOf path)
dougqh Jun 24, 2026
5e6f330
Merge branch 'master' into dougqh/tagmap-access-benchmark
dougqh Jun 29, 2026
7b2ceb2
Merge branch 'master' into dougqh/tagmap-access-benchmark
dougqh Jun 29, 2026
5bcd5d1
Merge branch 'master' into dougqh/tagmap-access-benchmark
dougqh Jun 29, 2026
84e17a3
Merge branch 'master' into dougqh/tagmap-access-benchmark
dougqh Jun 29, 2026
112b560
Address review (bric3): rename copyOf benchmark arm to tracerImmutabl…
dougqh Jun 29, 2026
8f217dc
Merge branch 'master' into dougqh/tagmap-access-benchmark
dougqh Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package datadog.trace.api;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;

/**
* Throughput microbenchmark for the core {@link TagMap} access paths — insert (direct, via Ledger,
* and HashMap variants), raw-value read, and Entry read — over a representative HTTP-server-ish tag
* set.
*
* <p><b>Threading correctness.</b> Runs at {@code @Threads(8)}. All <i>shared</i> state is
* immutable ({@link #NAMES}/{@link #VALUES}); every bit of <i>mutable</i> state lives in a
* {@code @State(Scope.Thread)} holder so threads never contend on a shared map, index, or reader
* flyweight. Earlier TagMap benchmarks shared a cross-thread counter/index, which turned the result
* into a contention measurement rather than a TagMap measurement — this layout avoids that. Indices
* are plain per-invocation locals.
*
* <p>Run configuration is baked into annotations rather than relying on {@code -Pjmh.*} flags
* (which the {@code me.champeau.jmh} plugin ignores).
*
* <p><b>Key findings (MacBook M1, 8 threads, Java 17):</b>
*
* <ul>
* <li><b>get</b>: TagMap ({@code getObject}/{@code getEntry} ~96M ops/s) is essentially on par
* with HashMap — the slight difference is noise.
* <li><b>insert</b>: Direct {@code HashMap} put (65M) is faster than {@code TagMap} (52M) for
* plain insertion. However, if a builder pattern is required, {@code TagMap.Ledger} (41M)
* handily beats {@code HashMap} builder style — staging map + defensive copy (28M) — because
* it avoids the second allocation and second fill pass.
* <li><b>clone</b>: See {@link datadog.trace.util.SingleThreadedMapBenchmark} — TagMap clone is
* ~4.6x faster than HashMap clone (295M vs 64M ops/s), which dominates span lifecycle costs.
* </ul>
*
* <code>
* MacBook M1 with 8 threads (Java 17)
*
* Benchmark Mode Cnt Score Error Units
* TagMapAccessBenchmark.getEntry thrpt 5 95559437.524 ± 1381678.908 ops/s
* TagMapAccessBenchmark.getObject thrpt 5 95980166.452 ± 2217719.560 ops/s
* TagMapAccessBenchmark.insert thrpt 5 52523529.023 ± 1816998.150 ops/s
* TagMapAccessBenchmark.insert_hashMap thrpt 5 65344306.574 ± 4013136.530 ops/s
* TagMapAccessBenchmark.insert_hashMap_builderStyle thrpt 5 28057827.189 ± 1359655.664 ops/s
* TagMapAccessBenchmark.insert_via_ledger thrpt 5 41169656.095 ± 773264.754 ops/s
* </code>
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(2)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@Threads(8)
@State(Scope.Benchmark)
public class TagMapAccessBenchmark {
// a representative HTTP-server-ish tag set (immutable -> safe to share across threads)
static final String[] NAMES = {
"http.request.method",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future intended changes will care about the specifics of the tags, so using real tags is preferable for future-proofing

"http.response.status_code",
"http.route",
"url.path",
"url.scheme",
"server.address",
"server.port",
"client.address",
"network.protocol.version",
"user_agent.original",
"span.kind",
"component",
"language",
"error",
"resource.name",
"service.name",
"operation.name",
"env",
};

static final Object[] VALUES = new Object[NAMES.length];

static {
for (int i = 0; i < NAMES.length; ++i) {
VALUES[i] = "value-" + i;
}
}

/**
* Pre-populated read map, PER-THREAD ({@code Scope.Thread}): each thread owns its own map so
* reads don't contend on shared mutable state under {@code @Threads(8)}.
*/
@State(Scope.Thread)
public static class ReadMap {
TagMap map;

@Setup(Level.Trial)
public void build() {
this.map = TagMap.create();
for (int i = 0; i < NAMES.length; ++i) {
this.map.set(NAMES[i], VALUES[i]);
}
}
}

@Benchmark
public TagMap insert() {
TagMap map = TagMap.create();
for (int i = 0; i < NAMES.length; ++i) {
map.set(NAMES[i], VALUES[i]);
}
return map;
}

@Benchmark
public TagMap insert_via_ledger() {
TagMap.Ledger ledger = TagMap.ledger();
for (int i = 0; i < NAMES.length; ++i) {
ledger.set(NAMES[i], VALUES[i]);
}
return ledger.build();
}

@Benchmark
public Map<String, Object> insert_hashMap() {
HashMap<String, Object> map = new HashMap<>();
for (int i = 0; i < NAMES.length; ++i) {
map.put(NAMES[i], VALUES[i]);
}
return map;
}

/**
* Models the builder idiom for HashMap: accumulate into a staging map, then defensively copy. Two
* allocations, two fill passes — the honest cost of a HashMap-based builder pattern.
*/
@Benchmark
public Map<String, Object> insert_hashMap_builderStyle() {
HashMap<String, Object> staging = new HashMap<>();
for (int i = 0; i < NAMES.length; ++i) {
staging.put(NAMES[i], VALUES[i]);
}
return new HashMap<>(staging);
}

@Benchmark
public void getObject(ReadMap rm, Blackhole bh) {
for (int i = 0; i < NAMES.length; ++i) {
bh.consume(rm.map.getObject(NAMES[i]));
}
}

@Benchmark
public void getEntry(ReadMap rm, Blackhole bh) {
for (int i = 0; i < NAMES.length; ++i) {
bh.consume(rm.map.getEntry(NAMES[i]).objectValue());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package datadog.trace.util;

import datadog.trace.api.TagMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;

/**
* Read-side benchmark for precomputed, immutable / read-mostly maps that are <i>shared</i> across
* threads. Models the use case where a map is built once and then only read — often published and
* read concurrently by many threads.
*
* <p>Because nothing mutates after construction, a single shared instance ({@link Scope#Benchmark})
* read by all {@code @Threads} is realistic and contention-free. This is the read-mostly
* counterpart to the per-thread mutable {@link SingleThreadedMapBenchmark} and the contended {@code
* ConcurrentHashtable} / {@code ThreadSafeMap} suites.
*
* <p>Compares {@code get} + {@code iterate} across {@link HashMap}, {@link LinkedHashMap}, {@link
* TreeMap}, {@link TagMap}, and {@link java.util.Map#copyOf} (via {@link
* CollectionUtils#tryMakeImmutableMap} — the JDK's compact, array-backed {@code
* ImmutableCollections.MapN}, which is what the agent actually uses for fixed config maps; Java
* 10+, falls back to the input map pre-10). {@code Map.copyOf}/{@code MapN} is the honest
* immutable-map baseline, not {@code HashMap}.
*
* <p>Lookups use {@code EQUAL_KEYS} (distinct String instances) to exercise {@code equals()};
* {@code *_sameKey} variants reuse the original interned key instances to show the identity fast
* path — which is the common tracer case, since map keys are typically interned tag-name constants.
* (Results pending a fresh multi-JVM run — {@code Map.copyOf} only materializes the compact form on
* Java 10+.)
*/
// @Fork(5): get_tracerImmutableMap* (MapN reached via interface dispatch) is JIT-bimodal at fewer
// forks — 5
// forks resolves it (get_tracerImmutableMap_sameKey measured ±90% at @Fork(2) -> ±1.8% at
// @Fork(5)).
@Fork(5)
@Warmup(iterations = 2)
@Measurement(iterations = 3)
@Threads(8)
@State(Scope.Benchmark)
public class ImmutableMapBenchmark {
static final String[] INSERTION_KEYS = {
"foo", "bar", "baz", "quux", "foobar", "foobaz", "key0", "key1", "key2", "key3"
};

// Distinct String instances (not the literals used to build the maps) so lookups exercise
// equals(), not identity -- the realistic case for keys arriving from parsing/decoding.
static final String[] EQUAL_KEYS = newEqualKeys();

static String[] newEqualKeys() {
String[] keys = new String[INSERTION_KEYS.length];
for (int i = 0; i < INSERTION_KEYS.length; ++i) {
keys[i] = new String(INSERTION_KEYS[i]);
}
return keys;
}

static void fill(Map<String, Integer> map) {
for (int i = 0; i < INSERTION_KEYS.length; ++i) {
map.put(INSERTION_KEYS[i], i);
}
}

// Built once, never mutated -- safe to share across the reader threads.
HashMap<String, Integer> hashMap;
LinkedHashMap<String, Integer> linkedHashMap;
TreeMap<String, Integer> treeMap;
TagMap tagMap;
Map<String, Integer> tracerImmutableMap;

@Setup(Level.Trial)
public void setUp() {
hashMap = new HashMap<>();
fill(hashMap);
linkedHashMap = new LinkedHashMap<>();
fill(linkedHashMap);
treeMap = new TreeMap<>();
fill(treeMap);
tagMap = TagMap.create();
for (int i = 0; i < INSERTION_KEYS.length; ++i) {
tagMap.set(INSERTION_KEYS[i], i); // primitive support
}
// JDK compact immutable map (MapN on Java 10+); the agent's actual fixed-map representation.
tracerImmutableMap = CollectionUtils.tryMakeImmutableMap(hashMap);
}

/** Per-thread lookup cursor so each reader thread cycles keys independently. */
@State(Scope.Thread)
public static class Cursor {
int index = 0;

String nextKey() {
return nextKey(EQUAL_KEYS);
}

String nextKey(String[] keys) {
if (++index >= keys.length) index = 0;
return keys[index];
}
}

@Benchmark
public Integer get_hashMap(Cursor cursor) {
return hashMap.get(cursor.nextKey());
}

@Benchmark
public Integer get_hashMap_sameKey(Cursor cursor) {
return hashMap.get(cursor.nextKey(INSERTION_KEYS));
}

@Benchmark
public void iterate_hashMap(Blackhole blackhole) {
for (Map.Entry<String, Integer> entry : hashMap.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}

@Benchmark
public Integer get_linkedHashMap(Cursor cursor) {
return linkedHashMap.get(cursor.nextKey());
}

@Benchmark
public void iterate_linkedHashMap(Blackhole blackhole) {
for (Map.Entry<String, Integer> entry : linkedHashMap.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}

@Benchmark
public Integer get_treeMap(Cursor cursor) {
return treeMap.get(cursor.nextKey());
}

@Benchmark
public void iterate_treeMap(Blackhole blackhole) {
for (Map.Entry<String, Integer> entry : treeMap.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}

@Benchmark
public int get_tagMap(Cursor cursor) {
return tagMap.getInt(cursor.nextKey());
}

@Benchmark
public int get_tagMap_sameKey(Cursor cursor) {
return tagMap.getInt(cursor.nextKey(INSERTION_KEYS));
}

@Benchmark
public void iterate_tagMap(Blackhole blackhole) {
for (TagMap.EntryReader entry : tagMap) {
blackhole.consume(entry.tag());
blackhole.consume(entry.intValue());
}
}

@Benchmark
public void iterate_tagMap_forEach(Blackhole blackhole) {
// Taking advantage of passthrough of contextObj to avoid capturing lambda
tagMap.forEach(
blackhole,
(bh, entry) -> {
bh.consume(entry.tag());
bh.consume(entry.intValue());
});
}

@Benchmark
public Integer get_tracerImmutableMap(Cursor cursor) {
return tracerImmutableMap.get(cursor.nextKey());
}

@Benchmark
public Integer get_tracerImmutableMap_sameKey(Cursor cursor) {
return tracerImmutableMap.get(cursor.nextKey(INSERTION_KEYS));
}

@Benchmark
public void iterate_tracerImmutableMap(Blackhole blackhole) {
for (Map.Entry<String, Integer> entry : tracerImmutableMap.entrySet()) {
blackhole.consume(entry.getKey());
blackhole.consume(entry.getValue());
}
}
}
Loading