blob: 09f6706c9f45f0be1eec5bbad5490c4ae3ccddcd [file]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
/// The comparison of a single metric between a baseline run and the current run.
class MetricComparison {
/// Creates a comparison of [metricKey] between [baseline] and [current].
const MetricComparison({
required this.metricKey,
required this.baseline,
required this.current,
required this.deltaPercent,
required this.isRegression,
});
/// The flattened metric key, such as `draw_rect.canvaskit.totalUiFrame.average`.
final String metricKey;
/// The value of the metric in the baseline run.
final double baseline;
/// The value of the metric in the current run.
final double current;
/// The change from [baseline] to [current], as a percentage of [baseline].
///
/// Positive values are slowdowns. This is [double.infinity] when [baseline]
/// is zero and [current] is not.
final double deltaPercent;
/// Whether [deltaPercent] exceeds the regression threshold.
final bool isRegression;
}
/// The result of [compareResults].
class BenchmarkComparison {
/// Creates a comparison of [metrics].
const BenchmarkComparison(this.metrics);
/// The compared metrics, which [compareResults] sorts by
/// [MetricComparison.metricKey].
final List<MetricComparison> metrics;
/// Whether any of the [metrics] is a regression.
bool get hasRegressions => metrics.any((MetricComparison m) => m.isRegression);
}
/// Compares the `.average` metrics in [current] against those in [baseline].
///
/// Both maps are flattened `TaskResult.data` maps, such as
/// `{'draw_rect.canvaskit.totalUiFrame.average': 123.4}`. Only keys that end
/// in `.average` and appear in both maps are compared; all other keys are
/// ignored. The resulting metrics are sorted by key.
///
/// All metrics are durations or counts, so higher is worse. A metric is a
/// regression if [regressionThreshold] is non-null and the metric's
/// [MetricComparison.deltaPercent] is greater than it, so speedups are never
/// regressions.
///
/// Throws an [ArgumentError] if [regressionThreshold] is negative or not
/// finite, and a [FormatException] if a compared value is not a finite,
/// non-negative number.
BenchmarkComparison compareResults({
required Map<String, Object?> current,
required Map<String, Object?> baseline,
double? regressionThreshold,
}) {
if (regressionThreshold != null && (regressionThreshold < 0 || !regressionThreshold.isFinite)) {
throw ArgumentError.value(
regressionThreshold,
'regressionThreshold',
'must be a finite, non-negative number',
);
}
double read(Map<String, Object?> values, String key) {
final Object? value = values[key];
if (value is! num || !value.isFinite || value < 0) {
throw FormatException('Expected a finite, non-negative number for $key, but got $value.');
}
return value.toDouble();
}
final List<String> keys =
baseline.keys
.where((String key) => key.endsWith('.average') && current.containsKey(key))
.toList()
..sort();
return BenchmarkComparison(<MetricComparison>[
for (final key in keys)
_compareMetric(key, read(baseline, key), read(current, key), regressionThreshold),
]);
}
MetricComparison _compareMetric(
String key,
double baseline,
double current,
double? regressionThreshold,
) {
final double deltaPercent = baseline == 0
? (current == 0 ? 0.0 : double.infinity)
: (current - baseline) / baseline * 100;
return MetricComparison(
metricKey: key,
baseline: baseline,
current: current,
deltaPercent: deltaPercent,
isRegression: regressionThreshold != null && deltaPercent > regressionThreshold,
);
}
/// Formats [comparison] as a table for terminal output.
///
/// The table has a header row, a separator row, and one row per metric, with
/// the columns `Metric`, `Baseline`, `Current`, `Delta` and `Status` two spaces
/// apart and the numeric columns right-aligned. An infinite delta renders as
/// `N/A`. The status is `FAIL` for regressions and `PASS` otherwise.
///
/// If [enableAnsi] is true, the status is colored red or green; pass false
/// when the output is not a terminal.
///
/// Returns an empty string if [comparison] has no metrics.
String formatAnsiSummaryTable(BenchmarkComparison comparison, {required bool enableAnsi}) {
if (comparison.metrics.isEmpty) {
return '';
}
final rows = <List<String>>[
<String>['Metric', 'Baseline', 'Current', 'Delta', 'Status'],
for (final MetricComparison m in comparison.metrics)
<String>[
m.metricKey,
m.baseline.toStringAsFixed(1),
m.current.toStringAsFixed(1),
if (m.deltaPercent.isFinite) '${m.deltaPercent.toStringAsFixed(1)}%' else 'N/A',
if (m.isRegression) 'FAIL' else 'PASS',
],
];
final widths = <int>[
for (var column = 0; column < rows.first.length; column++)
rows.map((List<String> row) => row[column].length).reduce(math.max),
];
final buffer = StringBuffer();
for (var i = 0; i < rows.length; i++) {
final List<String> row = rows[i];
// The status column is last, so it is left unpadded to avoid trailing
// whitespace, and colors do not affect the alignment.
String status = row[4];
if (enableAnsi && i > 0) {
status = '${comparison.metrics[i - 1].isRegression ? '\x1B[31m' : '\x1B[32m'}$status\x1B[0m';
}
buffer.writeln(
<String>[
row[0].padRight(widths[0]),
for (var column = 1; column < 4; column++) row[column].padLeft(widths[column]),
status,
].join(' '),
);
if (i == 0) {
buffer.writeln(widths.map((int width) => '-' * width).join(' '));
}
}
return buffer.toString();
}