[go: nahoru, domu]

blob: a677b559f6c5e7f6f63b0e532800b99404bd3e13 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2020 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Tool to run build benchmarks (e.g. incremental build time).
Example Command:
tools/android/build_speed/benchmark.py all_incremental
Example Output:
Summary
gn args: target_os="android" use_goma=true incremental_install=true
gn gen: 6.7s
chrome_java_nosig: 36.1s avg (35.9s, 36.3s)
chrome_java_sig: 38.9s avg (38.8s, 39.1s)
base_java_nosig: 41.0s avg (41.1s, 40.9s)
base_java_sig: 93.1s avg (93.1s, 93.2s)
Note: This tool will make edits on files in your local repo. It will revert the
edits afterwards.
"""
import argparse
import collections
import contextlib
import dataclasses
import functools
import logging
import pathlib
import re
import statistics
import subprocess
import sys
import time
import shutil
from typing import Dict, Callable, Iterator, List, Tuple, Optional
USE_PYTHON_3 = f'{__file__} will only run under python3.'
_SRC_ROOT = pathlib.Path(__file__).resolve().parents[3]
sys.path.insert(1, str(_SRC_ROOT / 'build'))
import gn_helpers
sys.path.insert(1, str(_SRC_ROOT / 'build/android'))
from pylib import constants
import devil_chromium
sys.path.insert(1, str(_SRC_ROOT / 'third_party/catapult/devil'))
from devil.android.sdk import adb_wrapper
from devil.android import device_utils
_GN_PATH = _SRC_ROOT / 'third_party/depot_tools/gn'
_EMULATOR_AVD_DIR = _SRC_ROOT / 'tools/android/avd'
_AVD_SCRIPT = _EMULATOR_AVD_DIR / 'avd.py'
_AVD_CONFIG_DIR = _EMULATOR_AVD_DIR / 'proto'
_SECONDS_TO_POLL_FOR_EMULATOR = 30
_SUPPORTED_EMULATORS = {
'generic_android23.textpb': 'x86',
'generic_android24.textpb': 'x86',
'generic_android25.textpb': 'x86',
'generic_android27.textpb': 'x86',
'generic_android28.textpb': 'x86',
'generic_android29.textpb': 'x86',
'generic_android30.textpb': 'x86',
'generic_android31.textpb': 'x64',
'generic_android32_foldable.textpb': 'x64',
'generic_android33': 'x64',
'generic_android34': 'x64',
}
_GN_ARGS = [
'target_os="android"',
'incremental_install=true',
]
_GOMA_GN_ARG = 'use_goma=true'
_RECLIENT_GN_ARG = 'use_remoteexec=true'
_TARGETS = {
'bundle': 'monochrome_public_bundle',
'apk': 'chrome_public_apk',
}
_SUITES = {
'all_incremental': [
'chrome_java_nosig',
'chrome_java_sig',
'module_java_public_sig',
'module_java_internal_nosig',
'base_java_nosig',
'base_java_sig',
],
'all_chrome_java': [
'chrome_java_nosig',
'chrome_java_sig',
],
'all_module_java': [
'module_java_public_sig',
'module_java_internal_nosig',
],
'all_base_java': [
'base_java_nosig',
'base_java_sig',
],
'extra_incremental': [
'turbine_headers',
'compile_java',
'write_build_config',
],
}
@dataclasses.dataclass
class Benchmark:
name: str
is_incremental: bool = True
can_build: bool = True
can_install: bool = True
from_string: str = ''
to_string: str = ''
change_file: str = ''
_BENCHMARKS = [
Benchmark(
name='chrome_java_nosig',
from_string='sInstanceForTesting = instance;',
to_string='sInstanceForTesting = instance;String test = "Test";',
change_file=
'chrome/android/java/src/org/chromium/chrome/browser/AppHooks.java',
),
Benchmark(
name='chrome_java_sig',
from_string='AppHooksImpl sInstanceForTesting;',
to_string=
'AppHooksImpl sInstanceForTesting;public void NewInterfaceMethod(){}',
change_file=
'chrome/android/java/src/org/chromium/chrome/browser/AppHooks.java',
),
Benchmark(
name='module_java_public_sig',
from_string='INVALID_WINDOW_INDEX = -1',
to_string='INVALID_WINDOW_INDEX = -2',
change_file=
'chrome/browser/tabmodel/android/java/src/org/chromium/chrome/browser/tabmodel/TabWindowManager.java', # pylint: disable=line-too-long
),
Benchmark(
name='module_java_internal_nosig',
from_string='"TabModelSelector',
to_string='"DifferentUniqueString',
change_file=
'chrome/browser/tabmodel/internal/android/java/src/org/chromium/chrome/browser/tabmodel/TabWindowManagerImpl.java', # pylint: disable=line-too-long
),
Benchmark(
name='base_java_nosig',
from_string='"SysUtil',
to_string='"SysUtil1',
change_file='base/android/java/src/org/chromium/base/SysUtils.java',
),
Benchmark(
name='base_java_sig',
from_string='SysUtils";',
to_string='SysUtils";public void NewInterfaceMethod(){}',
change_file='base/android/java/src/org/chromium/base/SysUtils.java',
),
Benchmark(
name='turbine_headers',
from_string='# found in the LICENSE file.',
to_string='#temporary_edit_for_benchmark.py',
change_file='build/android/gyp/turbine.py',
can_install=False,
),
Benchmark(
name='compile_java',
from_string='# found in the LICENSE file.',
to_string='#temporary_edit_for_benchmark.py',
change_file='build/android/gyp/compile_java.py',
can_install=False,
),
Benchmark(
name='write_build_config',
from_string='# found in the LICENSE file.',
to_string='#temporary_edit_for_benchmark.py',
change_file='build/android/gyp/write_build_config.py',
can_install=False,
),
]
_BENCHMARK_FROM_NAME = {benchmark.name: benchmark for benchmark in _BENCHMARKS}
@contextlib.contextmanager
def _backup_file(file_path: pathlib.Path):
if not file_path.exists():
try:
yield
finally:
if file_path.exists():
file_path.unlink()
return
file_backup_path = file_path.with_suffix('.backup')
logging.info('Creating %s for backup', file_backup_path)
# Move the original file and copy back to preserve metadata.
shutil.move(file_path, file_backup_path)
try:
shutil.copy(file_backup_path, file_path)
yield
finally:
shutil.move(file_backup_path, file_path)
# Update the timestamp so ninja knows to rebuild next time.
pathlib.Path(file_path).touch()
@contextlib.contextmanager
def _server():
cmd = [_SRC_ROOT / 'build/android/fast_local_dev_server.py']
# Avoid the build server's output polluting benchmark results, but allow
# stderr to get through in case the build server fails with an error.
# TODO(wnwen): Switch to using subprocess.run and check=True to quit if the
# server cannot be started.
server_proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL)
logging.debug('Started fast local dev server.')
try:
yield
finally:
# Since Popen's default context manager just waits on exit, we need to
# use our custom context manager to actually terminate the build server
# when the current build is done to avoid skewing the next benchmark.
server_proc.terminate()
server_proc.wait()
def _detect_emulators() -> List[device_utils.DeviceUtils]:
return [
device_utils.DeviceUtils(d) for d in adb_wrapper.AdbWrapper.Devices()
if isinstance(d, adb_wrapper.AdbWrapper) and d.is_emulator
]
def _poll_for_emulators(
condition: Callable[[List[device_utils.DeviceUtils]], bool], *,
expected: str):
for sec in range(_SECONDS_TO_POLL_FOR_EMULATOR):
emulators = _detect_emulators()
if condition(emulators):
break
logging.debug(f'Waited {sec}s for emulator to become ready...')
time.sleep(1)
else:
raise Exception(
f'Emulator is not ready after {_SECONDS_TO_POLL_FOR_EMULATOR}s. '
f'Expected {expected}.')
@contextlib.contextmanager
def _emulator(emulator_avd_name):
logging.info(f'Starting emulator image: {emulator_avd_name}')
_poll_for_emulators(lambda emulators: len(emulators) == 0,
expected='no running emulators')
avd_config = _AVD_CONFIG_DIR / emulator_avd_name
is_verbose = logging.getLogger().isEnabledFor(logging.INFO)
# Always start with --wipe-data to get consistent results. It adds around
# 20 seconds to startup timing but is essential to avoid Timeout errors.
cmd = [_AVD_SCRIPT, 'start', '--wipe-data', '--avd-config', avd_config]
if not is_verbose:
cmd.append('-q')
logging.debug('Running AVD cmd: %s', cmd)
try:
# Ensure that stdout goes to stderr so that timing output does not get
# mixed with logging output.
subprocess.run(cmd, check=True, stdout=sys.stderr)
except subprocess.CalledProcessError:
logging.error(f'Unable to start the emulator {emulator_avd_name}')
raise
_poll_for_emulators(lambda emulators: len(emulators) == 1,
expected='exactly one emulator started successfully')
device = _detect_emulators()[0]
assert device.adb is not None
try:
# Ensure the emulator and its disk are fully set up.
device.WaitUntilFullyBooted(decrypt=True)
logging.info('Started emulator: %s', device.serial)
yield device
finally:
device.adb.Emu('kill')
_poll_for_emulators(lambda emulators: len(emulators) == 0,
expected='no running emulators')
logging.info('Stopped emulator.')
def _run_and_time_cmd(cmd: List[str]) -> float:
logging.debug('Running %s', cmd)
start = time.time()
try:
# Since output can be verbose, only show it for debug/errors.
show_output = logging.getLogger().isEnabledFor(logging.DEBUG)
# Ensure that stdout goes to stderr so that timing output does not get
# mixed with logging output.
subprocess.run(cmd,
cwd=_SRC_ROOT,
capture_output=not show_output,
stdout=sys.stderr if show_output else None,
check=True,
text=True)
except subprocess.CalledProcessError as e:
logging.error('Output was: %s', e.output)
raise
return time.time() - start
def _run_gn_gen(out_dir: pathlib.Path) -> float:
return _run_and_time_cmd([str(_GN_PATH), 'gen', '-C', str(out_dir)])
def _compile(out_dir: pathlib.Path, target: str, j: Optional[str]) -> float:
cmd = gn_helpers.CreateBuildCommand(str(out_dir))
if j is not None:
cmd += ['-j', j]
return _run_and_time_cmd(cmd + [target])
def _run_install(out_dir: pathlib.Path, target: str,
device_serial: str) -> float:
# Example script path: out/Debug/bin/chrome_public_apk
script_path = out_dir / 'bin' / target
# Disable first run to get a more accurate timing of startup.
cmd = [
str(script_path), 'run', '--device', device_serial,
'--args=--disable-fre', '--exit-on-match',
'^Successfully loaded native library$'
]
if logging.getLogger().isEnabledFor(logging.DEBUG):
cmd += ['-vv']
return _run_and_time_cmd(cmd)
def _run_and_maybe_install(out_dir: pathlib.Path, target: str,
emulator: Optional[device_utils.DeviceUtils],
j: Optional[str]) -> float:
total_time = _compile(out_dir, target, j)
if emulator:
total_time += _run_install(out_dir, target, emulator.serial)
return total_time
def _run_benchmark(benchmark: Benchmark, out_dir: pathlib.Path, target: str,
emulator: Optional[device_utils.DeviceUtils],
j: Optional[str]) -> float:
# This ensures that the only change is the one that this script makes.
logging.info(f'Prepping benchmark...')
if not benchmark.can_install:
emulator = None
prep_time = _run_and_maybe_install(out_dir, target, emulator, j)
logging.info(f'Took {prep_time:.1f}s to prep.')
logging.info(f'Starting actual test...')
change_file_path = _SRC_ROOT / benchmark.change_file
with _backup_file(change_file_path):
with open(change_file_path, 'r') as f:
content = f.read()
with open(change_file_path, 'w') as f:
new_content = re.sub(benchmark.from_string, benchmark.to_string,
content)
assert content != new_content, (
f'Need to update {benchmark.from_string} in '
f'{benchmark.change_file}')
f.write(new_content)
return _run_and_maybe_install(out_dir, target, emulator, j)
def _format_result(time_taken: List[float]) -> str:
avg_time = sum(time_taken) / len(time_taken)
result = f'{avg_time:.1f}s'
if len(time_taken) > 1:
standard_deviation = statistics.stdev(time_taken)
list_of_times = ', '.join(f'{t:.1f}s' for t in time_taken)
result += f' avg [sd: {standard_deviation:.1f}s] ({list_of_times})'
return result
def _parse_benchmarks(benchmarks: List[str]) -> Iterator[Benchmark]:
for name in benchmarks:
if name in _SUITES:
for benchmark_name in _SUITES[name]:
yield _BENCHMARK_FROM_NAME[benchmark_name]
else:
yield _BENCHMARK_FROM_NAME[name]
def run_benchmarks(benchmarks: List[str], gn_args: List[str],
output_directory: pathlib.Path, target: str, repeat: int,
no_server: bool, emulator_avd_name: Optional[str],
j: Optional[str]) -> Dict[str, List[float]]:
args_gn_path = output_directory / 'args.gn'
if emulator_avd_name is None:
emulator_ctx = contextlib.nullcontext
else:
emulator_ctx = functools.partial(_emulator, emulator_avd_name)
server_ctx = _server if not no_server else contextlib.nullcontext
timings = collections.defaultdict(list)
with _backup_file(args_gn_path):
with open(args_gn_path, 'w') as f:
# Use newlines instead of spaces since autoninja.py uses regex to
# determine whether use_goma is turned on or off.
f.write('\n'.join(gn_args))
for run_num in range(repeat):
logging.info(f'Run number: {run_num + 1}')
timings['gn gen'].append(_run_gn_gen(output_directory))
for benchmark in _parse_benchmarks(benchmarks):
logging.info(f'Starting {benchmark.name}...')
# Run the fast local dev server fresh for each benchmark run
# to avoid later benchmarks being slower due to the server
# accumulating queued tasks. Start a fresh emulator for each
# benchmark to produce more consistent results.
with emulator_ctx() as emulator, server_ctx():
elapsed = _run_benchmark(benchmark=benchmark,
out_dir=output_directory,
target=target,
emulator=emulator,
j=j)
logging.info(f'Completed {benchmark.name}: {elapsed:.1f}s')
timings[benchmark.name].append(elapsed)
return timings
def _all_benchmark_and_suite_names() -> Iterator[str]:
for key in _SUITES.keys():
yield key
for benchmark in _BENCHMARKS:
yield benchmark.name
def _list_benchmarks() -> str:
strs = ['\nSuites and Individual Benchmarks:']
for name in _all_benchmark_and_suite_names():
strs.append(f' {name}')
return '\n'.join(strs)
def main():
assert __doc__ is not None
parser = argparse.ArgumentParser(
description=__doc__ + _list_benchmarks(),
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
'benchmark',
nargs='*',
metavar='BENCHMARK',
# Allow empty to just test `gn gen` speed.
choices=list(_all_benchmark_and_suite_names()) + [[]],
help='Names of benchmark(s) or suites(s) to run.')
parser.add_argument('--bundle',
action='store_true',
help='Switch the default target from apk to bundle.')
parser.add_argument('--no-server',
action='store_true',
help='Do not start a faster local dev server before '
'running the test.')
parser.add_argument('-r',
'--repeat',
type=int,
default=1,
help='Number of times to repeat the benchmark.')
parser.add_argument(
'-C',
'--output-directory',
help='If outdir is not provided, will attempt to guess.')
parser.add_argument('--emulator',
choices=list(_SUPPORTED_EMULATORS.keys()),
help='Specify this to override the default emulator.')
parser.add_argument('--target',
help='Specify this to override the default target.')
parser.add_argument('-j',
help='Pass -j to use ninja instead of autoninja.')
parser.add_argument('--use-reclient',
action='store_true',
help='Allow bots use reclient instead of goma.')
parser.add_argument('-v',
'--verbose',
action='count',
default=0,
help='1 to print logging, 2 to print ninja output.')
args = parser.parse_args()
if args.output_directory:
constants.SetOutputDirectory(args.output_directory)
constants.CheckOutputDirectory()
out_dir = pathlib.Path(constants.GetOutDirectory()).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
if args.verbose >= 2:
level = logging.DEBUG
elif args.verbose == 1:
level = logging.INFO
else:
level = logging.WARNING
logging.basicConfig(
level=level, format='%(levelname).1s %(relativeCreated)6d %(message)s')
gn_args = _GN_ARGS
if args.emulator:
devil_chromium.Initialize()
logging.info('Using emulator %s', args.emulator)
gn_args.append(f'target_cpu="{_SUPPORTED_EMULATORS[args.emulator]}"')
else:
# Default to an emulator target_cpu when just building to be comparable
# to building and installing on an emulator. It is likely that devs are
# mostly using emulator builds so this is more valuable to track.
gn_args.append('target_cpu="x86"')
if args.use_reclient:
gn_args.append(_RECLIENT_GN_ARG)
else:
gn_args.append(_GOMA_GN_ARG)
if args.target:
target = args.target
else:
target = _TARGETS['bundle' if args.bundle else 'apk']
results = run_benchmarks(args.benchmark, gn_args, out_dir, target,
args.repeat, args.no_server, args.emulator,
args.j)
server_str = f'{"not " if args.no_server else ""}using build server'
print(f'Summary ({server_str})')
print(f'emulator: {args.emulator}')
print(f'gn args: {" ".join(gn_args)}')
print(f'target: {target}')
for name, timings in results.items():
print(f'{name}: {_format_result(timings)}')
if __name__ == '__main__':
sys.exit(main())