xref: /aosp_15_r20/external/AFLplusplus/benchmark/benchmark.py (revision 08b48e0b10e97b33e7b60c5b6e2243bd915777f2)
1#!/usr/bin/env python3
2# Part of the aflplusplus project, requires Python 3.8+.
3# Author: Chris Ball <[email protected]>, ported from Marc "van Hauser" Heuse's "benchmark.sh".
4import argparse, asyncio, json, multiprocessing, os, platform, re, shutil, sys
5from dataclasses import asdict, dataclass
6from decimal import Decimal
7from enum import Enum, auto
8from pathlib import Path
9from typing import Dict, List, Optional, Tuple
10
11blue   = lambda text: f"\033[1;94m{text}\033[0m"; gray = lambda text: f"\033[1;90m{text}\033[0m"
12green  = lambda text: f"\033[0;32m{text}\033[0m"; red  = lambda text: f"\033[0;31m{text}\033[0m"
13yellow = lambda text: f"\033[0;33m{text}\033[0m"
14
15class Mode(Enum):
16    multicore  = auto()
17    singlecore = auto()
18
19@dataclass
20class Target:
21    source: Path
22    binary: Path
23
24@dataclass
25class Run:
26    execs_per_sec: float
27    execs_total: float
28    fuzzers_used: int
29
30@dataclass
31class Config:
32    afl_persistent_config: bool
33    afl_system_config: bool
34    afl_version: Optional[str]
35    comment: str
36    compiler: str
37    target_arch: str
38
39@dataclass
40class Hardware:
41    cpu_fastest_core_mhz: float
42    cpu_model: str
43    cpu_threads: int
44
45@dataclass
46class Results:
47    config: Optional[Config]
48    hardware: Optional[Hardware]
49    targets: Dict[str, Dict[str, Optional[Run]]]
50
51all_modes = [Mode.singlecore, Mode.multicore]
52all_targets = [
53    Target(source=Path("../utils/persistent_mode/test-instr.c").resolve(), binary=Path("test-instr-persist-shmem")),
54    Target(source=Path("../test-instr.c").resolve(), binary=Path("test-instr"))
55]
56modes = [mode.name for mode in all_modes]
57targets = [str(target.binary) for target in all_targets]
58cpu_count = multiprocessing.cpu_count()
59env_vars = {
60    "AFL_DISABLE_TRIM": "1", "AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES": "1", "AFL_FAST_CAL": "1",
61    "AFL_NO_UI": "1", "AFL_TRY_AFFINITY": "1", "PATH": f'{str(Path("../").resolve())}:{os.environ["PATH"]}',
62}
63
64parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
65parser.add_argument("-b", "--basedir", help="directory to use for temp files", type=str, default="/tmp/aflpp-benchmark")
66parser.add_argument("-d", "--debug", help="show verbose debugging output", action="store_true")
67parser.add_argument("-r", "--runs", help="how many runs to average results over", type=int, default=3)
68parser.add_argument("-f", "--fuzzers", help="how many afl-fuzz workers to use", type=int, default=cpu_count)
69parser.add_argument("-m", "--mode", help="pick modes", action="append", default=modes, choices=modes)
70parser.add_argument("-c", "--comment", help="add a comment about your setup", type=str, default="")
71parser.add_argument("--cpu", help="override the detected CPU model name", type=str, default="")
72parser.add_argument("--mhz", help="override the detected CPU MHz", type=str, default="")
73parser.add_argument(
74    "-t", "--target", help="pick targets", action="append", default=["test-instr-persist-shmem"], choices=targets
75)
76args = parser.parse_args()
77# Really unsatisfying argparse behavior: we want a default and to allow multiple choices, but if there's a manual choice
78# it should override the default.  Seems like we have to remove the default to get that and have correct help text?
79if len(args.target) > 1:
80    args.target = args.target[1:]
81if len(args.mode) > 2:
82    args.mode = args.mode[2:]
83
84chosen_modes = [mode for mode in all_modes if mode.name in args.mode]
85chosen_targets = [target for target in all_targets if str(target.binary) in args.target]
86results = Results(config=None, hardware=None, targets={
87    str(t.binary): {m.name: None for m in chosen_modes} for t in chosen_targets}
88)
89debug = lambda text: args.debug and print(blue(text))
90
91async def clean_up_tempfiles() -> None:
92    shutil.rmtree(f"{args.basedir}/in")
93    for target in chosen_targets:
94        target.binary.unlink()
95        for mode in chosen_modes:
96            shutil.rmtree(f"{args.basedir}/out-{mode.name}-{str(target.binary)}")
97
98async def check_afl_persistent() -> bool:
99    with open("/proc/cmdline", "r") as cmdline:
100        return "mitigations=off" in cmdline.read().strip().split(" ")
101
102async def check_afl_system() -> bool:
103    sysctl = next((s for s in ["sysctl", "/sbin/sysctl"] if shutil.which(s)), None)
104    if sysctl:
105        (returncode, stdout, _) = await run_command([sysctl, "kernel.randomize_va_space"])
106        return returncode == 0 and stdout.decode().rstrip().split(" = ")[1] == "0"
107    return False
108
109async def prep_env() -> None:
110    Path(f"{args.basedir}/in").mkdir(exist_ok=True, parents=True)
111    with open(f"{args.basedir}/in/in.txt", "wb") as seed:
112        seed.write(b"\x00" * 10240)
113
114async def compile_target(source: Path, binary: Path) -> None:
115    print(f" [*] Compiling the {binary} fuzzing harness for the benchmark to use.")
116    (returncode, stdout, stderr) = await run_command(
117        [str(Path("../afl-clang-lto").resolve()), "-o", str(Path(binary.resolve())), str(Path(source).resolve())]
118    )
119    if returncode == 0:
120        return
121    print(yellow(f" [*] afl-clang-lto was unable to compile; falling back to afl-cc."))
122    (returncode, stdout, stderr) = await run_command(
123        [str(Path("../afl-cc").resolve()), "-o", str(Path(binary.resolve())), str(Path(source).resolve())]
124    )
125    if returncode != 0:
126        sys.exit(red(f" [*] Error: afl-cc is unable to compile: {stderr.decode()} {stdout.decode()}"))
127
128async def run_command(cmd: List[str]) -> Tuple[Optional[int], bytes, bytes]:
129    debug(f"Launching command: {cmd} with env {env_vars}")
130    p = await asyncio.create_subprocess_exec(
131        *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env_vars
132    )
133    stdout, stderr = await p.communicate()
134    debug(f"Output: {stdout.decode()} {stderr.decode()}")
135    return (p.returncode, stdout, stderr)
136
137async def check_deps() -> None:
138    if not (plat := platform.system()) == "Linux": sys.exit(red(f" [*] {plat} is not supported by this script yet."))
139    if not os.access(Path("../afl-fuzz").resolve(), os.X_OK) and os.access(Path("../afl-cc").resolve(), os.X_OK) and (
140        os.path.exists(Path("../SanitizerCoveragePCGUARD.so").resolve())):
141        sys.exit(red(" [*] Compile AFL++: we need afl-fuzz, afl-clang-fast and SanitizerCoveragePCGUARD.so built."))
142
143    (returncode, stdout, stderr) = await run_command([str(Path("../afl-cc").resolve()), "-v"])
144    if returncode != 0:
145        sys.exit(red(f" [*] Error: afl-cc -v returned: {stderr.decode()} {stdout.decode()}"))
146    compiler = ""
147    target_arch = ""
148    for line in stderr.decode().split("\n"):
149        if "clang version" in line:
150            compiler = line
151        elif m := re.match(r"^Target: (.*)", line):
152            target_arch = m.group(1)
153
154    # Pick some sample settings from afl-{persistent,system}-config to try to see whether they were run.
155    afl_pc = await check_afl_persistent()
156    afl_sc = await check_afl_system()
157    if not afl_pc:
158        print(yellow(f" [*] afl-persistent-config did not run; run it to improve performance (and decrease security)."))
159    if not afl_sc:
160        print(yellow(f" [*] afl-system-config did not run; run it to improve performance (and decrease security)."))
161    results.config = Config(afl_persistent_config=afl_pc, afl_system_config=afl_sc, afl_version="",
162                            comment=args.comment, compiler=compiler, target_arch=target_arch)
163
164async def colon_values(filename: str, searchKey: str) -> List[str]:
165    """Return a colon-separated value given a key in a file, e.g. 'cpu MHz         : 4976.109')"""
166    with open(filename, "r") as fh:
167        kv_pairs = (line.split(": ", 1) for line in fh if ": " in line)
168        v_list = [v.rstrip() for k, v in kv_pairs if k.rstrip() == searchKey]
169        return v_list
170
171async def describe_afl_config() -> str:
172   if results.config is None:
173       return "unknown"
174   elif results.config.afl_persistent_config and results.config.afl_system_config:
175       return "both"
176   elif results.config.afl_persistent_config:
177       return "persistent"
178   elif results.config.afl_system_config:
179       return "system"
180   else:
181       return "none"
182
183async def save_benchmark_results() -> None:
184    """Append a single row to the benchmark results in JSON Lines format (which is simple to write and diff)."""
185    with open("benchmark-results.jsonl", "a") as jsonfile:
186        json.dump(asdict(results), jsonfile, sort_keys=True)
187        jsonfile.write("\n")
188        print(blue(f" [*] Results have been written to the {jsonfile.name} file."))
189    with open("COMPARISON.md", "r+") as comparisonfile:
190        described_config = await describe_afl_config()
191        aflconfig = described_config.ljust(12)
192        if results.hardware is None:
193            return
194        cpu_model = results.hardware.cpu_model.ljust(51)
195        if cpu_model in comparisonfile.read():
196            print(blue(f" [*] Results have not been written to the COMPARISON.md file; this CPU is already present."))
197            return
198        cpu_mhz = str(round(results.hardware.cpu_fastest_core_mhz)).ljust(5)
199        if not "test-instr-persist-shmem" in results.targets or \
200           not "multicore" in results.targets["test-instr-persist-shmem"] or \
201           not "singlecore" in results.targets["test-instr-persist-shmem"] or \
202           results.targets["test-instr-persist-shmem"]["singlecore"] is None or \
203           results.targets["test-instr-persist-shmem"]["multicore"] is None:
204            return
205        single = str(round(results.targets["test-instr-persist-shmem"]["singlecore"].execs_per_sec)).ljust(10)
206        multi = str(round(results.targets["test-instr-persist-shmem"]["multicore"].execs_per_sec)).ljust(9)
207        cores = str(args.fuzzers).ljust(7)
208        comparisonfile.write(f"{cpu_model} | {cpu_mhz} | {cores} | {single} | {multi} | {aflconfig} |\n")
209        print(blue(f" [*] Results have been written to the COMPARISON.md file."))
210    with open("COMPARISON.md", "r") as comparisonfile:
211        print(comparisonfile.read())
212
213
214async def main() -> None:
215    try:
216        await clean_up_tempfiles()
217    except FileNotFoundError:
218        pass
219    await check_deps()
220    if args.mhz:
221        cpu_mhz = float(args.mhz)
222    else:
223        cpu_mhz_str = await colon_values("/proc/cpuinfo", "cpu MHz")
224        if len(cpu_mhz_str) == 0:
225            cpu_mhz_str.append("0")
226        cpu_mhz = max([float(c) for c in cpu_mhz_str]) # use the fastest CPU MHz for now
227    if args.cpu:
228        cpu_model = [args.cpu]
229    else:
230        cpu_model = await colon_values("/proc/cpuinfo", "model name") or [""]
231    results.hardware = Hardware(cpu_fastest_core_mhz=cpu_mhz, cpu_model=cpu_model[0], cpu_threads=cpu_count)
232    await prep_env()
233    print(f" [*] Ready, starting benchmark...")
234    for target in chosen_targets:
235        await compile_target(target.source, target.binary)
236        binary = str(target.binary)
237        for mode in chosen_modes:
238            if mode == Mode.multicore:
239                print(blue(f" [*] Using {args.fuzzers} fuzzers for multicore fuzzing "), end="")
240                print(blue("(use --fuzzers to override)." if args.fuzzers == cpu_count else f"(the default is {cpu_count})"))
241            execs_per_sec, execs_total = ([] for _ in range(2))
242            for run_idx in range(0, args.runs):
243                print(gray(f" [*] {mode.name} {binary} run {run_idx+1} of {args.runs}, execs/s: "), end="", flush=True)
244                fuzzers = range(0, args.fuzzers if mode == Mode.multicore else 1)
245                outdir = f"{args.basedir}/out-{mode.name}-{binary}"
246                cmds = []
247                for fuzzer_idx, afl in enumerate(fuzzers):
248                    name = ["-o", outdir, "-M" if fuzzer_idx == 0 else "-S", str(afl)]
249                    cmds.append(["afl-fuzz", "-i", f"{args.basedir}/in"] + name + ["-s", "123", "-V10", "-D", f"./{binary}"])
250                # Prepare the afl-fuzz tasks, and then block while waiting for them to finish.
251                fuzztasks = [run_command(cmds[cpu]) for cpu in fuzzers]
252                await asyncio.gather(*fuzztasks)
253                afl_versions = await colon_values(f"{outdir}/0/fuzzer_stats", "afl_version")
254                if results.config:
255                    results.config.afl_version = afl_versions[0]
256                # Our score is the sum of all execs_per_sec entries in fuzzer_stats files for the run.
257                sectasks = [colon_values(f"{outdir}/{afl}/fuzzer_stats", "execs_per_sec") for afl in fuzzers]
258                all_execs_per_sec = await asyncio.gather(*sectasks)
259                execs = sum([Decimal(count[0]) for count in all_execs_per_sec])
260                print(green(execs))
261                execs_per_sec.append(execs)
262                # Also gather execs_total and total_run_time for this run.
263                exectasks = [colon_values(f"{outdir}/{afl}/fuzzer_stats", "execs_done") for afl in fuzzers]
264                all_execs_total = await asyncio.gather(*exectasks)
265                execs_total.append(sum([Decimal(count[0]) for count in all_execs_total]))
266
267            # (Using float() because Decimal() is not JSON-serializable.)
268            avg_afl_execs_per_sec = round(Decimal(sum(execs_per_sec) / len(execs_per_sec)), 2)
269            afl_execs_total = int(sum([Decimal(execs) for execs in execs_total]))
270            run = Run(execs_per_sec=float(avg_afl_execs_per_sec), execs_total=afl_execs_total, fuzzers_used=len(fuzzers))
271            results.targets[binary][mode.name] = run
272            print(f" [*] Average execs/sec for this test across all runs was: {green(avg_afl_execs_per_sec)}")
273            if (((max(execs_per_sec) - min(execs_per_sec)) / avg_afl_execs_per_sec) * 100) > 15:
274                print(yellow(" [*] The difference between your slowest and fastest runs was >15%, maybe try again?"))
275
276    await clean_up_tempfiles()
277    await save_benchmark_results()
278
279if __name__ == "__main__":
280    asyncio.run(main())
281
282