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