| #!/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 binary size bloat. |
| |
| See //tools/binary_size/README.md for example usage. |
| |
| Note: this tool will perform gclient sync/git checkout on your local repo if |
| you don't use the --cloud option. |
| """ |
| |
| import atexit |
| import argparse |
| import collections |
| from contextlib import contextmanager |
| import distutils.spawn |
| import json |
| import logging |
| import multiprocessing |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| import zipfile |
| |
| _COMMIT_COUNT_WARN_THRESHOLD = 15 |
| _ALLOWED_CONSECUTIVE_FAILURES = 2 |
| _SRC_ROOT = os.path.abspath( |
| os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) |
| _DEFAULT_ARCHIVE_DIR = os.path.join(_SRC_ROOT, 'out', 'binary-size-results') |
| _DEFAULT_OUT_DIR = os.path.join(_SRC_ROOT, 'out', 'binary-size-build') |
| _BINARY_SIZE_DIR = os.path.join(_SRC_ROOT, 'tools', 'binary_size') |
| _RESOURCE_SIZES_PATH = os.path.join( |
| _SRC_ROOT, 'build', 'android', 'resource_sizes.py') |
| _LLVM_TOOLS_DIR = os.path.join( |
| _SRC_ROOT, 'third_party', 'llvm-build', 'Release+Asserts', 'bin') |
| _DOWNLOAD_OBJDUMP_PATH = os.path.join( |
| _SRC_ROOT, 'tools', 'clang', 'scripts', 'download_objdump.py') |
| |
| |
| _DiffResult = collections.namedtuple('DiffResult', ['name', 'value', 'units']) |
| |
| |
| class BaseDiff(object): |
| """Base class capturing binary size diffs.""" |
| def __init__(self, name): |
| self.name = name |
| self.banner = '\n' + '*' * 30 + name + '*' * 30 |
| |
| def AppendResults(self, logfiles): |
| """Print and write diff results to an open |logfile|.""" |
| full, short = logfiles |
| _WriteToFile(full, self.banner) |
| _WriteToFile(short, self.banner) |
| |
| for s in self.Summary(): |
| _WriteToFile(short, s) |
| _WriteToFile(short, '') |
| |
| for s in self.DetailedResults(): |
| full.write(s + '\n') |
| |
| @property |
| def summary_stat(self): |
| """Returns a tuple of (name, value, units) for the most important metric.""" |
| raise NotImplementedError() |
| |
| 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, before_dir, after_dir): |
| """Prepare a binary size diff with ready to print results.""" |
| raise NotImplementedError() |
| |
| def RunDiff(self, logfiles, before_dir, after_dir): |
| logging.info('Creating: %s', self.name) |
| self.ProduceDiff(before_dir, after_dir) |
| self.AppendResults(logfiles) |
| |
| |
| class NativeDiff(BaseDiff): |
| # E.g.: Section Sizes (Total=1.2 kb (1222 bytes)): |
| _RE_SUMMARY_STAT = re.compile( |
| r'Section Sizes \(Total=(?P<value>-?[0-9\.]+) ?(?P<units>\w+)') |
| _SUMMARY_STAT_NAME = 'Native Library Delta' |
| |
| def __init__(self, size_name, supersize_path): |
| self._size_name = size_name |
| self._supersize_path = supersize_path |
| self._diff = [] |
| super(NativeDiff, self).__init__('Native Diff') |
| |
| @property |
| def summary_stat(self): |
| m = NativeDiff._RE_SUMMARY_STAT.search(self._diff) |
| if m: |
| return _DiffResult( |
| NativeDiff._SUMMARY_STAT_NAME, m.group('value'), m.group('units')) |
| raise Exception('Could not extract total from:\n' + self._diff) |
| |
| def DetailedResults(self): |
| return self._diff.splitlines() |
| |
| def Summary(self): |
| return self.DetailedResults()[:100] |
| |
| def ProduceDiff(self, before_dir, after_dir): |
| before_size = os.path.join(before_dir, self._size_name) |
| after_size = os.path.join(after_dir, self._size_name) |
| cmd = [self._supersize_path, 'diff', before_size, after_size] |
| self._diff = _RunCmd(cmd)[0].replace('{', '{{').replace('}', '}}') |
| |
| |
| class ResourceSizesDiff(BaseDiff): |
| # Ordered by output appearance. |
| _SUMMARY_SECTIONS = ( |
| 'Specifics', 'InstallSize', 'InstallBreakdown', 'Dex') |
| # Sections where it makes sense to sum subsections into a section total. |
| _AGGREGATE_SECTIONS = ( |
| 'InstallBreakdown', 'Breakdown', 'MainLibInfo', 'Uncompressed') |
| |
| def __init__(self, apk_name, filename='results-chart.json'): |
| self._apk_name = apk_name |
| self._diff = None # Set by |ProduceDiff()| |
| self._filename = filename |
| super(ResourceSizesDiff, self).__init__('Resource Sizes Diff') |
| |
| @property |
| def summary_stat(self): |
| for section_name, results in self._diff.iteritems(): |
| for subsection_name, value, units in results: |
| if 'normalized' in subsection_name: |
| full_name = '{} {}'.format(section_name, subsection_name) |
| return _DiffResult(full_name, value, units) |
| raise Exception('Could not find "normalized" in: ' + repr(self._diff)) |
| |
| def DetailedResults(self): |
| return self._ResultLines() |
| |
| def Summary(self): |
| footer_lines = [ |
| '', |
| 'For an explanation of these metrics, see:', |
| ('https://chromium.googlesource.com/chromium/src/+/master/docs/speed/' |
| 'binary_size/metrics.md#Metrics-for-Android')] |
| return self._ResultLines( |
| include_sections=ResourceSizesDiff._SUMMARY_SECTIONS) + footer_lines |
| |
| def ProduceDiff(self, before_dir, after_dir): |
| before = self._LoadResults(before_dir) |
| after = self._LoadResults(after_dir) |
| self._diff = collections.defaultdict(list) |
| for section, section_dict in after.iteritems(): |
| for subsection, v in section_dict.iteritems(): |
| # Ignore entries when resource_sizes.py chartjson format has changed. |
| if (section not in before or |
| subsection not in before[section] or |
| v['units'] != before[section][subsection]['units']): |
| logging.warning( |
| 'Found differing dict structures for resource_sizes.py, ' |
| 'skipping %s %s', section, subsection) |
| else: |
| self._diff[section].append(_DiffResult( |
| subsection, |
| v['value'] - before[section][subsection]['value'], |
| v['units'])) |
| |
| def _ResultLines(self, include_sections=None): |
| """Generates diff lines for the specified sections (defaults to all).""" |
| section_lines = collections.defaultdict(list) |
| for section_name, section_results in self._diff.iteritems(): |
| if not include_sections or section_name in include_sections: |
| subsection_lines = [] |
| section_sum = 0 |
| units = '' |
| for name, value, units in section_results: |
| # Omit subsections with no changes for summaries. |
| if value == 0 and include_sections: |
| continue |
| section_sum += value |
| subsection_lines.append('{:>+14,} {} {}'.format(value, units, name)) |
| section_header = section_name |
| if section_name in ResourceSizesDiff._AGGREGATE_SECTIONS: |
| section_header += ' ({:+,} {})'.format(section_sum, units) |
| section_header += ':' |
| # Omit sections with empty subsections. |
| if subsection_lines: |
| section_lines[section_name].append(section_header) |
| section_lines[section_name].extend(subsection_lines) |
| if not section_lines: |
| return ['Empty ' + self.name] |
| ret = [] |
| for k in include_sections or sorted(section_lines): |
| ret.extend(section_lines[k]) |
| return ret |
| |
| def _LoadResults(self, archive_dir): |
| chartjson_file = os.path.join(archive_dir, self._filename) |
| with open(chartjson_file) as f: |
| chartjson = json.load(f) |
| charts = chartjson['charts'] |
| # Older versions of resource_sizes.py prefixed the apk onto section names. |
| ret = {} |
| for section, section_dict in charts.iteritems(): |
| section_no_target = re.sub(r'^.*_', '', section) |
| ret[section_no_target] = section_dict |
| return ret |
| |
| |
| class _BuildHelper(object): |
| """Helper class for generating and building targets.""" |
| def __init__(self, args): |
| self.clean = args.clean |
| self.cloud = args.cloud |
| self.enable_chrome_android_internal = args.enable_chrome_android_internal |
| self.extra_gn_args_str = args.gn_args |
| self.apply_patch = args.extra_rev |
| 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() |
| self.is_bundle = 'minimal' in self.target |
| |
| @property |
| def abs_apk_path(self): |
| return os.path.join(self.output_directory, self.apk_path) |
| |
| @property |
| def abs_mapping_path(self): |
| return os.path.join(self.output_directory, self.mapping_path) |
| |
| @property |
| def apk_name(self): |
| # my_great_apk -> MyGreat.apk |
| apk_name = ''.join(s.title() for s in self.target.split('_')[:-1]) + '.apk' |
| if self.is_bundle: |
| # my_great_minimal_apks -> MyGreatMinimal.apk -> MyGreat.minimal.apks |
| apk_name = apk_name.replace('Minimal.apk', '.minimal.apks') |
| return apk_name.replace('Webview', 'WebView') |
| |
| @property |
| def apk_path(self): |
| return os.path.join('apks', self.apk_name) |
| |
| @property |
| def mapping_path(self): |
| if self.is_bundle: |
| return self.apk_path.replace('.minimal.apks', '.aab') + '.mapping' |
| else: |
| return self.apk_path + '.mapping' |
| |
| @property |
| def main_lib_path(self): |
| # Cannot extract this from GN because --cloud needs to work without GN. |
| if self.IsLinux(): |
| return 'chrome' |
| if 'monochrome' in self.target: |
| ret = 'lib.unstripped/libmonochrome_base.so' |
| else: |
| ret = 'lib.unstripped/libchrome_base.so' |
| # Maintain support for measuring non-bundle apks. |
| if not self.is_bundle: |
| ret = ret.replace('_base', '') |
| return ret |
| |
| @property |
| def abs_main_lib_path(self): |
| return os.path.join(self.output_directory, self.main_lib_path) |
| |
| @property |
| def builder_url(self): |
| url = 'https://build.chromium.org/p/chromium.perf/builders/%s%%20Builder' |
| return url % self.target_os.title() |
| |
| @property |
| def download_bucket(self): |
| return 'gs://chrome-perf/%s Builder/' % self.target_os.title() |
| |
| @property |
| def map_file_path(self): |
| return self.main_lib_path + '.map.gz' |
| |
| @property |
| def size_name(self): |
| if self.IsLinux(): |
| return os.path.basename(self.main_lib_path) + '.size' |
| return self.apk_name + '.size' |
| |
| 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())) |
| |
| has_internal = os.path.exists( |
| os.path.join(os.path.dirname(_SRC_ROOT), 'src-internal')) |
| if has_internal: |
| self.extra_gn_args_str = ( |
| 'is_chrome_branded=true ' + self.extra_gn_args_str) |
| else: |
| self.extra_gn_args_str = ( |
| 'ffmpeg_branding="Chrome" proprietary_codecs=true' + |
| self.extra_gn_args_str) |
| if self.IsLinux(): |
| self.extra_gn_args_str = ( |
| 'is_cfi=false generate_linker_map=true ' + self.extra_gn_args_str) |
| self.extra_gn_args_str = ' ' + self.extra_gn_args_str.strip() |
| |
| if not self.max_jobs: |
| if self.use_goma: |
| self.max_jobs = '10000' |
| elif has_internal: |
| self.max_jobs = '500' |
| else: |
| self.max_jobs = '50' |
| |
| if not self.target: |
| if self.IsLinux(): |
| self.target = 'chrome' |
| elif self.enable_chrome_android_internal: |
| self.target = 'monochrome_minimal_apks' |
| else: |
| self.target = 'monochrome_public_minimal_apks' |
| |
| def _GenGnCmd(self): |
| gn_args = 'is_official_build=true' |
| # Variables often become unused when experimenting with macros to reduce |
| # size, so don't fail on warnings. |
| gn_args += ' treat_warnings_as_errors=false' |
| gn_args += ' use_goma=%s' % str(self.use_goma).lower() |
| gn_args += ' target_os="%s"' % self.target_os |
| if self.IsAndroid(): |
| 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): |
| """Run GN gen/ninja build and return the process returncode.""" |
| logging.info('Building %s within %s (this might take a while).', |
| self.target, os.path.relpath(self.output_directory)) |
| if self.clean: |
| _RunCmd(['gn', 'clean', self.output_directory]) |
| retcode = _RunCmd( |
| self._GenGnCmd(), verbose=True, exit_on_failure=False)[1] |
| if retcode: |
| return retcode |
| return _RunCmd( |
| self._GenNinjaCmd(), verbose=True, exit_on_failure=False)[1] |
| |
| def DownloadUrl(self, rev): |
| return self.download_bucket + 'full-build-linux_%s.zip' % rev |
| |
| def IsAndroid(self): |
| return self.target_os == 'android' |
| |
| def IsLinux(self): |
| return self.target_os == 'linux' |
| |
| def IsCloud(self): |
| return self.cloud |
| |
| |
| class _BuildArchive(object): |
| """Class for managing a directory with build results and build metadata.""" |
| def __init__(self, rev, base_archive_dir, build, subrepo, slow_options, |
| save_unstripped): |
| self.build = build |
| self.dir = os.path.join(base_archive_dir, rev) |
| metadata_path = os.path.join(self.dir, 'metadata.txt') |
| self.rev = rev |
| self.metadata = _Metadata([self], build, metadata_path, subrepo) |
| self._slow_options = slow_options |
| self._save_unstripped = save_unstripped |
| |
| def ArchiveBuildResults(self, supersize_path, tool_prefix=None): |
| """Save build artifacts necessary for diffing.""" |
| logging.info('Saving build results to: %s', self.dir) |
| _EnsureDirsExist(self.dir) |
| if self.build.IsAndroid(): |
| self._ArchiveFile(self.build.abs_apk_path) |
| self._ArchiveFile(self.build.abs_mapping_path) |
| self._ArchiveResourceSizes() |
| self._ArchiveSizeFile(supersize_path, tool_prefix) |
| if self._save_unstripped: |
| self._ArchiveFile(self.build.abs_main_lib_path) |
| self.metadata.Write() |
| assert self.Exists() |
| |
| def Exists(self): |
| ret = self.metadata.Exists() and os.path.exists(self.archived_size_path) |
| if self._save_unstripped: |
| ret = ret and os.path.exists(self.archived_unstripped_path) |
| return ret |
| |
| @property |
| def archived_unstripped_path(self): |
| return os.path.join(self.dir, os.path.basename(self.build.main_lib_path)) |
| |
| @property |
| def archived_size_path(self): |
| return os.path.join(self.dir, self.build.size_name) |
| |
| def _ArchiveResourceSizes(self): |
| cmd = [_RESOURCE_SIZES_PATH, self.build.abs_apk_path,'--output-dir', |
| self.dir, '--chartjson'] |
| if not self.build.IsCloud(): |
| cmd += ['--chromium-output-dir', self.build.output_directory] |
| if self._slow_options: |
| cmd += ['--estimate-patch-size', '--dump-static-initializers'] |
| _RunCmd(cmd) |
| |
| def _ArchiveFile(self, filename): |
| if not os.path.exists(filename): |
| _Die('missing expected file: %s', filename) |
| shutil.copy(filename, self.dir) |
| |
| def _ArchiveSizeFile(self, supersize_path, tool_prefix): |
| existing_size_file = self.build.abs_apk_path + '.size' |
| if os.path.exists(existing_size_file): |
| logging.info('Found existing .size file') |
| shutil.copy(existing_size_file, self.archived_size_path) |
| else: |
| supersize_cmd = [supersize_path, 'archive', self.archived_size_path, |
| '--elf-file', self.build.abs_main_lib_path] |
| if tool_prefix: |
| supersize_cmd += ['--tool-prefix', tool_prefix] |
| if self.build.IsCloud(): |
| supersize_cmd += ['--no-source-paths'] |
| else: |
| supersize_cmd += ['--output-directory', self.build.output_directory] |
| if self.build.IsAndroid(): |
| supersize_cmd += ['-f', self.build.abs_apk_path] |
| logging.info('Creating .size file') |
| _RunCmd(supersize_cmd) |
| |
| |
| class _DiffArchiveManager(object): |
| """Class for maintaining BuildArchives and their related diff artifacts.""" |
| def __init__(self, revs, archive_dir, diffs, build, subrepo, slow_options, |
| save_unstripped): |
| self.archive_dir = archive_dir |
| self.build = build |
| self.build_archives = [ |
| _BuildArchive(rev, archive_dir, build, subrepo, slow_options, |
| save_unstripped) |
| for rev in revs |
| ] |
| self.diffs = diffs |
| self.subrepo = subrepo |
| self._summary_stats = [] |
| |
| def MaybeDiff(self, before_id, after_id): |
| """Perform diffs given two build archives.""" |
| before = self.build_archives[before_id] |
| after = self.build_archives[after_id] |
| diff_path, short_diff_path = self._DiffFilePaths(before, after) |
| if not self._CanDiff(before, after): |
| logging.info( |
| 'Skipping diff for %s due to missing build archives.', diff_path) |
| return |
| |
| metadata_path = self._DiffMetadataPath(before, after) |
| metadata = _Metadata( |
| [before, after], self.build, metadata_path, self.subrepo) |
| if metadata.Exists(): |
| logging.info( |
| 'Skipping diff for %s and %s. Matching diff already exists: %s', |
| before.rev, after.rev, diff_path) |
| else: |
| with open(diff_path, 'w') as diff_file, \ |
| open(short_diff_path, 'w') as summary_file: |
| for d in self.diffs: |
| d.RunDiff((diff_file, summary_file), before.dir, after.dir) |
| metadata.Write() |
| self._AddDiffSummaryStat(before, after) |
| if os.path.exists(short_diff_path): |
| _PrintFile(short_diff_path) |
| logging.info('See detailed diff results here: %s', |
| os.path.relpath(diff_path)) |
| |
| def Summarize(self): |
| path = os.path.join(self.archive_dir, 'last_diff_summary.txt') |
| if self._summary_stats: |
| with open(path, 'w') as f: |
| stats = sorted( |
| self._summary_stats, key=lambda x: x[0].value, reverse=True) |
| _WriteToFile(f, '\nDiff Summary') |
| for s, before, after in stats: |
| _WriteToFile(f, '{:>+10} {} {} for range: {}..{}', |
| s.value, s.units, s.name, before, after) |
| # Print cached file if all builds were cached. |
| if os.path.exists(path): |
| _PrintFile(path) |
| if self.build_archives and len(self.build_archives) <= 2: |
| if not all(a.Exists() for a in self.build_archives): |
| return |
| supersize_path = os.path.join(_BINARY_SIZE_DIR, 'supersize') |
| size2 = '' |
| if len(self.build_archives) == 2: |
| size2 = os.path.relpath(self.build_archives[-1].archived_size_path) |
| logging.info('Enter supersize console via: %s console %s %s', |
| os.path.relpath(supersize_path), |
| os.path.relpath(self.build_archives[0].archived_size_path), size2) |
| |
| |
| def _AddDiffSummaryStat(self, before, after): |
| stat = None |
| if self.build.IsAndroid(): |
| summary_diff_type = ResourceSizesDiff |
| else: |
| summary_diff_type = NativeDiff |
| for d in self.diffs: |
| if isinstance(d, summary_diff_type): |
| stat = d.summary_stat |
| if stat: |
| self._summary_stats.append((stat, before.rev, after.rev)) |
| |
| def _CanDiff(self, before, after): |
| return before.Exists() and after.Exists() |
| |
| def _DiffFilePaths(self, before, after): |
| ret = os.path.join(self._DiffDir(before, after), 'diff_results') |
| return ret + '.txt', ret + '.short.txt' |
| |
| def _DiffMetadataPath(self, before, after): |
| return os.path.join(self._DiffDir(before, after), 'metadata.txt') |
| |
| def _DiffDir(self, before, after): |
| archive_range = '%s..%s' % (before.rev, after.rev) |
| diff_path = os.path.join(self.archive_dir, 'diffs', archive_range) |
| _EnsureDirsExist(diff_path) |
| return diff_path |
| |
| |
| class _Metadata(object): |
| |
| def __init__(self, archives, build, path, subrepo): |
| self.is_cloud = build.IsCloud() |
| self.data = { |
| 'revs': [a.rev for a in archives], |
| 'apply_patch': build.apply_patch, |
| 'archive_dirs': [a.dir for a in archives], |
| 'target': build.target, |
| 'target_os': build.target_os, |
| 'is_cloud': build.IsCloud(), |
| 'subrepo': subrepo, |
| 'path': path, |
| 'gn_args': { |
| 'extra_gn_args_str': build.extra_gn_args_str, |
| 'enable_chrome_android_internal': build.enable_chrome_android_internal, |
| } |
| } |
| |
| def Exists(self): |
| path = self.data['path'] |
| if os.path.exists(path): |
| with open(path, 'r') as f: |
| return self.data == json.load(f) |
| return False |
| |
| def Write(self): |
| with open(self.data['path'], 'w') as f: |
| json.dump(self.data, f) |
| |
| |
| def _EnsureDirsExist(path): |
| if not os.path.exists(path): |
| os.makedirs(path) |
| |
| |
| def _RunCmd(cmd, verbose=False, exit_on_failure=True): |
| """Convenience function for running commands. |
| |
| Args: |
| cmd: the command to run. |
| verbose: if this is True, then the stdout and stderr of the process will be |
| printed. If it's false, the stdout will be returned. |
| exit_on_failure: die if an error occurs when this is True. |
| |
| Returns: |
| Tuple of (process stdout, process returncode). |
| """ |
| assert not (verbose and exit_on_failure) |
| cmd_str = ' '.join(c for c in cmd) |
| logging.debug('Running: %s', cmd_str) |
| proc_stdout = proc_stderr = subprocess.PIPE |
| if verbose: |
| proc_stdout, proc_stderr = sys.stdout, subprocess.STDOUT |
| |
| proc = subprocess.Popen(cmd, stdout=proc_stdout, stderr=proc_stderr) |
| stdout, stderr = proc.communicate() |
| |
| if proc.returncode and exit_on_failure: |
| _Die('command failed: %s\nstderr:\n%s', cmd_str, stderr) |
| |
| stdout = stdout.strip() if stdout else '' |
| return stdout, proc.returncode |
| |
| |
| def _GitCmd(args, subrepo): |
| return _RunCmd(['git', '-C', subrepo] + args)[0] |
| |
| |
| def _GclientSyncCmd(rev, subrepo): |
| cwd = os.getcwd() |
| os.chdir(subrepo) |
| _, retcode = _RunCmd(['gclient', 'sync', '-r', 'src@' + rev], |
| verbose=True, exit_on_failure=False) |
| os.chdir(cwd) |
| return retcode |
| |
| |
| def _SyncAndBuild(archive, build, subrepo, no_gclient, extra_rev): |
| """Sync, build and return non 0 if any commands failed.""" |
| # Simply do a checkout if subrepo is used. |
| if _CurrentGitHash(subrepo) == archive.rev: |
| if subrepo != _SRC_ROOT: |
| logging.info('Skipping git checkout since already at desired rev') |
| else: |
| logging.info('Skipping gclient sync since already at desired rev') |
| elif subrepo != _SRC_ROOT or no_gclient: |
| _GitCmd(['checkout', archive.rev], subrepo) |
| else: |
| # Move to a detached state since gclient sync doesn't work with local |
| # commits on a branch. |
| _GitCmd(['checkout', '--detach'], subrepo) |
| logging.info('Syncing to %s', archive.rev) |
| ret = _GclientSyncCmd(archive.rev, subrepo) |
| if ret: |
| return ret |
| with _ApplyPatch(extra_rev, subrepo): |
| return build.Run() |
| |
| |
| @contextmanager |
| def _ApplyPatch(rev, subrepo): |
| if not rev: |
| yield |
| else: |
| restore_func = _GenRestoreFunc(subrepo) |
| try: |
| _GitCmd(['cherry-pick', rev, '--strategy-option', 'theirs'], subrepo) |
| yield |
| finally: |
| restore_func() |
| |
| |
| def _GenerateRevList(rev, reference_rev, all_in_range, subrepo, step): |
| """Normalize and optionally generate a list of commits in the given range. |
| |
| Returns: |
| A list of revisions ordered from oldest to newest. |
| """ |
| rev_seq = '%s^..%s' % (reference_rev, rev) |
| stdout = _GitCmd(['rev-list', rev_seq], subrepo) |
| all_revs = stdout.splitlines()[::-1] |
| if all_in_range or len(all_revs) < 2 or step: |
| revs = all_revs |
| if step: |
| revs = revs[::step] |
| else: |
| revs = [all_revs[0], all_revs[-1]] |
| num_revs = len(revs) |
| if num_revs >= _COMMIT_COUNT_WARN_THRESHOLD: |
| _VerifyUserAccepts( |
| 'You\'ve provided a commit range that contains %d commits.' % num_revs) |
| logging.info('Processing %d commits', num_revs) |
| return revs |
| |
| |
| def _ValidateRevs(rev, reference_rev, subrepo, extra_rev): |
| def git_fatal(args, message): |
| devnull = open(os.devnull, 'wb') |
| retcode = subprocess.call( |
| ['git', '-C', subrepo] + args, stdout=devnull, stderr=subprocess.STDOUT) |
| if retcode: |
| _Die(message) |
| |
| no_obj_message = ('%s either doesn\'t exist or your local repo is out of ' |
| 'date, try "git fetch origin master"') |
| git_fatal(['cat-file', '-e', rev], no_obj_message % rev) |
| git_fatal(['cat-file', '-e', reference_rev], no_obj_message % reference_rev) |
| if extra_rev: |
| git_fatal(['cat-file', '-e', extra_rev], no_obj_message % extra_rev) |
| git_fatal(['merge-base', '--is-ancestor', reference_rev, rev], |
| 'reference-rev is newer than rev') |
| |
| |
| def _VerifyUserAccepts(message): |
| print message + ' Do you want to proceed? [y/n]' |
| if raw_input('> ').lower() != 'y': |
| sys.exit() |
| |
| |
| def _EnsureDirectoryClean(subrepo): |
| logging.info('Checking source directory') |
| stdout = _GitCmd(['status', '--porcelain'], subrepo) |
| # Ignore untracked files. |
| if stdout and stdout[:2] != '??': |
| logging.error('Failure: please ensure working directory is clean.') |
| sys.exit() |
| |
| |
| def _Die(s, *args): |
| logging.error('Failure: ' + s, *args) |
| sys.exit(1) |
| |
| |
| def _DownloadBuildArtifacts(archive, build, supersize_path, depot_tools_path): |
| """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: |
| _DownloadAndArchive( |
| gsutil_path, archive, download_dir, build, supersize_path) |
| finally: |
| shutil.rmtree(download_dir) |
| |
| |
| def _DownloadAndArchive(gsutil_path, archive, dl_dir, build, supersize_path): |
| # Wraps gsutil calls and returns stdout + stderr. |
| def gsutil_cmd(args, fail_msg=None): |
| fail_msg = fail_msg or '' |
| proc = subprocess.Popen([gsutil_path] + args, stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT) |
| output = proc.communicate()[0].rstrip() |
| if proc.returncode or not output: |
| _Die(fail_msg + ' Process output:\n%s' % output) |
| return output |
| |
| # Fails if gsutil isn't configured. |
| gsutil_cmd(['version'], |
| 'gsutil error. Please file a bug in Tools>BinarySize.') |
| dl_dst = os.path.join(dl_dir, archive.rev) |
| logging.info('Downloading build artifacts for %s', archive.rev) |
| |
| # Fails if archive isn't found. |
| output = gsutil_cmd(['stat', build.DownloadUrl(archive.rev)], |
| 'Unexpected error while downloading %s. It may no longer exist on the ' |
| 'server or it may not have been uploaded yet (check %s). Otherwise, you ' |
| 'may not have the correct access permissions.' % ( |
| build.DownloadUrl(archive.rev), build.builder_url)) |
| size = re.search(r'Content-Length:\s+([0-9]+)', output).group(1) |
| logging.info('File size: %s', _ReadableBytes(int(size))) |
| |
| # Download archive. Any failures here are unexpected. |
| gsutil_cmd(['cp', build.DownloadUrl(archive.rev), dl_dst]) |
| |
| # Files needed for supersize and resource_sizes. Paths relative to out dir. |
| to_extract = [build.main_lib_path, build.map_file_path, 'args.gn'] |
| if build.IsAndroid(): |
| to_extract += ['build_vars.txt', build.apk_path, |
| build.mapping_path, build.apk_path + '.size'] |
| extract_dir = dl_dst + '_' + 'unzipped' |
| logging.info('Extracting build artifacts') |
| with zipfile.ZipFile(dl_dst, 'r') as z: |
| dl_out = _ExtractFiles(to_extract, extract_dir, z) |
| build.output_directory, output_directory = dl_out, build.output_directory |
| archive.ArchiveBuildResults(supersize_path) |
| build.output_directory = output_directory |
| |
| |
| def _ReadableBytes(b): |
| val = b |
| units = ['Bytes','KB', 'MB', 'GB'] |
| for unit in units: |
| if val < 1024: |
| return '%.2f %s' % (val, unit) |
| val /= 1024.0 |
| else: |
| return '%d %s' % (b, 'Bytes') |
| |
| |
| def _ExtractFiles(to_extract, dst, z): |
| """Extract a list of files. Returns the common prefix of the extracted files. |
| |
| Paths in |to_extract| should be relative to the output directory. |
| """ |
| zipped_paths = z.namelist() |
| output_dir = os.path.commonprefix(zipped_paths) |
| for f in to_extract: |
| path = os.path.join(output_dir, f) |
| if path in zipped_paths: |
| z.extract(path, path=dst) |
| return os.path.join(dst, output_dir) |
| |
| |
| def _WriteToFile(logfile, s, *args, **kwargs): |
| if isinstance(s, basestring): |
| data = s.format(*args, **kwargs) + '\n' |
| else: |
| data = '\n'.join(s) + '\n' |
| logfile.write(data) |
| |
| |
| def _PrintFile(path): |
| with open(path) as f: |
| sys.stdout.write(f.read()) |
| |
| |
| @contextmanager |
| def _TmpCopyBinarySizeDir(): |
| """Recursively copy files to a temp dir and yield temp paths.""" |
| # Needs to be at same level of nesting as the real //tools/binary_size |
| # since supersize uses this to find d3 in //third_party. |
| tmp_dir = tempfile.mkdtemp(dir=_SRC_ROOT) |
| try: |
| bs_dir = os.path.join(tmp_dir, 'binary_size') |
| shutil.copytree(_BINARY_SIZE_DIR, bs_dir) |
| # We also copy the tools supersize needs, but only if they exist. |
| tool_prefix = None |
| if os.path.exists(_DOWNLOAD_OBJDUMP_PATH): |
| if not os.path.exists(os.path.join(_LLVM_TOOLS_DIR, 'llvm-readelf')): |
| _RunCmd([_DOWNLOAD_OBJDUMP_PATH]) |
| tools_dir = os.path.join(bs_dir, 'bintools') |
| tool_prefix = os.path.join(tools_dir, 'llvm-') |
| shutil.copytree(_LLVM_TOOLS_DIR, tools_dir) |
| yield (os.path.join(bs_dir, 'supersize'), tool_prefix) |
| finally: |
| shutil.rmtree(tmp_dir) |
| |
| |
| def _CurrentGitHash(subrepo): |
| return _GitCmd(['rev-parse', 'HEAD'], subrepo) |
| |
| |
| def _GenRestoreFunc(subrepo): |
| branch = _GitCmd(['rev-parse', '--abbrev-ref', 'HEAD'], subrepo) |
| # Happens when the repo didn't start on a named branch. |
| if branch == 'HEAD': |
| branch = _GitCmd(['rev-parse', 'HEAD'], subrepo) |
| def _RestoreFunc(): |
| logging.warning('Restoring original git checkout') |
| _GitCmd(['checkout', branch], subrepo) |
| return _RestoreFunc |
| |
| |
| def _SetRestoreFunc(subrepo): |
| atexit.register(_GenRestoreFunc(subrepo)) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| parser.add_argument('rev', |
| help='Find binary size bloat for this commit.') |
| parser.add_argument('--archive-directory', |
| default=_DEFAULT_ARCHIVE_DIR, |
| help='Where results are stored.') |
| parser.add_argument('--reference-rev', |
| help='Older rev to diff against. If not supplied, ' |
| 'the previous commit to rev will be used.') |
| parser.add_argument('--all', |
| action='store_true', |
| help='Build/download all revs from --reference-rev to ' |
| 'rev and diff the contiguous revisions.') |
| 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 ' |
| '(Googlers only).') |
| parser.add_argument('--single', |
| action='store_true', |
| help='Sets --reference-rev=rev.') |
| parser.add_argument('--unstripped', |
| action='store_true', |
| help='Save the unstripped native library when archiving.') |
| parser.add_argument('--depot-tools-path', |
| help='Custom path to depot tools. Needed for --cloud if ' |
| 'depot tools isn\'t in your PATH.') |
| parser.add_argument('--subrepo', |
| help='Specify a subrepo directory to use. Implies ' |
| '--no-gclient. All git commands will be executed ' |
| 'from the subrepo directory. Does not work with ' |
| '--cloud.') |
| parser.add_argument('--no-gclient', |
| action='store_true', |
| help='Do not perform gclient sync steps.') |
| parser.add_argument('--apply-patch', dest='extra_rev', |
| help='A local commit to cherry-pick before each build. ' |
| 'This can leave your repo in a broken state if ' |
| 'the cherry-pick fails.') |
| parser.add_argument('--step', type=int, |
| help='Assumes --all and only builds/downloads every ' |
| '--step\'th revision.') |
| parser.add_argument('-v', |
| '--verbose', |
| action='store_true', |
| help='Show commands executed, extra debugging output' |
| ', and Ninja/GN output.') |
| |
| build_group = parser.add_argument_group('build arguments') |
| 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='Do not use goma when building with ninja.') |
| build_group.add_argument('--clean', |
| action='store_true', |
| help='Do a clean build for each revision.') |
| build_group.add_argument('--gn-args', |
| default='', |
| help='Extra GN args to set.') |
| build_group.add_argument('--target-os', |
| default='android', |
| choices=['android', 'linux'], |
| help='target_os gn arg. Default: android.') |
| build_group.add_argument('--output-directory', |
| default=_DEFAULT_OUT_DIR, |
| help='ninja output directory. ' |
| 'Default: %s.' % _DEFAULT_OUT_DIR) |
| build_group.add_argument('--enable-chrome-android-internal', |
| action='store_true', |
| help='Allow downstream targets to be built.') |
| build_group.add_argument('--target', |
| help='GN target to build. Linux default: chrome. ' |
| 'Android default: monochrome_public_apk or ' |
| 'monochrome_apk (depending on ' |
| '--enable-chrome-android-internal).') |
| if len(sys.argv) == 1: |
| parser.print_help() |
| return 1 |
| |
| args = parser.parse_args() |
| log_level = logging.DEBUG if args.verbose else logging.INFO |
| logging.basicConfig(level=log_level, |
| format='%(levelname).1s %(relativeCreated)6d %(message)s') |
| build = _BuildHelper(args) |
| if build.IsCloud(): |
| if args.subrepo: |
| parser.error('--subrepo doesn\'t work with --cloud') |
| if build.IsLinux(): |
| parser.error('--target-os linux doesn\'t work with --cloud because map ' |
| 'files aren\'t generated by builders (crbug.com/716209).') |
| if args.extra_rev: |
| parser.error('--apply-patch doesn\'t work with --cloud') |
| |
| subrepo = args.subrepo or _SRC_ROOT |
| if not build.IsCloud(): |
| _EnsureDirectoryClean(subrepo) |
| _SetRestoreFunc(subrepo) |
| |
| if build.IsLinux(): |
| _VerifyUserAccepts('Linux diffs have known deficiencies (crbug/717550).') |
| |
| reference_rev = args.reference_rev or args.rev + '^' |
| if args.single: |
| reference_rev = args.rev |
| _ValidateRevs(args.rev, reference_rev, subrepo, args.extra_rev) |
| revs = _GenerateRevList(args.rev, reference_rev, args.all, subrepo, args.step) |
| with _TmpCopyBinarySizeDir() as paths: |
| supersize_path, tool_prefix = paths |
| diffs = [NativeDiff(build.size_name, supersize_path)] |
| if build.IsAndroid(): |
| diffs += [ |
| ResourceSizesDiff(build.apk_name) |
| ] |
| diff_mngr = _DiffArchiveManager(revs, args.archive_directory, diffs, build, |
| subrepo, args.include_slow_options, |
| args.unstripped) |
| consecutive_failures = 0 |
| for i, archive in enumerate(diff_mngr.build_archives): |
| if archive.Exists(): |
| step = 'download' if build.IsCloud() else 'build' |
| logging.info('Found matching metadata for %s, skipping %s step.', |
| archive.rev, step) |
| else: |
| if build.IsCloud(): |
| _DownloadBuildArtifacts( |
| archive, build, supersize_path, args.depot_tools_path) |
| else: |
| build_failure = _SyncAndBuild(archive, build, subrepo, |
| args.no_gclient, args.extra_rev) |
| if build_failure: |
| logging.info( |
| 'Build failed for %s, diffs using this rev will be skipped.', |
| archive.rev) |
| consecutive_failures += 1 |
| if len(diff_mngr.build_archives) <= 2: |
| _Die('Stopping due to build failure.') |
| elif consecutive_failures > _ALLOWED_CONSECUTIVE_FAILURES: |
| _Die('%d builds failed in a row, last failure was %s.', |
| consecutive_failures, archive.rev) |
| else: |
| archive.ArchiveBuildResults(supersize_path, tool_prefix) |
| consecutive_failures = 0 |
| |
| if i != 0: |
| diff_mngr.MaybeDiff(i - 1, i) |
| |
| diff_mngr.Summarize() |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |
| |