#!/usr/bin/env vpython3 # Copyright 2011 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Reports binary size metrics for an APK. More information at //docs/speed/binary_size/metrics.md. """ import argparse import collections from contextlib import contextmanager import json import logging import os import posixpath import re import struct import sys import tempfile import zipfile import zlib import devil_chromium from devil.android.sdk import build_tools from devil.utils import cmd_helper from devil.utils import lazy import method_count from pylib import constants from pylib.constants import host_paths _AAPT_PATH = lazy.WeakConstant(lambda: build_tools.GetPath('aapt')) _ANDROID_UTILS_PATH = os.path.join(host_paths.DIR_SOURCE_ROOT, 'build', 'android', 'gyp') _BUILD_UTILS_PATH = os.path.join(host_paths.DIR_SOURCE_ROOT, 'build', 'util') _READOBJ_PATH = os.path.join(host_paths.DIR_SOURCE_ROOT, 'third_party', 'llvm-build', 'Release+Asserts', 'bin', 'llvm-readobj') with host_paths.SysPath(host_paths.BUILD_COMMON_PATH): import perf_tests_results_helper # pylint: disable=import-error with host_paths.SysPath(host_paths.TRACING_PATH): from tracing.value import convert_chart_json # pylint: disable=import-error with host_paths.SysPath(_ANDROID_UTILS_PATH, 0): from util import build_utils # pylint: disable=import-error with host_paths.SysPath(_BUILD_UTILS_PATH, 0): from lib.results import result_sink # pylint: disable=import-error from lib.results import result_types # pylint: disable=import-error # Captures an entire config from aapt output. _AAPT_CONFIG_PATTERN = r'config %s:(.*?)config [a-zA-Z-]+:' # Matches string resource entries from aapt output. _AAPT_ENTRY_RE = re.compile( r'resource (?P\w{10}) [\w\.]+:string/.*?"(?P.+?)"', re.DOTALL) _BASE_CHART = { 'format_version': '0.1', 'benchmark_name': 'resource_sizes', 'benchmark_description': 'APK resource size information.', 'trace_rerun_options': [], 'charts': {} } # Macro definitions look like (something, 123) when # enable_resource_allowlist_generation=true. _RC_HEADER_RE = re.compile(r'^#define (?P\w+).* (?P\d+)\)?$') _RE_NON_LANGUAGE_PAK = re.compile(r'^assets/.*(resources|percent)\.pak$') _READELF_SIZES_METRICS = { 'text': ['.text'], 'data': ['.data', '.rodata', '.data.rel.ro', '.data.rel.ro.local'], 'relocations': ['.rel.dyn', '.rel.plt', '.rela.dyn', '.rela.plt', '.relr.dyn'], 'unwind': [ '.ARM.extab', '.ARM.exidx', '.eh_frame', '.eh_frame_hdr', '.ARM.exidxsentinel_section_after_text' ], 'symbols': [ '.dynsym', '.dynstr', '.dynamic', '.shstrtab', '.got', '.plt', '.iplt', '.got.plt', '.hash', '.gnu.hash' ], 'other': [ '.init_array', '.preinit_array', '.ctors', '.fini_array', '.comment', '.note.gnu.gold-version', '.note.crashpad.info', '.note.android.ident', '.ARM.attributes', '.note.gnu.build-id', '.gnu.version', '.gnu.version_d', '.gnu.version_r', '.interp', '.gcc_except_table', '.note.gnu.property' ] } class _AccumulatingReporter: def __init__(self): self._combined_metrics = collections.defaultdict(int) def __call__(self, graph_title, trace_title, value, units): self._combined_metrics[(graph_title, trace_title, units)] += value def DumpReports(self, report_func): for (graph_title, trace_title, units), value in sorted(self._combined_metrics.items()): report_func(graph_title, trace_title, value, units) class _ChartJsonReporter(_AccumulatingReporter): def __init__(self, chartjson): super().__init__() self._chartjson = chartjson self.trace_title_prefix = '' def __call__(self, graph_title, trace_title, value, units): super().__call__(graph_title, trace_title, value, units) perf_tests_results_helper.ReportPerfResult( self._chartjson, graph_title, self.trace_title_prefix + trace_title, value, units) def SynthesizeTotals(self, unique_method_count): for tup, value in sorted(self._combined_metrics.items()): graph_title, trace_title, units = tup if trace_title == 'unique methods': value = unique_method_count perf_tests_results_helper.ReportPerfResult(self._chartjson, graph_title, 'Combined_' + trace_title, value, units) def _PercentageDifference(a, b): if a == 0: return 0 return float(b - a) / a def _ReadZipInfoExtraFieldLength(zip_file, zip_info): """Reads the value of |extraLength| from |zip_info|'s local file header. |zip_info| has an |extra| field, but it's read from the central directory. Android's zipalign tool sets the extra field only in local file headers. """ # Refer to https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers zip_file.fp.seek(zip_info.header_offset + 28) return struct.unpack(', or . on_demand = parse_attr('dist', 'onDemand') or 'on-demand' in output on_demand = bool(on_demand and is_feature_split) return sdk_version, skip_extract_lib, on_demand def _NormalizeLanguagePaks(translations, factor): english_pak = translations.FindByPattern(r'.*/en[-_][Uu][Ss]\.l?pak') num_translations = translations.GetNumEntries() ret = 0 if english_pak: ret -= translations.ComputeZippedSize() ret += int(english_pak.compress_size * num_translations * factor) return ret def _NormalizeResourcesArsc(apk_path, num_arsc_files, num_translations, out_dir): """Estimates the expected overhead of untranslated strings in resources.arsc. See http://crbug.com/677966 for why this is necessary. """ # If there are multiple .arsc files, use the resource packaged APK instead. if num_arsc_files > 1: if not out_dir: return -float('inf') ap_name = os.path.basename(apk_path).replace('.apk', '.ap_') ap_path = os.path.join(out_dir, 'arsc/apks', ap_name) if not os.path.exists(ap_path): raise Exception('Missing expected file: %s, try rebuilding.' % ap_path) apk_path = ap_path aapt_output = _RunAaptDumpResources(apk_path) # en-rUS is in the default config and may be cluttered with non-translatable # strings, so en-rGB is a better baseline for finding missing translations. en_strings = _CreateResourceIdValueMap(aapt_output, 'en-rGB') fr_strings = _CreateResourceIdValueMap(aapt_output, 'fr') # en-US and en-GB will never be translated. config_count = num_translations - 2 size = 0 for res_id, string_val in en_strings.items(): if string_val == fr_strings[res_id]: string_size = len(string_val) # 7 bytes is the per-entry overhead (not specific to any string). See # https://android.googlesource.com/platform/frameworks/base.git/+/android-4.2.2_r1/tools/aapt/StringPool.cpp#414. # The 1.5 factor was determined experimentally and is meant to account for # other languages generally having longer strings than english. size += config_count * (7 + string_size * 1.5) return int(size) def _CreateResourceIdValueMap(aapt_output, lang): """Return a map of resource ids to string values for the given |lang|.""" config_re = _AAPT_CONFIG_PATTERN % lang return {entry.group('id'): entry.group('val') for config_section in re.finditer(config_re, aapt_output, re.DOTALL) for entry in re.finditer(_AAPT_ENTRY_RE, config_section.group(0))} def _RunAaptDumpResources(apk_path): cmd = [_AAPT_PATH.read(), 'dump', '--values', 'resources', apk_path] status, output = cmd_helper.GetCmdStatusAndOutput(cmd) if status != 0: raise Exception('Failed running aapt command: "%s" with output "%s".' % (' '.join(cmd), output)) return output class _FileGroup: """Represents a category that apk files can fall into.""" def __init__(self, name): self.name = name self._zip_infos = [] self._extracted_multipliers = [] def AddZipInfo(self, zip_info, extracted_multiplier=0): self._zip_infos.append(zip_info) self._extracted_multipliers.append(extracted_multiplier) def AllEntries(self): return iter(self._zip_infos) def GetNumEntries(self): return len(self._zip_infos) def FindByPattern(self, pattern): return next((i for i in self._zip_infos if re.match(pattern, i.filename)), None) def FindLargest(self): if not self._zip_infos: return None return max(self._zip_infos, key=lambda i: i.file_size) def ComputeZippedSize(self): return sum(i.compress_size for i in self._zip_infos) def ComputeUncompressedSize(self): return sum(i.file_size for i in self._zip_infos) def ComputeExtractedSize(self): ret = 0 for zi, multiplier in zip(self._zip_infos, self._extracted_multipliers): ret += zi.file_size * multiplier return ret def ComputeInstallSize(self): return self.ComputeExtractedSize() + self.ComputeZippedSize() def _AnalyzeInternal(apk_path, sdk_version, report_func, dex_stats_collector, out_dir, apks_path=None, split_name=None): """Analyse APK to determine size contributions of different file classes. Returns: Normalized APK size. """ dex_stats_collector.CollectFromZip(split_name or '', apk_path) file_groups = [] def make_group(name): group = _FileGroup(name) file_groups.append(group) return group def has_no_extension(filename): return os.path.splitext(filename)[1] == '' native_code = make_group('Native code') java_code = make_group('Java code') native_resources_no_translations = make_group('Native resources (no l10n)') translations = make_group('Native resources (l10n)') stored_translations = make_group('Native resources stored (l10n)') icu_data = make_group('ICU (i18n library) data') v8_snapshots = make_group('V8 Snapshots') png_drawables = make_group('PNG drawables') res_directory = make_group('Non-compiled Android resources') arsc = make_group('Compiled Android resources') metadata = make_group('Package metadata') unknown = make_group('Unknown files') notices = make_group('licenses.notice file') unwind_cfi = make_group('unwind_cfi (dev and canary only)') with zipfile.ZipFile(apk_path, 'r') as apk: apk_contents = apk.infolist() # Account for zipalign overhead that exists in local file header. zipalign_overhead = sum( _ReadZipInfoExtraFieldLength(apk, i) for i in apk_contents) # Account for zipalign overhead that exists in central directory header. # Happens when python aligns entries in apkbuilder.py, but does not # exist when using Android's zipalign. E.g. for bundle .apks files. zipalign_overhead += sum(len(i.extra) for i in apk_contents) signing_block_size = _MeasureApkSignatureBlock(apk) _, skip_extract_lib, _ = _ParseManifestAttributes(apk_path) # Pre-L: Dalvik - .odex file is simply decompressed/optimized dex file (~1x). # L, M: ART - .odex file is compiled version of the dex file (~4x). # N: ART - Uses Dalvik-like JIT for normal apps (~1x), full compilation for # shared apps (~4x). # Actual multipliers calculated using "apk_operations.py disk-usage". # Will need to update multipliers once apk obfuscation is enabled. # E.g. with obfuscation, the 4.04 changes to 4.46. speed_profile_dex_multiplier = 1.17 orig_filename = apks_path or apk_path is_webview = 'WebView' in orig_filename is_monochrome = 'Monochrome' in orig_filename is_library = 'Library' in orig_filename is_trichrome = 'TrichromeChrome' in orig_filename # WebView is always a shared APK since other apps load it. # Library is always shared since it's used by chrome and webview # Chrome is always shared since renderers can't access dex otherwise # (see DexFixer). is_shared_apk = sdk_version >= 24 and (is_monochrome or is_webview or is_library or is_trichrome) # Dex decompression overhead varies by Android version. if sdk_version < 21: # JellyBean & KitKat dex_multiplier = 1.16 elif sdk_version < 24: # Lollipop & Marshmallow dex_multiplier = 4.04 elif is_shared_apk: # Oreo and above, compilation_filter=speed dex_multiplier = 4.04 else: # Oreo and above, compilation_filter=speed-profile dex_multiplier = speed_profile_dex_multiplier total_apk_size = os.path.getsize(apk_path) for member in apk_contents: filename = member.filename if filename.endswith('/'): continue if filename.endswith('.so'): basename = posixpath.basename(filename) should_extract_lib = not skip_extract_lib and basename.startswith('lib') native_code.AddZipInfo( member, extracted_multiplier=int(should_extract_lib)) elif filename.startswith('classes') and filename.endswith('.dex'): # Android P+, uncompressed dex does not need to be extracted. compressed = member.compress_type != zipfile.ZIP_STORED multiplier = dex_multiplier if not compressed and sdk_version >= 28: multiplier -= 1 java_code.AddZipInfo(member, extracted_multiplier=multiplier) elif re.search(_RE_NON_LANGUAGE_PAK, filename): native_resources_no_translations.AddZipInfo(member) elif filename.endswith('.pak') or filename.endswith('.lpak'): compressed = member.compress_type != zipfile.ZIP_STORED bucket = translations if compressed else stored_translations extracted_multiplier = 0 if compressed: extracted_multiplier = int('en_' in filename or 'en-' in filename) bucket.AddZipInfo(member, extracted_multiplier=extracted_multiplier) elif 'icu' in filename and filename.endswith('.dat'): icu_data.AddZipInfo(member) elif filename.endswith('.bin'): v8_snapshots.AddZipInfo(member) elif filename.startswith('res/'): if (filename.endswith('.png') or filename.endswith('.webp') or has_no_extension(filename)): png_drawables.AddZipInfo(member) else: res_directory.AddZipInfo(member) elif filename.endswith('.arsc'): arsc.AddZipInfo(member) elif filename.startswith('META-INF') or filename in ( 'AndroidManifest.xml', 'assets/webapk_dex_version.txt', 'stamp-cert-sha256'): metadata.AddZipInfo(member) elif filename.endswith('.notice'): notices.AddZipInfo(member) elif filename.startswith('assets/unwind_cfi'): unwind_cfi.AddZipInfo(member) else: unknown.AddZipInfo(member) if apks_path: # We're mostly focused on size of Chrome for non-English locales, so assume # Hindi (arbitrarily chosen) locale split is installed. with zipfile.ZipFile(apks_path) as z: subpath = 'splits/{}-hi.apk'.format(split_name) if subpath in z.namelist(): hindi_apk_info = z.getinfo(subpath) total_apk_size += hindi_apk_info.file_size else: assert split_name != 'base', 'splits/base-hi.apk should always exist' total_install_size = total_apk_size total_install_size_android_go = total_apk_size zip_overhead = total_apk_size for group in file_groups: actual_size = group.ComputeZippedSize() install_size = group.ComputeInstallSize() uncompressed_size = group.ComputeUncompressedSize() extracted_size = group.ComputeExtractedSize() total_install_size += extracted_size zip_overhead -= actual_size report_func('Breakdown', group.name + ' size', actual_size, 'bytes') report_func('InstallBreakdown', group.name + ' size', int(install_size), 'bytes') # Only a few metrics are compressed in the first place. # To avoid over-reporting, track uncompressed size only for compressed # entries. if uncompressed_size != actual_size: report_func('Uncompressed', group.name + ' size', uncompressed_size, 'bytes') if group is java_code: # Updates are compiled using quicken, but system image uses speed-profile. multiplier = speed_profile_dex_multiplier # Android P+, uncompressed dex does not need to be extracted. compressed = uncompressed_size != actual_size if not compressed and sdk_version >= 28: multiplier -= 1 extracted_size = int(uncompressed_size * multiplier) total_install_size_android_go += extracted_size report_func('InstallBreakdownGo', group.name + ' size', actual_size + extracted_size, 'bytes') elif group is translations and apks_path: # Assume Hindi rather than English (accounted for above in total_apk_size) total_install_size_android_go += actual_size else: total_install_size_android_go += extracted_size # Per-file zip overhead is caused by: # * 30 byte entry header + len(file name) # * 46 byte central directory entry + len(file name) # * 0-3 bytes for zipalign. report_func('Breakdown', 'Zip Overhead', zip_overhead, 'bytes') report_func('InstallSize', 'APK size', total_apk_size, 'bytes') report_func('InstallSize', 'Estimated installed size', int(total_install_size), 'bytes') report_func('InstallSize', 'Estimated installed size (Android Go)', int(total_install_size_android_go), 'bytes') transfer_size = _CalculateCompressedSize(apk_path) report_func('TransferSize', 'Transfer size (deflate)', transfer_size, 'bytes') # Size of main dex vs remaining. main_dex_info = java_code.FindByPattern('classes.dex') if main_dex_info: main_dex_size = main_dex_info.file_size report_func('Specifics', 'main dex size', main_dex_size, 'bytes') secondary_size = java_code.ComputeUncompressedSize() - main_dex_size report_func('Specifics', 'secondary dex size', secondary_size, 'bytes') main_lib_info = native_code.FindLargest() native_code_unaligned_size = 0 for lib_info in native_code.AllEntries(): # Skip placeholders. if lib_info.file_size == 0: continue section_sizes = _ExtractLibSectionSizesFromApk(apk_path, lib_info.filename) native_code_unaligned_size += sum(v for k, v in section_sizes.items() if k != 'bss') # Size of main .so vs remaining. if lib_info == main_lib_info: main_lib_size = lib_info.file_size report_func('Specifics', 'main lib size', main_lib_size, 'bytes') secondary_size = native_code.ComputeUncompressedSize() - main_lib_size report_func('Specifics', 'other lib size', secondary_size, 'bytes') for metric_name, size in section_sizes.items(): report_func('MainLibInfo', metric_name, size, 'bytes') # Main metric that we want to monitor for jumps. normalized_apk_size = total_apk_size # unwind_cfi exists only in dev, canary, and non-channel builds. normalized_apk_size -= unwind_cfi.ComputeZippedSize() # Sections within .so files get 4kb aligned, so use section sizes rather than # file size. Also gets rid of compression. normalized_apk_size -= native_code.ComputeZippedSize() normalized_apk_size += native_code_unaligned_size # Normalized dex size: Size within the zip + size on disk for Android Go # devices running Android O (which ~= uncompressed dex size). # Use a constant compression factor to account for fluctuations. normalized_apk_size -= java_code.ComputeZippedSize() normalized_apk_size += java_code.ComputeUncompressedSize() # Don't include zipalign overhead in normalized size, since it effectively # causes size changes files that proceed aligned files to be rounded. # For APKs where classes.dex directly proceeds libchrome.so (the normal case), # this causes small dex size changes to disappear into libchrome.so alignment. normalized_apk_size -= zipalign_overhead # Don't include the size of the apk's signing block because it can fluctuate # by up to 4kb (from my non-scientific observations), presumably based on hash # sizes. normalized_apk_size -= signing_block_size # Unaligned size should be ~= uncompressed size or something is wrong. # As of now, padding_fraction ~= .007 padding_fraction = -_PercentageDifference( native_code.ComputeUncompressedSize(), native_code_unaligned_size) # Ignore this check for small / no native code if native_code.ComputeUncompressedSize() > 1000000: assert 0 <= padding_fraction < .02, ( 'Padding was: {} (file_size={}, sections_sum={})'.format( padding_fraction, native_code.ComputeUncompressedSize(), native_code_unaligned_size)) if apks_path: # Locale normalization not needed when measuring only one locale. # E.g. a change that adds 300 chars of unstranslated strings would cause the # metric to be off by only 390 bytes (assuming a multiplier of 2.3 for # Hindi). pass else: # Avoid noise caused when strings change and translations haven't yet been # updated. num_translations = translations.GetNumEntries() num_stored_translations = stored_translations.GetNumEntries() if num_translations > 1: # Multipliers found by looking at MonochromePublic.apk and seeing how much # smaller en-US.pak is relative to the average locale.pak. normalized_apk_size += _NormalizeLanguagePaks(translations, 1.17) if num_stored_translations > 1: normalized_apk_size += _NormalizeLanguagePaks(stored_translations, 1.43) if num_translations + num_stored_translations > 1: if num_translations == 0: # WebView stores all locale paks uncompressed. num_arsc_translations = num_stored_translations else: # Monochrome has more configurations than Chrome since it includes # WebView (which supports more locales), but these should mostly be # empty so ignore them here. num_arsc_translations = num_translations normalized_apk_size += _NormalizeResourcesArsc(apk_path, arsc.GetNumEntries(), num_arsc_translations, out_dir) # It will be -Inf for .apk files with multiple .arsc files and no out_dir set. if normalized_apk_size < 0: sys.stderr.write('Skipping normalized_apk_size (no output directory set)\n') else: report_func('Specifics', 'normalized apk size', normalized_apk_size, 'bytes') # The "file count" metric cannot be grouped with any other metrics when the # end result is going to be uploaded to the perf dashboard in the HistogramSet # format due to mixed units (bytes vs. zip entries) causing malformed # summaries to be generated. # TODO(https://crbug.com/903970): Remove this workaround if unit mixing is # ever supported. report_func('FileCount', 'file count', len(apk_contents), 'zip entries') for info in unknown.AllEntries(): sys.stderr.write( 'Unknown entry: %s %d\n' % (info.filename, info.compress_size)) return normalized_apk_size def _CalculateCompressedSize(file_path): CHUNK_SIZE = 256 * 1024 compressor = zlib.compressobj() total_size = 0 with open(file_path, 'rb') as f: for chunk in iter(lambda: f.read(CHUNK_SIZE), b''): total_size += len(compressor.compress(chunk)) total_size += len(compressor.flush()) return total_size @contextmanager def Unzip(zip_file, filename=None): """Utility for temporary use of a single file in a zip archive.""" with build_utils.TempDir() as unzipped_dir: unzipped_files = build_utils.ExtractAll( zip_file, unzipped_dir, True, pattern=filename) if len(unzipped_files) == 0: raise Exception( '%s not found in %s' % (filename, zip_file)) yield unzipped_files[0] def _ConfigOutDir(out_dir): if out_dir: constants.SetOutputDirectory(out_dir) else: try: # Triggers auto-detection when CWD == output directory. constants.CheckOutputDirectory() out_dir = constants.GetOutDirectory() except Exception: # pylint: disable=broad-except pass return out_dir def _IterSplits(namelist): for subpath in namelist: # Looks for paths like splits/vr-master.apk, splits/vr-hi.apk. name_parts = subpath.split('/') if name_parts[0] == 'splits' and len(name_parts) == 2: name_parts = name_parts[1].split('-') if len(name_parts) == 2: split_name, config_name = name_parts if config_name == 'master.apk': yield subpath, split_name def _ExtractToTempFile(zip_obj, subpath, temp_file): temp_file.seek(0) temp_file.truncate() temp_file.write(zip_obj.read(subpath)) temp_file.flush() def _AnalyzeApkOrApks(report_func, apk_path, out_dir): # Create DexStatsCollector here to track unique methods across base & chrome # modules. dex_stats_collector = method_count.DexStatsCollector() if apk_path.endswith('.apk'): sdk_version, _, _ = _ParseManifestAttributes(apk_path) _AnalyzeInternal(apk_path, sdk_version, report_func, dex_stats_collector, out_dir) elif apk_path.endswith('.apks'): with tempfile.NamedTemporaryFile(suffix='.apk') as f: with zipfile.ZipFile(apk_path) as z: # Currently bundletool is creating two apks when .apks is created # without specifying an sdkVersion. Always measure the one with an # uncompressed shared library. try: info = z.getinfo('splits/base-master_2.apk') except KeyError: info = z.getinfo('splits/base-master.apk') _ExtractToTempFile(z, info.filename, f) sdk_version, _, _ = _ParseManifestAttributes(f.name) orig_report_func = report_func report_func = _AccumulatingReporter() def do_measure(split_name, on_demand): logging.info('Measuring %s on_demand=%s', split_name, on_demand) # Use no-op reporting functions to get normalized size for DFMs. inner_report_func = report_func inner_dex_stats_collector = dex_stats_collector if on_demand: inner_report_func = lambda *_: None inner_dex_stats_collector = method_count.DexStatsCollector() size = _AnalyzeInternal(f.name, sdk_version, inner_report_func, inner_dex_stats_collector, out_dir, apks_path=apk_path, split_name=split_name) report_func('DFM_' + split_name, 'Size with hindi', size, 'bytes') # Measure base outside of the loop since we've already extracted it. do_measure('base', on_demand=False) for subpath, split_name in _IterSplits(z.namelist()): if split_name != 'base': _ExtractToTempFile(z, subpath, f) _, _, on_demand = _ParseManifestAttributes(f.name) do_measure(split_name, on_demand=on_demand) report_func.DumpReports(orig_report_func) report_func = orig_report_func else: raise Exception('Unknown file type: ' + apk_path) # Report dex stats outside of _AnalyzeInternal() so that the "unique methods" # metric is not just the sum of the base and chrome modules. for metric, count in dex_stats_collector.GetTotalCounts().items(): report_func('Dex', metric, count, 'entries') report_func('Dex', 'unique methods', dex_stats_collector.GetUniqueMethodCount(), 'entries') report_func('DexCache', 'DexCache', dex_stats_collector.GetDexCacheSize(pre_oreo=sdk_version < 26), 'bytes') return dex_stats_collector def _ResourceSizes(args): chartjson = _BASE_CHART.copy() if args.output_format else None reporter = _ChartJsonReporter(chartjson) # Create DexStatsCollector here to track unique methods across trichrome APKs. dex_stats_collector = method_count.DexStatsCollector() specs = [ ('Chrome_', args.trichrome_chrome), ('WebView_', args.trichrome_webview), ('Library_', args.trichrome_library), ] for prefix, path in specs: if path: reporter.trace_title_prefix = prefix child_dex_stats_collector = _AnalyzeApkOrApks(reporter, path, args.out_dir) dex_stats_collector.MergeFrom(prefix, child_dex_stats_collector) if any(path for _, path in specs): reporter.SynthesizeTotals(dex_stats_collector.GetUniqueMethodCount()) else: _AnalyzeApkOrApks(reporter, args.input, args.out_dir) if chartjson: _DumpChartJson(args, chartjson) def _DumpChartJson(args, chartjson): if args.output_file == '-': json_file = sys.stdout elif args.output_file: json_file = open(args.output_file, 'w') else: results_path = os.path.join(args.output_dir, 'results-chart.json') logging.critical('Dumping chartjson to %s', results_path) json_file = open(results_path, 'w') json.dump(chartjson, json_file, indent=2) if json_file is not sys.stdout: json_file.close() # We would ideally generate a histogram set directly instead of generating # chartjson then converting. However, perf_tests_results_helper is in # //build, which doesn't seem to have any precedent for depending on # anything in Catapult. This can probably be fixed, but since this doesn't # need to be super fast or anything, converting is a good enough solution # for the time being. if args.output_format == 'histograms': histogram_result = convert_chart_json.ConvertChartJson(results_path) if histogram_result.returncode != 0: raise Exception('chartjson conversion failed with error: ' + histogram_result.stdout) histogram_path = os.path.join(args.output_dir, 'perf_results.json') logging.critical('Dumping histograms to %s', histogram_path) with open(histogram_path, 'wb') as json_file: json_file.write(histogram_result.stdout) def main(): build_utils.InitLogging('RESOURCE_SIZES_DEBUG') argparser = argparse.ArgumentParser(description='Print APK size metrics.') argparser.add_argument( '--min-pak-resource-size', type=int, default=20 * 1024, help='Minimum byte size of displayed pak resources.') argparser.add_argument( '--chromium-output-directory', dest='out_dir', type=os.path.realpath, help='Location of the build artifacts.') argparser.add_argument( '--chartjson', action='store_true', help='DEPRECATED. Use --output-format=chartjson ' 'instead.') argparser.add_argument( '--output-format', choices=['chartjson', 'histograms'], help='Output the results to a file in the given ' 'format instead of printing the results.') argparser.add_argument('--loadable_module', help='Obsolete (ignored).') # Accepted to conform to the isolated script interface, but ignored. argparser.add_argument( '--isolated-script-test-filter', help=argparse.SUPPRESS) argparser.add_argument( '--isolated-script-test-perf-output', type=os.path.realpath, help=argparse.SUPPRESS) output_group = argparser.add_mutually_exclusive_group() output_group.add_argument( '--output-dir', default='.', help='Directory to save chartjson to.') output_group.add_argument( '--output-file', help='Path to output .json (replaces --output-dir). Works only for ' '--output-format=chartjson') output_group.add_argument( '--isolated-script-test-output', type=os.path.realpath, help='File to which results will be written in the ' 'simplified JSON output format.') argparser.add_argument('input', help='Path to .apk or .apks file to measure.') trichrome_group = argparser.add_argument_group( 'Trichrome inputs', description='When specified, |input| is used only as Test suite name.') trichrome_group.add_argument( '--trichrome-chrome', help='Path to Trichrome Chrome .apks') trichrome_group.add_argument( '--trichrome-webview', help='Path to Trichrome WebView .apk(s)') trichrome_group.add_argument( '--trichrome-library', help='Path to Trichrome Library .apk') args = argparser.parse_args() args.out_dir = _ConfigOutDir(args.out_dir) devil_chromium.Initialize(output_directory=args.out_dir) # TODO(bsheedy): Remove this once uses of --chartjson have been removed. if args.chartjson: args.output_format = 'chartjson' result_sink_client = result_sink.TryInitClient() isolated_script_output = {'valid': False, 'failures': []} test_name = 'resource_sizes (%s)' % os.path.basename(args.input) if args.isolated_script_test_output: args.output_dir = os.path.join( os.path.dirname(args.isolated_script_test_output), test_name) if not os.path.exists(args.output_dir): os.makedirs(args.output_dir) try: _ResourceSizes(args) isolated_script_output = { 'valid': True, 'failures': [], } finally: if args.isolated_script_test_output: results_path = os.path.join(args.output_dir, 'test_results.json') with open(results_path, 'w') as output_file: json.dump(isolated_script_output, output_file) with open(args.isolated_script_test_output, 'w') as output_file: json.dump(isolated_script_output, output_file) if result_sink_client: status = result_types.PASS if not isolated_script_output['valid']: status = result_types.UNKNOWN elif isolated_script_output['failures']: status = result_types.FAIL result_sink_client.Post(test_name, status, None, None, None) if __name__ == '__main__': main()