blob: 0a126e6290f265ee85c678bf8670bccbceb850d4 [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
Joe Onorato01277d42023-12-20 04:00:12 +000026import random
27import re
Joe Onorato88ede352023-12-19 02:56:38 +000028import shutil
29import subprocess
30import time
Joe Onorato01277d42023-12-20 04:00:12 +000031import uuid
Joe Onorato88ede352023-12-19 02:56:38 +000032
33import pretty
34import utils
35
36
37class FatalError(Exception):
38 def __init__(self):
39 pass
40
41
42class OptionsError(Exception):
43 def __init__(self, message):
44 self.message = message
45
46
47@dataclasses.dataclass(frozen=True)
48class Lunch:
49 "Lunch combination"
50
51 target_product: str
52 "TARGET_PRODUCT"
53
54 target_release: str
55 "TARGET_RELEASE"
56
57 target_build_variant: str
58 "TARGET_BUILD_VARIANT"
59
60 def ToDict(self):
61 return {
62 "TARGET_PRODUCT": self.target_product,
63 "TARGET_RELEASE": self.target_release,
64 "TARGET_BUILD_VARIANT": self.target_build_variant,
65 }
66
67 def Combine(self):
68 return f"{self.target_product}-{self.target_release}-{self.target_build_variant}"
69
70
71@dataclasses.dataclass(frozen=True)
72class Change:
73 "A change that we make to the tree, and how to undo it"
74 label: str
75 "String to print in the log when the change is made"
76
77 change: callable
78 "Function to change the source tree"
79
80 undo: callable
81 "Function to revert the source tree to its previous condition in the most minimal way possible."
82
83
84@dataclasses.dataclass(frozen=True)
85class Benchmark:
86 "Something we measure"
87
88 id: str
89 "Short ID for the benchmark, for the command line"
90
91 title: str
92 "Title for reports"
93
94 change: Change
95 "Source tree modification for the benchmark that will be measured"
96
97 modules: list[str]
98 "Build modules to build on soong command line"
99
100 preroll: int
101 "Number of times to run the build command to stabilize"
102
103 postroll: int
104 "Number of times to run the build command after reverting the action to stabilize"
105
106
107@dataclasses.dataclass(frozen=True)
108class FileSnapshot:
109 "Snapshot of a file's contents."
110
111 filename: str
112 "The file that was snapshottened"
113
114 contents: str
115 "The contents of the file"
116
117 def write(self):
118 "Write the contents back to the file"
119 with open(self.filename, "w") as f:
120 f.write(self.contents)
121
122
123def Snapshot(filename):
124 """Return a FileSnapshot with the file's current contents."""
125 with open(filename) as f:
126 contents = f.read()
127 return FileSnapshot(filename, contents)
128
129
130def Clean():
131 """Remove the out directory."""
132 def remove_out():
133 if os.path.exists("out"):
134 shutil.rmtree("out")
135 return Change(label="Remove out", change=remove_out, undo=lambda: None)
136
137
138def NoChange():
139 """No change to the source tree."""
140 return Change(label="No change", change=lambda: None, undo=lambda: None)
141
142
Joe Onorato01277d42023-12-20 04:00:12 +0000143def Create(filename):
144 "Create an action to create `filename`. The parent directory must exist."
145 def create():
146 with open(filename, "w") as f:
147 pass
148 def delete():
149 os.remove(filename)
150 return Change(
151 label=f"Create {filename}",
152 change=create,
153 undo=delete,
154 )
155
156
Joe Onorato88ede352023-12-19 02:56:38 +0000157def Modify(filename, contents, before=None):
Joe Onorato01277d42023-12-20 04:00:12 +0000158 """Create an action to modify `filename` by appending the result of `contents`
159 before the last instances of `before` in the file.
Joe Onorato88ede352023-12-19 02:56:38 +0000160
161 Raises an error if `before` doesn't appear in the file.
162 """
163 orig = Snapshot(filename)
164 if before:
165 index = orig.contents.rfind(before)
166 if index < 0:
167 report_error(f"{filename}: Unable to find string '{before}' for modify operation.")
168 raise FatalError()
169 else:
170 index = len(orig.contents)
Joe Onorato01277d42023-12-20 04:00:12 +0000171 modified = FileSnapshot(filename, orig.contents[:index] + contents() + orig.contents[index:])
172 if False:
173 print(f"Modify: {filename}")
174 x = orig.contents.replace("\n", "\n ORIG")
175 print(f" ORIG {x}")
176 x = modified.contents.replace("\n", "\n MODIFIED")
177 print(f" MODIFIED {x}")
178
Joe Onorato88ede352023-12-19 02:56:38 +0000179 return Change(
180 label="Modify " + filename,
181 change=lambda: modified.write(),
182 undo=lambda: orig.write()
183 )
184
Joe Onorato01277d42023-12-20 04:00:12 +0000185def AddJavaField(filename, prefix):
186 return Modify(filename,
187 lambda: f"{prefix} static final int BENCHMARK = {random.randint(0, 1000000)};\n",
188 before="}")
189
190
191def Comment(prefix, suffix=""):
192 return lambda: prefix + " " + str(uuid.uuid4()) + suffix
193
Joe Onorato88ede352023-12-19 02:56:38 +0000194
195class BenchmarkReport():
196 "Information about a run of the benchmark"
197
198 lunch: Lunch
199 "lunch combo"
200
201 benchmark: Benchmark
202 "The benchmark object."
203
204 iteration: int
205 "Which iteration of the benchmark"
206
207 log_dir: str
208 "Path the the log directory, relative to the root of the reports directory"
209
210 preroll_duration_ns: [int]
211 "Durations of the in nanoseconds."
212
213 duration_ns: int
214 "Duration of the measured portion of the benchmark in nanoseconds."
215
216 postroll_duration_ns: [int]
217 "Durations of the postrolls in nanoseconds."
218
219 complete: bool
220 "Whether the benchmark made it all the way through the postrolls."
221
222 def __init__(self, lunch, benchmark, iteration, log_dir):
223 self.lunch = lunch
224 self.benchmark = benchmark
225 self.iteration = iteration
226 self.log_dir = log_dir
227 self.preroll_duration_ns = []
228 self.duration_ns = -1
229 self.postroll_duration_ns = []
230 self.complete = False
231
232 def ToDict(self):
233 return {
234 "lunch": self.lunch.ToDict(),
235 "id": self.benchmark.id,
236 "title": self.benchmark.title,
237 "modules": self.benchmark.modules,
238 "change": self.benchmark.change.label,
239 "iteration": self.iteration,
240 "log_dir": self.log_dir,
241 "preroll_duration_ns": self.preroll_duration_ns,
242 "duration_ns": self.duration_ns,
243 "postroll_duration_ns": self.postroll_duration_ns,
244 "complete": self.complete,
245 }
246
247class Runner():
248 """Runs the benchmarks."""
249
250 def __init__(self, options):
251 self._options = options
252 self._reports = []
253 self._complete = False
254
255 def Run(self):
256 """Run all of the user-selected benchmarks."""
257 # Clean out the log dir or create it if necessary
258 prepare_log_dir(self._options.LogDir())
259
260 try:
261 for lunch in self._options.Lunches():
262 print(lunch)
263 for benchmark in self._options.Benchmarks():
264 for iteration in range(self._options.Iterations()):
265 self._run_benchmark(lunch, benchmark, iteration)
266 self._complete = True
267 finally:
268 self._write_summary()
269
270
271 def _run_benchmark(self, lunch, benchmark, iteration):
272 """Run a single benchmark."""
273 benchmark_log_subdir = self._log_dir(lunch, benchmark, iteration)
274 benchmark_log_dir = self._options.LogDir().joinpath(benchmark_log_subdir)
275
276 sys.stderr.write(f"STARTING BENCHMARK: {benchmark.id}\n")
277 sys.stderr.write(f" lunch: {lunch.Combine()}\n")
278 sys.stderr.write(f" iteration: {iteration}\n")
279 sys.stderr.write(f" benchmark_log_dir: {benchmark_log_dir}\n")
280
281 report = BenchmarkReport(lunch, benchmark, iteration, benchmark_log_subdir)
282 self._reports.append(report)
283
284 # Preroll builds
285 for i in range(benchmark.preroll):
286 ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"pre_{i}"), benchmark.modules)
287 report.preroll_duration_ns.append(ns)
288
289 sys.stderr.write(f"PERFORMING CHANGE: {benchmark.change.label}\n")
290 if not self._options.DryRun():
291 benchmark.change.change()
292 try:
293
294 # Measured build
295 ns = self._run_build(lunch, benchmark_log_dir.joinpath("measured"), benchmark.modules)
296 report.duration_ns = ns
297
298 # Postroll builds
299 for i in range(benchmark.preroll):
300 ns = self._run_build(lunch, benchmark_log_dir.joinpath(f"post_{i}"),
301 benchmark.modules)
302 report.postroll_duration_ns.append(ns)
303
304 finally:
305 # Always undo, even if we crashed or the build failed and we stopped.
306 sys.stderr.write(f"UNDOING CHANGE: {benchmark.change.label}\n")
307 if not self._options.DryRun():
308 benchmark.change.undo()
309
310 self._write_summary()
311 sys.stderr.write(f"FINISHED BENCHMARK: {benchmark.id}\n")
312
313 def _log_dir(self, lunch, benchmark, iteration):
314 """Construct the log directory fir a benchmark run."""
315 path = f"{lunch.Combine()}/{benchmark.id}"
316 # Zero pad to the correct length for correct alpha sorting
317 path += ("/%0" + str(len(str(self._options.Iterations()))) + "d") % iteration
318 return path
319
320 def _run_build(self, lunch, build_log_dir, modules):
321 """Builds the modules. Saves interesting log files to log_dir. Raises FatalError
322 if the build fails.
323 """
324 sys.stderr.write(f"STARTING BUILD {modules}\n")
325
326 before_ns = time.perf_counter_ns()
327 if not self._options.DryRun():
328 cmd = [
329 "build/soong/soong_ui.bash",
330 "--build-mode",
331 "--all-modules",
332 f"--dir={self._options.root}",
333 ] + modules
334 env = dict(os.environ)
335 env["TARGET_PRODUCT"] = lunch.target_product
336 env["TARGET_RELEASE"] = lunch.target_release
337 env["TARGET_BUILD_VARIANT"] = lunch.target_build_variant
338 returncode = subprocess.call(cmd, env=env)
339 if returncode != 0:
340 report_error(f"Build failed: {' '.join(cmd)}")
341 raise FatalError()
342
343 after_ns = time.perf_counter_ns()
344
345 # TODO: Copy some log files.
346
347 sys.stderr.write(f"FINISHED BUILD {modules}\n")
348
349 return after_ns - before_ns
350
351 def _write_summary(self):
352 # Write the results, even if the build failed or we crashed, including
353 # whether we finished all of the benchmarks.
354 data = {
355 "start_time": self._options.Timestamp().isoformat(),
356 "branch": self._options.Branch(),
357 "tag": self._options.Tag(),
358 "benchmarks": [report.ToDict() for report in self._reports],
359 "complete": self._complete,
360 }
361 with open(self._options.LogDir().joinpath("summary.json"), "w", encoding="utf-8") as f:
362 json.dump(data, f, indent=2, sort_keys=True)
363
364
365def benchmark_table(benchmarks):
366 rows = [("ID", "DESCRIPTION", "REBUILD"),]
367 rows += [(benchmark.id, benchmark.title, " ".join(benchmark.modules)) for benchmark in
368 benchmarks]
369 return rows
370
371
372def prepare_log_dir(directory):
373 if os.path.exists(directory):
374 # If it exists and isn't a directory, fail.
375 if not os.path.isdir(directory):
376 report_error(f"Log directory already exists but isn't a directory: {directory}")
377 raise FatalError()
378 # Make sure the directory is empty. Do this rather than deleting it to handle
379 # symlinks cleanly.
380 for filename in os.listdir(directory):
381 entry = os.path.join(directory, filename)
382 if os.path.isdir(entry):
383 shutil.rmtree(entry)
384 else:
385 os.unlink(entry)
386 else:
387 # Create it
388 os.makedirs(directory)
389
390
391class Options():
392 def __init__(self):
393 self._had_error = False
394
395 # Wall time clock when we started
396 self._timestamp = datetime.datetime.now(datetime.timezone.utc)
397
398 # Move to the root of the tree right away. Everything must happen from there.
399 self.root = utils.get_root()
400 if not self.root:
401 report_error("Unable to find root of tree from cwd.")
402 raise FatalError()
403 os.chdir(self.root)
404
405 # Initialize the Benchmarks. Note that this pre-loads all of the files, etc.
406 # Doing all that here forces us to fail fast if one of them can't load a required
407 # file, at the cost of a small startup speed. Don't make this do something slow
408 # like scan the whole tree.
409 self._init_benchmarks()
410
411 # Argument parsing
412 epilog = f"""
413benchmarks:
414{pretty.FormatTable(benchmark_table(self._benchmarks), prefix=" ")}
415"""
416
417 parser = argparse.ArgumentParser(
418 prog="benchmarks",
419 allow_abbrev=False, # Don't let people write unsupportable scripts.
420 formatter_class=argparse.RawDescriptionHelpFormatter,
421 epilog=epilog,
422 description="Run build system performance benchmarks.")
423 self.parser = parser
424
425 parser.add_argument("--log-dir",
426 help="Directory for logs. Default is $TOP/../benchmarks/.")
427 parser.add_argument("--dated-logs", action="store_true",
428 help="Append timestamp to log dir.")
429 parser.add_argument("-n", action="store_true", dest="dry_run",
430 help="Dry run. Don't run the build commands but do everything else.")
431 parser.add_argument("--tag",
432 help="Variant of the run, for when there are multiple perf runs.")
433 parser.add_argument("--lunch", nargs="*",
434 help="Lunch combos to test")
435 parser.add_argument("--iterations", type=int, default=1,
436 help="Number of iterations of each test to run.")
437 parser.add_argument("--branch", type=str,
438 help="Specify branch. Otherwise a guess will be made based on repo.")
439 parser.add_argument("--benchmark", nargs="*", default=[b.id for b in self._benchmarks],
440 metavar="BENCHMARKS",
441 help="Benchmarks to run. Default suite will be run if omitted.")
442
443 self._args = parser.parse_args()
444
445 self._branch = self._branch()
446 self._log_dir = self._log_dir()
447 self._lunches = self._lunches()
448
449 # Validate the benchmark ids
450 all_ids = [benchmark.id for benchmark in self._benchmarks]
451 bad_ids = [id for id in self._args.benchmark if id not in all_ids]
452 if bad_ids:
453 for id in bad_ids:
454 self._error(f"Invalid benchmark: {id}")
455
456 if self._had_error:
457 raise FatalError()
458
459 def Timestamp(self):
460 return self._timestamp
461
462 def _branch(self):
463 """Return the branch, either from the command line or by guessing from repo."""
464 if self._args.branch:
465 return self._args.branch
466 try:
467 branch = subprocess.check_output(f"cd {self.root}/.repo/manifests"
468 + " && git rev-parse --abbrev-ref --symbolic-full-name @{u}",
469 shell=True, encoding="utf-8")
470 return branch.strip().split("/")[-1]
471 except subprocess.CalledProcessError as ex:
472 report_error("Can't get branch from .repo dir. Specify --branch argument")
473 report_error(str(ex))
474 raise FatalError()
475
476 def Branch(self):
477 return self._branch
478
479 def _log_dir(self):
480 "The log directory to use, based on the current options"
481 if self._args.log_dir:
482 d = pathlib.Path(self._args.log_dir).resolve().absolute()
483 else:
484 d = self.root.joinpath("..", utils.DEFAULT_REPORT_DIR)
485 if self._args.dated_logs:
486 d = d.joinpath(self._timestamp.strftime('%Y-%m-%d'))
487 d = d.joinpath(self._branch)
488 if self._args.tag:
489 d = d.joinpath(self._args.tag)
490 return d.resolve().absolute()
491
492 def LogDir(self):
493 return self._log_dir
494
495 def Benchmarks(self):
496 return [b for b in self._benchmarks if b.id in self._args.benchmark]
497
498 def Tag(self):
499 return self._args.tag
500
501 def DryRun(self):
502 return self._args.dry_run
503
504 def _lunches(self):
505 def parse_lunch(lunch):
506 parts = lunch.split("-")
507 if len(parts) != 3:
508 raise OptionsError(f"Invalid lunch combo: {lunch}")
509 return Lunch(parts[0], parts[1], parts[2])
510 # If they gave lunch targets on the command line use that
511 if self._args.lunch:
512 result = []
513 # Split into Lunch objects
514 for lunch in self._args.lunch:
515 try:
516 result.append(parse_lunch(lunch))
517 except OptionsError as ex:
518 self._error(ex.message)
519 return result
520 # Use whats in the environment
521 product = os.getenv("TARGET_PRODUCT")
522 release = os.getenv("TARGET_RELEASE")
523 variant = os.getenv("TARGET_BUILD_VARIANT")
524 if (not product) or (not release) or (not variant):
525 # If they didn't give us anything, fail rather than guessing. There's no good
526 # default for AOSP.
527 self._error("No lunch combo specified. Either pass --lunch argument or run lunch.")
528 return []
529 return [Lunch(product, release, variant),]
530
531 def Lunches(self):
532 return self._lunches
533
534 def Iterations(self):
535 return self._args.iterations
536
537 def _init_benchmarks(self):
538 """Initialize the list of benchmarks."""
539 # Assumes that we've already chdired to the root of the tree.
540 self._benchmarks = [
541 Benchmark(id="full",
Joe Onorato01277d42023-12-20 04:00:12 +0000542 title="Full build",
543 change=Clean(),
544 modules=["droid"],
545 preroll=0,
546 postroll=3,
547 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000548 Benchmark(id="nochange",
Joe Onorato01277d42023-12-20 04:00:12 +0000549 title="No change",
550 change=NoChange(),
551 modules=["droid"],
552 preroll=2,
553 postroll=3,
554 ),
555 Benchmark(id="unreferenced",
556 title="Create unreferenced file",
557 change=Create("bionic/unreferenced.txt"),
558 modules=["droid"],
559 preroll=1,
560 postroll=2,
561 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000562 Benchmark(id="modify_bp",
Joe Onorato01277d42023-12-20 04:00:12 +0000563 title="Modify Android.bp",
564 change=Modify("bionic/libc/Android.bp", Comment("//")),
565 modules=["droid"],
566 preroll=1,
567 postroll=3,
568 ),
569 Benchmark(id="modify_stdio",
570 title="Modify stdio.cpp",
571 change=Modify("bionic/libc/stdio/stdio.cpp", Comment("//")),
572 modules=["libc"],
573 preroll=1,
574 postroll=2,
575 ),
576 Benchmark(id="modify_adbd",
577 title="Modify adbd",
578 change=Modify("packages/modules/adb/daemon/main.cpp", Comment("//")),
579 modules=["adbd"],
580 preroll=1,
581 postroll=2,
582 ),
583 Benchmark(id="services_private_field",
584 title="Add private field to ActivityManagerService.java",
585 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
586 "private"),
587 modules=["services"],
588 preroll=1,
589 postroll=2,
590 ),
591 Benchmark(id="services_public_field",
592 title="Add public field to ActivityManagerService.java",
593 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
594 "/** @hide */ public"),
595 modules=["services"],
596 preroll=1,
597 postroll=2,
598 ),
599 Benchmark(id="services_api",
600 title="Add API to ActivityManagerService.javaa",
601 change=AddJavaField("frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java",
602 "@android.annotation.SuppressLint(\"UnflaggedApi\") public"),
603 modules=["services"],
604 preroll=1,
605 postroll=2,
606 ),
607 Benchmark(id="framework_private_field",
608 title="Add private field to Settings.java",
609 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
610 "private"),
611 modules=["framework-minus-apex"],
612 preroll=1,
613 postroll=2,
614 ),
615 Benchmark(id="framework_public_field",
616 title="Add public field to Settings.java",
617 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
618 "/** @hide */ public"),
619 modules=["framework-minus-apex"],
620 preroll=1,
621 postroll=2,
622 ),
623 Benchmark(id="framework_api",
624 title="Add API to Settings.java",
625 change=AddJavaField("frameworks/base/core/java/android/provider/Settings.java",
626 "@android.annotation.SuppressLint(\"UnflaggedApi\") public"),
627 modules=["framework-minus-apex"],
628 preroll=1,
629 postroll=2,
630 ),
631 Benchmark(id="modify_framework_resource",
632 title="Modify framework resource",
633 change=Modify("frameworks/base/core/res/res/values/config.xml",
634 lambda: str(uuid.uuid4()),
635 before="</string>"),
636 modules=["framework-minus-apex"],
637 preroll=1,
638 postroll=2,
639 ),
640 Benchmark(id="add_framework_resource",
641 title="Add framework resource",
642 change=Modify("frameworks/base/core/res/res/values/config.xml",
643 lambda: f"<string name=\"BENCHMARK\">{uuid.uuid4()}</string>",
644 before="</resources>"),
645 modules=["framework-minus-apex"],
646 preroll=1,
647 postroll=2,
648 ),
649 Benchmark(id="add_systemui_field",
650 title="Add SystemUI field",
651 change=AddJavaField("frameworks/base/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java",
652 "public"),
653 modules=["SystemUI"],
654 preroll=1,
655 postroll=2,
656 ),
Joe Onorato88ede352023-12-19 02:56:38 +0000657 ]
658
659 def _error(self, message):
660 report_error(message)
661 self._had_error = True
662
663
664def report_error(message):
665 sys.stderr.write(f"error: {message}\n")
666
667
668def main(argv):
669 try:
670 options = Options()
671 runner = Runner(options)
672 runner.Run()
673 except FatalError:
674 sys.stderr.write(f"FAILED\n")
675
676
677if __name__ == "__main__":
678 main(sys.argv)