1#!/usr/bin/env python2 2# 3# Copyright 2016 The Chromium OS Authors. All rights reserved. 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6 7"""Given a specially-formatted JSON object, generates results report(s). 8 9The JSON object should look like: 10{"data": BenchmarkData, "platforms": BenchmarkPlatforms} 11 12BenchmarkPlatforms is a [str], each of which names a platform the benchmark 13 was run on (e.g. peppy, shamu, ...). Note that the order of this list is 14 related with the order of items in BenchmarkData. 15 16BenchmarkData is a {str: [PlatformData]}. The str is the name of the benchmark, 17and a PlatformData is a set of data for a given platform. There must be one 18PlatformData for each benchmark, for each element in BenchmarkPlatforms. 19 20A PlatformData is a [{str: float}], where each str names a metric we recorded, 21and the float is the value for that metric. Each element is considered to be 22the metrics collected from an independent run of this benchmark. NOTE: Each 23PlatformData is expected to have a "retval" key, with the return value of 24the benchmark. If the benchmark is successful, said return value should be 0. 25Otherwise, this will break some of our JSON functionality. 26 27Putting it all together, a JSON object will end up looking like: 28 { "platforms": ["peppy", "peppy-new-crosstool"], 29 "data": { 30 "bench_draw_line": [ 31 [{"time (ms)": 1.321, "memory (mb)": 128.1, "retval": 0}, 32 {"time (ms)": 1.920, "memory (mb)": 128.4, "retval": 0}], 33 [{"time (ms)": 1.221, "memory (mb)": 124.3, "retval": 0}, 34 {"time (ms)": 1.423, "memory (mb)": 123.9, "retval": 0}] 35 ] 36 } 37 } 38 39Which says that we ran a benchmark on platforms named peppy, and 40 peppy-new-crosstool. 41We ran one benchmark, named bench_draw_line. 42It was run twice on each platform. 43Peppy's runs took 1.321ms and 1.920ms, while peppy-new-crosstool's took 1.221ms 44 and 1.423ms. None of the runs failed to complete. 45""" 46 47from __future__ import division 48from __future__ import print_function 49 50import argparse 51import functools 52import json 53import os 54import sys 55import traceback 56 57from results_report import BenchmarkResults 58from results_report import HTMLResultsReport 59from results_report import JSONResultsReport 60from results_report import TextResultsReport 61 62 63def CountBenchmarks(benchmark_runs): 64 """Counts the number of iterations for each benchmark in benchmark_runs.""" 65 # Example input for benchmark_runs: 66 # {"bench": [[run1, run2, run3], [run1, run2, run3, run4]]} 67 def _MaxLen(results): 68 return 0 if not results else max(len(r) for r in results) 69 return [(name, _MaxLen(results)) 70 for name, results in benchmark_runs.iteritems()] 71 72 73def CutResultsInPlace(results, max_keys=50, complain_on_update=True): 74 """Limits the given benchmark results to max_keys keys in-place. 75 76 This takes the `data` field from the benchmark input, and mutates each 77 benchmark run to contain `max_keys` elements (ignoring special elements, like 78 "retval"). At the moment, it just selects the first `max_keys` keyvals, 79 alphabetically. 80 81 If complain_on_update is true, this will print a message noting that a 82 truncation occurred. 83 84 This returns the `results` object that was passed in, for convenience. 85 86 e.g. 87 >>> benchmark_data = { 88 ... "bench_draw_line": [ 89 ... [{"time (ms)": 1.321, "memory (mb)": 128.1, "retval": 0}, 90 ... {"time (ms)": 1.920, "memory (mb)": 128.4, "retval": 0}], 91 ... [{"time (ms)": 1.221, "memory (mb)": 124.3, "retval": 0}, 92 ... {"time (ms)": 1.423, "memory (mb)": 123.9, "retval": 0}] 93 ... ] 94 ... } 95 >>> CutResultsInPlace(benchmark_data, max_keys=1, complain_on_update=False) 96 { 97 'bench_draw_line': [ 98 [{'memory (mb)': 128.1, 'retval': 0}, 99 {'memory (mb)': 128.4, 'retval': 0}], 100 [{'memory (mb)': 124.3, 'retval': 0}, 101 {'memory (mb)': 123.9, 'retval': 0}] 102 ] 103 } 104 """ 105 actually_updated = False 106 for bench_results in results.itervalues(): 107 for platform_results in bench_results: 108 for i, result in enumerate(platform_results): 109 # Keep the keys that come earliest when sorted alphabetically. 110 # Forcing alphabetical order is arbitrary, but necessary; otherwise, 111 # the keyvals we'd emit would depend on our iteration order through a 112 # map. 113 removable_keys = sorted(k for k in result if k != 'retval') 114 retained_keys = removable_keys[:max_keys] 115 platform_results[i] = {k: result[k] for k in retained_keys} 116 # retval needs to be passed through all of the time. 117 retval = result.get('retval') 118 if retval is not None: 119 platform_results[i]['retval'] = retval 120 actually_updated = actually_updated or \ 121 len(retained_keys) != len(removable_keys) 122 123 if actually_updated and complain_on_update: 124 print("Warning: Some benchmark keyvals have been truncated.", 125 file=sys.stderr) 126 return results 127 128 129def _ConvertToASCII(obj): 130 """Convert an object loaded from JSON to ASCII; JSON gives us unicode.""" 131 132 # Using something like `object_hook` is insufficient, since it only fires on 133 # actual JSON objects. `encoding` fails, too, since the default decoder always 134 # uses unicode() to decode strings. 135 if isinstance(obj, unicode): 136 return str(obj) 137 if isinstance(obj, dict): 138 return {_ConvertToASCII(k): _ConvertToASCII(v) for k, v in obj.iteritems()} 139 if isinstance(obj, list): 140 return [_ConvertToASCII(v) for v in obj] 141 return obj 142 143 144def _PositiveInt(s): 145 i = int(s) 146 if i < 0: 147 raise argparse.ArgumentTypeError('%d is not a positive integer.' % (i, )) 148 return i 149 150 151def _AccumulateActions(args): 152 """Given program arguments, determines what actions we want to run. 153 154 Returns [(ResultsReportCtor, str)], where ResultsReportCtor can construct a 155 ResultsReport, and the str is the file extension for the given report. 156 """ 157 results = [] 158 # The order of these is arbitrary. 159 if args.json: 160 results.append((JSONResultsReport, 'json')) 161 if args.text: 162 results.append((TextResultsReport, 'txt')) 163 if args.email: 164 email_ctor = functools.partial(TextResultsReport, email=True) 165 results.append((email_ctor, 'email')) 166 # We emit HTML if nothing else was specified. 167 if args.html or not results: 168 results.append((HTMLResultsReport, 'html')) 169 return results 170 171 172# Note: get_contents is a function, because it may be expensive (generating some 173# HTML reports takes O(seconds) on my machine, depending on the size of the 174# input data). 175def WriteFile(output_prefix, extension, get_contents, overwrite, verbose): 176 """Writes `contents` to a file named "${output_prefix}.${extension}". 177 178 get_contents should be a zero-args function that returns a string (of the 179 contents to write). 180 If output_prefix == '-', this writes to stdout. 181 If overwrite is False, this will not overwrite files. 182 """ 183 if output_prefix == '-': 184 if verbose: 185 print('Writing %s report to stdout' % (extension, ), file=sys.stderr) 186 sys.stdout.write(get_contents()) 187 return 188 189 file_name = '%s.%s' % (output_prefix, extension) 190 if not overwrite and os.path.exists(file_name): 191 raise IOError('Refusing to write %s -- it already exists' % (file_name, )) 192 193 with open(file_name, 'w') as out_file: 194 if verbose: 195 print('Writing %s report to %s' % (extension, file_name), file=sys.stderr) 196 out_file.write(get_contents()) 197 198 199def RunActions(actions, benchmark_results, output_prefix, overwrite, verbose): 200 """Runs `actions`, returning True if all succeeded.""" 201 failed = False 202 203 report_ctor = None # Make the linter happy 204 for report_ctor, extension in actions: 205 try: 206 get_contents = lambda: report_ctor(benchmark_results).GetReport() 207 WriteFile(output_prefix, extension, get_contents, overwrite, verbose) 208 except Exception: 209 # Complain and move along; we may have more actions that might complete 210 # successfully. 211 failed = True 212 traceback.print_exc() 213 return not failed 214 215 216def PickInputFile(input_name): 217 """Given program arguments, returns file to read for benchmark input.""" 218 return sys.stdin if input_name == '-' else open(input_name) 219 220 221def _NoPerfReport(_label_name, _benchmark_name, _benchmark_iteration): 222 return {} 223 224 225def _ParseArgs(argv): 226 parser = argparse.ArgumentParser(description='Turns JSON into results ' 227 'report(s).') 228 parser.add_argument('-v', '--verbose', action='store_true', 229 help='Be a tiny bit more verbose.') 230 parser.add_argument('-f', '--force', action='store_true', 231 help='Overwrite existing results files.') 232 parser.add_argument('-o', '--output', default='report', type=str, 233 help='Prefix of the output filename (default: report). ' 234 '- means stdout.') 235 parser.add_argument('-i', '--input', required=True, type=str, 236 help='Where to read the JSON from. - means stdin.') 237 parser.add_argument('-l', '--statistic-limit', default=0, type=_PositiveInt, 238 help='The maximum number of benchmark statistics to ' 239 'display from a single run. 0 implies unlimited.') 240 parser.add_argument('--json', action='store_true', 241 help='Output a JSON report.') 242 parser.add_argument('--text', action='store_true', 243 help='Output a text report.') 244 parser.add_argument('--email', action='store_true', 245 help='Output a text report suitable for email.') 246 parser.add_argument('--html', action='store_true', 247 help='Output an HTML report (this is the default if no ' 248 'other output format is specified).') 249 return parser.parse_args(argv) 250 251 252def Main(argv): 253 args = _ParseArgs(argv) 254 # JSON likes to load UTF-8; our results reporter *really* doesn't like 255 # UTF-8. 256 with PickInputFile(args.input) as in_file: 257 raw_results = _ConvertToASCII(json.load(in_file)) 258 259 platform_names = raw_results['platforms'] 260 results = raw_results['data'] 261 if args.statistic_limit: 262 results = CutResultsInPlace(results, max_keys=args.statistic_limit) 263 benches = CountBenchmarks(results) 264 # In crosperf, a label is essentially a platform+configuration. So, a name of 265 # a label and a name of a platform are equivalent for our purposes. 266 bench_results = BenchmarkResults(label_names=platform_names, 267 benchmark_names_and_iterations=benches, 268 run_keyvals=results, 269 read_perf_report=_NoPerfReport) 270 actions = _AccumulateActions(args) 271 ok = RunActions(actions, bench_results, args.output, args.force, 272 args.verbose) 273 return 0 if ok else 1 274 275 276if __name__ == '__main__': 277 sys.exit(Main(sys.argv[1:])) 278