1#!/usr/bin/env python3 2 3# Copyright (C) 2022 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17""" 18A tool for running builds (soong or b) and measuring the time taken. 19""" 20import datetime 21import functools 22import hashlib 23import logging 24import os 25import subprocess 26import sys 27import textwrap 28import time 29from pathlib import Path 30from typing import Final 31from typing import Mapping 32 33import cuj_catalog 34import perf_metrics 35import pretty 36import ui 37import util 38 39MAX_RUN_COUNT: int = 5 40 41 42@functools.cache 43def _prepare_env() -> (Mapping[str, str], str): 44 def get_soong_build_ninja_args(): 45 ninja_args = os.environ.get('NINJA_ARGS') or '' 46 if ninja_args != '': 47 ninja_args += ' ' 48 ninja_args += '-d explain --quiet' 49 if util.is_ninja_dry_run(ninja_args): 50 global MAX_RUN_COUNT 51 MAX_RUN_COUNT = 1 52 logging.warning(f'Running dry ninja runs NINJA_ARGS={ninja_args}') 53 return ninja_args 54 55 def get_soong_ui_ninja_args(): 56 soong_ui_ninja_args = os.environ.get('SOONG_UI_NINJA_ARGS') or '' 57 if util.is_ninja_dry_run(soong_ui_ninja_args): 58 sys.exit('"-n" in SOONG_UI_NINJA_ARGS would not update build.ninja etc') 59 60 if soong_ui_ninja_args != '': 61 soong_ui_ninja_args += ' ' 62 soong_ui_ninja_args += '-d explain --quiet' 63 return soong_ui_ninja_args 64 65 overrides: Mapping[str, str] = { 66 'NINJA_ARGS': get_soong_build_ninja_args(), 67 'SOONG_UI_NINJA_ARGS': get_soong_ui_ninja_args() 68 } 69 env = {**os.environ, **overrides} 70 # TODO: Switch to oriole when it works 71 default_product: Final[str] = 'cf_x86_64_phone' \ 72 if util.get_top_dir().joinpath('vendor/google/build').exists() \ 73 else 'aosp_cf_x86_64_phone' 74 target_product = os.environ.get('TARGET_PRODUCT') or default_product 75 variant = os.environ.get('TARGET_BUILD_VARIANT') or 'eng' 76 77 if target_product != default_product or variant != 'eng': 78 if util.is_interactive_shell(): 79 response = input(f'Are you sure you want {target_product}-{variant} ' 80 f'and not {default_product}-eng? [Y/n]') 81 if response.upper() != 'Y': 82 sys.exit(1) 83 else: 84 logging.warning( 85 f'Using {target_product}-{variant} instead of {default_product}-eng') 86 env['TARGET_PRODUCT'] = target_product 87 env['TARGET_BUILD_VARIANT'] = variant 88 pretty_env_str = [f'{k}={v}' for (k, v) in env.items()] 89 pretty_env_str.sort() 90 return env, '\n'.join(pretty_env_str) 91 92 93def _build_file_sha() -> str: 94 build_file = util.get_out_dir().joinpath('soong/build.ninja') 95 if not build_file.exists(): 96 return '--' 97 with open(build_file, mode="rb") as f: 98 h = hashlib.sha256() 99 for block in iter(lambda: f.read(4096), b''): 100 h.update(block) 101 return h.hexdigest()[0:8] 102 103 104def _build_file_size() -> int: 105 build_file = util.get_out_dir().joinpath('soong/build.ninja') 106 return os.path.getsize(build_file) if build_file.exists() else 0 107 108 109BuildInfo = dict[str, any] 110 111 112def _build(build_type: ui.BuildType, run_dir: Path) -> (int, BuildInfo): 113 logfile = run_dir.joinpath('output.txt') 114 logging.info('TIP: to see the log:\n tail -f "%s"', logfile) 115 cmd = [*build_type.value, *ui.get_user_input().targets] 116 logging.info('Command: %s', cmd) 117 env, env_str = _prepare_env() 118 ninja_log_file = util.get_out_dir().joinpath('.ninja_log') 119 120 def get_action_count() -> int: 121 if not ninja_log_file.exists(): 122 return 0 123 with open(ninja_log_file, 'r') as ninja_log: 124 # subtracting 1 to account for "# ninja log v5" in the first line 125 return sum(1 for _ in ninja_log) - 1 126 127 def recompact_ninja_log(): 128 subprocess.run([ 129 util.get_top_dir().joinpath( 130 'prebuilts/build-tools/linux-x86/bin/ninja'), 131 '-f', 132 util.get_out_dir().joinpath( 133 f'combined-{env.get("TARGET_PRODUCT", "aosp_arm")}.ninja'), 134 '-t', 'recompact'], 135 check=False, cwd=util.get_top_dir(), shell=False, 136 stdout=f, stderr=f) 137 138 with open(logfile, mode='w') as f: 139 action_count_before = get_action_count() 140 if action_count_before > 0: 141 recompact_ninja_log() 142 action_count_before = get_action_count() 143 f.write(f'Command: {cmd}\n') 144 f.write(f'Environment Variables:\n{textwrap.indent(env_str, " ")}\n\n\n') 145 f.flush() 146 start_ns = time.perf_counter_ns() 147 p = subprocess.run(cmd, check=False, cwd=util.get_top_dir(), env=env, 148 shell=False, stdout=f, stderr=f) 149 elapsed_ns = time.perf_counter_ns() - start_ns 150 action_count_after = get_action_count() 151 152 return (p.returncode, { 153 'build_type': build_type.to_flag(), 154 'build.ninja': _build_file_sha(), 155 'build.ninja.size': _build_file_size(), 156 'targets': ' '.join(ui.get_user_input().targets), 157 'log': str(run_dir.relative_to(ui.get_user_input().log_dir)), 158 'ninja_explains': util.count_explanations(logfile), 159 'actions': action_count_after - action_count_before, 160 'time': util.hhmmss(datetime.timedelta(microseconds=elapsed_ns / 1000)) 161 }) 162 163 164def _run_cuj(run_dir: Path, build_type: ui.BuildType, 165 cujstep: cuj_catalog.CujStep, desc: str, run) -> BuildInfo: 166 run_dir.mkdir(parents=True, exist_ok=False) 167 (exit_code, build_info) = _build(build_type, run_dir) 168 # if build was successful, run test 169 if exit_code != 0: 170 build_result = cuj_catalog.BuildResult.FAILED.name 171 else: 172 try: 173 cujstep.verify() 174 build_result = cuj_catalog.BuildResult.SUCCESS.name 175 except Exception as e: 176 logging.error(e) 177 build_result = (cuj_catalog.BuildResult.TEST_FAILURE.name + 178 ':' + str(e)) 179 # summarize 180 log_desc = desc if run == 0 else f'rebuild-{run} after {desc}' 181 build_info = { 182 'description': log_desc, 183 'build_result': build_result 184 } | build_info 185 logging.info('%s after %s: %s', 186 build_info["build_result"], build_info["time"], log_desc) 187 return build_info 188 189 190def main(): 191 """ 192 Run provided target(s) under various CUJs and collect metrics. 193 In pseudocode: 194 time build <target> with m or b 195 collect metrics 196 for each cuj: 197 make relevant changes 198 time rebuild 199 collect metrics 200 revert those changes 201 time rebuild 202 collect metrics 203 """ 204 user_input = ui.get_user_input() 205 206 logging.warning(textwrap.dedent(''' 207 If you kill this process, make sure to revert unwanted changes. 208 TIP: If you have no local changes of interest you may 209 `repo forall -p -c git reset --hard` and 210 `repo forall -p -c git clean --force` and even 211 `m clean && rm -rf out` 212 ''')) 213 214 run_dir_gen = util.next_path(user_input.log_dir.joinpath(util.RUN_DIR_PREFIX)) 215 216 def run_cuj_group(cuj_group: cuj_catalog.CujGroup): 217 for cujstep in cuj_group.steps: 218 desc = cujstep.verb 219 desc = f'{desc} {cuj_group.description}'.strip() 220 desc = f'{desc} {user_input.description}'.strip() 221 logging.info('START %s %s [%s]', build_type.name, 222 ' '.join(user_input.targets), desc) 223 cujstep.apply_change() 224 for run in range(0, MAX_RUN_COUNT): 225 run_dir = next(run_dir_gen) 226 build_info = _run_cuj(run_dir, build_type, cujstep, desc, run) 227 perf_metrics.archive_run(run_dir, build_info) 228 if build_info['ninja_explains'] == 0: 229 break 230 logging.info(' DONE %s %s [%s]', build_type.name, 231 ' '.join(user_input.targets), desc) 232 233 for build_type in user_input.build_types: 234 # warm-up run reduces variations attributable to OS caches 235 run_cuj_group(cuj_catalog.Warmup) 236 for i in user_input.chosen_cujgroups: 237 run_cuj_group(cuj_catalog.get_cujgroups()[i]) 238 239 perf_metrics.tabulate_metrics_csv(user_input.log_dir) 240 perf_metrics.display_tabulated_metrics(user_input.log_dir) 241 pretty.summarize_metrics(user_input.log_dir) 242 pretty.display_summarized_metrics(user_input.log_dir) 243 244 245if __name__ == '__main__': 246 logging.root.setLevel(logging.INFO) 247 main() 248