blob: c42a2d8c82db99e6bb592269263b5656b5fc85ba [file] [log] [blame]
Joe Onorato88ede352023-12-19 02:56:38 +00001#!/usr/bin/env python3
2# Copyright (C) 2023 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16import sys
17if __name__ == "__main__":
18 sys.dont_write_bytecode = True
19
20import argparse
21import dataclasses
22import datetime
23import json
24import os
25import pathlib
26import shutil
27import subprocess
28import time
29
30import pretty
31import utils
32
33
34class FatalError(Exception):
35 def __init__(self):
36 pass
37
38
39class OptionsError(Exception):
40 def __init__(self, message):
41 self.message = message
42
43
44@dataclasses.dataclass(frozen=True)
45class Lunch:
46 "Lunch combination"
47
48 target_product: str
49 "TARGET_PRODUCT"
50
51 target_release: str
52 "TARGET_RELEASE"
53
54 target_build_variant: str
55 "TARGET_BUILD_VARIANT"
56
57 def ToDict(self):
58 return {
59 "TARGET_PRODUCT": self.target_product,
60 "TARGET_RELEASE": self.target_release,
61 "TARGET_BUILD_VARIANT": self.target_build_variant,
62 }
63
64 def Combine(self):
65 return f"{self.target_product}-{self.target_release}-{self.target_build_variant}"
66
67
68@dataclasses.dataclass(frozen=True)
69class Change:
70 "A change that we make to the tree, and how to undo it"
71 label: str
72 "String to print in the log when the change is made"
73
74 change: callable
75 "Function to change the source tree"
76
77 undo: callable
78 "Function to revert the source tree to its previous condition in the most minimal way possible."
79
80
81@dataclasses.dataclass(frozen=True)
82class Benchmark:
83 "Something we measure"
84
85 id: str
86 "Short ID for the benchmark, for the command line"
87
88 title: str
89 "Title for reports"
90
91 change: Change
92 "Source tree modification for the benchmark that will be measured"
93
94 modules: list[str]
95 "Build modules to build on soong command line"
96
97 preroll: int
98 "Number of times to run the build command to stabilize"
99
100 postroll: int
101 "Number of times to run the build command after reverting the action to stabilize"
102
103
104@dataclasses.dataclass(frozen=True)
105class FileSnapshot:
106 "Snapshot of a file's contents."
107
108 filename: str
109 "The file that was snapshottened"
110
111 contents: str
112 "The contents of the file"
113
114 def write(self):
115 "Write the contents back to the file"
116 with open(self.filename, "w") as f:
117 f.write(self.contents)
118
119
120def Snapshot(filename):
121 """Return a FileSnapshot with the file's current contents."""
122 with open(filename) as f:
123 contents = f.read()
124 return FileSnapshot(filename, contents)
125
126
127def Clean():
128 """Remove the out directory."""
129 def remove_out():
130 if os.path.exists("out"):
131 shutil.rmtree("out")
132 return Change(label="Remove out", change=remove_out, undo=lambda: None)
133
134
135def NoChange():
136 """No change to the source tree."""
137 return Change(label="No change", change=lambda: None, undo=lambda: None)
138
139
140def Modify(filename, contents, before=None):
141 """Create an action to modify `filename` by appending `contents` before the last instances
142 of `before` in the file.
143
144 Raises an error if `before` doesn't appear in the file.
145 """
146 orig = Snapshot(filename)
147 if before:
148 index = orig.contents.rfind(before)
149 if index < 0:
150 report_error(f"{filename}: Unable to find string '{before}' for modify operation.")
151 raise FatalError()
152 else:
153 index = len(orig.contents)
154 modified = FileSnapshot(filename, orig.contents[:index] + contents + orig.contents[index:])
155 return Change(
156 label="Modify " + filename,
157 change=lambda: modified.write(),
158 undo=lambda: orig.write()
159 )
160
161
162class BenchmarkReport():
163 "Information about a run of the benchmark"
164
165 lunch: Lunch
166 "lunch combo"
167
168 benchmark: Benchmark
169 "The benchmark object."
170
171 iteration: int
172 "Which iteration of the benchmark"
173
174 log_dir: str
175 "Path the the log directory, relative to the root of the reports directory"
176
177 preroll_duration_ns: [int]
178 "Durations of the in nanoseconds."
179
180 duration_ns: int
181 "Duration of the measured portion of the benchmark in nanoseconds."
182
183 postroll_duration_ns: [int]
184 "Durations of the postrolls in nanoseconds."
185
186 complete: bool
187 "Whether the benchmark made it all the way through the postrolls."
188
189 def __init__(self, lunch, benchmark, iteration, log_dir):
190 self.lunch = lunch
191 self.benchmark = benchmark
192 self.iteration = iteration
193 self.log_dir = log_dir
194 self.preroll_duration_ns = []
195 self.duration_ns = -1
196 self.postroll_duration_ns = []
197 self.complete = False
198
199 def ToDict(self):
200 return {
201 "lunch": self.lunch.ToDict(),
202 "id": self.benchmark.id,
203 "title": self.benchmark.title,
204 "modules": self.benchmark.modules,
205 "change": self.benchmark.change.label,
206 "iteration": self.iteration,
207 "log_dir": self.log_dir,
208 "preroll_duration_ns": self.preroll_duration_ns,
209 "duration_ns": self.duration_ns,
210 "postroll_duration_ns": self.postroll_duration_ns,
211 "complete": self.complete,
212 }
213
214class Runner():
215 """Runs the benchmarks."""
216
217 def __init__(self, options):
218 self._options = options
219 self._reports = []
220 self._complete = False
221
222 def Run(self):
223 """Run all of the user-selected benchmarks."""
224 # Clean out the log dir or create it if necessary
225 prepare_log_dir(self._options.LogDir())
226
227 try:
228 for lunch in self._options.Lunches():
229 print(lunch)
230 for benchmark in self._options.Benchmarks():
231 for iteration in range(self._options.Iterations()):
232 self._run_benchmark(lunch, benchmark, iteration)
233 self._complete = True
234 finally:
235 self._write_summary()
236
237
238 def _run_benchmark(self, lunch, benchmark, iteration):
239 """Run a single benchmark."""
240 benchmark_log_subdir = self._log_dir(lunch, benchmark, iteration)
241 benchmark_log_dir = self._options.LogDir().joinpath(benchmark_log_subdir)
242
243 sys.stderr.write(f"STARTING BENCHMARK: {benchmark.id}\n")
244 sys.stderr.write(f" lunch: {lunch.Combine()}\n")
245 sys.stderr.write(f" iteration: {iteration}\n")
246 sys.stderr.write(f" benchmark_log_dir: {benchmark_log_dir}\n")
247
248 report = BenchmarkReport(lunch, benchmark, iteration, benchmark_log_subdir)
249 self._reports.append(report)
250
251 # Preroll builds
252 for i in range(benchmark.preroll):
253 ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"pre_{i}"), benchmark.modules)
254 report.preroll_duration_ns.append(ns)
255
256 sys.stderr.write(f"PERFORMING CHANGE: {benchmark.change.label}\n")
257 if not self._options.DryRun():
258 benchmark.change.change()
259 try:
260
261 # Measured build
262 ns = self._run_build(lunch, benchmark_log_dir.joinpath("measured"), benchmark.modules)
263 report.duration_ns = ns
264
265 # Postroll builds
266 for i in range(benchmark.preroll):
267 ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"post_{i}"),
268 benchmark.modules)
269 report.postroll_duration_ns.append(ns)
270
271 finally:
272 # Always undo, even if we crashed or the build failed and we stopped.
273 sys.stderr.write(f"UNDOING CHANGE: {benchmark.change.label}\n")
274 if not self._options.DryRun():
275 benchmark.change.undo()
276
277 self._write_summary()
278 sys.stderr.write(f"FINISHED BENCHMARK: {benchmark.id}\n")
279
280 def _log_dir(self, lunch, benchmark, iteration):
281 """Construct the log directory fir a benchmark run."""
282 path = f"{lunch.Combine()}/{benchmark.id}"
283 # Zero pad to the correct length for correct alpha sorting
284 path += ("/%0" + str(len(str(self._options.Iterations()))) + "d") % iteration
285 return path
286
287 def _run_build(self, lunch, build_log_dir, modules):
288 """Builds the modules. Saves interesting log files to log_dir. Raises FatalError
289 if the build fails.
290 """
291 sys.stderr.write(f"STARTING BUILD {modules}\n")
292
293 before_ns = time.perf_counter_ns()
294 if not self._options.DryRun():
295 cmd = [
296 "build/soong/soong_ui.bash",
297 "--build-mode",
298 "--all-modules",
299 f"--dir={self._options.root}",
300 ] + modules
301 env = dict(os.environ)
302 env["TARGET_PRODUCT"] = lunch.target_product
303 env["TARGET_RELEASE"] = lunch.target_release
304 env["TARGET_BUILD_VARIANT"] = lunch.target_build_variant
305 returncode = subprocess.call(cmd, env=env)
306 if returncode != 0:
307 report_error(f"Build failed: {' '.join(cmd)}")
308 raise FatalError()
309
310 after_ns = time.perf_counter_ns()
311
312 # TODO: Copy some log files.
313
314 sys.stderr.write(f"FINISHED BUILD {modules}\n")
315
316 return after_ns - before_ns
317
318 def _write_summary(self):
319 # Write the results, even if the build failed or we crashed, including
320 # whether we finished all of the benchmarks.
321 data = {
322 "start_time": self._options.Timestamp().isoformat(),
323 "branch": self._options.Branch(),
324 "tag": self._options.Tag(),
325 "benchmarks": [report.ToDict() for report in self._reports],
326 "complete": self._complete,
327 }
328 with open(self._options.LogDir().joinpath("summary.json"), "w", encoding="utf-8") as f:
329 json.dump(data, f, indent=2, sort_keys=True)
330
331
332def benchmark_table(benchmarks):
333 rows = [("ID", "DESCRIPTION", "REBUILD"),]
334 rows += [(benchmark.id, benchmark.title, " ".join(benchmark.modules)) for benchmark in
335 benchmarks]
336 return rows
337
338
339def prepare_log_dir(directory):
340 if os.path.exists(directory):
341 # If it exists and isn't a directory, fail.
342 if not os.path.isdir(directory):
343 report_error(f"Log directory already exists but isn't a directory: {directory}")
344 raise FatalError()
345 # Make sure the directory is empty. Do this rather than deleting it to handle
346 # symlinks cleanly.
347 for filename in os.listdir(directory):
348 entry = os.path.join(directory, filename)
349 if os.path.isdir(entry):
350 shutil.rmtree(entry)
351 else:
352 os.unlink(entry)
353 else:
354 # Create it
355 os.makedirs(directory)
356
357
358class Options():
359 def __init__(self):
360 self._had_error = False
361
362 # Wall time clock when we started
363 self._timestamp = datetime.datetime.now(datetime.timezone.utc)
364
365 # Move to the root of the tree right away. Everything must happen from there.
366 self.root = utils.get_root()
367 if not self.root:
368 report_error("Unable to find root of tree from cwd.")
369 raise FatalError()
370 os.chdir(self.root)
371
372 # Initialize the Benchmarks. Note that this pre-loads all of the files, etc.
373 # Doing all that here forces us to fail fast if one of them can't load a required
374 # file, at the cost of a small startup speed. Don't make this do something slow
375 # like scan the whole tree.
376 self._init_benchmarks()
377
378 # Argument parsing
379 epilog = f"""
380benchmarks:
381{pretty.FormatTable(benchmark_table(self._benchmarks), prefix=" ")}
382"""
383
384 parser = argparse.ArgumentParser(
385 prog="benchmarks",
386 allow_abbrev=False, # Don't let people write unsupportable scripts.
387 formatter_class=argparse.RawDescriptionHelpFormatter,
388 epilog=epilog,
389 description="Run build system performance benchmarks.")
390 self.parser = parser
391
392 parser.add_argument("--log-dir",
393 help="Directory for logs. Default is $TOP/../benchmarks/.")
394 parser.add_argument("--dated-logs", action="store_true",
395 help="Append timestamp to log dir.")
396 parser.add_argument("-n", action="store_true", dest="dry_run",
397 help="Dry run. Don't run the build commands but do everything else.")
398 parser.add_argument("--tag",
399 help="Variant of the run, for when there are multiple perf runs.")
400 parser.add_argument("--lunch", nargs="*",
401 help="Lunch combos to test")
402 parser.add_argument("--iterations", type=int, default=1,
403 help="Number of iterations of each test to run.")
404 parser.add_argument("--branch", type=str,
405 help="Specify branch. Otherwise a guess will be made based on repo.")
406 parser.add_argument("--benchmark", nargs="*", default=[b.id for b in self._benchmarks],
407 metavar="BENCHMARKS",
408 help="Benchmarks to run. Default suite will be run if omitted.")
409
410 self._args = parser.parse_args()
411
412 self._branch = self._branch()
413 self._log_dir = self._log_dir()
414 self._lunches = self._lunches()
415
416 # Validate the benchmark ids
417 all_ids = [benchmark.id for benchmark in self._benchmarks]
418 bad_ids = [id for id in self._args.benchmark if id not in all_ids]
419 if bad_ids:
420 for id in bad_ids:
421 self._error(f"Invalid benchmark: {id}")
422
423 if self._had_error:
424 raise FatalError()
425
426 def Timestamp(self):
427 return self._timestamp
428
429 def _branch(self):
430 """Return the branch, either from the command line or by guessing from repo."""
431 if self._args.branch:
432 return self._args.branch
433 try:
434 branch = subprocess.check_output(f"cd {self.root}/.repo/manifests"
435 + " && git rev-parse --abbrev-ref --symbolic-full-name @{u}",
436 shell=True, encoding="utf-8")
437 return branch.strip().split("/")[-1]
438 except subprocess.CalledProcessError as ex:
439 report_error("Can't get branch from .repo dir. Specify --branch argument")
440 report_error(str(ex))
441 raise FatalError()
442
443 def Branch(self):
444 return self._branch
445
446 def _log_dir(self):
447 "The log directory to use, based on the current options"
448 if self._args.log_dir:
449 d = pathlib.Path(self._args.log_dir).resolve().absolute()
450 else:
451 d = self.root.joinpath("..", utils.DEFAULT_REPORT_DIR)
452 if self._args.dated_logs:
453 d = d.joinpath(self._timestamp.strftime('%Y-%m-%d'))
454 d = d.joinpath(self._branch)
455 if self._args.tag:
456 d = d.joinpath(self._args.tag)
457 return d.resolve().absolute()
458
459 def LogDir(self):
460 return self._log_dir
461
462 def Benchmarks(self):
463 return [b for b in self._benchmarks if b.id in self._args.benchmark]
464
465 def Tag(self):
466 return self._args.tag
467
468 def DryRun(self):
469 return self._args.dry_run
470
471 def _lunches(self):
472 def parse_lunch(lunch):
473 parts = lunch.split("-")
474 if len(parts) != 3:
475 raise OptionsError(f"Invalid lunch combo: {lunch}")
476 return Lunch(parts[0], parts[1], parts[2])
477 # If they gave lunch targets on the command line use that
478 if self._args.lunch:
479 result = []
480 # Split into Lunch objects
481 for lunch in self._args.lunch:
482 try:
483 result.append(parse_lunch(lunch))
484 except OptionsError as ex:
485 self._error(ex.message)
486 return result
487 # Use whats in the environment
488 product = os.getenv("TARGET_PRODUCT")
489 release = os.getenv("TARGET_RELEASE")
490 variant = os.getenv("TARGET_BUILD_VARIANT")
491 if (not product) or (not release) or (not variant):
492 # If they didn't give us anything, fail rather than guessing. There's no good
493 # default for AOSP.
494 self._error("No lunch combo specified. Either pass --lunch argument or run lunch.")
495 return []
496 return [Lunch(product, release, variant),]
497
498 def Lunches(self):
499 return self._lunches
500
501 def Iterations(self):
502 return self._args.iterations
503
504 def _init_benchmarks(self):
505 """Initialize the list of benchmarks."""
506 # Assumes that we've already chdired to the root of the tree.
507 self._benchmarks = [
508 Benchmark(id="full",
509 title="Full build",
510 change=Clean(),
511 modules=["droid"],
512 preroll=0,
513 postroll=3
514 ),
515 Benchmark(id="nochange",
516 title="No change",
517 change=NoChange(),
518 modules=["droid"],
519 preroll=2,
520 postroll=3
521 ),
522 Benchmark(id="modify_bp",
523 title="Modify Android.bp",
524 change=Modify("bionic/libc/Android.bp", "// Comment"),
525 modules=["droid"],
526 preroll=1,
527 postroll=3
528 ),
529 ]
530
531 def _error(self, message):
532 report_error(message)
533 self._had_error = True
534
535
536def report_error(message):
537 sys.stderr.write(f"error: {message}\n")
538
539
540def main(argv):
541 try:
542 options = Options()
543 runner = Runner(options)
544 runner.Run()
545 except FatalError:
546 sys.stderr.write(f"FAILED\n")
547
548
549if __name__ == "__main__":
550 main(sys.argv)