| # Copyright 2015 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """API for the perf try job recipe module. |
| |
| This API is meant to enable the perf try job recipe on any chromium-supported |
| platform for any test that can be run via buildbot, perf or otherwise. |
| """ |
| |
| import re |
| import urllib |
| import uuid |
| |
| from recipe_engine import recipe_api |
| from . import build_state |
| |
| PERF_CONFIG_FILE = 'tools/run-perf-test.cfg' |
| WEBKIT_PERF_CONFIG_FILE = 'third_party/WebKit/Tools/run-perf-test.cfg' |
| |
| CLOUD_RESULTS_LINK = (r'\s(?P<VALUES>https://console.developers.google.com/' |
| 'm/cloudstorage/b/chromium-telemetry/o/html-results/results-[a-z0-9-_]+)\s') |
| PROFILER_RESULTS_LINK = (r'\s(?P<VALUES>https://console.developers.google.com/' |
| 'm/cloudstorage/b/[a-z-]+/o/profiler-[a-z0-9-_.]+)\s') |
| RESULTS_BANNER = """ |
| ===== PERF TRY JOB RESULTS ===== |
| |
| Test Command: %(command)s |
| Test Metric: %(metric)s |
| Relative Change: %(relative_change).05f%% |
| Standard Error: +- %(std_err).05f delta |
| |
| %(results)s |
| """ |
| |
| class PerfTryJobApi(recipe_api.RecipeApi): |
| |
| def __init__(self, *args, **kwargs): |
| super(PerfTryJobApi, self).__init__(*args, **kwargs) |
| self.is_internal = False |
| |
| def set_internal(self): |
| self.is_internal = True # pragma: no cover |
| |
| def start_perf_try_job(self, api, affected_files, bot_update_step, bot_db): |
| """Entry point pert tryjob or CQ tryjob.""" |
| perf_config = self._get_perf_config(api, affected_files) |
| if perf_config: |
| self._run_perf_job(perf_config, bot_update_step, bot_db) |
| else: |
| self.m.halt('Could not load config file. Double check your changes to ' |
| 'config files for syntax errors.') |
| |
| def _run_perf_job(self, perf_cfg, bot_update_step, bot_db): |
| """Runs performance try job with and without patch.""" |
| r = self._resolve_revisions_from_config(perf_cfg) |
| test_cfg = self.m.bisect_tester.load_config_from_dict(perf_cfg) |
| |
| # TODO(prasadv): This is tempory hack to prepend 'src' to test command, |
| # until dashboard and trybot scripts are changed. |
| _prepend_src_to_path_in_command(test_cfg) |
| |
| # Always set the upload bucket to public for perf try jobs on the public |
| # waterfall, and private on internal ones. |
| _modify_upload_bucket(test_cfg, not self.is_internal) |
| |
| # Run with patch. |
| with self.m.step.nest('Running WITH patch'): |
| results_label = 'Patch' |
| if r[0]: |
| results_label += '_%s' % r[0] |
| results_with_patch = self._build_and_run_tests( |
| test_cfg, bot_update_step, bot_db, r[0], |
| name='With Patch', |
| reset_on_first_run=True, |
| upload_on_last_run=True, |
| results_label=results_label, |
| allow_flakes=False) |
| |
| with self.m.step.nest('De-applying patch'): |
| if not any(r): |
| # Revert changes. |
| self.m.chromium_tests.deapply_patch(bot_update_step) |
| |
| # Run without patch. |
| results_label_without_patch = 'TOT' if r[1] is None else r[1] |
| with self.m.step.nest('Running WITHOUT patch'): |
| results_name = 'Without Patch' |
| results_without_patch = self._build_and_run_tests( |
| test_cfg, bot_update_step, bot_db, r[1], |
| name=results_name, |
| reset_on_first_run=False, |
| upload_on_last_run=True, |
| results_label=results_label_without_patch, |
| allow_flakes=False) |
| |
| labels = { |
| 'profiler_link1': ('%s - Profiler Data' % 'With Patch' |
| if r[0] is None else r[0]), |
| 'profiler_link2': ('%s - Profiler Data' % 'Without Patch' |
| if r[1] is None else r[1]) |
| } |
| |
| # TODO(chrisphan): Deprecate this. perf_dashboard.post_bisect_results below |
| # already outputs data in json format. |
| with self.m.step.nest('Results'): |
| self._compare_and_present_results( |
| test_cfg, results_without_patch, results_with_patch, labels, |
| results_label_without_patch) |
| |
| with self.m.step.nest('Notify dashboard'): |
| bisect_results = self.get_result( |
| test_cfg, results_without_patch, results_with_patch, labels) |
| self.m.perf_dashboard.set_default_config() |
| self.m.perf_dashboard.post_bisect_results( |
| bisect_results, halt_on_failure=True) |
| |
| def _checkout_revision(self, update_step, revision=None): |
| """Checkouts specific revisions and updates bot_update step.""" |
| if revision: |
| if self.m.platform.is_win: # pragma: no cover |
| self.m.chromium.taskkill() |
| self.m.gclient.c.revisions['src'] = str(revision) |
| update_step = self.m.bot_update.ensure_checkout( |
| suffix=str(revision), patch=False, update_presentation=False) |
| assert update_step.json.output['did_run'] |
| with self.m.context(cwd=self.m.path['checkout']): |
| self.m.chromium.runhooks(name='runhooks on %s' % str(revision)) |
| |
| return update_step |
| |
| def _run_test(self, cfg, **kwargs): |
| """Runs test from config and return results.""" |
| all_values = self.m.bisect_tester.run_test( |
| cfg, **kwargs) |
| overall_success = True |
| if (not kwargs.get('allow_flakes', True) and |
| cfg.get('test_type', 'perf') != 'return_code'): |
| overall_success = all(v == 0 for v in all_values['retcodes']) |
| return { |
| 'results': all_values, |
| 'ret_code': overall_success, |
| 'output': ''.join(all_values['output']) |
| } |
| |
| def _build_and_run_tests(self, cfg, update_step, bot_db, revision_hash, |
| **kwargs): |
| """Compiles binaries and runs tests for a given a revision.""" |
| with_patch = 'With Patch' in kwargs.get('name') # pragma: no cover |
| |
| # We don't need to do a checkout if there's a patch applied, since that will |
| # overwrite the local changes and potentially change the test results. |
| if not with_patch: # pragma: no cover |
| update_step = self._checkout_revision(update_step, revision_hash) |
| if not revision_hash: # pragma: no cover |
| if update_step.presentation.properties: |
| revision_hash = update_step.presentation.properties['got_revision'] |
| revision = build_state.BuildState(self, revision_hash, with_patch) |
| # request build and wait for it only when the build is nonexistent |
| if with_patch or not self._gsutil_file_exists(revision.build_file_path): |
| revision.request_build() |
| revision.wait_for() |
| revision.download_build(update_step, bot_db) |
| if self.m.chromium.c.TARGET_PLATFORM == 'android': |
| self.m.chromium_android.adb_install_apk('ChromePublic.apk') |
| |
| return self._run_test(cfg, **kwargs) |
| |
| def _gsutil_file_exists(self, path): |
| """Returns True if a file exists at the given GS path.""" |
| try: |
| self.m.gsutil(['ls', path], name='exists') |
| except self.m.step.StepFailure: # pragma: no cover |
| return False |
| return True # pragma: no cover |
| |
| def _load_config_file(self, name, src_path, **kwargs): |
| """Attempts to load the specified config file and grab config dict.""" |
| step_result = self.m.python( |
| name, |
| self.resource('load_config_to_json.py'), |
| ['--source', src_path, '--output_json', self.m.json.output()], |
| **kwargs) |
| if not step_result.json.output: # pragma: no cover |
| raise self.m.step.StepFailure('Loading config file failed. [%s]' % |
| src_path) |
| return step_result.json.output |
| |
| def _get_perf_config(self, api, affected_files): |
| """Checks affected config file and loads the config params to a dict.""" |
| perf_cfg_files = [PERF_CONFIG_FILE, WEBKIT_PERF_CONFIG_FILE] |
| cfg_file = [f for f in perf_cfg_files if str(f) in affected_files] |
| if cfg_file: # pragma: no cover |
| # Try reading any possible perf test config files. |
| cfg_content = self._load_config_file( |
| 'load config', self.m.path['checkout'].join(cfg_file[0])) |
| elif api.properties.get('perf_try_config'): # pragma: no cover |
| cfg_content = dict(api.m.properties.get('perf_try_config')) |
| else: |
| return None |
| |
| cfg_is_valid = _validate_perf_config( |
| cfg_content, required_parameters=['command']) |
| if cfg_content and cfg_is_valid: |
| return cfg_content |
| |
| return None |
| |
| def _get_hash(self, rev): |
| """Returns git hash for the given commit position.""" |
| def _check_if_hash(s): # pragma: no cover |
| if len(s) <= 8: |
| try: |
| int(s) |
| return False |
| except ValueError: |
| pass |
| elif not re.match(r'[a-fA-F0-9]{40}$', str(s)): |
| raise RuntimeError('Error, Unsupported revision %s' % s) |
| return True |
| |
| if _check_if_hash(rev): # pragma: no cover |
| return rev |
| |
| try: |
| result = self.m.commit_position.chromium_hash_from_commit_position(rev) |
| except self.m.step.StepFailure as sf: # pragma: no cover |
| self.m.halt(('Failed to resolve commit position %s- ' % rev) + sf.reason) |
| raise |
| return result |
| |
| def _resolve_revisions_from_config(self, config): |
| """Resolves commit position into git hash for good and bad revisions.""" |
| if 'good_revision' not in config and 'bad_revision' not in config: |
| return (None, None) |
| return (self._get_hash(config.get('bad_revision')), |
| self._get_hash(config.get('good_revision'))) |
| |
| def _compare_and_present_results( |
| self, cfg, results_without_patch, results_with_patch, labels, |
| results_label_without_patch): |
| """Parses results and creates Results step.""" |
| step_result = self.m.step.active_result |
| |
| output_with_patch = results_with_patch.get('output') |
| output_without_patch = results_without_patch.get('output') |
| values_with_patch, values_without_patch = self.parse_values( |
| results_with_patch.get('results'), |
| results_without_patch.get('results'), |
| cfg.get('metric'), |
| _output_format(cfg.get('command'))) |
| |
| cloud_links_without_patch = self.parse_cloud_links(output_without_patch) |
| cloud_links_with_patch = self.parse_cloud_links(output_with_patch) |
| |
| results_link = (cloud_links_without_patch['html'][0] |
| if cloud_links_without_patch['html'] else '') |
| |
| if results_link: |
| # Automatically compare the Patch column against the TOT column and set |
| # the summary statistic to percent delta average: |
| # Use URL fragment since cloudstorage loses query. |
| results_link += '#r=' + results_label_without_patch |
| results_link += '&s=%25' + unichr(916) + 'avg' |
| |
| step_result.presentation.links.update({'HTML Results': results_link}) |
| |
| profiler_with_patch = cloud_links_with_patch['profiler'] |
| profiler_without_patch = cloud_links_without_patch['profiler'] |
| |
| if profiler_with_patch and profiler_without_patch: |
| for i in xrange(len(profiler_with_patch)): # pragma: no cover |
| step_result.presentation.links.update({ |
| '%s[%d]' % ( |
| labels.get('profiler_link1'), i): profiler_with_patch[i] |
| }) |
| for i in xrange(len(profiler_without_patch)): # pragma: no cover |
| step_result.presentation.links.update({ |
| '%s[%d]' % ( |
| labels.get('profiler_link2'), i): profiler_without_patch[i] |
| }) |
| |
| if not values_with_patch or not values_without_patch: |
| return |
| |
| mean_with_patch = self.m.math_utils.mean(values_with_patch) |
| mean_without_patch = self.m.math_utils.mean(values_without_patch) |
| |
| # TODO(qyearsley): Change this to print either std. dev. and sample |
| # size if that makes sense, or remove this computation altogether if |
| # values_with_patch and values_without_patch are expected to always |
| # contain only one value. |
| stderr_with_patch = self.m.math_utils.standard_error(values_with_patch) |
| stderr_without_patch = self.m.math_utils.standard_error( |
| values_without_patch) |
| |
| # Calculate the % difference in the means of the 2 runs. |
| relative_change = None |
| std_err = None |
| if mean_with_patch and values_with_patch: |
| relative_change = self.m.math_utils.relative_change( |
| mean_without_patch, mean_with_patch) * 100 |
| std_err = self.m.math_utils.pooled_standard_error( |
| [values_with_patch, values_without_patch]) |
| |
| if relative_change is not None and std_err is not None: |
| data = [ |
| ['Revision', 'Mean', 'Std.Error'], |
| ['Patch', str(mean_with_patch), str(stderr_with_patch)], |
| ['No Patch', str(mean_without_patch), str(stderr_without_patch)] |
| ] |
| display_results = RESULTS_BANNER % { |
| 'command': cfg.get('command'), |
| 'metric': cfg.get('metric', 'NO SPECIFIED'), |
| 'relative_change': relative_change, |
| 'std_err': std_err, |
| 'results': _pretty_table(data), |
| } |
| step_result.presentation.step_text += ( |
| self.m.test_utils.format_step_text([[display_results]])) |
| |
| def parse_cloud_links(self, output): |
| html_results_pattern = re.compile(CLOUD_RESULTS_LINK, re.MULTILINE) |
| profiler_pattern = re.compile(PROFILER_RESULTS_LINK, re.MULTILINE) |
| |
| results = { |
| 'html': html_results_pattern.findall(output), |
| 'profiler': profiler_pattern.findall(output), |
| } |
| return results |
| |
| |
| def get_result(self, config, results_without_patch, results_with_patch, |
| labels): |
| """Returns the results as a dict.""" |
| output_with_patch = results_with_patch.get('output') |
| output_without_patch = results_without_patch.get('output') |
| |
| values_with_patch, values_without_patch = self.parse_values( |
| results_with_patch.get('results'), |
| results_without_patch.get('results'), |
| config.get('metric'), |
| _output_format(config.get('command'))) |
| |
| cloud_links_without_patch = self.parse_cloud_links(output_without_patch) |
| cloud_links_with_patch = self.parse_cloud_links(output_with_patch) |
| |
| cloud_link = (cloud_links_without_patch['html'][0] |
| if cloud_links_without_patch['html'] else '') |
| |
| results = { |
| 'try_job_id': config.get('try_job_id'), |
| 'status': 'completed', # TODO(chrisphan) Get partial results state. |
| 'buildbot_log_url': self._get_build_url(), |
| 'bisect_bot': self.m.properties.get('buildername', 'Not found'), |
| 'command': config.get('command'), |
| 'metric': config.get('metric'), |
| 'cloud_link': cloud_link, |
| } |
| |
| if not values_with_patch or not values_without_patch: |
| results['warnings'] = ['No values from test with patch, or none ' |
| 'from test without patch.\n Output with patch:\n%s\n\nOutput without ' |
| 'patch:\n%s' % (output_with_patch, output_without_patch)] |
| return results |
| |
| mean_with_patch = self.m.math_utils.mean(values_with_patch) |
| mean_without_patch = self.m.math_utils.mean(values_without_patch) |
| |
| stderr_with_patch = self.m.math_utils.standard_error(values_with_patch) |
| stderr_without_patch = self.m.math_utils.standard_error( |
| values_without_patch) |
| |
| profiler_with_patch = cloud_links_with_patch['profiler'] |
| profiler_without_patch = cloud_links_without_patch['profiler'] |
| |
| # Calculate the % difference in the means of the 2 runs. |
| relative_change = None |
| std_err = None |
| if mean_with_patch and values_with_patch: |
| relative_change = self.m.math_utils.relative_change( |
| mean_without_patch, mean_with_patch) * 100 |
| std_err = self.m.math_utils.pooled_standard_error( |
| [values_with_patch, values_without_patch]) |
| |
| if relative_change is not None and std_err is not None: |
| data = [ |
| ['Revision', 'Mean', 'Std.Error'], |
| ['Patch', str(mean_with_patch), str(stderr_with_patch)], |
| ['No Patch', str(mean_without_patch), str(stderr_without_patch)] |
| ] |
| results['change'] = relative_change |
| results['std_err'] = std_err |
| results['result'] = _pretty_table(data) |
| |
| profiler_links = [] |
| if profiler_with_patch and profiler_without_patch: |
| for i in xrange(len(profiler_with_patch)): # pragma: no cover |
| profiler_links.append({ |
| 'title': '%s[%d]' % (labels.get('profiler_link1'), i), |
| 'link': profiler_with_patch[i] |
| }) |
| for i in xrange(len(profiler_without_patch)): # pragma: no cover |
| profiler_links.append({ |
| 'title': '%s[%d]' % (labels.get('profiler_link2'), i), |
| 'link': profiler_without_patch[i] |
| }) |
| results['profiler_links'] = profiler_links |
| |
| return results |
| |
| def _get_build_url(self): |
| properties = self.m.properties |
| bot_url = properties.get('buildbotURL', |
| 'http://build.chromium.org/p/chromium/') |
| builder_name = urllib.quote(properties.get('buildername', '')) |
| builder_number = str(properties.get('buildnumber', '')) |
| return '%sbuilders/%s/builds/%s' % (bot_url, builder_name, builder_number) |
| |
| |
| def parse_values(self, results_a, results_b, metric, output_format, **kwargs): |
| """Parse the values for a given metric for the given results. |
| |
| This is meant to be used by tryjobs with a metric.""" |
| if not metric: |
| return None, None |
| |
| results_index = None |
| if output_format == 'buildbot': |
| results_index = 'stdout_paths' |
| elif output_format == 'chartjson': |
| results_index = 'chartjson_paths' |
| elif output_format == 'valueset': |
| results_index = 'valueset_paths' |
| else: # pragma: no cover |
| raise self.m.step.StepFailure('Unsupported format: ' + output_format) |
| |
| files_a = ','.join(map(str, results_a[results_index])) |
| files_b = ','.join(map(str, results_b[results_index])) |
| |
| # Apply str to files to constrain cmdline args to ascii, as this used to |
| # break when unicode things were passed instead. |
| args = [files_a, files_b, str(metric), '--' + output_format] |
| script = self.m.path['catapult'].join( |
| 'tracing', 'bin', 'compare_samples') |
| result = self.m.python( |
| 'Parse metric values', |
| script=script, |
| args=args, |
| stdout=self.m.json.output(), |
| step_test_data=lambda: self.m.json.test_api.output_stream( |
| {'sampleA':[1, 1, 1], 'sampleB':[9, 9, 9]}), |
| **kwargs).stdout |
| |
| sample_a = result.get('sampleA', []) |
| sample_b = result.get('sampleB', []) |
| return sample_a, sample_b |
| |
| |
| def _validate_perf_config(config_contents, required_parameters): |
| """Validates the perf config file contents. |
| |
| This is used when we're doing a perf try job, the config file is called |
| run-perf-test.cfg by default. |
| |
| The parameters checked are the required parameters; any additional optional |
| parameters won't be checked and validation will still pass. |
| |
| Args: |
| config_contents: A config dictionary. |
| required_parameters: List of parameter names to confirm in config. |
| |
| Returns: |
| True if valid. |
| """ |
| for parameter in required_parameters: |
| if not config_contents.get(parameter): |
| return False |
| value = config_contents[parameter] |
| if not value or not isinstance(value, basestring): # pragma: no cover |
| return False |
| |
| return True |
| |
| |
| def _pretty_table(data): |
| results = [] |
| for row in data: |
| results.append(('%-12s' * len(row) % tuple(row)).rstrip()) |
| return '\n'.join(results) |
| |
| |
| def _modify_upload_bucket(test_cfg, is_public): |
| bucket = 'public' if is_public else 'private' |
| new_arg = '--upload-bucket=' + bucket |
| command = test_cfg.get('command') |
| |
| if not '--upload-bucket' in command: |
| command = '%s %s' % (command, new_arg) |
| else: |
| out_dir_regex = re.compile( |
| r"--upload-bucket[= ](?P<path>([a-zA-Z]+))") |
| command = out_dir_regex.sub(new_arg, command) |
| test_cfg.update({'command': command}) |
| |
| |
| def _prepend_src_to_path_in_command(test_cfg): |
| command_to_run = [] |
| for v in test_cfg.get('command').split(): |
| if v in ['./tools/perf/run_benchmark', |
| 'tools/perf/run_benchmark', |
| 'tools\\perf\\run_benchmark']: |
| v = 'src/tools/perf/run_benchmark' |
| command_to_run.append(v) |
| test_cfg.update({'command': ' '.join(command_to_run)}) |
| |
| |
| def _output_format(command): |
| """Determine the output format for a given command.""" |
| if 'chartjson' in command: |
| return 'chartjson' |
| elif 'valueset' in command: |
| return 'valueset' |
| return 'buildbot' |