2626from datetime import datetime , timezone
2727from pathlib import Path
2828
29+ PRACTICAL_CHANGE_THRESHOLD = 5.0
30+
2931
3032def 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+
288329def 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
318355def 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+
336399def 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