xref: /aosp_15_r20/build/bazel/scripts/incremental_build/incremental_build.py (revision 7594170e27e0732bc44b93d1440d87a54b6ffe7c)
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