blob: 6d59f8a7fc85e17d47ffc23dc3fc717238a6331d [file] [log] [blame]
rnephewf10ebab12015-12-17 23:06:591#!/usr/bin/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"""Prints the size of each given file and optionally computes the size of
7 libchrome.so without the dependencies added for building with android NDK.
8 Also breaks down the contents of the APK to determine the installed size
9 and assign size contributions to different classes of file.
10"""
11
12import collections
13import json
rnephew526a5c32016-04-28 21:37:0514import logging
rnephewf10ebab12015-12-17 23:06:5915import operator
16import optparse
17import os
18import re
agrieve83011ed2016-05-12 19:56:4519import struct
rnephewf10ebab12015-12-17 23:06:5920import sys
21import tempfile
22import zipfile
23import zlib
24
jbudorickd28554a2016-01-11 16:22:5925import devil_chromium
rnephewf10ebab12015-12-17 23:06:5926from devil.utils import cmd_helper
agrieve05957d7e2016-04-05 21:06:0627from pylib import constants
jbudorickd28554a2016-01-11 16:22:5928from pylib.constants import host_paths
rnephewf10ebab12015-12-17 23:06:5929
jbudorickd28554a2016-01-11 16:22:5930_GRIT_PATH = os.path.join(host_paths.DIR_SOURCE_ROOT, 'tools', 'grit')
31
32with host_paths.SysPath(_GRIT_PATH):
33 from grit.format import data_pack # pylint: disable=import-error
34
35with host_paths.SysPath(host_paths.BUILD_COMMON_PATH):
36 import perf_tests_results_helper # pylint: disable=import-error
rnephewf10ebab12015-12-17 23:06:5937
agrieve83011ed2016-05-12 19:56:4538# Python had a bug in zipinfo parsing that triggers on ChromeModern.apk
39# https://bugs.python.org/issue14315
40def _PatchedDecodeExtra(self):
41 # Try to decode the extra field.
42 extra = self.extra
43 unpack = struct.unpack
44 while len(extra) >= 4:
45 tp, ln = unpack('<HH', extra[:4])
46 if tp == 1:
47 if ln >= 24:
48 counts = unpack('<QQQ', extra[4:28])
49 elif ln == 16:
50 counts = unpack('<QQ', extra[4:20])
51 elif ln == 8:
52 counts = unpack('<Q', extra[4:12])
53 elif ln == 0:
54 counts = ()
55 else:
56 raise RuntimeError, "Corrupt extra field %s"%(ln,)
57
58 idx = 0
59
60 # ZIP64 extension (large files and/or large archives)
61 if self.file_size in (0xffffffffffffffffL, 0xffffffffL):
62 self.file_size = counts[idx]
63 idx += 1
64
65 if self.compress_size == 0xFFFFFFFFL:
66 self.compress_size = counts[idx]
67 idx += 1
68
69 if self.header_offset == 0xffffffffL:
70 self.header_offset = counts[idx]
71 idx += 1
72
73 extra = extra[ln + 4:]
74
75zipfile.ZipInfo._decodeExtra = ( # pylint: disable=protected-access
76 _PatchedDecodeExtra)
rnephewf10ebab12015-12-17 23:06:5977
78# Static initializers expected in official builds. Note that this list is built
79# using 'nm' on libchrome.so which results from a GCC official build (i.e.
80# Clang is not supported currently).
81
rnephewf10ebab12015-12-17 23:06:5982_BASE_CHART = {
83 'format_version': '0.1',
84 'benchmark_name': 'resource_sizes',
85 'benchmark_description': 'APK resource size information.',
86 'trace_rerun_options': [],
87 'charts': {}
88}
rnephewb5fe0972016-02-10 17:45:1389_DUMP_STATIC_INITIALIZERS_PATH = os.path.join(
90 host_paths.DIR_SOURCE_ROOT, 'tools', 'linux', 'dump-static-initializers.py')
agrieve12ff4bb2016-05-19 15:57:3491# Pragma exists when enable_resource_whitelist_generation=true.
92_RC_HEADER_RE = re.compile(
93 r'^#define (?P<name>\w+) (?:_Pragma\(.*?\) )?(?P<id>\d+)$')
rnephewf10ebab12015-12-17 23:06:5994
95
rnephewb5fe0972016-02-10 17:45:1396def CountStaticInitializers(so_path):
97 def get_elf_section_size(readelf_stdout, section_name):
98 # Matches: .ctors PROGBITS 000000000516add0 5169dd0 000010 00 WA 0 0 8
99 match = re.search(r'\.%s.*$' % re.escape(section_name),
100 readelf_stdout, re.MULTILINE)
101 if not match:
102 return (False, -1)
103 size_str = re.split(r'\W+', match.group(0))[5]
104 return (True, int(size_str, 16))
105
106 # Find the number of files with at least one static initializer.
107 # First determine if we're 32 or 64 bit
108 stdout = cmd_helper.GetCmdOutput(['readelf', '-h', so_path])
109 elf_class_line = re.search('Class:.*$', stdout, re.MULTILINE).group(0)
110 elf_class = re.split(r'\W+', elf_class_line)[1]
111 if elf_class == 'ELF32':
112 word_size = 4
113 else:
114 word_size = 8
115
116 # Then find the number of files with global static initializers.
117 # NOTE: this is very implementation-specific and makes assumptions
118 # about how compiler and linker implement global static initializers.
119 si_count = 0
120 stdout = cmd_helper.GetCmdOutput(['readelf', '-SW', so_path])
121 has_init_array, init_array_size = get_elf_section_size(stdout, 'init_array')
122 if has_init_array:
123 si_count = init_array_size / word_size
124 si_count = max(si_count, 0)
125 return si_count
126
127
rnephewf10ebab12015-12-17 23:06:59128def GetStaticInitializers(so_path):
rnephewb5fe0972016-02-10 17:45:13129 output = cmd_helper.GetCmdOutput([_DUMP_STATIC_INITIALIZERS_PATH, '-d',
130 so_path])
131 return output.splitlines()
rnephewf10ebab12015-12-17 23:06:59132
133
134def ReportPerfResult(chart_data, graph_title, trace_title, value, units,
135 improvement_direction='down', important=True):
136 """Outputs test results in correct format.
137
138 If chart_data is None, it outputs data in old format. If chart_data is a
139 dictionary, formats in chartjson format. If any other format defaults to
140 old format.
141 """
142 if chart_data and isinstance(chart_data, dict):
143 chart_data['charts'].setdefault(graph_title, {})
144 chart_data['charts'][graph_title][trace_title] = {
145 'type': 'scalar',
146 'value': value,
147 'units': units,
148 'improvement_direction': improvement_direction,
149 'important': important
150 }
151 else:
152 perf_tests_results_helper.PrintPerfResult(
153 graph_title, trace_title, [value], units)
154
155
156def PrintResourceSizes(files, chartjson=None):
157 """Prints the sizes of each given file.
158
159 Args:
160 files: List of files to print sizes for.
161 """
162 for f in files:
163 ReportPerfResult(chartjson, 'ResourceSizes', os.path.basename(f) + ' size',
164 os.path.getsize(f), 'bytes')
165
166
167def PrintApkAnalysis(apk_filename, chartjson=None):
168 """Analyse APK to determine size contributions of different file classes."""
169 # Define a named tuple type for file grouping.
170 # name: Human readable name for this file group
171 # regex: Regular expression to match filename
172 # extracted: Function that takes a file name and returns whether the file is
173 # extracted from the apk at install/runtime.
174 FileGroup = collections.namedtuple('FileGroup',
175 ['name', 'regex', 'extracted'])
176
177 # File groups are checked in sequence, so more specific regexes should be
178 # earlier in the list.
179 YES = lambda _: True
180 NO = lambda _: False
181 FILE_GROUPS = (
182 FileGroup('Native code', r'\.so$', lambda f: 'crazy' not in f),
183 FileGroup('Java code', r'\.dex$', YES),
184 FileGroup('Native resources (no l10n)', r'\.pak$', NO),
185 # For locale paks, assume only english paks are extracted.
186 FileGroup('Native resources (l10n)', r'\.lpak$', lambda f: 'en_' in f),
187 FileGroup('ICU (i18n library) data', r'assets/icudtl\.dat$', NO),
188 FileGroup('V8 Snapshots', r'\.bin$', NO),
189 FileGroup('PNG drawables', r'\.png$', NO),
190 FileGroup('Non-compiled Android resources', r'^res/', NO),
191 FileGroup('Compiled Android resources', r'\.arsc$', NO),
192 FileGroup('Package metadata', r'^(META-INF/|AndroidManifest\.xml$)', NO),
193 FileGroup('Unknown files', r'.', NO),
194 )
195
196 apk = zipfile.ZipFile(apk_filename, 'r')
197 try:
198 apk_contents = apk.infolist()
199 finally:
200 apk.close()
201
202 total_apk_size = os.path.getsize(apk_filename)
203 apk_basename = os.path.basename(apk_filename)
204
205 found_files = {}
206 for group in FILE_GROUPS:
207 found_files[group] = []
208
209 for member in apk_contents:
210 for group in FILE_GROUPS:
211 if re.search(group.regex, member.filename):
212 found_files[group].append(member)
213 break
214 else:
215 raise KeyError('No group found for file "%s"' % member.filename)
216
217 total_install_size = total_apk_size
218
219 for group in FILE_GROUPS:
220 apk_size = sum(member.compress_size for member in found_files[group])
221 install_size = apk_size
222 install_bytes = sum(f.file_size for f in found_files[group]
223 if group.extracted(f.filename))
224 install_size += install_bytes
225 total_install_size += install_bytes
226
227 ReportPerfResult(chartjson, apk_basename + '_Breakdown',
228 group.name + ' size', apk_size, 'bytes')
229 ReportPerfResult(chartjson, apk_basename + '_InstallBreakdown',
230 group.name + ' size', install_size, 'bytes')
231
232 transfer_size = _CalculateCompressedSize(apk_filename)
233 ReportPerfResult(chartjson, apk_basename + '_InstallSize',
234 'Estimated installed size', total_install_size, 'bytes')
235 ReportPerfResult(chartjson, apk_basename + '_InstallSize', 'APK size',
236 total_apk_size, 'bytes')
237 ReportPerfResult(chartjson, apk_basename + '_TransferSize',
238 'Transfer size (deflate)', transfer_size, 'bytes')
239
240
241def IsPakFileName(file_name):
242 """Returns whether the given file name ends with .pak or .lpak."""
243 return file_name.endswith('.pak') or file_name.endswith('.lpak')
244
245
agrieve05957d7e2016-04-05 21:06:06246def PrintPakAnalysis(apk_filename, min_pak_resource_size):
rnephewf10ebab12015-12-17 23:06:59247 """Print sizes of all resources in all pak files in |apk_filename|."""
248 print
249 print 'Analyzing pak files in %s...' % apk_filename
250
251 # A structure for holding details about a pak file.
252 Pak = collections.namedtuple(
253 'Pak', ['filename', 'compress_size', 'file_size', 'resources'])
254
255 # Build a list of Pak objets for each pak file.
256 paks = []
257 apk = zipfile.ZipFile(apk_filename, 'r')
258 try:
259 for i in (x for x in apk.infolist() if IsPakFileName(x.filename)):
260 with tempfile.NamedTemporaryFile() as f:
261 f.write(apk.read(i.filename))
262 f.flush()
263 paks.append(Pak(i.filename, i.compress_size, i.file_size,
264 data_pack.DataPack.ReadDataPack(f.name).resources))
265 finally:
266 apk.close()
267
268 # Output the overall pak file summary.
269 total_files = len(paks)
270 total_compress_size = sum(pak.compress_size for pak in paks)
271 total_file_size = sum(pak.file_size for pak in paks)
272 print 'Total pak files: %d' % total_files
273 print 'Total compressed size: %s' % _FormatBytes(total_compress_size)
274 print 'Total uncompressed size: %s' % _FormatBytes(total_file_size)
275 print
276
277 # Output the table of details about all pak files.
278 print '%25s%11s%21s%21s' % (
279 'FILENAME', 'RESOURCES', 'COMPRESSED SIZE', 'UNCOMPRESSED SIZE')
280 for pak in sorted(paks, key=operator.attrgetter('file_size'), reverse=True):
281 print '%25s %10s %12s %6.2f%% %12s %6.2f%%' % (
282 pak.filename,
283 len(pak.resources),
284 _FormatBytes(pak.compress_size),
285 100.0 * pak.compress_size / total_compress_size,
286 _FormatBytes(pak.file_size),
287 100.0 * pak.file_size / total_file_size)
288
289 print
290 print 'Analyzing pak resources in %s...' % apk_filename
291
292 # Calculate aggregate stats about resources across pak files.
293 resource_count_map = collections.defaultdict(int)
294 resource_size_map = collections.defaultdict(int)
295 resource_overhead_bytes = 6
296 for pak in paks:
297 for r in pak.resources:
298 resource_count_map[r] += 1
299 resource_size_map[r] += len(pak.resources[r]) + resource_overhead_bytes
300
301 # Output the overall resource summary.
302 total_resource_size = sum(resource_size_map.values())
303 total_resource_count = len(resource_count_map)
304 assert total_resource_size <= total_file_size
305 print 'Total pak resources: %s' % total_resource_count
306 print 'Total uncompressed resource size: %s' % _FormatBytes(
307 total_resource_size)
308 print
309
agrieve05957d7e2016-04-05 21:06:06310 resource_id_name_map = _GetResourceIdNameMap()
rnephewf10ebab12015-12-17 23:06:59311
312 # Output the table of details about all resources across pak files.
313 print
314 print '%56s %5s %17s' % ('RESOURCE', 'COUNT', 'UNCOMPRESSED SIZE')
315 for i in sorted(resource_size_map, key=resource_size_map.get,
316 reverse=True):
317 if resource_size_map[i] >= min_pak_resource_size:
318 print '%56s %5s %9s %6.2f%%' % (
319 resource_id_name_map.get(i, i),
320 resource_count_map[i],
321 _FormatBytes(resource_size_map[i]),
322 100.0 * resource_size_map[i] / total_resource_size)
323
324
agrieve05957d7e2016-04-05 21:06:06325def _GetResourceIdNameMap():
rnephewf10ebab12015-12-17 23:06:59326 """Returns a map of {resource_id: resource_name}."""
agrieve05957d7e2016-04-05 21:06:06327 out_dir = constants.GetOutDirectory()
rnephewf10ebab12015-12-17 23:06:59328 assert os.path.isdir(out_dir), 'Failed to locate out dir at %s' % out_dir
329 print 'Looking at resources in: %s' % out_dir
330
331 grit_headers = []
332 for root, _, files in os.walk(out_dir):
333 if root.endswith('grit'):
334 grit_headers += [os.path.join(root, f) for f in files if f.endswith('.h')]
335 assert grit_headers, 'Failed to find grit headers in %s' % out_dir
336
337 id_name_map = {}
338 for header in grit_headers:
339 with open(header, 'r') as f:
340 for line in f.readlines():
341 m = _RC_HEADER_RE.match(line.strip())
342 if m:
343 i = int(m.group('id'))
344 name = m.group('name')
345 if i in id_name_map and name != id_name_map[i]:
346 print 'WARNING: Resource ID conflict %s (%s vs %s)' % (
347 i, id_name_map[i], name)
348 id_name_map[i] = name
349 return id_name_map
350
351
352def PrintStaticInitializersCount(so_with_symbols_path, chartjson=None):
353 """Emits the performance result for static initializers found in the provided
354 shared library. Additionally, files for which static initializers were
355 found are printed on the standard output.
356
357 Args:
358 so_with_symbols_path: Path to the unstripped libchrome.so file.
359 """
rnephewb5fe0972016-02-10 17:45:13360 # GetStaticInitializers uses get-static-initializers.py to get a list of all
361 # static initializers. This does not work on all archs (particularly arm).
362 # TODO(rnephew): Get rid of warning when crbug.com/585588 is fixed.
363 si_count = CountStaticInitializers(so_with_symbols_path)
rnephewf10ebab12015-12-17 23:06:59364 static_initializers = GetStaticInitializers(so_with_symbols_path)
rnephewb5fe0972016-02-10 17:45:13365 if si_count != len(static_initializers):
366 print ('There are %d files with static initializers, but '
367 'dump-static-initializers found %d:' %
368 (si_count, len(static_initializers)))
369 else:
370 print 'Found %d files with static initializers:' % si_count
rnephewf10ebab12015-12-17 23:06:59371 print '\n'.join(static_initializers)
372
373 ReportPerfResult(chartjson, 'StaticInitializersCount', 'count',
rnephewb5fe0972016-02-10 17:45:13374 si_count, 'count')
rnephewf10ebab12015-12-17 23:06:59375
376def _FormatBytes(byts):
377 """Pretty-print a number of bytes."""
378 if byts > 2**20.0:
379 byts /= 2**20.0
380 return '%.2fm' % byts
381 if byts > 2**10.0:
382 byts /= 2**10.0
383 return '%.2fk' % byts
384 return str(byts)
385
386
387def _CalculateCompressedSize(file_path):
388 CHUNK_SIZE = 256 * 1024
389 compressor = zlib.compressobj()
390 total_size = 0
391 with open(file_path, 'rb') as f:
392 for chunk in iter(lambda: f.read(CHUNK_SIZE), ''):
393 total_size += len(compressor.compress(chunk))
394 total_size += len(compressor.flush())
395 return total_size
396
397
398def main(argv):
399 usage = """Usage: %prog [options] file1 file2 ...
400
401Pass any number of files to graph their sizes. Any files with the extension
402'.apk' will be broken down into their components on a separate graph."""
403 option_parser = optparse.OptionParser(usage=usage)
404 option_parser.add_option('--so-path', help='Path to libchrome.so.')
405 option_parser.add_option('--so-with-symbols-path',
406 help='Path to libchrome.so with symbols.')
407 option_parser.add_option('--min-pak-resource-size', type='int',
408 default=20*1024,
409 help='Minimum byte size of displayed pak resources.')
410 option_parser.add_option('--build_type', dest='build_type', default='Debug',
411 help='Sets the build type, default is Debug.')
agrieve05957d7e2016-04-05 21:06:06412 option_parser.add_option('--chromium-output-directory',
413 help='Location of the build artifacts. '
414 'Takes precidence over --build_type.')
rnephewf10ebab12015-12-17 23:06:59415 option_parser.add_option('--chartjson', action="store_true",
416 help='Sets output mode to chartjson.')
417 option_parser.add_option('--output-dir', default='.',
418 help='Directory to save chartjson to.')
419 option_parser.add_option('-d', '--device',
420 help='Dummy option for perf runner.')
421 options, args = option_parser.parse_args(argv)
422 files = args[1:]
423 chartjson = _BASE_CHART.copy() if options.chartjson else None
424
agrieve05957d7e2016-04-05 21:06:06425 constants.SetBuildType(options.build_type)
426 if options.chromium_output_directory:
427 constants.SetOutputDirectory(options.chromium_output_directory)
428 constants.CheckOutputDirectory()
429
rnephewf10ebab12015-12-17 23:06:59430 # For backward compatibilty with buildbot scripts, treat --so-path as just
431 # another file to print the size of. We don't need it for anything special any
432 # more.
433 if options.so_path:
434 files.append(options.so_path)
435
436 if not files:
437 option_parser.error('Must specify a file')
438
jbudorickd28554a2016-01-11 16:22:59439 devil_chromium.Initialize()
440
rnephewf10ebab12015-12-17 23:06:59441 if options.so_with_symbols_path:
442 PrintStaticInitializersCount(
443 options.so_with_symbols_path, chartjson=chartjson)
444
445 PrintResourceSizes(files, chartjson=chartjson)
446
447 for f in files:
448 if f.endswith('.apk'):
449 PrintApkAnalysis(f, chartjson=chartjson)
agrieve05957d7e2016-04-05 21:06:06450 PrintPakAnalysis(f, options.min_pak_resource_size)
rnephewf10ebab12015-12-17 23:06:59451
452 if chartjson:
perezju803fe78f2016-03-08 13:10:51453 results_path = os.path.join(options.output_dir, 'results-chart.json')
rnephew526a5c32016-04-28 21:37:05454 logging.critical('Dumping json to %s', results_path)
rnephewf10ebab12015-12-17 23:06:59455 with open(results_path, 'w') as json_file:
456 json.dump(chartjson, json_file)
457
458
459if __name__ == '__main__':
460 sys.exit(main(sys.argv))