1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3# Copyright 2019 The ChromiumOS Authors 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6 7"""AFDO Profile analysis tool. 8 9This script takes a good AFDO profile, a bad AFDO profile, and an external 10script which deems particular AFDO profiles as GOOD/BAD/SKIP, and an output 11file as arguments. Given these pieces of information, it analyzes the profiles 12to try and determine what exactly is bad about the bad profile. It does this 13with three main techniques: bisecting search, range search, and rough diff-ing. 14 15The external script communicates the 'goodness' of an AFDO profile through its 16exit code. The codes known to this script are: 17 - 0: the AFDO profile produced a good binary 18 - 1: the AFDO profile produced a bad binary 19 - 125: no result could be determined; just try another profile 20 - >127: quit immediately 21""" 22 23 24import argparse 25import json 26import logging 27import os 28import random 29import subprocess 30import time 31from datetime import date 32from enum import IntEnum 33from tempfile import mkstemp 34 35 36class StatusEnum(IntEnum): 37 """Enum of valid statuses returned by profile decider.""" 38 39 GOOD_STATUS = 0 40 BAD_STATUS = 1 41 SKIP_STATUS = 125 42 PROBLEM_STATUS = 127 43 44 45statuses = StatusEnum.__members__.values() 46 47_NUM_RUNS_RANGE_SEARCH = 20 # how many times range search should run its algo 48 49 50def json_to_text(json_prof): 51 text_profile = [] 52 for func in json_prof: 53 text_profile.append(func) 54 text_profile.append(json_prof[func]) 55 return "".join(text_profile) 56 57 58def text_to_json(f): 59 """Performs basic parsing of an AFDO text-based profile. 60 61 This parsing expects an input file object with contents of the form generated 62 by bin/llvm-profdata (within an LLVM build). 63 """ 64 results = {} 65 curr_func = None 66 curr_data = [] 67 for line in f: 68 if not line.startswith(" "): 69 if curr_func: 70 results[curr_func] = "".join(curr_data) 71 curr_data = [] 72 curr_func, rest = line.split(":", 1) 73 curr_func = curr_func.strip() 74 curr_data.append(":" + rest) 75 else: 76 curr_data.append(line) 77 78 if curr_func: 79 results[curr_func] = "".join(curr_data) 80 return results 81 82 83def prof_to_tmp(prof): 84 """Creates (and returns) temp filename for given JSON-based AFDO profile.""" 85 fd, temp_path = mkstemp() 86 text_profile = json_to_text(prof) 87 with open(temp_path, "w") as f: 88 f.write(text_profile) 89 os.close(fd) 90 return temp_path 91 92 93class DeciderState(object): 94 """Class for the external decider.""" 95 96 def __init__(self, state_file, external_decider, seed): 97 self.accumulated_results = [] # over this run of the script 98 self.external_decider = external_decider 99 self.saved_results = [] # imported from a previous run of this script 100 self.state_file = state_file 101 self.seed = seed if seed is not None else time.time() 102 103 def load_state(self): 104 if not os.path.exists(self.state_file): 105 logging.info( 106 "State file %s is empty, starting from beginning", 107 self.state_file, 108 ) 109 return 110 111 with open(self.state_file, encoding="utf-8") as f: 112 try: 113 data = json.load(f) 114 except: 115 raise ValueError( 116 "Provided state file %s to resume from does not" 117 " contain a valid JSON." % self.state_file 118 ) 119 120 if "seed" not in data or "accumulated_results" not in data: 121 raise ValueError( 122 "Provided state file %s to resume from does not contain" 123 " the correct information" % self.state_file 124 ) 125 126 self.seed = data["seed"] 127 self.saved_results = data["accumulated_results"] 128 logging.info("Restored state from %s...", self.state_file) 129 130 def save_state(self): 131 state = { 132 "seed": self.seed, 133 "accumulated_results": self.accumulated_results, 134 } 135 tmp_file = self.state_file + ".new" 136 with open(tmp_file, "w", encoding="utf-8") as f: 137 json.dump(state, f, indent=2) 138 os.rename(tmp_file, self.state_file) 139 logging.info("Logged state to %s...", self.state_file) 140 141 def run(self, prof, save_run=True): 142 """Run the external deciding script on the given profile.""" 143 if self.saved_results and save_run: 144 result = self.saved_results.pop(0) 145 self.accumulated_results.append(result) 146 self.save_state() 147 return StatusEnum(result) 148 149 filename = prof_to_tmp(prof) 150 151 try: 152 return_code = subprocess.call([self.external_decider, filename]) 153 finally: 154 os.remove(filename) 155 156 if return_code in statuses: 157 status = StatusEnum(return_code) 158 if status == StatusEnum.PROBLEM_STATUS: 159 prof_file = prof_to_tmp(prof) 160 raise RuntimeError( 161 "Provided decider script returned PROBLEM_STATUS " 162 "when run on profile stored at %s. AFDO Profile " 163 "analysis aborting" % prof_file 164 ) 165 if save_run: 166 self.accumulated_results.append(status.value) 167 logging.info( 168 "Run %d of external script %s returned %s", 169 len(self.accumulated_results), 170 self.external_decider, 171 status.name, 172 ) 173 self.save_state() 174 return status 175 raise ValueError( 176 "Provided external script had unexpected return code %d" 177 % return_code 178 ) 179 180 181def bisect_profiles(decider, good, bad, common_funcs, lo, hi): 182 """Recursive function which bisects good and bad profiles. 183 184 Args: 185 decider: function which, given a JSON-based AFDO profile, returns an 186 element of 'statuses' based on the status of the profile 187 good: JSON-based good AFDO profile 188 bad: JSON-based bad AFDO profile 189 common_funcs: the list of functions which have top-level profiles in both 190 'good' and 'bad' 191 lo: lower bound of range being bisected on 192 hi: upper bound of range being bisected on 193 194 Returns a dictionary with two keys: 'individuals' and 'ranges'. 195 'individuals': a list of individual functions found to make the profile BAD 196 'ranges': a list of lists of function names. Each list of functions is a list 197 such that including all of those from the bad profile makes the good 198 profile BAD. It may not be the smallest problematic combination, but 199 definitely contains a problematic combination of profiles. 200 """ 201 202 results = {"individuals": [], "ranges": []} 203 if hi - lo <= 1: 204 logging.info( 205 "Found %s as a problematic function profile", common_funcs[lo] 206 ) 207 results["individuals"].append(common_funcs[lo]) 208 return results 209 210 mid = (lo + hi) // 2 211 lo_mid_prof = good.copy() # covers bad from lo:mid 212 mid_hi_prof = good.copy() # covers bad from mid:hi 213 for func in common_funcs[lo:mid]: 214 lo_mid_prof[func] = bad[func] 215 for func in common_funcs[mid:hi]: 216 mid_hi_prof[func] = bad[func] 217 218 lo_mid_verdict = decider.run(lo_mid_prof) 219 mid_hi_verdict = decider.run(mid_hi_prof) 220 221 if lo_mid_verdict == StatusEnum.BAD_STATUS: 222 result = bisect_profiles(decider, good, bad, common_funcs, lo, mid) 223 results["individuals"].extend(result["individuals"]) 224 results["ranges"].extend(result["ranges"]) 225 if mid_hi_verdict == StatusEnum.BAD_STATUS: 226 result = bisect_profiles(decider, good, bad, common_funcs, mid, hi) 227 results["individuals"].extend(result["individuals"]) 228 results["ranges"].extend(result["ranges"]) 229 230 # neither half is bad -> the issue is caused by several things occuring 231 # in conjunction, and this combination crosses 'mid' 232 if lo_mid_verdict == mid_hi_verdict == StatusEnum.GOOD_STATUS: 233 problem_range = range_search(decider, good, bad, common_funcs, lo, hi) 234 if problem_range: 235 logging.info( 236 "Found %s as a problematic combination of profiles", 237 str(problem_range), 238 ) 239 results["ranges"].append(problem_range) 240 241 return results 242 243 244def bisect_profiles_wrapper(decider, good, bad, perform_check=True): 245 """Wrapper for recursive profile bisection.""" 246 247 # Validate good and bad profiles are such, otherwise bisection reports noise 248 # Note that while decider is a random mock, these assertions may fail. 249 if perform_check: 250 if decider.run(good, save_run=False) != StatusEnum.GOOD_STATUS: 251 raise ValueError("Supplied good profile is not actually GOOD") 252 if decider.run(bad, save_run=False) != StatusEnum.BAD_STATUS: 253 raise ValueError("Supplied bad profile is not actually BAD") 254 255 common_funcs = sorted(func for func in good if func in bad) 256 if not common_funcs: 257 return {"ranges": [], "individuals": []} 258 259 # shuffle because the results of our analysis can be quite order-dependent 260 # but this list has no inherent ordering. By shuffling each time, the chances 261 # of finding new, potentially interesting results are increased each time 262 # the program is run 263 random.shuffle(common_funcs) 264 results = bisect_profiles( 265 decider, good, bad, common_funcs, 0, len(common_funcs) 266 ) 267 results["ranges"].sort() 268 results["individuals"].sort() 269 return results 270 271 272def range_search(decider, good, bad, common_funcs, lo, hi): 273 """Searches for problematic range crossing mid border. 274 275 The main inner algorithm is the following, which looks for the smallest 276 possible ranges with problematic combinations. It starts the upper bound at 277 the midpoint, and increments in halves until it gets a BAD profile. 278 Then, it increments the lower bound (in halves) until the resultant profile 279 is GOOD, and then we have a range that causes 'BAD'ness. 280 281 It does this _NUM_RUNS_RANGE_SEARCH times, and shuffles the functions being 282 looked at uniquely each time to try and get the smallest possible range 283 of functions in a reasonable timeframe. 284 """ 285 286 average = lambda x, y: int(round((x + y) // 2.0)) 287 288 def find_upper_border(good_copy, funcs, lo, hi, last_bad_val=None): 289 """Finds the upper border of problematic range.""" 290 mid = average(lo, hi) 291 if mid in (lo, hi): 292 return last_bad_val or hi 293 294 for func in funcs[lo:mid]: 295 good_copy[func] = bad[func] 296 verdict = decider.run(good_copy) 297 298 # reset for next iteration 299 for func in funcs: 300 good_copy[func] = good[func] 301 302 if verdict == StatusEnum.BAD_STATUS: 303 return find_upper_border(good_copy, funcs, lo, mid, mid) 304 return find_upper_border(good_copy, funcs, mid, hi, last_bad_val) 305 306 def find_lower_border(good_copy, funcs, lo, hi, last_bad_val=None): 307 """Finds the lower border of problematic range.""" 308 mid = average(lo, hi) 309 if mid in (lo, hi): 310 return last_bad_val or lo 311 312 for func in funcs[lo:mid]: 313 good_copy[func] = good[func] 314 verdict = decider.run(good_copy) 315 316 # reset for next iteration 317 for func in funcs: 318 good_copy[func] = bad[func] 319 320 if verdict == StatusEnum.BAD_STATUS: 321 return find_lower_border(good_copy, funcs, mid, hi, lo) 322 return find_lower_border(good_copy, funcs, lo, mid, last_bad_val) 323 324 lo_mid_funcs = [] 325 mid_hi_funcs = [] 326 min_range_funcs = [] 327 for _ in range(_NUM_RUNS_RANGE_SEARCH): 328 329 if min_range_funcs: # only examine range we've already narrowed to 330 random.shuffle(lo_mid_funcs) 331 random.shuffle(mid_hi_funcs) 332 else: # consider lo-mid and mid-hi separately bc must cross border 333 mid = (lo + hi) // 2 334 lo_mid_funcs = common_funcs[lo:mid] 335 mid_hi_funcs = common_funcs[mid:hi] 336 337 funcs = lo_mid_funcs + mid_hi_funcs 338 hi = len(funcs) 339 mid = len(lo_mid_funcs) 340 lo = 0 341 342 # because we need the problematic pair to pop up before we can narrow it 343 prof = good.copy() 344 for func in lo_mid_funcs: 345 prof[func] = bad[func] 346 347 upper_border = find_upper_border(prof, funcs, mid, hi) 348 for func in lo_mid_funcs + funcs[mid:upper_border]: 349 prof[func] = bad[func] 350 351 lower_border = find_lower_border(prof, funcs, lo, mid) 352 curr_range_funcs = funcs[lower_border:upper_border] 353 354 if not min_range_funcs or len(curr_range_funcs) < len(min_range_funcs): 355 min_range_funcs = curr_range_funcs 356 lo_mid_funcs = lo_mid_funcs[ 357 lo_mid_funcs.index(min_range_funcs[0]) : 358 ] 359 mid_hi_funcs = mid_hi_funcs[ 360 : mid_hi_funcs.index(min_range_funcs[-1]) + 1 361 ] 362 if len(min_range_funcs) == 2: 363 min_range_funcs.sort() 364 return min_range_funcs # can't get any smaller 365 366 min_range_funcs.sort() 367 return min_range_funcs 368 369 370def check_good_not_bad(decider, good, bad): 371 """Check if bad prof becomes GOOD by adding funcs it lacks from good prof""" 372 bad_copy = bad.copy() 373 for func in good: 374 if func not in bad: 375 bad_copy[func] = good[func] 376 return decider.run(bad_copy) == StatusEnum.GOOD_STATUS 377 378 379def check_bad_not_good(decider, good, bad): 380 """Check if good prof BAD after adding funcs bad prof has that good doesnt""" 381 good_copy = good.copy() 382 for func in bad: 383 if func not in good: 384 good_copy[func] = bad[func] 385 return decider.run(good_copy) == StatusEnum.BAD_STATUS 386 387 388def parse_args(): 389 parser = argparse.ArgumentParser( 390 description=__doc__, 391 formatter_class=argparse.RawDescriptionHelpFormatter, 392 ) 393 parser.add_argument( 394 "--good_prof", 395 required=True, 396 help='Text-based "Good" profile for analysis', 397 ) 398 parser.add_argument( 399 "--bad_prof", 400 required=True, 401 help='Text-based "Bad" profile for analysis', 402 ) 403 parser.add_argument( 404 "--external_decider", 405 required=True, 406 help="External script that, given an AFDO profile, returns " 407 "GOOD/BAD/SKIP", 408 ) 409 parser.add_argument( 410 "--analysis_output_file", 411 required=True, 412 help="File to output JSON results to", 413 ) 414 parser.add_argument( 415 "--state_file", 416 default="%s/afdo_analysis_state.json" % os.getcwd(), 417 help="File path containing state to load from initially, and will be " 418 "overwritten with new state on each iteration", 419 ) 420 parser.add_argument( 421 "--no_resume", 422 action="store_true", 423 help="If enabled, no initial state will be loaded and the program will " 424 "run from the beginning", 425 ) 426 parser.add_argument( 427 "--remove_state_on_completion", 428 action="store_true", 429 help="If enabled, state file will be removed once profile analysis is " 430 "completed", 431 ) 432 parser.add_argument( 433 "--seed", type=float, help="Float specifying seed for randomness" 434 ) 435 return parser.parse_args() 436 437 438def main(flags): 439 logging.getLogger().setLevel(logging.INFO) 440 if not flags.no_resume and flags.seed: # conflicting seeds 441 raise RuntimeError( 442 "Ambiguous seed value; do not resume from existing " 443 "state and also specify seed by command line flag" 444 ) 445 446 decider = DeciderState( 447 flags.state_file, flags.external_decider, seed=flags.seed 448 ) 449 if not flags.no_resume: 450 decider.load_state() 451 random.seed(decider.seed) 452 453 with open(flags.good_prof) as good_f: 454 good_items = text_to_json(good_f) 455 with open(flags.bad_prof) as bad_f: 456 bad_items = text_to_json(bad_f) 457 458 bisect_results = bisect_profiles_wrapper(decider, good_items, bad_items) 459 gnb_result = check_good_not_bad(decider, good_items, bad_items) 460 bng_result = check_bad_not_good(decider, good_items, bad_items) 461 462 results = { 463 "seed": decider.seed, 464 "bisect_results": bisect_results, 465 "good_only_functions": gnb_result, 466 "bad_only_functions": bng_result, 467 } 468 with open(flags.analysis_output_file, "w", encoding="utf-8") as f: 469 json.dump(results, f, indent=2) 470 if flags.remove_state_on_completion: 471 os.remove(flags.state_file) 472 logging.info( 473 "Removed state file %s following completion of script...", 474 flags.state_file, 475 ) 476 else: 477 completed_state_file = "%s.completed.%s" % ( 478 flags.state_file, 479 str(date.today()), 480 ) 481 os.rename(flags.state_file, completed_state_file) 482 logging.info( 483 "Stored completed state file as %s...", completed_state_file 484 ) 485 return results 486 487 488if __name__ == "__main__": 489 main(parse_args()) 490