Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
9e76978
Add TagSet: a generic open-addressed string set (+ benchmarks)
dougqh Jun 17, 2026
39c91c0
Drop KeyOfBenchmark from the TagSet PR (it's a keyOf-integration benc…
dougqh Jun 17, 2026
66ee8ed
Add TagSet + parallel int[] fixed-map option to UnsynchronizedMapBenc…
dougqh Jun 17, 2026
4d493ed
Move UnsynchronizedMapBenchmark lookup rotation to @State(Scope.Thread)
dougqh Jun 17, 2026
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
0b1b9bd
Rename TagSet to StringIndex
dougqh Jun 23, 2026
24a9fdc
Overhaul set benchmarks: split Immutable / SingleThreaded, add Set.co…
dougqh Jun 23, 2026
d916762
Drop benchmarks from StringIndex PR; revert SetBenchmark/Unsynchroniz…
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
5a14e62
Add Java 17 results to set benchmark Javadocs
dougqh Jun 24, 2026
227fcc0
Add JOL retained-footprint test for StringIndex vs JDK sets
dougqh Jun 24, 2026
af19570
Note StringIndex's footprint-for-throughput tradeoff in class Javadoc
dougqh Jun 24, 2026
41e9777
Correct StringIndex Javadoc: 2x sizing is for build simplicity, not t…
dougqh Jun 24, 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
78075dc
Rename copyOf -> tracerImmutableSet in ImmutableSetBenchmark
dougqh Jun 24, 2026
e1a6de3
Fix clone_synchronizedSet to clone the synchronized set (not the plai…
dougqh Jun 24, 2026
d11d3e3
Merge remote-tracking branch 'origin/dougqh/set-benchmark' into dougq…
dougqh Jun 25, 2026
a603f7a
Merge remote-tracking branch 'origin/dougqh/tagmap-access-benchmark' …
dougqh Jun 25, 2026
60bcf7b
Add StringIndex benchmark arms across its three use cases
dougqh Jun 25, 2026
afc6563
Add preliminary @Fork(2) StringIndex benchmark results to Javadocs
dougqh Jun 25, 2026
b48eb0d
Merge remote-tracking branch 'origin/master' into dougqh/tagset
dougqh Jun 25, 2026
a903c3a
Add StringIndex value-mapping and lookup surface API
dougqh Jun 25, 2026
2d4d779
Test StringIndex value-mapping and lookup API
dougqh Jun 25, 2026
2ea9ae5
Add constant-propagated switch arms to StringIndexSwitchBenchmark
dougqh Jun 25, 2026
ecf3643
Refresh StringIndexSwitchBenchmark results: clean @Fork(5), const-key…
dougqh Jun 25, 2026
d6378a4
Reconcile StringIndexSwitchBenchmark intro with results (key-constanc…
dougqh Jun 25, 2026
daca028
Finalize StringIndex benchmark Javadocs with quiet @Fork(5) numbers
dougqh Jun 26, 2026
ba9a3ec
Sharpen stringIndex_miss caveat with the confirmed @Fork(10) bimodality
dougqh Jun 26, 2026
63fe111
Merge remote-tracking branch 'origin/master' into dougqh/tagset
dougqh Jun 30, 2026
767af3d
spotless: reflow grafted StringIndex Javadoc in ImmutableMapBenchmark
dougqh Jun 30, 2026
2f8c503
Merge branch 'master' into dougqh/tagset
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
1 change: 1 addition & 0 deletions internal-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ dependencies {
testImplementation("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
testImplementation(libs.commons.math)
testImplementation(libs.bundles.mockito)
testImplementation("org.openjdk.jol:jol-core:0.17")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Regenerate the internal-api lockfile for JOL

This adds a new test dependency, but internal-api/gradle.lockfile has no org.openjdk.jol:jol-core entry. The repo's lock-file workflow says added dependencies should be resolved with --write-locks and the gradle.lockfile change committed (docs/how_to_work_with_gradle.md:918-923); because this build uses LockMode.LENIENT (buildSrc/src/main/kotlin/dd-trace-java.dependency-locking.gradle.kts:13-18, and Gradle documents lenient mode as allowing added dependencies without failing), the JOL test classpath can now resolve outside the recorded lock state instead of staying reproducible. Please regenerate and commit internal-api/gradle.lockfile.

Useful? React with 👍 / 👎.

}

jmh {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,55 @@
* 10+, falls back to the input map pre-10). {@code Map.copyOf}/{@code MapN} is the honest
* immutable-map baseline, not {@code HashMap}.
*
* <p>Also compared: {@link StringIndex} used as a string-&gt;int map — an open-addressed index plus
* a slot-aligned {@code int[]} of values ({@code SI_VALUES[indexOf(key)]}). {@code
* stringIndex_get*} goes through the instance wrapper; {@code support_get*} reads via {@code static
* final} arrays (the JIT folds the refs). No {@code iterate} arm — StringIndex is a lookup index,
* not an iteration structure; its map use case is the {@code indexOf}-&gt;parallel-array read.
*
* <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+.)
*
* <p>JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s).
* {@code get} uses distinct keys (exercises {@code equals()}); {@code sameKey} reuses the interned
* key (the {@code ==} fast path — the common tracer case):
*
* <pre>{@code
* Structure get sameKey
* support (static) 1498 2081 (fastest)
* stringIndex (inst) 1363 1900
* hashMap 1216 1850
* linkedHashMap 1214 -
* tagMap 1167 1386
* tracerImmutableMap 1049 1364 (MapN)
* treeMap 656 -
* }</pre>
*
* <p>{@code iterate} (full traversal):
*
* <pre>{@code
* tagMap.forEach 148 (fastest)
* linkedHashMap 136
* tracerImmutableMap 135 (MapN)
* treeMap 134
* hashMap 104
* tagMap (iterator) 96
* }</pre>
*
* <p>Key findings:
*
* <ul>
* <li>StringIndex-as-map ({@code Support}) is the fastest {@code get} — beating {@code HashMap}
* and {@code Map.copyOf}/{@code MapN}, most on the interned path; the instance wrapper trails
* it by ~10%. (vs {@code MapN} the edge is speed + the slot/parallel-array capability, not
* footprint — see {@link ImmutableSetBenchmark}.)
* <li>{@code TagMap.forEach} (148) beats its own {@code iterator} (96) by ~1.5x: TagMap's
* structure makes a faithful external {@code Iterator} expensive (externalized cursor +
* skip-empty + per-call re-entry + the iterator allocation) — all of which internal {@code
* forEach} avoids. Traverse TagMap via {@code forEach}, never its iterator; that gap only
* widens as TagMap's entry model grows.
* </ul>
*/
// @Fork(5): get_tracerImmutableMap* (MapN reached via interface dispatch) is JIT-bimodal at fewer
// forks — 5
Expand Down Expand Up @@ -71,12 +115,31 @@ static void fill(Map<String, Integer> map) {
}
}

// StringIndex as a string->int map: an open-addressed index plus a slot-aligned int[] of values
// (VALUES[indexOf(key)]). support_* reads via static final arrays (JIT folds the refs to
// constants); stringIndex_* goes through the instance wrapper. Both share one placement --
// StringIndex.of and Support.create place identically -- so SI_VALUES aligns with either.
static final int[] SI_HASHES;
static final String[] SI_NAMES;
static final int[] SI_VALUES;

static {
StringIndex.Data data = StringIndex.Support.create(INSERTION_KEYS);
SI_HASHES = data.hashes;
SI_NAMES = data.names;
SI_VALUES = new int[SI_HASHES.length];
for (int i = 0; i < INSERTION_KEYS.length; ++i) {
SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, 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;
StringIndex stringIndex;

@Setup(Level.Trial)
public void setUp() {
Expand All @@ -92,6 +155,7 @@ public void setUp() {
}
// JDK compact immutable map (MapN on Java 10+); the agent's actual fixed-map representation.
tracerImmutableMap = CollectionUtils.tryMakeImmutableMap(hashMap);
stringIndex = StringIndex.of(INSERTION_KEYS);
}

/** Per-thread lookup cursor so each reader thread cycles keys independently. */
Expand Down Expand Up @@ -199,4 +263,25 @@ public void iterate_tracerImmutableMap(Blackhole blackhole) {
blackhole.consume(entry.getValue());
}
}

@Benchmark
public int stringIndex_get(Cursor cursor) {
return SI_VALUES[stringIndex.indexOf(cursor.nextKey())];
}

@Benchmark
public int stringIndex_get_sameKey(Cursor cursor) {
return SI_VALUES[stringIndex.indexOf(cursor.nextKey(INSERTION_KEYS))];
}

@Benchmark
public int support_get(Cursor cursor) {
return SI_VALUES[StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey())];
}

@Benchmark
public int support_get_sameKey(Cursor cursor) {
return SI_VALUES[
StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey(INSERTION_KEYS))];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,34 +35,59 @@
* ({@code ImmutableCollections.SetN}), which is what the agent actually uses for fixed config
* sets. Java 10+; falls back to {@code HashSet} pre-10. The realistic baseline for any
* flat/immutable set comparison.
* <li>{@code stringIndex} — {@link StringIndex#contains} on the instance wrapper (one field load
* to reach the placed arrays, then an open-addressed probe).
* <li>{@code support} — the same probe via {@link StringIndex.Support#indexOf} over {@code static
* final} arrays, so the JIT folds the refs to constants and there is nothing to dereference
* (the hot path StringIndex recommends). The {@code stringIndex}/{@code support} pair shows
* the indirection cost of the wrapper.
* </ul>
*
* <p>Lookups are interned (the {@code ==} fast path where a structure has one); misses are short
* and never present.
*
* <p>Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}; M ops/s = millions):
* <p>JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s =
* millions):
*
* <pre>{@code
* Structure hit miss
* hashSet 2159 1751 (fastest)
* tracerImmutableSet 1946 1633 (Set.copyOf / SetN)
* array 926 584
* sortedArray 664 588
* treeSet 642 593
* support (static) 2320 2159 (fastest)
* hashSet 2198 2134
* stringIndex (inst) 2098 1548 * (* miss bimodal -- see caveat)
* tracerImmutableSet 1914 1663 (Set.copyOf / SetN)
* array 941 589
* sortedArray 685 610
* treeSet 657 610
* }</pre>
*
* <p>Key findings:
*
* <ul>
* <li>{@code HashSet} is fastest; {@link java.util.Set#copyOf} ({@code SetN}) trails by only ~10%
* on hit and ~7% on miss — and it's the compact, array-backed form the agent already uses for
* fixed config sets, so it's a strong default when the set is immutable.
* <li>{@code array} / {@code sortedArray} / {@code treeSet} cluster at ~0.6–0.9B — they scan,
* binary-search, or tree-walk per lookup, so they trail the hashed structures, most visibly
* on the miss path.
* <li>The static {@code Support} path is the fastest membership structure — it beats {@code
* HashSet} on both hit and miss, and crushes the scan/search/tree forms.
* <li>{@code stringIndex} (the instance wrapper) trails {@code Support} by the field-load
* indirection (~10% on hit), landing near {@code HashSet}. Off the hot path that cost is
* fine; on it, prefer {@code Support}.
* <li>{@link java.util.Set#copyOf} ({@code SetN}, the compact array-backed form the agent uses
* for fixed config sets) is ~1.2x behind {@code Support} on hit — but it is the most
* <i>compact</i> (~27% smaller than StringIndex per the JOL test): StringIndex pays for its
* cached {@code int[]} hashes + the 2x-oversized table. So StringIndex's edge over {@code
* SetN} is speed + the {@code indexOf}-&gt;parallel-array capability, NOT footprint; vs
* {@code HashSet} it wins on both.
* <li>{@code array} / {@code sortedArray} / {@code treeSet} scan, binary-search, or tree-walk per
* lookup, so they trail the hashed structures, most visibly on miss.
* </ul>
*
* <p><b>Caveat — the instance {@code stringIndex} miss is bimodal across forks</b> (confirmed at
* {@code @Fork(10)}: 6 forks fast, 4 slow, nothing between). ~60% of forks compile to a fast mode
* (~2000, ≈ {@code support_miss} — the wrapper indirection is then free) and ~40% to a slow mode
* (~1070, ~half); each fork locks one at warmup. So the {@code 1548 ±27%} above is a mode-mix, not
* noise. Cause: C2 hoists the instance field-loads ({@code this.hashes}/{@code names}) out of the
* miss-path probe loop only in the fast mode; the static {@code Support} path const-folds those
* refs and is never bimodal ({@code support_miss} ±0.3%). Prefer {@code Support} where miss latency
* matters.
*/
@Fork(2)
@Fork(5) // 5 forks settle the bimodal stringIndex_miss / interface-dispatch arms (see header)
@Warmup(iterations = 2)
@Measurement(iterations = 3)
@Threads(8)
Expand All @@ -84,12 +109,26 @@ static String[] newMisses() {
return misses;
}

// StringIndex static-Support mode: the placed arrays pulled into static final fields, so the JIT
// folds the refs to constants and Support.indexOf has nothing to dereference (the hot path the
// StringIndex class Javadoc recommends). Contrast support_* (these) with stringIndex_* (the
// instance wrapper, one field load) to see the indirection cost.
static final int[] SI_HASHES;
static final String[] SI_NAMES;

static {
StringIndex.Data data = StringIndex.Support.create(STRINGS);
SI_HASHES = data.hashes;
SI_NAMES = data.names;
}

// Built once, never mutated -- safe to share across the reader threads.
String[] array;
String[] sortedArray;
HashSet<String> hashSet;
TreeSet<String> treeSet;
Set<String> tracerImmutableSet;
StringIndex stringIndex;

@Setup(Level.Trial)
public void setUp() {
Expand All @@ -99,6 +138,7 @@ public void setUp() {
hashSet = new HashSet<>(Arrays.asList(STRINGS));
treeSet = new TreeSet<>(Arrays.asList(STRINGS));
tracerImmutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(STRINGS));
stringIndex = StringIndex.of(STRINGS);
}

/** Per-thread lookup cursor so each reader thread cycles keys independently. */
Expand Down Expand Up @@ -184,4 +224,24 @@ public boolean tracerImmutableSet_hit(Cursor cursor) {
public boolean tracerImmutableSet_miss(Cursor cursor) {
return tracerImmutableSet.contains(cursor.nextMiss());
}

@Benchmark
public boolean stringIndex_hit(Cursor cursor) {
return stringIndex.contains(cursor.nextHit());
}

@Benchmark
public boolean stringIndex_miss(Cursor cursor) {
return stringIndex.contains(cursor.nextMiss());
}

@Benchmark
public boolean support_hit(Cursor cursor) {
return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextHit()) >= 0;
}

@Benchmark
public boolean support_miss(Cursor cursor) {
return StringIndex.Support.indexOf(SI_HASHES, SI_NAMES, cursor.nextMiss()) >= 0;
}
}
Loading