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