blob: 156572d789dcdd71bed404a5460ddc0d99b0d347 [file] [log] [blame]
# Copyright (C) 2015 Apple Inc. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND ANY
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import json
import math
import re
import sys
from webkitpy.common.iteration_compatibility import iteritems
class BenchmarkResults(object):
aggregators = {
'Total': (lambda values: sum(values)),
'Arithmetic': (lambda values: sum(values) // len(values)),
'Geometric': (lambda values: math.exp(sum(map(math.log, values)) / len(values))),
}
metric_to_unit = {
'FrameRate': 'fps',
'Runs': '/s',
'Time': 'ms',
'Duration': 'ms',
'Malloc': 'B',
'Heap': 'B',
'Allocations': 'B',
'Score': 'pt',
'Power': 'W',
}
SI_prefixes = ['n', 'u', 'm', '', 'K', 'M', 'G', 'T', 'P', 'E']
def __init__(self, results):
self._lint_results(results)
self._results = self._aggregate_results(results)
def format(self, scale_unit=True, show_iteration_values=False, max_depth=sys.maxsize):
return self._format_tests(self._results, scale_unit, show_iteration_values, max_depth)
@classmethod
def _format_tests(cls, tests, scale_unit, show_iteration_values, max_depth, indent=''):
output = ''
config_name = 'current'
for test_name in sorted(tests.keys()):
is_first = True
test = tests[test_name]
metrics = test.get('metrics', {})
for metric_name in sorted(metrics.keys()):
metric = metrics[metric_name]
for aggregator_name in sorted(metric.keys()):
output += indent
if is_first:
output += test_name
is_first = False
else:
output += ' ' * len(test_name)
output += ':' + metric_name + ':'
if aggregator_name:
output += aggregator_name + ':'
output += ' ' + cls._format_values(metric_name, metric[aggregator_name][config_name], scale_unit, show_iteration_values) + '\n'
if 'tests' in test and max_depth > 1:
output += cls._format_tests(test['tests'], scale_unit, show_iteration_values, max_depth - 1, indent=(indent + ' ' * len(test_name)))
return output
@classmethod
def _format_values(cls, metric_name, values, scale_unit=True, show_iteration_values=False):
values = list(map(float, values))
total = sum(values)
mean = total / len(values)
square_sum = sum([x * x for x in values])
sample_count = len(values)
# With sum and sum of squares, we can compute the sample standard deviation in O(1).
# See https://rniwa.com/2012-11-10/sample-standard-deviation-in-terms-of-sum-and-square-sum-of-samples/
if sample_count <= 1:
sample_stdev = 0
else:
# Be careful about round-off error when sample_stdev is 0.
sample_stdev = math.sqrt(max(0, square_sum / (sample_count - 1) - total * total / (sample_count - 1) / sample_count))
unit = cls._unit_from_metric(metric_name)
if not scale_unit:
formatted_value = '{mean:.3f}{unit} stdev={delta:.1%}'.format(mean=mean, delta=sample_stdev / mean, unit=unit)
if show_iteration_values:
formatted_value += ' [' + ', '.join(['{value:.3f}'.format(value=value) for value in values]) + ']'
return formatted_value
if unit == 'ms':
unit = 's'
mean = float(mean) / 1000
values = list([float(value) / 1000 for value in values])
sample_stdev /= 1000
base = 1024 if unit == 'B' else 1000
value_sig_fig = 1 - math.floor(math.log10(sample_stdev / mean)) if sample_stdev else 3
SI_magnitude = math.floor(math.log(mean, base))
scaling_factor = math.pow(base, -SI_magnitude)
scaled_mean = mean * scaling_factor
SI_prefix = cls.SI_prefixes[int(SI_magnitude) + 3]
non_floating_digits = 1 + math.floor(math.log10(scaled_mean))
floating_points_count = max(0, value_sig_fig - non_floating_digits)
def format_scaled(value):
return ('{value:.' + str(int(floating_points_count)) + 'f}').format(value=value)
formatted_value = '{mean}{prefix}{unit} stdev={delta:.1%}'.format(mean=format_scaled(scaled_mean), delta=sample_stdev / mean, prefix=SI_prefix, unit=unit)
if show_iteration_values:
formatted_value += ' [' + ', '.join([format_scaled(value * scaling_factor) for value in values]) + ']'
return formatted_value
@classmethod
def _unit_from_metric(cls, metric_name):
# FIXME: Detect unknown mettric names
suffix = re.match(r'.*?([A-z][a-z]+|FrameRate)$', metric_name)
return cls.metric_to_unit[suffix.group(1)]
@classmethod
def _aggregate_results(cls, tests):
results = {}
for test_name, test in iteritems(tests):
results[test_name] = cls._aggregate_results_for_test(test)
return results
@classmethod
def _aggregate_results_for_test(cls, test):
subtest_results = cls._aggregate_results(test['tests']) if 'tests' in test else {}
results = {}
for metric_name, metric in iteritems(test.get('metrics', {})):
if not isinstance(metric, list):
results[metric_name] = {None: {}}
for config_name, values in iteritems(metric):
results[metric_name][None][config_name] = cls._flatten_list(values)
continue
# Filter duplicate aggregators that could have arisen from merging JSONs.
aggregator_list = list(set(metric))
results[metric_name] = {}
for aggregator in aggregator_list:
values_by_config_iteration = cls._subtest_values_by_config_iteration(subtest_results, metric_name, aggregator)
for config_name, values_by_iteration in iteritems(values_by_config_iteration):
results[metric_name].setdefault(aggregator, {})
results[metric_name][aggregator][config_name] = [cls._aggregate_values(aggregator, values) for values in values_by_iteration]
return {'metrics': results, 'tests': subtest_results}
@classmethod
def _flatten_list(cls, nested_list):
flattened_list = []
for item in nested_list:
if isinstance(item, list):
flattened_list += cls._flatten_list(item)
else:
flattened_list.append(item)
return flattened_list
@classmethod
def _subtest_values_by_config_iteration(cls, subtest_results, metric_name, aggregator):
values_by_config_iteration = {}
for subtest_name, subtest in iteritems(subtest_results):
results_for_metric = subtest['metrics'].get(metric_name, {})
if aggregator in results_for_metric:
results_for_aggregator = results_for_metric.get(aggregator)
elif None in results_for_metric:
results_for_aggregator = results_for_metric.get(None)
elif len(list(results_for_metric.keys())) == 1:
results_for_aggregator = results_for_metric.get(list(results_for_metric.keys())[0])
else:
results_for_aggregator = {}
for config_name, values in iteritems(results_for_aggregator):
values_by_config_iteration.setdefault(config_name, [[] for _ in values])
for iteration, value in enumerate(values):
values_by_config_iteration[config_name][iteration].append(value)
return values_by_config_iteration
@classmethod
def _aggregate_values(cls, aggregator, values):
return cls.aggregators[aggregator](values)
@classmethod
def _lint_results(cls, tests):
cls._lint_subtest_results(tests, None, None)
return True
@classmethod
def _lint_subtest_results(cls, subtests, parent_test, parent_aggregator_list):
iteration_groups_by_config = {}
for test_name, test in iteritems(subtests):
aggregator_list = None
if 'metrics' not in test and 'tests' not in test:
raise TypeError('"%s" does not contain metrics or tests' % test_name)
if 'metrics' in test:
metrics = test['metrics']
if not isinstance(metrics, dict):
raise TypeError('The metrics in "%s" is not a dictionary' % test_name)
for metric_name, metric in iteritems(metrics):
if isinstance(metric, list):
# Filter duplicate aggregators that could have arisen from merging JSONs.
aggregator_list = list(set(metric))
cls._lint_aggregator_list(test_name, metric_name, aggregator_list, parent_test, parent_aggregator_list)
elif isinstance(metric, dict):
cls._lint_configuration(test_name, metric_name, metric, parent_test, parent_aggregator_list, iteration_groups_by_config)
else:
raise TypeError('"%s" metric of "%s" was not an aggregator list or a dictionary of configurations: %s' % (metric_name, test_name, str(metric)))
if 'tests' in test:
cls._lint_subtest_results(test['tests'], test_name, aggregator_list)
elif aggregator_list:
raise TypeError('"%s" requires aggregation but it has no subtests' % (test_name))
return iteration_groups_by_config
@classmethod
def _lint_aggregator_list(cls, test_name, metric_name, aggregator_list, parent_test, parent_aggregator_list):
if len(aggregator_list) != len(set(aggregator_list)):
raise TypeError('"%s" metric of "%s" had invalid aggregator list: %s' % (metric_name, test_name, json.dumps(aggregator_list)))
if not aggregator_list:
raise TypeError('The aggregator list is empty in "%s" metric of "%s"' % (metric_name, test_name))
for aggregator_name in aggregator_list:
if cls._is_numeric(aggregator_name):
raise TypeError('"%s" metric of "%s" is not wrapped by a configuration; e.g. "current"' % (metric_name, test_name))
if aggregator_name not in cls.aggregators:
raise TypeError('"%s" metric of "%s" uses unknown aggregator: %s' % (metric_name, test_name, aggregator_name))
if not parent_aggregator_list:
return
for parent_aggregator in parent_aggregator_list:
if parent_aggregator not in aggregator_list and len(aggregator_list) > 1:
raise TypeError('"%s" metric of "%s" has no value to aggregate as "%s" in a subtest "%s"' % (
metric_name, parent_test, parent_aggregator, test_name))
@classmethod
def _lint_configuration(cls, test_name, metric_name, configurations, parent_test, parent_aggregator_list, iteration_groups_by_config):
# FIXME: Check that config_name is always "current".
for config_name, values in iteritems(configurations):
nested_list_count = [isinstance(value, list) for value in values].count(True)
if nested_list_count not in [0, len(values)]:
raise TypeError('"%s" metric of "%s" had malformed values: %s' % (metric_name, test_name, json.dumps(values)))
if nested_list_count:
value_shape = []
for value_group in values:
value_shape.append(len(value_group))
cls._lint_values(test_name, metric_name, value_group)
else:
value_shape = len(values)
cls._lint_values(test_name, metric_name, values)
iteration_groups_by_config.setdefault(metric_name, {}).setdefault(config_name, value_shape)
if parent_aggregator_list and value_shape != iteration_groups_by_config[metric_name][config_name]:
raise TypeError('"%s" metric of "%s" had a mismatching subtest values' % (metric_name, parent_test))
@classmethod
def _lint_values(cls, test_name, metric_name, values):
if any([not cls._is_numeric(value) for value in values]):
raise TypeError('"%s" metric of "%s" contains non-numeric value: %s' % (metric_name, test_name, json.dumps(values)))
@classmethod
def _is_numeric(cls, value):
return isinstance(value, int) or isinstance(value, float)