blob: 27fcbf60f289d6f128ca51a8caf52db223c6787a [file] [edit]
#!/usr/bin/env python3
import argparse
import functools
import math
import pathlib
import re
import statistics
import sys
import tempfile
import warnings
import numpy
import pandas
import plotly.express
import scipy.stats
import tabulate
def parse_lnt(lines):
"""
Parse lines in LNT format and return a list of dictionnaries of the form:
[
{
'benchmark': <benchmark1>,
<metric1>: [float, ...],
<metric2>: [float, ...],
...
},
...
]
Each metric maps to the list of its raw samples (a metric can appear more than once for
a benchmark, e.g. when several runs are present in the same file). Aggregation is done
separately.
"""
results = {}
for line in lines:
line = line.strip()
if not line:
continue
(identifier, value) = line.split(' ')
(benchmark, metric) = identifier.split('.')
if benchmark not in results:
results[benchmark] = {'benchmark': benchmark}
entry = results[benchmark]
if metric not in entry:
entry[metric] = []
entry[metric].append(float(value))
return list(results.values())
def aggregate_metrics(entry, aggregate=statistics.median):
"""
Return a copy of a parsed entry where each metric's list of samples is reduced to a
single value using `aggregate` (the median by default). This produces the scalar values
shown in the comparison; statistics that need the raw samples are computed separately.
"""
return {key: (aggregate(value) if isinstance(value, list) else value)
for (key, value) in entry.items()}
def samples_matrix(entries, metric, benchmarks):
"""
Build a 2D array of raw samples for `metric`, with one row per benchmark in `benchmarks`
(in that order) and one column per sample. Rows are padded with NaN so benchmarks with
fewer samples (or none at all) still line up. This is the shape scipy expects.
"""
samples = {entry['benchmark']: entry[metric] for entry in entries if metric in entry}
rows = [samples.get(benchmark, []) for benchmark in benchmarks]
width = max((len(row) for row in rows), default=0)
matrix = numpy.full((len(rows), max(width, 1)), numpy.nan)
for (i, row) in enumerate(rows):
matrix[i, :len(row)] = row
return matrix
def confidence_interval(baseline, candidate, confidence=0.95):
"""
Compute a two-sided Welch T confidence interval on the relative difference of the means,
`(candidate - baseline) / baseline`, for each benchmark. `baseline` and `candidate` are 2D
arrays of raw samples (one row per benchmark). Returns two arrays (low, high) expressed as
fractions of the baseline mean, with NaN where the interval is undefined (e.g. fewer than
two samples on a side).
We deliberately use the mean rather than the aggregated median: a T interval is defined
around the mean. With only a handful of samples per benchmark, this is the only estimator
with any statistical power, which is why the interval is reported as an explicit
`[low, high]` range that is not generally centered on the median-based difference shown
next to it.
"""
with warnings.catch_warnings():
warnings.simplefilter('ignore') # empty/tiny per-row samples make scipy chatty
test = scipy.stats.ttest_ind(candidate, baseline, axis=1, equal_var=False, nan_policy='omit')
(low, high) = test.confidence_interval(confidence)
baseline_mean = numpy.nanmean(baseline, axis=1)
return (numpy.ma.filled(low, numpy.nan) / baseline_mean,
numpy.ma.filled(high, numpy.nan) / baseline_mean)
def plain_text_comparison(data, metric, baseline_name=None, candidate_name=None):
"""
Create a tabulated comparison of the baseline and the candidate for the given metric.
"""
# Show a confidence interval on the % difference, but only when the inputs contain more
# than one sample per benchmark (otherwise there's no dispersion to estimate it from).
# This flags statistically significant rows without altering the common single-sample output.
show_ci = max(data['samples_0'].max(), data['samples_1'].max()) > 1
# Compute the geomean and report on their difference (before NaNs are replaced below)
geomean_0 = statistics.geometric_mean(data[f'{metric}_0'].dropna())
geomean_1 = statistics.geometric_mean(data[f'{metric}_1'].dropna())
headers = ['Benchmark', baseline_name, candidate_name, 'Difference', '% Difference']
fmt = [None, '.2f', '.2f', '.2f', '.2%']
columns = ['benchmark', f'{metric}_0', f'{metric}_1', 'difference', 'percent']
geomean_row = ['Geomean', geomean_0, geomean_1, (geomean_1 - geomean_0), (geomean_1 - geomean_0) / geomean_0]
colalign = None
if show_ci:
# Render the interval as an explicit [low, high] range, and mark rows whose interval
# excludes zero: those are statistically significant changes we can be confident are real.
def render(row):
(low, high) = (row['ci_low'], row['ci_high'])
if any(x is None or not math.isfinite(x) for x in (low, high)):
return ('', '')
significant = not (low < 0 < high) # the confidence interval excludes zero
return (f'[{low:+.1%}, {high:+.1%}]', 'x' if significant else '')
rendered = data.apply(render, axis=1, result_type='expand')
data = data.assign(ci_range=rendered[0], ci_flag=rendered[1])
headers += ['Significant?', '95% C.I. of %diff']
fmt += [None, None]
columns += ['ci_flag', 'ci_range']
geomean_row += ['', '']
# Center the significance marker; keep the benchmark name and interval left-aligned
# and every numeric column right-aligned (matching `numalign` above).
def alignment(column):
if column == 'ci_flag':
return 'center'
return 'left' if column in ('benchmark', 'ci_range') else 'right'
colalign = [alignment(column) for column in columns]
data = data.replace(numpy.nan, None) # avoid NaNs in tabulate output
table = data[columns]
table.loc[table.index.max() + 1] = geomean_row
return tabulate.tabulate(table.set_index('benchmark'), headers=headers, floatfmt=fmt, numalign='right', colalign=colalign)
def create_chart(data, metric, subtitle=None, series_names=None):
"""
Create a bar chart comparing the given metric across the provided series.
"""
data = data.rename(columns={f'{metric}_{i}': series_names[i] for i in range(len(series_names))})
title = ' vs '.join(series_names)
figure = plotly.express.bar(data, title=title, subtitle=subtitle, x='benchmark', y=series_names, barmode='group')
figure.update_layout(xaxis_title='', yaxis_title='', legend_title='')
return figure
def produce_kpis(data, noise, extrema, series, series_names, meta_candidate, title):
addendum = f"{noise:.0%} noise threshold, based on {len(data)} benchmarks"
top_addendum = f"by >= {extrema:.0%}, {noise:.0%} noise threshold, based on {len(data)} benchmarks"
headers = [title if title else '']
columns = [[
f'Benchmarks where {meta_candidate} is faster than {series_names[0]} ({addendum})',
f'Neutral benchmarks ({addendum})',
f'Benchmarks where {meta_candidate} is slower than {series_names[0]} ({addendum})',
f'Worst performers ({top_addendum})',
f'Best performers ({top_addendum})',
]]
fmt = [None]
def compute_kpis(base, cand):
diff = data[cand] - data[base]
pct = diff / data[base]
faster = data[(data[base] > data[cand]) & (pct.abs() > noise)]
neutral = data[pct.abs() <= noise]
slower = data[(data[base] < data[cand]) & (pct.abs() > noise)]
worst = data[(data[base] < data[cand]) & (pct.abs() >= extrema)]
best = data[(data[base] > data[cand]) & (pct.abs() >= extrema)]
return list(map(lambda k: len(k) / len(data), [faster, neutral, slower, worst, best]))
baseline = series[0]
for (i, candidate) in enumerate(series[1:], start=1):
kpis = compute_kpis(baseline, candidate)
headers.append(series_names[i])
columns.append(kpis)
fmt.append('.2%')
rows = list(zip(*columns))
print(tabulate.tabulate(rows, headers=headers, floatfmt=fmt))
def main(argv):
parser = argparse.ArgumentParser(
prog='compare-benchmarks',
description='Compare the results of multiple sets of benchmarks in LNT format.',
epilog='This script depends on the modules listed in `libcxx/utils/requirements.txt`.')
parser.add_argument('files', type=argparse.FileType('r'), nargs='+',
help='Path to LNT format files containing the benchmark results to compare. In the text format, '
'exactly two files must be compared.')
parser.add_argument('--output', '-o', type=pathlib.Path, required=False,
help='Path of a file where to output the resulting comparison. If the output format is `text`, '
'default to stdout. If the output format is `chart`, default to a temporary file which is '
'opened automatically once generated, but not removed after creation.')
parser.add_argument('--metric', type=str, default='execution_time',
help='The metric to compare. LNT data may contain multiple metrics (e.g. code size, execution time, etc) -- '
'this option allows selecting which metric is being analyzed. The default is `execution_time`.')
parser.add_argument('--filter', type=str, required=False,
help='An optional regular expression used to filter the benchmarks included in the comparison. '
'Only benchmarks whose names match the regular expression will be included.')
parser.add_argument('--ignore-under', type=float, required=False,
help='Ignore benchmarks whose value (in absolute terms) is less than the provided float for all '
'the data sets being compared. This allows ignoring benchmarks that are likely to contain '
'a significant amount of noise.')
parser.add_argument('--sort', type=str, required=False, default='benchmark',
choices=['benchmark', 'baseline', 'candidate', 'percent_diff'],
help='Optional sorting criteria for displaying results. By default, results are displayed in '
'alphabetical order of the benchmark. Supported sorting criteria are: '
'`benchmark` (sort using the alphabetical name of the benchmark), '
'`baseline` (sort using the absolute number of the baseline run), '
'`candidate` (sort using the absolute number of the candidate run), '
'and `percent_diff` (sort using the percent difference between the baseline and the candidate). '
'Note that when more than two input files are compared, the only valid sorting order is `benchmark`.')
parser.add_argument('--format', type=str, choices=['text', 'chart', 'kpi'], default='text',
help='Select the output format. `text` generates a plain-text comparison in tabular form, `chart` '
'generates a self-contained HTML graph that can be opened in a browser, and `kpi` generates a '
'summary report based on a few KPIs. The default is `text`.')
parser.add_argument('--open', action='store_true',
help='Whether to automatically open the generated HTML file when finished. This option only makes sense '
'when the output format is `chart`.')
parser.add_argument('--series-names', type=str, required=False,
help='Optional comma-delimited list of names to use for the various series. By default, we use '
'Baseline and Candidate for two input files, and CandidateN for subsequent inputs.')
parser.add_argument('--subtitle', type=str, required=False,
help='Optional subtitle to use for the chart. This can be used to help identify the contents of the chart. '
'This option cannot be used with the plain text output.')
parser.add_argument('--noise-threshold', type=float, required=False,
help='Noise threshold used by KPIs to determine noise. This is a floating point number between '
'0 and 1 that represents the percentage of difference required between two results in order '
'for them not to be considered "within the noise" of each other.')
parser.add_argument('--top-performer-threshold', type=float, required=False, default=0.5,
help='Threshold percent used by KPIs to determine top (and worst) performers. This is a floating '
'point number between 0 and 1 that represents the percentage of difference required to consider '
'a benchmark to be a top/worst performer. For example, if this number is 0.5, we consider top/worst '
'performers in the data to be benchmarks that have at least 50%% of difference between the baseline '
'and the candidate.')
parser.add_argument('--meta-candidate', type=str, required=False,
help='The name to use for the candidate when producing a KPI report. Required for --format=kpi.')
parser.add_argument('--discard-benchmarks-introduced-after', type=str, required=False,
help='Discard benchmarks introduced after the given candidate. This is useful to stabilize reports '
'when new benchmarks are introduced as time goes on, which would change the total number of '
'benchmarks and hence appear to retroactively change the report for previous candidates. '
'If used, the name used here must correspond to the name of a series (as passed to or defaulted '
'via `--series-names`.')
args = parser.parse_args(argv)
# Validate arguments (the values admissible for various arguments depend on other
# arguments, the number of inputs, etc)
if args.format == 'text':
if len(args.files) != 2:
parser.error('--format=text requires exactly two input files to compare')
if args.subtitle is not None:
parser.error('Passing --subtitle makes no sense with --format=text')
if args.open:
parser.error('Passing --open makes no sense with --format=text')
if args.format == 'kpi':
if args.open:
parser.error('Passing --open makes no sense with --format=kpi')
if args.noise_threshold is None:
raise parser.error('--format=kpi requires passing a --noise-threshold')
if args.meta_candidate is None:
raise parser.error('--format=kpi requires passing a --meta-candidate')
if len(args.files) != 2 and args.sort != 'benchmark':
parser.error('Using any sort order other than `benchmark` requires exactly two input files.')
if args.series_names is None:
args.series_names = ['Baseline']
if len(args.files) == 2:
args.series_names += ['Candidate']
elif len(args.files) > 2:
args.series_names.extend(f'Candidate{n}' for n in range(1, len(args.files)))
else:
args.series_names = args.series_names.split(',')
if len(args.series_names) != len(args.files):
parser.error(f'Passed incorrect number of series names: got {len(args.series_names)} series names but {len(args.files)} inputs to compare')
# Parse the raw LNT data (keeping the individual samples) and store each input as a
# dataframe of aggregated (median) values for display.
lnt_inputs = [parse_lnt(file.readlines()) for file in args.files]
series = [f'{args.metric}_{i}' for (i, _) in enumerate(lnt_inputs)]
inputs = []
for (s, lnt) in zip(series, lnt_inputs):
aggregated = [aggregate_metrics(entry) for entry in lnt]
frame = pandas.DataFrame(aggregated).rename(columns={args.metric: s})
# Keep only the selected metric; other metrics in the file are not part of this
# comparison and would otherwise collide when the inputs are merged below.
inputs.append(frame[['benchmark', s]])
# Join the inputs into a single dataframe
data = functools.reduce(lambda a, b: a.merge(b, how='outer', on='benchmark'), inputs)
# If we have exactly two data sets, compute additional info in new columns
if len(lnt_inputs) == 2:
data['difference'] = data[f'{args.metric}_1'] - data[f'{args.metric}_0']
data['percent'] = data['difference'] / data[f'{args.metric}_0']
# Attach a confidence interval computed from the raw samples. We line the samples up
# into one row per benchmark (matching the merged frame) and let scipy compute every
# benchmark's interval.
benchmarks = data['benchmark'].tolist()
baseline = samples_matrix(lnt_inputs[0], args.metric, benchmarks)
candidate = samples_matrix(lnt_inputs[1], args.metric, benchmarks)
(data['ci_low'], data['ci_high']) = confidence_interval(baseline, candidate)
data['samples_0'] = numpy.count_nonzero(~numpy.isnan(baseline), axis=1)
data['samples_1'] = numpy.count_nonzero(~numpy.isnan(candidate), axis=1)
if args.filter is not None:
keeplist = [b for b in data['benchmark'] if re.search(args.filter, b) is not None]
data = data[data['benchmark'].isin(keeplist)]
if args.ignore_under is not None:
data = data[~(data[series] < args.ignore_under).all(axis=1)]
# Sort the data by the appropriate criteria
if args.sort == 'benchmark':
data = data.sort_values(by='benchmark')
elif args.sort == 'baseline':
data = data.sort_values(by=f'{args.metric}_0')
elif args.sort == 'candidate':
data = data.sort_values(by=f'{args.metric}_1')
elif args.sort == 'percent_diff':
data = data.sort_values(by=f'percent')
if args.format == 'chart':
figure = create_chart(data, args.metric, subtitle=args.subtitle, series_names=args.series_names)
do_open = args.output is None or args.open
output = args.output or tempfile.NamedTemporaryFile(suffix='.html').name
plotly.io.write_html(figure, file=output, auto_open=do_open)
elif args.format == 'kpi':
if args.discard_benchmarks_introduced_after is not None:
index = args.series_names.index(args.discard_benchmarks_introduced_after)
for candidate in series[index+1:]:
first_candidate = f'{args.metric}_1'
data = data[~(data[first_candidate].isna() & data[candidate].notna())]
produce_kpis(data, noise=args.noise_threshold,
extrema=args.top_performer_threshold,
series=series,
series_names=args.series_names,
meta_candidate=args.meta_candidate,
title=args.subtitle)
else:
diff = plain_text_comparison(data, args.metric, baseline_name=args.series_names[0],
candidate_name=args.series_names[1])
diff += '\n'
if args.output is not None:
with open(args.output, 'w') as out:
out.write(diff)
else:
sys.stdout.write(diff)
if __name__ == '__main__':
main(sys.argv[1:])