lacros_resource_sizes.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. #!/usr/bin/env python3
  2. # Copyright 2020 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. """Reports binary size metrics for LaCrOS build artifacts.
  6. More information at //docs/speed/binary_size/metrics.md.
  7. """
  8. import argparse
  9. import collections
  10. import contextlib
  11. import json
  12. import logging
  13. import os
  14. import subprocess
  15. import sys
  16. import tempfile
  17. SRC_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
  18. sys.path.insert(0, os.path.join(SRC_DIR, 'build', 'util'))
  19. from lib.results import result_sink
  20. from lib.results import result_types
  21. @contextlib.contextmanager
  22. def _SysPath(path):
  23. """Library import context that temporarily appends |path| to |sys.path|."""
  24. if path and path not in sys.path:
  25. sys.path.insert(0, path)
  26. else:
  27. path = None # Indicates that |sys.path| is not modified.
  28. try:
  29. yield
  30. finally:
  31. if path:
  32. sys.path.pop(0)
  33. DIR_SOURCE_ROOT = os.environ.get(
  34. 'CHECKOUT_SOURCE_ROOT',
  35. os.path.abspath(
  36. os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)))
  37. BUILD_COMMON_PATH = os.path.join(DIR_SOURCE_ROOT, 'build', 'util', 'lib',
  38. 'common')
  39. TRACING_PATH = os.path.join(DIR_SOURCE_ROOT, 'third_party', 'catapult',
  40. 'tracing')
  41. EU_STRIP_PATH = os.path.join(DIR_SOURCE_ROOT, 'buildtools', 'third_party',
  42. 'eu-strip', 'bin', 'eu-strip')
  43. with _SysPath(BUILD_COMMON_PATH):
  44. import perf_tests_results_helper # pylint: disable=import-error
  45. with _SysPath(TRACING_PATH):
  46. from tracing.value import convert_chart_json # pylint: disable=import-error
  47. _BASE_CHART = {
  48. 'format_version': '0.1',
  49. 'benchmark_name': 'resource_sizes',
  50. 'trace_rerun_options': [],
  51. 'charts': {}
  52. }
  53. _KEY_RAW = 'raw'
  54. _KEY_GZIPPED = 'gzipped'
  55. _KEY_STRIPPED = 'stripped'
  56. _KEY_STRIPPED_GZIPPED = 'stripped_then_gzipped'
  57. class _Group:
  58. """A group of build artifacts whose file sizes are summed and tracked.
  59. Build artifacts for size tracking fall under these categories:
  60. * File: A single file.
  61. * Group: A collection of files.
  62. * Dir: All files under a directory.
  63. Attributes:
  64. paths: A list of files or directories to be tracked together.
  65. title: The display name of the group.
  66. track_stripped: Whether to also track summed stripped ELF sizes.
  67. track_compressed: Whether to also track summed compressed sizes.
  68. """
  69. def __init__(self, paths, title, track_stripped=False,
  70. track_compressed=False):
  71. self.paths = paths
  72. self.title = title
  73. self.track_stripped = track_stripped
  74. self.track_compressed = track_compressed
  75. # Common artifacts in official builder lacros-arm32 and lacros64 in
  76. # src-internal. The artifcts can be found in
  77. # chromium/src-internal/testing/buildbot/archive/lacros64.json and
  78. # chromium/src-internal/testing/buildbot/archive/lacros-arm32.json
  79. _TRACKED_GROUPS = [
  80. _Group(paths=['chrome'],
  81. title='File: chrome',
  82. track_stripped=True,
  83. track_compressed=True),
  84. _Group(paths=['chrome_crashpad_handler'],
  85. title='File: chrome_crashpad_handler'),
  86. _Group(paths=['icudtl.dat'], title='File: icudtl.dat'),
  87. _Group(paths=['icudtl.dat.hash'], title='File: icudtl.dat.hash'),
  88. _Group(paths=['libEGL.so'], title='File: libEGL.so'),
  89. _Group(paths=['libGLESv2.so'], title='File: libGLESv2.so'),
  90. _Group(paths=['nacl_helper'], title='File: nacl_helper'),
  91. _Group(paths=['resources.pak'], title='File: resources.pak'),
  92. _Group(paths=[
  93. 'chrome_100_percent.pak', 'chrome_200_percent.pak',
  94. 'headless_lib_data.pak', 'headless_lib_strings.pak'
  95. ],
  96. title='Group: Other PAKs'),
  97. _Group(paths=['snapshot_blob.bin'], title='Group: Misc'),
  98. _Group(paths=['locales/'], title='Dir: locales'),
  99. _Group(paths=['WidevineCdm/'], title='Dir: WidevineCdm'),
  100. ]
  101. def _visit_paths(base_dir, paths):
  102. """Itemizes files specified by a list of paths.
  103. Args:
  104. base_dir: Base directory for all elements in |paths|.
  105. paths: A list of filenames or directory names to specify files whose sizes
  106. to be counted. Directories are recursed. There's no de-duping effort.
  107. Non-existing files or directories are ignored (with warning message).
  108. """
  109. for path in paths:
  110. full_path = os.path.join(base_dir, path)
  111. if os.path.exists(full_path):
  112. if os.path.isdir(full_path):
  113. for dirpath, _, filenames in os.walk(full_path):
  114. for filename in filenames:
  115. yield os.path.join(dirpath, filename)
  116. else: # Assume is file.
  117. yield full_path
  118. else:
  119. logging.critical('Not found: %s', path)
  120. def _is_probably_elf(filename):
  121. """Heuristically decides whether |filename| is ELF via magic signature."""
  122. with open(filename, 'rb') as fh:
  123. return fh.read(4) == '\x7FELF'
  124. def _is_unstrippable_elf(filename):
  125. """Identifies known-unstrippable ELF files to denoise the system."""
  126. return filename.endswith('.nexe') or filename.endswith('libwidevinecdm.so')
  127. def _get_filesize(filename):
  128. """Returns the size of a file, or 0 if file is not found."""
  129. try:
  130. return os.path.getsize(filename)
  131. except OSError:
  132. logging.critical('Failed to get size: %s', filename)
  133. return 0
  134. def _get_gzipped_filesize(filename):
  135. """Returns the gzipped size of a file, or 0 if file is not found."""
  136. BUFFER_SIZE = 65536
  137. if not os.path.isfile(filename):
  138. return 0
  139. try:
  140. # Call gzip externally instead of using gzip package since it's > 2x faster.
  141. cmd = ['gzip', '-c', filename]
  142. p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
  143. # Manually counting bytes instead of using len(p.communicate()[0]) to avoid
  144. # buffering the entire compressed data (can be ~100 MB).
  145. ret = 0
  146. while True:
  147. chunk = len(p.stdout.read(BUFFER_SIZE))
  148. if chunk == 0:
  149. break
  150. ret += chunk
  151. return ret
  152. except OSError:
  153. logging.critical('Failed to get gzipped size: %s', filename)
  154. return 0
  155. def _get_catagorized_filesizes(filename):
  156. """Measures |filename| sizes under various transforms.
  157. Returns: A Counter (keyed by _Key_* constants) that stores measured sizes.
  158. """
  159. sizes = collections.Counter()
  160. sizes[_KEY_RAW] = _get_filesize(filename)
  161. sizes[_KEY_GZIPPED] = _get_gzipped_filesize(filename)
  162. # Pre-assign values for non-ELF, or in case of failure for ELF.
  163. sizes[_KEY_STRIPPED] = sizes[_KEY_RAW]
  164. sizes[_KEY_STRIPPED_GZIPPED] = sizes[_KEY_GZIPPED]
  165. if _is_probably_elf(filename) and not _is_unstrippable_elf(filename):
  166. try:
  167. fd, temp_file = tempfile.mkstemp()
  168. os.close(fd)
  169. cmd = [EU_STRIP_PATH, filename, '-o', temp_file]
  170. subprocess.check_output(cmd)
  171. sizes[_KEY_STRIPPED] = _get_filesize(temp_file)
  172. sizes[_KEY_STRIPPED_GZIPPED] = _get_gzipped_filesize(temp_file)
  173. if sizes[_KEY_STRIPPED] > sizes[_KEY_RAW]:
  174. # This weird case has been observed for libwidevinecdm.so.
  175. logging.critical('Stripping made things worse for %s' % filename)
  176. except subprocess.CalledProcessError:
  177. logging.critical('Failed to strip file: %s' % filename)
  178. finally:
  179. os.unlink(temp_file)
  180. return sizes
  181. def _dump_chart_json(output_dir, chartjson):
  182. """Writes chart histogram to JSON files.
  183. Output files:
  184. results-chart.json contains the chart JSON.
  185. perf_results.json contains histogram JSON for Catapult.
  186. Args:
  187. output_dir: Directory to place the JSON files.
  188. chartjson: Source JSON data for output files.
  189. """
  190. results_path = os.path.join(output_dir, 'results-chart.json')
  191. logging.critical('Dumping chartjson to %s', results_path)
  192. with open(results_path, 'w') as json_file:
  193. json.dump(chartjson, json_file, indent=2)
  194. # We would ideally generate a histogram set directly instead of generating
  195. # chartjson then converting. However, perf_tests_results_helper is in
  196. # //build, which doesn't seem to have any precedent for depending on
  197. # anything in Catapult. This can probably be fixed, but since this doesn't
  198. # need to be super fast or anything, converting is a good enough solution
  199. # for the time being.
  200. histogram_result = convert_chart_json.ConvertChartJson(results_path)
  201. if histogram_result.returncode != 0:
  202. raise Exception('chartjson conversion failed with error: ' +
  203. histogram_result.stdout)
  204. histogram_path = os.path.join(output_dir, 'perf_results.json')
  205. logging.critical('Dumping histograms to %s', histogram_path)
  206. with open(histogram_path, 'wb') as json_file:
  207. json_file.write(histogram_result.stdout)
  208. def _run_resource_sizes(args):
  209. """Main flow to extract and output size data."""
  210. chartjson = _BASE_CHART.copy()
  211. chartjson.update({
  212. 'benchmark_description':
  213. ('LaCrOS %s resource size information.' % args.arch)
  214. })
  215. report_func = perf_tests_results_helper.ReportPerfResult
  216. total_sizes = collections.Counter()
  217. def report_sizes(sizes, title, track_stripped, track_compressed):
  218. report_func(chart_data=chartjson,
  219. graph_title=title,
  220. trace_title='size',
  221. value=sizes[_KEY_RAW],
  222. units='bytes')
  223. if track_stripped:
  224. report_func(chart_data=chartjson,
  225. graph_title=title + ' (Stripped)',
  226. trace_title='size',
  227. value=sizes[_KEY_STRIPPED],
  228. units='bytes')
  229. if track_compressed:
  230. report_func(chart_data=chartjson,
  231. graph_title=title + ' (Gzipped)',
  232. trace_title='size',
  233. value=sizes[_KEY_GZIPPED],
  234. units='bytes')
  235. if track_stripped and track_compressed:
  236. report_func(chart_data=chartjson,
  237. graph_title=title + ' (Stripped, Gzipped)',
  238. trace_title='size',
  239. value=sizes[_KEY_STRIPPED_GZIPPED],
  240. units='bytes')
  241. tracked_groups = _TRACKED_GROUPS.copy()
  242. # Architecture amd64 requires artifact nacl_irt_x86_64.nexe.
  243. if args.arch == 'amd64':
  244. tracked_groups.append(
  245. _Group(paths=['nacl_irt_x86_64.nexe'],
  246. title='File: nacl_irt_x86_64.nexe'))
  247. # Architecture arm32 requires artifact nacl_irt_arm.nexe.
  248. elif args.arch == 'arm32':
  249. tracked_groups.append(
  250. _Group(paths=['nacl_irt_arm.nexe'], title='File: nacl_irt_arm.nexe'))
  251. tracked_groups.append(
  252. _Group(paths=['nacl_helper_bootstrap'],
  253. title='File: nacl_helper_bootstrap'))
  254. for g in tracked_groups:
  255. sizes = sum(
  256. map(_get_catagorized_filesizes, _visit_paths(args.out_dir, g.paths)),
  257. collections.Counter())
  258. report_sizes(sizes, g.title, g.track_stripped, g.track_compressed)
  259. # Total compressed size is summed over individual compressed sizes, instead
  260. # of concatanating first, then compress everything. This is done for
  261. # simplicity. It also gives a conservative size estimate (assuming file
  262. # metadata and overheads are negligible).
  263. total_sizes += sizes
  264. report_sizes(total_sizes, 'Total', True, True)
  265. _dump_chart_json(args.output_dir, chartjson)
  266. def main():
  267. """Parses arguments and runs high level flows."""
  268. argparser = argparse.ArgumentParser(description='Writes LaCrOS size metrics.')
  269. argparser.add_argument('--chromium-output-directory',
  270. dest='out_dir',
  271. required=True,
  272. type=os.path.realpath,
  273. help='Location of the build artifacts.')
  274. argparser.add_argument('--arch',
  275. required=True,
  276. type=str,
  277. help='The architecture of lacros.')
  278. output_group = argparser.add_mutually_exclusive_group()
  279. output_group.add_argument('--output-dir',
  280. default='.',
  281. help='Directory to save chartjson to.')
  282. # Accepted to conform to the isolated script interface, but ignored.
  283. argparser.add_argument('--isolated-script-test-filter',
  284. help=argparse.SUPPRESS)
  285. argparser.add_argument('--isolated-script-test-perf-output',
  286. type=os.path.realpath,
  287. help=argparse.SUPPRESS)
  288. output_group.add_argument(
  289. '--isolated-script-test-output',
  290. type=os.path.realpath,
  291. help='File to which results will be written in the simplified JSON '
  292. 'output format.')
  293. args = argparser.parse_args()
  294. isolated_script_output = {'valid': False, 'failures': []}
  295. if args.isolated_script_test_output:
  296. test_name = 'lacros_resource_sizes'
  297. args.output_dir = os.path.join(
  298. os.path.dirname(args.isolated_script_test_output), test_name)
  299. if not os.path.exists(args.output_dir):
  300. os.makedirs(args.output_dir)
  301. try:
  302. _run_resource_sizes(args)
  303. isolated_script_output = {'valid': True, 'failures': []}
  304. finally:
  305. if args.isolated_script_test_output:
  306. results_path = os.path.join(args.output_dir, 'test_results.json')
  307. with open(results_path, 'w') as output_file:
  308. json.dump(isolated_script_output, output_file)
  309. with open(args.isolated_script_test_output, 'w') as output_file:
  310. json.dump(isolated_script_output, output_file)
  311. result_sink_client = result_sink.TryInitClient()
  312. if result_sink_client:
  313. status = result_types.PASS
  314. if not isolated_script_output['valid']:
  315. status = result_types.UNKNOWN
  316. elif isolated_script_output['failures']:
  317. status = result_types.FAIL
  318. result_sink_client.Post(test_name, status, None, None, None)
  319. if __name__ == '__main__':
  320. main()