xref: /aosp_15_r20/external/toolchain-utils/afdo_tools/bisection/afdo_prof_analysis.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
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