blob: 1995d1eb630a10603c324f733b0cb5331a4a0f8b [file] [log] [blame]
Ben Murdoch097c5b22016-05-18 11:27:45 +01001#!/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
14import logging
15import operator
16import optparse
17import os
18import re
19import struct
20import sys
21import tempfile
22import zipfile
23import zlib
24
25import devil_chromium
26from devil.utils import cmd_helper
27from pylib import constants
28from pylib.constants import host_paths
29
30_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
37
38# 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)
77
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
82_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}
89_DUMP_STATIC_INITIALIZERS_PATH = os.path.join(
90 host_paths.DIR_SOURCE_ROOT, 'tools', 'linux', 'dump-static-initializers.py')
91_RC_HEADER_RE = re.compile(r'^#define (?P<name>\w+) (?P<id>\d+)$')
92
93
94def CountStaticInitializers(so_path):
95 def get_elf_section_size(readelf_stdout, section_name):
96 # Matches: .ctors PROGBITS 000000000516add0 5169dd0 000010 00 WA 0 0 8
97 match = re.search(r'\.%s.*$' % re.escape(section_name),
98 readelf_stdout, re.MULTILINE)
99 if not match:
100 return (False, -1)
101 size_str = re.split(r'\W+', match.group(0))[5]
102 return (True, int(size_str, 16))
103
104 # Find the number of files with at least one static initializer.
105 # First determine if we're 32 or 64 bit
106 stdout = cmd_helper.GetCmdOutput(['readelf', '-h', so_path])
107 elf_class_line = re.search('Class:.*$', stdout, re.MULTILINE).group(0)
108 elf_class = re.split(r'\W+', elf_class_line)[1]
109 if elf_class == 'ELF32':
110 word_size = 4
111 else:
112 word_size = 8
113
114 # Then find the number of files with global static initializers.
115 # NOTE: this is very implementation-specific and makes assumptions
116 # about how compiler and linker implement global static initializers.
117 si_count = 0
118 stdout = cmd_helper.GetCmdOutput(['readelf', '-SW', so_path])
119 has_init_array, init_array_size = get_elf_section_size(stdout, 'init_array')
120 if has_init_array:
121 si_count = init_array_size / word_size
122 si_count = max(si_count, 0)
123 return si_count
124
125
126def GetStaticInitializers(so_path):
127 output = cmd_helper.GetCmdOutput([_DUMP_STATIC_INITIALIZERS_PATH, '-d',
128 so_path])
129 return output.splitlines()
130
131
132def ReportPerfResult(chart_data, graph_title, trace_title, value, units,
133 improvement_direction='down', important=True):
134 """Outputs test results in correct format.
135
136 If chart_data is None, it outputs data in old format. If chart_data is a
137 dictionary, formats in chartjson format. If any other format defaults to
138 old format.
139 """
140 if chart_data and isinstance(chart_data, dict):
141 chart_data['charts'].setdefault(graph_title, {})
142 chart_data['charts'][graph_title][trace_title] = {
143 'type': 'scalar',
144 'value': value,
145 'units': units,
146 'improvement_direction': improvement_direction,
147 'important': important
148 }
149 else:
150 perf_tests_results_helper.PrintPerfResult(
151 graph_title, trace_title, [value], units)
152
153
154def PrintResourceSizes(files, chartjson=None):
155 """Prints the sizes of each given file.
156
157 Args:
158 files: List of files to print sizes for.
159 """
160 for f in files:
161 ReportPerfResult(chartjson, 'ResourceSizes', os.path.basename(f) + ' size',
162 os.path.getsize(f), 'bytes')
163
164
165def PrintApkAnalysis(apk_filename, chartjson=None):
166 """Analyse APK to determine size contributions of different file classes."""
167 # Define a named tuple type for file grouping.
168 # name: Human readable name for this file group
169 # regex: Regular expression to match filename
170 # extracted: Function that takes a file name and returns whether the file is
171 # extracted from the apk at install/runtime.
172 FileGroup = collections.namedtuple('FileGroup',
173 ['name', 'regex', 'extracted'])
174
175 # File groups are checked in sequence, so more specific regexes should be
176 # earlier in the list.
177 YES = lambda _: True
178 NO = lambda _: False
179 FILE_GROUPS = (
180 FileGroup('Native code', r'\.so$', lambda f: 'crazy' not in f),
181 FileGroup('Java code', r'\.dex$', YES),
182 FileGroup('Native resources (no l10n)', r'\.pak$', NO),
183 # For locale paks, assume only english paks are extracted.
184 FileGroup('Native resources (l10n)', r'\.lpak$', lambda f: 'en_' in f),
185 FileGroup('ICU (i18n library) data', r'assets/icudtl\.dat$', NO),
186 FileGroup('V8 Snapshots', r'\.bin$', NO),
187 FileGroup('PNG drawables', r'\.png$', NO),
188 FileGroup('Non-compiled Android resources', r'^res/', NO),
189 FileGroup('Compiled Android resources', r'\.arsc$', NO),
190 FileGroup('Package metadata', r'^(META-INF/|AndroidManifest\.xml$)', NO),
191 FileGroup('Unknown files', r'.', NO),
192 )
193
194 apk = zipfile.ZipFile(apk_filename, 'r')
195 try:
196 apk_contents = apk.infolist()
197 finally:
198 apk.close()
199
200 total_apk_size = os.path.getsize(apk_filename)
201 apk_basename = os.path.basename(apk_filename)
202
203 found_files = {}
204 for group in FILE_GROUPS:
205 found_files[group] = []
206
207 for member in apk_contents:
208 for group in FILE_GROUPS:
209 if re.search(group.regex, member.filename):
210 found_files[group].append(member)
211 break
212 else:
213 raise KeyError('No group found for file "%s"' % member.filename)
214
215 total_install_size = total_apk_size
216
217 for group in FILE_GROUPS:
218 apk_size = sum(member.compress_size for member in found_files[group])
219 install_size = apk_size
220 install_bytes = sum(f.file_size for f in found_files[group]
221 if group.extracted(f.filename))
222 install_size += install_bytes
223 total_install_size += install_bytes
224
225 ReportPerfResult(chartjson, apk_basename + '_Breakdown',
226 group.name + ' size', apk_size, 'bytes')
227 ReportPerfResult(chartjson, apk_basename + '_InstallBreakdown',
228 group.name + ' size', install_size, 'bytes')
229
230 transfer_size = _CalculateCompressedSize(apk_filename)
231 ReportPerfResult(chartjson, apk_basename + '_InstallSize',
232 'Estimated installed size', total_install_size, 'bytes')
233 ReportPerfResult(chartjson, apk_basename + '_InstallSize', 'APK size',
234 total_apk_size, 'bytes')
235 ReportPerfResult(chartjson, apk_basename + '_TransferSize',
236 'Transfer size (deflate)', transfer_size, 'bytes')
237
238
239def IsPakFileName(file_name):
240 """Returns whether the given file name ends with .pak or .lpak."""
241 return file_name.endswith('.pak') or file_name.endswith('.lpak')
242
243
244def PrintPakAnalysis(apk_filename, min_pak_resource_size):
245 """Print sizes of all resources in all pak files in |apk_filename|."""
246 print
247 print 'Analyzing pak files in %s...' % apk_filename
248
249 # A structure for holding details about a pak file.
250 Pak = collections.namedtuple(
251 'Pak', ['filename', 'compress_size', 'file_size', 'resources'])
252
253 # Build a list of Pak objets for each pak file.
254 paks = []
255 apk = zipfile.ZipFile(apk_filename, 'r')
256 try:
257 for i in (x for x in apk.infolist() if IsPakFileName(x.filename)):
258 with tempfile.NamedTemporaryFile() as f:
259 f.write(apk.read(i.filename))
260 f.flush()
261 paks.append(Pak(i.filename, i.compress_size, i.file_size,
262 data_pack.DataPack.ReadDataPack(f.name).resources))
263 finally:
264 apk.close()
265
266 # Output the overall pak file summary.
267 total_files = len(paks)
268 total_compress_size = sum(pak.compress_size for pak in paks)
269 total_file_size = sum(pak.file_size for pak in paks)
270 print 'Total pak files: %d' % total_files
271 print 'Total compressed size: %s' % _FormatBytes(total_compress_size)
272 print 'Total uncompressed size: %s' % _FormatBytes(total_file_size)
273 print
274
275 # Output the table of details about all pak files.
276 print '%25s%11s%21s%21s' % (
277 'FILENAME', 'RESOURCES', 'COMPRESSED SIZE', 'UNCOMPRESSED SIZE')
278 for pak in sorted(paks, key=operator.attrgetter('file_size'), reverse=True):
279 print '%25s %10s %12s %6.2f%% %12s %6.2f%%' % (
280 pak.filename,
281 len(pak.resources),
282 _FormatBytes(pak.compress_size),
283 100.0 * pak.compress_size / total_compress_size,
284 _FormatBytes(pak.file_size),
285 100.0 * pak.file_size / total_file_size)
286
287 print
288 print 'Analyzing pak resources in %s...' % apk_filename
289
290 # Calculate aggregate stats about resources across pak files.
291 resource_count_map = collections.defaultdict(int)
292 resource_size_map = collections.defaultdict(int)
293 resource_overhead_bytes = 6
294 for pak in paks:
295 for r in pak.resources:
296 resource_count_map[r] += 1
297 resource_size_map[r] += len(pak.resources[r]) + resource_overhead_bytes
298
299 # Output the overall resource summary.
300 total_resource_size = sum(resource_size_map.values())
301 total_resource_count = len(resource_count_map)
302 assert total_resource_size <= total_file_size
303 print 'Total pak resources: %s' % total_resource_count
304 print 'Total uncompressed resource size: %s' % _FormatBytes(
305 total_resource_size)
306 print
307
308 resource_id_name_map = _GetResourceIdNameMap()
309
310 # Output the table of details about all resources across pak files.
311 print
312 print '%56s %5s %17s' % ('RESOURCE', 'COUNT', 'UNCOMPRESSED SIZE')
313 for i in sorted(resource_size_map, key=resource_size_map.get,
314 reverse=True):
315 if resource_size_map[i] >= min_pak_resource_size:
316 print '%56s %5s %9s %6.2f%%' % (
317 resource_id_name_map.get(i, i),
318 resource_count_map[i],
319 _FormatBytes(resource_size_map[i]),
320 100.0 * resource_size_map[i] / total_resource_size)
321
322
323def _GetResourceIdNameMap():
324 """Returns a map of {resource_id: resource_name}."""
325 out_dir = constants.GetOutDirectory()
326 assert os.path.isdir(out_dir), 'Failed to locate out dir at %s' % out_dir
327 print 'Looking at resources in: %s' % out_dir
328
329 grit_headers = []
330 for root, _, files in os.walk(out_dir):
331 if root.endswith('grit'):
332 grit_headers += [os.path.join(root, f) for f in files if f.endswith('.h')]
333 assert grit_headers, 'Failed to find grit headers in %s' % out_dir
334
335 id_name_map = {}
336 for header in grit_headers:
337 with open(header, 'r') as f:
338 for line in f.readlines():
339 m = _RC_HEADER_RE.match(line.strip())
340 if m:
341 i = int(m.group('id'))
342 name = m.group('name')
343 if i in id_name_map and name != id_name_map[i]:
344 print 'WARNING: Resource ID conflict %s (%s vs %s)' % (
345 i, id_name_map[i], name)
346 id_name_map[i] = name
347 return id_name_map
348
349
350def PrintStaticInitializersCount(so_with_symbols_path, chartjson=None):
351 """Emits the performance result for static initializers found in the provided
352 shared library. Additionally, files for which static initializers were
353 found are printed on the standard output.
354
355 Args:
356 so_with_symbols_path: Path to the unstripped libchrome.so file.
357 """
358 # GetStaticInitializers uses get-static-initializers.py to get a list of all
359 # static initializers. This does not work on all archs (particularly arm).
360 # TODO(rnephew): Get rid of warning when crbug.com/585588 is fixed.
361 si_count = CountStaticInitializers(so_with_symbols_path)
362 static_initializers = GetStaticInitializers(so_with_symbols_path)
363 if si_count != len(static_initializers):
364 print ('There are %d files with static initializers, but '
365 'dump-static-initializers found %d:' %
366 (si_count, len(static_initializers)))
367 else:
368 print 'Found %d files with static initializers:' % si_count
369 print '\n'.join(static_initializers)
370
371 ReportPerfResult(chartjson, 'StaticInitializersCount', 'count',
372 si_count, 'count')
373
374def _FormatBytes(byts):
375 """Pretty-print a number of bytes."""
376 if byts > 2**20.0:
377 byts /= 2**20.0
378 return '%.2fm' % byts
379 if byts > 2**10.0:
380 byts /= 2**10.0
381 return '%.2fk' % byts
382 return str(byts)
383
384
385def _CalculateCompressedSize(file_path):
386 CHUNK_SIZE = 256 * 1024
387 compressor = zlib.compressobj()
388 total_size = 0
389 with open(file_path, 'rb') as f:
390 for chunk in iter(lambda: f.read(CHUNK_SIZE), ''):
391 total_size += len(compressor.compress(chunk))
392 total_size += len(compressor.flush())
393 return total_size
394
395
396def main(argv):
397 usage = """Usage: %prog [options] file1 file2 ...
398
399Pass any number of files to graph their sizes. Any files with the extension
400'.apk' will be broken down into their components on a separate graph."""
401 option_parser = optparse.OptionParser(usage=usage)
402 option_parser.add_option('--so-path', help='Path to libchrome.so.')
403 option_parser.add_option('--so-with-symbols-path',
404 help='Path to libchrome.so with symbols.')
405 option_parser.add_option('--min-pak-resource-size', type='int',
406 default=20*1024,
407 help='Minimum byte size of displayed pak resources.')
408 option_parser.add_option('--build_type', dest='build_type', default='Debug',
409 help='Sets the build type, default is Debug.')
410 option_parser.add_option('--chromium-output-directory',
411 help='Location of the build artifacts. '
412 'Takes precidence over --build_type.')
413 option_parser.add_option('--chartjson', action="store_true",
414 help='Sets output mode to chartjson.')
415 option_parser.add_option('--output-dir', default='.',
416 help='Directory to save chartjson to.')
417 option_parser.add_option('-d', '--device',
418 help='Dummy option for perf runner.')
419 options, args = option_parser.parse_args(argv)
420 files = args[1:]
421 chartjson = _BASE_CHART.copy() if options.chartjson else None
422
423 constants.SetBuildType(options.build_type)
424 if options.chromium_output_directory:
425 constants.SetOutputDirectory(options.chromium_output_directory)
426 constants.CheckOutputDirectory()
427
428 # For backward compatibilty with buildbot scripts, treat --so-path as just
429 # another file to print the size of. We don't need it for anything special any
430 # more.
431 if options.so_path:
432 files.append(options.so_path)
433
434 if not files:
435 option_parser.error('Must specify a file')
436
437 devil_chromium.Initialize()
438
439 if options.so_with_symbols_path:
440 PrintStaticInitializersCount(
441 options.so_with_symbols_path, chartjson=chartjson)
442
443 PrintResourceSizes(files, chartjson=chartjson)
444
445 for f in files:
446 if f.endswith('.apk'):
447 PrintApkAnalysis(f, chartjson=chartjson)
448 PrintPakAnalysis(f, options.min_pak_resource_size)
449
450 if chartjson:
451 results_path = os.path.join(options.output_dir, 'results-chart.json')
452 logging.critical('Dumping json to %s', results_path)
453 with open(results_path, 'w') as json_file:
454 json.dump(chartjson, json_file)
455
456
457if __name__ == '__main__':
458 sys.exit(main(sys.argv))