| Algorithm | ') + for i, s in enumerate(sizes): + out.append(f'size={h(s)} {h(unit)} | ')
+ out.append('
|---|---|
| {h(method)} | ') + for s in sizes: + out.append(f'') + out.append(' |
diff --git a/lucene/benchmark-jmh/jmh-table.py b/lucene/benchmark-jmh/jmh-table.py
new file mode 100755
index 000000000000..1915672ee43f
--- /dev/null
+++ b/lucene/benchmark-jmh/jmh-table.py
@@ -0,0 +1,771 @@
+#!/usr/bin/env python3
+"""Parse JMH JSON output from stdin, produce an interactive HTML table on stdout.
+
+Supports both JSON (-rf json) and plain text JMH output.
+With JSON input, clicking a cell shows a histogram of the raw iteration samples
+and the benchmark method source code.
+
+Usage:
+ # JSON (recommended – enables histograms + source):
+ java --module-path ... --module org.apache.lucene.benchmark.jmh ScoreDocSortBenchmark \
+ -rf json -rff results.json \
+ && python3 jmh-table.py [BenchmarkSource.java] < results.json > results.html
+
+ # Plain text (no histograms):
+ java --module-path ... --module org.apache.lucene.benchmark.jmh ScoreDocSortBenchmark \
+ | python3 jmh-table.py > results.html
+
+ The optional positional argument is the path to the Java source file containing
+ the @Benchmark methods. If provided, clicking a cell also shows the method source.
+"""
+
+import sys
+import re
+import json
+import html
+import math
+
+
+def parse_jmh_text(text):
+ """Parse plain-text JMH output."""
+ entries = []
+ for line in text.splitlines():
+ m = re.match(
+ r'\S+\.(\S+)\s+'
+ r'(\S+)\s+'
+ r'\S+\s+'
+ r'\d+\s+'
+ r'(\S+)\s+'
+ r'.\s+'
+ r'(\S+)\s+'
+ r'(\S+)',
+ line,
+ )
+ if m:
+ method, param, score, error, unit = m.groups()
+ entries.append({
+ 'method': method,
+ 'param': param,
+ 'score': float(score),
+ 'error': float(error),
+ 'unit': unit,
+ 'raw': [],
+ })
+ return entries, {}
+
+
+def parse_jmh_json(data):
+ """Parse JMH JSON output. Returns (entries, config_dict)."""
+ entries = []
+ config = {}
+ total_sec = 0
+ for i, result in enumerate(data):
+ bench = result['benchmark'].rsplit('.', 1)[-1]
+ params = result.get('params', {})
+ # Handle multiple params: ScoreDocSortBenchmark uses 'size' and 'distribution'
+ size = params.get('size', '')
+ dist = params.get('distribution', 'random')
+ pm = result['primaryMetric']
+ raw = []
+ for fork_data in pm.get('rawData', []):
+ raw.extend(fork_data)
+ entries.append({
+ 'method': bench,
+ 'size': size,
+ 'dist': dist,
+ 'score': pm['score'],
+ 'error': pm['scoreError'],
+ 'unit': pm['scoreUnit'],
+ 'raw': raw,
+ })
+
+ # Estimate total time for this benchmark
+ forks = result.get('forks', 0)
+ wi = result.get('warmupIterations', 0)
+ wt = result.get('warmupTime', '0 s')
+ mi = result.get('measurementIterations', 0)
+ mt = result.get('measurementTime', '0 s')
+
+ def to_sec(t_str):
+ try:
+ val, unit = t_str.split()
+ val = float(val)
+ if unit == 'ms': return val / 1000
+ if unit == 's': return val
+ if unit == 'min': return val * 60
+ return 0
+ except: return 0
+
+ total_sec += forks * (wi * to_sec(wt) + mi * to_sec(mt))
+
+ if i == 0:
+ mode_map = {'avgt': 'Average Time', 'thrpt': 'Throughput',
+ 'sample': 'Sampling', 'ss': 'Single Shot'}
+ # split jvmArgs into harness args (module-path, module-main)
+ # vs benchmark args (user/annotation provided like -Xmx, -XX:)
+ all_jvm_args = result.get('jvmArgs', [])
+ harness_prefixes = ('--module-path', '-Djdk.module.main', '-Djmh.')
+ harness_args = [a for a in all_jvm_args
+ if any(a.startswith(p) for p in harness_prefixes)]
+ benchmark_args = [a for a in all_jvm_args
+ if not any(a.startswith(p) for p in harness_prefixes)]
+ config = {
+ 'mode': mode_map.get(result.get('mode', ''), result.get('mode', '?')),
+ 'forks': result.get('forks', '?'),
+ 'threads': result.get('threads', '?'),
+ 'warmupIterations': result.get('warmupIterations', '?'),
+ 'warmupTime': result.get('warmupTime', '?'),
+ 'measurementIterations': result.get('measurementIterations', '?'),
+ 'measurementTime': result.get('measurementTime', '?'),
+ 'harnessJvmArgs': harness_args,
+ 'benchmarkJvmArgs': benchmark_args,
+ 'jvm': result.get('jvm', ''),
+ 'jdkVersion': result.get('jdkVersion', ''),
+ 'vmName': result.get('vmName', ''),
+ 'vmVersion': result.get('vmVersion', ''),
+ 'jmhVersion': result.get('jmhVersion', ''),
+ }
+
+ if config:
+ if total_sec > 3600:
+ config['totalTime'] = f"{total_sec/3600:.1f} hours"
+ elif total_sec > 60:
+ config['totalTime'] = f"{total_sec/60:.1f} mins"
+ else:
+ config['totalTime'] = f"{total_sec:.1f} s"
+
+ return entries, config
+
+
+def extract_methods(source_path):
+ """Extract @Benchmark method bodies and their runXXX helpers from a Java source file.
+
+ Returns dict of method_name -> source_code_string.
+ """
+ methods = {}
+ if not source_path:
+ return methods
+ try:
+ with open(source_path, 'r') as f:
+ content = f.read()
+ except (OSError, IOError):
+ return methods
+
+ # 1. Find all methods first (crude but effective for this benchmark style)
+ all_methods = {}
+ # Matches: [modifiers] [type] name([args]) { [body] }
+ # Handles nested braces
+ pos = 0
+ while True:
+ m = re.search(r'(?:public|private|protected|static|\s)+\s+[\w<>[\]]+\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+[\w, \t]+)?\s*\{', content[pos:])
+ if not m:
+ break
+ method_name = m.group(1)
+ start_brace = pos + m.end() - 1
+
+ # Find matching closing brace
+ depth = 0
+ end_brace = -1
+ for i in range(start_brace, len(content)):
+ if content[i] == '{':
+ depth += 1
+ elif content[i] == '}':
+ depth -= 1
+ if depth == 0:
+ end_brace = i
+ break
+
+ if end_brace != -1:
+ # Find start of method (including annotations/comments)
+ method_start = pos + m.start()
+ # Look back for comments or annotations
+ lines = content[:method_start].splitlines()
+ actual_start = method_start
+ for i in range(len(lines) - 1, -1, -1):
+ line = lines[i].strip()
+ if line.startswith('@') or line.startswith('//') or line.startswith('*') or line.startswith('/*'):
+ actual_start = content.rfind(lines[i], 0, actual_start)
+ elif not line:
+ continue
+ else:
+ break
+
+ body = content[actual_start:end_brace + 1]
+ # Dedent
+ lines = body.splitlines()
+ non_empty = [l for l in lines if l.strip()]
+ if non_empty:
+ min_indent = min(len(l) - len(l.lstrip()) for l in non_empty)
+ body = '\n'.join(l[min_indent:] if len(l) > min_indent else l for l in lines)
+
+ all_methods[method_name] = body
+ pos = end_brace + 1
+ else:
+ pos += m.end()
+
+ # 2. Filter for @Benchmark methods and attach runXXX helpers
+ for name, body in all_methods.items():
+ if '@Benchmark' in body:
+ # Look for runXXX call: e.g. runJdkSortLambda(work)
+ # Pattern: run followed by capitalized method name
+ run_name = "run" + name[0].upper() + name[1:]
+ if run_name in all_methods:
+ methods[name] = body + "\n\n" + all_methods[run_name]
+ else:
+ methods[name] = body
+
+ return methods
+
+
+def lerp_color(t):
+ """Green (t=0, best) -> yellow (t=0.5) -> red (t=1, worst)."""
+ t = max(0.0, min(1.0, t))
+ if t < 0.5:
+ u = t * 2
+ r = int(120 * u)
+ g = 180
+ b = int(80 * (1 - u))
+ else:
+ u = (t - 0.5) * 2
+ r = 120 + int(100 * u)
+ g = int(180 * (1 - u))
+ b = 0
+ return r, g, b
+
+
+def sparkline_svg(raw_samples, width=120, height=24, num_bins=20):
+ """Generate a tiny inline SVG histogram sparkline from raw samples."""
+ if not raw_samples or len(raw_samples) < 2:
+ return ''
+ lo = min(raw_samples)
+ hi = max(raw_samples)
+ span = hi - lo
+ if span == 0:
+ span = 1
+ bins = [0] * num_bins
+ for v in raw_samples:
+ idx = int((v - lo) / span * num_bins)
+ if idx >= num_bins:
+ idx = num_bins - 1
+ bins[idx] += 1
+ max_count = max(bins)
+ if max_count == 0:
+ return ''
+ bar_w = width / num_bins
+ bars = []
+ for i, count in enumerate(bins):
+ bar_h = (count / max_count) * height
+ x = i * bar_w
+ y = height - bar_h
+ # Monochrome sparkline to avoid confusion with heatmap colors
+ r, g, b = 102, 136, 170
+ bars.append(
+ f'
| {h(label)} | {h(val)} |
Click column headers to sort.{click_hint}
') + + out.append('| Algorithm | ') + for i, s in enumerate(sizes): + out.append(f'size={h(s)} {h(unit)} | ')
+ out.append('
|---|---|
| {h(method)} | ') + for s in sizes: + out.append(f'') + out.append(' |
{@code
+ * ./lucene/benchmark-jmh/run-benchmark.sh ScoreDocSortBenchmark \
+ * -rf json -rff results.json
+ * }
+ *
+ * Or build and run manually: + * + *
{@code
+ * ./gradlew :lucene:benchmark-jmh:assemble
+ * java --module-path lucene/benchmark-jmh/build/benchmarks \
+ * --module org.apache.lucene.benchmark.jmh \
+ * ScoreDocSortBenchmark \
+ * -rf json -rff results.json
+ * }
+ *
+ * {@code
+ * python3 lucene/benchmark-jmh/jmh-table.py \
+ * lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/ScoreDocSortBenchmark.java \
+ * < results.json > results.html
+ * }
+ *
+ * The HTML report provides: + * + *
Note: using Level.Invocation introduces overhead that JMH cannot easily subtract. For very + * small sizes (e.g. size=10), this overhead might be comparable to the benchmarked sort itself. + * We accept this because each invocation must start with the same unsorted array to ensure + * reproducibility across different sorting algorithms. + */ + @Setup(Level.Invocation) + public void setupInvocation() { + work = new ScoreDoc[size]; + System.arraycopy(template, 0, work, 0, size); + } + + // ---- 1. JDK Arrays.sort with lambda ---- + + private ScoreDoc[] runJdkSortLambda(ScoreDoc[] work) { + Arrays.sort(work, (a, b) -> Integer.compare(a.doc, b.doc)); + return work; + } + + @Benchmark + public void jdkSortLambda(Blackhole bh) { + // intentionally inline — tests whether JIT handles inline lambda differently than static + // comparator + bh.consume(runJdkSortLambda(work)); + } + + // ---- 2. JDK Arrays.sort with static comparator ---- + + private ScoreDoc[] runJdkSortComparator(ScoreDoc[] work) { + Arrays.sort(work, BY_DOC_ASC); + return work; + } + + @Benchmark + public void jdkSortComparator(Blackhole bh) { + bh.consume(runJdkSortComparator(work)); + } + + // ---- 3. ArrayUtil.introSort (wraps ArrayIntroSorter) ---- + + private ScoreDoc[] runArrayUtilIntroSort(ScoreDoc[] work) { + ArrayUtil.introSort(work, BY_DOC_ASC); + return work; + } + + @Benchmark + public void arrayUtilIntroSort(Blackhole bh) { + bh.consume(runArrayUtilIntroSort(work)); + } + + // ---- 4. ArrayUtil.timSort (wraps ArrayTimSorter) ---- + + private ScoreDoc[] runArrayUtilTimSort(ScoreDoc[] work) { + ArrayUtil.timSort(work, BY_DOC_ASC); + return work; + } + + @Benchmark + public void arrayUtilTimSort(Blackhole bh) { + bh.consume(runArrayUtilTimSort(work)); + } + + // ---- 5. Anonymous IntroSorter ---- + + private ScoreDoc[] runIntroSorterAnonymous(ScoreDoc[] work) { + final ScoreDoc[] arr = work; + new IntroSorter() { + ScoreDoc pivot; + + @Override + protected void swap(int i, int j) { + ScoreDoc tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + } + + @Override + protected void setPivot(int i) { + pivot = arr[i]; + } + + @Override + protected int comparePivot(int j) { + return Integer.compare(pivot.doc, arr[j].doc); + } + + @Override + protected int compare(int i, int j) { + return Integer.compare(arr[i].doc, arr[j].doc); + } + }.sort(0, arr.length); + return arr; + } + + @Benchmark + public void introSorterAnonymous(Blackhole bh) { + bh.consume(runIntroSorterAnonymous(work)); + } + + // ---- 6. Anonymous TimSorter ---- + + private ScoreDoc[] runTimSorterAnonymous(ScoreDoc[] work) { + final ScoreDoc[] arr = work; + final int len = arr.length; + new TimSorter(len / 2) { + ScoreDoc[] tmp = new ScoreDoc[len / 2]; + + @Override + protected void swap(int i, int j) { + ScoreDoc t = arr[i]; + arr[i] = arr[j]; + arr[j] = t; + } + + @Override + protected int compare(int i, int j) { + return Integer.compare(arr[i].doc, arr[j].doc); + } + + @Override + protected void copy(int src, int dest) { + arr[dest] = arr[src]; + } + + @Override + protected void save(int start, int l) { + System.arraycopy(arr, start, tmp, 0, l); + } + + @Override + protected void restore(int src, int dest) { + arr[dest] = tmp[src]; + } + + @Override + protected int compareSaved(int i, int j) { + return Integer.compare(tmp[i].doc, arr[j].doc); + } + }.sort(0, len); + return arr; + } + + @Benchmark + public void timSorterAnonymous(Blackhole bh) { + bh.consume(runTimSorterAnonymous(work)); + } + + // ---- 7. Anonymous InPlaceMergeSorter ---- + + private ScoreDoc[] runInPlaceMergeSorterAnonymous(ScoreDoc[] work) { + final ScoreDoc[] arr = work; + new InPlaceMergeSorter() { + @Override + protected void swap(int i, int j) { + ScoreDoc tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + } + + @Override + protected int compare(int i, int j) { + return Integer.compare(arr[i].doc, arr[j].doc); + } + }.sort(0, arr.length); + return arr; + } + + @Benchmark + public void inPlaceMergeSorterAnonymous(Blackhole bh) { + bh.consume(runInPlaceMergeSorterAnonymous(work)); + } + + // ---- 8. JDK Arrays.parallelSort with static comparator ---- + + private ScoreDoc[] runJdkParallelSort(ScoreDoc[] work) { + Arrays.parallelSort(work, BY_DOC_ASC); + return work; + } + + @Benchmark + public void jdkParallelSort(Blackhole bh) { + bh.consume(runJdkParallelSort(work)); + } + + // ---- 9. Extract doc IDs, sort with JDK Arrays.sort (primitive long[]), reorder ---- + + private ScoreDoc[] runJdkSortPrimitiveExtractLong(ScoreDoc[] work) { + int len = work.length; + // pack (doc, originalIndex) into a long: doc in upper 32, index in lower 32 + long[] packed = new long[len]; + for (int i = 0; i < len; i++) { + packed[i] = ((long) work[i].doc << 32) | (i & 0xFFFFFFFFL); + } + Arrays.sort(packed); + ScoreDoc[] sorted = new ScoreDoc[len]; + for (int i = 0; i < len; i++) { + sorted[i] = work[(int) packed[i]]; + } + return sorted; + } + + @Benchmark + public void jdkSortPrimitiveExtractLong(Blackhole bh) { + bh.consume(runJdkSortPrimitiveExtractLong(work)); + } + + // ---- 10. Extract doc IDs, sort with int[] when bits fit, else long[] ---- + + // bits needed to represent values in [0, max) + private static int bitsNeeded(int max) { + return 32 - Integer.numberOfLeadingZeros(max - 1); + } + + private ScoreDoc[] runJdkSortPrimitiveExtractAdaptive(ScoreDoc[] work) { + int len = work.length; + int docBits = bitsNeeded(MAX_DOC); + int indexBits = bitsNeeded(len); + if (docBits + indexBits <= 31) { + // pack into int[]: doc in upper bits, index in lower bits + // <= 31 (not 32) because Arrays.sort uses signed comparison, + // so bit 31 must stay clear to avoid sign-bit corruption + int[] packed = new int[len]; + for (int i = 0; i < len; i++) { + packed[i] = (work[i].doc << indexBits) | i; + } + Arrays.sort(packed); + int indexMask = (1 << indexBits) - 1; + ScoreDoc[] sorted = new ScoreDoc[len]; + for (int i = 0; i < len; i++) { + sorted[i] = work[packed[i] & indexMask]; + } + return sorted; + } else { + // fall back to long[] + long[] packed = new long[len]; + for (int i = 0; i < len; i++) { + packed[i] = ((long) work[i].doc << 32) | (i & 0xFFFFFFFFL); + } + Arrays.sort(packed); + ScoreDoc[] sorted = new ScoreDoc[len]; + for (int i = 0; i < len; i++) { + sorted[i] = work[(int) packed[i]]; + } + return sorted; + } + } + + @Benchmark + public void jdkSortPrimitiveExtractAdaptive(Blackhole bh) { + /** + * Documentation of int vs long paths given MAX_DOC = 5,000,000: + * + *