xref: /OK3568_Linux_fs/yocto/scripts/lib/build_perf/report.py (revision 4882a59341e53eb6f0b4789bf948001014eff981)
1*4882a593Smuzhiyun#
2*4882a593Smuzhiyun# Copyright (c) 2017, Intel Corporation.
3*4882a593Smuzhiyun#
4*4882a593Smuzhiyun# SPDX-License-Identifier: GPL-2.0-only
5*4882a593Smuzhiyun#
6*4882a593Smuzhiyun"""Handling of build perf test reports"""
7*4882a593Smuzhiyunfrom collections import OrderedDict, namedtuple
8*4882a593Smuzhiyunfrom collections.abc import Mapping
9*4882a593Smuzhiyunfrom datetime import datetime, timezone
10*4882a593Smuzhiyunfrom numbers import Number
11*4882a593Smuzhiyunfrom statistics import mean, stdev, variance
12*4882a593Smuzhiyun
13*4882a593Smuzhiyun
14*4882a593SmuzhiyunAggregateTestData = namedtuple('AggregateTestData', ['metadata', 'results'])
15*4882a593Smuzhiyun
16*4882a593Smuzhiyun
17*4882a593Smuzhiyundef isofmt_to_timestamp(string):
18*4882a593Smuzhiyun    """Convert timestamp string in ISO 8601 format into unix timestamp"""
19*4882a593Smuzhiyun    if '.' in string:
20*4882a593Smuzhiyun        dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S.%f')
21*4882a593Smuzhiyun    else:
22*4882a593Smuzhiyun        dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S')
23*4882a593Smuzhiyun    return dt.replace(tzinfo=timezone.utc).timestamp()
24*4882a593Smuzhiyun
25*4882a593Smuzhiyun
26*4882a593Smuzhiyundef metadata_xml_to_json(elem):
27*4882a593Smuzhiyun    """Convert metadata xml into JSON format"""
28*4882a593Smuzhiyun    assert elem.tag == 'metadata', "Invalid metadata file format"
29*4882a593Smuzhiyun
30*4882a593Smuzhiyun    def _xml_to_json(elem):
31*4882a593Smuzhiyun        """Convert xml element to JSON object"""
32*4882a593Smuzhiyun        out = OrderedDict()
33*4882a593Smuzhiyun        for child in elem.getchildren():
34*4882a593Smuzhiyun            key = child.attrib.get('name', child.tag)
35*4882a593Smuzhiyun            if len(child):
36*4882a593Smuzhiyun                out[key] = _xml_to_json(child)
37*4882a593Smuzhiyun            else:
38*4882a593Smuzhiyun                out[key] = child.text
39*4882a593Smuzhiyun        return out
40*4882a593Smuzhiyun    return _xml_to_json(elem)
41*4882a593Smuzhiyun
42*4882a593Smuzhiyun
43*4882a593Smuzhiyundef results_xml_to_json(elem):
44*4882a593Smuzhiyun    """Convert results xml into JSON format"""
45*4882a593Smuzhiyun    rusage_fields = ('ru_utime', 'ru_stime', 'ru_maxrss', 'ru_minflt',
46*4882a593Smuzhiyun                     'ru_majflt', 'ru_inblock', 'ru_oublock', 'ru_nvcsw',
47*4882a593Smuzhiyun                     'ru_nivcsw')
48*4882a593Smuzhiyun    iostat_fields = ('rchar', 'wchar', 'syscr', 'syscw', 'read_bytes',
49*4882a593Smuzhiyun                     'write_bytes', 'cancelled_write_bytes')
50*4882a593Smuzhiyun
51*4882a593Smuzhiyun    def _read_measurement(elem):
52*4882a593Smuzhiyun        """Convert measurement to JSON"""
53*4882a593Smuzhiyun        data = OrderedDict()
54*4882a593Smuzhiyun        data['type'] = elem.tag
55*4882a593Smuzhiyun        data['name'] = elem.attrib['name']
56*4882a593Smuzhiyun        data['legend'] = elem.attrib['legend']
57*4882a593Smuzhiyun        values = OrderedDict()
58*4882a593Smuzhiyun
59*4882a593Smuzhiyun        # SYSRES measurement
60*4882a593Smuzhiyun        if elem.tag == 'sysres':
61*4882a593Smuzhiyun            for subel in elem:
62*4882a593Smuzhiyun                if subel.tag == 'time':
63*4882a593Smuzhiyun                    values['start_time'] = isofmt_to_timestamp(subel.attrib['timestamp'])
64*4882a593Smuzhiyun                    values['elapsed_time'] = float(subel.text)
65*4882a593Smuzhiyun                elif subel.tag == 'rusage':
66*4882a593Smuzhiyun                    rusage = OrderedDict()
67*4882a593Smuzhiyun                    for field in rusage_fields:
68*4882a593Smuzhiyun                        if 'time' in field:
69*4882a593Smuzhiyun                            rusage[field] = float(subel.attrib[field])
70*4882a593Smuzhiyun                        else:
71*4882a593Smuzhiyun                            rusage[field] = int(subel.attrib[field])
72*4882a593Smuzhiyun                    values['rusage'] = rusage
73*4882a593Smuzhiyun                elif subel.tag == 'iostat':
74*4882a593Smuzhiyun                    values['iostat'] = OrderedDict([(f, int(subel.attrib[f]))
75*4882a593Smuzhiyun                        for f in iostat_fields])
76*4882a593Smuzhiyun                elif subel.tag == 'buildstats_file':
77*4882a593Smuzhiyun                    values['buildstats_file'] = subel.text
78*4882a593Smuzhiyun                else:
79*4882a593Smuzhiyun                    raise TypeError("Unknown sysres value element '{}'".format(subel.tag))
80*4882a593Smuzhiyun        # DISKUSAGE measurement
81*4882a593Smuzhiyun        elif elem.tag == 'diskusage':
82*4882a593Smuzhiyun            values['size'] = int(elem.find('size').text)
83*4882a593Smuzhiyun        else:
84*4882a593Smuzhiyun            raise Exception("Unknown measurement tag '{}'".format(elem.tag))
85*4882a593Smuzhiyun        data['values'] = values
86*4882a593Smuzhiyun        return data
87*4882a593Smuzhiyun
88*4882a593Smuzhiyun    def _read_testcase(elem):
89*4882a593Smuzhiyun        """Convert testcase into JSON"""
90*4882a593Smuzhiyun        assert elem.tag == 'testcase', "Expecting 'testcase' element instead of {}".format(elem.tag)
91*4882a593Smuzhiyun
92*4882a593Smuzhiyun        data = OrderedDict()
93*4882a593Smuzhiyun        data['name'] = elem.attrib['name']
94*4882a593Smuzhiyun        data['description'] = elem.attrib['description']
95*4882a593Smuzhiyun        data['status'] = 'SUCCESS'
96*4882a593Smuzhiyun        data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp'])
97*4882a593Smuzhiyun        data['elapsed_time'] = float(elem.attrib['time'])
98*4882a593Smuzhiyun        measurements = OrderedDict()
99*4882a593Smuzhiyun
100*4882a593Smuzhiyun        for subel in elem.getchildren():
101*4882a593Smuzhiyun            if subel.tag == 'error' or subel.tag == 'failure':
102*4882a593Smuzhiyun                data['status'] = subel.tag.upper()
103*4882a593Smuzhiyun                data['message'] = subel.attrib['message']
104*4882a593Smuzhiyun                data['err_type'] = subel.attrib['type']
105*4882a593Smuzhiyun                data['err_output'] = subel.text
106*4882a593Smuzhiyun            elif subel.tag == 'skipped':
107*4882a593Smuzhiyun                data['status'] = 'SKIPPED'
108*4882a593Smuzhiyun                data['message'] = subel.text
109*4882a593Smuzhiyun            else:
110*4882a593Smuzhiyun                measurements[subel.attrib['name']] = _read_measurement(subel)
111*4882a593Smuzhiyun        data['measurements'] = measurements
112*4882a593Smuzhiyun        return data
113*4882a593Smuzhiyun
114*4882a593Smuzhiyun    def _read_testsuite(elem):
115*4882a593Smuzhiyun        """Convert suite to JSON"""
116*4882a593Smuzhiyun        assert elem.tag == 'testsuite', \
117*4882a593Smuzhiyun                "Expecting 'testsuite' element instead of {}".format(elem.tag)
118*4882a593Smuzhiyun
119*4882a593Smuzhiyun        data = OrderedDict()
120*4882a593Smuzhiyun        if 'hostname' in elem.attrib:
121*4882a593Smuzhiyun            data['tester_host'] = elem.attrib['hostname']
122*4882a593Smuzhiyun        data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp'])
123*4882a593Smuzhiyun        data['elapsed_time'] = float(elem.attrib['time'])
124*4882a593Smuzhiyun        tests = OrderedDict()
125*4882a593Smuzhiyun
126*4882a593Smuzhiyun        for case in elem.getchildren():
127*4882a593Smuzhiyun            tests[case.attrib['name']] = _read_testcase(case)
128*4882a593Smuzhiyun        data['tests'] = tests
129*4882a593Smuzhiyun        return data
130*4882a593Smuzhiyun
131*4882a593Smuzhiyun    # Main function
132*4882a593Smuzhiyun    assert elem.tag == 'testsuites', "Invalid test report format"
133*4882a593Smuzhiyun    assert len(elem) == 1, "Too many testsuites"
134*4882a593Smuzhiyun
135*4882a593Smuzhiyun    return _read_testsuite(elem.getchildren()[0])
136*4882a593Smuzhiyun
137*4882a593Smuzhiyun
138*4882a593Smuzhiyundef aggregate_metadata(metadata):
139*4882a593Smuzhiyun    """Aggregate metadata into one, basically a sanity check"""
140*4882a593Smuzhiyun    mutable_keys = ('pretty_name', 'version_id')
141*4882a593Smuzhiyun
142*4882a593Smuzhiyun    def aggregate_obj(aggregate, obj, assert_str=True):
143*4882a593Smuzhiyun        """Aggregate objects together"""
144*4882a593Smuzhiyun        assert type(aggregate) is type(obj), \
145*4882a593Smuzhiyun                "Type mismatch: {} != {}".format(type(aggregate), type(obj))
146*4882a593Smuzhiyun        if isinstance(obj, Mapping):
147*4882a593Smuzhiyun            assert set(aggregate.keys()) == set(obj.keys())
148*4882a593Smuzhiyun            for key, val in obj.items():
149*4882a593Smuzhiyun                aggregate_obj(aggregate[key], val, key not in mutable_keys)
150*4882a593Smuzhiyun        elif isinstance(obj, list):
151*4882a593Smuzhiyun            assert len(aggregate) == len(obj)
152*4882a593Smuzhiyun            for i, val in enumerate(obj):
153*4882a593Smuzhiyun                aggregate_obj(aggregate[i], val)
154*4882a593Smuzhiyun        elif not isinstance(obj, str) or (isinstance(obj, str) and assert_str):
155*4882a593Smuzhiyun            assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj)
156*4882a593Smuzhiyun
157*4882a593Smuzhiyun    if not metadata:
158*4882a593Smuzhiyun        return {}
159*4882a593Smuzhiyun
160*4882a593Smuzhiyun    # Do the aggregation
161*4882a593Smuzhiyun    aggregate = metadata[0].copy()
162*4882a593Smuzhiyun    for testrun in metadata[1:]:
163*4882a593Smuzhiyun        aggregate_obj(aggregate, testrun)
164*4882a593Smuzhiyun    aggregate['testrun_count'] = len(metadata)
165*4882a593Smuzhiyun    return aggregate
166*4882a593Smuzhiyun
167*4882a593Smuzhiyun
168*4882a593Smuzhiyundef aggregate_data(data):
169*4882a593Smuzhiyun    """Aggregate multiple test results JSON structures into one"""
170*4882a593Smuzhiyun
171*4882a593Smuzhiyun    mutable_keys = ('status', 'message', 'err_type', 'err_output')
172*4882a593Smuzhiyun
173*4882a593Smuzhiyun    class SampleList(list):
174*4882a593Smuzhiyun        """Container for numerical samples"""
175*4882a593Smuzhiyun        pass
176*4882a593Smuzhiyun
177*4882a593Smuzhiyun    def new_aggregate_obj(obj):
178*4882a593Smuzhiyun        """Create new object for aggregate"""
179*4882a593Smuzhiyun        if isinstance(obj, Number):
180*4882a593Smuzhiyun            new_obj = SampleList()
181*4882a593Smuzhiyun            new_obj.append(obj)
182*4882a593Smuzhiyun        elif isinstance(obj, str):
183*4882a593Smuzhiyun            new_obj = obj
184*4882a593Smuzhiyun        else:
185*4882a593Smuzhiyun            # Lists and and dicts are kept as is
186*4882a593Smuzhiyun            new_obj = obj.__class__()
187*4882a593Smuzhiyun            aggregate_obj(new_obj, obj)
188*4882a593Smuzhiyun        return new_obj
189*4882a593Smuzhiyun
190*4882a593Smuzhiyun    def aggregate_obj(aggregate, obj, assert_str=True):
191*4882a593Smuzhiyun        """Recursive "aggregation" of JSON objects"""
192*4882a593Smuzhiyun        if isinstance(obj, Number):
193*4882a593Smuzhiyun            assert isinstance(aggregate, SampleList)
194*4882a593Smuzhiyun            aggregate.append(obj)
195*4882a593Smuzhiyun            return
196*4882a593Smuzhiyun
197*4882a593Smuzhiyun        assert type(aggregate) == type(obj), \
198*4882a593Smuzhiyun                "Type mismatch: {} != {}".format(type(aggregate), type(obj))
199*4882a593Smuzhiyun        if isinstance(obj, Mapping):
200*4882a593Smuzhiyun            for key, val in obj.items():
201*4882a593Smuzhiyun                if not key in aggregate:
202*4882a593Smuzhiyun                    aggregate[key] = new_aggregate_obj(val)
203*4882a593Smuzhiyun                else:
204*4882a593Smuzhiyun                    aggregate_obj(aggregate[key], val, key not in mutable_keys)
205*4882a593Smuzhiyun        elif isinstance(obj, list):
206*4882a593Smuzhiyun            for i, val in enumerate(obj):
207*4882a593Smuzhiyun                if i >= len(aggregate):
208*4882a593Smuzhiyun                    aggregate[key] = new_aggregate_obj(val)
209*4882a593Smuzhiyun                else:
210*4882a593Smuzhiyun                    aggregate_obj(aggregate[i], val)
211*4882a593Smuzhiyun        elif isinstance(obj, str):
212*4882a593Smuzhiyun            # Sanity check for data
213*4882a593Smuzhiyun            if assert_str:
214*4882a593Smuzhiyun                assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj)
215*4882a593Smuzhiyun        else:
216*4882a593Smuzhiyun            raise Exception("BUG: unable to aggregate '{}' ({})".format(type(obj), str(obj)))
217*4882a593Smuzhiyun
218*4882a593Smuzhiyun    if not data:
219*4882a593Smuzhiyun        return {}
220*4882a593Smuzhiyun
221*4882a593Smuzhiyun    # Do the aggregation
222*4882a593Smuzhiyun    aggregate = data[0].__class__()
223*4882a593Smuzhiyun    for testrun in data:
224*4882a593Smuzhiyun        aggregate_obj(aggregate, testrun)
225*4882a593Smuzhiyun    return aggregate
226*4882a593Smuzhiyun
227*4882a593Smuzhiyun
228*4882a593Smuzhiyunclass MeasurementVal(float):
229*4882a593Smuzhiyun    """Base class representing measurement values"""
230*4882a593Smuzhiyun    gv_data_type = 'number'
231*4882a593Smuzhiyun
232*4882a593Smuzhiyun    def gv_value(self):
233*4882a593Smuzhiyun        """Value formatting for visualization"""
234*4882a593Smuzhiyun        if self != self:
235*4882a593Smuzhiyun            return "null"
236*4882a593Smuzhiyun        else:
237*4882a593Smuzhiyun            return self
238*4882a593Smuzhiyun
239*4882a593Smuzhiyun
240*4882a593Smuzhiyunclass TimeVal(MeasurementVal):
241*4882a593Smuzhiyun    """Class representing time values"""
242*4882a593Smuzhiyun    quantity = 'time'
243*4882a593Smuzhiyun    gv_title = 'elapsed time'
244*4882a593Smuzhiyun    gv_data_type = 'timeofday'
245*4882a593Smuzhiyun
246*4882a593Smuzhiyun    def hms(self):
247*4882a593Smuzhiyun        """Split time into hours, minutes and seconeds"""
248*4882a593Smuzhiyun        hhh = int(abs(self) / 3600)
249*4882a593Smuzhiyun        mmm = int((abs(self) % 3600) / 60)
250*4882a593Smuzhiyun        sss = abs(self) % 60
251*4882a593Smuzhiyun        return hhh, mmm, sss
252*4882a593Smuzhiyun
253*4882a593Smuzhiyun    def __str__(self):
254*4882a593Smuzhiyun        if self != self:
255*4882a593Smuzhiyun            return "nan"
256*4882a593Smuzhiyun        hh, mm, ss = self.hms()
257*4882a593Smuzhiyun        sign = '-' if self < 0 else ''
258*4882a593Smuzhiyun        if hh > 0:
259*4882a593Smuzhiyun            return '{}{:d}:{:02d}:{:02.0f}'.format(sign, hh, mm, ss)
260*4882a593Smuzhiyun        elif mm > 0:
261*4882a593Smuzhiyun            return '{}{:d}:{:04.1f}'.format(sign, mm, ss)
262*4882a593Smuzhiyun        elif ss > 1:
263*4882a593Smuzhiyun            return '{}{:.1f} s'.format(sign, ss)
264*4882a593Smuzhiyun        else:
265*4882a593Smuzhiyun            return '{}{:.2f} s'.format(sign, ss)
266*4882a593Smuzhiyun
267*4882a593Smuzhiyun    def gv_value(self):
268*4882a593Smuzhiyun        """Value formatting for visualization"""
269*4882a593Smuzhiyun        if self != self:
270*4882a593Smuzhiyun            return "null"
271*4882a593Smuzhiyun        hh, mm, ss = self.hms()
272*4882a593Smuzhiyun        return [hh, mm, int(ss), int(ss*1000) % 1000]
273*4882a593Smuzhiyun
274*4882a593Smuzhiyun
275*4882a593Smuzhiyunclass SizeVal(MeasurementVal):
276*4882a593Smuzhiyun    """Class representing time values"""
277*4882a593Smuzhiyun    quantity = 'size'
278*4882a593Smuzhiyun    gv_title = 'size in MiB'
279*4882a593Smuzhiyun    gv_data_type = 'number'
280*4882a593Smuzhiyun
281*4882a593Smuzhiyun    def __str__(self):
282*4882a593Smuzhiyun        if self != self:
283*4882a593Smuzhiyun            return "nan"
284*4882a593Smuzhiyun        if abs(self) < 1024:
285*4882a593Smuzhiyun            return '{:.1f} kiB'.format(self)
286*4882a593Smuzhiyun        elif abs(self) < 1048576:
287*4882a593Smuzhiyun            return '{:.2f} MiB'.format(self / 1024)
288*4882a593Smuzhiyun        else:
289*4882a593Smuzhiyun            return '{:.2f} GiB'.format(self / 1048576)
290*4882a593Smuzhiyun
291*4882a593Smuzhiyun    def gv_value(self):
292*4882a593Smuzhiyun        """Value formatting for visualization"""
293*4882a593Smuzhiyun        if self != self:
294*4882a593Smuzhiyun            return "null"
295*4882a593Smuzhiyun        return self / 1024
296*4882a593Smuzhiyun
297*4882a593Smuzhiyundef measurement_stats(meas, prefix=''):
298*4882a593Smuzhiyun    """Get statistics of a measurement"""
299*4882a593Smuzhiyun    if not meas:
300*4882a593Smuzhiyun        return {prefix + 'sample_cnt': 0,
301*4882a593Smuzhiyun                prefix + 'mean': MeasurementVal('nan'),
302*4882a593Smuzhiyun                prefix + 'stdev': MeasurementVal('nan'),
303*4882a593Smuzhiyun                prefix + 'variance': MeasurementVal('nan'),
304*4882a593Smuzhiyun                prefix + 'min': MeasurementVal('nan'),
305*4882a593Smuzhiyun                prefix + 'max': MeasurementVal('nan'),
306*4882a593Smuzhiyun                prefix + 'minus': MeasurementVal('nan'),
307*4882a593Smuzhiyun                prefix + 'plus': MeasurementVal('nan')}
308*4882a593Smuzhiyun
309*4882a593Smuzhiyun    stats = {'name': meas['name']}
310*4882a593Smuzhiyun    if meas['type'] == 'sysres':
311*4882a593Smuzhiyun        val_cls = TimeVal
312*4882a593Smuzhiyun        values = meas['values']['elapsed_time']
313*4882a593Smuzhiyun    elif meas['type'] == 'diskusage':
314*4882a593Smuzhiyun        val_cls = SizeVal
315*4882a593Smuzhiyun        values = meas['values']['size']
316*4882a593Smuzhiyun    else:
317*4882a593Smuzhiyun        raise Exception("Unknown measurement type '{}'".format(meas['type']))
318*4882a593Smuzhiyun    stats['val_cls'] = val_cls
319*4882a593Smuzhiyun    stats['quantity'] = val_cls.quantity
320*4882a593Smuzhiyun    stats[prefix + 'sample_cnt'] = len(values)
321*4882a593Smuzhiyun
322*4882a593Smuzhiyun    mean_val = val_cls(mean(values))
323*4882a593Smuzhiyun    min_val = val_cls(min(values))
324*4882a593Smuzhiyun    max_val = val_cls(max(values))
325*4882a593Smuzhiyun
326*4882a593Smuzhiyun    stats[prefix + 'mean'] = mean_val
327*4882a593Smuzhiyun    if len(values) > 1:
328*4882a593Smuzhiyun        stats[prefix + 'stdev'] = val_cls(stdev(values))
329*4882a593Smuzhiyun        stats[prefix + 'variance'] = val_cls(variance(values))
330*4882a593Smuzhiyun    else:
331*4882a593Smuzhiyun        stats[prefix + 'stdev'] = float('nan')
332*4882a593Smuzhiyun        stats[prefix + 'variance'] = float('nan')
333*4882a593Smuzhiyun    stats[prefix + 'min'] = min_val
334*4882a593Smuzhiyun    stats[prefix + 'max'] = max_val
335*4882a593Smuzhiyun    stats[prefix + 'minus'] = val_cls(mean_val - min_val)
336*4882a593Smuzhiyun    stats[prefix + 'plus'] = val_cls(max_val - mean_val)
337*4882a593Smuzhiyun
338*4882a593Smuzhiyun    return stats
339*4882a593Smuzhiyun
340