Chromium Code Reviews| Index: tools/binary_size/diagnose_apk_bloat.py |
| diff --git a/tools/binary_size/diagnose_apk_bloat.py b/tools/binary_size/diagnose_apk_bloat.py |
| index 510ee1314f59a615cdb331962a5b17018e4c995a..23eacdbdabc23e0d41d32ed10275cd5a0717d261 100755 |
| --- a/tools/binary_size/diagnose_apk_bloat.py |
| +++ b/tools/binary_size/diagnose_apk_bloat.py |
| @@ -21,6 +21,8 @@ import sys |
| import tempfile |
| import zipfile |
| +_ALLOWED_CONSECUTIVE_BUILDS = 15 |
|
agrieve
2017/04/21 16:57:31
Limit seems a bit small. I've seen rolls go in tha
estevenson
2017/04/21 20:15:49
Done.
|
| +_ALLOWED_CONSECUTIVE_FAILURES = 2 |
| _BUILDER_URL = \ |
| 'https://build.chromium.org/p/chromium.perf/builders/Android%20Builder' |
| _CLOUD_OUT_DIR = os.path.join('out', 'Release') |
| @@ -34,13 +36,15 @@ _DEFAULT_TARGET = 'monochrome_public_apk' |
| # so that it doesn't need to be passed everywhere in case we fail and exit. |
| _initial_branch = None |
| +# Global variable for storing the subrepo directory. |
| +_subrepo = None |
|
agrieve
2017/04/21 16:57:31
Having the global makes the code a bit harder to f
|
| + |
| 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|.""" |
| @@ -59,13 +63,14 @@ class BaseDiff(object): |
| """An iterable description of the cause of binary size bloat.""" |
| raise NotImplementedError() |
| - def ProduceDiff(self): |
| + def ProduceDiff(self, archive_dirs): |
| """Prepare a binary size diff with ready to print results.""" |
| raise NotImplementedError() |
| - def RunDiff(self): |
| + def RunDiff(self, logfile, archive_dirs): |
| _Print('Creating {}', self.name) |
| - self.ProduceDiff() |
| + self.ProduceDiff(archive_dirs) |
| + self.AppendResults(logfile) |
| _ResourceSizesDiffResult = collections.namedtuple( |
| @@ -76,8 +81,7 @@ 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 |
| + def __init__(self, apk_name, slow_options=False): |
| self._apk_name = apk_name |
| self._slow_options = slow_options |
| self._diff = None # Set by |ProduceDiff()| |
| @@ -93,8 +97,8 @@ class ResourceSizesDiff(BaseDiff): |
| return 'Normalized APK size: {:+,} {}'.format(s.value, s.units) |
| return '' |
| - def ProduceDiff(self): |
| - chartjsons = self._RunResourceSizes() |
| + def ProduceDiff(self, archive_dirs): |
| + chartjsons = self._RunResourceSizes(archive_dirs) |
| diff = [] |
| with_patch = chartjsons[0]['charts'] |
| without_patch = chartjsons[1]['charts'] |
| @@ -114,9 +118,9 @@ class ResourceSizesDiff(BaseDiff): |
| v['units'])) |
| self._diff = sorted(diff, key=lambda x: abs(x.value), reverse=True) |
| - def _RunResourceSizes(self): |
| + def _RunResourceSizes(self, archive_dirs): |
| chartjsons = [] |
| - for archive_dir in self._archive_dirs: |
| + for archive_dir in 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, |
| @@ -134,6 +138,7 @@ class ResourceSizesDiff(BaseDiff): |
| class _BuildHelper(object): |
| """Helper class for generating and building targets.""" |
| def __init__(self, args): |
| + self.cloud = args.cloud |
| self.enable_chrome_android_internal = args.enable_chrome_android_internal |
| self.extra_gn_args_str = '' |
| self.max_jobs = args.max_jobs |
| @@ -207,9 +212,14 @@ class _BuildHelper(object): |
| return cmd |
| def Run(self): |
| + """Run GN gen/ninja build and return the process returncode.""" |
| _Print('Building: {}.', self.target) |
| - _RunCmd(self._GenGnCmd(), print_stdout=True) |
| - _RunCmd(self._GenNinjaCmd(), print_stdout=True) |
| + retcode = _RunCmd( |
| + self._GenGnCmd(), print_stdout=True, exit_on_failure=False)[1] |
| + if retcode: |
| + return retcode |
| + return _RunCmd( |
| + self._GenNinjaCmd(), print_stdout=True, exit_on_failure=False)[1] |
| def IsAndroid(self): |
| return self.target_os == 'android' |
| @@ -217,36 +227,168 @@ class _BuildHelper(object): |
| def IsLinux(self): |
| return self.target_os == 'linux' |
| - |
| -def _RunCmd(cmd, print_stdout=False): |
| + 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): |
| + self.build = build |
| + self.dir = os.path.join(base_archive_dir, rev) |
| + _EnsureDirsExist(self.dir) |
|
agrieve
2017/04/21 16:57:31
nit: Generally best to make constructors as light-
estevenson
2017/04/21 20:15:49
Done.
|
| + metadata_path = os.path.join(self.dir, 'metadata.txt') |
| + self.rev = rev |
| + self.metadata = _GenerateMetadata([self], build, metadata_path) |
| + |
| + def ArchiveBuildResults(self): |
| + """Save build artifacts necessary for diffing.""" |
| + _Print('Saving build results to: {}', self.dir) |
| + build = self.build |
| + self._ArchiveFile(build.main_lib_path) |
| + lib_name_noext = os.path.splitext(os.path.basename(build.main_lib_path))[0] |
| + size_path = os.path.join(self.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] |
| + self._ArchiveFile(build.abs_apk_path) |
| + |
| + _RunCmd(supersize_cmd) |
| + _WriteMetadata(self.metadata) |
| + |
| + def Exists(self): |
| + return _MetadataExists(self.metadata) |
| + |
| + def _ArchiveFile(self, filename): |
| + if not os.path.exists(filename): |
| + _Die('missing expected file: {}', filename) |
| + shutil.copy(filename, self.dir) |
| + |
| + |
| +class _DiffArchiveManager(object): |
| + """Class for maintaining BuildArchives and their related diff artifacts.""" |
| + def __init__(self, revs, archive_dir, diffs, build): |
| + self.archive_dir = archive_dir |
| + _EnsureDirsExist(archive_dir) |
| + self.build = build |
| + self.build_archives = [_BuildArchive(rev, archive_dir, build) |
| + for rev in revs] |
| + self.diffs = diffs |
| + |
| + def IterArchives(self): |
| + return iter(self.build_archives) |
| + |
| + def MaybeDiff(self, first_id, second_id): |
| + """Perform diffs given two build archives.""" |
| + archives = [ |
| + self.build_archives[first_id], self.build_archives[second_id]] |
| + diff_path = self._DiffFilePath(archives) |
| + if not self._CanDiff(archives): |
| + _Print('Skipping diff for {} due to missing build archives.', diff_path) |
| + return |
| + |
| + metadata_path = self._DiffMetadataPath(archives) |
| + metadata = _GenerateMetadata(archives, self.build, metadata_path) |
| + if _MetadataExists(metadata): |
| + _Print('Skipping diff for {} and {}. Matching diff already exists: {}', |
| + archives[0].rev, archives[1].rev, diff_path) |
| + else: |
| + archive_dirs = [archives[0].dir, archives[1].dir] |
| + with open(diff_path, 'a') as diff_file: |
| + for d in self.diffs: |
| + d.RunDiff(diff_file, archive_dirs) |
| + _WriteMetadata(metadata) |
| + |
| + def _CanDiff(self, archives): |
| + return all(a.Exists() for a in archives) |
| + |
| + def _DiffFilePath(self, archives): |
| + return os.path.join(self._DiffDir(archives), 'diff_results.txt') |
| + |
| + def _DiffMetadataPath(self, archives): |
| + return os.path.join(self._DiffDir(archives), 'metadata.txt') |
| + |
| + def _DiffDir(self, archives): |
| + diff_path = os.path.join( |
| + self.archive_dir, 'diffs', '_'.join(a.rev for a in archives)) |
| + _EnsureDirsExist(diff_path) |
| + return diff_path |
| + |
| + |
| +def _EnsureDirsExist(path): |
| + if not os.path.exists(path): |
| + os.makedirs(path) |
| + |
| + |
| +def _GenerateMetadata(archives, build, path): |
| + return { |
| + 'revs': [a.rev for a in archives], |
| + '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 _WriteMetadata(metadata): |
| + with open(metadata['path'], 'w') as f: |
| + json.dump(metadata, f) |
| + |
| + |
| +def _MetadataExists(metadata): |
| + old_metadata = {} |
| + path = metadata['path'] |
| + if os.path.exists(path): |
| + with open(path, 'r') as f: |
| + old_metadata = json.load(f) |
| + ret = len(metadata) == len(old_metadata) |
| + ret &= all(v == old_metadata[k] |
| + for k, v in metadata.items() if k != 'gn_args') |
| + if not metadata['is_cloud']: |
|
agrieve
2017/04/21 16:57:31
Can you add a comment saying why this check is nec
estevenson
2017/04/21 20:15:49
Done.
|
| + ret &= metadata['gn_args'] == old_metadata['gn_args'] |
| + return ret |
| + return False |
| + |
| + |
| +def _RunCmd(cmd, print_stdout=False, exit_on_failure=True): |
| """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. |
| + printed instead of returned. |
| + exit_on_failure: die if an error occurs when this is True. |
| Returns: |
| - Command stdout if |print_stdout| is False otherwise ''. |
| + Tuple of (process stdout, process returncode). |
| """ |
| 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_stdout = sys.stdout if print_stdout else subprocess.PIPE |
| proc = subprocess.Popen(cmd, stdout=proc_stdout, stderr=subprocess.PIPE) |
| stdout, stderr = proc.communicate() |
| - if proc.returncode: |
| + if proc.returncode and exit_on_failure: |
| _Die('command failed: {}\nstderr:\n{}', cmd_str, stderr) |
| - return stdout.strip() if stdout else '' |
| + stdout = stdout.strip() if stdout else '' |
| + return stdout, proc.returncode |
| -def _GitCmd(args): |
| - return _RunCmd(['git', '-C', _SRC_ROOT] + args) |
| +def _GitCmd(args, retcode=False): |
|
agrieve
2017/04/21 16:57:31
I don't see retcode being used anywhere. You also
estevenson
2017/04/21 20:15:49
Oops, meant to use it in _GenerateRevList. Changed
|
| + return _RunCmd(['git', '-C', _subrepo] + args)[int(retcode)] |
| def _GclientSyncCmd(rev): |
| @@ -256,36 +398,6 @@ def _GclientSyncCmd(rev): |
| 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): |
| @@ -301,19 +413,40 @@ def _FindToolPrefix(output_directory): |
| 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 _SyncAndBuild(archive, build, use_subrepo): |
| + if use_subrepo: |
| + _GitCmd(['checkout', archive.rev]) |
| + else: |
| + # Move to a detached state since gclient sync doesn't work with local |
| + # commits on a branch. |
| + _GitCmd(['checkout', '--detach']) |
| + _GclientSyncCmd(archive.rev) |
| + retcode = build.Run() |
| + return retcode == 0 |
| + |
| +def _GenerateRevList(with_patch, without_patch, all_in_range): |
| + """Normalize and optionally generate a list of commits in the given range. |
| + |
| + Returns a list of revisions ordered from newest to oldest. |
| + """ |
| + retcode = _GitCmd(['merge-base', '--is-ancestor', without_patch, with_patch]) |
| + assert not retcode and with_patch != without_patch, ( |
| + 'Invalid revision arguments, rev_without_patch (%s) is newer than ' |
| + 'rev_with_patch (%s)' % (without_patch, with_patch)) |
| + |
| + rev_seq = '%s^..%s' % (without_patch, with_patch) |
| + stdout = _GitCmd(['rev-list', rev_seq]) |
| + all_revs = stdout.splitlines() |
| + if all_in_range: |
| + revs = all_revs |
| + else: |
| + revs = [all_revs[0], all_revs[-1]] |
| -def _NormalizeRev(rev): |
| - """Use actual revs instead of HEAD, HEAD^, etc.""" |
| - return _GitCmd(['rev-parse', rev]) |
| + assert len(revs) <= _ALLOWED_CONSECUTIVE_BUILDS, ( |
| + 'Too many commits in range %s..%s, allowed: %d, found %d' % ( |
| + without_patch, with_patch, _ALLOWED_CONSECUTIVE_BUILDS, len(revs))) |
| + return revs |
| def _EnsureDirectoryClean(): |
| @@ -334,13 +467,18 @@ def _RestoreInitialBranch(): |
| _GitCmd(['checkout', _initial_branch]) |
| +def _SetSubrepo(subrepo): |
| + global _subrepo |
| + _subrepo = subrepo or _SRC_ROOT |
| + |
| + |
| def _Die(s, *args, **kwargs): |
| _Print('Failure: ' + s, *args, **kwargs) |
| _RestoreInitialBranch() |
| sys.exit(1) |
| -def _DownloadBuildArtifacts(revs, archive_dirs, build, depot_tools_path=None): |
| +def _DownloadBuildArtifacts(archive, 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') |
| @@ -353,17 +491,16 @@ def _DownloadBuildArtifacts(revs, archive_dirs, build, depot_tools_path=None): |
| 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) |
| + _DownloadAndArchive(gsutil_path, archive, 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 |
| +def _DownloadAndArchive(gsutil_path, archive, dl_dir, build): |
| + dl_file = 'full-build-linux_%s.zip' % archive.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) |
| + _Print('Downloading build artifacts for {}', archive.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], |
| @@ -384,7 +521,7 @@ def _DownloadAndArchive(gsutil_path, rev, archive_dir, dl_dir, build): |
| _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) |
| + archive.ArchiveBuildResults() |
| build.output_directory = output_directory |
| @@ -420,6 +557,10 @@ def main(): |
| 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('--all', |
| + action='store_true', |
| + help='Build all revs from rev_with_patch to ' |
|
agrieve
2017/04/21 16:57:31
I've been finding the flags a bit unintuitive, I t
estevenson
2017/04/21 20:15:49
Spoke offline a bit about this, I've change it so
|
| + 'rev_without_patch and diff contiguous revisions.') |
| parser.add_argument('--include-slow-options', |
| action='store_true', |
| help='Run some extra steps that take longer to complete. ' |
| @@ -432,6 +573,11 @@ def main(): |
| 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. Gclient sync ' |
| + 'will be skipped if this option is used and all git ' |
| + 'commands will be executed from the subrepo directory. ' |
| + 'This option doesn\'t work with --cloud.') |
| build_group = parser.add_argument_group('ninja', 'Args to use with ninja/gn') |
| build_group.add_argument('-j', |
| @@ -453,7 +599,7 @@ def main(): |
| build_group.add_argument('--output-directory', |
| default=_DEFAULT_OUT_DIR, |
| help='ninja output directory.') |
| - build_group.add_argument('--enable_chrome_android_internal', |
| + build_group.add_argument('--enable-chrome-android-internal', |
| action='store_true', |
| help='Allow downstream targets to be built.') |
| build_group.add_argument('--target', |
| @@ -461,37 +607,50 @@ def main(): |
| 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') |
| + _SetSubrepo(args.subrepo) |
| + use_subrepo = bool(args.subrepo) |
| + if build.IsCloud(): |
| + if build.IsLinux(): |
| + parser.error('--cloud only works for android') |
| + if use_subrepo: |
| + parser.error('--subrepo doesn\'t work with --cloud') |
| _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) |
| + revs = _GenerateRevList(args.rev_with_patch, |
| + args.rev_without_patch or args.rev_with_patch + '^', |
| + args.all) |
| diffs = [] |
| if build.IsAndroid(): |
| diffs += [ |
| - ResourceSizesDiff(archive_dirs, build.apk_name, |
| - slow_options=args.include_slow_options) |
| + ResourceSizesDiff( |
| + build.apk_name, slow_options=args.include_slow_options) |
| ] |
| - with open(output_file, 'a') as logfile: |
| - for d in diffs: |
| - d.AppendResults(logfile) |
| + diff_mngr = _DiffArchiveManager(revs, args.archive_dir, diffs, build) |
| + consecutive_failures = 0 |
| + for i, archive in enumerate(diff_mngr.IterArchives()): |
| + if archive.Exists(): |
| + _Print('Found matching metadata for {}, skipping build step.', |
| + archive.rev) |
| + else: |
| + if build.IsCloud(): |
| + _DownloadBuildArtifacts(archive, build, |
| + depot_tools_path=args.depot_tools_path) |
| + else: |
| + build_success = _SyncAndBuild(archive, build, use_subrepo) |
| + if not build_success: |
| + consecutive_failures += 1 |
| + if consecutive_failures > _ALLOWED_CONSECUTIVE_FAILURES: |
| + _Die('{} builds failed in a row, last failure was {}.', |
| + consecutive_failures, archive.rev) |
| + else: |
| + archive.ArchiveBuildResults() |
| + consecutive_failures = 0 |
| + |
| + if i != 0: |
| + diff_mngr.MaybeDiff(i - 1, i) |
| + |
| + _RestoreInitialBranch() |
| if __name__ == '__main__': |
| sys.exit(main()) |