1#!/usr/bin/env python 2# Copyright (c) 2011 The Chromium Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Reports binary size metrics for an APK. 7 8More information at //docs/speed/binary_size/metrics.md. 9""" 10 11from __future__ import print_function 12 13import argparse 14import collections 15from contextlib import contextmanager 16import json 17import logging 18import os 19import posixpath 20import re 21import sys 22import tempfile 23import zipfile 24import zlib 25 26import devil_chromium 27from devil.android.sdk import build_tools 28from devil.utils import cmd_helper 29from devil.utils import lazy 30import method_count 31from pylib import constants 32from pylib.constants import host_paths 33 34_AAPT_PATH = lazy.WeakConstant(lambda: build_tools.GetPath('aapt')) 35_BUILD_UTILS_PATH = os.path.join( 36 host_paths.DIR_SOURCE_ROOT, 'build', 'android', 'gyp') 37 38with host_paths.SysPath(os.path.join(host_paths.DIR_SOURCE_ROOT, 'build')): 39 import gn_helpers # pylint: disable=import-error 40 41with host_paths.SysPath(host_paths.BUILD_COMMON_PATH): 42 import perf_tests_results_helper # pylint: disable=import-error 43 44with host_paths.SysPath(host_paths.TRACING_PATH): 45 from tracing.value import convert_chart_json # pylint: disable=import-error 46 47with host_paths.SysPath(_BUILD_UTILS_PATH, 0): 48 from util import build_utils # pylint: disable=import-error 49 from util import zipalign # pylint: disable=import-error 50 51 52zipalign.ApplyZipFileZipAlignFix() 53 54# Captures an entire config from aapt output. 55_AAPT_CONFIG_PATTERN = r'config %s:(.*?)config [a-zA-Z-]+:' 56# Matches string resource entries from aapt output. 57_AAPT_ENTRY_RE = re.compile( 58 r'resource (?P<id>\w{10}) [\w\.]+:string/.*?"(?P<val>.+?)"', re.DOTALL) 59_BASE_CHART = { 60 'format_version': '0.1', 61 'benchmark_name': 'resource_sizes', 62 'benchmark_description': 'APK resource size information.', 63 'trace_rerun_options': [], 64 'charts': {} 65} 66# Macro definitions look like (something, 123) when 67# enable_resource_allowlist_generation=true. 68_RC_HEADER_RE = re.compile(r'^#define (?P<name>\w+).* (?P<id>\d+)\)?$') 69_RE_NON_LANGUAGE_PAK = re.compile(r'^assets/.*(resources|percent)\.pak$') 70_READELF_SIZES_METRICS = { 71 'text': ['.text'], 72 'data': ['.data', '.rodata', '.data.rel.ro', '.data.rel.ro.local'], 73 'relocations': ['.rel.dyn', '.rel.plt', '.rela.dyn', '.rela.plt'], 74 'unwind': [ 75 '.ARM.extab', '.ARM.exidx', '.eh_frame', '.eh_frame_hdr', 76 '.ARM.exidxsentinel_section_after_text' 77 ], 78 'symbols': [ 79 '.dynsym', '.dynstr', '.dynamic', '.shstrtab', '.got', '.plt', 80 '.got.plt', '.hash', '.gnu.hash' 81 ], 82 'other': [ 83 '.init_array', '.preinit_array', '.ctors', '.fini_array', '.comment', 84 '.note.gnu.gold-version', '.note.crashpad.info', '.note.android.ident', 85 '.ARM.attributes', '.note.gnu.build-id', '.gnu.version', 86 '.gnu.version_d', '.gnu.version_r', '.interp', '.gcc_except_table' 87 ] 88} 89 90 91def _PercentageDifference(a, b): 92 if a == 0: 93 return 0 94 return float(b - a) / a 95 96 97def _RunReadelf(so_path, options, tool_prefix=''): 98 return cmd_helper.GetCmdOutput( 99 [tool_prefix + 'readelf'] + options + [so_path]) 100 101 102def _ExtractLibSectionSizesFromApk(apk_path, lib_path, tool_prefix): 103 with Unzip(apk_path, filename=lib_path) as extracted_lib_path: 104 grouped_section_sizes = collections.defaultdict(int) 105 no_bits_section_sizes, section_sizes = _CreateSectionNameSizeMap( 106 extracted_lib_path, tool_prefix) 107 for group_name, section_names in _READELF_SIZES_METRICS.iteritems(): 108 for section_name in section_names: 109 if section_name in section_sizes: 110 grouped_section_sizes[group_name] += section_sizes.pop(section_name) 111 112 # Consider all NOBITS sections as .bss. 113 grouped_section_sizes['bss'] = sum( 114 v for v in no_bits_section_sizes.itervalues()) 115 116 # Group any unknown section headers into the "other" group. 117 for section_header, section_size in section_sizes.iteritems(): 118 sys.stderr.write('Unknown elf section header: %s\n' % section_header) 119 grouped_section_sizes['other'] += section_size 120 121 return grouped_section_sizes 122 123 124def _CreateSectionNameSizeMap(so_path, tool_prefix): 125 stdout = _RunReadelf(so_path, ['-S', '--wide'], tool_prefix) 126 section_sizes = {} 127 no_bits_section_sizes = {} 128 # Matches [ 2] .hash HASH 00000000006681f0 0001f0 003154 04 A 3 0 8 129 for match in re.finditer(r'\[[\s\d]+\] (\..*)$', stdout, re.MULTILINE): 130 items = match.group(1).split() 131 target = no_bits_section_sizes if items[1] == 'NOBITS' else section_sizes 132 target[items[0]] = int(items[4], 16) 133 134 return no_bits_section_sizes, section_sizes 135 136 137def _ParseManifestAttributes(apk_path): 138 # Check if the manifest specifies whether or not to extract native libs. 139 skip_extract_lib = False 140 output = cmd_helper.GetCmdOutput([ 141 _AAPT_PATH.read(), 'd', 'xmltree', apk_path, 'AndroidManifest.xml']) 142 m = re.search(r'extractNativeLibs\(.*\)=\(.*\)(\w)', output) 143 if m: 144 skip_extract_lib = not bool(int(m.group(1))) 145 146 # Dex decompression overhead varies by Android version. 147 m = re.search(r'android:minSdkVersion\(\w+\)=\(type \w+\)(\w+)', output) 148 sdk_version = int(m.group(1), 16) 149 150 return sdk_version, skip_extract_lib 151 152 153def _NormalizeLanguagePaks(translations, factor): 154 english_pak = translations.FindByPattern(r'.*/en[-_][Uu][Ss]\.l?pak') 155 num_translations = translations.GetNumEntries() 156 ret = 0 157 if english_pak: 158 ret -= translations.ComputeZippedSize() 159 ret += int(english_pak.compress_size * num_translations * factor) 160 return ret 161 162 163def _NormalizeResourcesArsc(apk_path, num_arsc_files, num_translations, 164 out_dir): 165 """Estimates the expected overhead of untranslated strings in resources.arsc. 166 167 See http://crbug.com/677966 for why this is necessary. 168 """ 169 # If there are multiple .arsc files, use the resource packaged APK instead. 170 if num_arsc_files > 1: 171 if not out_dir: 172 return -float('inf') 173 ap_name = os.path.basename(apk_path).replace('.apk', '.ap_') 174 ap_path = os.path.join(out_dir, 'arsc/apks', ap_name) 175 if not os.path.exists(ap_path): 176 raise Exception('Missing expected file: %s, try rebuilding.' % ap_path) 177 apk_path = ap_path 178 179 aapt_output = _RunAaptDumpResources(apk_path) 180 # en-rUS is in the default config and may be cluttered with non-translatable 181 # strings, so en-rGB is a better baseline for finding missing translations. 182 en_strings = _CreateResourceIdValueMap(aapt_output, 'en-rGB') 183 fr_strings = _CreateResourceIdValueMap(aapt_output, 'fr') 184 185 # en-US and en-GB will never be translated. 186 config_count = num_translations - 2 187 188 size = 0 189 for res_id, string_val in en_strings.iteritems(): 190 if string_val == fr_strings[res_id]: 191 string_size = len(string_val) 192 # 7 bytes is the per-entry overhead (not specific to any string). See 193 # https://android.googlesource.com/platform/frameworks/base.git/+/android-4.2.2_r1/tools/aapt/StringPool.cpp#414. 194 # The 1.5 factor was determined experimentally and is meant to account for 195 # other languages generally having longer strings than english. 196 size += config_count * (7 + string_size * 1.5) 197 198 return int(size) 199 200 201def _CreateResourceIdValueMap(aapt_output, lang): 202 """Return a map of resource ids to string values for the given |lang|.""" 203 config_re = _AAPT_CONFIG_PATTERN % lang 204 return {entry.group('id'): entry.group('val') 205 for config_section in re.finditer(config_re, aapt_output, re.DOTALL) 206 for entry in re.finditer(_AAPT_ENTRY_RE, config_section.group(0))} 207 208 209def _RunAaptDumpResources(apk_path): 210 cmd = [_AAPT_PATH.read(), 'dump', '--values', 'resources', apk_path] 211 status, output = cmd_helper.GetCmdStatusAndOutput(cmd) 212 if status != 0: 213 raise Exception('Failed running aapt command: "%s" with output "%s".' % 214 (' '.join(cmd), output)) 215 return output 216 217 218def _ReportDfmSizes(zip_obj, report_func): 219 sizes = collections.defaultdict(int) 220 for info in zip_obj.infolist(): 221 # Looks for paths like splits/vr-master.apk, splits/vr-hi.apk. 222 name_parts = info.filename.split('/') 223 if name_parts[0] == 'splits' and len(name_parts) == 2: 224 name_parts = name_parts[1].split('-') 225 if len(name_parts) == 2: 226 module_name, config_name = name_parts 227 if module_name != 'base' and config_name[:-4] in ('master', 'hi'): 228 sizes[module_name] += info.file_size 229 230 for module_name, size in sorted(sizes.iteritems()): 231 report_func('DFM_' + module_name, 'Size with hindi', size, 'bytes') 232 233 234class _FileGroup(object): 235 """Represents a category that apk files can fall into.""" 236 237 def __init__(self, name): 238 self.name = name 239 self._zip_infos = [] 240 self._extracted_multipliers = [] 241 242 def AddZipInfo(self, zip_info, extracted_multiplier=0): 243 self._zip_infos.append(zip_info) 244 self._extracted_multipliers.append(extracted_multiplier) 245 246 def AllEntries(self): 247 return iter(self._zip_infos) 248 249 def GetNumEntries(self): 250 return len(self._zip_infos) 251 252 def FindByPattern(self, pattern): 253 return next((i for i in self._zip_infos if re.match(pattern, i.filename)), 254 None) 255 256 def FindLargest(self): 257 if not self._zip_infos: 258 return None 259 return max(self._zip_infos, key=lambda i: i.file_size) 260 261 def ComputeZippedSize(self): 262 return sum(i.compress_size for i in self._zip_infos) 263 264 def ComputeUncompressedSize(self): 265 return sum(i.file_size for i in self._zip_infos) 266 267 def ComputeExtractedSize(self): 268 ret = 0 269 for zi, multiplier in zip(self._zip_infos, self._extracted_multipliers): 270 ret += zi.file_size * multiplier 271 return ret 272 273 def ComputeInstallSize(self): 274 return self.ComputeExtractedSize() + self.ComputeZippedSize() 275 276 277def _DoApkAnalysis(apk_filename, apks_path, tool_prefix, out_dir, report_func): 278 """Analyse APK to determine size contributions of different file classes.""" 279 file_groups = [] 280 281 def make_group(name): 282 group = _FileGroup(name) 283 file_groups.append(group) 284 return group 285 286 def has_no_extension(filename): 287 return os.path.splitext(filename)[1] == '' 288 289 native_code = make_group('Native code') 290 java_code = make_group('Java code') 291 native_resources_no_translations = make_group('Native resources (no l10n)') 292 translations = make_group('Native resources (l10n)') 293 stored_translations = make_group('Native resources stored (l10n)') 294 icu_data = make_group('ICU (i18n library) data') 295 v8_snapshots = make_group('V8 Snapshots') 296 png_drawables = make_group('PNG drawables') 297 res_directory = make_group('Non-compiled Android resources') 298 arsc = make_group('Compiled Android resources') 299 metadata = make_group('Package metadata') 300 unknown = make_group('Unknown files') 301 notices = make_group('licenses.notice file') 302 unwind_cfi = make_group('unwind_cfi (dev and canary only)') 303 304 with zipfile.ZipFile(apk_filename, 'r') as apk: 305 apk_contents = apk.infolist() 306 307 sdk_version, skip_extract_lib = _ParseManifestAttributes(apk_filename) 308 309 # Pre-L: Dalvik - .odex file is simply decompressed/optimized dex file (~1x). 310 # L, M: ART - .odex file is compiled version of the dex file (~4x). 311 # N: ART - Uses Dalvik-like JIT for normal apps (~1x), full compilation for 312 # shared apps (~4x). 313 # Actual multipliers calculated using "apk_operations.py disk-usage". 314 # Will need to update multipliers once apk obfuscation is enabled. 315 # E.g. with obfuscation, the 4.04 changes to 4.46. 316 speed_profile_dex_multiplier = 1.17 317 orig_filename = apks_path or apk_filename 318 is_webview = 'WebView' in orig_filename 319 is_monochrome = 'Monochrome' in orig_filename 320 is_library = 'Library' in orig_filename 321 is_shared_apk = sdk_version >= 24 and (is_monochrome or is_webview 322 or is_library) 323 if sdk_version < 21: 324 # JellyBean & KitKat 325 dex_multiplier = 1.16 326 elif sdk_version < 24: 327 # Lollipop & Marshmallow 328 dex_multiplier = 4.04 329 elif is_shared_apk: 330 # Oreo and above, compilation_filter=speed 331 dex_multiplier = 4.04 332 else: 333 # Oreo and above, compilation_filter=speed-profile 334 dex_multiplier = speed_profile_dex_multiplier 335 336 total_apk_size = os.path.getsize(apk_filename) 337 for member in apk_contents: 338 filename = member.filename 339 if filename.endswith('/'): 340 continue 341 if filename.endswith('.so'): 342 basename = posixpath.basename(filename) 343 should_extract_lib = not skip_extract_lib and basename.startswith('lib') 344 native_code.AddZipInfo( 345 member, extracted_multiplier=int(should_extract_lib)) 346 elif filename.endswith('.dex'): 347 java_code.AddZipInfo(member, extracted_multiplier=dex_multiplier) 348 elif re.search(_RE_NON_LANGUAGE_PAK, filename): 349 native_resources_no_translations.AddZipInfo(member) 350 elif filename.endswith('.pak') or filename.endswith('.lpak'): 351 compressed = member.compress_type != zipfile.ZIP_STORED 352 bucket = translations if compressed else stored_translations 353 extracted_multiplier = 0 354 if compressed: 355 extracted_multiplier = int('en_' in filename or 'en-' in filename) 356 bucket.AddZipInfo(member, extracted_multiplier=extracted_multiplier) 357 elif filename == 'assets/icudtl.dat': 358 icu_data.AddZipInfo(member) 359 elif filename.endswith('.bin'): 360 v8_snapshots.AddZipInfo(member) 361 elif filename.startswith('res/'): 362 if (filename.endswith('.png') or filename.endswith('.webp') 363 or has_no_extension(filename)): 364 png_drawables.AddZipInfo(member) 365 else: 366 res_directory.AddZipInfo(member) 367 elif filename.endswith('.arsc'): 368 arsc.AddZipInfo(member) 369 elif filename.startswith('META-INF') or filename == 'AndroidManifest.xml': 370 metadata.AddZipInfo(member) 371 elif filename.endswith('.notice'): 372 notices.AddZipInfo(member) 373 elif filename.startswith('assets/unwind_cfi'): 374 unwind_cfi.AddZipInfo(member) 375 else: 376 unknown.AddZipInfo(member) 377 378 if apks_path: 379 # We're mostly focused on size of Chrome for non-English locales, so assume 380 # Hindi (arbitrarily chosen) locale split is installed. 381 with zipfile.ZipFile(apks_path) as z: 382 hindi_apk_info = z.getinfo('splits/base-hi.apk') 383 total_apk_size += hindi_apk_info.file_size 384 _ReportDfmSizes(z, report_func) 385 386 total_install_size = total_apk_size 387 total_install_size_android_go = total_apk_size 388 zip_overhead = total_apk_size 389 390 for group in file_groups: 391 actual_size = group.ComputeZippedSize() 392 install_size = group.ComputeInstallSize() 393 uncompressed_size = group.ComputeUncompressedSize() 394 extracted_size = group.ComputeExtractedSize() 395 total_install_size += extracted_size 396 zip_overhead -= actual_size 397 398 report_func('Breakdown', group.name + ' size', actual_size, 'bytes') 399 report_func('InstallBreakdown', group.name + ' size', int(install_size), 400 'bytes') 401 # Only a few metrics are compressed in the first place. 402 # To avoid over-reporting, track uncompressed size only for compressed 403 # entries. 404 if uncompressed_size != actual_size: 405 report_func('Uncompressed', group.name + ' size', uncompressed_size, 406 'bytes') 407 408 if group is java_code and is_shared_apk: 409 # Updates are compiled using quicken, but system image uses speed-profile. 410 extracted_size = int(uncompressed_size * speed_profile_dex_multiplier) 411 total_install_size_android_go += extracted_size 412 report_func('InstallBreakdownGo', group.name + ' size', 413 actual_size + extracted_size, 'bytes') 414 elif group is translations and apks_path: 415 # Assume Hindi rather than English (accounted for above in total_apk_size) 416 total_install_size_android_go += actual_size 417 else: 418 total_install_size_android_go += extracted_size 419 420 # Per-file zip overhead is caused by: 421 # * 30 byte entry header + len(file name) 422 # * 46 byte central directory entry + len(file name) 423 # * 0-3 bytes for zipalign. 424 report_func('Breakdown', 'Zip Overhead', zip_overhead, 'bytes') 425 report_func('InstallSize', 'APK size', total_apk_size, 'bytes') 426 report_func('InstallSize', 'Estimated installed size', 427 int(total_install_size), 'bytes') 428 if is_shared_apk: 429 report_func('InstallSize', 'Estimated installed size (Android Go)', 430 int(total_install_size_android_go), 'bytes') 431 transfer_size = _CalculateCompressedSize(apk_filename) 432 report_func('TransferSize', 'Transfer size (deflate)', transfer_size, 'bytes') 433 434 # Size of main dex vs remaining. 435 main_dex_info = java_code.FindByPattern('classes.dex') 436 if main_dex_info: 437 main_dex_size = main_dex_info.file_size 438 report_func('Specifics', 'main dex size', main_dex_size, 'bytes') 439 secondary_size = java_code.ComputeUncompressedSize() - main_dex_size 440 report_func('Specifics', 'secondary dex size', secondary_size, 'bytes') 441 442 main_lib_info = native_code.FindLargest() 443 native_code_unaligned_size = 0 444 for lib_info in native_code.AllEntries(): 445 section_sizes = _ExtractLibSectionSizesFromApk( 446 apk_filename, lib_info.filename, tool_prefix) 447 native_code_unaligned_size += sum( 448 v for k, v in section_sizes.iteritems() if k != 'bss') 449 # Size of main .so vs remaining. 450 if lib_info == main_lib_info: 451 main_lib_size = lib_info.file_size 452 report_func('Specifics', 'main lib size', main_lib_size, 'bytes') 453 secondary_size = native_code.ComputeUncompressedSize() - main_lib_size 454 report_func('Specifics', 'other lib size', secondary_size, 'bytes') 455 456 for metric_name, size in section_sizes.iteritems(): 457 report_func('MainLibInfo', metric_name, size, 'bytes') 458 459 # Main metric that we want to monitor for jumps. 460 normalized_apk_size = total_apk_size 461 # unwind_cfi exists only in dev, canary, and non-channel builds. 462 normalized_apk_size -= unwind_cfi.ComputeZippedSize() 463 # Sections within .so files get 4kb aligned, so use section sizes rather than 464 # file size. Also gets rid of compression. 465 normalized_apk_size -= native_code.ComputeZippedSize() 466 normalized_apk_size += native_code_unaligned_size 467 # Normalized dex size: Size within the zip + size on disk for Android Go 468 # devices running Android O (which ~= uncompressed dex size). 469 # Use a constant compression factor to account for fluctuations. 470 normalized_apk_size -= java_code.ComputeZippedSize() 471 normalized_apk_size += java_code.ComputeUncompressedSize() 472 # Unaligned size should be ~= uncompressed size or something is wrong. 473 # As of now, padding_fraction ~= .007 474 padding_fraction = -_PercentageDifference( 475 native_code.ComputeUncompressedSize(), native_code_unaligned_size) 476 # Ignore this check for small / no native code 477 if native_code.ComputeUncompressedSize() > 1000000: 478 assert 0 <= padding_fraction < .02, ( 479 'Padding was: {} (file_size={}, sections_sum={})'.format( 480 padding_fraction, native_code.ComputeUncompressedSize(), 481 native_code_unaligned_size)) 482 483 if apks_path: 484 # Locale normalization not needed when measuring only one locale. 485 # E.g. a change that adds 300 chars of unstranslated strings would cause the 486 # metric to be off by only 390 bytes (assuming a multiplier of 2.3 for 487 # Hindi). 488 pass 489 else: 490 # Avoid noise caused when strings change and translations haven't yet been 491 # updated. 492 num_translations = translations.GetNumEntries() 493 num_stored_translations = stored_translations.GetNumEntries() 494 495 if num_translations > 1: 496 # Multipliers found by looking at MonochromePublic.apk and seeing how much 497 # smaller en-US.pak is relative to the average locale.pak. 498 normalized_apk_size += _NormalizeLanguagePaks(translations, 1.17) 499 if num_stored_translations > 1: 500 normalized_apk_size += _NormalizeLanguagePaks(stored_translations, 1.43) 501 if num_translations + num_stored_translations > 1: 502 if num_translations == 0: 503 # WebView stores all locale paks uncompressed. 504 num_arsc_translations = num_stored_translations 505 else: 506 # Monochrome has more configurations than Chrome since it includes 507 # WebView (which supports more locales), but these should mostly be 508 # empty so ignore them here. 509 num_arsc_translations = num_translations 510 normalized_apk_size += _NormalizeResourcesArsc( 511 apk_filename, arsc.GetNumEntries(), num_arsc_translations, out_dir) 512 513 # It will be -Inf for .apk files with multiple .arsc files and no out_dir set. 514 if normalized_apk_size < 0: 515 sys.stderr.write('Skipping normalized_apk_size (no output directory set)\n') 516 else: 517 report_func('Specifics', 'normalized apk size', normalized_apk_size, 518 'bytes') 519 # The "file count" metric cannot be grouped with any other metrics when the 520 # end result is going to be uploaded to the perf dashboard in the HistogramSet 521 # format due to mixed units (bytes vs. zip entries) causing malformed 522 # summaries to be generated. 523 # TODO(https://crbug.com/903970): Remove this workaround if unit mixing is 524 # ever supported. 525 report_func('FileCount', 'file count', len(apk_contents), 'zip entries') 526 527 for info in unknown.AllEntries(): 528 sys.stderr.write( 529 'Unknown entry: %s %d\n' % (info.filename, info.compress_size)) 530 531 532def _CalculateCompressedSize(file_path): 533 CHUNK_SIZE = 256 * 1024 534 compressor = zlib.compressobj() 535 total_size = 0 536 with open(file_path, 'rb') as f: 537 for chunk in iter(lambda: f.read(CHUNK_SIZE), ''): 538 total_size += len(compressor.compress(chunk)) 539 total_size += len(compressor.flush()) 540 return total_size 541 542 543def _DoDexAnalysis(apk_filename, report_func): 544 sizes, total_size, num_unique_methods = method_count.ExtractSizesFromZip( 545 apk_filename) 546 cumulative_sizes = collections.defaultdict(int) 547 for classes_dex_sizes in sizes.itervalues(): 548 for count_type, count in classes_dex_sizes.iteritems(): 549 cumulative_sizes[count_type] += count 550 for count_type, count in cumulative_sizes.iteritems(): 551 report_func('Dex', count_type, count, 'entries') 552 553 report_func('Dex', 'unique methods', num_unique_methods, 'entries') 554 report_func('DexCache', 'DexCache', total_size, 'bytes') 555 556 557@contextmanager 558def Unzip(zip_file, filename=None): 559 """Utility for temporary use of a single file in a zip archive.""" 560 with build_utils.TempDir() as unzipped_dir: 561 unzipped_files = build_utils.ExtractAll( 562 zip_file, unzipped_dir, True, pattern=filename) 563 if len(unzipped_files) == 0: 564 raise Exception( 565 '%s not found in %s' % (filename, zip_file)) 566 yield unzipped_files[0] 567 568 569def _ConfigOutDirAndToolsPrefix(out_dir): 570 if out_dir: 571 constants.SetOutputDirectory(out_dir) 572 else: 573 try: 574 # Triggers auto-detection when CWD == output directory. 575 constants.CheckOutputDirectory() 576 out_dir = constants.GetOutDirectory() 577 except Exception: # pylint: disable=broad-except 578 return out_dir, '' 579 build_vars = gn_helpers.ReadBuildVars(out_dir) 580 tool_prefix = os.path.join(out_dir, build_vars['android_tool_prefix']) 581 return out_dir, tool_prefix 582 583 584def _AnalyzeInternal(apk_path, report_func, args, apks_path=None): 585 out_dir, tool_prefix = _ConfigOutDirAndToolsPrefix(args.out_dir) 586 _DoApkAnalysis(apk_path, apks_path, tool_prefix, out_dir, report_func) 587 _DoDexAnalysis(apk_path, report_func) 588 589 590def _AnalyzeApkOrApks(report_func, apk_path, args): 591 if apk_path.endswith('.apk'): 592 _AnalyzeInternal(apk_path, report_func, args) 593 elif apk_path.endswith('.apks'): 594 with tempfile.NamedTemporaryFile(suffix='.apk') as f: 595 with zipfile.ZipFile(apk_path) as z: 596 # Currently bundletool is creating two apks when .apks is created 597 # without specifying an sdkVersion. Always measure the one with an 598 # uncompressed shared library. 599 try: 600 info = z.getinfo('splits/base-master_2.apk') 601 except KeyError: 602 info = z.getinfo('splits/base-master.apk') 603 f.write(z.read(info)) 604 f.flush() 605 _AnalyzeInternal(f.name, report_func, args, apks_path=apk_path) 606 else: 607 raise Exception('Unknown file type: ' + apk_path) 608 609 610class _Reporter(object): 611 def __init__(self, chartjson): 612 self._chartjson = chartjson 613 self.trace_title_prefix = '' 614 self._combined_metrics = collections.defaultdict(int) 615 616 def __call__(self, graph_title, trace_title, value, units): 617 self._combined_metrics[(graph_title, trace_title, units)] += value 618 619 perf_tests_results_helper.ReportPerfResult( 620 self._chartjson, graph_title, self.trace_title_prefix + trace_title, 621 value, units) 622 623 def SynthesizeTotals(self): 624 for tup, value in sorted(self._combined_metrics.iteritems()): 625 graph_title, trace_title, units = tup 626 perf_tests_results_helper.ReportPerfResult( 627 self._chartjson, graph_title, 'Combined_' + trace_title, value, units) 628 629 630def _ResourceSizes(args): 631 chartjson = _BASE_CHART.copy() if args.output_format else None 632 reporter = _Reporter(chartjson) 633 634 specs = [ 635 ('Chrome_', args.trichrome_chrome), 636 ('WebView_', args.trichrome_webview), 637 ('Library_', args.trichrome_library), 638 ] 639 for prefix, path in specs: 640 if path: 641 reporter.trace_title_prefix = prefix 642 _AnalyzeApkOrApks(reporter, path, args) 643 644 if any(path for _, path in specs): 645 reporter.SynthesizeTotals() 646 else: 647 _AnalyzeApkOrApks(reporter, args.input, args) 648 649 if chartjson: 650 _DumpChartJson(args, chartjson) 651 652 653def _DumpChartJson(args, chartjson): 654 if args.output_file == '-': 655 json_file = sys.stdout 656 elif args.output_file: 657 json_file = open(args.output_file, 'w') 658 else: 659 results_path = os.path.join(args.output_dir, 'results-chart.json') 660 logging.critical('Dumping chartjson to %s', results_path) 661 json_file = open(results_path, 'w') 662 663 json.dump(chartjson, json_file, indent=2) 664 665 if json_file is not sys.stdout: 666 json_file.close() 667 668 # We would ideally generate a histogram set directly instead of generating 669 # chartjson then converting. However, perf_tests_results_helper is in 670 # //build, which doesn't seem to have any precedent for depending on 671 # anything in Catapult. This can probably be fixed, but since this doesn't 672 # need to be super fast or anything, converting is a good enough solution 673 # for the time being. 674 if args.output_format == 'histograms': 675 histogram_result = convert_chart_json.ConvertChartJson(results_path) 676 if histogram_result.returncode != 0: 677 raise Exception('chartjson conversion failed with error: ' + 678 histogram_result.stdout) 679 680 histogram_path = os.path.join(args.output_dir, 'perf_results.json') 681 logging.critical('Dumping histograms to %s', histogram_path) 682 with open(histogram_path, 'w') as json_file: 683 json_file.write(histogram_result.stdout) 684 685 686def main(): 687 argparser = argparse.ArgumentParser(description='Print APK size metrics.') 688 argparser.add_argument( 689 '--min-pak-resource-size', 690 type=int, 691 default=20 * 1024, 692 help='Minimum byte size of displayed pak resources.') 693 argparser.add_argument( 694 '--chromium-output-directory', 695 dest='out_dir', 696 type=os.path.realpath, 697 help='Location of the build artifacts.') 698 argparser.add_argument( 699 '--chartjson', 700 action='store_true', 701 help='DEPRECATED. Use --output-format=chartjson ' 702 'instead.') 703 argparser.add_argument( 704 '--output-format', 705 choices=['chartjson', 'histograms'], 706 help='Output the results to a file in the given ' 707 'format instead of printing the results.') 708 argparser.add_argument('--loadable_module', help='Obsolete (ignored).') 709 710 # Accepted to conform to the isolated script interface, but ignored. 711 argparser.add_argument( 712 '--isolated-script-test-filter', help=argparse.SUPPRESS) 713 argparser.add_argument( 714 '--isolated-script-test-perf-output', 715 type=os.path.realpath, 716 help=argparse.SUPPRESS) 717 718 output_group = argparser.add_mutually_exclusive_group() 719 720 output_group.add_argument( 721 '--output-dir', default='.', help='Directory to save chartjson to.') 722 output_group.add_argument( 723 '--output-file', 724 help='Path to output .json (replaces --output-dir). Works only for ' 725 '--output-format=chartjson') 726 output_group.add_argument( 727 '--isolated-script-test-output', 728 type=os.path.realpath, 729 help='File to which results will be written in the ' 730 'simplified JSON output format.') 731 732 argparser.add_argument('input', help='Path to .apk or .apks file to measure.') 733 trichrome_group = argparser.add_argument_group( 734 'Trichrome inputs', 735 description='When specified, |input| is used only as Test suite name.') 736 trichrome_group.add_argument( 737 '--trichrome-chrome', help='Path to Trichrome Chrome .apks') 738 trichrome_group.add_argument( 739 '--trichrome-webview', help='Path to Trichrome WebView .apk(s)') 740 trichrome_group.add_argument( 741 '--trichrome-library', help='Path to Trichrome Library .apk') 742 args = argparser.parse_args() 743 744 devil_chromium.Initialize(output_directory=args.out_dir) 745 746 # TODO(bsheedy): Remove this once uses of --chartjson have been removed. 747 if args.chartjson: 748 args.output_format = 'chartjson' 749 750 isolated_script_output = {'valid': False, 'failures': []} 751 752 test_name = 'resource_sizes (%s)' % os.path.basename(args.input) 753 754 if args.isolated_script_test_output: 755 args.output_dir = os.path.join( 756 os.path.dirname(args.isolated_script_test_output), test_name) 757 if not os.path.exists(args.output_dir): 758 os.makedirs(args.output_dir) 759 760 try: 761 _ResourceSizes(args) 762 isolated_script_output = { 763 'valid': True, 764 'failures': [], 765 } 766 finally: 767 if args.isolated_script_test_output: 768 results_path = os.path.join(args.output_dir, 'test_results.json') 769 with open(results_path, 'w') as output_file: 770 json.dump(isolated_script_output, output_file) 771 with open(args.isolated_script_test_output, 'w') as output_file: 772 json.dump(isolated_script_output, output_file) 773 774 775if __name__ == '__main__': 776 main() 777