diff options
Diffstat (limited to 'afdo_tools')
-rwxr-xr-x | afdo_tools/bisection/afdo_prof_analysis.py | 784 | ||||
-rwxr-xr-x | afdo_tools/bisection/afdo_prof_analysis_e2e_test.py | 505 | ||||
-rwxr-xr-x | afdo_tools/bisection/afdo_prof_analysis_test.py | 290 | ||||
-rwxr-xr-x | afdo_tools/bisection/state_assumption_external.sh | 2 | ||||
-rwxr-xr-x | afdo_tools/bisection/state_assumption_interrupt.sh | 2 | ||||
-rwxr-xr-x | afdo_tools/generate_afdo_from_tryjob.py | 266 | ||||
-rwxr-xr-x | afdo_tools/run_afdo_tryjob.py | 231 | ||||
-rwxr-xr-x | afdo_tools/update_kernel_afdo | 308 |
8 files changed, 1296 insertions, 1092 deletions
diff --git a/afdo_tools/bisection/afdo_prof_analysis.py b/afdo_tools/bisection/afdo_prof_analysis.py index ce8afd64..c9ca9214 100755 --- a/afdo_tools/bisection/afdo_prof_analysis.py +++ b/afdo_tools/bisection/afdo_prof_analysis.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# Copyright 2019 The Chromium OS Authors. All rights reserved. +# Copyright 2019 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. @@ -20,7 +20,6 @@ exit code. The codes known to this script are: - >127: quit immediately """ -from __future__ import division, print_function import argparse import json @@ -35,11 +34,12 @@ from tempfile import mkstemp class StatusEnum(IntEnum): - """Enum of valid statuses returned by profile decider.""" - GOOD_STATUS = 0 - BAD_STATUS = 1 - SKIP_STATUS = 125 - PROBLEM_STATUS = 127 + """Enum of valid statuses returned by profile decider.""" + + GOOD_STATUS = 0 + BAD_STATUS = 1 + SKIP_STATUS = 125 + PROBLEM_STATUS = 127 statuses = StatusEnum.__members__.values() @@ -48,396 +48,442 @@ _NUM_RUNS_RANGE_SEARCH = 20 # how many times range search should run its algo def json_to_text(json_prof): - text_profile = [] - for func in json_prof: - text_profile.append(func) - text_profile.append(json_prof[func]) - return ''.join(text_profile) + text_profile = [] + for func in json_prof: + text_profile.append(func) + text_profile.append(json_prof[func]) + return "".join(text_profile) def text_to_json(f): - """Performs basic parsing of an AFDO text-based profile. - - This parsing expects an input file object with contents of the form generated - by bin/llvm-profdata (within an LLVM build). - """ - results = {} - curr_func = None - curr_data = [] - for line in f: - if not line.startswith(' '): - if curr_func: - results[curr_func] = ''.join(curr_data) - curr_data = [] - curr_func, rest = line.split(':', 1) - curr_func = curr_func.strip() - curr_data.append(':' + rest) - else: - curr_data.append(line) - - if curr_func: - results[curr_func] = ''.join(curr_data) - return results + """Performs basic parsing of an AFDO text-based profile. + + This parsing expects an input file object with contents of the form generated + by bin/llvm-profdata (within an LLVM build). + """ + results = {} + curr_func = None + curr_data = [] + for line in f: + if not line.startswith(" "): + if curr_func: + results[curr_func] = "".join(curr_data) + curr_data = [] + curr_func, rest = line.split(":", 1) + curr_func = curr_func.strip() + curr_data.append(":" + rest) + else: + curr_data.append(line) + + if curr_func: + results[curr_func] = "".join(curr_data) + return results def prof_to_tmp(prof): - """Creates (and returns) temp filename for given JSON-based AFDO profile.""" - fd, temp_path = mkstemp() - text_profile = json_to_text(prof) - with open(temp_path, 'w') as f: - f.write(text_profile) - os.close(fd) - return temp_path + """Creates (and returns) temp filename for given JSON-based AFDO profile.""" + fd, temp_path = mkstemp() + text_profile = json_to_text(prof) + with open(temp_path, "w") as f: + f.write(text_profile) + os.close(fd) + return temp_path class DeciderState(object): - """Class for the external decider.""" - - def __init__(self, state_file, external_decider, seed): - self.accumulated_results = [] # over this run of the script - self.external_decider = external_decider - self.saved_results = [] # imported from a previous run of this script - self.state_file = state_file - self.seed = seed if seed is not None else time.time() - - def load_state(self): - if not os.path.exists(self.state_file): - logging.info('State file %s is empty, starting from beginning', - self.state_file) - return - - with open(self.state_file, encoding='utf-8') as f: - try: - data = json.load(f) - except: - raise ValueError('Provided state file %s to resume from does not' - ' contain a valid JSON.' % self.state_file) - - if 'seed' not in data or 'accumulated_results' not in data: - raise ValueError('Provided state file %s to resume from does not contain' - ' the correct information' % self.state_file) - - self.seed = data['seed'] - self.saved_results = data['accumulated_results'] - logging.info('Restored state from %s...', self.state_file) - - def save_state(self): - state = {'seed': self.seed, 'accumulated_results': self.accumulated_results} - tmp_file = self.state_file + '.new' - with open(tmp_file, 'w', encoding='utf-8') as f: - json.dump(state, f, indent=2) - os.rename(tmp_file, self.state_file) - logging.info('Logged state to %s...', self.state_file) - - def run(self, prof, save_run=True): - """Run the external deciding script on the given profile.""" - if self.saved_results and save_run: - result = self.saved_results.pop(0) - self.accumulated_results.append(result) - self.save_state() - return StatusEnum(result) - - filename = prof_to_tmp(prof) - - try: - return_code = subprocess.call([self.external_decider, filename]) - finally: - os.remove(filename) - - if return_code in statuses: - status = StatusEnum(return_code) - if status == StatusEnum.PROBLEM_STATUS: - prof_file = prof_to_tmp(prof) - raise RuntimeError('Provided decider script returned PROBLEM_STATUS ' - 'when run on profile stored at %s. AFDO Profile ' - 'analysis aborting' % prof_file) - if save_run: - self.accumulated_results.append(status.value) - logging.info('Run %d of external script %s returned %s', - len(self.accumulated_results), self.external_decider, - status.name) - self.save_state() - return status - raise ValueError( - 'Provided external script had unexpected return code %d' % return_code) + """Class for the external decider.""" + + def __init__(self, state_file, external_decider, seed): + self.accumulated_results = [] # over this run of the script + self.external_decider = external_decider + self.saved_results = [] # imported from a previous run of this script + self.state_file = state_file + self.seed = seed if seed is not None else time.time() + + def load_state(self): + if not os.path.exists(self.state_file): + logging.info( + "State file %s is empty, starting from beginning", + self.state_file, + ) + return + + with open(self.state_file, encoding="utf-8") as f: + try: + data = json.load(f) + except: + raise ValueError( + "Provided state file %s to resume from does not" + " contain a valid JSON." % self.state_file + ) + + if "seed" not in data or "accumulated_results" not in data: + raise ValueError( + "Provided state file %s to resume from does not contain" + " the correct information" % self.state_file + ) + + self.seed = data["seed"] + self.saved_results = data["accumulated_results"] + logging.info("Restored state from %s...", self.state_file) + + def save_state(self): + state = { + "seed": self.seed, + "accumulated_results": self.accumulated_results, + } + tmp_file = self.state_file + ".new" + with open(tmp_file, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + os.rename(tmp_file, self.state_file) + logging.info("Logged state to %s...", self.state_file) + + def run(self, prof, save_run=True): + """Run the external deciding script on the given profile.""" + if self.saved_results and save_run: + result = self.saved_results.pop(0) + self.accumulated_results.append(result) + self.save_state() + return StatusEnum(result) + + filename = prof_to_tmp(prof) + + try: + return_code = subprocess.call([self.external_decider, filename]) + finally: + os.remove(filename) + + if return_code in statuses: + status = StatusEnum(return_code) + if status == StatusEnum.PROBLEM_STATUS: + prof_file = prof_to_tmp(prof) + raise RuntimeError( + "Provided decider script returned PROBLEM_STATUS " + "when run on profile stored at %s. AFDO Profile " + "analysis aborting" % prof_file + ) + if save_run: + self.accumulated_results.append(status.value) + logging.info( + "Run %d of external script %s returned %s", + len(self.accumulated_results), + self.external_decider, + status.name, + ) + self.save_state() + return status + raise ValueError( + "Provided external script had unexpected return code %d" + % return_code + ) def bisect_profiles(decider, good, bad, common_funcs, lo, hi): - """Recursive function which bisects good and bad profiles. - - Args: - decider: function which, given a JSON-based AFDO profile, returns an - element of 'statuses' based on the status of the profile - good: JSON-based good AFDO profile - bad: JSON-based bad AFDO profile - common_funcs: the list of functions which have top-level profiles in both - 'good' and 'bad' - lo: lower bound of range being bisected on - hi: upper bound of range being bisected on - - Returns a dictionary with two keys: 'individuals' and 'ranges'. - 'individuals': a list of individual functions found to make the profile BAD - 'ranges': a list of lists of function names. Each list of functions is a list - such that including all of those from the bad profile makes the good - profile BAD. It may not be the smallest problematic combination, but - definitely contains a problematic combination of profiles. - """ - - results = {'individuals': [], 'ranges': []} - if hi - lo <= 1: - logging.info('Found %s as a problematic function profile', common_funcs[lo]) - results['individuals'].append(common_funcs[lo]) - return results + """Recursive function which bisects good and bad profiles. + + Args: + decider: function which, given a JSON-based AFDO profile, returns an + element of 'statuses' based on the status of the profile + good: JSON-based good AFDO profile + bad: JSON-based bad AFDO profile + common_funcs: the list of functions which have top-level profiles in both + 'good' and 'bad' + lo: lower bound of range being bisected on + hi: upper bound of range being bisected on + + Returns a dictionary with two keys: 'individuals' and 'ranges'. + 'individuals': a list of individual functions found to make the profile BAD + 'ranges': a list of lists of function names. Each list of functions is a list + such that including all of those from the bad profile makes the good + profile BAD. It may not be the smallest problematic combination, but + definitely contains a problematic combination of profiles. + """ + + results = {"individuals": [], "ranges": []} + if hi - lo <= 1: + logging.info( + "Found %s as a problematic function profile", common_funcs[lo] + ) + results["individuals"].append(common_funcs[lo]) + return results + + mid = (lo + hi) // 2 + lo_mid_prof = good.copy() # covers bad from lo:mid + mid_hi_prof = good.copy() # covers bad from mid:hi + for func in common_funcs[lo:mid]: + lo_mid_prof[func] = bad[func] + for func in common_funcs[mid:hi]: + mid_hi_prof[func] = bad[func] + + lo_mid_verdict = decider.run(lo_mid_prof) + mid_hi_verdict = decider.run(mid_hi_prof) + + if lo_mid_verdict == StatusEnum.BAD_STATUS: + result = bisect_profiles(decider, good, bad, common_funcs, lo, mid) + results["individuals"].extend(result["individuals"]) + results["ranges"].extend(result["ranges"]) + if mid_hi_verdict == StatusEnum.BAD_STATUS: + result = bisect_profiles(decider, good, bad, common_funcs, mid, hi) + results["individuals"].extend(result["individuals"]) + results["ranges"].extend(result["ranges"]) + + # neither half is bad -> the issue is caused by several things occuring + # in conjunction, and this combination crosses 'mid' + if lo_mid_verdict == mid_hi_verdict == StatusEnum.GOOD_STATUS: + problem_range = range_search(decider, good, bad, common_funcs, lo, hi) + if problem_range: + logging.info( + "Found %s as a problematic combination of profiles", + str(problem_range), + ) + results["ranges"].append(problem_range) - mid = (lo + hi) // 2 - lo_mid_prof = good.copy() # covers bad from lo:mid - mid_hi_prof = good.copy() # covers bad from mid:hi - for func in common_funcs[lo:mid]: - lo_mid_prof[func] = bad[func] - for func in common_funcs[mid:hi]: - mid_hi_prof[func] = bad[func] - - lo_mid_verdict = decider.run(lo_mid_prof) - mid_hi_verdict = decider.run(mid_hi_prof) - - if lo_mid_verdict == StatusEnum.BAD_STATUS: - result = bisect_profiles(decider, good, bad, common_funcs, lo, mid) - results['individuals'].extend(result['individuals']) - results['ranges'].extend(result['ranges']) - if mid_hi_verdict == StatusEnum.BAD_STATUS: - result = bisect_profiles(decider, good, bad, common_funcs, mid, hi) - results['individuals'].extend(result['individuals']) - results['ranges'].extend(result['ranges']) - - # neither half is bad -> the issue is caused by several things occuring - # in conjunction, and this combination crosses 'mid' - if lo_mid_verdict == mid_hi_verdict == StatusEnum.GOOD_STATUS: - problem_range = range_search(decider, good, bad, common_funcs, lo, hi) - if problem_range: - logging.info('Found %s as a problematic combination of profiles', - str(problem_range)) - results['ranges'].append(problem_range) - - return results + return results def bisect_profiles_wrapper(decider, good, bad, perform_check=True): - """Wrapper for recursive profile bisection.""" - - # Validate good and bad profiles are such, otherwise bisection reports noise - # Note that while decider is a random mock, these assertions may fail. - if perform_check: - if decider.run(good, save_run=False) != StatusEnum.GOOD_STATUS: - raise ValueError('Supplied good profile is not actually GOOD') - if decider.run(bad, save_run=False) != StatusEnum.BAD_STATUS: - raise ValueError('Supplied bad profile is not actually BAD') - - common_funcs = sorted(func for func in good if func in bad) - if not common_funcs: - return {'ranges': [], 'individuals': []} - - # shuffle because the results of our analysis can be quite order-dependent - # but this list has no inherent ordering. By shuffling each time, the chances - # of finding new, potentially interesting results are increased each time - # the program is run - random.shuffle(common_funcs) - results = bisect_profiles(decider, good, bad, common_funcs, 0, - len(common_funcs)) - results['ranges'].sort() - results['individuals'].sort() - return results + """Wrapper for recursive profile bisection.""" + + # Validate good and bad profiles are such, otherwise bisection reports noise + # Note that while decider is a random mock, these assertions may fail. + if perform_check: + if decider.run(good, save_run=False) != StatusEnum.GOOD_STATUS: + raise ValueError("Supplied good profile is not actually GOOD") + if decider.run(bad, save_run=False) != StatusEnum.BAD_STATUS: + raise ValueError("Supplied bad profile is not actually BAD") + + common_funcs = sorted(func for func in good if func in bad) + if not common_funcs: + return {"ranges": [], "individuals": []} + + # shuffle because the results of our analysis can be quite order-dependent + # but this list has no inherent ordering. By shuffling each time, the chances + # of finding new, potentially interesting results are increased each time + # the program is run + random.shuffle(common_funcs) + results = bisect_profiles( + decider, good, bad, common_funcs, 0, len(common_funcs) + ) + results["ranges"].sort() + results["individuals"].sort() + return results def range_search(decider, good, bad, common_funcs, lo, hi): - """Searches for problematic range crossing mid border. - - The main inner algorithm is the following, which looks for the smallest - possible ranges with problematic combinations. It starts the upper bound at - the midpoint, and increments in halves until it gets a BAD profile. - Then, it increments the lower bound (in halves) until the resultant profile - is GOOD, and then we have a range that causes 'BAD'ness. - - It does this _NUM_RUNS_RANGE_SEARCH times, and shuffles the functions being - looked at uniquely each time to try and get the smallest possible range - of functions in a reasonable timeframe. - """ - - average = lambda x, y: int(round((x + y) // 2.0)) - - def find_upper_border(good_copy, funcs, lo, hi, last_bad_val=None): - """Finds the upper border of problematic range.""" - mid = average(lo, hi) - if mid in (lo, hi): - return last_bad_val or hi - - for func in funcs[lo:mid]: - good_copy[func] = bad[func] - verdict = decider.run(good_copy) - - # reset for next iteration - for func in funcs: - good_copy[func] = good[func] - - if verdict == StatusEnum.BAD_STATUS: - return find_upper_border(good_copy, funcs, lo, mid, mid) - return find_upper_border(good_copy, funcs, mid, hi, last_bad_val) - - def find_lower_border(good_copy, funcs, lo, hi, last_bad_val=None): - """Finds the lower border of problematic range.""" - mid = average(lo, hi) - if mid in (lo, hi): - return last_bad_val or lo - - for func in funcs[lo:mid]: - good_copy[func] = good[func] - verdict = decider.run(good_copy) - - # reset for next iteration - for func in funcs: - good_copy[func] = bad[func] - - if verdict == StatusEnum.BAD_STATUS: - return find_lower_border(good_copy, funcs, mid, hi, lo) - return find_lower_border(good_copy, funcs, lo, mid, last_bad_val) - - lo_mid_funcs = [] - mid_hi_funcs = [] - min_range_funcs = [] - for _ in range(_NUM_RUNS_RANGE_SEARCH): - - if min_range_funcs: # only examine range we've already narrowed to - random.shuffle(lo_mid_funcs) - random.shuffle(mid_hi_funcs) - else: # consider lo-mid and mid-hi separately bc must cross border - mid = (lo + hi) // 2 - lo_mid_funcs = common_funcs[lo:mid] - mid_hi_funcs = common_funcs[mid:hi] - - funcs = lo_mid_funcs + mid_hi_funcs - hi = len(funcs) - mid = len(lo_mid_funcs) - lo = 0 - - # because we need the problematic pair to pop up before we can narrow it - prof = good.copy() - for func in lo_mid_funcs: - prof[func] = bad[func] - - upper_border = find_upper_border(prof, funcs, mid, hi) - for func in lo_mid_funcs + funcs[mid:upper_border]: - prof[func] = bad[func] - - lower_border = find_lower_border(prof, funcs, lo, mid) - curr_range_funcs = funcs[lower_border:upper_border] - - if not min_range_funcs or len(curr_range_funcs) < len(min_range_funcs): - min_range_funcs = curr_range_funcs - lo_mid_funcs = lo_mid_funcs[lo_mid_funcs.index(min_range_funcs[0]):] - mid_hi_funcs = mid_hi_funcs[:mid_hi_funcs.index(min_range_funcs[-1]) + 1] - if len(min_range_funcs) == 2: - min_range_funcs.sort() - return min_range_funcs # can't get any smaller - - min_range_funcs.sort() - return min_range_funcs + """Searches for problematic range crossing mid border. + + The main inner algorithm is the following, which looks for the smallest + possible ranges with problematic combinations. It starts the upper bound at + the midpoint, and increments in halves until it gets a BAD profile. + Then, it increments the lower bound (in halves) until the resultant profile + is GOOD, and then we have a range that causes 'BAD'ness. + + It does this _NUM_RUNS_RANGE_SEARCH times, and shuffles the functions being + looked at uniquely each time to try and get the smallest possible range + of functions in a reasonable timeframe. + """ + + average = lambda x, y: int(round((x + y) // 2.0)) + + def find_upper_border(good_copy, funcs, lo, hi, last_bad_val=None): + """Finds the upper border of problematic range.""" + mid = average(lo, hi) + if mid in (lo, hi): + return last_bad_val or hi + + for func in funcs[lo:mid]: + good_copy[func] = bad[func] + verdict = decider.run(good_copy) + + # reset for next iteration + for func in funcs: + good_copy[func] = good[func] + + if verdict == StatusEnum.BAD_STATUS: + return find_upper_border(good_copy, funcs, lo, mid, mid) + return find_upper_border(good_copy, funcs, mid, hi, last_bad_val) + + def find_lower_border(good_copy, funcs, lo, hi, last_bad_val=None): + """Finds the lower border of problematic range.""" + mid = average(lo, hi) + if mid in (lo, hi): + return last_bad_val or lo + + for func in funcs[lo:mid]: + good_copy[func] = good[func] + verdict = decider.run(good_copy) + + # reset for next iteration + for func in funcs: + good_copy[func] = bad[func] + + if verdict == StatusEnum.BAD_STATUS: + return find_lower_border(good_copy, funcs, mid, hi, lo) + return find_lower_border(good_copy, funcs, lo, mid, last_bad_val) + + lo_mid_funcs = [] + mid_hi_funcs = [] + min_range_funcs = [] + for _ in range(_NUM_RUNS_RANGE_SEARCH): + + if min_range_funcs: # only examine range we've already narrowed to + random.shuffle(lo_mid_funcs) + random.shuffle(mid_hi_funcs) + else: # consider lo-mid and mid-hi separately bc must cross border + mid = (lo + hi) // 2 + lo_mid_funcs = common_funcs[lo:mid] + mid_hi_funcs = common_funcs[mid:hi] + + funcs = lo_mid_funcs + mid_hi_funcs + hi = len(funcs) + mid = len(lo_mid_funcs) + lo = 0 + + # because we need the problematic pair to pop up before we can narrow it + prof = good.copy() + for func in lo_mid_funcs: + prof[func] = bad[func] + + upper_border = find_upper_border(prof, funcs, mid, hi) + for func in lo_mid_funcs + funcs[mid:upper_border]: + prof[func] = bad[func] + + lower_border = find_lower_border(prof, funcs, lo, mid) + curr_range_funcs = funcs[lower_border:upper_border] + + if not min_range_funcs or len(curr_range_funcs) < len(min_range_funcs): + min_range_funcs = curr_range_funcs + lo_mid_funcs = lo_mid_funcs[ + lo_mid_funcs.index(min_range_funcs[0]) : + ] + mid_hi_funcs = mid_hi_funcs[ + : mid_hi_funcs.index(min_range_funcs[-1]) + 1 + ] + if len(min_range_funcs) == 2: + min_range_funcs.sort() + return min_range_funcs # can't get any smaller + + min_range_funcs.sort() + return min_range_funcs def check_good_not_bad(decider, good, bad): - """Check if bad prof becomes GOOD by adding funcs it lacks from good prof""" - bad_copy = bad.copy() - for func in good: - if func not in bad: - bad_copy[func] = good[func] - return decider.run(bad_copy) == StatusEnum.GOOD_STATUS + """Check if bad prof becomes GOOD by adding funcs it lacks from good prof""" + bad_copy = bad.copy() + for func in good: + if func not in bad: + bad_copy[func] = good[func] + return decider.run(bad_copy) == StatusEnum.GOOD_STATUS def check_bad_not_good(decider, good, bad): - """Check if good prof BAD after adding funcs bad prof has that good doesnt""" - good_copy = good.copy() - for func in bad: - if func not in good: - good_copy[func] = bad[func] - return decider.run(good_copy) == StatusEnum.BAD_STATUS + """Check if good prof BAD after adding funcs bad prof has that good doesnt""" + good_copy = good.copy() + for func in bad: + if func not in good: + good_copy[func] = bad[func] + return decider.run(good_copy) == StatusEnum.BAD_STATUS def parse_args(): - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - '--good_prof', - required=True, - help='Text-based "Good" profile for analysis') - parser.add_argument( - '--bad_prof', required=True, help='Text-based "Bad" profile for analysis') - parser.add_argument( - '--external_decider', - required=True, - help='External script that, given an AFDO profile, returns ' - 'GOOD/BAD/SKIP') - parser.add_argument( - '--analysis_output_file', - required=True, - help='File to output JSON results to') - parser.add_argument( - '--state_file', - default='%s/afdo_analysis_state.json' % os.getcwd(), - help='File path containing state to load from initially, and will be ' - 'overwritten with new state on each iteration') - parser.add_argument( - '--no_resume', - action='store_true', - help='If enabled, no initial state will be loaded and the program will ' - 'run from the beginning') - parser.add_argument( - '--remove_state_on_completion', - action='store_true', - help='If enabled, state file will be removed once profile analysis is ' - 'completed') - parser.add_argument( - '--seed', type=float, help='Float specifying seed for randomness') - return parser.parse_args() + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--good_prof", + required=True, + help='Text-based "Good" profile for analysis', + ) + parser.add_argument( + "--bad_prof", + required=True, + help='Text-based "Bad" profile for analysis', + ) + parser.add_argument( + "--external_decider", + required=True, + help="External script that, given an AFDO profile, returns " + "GOOD/BAD/SKIP", + ) + parser.add_argument( + "--analysis_output_file", + required=True, + help="File to output JSON results to", + ) + parser.add_argument( + "--state_file", + default="%s/afdo_analysis_state.json" % os.getcwd(), + help="File path containing state to load from initially, and will be " + "overwritten with new state on each iteration", + ) + parser.add_argument( + "--no_resume", + action="store_true", + help="If enabled, no initial state will be loaded and the program will " + "run from the beginning", + ) + parser.add_argument( + "--remove_state_on_completion", + action="store_true", + help="If enabled, state file will be removed once profile analysis is " + "completed", + ) + parser.add_argument( + "--seed", type=float, help="Float specifying seed for randomness" + ) + return parser.parse_args() def main(flags): - logging.getLogger().setLevel(logging.INFO) - if not flags.no_resume and flags.seed: # conflicting seeds - raise RuntimeError('Ambiguous seed value; do not resume from existing ' - 'state and also specify seed by command line flag') - - decider = DeciderState( - flags.state_file, flags.external_decider, seed=flags.seed) - if not flags.no_resume: - decider.load_state() - random.seed(decider.seed) - - with open(flags.good_prof) as good_f: - good_items = text_to_json(good_f) - with open(flags.bad_prof) as bad_f: - bad_items = text_to_json(bad_f) - - bisect_results = bisect_profiles_wrapper(decider, good_items, bad_items) - gnb_result = check_good_not_bad(decider, good_items, bad_items) - bng_result = check_bad_not_good(decider, good_items, bad_items) - - results = { - 'seed': decider.seed, - 'bisect_results': bisect_results, - 'good_only_functions': gnb_result, - 'bad_only_functions': bng_result - } - with open(flags.analysis_output_file, 'w', encoding='utf-8') as f: - json.dump(results, f, indent=2) - if flags.remove_state_on_completion: - os.remove(flags.state_file) - logging.info('Removed state file %s following completion of script...', - flags.state_file) - else: - completed_state_file = '%s.completed.%s' % (flags.state_file, - str(date.today())) - os.rename(flags.state_file, completed_state_file) - logging.info('Stored completed state file as %s...', completed_state_file) - return results - - -if __name__ == '__main__': - main(parse_args()) + logging.getLogger().setLevel(logging.INFO) + if not flags.no_resume and flags.seed: # conflicting seeds + raise RuntimeError( + "Ambiguous seed value; do not resume from existing " + "state and also specify seed by command line flag" + ) + + decider = DeciderState( + flags.state_file, flags.external_decider, seed=flags.seed + ) + if not flags.no_resume: + decider.load_state() + random.seed(decider.seed) + + with open(flags.good_prof) as good_f: + good_items = text_to_json(good_f) + with open(flags.bad_prof) as bad_f: + bad_items = text_to_json(bad_f) + + bisect_results = bisect_profiles_wrapper(decider, good_items, bad_items) + gnb_result = check_good_not_bad(decider, good_items, bad_items) + bng_result = check_bad_not_good(decider, good_items, bad_items) + + results = { + "seed": decider.seed, + "bisect_results": bisect_results, + "good_only_functions": gnb_result, + "bad_only_functions": bng_result, + } + with open(flags.analysis_output_file, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + if flags.remove_state_on_completion: + os.remove(flags.state_file) + logging.info( + "Removed state file %s following completion of script...", + flags.state_file, + ) + else: + completed_state_file = "%s.completed.%s" % ( + flags.state_file, + str(date.today()), + ) + os.rename(flags.state_file, completed_state_file) + logging.info( + "Stored completed state file as %s...", completed_state_file + ) + return results + + +if __name__ == "__main__": + main(parse_args()) diff --git a/afdo_tools/bisection/afdo_prof_analysis_e2e_test.py b/afdo_tools/bisection/afdo_prof_analysis_e2e_test.py index b293b8aa..8a0dae38 100755 --- a/afdo_tools/bisection/afdo_prof_analysis_e2e_test.py +++ b/afdo_tools/bisection/afdo_prof_analysis_e2e_test.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# Copyright 2019 The Chromium OS Authors. All rights reserved. +# Copyright 2019 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """End-to-end test for afdo_prof_analysis.""" -from __future__ import absolute_import, division, print_function import json import os @@ -19,263 +18,273 @@ from afdo_tools.bisection import afdo_prof_analysis as analysis class ObjectWithFields(object): - """Turns kwargs given to the constructor into fields on an object. + """Turns kwargs given to the constructor into fields on an object. - Examples: - x = ObjectWithFields(a=1, b=2) - assert x.a == 1 - assert x.b == 2 - """ + Examples: + x = ObjectWithFields(a=1, b=2) + assert x.a == 1 + assert x.b == 2 + """ - def __init__(self, **kwargs): - for key, val in kwargs.items(): - setattr(self, key, val) + def __init__(self, **kwargs): + for key, val in kwargs.items(): + setattr(self, key, val) class AfdoProfAnalysisE2ETest(unittest.TestCase): - """Class for end-to-end testing of AFDO Profile Analysis""" - - # nothing significant about the values, just easier to remember even vs odd - good_prof = { - 'func_a': ':1\n 1: 3\n 3: 5\n 5: 7\n', - 'func_b': ':3\n 3: 5\n 5: 7\n 7: 9\n', - 'func_c': ':5\n 5: 7\n 7: 9\n 9: 11\n', - 'func_d': ':7\n 7: 9\n 9: 11\n 11: 13\n', - 'good_func_a': ':11\n', - 'good_func_b': ':13\n' - } - - bad_prof = { - 'func_a': ':2\n 2: 4\n 4: 6\n 6: 8\n', - 'func_b': ':4\n 4: 6\n 6: 8\n 8: 10\n', - 'func_c': ':6\n 6: 8\n 8: 10\n 10: 12\n', - 'func_d': ':8\n 8: 10\n 10: 12\n 12: 14\n', - 'bad_func_a': ':12\n', - 'bad_func_b': ':14\n' - } - - expected = { - 'good_only_functions': False, - 'bad_only_functions': True, - 'bisect_results': { - 'ranges': [], - 'individuals': ['func_a'] - } - } - - def test_afdo_prof_analysis(self): - # Individual issues take precedence by nature of our algos - # so first, that should be caught - good = self.good_prof.copy() - bad = self.bad_prof.copy() - self.run_check(good, bad, self.expected) - - # Now remove individuals and exclusively BAD, and check that range is caught - bad['func_a'] = good['func_a'] - bad.pop('bad_func_a') - bad.pop('bad_func_b') - - expected_cp = self.expected.copy() - expected_cp['bad_only_functions'] = False - expected_cp['bisect_results'] = { - 'individuals': [], - 'ranges': [['func_b', 'func_c', 'func_d']] + """Class for end-to-end testing of AFDO Profile Analysis""" + + # nothing significant about the values, just easier to remember even vs odd + good_prof = { + "func_a": ":1\n 1: 3\n 3: 5\n 5: 7\n", + "func_b": ":3\n 3: 5\n 5: 7\n 7: 9\n", + "func_c": ":5\n 5: 7\n 7: 9\n 9: 11\n", + "func_d": ":7\n 7: 9\n 9: 11\n 11: 13\n", + "good_func_a": ":11\n", + "good_func_b": ":13\n", + } + + bad_prof = { + "func_a": ":2\n 2: 4\n 4: 6\n 6: 8\n", + "func_b": ":4\n 4: 6\n 6: 8\n 8: 10\n", + "func_c": ":6\n 6: 8\n 8: 10\n 10: 12\n", + "func_d": ":8\n 8: 10\n 10: 12\n 12: 14\n", + "bad_func_a": ":12\n", + "bad_func_b": ":14\n", } - self.run_check(good, bad, expected_cp) - - def test_afdo_prof_state(self): - """Verifies that saved state is correct replication.""" - temp_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True) - - good = self.good_prof.copy() - bad = self.bad_prof.copy() - # add more functions to data - for x in range(400): - good['func_%d' % x] = '' - bad['func_%d' % x] = '' - - fd_first, first_result = tempfile.mkstemp(dir=temp_dir) - os.close(fd_first) - fd_state, state_file = tempfile.mkstemp(dir=temp_dir) - os.close(fd_state) - self.run_check( - self.good_prof, - self.bad_prof, - self.expected, - state_file=state_file, - out_file=first_result) - - fd_second, second_result = tempfile.mkstemp(dir=temp_dir) - os.close(fd_second) - completed_state_file = '%s.completed.%s' % (state_file, str(date.today())) - self.run_check( - self.good_prof, - self.bad_prof, - self.expected, - state_file=completed_state_file, - no_resume=False, - out_file=second_result) - - with open(first_result) as f: - initial_run = json.load(f) - with open(second_result) as f: - loaded_run = json.load(f) - self.assertEqual(initial_run, loaded_run) - - def test_exit_on_problem_status(self): - temp_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True) - - fd_state, state_file = tempfile.mkstemp(dir=temp_dir) - os.close(fd_state) - with self.assertRaises(RuntimeError): - self.run_check( - self.good_prof, - self.bad_prof, - self.expected, - state_file=state_file, - extern_decider='problemstatus_external.sh') - - def test_state_assumption(self): - - def compare_runs(tmp_dir, first_ctr, second_ctr): - """Compares given prof versions between first and second run in test.""" - first_prof = '%s/.first_run_%d' % (tmp_dir, first_ctr) - second_prof = '%s/.second_run_%d' % (tmp_dir, second_ctr) - with open(first_prof) as f: - first_prof_text = f.read() - with open(second_prof) as f: - second_prof_text = f.read() - self.assertEqual(first_prof_text, second_prof_text) - - good_prof = {'func_a': ':1\n3: 3\n5: 7\n'} - bad_prof = {'func_a': ':2\n4: 4\n6: 8\n'} - # add some noise to the profiles; 15 is an arbitrary choice - for x in range(15): - func = 'func_%d' % x - good_prof[func] = ':%d\n' % (x) - bad_prof[func] = ':%d\n' % (x + 1) expected = { - 'bisect_results': { - 'ranges': [], - 'individuals': ['func_a'] - }, - 'good_only_functions': False, - 'bad_only_functions': False + "good_only_functions": False, + "bad_only_functions": True, + "bisect_results": {"ranges": [], "individuals": ["func_a"]}, } - # using a static temp dir rather than a dynamic one because these files are - # shared between the bash scripts and this Python test, and the arguments - # to the bash scripts are fixed by afdo_prof_analysis.py so it would be - # difficult to communicate dynamically generated directory to bash scripts - scripts_tmp_dir = '%s/afdo_test_tmp' % os.getcwd() - os.mkdir(scripts_tmp_dir) - self.addCleanup(shutil.rmtree, scripts_tmp_dir, ignore_errors=True) - - # files used in the bash scripts used as external deciders below - # - count_file tracks the current number of calls to the script in total - # - local_count_file tracks the number of calls to the script without - # interruption - count_file = '%s/.count' % scripts_tmp_dir - local_count_file = '%s/.local_count' % scripts_tmp_dir - - # runs through whole thing at once - initial_seed = self.run_check( - good_prof, - bad_prof, - expected, - extern_decider='state_assumption_external.sh') - with open(count_file) as f: - num_calls = int(f.read()) - os.remove(count_file) # reset counts for second run - finished_state_file = 'afdo_analysis_state.json.completed.%s' % str( - date.today()) - self.addCleanup(os.remove, finished_state_file) - - # runs the same analysis but interrupted each iteration - for i in range(2 * num_calls + 1): - no_resume_run = (i == 0) - seed = initial_seed if no_resume_run else None - try: + def test_afdo_prof_analysis(self): + # Individual issues take precedence by nature of our algos + # so first, that should be caught + good = self.good_prof.copy() + bad = self.bad_prof.copy() + self.run_check(good, bad, self.expected) + + # Now remove individuals and exclusively BAD, and check that range is caught + bad["func_a"] = good["func_a"] + bad.pop("bad_func_a") + bad.pop("bad_func_b") + + expected_cp = self.expected.copy() + expected_cp["bad_only_functions"] = False + expected_cp["bisect_results"] = { + "individuals": [], + "ranges": [["func_b", "func_c", "func_d"]], + } + + self.run_check(good, bad, expected_cp) + + def test_afdo_prof_state(self): + """Verifies that saved state is correct replication.""" + temp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True) + + good = self.good_prof.copy() + bad = self.bad_prof.copy() + # add more functions to data + for x in range(400): + good["func_%d" % x] = "" + bad["func_%d" % x] = "" + + fd_first, first_result = tempfile.mkstemp(dir=temp_dir) + os.close(fd_first) + fd_state, state_file = tempfile.mkstemp(dir=temp_dir) + os.close(fd_state) + self.run_check( + self.good_prof, + self.bad_prof, + self.expected, + state_file=state_file, + out_file=first_result, + ) + + fd_second, second_result = tempfile.mkstemp(dir=temp_dir) + os.close(fd_second) + completed_state_file = "%s.completed.%s" % ( + state_file, + str(date.today()), + ) self.run_check( + self.good_prof, + self.bad_prof, + self.expected, + state_file=completed_state_file, + no_resume=False, + out_file=second_result, + ) + + with open(first_result) as f: + initial_run = json.load(f) + with open(second_result) as f: + loaded_run = json.load(f) + self.assertEqual(initial_run, loaded_run) + + def test_exit_on_problem_status(self): + temp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True) + + fd_state, state_file = tempfile.mkstemp(dir=temp_dir) + os.close(fd_state) + with self.assertRaises(RuntimeError): + self.run_check( + self.good_prof, + self.bad_prof, + self.expected, + state_file=state_file, + extern_decider="problemstatus_external.sh", + ) + + def test_state_assumption(self): + def compare_runs(tmp_dir, first_ctr, second_ctr): + """Compares given prof versions between first and second run in test.""" + first_prof = "%s/.first_run_%d" % (tmp_dir, first_ctr) + second_prof = "%s/.second_run_%d" % (tmp_dir, second_ctr) + with open(first_prof) as f: + first_prof_text = f.read() + with open(second_prof) as f: + second_prof_text = f.read() + self.assertEqual(first_prof_text, second_prof_text) + + good_prof = {"func_a": ":1\n3: 3\n5: 7\n"} + bad_prof = {"func_a": ":2\n4: 4\n6: 8\n"} + # add some noise to the profiles; 15 is an arbitrary choice + for x in range(15): + func = "func_%d" % x + good_prof[func] = ":%d\n" % (x) + bad_prof[func] = ":%d\n" % (x + 1) + expected = { + "bisect_results": {"ranges": [], "individuals": ["func_a"]}, + "good_only_functions": False, + "bad_only_functions": False, + } + + # using a static temp dir rather than a dynamic one because these files are + # shared between the bash scripts and this Python test, and the arguments + # to the bash scripts are fixed by afdo_prof_analysis.py so it would be + # difficult to communicate dynamically generated directory to bash scripts + scripts_tmp_dir = "%s/afdo_test_tmp" % os.getcwd() + os.mkdir(scripts_tmp_dir) + self.addCleanup(shutil.rmtree, scripts_tmp_dir, ignore_errors=True) + + # files used in the bash scripts used as external deciders below + # - count_file tracks the current number of calls to the script in total + # - local_count_file tracks the number of calls to the script without + # interruption + count_file = "%s/.count" % scripts_tmp_dir + local_count_file = "%s/.local_count" % scripts_tmp_dir + + # runs through whole thing at once + initial_seed = self.run_check( good_prof, bad_prof, expected, - no_resume=no_resume_run, - extern_decider='state_assumption_interrupt.sh', - seed=seed) - break - except RuntimeError: - # script was interrupted, so we restart local count - os.remove(local_count_file) - else: - raise RuntimeError('Test failed -- took too many iterations') - - for initial_ctr in range(3): # initial runs unaffected by interruption - compare_runs(scripts_tmp_dir, initial_ctr, initial_ctr) - - start = 3 - for ctr in range(start, num_calls): - # second run counter incremented by 4 for each one first run is because - # +2 for performing initial checks on good and bad profs each time - # +1 for PROBLEM_STATUS run which causes error and restart - compare_runs(scripts_tmp_dir, ctr, 6 + (ctr - start) * 4) - - def run_check(self, - good_prof, - bad_prof, - expected, - state_file=None, - no_resume=True, - out_file=None, - extern_decider=None, - seed=None): - - temp_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True) - - good_prof_file = '%s/%s' % (temp_dir, 'good_prof.txt') - bad_prof_file = '%s/%s' % (temp_dir, 'bad_prof.txt') - good_prof_text = analysis.json_to_text(good_prof) - bad_prof_text = analysis.json_to_text(bad_prof) - with open(good_prof_file, 'w') as f: - f.write(good_prof_text) - with open(bad_prof_file, 'w') as f: - f.write(bad_prof_text) - - dir_path = os.path.dirname(os.path.realpath(__file__)) # dir of this file - external_script = '%s/%s' % (dir_path, extern_decider or 'e2e_external.sh') - - # FIXME: This test ideally shouldn't be writing to $PWD - if state_file is None: - state_file = '%s/afdo_analysis_state.json' % os.getcwd() - - def rm_state(): - try: - os.unlink(state_file) - except OSError: - # Probably because the file DNE. That's fine. - pass - - self.addCleanup(rm_state) - - actual = analysis.main( - ObjectWithFields( - good_prof=good_prof_file, - bad_prof=bad_prof_file, - external_decider=external_script, - analysis_output_file=out_file or '/dev/null', - state_file=state_file, - no_resume=no_resume, - remove_state_on_completion=False, - seed=seed, - )) - actual_seed = actual.pop('seed') # nothing to check - self.assertEqual(actual, expected) - return actual_seed - - -if __name__ == '__main__': - unittest.main() + extern_decider="state_assumption_external.sh", + ) + with open(count_file) as f: + num_calls = int(f.read()) + os.remove(count_file) # reset counts for second run + finished_state_file = "afdo_analysis_state.json.completed.%s" % str( + date.today() + ) + self.addCleanup(os.remove, finished_state_file) + + # runs the same analysis but interrupted each iteration + for i in range(2 * num_calls + 1): + no_resume_run = i == 0 + seed = initial_seed if no_resume_run else None + try: + self.run_check( + good_prof, + bad_prof, + expected, + no_resume=no_resume_run, + extern_decider="state_assumption_interrupt.sh", + seed=seed, + ) + break + except RuntimeError: + # script was interrupted, so we restart local count + os.remove(local_count_file) + else: + raise RuntimeError("Test failed -- took too many iterations") + + for initial_ctr in range(3): # initial runs unaffected by interruption + compare_runs(scripts_tmp_dir, initial_ctr, initial_ctr) + + start = 3 + for ctr in range(start, num_calls): + # second run counter incremented by 4 for each one first run is because + # +2 for performing initial checks on good and bad profs each time + # +1 for PROBLEM_STATUS run which causes error and restart + compare_runs(scripts_tmp_dir, ctr, 6 + (ctr - start) * 4) + + def run_check( + self, + good_prof, + bad_prof, + expected, + state_file=None, + no_resume=True, + out_file=None, + extern_decider=None, + seed=None, + ): + + temp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True) + + good_prof_file = "%s/%s" % (temp_dir, "good_prof.txt") + bad_prof_file = "%s/%s" % (temp_dir, "bad_prof.txt") + good_prof_text = analysis.json_to_text(good_prof) + bad_prof_text = analysis.json_to_text(bad_prof) + with open(good_prof_file, "w") as f: + f.write(good_prof_text) + with open(bad_prof_file, "w") as f: + f.write(bad_prof_text) + + dir_path = os.path.dirname( + os.path.realpath(__file__) + ) # dir of this file + external_script = "%s/%s" % ( + dir_path, + extern_decider or "e2e_external.sh", + ) + + # FIXME: This test ideally shouldn't be writing to $PWD + if state_file is None: + state_file = "%s/afdo_analysis_state.json" % os.getcwd() + + def rm_state(): + try: + os.unlink(state_file) + except OSError: + # Probably because the file DNE. That's fine. + pass + + self.addCleanup(rm_state) + + actual = analysis.main( + ObjectWithFields( + good_prof=good_prof_file, + bad_prof=bad_prof_file, + external_decider=external_script, + analysis_output_file=out_file or "/dev/null", + state_file=state_file, + no_resume=no_resume, + remove_state_on_completion=False, + seed=seed, + ) + ) + actual_seed = actual.pop("seed") # nothing to check + self.assertEqual(actual, expected) + return actual_seed + + +if __name__ == "__main__": + unittest.main() diff --git a/afdo_tools/bisection/afdo_prof_analysis_test.py b/afdo_tools/bisection/afdo_prof_analysis_test.py index 245edc33..babfc021 100755 --- a/afdo_tools/bisection/afdo_prof_analysis_test.py +++ b/afdo_tools/bisection/afdo_prof_analysis_test.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# Copyright 2019 The Chromium OS Authors. All rights reserved. +# Copyright 2019 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Tests for afdo_prof_analysis.""" -from __future__ import print_function import random import io @@ -16,139 +15,154 @@ from afdo_tools.bisection import afdo_prof_analysis as analysis class AfdoProfAnalysisTest(unittest.TestCase): - """Class for testing AFDO Profile Analysis""" - bad_items = {'func_a': '1', 'func_b': '3', 'func_c': '5'} - good_items = {'func_a': '2', 'func_b': '4', 'func_d': '5'} - random.seed(13) # 13 is an arbitrary choice. just for consistency - # add some extra info to make tests more reflective of real scenario - for num in range(128): - func_name = 'func_extra_%d' % num - # 1/3 to both, 1/3 only to good, 1/3 only to bad - rand_val = random.randint(1, 101) - if rand_val < 67: - bad_items[func_name] = 'test_data' - if rand_val < 34 or rand_val >= 67: - good_items[func_name] = 'test_data' - - analysis.random.seed(5) # 5 is an arbitrary choice. For consistent testing - - def test_text_to_json(self): - test_data = io.StringIO('deflate_slow:87460059:3\n' - ' 3: 24\n' - ' 14: 54767\n' - ' 15: 664 fill_window:22\n' - ' 16: 661\n' - ' 19: 637\n' - ' 41: 36692 longest_match:36863\n' - ' 44: 36692\n' - ' 44.2: 5861\n' - ' 46: 13942\n' - ' 46.1: 14003\n') - expected = { - 'deflate_slow': ':87460059:3\n' - ' 3: 24\n' - ' 14: 54767\n' - ' 15: 664 fill_window:22\n' - ' 16: 661\n' - ' 19: 637\n' - ' 41: 36692 longest_match:36863\n' - ' 44: 36692\n' - ' 44.2: 5861\n' - ' 46: 13942\n' - ' 46.1: 14003\n' - } - actual = analysis.text_to_json(test_data) - self.assertEqual(actual, expected) - test_data.close() - - def test_text_to_json_empty_afdo(self): - expected = {} - actual = analysis.text_to_json('') - self.assertEqual(actual, expected) - - def test_json_to_text(self): - example_prof = {'func_a': ':1\ndata\n', 'func_b': ':2\nmore data\n'} - expected_text = 'func_a:1\ndata\nfunc_b:2\nmore data\n' - self.assertEqual(analysis.json_to_text(example_prof), expected_text) - - def test_bisect_profiles(self): - - # mock run of external script with arbitrarily-chosen bad profile vals - # save_run specified and unused b/c afdo_prof_analysis.py - # will call with argument explicitly specified - # pylint: disable=unused-argument - class DeciderClass(object): - """Class for this tests's decider.""" - - def run(self, prof, save_run=False): - if '1' in prof['func_a'] or '3' in prof['func_b']: - return analysis.StatusEnum.BAD_STATUS - return analysis.StatusEnum.GOOD_STATUS - - results = analysis.bisect_profiles_wrapper(DeciderClass(), self.good_items, - self.bad_items) - self.assertEqual(results['individuals'], sorted(['func_a', 'func_b'])) - self.assertEqual(results['ranges'], []) - - def test_range_search(self): - - # arbitrarily chosen functions whose values in the bad profile constitute - # a problematic pair - # pylint: disable=unused-argument - class DeciderClass(object): - """Class for this tests's decider.""" - - def run(self, prof, save_run=False): - if '1' in prof['func_a'] and '3' in prof['func_b']: - return analysis.StatusEnum.BAD_STATUS - return analysis.StatusEnum.GOOD_STATUS - - # put the problematic combination in separate halves of the common funcs - # so that non-bisecting search is invoked for its actual use case - common_funcs = [func for func in self.good_items if func in self.bad_items] - common_funcs.remove('func_a') - common_funcs.insert(0, 'func_a') - common_funcs.remove('func_b') - common_funcs.append('func_b') - - problem_range = analysis.range_search(DeciderClass(), self.good_items, - self.bad_items, common_funcs, 0, - len(common_funcs)) - - self.assertEqual(['func_a', 'func_b'], problem_range) - - def test_check_good_not_bad(self): - func_in_good = 'func_c' - - # pylint: disable=unused-argument - class DeciderClass(object): - """Class for this tests's decider.""" - - def run(self, prof, save_run=False): - if func_in_good in prof: - return analysis.StatusEnum.GOOD_STATUS - return analysis.StatusEnum.BAD_STATUS - - self.assertTrue( - analysis.check_good_not_bad(DeciderClass(), self.good_items, - self.bad_items)) - - def test_check_bad_not_good(self): - func_in_bad = 'func_d' - - # pylint: disable=unused-argument - class DeciderClass(object): - """Class for this tests's decider.""" - - def run(self, prof, save_run=False): - if func_in_bad in prof: - return analysis.StatusEnum.BAD_STATUS - return analysis.StatusEnum.GOOD_STATUS - - self.assertTrue( - analysis.check_bad_not_good(DeciderClass(), self.good_items, - self.bad_items)) - - -if __name__ == '__main__': - unittest.main() + """Class for testing AFDO Profile Analysis""" + + bad_items = {"func_a": "1", "func_b": "3", "func_c": "5"} + good_items = {"func_a": "2", "func_b": "4", "func_d": "5"} + random.seed(13) # 13 is an arbitrary choice. just for consistency + # add some extra info to make tests more reflective of real scenario + for num in range(128): + func_name = "func_extra_%d" % num + # 1/3 to both, 1/3 only to good, 1/3 only to bad + rand_val = random.randint(1, 101) + if rand_val < 67: + bad_items[func_name] = "test_data" + if rand_val < 34 or rand_val >= 67: + good_items[func_name] = "test_data" + + analysis.random.seed(5) # 5 is an arbitrary choice. For consistent testing + + def test_text_to_json(self): + test_data = io.StringIO( + "deflate_slow:87460059:3\n" + " 3: 24\n" + " 14: 54767\n" + " 15: 664 fill_window:22\n" + " 16: 661\n" + " 19: 637\n" + " 41: 36692 longest_match:36863\n" + " 44: 36692\n" + " 44.2: 5861\n" + " 46: 13942\n" + " 46.1: 14003\n" + ) + expected = { + "deflate_slow": ":87460059:3\n" + " 3: 24\n" + " 14: 54767\n" + " 15: 664 fill_window:22\n" + " 16: 661\n" + " 19: 637\n" + " 41: 36692 longest_match:36863\n" + " 44: 36692\n" + " 44.2: 5861\n" + " 46: 13942\n" + " 46.1: 14003\n" + } + actual = analysis.text_to_json(test_data) + self.assertEqual(actual, expected) + test_data.close() + + def test_text_to_json_empty_afdo(self): + expected = {} + actual = analysis.text_to_json("") + self.assertEqual(actual, expected) + + def test_json_to_text(self): + example_prof = {"func_a": ":1\ndata\n", "func_b": ":2\nmore data\n"} + expected_text = "func_a:1\ndata\nfunc_b:2\nmore data\n" + self.assertEqual(analysis.json_to_text(example_prof), expected_text) + + def test_bisect_profiles(self): + + # mock run of external script with arbitrarily-chosen bad profile vals + # save_run specified and unused b/c afdo_prof_analysis.py + # will call with argument explicitly specified + # pylint: disable=unused-argument + class DeciderClass(object): + """Class for this tests's decider.""" + + def run(self, prof, save_run=False): + if "1" in prof["func_a"] or "3" in prof["func_b"]: + return analysis.StatusEnum.BAD_STATUS + return analysis.StatusEnum.GOOD_STATUS + + results = analysis.bisect_profiles_wrapper( + DeciderClass(), self.good_items, self.bad_items + ) + self.assertEqual(results["individuals"], sorted(["func_a", "func_b"])) + self.assertEqual(results["ranges"], []) + + def test_range_search(self): + + # arbitrarily chosen functions whose values in the bad profile constitute + # a problematic pair + # pylint: disable=unused-argument + class DeciderClass(object): + """Class for this tests's decider.""" + + def run(self, prof, save_run=False): + if "1" in prof["func_a"] and "3" in prof["func_b"]: + return analysis.StatusEnum.BAD_STATUS + return analysis.StatusEnum.GOOD_STATUS + + # put the problematic combination in separate halves of the common funcs + # so that non-bisecting search is invoked for its actual use case + common_funcs = [ + func for func in self.good_items if func in self.bad_items + ] + common_funcs.remove("func_a") + common_funcs.insert(0, "func_a") + common_funcs.remove("func_b") + common_funcs.append("func_b") + + problem_range = analysis.range_search( + DeciderClass(), + self.good_items, + self.bad_items, + common_funcs, + 0, + len(common_funcs), + ) + + self.assertEqual(["func_a", "func_b"], problem_range) + + def test_check_good_not_bad(self): + func_in_good = "func_c" + + # pylint: disable=unused-argument + class DeciderClass(object): + """Class for this tests's decider.""" + + def run(self, prof, save_run=False): + if func_in_good in prof: + return analysis.StatusEnum.GOOD_STATUS + return analysis.StatusEnum.BAD_STATUS + + self.assertTrue( + analysis.check_good_not_bad( + DeciderClass(), self.good_items, self.bad_items + ) + ) + + def test_check_bad_not_good(self): + func_in_bad = "func_d" + + # pylint: disable=unused-argument + class DeciderClass(object): + """Class for this tests's decider.""" + + def run(self, prof, save_run=False): + if func_in_bad in prof: + return analysis.StatusEnum.BAD_STATUS + return analysis.StatusEnum.GOOD_STATUS + + self.assertTrue( + analysis.check_bad_not_good( + DeciderClass(), self.good_items, self.bad_items + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/afdo_tools/bisection/state_assumption_external.sh b/afdo_tools/bisection/state_assumption_external.sh index 1ad78ee2..a2076b0d 100755 --- a/afdo_tools/bisection/state_assumption_external.sh +++ b/afdo_tools/bisection/state_assumption_external.sh @@ -1,5 +1,5 @@ #!/bin/bash -eu -# Copyright 2019 The Chromium OS Authors. All rights reserved. +# Copyright 2019 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. diff --git a/afdo_tools/bisection/state_assumption_interrupt.sh b/afdo_tools/bisection/state_assumption_interrupt.sh index eba3a4b4..d1599d0b 100755 --- a/afdo_tools/bisection/state_assumption_interrupt.sh +++ b/afdo_tools/bisection/state_assumption_interrupt.sh @@ -1,5 +1,5 @@ #!/bin/bash -eu -# Copyright 2019 The Chromium OS Authors. All rights reserved. +# Copyright 2019 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. diff --git a/afdo_tools/generate_afdo_from_tryjob.py b/afdo_tools/generate_afdo_from_tryjob.py index 3ed578ea..e398f8a1 100755 --- a/afdo_tools/generate_afdo_from_tryjob.py +++ b/afdo_tools/generate_afdo_from_tryjob.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# Copyright 2019 The Chromium OS Authors. All rights reserved. +# Copyright 2019 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Given a tryjob and perf profile, generates an AFDO profile.""" -from __future__ import print_function import argparse import distutils.spawn @@ -17,149 +16,162 @@ import subprocess import sys import tempfile -_CREATE_LLVM_PROF = 'create_llvm_prof' -_GS_PREFIX = 'gs://' + +_CREATE_LLVM_PROF = "create_llvm_prof" +_GS_PREFIX = "gs://" def _fetch_gs_artifact(remote_name, local_name): - assert remote_name.startswith(_GS_PREFIX) - subprocess.check_call(['gsutil', 'cp', remote_name, local_name]) + assert remote_name.startswith(_GS_PREFIX) + subprocess.check_call(["gsutil", "cp", remote_name, local_name]) def _fetch_and_maybe_unpack(remote_name, local_name): - unpackers = [ - ('.tar.bz2', ['tar', 'xaf']), - ('.bz2', ['bunzip2']), - ('.tar.xz', ['tar', 'xaf']), - ('.xz', ['xz', '-d']), - ] - - unpack_ext = None - unpack_cmd = None - for ext, unpack in unpackers: - if remote_name.endswith(ext): - unpack_ext, unpack_cmd = ext, unpack - break - - download_to = local_name + unpack_ext if unpack_ext else local_name - _fetch_gs_artifact(remote_name, download_to) - if unpack_cmd is not None: - print('Unpacking', download_to) - subprocess.check_output(unpack_cmd + [download_to]) - assert os.path.exists(local_name) + unpackers = [ + (".tar.bz2", ["tar", "xaf"]), + (".bz2", ["bunzip2"]), + (".tar.xz", ["tar", "xaf"]), + (".xz", ["xz", "-d"]), + ] + + unpack_ext = None + unpack_cmd = None + for ext, unpack in unpackers: + if remote_name.endswith(ext): + unpack_ext, unpack_cmd = ext, unpack + break + + download_to = local_name + unpack_ext if unpack_ext else local_name + _fetch_gs_artifact(remote_name, download_to) + if unpack_cmd is not None: + print("Unpacking", download_to) + subprocess.check_output(unpack_cmd + [download_to]) + assert os.path.exists(local_name) def _generate_afdo(perf_profile_loc, tryjob_loc, output_name): - if perf_profile_loc.startswith(_GS_PREFIX): - local_loc = 'perf.data' - _fetch_and_maybe_unpack(perf_profile_loc, local_loc) - perf_profile_loc = local_loc - - chrome_in_debug_loc = 'debug/opt/google/chrome/chrome.debug' - debug_out = 'debug.tgz' - _fetch_gs_artifact(os.path.join(tryjob_loc, 'debug.tgz'), debug_out) - - print('Extracting chrome.debug.') - # This has tons of artifacts, and we only want Chrome; don't waste time - # extracting the rest in _fetch_and_maybe_unpack. - subprocess.check_call(['tar', 'xaf', 'debug.tgz', chrome_in_debug_loc]) - - # Note that the AFDO tool *requires* a binary named `chrome` to be present if - # we're generating a profile for chrome. It's OK for this to be split debug - # information. - os.rename(chrome_in_debug_loc, 'chrome') - - print('Generating AFDO profile.') - subprocess.check_call([ - _CREATE_LLVM_PROF, '--out=' + output_name, '--binary=chrome', - '--profile=' + perf_profile_loc - ]) + if perf_profile_loc.startswith(_GS_PREFIX): + local_loc = "perf.data" + _fetch_and_maybe_unpack(perf_profile_loc, local_loc) + perf_profile_loc = local_loc + + chrome_in_debug_loc = "debug/opt/google/chrome/chrome.debug" + debug_out = "debug.tgz" + _fetch_gs_artifact(os.path.join(tryjob_loc, "debug.tgz"), debug_out) + + print("Extracting chrome.debug.") + # This has tons of artifacts, and we only want Chrome; don't waste time + # extracting the rest in _fetch_and_maybe_unpack. + subprocess.check_call(["tar", "xaf", "debug.tgz", chrome_in_debug_loc]) + + # Note that the AFDO tool *requires* a binary named `chrome` to be present if + # we're generating a profile for chrome. It's OK for this to be split debug + # information. + os.rename(chrome_in_debug_loc, "chrome") + + print("Generating AFDO profile.") + subprocess.check_call( + [ + _CREATE_LLVM_PROF, + "--out=" + output_name, + "--binary=chrome", + "--profile=" + perf_profile_loc, + ] + ) def _abspath_or_gs_link(path): - if path.startswith(_GS_PREFIX): - return path - return os.path.abspath(path) + if path.startswith(_GS_PREFIX): + return path + return os.path.abspath(path) def _tryjob_arg(tryjob_arg): - # Forward gs args through - if tryjob_arg.startswith(_GS_PREFIX): - return tryjob_arg + # Forward gs args through + if tryjob_arg.startswith(_GS_PREFIX): + return tryjob_arg - # Clicking on the 'Artifacts' link gives us a pantheon link that's basically - # a preamble and gs path. - pantheon = 'https://pantheon.corp.google.com/storage/browser/' - if tryjob_arg.startswith(pantheon): - return _GS_PREFIX + tryjob_arg[len(pantheon):] + # Clicking on the 'Artifacts' link gives us a pantheon link that's basically + # a preamble and gs path. + pantheon = "https://pantheon.corp.google.com/storage/browser/" + if tryjob_arg.startswith(pantheon): + return _GS_PREFIX + tryjob_arg[len(pantheon) :] - # Otherwise, only do things with a tryjob ID (e.g. R75-11965.0.0-b3648595) - if not tryjob_arg.startswith('R'): - raise ValueError('Unparseable tryjob arg; give a tryjob ID, pantheon ' - 'link, or gs:// link. Please see source for more.') + # Otherwise, only do things with a tryjob ID (e.g. R75-11965.0.0-b3648595) + if not tryjob_arg.startswith("R"): + raise ValueError( + "Unparseable tryjob arg; give a tryjob ID, pantheon " + "link, or gs:// link. Please see source for more." + ) - chell_path = 'chromeos-image-archive/chell-chrome-pfq-tryjob/' - # ...And assume it's from chell, since that's the only thing we generate - # profiles with today. - return _GS_PREFIX + chell_path + tryjob_arg + chell_path = "chromeos-image-archive/chell-chrome-pfq-tryjob/" + # ...And assume it's from chell, since that's the only thing we generate + # profiles with today. + return _GS_PREFIX + chell_path + tryjob_arg def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - '--perf_profile', - required=True, - help='Path to our perf profile. Accepts either a gs:// path or local ' - 'filepath.') - parser.add_argument( - '--tryjob', - required=True, - type=_tryjob_arg, - help="Path to our tryjob's artifacts. Accepts a gs:// path, pantheon " - 'link, or tryjob ID, e.g. R75-11965.0.0-b3648595. In the last case, ' - 'the assumption is that you ran a chell-chrome-pfq-tryjob.') - parser.add_argument( - '-o', - '--output', - default='afdo.prof', - help='Where to put the AFDO profile. Default is afdo.prof.') - parser.add_argument( - '-k', - '--keep_artifacts_on_failure', - action='store_true', - help="Don't remove the tempdir on failure") - args = parser.parse_args() - - if not distutils.spawn.find_executable(_CREATE_LLVM_PROF): - sys.exit(_CREATE_LLVM_PROF + ' not found; are you in the chroot?') - - profile = _abspath_or_gs_link(args.perf_profile) - afdo_output = os.path.abspath(args.output) - - initial_dir = os.getcwd() - temp_dir = tempfile.mkdtemp(prefix='generate_afdo') - success = True - try: - os.chdir(temp_dir) - _generate_afdo(profile, args.tryjob, afdo_output) - - # The AFDO tooling is happy to generate essentially empty profiles for us. - # Chrome's profiles are often 8+ MB; if we only see a small fraction of - # that, something's off. 512KB was arbitrarily selected. - if os.path.getsize(afdo_output) < 512 * 1024: - raise ValueError('The AFDO profile is suspiciously small for Chrome. ' - 'Something might have gone wrong.') - except: - success = False - raise - finally: - os.chdir(initial_dir) - - if success or not args.keep_artifacts_on_failure: - shutil.rmtree(temp_dir, ignore_errors=True) - else: - print('Artifacts are available at', temp_dir) - - -if __name__ == '__main__': - sys.exit(main()) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--perf_profile", + required=True, + help="Path to our perf profile. Accepts either a gs:// path or local " + "filepath.", + ) + parser.add_argument( + "--tryjob", + required=True, + type=_tryjob_arg, + help="Path to our tryjob's artifacts. Accepts a gs:// path, pantheon " + "link, or tryjob ID, e.g. R75-11965.0.0-b3648595. In the last case, " + "the assumption is that you ran a chell-chrome-pfq-tryjob.", + ) + parser.add_argument( + "-o", + "--output", + default="afdo.prof", + help="Where to put the AFDO profile. Default is afdo.prof.", + ) + parser.add_argument( + "-k", + "--keep_artifacts_on_failure", + action="store_true", + help="Don't remove the tempdir on failure", + ) + args = parser.parse_args() + + if not distutils.spawn.find_executable(_CREATE_LLVM_PROF): + sys.exit(_CREATE_LLVM_PROF + " not found; are you in the chroot?") + + profile = _abspath_or_gs_link(args.perf_profile) + afdo_output = os.path.abspath(args.output) + + initial_dir = os.getcwd() + temp_dir = tempfile.mkdtemp(prefix="generate_afdo") + success = True + try: + os.chdir(temp_dir) + _generate_afdo(profile, args.tryjob, afdo_output) + + # The AFDO tooling is happy to generate essentially empty profiles for us. + # Chrome's profiles are often 8+ MB; if we only see a small fraction of + # that, something's off. 512KB was arbitrarily selected. + if os.path.getsize(afdo_output) < 512 * 1024: + raise ValueError( + "The AFDO profile is suspiciously small for Chrome. " + "Something might have gone wrong." + ) + except: + success = False + raise + finally: + os.chdir(initial_dir) + + if success or not args.keep_artifacts_on_failure: + shutil.rmtree(temp_dir, ignore_errors=True) + else: + print("Artifacts are available at", temp_dir) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/afdo_tools/run_afdo_tryjob.py b/afdo_tools/run_afdo_tryjob.py index e14cd918..013e10c6 100755 --- a/afdo_tools/run_afdo_tryjob.py +++ b/afdo_tools/run_afdo_tryjob.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# Copyright 2019 The Chromium OS Authors. All rights reserved. +# Copyright 2019 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. @@ -49,7 +49,6 @@ If you provide neither --use_afdo_generation_stage nor since it's safer. """ -from __future__ import print_function import argparse import collections @@ -60,112 +59,124 @@ import time def main(): - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - '--force_no_patches', - action='store_true', - help='Run even if no patches are provided') - parser.add_argument( - '--tag_profiles_with_current_time', - action='store_true', - help='Perf profile names will have the current time added to them.') - parser.add_argument( - '--use_afdo_generation_stage', - action='store_true', - help='Perf profiles will be automatically converted to AFDO profiles.') - parser.add_argument( - '-g', - '--patch', - action='append', - default=[], - help='A patch to add to the AFDO run') - parser.add_argument( - '-n', - '--dry_run', - action='store_true', - help='Just print the command that would be run') - args = parser.parse_args() - - dry_run = args.dry_run - force_no_patches = args.force_no_patches - tag_profiles_with_current_time = args.tag_profiles_with_current_time - use_afdo_generation_stage = args.use_afdo_generation_stage - user_patches = args.patch - - if tag_profiles_with_current_time and use_afdo_generation_stage: - raise ValueError("You can't tag profiles with the time + have " - 'afdo-generate') - - if not tag_profiles_with_current_time and not use_afdo_generation_stage: - print('Neither current_time nor afdo_generate asked for. Assuming you ' - 'prefer current time tagging.') - print('You have 5 seconds to cancel and try again.') - print() + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--force_no_patches", + action="store_true", + help="Run even if no patches are provided", + ) + parser.add_argument( + "--tag_profiles_with_current_time", + action="store_true", + help="Perf profile names will have the current time added to them.", + ) + parser.add_argument( + "--use_afdo_generation_stage", + action="store_true", + help="Perf profiles will be automatically converted to AFDO profiles.", + ) + parser.add_argument( + "-g", + "--patch", + action="append", + default=[], + help="A patch to add to the AFDO run", + ) + parser.add_argument( + "-n", + "--dry_run", + action="store_true", + help="Just print the command that would be run", + ) + args = parser.parse_args() + + dry_run = args.dry_run + force_no_patches = args.force_no_patches + tag_profiles_with_current_time = args.tag_profiles_with_current_time + use_afdo_generation_stage = args.use_afdo_generation_stage + user_patches = args.patch + + if tag_profiles_with_current_time and use_afdo_generation_stage: + raise ValueError( + "You can't tag profiles with the time + have " "afdo-generate" + ) + + if not tag_profiles_with_current_time and not use_afdo_generation_stage: + print( + "Neither current_time nor afdo_generate asked for. Assuming you " + "prefer current time tagging." + ) + print("You have 5 seconds to cancel and try again.") + print() + if not dry_run: + time.sleep(5) + tag_profiles_with_current_time = True + + patches = [ + # Send profiles to localmirror instead of chromeos-prebuilt. This should + # always be done, since sending profiles into production is bad. :) + # https://chromium-review.googlesource.com/c/chromiumos/third_party/autotest/+/1436158 + 1436158, + # Force profile generation. Otherwise, we'll decide to not spawn off the + # perf hwtests. + # https://chromium-review.googlesource.com/c/chromiumos/chromite/+/1313291 + 1313291, + ] + + if tag_profiles_with_current_time: + # Tags the profiles with the current time of day. As detailed in the + # docstring, this is desirable unless you're sure that this is the only + # experimental profile that will be generated today. + # https://chromium-review.googlesource.com/c/chromiumos/third_party/autotest/+/1436157 + patches.append(1436157) + + if use_afdo_generation_stage: + # Make the profile generation stage look in localmirror, instead of having + # it look in chromeos-prebuilt. Without this, we'll never upload + # chrome.debug or try to generate an AFDO profile. + # https://chromium-review.googlesource.com/c/chromiumos/chromite/+/1436583 + patches.append(1436583) + + if not user_patches and not force_no_patches: + raise ValueError( + "No patches given; pass --force_no_patches to force a " "tryjob" + ) + + for patch in user_patches: + # We accept two formats. Either a URL that ends with a number, or a number. + if patch.startswith("http"): + patch = patch.split("/")[-1] + patches.append(int(patch)) + + count = collections.Counter(patches) + too_many = [k for k, v in count.items() if v > 1] + if too_many: + too_many.sort() + raise ValueError( + "Patch(es) asked for application more than once: %s" % too_many + ) + + args = [ + "cros", + "tryjob", + ] + + for patch in patches: + args += ["-g", str(patch)] + + args += [ + "--nochromesdk", + "--hwtest", + "chell-chrome-pfq-tryjob", + ] + + print(" ".join(pipes.quote(a) for a in args)) if not dry_run: - time.sleep(5) - tag_profiles_with_current_time = True - - patches = [ - # Send profiles to localmirror instead of chromeos-prebuilt. This should - # always be done, since sending profiles into production is bad. :) - # https://chromium-review.googlesource.com/c/chromiumos/third_party/autotest/+/1436158 - 1436158, - # Force profile generation. Otherwise, we'll decide to not spawn off the - # perf hwtests. - # https://chromium-review.googlesource.com/c/chromiumos/chromite/+/1313291 - 1313291, - ] - - if tag_profiles_with_current_time: - # Tags the profiles with the current time of day. As detailed in the - # docstring, this is desirable unless you're sure that this is the only - # experimental profile that will be generated today. - # https://chromium-review.googlesource.com/c/chromiumos/third_party/autotest/+/1436157 - patches.append(1436157) - - if use_afdo_generation_stage: - # Make the profile generation stage look in localmirror, instead of having - # it look in chromeos-prebuilt. Without this, we'll never upload - # chrome.debug or try to generate an AFDO profile. - # https://chromium-review.googlesource.com/c/chromiumos/chromite/+/1436583 - patches.append(1436583) - - if not user_patches and not force_no_patches: - raise ValueError('No patches given; pass --force_no_patches to force a ' - 'tryjob') - - for patch in user_patches: - # We accept two formats. Either a URL that ends with a number, or a number. - if patch.startswith('http'): - patch = patch.split('/')[-1] - patches.append(int(patch)) - - count = collections.Counter(patches) - too_many = [k for k, v in count.items() if v > 1] - if too_many: - too_many.sort() - raise ValueError( - 'Patch(es) asked for application more than once: %s' % too_many) - - args = [ - 'cros', - 'tryjob', - ] - - for patch in patches: - args += ['-g', str(patch)] - - args += [ - '--nochromesdk', - '--hwtest', - 'chell-chrome-pfq-tryjob', - ] - - print(' '.join(pipes.quote(a) for a in args)) - if not dry_run: - sys.exit(subprocess.call(args)) - - -if __name__ == '__main__': - main() + sys.exit(subprocess.call(args)) + + +if __name__ == "__main__": + main() diff --git a/afdo_tools/update_kernel_afdo b/afdo_tools/update_kernel_afdo index ff0ab224..9e4d645d 100755 --- a/afdo_tools/update_kernel_afdo +++ b/afdo_tools/update_kernel_afdo @@ -1,5 +1,5 @@ #!/bin/bash -# Copyright 2020 The Chromium OS Authors. All rights reserved. +# Copyright 2020 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. @@ -9,37 +9,74 @@ # USAGE=" -Usage: $(basename $0) [main|beta|stable|all] [--help] +Usage: $(basename "$0") [--noupload|-upload] [main|beta|stable|all] [--help] Description: The script takes one optional argument which is the channel where we want to update the kernel afdo and creates a commit (or commits with \"all\" channels) in the corresponding branch. No arguments defaults to \"all\". - Follow the prompt to submit the changes. + Follow the prompt to upload the changes. NO CLEAN-UP NEEDED. The script ignores any local changes and keeps the current branch unchanged. + + Args: + --help Show this help. + --upload Upload CLs when the update succeeded (default). + --noupload Do not upload CLs. Instead, print the upload commands. + main|beta|stable Update metadata only on the specified channel. " set -eu set -o pipefail -GS_BASE=gs://chromeos-prebuilt/afdo-job/vetted/kernel -KVERS="4.4 4.14 4.19 5.4" +AMD_GS_BASE=gs://chromeos-prebuilt/afdo-job/vetted/kernel +ARM_GS_BASE=gs://chromeos-prebuilt/afdo-job/vetted/kernel/arm +AMD_KVERS="4.14 4.19 5.4 5.10" +ARM_KVERS="5.15" failed_channels="" # Add skipped chrome branches in ascending order here. SKIPPED_BRANCHES="95" +# NOTE: We enable/disable kernel AFDO starting from a particular branch. +# For example if we want to enable kernel AFDO in 5.15, first, we do it +# in main. In this case we want to disable it in beta and stable branches. +# The second scenario is when we want to disable kernel AFDO (when all devices +# move to kernelnext and there are no new profiles from the field). In this +# case we disable AFDO in main but still keep it live in beta and stable. +declare -A SKIPPED_KVERS_IN_BRANCHES +# In SKIPPED_KVERS_IN_BRANCHES +# - key is a branch number string; +# - value is the list of kernels separated by space. +# Example: SKIPPED_KVERS_IN_BRANCHES["105"]="4.4 4.14" + +# b/223115767. In M-100 there are no new profiles in 5.10. And AFDO is not +# enabled on any 5.10 board in M-100 either. +SKIPPED_KVERS_IN_BRANCHES["100"]="5.10" + script_dir=$(dirname "$0") tc_utils_dir="${script_dir}/.." metadata_dir="${tc_utils_dir}/afdo_metadata" -outfile="$(realpath --relative-to="${tc_utils_dir}" \ +amd_outfile="$(realpath --relative-to="${tc_utils_dir}" \ "${metadata_dir}"/kernel_afdo.json)" +arm_outfile="$(realpath --relative-to="${tc_utils_dir}" \ + "${metadata_dir}"/kernel_arm_afdo.json)" # Convert toolchain_utils into the absolute path. -abs_tc_utils_dir="$(realpath ${tc_utils_dir})" +abs_tc_utils_dir="$(realpath "${tc_utils_dir}")" # Check profiles uploaded within the last week. expected_time=$(date +%s -d "week ago") +# Upload CLs on success. +upload_cl=true + +ARCHS="amd arm" +declare -A arch_gsbase arch_kvers arch_outfile +arch_gsbase["amd"]="${AMD_GS_BASE}" +arch_gsbase["arm"]="${ARM_GS_BASE}" +arch_kvers["amd"]="${AMD_KVERS}" +arch_kvers["arm"]="${ARM_KVERS}" +arch_outfile["amd"]="${amd_outfile}" +arch_outfile["arm"]="${arm_outfile}" declare -A branch branch_number commit remote_repo=$(git -C "${tc_utils_dir}" remote) @@ -47,10 +84,9 @@ canary_ref="refs/heads/main" # Read the last two release-Rxx from remote branches # and assign them to stable_ref and beta_ref. # sort -V is the version sort which puts R100 after R99. -last_branches=$(git -C "${tc_utils_dir}" ls-remote -h "${remote_repo}" \ - release-R\* | cut -f2 | sort -V | tail -n 2) # We need `echo` to convert newlines into spaces for read. -read stable_ref beta_ref <<< $(echo ${last_branches}) +read -r stable_ref beta_ref <<< "$(git -C "${tc_utils_dir}" ls-remote -h \ + "${remote_repo}" release-R\* | cut -f2 | sort -V | tail -n 2 | paste -s)" # Branch names which start from release-R. branch["beta"]=${beta_ref##*/} branch["stable"]=${stable_ref##*/} @@ -62,33 +98,53 @@ branch_number["stable"]=$(echo "${branch["stable"]}" | \ branch_number["beta"]=$(echo "${branch["beta"]}" | \ sed -n -e "s/^release-R\([0-9][0-9]*\).*$/\1/p") branch_number["canary"]="$((branch_number[beta] + 1))" -for skipped_branch in $SKIPPED_BRANCHES ; do - if [[ ${branch_number["canary"]} == $skipped_branch ]] ; then +for skipped_branch in ${SKIPPED_BRANCHES} ; do + if [[ ${branch_number["canary"]} == "${skipped_branch}" ]] ; then ((branch_number[canary]++)) fi done # Without arguments the script updates all branches. -channels=${1:-"all"} -case "${channels}" in +channels="" +for arg in "$@" +do + case "${arg}" in stable | canary | beta ) + channels="${channels} ${arg}" ;; main ) - channels="canary" + channels="${channels} canary" ;; all ) channels="canary beta stable" ;; + --noupload | --no-upload) + upload_cl=false + ;; + --upload) + upload_cl=true + ;; --help | help | -h ) - echo "$USAGE" + echo "${USAGE}" exit 0 ;; - * ) - echo "Channel \"${channels}\" is not supported. + -*) + echo "Option \"${arg}\" is not supported." >&2 + echo "${USAGE}" + exit 1 + ;; + *) + echo "Channel \"${arg}\" is not supported. Must be main (or canary), beta, stable or all." >&2 - echo "$USAGE" + echo "${USAGE}" exit 1 -esac + esac +done + +if [[ -z "${channels}" ]] +then + channels="canary beta stable" +fi # Fetch latest branches. git -C "${tc_utils_dir}" fetch "${remote_repo}" @@ -99,11 +155,20 @@ echo "-> Working in ${worktree_dir}" # This way we don't need to clean-up and sync toolchain_utils before the # change. Neither we should care about clean-up after the submit. git -C "${tc_utils_dir}" worktree add --detach "${worktree_dir}" -trap "git -C ${abs_tc_utils_dir} worktree remove ${worktree_dir}" EXIT -cd "${worktree_dir}" +trap 'git -C "${abs_tc_utils_dir}" worktree remove -f "${worktree_dir}"' EXIT +pushd "${worktree_dir}" for channel in ${channels} do + set +u + if [[ -n "${commit[${channel}]}" ]] + then + echo "Skipping channel ${channel} which already has commit\ + ${commit[${channel}]}." + continue + fi + set -u + errs="" successes=0 curr_branch_number=${branch_number[${channel}]} @@ -111,87 +176,124 @@ do echo echo "Checking \"${channel}\" channel..." echo "branch_number=${curr_branch_number} branch=${curr_branch}" - json="{" - sep="" - for kver in $KVERS + + git reset --hard HEAD + git checkout "${remote_repo}/${curr_branch}" + + for arch in ${ARCHS} do - # Sort the gs output by timestamp (default ordering is by name, so - # R86-13310.3-1594633089.gcov.xz goes after R86-13310.18-1595237847.gcov.xz) - latest=$(gsutil.py ls -l "$GS_BASE/$kver/" | sort -k2 | \ - grep "R${curr_branch_number}" | tail -1 || true) - if [[ -z "$latest" && "${channel}" != "stable" ]] - then - # if no profiles exist for the current branch, try the previous branch - latest=$(gsutil.py ls -l "$GS_BASE/$kver/" | sort -k2 | \ - grep "R$((curr_branch_number - 1))" | tail -1) - fi + json="{" + sep="" + for kver in ${arch_kvers[${arch}]} + do + # Skip kernels disabled in this branch. + skipped=false + for skipped_branch in "${!SKIPPED_KVERS_IN_BRANCHES[@]}" + do + if [[ ${curr_branch_number} == "${skipped_branch}" ]] + then + # Current branch is in the keys of SKIPPED_KVERS_IN_BRANCHES. + # Now lets check if $kver is in the list. + for skipped_kver in ${SKIPPED_KVERS_IN_BRANCHES[${skipped_branch}]} + do + if [[ ${kver} == "${skipped_kver}" ]] + then + skipped=true + break + fi + done + fi + done + if ${skipped} + then + echo "${kver} is skipped in branch ${curr_branch_number}. Skip it." + continue + fi + # Sort the gs output by timestamp, default ordering is by name. So + # R86-13310.3-1594633089.gcov.xz goes after + # R86-13310.18-1595237847.gcov.xz. + latest=$(gsutil.py ls -l "${arch_gsbase[${arch}]}/${kver}/" | sort -k2 | \ + grep "R${curr_branch_number}" | tail -1 || true) + if [[ -z "${latest}" && "${channel}" != "stable" ]] + then + # if no profiles exist for the current branch, try the previous branch + latest=$(gsutil.py ls -l "${arch_gsbase[${arch}]}/${kver}/" | \ + sort -k2 | grep "R$((curr_branch_number - 1))" | tail -1) + fi - # Verify that the file has the expected date. - file_time=$(echo "$latest" | awk '{print $2}') - file_time_unix=$(date +%s -d "$file_time") - if [ $file_time_unix -lt $expected_time ] - then - expected=$(env TZ=UTC date +%Y-%m-%dT%H:%M:%SZ -d @$expected_time) - echo "Wrong date for $kver: $file_time is before $expected" >&2 - errs="$errs $kver" - continue - fi + # Verify that the file has the expected date. + file_time=$(echo "${latest}" | awk '{print $2}') + file_time_unix=$(date +%s -d "${file_time}") + if [ "${file_time_unix}" -lt "${expected_time}" ] + then + expected=$(env TZ=UTC date +%Y-%m-%dT%H:%M:%SZ -d @"${expected_time}") + echo "Wrong date for ${kver}: ${file_time} is before ${expected}" >&2 + errs="${errs} ${kver}" + continue + fi - # Generate JSON. - json_kver=$(echo "$kver" | tr . _) - # b/147370213 (migrating profiles from gcov format) may result in the - # pattern below no longer doing the right thing. - name=$(echo "$latest" | sed 's%.*/\(.*\)\.gcov.*%\1%') - json=$(cat <<EOT -$json$sep - "chromeos-kernel-$json_kver": { - "name": "$name" + # Generate JSON. + json_kver=$(echo "${kver}" | tr . _) + # b/147370213 (migrating profiles from gcov format) may result in the + # pattern below no longer doing the right thing. + name="$(basename "${latest%.gcov.*}")" + # Skip kernels with no AFDO support in the current channel. + if [[ "${name}" == "" ]] + then + continue + fi + json=$(cat <<EOT +${json}${sep} + "chromeos-kernel-${json_kver}": { + "name": "${name}" } EOT - ) - sep="," - successes=$((successes + 1)) - done + ) + sep="," + successes=$((successes + 1)) + done # kvers loop - # If we did not succeed for any kvers, exit now. - if [[ $successes -eq 0 ]] - then - echo "error: AFDO profiles out of date for all kernel versions" >&2 - failed_channels="${failed_channels} ${channel}" - continue - fi + # If we did not succeed for any kvers, exit now. + if [[ ${successes} -eq 0 ]] + then + echo "error: AFDO profiles out of date for all kernel versions" >&2 + failed_channels="${failed_channels} ${channel}" + continue + fi - git reset --hard HEAD - echo git checkout "${remote_repo}/${curr_branch}" - git checkout "${remote_repo}/${curr_branch}" + # Write new JSON file. + # Don't use `echo` since `json` might have esc characters in it. + printf "%s\n}\n" "${json}" > "${arch_outfile[${arch}]}" - # Write new JSON file. - # Don't use `echo` since `json` might have esc characters in it. - printf "%s\n}\n" "$json" > "$outfile" + # If no changes were made, say so. + outdir=$(dirname "${arch_outfile[${arch}]}") + shortstat=$(cd "${outdir}" &&\ + git status --short "$(basename "${arch_outfile[${arch}]}")") + [ -z "${shortstat}" ] &&\ + echo "$(basename "${arch_outfile[${arch}]}") is up to date." \ + && continue - # If no changes were made, say so. - outdir=$(dirname "$outfile") - shortstat=$(cd "$outdir" && git status --short $(basename "$outfile")) - [ -z "$shortstat" ] && echo $(basename "$outfile")" is up to date." \ - && continue + # If we had any errors, warn about them. + if [[ -n "${errs}" ]] + then + echo "warning: failed to update ${errs} in ${channel}" >&2 + failed_channels="${failed_channels} ${channel}" + continue + fi - # If we had any errors, warn about them. - if [[ -n "$errs" ]] - then - echo "warning: failed to update $errs in ${channel}" >&2 - failed_channels="${failed_channels} ${channel}" - continue - fi + git add "${arch_outfile[${arch}]}" + done # ARCHS loop - git add afdo_metadata/kernel_afdo.json case "${channel}" in canary ) - commit_contents="afdo_metadata: Publish the new kernel profiles - -Update chromeos-kernel-4_4 -Update chromeos-kernel-4_14 -Update chromeos-kernel-4_19 -Update chromeos-kernel-5_4 + commit_contents=$'afdo_metadata: Publish the new kernel profiles\n\n' + for arch in ${ARCHS} ; do + for kver in ${arch_kvers[${arch}]} ; do + commit_contents="${commit_contents}Update ${arch} profile on\ + chromeos-kernel-${kver}"$'\n' + done + done + commit_contents="${commit_contents} BUG=None TEST=Verified in kernel-release-afdo-verify-orchestrator" @@ -215,20 +317,30 @@ TEST=Verified in kernel-release-afdo-verify-orchestrator" commit[${channel}]=$(git -C "${worktree_dir}" rev-parse HEAD) done +popd echo # Array size check doesn't play well with the unbound variable option. set +u if [[ ${#commit[@]} -gt 0 ]] then set -u - echo "The change is applied in ${!commit[@]}." - echo "Run these commands to submit the change:" - echo - for channel in ${!commit[@]} - do - echo -e "\tgit -C ${tc_utils_dir} push ${remote_repo} \ -${commit[${channel}]}:refs/for/${branch[${channel}]}" - done + echo "The change is applied in ${!commit[*]}." + if ${upload_cl} + then + for channel in "${!commit[@]}" + do + git -C "${tc_utils_dir}" push "${remote_repo}" \ + "${commit[${channel}]}:refs/for/${branch[${channel}]}" + done + else + echo "Run these commands to upload the change:" + echo + for channel in "${!commit[@]}" + do + echo -e "\tgit -C ${tc_utils_dir} push ${remote_repo} \ + ${commit[${channel}]}:refs/for/${branch[${channel}]}" + done + fi # Report failed channels. if [[ -n "${failed_channels}" ]] |