Stage 10 — Performance Improvements
What this stage teaches
A performance PR without numbers is a guess. Stage 10 drills the discipline that separates a real improvement from a plausible-looking refactor: measure first, change, measure again, and prove the delta with a reproducible benchmark. The skill set:
- Write a JMH microbenchmark in the
:benchmarksmodule to isolate a hot method and show a before/after allocation or throughput change. - Use OpenSearch Benchmark (
opensearch-benchmark, formerly Rally) for macro numbers — end-to-end indexing/search throughput and latency against a running cluster. - Reason about allocation and GC pressure: object churn on a hot path, autoboxing, and
using
BigArrays(the engine's pooled, circuit-breaker-aware array allocator) instead of raw arrays. - Avoid regressions: a change that helps one workload often hurts another; the bar is "no workload meaningfully worse, target workload meaningfully better."
Prerequisite: Stage 6 or Stage 7 (you must be in a real hot path), Stage 9 (you need deterministic measurement), plus the Level 9 performance lab and the circuit breakers & memory deep dive.
The measurement loop
flowchart LR
P[profile / identify hot path] --> M1[baseline: JMH micro + OSB macro]
M1 --> C[change]
C --> M2[re-measure same JMH + OSB]
M2 --> D{delta real &<br/>no regression?}
D -->|yes| PR[PR with before/after numbers]
D -->|no| C
Rules:
- Never optimise un-profiled code. Find the hot path with a profiler (async-profiler / JFR) or an existing benchmark, not intuition.
- Micro proves the mechanism; macro proves it matters. A JMH win that does not move an OSB workload is noise — say so honestly.
- One change, one number. Bundle two optimisations and you cannot attribute the delta.
Finding Stage 10 issues
is:issue is:open label:enhancement no:assignee "performance" in:title,body
is:issue is:open label:bug no:assignee "regression" "performance" in:body
is:issue is:open label:"help wanted" no:assignee label:"Search:Performance"
is:issue is:open label:"help wanted" no:assignee label:"Storage:Performance"
Area labels: Search:Performance, Storage:Performance, Indexing:Performance,
Performance. Check the current set.
Fallback grep — hot paths that allocate per-document or per-request:
ls benchmarks/src/main/java/org/opensearch/benchmark/
# Existing JMH benchmarks tell you which paths are already considered hot:
grep -rln "@Benchmark" benchmarks/src/main/java/org/opensearch/
# Raw arrays on hot paths that could use BigArrays:
grep -rn "new long\[\|new double\[\|new int\[" \
server/src/main/java/org/opensearch/search/aggregations/ | head
Walked example — a hot-path allocation reduction with a JMH benchmark
Illustrative of the pattern. The grep finds a real hot method; treat the details as a stand-in for "a per-document path that allocates a throwaway object."
Symptom: an aggregation's per-bucket collection allocates a small temporary object (a
boxed Long, or a fresh array) for every document, producing heavy young-gen GC pressure
on high-cardinality aggregations. The fix reuses a pooled BigArrays-backed structure or
hoists the allocation out of the per-doc loop.
Profile to confirm it is hot
# Run a node, drive a high-cardinality aggregation with OSB, attach async-profiler:
./gradlew run &
# (in another shell) drive load with opensearch-benchmark, then profile allocations:
# async-profiler: ./profiler.sh -e alloc -d 30 -f /tmp/alloc.html <opensearch-pid>
The allocation flame graph should show the per-doc allocation dominating. If it does not, stop — you have not found the hot path.
Locate the allocation
grep -n "collect(\|LongValuesSource\|new Long\|new long\[" \
server/src/main/java/org/opensearch/search/aggregations/bucket/terms/<SomeAggregator>.java | head
The schematic of the churn:
@Override
public void collect(int doc, long owningBucketOrd) throws IOException {
long[] tmp = new long[1]; // allocated per document — GC pressure
tmp[0] = values.nextValue();
addToBucket(owningBucketOrd, tmp[0]);
}
Diff — hoist / pool the allocation
--- a/server/src/main/java/org/opensearch/search/aggregations/bucket/terms/SomeAggregator.java
+++ b/server/src/main/java/org/opensearch/search/aggregations/bucket/terms/SomeAggregator.java
@@
- public void collect(int doc, long owningBucketOrd) throws IOException {
- long[] tmp = new long[1];
- tmp[0] = values.nextValue();
- addToBucket(owningBucketOrd, tmp[0]);
- }
+ public void collect(int doc, long owningBucketOrd) throws IOException {
+ // No per-doc allocation: read the value directly.
+ addToBucket(owningBucketOrd, values.nextValue());
+ }
For a structure that genuinely needs growable, large backing storage on a hot path, use
BigArrays (it is pooled and accounted against the request circuit breaker — see
circuit breakers & memory):
// Allocated once, grown as needed, released in close(); accounted by the breaker.
private LongArray counts = bigArrays.newLongArray(1, true);
// ... counts = bigArrays.grow(counts, owningBucketOrd + 1);
Write a JMH microbenchmark that proves it
Add a benchmark under :benchmarks isolating the path:
@Fork(2)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class SomeAggregatorBenchmark {
private long[] values;
@Setup
public void setup() {
values = new long[1_000_000];
Random r = new Random(42); // deterministic
for (int i = 0; i < values.length; i++) values[i] = r.nextInt(100_000);
}
@Benchmark
public long collectAll() {
long acc = 0;
for (long v : values) acc += collectOne(v); // calls the path under test
return acc;
}
}
Run it before and after the change:
# Build the benchmarks jar and run JMH:
./gradlew :benchmarks:jmh -Pjmh.include="SomeAggregatorBenchmark" 2>&1 | tee /tmp/after.txt
# (run on the baseline commit too -> /tmp/before.txt)
Capture both throughput and the gc.alloc.rate JMH profiler if you enable
-prof gc — the allocation-rate drop is the headline number for a GC-pressure fix.
Prove it matters with a macro benchmark
JMH shows the mechanism; OpenSearch Benchmark shows the user-visible effect. Run the same workload against a baseline node and a patched node:
# Against a running cluster on :9200, with a workload that exercises the aggregation:
opensearch-benchmark execute-test --target-hosts=localhost:9200 \
--pipeline=benchmark-only --workload=<a high-cardinality agg workload>
Compare the Mean Throughput / p50/p99 Service Time and the young-GC time between runs.
A real fix moves the macro number or at least the GC time; if it does not, the
micro-improvement was not on a path the macro workload stresses — report that honestly.
Build, CHANGELOG, PR
./gradlew :server:test --tests "*SomeAggregatorTests" -q # correctness must still hold
./gradlew :server:precommit
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ ### Changed
+- Remove per-document allocation in `SomeAggregator.collect`, reducing young-gen GC on high-cardinality terms aggs ([#NNNNN](...))
In the PR description, paste: (1) the JMH before/after with Score ± error, (2) the OSB
throughput/latency/GC deltas, and (3) the seeds/workload so a reviewer can reproduce. A perf
PR with no reproducible numbers is unmergeable in this project.
Reading the numbers honestly
| Claim | What you must show |
|---|---|
| "Faster" | JMH Score improved beyond the ± error bars (overlap = no result) |
| "Less GC" | JMH -prof gc alloc.rate drop, or OSB young-GC-time drop |
| "No regression" | OSB on other common workloads unchanged within noise |
| "Real-world impact" | OSB macro delta, not just JMH micro |
Warning: Overlapping JMH error bars mean no measured difference, no matter how good the change looks on paper. Two forks (
@Fork(2)) and enough iterations are the minimum to trust a delta. A single-fork JMH run is noise.
Pitfalls
- Optimising without a profile. Intuition about hot paths is wrong more often than right. Profile, then optimise the thing the profiler points at.
- Micro-only evidence. A JMH win that no OSB workload reflects is not worth the complexity. State the macro result, even when it is "no measurable change."
- Trading correctness for speed. Re-run the subsystem's unit tests. A faster wrong answer is a Stage 6/7 bug.
- Ignoring the circuit breaker. Pooled
BigArraysallocations are accounted against the request breaker for a reason. Replacing them with un-accounted raw arrays "to go faster" reintroduces the OOM the breaker exists to prevent. - Helping one workload, hurting another. Always run a couple of other OSB workloads. The bar is target-better, others-not-worse.
- Non-reproducible numbers. No seed, no workload name, no hardware note = reviewers cannot trust it. Include all three.
- Bundling optimisations. Two changes, one number = unattributable. One change per PR.
Exit criteria — when you're ready for Stage 11
- One performance PR is merged with a JMH before/after (non-overlapping error bars) and an OpenSearch Benchmark macro delta, both reproducible from the PR.
- You can profile (async-profiler/JFR), find the hot path, and resist optimising anything else.
- You reach for
BigArrays(breaker-accounted) on growable hot-path storage rather than raw arrays. - You report honest negatives: when a micro win does not move the macro, you say so.
Measurement is the evidence base for the hardest judgment calls in the project: when changing the wire or storage format is worth it. Stage 11 applies that judgment to backward compatibility.