JMH + JFR Performance Optimization Skill
A skill for running JMH benchmarks, collecting JFR profiles, and driving iterative performance improvements in Java/Maven projects.
Workflow Overview
setup → benchmark → profile → analyze → change → repeat
Step 1 — Verify readiness
Confirm the workload under test is already covered by a JMH benchmark in the performance/ module
(e.g. JavaVsJniReadBenchmark). If the column / codec / path is not benched, add it before profiling.
Always pass a fully-qualified JMH filter (ClassName.methodName) when invoking the harness — never
class-only or empty.
Step 2 — Use the existing benchmark script
./benchmark.sh in the repo root drives JMH + JFR. It runs:
./mvnw package -DskipTests -q -pl performance --also-make
java -jar performance/target/benchmarks.jar \
${BENCHMARK_FILTER} \
-f 1 -wi 3 -i 5 \
-rf json -rff performance/target/jmh-results.json \
-jvmArgs "-XX:StartFlightRecording=filename=performance/target/recording.jfr,settings=profile"
Never substitute mvn for ./mvnw (project mandates the wrapper) and never run mvn install.
Step 3 — Run the baseline
./benchmark.sh JavaVsJniReadBenchmark.javaReadVolume
Read both output files:
performance/target/jmh-results.json— throughput / latency per benchmarkperformance/target/recording-filtered.json— JFR events
Store the baseline score. Compare every subsequent run against it.
Step 4 — Analyze results
Key things to look for:
In jmh-results.json:
primaryMetric.score— the main result (ops/s or ns/op depending on mode)primaryMetric.scoreError— high error = unstable benchmark, increase-i- Compare
AverageTimevsThroughputto understand the shape of the workload
In recording-filtered.json:
jdk.ObjectAllocationInNewTLAB— allocation hotspots; look for largeallocationSizeor high-frequency small allocations in hot methodsjdk.GarbageCollection— GC pause duration and frequency; long pauses = allocation pressurejdk.TLBMiss— high counts suggest memory access patterns not benefiting from huge pagesjdk.GCHeapSummary— heap growth between events reveals live-set size
Priority order for investigation:
- If GC pauses are long → reduce allocations first
- If allocations are high but pauses are short → object pooling or value types
- If TLB misses are high → evaluate huge pages or access pattern changes
- If CPU is saturated with no GC/alloc issues → algorithmic / cache-locality problem
Step 5 — Apply one change at a time
Critical rule: change one thing per iteration.
Do not batch multiple optimizations. Each iteration must produce a clear before/after comparison so regressions are traceable.
Common optimizations to consider (in order of typical impact):
- Branch-split modulo / division / variable-target branches out of hot loops. ⚠️ Top of the
list because we shipped this regression twice in this codebase. A single
i % capper element blocks C2's auto-vectorizer (superword pass refusesOp_ModL/Op_DivL; no SIMD ISA has integer divide). Loop-invariant cap doesn't save you — strength-reduction needs a compile-time constant divisor. Per-element scalar modulo is also 20–40 cycles on Apple silicon. Combined: easy 5–10× throughput loss. Fix shape: hoist the divisor once, gate two specialized loop bodies on a single boolean check so the fast path has zero modulos and the slow path covers the rare case (broadcast, clamp, etc.).
Same trap: per-element validity-bit checks, sign-extension switches, narrow-vs-wide branches — anything that makes the body non-uniform across rows. History: commitlong cap = SegmentBroadcast.capacity(src, 8); if (cap == n) { for (long i = 0; i < n; i++) ... src.getAtIndex(LE_LONG, i) ... } else { for (long i = 0; i < n; i++) ... src.getAtIndex(LE_LONG, i % cap) ... }ed658b7added modulo for ConstantEncoding broadcast safety with a (wrong) "JIT hoists it" claim; bisect later proved single-commit 5.5× regression onvortexRead. Fixes in051a794,442021f. - Allocate decode output from
ctx.arena()— hard rule fromCLAUDE.md. Nevernew byte[n]+MemorySegment.ofArray()for codec output: heap allocation, GC pressure, extra copy. Usectx.arena().allocate(n * elemBytes, alignment)so the buffer lives on the confined arena tied to theVortexFile. - Reduce allocations elsewhere — reuse objects, use primitives, avoid boxing.
- Hoist
ValueLayoutconstants — declarestatic final ValueLayout.OfXxx L = ...so JIT constant-folds the stride / alignment / order. InlineValueLayout.JAVA_LONG_UNALIGNEDon each call defeats this. - Use
getAtIndex/setAtIndexin tight loops over aMemorySegment— stride is implicit, bounds check hoists, and the auto-vectorizer reads the shape cleanly. - Aligned arena allocation —
arena.allocate(n, 64)keeps SIMD-friendly addresses. - Improve data locality — colocate fields accessed together, prefer flat arrays / segments over linked structures.
- Avoid synchronization on hot paths —
VarHandle,AtomicLong, lock-free structures. - Reduce GC pressure — pool expensive objects, avoid finalizers, off-heap where justified.
- Enable huge pages — add
-XX:+UseTransparentHugePagestojvmArgsfor comparison.
After each change, run ./bench and compare the new score against the stored baseline. If JFR
-prof stack:lines=10 shows idiv, sdiv, or any arithmetic-helper frame as a hot stack, suspect
modulo/division in a hot loop first — go straight to #0.
Step 6 — Report progress
After each iteration, report:
Iteration N
Change : <one-line description of what changed>
Before : <score> ± <error> <unit>
After : <score> ± <error> <unit>
Delta : <+/- %>
JFR note : <key observation from JFR events>
Next : <what to investigate next>
If performance regressed, revert the change immediately and explain why it likely hurt.
Step 7 — Stop conditions
Stop iterating when any of the following is true:
- The goal stated by the user has been reached
- Three consecutive iterations produce < 2% improvement
- All obvious hotspots from JFR have been addressed
- The user says stop
Produce a final summary table of all iterations.
Important constraints
- Never modify the benchmark harness (
@Benchmark,@Setup,@TearDown) unless the user explicitly asks. Benchmark changes invalidate all prior comparisons. - Do not change JMH flags (
-f,-wi,-i) between iterations. Consistency is required for valid comparisons. - Keep JFR event filter consistent across all runs.
- If
jmh-results.jsonshowsscoreError> 10% ofscore, warn the user the benchmark is noisy and suggest increasing-ibefore drawing conclusions.