1# Copyright (C) 2022 The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15""" 16A tool for running builds (soong or b) and measuring the time taken. 17""" 18import datetime 19import enum 20import functools 21import hashlib 22import itertools 23import json 24import logging 25import os 26import shutil 27import subprocess 28import sys 29import textwrap 30import time 31from pathlib import Path 32from typing import Final 33from typing import Mapping 34from typing import Optional 35from typing import TextIO 36 37import cuj_catalog 38import perf_metrics 39import pretty 40import ui 41import util 42from cuj import skip_for 43from util import BuildInfo 44from util import BuildResult 45from util import BuildType 46 47MAX_RUN_COUNT: Final[int] = 5 48BAZEL_PROFILES: Final[str] = "bazel_metrics" 49CQUERY_OUT: Final[str] = "soong/soong_injection/cquery.out" 50 51 52@functools.cache 53def _prepare_env() -> Mapping[str, str]: 54 env = os.environ.copy() 55 # TODO: Switch to oriole when it works 56 default_product: Final[str] = ( 57 "cf_x86_64_phone" 58 if util.get_top_dir().joinpath("vendor/google/build").exists() 59 else "aosp_cf_x86_64_phone" 60 ) 61 target_product = os.environ.get("TARGET_PRODUCT") or default_product 62 variant = os.environ.get("TARGET_BUILD_VARIANT") or "eng" 63 64 if target_product != default_product or variant != "eng": 65 logging.warning( 66 f"USING {target_product}-{variant} INSTEAD OF {default_product}-eng" 67 ) 68 env["TARGET_PRODUCT"] = target_product 69 env["TARGET_BUILD_VARIANT"] = variant 70 71 if "USE_PERSISTENT_BAZEL" not in env: 72 env["USE_PERSISTENT_BAZEL"] = "1" 73 return env 74 75 76def _new_action_count(actions_output: TextIO = None, previous_count=0) -> int: 77 """the new actions are written to `actions_output`""" 78 ninja_log_file = util.get_out_dir().joinpath(".ninja_log") 79 if not ninja_log_file.exists(): 80 return 0 81 action_count: int = 0 82 with open(ninja_log_file, "r") as ninja_log: 83 for line in ninja_log: 84 # note "# ninja log v5" is the first line in `.nina_log` 85 if line.startswith("#"): 86 continue 87 action_count += 1 88 if actions_output and previous_count < action_count: 89 # second from last column is the file 90 print(line.split()[-2], file=actions_output) 91 delta = action_count - previous_count 92 return delta 93 94 95def _recompact_ninja_log(f: TextIO): 96 env = _prepare_env() 97 target_product = env["TARGET_PRODUCT"] 98 subprocess.run( 99 [ 100 util.get_top_dir().joinpath("prebuilts/build-tools/linux-x86/bin/ninja"), 101 "-f", 102 util.get_out_dir().joinpath(f"combined-{target_product}.ninja"), 103 "-t", 104 "recompact", 105 ], 106 check=False, 107 cwd=util.get_top_dir(), 108 env=env, 109 shell=False, 110 stdout=f, 111 stderr=f, 112 text=True, 113 ) 114 115 116def _build_file_sha(target_product: str) -> str: 117 build_file = util.get_out_dir().joinpath(f"soong/build.{target_product}.ninja") 118 if not build_file.exists(): 119 return "" 120 with open(build_file, mode="rb") as f: 121 h = hashlib.sha256() 122 for block in iter(lambda: f.read(4096), b""): 123 h.update(block) 124 return h.hexdigest()[0:8] 125 126 127def _build_file_size(target_product: str) -> int: 128 build_file = util.get_out_dir().joinpath(f"soong/build.{target_product}.ninja") 129 return os.path.getsize(build_file) if build_file.exists() else 0 130 131 132def _pretty_env(env: Mapping[str, str]) -> str: 133 env_copy = [f"{k}={v}" for (k, v) in env.items()] 134 env_copy.sort() 135 return "\n".join(env_copy) 136 137 138def _total_size(filetype: str) -> int | None: 139 match filetype: 140 case "Android.bp": 141 top = util.get_top_dir().resolve() 142 out = util.get_out_dir().resolve() 143 ignore_out = f'-not -path "{out}" ' if out.is_relative_to(top) else "" 144 cmd = f'find {top} -name Android.bp {ignore_out} | xargs stat -L -c "%s"' 145 case "BUILD.bazel": 146 ws = util.get_out_dir().joinpath("soong", "workspace") 147 cmd = f'find {ws} -name BUILD.bazel | xargs stat -L -c "%s"' 148 case _: 149 raise RuntimeError(f"Android.bp or BUILD.bazel expected: {filetype}") 150 logging.debug(f"Running {cmd}") 151 p = subprocess.run(cmd, shell=True, text=True, capture_output=True) 152 if p.returncode: 153 logging.error( 154 "Failed to compute total size of %s files:\n%s", filetype, p.stderr 155 ) 156 return None 157 return sum(int(line) for line in p.stdout.splitlines()) 158 159 160def _build(build_type: BuildType, run_dir: Path) -> BuildInfo: 161 logfile = run_dir.joinpath("output.txt") 162 run_dir.mkdir(parents=True, exist_ok=False) 163 cmd = [*build_type.value, *ui.get_user_input().targets] 164 env = _prepare_env() 165 target_product = env["TARGET_PRODUCT"] 166 167 cquery_out = util.get_out_dir().joinpath("soong/soong_injection/cquery.out") 168 169 def get_cquery_ts() -> float: 170 try: 171 return os.stat(cquery_out).st_mtime 172 except FileNotFoundError: 173 return -1 174 175 @skip_for(BuildType.SOONG_ONLY, BuildType.B_BUILD, BuildType.B_ANDROID) 176 def get_cquery_size() -> int: 177 return os.stat(cquery_out).st_size if cquery_out.exists() else None 178 179 cquery_ts = get_cquery_ts() 180 with open(logfile, mode="wt") as f: 181 action_count_before = _new_action_count() 182 if action_count_before > 0: 183 _recompact_ninja_log(f) 184 f.flush() 185 action_count_before = _new_action_count() 186 f.write( 187 f"Command: {cmd}\n" 188 f"Environment Variables:\n" 189 f'{textwrap.indent(_pretty_env(env), " ")}\n\n\n' 190 ) 191 f.flush() # because we pass f to a subprocess, we want to flush now 192 logging.info("Command: %s", cmd) 193 logging.info('TIP: To view the log:\n tail -f "%s"', logfile) 194 start_ns = time.perf_counter_ns() 195 p = subprocess.run( 196 cmd, 197 check=False, 198 cwd=util.get_top_dir(), 199 env=env, 200 shell=False, 201 stdout=f, 202 stderr=f, 203 ) 204 elapsed_ns = time.perf_counter_ns() - start_ns 205 with open(run_dir.joinpath("new_ninja_actions.txt"), "w") as af: 206 action_count_delta = _new_action_count(af, action_count_before) 207 208 if get_cquery_ts() > cquery_ts: 209 shutil.copy(cquery_out, run_dir.joinpath("cquery.out")) 210 bazel_profiles = util.get_out_dir().joinpath(BAZEL_PROFILES) 211 if bazel_profiles.exists(): 212 shutil.copytree(bazel_profiles, run_dir.joinpath(BAZEL_PROFILES)) 213 214 return BuildInfo( 215 actions=action_count_delta, 216 bp_size_total=_total_size("Android.bp"), 217 build_type=build_type, 218 build_result=BuildResult.FAILED if p.returncode else BuildResult.SUCCESS, 219 build_ninja_hash=_build_file_sha(target_product), 220 build_ninja_size=_build_file_size(target_product), 221 bz_size_total=_total_size("BUILD.bazel"), 222 cquery_out_size=get_cquery_size(), 223 description="<placeholder>", 224 product=f'{target_product}-{env["TARGET_BUILD_VARIANT"]}', 225 targets=ui.get_user_input().targets, 226 time=datetime.timedelta(microseconds=elapsed_ns / 1000), 227 ) 228 229 230def _run_cuj( 231 run_dir: Path, build_type: ui.BuildType, cujstep: cuj_catalog.CujStep 232) -> BuildInfo: 233 build_info: Final[BuildInfo] = _build(build_type, run_dir) 234 # if build was successful, run test 235 if build_info.build_result == BuildResult.SUCCESS: 236 try: 237 cujstep.verify() 238 except Exception as e: 239 logging.exception(e) 240 build_info.build_result = BuildResult.TEST_FAILURE 241 242 return build_info 243 244 245def _display(prop_regex: str): 246 user_input = ui.get_user_input() 247 metrics = user_input.log_dir.joinpath(util.METRICS_TABLE) 248 if not metrics.exists(): 249 perf_metrics.tabulate_metrics_csv(user_input.log_dir) 250 perf_metrics.display_tabulated_metrics(user_input.log_dir, user_input.ci_mode) 251 pretty.summarize( 252 metrics, 253 prop_regex, 254 user_input.log_dir.joinpath("perf"), 255 ) 256 257 258def main(): 259 """ 260 Run provided target(s) under various CUJs and collect metrics. 261 In pseudocode: 262 time build <target> with m or b 263 collect metrics 264 for each cuj: 265 make relevant changes 266 time rebuild 267 collect metrics 268 revert those changes 269 time rebuild 270 collect metrics 271 """ 272 user_input = ui.get_user_input() 273 logging.warning( 274 textwrap.dedent( 275 f"""\ 276 If you kill this process, make sure to revert unwanted changes. 277 TIP: If you have no local changes of interest you may 278 `repo forall -p -c git reset --hard` and 279 `repo forall -p -c git clean --force` and even 280 `m clean && rm -rf {util.get_out_dir()}` 281 """ 282 ) 283 ) 284 285 run_dir_gen = util.next_path(user_input.log_dir.joinpath(util.RUN_DIR_PREFIX)) 286 287 class StopBuilding(enum.Enum): 288 CI_MODE = enum.auto() 289 DUE_TO_ERROR = enum.auto() 290 291 stop_building: Optional[StopBuilding] = None 292 293 def run_cuj_group(cuj_group: cuj_catalog.CujGroup): 294 nonlocal stop_building 295 for cujstep in cuj_group.get_steps(): 296 desc = cujstep.verb 297 desc = f"{desc} {cuj_group.description}".strip() 298 logging.info( 299 "********* %s %s [%s] **********", 300 build_type.name, 301 " ".join(user_input.targets), 302 desc, 303 ) 304 cujstep.apply_change() 305 306 for run in itertools.count(): 307 if stop_building: 308 logging.warning("SKIPPING BUILD") 309 break 310 run_dir = next(run_dir_gen) 311 312 build_info = _run_cuj(run_dir, build_type, cujstep) 313 build_info.description = ( 314 desc if run == 0 else f"rebuild-{run} after {desc}" 315 ) 316 build_info.warmup = cuj_group == cuj_catalog.Warmup 317 build_info.rebuild = run != 0 318 build_info.tag = user_input.tag 319 320 logging.info(json.dumps(build_info, indent=2, cls=util.CustomEncoder)) 321 322 if user_input.ci_mode and cuj_group != cuj_catalog.Warmup: 323 stop_building = StopBuilding.CI_MODE 324 logs_dir_for_ci = user_input.log_dir.parent.joinpath("logs") 325 if logs_dir_for_ci.exists(): 326 perf_metrics.archive_run(logs_dir_for_ci, build_info) 327 328 if build_info.build_result != BuildResult.SUCCESS: 329 stop_building = StopBuilding.DUE_TO_ERROR 330 logging.critical(f"Failed cuj run! Please see logs in: {run_dir}") 331 elif run == MAX_RUN_COUNT - 1: 332 stop_building = StopBuilding.DUE_TO_ERROR 333 logging.critical(f"Build did not stabilize in {run} attempts") 334 335 perf_metrics.archive_run(run_dir, build_info) 336 perf_metrics.tabulate_metrics_csv(user_input.log_dir) 337 if cuj_group != cuj_catalog.Warmup and run == 0: 338 _display("^time$") # display intermediate results 339 340 if build_info.actions == 0: 341 # build has stabilized 342 break 343 if stop_building == StopBuilding.DUE_TO_ERROR: 344 sys.exit(1) 345 346 for build_type in user_input.build_types: 347 util.CURRENT_BUILD_TYPE = build_type 348 # warm-up run reduces variations attributable to OS caches 349 if user_input.chosen_cujgroups and not user_input.no_warmup: 350 run_cuj_group(cuj_catalog.Warmup) 351 for i in user_input.chosen_cujgroups: 352 run_cuj_group(cuj_catalog.get_cujgroups()[i]) 353 _display(r"^(?:time|bp2build|soong_build/\*\.bazel)$") 354 355 356class InfoAndBelow(logging.Filter): 357 def filter(self, record): 358 return record.levelno < logging.WARNING 359 360 361class ColoredLoggingFormatter(logging.Formatter): 362 GREEN = "\x1b[32m" 363 PURPLE = "\x1b[35m" 364 RED = "\x1b[31m" 365 YELLOW = "\x1b[33m" 366 RESET = "\x1b[0m" 367 BASIC = "%(asctime)s %(levelname)s: %(message)s" 368 369 FORMATS = { 370 logging.DEBUG: f"{YELLOW}%(asctime)s %(levelname)s:{RESET} %(message)s", 371 logging.INFO: f"{GREEN}%(asctime)s %(levelname)s:{RESET} %(message)s", 372 logging.WARNING: f"{PURPLE}{BASIC}{RESET}", 373 logging.ERROR: f"{RED}{BASIC}{RESET}", 374 logging.CRITICAL: f"{RED}{BASIC}{RESET}", 375 } 376 377 def format(self, record): 378 f = self.FORMATS.get(record.levelno, ColoredLoggingFormatter.BASIC) 379 formatter = logging.Formatter(fmt=f, datefmt="%H:%M:%S") 380 return formatter.format(record) 381 382 383def configure_logger(): 384 logging.root.setLevel(logging.INFO) 385 386 eh = logging.StreamHandler(stream=sys.stderr) 387 eh.setLevel(logging.WARNING) 388 eh.setFormatter(ColoredLoggingFormatter()) 389 logging.getLogger().addHandler(eh) 390 391 oh = logging.StreamHandler(stream=sys.stdout) 392 oh.addFilter(InfoAndBelow()) 393 oh.setFormatter(ColoredLoggingFormatter()) 394 logging.getLogger().addHandler(oh) 395 396 397if __name__ == "__main__": 398 configure_logger() 399 main() 400