From eb8dc40360f0cfef56fb6947cc817a547d6d9bc6 Mon Sep 17 00:00:00 2001 From: Dave Cobbley Date: Tue, 14 Aug 2018 10:05:37 -0700 Subject: [Subtree] Removing import-layers directory As part of the move to subtrees, need to bring all the import layers content to the top level. Change-Id: I4a163d10898cbc6e11c27f776f60e1a470049d8f Signed-off-by: Dave Cobbley Signed-off-by: Brad Bishop --- poky/scripts/lib/build_perf/__init__.py | 31 ++ poky/scripts/lib/build_perf/html.py | 19 ++ .../lib/build_perf/html/measurement_chart.html | 50 +++ poky/scripts/lib/build_perf/html/report.html | 286 +++++++++++++++++ poky/scripts/lib/build_perf/report.py | 345 +++++++++++++++++++++ poky/scripts/lib/build_perf/scrape-html-report.js | 56 ++++ 6 files changed, 787 insertions(+) create mode 100644 poky/scripts/lib/build_perf/__init__.py create mode 100644 poky/scripts/lib/build_perf/html.py create mode 100644 poky/scripts/lib/build_perf/html/measurement_chart.html create mode 100644 poky/scripts/lib/build_perf/html/report.html create mode 100644 poky/scripts/lib/build_perf/report.py create mode 100644 poky/scripts/lib/build_perf/scrape-html-report.js (limited to 'poky/scripts/lib/build_perf') diff --git a/poky/scripts/lib/build_perf/__init__.py b/poky/scripts/lib/build_perf/__init__.py new file mode 100644 index 000000000..1f8b72907 --- /dev/null +++ b/poky/scripts/lib/build_perf/__init__.py @@ -0,0 +1,31 @@ +# +# Copyright (c) 2017, Intel Corporation. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms and conditions of the GNU General Public License, +# version 2, as published by the Free Software Foundation. +# +# This program is distributed in the hope it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +"""Build performance test library functions""" + +def print_table(rows, row_fmt=None): + """Print data table""" + if not rows: + return + if not row_fmt: + row_fmt = ['{:{wid}} '] * len(rows[0]) + + # Go through the data to get maximum cell widths + num_cols = len(row_fmt) + col_widths = [0] * num_cols + for row in rows: + for i, val in enumerate(row): + col_widths[i] = max(col_widths[i], len(str(val))) + + for row in rows: + print(*[row_fmt[i].format(col, wid=col_widths[i]) for i, col in enumerate(row)]) + diff --git a/poky/scripts/lib/build_perf/html.py b/poky/scripts/lib/build_perf/html.py new file mode 100644 index 000000000..578bb162e --- /dev/null +++ b/poky/scripts/lib/build_perf/html.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2017, Intel Corporation. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms and conditions of the GNU General Public License, +# version 2, as published by the Free Software Foundation. +# +# This program is distributed in the hope it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +"""Helper module for HTML reporting""" +from jinja2 import Environment, PackageLoader + + +env = Environment(loader=PackageLoader('build_perf', 'html')) + +template = env.get_template('report.html') diff --git a/poky/scripts/lib/build_perf/html/measurement_chart.html b/poky/scripts/lib/build_perf/html/measurement_chart.html new file mode 100644 index 000000000..65f1a227a --- /dev/null +++ b/poky/scripts/lib/build_perf/html/measurement_chart.html @@ -0,0 +1,50 @@ + + diff --git a/poky/scripts/lib/build_perf/html/report.html b/poky/scripts/lib/build_perf/html/report.html new file mode 100644 index 000000000..291ad9d72 --- /dev/null +++ b/poky/scripts/lib/build_perf/html/report.html @@ -0,0 +1,286 @@ + + + +{# Scripts, for visualization#} + + + + +{# Render measurement result charts #} +{% for test in test_data %} + {% if test.status == 'SUCCESS' %} + {% for measurement in test.measurements %} + {% set chart_elem_id = test.name + '_' + measurement.name + '_chart' %} + {% include 'measurement_chart.html' %} + {% endfor %} + {% endif %} +{% endfor %} + + + +{# Styles #} + + +{{ title }} + + +{% macro poky_link(commit) -%} + {{ commit[0:11] }} +{%- endmacro %} + +
+ {# Test metadata #} +

General

+
+ + + + + + + {% for key, item in metadata.items() %} + + + {%if key == 'commit' %} + + + {% else %} + + + {% endif %} + + {% endfor %} +
Current commitComparing with
{{ item.title }}{{ poky_link(item.value) }}{{ poky_link(item.value_old) }}{{ item.value }}{{ item.value_old }}
+ + {# Test result summary #} +

Test result summary

+
+ + {% for test in test_data %} + {% if loop.index is even %} + {% set row_style = 'style="background-color: #f2f2f2"' %} + {% else %} + {% set row_style = 'style="background-color: #ffffff"' %} + {% endif %} + {% if test.status == 'SUCCESS' %} + {% for measurement in test.measurements %} + + {% if loop.index == 1 %} + + {% else %} + {# add empty cell in place of the test name#} + + {% endif %} + {% if measurement.absdiff > 0 %} + {% set result_style = "color: red" %} + {% elif measurement.absdiff == measurement.absdiff %} + {% set result_style = "color: green" %} + {% else %} + {% set result_style = "color: orange" %} + {%endif %} + + + + + + {% endfor %} + {% else %} + + + {% endif %} + {% endfor %} +
{{ test.name }}: {{ test.description }}{{ measurement.description }}{{ measurement.value.mean }}{{ measurement.absdiff_str }}{{ measurement.reldiff }}
{{test.status }}
+ + {# Detailed test results #} + {% for test in test_data %} +

{{ test.name }}: {{ test.description }}

+
+ {% if test.status == 'SUCCESS' %} + {% for measurement in test.measurements %} +
+

{{ measurement.description }}

+
+ {{ measurement.value.mean }} + + {% if measurement.absdiff > 0 %} + + {% elif measurement.absdiff == measurement.absdiff %} + + {% else %} + + {% endif %} + {{ measurement.absdiff_str }} ({{measurement.reldiff}}) + +
+ {# Table for trendchart and the statistics #} + + + + + +
+ {# Linechart #} +
+
+ {# Measurement statistics #} + + + + + + + + + + + + + + + +
Test runs{{ measurement.value.sample_cnt }}
-/+-{{ measurement.value.minus }} / +{{ measurement.value.plus }}
Min{{ measurement.value.min }}
Max{{ measurement.value.max }}
Stdev{{ measurement.value.stdev }}
+
+ + {# Task and recipe summary from buildstats #} + {% if 'buildstats' in measurement %} + Task resource usage + + + + + + + + {# Table of most resource-hungry tasks #} + + + + + + + + {# Table biggest increase in resource usage #} + + {# Table biggest decrease in resource usage #} + + +
Number of tasksTop consumers of cputime
{{ measurement.buildstats.tasks.count }} ({{ measurement.buildstats.tasks.change }}) + + {% for diff in measurement.buildstats.top_consumer|reverse %} + + + + + {% endfor %} +
{{ diff.pkg }}.{{ diff.task }}{{ '%0.0f' % diff.value2 }} s
+
Biggest increase in cputimeBiggest decrease in cputime
+ + {% for diff in measurement.buildstats.top_increase|reverse %} + + + + + {% endfor %} +
{{ diff.pkg }}.{{ diff.task }}{{ '%+0.0f' % diff.absdiff }} s
+
+ + {% for diff in measurement.buildstats.top_decrease %} + + + + + {% endfor %} +
{{ diff.pkg }}.{{ diff.task }}{{ '%+0.0f' % diff.absdiff }} s
+
+ + {# Recipe version differences #} + {% if measurement.buildstats.ver_diff %} +
Recipe version changes
+ + {% for head, recipes in measurement.buildstats.ver_diff.items() %} + + + + {% for name, info in recipes|sort %} + + + + + {% endfor %} + {% endfor %} +
{{ head }}
{{ name }}{{ info }}
+ {% else %} +
No recipe version changes detected
+ {% endif %} + {% endif %} +
+ {% endfor %} + {# Unsuccessful test #} + {% else %} + {{ test.status }} + {% if test.err_type %}({{ test.err_type }}){% endif %} + +
{{ test.message }}
+ {% endif %} + {% endfor %} +
+ + diff --git a/poky/scripts/lib/build_perf/report.py b/poky/scripts/lib/build_perf/report.py new file mode 100644 index 000000000..d99a36797 --- /dev/null +++ b/poky/scripts/lib/build_perf/report.py @@ -0,0 +1,345 @@ +# +# Copyright (c) 2017, Intel Corporation. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms and conditions of the GNU General Public License, +# version 2, as published by the Free Software Foundation. +# +# This program is distributed in the hope it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +"""Handling of build perf test reports""" +from collections import OrderedDict, Mapping, namedtuple +from datetime import datetime, timezone +from numbers import Number +from statistics import mean, stdev, variance + + +AggregateTestData = namedtuple('AggregateTestData', ['metadata', 'results']) + + +def isofmt_to_timestamp(string): + """Convert timestamp string in ISO 8601 format into unix timestamp""" + if '.' in string: + dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S.%f') + else: + dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S') + return dt.replace(tzinfo=timezone.utc).timestamp() + + +def metadata_xml_to_json(elem): + """Convert metadata xml into JSON format""" + assert elem.tag == 'metadata', "Invalid metadata file format" + + def _xml_to_json(elem): + """Convert xml element to JSON object""" + out = OrderedDict() + for child in elem.getchildren(): + key = child.attrib.get('name', child.tag) + if len(child): + out[key] = _xml_to_json(child) + else: + out[key] = child.text + return out + return _xml_to_json(elem) + + +def results_xml_to_json(elem): + """Convert results xml into JSON format""" + rusage_fields = ('ru_utime', 'ru_stime', 'ru_maxrss', 'ru_minflt', + 'ru_majflt', 'ru_inblock', 'ru_oublock', 'ru_nvcsw', + 'ru_nivcsw') + iostat_fields = ('rchar', 'wchar', 'syscr', 'syscw', 'read_bytes', + 'write_bytes', 'cancelled_write_bytes') + + def _read_measurement(elem): + """Convert measurement to JSON""" + data = OrderedDict() + data['type'] = elem.tag + data['name'] = elem.attrib['name'] + data['legend'] = elem.attrib['legend'] + values = OrderedDict() + + # SYSRES measurement + if elem.tag == 'sysres': + for subel in elem: + if subel.tag == 'time': + values['start_time'] = isofmt_to_timestamp(subel.attrib['timestamp']) + values['elapsed_time'] = float(subel.text) + elif subel.tag == 'rusage': + rusage = OrderedDict() + for field in rusage_fields: + if 'time' in field: + rusage[field] = float(subel.attrib[field]) + else: + rusage[field] = int(subel.attrib[field]) + values['rusage'] = rusage + elif subel.tag == 'iostat': + values['iostat'] = OrderedDict([(f, int(subel.attrib[f])) + for f in iostat_fields]) + elif subel.tag == 'buildstats_file': + values['buildstats_file'] = subel.text + else: + raise TypeError("Unknown sysres value element '{}'".format(subel.tag)) + # DISKUSAGE measurement + elif elem.tag == 'diskusage': + values['size'] = int(elem.find('size').text) + else: + raise Exception("Unknown measurement tag '{}'".format(elem.tag)) + data['values'] = values + return data + + def _read_testcase(elem): + """Convert testcase into JSON""" + assert elem.tag == 'testcase', "Expecting 'testcase' element instead of {}".format(elem.tag) + + data = OrderedDict() + data['name'] = elem.attrib['name'] + data['description'] = elem.attrib['description'] + data['status'] = 'SUCCESS' + data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp']) + data['elapsed_time'] = float(elem.attrib['time']) + measurements = OrderedDict() + + for subel in elem.getchildren(): + if subel.tag == 'error' or subel.tag == 'failure': + data['status'] = subel.tag.upper() + data['message'] = subel.attrib['message'] + data['err_type'] = subel.attrib['type'] + data['err_output'] = subel.text + elif subel.tag == 'skipped': + data['status'] = 'SKIPPED' + data['message'] = subel.text + else: + measurements[subel.attrib['name']] = _read_measurement(subel) + data['measurements'] = measurements + return data + + def _read_testsuite(elem): + """Convert suite to JSON""" + assert elem.tag == 'testsuite', \ + "Expecting 'testsuite' element instead of {}".format(elem.tag) + + data = OrderedDict() + if 'hostname' in elem.attrib: + data['tester_host'] = elem.attrib['hostname'] + data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp']) + data['elapsed_time'] = float(elem.attrib['time']) + tests = OrderedDict() + + for case in elem.getchildren(): + tests[case.attrib['name']] = _read_testcase(case) + data['tests'] = tests + return data + + # Main function + assert elem.tag == 'testsuites', "Invalid test report format" + assert len(elem) == 1, "Too many testsuites" + + return _read_testsuite(elem.getchildren()[0]) + + +def aggregate_metadata(metadata): + """Aggregate metadata into one, basically a sanity check""" + mutable_keys = ('pretty_name', 'version_id') + + def aggregate_obj(aggregate, obj, assert_str=True): + """Aggregate objects together""" + assert type(aggregate) is type(obj), \ + "Type mismatch: {} != {}".format(type(aggregate), type(obj)) + if isinstance(obj, Mapping): + assert set(aggregate.keys()) == set(obj.keys()) + for key, val in obj.items(): + aggregate_obj(aggregate[key], val, key not in mutable_keys) + elif isinstance(obj, list): + assert len(aggregate) == len(obj) + for i, val in enumerate(obj): + aggregate_obj(aggregate[i], val) + elif not isinstance(obj, str) or (isinstance(obj, str) and assert_str): + assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj) + + if not metadata: + return {} + + # Do the aggregation + aggregate = metadata[0].copy() + for testrun in metadata[1:]: + aggregate_obj(aggregate, testrun) + aggregate['testrun_count'] = len(metadata) + return aggregate + + +def aggregate_data(data): + """Aggregate multiple test results JSON structures into one""" + + mutable_keys = ('status', 'message', 'err_type', 'err_output') + + class SampleList(list): + """Container for numerical samples""" + pass + + def new_aggregate_obj(obj): + """Create new object for aggregate""" + if isinstance(obj, Number): + new_obj = SampleList() + new_obj.append(obj) + elif isinstance(obj, str): + new_obj = obj + else: + # Lists and and dicts are kept as is + new_obj = obj.__class__() + aggregate_obj(new_obj, obj) + return new_obj + + def aggregate_obj(aggregate, obj, assert_str=True): + """Recursive "aggregation" of JSON objects""" + if isinstance(obj, Number): + assert isinstance(aggregate, SampleList) + aggregate.append(obj) + return + + assert type(aggregate) == type(obj), \ + "Type mismatch: {} != {}".format(type(aggregate), type(obj)) + if isinstance(obj, Mapping): + for key, val in obj.items(): + if not key in aggregate: + aggregate[key] = new_aggregate_obj(val) + else: + aggregate_obj(aggregate[key], val, key not in mutable_keys) + elif isinstance(obj, list): + for i, val in enumerate(obj): + if i >= len(aggregate): + aggregate[key] = new_aggregate_obj(val) + else: + aggregate_obj(aggregate[i], val) + elif isinstance(obj, str): + # Sanity check for data + if assert_str: + assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj) + else: + raise Exception("BUG: unable to aggregate '{}' ({})".format(type(obj), str(obj))) + + if not data: + return {} + + # Do the aggregation + aggregate = data[0].__class__() + for testrun in data: + aggregate_obj(aggregate, testrun) + return aggregate + + +class MeasurementVal(float): + """Base class representing measurement values""" + gv_data_type = 'number' + + def gv_value(self): + """Value formatting for visualization""" + if self != self: + return "null" + else: + return self + + +class TimeVal(MeasurementVal): + """Class representing time values""" + quantity = 'time' + gv_title = 'elapsed time' + gv_data_type = 'timeofday' + + def hms(self): + """Split time into hours, minutes and seconeds""" + hhh = int(abs(self) / 3600) + mmm = int((abs(self) % 3600) / 60) + sss = abs(self) % 60 + return hhh, mmm, sss + + def __str__(self): + if self != self: + return "nan" + hh, mm, ss = self.hms() + sign = '-' if self < 0 else '' + if hh > 0: + return '{}{:d}:{:02d}:{:02.0f}'.format(sign, hh, mm, ss) + elif mm > 0: + return '{}{:d}:{:04.1f}'.format(sign, mm, ss) + elif ss > 1: + return '{}{:.1f} s'.format(sign, ss) + else: + return '{}{:.2f} s'.format(sign, ss) + + def gv_value(self): + """Value formatting for visualization""" + if self != self: + return "null" + hh, mm, ss = self.hms() + return [hh, mm, int(ss), int(ss*1000) % 1000] + + +class SizeVal(MeasurementVal): + """Class representing time values""" + quantity = 'size' + gv_title = 'size in MiB' + gv_data_type = 'number' + + def __str__(self): + if self != self: + return "nan" + if abs(self) < 1024: + return '{:.1f} kiB'.format(self) + elif abs(self) < 1048576: + return '{:.2f} MiB'.format(self / 1024) + else: + return '{:.2f} GiB'.format(self / 1048576) + + def gv_value(self): + """Value formatting for visualization""" + if self != self: + return "null" + return self / 1024 + +def measurement_stats(meas, prefix=''): + """Get statistics of a measurement""" + if not meas: + return {prefix + 'sample_cnt': 0, + prefix + 'mean': MeasurementVal('nan'), + prefix + 'stdev': MeasurementVal('nan'), + prefix + 'variance': MeasurementVal('nan'), + prefix + 'min': MeasurementVal('nan'), + prefix + 'max': MeasurementVal('nan'), + prefix + 'minus': MeasurementVal('nan'), + prefix + 'plus': MeasurementVal('nan')} + + stats = {'name': meas['name']} + if meas['type'] == 'sysres': + val_cls = TimeVal + values = meas['values']['elapsed_time'] + elif meas['type'] == 'diskusage': + val_cls = SizeVal + values = meas['values']['size'] + else: + raise Exception("Unknown measurement type '{}'".format(meas['type'])) + stats['val_cls'] = val_cls + stats['quantity'] = val_cls.quantity + stats[prefix + 'sample_cnt'] = len(values) + + mean_val = val_cls(mean(values)) + min_val = val_cls(min(values)) + max_val = val_cls(max(values)) + + stats[prefix + 'mean'] = mean_val + if len(values) > 1: + stats[prefix + 'stdev'] = val_cls(stdev(values)) + stats[prefix + 'variance'] = val_cls(variance(values)) + else: + stats[prefix + 'stdev'] = float('nan') + stats[prefix + 'variance'] = float('nan') + stats[prefix + 'min'] = min_val + stats[prefix + 'max'] = max_val + stats[prefix + 'minus'] = val_cls(mean_val - min_val) + stats[prefix + 'plus'] = val_cls(max_val - mean_val) + + return stats + diff --git a/poky/scripts/lib/build_perf/scrape-html-report.js b/poky/scripts/lib/build_perf/scrape-html-report.js new file mode 100644 index 000000000..05a1f5700 --- /dev/null +++ b/poky/scripts/lib/build_perf/scrape-html-report.js @@ -0,0 +1,56 @@ +var fs = require('fs'); +var system = require('system'); +var page = require('webpage').create(); + +// Examine console log for message from chart drawing +page.onConsoleMessage = function(msg) { + console.log(msg); + if (msg === "ALL CHARTS READY") { + window.charts_ready = true; + } + else if (msg.slice(0, 11) === "CHART READY") { + var chart_id = msg.split(" ")[2]; + console.log('grabbing ' + chart_id); + var png_data = page.evaluate(function (chart_id) { + var chart_div = document.getElementById(chart_id + '_png'); + return chart_div.outerHTML; + }, chart_id); + fs.write(args[2] + '/' + chart_id + '.png', png_data, 'w'); + } +}; + +// Check command line arguments +var args = system.args; +if (args.length != 3) { + console.log("USAGE: " + args[0] + " REPORT_HTML OUT_DIR\n"); + phantom.exit(1); +} + +// Open the web page +page.open(args[1], function(status) { + if (status == 'fail') { + console.log("Failed to open file '" + args[1] + "'"); + phantom.exit(1); + } +}); + +// Check status every 100 ms +interval = window.setInterval(function () { + //console.log('waiting'); + if (window.charts_ready) { + clearTimeout(timer); + clearInterval(interval); + + var fname = args[1].replace(/\/+$/, "").split("/").pop() + console.log("saving " + fname); + fs.write(args[2] + '/' + fname, page.content, 'w'); + phantom.exit(0); + } +}, 100); + +// Time-out after 10 seconds +timer = window.setTimeout(function () { + clearInterval(interval); + console.log("ERROR: timeout"); + phantom.exit(1); +}, 10000); -- cgit v1.2.3