447 lines
16 KiB
Python
Executable File
447 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
# Copyright 2019 The Chromium OS Authors. All rights reserved.
|
|
# Use of this source code is governed by a BSD-style license that can be
|
|
# found in the LICENSE file.
|
|
|
|
"""AFDO Profile analysis tool.
|
|
|
|
This script takes a good AFDO profile, a bad AFDO profile, and an external
|
|
script which deems particular AFDO profiles as GOOD/BAD/SKIP, and an output
|
|
file as arguments. Given these pieces of information, it analyzes the profiles
|
|
to try and determine what exactly is bad about the bad profile. It does this
|
|
with three main techniques: bisecting search, range search, and rough diff-ing.
|
|
|
|
The external script communicates the 'goodness' of an AFDO profile through its
|
|
exit code. The codes known to this script are:
|
|
- 0: the AFDO profile produced a good binary
|
|
- 1: the AFDO profile produced a bad binary
|
|
- 125: no result could be determined; just try another profile
|
|
- >127: quit immediately
|
|
"""
|
|
|
|
from __future__ import division, print_function
|
|
|
|
import argparse
|
|
import json
|
|
# Pylint recommends we use "from chromite.lib import cros_logging as logging".
|
|
# Chromite specific policy message, we want to keep using the standard logging
|
|
# pylint: disable=cros-logging-import
|
|
import logging
|
|
import os
|
|
import random
|
|
import subprocess
|
|
import time
|
|
from datetime import date
|
|
from enum import IntEnum
|
|
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
|
|
|
|
|
|
statuses = StatusEnum.__members__.values()
|
|
|
|
_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)
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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()
|
|
|
|
|
|
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())
|