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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jmh = "1.37"
# Profiling
jmc = "8.1.0"
jafar = "0.16.0"
jol = "0.17"

# Web & Network
jnr-unixsocket = "0.38.25"
Expand Down Expand Up @@ -125,6 +126,7 @@ instrument-java = { module = "com.datadoghq:dd-instrument-java", version.ref = "
jmc-common = { module = "org.openjdk.jmc:common", version.ref = "jmc" }
jmc-flightrecorder = { module = "org.openjdk.jmc:flightrecorder", version.ref = "jmc" }
jafar-tools = { module = "io.btrace:jafar-tools", version.ref = "jafar" }
jol-core = { module = "org.openjdk.jol:jol-core", version.ref = "jol" }

# Web & Network
okio = { module = "com.datadoghq.okio:okio", version.ref = "okio" }
Expand Down
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(libs.jol.core)
}

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,57 @@
* ({@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 — it beats {@code HashSet} on 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} — fine off the hot path, prefer
* {@code Support} on it.
* <li>{@link java.util.Set#copyOf} ({@code SetN}, the agent's compact fixed-set form) is ~1.2x
* behind {@code Support} on hit but the most <i>compact</i> (~27% smaller — no cached hashes,
* no 2x table). So StringIndex's edge over {@code SetN} is speed + the {@code
* indexOf}-&gt;parallel-array capability, not footprint; over {@code HashSet} it wins both.
* <li>{@code array} / {@code sortedArray} / {@code treeSet} trail the hashed structures, most 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 +107,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 +136,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 +222,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