xref: /llvm-project/bolt/utils/llvm-bolt-wrapper.py (revision f98ee40f4b5d7474fc67e82824bf6abbaedb7b1c)
1#!/usr/bin/env python3
2import argparse
3import subprocess
4from typing import *
5import tempfile
6import copy
7import os
8import shutil
9import sys
10import re
11import configparser
12from types import SimpleNamespace
13from textwrap import dedent
14
15# USAGE:
16# 0. Prepare two BOLT build versions: base and compare.
17# 1. Create the config by invoking this script with required options.
18#    Save the config as `llvm-bolt-wrapper.ini` next to the script or
19#    in the testing directory.
20# In the base BOLT build directory:
21# 2. Rename `llvm-bolt` to `llvm-bolt.real`
22# 3. Create a symlink from this script to `llvm-bolt`
23# 4. Create `llvm-bolt-wrapper.ini` and fill it using the example below.
24#
25# This script will compare binaries produced by base and compare BOLT, and
26# report elapsed processing time and max RSS.
27
28# read options from config file llvm-bolt-wrapper.ini in script CWD
29#
30# [config]
31# # mandatory
32# base_bolt = /full/path/to/llvm-bolt.real
33# cmp_bolt = /full/path/to/other/llvm-bolt
34# # optional, default to False
35# verbose
36# keep_tmp
37# no_minimize
38# run_sequentially
39# compare_output
40# skip_binary_cmp
41# # optional, defaults to timing.log in CWD
42# timing_file = timing1.log
43
44
45def read_cfg():
46    src_dir = os.path.dirname(os.path.abspath(__file__))
47    cfg = configparser.ConfigParser(allow_no_value=True)
48    cfgs = cfg.read("llvm-bolt-wrapper.ini")
49    if not cfgs:
50        cfgs = cfg.read(os.path.join(src_dir, "llvm-bolt-wrapper.ini"))
51    assert cfgs, f"llvm-bolt-wrapper.ini is not found in {os.getcwd()}"
52
53    def get_cfg(key):
54        # if key is not present in config, assume False
55        if key not in cfg["config"]:
56            return False
57        # if key is present, but has no value, assume True
58        if not cfg["config"][key]:
59            return True
60        # if key has associated value, interpret the value
61        return cfg["config"].getboolean(key)
62
63    d = {
64        # BOLT binary locations
65        "BASE_BOLT": cfg["config"]["base_bolt"],
66        "CMP_BOLT": cfg["config"]["cmp_bolt"],
67        # optional
68        "VERBOSE": get_cfg("verbose"),
69        "KEEP_TMP": get_cfg("keep_tmp"),
70        "NO_MINIMIZE": get_cfg("no_minimize"),
71        "RUN_SEQUENTIALLY": get_cfg("run_sequentially"),
72        "COMPARE_OUTPUT": get_cfg("compare_output"),
73        "SKIP_BINARY_CMP": get_cfg("skip_binary_cmp"),
74        "TIMING_FILE": cfg["config"].get("timing_file", "timing.log"),
75    }
76    if d["VERBOSE"]:
77        print(f"Using config {os.path.abspath(cfgs[0])}")
78    return SimpleNamespace(**d)
79
80
81# perf2bolt mode
82PERF2BOLT_MODE = ["-aggregate-only", "-ignore-build-id"]
83
84# boltdiff mode
85BOLTDIFF_MODE = ["-diff-only", "-o", "/dev/null"]
86
87# options to suppress binary differences as much as possible
88MINIMIZE_DIFFS = ["-bolt-info=0"]
89
90# bolt output options that need to be intercepted
91BOLT_OUTPUT_OPTS = {
92    "-o": "BOLT output binary",
93    "-w": "BOLT recorded profile",
94}
95
96# regex patterns to exclude the line from log comparison
97SKIP_MATCH = [
98    "BOLT-INFO: BOLT version",
99    r"^Args: ",
100    r"^BOLT-DEBUG:",
101    r"BOLT-INFO:.*data.*output data",
102    "WARNING: reading perf data directly",
103]
104
105
106def run_cmd(cmd, out_f, cfg):
107    if cfg.VERBOSE:
108        print(" ".join(cmd))
109    return subprocess.Popen(cmd, stdout=out_f, stderr=subprocess.STDOUT)
110
111
112def run_bolt(bolt_path, bolt_args, out_f, cfg):
113    p2b = os.path.basename(sys.argv[0]) == "perf2bolt"  # perf2bolt mode
114    bd = os.path.basename(sys.argv[0]) == "llvm-boltdiff"  # boltdiff mode
115    hm = sys.argv[1] == "heatmap"  # heatmap mode
116    cmd = ["/usr/bin/time", "-f", "%e %M", bolt_path] + bolt_args
117    if p2b:
118        # -ignore-build-id can occur at most once, hence remove it from cmd
119        if "-ignore-build-id" in cmd:
120            cmd.remove("-ignore-build-id")
121        cmd += PERF2BOLT_MODE
122    elif bd:
123        cmd += BOLTDIFF_MODE
124    elif not cfg.NO_MINIMIZE and not hm:
125        cmd += MINIMIZE_DIFFS
126    return run_cmd(cmd, out_f, cfg)
127
128
129def prepend_dash(args: Mapping[AnyStr, AnyStr]) -> Sequence[AnyStr]:
130    """
131    Accepts parsed arguments and returns flat list with dash prepended to
132    the option.
133    Example: Namespace(o='test.tmp') -> ['-o', 'test.tmp']
134    """
135    dashed = [("-" + key, value) for (key, value) in args.items()]
136    flattened = list(sum(dashed, ()))
137    return flattened
138
139
140def replace_cmp_path(tmp: AnyStr, args: Mapping[AnyStr, AnyStr]) -> Sequence[AnyStr]:
141    """
142    Keeps file names, but replaces the path to a temp folder.
143    Example: Namespace(o='abc/test.tmp') -> Namespace(o='/tmp/tmpf9un/test.tmp')
144    Except preserve /dev/null.
145    """
146    replace_path = (
147        lambda x: os.path.join(tmp, os.path.basename(x))
148        if x != "/dev/null"
149        else "/dev/null"
150    )
151    new_args = {key: replace_path(value) for key, value in args.items()}
152    return prepend_dash(new_args)
153
154
155def preprocess_args(args: argparse.Namespace) -> Mapping[AnyStr, AnyStr]:
156    """
157    Drop options that weren't parsed (e.g. -w), convert to a dict
158    """
159    return {key: value for key, value in vars(args).items() if value}
160
161
162def write_to(txt, filename, mode="w"):
163    with open(filename, mode) as f:
164        f.write(txt)
165
166
167def wait(proc, fdesc):
168    proc.wait()
169    fdesc.close()
170    return open(fdesc.name)
171
172
173def compare_logs(main, cmp, skip_begin=0, skip_end=0, str_input=True):
174    """
175    Compares logs but allows for certain lines to be excluded from comparison.
176    If str_input is True (default), the input it assumed to be a string,
177    which is split into lines. Otherwise the input is assumed to be a file.
178    Returns None on success, mismatch otherwise.
179    """
180    main_inp = main.splitlines() if str_input else main.readlines()
181    cmp_inp = cmp.splitlines() if str_input else cmp.readlines()
182    # rewind logs after consumption
183    if not str_input:
184        main.seek(0)
185        cmp.seek(0)
186    for lhs, rhs in list(zip(main_inp, cmp_inp))[skip_begin : -skip_end or None]:
187        if lhs != rhs:
188            # check skip patterns
189            for skip in SKIP_MATCH:
190                # both lines must contain the pattern
191                if re.search(skip, lhs) and re.search(skip, rhs):
192                    break
193            # otherwise return mismatching lines
194            else:
195                return (lhs, rhs)
196    return None
197
198
199def fmt_cmp(cmp_tuple):
200    if not cmp_tuple:
201        return ""
202    return f"main:\n{cmp_tuple[0]}\ncmp:\n{cmp_tuple[1]}\n"
203
204
205def compare_with(lhs, rhs, cmd, skip_begin=0, skip_end=0):
206    """
207    Runs cmd on both lhs and rhs and compares stdout.
208    Returns tuple (mismatch, lhs_stdout):
209        - if stdout matches between two files, mismatch is None,
210        - otherwise mismatch is a tuple of mismatching lines.
211    """
212    run = lambda binary: subprocess.run(
213        cmd.split() + [binary], text=True, check=True, capture_output=True
214    ).stdout
215    run_lhs = run(lhs)
216    run_rhs = run(rhs)
217    cmp = compare_logs(run_lhs, run_rhs, skip_begin, skip_end)
218    return cmp, run_lhs
219
220
221def parse_cmp_offset(cmp_out):
222    """
223    Extracts byte number from cmp output:
224    file1 file2 differ: byte X, line Y
225    """
226    # NOTE: cmp counts bytes starting from 1!
227    return int(re.search(r"byte (\d+),", cmp_out).groups()[0]) - 1
228
229
230def report_real_time(binary, main_err, cmp_err, cfg):
231    """
232    Extracts real time from stderr and appends it to TIMING FILE it as csv:
233    "output binary; base bolt; cmp bolt"
234    """
235
236    def get_real_from_stderr(logline):
237        return "; ".join(logline.split())
238
239    for line in main_err:
240        pass
241    main = get_real_from_stderr(line)
242    for line in cmp_err:
243        pass
244    cmp = get_real_from_stderr(line)
245    write_to(f"{binary}; {main}; {cmp}\n", cfg.TIMING_FILE, "a")
246    # rewind logs after consumption
247    main_err.seek(0)
248    cmp_err.seek(0)
249
250
251def clean_exit(tmp, out, exitcode, cfg):
252    # temp files are only cleaned on success
253    if not cfg.KEEP_TMP:
254        shutil.rmtree(tmp)
255
256    # report stdout and stderr from the main process
257    shutil.copyfileobj(out, sys.stdout)
258    sys.exit(exitcode)
259
260
261def find_section(offset, readelf_hdr):
262    hdr = readelf_hdr.split("\n")
263    section = None
264    # extract sections table (parse objdump -hw output)
265    for line in hdr[5:-1]:
266        cols = line.strip().split()
267        # extract section offset
268        file_offset = int(cols[5], 16)
269        # section size
270        size = int(cols[2], 16)
271        if offset >= file_offset and offset < file_offset + size:
272            if sys.stdout.isatty():  # terminal supports colors
273                print(f"\033[1m{line}\033[0m")
274            else:
275                print(f">{line}")
276            section = cols[1]
277        else:
278            print(line)
279    return section
280
281
282def main_config_generator():
283    parser = argparse.ArgumentParser()
284    parser.add_argument("base_bolt", help="Full path to base llvm-bolt binary")
285    parser.add_argument("cmp_bolt", help="Full path to cmp llvm-bolt binary")
286    parser.add_argument(
287        "--verbose",
288        action="store_true",
289        help="Print subprocess invocation cmdline (default False)",
290    )
291    parser.add_argument(
292        "--keep_tmp",
293        action="store_true",
294        help="Preserve tmp folder on a clean exit "
295        "(tmp directory is preserved on crash by default)",
296    )
297    parser.add_argument(
298        "--no_minimize",
299        action="store_true",
300        help=f"Do not add `{MINIMIZE_DIFFS}` that is used "
301        "by default to reduce binary differences",
302    )
303    parser.add_argument(
304        "--run_sequentially",
305        action="store_true",
306        help="Run both binaries sequentially (default "
307        "in parallel). Use for timing comparison",
308    )
309    parser.add_argument(
310        "--compare_output",
311        action="store_true",
312        help="Compare bolt stdout/stderr (disabled by default)",
313    )
314    parser.add_argument(
315        "--skip_binary_cmp", action="store_true", help="Disable output comparison"
316    )
317    parser.add_argument(
318        "--timing_file",
319        help="Override path to timing log " "file (default `timing.log` in CWD)",
320    )
321    args = parser.parse_args()
322
323    print(
324        dedent(
325            f"""\
326    [config]
327    # mandatory
328    base_bolt = {args.base_bolt}
329    cmp_bolt = {args.cmp_bolt}"""
330        )
331    )
332    del args.base_bolt
333    del args.cmp_bolt
334    d = vars(args)
335    if any(d.values()):
336        print("# optional")
337        for key, value in d.items():
338            if value:
339                print(key)
340
341
342def main():
343    cfg = read_cfg()
344    # intercept output arguments
345    parser = argparse.ArgumentParser(add_help=False)
346    for option, help in BOLT_OUTPUT_OPTS.items():
347        parser.add_argument(option, help=help)
348    args, unknownargs = parser.parse_known_args()
349    args = preprocess_args(args)
350    cmp_args = copy.deepcopy(args)
351    tmp = tempfile.mkdtemp()
352    cmp_args = replace_cmp_path(tmp, cmp_args)
353
354    # reconstruct output arguments: prepend dash
355    args = prepend_dash(args)
356
357    # run both BOLT binaries
358    main_f = open(os.path.join(tmp, "main_bolt.stdout"), "w")
359    cmp_f = open(os.path.join(tmp, "cmp_bolt.stdout"), "w")
360    main_bolt = run_bolt(cfg.BASE_BOLT, unknownargs + args, main_f, cfg)
361    if cfg.RUN_SEQUENTIALLY:
362        main_out = wait(main_bolt, main_f)
363        cmp_bolt = run_bolt(cfg.CMP_BOLT, unknownargs + cmp_args, cmp_f, cfg)
364    else:
365        cmp_bolt = run_bolt(cfg.CMP_BOLT, unknownargs + cmp_args, cmp_f, cfg)
366        main_out = wait(main_bolt, main_f)
367    cmp_out = wait(cmp_bolt, cmp_f)
368
369    # check exit code
370    if main_bolt.returncode != cmp_bolt.returncode:
371        print(tmp)
372        exit("exitcode mismatch")
373
374    # don't compare output upon unsuccessful exit
375    if main_bolt.returncode != 0:
376        cfg.SKIP_BINARY_CMP = True
377
378    # compare logs, skip_end=1 skips the line with time
379    out = (
380        compare_logs(main_out, cmp_out, skip_end=1, str_input=False)
381        if cfg.COMPARE_OUTPUT
382        else None
383    )
384    if out:
385        print(tmp)
386        print(fmt_cmp(out))
387        write_to(fmt_cmp(out), os.path.join(tmp, "summary.txt"))
388        exit("logs mismatch")
389
390    if os.path.basename(sys.argv[0]) == "llvm-boltdiff":  # boltdiff mode
391        # no output binary to compare, so just exit
392        clean_exit(tmp, main_out, main_bolt.returncode, cfg)
393
394    # compare binaries (using cmp)
395    main_binary = args[args.index("-o") + 1]
396    cmp_binary = cmp_args[cmp_args.index("-o") + 1]
397    if main_binary == "/dev/null":
398        assert cmp_binary == "/dev/null"
399        cfg.SKIP_BINARY_CMP = True
400
401    # report binary timing as csv: output binary; base bolt real; cmp bolt real
402    report_real_time(main_binary, main_out, cmp_out, cfg)
403
404    if not cfg.SKIP_BINARY_CMP:
405        # check if files exist
406        main_exists = os.path.exists(main_binary)
407        cmp_exists = os.path.exists(cmp_binary)
408        if main_exists and cmp_exists:
409            # proceed to comparison
410            pass
411        elif not main_exists and not cmp_exists:
412            # both don't exist, assume it's intended, skip comparison
413            clean_exit(tmp, main_out, main_bolt.returncode, cfg)
414        elif main_exists:
415            assert not cmp_exists
416            exit(f"{cmp_binary} doesn't exist")
417        else:
418            assert not main_exists
419            exit(f"{main_binary} doesn't exist")
420
421        cmp_proc = subprocess.run(
422            ["cmp", "-b", main_binary, cmp_binary], capture_output=True, text=True
423        )
424        if cmp_proc.returncode:
425            # check if output is an ELF file (magic bytes)
426            with open(main_binary, "rb") as f:
427                magic = f.read(4)
428                if magic != b"\x7fELF":
429                    exit("output mismatch")
430            # check if ELF headers match
431            mismatch, _ = compare_with(main_binary, cmp_binary, "readelf -We")
432            if mismatch:
433                print(fmt_cmp(mismatch))
434                write_to(fmt_cmp(mismatch), os.path.join(tmp, "headers.txt"))
435                exit("headers mismatch")
436            # if headers match, compare sections (skip line with filename)
437            mismatch, hdr = compare_with(
438                main_binary, cmp_binary, "objdump -hw", skip_begin=2
439            )
440            assert not mismatch
441            # check which section has the first mismatch
442            mismatch_offset = parse_cmp_offset(cmp_proc.stdout)
443            section = find_section(mismatch_offset, hdr)
444            exit(f"binary mismatch @{hex(mismatch_offset)} ({section})")
445
446    clean_exit(tmp, main_out, main_bolt.returncode, cfg)
447
448
449if __name__ == "__main__":
450    # config generator mode if the script is launched as is
451    if os.path.basename(__file__) == "llvm-bolt-wrapper.py":
452        main_config_generator()
453    else:
454        # llvm-bolt interceptor mode otherwise
455        main()
456