blob: 510ee1314f59a615cdb331962a5b17018e4c995a [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2017 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Tool for finding the cause of APK bloat.
Run diagnose_apk_bloat.py -h for detailed usage help.
"""
import argparse
import collections
import distutils.spawn
import itertools
import json
import multiprocessing
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
_BUILDER_URL = \
'https://build.chromium.org/p/chromium.perf/builders/Android%20Builder'
_CLOUD_OUT_DIR = os.path.join('out', 'Release')
_SRC_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
_DEFAULT_ARCHIVE_DIR = os.path.join(_SRC_ROOT, 'binary-size-bloat')
_DEFAULT_OUT_DIR = os.path.join(_SRC_ROOT, 'out', 'diagnose-apk-bloat')
_DEFAULT_TARGET = 'monochrome_public_apk'
# Global variable for storing the initial branch before the script was launched
# so that it doesn't need to be passed everywhere in case we fail and exit.
_initial_branch = None
class BaseDiff(object):
"""Base class capturing binary size diffs."""
def __init__(self, name):
self.name = name
self.banner = '\n' + '*' * 30 + name + '*' * 30
self.RunDiff()
def AppendResults(self, logfile):
"""Print and write diff results to an open |logfile|."""
_PrintAndWriteToFile(logfile, self.banner)
_PrintAndWriteToFile(logfile, 'Summary:')
_PrintAndWriteToFile(logfile, self.Summary())
_PrintAndWriteToFile(logfile, '\nDetails:')
for l in self.DetailedResults():
_PrintAndWriteToFile(logfile, l)
def Summary(self):
"""A short description that summarizes the source of binary size bloat."""
raise NotImplementedError()
def DetailedResults(self):
"""An iterable description of the cause of binary size bloat."""
raise NotImplementedError()
def ProduceDiff(self):
"""Prepare a binary size diff with ready to print results."""
raise NotImplementedError()
def RunDiff(self):
_Print('Creating {}', self.name)
self.ProduceDiff()
_ResourceSizesDiffResult = collections.namedtuple(
'ResourceSizesDiffResult', ['section', 'value', 'units'])
class ResourceSizesDiff(BaseDiff):
_RESOURCE_SIZES_PATH = os.path.join(
_SRC_ROOT, 'build', 'android', 'resource_sizes.py')
def __init__(self, archive_dirs, apk_name, slow_options=False):
self._archive_dirs = archive_dirs
self._apk_name = apk_name
self._slow_options = slow_options
self._diff = None # Set by |ProduceDiff()|
super(ResourceSizesDiff, self).__init__('Resource Sizes Diff')
def DetailedResults(self):
for section, value, units in self._diff:
yield '{:>+10,} {} {}'.format(value, units, section)
def Summary(self):
for s in self._diff:
if 'normalized' in s.section:
return 'Normalized APK size: {:+,} {}'.format(s.value, s.units)
return ''
def ProduceDiff(self):
chartjsons = self._RunResourceSizes()
diff = []
with_patch = chartjsons[0]['charts']
without_patch = chartjsons[1]['charts']
for section, section_dict in with_patch.iteritems():
for subsection, v in section_dict.iteritems():
# Ignore entries when resource_sizes.py chartjson format has changed.
if (section not in without_patch or
subsection not in without_patch[section] or
v['units'] != without_patch[section][subsection]['units']):
_Print('Found differing dict structures for resource_sizes.py, '
'skipping {} {}', section, subsection)
else:
diff.append(
_ResourceSizesDiffResult(
'%s %s' % (section, subsection),
v['value'] - without_patch[section][subsection]['value'],
v['units']))
self._diff = sorted(diff, key=lambda x: abs(x.value), reverse=True)
def _RunResourceSizes(self):
chartjsons = []
for archive_dir in self._archive_dirs:
apk_path = os.path.join(archive_dir, self._apk_name)
chartjson_file = os.path.join(archive_dir, 'results-chart.json')
cmd = [self._RESOURCE_SIZES_PATH, apk_path,'--output-dir', archive_dir,
'--no-output-dir', '--chartjson']
if self._slow_options:
cmd += ['--estimate-patch-size']
else:
cmd += ['--no-static-initializer-check']
_RunCmd(cmd)
with open(chartjson_file) as f:
chartjsons.append(json.load(f))
return chartjsons
class _BuildHelper(object):
"""Helper class for generating and building targets."""
def __init__(self, args):
self.enable_chrome_android_internal = args.enable_chrome_android_internal
self.extra_gn_args_str = ''
self.max_jobs = args.max_jobs
self.max_load_average = args.max_load_average
self.output_directory = args.output_directory
self.target = args.target
self.target_os = args.target_os
self.use_goma = args.use_goma
self._SetDefaults()
@property
def abs_apk_path(self):
return os.path.join(self.output_directory, self.apk_path)
@property
def apk_name(self):
# Only works on apk targets that follow: my_great_apk naming convention.
apk_name = ''.join(s.title() for s in self.target.split('_')[:-1]) + '.apk'
return apk_name.replace('Webview', 'WebView')
@property
def apk_path(self):
return os.path.join('apks', self.apk_name)
@property
def main_lib_name(self):
# TODO(estevenson): Get this from GN instead of hardcoding.
if self.IsLinux():
return 'chrome'
elif 'monochrome' in self.target:
return 'lib.unstripped/libmonochrome.so'
else:
return 'lib.unstripped/libchrome.so'
@property
def main_lib_path(self):
return os.path.join(self.output_directory, self.main_lib_name)
@property
def map_file_name(self):
return self.main_lib_name + '.map.gz'
def _SetDefaults(self):
has_goma_dir = os.path.exists(os.path.join(os.path.expanduser('~'), 'goma'))
self.use_goma = self.use_goma or has_goma_dir
self.max_load_average = (self.max_load_average or
str(multiprocessing.cpu_count()))
if not self.max_jobs:
self.max_jobs = '10000' if self.use_goma else '500'
if os.path.exists(os.path.join(os.path.dirname(_SRC_ROOT), 'src-internal')):
self.extra_gn_args_str = ' is_chrome_branded=true'
else:
self.extra_gn_args_str = (' exclude_unwind_tables=true '
'ffmpeg_branding="Chrome" proprietary_codecs=true')
def _GenGnCmd(self):
gn_args = 'is_official_build=true symbol_level=1'
gn_args += ' use_goma=%s' % str(self.use_goma).lower()
gn_args += ' target_os="%s"' % self.target_os
gn_args += (' enable_chrome_android_internal=%s' %
str(self.enable_chrome_android_internal).lower())
gn_args += self.extra_gn_args_str
return ['gn', 'gen', self.output_directory, '--args=%s' % gn_args]
def _GenNinjaCmd(self):
cmd = ['ninja', '-C', self.output_directory]
cmd += ['-j', self.max_jobs] if self.max_jobs else []
cmd += ['-l', self.max_load_average] if self.max_load_average else []
cmd += [self.target]
return cmd
def Run(self):
_Print('Building: {}.', self.target)
_RunCmd(self._GenGnCmd(), print_stdout=True)
_RunCmd(self._GenNinjaCmd(), print_stdout=True)
def IsAndroid(self):
return self.target_os == 'android'
def IsLinux(self):
return self.target_os == 'linux'
def _RunCmd(cmd, print_stdout=False):
"""Convenience function for running commands.
Args:
cmd: the command to run.
print_stdout: if this is True, then the stdout of the process will be
printed, otherwise stdout will be returned.
Returns:
Command stdout if |print_stdout| is False otherwise ''.
"""
cmd_str = ' '.join(c for c in cmd)
_Print('Running: {}', cmd_str)
if print_stdout:
proc_stdout = sys.stdout
else:
proc_stdout = subprocess.PIPE
proc = subprocess.Popen(cmd, stdout=proc_stdout, stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
if proc.returncode:
_Die('command failed: {}\nstderr:\n{}', cmd_str, stderr)
return stdout.strip() if stdout else ''
def _GitCmd(args):
return _RunCmd(['git', '-C', _SRC_ROOT] + args)
def _GclientSyncCmd(rev):
cwd = os.getcwd()
os.chdir(_SRC_ROOT)
_RunCmd(['gclient', 'sync', '-r', 'src@' + rev], print_stdout=True)
os.chdir(cwd)
def _ArchiveBuildResult(archive_dir, build):
"""Save build artifacts necessary for diffing.
Expects |build.output_directory| to be correct.
"""
_Print('Saving build results to: {}', archive_dir)
if not os.path.exists(archive_dir):
os.makedirs(archive_dir)
def ArchiveFile(filename):
if not os.path.exists(filename):
_Die('missing expected file: {}', filename)
shutil.copy(filename, archive_dir)
ArchiveFile(build.main_lib_path)
lib_name_noext = os.path.splitext(os.path.basename(build.main_lib_path))[0]
size_path = os.path.join(archive_dir, lib_name_noext + '.size')
supersize_path = os.path.join(_SRC_ROOT, 'tools/binary_size/supersize')
tool_prefix = _FindToolPrefix(build.output_directory)
supersize_cmd = [supersize_path, 'archive', size_path, '--elf-file',
build.main_lib_path, '--tool-prefix', tool_prefix,
'--output-directory', build.output_directory,
'--no-source-paths']
if build.IsAndroid():
supersize_cmd += ['--apk-file', build.abs_apk_path]
ArchiveFile(build.abs_apk_path)
_RunCmd(supersize_cmd)
def _FindToolPrefix(output_directory):
build_vars_path = os.path.join(output_directory, 'build_vars.txt')
if os.path.exists(build_vars_path):
with open(build_vars_path) as f:
build_vars = dict(l.rstrip().split('=', 1) for l in f if '=' in l)
# Tool prefix is relative to output dir, rebase to source root.
tool_prefix = build_vars['android_tool_prefix']
while os.path.sep in tool_prefix:
rebased_tool_prefix = os.path.join(_SRC_ROOT, tool_prefix)
if os.path.exists(rebased_tool_prefix + 'readelf'):
return rebased_tool_prefix
tool_prefix = tool_prefix[tool_prefix.find(os.path.sep) + 1:]
return ''
def _SyncAndBuild(revs, archive_dirs, build):
# Move to a detached state since gclient sync doesn't work with local commits
# on a branch.
_GitCmd(['checkout', '--detach'])
for rev, archive_dir in itertools.izip(revs, archive_dirs):
_GclientSyncCmd(rev)
build.Run()
_ArchiveBuildResult(archive_dir, build)
def _NormalizeRev(rev):
"""Use actual revs instead of HEAD, HEAD^, etc."""
return _GitCmd(['rev-parse', rev])
def _EnsureDirectoryClean():
_Print('Checking source directory')
stdout = _GitCmd(['status', '--porcelain'])
# Ignore untracked files.
if stdout and stdout[:2] != '??':
_Die('please ensure working directory is clean.')
def _SetInitialBranch():
global _initial_branch
_initial_branch = _GitCmd(['rev-parse', '--abbrev-ref', 'HEAD'])
def _RestoreInitialBranch():
if _initial_branch:
_GitCmd(['checkout', _initial_branch])
def _Die(s, *args, **kwargs):
_Print('Failure: ' + s, *args, **kwargs)
_RestoreInitialBranch()
sys.exit(1)
def _DownloadBuildArtifacts(revs, archive_dirs, build, depot_tools_path=None):
"""Download artifacts from arm32 chromium perf builder."""
if depot_tools_path:
gsutil_path = os.path.join(depot_tools_path, 'gsutil.py')
else:
gsutil_path = distutils.spawn.find_executable('gsutil.py')
if not gsutil_path:
_Die('gsutil.py not found, please provide path to depot_tools via '
'--depot-tools-path or add it to your PATH')
download_dir = tempfile.mkdtemp(dir=_SRC_ROOT)
try:
for rev, archive_dir in itertools.izip(revs, archive_dirs):
_DownloadAndArchive(gsutil_path, rev, archive_dir, download_dir, build)
finally:
shutil.rmtree(download_dir)
def _DownloadAndArchive(gsutil_path, rev, archive_dir, dl_dir, build):
dl_file = 'full-build-linux_%s.zip' % rev
dl_url = 'gs://chrome-perf/Android Builder/%s' % dl_file
dl_dst = os.path.join(dl_dir, dl_file)
_Print('Downloading build artifacts for {}', rev)
# gsutil writes stdout and stderr to stderr, so pipe stdout and stderr to
# sys.stdout.
retcode = subprocess.call([gsutil_path, 'cp', dl_url, dl_dir],
stdout=sys.stdout, stderr=subprocess.STDOUT)
if retcode:
_Die('unexpected error while downloading {}. It may no longer exist on '
'the server or it may not have been uploaded yet (check {}). '
'Otherwise, you may not have the correct access permissions.',
dl_url, _BUILDER_URL)
# Files needed for supersize and resource_sizes. Paths relative to out dir.
to_extract = [build.main_lib_name, build.map_file_name, 'args.gn',
'build_vars.txt', build.apk_path]
extract_dir = os.path.join(os.path.splitext(dl_dst)[0], 'unzipped')
# Storage bucket stores entire output directory including out/Release prefix.
_Print('Extracting build artifacts')
with zipfile.ZipFile(dl_dst, 'r') as z:
_ExtractFiles(to_extract, _CLOUD_OUT_DIR, extract_dir, z)
dl_out = os.path.join(extract_dir, _CLOUD_OUT_DIR)
build.output_directory, output_directory = dl_out, build.output_directory
_ArchiveBuildResult(archive_dir, build)
build.output_directory = output_directory
def _ExtractFiles(to_extract, prefix, dst, z):
zip_infos = z.infolist()
assert all(info.filename.startswith(prefix) for info in zip_infos), (
'Storage bucket folder structure doesn\'t start with %s' % prefix)
to_extract = [os.path.join(prefix, f) for f in to_extract]
for f in to_extract:
z.extract(f, path=dst)
def _Print(s, *args, **kwargs):
print s.format(*args, **kwargs)
def _PrintAndWriteToFile(logfile, s):
"""Print |s| to |logfile| and stdout."""
_Print(s)
logfile.write('%s\n' % s)
def main():
parser = argparse.ArgumentParser(
description='Find the cause of APK size bloat.',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--archive-dir',
default=_DEFAULT_ARCHIVE_DIR,
help='Where results are stored.')
parser.add_argument('--rev-with-patch',
default='HEAD',
help='Commit with patch.')
parser.add_argument('--rev-without-patch',
help='Older patch to diff against. If not supplied, '
'the previous commit to rev_with_patch will be used.')
parser.add_argument('--include-slow-options',
action='store_true',
help='Run some extra steps that take longer to complete. '
'This includes apk-patch-size estimation and '
'static-initializer counting')
parser.add_argument('--cloud',
action='store_true',
help='Download build artifacts from perf builders '
'(Android only, Googlers only).')
parser.add_argument('--depot-tools-path',
help='Custom path to depot tools. Needed for --cloud if '
'depot tools isn\'t in your PATH')
build_group = parser.add_argument_group('ninja', 'Args to use with ninja/gn')
build_group.add_argument('-j',
dest='max_jobs',
help='Run N jobs in parallel.')
build_group.add_argument('-l',
dest='max_load_average',
help='Do not start new jobs if the load average is '
'greater than N.')
build_group.add_argument('--no-goma',
action='store_false',
dest='use_goma',
default=True,
help='Use goma when building with ninja.')
build_group.add_argument('--target-os',
default='android',
choices=['android', 'linux'],
help='target_os gn arg.')
build_group.add_argument('--output-directory',
default=_DEFAULT_OUT_DIR,
help='ninja output directory.')
build_group.add_argument('--enable_chrome_android_internal',
action='store_true',
help='Allow downstream targets to be built.')
build_group.add_argument('--target',
default=_DEFAULT_TARGET,
help='GN APK target to build.')
args = parser.parse_args()
build = _BuildHelper(args)
if args.cloud and build.IsLinux():
parser.error('--cloud only works for android')
_EnsureDirectoryClean()
_SetInitialBranch()
revs = [args.rev_with_patch,
args.rev_without_patch or args.rev_with_patch + '^']
revs = [_NormalizeRev(r) for r in revs]
archive_dirs = [os.path.join(args.archive_dir, '%d-%s' % (len(revs) - i, rev))
for i, rev in enumerate(revs)]
if args.cloud:
_DownloadBuildArtifacts(revs, archive_dirs, build,
depot_tools_path=args.depot_tools_path)
else:
_SetInitialBranch()
_SyncAndBuild(revs, archive_dirs, build)
_RestoreInitialBranch()
output_file = os.path.join(args.archive_dir,
'diff_result_{}_{}.txt'.format(*revs))
if os.path.exists(output_file):
os.remove(output_file)
diffs = []
if build.IsAndroid():
diffs += [
ResourceSizesDiff(archive_dirs, build.apk_name,
slow_options=args.include_slow_options)
]
with open(output_file, 'a') as logfile:
for d in diffs:
d.AppendResults(logfile)
if __name__ == '__main__':
sys.exit(main())