blob: ff3cb09a5d8d3b2077a9faa61e639f03bd253c0a [file] [log] [blame]
# 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'