| // 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; | |
| import 'task_result.dart'; | |
| const String kBenchmarkTypeKeyName = 'benchmark_type'; | |
| const String kBenchmarkVersionKeyName = 'version'; | |
| const String kLocalEngineKeyName = 'local_engine'; | |
| const String kLocalEngineHostKeyName = 'local_engine_host'; | |
| const String kTaskNameKeyName = 'task_name'; | |
| const String kRunStartKeyName = 'run_start'; | |
| const String kRunEndKeyName = 'run_end'; | |
| const String kAResultsKeyName = 'default_results'; | |
| const String kBResultsKeyName = 'local_engine_results'; | |
| const String kBenchmarkResultsType = 'A/B summaries'; | |
| const String kBenchmarkABVersion = '1.0'; | |
| enum FieldJustification { LEFT, RIGHT, CENTER } | |
| /// Collects data from an A/B test and produces a summary for human evaluation. | |
| /// | |
| /// See [printSummary] for more. | |
| class ABTest { | |
| ABTest({required this.localEngine, required this.localEngineHost, required this.taskName}) | |
| : runStart = DateTime.now(), | |
| _aResults = <String, List<double>>{}, | |
| _bResults = <String, List<double>>{}; | |
| ABTest.fromJsonMap(Map<String, dynamic> jsonResults) | |
| : localEngine = jsonResults[kLocalEngineKeyName] as String, | |
| localEngineHost = jsonResults[kLocalEngineHostKeyName] as String?, | |
| taskName = jsonResults[kTaskNameKeyName] as String, | |
| runStart = DateTime.parse(jsonResults[kRunStartKeyName] as String), | |
| _runEnd = DateTime.parse(jsonResults[kRunEndKeyName] as String), | |
| _aResults = _convertFrom(jsonResults[kAResultsKeyName] as Map<String, dynamic>), | |
| _bResults = _convertFrom(jsonResults[kBResultsKeyName] as Map<String, dynamic>); | |
| final String localEngine; | |
| final String? localEngineHost; | |
| final String taskName; | |
| final DateTime runStart; | |
| DateTime? _runEnd; | |
| DateTime? get runEnd => _runEnd; | |
| final Map<String, List<double>> _aResults; | |
| final Map<String, List<double>> _bResults; | |
| static Map<String, List<double>> _convertFrom(dynamic results) { | |
| final resultMap = results as Map<String, dynamic>; | |
| return <String, List<double>>{ | |
| for (final String key in resultMap.keys) | |
| key: (resultMap[key] as List<dynamic>).cast<double>(), | |
| }; | |
| } | |
| /// Adds the result of a single A run of the benchmark. | |
| /// | |
| /// The result may contain multiple score keys. | |
| /// | |
| /// [result] is expected to be a serialization of [TaskResult]. | |
| void addAResult(TaskResult result) { | |
| if (_runEnd != null) { | |
| throw StateError('Cannot add results to ABTest after it is finalized'); | |
| } | |
| _addResult(result, _aResults); | |
| } | |
| /// Adds the result of a single B run of the benchmark. | |
| /// | |
| /// The result may contain multiple score keys. | |
| /// | |
| /// [result] is expected to be a serialization of [TaskResult]. | |
| void addBResult(TaskResult result) { | |
| if (_runEnd != null) { | |
| throw StateError('Cannot add results to ABTest after it is finalized'); | |
| } | |
| _addResult(result, _bResults); | |
| } | |
| void finalize() { | |
| _runEnd = DateTime.now(); | |
| } | |
| Map<String, dynamic> get jsonMap => <String, dynamic>{ | |
| kBenchmarkTypeKeyName: kBenchmarkResultsType, | |
| kBenchmarkVersionKeyName: kBenchmarkABVersion, | |
| kLocalEngineKeyName: localEngine, | |
| kLocalEngineHostKeyName: ?localEngineHost, | |
| kTaskNameKeyName: taskName, | |
| kRunStartKeyName: runStart.toIso8601String(), | |
| kRunEndKeyName: runEnd!.toIso8601String(), | |
| kAResultsKeyName: _aResults, | |
| kBResultsKeyName: _bResults, | |
| }; | |
| static void updateColumnLengths(List<int> lengths, List<String?> results) { | |
| for (var column = 0; column < lengths.length; column++) { | |
| if (results[column] != null) { | |
| lengths[column] = math.max(lengths[column], results[column]?.length ?? 0); | |
| } | |
| } | |
| } | |
| static void formatResult( | |
| StringBuffer buffer, | |
| List<int> lengths, | |
| List<FieldJustification> aligns, | |
| List<String?> values, | |
| ) { | |
| for (var column = 0; column < lengths.length; column++) { | |
| final int len = lengths[column]; | |
| String? value = values[column]; | |
| if (value == null) { | |
| value = ''.padRight(len); | |
| } else { | |
| value = switch (aligns[column]) { | |
| FieldJustification.LEFT => value.padRight(len), | |
| FieldJustification.RIGHT => value.padLeft(len), | |
| FieldJustification.CENTER => value.padLeft((len + value.length) ~/ 2).padRight(len), | |
| }; | |
| } | |
| if (column > 0) { | |
| value = value.padLeft(len + 1); | |
| } | |
| buffer.write(value); | |
| } | |
| buffer.writeln(); | |
| } | |
| /// Returns the summary as a tab-separated spreadsheet. | |
| /// | |
| /// This value can be copied straight to a Google Spreadsheet for further analysis. | |
| String asciiSummary() { | |
| final Map<String, _ScoreSummary> summariesA = _summarize(_aResults); | |
| final Map<String, _ScoreSummary> summariesB = _summarize(_bResults); | |
| final tableRows = <List<String?>>[ | |
| for (final String scoreKey in <String>{...summariesA.keys, ...summariesB.keys}) | |
| <String?>[ | |
| scoreKey, | |
| summariesA[scoreKey]?.averageString, | |
| summariesA[scoreKey]?.noiseString, | |
| summariesB[scoreKey]?.averageString, | |
| summariesB[scoreKey]?.noiseString, | |
| summariesA[scoreKey]?.improvementOver(summariesB[scoreKey]), | |
| ], | |
| ]; | |
| final titles = <String>['Score', 'Average A', '(noise)', 'Average B', '(noise)', 'Speed-up']; | |
| final alignments = <FieldJustification>[ | |
| FieldJustification.LEFT, | |
| FieldJustification.RIGHT, | |
| FieldJustification.LEFT, | |
| FieldJustification.RIGHT, | |
| FieldJustification.LEFT, | |
| FieldJustification.CENTER, | |
| ]; | |
| final lengths = List<int>.filled(6, 0); | |
| updateColumnLengths(lengths, titles); | |
| for (final row in tableRows) { | |
| updateColumnLengths(lengths, row); | |
| } | |
| final buffer = StringBuffer(); | |
| formatResult(buffer, lengths, <FieldJustification>[ | |
| FieldJustification.CENTER, | |
| ...alignments.skip(1), | |
| ], titles); | |
| for (final row in tableRows) { | |
| formatResult(buffer, lengths, alignments, row); | |
| } | |
| return buffer.toString(); | |
| } | |
| /// Returns unprocessed data collected by the A/B test formatted as | |
| /// a tab-separated spreadsheet. | |
| String rawResults() { | |
| final buffer = StringBuffer(); | |
| for (final String scoreKey in _allScoreKeys) { | |
| buffer.writeln('$scoreKey:'); | |
| buffer.write(' A:\t'); | |
| if (_aResults.containsKey(scoreKey)) { | |
| for (final double score in _aResults[scoreKey]!) { | |
| buffer.write('${score.toStringAsFixed(2)}\t'); | |
| } | |
| } else { | |
| buffer.write('N/A'); | |
| } | |
| buffer.writeln(); | |
| buffer.write(' B:\t'); | |
| if (_bResults.containsKey(scoreKey)) { | |
| for (final double score in _bResults[scoreKey]!) { | |
| buffer.write('${score.toStringAsFixed(2)}\t'); | |
| } | |
| } else { | |
| buffer.write('N/A'); | |
| } | |
| buffer.writeln(); | |
| } | |
| return buffer.toString(); | |
| } | |
| Set<String> get _allScoreKeys => <String>{..._aResults.keys, ..._bResults.keys}; | |
| /// Returns the summary as a tab-separated spreadsheet. | |
| /// | |
| /// This value can be copied straight to a Google Spreadsheet for further analysis. | |
| String printSummary() { | |
| final Map<String, _ScoreSummary> summariesA = _summarize(_aResults); | |
| final Map<String, _ScoreSummary> summariesB = _summarize(_bResults); | |
| final buffer = StringBuffer('Score\tAverage A (noise)\tAverage B (noise)\tSpeed-up\n'); | |
| for (final String scoreKey in _allScoreKeys) { | |
| final _ScoreSummary? summaryA = summariesA[scoreKey]; | |
| final _ScoreSummary? summaryB = summariesB[scoreKey]; | |
| buffer.write('$scoreKey\t'); | |
| if (summaryA != null) { | |
| buffer.write('${summaryA.averageString} ${summaryA.noiseString}\t'); | |
| } else { | |
| buffer.write('\t'); | |
| } | |
| if (summaryB != null) { | |
| buffer.write('${summaryB.averageString} ${summaryB.noiseString}\t'); | |
| } else { | |
| buffer.write('\t'); | |
| } | |
| if (summaryA != null && summaryB != null) { | |
| buffer.write('${summaryA.improvementOver(summaryB)}\t'); | |
| } | |
| buffer.writeln(); | |
| } | |
| return buffer.toString(); | |
| } | |
| } | |
| class _ScoreSummary { | |
| _ScoreSummary({required this.average, required this.noise}); | |
| /// Average (arithmetic mean) of a series of values collected by a benchmark. | |
| final double average; | |
| /// The noise (standard deviation divided by [average]) in the collected | |
| /// values. | |
| final double noise; | |
| String get averageString => average.toStringAsFixed(2); | |
| String get noiseString => '(${_ratioToPercent(noise)})'; | |
| String improvementOver(_ScoreSummary? other) { | |
| return other == null ? '' : '${(average / other.average).toStringAsFixed(2)}x'; | |
| } | |
| } | |
| void _addResult(TaskResult result, Map<String, List<double>> results) { | |
| for (final String scoreKey in result.benchmarkScoreKeys ?? <String>[]) { | |
| final double score = (result.data![scoreKey] as num).toDouble(); | |
| results.putIfAbsent(scoreKey, () => <double>[]).add(score); | |
| } | |
| } | |
| Map<String, _ScoreSummary> _summarize(Map<String, List<double>> results) { | |
| return results.map<String, _ScoreSummary>((String scoreKey, List<double> values) { | |
| final double average = _computeAverage(values); | |
| return MapEntry<String, _ScoreSummary>( | |
| scoreKey, | |
| _ScoreSummary( | |
| average: average, | |
| // If the average is zero, the benchmark got the perfect score with no noise. | |
| noise: average > 0 ? _computeStandardDeviationForPopulation(values) / average : 0.0, | |
| ), | |
| ); | |
| }); | |
| } | |
| /// Computes the arithmetic mean (or average) of given [values]. | |
| double _computeAverage(Iterable<double> values) { | |
| final double sum = values.reduce((double a, double b) => a + b); | |
| return sum / values.length; | |
| } | |
| /// Computes population standard deviation. | |
| /// | |
| /// Unlike sample standard deviation, which divides by N - 1, this divides by N. | |
| /// | |
| /// See also: | |
| /// | |
| /// * https://en.wikipedia.org/wiki/Standard_deviation | |
| double _computeStandardDeviationForPopulation(Iterable<double> population) { | |
| final double mean = _computeAverage(population); | |
| final double sumOfSquaredDeltas = population.fold<double>( | |
| 0.0, | |
| (double previous, num value) => previous += math.pow(value - mean, 2), | |
| ); | |
| return math.sqrt(sumOfSquaredDeltas / population.length); | |
| } | |
| String _ratioToPercent(double value) { | |
| return '${(value * 100).toStringAsFixed(2)}%'; | |
| } |