| #!/usr/bin/env python |
| """Performance test to compare the performance of buck between two revisions. |
| |
| The general algorithm is: |
| |
| Checkout <revisions_to_go_back - 1> |
| Warm up the cache: |
| Set .buckversion to old revision, build all targets |
| Set .buckversion to new revision, build all targets |
| |
| For each revision to test: |
| - Rename directory being tested |
| - Build all targets, check to ensure everything pulls from dir cache |
| - Check out revision to test |
| - Clean Build all targets <iterations_per_diff> times, only reading from |
| cache, not writing (except for the last one, write that time) |
| - Buck build all targets to verify no-op build works. |
| |
| """ |
| import argparse |
| import re |
| import subprocess |
| import os |
| import tempfile |
| import sys |
| |
| from collections import defaultdict |
| from datetime import datetime |
| |
| |
| def createArgParser(): |
| parser = argparse.ArgumentParser( |
| description='Run the buck performance test') |
| parser.add_argument( |
| '--perftest_id', |
| action='store', |
| type=str, |
| help='The identifier of this performance test') |
| parser.add_argument( |
| '--revisions_to_go_back', |
| action='store', |
| type=int, |
| help='The maximum number of revisions to go back when testing') |
| parser.add_argument( |
| '--iterations_per_diff', |
| action='store', |
| type=int, |
| help='The number of iterations to run on diff') |
| parser.add_argument( |
| '--targets_to_build', |
| action='append', |
| type=str, |
| help='The targets to build') |
| parser.add_argument( |
| '--repo_under_test', |
| action='store', |
| type=str, |
| help='Path to the repo under test') |
| parser.add_argument( |
| '--path_to_buck', |
| action='store', |
| type=str, |
| help='The path to the buck binary') |
| parser.add_argument( |
| '--old_buck_revision', |
| action='store', |
| type=str, |
| help='The original buck revision') |
| parser.add_argument( |
| '--new_buck_revision', |
| action='store', |
| type=str, |
| help='The new buck revision') |
| return parser |
| |
| |
| def log(message): |
| print '%s\t%s' % (str(datetime.now()), message) |
| sys.stdout.flush() |
| |
| |
| class BuildResult(): |
| def __init__(self, time_delta, cache_results, rule_key_map): |
| self.time_delta = time_delta |
| self.cache_results = cache_results |
| self.rule_key_map = rule_key_map |
| |
| |
| def git_clean(cwd): |
| log('Running git clean.') |
| subprocess.check_call( |
| ['git', 'clean', '--quiet', '-xfd'], |
| cwd=cwd) |
| |
| |
| def git_reset(cwd, revision): |
| subprocess.check_call( |
| ['git', 'reset', '--quiet', '--hard', revision], |
| cwd=cwd) |
| |
| |
| def buck_clean(args, cwd): |
| log('Running buck clean.') |
| subprocess.check_call( |
| [args.path_to_buck, 'clean'], |
| cwd=cwd) |
| |
| |
| def git_get_revisions(args): |
| return list(reversed(subprocess.check_output( |
| ['git', 'log', '--pretty=format:%H', 'HEAD', '-n', |
| str(args.revisions_to_go_back + 1)], |
| cwd=args.repo_under_test).splitlines())) |
| |
| |
| def git_checkout(revision, cwd): |
| log('Checking out %s.' % revision) |
| git_reset(cwd, 'HEAD') |
| subprocess.check_call( |
| ['git', 'checkout', '--quiet', revision], |
| cwd=cwd) |
| |
| |
| BUILD_RESULT_LOG_LINE = re.compile( |
| r'BuildRuleFinished\((?P<rule_name>[\w_\-:#\/]+)\): (?P<result>[A-Z_]+) ' |
| r'(?P<cache_result>[A-Z_]+) (?P<success_type>[A-Z_]+) ' |
| r'(?P<rule_key>[0-9a-f]*)') |
| |
| |
| RULEKEY_LINE = re.compile( |
| r'^INFO: RuleKey (?P<rule_key>[0-9a-f]*)=' |
| r'(?P<rule_key_debug>.*)$') |
| |
| |
| BUCK_LOG_RULEKEY_LINE = re.compile( |
| r'.*\[\w+\](?:\[command:[0-9a-f-]+\])?\[tid:\d+\]' |
| r'\[com.facebook.buck.rules.RuleKey[\$\.]Builder\] ' |
| r'RuleKey (?P<rule_key>[0-9a-f]+)=' |
| r'(?P<rule_key_debug>.*)$') |
| |
| |
| def buck_build_target(args, cwd, target, perftest_side, log_as_perftest=True): |
| """Builds a target with buck and returns performance information. |
| """ |
| log('Running buck build %s.' % target) |
| bucklogging_properties_path = os.path.join( |
| cwd, '.bucklogging.local.properties') |
| with open(bucklogging_properties_path, 'w') as bucklogging_properties: |
| # The default configuration has the root logger and FileHandler |
| # discard anything below FINE level. |
| # |
| # We need RuleKey logging, which uses FINER (verbose), so the |
| # root logger and file handler both need to be reconfigured |
| # to enable verbose logging. |
| bucklogging_properties.write( |
| '''.level=FINER |
| java.util.logging.FileHandler.level=FINER''') |
| env = os.environ.copy() |
| # Force buck to pretend it's repo is clean. |
| env.update({ |
| 'BUCK_REPOSITORY_DIRTY': '0' |
| }) |
| if log_as_perftest: |
| env.update({ |
| 'BUCK_EXTRA_JAVA_ARGS': |
| '-Dbuck.perftest_id=%s, -Dbuck.perftest_side=%s' % ( |
| args.perftest_id, perftest_side) |
| }) |
| start = datetime.now() |
| with tempfile.TemporaryFile() as tmpFile: |
| try: |
| subprocess.check_call( |
| [args.path_to_buck, 'build', target, '-v', '5'], |
| stdout=tmpFile, |
| stderr=tmpFile, |
| cwd=cwd, |
| env=env) |
| except: |
| tmpFile.seek(0) |
| log('Buck build failed: %s' % tmpFile.read()) |
| raise |
| tmpFile.seek(0) |
| build_output = tmpFile.read() |
| finish = datetime.now() |
| |
| java_utils_log_path = os.path.join( |
| cwd, |
| 'buck-out', 'log', 'buck-0.log') |
| if os.path.exists(java_utils_log_path): |
| pattern = BUCK_LOG_RULEKEY_LINE |
| with open(java_utils_log_path) as f: |
| build_output = f.read() |
| else: |
| pattern = RULEKEY_LINE |
| |
| rule_debug_map = {} |
| for line in build_output.splitlines(): |
| match = pattern.match(line) |
| if match: |
| rule_debug_map[match.group('rule_key')] = match.group( |
| 'rule_key_debug') |
| |
| logfile_path = os.path.join( |
| cwd, |
| 'buck-out', 'bin', 'build.log') |
| cache_results = defaultdict(list) |
| rule_key_map = {} |
| with open(logfile_path, 'r') as logfile: |
| for line in logfile.readlines(): |
| line = line.strip() |
| match = BUILD_RESULT_LOG_LINE.search(line) |
| if match: |
| rule_name = match.group('rule_name') |
| rule_key = match.group('rule_key') |
| if not rule_key in rule_debug_map: |
| raise Exception('''ERROR: build.log contains an entry |
| which was not found in buck build -v 5 output. |
| Rule: {}, rule key: {}'''.format(rule_name, rule_key)) |
| cache_results[match.group('cache_result')].append({ |
| 'rule_name': rule_name, |
| 'rule_key': rule_key, |
| 'rule_key_debug': rule_debug_map[rule_key] |
| }) |
| rule_key_map[match.group('rule_name')] = rule_debug_map[ |
| match.group('rule_key')] |
| |
| result = BuildResult(finish - start, cache_results, rule_key_map) |
| cache_counts = {} |
| for key, value in result.cache_results.iteritems(): |
| cache_counts[key] = len(value) |
| log('Test Build Finished! Elapsed Seconds: %d, Cache Counts: %s' % ( |
| result.time_delta.total_seconds(), repr(cache_counts))) |
| return result |
| |
| |
| def set_perftest_side( |
| args, |
| cwd, |
| perftest_side, |
| cache_mode, |
| dir_cache_only=True): |
| log('Reconfiguring to test %s version of buck.' % perftest_side) |
| buckconfig_path = os.path.join(cwd, '.buckconfig.local') |
| with open(buckconfig_path, 'w') as buckconfig: |
| buckconfig.write('''[cache] |
| %s |
| dir = buck-cache-%s |
| dir_mode = %s |
| ''' % ('mode = dir' if dir_cache_only else '', perftest_side, cache_mode)) |
| buckconfig.truncate() |
| buckversion_path = os.path.join(cwd, '.buckversion') |
| with open(buckversion_path, 'w') as buckversion: |
| if perftest_side == 'old': |
| buckversion.write(args.old_buck_revision + os.linesep) |
| else: |
| buckversion.write(args.new_buck_revision + os.linesep) |
| buckversion.truncate() |
| |
| def build_all_targets( |
| args, |
| cwd, |
| perftest_side, |
| cache_mode, |
| run_clean=True, |
| dir_cache_only=True, |
| log_as_perftest=True): |
| set_perftest_side( |
| args, |
| cwd, |
| perftest_side, |
| cache_mode, |
| dir_cache_only=dir_cache_only) |
| result = {} |
| for target in args.targets_to_build: |
| if run_clean: |
| buck_clean(args, cwd) |
| #TODO(royw): Do smart things with the results here. |
| result[target] = buck_build_target( |
| args, |
| cwd, |
| target, |
| perftest_side, |
| log_as_perftest=log_as_perftest) |
| return result |
| |
| |
| def run_tests_for_diff(args, revisions_to_test, test_index, last_results): |
| log('=== Running tests at revision %s ===' % revisions_to_test[test_index]) |
| new_directory_name = (os.path.basename(args.repo_under_test) + |
| '_test_iteration_%d' % test_index) |
| |
| # Rename the directory to flesh out any cache problems. |
| cwd = os.path.join(os.path.dirname(args.repo_under_test), |
| new_directory_name) |
| log('Renaming %s to %s' % (args.repo_under_test, cwd)) |
| os.rename(args.repo_under_test, cwd) |
| |
| try: |
| log('== Checking new revision for problems with absolute paths ==') |
| results = build_all_targets(args, cwd, 'new', 'readonly') |
| for target, result in results.iteritems(): |
| if (len(result.cache_results.keys()) != 1 or |
| 'DIR_HIT' not in result.cache_results): |
| # Remove DIR_HITs to make error message cleaner |
| result.cache_results.pop('DIR_HIT', None) |
| log('Building %s at revision %s with the new buck version ' |
| 'was unable to reuse the cache from a previous run. ' |
| 'This suggests one of the rule keys contains an ' |
| 'abosolute path.' % ( |
| target, |
| revisions_to_test[test_index - 1])) |
| for rule in result.cache_results['MISS']: |
| rule_name = rule['rule_name'] |
| old_key = last_results[target].rule_key_map[rule_name] |
| log('Rule %s missed.' % rule_name) |
| log('\tOld Rule Key: %s.' % old_key) |
| log('\tNew Rule Key: %s.' % result.rule_key_map[rule_name]) |
| raise Exception('Failed to reuse cache across directories!!!') |
| |
| git_checkout(revisions_to_test[test_index], cwd) |
| |
| for attempt in xrange(args.iterations_per_diff): |
| cache_mode = 'readonly' |
| if attempt == args.iterations_per_diff - 1: |
| cache_mode = 'readwrite' |
| |
| build_all_targets(args, cwd, 'old', cache_mode) |
| build_all_targets(args, cwd, 'new', cache_mode) |
| |
| log('== Checking new revision to ensure noop build does nothing. ==') |
| results = build_all_targets( |
| args, |
| cwd, |
| 'new', |
| cache_mode, |
| run_clean=False) |
| for target, result in results.iteritems(): |
| if (len(result.cache_results.keys()) != 1 or |
| 'LOCAL_KEY_UNCHANGED_HIT' not in result.cache_results): |
| result.cache_results.pop('DIR_HIT', None) |
| raise Exception( |
| 'Doing a noop build for %s at revision %s with the new ' |
| 'buck version did not hit all of it\'s keys.\nMissed ' |
| 'Rules: %s' % ( |
| target, |
| revisions_to_test[test_index - 1], |
| repr(result.cache_results))) |
| |
| finally: |
| log('Renaming %s to %s' % (cwd, args.repo_under_test)) |
| os.rename(cwd, args.repo_under_test) |
| |
| return results |
| |
| |
| def main(): |
| args = createArgParser().parse_args() |
| log('Running Performance Test!') |
| git_clean(args.repo_under_test) |
| revisions_to_test = git_get_revisions(args) |
| # Checkout the revision previous to the test and warm up the local dir |
| # cache. |
| # git_clean(args.repo_under_test) |
| log('=== Warming up cache ===') |
| git_checkout(revisions_to_test[0], args.repo_under_test) |
| build_all_targets( |
| args, |
| args.repo_under_test, |
| 'old', |
| 'readwrite', |
| dir_cache_only=False, |
| log_as_perftest=False) |
| results_for_new = build_all_targets( |
| args, |
| args.repo_under_test, |
| 'new', |
| 'readwrite', |
| dir_cache_only=False, |
| log_as_perftest=False) |
| log('=== Cache Warm! Running tests ===') |
| for i in xrange(1, args.revisions_to_go_back): |
| results_for_new = run_tests_for_diff( |
| args, |
| revisions_to_test, |
| i, |
| results_for_new) |
| |
| |
| if __name__ == '__main__': |
| main() |