-
Notifications
You must be signed in to change notification settings - Fork 340
Overhaul map JMH benchmarks: remove thread contention and split by use case #11679
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dougqh
wants to merge
11
commits into
master
Choose a base branch
from
dougqh/tagmap-access-benchmark
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
94830b2
Add threading-correct TagMap access microbenchmark
dougqh 54979cb
Fix data race in UnsynchronizedMapBenchmark scaffolding index
dougqh 0ecfa48
Wire -Pjmh.includes and -PtestJvm into internal-api JMH config
dougqh 9dc9122
Update UnsynchronizedMapBenchmark results with Java 17 numbers
dougqh a2b6a2f
Update UnsynchronizedMapBenchmark prose to match Java 17 results
dougqh cd883e1
Add builder-style insert benchmarks and update results in TagMapAcces…
dougqh 02d08f0
Merge branch 'master' into dougqh/tagmap-access-benchmark
dougqh 36a711e
Use @Fork(2) in TagMapAccessBenchmark; drop JMH gradle wiring leak
dougqh 78a786e
Split map benchmarks into ImmutableMapBenchmark and SingleThreadedMap…
dougqh 613952d
Add Map.copyOf case to ImmutableMapBenchmark; fix dangling Javadoc link
dougqh a9329e0
Merge remote-tracking branch 'origin/master' into dougqh/tagmap-acces…
dougqh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
168 changes: 168 additions & 0 deletions
168
internal-api/src/jmh/java/datadog/trace/api/TagMapAccessBenchmark.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| "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()); | ||
| } | ||
| } | ||
| } | ||
198 changes: 198 additions & 0 deletions
198
internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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