1#!/usr/bin/env python3 2# 3# Script for comparing buildstats from two different builds 4# 5# Copyright (c) 2016, Intel Corporation. 6# 7# SPDX-License-Identifier: GPL-2.0-only 8# 9 10import argparse 11import glob 12import logging 13import math 14import os 15import sys 16from operator import attrgetter 17 18# Import oe libs 19scripts_path = os.path.dirname(os.path.realpath(__file__)) 20sys.path.append(os.path.join(scripts_path, 'lib')) 21from buildstats import BuildStats, diff_buildstats, taskdiff_fields, BSVerDiff 22 23 24# Setup logging 25logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") 26log = logging.getLogger() 27 28 29class ScriptError(Exception): 30 """Exception for internal error handling of this script""" 31 pass 32 33 34def read_buildstats(path, multi): 35 """Read buildstats""" 36 if not os.path.exists(path): 37 raise ScriptError("No such file or directory: {}".format(path)) 38 39 if os.path.isfile(path): 40 return BuildStats.from_file_json(path) 41 42 if os.path.isfile(os.path.join(path, 'build_stats')): 43 return BuildStats.from_dir(path) 44 45 # Handle a non-buildstat directory 46 subpaths = sorted(glob.glob(path + '/*')) 47 if len(subpaths) > 1: 48 if multi: 49 log.info("Averaging over {} buildstats from {}".format( 50 len(subpaths), path)) 51 else: 52 raise ScriptError("Multiple buildstats found in '{}'. Please give " 53 "a single buildstat directory of use the --multi " 54 "option".format(path)) 55 bs = None 56 for subpath in subpaths: 57 if os.path.isfile(subpath): 58 _bs = BuildStats.from_file_json(subpath) 59 else: 60 _bs = BuildStats.from_dir(subpath) 61 if bs is None: 62 bs = _bs 63 else: 64 bs.aggregate(_bs) 65 if not bs: 66 raise ScriptError("No buildstats found under {}".format(path)) 67 68 return bs 69 70 71def print_ver_diff(bs1, bs2): 72 """Print package version differences""" 73 74 diff = BSVerDiff(bs1, bs2) 75 76 maxlen = max([len(r) for r in set(bs1.keys()).union(set(bs2.keys()))]) 77 fmt_str = " {:{maxlen}} ({})" 78 79 if diff.new: 80 print("\nNEW RECIPES:") 81 print("------------") 82 for name, val in sorted(diff.new.items()): 83 print(fmt_str.format(name, val.nevr, maxlen=maxlen)) 84 85 if diff.dropped: 86 print("\nDROPPED RECIPES:") 87 print("----------------") 88 for name, val in sorted(diff.dropped.items()): 89 print(fmt_str.format(name, val.nevr, maxlen=maxlen)) 90 91 fmt_str = " {0:{maxlen}} {1:<20} ({2})" 92 if diff.rchanged: 93 print("\nREVISION CHANGED:") 94 print("-----------------") 95 for name, val in sorted(diff.rchanged.items()): 96 field1 = "{} -> {}".format(val.left.revision, val.right.revision) 97 field2 = "{} -> {}".format(val.left.nevr, val.right.nevr) 98 print(fmt_str.format(name, field1, field2, maxlen=maxlen)) 99 100 if diff.vchanged: 101 print("\nVERSION CHANGED:") 102 print("----------------") 103 for name, val in sorted(diff.vchanged.items()): 104 field1 = "{} -> {}".format(val.left.version, val.right.version) 105 field2 = "{} -> {}".format(val.left.nevr, val.right.nevr) 106 print(fmt_str.format(name, field1, field2, maxlen=maxlen)) 107 108 if diff.echanged: 109 print("\nEPOCH CHANGED:") 110 print("--------------") 111 for name, val in sorted(diff.echanged.items()): 112 field1 = "{} -> {}".format(val.left.epoch, val.right.epoch) 113 field2 = "{} -> {}".format(val.left.nevr, val.right.nevr) 114 print(fmt_str.format(name, field1, field2, maxlen=maxlen)) 115 116 117def print_task_diff(bs1, bs2, val_type, min_val=0, min_absdiff=0, sort_by=('absdiff',), only_tasks=[]): 118 """Diff task execution times""" 119 def val_to_str(val, human_readable=False): 120 """Convert raw value to printable string""" 121 def hms_time(secs): 122 """Get time in human-readable HH:MM:SS format""" 123 h = int(secs / 3600) 124 m = int((secs % 3600) / 60) 125 s = secs % 60 126 if h == 0: 127 return "{:02d}:{:04.1f}".format(m, s) 128 else: 129 return "{:d}:{:02d}:{:04.1f}".format(h, m, s) 130 131 if 'time' in val_type: 132 if human_readable: 133 return hms_time(val) 134 else: 135 return "{:.1f}s".format(val) 136 elif 'bytes' in val_type and human_readable: 137 prefix = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi'] 138 dec = int(math.log(val, 2) / 10) 139 prec = 1 if dec > 0 else 0 140 return "{:.{prec}f}{}B".format(val / (2 ** (10 * dec)), 141 prefix[dec], prec=prec) 142 elif 'ops' in val_type and human_readable: 143 prefix = ['', 'k', 'M', 'G', 'T', 'P'] 144 dec = int(math.log(val, 1000)) 145 prec = 1 if dec > 0 else 0 146 return "{:.{prec}f}{}ops".format(val / (1000 ** dec), 147 prefix[dec], prec=prec) 148 return str(int(val)) 149 150 def sum_vals(buildstats): 151 """Get cumulative sum of all tasks""" 152 total = 0.0 153 for recipe_data in buildstats.values(): 154 for name, bs_task in recipe_data.tasks.items(): 155 if not only_tasks or name in only_tasks: 156 total += getattr(bs_task, val_type) 157 return total 158 159 if min_val: 160 print("Ignoring tasks less than {} ({})".format( 161 val_to_str(min_val, True), val_to_str(min_val))) 162 if min_absdiff: 163 print("Ignoring differences less than {} ({})".format( 164 val_to_str(min_absdiff, True), val_to_str(min_absdiff))) 165 166 # Prepare the data 167 tasks_diff = diff_buildstats(bs1, bs2, val_type, min_val, min_absdiff, only_tasks) 168 169 # Sort our list 170 for field in reversed(sort_by): 171 if field.startswith('-'): 172 field = field[1:] 173 reverse = True 174 else: 175 reverse = False 176 tasks_diff = sorted(tasks_diff, key=attrgetter(field), reverse=reverse) 177 178 linedata = [(' ', 'PKG', ' ', 'TASK', 'ABSDIFF', 'RELDIFF', 179 val_type.upper() + '1', val_type.upper() + '2')] 180 field_lens = dict([('len_{}'.format(i), len(f)) for i, f in enumerate(linedata[0])]) 181 182 # Prepare fields in string format and measure field lengths 183 for diff in tasks_diff: 184 task_prefix = diff.task_op if diff.pkg_op == ' ' else ' ' 185 linedata.append((diff.pkg_op, diff.pkg, task_prefix, diff.task, 186 val_to_str(diff.absdiff), 187 '{:+.1f}%'.format(diff.reldiff), 188 val_to_str(diff.value1), 189 val_to_str(diff.value2))) 190 for i, field in enumerate(linedata[-1]): 191 key = 'len_{}'.format(i) 192 if len(field) > field_lens[key]: 193 field_lens[key] = len(field) 194 195 # Print data 196 print() 197 for fields in linedata: 198 print("{:{len_0}}{:{len_1}} {:{len_2}}{:{len_3}} {:>{len_4}} {:>{len_5}} {:>{len_6}} -> {:{len_7}}".format( 199 *fields, **field_lens)) 200 201 # Print summary of the diffs 202 total1 = sum_vals(bs1) 203 total2 = sum_vals(bs2) 204 print("\nCumulative {}:".format(val_type)) 205 print (" {} {:+.1f}% {} ({}) -> {} ({})".format( 206 val_to_str(total2 - total1), 100 * (total2-total1) / total1, 207 val_to_str(total1, True), val_to_str(total1), 208 val_to_str(total2, True), val_to_str(total2))) 209 210 211def parse_args(argv): 212 """Parse cmdline arguments""" 213 description=""" 214Script for comparing buildstats of two separate builds.""" 215 parser = argparse.ArgumentParser( 216 formatter_class=argparse.ArgumentDefaultsHelpFormatter, 217 description=description) 218 219 min_val_defaults = {'cputime': 3.0, 220 'read_bytes': 524288, 221 'write_bytes': 524288, 222 'read_ops': 500, 223 'write_ops': 500, 224 'walltime': 5} 225 min_absdiff_defaults = {'cputime': 1.0, 226 'read_bytes': 131072, 227 'write_bytes': 131072, 228 'read_ops': 50, 229 'write_ops': 50, 230 'walltime': 2} 231 232 parser.add_argument('--debug', '-d', action='store_true', 233 help="Verbose logging") 234 parser.add_argument('--ver-diff', action='store_true', 235 help="Show package version differences and exit") 236 parser.add_argument('--diff-attr', default='cputime', 237 choices=min_val_defaults.keys(), 238 help="Buildstat attribute which to compare") 239 parser.add_argument('--min-val', default=min_val_defaults, type=float, 240 help="Filter out tasks less than MIN_VAL. " 241 "Default depends on --diff-attr.") 242 parser.add_argument('--min-absdiff', default=min_absdiff_defaults, type=float, 243 help="Filter out tasks whose difference is less than " 244 "MIN_ABSDIFF, Default depends on --diff-attr.") 245 parser.add_argument('--sort-by', default='absdiff', 246 help="Comma-separated list of field sort order. " 247 "Prepend the field name with '-' for reversed sort. " 248 "Available fields are: {}".format(', '.join(taskdiff_fields))) 249 parser.add_argument('--multi', action='store_true', 250 help="Read all buildstats from the given paths and " 251 "average over them") 252 parser.add_argument('--only-task', dest='only_tasks', metavar='TASK', action='append', default=[], 253 help="Only include TASK in report. May be specified multiple times") 254 parser.add_argument('buildstats1', metavar='BUILDSTATS1', help="'Left' buildstat") 255 parser.add_argument('buildstats2', metavar='BUILDSTATS2', help="'Right' buildstat") 256 257 args = parser.parse_args(argv) 258 259 # We do not nedd/want to read all buildstats if we just want to look at the 260 # package versions 261 if args.ver_diff: 262 args.multi = False 263 264 # Handle defaults for the filter arguments 265 if args.min_val is min_val_defaults: 266 args.min_val = min_val_defaults[args.diff_attr] 267 if args.min_absdiff is min_absdiff_defaults: 268 args.min_absdiff = min_absdiff_defaults[args.diff_attr] 269 270 return args 271 272def main(argv=None): 273 """Script entry point""" 274 args = parse_args(argv) 275 if args.debug: 276 log.setLevel(logging.DEBUG) 277 278 # Validate sort fields 279 sort_by = [] 280 for field in args.sort_by.split(','): 281 if field.lstrip('-') not in taskdiff_fields: 282 log.error("Invalid sort field '%s' (must be one of: %s)" % 283 (field, ', '.join(taskdiff_fields))) 284 sys.exit(1) 285 sort_by.append(field) 286 287 try: 288 bs1 = read_buildstats(args.buildstats1, args.multi) 289 bs2 = read_buildstats(args.buildstats2, args.multi) 290 291 if args.ver_diff: 292 print_ver_diff(bs1, bs2) 293 else: 294 print_task_diff(bs1, bs2, args.diff_attr, args.min_val, 295 args.min_absdiff, sort_by, args.only_tasks) 296 except ScriptError as err: 297 log.error(str(err)) 298 return 1 299 return 0 300 301if __name__ == "__main__": 302 sys.exit(main()) 303