Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,198 @@
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(2)
@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> copyOfMap;

@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.
copyOfMap = 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_copyOf(Cursor cursor) {
return copyOfMap.get(cursor.nextKey());
}

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

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