#!/usr/bin/env python3 # Copyright (C) 2023 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import sys if __name__ == "__main__": sys.dont_write_bytecode = True import argparse import dataclasses import datetime import json import os import pathlib import random import re import shutil import subprocess import time import uuid from typing import Optional import pretty import utils class FatalError(Exception): def __init__(self): pass class OptionsError(Exception): def __init__(self, message): self.message = message @dataclasses.dataclass(frozen=True) class Lunch: "Lunch combination" target_product: str "TARGET_PRODUCT" target_release: str "TARGET_RELEASE" target_build_variant: str "TARGET_BUILD_VARIANT" def ToDict(self): return { "TARGET_PRODUCT": self.target_product, "TARGET_RELEASE": self.target_release, "TARGET_BUILD_VARIANT": self.target_build_variant, } def Combine(self): return f"{self.target_product}-{self.target_release}-{self.target_build_variant}" @dataclasses.dataclass(frozen=True) class Change: "A change that we make to the tree, and how to undo it" label: str "String to print in the log when the change is made" change: callable "Function to change the source tree" undo: callable "Function to revert the source tree to its previous condition in the most minimal way possible." _DUMPVARS_VARS=[ "COMMON_LUNCH_CHOICES", "HOST_PREBUILT_TAG", "print", "PRODUCT_OUT", "report_config", "TARGET_ARCH", "TARGET_BUILD_VARIANT", "TARGET_DEVICE", "TARGET_PRODUCT", ] _DUMPVARS_ABS_VARS =[ "ANDROID_CLANG_PREBUILTS", "ANDROID_JAVA_HOME", "ANDROID_JAVA_TOOLCHAIN", "ANDROID_PREBUILTS", "HOST_OUT", "HOST_OUT_EXECUTABLES", "HOST_OUT_TESTCASES", "OUT_DIR", "print", "PRODUCT_OUT", "SOONG_HOST_OUT", "SOONG_HOST_OUT_EXECUTABLES", "TARGET_OUT_TESTCASES", ] @dataclasses.dataclass(frozen=True) class Benchmark: "Something we measure" id: str "Short ID for the benchmark, for the command line" title: str "Title for reports" change: Change "Source tree modification for the benchmark that will be measured" dumpvars: Optional[bool] = False "If specified, soong will run in dumpvars mode rather than build-mode." modules: Optional[list[str]] = None "Build modules to build on soong command line" preroll: Optional[int] = 0 "Number of times to run the build command to stabilize" postroll: Optional[int] = 3 "Number of times to run the build command after reverting the action to stabilize" def build_description(self): "Short description of the benchmark's Soong invocation." if self.dumpvars: return "dumpvars" elif self.modules: return " ".join(self.modules) return "" def soong_command(self, root): "Command line args to soong_ui for this benchmark." if self.dumpvars: return [ "--dumpvars-mode", f"--vars=\"{' '.join(_DUMPVARS_VARS)}\"", f"--abs-vars=\"{' '.join(_DUMPVARS_ABS_VARS)}\"", "--var-prefix=var_cache_", "--abs-var-prefix=abs_var_cache_", ] elif self.modules: return [ "--build-mode", "--all-modules", f"--dir={root}", "--skip-metrics-upload", ] + self.modules else: raise Exception("Benchmark must specify dumpvars or modules") @dataclasses.dataclass(frozen=True) class FileSnapshot: "Snapshot of a file's contents." filename: str "The file that was snapshottened" contents: str "The contents of the file" def write(self): "Write the contents back to the file" with open(self.filename, "w") as f: f.write(self.contents) def Snapshot(filename): """Return a FileSnapshot with the file's current contents.""" with open(filename) as f: contents = f.read() return FileSnapshot(filename, contents) def Clean(): """Remove the out directory.""" def remove_out(): out_dir = utils.get_out_dir() #only remove actual contents, in case out is a symlink (as is the case for cog) if os.path.exists(out_dir): for filename in os.listdir(out_dir): p = os.path.join(out_dir, filename) if os.path.isfile(p) or os.path.islink(p): os.remove(p) elif os.path.isdir(p): shutil.rmtree(p) return Change(label="Remove out", change=remove_out, undo=lambda: None) def NoChange(): """No change to the source tree.""" return Change(label="No change", change=lambda: None, undo=lambda: None) def Create(filename): "Create an action to create `filename`. The parent directory must exist." def create(): with open(filename, "w") as f: pass def delete(): os.remove(filename) return Change( label=f"Create {filename}", change=create, undo=delete, ) def Modify(filename, contents, before=None): """Create an action to modify `filename` by appending the result of `contents` before the last instances of `before` in the file. Raises an error if `before` doesn't appear in the file. """ orig = Snapshot(filename) if before: index = orig.contents.rfind(before) if index < 0: report_error(f"{filename}: Unable to find string '{before}' for modify operation.") raise FatalError() else: index = len(orig.contents) modified = FileSnapshot(filename, orig.contents[:index] + contents() + orig.contents[index:]) if False: print(f"Modify: {filename}") x = orig.contents.replace("\n", "\n ORIG") print(f" ORIG {x}") x = modified.contents.replace("\n", "\n MODIFIED") print(f" MODIFIED {x}") return Change( label="Modify " + filename, change=lambda: modified.write(), undo=lambda: orig.write() ) def ChangePublicApi(): change = AddJavaField("frameworks/base/core/java/android/provider/Settings.java", "@android.annotation.SuppressLint(\"UnflaggedApi\") public") orig_current_text = Snapshot("frameworks/base/core/api/current.txt") def undo(): change.undo() orig_current_text.write() return Change( label=change.label, change=change.change, undo=lambda: undo() ) def AddJavaField(filename, prefix): return Modify(filename, lambda: f"{prefix} static final int BENCHMARK = {random.randint(0, 1000000)};\n", before="}") def Comment(prefix, suffix=""): return lambda: prefix + " " + str(uuid.uuid4()) + suffix class BenchmarkReport(): "Information about a run of the benchmark" lunch: Lunch "lunch combo" benchmark: Benchmark "The benchmark object." iteration: int "Which iteration of the benchmark" log_dir: str "Path the the log directory, relative to the root of the reports directory" preroll_duration_ns: [int] "Durations of the in nanoseconds." duration_ns: int "Duration of the measured portion of the benchmark in nanoseconds." postroll_duration_ns: [int] "Durations of the postrolls in nanoseconds." complete: bool "Whether the benchmark made it all the way through the postrolls." def __init__(self, lunch, benchmark, iteration, log_dir): self.lunch = lunch self.benchmark = benchmark self.iteration = iteration self.log_dir = log_dir self.preroll_duration_ns = [] self.duration_ns = -1 self.postroll_duration_ns = [] self.complete = False def ToDict(self): return { "lunch": self.lunch.ToDict(), "id": self.benchmark.id, "title": self.benchmark.title, "modules": self.benchmark.modules, "dumpvars": self.benchmark.dumpvars, "change": self.benchmark.change.label, "iteration": self.iteration, "log_dir": self.log_dir, "preroll_duration_ns": self.preroll_duration_ns, "duration_ns": self.duration_ns, "postroll_duration_ns": self.postroll_duration_ns, "complete": self.complete, } class Runner(): """Runs the benchmarks.""" def __init__(self, options): self._options = options self._reports = [] self._complete = False def Run(self): """Run all of the user-selected benchmarks.""" # Clean out the log dir or create it if necessary prepare_log_dir(self._options.LogDir()) try: for lunch in self._options.Lunches(): print(lunch) for benchmark in self._options.Benchmarks(): for iteration in range(self._options.Iterations()): self._run_benchmark(lunch, benchmark, iteration) self._complete = True finally: self._write_summary() def _run_benchmark(self, lunch, benchmark, iteration): """Run a single benchmark.""" benchmark_log_subdir = self._benchmark_log_dir(lunch, benchmark, iteration) benchmark_log_dir = self._options.LogDir().joinpath(benchmark_log_subdir) sys.stderr.write(f"STARTING BENCHMARK: {benchmark.id}\n") sys.stderr.write(f" lunch: {lunch.Combine()}\n") sys.stderr.write(f" iteration: {iteration}\n") sys.stderr.write(f" benchmark_log_dir: {benchmark_log_dir}\n") report = BenchmarkReport(lunch, benchmark, iteration, benchmark_log_subdir) self._reports.append(report) # Preroll builds for i in range(benchmark.preroll): ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"pre_{i}"), benchmark) report.preroll_duration_ns.append(ns) sys.stderr.write(f"PERFORMING CHANGE: {benchmark.change.label}\n") if not self._options.DryRun(): benchmark.change.change() try: # Measured build ns = self._run_build(lunch, benchmark_log_dir.joinpath("measured"), benchmark) report.duration_ns = ns dist_one = self._options.DistOne() if dist_one: # If we're disting just one benchmark, save the logs and we can stop here. self._dist(utils.get_dist_dir(), benchmark.dumpvars) else: self._dist(benchmark_log_dir, benchmark.dumpvars, store_metrics_only=True) # Postroll builds for i in range(benchmark.postroll): ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"post_{i}"), benchmark) report.postroll_duration_ns.append(ns) finally: # Always undo, even if we crashed or the build failed and we stopped. sys.stderr.write(f"UNDOING CHANGE: {benchmark.change.label}\n") if not self._options.DryRun(): benchmark.change.undo() self._write_summary() sys.stderr.write(f"FINISHED BENCHMARK: {benchmark.id}\n") def _benchmark_log_dir(self, lunch, benchmark, iteration): """Construct the log directory fir a benchmark run.""" path = f"{lunch.Combine()}/{benchmark.id}" # Zero pad to the correct length for correct alpha sorting path += ("/%0" + str(len(str(self._options.Iterations()))) + "d") % iteration return path def _run_build(self, lunch, build_log_dir, benchmark): """Builds the modules. Saves interesting log files to log_dir. Raises FatalError if the build fails. """ sys.stderr.write(f"STARTING BUILD {benchmark.build_description()}\n") before_ns = time.perf_counter_ns() if not self._options.DryRun(): cmd = [ "build/soong/soong_ui.bash", ] + benchmark.soong_command(self._options.root) env = dict(os.environ) env["TARGET_PRODUCT"] = lunch.target_product env["TARGET_RELEASE"] = lunch.target_release env["TARGET_BUILD_VARIANT"] = lunch.target_build_variant returncode = subprocess.call(cmd, env=env) if returncode != 0: report_error(f"Build failed: {' '.join(cmd)}") raise FatalError() after_ns = time.perf_counter_ns() # TODO: Copy some log files. sys.stderr.write(f"FINISHED BUILD {benchmark.build_description()}\n") return after_ns - before_ns def _dist(self, dist_dir, dumpvars, store_metrics_only=False): out_dir = utils.get_out_dir() dest_dir = dist_dir.joinpath("logs") os.makedirs(dest_dir, exist_ok=True) basenames = [ "soong_build_metrics.pb", "soong_metrics", ] if not store_metrics_only: basenames.extend([ "build.trace.gz", "soong.log", ]) if dumpvars: basenames = ['dumpvars-'+b for b in basenames] for base in basenames: src = out_dir.joinpath(base) if src.exists(): sys.stderr.write(f"DIST: copied {src} to {dest_dir}\n") shutil.copy(src, dest_dir) def _write_summary(self): # Write the results, even if the build failed or we crashed, including # whether we finished all of the benchmarks. data = { "start_time": self._options.Timestamp().isoformat(), "branch": self._options.Branch(), "tag": self._options.Tag(), "benchmarks": [report.ToDict() for report in self._reports], "complete": self._complete, } with open(self._options.LogDir().joinpath("summary.json"), "w", encoding="utf-8") as f: json.dump(data, f, indent=2, sort_keys=True) def benchmark_table(benchmarks): rows = [("ID", "DESCRIPTION", "REBUILD"),] rows += [(benchmark.id, benchmark.title, benchmark.build_description()) for benchmark in benchmarks] return rows def prepare_log_dir(directory): if os.path.exists(directory): # If it exists and isn't a directory, fail. if not os.path.isdir(directory): report_error(f"Log directory already exists but isn't a directory: {directory}") raise FatalError() # Make sure the directory is empty. Do this rather than deleting it to handle # symlinks cleanly. for filename in os.listdir(directory): entry = os.path.join(directory, filename) if os.path.isdir(entry): shutil.rmtree(entry) else: os.unlink(entry) else: # Create it os.makedirs(directory) class Options(): def __init__(self): self._had_error = False # Wall time clock when we started self._timestamp = datetime.datetime.now(datetime.timezone.utc) # Move to the root of the tree right away. Everything must happen from there. self.root = utils.get_root() if not self.root: report_error("Unable to find root of tree from cwd.") raise FatalError() os.chdir(self.root) # Initialize the Benchmarks. Note that this pre-loads all of the files, etc. # Doing all that here forces us to fail fast if one of them can't load a required # file, at the cost of a small startup speed. Don't make this do something slow # like scan the whole tree. self._init_benchmarks() # Argument parsing epilog = f""" benchmarks: {pretty.FormatTable(benchmark_table(self._benchmarks), prefix=" ")} """ parser = argparse.ArgumentParser( prog="benchmarks", allow_abbrev=False, # Don't let people write unsupportable scripts. formatter_class=argparse.RawDescriptionHelpFormatter, epilog=epilog, description="Run build system performance benchmarks.") self.parser = parser parser.add_argument("--log-dir", help="Directory for logs. Default is $TOP/../benchmarks/.") parser.add_argument("--dated-logs", action="store_true", help="Append timestamp to log dir.") parser.add_argument("-n", action="store_true", dest="dry_run", help="Dry run. Don't run the build commands but do everything else.") parser.add_argument("--tag", help="Variant of the run, for when there are multiple perf runs.") parser.add_argument("--lunch", nargs="*", help="Lunch combos to test") parser.add_argument("--iterations", type=int, default=1, help="Number of iterations of each test to run.") parser.add_argument("--branch", type=str, help="Specify branch. Otherwise a guess will be made based on repo.") parser.add_argument("--benchmark", nargs="*", default=[b.id for b in self._benchmarks], metavar="BENCHMARKS", help="Benchmarks to run. Default suite will be run if omitted.") parser.add_argument("--dist-one", action="store_true", help="Copy logs and metrics to the given dist dir. Requires that only" + " one benchmark be supplied. Postroll steps will be skipped.") self._args = parser.parse_args() self._branch = self._branch() self._log_dir = self._log_dir() self._lunches = self._lunches() # Validate the benchmark ids all_ids = [benchmark.id for benchmark in self._benchmarks] bad_ids = [id for id in self._args.benchmark if id not in all_ids] if bad_ids: for id in bad_ids: self._error(f"Invalid benchmark: {id}") # --dist-one requires that only one benchmark be supplied if self._args.dist_one and len(self.Benchmarks()) != 1: self._error("--dist-one requires that exactly one --benchmark.") if self._had_error: raise FatalError() def Timestamp(self): return self._timestamp def _branch(self): """Return the branch, either from the command line or by guessing from repo.""" if self._args.branch: return self._args.branch try: branch = subprocess.check_output(f"cd {self.root}/.repo/manifests" + " && git rev-parse --abbrev-ref --symbolic-full-name @{u}", shell=True, encoding="utf-8") return branch.strip().split("/")[-1] except subprocess.CalledProcessError as ex: report_error("Can't get branch from .repo dir. Specify --branch argument") report_error(str(ex)) raise FatalError() def Branch(self): return self._branch def _log_dir(self): "The log directory to use, based on the current options" if self._args.log_dir: d = pathlib.Path(self._args.log_dir).resolve().absolute() else: d = self.root.joinpath("..", utils.DEFAULT_REPORT_DIR) if self._args.dated_logs: d = d.joinpath(self._timestamp.strftime('%Y-%m-%d')) d = d.joinpath(self._branch) if self._args.tag: d = d.joinpath(self._args.tag) return d.resolve().absolute() def LogDir(self): return self._log_dir def Benchmarks(self): return [b for b in self._benchmarks if b.id in self._args.benchmark] def Tag(self): return self._args.tag def DryRun(self): return self._args.dry_run def _lunches(self): def parse_lunch(lunch): parts = lunch.split("-") if len(parts) != 3: raise OptionsError(f"Invalid lunch combo: {lunch}") return Lunch(parts[0], parts[1], parts[2]) # If they gave lunch targets on the command line use that if self._args.lunch: result = [] # Split into Lunch objects for lunch in self._args.lunch: try: result.append(parse_lunch(lunch)) except OptionsError as ex: self._error(ex.message) return result # Use whats in the environment product = os.getenv("TARGET_PRODUCT") release = os.getenv("TARGET_RELEASE") variant = os.getenv("TARGET_BUILD_VARIANT") if (not product) or (not release) or (not variant): # If they didn't give us anything, fail rather than guessing. There's no good # default for AOSP. self._error("No lunch combo specified. Either pass --lunch argument or run lunch.") return [] return [Lunch(product, release, variant),] def Lunches(self): return self._lunches def Iterations(self): return self._args.iterations def DistOne(self): return self._args.dist_one def _init_benchmarks(self): """Initialize the list of benchmarks.""" # Assumes that we've already chdired to the root of the tree. self._benchmarks = [ Benchmark( id="full_lunch", title="Lunch from clean out", change=Clean(), dumpvars=True, preroll=0, postroll=0, ), Benchmark( id="noop_lunch", title="Lunch with no change", change=NoChange(), dumpvars=True, preroll=1, postroll=0, ), Benchmark(id="full", title="Full build", change=Clean(), modules=["droid"], preroll=0, postroll=3, ), Benchmark(id="nochange", title="No change", change=NoChange(), modules=["droid"], preroll=2, postroll=3, ), Benchmark(id="unreferenced", title="Create unreferenced file", change=Create("bionic/unreferenced.txt"), modules=["droid"], preroll=1, postroll=2, ), Benchmark(id="modify_bp", title="Modify Android.bp", change=Modify("bionic/libc/Android.bp", Comment("//")), modules=["droid"], preroll=1, postroll=3, ), Benchmark(id="modify_stdio", title="Modify stdio.cpp", change=Modify("bionic/libc/stdio/stdio.cpp", Comment("//")), modules=["libc"], preroll=1, postroll=2, ), Benchmark(id="modify_adbd", title="Modify adbd", change=Modify("packages/modules/adb/daemon/main.cpp", Comment("//")), modules=["adbd"], preroll=1, postroll=2, ), Benchmark(id="services_private_field", title="Add private field to ActivityManagerService.java", change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java", "private"), modules=["services"], preroll=1, postroll=2, ), Benchmark(id="services_public_field", title="Add public field to ActivityManagerService.java", change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java", "/** @hide */ public"), modules=["services"], preroll=1, postroll=2, ), Benchmark(id="services_api", title="Add API to ActivityManagerService.javaa", change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java", "@android.annotation.SuppressLint(\"UnflaggedApi\") public"), modules=["services"], preroll=1, postroll=2, ), Benchmark(id="framework_private_field", title="Add private field to Settings.java", change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java", "private"), modules=["framework-minus-apex"], preroll=1, postroll=2, ), Benchmark(id="framework_public_field", title="Add public field to Settings.java", change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java", "/** @hide */ public"), modules=["framework-minus-apex"], preroll=1, postroll=2, ), Benchmark(id="framework_api", title="Add API to Settings.java", change=ChangePublicApi(), modules=["api-stubs-docs-non-updatable-update-current-api", "framework-minus-apex"], preroll=1, postroll=2, ), Benchmark(id="modify_framework_resource", title="Modify framework resource", change=Modify("frameworks/base/core/res/res/values/config.xml", lambda: str(uuid.uuid4()), before=""), modules=["framework-minus-apex"], preroll=1, postroll=2, ), Benchmark(id="add_framework_resource", title="Add framework resource", change=Modify("frameworks/base/core/res/res/values/config.xml", lambda: f"{uuid.uuid4()}", before=""), modules=["framework-minus-apex"], preroll=1, postroll=2, ), Benchmark(id="add_systemui_field", title="Add SystemUI field", change=AddJavaField("frameworks/base/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java", "public"), modules=["SystemUI"], preroll=1, postroll=2, ), ] def _error(self, message): report_error(message) self._had_error = True def report_error(message): sys.stderr.write(f"error: {message}\n") def main(argv): try: options = Options() runner = Runner(options) runner.Run() except FatalError: sys.stderr.write(f"FAILED\n") sys.exit(1) if __name__ == "__main__": main(sys.argv)