| #!/usr/bin/env python2 |
| # Copyright (c) 2012 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Script to compare the performance of two different chromeOS builds. |
| |
| This script is meant to be used when the performance impact of a change in |
| chromeOS needs to be analyzed. It requires that you have already created two |
| chromeOS test images (one with the change, and one without), and that you have |
| at least one device available on which to run performance tests. |
| |
| This script is actually a light-weight wrapper around crosperf, a tool for |
| automatically imaging one or more chromeOS devices with particular builds, |
| running a set of tests on those builds, and then notifying the user of test |
| results (along with some statistical analysis of perf keyvals). This wrapper |
| script performs the following tasks: |
| |
| 1) Creates a crosperf "experiment" file to be consumed by crosperf. |
| 2) Invokes crosperf using the created experiment file. Crosperf produces 2 |
| outputs: an e-mail that is sent to the user who invoked it; and an output |
| folder that is named based on the given --experiment-name, which is created in |
| the directory in which this script was run. |
| 3) Parses the results of crosperf and outputs a summary of relevant data. This |
| script produces output in a CSV file, as well as in stdout. |
| |
| Before running this script for the first time, you should set up your system to |
| run sudo without prompting for a password (otherwise, crosperf prompts for a |
| sudo password). You should only have to do that once per host machine. |
| |
| Once you're set up with passwordless sudo, you can run the script (preferably |
| from an empty directory, since several output files are produced): |
| |
| > python perf_compare.py --crosperf=CROSPERF_EXE --image-1=IMAGE_1 \ |
| --image-2=IMAGE_2 --board-1=BOARD_1 --board-2=BOARD_2 --remote-1=REMOTE_1 \ |
| --remote-2=REMOTE_2 |
| |
| You'll need to specify the following inputs: the full path to the crosperf |
| executable; the absolute paths to 2 locally-built chromeOS images (which must |
| reside in the "typical location" relative to the chroot, as required by |
| crosperf); the name of the boards associated with the 2 images (if both images |
| have the same board, you can specify that single board with --board=BOARD); and |
| the IP addresses of the 2 remote devices on which to run crosperf (if you have |
| only a single device available, specify it with --remote=REMOTE). Run with -h to |
| see the full set of accepted command-line arguments. |
| |
| Notes: |
| |
| 1) When you run this script, it will delete any previously-created crosperf |
| output directories and created CSV files based on the specified |
| --experiment-name. If you don't want to lose any old crosperf/CSV data, either |
| move it to another location, or run this script with a different |
| --experiment-name. |
| 2) This script will only run the benchmarks and process the perf keys specified |
| in the file "perf_benchmarks.json". Some benchmarks output more perf keys than |
| what are specified in perf_benchmarks.json, and these will appear in the |
| crosperf outputs, but not in the outputs produced specifically by this script. |
| """ |
| |
| |
| import json |
| import logging |
| import math |
| import optparse |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| |
| |
| _ITERATIONS = 5 |
| _IMAGE_1_NAME = 'Image1' |
| _IMAGE_2_NAME = 'Image2' |
| _DEFAULT_EXPERIMENT_NAME = 'perf_comparison' |
| _ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) |
| _BENCHMARK_INFO_FILE_NAME = os.path.join(_ROOT_DIR, 'perf_benchmarks.json') |
| _CROSPERF_REPORT_LINE_DELIMITER = '\t' |
| _EXPERIMENT_FILE_NAME = 'experiment.txt' |
| |
| _BENCHMARK_INFO_TEMPLATE = """ |
| benchmark: {benchmark} {{ |
| autotest_name: {autotest_name} |
| autotest_args: --use_emerged {autotest_args} |
| iterations: {iterations} |
| }} |
| """ |
| |
| _IMAGE_INFO_TEMPLATE = """ |
| label: {label} {{ |
| chromeos_image: {image} |
| board: {board} |
| remote: {remote} |
| }} |
| """ |
| |
| |
| def prompt_for_input(prompt_message): |
| """Prompts for user input and returns the inputted text as a string.""" |
| return raw_input('%s:> ' % prompt_message) |
| |
| |
| def identify_benchmarks_to_run(benchmark_info, iteration_nums, perf_keys): |
| """Identifies which benchmarks to run, and for how many iterations. |
| |
| @param benchmark_info: A list of dictionaries containing information about |
| the complete set of default perf benchmarks to run. |
| @param iteration_nums: See output_benchmarks_info(). |
| @param perf_keys: See output_benchmarks_info(). |
| |
| @return A tuple (X, Y), where X is a list of dictionaries containing |
| information about the set of benchmarks to run, and Y is the set of |
| perf keys requested to be run. |
| """ |
| perf_keys_requested = set() |
| benchmarks_to_run = [] |
| if not perf_keys: |
| # Run every benchmark for the specified number of iterations. |
| benchmarks_to_run = benchmark_info |
| for benchmark in benchmarks_to_run: |
| benchmark['iterations'] = iteration_nums[0] |
| for perf_key in benchmark['perf_keys']: |
| perf_keys_requested.add(perf_key) |
| else: |
| # Identify which benchmarks to run, and for how many iterations. |
| identified_benchmarks = {} |
| for i, perf_key in enumerate(perf_keys): |
| perf_keys_requested.add(perf_key) |
| benchmarks = [benchmark for benchmark in benchmark_info |
| if perf_key in benchmark['perf_keys']] |
| if not benchmarks: |
| logging.error('Perf key "%s" isn\'t associated with a known ' |
| 'benchmark.', perf_key) |
| sys.exit(1) |
| elif len(benchmarks) > 1: |
| logging.error('Perf key "%s" is associated with more than one ' |
| 'benchmark, but should be unique.', perf_key) |
| sys.exit(1) |
| benchmark_to_add = benchmarks[0] |
| benchmark_to_add = identified_benchmarks.setdefault( |
| benchmark_to_add['benchmark'], benchmark_to_add) |
| if len(iteration_nums) == 1: |
| # If only a single iteration number is specified, we assume |
| # that applies to every benchmark. |
| benchmark_to_add['iterations'] = iteration_nums[0] |
| else: |
| # The user must have specified a separate iteration number for |
| # each perf key. If the benchmark associated with the current |
| # perf key already has an interation number associated with it, |
| # choose the maximum of the two. |
| iter_num = iteration_nums[i] |
| if 'iterations' in benchmark_to_add: |
| benchmark_to_add['iterations'] = ( |
| iter_num if iter_num > benchmark_to_add['iterations'] |
| else benchmark_to_add['iterations']) |
| else: |
| benchmark_to_add['iterations'] = iter_num |
| benchmarks_to_run = identified_benchmarks.values() |
| |
| return benchmarks_to_run, perf_keys_requested |
| |
| |
| def output_benchmarks_info(f, iteration_nums, perf_keys): |
| """Identifies details of benchmarks to run, and writes that info to a file. |
| |
| @param f: A file object that is writeable. |
| @param iteration_nums: A list of one or more integers representing the |
| number of iterations to run for one or more benchmarks. |
| @param perf_keys: A list of one or more string perf keys we need to |
| run, or None if we should use the complete set of default perf keys. |
| |
| @return Set of perf keys actually requested to be run in the output file. |
| """ |
| benchmark_info = [] |
| with open(_BENCHMARK_INFO_FILE_NAME, 'r') as f_bench: |
| benchmark_info = json.load(f_bench) |
| |
| benchmarks_to_run, perf_keys_requested = identify_benchmarks_to_run( |
| benchmark_info, iteration_nums, perf_keys) |
| |
| for benchmark in benchmarks_to_run: |
| f.write(_BENCHMARK_INFO_TEMPLATE.format( |
| benchmark=benchmark['benchmark'], |
| autotest_name=benchmark['autotest_name'], |
| autotest_args=benchmark.get('autotest_args', ''), |
| iterations=benchmark['iterations'])) |
| |
| return perf_keys_requested |
| |
| |
| def output_image_info(f, label, image, board, remote): |
| """Writes information about a given image to an output file. |
| |
| @param f: A file object that is writeable. |
| @param label: A string label for the given image. |
| @param image: The string path to the image on disk. |
| @param board: The string board associated with the image. |
| @param remote: The string IP address on which to install the image. |
| """ |
| f.write(_IMAGE_INFO_TEMPLATE.format( |
| label=label, image=image, board=board, remote=remote)) |
| |
| |
| def invoke_crosperf(crosperf_exe, result_dir, experiment_name, board_1, board_2, |
| remote_1, remote_2, iteration_nums, perf_keys, image_1, |
| image_2, image_1_name, image_2_name): |
| """Invokes crosperf with a set of benchmarks and waits for it to complete. |
| |
| @param crosperf_exe: The string path to a crosperf executable. |
| @param result_dir: The string name of the directory in which crosperf is |
| expected to write its output. |
| @param experiment_name: A string name to give the crosperf invocation. |
| @param board_1: The string board associated with the first image. |
| @param board_2: The string board associated with the second image. |
| @param remote_1: The string IP address/name of the first remote device. |
| @param remote_2: The string IP address/name of the second remote device. |
| @param iteration_nums: A list of integers representing the number of |
| iterations to run for the different benchmarks. |
| @param perf_keys: A list of perf keys to run, or None to run the full set |
| of default perf benchmarks. |
| @param image_1: The string path to the first image. |
| @param image_2: The string path to the second image. |
| @param image_1_name: A string label to give the first image. |
| @param image_2_name: A string label to give the second image. |
| |
| @return A tuple (X, Y), where X is the path to the created crosperf report |
| file, and Y is the set of perf keys actually requested to be run. |
| """ |
| # Create experiment file for crosperf. |
| with open(_EXPERIMENT_FILE_NAME, 'w') as f: |
| f.write('name: {name}\n'.format(name=experiment_name)) |
| perf_keys_requested = output_benchmarks_info( |
| f, iteration_nums, perf_keys) |
| output_image_info(f, image_1_name, image_1, board_1, remote_1) |
| output_image_info(f, image_2_name, image_2, board_2, remote_2) |
| |
| # Invoke crosperf with the experiment file. |
| logging.info('Invoking crosperf with created experiment file...') |
| p = subprocess.Popen([crosperf_exe, _EXPERIMENT_FILE_NAME], |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| |
| # Pass through crosperf output as debug messages until crosperf run is |
| # complete. |
| while True: |
| next_line = p.stdout.readline().strip() |
| if not next_line and p.poll() != None: |
| break |
| logging.debug(next_line) |
| sys.stdout.flush() |
| p.communicate() |
| exit_code = p.returncode |
| |
| if exit_code: |
| logging.error('Crosperf returned exit code %s', exit_code) |
| sys.exit(1) |
| |
| report_file = os.path.join(result_dir, 'results.html') |
| if not os.path.exists(report_file): |
| logging.error('Crosperf report file missing, cannot proceed.') |
| sys.exit(1) |
| |
| logging.info('Crosperf run complete.') |
| logging.info('Crosperf results available in "%s"', result_dir) |
| return report_file, perf_keys_requested |
| |
| |
| def parse_crosperf_report_file(report_file, perf_keys_requested): |
| """Reads in and parses a crosperf report file for relevant perf data. |
| |
| @param report_file: See generate_results(). |
| @param perf_keys_requested: See generate_results(). |
| |
| @return A dictionary containing perf information extracted from the crosperf |
| report file. |
| """ |
| results = {} |
| with open(report_file, 'r') as f: |
| contents = f.read() |
| |
| match = re.search(r'summary-tsv.+?/pre', contents, flags=re.DOTALL) |
| contents = match.group(0) |
| |
| curr_benchmark = None |
| for line in contents.splitlines(): |
| delimiter = r'\s+?' |
| match = re.search( |
| r'Benchmark:%s(?P<benchmark>\w+?);%sIterations:%s' |
| '(?P<iterations>\w+?)\s' % (delimiter, delimiter, delimiter), |
| line) |
| if match: |
| curr_benchmark = match.group('benchmark') |
| iterations = match.group('iterations') |
| results[curr_benchmark] = {'iterations': iterations, |
| 'p_values': []} |
| continue |
| split = line.strip().split(_CROSPERF_REPORT_LINE_DELIMITER) |
| if (len(split) == 12 and split[-2] == '--' and |
| split[0] not in ['retval', 'iterations'] and |
| split[0] in perf_keys_requested): |
| results[curr_benchmark]['p_values'].append( |
| (split[0], split[-1])) |
| |
| return results |
| |
| |
| def generate_results(report_file, result_file, perf_keys_requested): |
| """Output relevant crosperf results to a CSV file, and to stdout. |
| |
| This code parses the "results.html" output file of crosperf. It then creates |
| a CSV file that has the following format per line: |
| |
| benchmark_name,num_iterations,perf_key,p_value[,perf_key,p_value] |
| |
| @param report_file: The string name of the report file created by crosperf. |
| @param result_file: A string name for the CSV file to output. |
| @param perf_keys_requested: The set of perf keys originally requested to be |
| run. |
| """ |
| results = parse_crosperf_report_file(report_file, perf_keys_requested) |
| |
| # Output p-value data to a CSV file. |
| with open(result_file, 'w') as f: |
| for bench in results: |
| perf_key_substring = ','.join( |
| ['%s,%s' % (x[0], x[1]) for x in results[bench]['p_values']]) |
| f.write('%s,%s,%s\n' % ( |
| bench, results[bench]['iterations'], perf_key_substring)) |
| |
| logging.info('P-value results available in "%s"', result_file) |
| |
| # Collect and output some additional summary results to stdout. |
| small_p_value = [] |
| nan_p_value = [] |
| perf_keys_obtained = set() |
| for benchmark in results: |
| p_values = results[benchmark]['p_values'] |
| for key, p_val in p_values: |
| perf_keys_obtained.add(key) |
| if float(p_val) <= 0.05: |
| small_p_value.append((benchmark, key, p_val)) |
| elif math.isnan(float(p_val)): |
| nan_p_value.append((benchmark, key, p_val)) |
| |
| if small_p_value: |
| logging.info('The following perf keys showed statistically significant ' |
| 'result differences (p-value <= 0.05):') |
| for item in small_p_value: |
| logging.info('* [%s] %s (p-value %s)', item[0], item[1], item[2]) |
| else: |
| logging.info('No perf keys showed statistically significant result ' |
| 'differences (p-value <= 0.05)') |
| |
| if nan_p_value: |
| logging.info('The following perf keys had "NaN" p-values:') |
| for item in nan_p_value: |
| logging.info('* [%s] %s (p-value %s)', item[0], item[1], item[2]) |
| |
| # Check if any perf keys are missing from what was requested, and notify |
| # the user if so. |
| for key_requested in perf_keys_requested: |
| if key_requested not in perf_keys_obtained: |
| logging.warning('Could not find results for requested perf key ' |
| '"%s".', key_requested) |
| |
| |
| def parse_options(): |
| """Parses command-line arguments.""" |
| parser = optparse.OptionParser() |
| |
| parser.add_option('--crosperf', metavar='PATH', type='string', default=None, |
| help='Absolute path to the crosperf executable ' |
| '(required).') |
| parser.add_option('--image-1', metavar='PATH', type='string', default=None, |
| help='Absolute path to the first image .bin file ' |
| '(required).') |
| parser.add_option('--image-2', metavar='PATH', type='string', default=None, |
| help='Absolute path to the second image .bin file ' |
| '(required).') |
| |
| board_group = optparse.OptionGroup( |
| parser, 'Specifying the boards (required)') |
| board_group.add_option('--board', metavar='BOARD', type='string', |
| default=None, |
| help='Name of the board associated with the images, ' |
| 'if both images have the same board. If each ' |
| 'image has a different board, use ' |
| 'options --board-1 and --board-2 instead.') |
| board_group.add_option('--board-1', metavar='BOARD', type='string', |
| default=None, |
| help='Board associated with the first image.') |
| board_group.add_option('--board-2', metavar='BOARD', type='string', |
| default=None, |
| help='Board associated with the second image.') |
| parser.add_option_group(board_group) |
| |
| remote_group = optparse.OptionGroup( |
| parser, 'Specifying the remote devices (required)') |
| remote_group.add_option('--remote', metavar='IP', type='string', |
| default=None, |
| help='IP address/name of remote device to use, if ' |
| 'only one physical device is to be used. If ' |
| 'using two devices, use options --remote-1 ' |
| 'and --remote-2 instead.') |
| remote_group.add_option('--remote-1', metavar='IP', type='string', |
| default=None, |
| help='IP address/name of first device to use.') |
| remote_group.add_option('--remote-2', metavar='IP', type='string', |
| default=None, |
| help='IP address/name of second device to use.') |
| parser.add_option_group(remote_group) |
| |
| optional_group = optparse.OptionGroup(parser, 'Optional settings') |
| optional_group.add_option('--image-1-name', metavar='NAME', type='string', |
| default=_IMAGE_1_NAME, |
| help='Descriptive name for the first image. ' |
| 'Defaults to "%default".') |
| optional_group.add_option('--image-2-name', metavar='NAME', type='string', |
| default=_IMAGE_2_NAME, |
| help='Descriptive name for the second image. ' |
| 'Defaults to "%default".') |
| optional_group.add_option('--experiment-name', metavar='NAME', |
| type='string', default=_DEFAULT_EXPERIMENT_NAME, |
| help='A descriptive name for the performance ' |
| 'comparison experiment to run. Defaults to ' |
| '"%default".') |
| optional_group.add_option('--perf-keys', metavar='KEY1[,KEY2...]', |
| type='string', default=None, |
| help='Comma-separated list of perf keys to ' |
| 'evaluate, if you do not want to run the ' |
| 'complete set. By default, will evaluate ' |
| 'with the complete set of perf keys.') |
| optional_group.add_option('--iterations', metavar='N1[,N2...]', |
| type='string', default=str(_ITERATIONS), |
| help='Number of iterations to use to evaluate ' |
| 'each perf key (defaults to %default). If ' |
| 'specifying a custom list of perf keys ' |
| '(with --perf-keys) and you want to have a ' |
| 'different number of iterations for each ' |
| 'perf key, specify a comma-separated list ' |
| 'of iteration numbers where N1 corresponds ' |
| 'to KEY1, N2 corresponds to KEY2, etc.') |
| optional_group.add_option('-v', '--verbose', action='store_true', |
| default=False, help='Use verbose logging.') |
| parser.add_option_group(optional_group) |
| |
| options, _ = parser.parse_args() |
| return options |
| |
| |
| def verify_command_line_options(options, iteration_nums, perf_keys): |
| """Verifies there are no errors in the specified command-line options. |
| |
| @param options: An optparse.Options object. |
| @param iteration_nums: An array of numbers representing the number of |
| iterations to perform to evaluate each perf key. |
| @param perf_keys: A list of strings representing perf keys to evaluate, or |
| None if no particular perf keys are specified. |
| |
| @return True, if there were no errors in the command-line options, or |
| False if any error was detected. |
| """ |
| success = True |
| if not options.crosperf: |
| logging.error('You must specify the path to a crosperf executable.') |
| success = False |
| if options.crosperf and not os.path.isfile(options.crosperf): |
| logging.error('Could not locate crosperf executable "%s".', |
| options.crosperf) |
| if options.crosperf.startswith('/google'): |
| logging.error('Did you remember to run prodaccess?') |
| success = False |
| if not options.image_1 or not options.image_2: |
| logging.error('You must specify the paths for 2 image .bin files.') |
| success = False |
| if not options.board and (not options.board_1 or not options.board_2): |
| logging.error('You must specify the board name(s): either a single ' |
| 'board with --board, or else two board names with ' |
| '--board-1 and --board-2.') |
| success = False |
| if options.board and options.board_1 and options.board_2: |
| logging.error('Specify either one board with --board, or two boards ' |
| 'with --board-1 and --board-2, but not both.') |
| success = False |
| if not options.remote and (not options.remote_1 or not options.remote_2): |
| logging.error('You must specify the remote device(s) to use: either a ' |
| 'single device with --remote, or else two devices with ' |
| '--remote-1 and --remote-2.') |
| success = False |
| if options.remote and options.remote_1 and options.remote_2: |
| logging.error('Specify either one remote device with --remote, or two ' |
| 'devices with --remote-1 and --remote-2, but not both.') |
| success = False |
| if len(iteration_nums) > 1 and not perf_keys: |
| logging.error('You should only specify multiple iteration numbers ' |
| 'if you\'re specifying a custom list of perf keys to ' |
| 'evaluate.') |
| success = False |
| if (options.perf_keys and len(iteration_nums) > 1 and |
| len(options.perf_keys.split(',')) > len(iteration_nums)): |
| logging.error('You specified %d custom perf keys, but only %d ' |
| 'iteration numbers.', len(options.perf_keys.split(',')), |
| len(iteration_nums)) |
| success = False |
| return success |
| |
| |
| def main(): |
| options = parse_options() |
| |
| log_level = logging.DEBUG if options.verbose else logging.INFO |
| logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', |
| level=log_level) |
| |
| iteration_nums = [int(i) for i in options.iterations.split(',')] |
| perf_keys = options.perf_keys.split(',') if options.perf_keys else None |
| |
| # Verify there are no errors in the specified command-line options. |
| if not verify_command_line_options(options, iteration_nums, perf_keys): |
| return 1 |
| |
| # Clean up any old results that will be overwritten. |
| result_dir = options.experiment_name + '_results' |
| if os.path.isdir(result_dir): |
| shutil.rmtree(result_dir) |
| result_file = options.experiment_name + '_results.csv' |
| if os.path.isfile(result_file): |
| os.remove(result_file) |
| |
| if options.remote: |
| remote_1, remote_2 = options.remote, options.remote |
| else: |
| remote_1, remote_2 = options.remote_1, options.remote_2 |
| |
| if options.board: |
| board_1, board_2 = options.board, options.board |
| else: |
| board_1, board_2 = options.board_1, options.board_2 |
| |
| report_file, perf_keys_requested = invoke_crosperf( |
| options.crosperf, result_dir, options.experiment_name, board_1, board_2, |
| remote_1, remote_2, iteration_nums, perf_keys, options.image_1, |
| options.image_2, options.image_1_name, options.image_2_name) |
| generate_results(report_file, result_file, perf_keys_requested) |
| |
| return 0 |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |