This is the operational companion to performance-goals.md:
how the Phase 6 validation matrix is run, what it measures, and how to reproduce
the numbers. performance-goals.md fixes the success criteria; this page fixes the
method.
Two halves, both under benchmarks-jvm (the raw-JMH tier — JVM-only, which is where
the competitor libraries live):
- Time — JMH benchmarks comparing our primitive maps against the boxed
standard library and four reference-ceiling competitors, across operation, size,
and key distribution. Reported as ns per full sweep of
sizeentries. - Memory — the exact retained heap of every map, measured with JOL (deterministic, not a GC estimate), reported as bytes per entry.
impl |
Map | Role |
|---|---|---|
OURS |
LongLongMap / IntIntMap |
the subject |
HASH_MAP |
boxed java.util.HashMap |
the baseline the project commits to beating |
FASTUTIL |
Long2LongOpenHashMap / Int2IntOpenHashMap (8.5.18) |
reference ceiling |
HPPC |
LongLongHashMap / IntIntHashMap (0.10.0) |
reference ceiling |
ECLIPSE |
Eclipse Collections primitive *HashMap (13.0.0) |
reference ceiling |
AGRONA |
Long2LongHashMap / Int2IntHashMap (2.5.0) |
reference ceiling |
The four primitive competitors are a ceiling, not a gate: fastutil and HPPC are
co-fastest among the classic open-addressing libraries, and we do not expect to beat
them on raw single-thread JVM lookup. Our credible differentiators are memory
footprint, true Kotlin Multiplatform (all four competitors are JVM-only), and
Kotlin-idiomatic ergonomics. The committed win is against HASH_MAP.
lookupHit (random-access, the fair gate), lookupMiss, insertPresized,
insertGrowing (folds in resize cost), and churn (remove + re-insert every key —
size-stable). There is deliberately no iterate op: our primitive-value maps
expose no non-boxing traversal (a lean-surface choice, DP-10), so a fair head-to-head
cannot include it. Every competitor does offer non-boxing iteration; closing that gap
is tracked as a follow-up.
- Sizes:
10_000(cache-resident) and1_000_000(out of cache) — the regimes where the layout advantage is, respectively, marginal and decisive. - Distributions (
dist):DENSE(consecutive0..n-1) andCLUSTERED(strided keys, low bits zeroed — a distribution-sensitivity check for the hash finalizer). TheCLUSTEREDIntset is necessarily milder than theLongone: a key set that collides in a million-entry table cannot be built in-range for 32-bit keys without overflow, so theIntstride zeroes only the low 8 bits versus 21 forLong.
Lookup cost as a function of load factor {0.50, 0.75, 0.90, 0.95, 0.99}, for the two
competitors that can be held at a high load — fastutil and HPPC, whose load factor
tunes up to 0.99. Our SwissTable-family maps run at a fixed maximum load
(7/8 = 0.875, then they grow) and HashMap resizes at 0.75, so neither can be
held at an arbitrary load — they are fixed reference points, not sweepable. Agrona
is excluded too: its load factor is capped at 0.9 (its constructor rejects a higher
value), so it cannot reach the ≥0.95 points — its lookup at its own load is in the
main matrix instead. The high-load (≈0.99) regime is the home of the paper structures
(FunnelLongMap, ElasticLongMap), whose bounds are asserted by their own
probe-count specs rather than this wall-clock sweep.
To land on a chosen load on a power-of-two table, capacity is pinned at 2^20 and the
entry count is round(loadFactor · 2^20); every invocation then does the same 2^20
random lookups, so scores are comparable across the load axis.
Enforced in the harness, not left to convention:
- Primitive-vs-primitive, each map pre-sized in its own units (ours by
expected entries;
HashMapat its 0.75 load; the competitors by their own expected-element or initial-capacity arguments). No map is forced onto another's load factor. - Random-access lookups (Fisher–Yates, fixed seed
42, identical order across maps and runs), so no map is flattered by insertion-order locality. - An adversarial/clustered key set always accompanies the dense one.
- Monomorphic call sites: JMH runs a separate fork per
impl, so the measured loop sees exactly one adapter type — the boxed baseline never pollutes the primitive maps' type profile. - A correctness gate in
@Setup: each map is verified to return the stored value for every key (the hit-sum must equal the key-sum) before any number is taken — a benchmark on a miswired adapter is worthless.
./benchmarks-jvm/run-matrix.sh <label>This captures the environment, runs the JOL footprint report, runs the full JMH
matrix (@Fork 3, warmup 5×1s, measurement 5×1s, AverageTime — the settings in
benchmarks-jvm/build.gradle.kts), and collects a self-describing bundle under
benchmarks-jvm/results/<label>/:
environment.txt JDK + OS + CPU + whether frequency was pinned
footprint.md/tsv retained-heap footprint
jmh-results.json the JMH time matrix
The full matrix takes a few hours on a quiet machine. Pin CPU frequency where you
can and keep the machine idle; environment.txt records what you could not control
(on Apple Silicon, turbo and P/E scheduling are not controllable — say so).
Absolute time numbers are only citable when the machine is pinned — a Linux box is
the right tool (Apple Silicon exposes none of these knobs). Prep the box, then pass a
core list as the second argument to run the single-threaded matrix under taskset
(the JMH jar runs directly, so no Gradle daemon shares the cores):
sudo cpupower frequency-set -g performance # fix the governor
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo # Intel: turbo off
# AMD instead: echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost
# best: also boot with isolcpus=<cores> so nothing else is scheduled there
./benchmarks-jvm/run-matrix.sh linux-pinned 2,3 # pin to 2 cores (bench + GC/JIT)environment.txt then records the governor, turbo state, and pinned cores, so the
bundle proves it was pinned. Pin to two cores for these @Threads(1) benchmarks —
one runs the measured thread, the other absorbs GC/JIT so they don't perturb it. The
multi-threaded read-scaling / mixed-load benchmarks are deliberately excluded from the
pinned run (they measure scaling and need all cores); run them separately, unpinned.
Compile the jar once, then filter with JMH's own CLI:
./gradlew :benchmarks-jvm:jmhJar
JAR=$(ls -t benchmarks-jvm/build/libs/*-jmh.jar | head -1)
java -jar "$JAR" "LongLongMatrixBenchmark.lookupHit" \
-p impl=OURS,HASH_MAP,FASTUTIL -p size=1000000 -p dist=DENSE \
-f 1 -wi 3 -i 5 -rf json -rff slice.jsonAllocation profiling (bytes/op) is a JMH profiler flag, not extra code:
java -jar "$JAR" "IntIntMatrixBenchmark.insertGrowing" -prof gc -f 1./gradlew :benchmarks-jvm:footprintReport
# -> benchmarks-jvm/build/reports/footprint/footprint.{md,tsv}JOL runs in reflection mode (no agent needed); the numbers match the library's own
MemoryFootprintSpec guard.
Retained bytes per entry at n = 229_376 (the 7/8 load of a 2^18 table), each
map pre-sized in its own units. Measured with JOL on Apple M4 Max, Corretto 17. The
retained totals are exact; bytes/entry are derived and rounded to 0.1 for display,
and the ratios are computed from the exact totals (so display rounding never inflates
them).
| impl | Long→Long B/entry | vs HashMap |
Int→Int B/entry | vs HashMap |
|---|---|---|---|---|
OURS |
19.4 | 4.59× | 10.3 | 7.11× |
HASH_MAP |
89.1 | 1.00× | 73.1 | 1.00× |
FASTUTIL |
36.6 | 2.44× | 18.3 | 4.00× |
HPPC |
36.6 | 2.44× | 18.3 | 4.00× |
ECLIPSE |
36.6 | 2.44× | 18.3 | 4.00× |
AGRONA |
36.6 | 2.44× | 18.3 | 4.00× |
The memory win is decisive and, notably, holds against the specialist primitive
libraries too, not only the boxed baseline: LongLongMap (19.4) is ~1.9× more
compact than the competitors (36.6) and IntIntMap (10.3) is ~1.8× more compact than
theirs (18.3). The competitors are themselves 2.4×/4.0× more compact than boxed
HashMap, and we go further still — because of load factor and layout: our maps pack
keys and values plus one control byte per entry at 7/8 load, where the competitors
carry parallel key/value arrays at a lower occupancy. This is the project's strongest,
most portable
result.
Absolute time numbers are hardware-specific and must be regenerated on pinned
hardware — they are not committed as headline figures (an unpinned laptop with turbo
and opaque P/E scheduling is not a citable bench). Run run-matrix.sh on a controlled
machine and read jmh-results.json. Two findings are qualitative and survive the
hardware caveat, because they are ratios and their effects dwarf the run-to-run noise:
-
vs
HashMap(the gate):OURSis faster on out-of-cache (1M) random-access lookup and on presized insert; in-cache the margin narrows to near parity as the SWAR +fmix64arithmetic offsets the saved indirection. -
Distribution robustness (the important one). Our
fmix64finalizer scatters strided keys as well as dense ones, soLongLongMapbarely moves betweenDENSEandCLUSTERED, while the competitors' lighter mixers collapse on the adversarial set. Indicative1MlookupHit, Apple M4 Max / Corretto 17 (single-machine, ×1–2 forks — the ratios, not the absolute ms, are the point):distOURSFASTUTILECLIPSEDENSE9.9 ms 7.7 ms 2.0 ms CLUSTERED10.2 ms 36 ms 13 ms On dense keys Eclipse's weak spread keeps
0..n-1contiguous and cache-resident and it wins; on clustered keys that same weak spread produces long probe chains and it degrades ~6×, while fastutil degrades ~5×.OURSis essentially flat and becomes the fastest map under adversarial keys. ADENSE-only benchmark would have inverted this conclusion — which is why the fairness gate mandates the clustered set.
On raw dense lookup we sit in the same band as fastutil/HPPC/Agrona, not ahead of them; the committed differentiators remain memory, distribution robustness, and multiplatform reach — not single-thread dense-key JVM speed.
Report every number with its baseline named and its environment.txt alongside;
never cross-compare JVM and Native as a single figure.