• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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