Skip to content

Commit e5fa067

Browse files
authored
fix: clarify benchmark regression report verdicts (#2394)
## Summary - Add an explicit base-vs-head regression verdict using comparable JMH metadata, confidence-interval overlap, and a practical 5% threshold. - Remove cross-method `Nx slower` rankings from the PR-head tables. - List head-only benchmarks separately as descriptive results with no regression verdict. ## Motivation The [#2329 benchmark report](#2329 (comment)) described `HistogramBenchmark.openTelemetryExponential` as “20x slower” only because it was ranked against a different benchmark method in the same run. Its actual base/head delta was -5.7% with overlapping confidence intervals (within noise). This change makes that distinction explicit and prevents the within-run ranking from being mistaken for a regression. ## Tests - `python3 .mise/tasks/test_generate-benchmark-summary.py` - `python3 .mise/tasks/test_update-benchmarks.py` - `ruff check` and `ruff format --check` on the changed Python files - `mise run lint` (full lint is blocked only by the pre-existing README Slack link returning HTTP 403; the pre-push scoped lint passed) Related: [#2329](#2329). --------- Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent 0c4bcbd commit e5fa067

2 files changed

Lines changed: 298 additions & 57 deletions

File tree

.mise/tasks/generate_benchmark_summary.py

Lines changed: 142 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from datetime import datetime, timezone
2727
from pathlib import Path
2828

29+
PRACTICAL_CHANGE_THRESHOLD = 5.0
30+
2931

3032
def parse_args():
3133
parser = argparse.ArgumentParser(
@@ -246,7 +248,7 @@ def metric_score(result: dict) -> float | None:
246248
"""Extract a benchmark score as a finite float."""
247249
try:
248250
score = float(result.get("primaryMetric", {}).get("score"))
249-
if not math.isnan(score):
251+
if math.isfinite(score):
250252
return score
251253
except (ValueError, TypeError):
252254
pass
@@ -261,7 +263,7 @@ def score_interval(result: dict) -> tuple[float, float] | None:
261263
try:
262264
low = float(confidence[0])
263265
high = float(confidence[1])
264-
if not math.isnan(low) and not math.isnan(high):
266+
if math.isfinite(low) and math.isfinite(high):
265267
return min(low, high), max(low, high)
266268
except (ValueError, TypeError):
267269
pass
@@ -271,7 +273,7 @@ def score_interval(result: dict) -> tuple[float, float] | None:
271273
return None
272274
try:
273275
error = float(metric.get("scoreError"))
274-
if not math.isnan(error):
276+
if math.isfinite(error) and error >= 0:
275277
return score - error, score + error
276278
except (ValueError, TypeError):
277279
pass
@@ -285,41 +287,81 @@ def lower_is_better(result: dict) -> bool:
285287
return mode in {"avgt", "sample", "ss"} or unit.endswith("/op")
286288

287289

290+
def normalize_jvm_args(value) -> list:
291+
"""Normalize optional JMH JVM argument fields for metadata comparison."""
292+
if value is None:
293+
return []
294+
if isinstance(value, list):
295+
return value
296+
return [value]
297+
298+
299+
def benchmark_metadata(result: dict) -> dict:
300+
"""Return metadata that must match for a base/head comparison."""
301+
primary_metric = result.get("primaryMetric", {})
302+
return {
303+
"jmhVersion": result.get("jmhVersion"),
304+
"mode": result.get("mode"),
305+
"vmName": result.get("vmName"),
306+
"vmVersion": result.get("vmVersion"),
307+
"jvmArgs": normalize_jvm_args(result.get("jvmArgs")),
308+
"jvmArgsPrepend": normalize_jvm_args(result.get("jvmArgsPrepend")),
309+
"jvmArgsAppend": normalize_jvm_args(result.get("jvmArgsAppend")),
310+
"threads": result.get("threads"),
311+
"forks": result.get("forks"),
312+
"warmupIterations": result.get("warmupIterations"),
313+
"warmupTime": result.get("warmupTime"),
314+
"warmupBatchSize": result.get("warmupBatchSize"),
315+
"measurementIterations": result.get("measurementIterations"),
316+
"measurementTime": result.get("measurementTime"),
317+
"measurementBatchSize": result.get("measurementBatchSize"),
318+
"jdkVersion": result.get("jdkVersion"),
319+
"scoreUnit": primary_metric.get("scoreUnit"),
320+
"params": result.get("params", {}),
321+
}
322+
323+
324+
def comparable_metadata(head: dict, baseline: dict) -> bool:
325+
"""Return whether two results describe the same benchmark configuration."""
326+
return benchmark_metadata(head) == benchmark_metadata(baseline)
327+
328+
288329
def comparison_status(head: dict, baseline: dict) -> str:
289-
"""Classify a benchmark comparison using confidence intervals."""
330+
"""Classify a benchmark comparison using confidence intervals and a threshold."""
290331
head_interval = score_interval(head)
291332
baseline_interval = score_interval(baseline)
292333
head_score = metric_score(head)
293334
baseline_score = metric_score(baseline)
294-
if head_score is None or baseline_score is None:
295-
return ""
296-
297-
is_lower_better = lower_is_better(head)
298-
if head_interval and baseline_interval:
299-
head_low, head_high = head_interval
300-
baseline_low, baseline_high = baseline_interval
301-
if is_lower_better:
302-
if head_high < baseline_low:
303-
return "faster"
304-
if head_low > baseline_high:
305-
return "slower"
306-
else:
307-
if head_low > baseline_high:
308-
return "faster"
309-
if head_high < baseline_low:
310-
return "slower"
335+
if (
336+
not comparable_metadata(head, baseline)
337+
or head_score is None
338+
or baseline_score is None
339+
):
340+
return "inconclusive"
341+
342+
change = performance_change(head, baseline)
343+
if change is None or head_interval is None or baseline_interval is None:
344+
return "inconclusive"
345+
346+
head_low, head_high = head_interval
347+
baseline_low, baseline_high = baseline_interval
348+
intervals_overlap = head_low <= baseline_high and baseline_low <= head_high
349+
if intervals_overlap or abs(change) < PRACTICAL_CHANGE_THRESHOLD:
311350
return "within noise"
312351

313-
if is_lower_better:
314-
return "faster" if head_score < baseline_score else "slower"
315-
return "faster" if head_score > baseline_score else "slower"
352+
return "meaningful improvement" if change > 0 else "meaningful regression"
316353

317354

318355
def performance_change(head: dict, baseline: dict) -> float | None:
319356
"""Return percent performance change, with positive meaning faster."""
320357
head_score = metric_score(head)
321358
baseline_score = metric_score(baseline)
322-
if head_score is None or baseline_score in (None, 0):
359+
if (
360+
head_score is None
361+
or baseline_score is None
362+
or head_score == 0
363+
or baseline_score == 0
364+
):
323365
return None
324366
if lower_is_better(head):
325367
return (float(baseline_score) / head_score - 1) * 100
@@ -333,6 +375,27 @@ def format_change(change: float | None) -> str:
333375
return f"{change:+.1f}%"
334376

335377

378+
def metric_direction_note(results: list) -> str:
379+
"""Describe whether scores represent throughput or latency."""
380+
directions = {
381+
"latency" if lower_is_better(result) else "throughput" for result in results
382+
}
383+
if directions == {"throughput"}:
384+
return (
385+
"Throughput scores are higher-is-better; positive Head vs base deltas "
386+
"indicate faster performance."
387+
)
388+
if directions == {"latency"}:
389+
return (
390+
"Latency scores are lower-is-better; positive Head vs base deltas "
391+
"indicate faster performance."
392+
)
393+
return (
394+
"Throughput scores are higher-is-better and latency scores are "
395+
"lower-is-better; positive Head vs base deltas indicate faster performance."
396+
)
397+
398+
336399
def generate_comparison_section(
337400
results: list,
338401
baseline_results: list,
@@ -356,7 +419,7 @@ def generate_comparison_section(
356419
md.append("")
357420
md.append(f"- **Head:** {format_commit_link(commit_sha, repo)}")
358421
md.append(f"- **Base:** {format_commit_link(baseline_sha, baseline_repo)}")
359-
md.append("- **Change:** positive means the PR is faster than base.")
422+
md.append(f"- **Metric direction:** {metric_direction_note(results)}")
360423
if comparison_note:
361424
md.append(f"- **Note:** {comparison_note}")
362425
if baseline_system_info:
@@ -373,7 +436,7 @@ def generate_comparison_section(
373436
md.append("")
374437
return md
375438

376-
md.append("| Benchmark | PR | Base | Change | Result |")
439+
md.append("| Benchmark | PR | Base | Head vs base | Regression verdict |")
377440
md.append("|:----------|---:|-----:|-------:|:-------|")
378441

379442
for name in common_names:
@@ -396,7 +459,9 @@ def generate_comparison_section(
396459
md.append("")
397460
if missing_in_base:
398461
missing = ", ".join(short_benchmark_name(name) for name in missing_in_base)
399-
md.append(f"- Benchmarks only in PR results: {missing}")
462+
md.append(
463+
f"- Benchmarks only in PR results (listed separately below): {missing}"
464+
)
400465
if missing_in_head:
401466
missing = ", ".join(short_benchmark_name(name) for name in missing_in_head)
402467
md.append(f"- Benchmarks only in base results: {missing}")
@@ -474,9 +539,25 @@ def generate_markdown(
474539
)
475540
)
476541

542+
# A benchmark without a base counterpart cannot receive a regression verdict.
543+
# Keep it out of the comparison-oriented head table and list it separately.
544+
baseline_names = {
545+
b.get("benchmark", "") for b in (baseline_results or []) if b.get("benchmark")
546+
}
547+
if baseline_results:
548+
comparable_results = [
549+
b for b in results if b.get("benchmark", "") in baseline_names
550+
]
551+
head_only_results = [
552+
b for b in results if b.get("benchmark", "") not in baseline_names
553+
]
554+
else:
555+
comparable_results = results
556+
head_only_results = []
557+
477558
# Group by benchmark class
478559
benchmarks_by_class: dict[str, list] = {}
479-
for b in results:
560+
for b in comparable_results:
480561
name = b.get("benchmark", "")
481562
parts = name.rsplit(".", 1)
482563
if len(parts) == 2:
@@ -502,16 +583,10 @@ def generate_markdown(
502583
reverse=True,
503584
)
504585

505-
md.append("| Benchmark | Score | Error | Units | Within run |")
506-
md.append("|:----------|------:|------:|:------|:-----------|")
507-
508-
best_score = (
509-
sorted_benchmarks[0].get("primaryMetric", {}).get("score", 1)
510-
if sorted_benchmarks
511-
else 1
512-
)
586+
md.append("| Benchmark | Score | Error | Units |")
587+
md.append("|:----------|------:|------:|:------|")
513588

514-
for i, b in enumerate(sorted_benchmarks):
589+
for b in sorted_benchmarks:
515590
name = b.get("benchmark", "").split(".")[-1]
516591
score = b.get("primaryMetric", {}).get("score", 0)
517592
error = b.get("primaryMetric", {}).get("scoreError", 0)
@@ -520,23 +595,28 @@ def generate_markdown(
520595
score_fmt = format_score(score)
521596
error_fmt = format_error(error)
522597

523-
# Calculate relative performance as multiplier
524-
try:
525-
if i == 0:
526-
relative_fmt = "**fastest**"
527-
else:
528-
multiplier = float(best_score) / float(score)
529-
if multiplier >= 10:
530-
relative_fmt = f"{multiplier:.0f}x slower"
531-
else:
532-
relative_fmt = f"{multiplier:.1f}x slower"
533-
except (ValueError, TypeError, ZeroDivisionError):
534-
relative_fmt = ""
598+
md.append(f"| {name} | {score_fmt} | {error_fmt} | {unit} |")
535599

600+
md.append("")
601+
602+
if head_only_results:
603+
md.append("## New benchmarks in PR head")
604+
md.append("")
605+
md.append(
606+
"These benchmarks have no base counterpart; scores are descriptive only "
607+
"and have no regression verdict."
608+
)
609+
md.append("")
610+
md.append("| Benchmark | Score | Error | Units |")
611+
md.append("|:----------|------:|------:|:------|")
612+
for b in sorted(head_only_results, key=lambda x: x.get("benchmark", "")):
613+
name = short_benchmark_name(b.get("benchmark", ""))
614+
score = b.get("primaryMetric", {}).get("score", 0)
615+
error = b.get("primaryMetric", {}).get("scoreError", 0)
616+
unit = b.get("primaryMetric", {}).get("scoreUnit", "ops/s")
536617
md.append(
537-
f"| {name} | {score_fmt} | {error_fmt} | {unit} | {relative_fmt} |"
618+
f"| {name} | {format_score(score)} | {format_error(error)} | {unit} |"
538619
)
539-
540620
md.append("")
541621

542622
md.append("### Raw Results")
@@ -577,16 +657,21 @@ def generate_markdown(
577657

578658
md.append("## Notes")
579659
md.append("")
580-
md.append("- **Score** = Throughput in operations per second (higher is better)")
660+
md.append(
661+
"- **Score** = the JMH primary metric; "
662+
"throughput is higher-is-better and latency is lower-is-better."
663+
)
581664
md.append("- **Error** = 99.9% confidence interval")
582665
if baseline_results:
583666
md.append(
584-
"- **Comparison with base** uses JMH confidence intervals when "
585-
'available; overlapping intervals are marked "within noise".'
667+
"- **Regression verdict** requires comparable benchmark metadata, "
668+
"non-overlapping JMH confidence intervals, and a change of at least "
669+
f"{PRACTICAL_CHANGE_THRESHOLD:.0f}%; otherwise it is marked "
670+
'"within noise" or "inconclusive".'
586671
)
587672
md.append(
588-
"- **Within run** compares benchmarks in the same result set, not against "
589-
"the base commit."
673+
"- Scores for different benchmark methods are not ranked against one another; "
674+
"they may measure different workloads."
590675
)
591676
md.append("")
592677

0 commit comments

Comments
 (0)