• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env vpython3
2#
3# Copyright 2020 The Chromium Authors
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6'''Implements Chrome-Fuchsia package binary size checks.'''
7
8import argparse
9import collections
10import json
11import math
12import os
13import re
14import shutil
15import subprocess
16import sys
17import tempfile
18import time
19import traceback
20import uuid
21
22sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__),
23                                             'test')))
24
25from common import DIR_SRC_ROOT, SDK_ROOT, get_host_tool_path
26
27PACKAGES_BLOBS_FILE = 'package_blobs.json'
28PACKAGES_SIZES_FILE = 'package_sizes.json'
29
30# Structure representing the compressed and uncompressed sizes for a Fuchsia
31# package.
32PackageSizes = collections.namedtuple('PackageSizes',
33                                      ['compressed', 'uncompressed'])
34
35# Structure representing a Fuchsia package blob and its compressed and
36# uncompressed sizes.
37Blob = collections.namedtuple(
38    'Blob', ['name', 'hash', 'compressed', 'uncompressed', 'is_counted'])
39
40
41def CreateSizesExternalDiagnostic(sizes_guid):
42  """Creates a histogram external sizes diagnostic."""
43
44  benchmark_diagnostic = {
45      'type': 'GenericSet',
46      'guid': str(sizes_guid),
47      'values': ['sizes'],
48  }
49
50  return benchmark_diagnostic
51
52
53def CreateSizesHistogramItem(name, size, sizes_guid):
54  """Create a performance dashboard histogram from the histogram template and
55  binary size data."""
56
57  # Chromium performance dashboard histogram containing binary size data.
58  histogram = {
59      'name': name,
60      'unit': 'sizeInBytes_smallerIsBetter',
61      'diagnostics': {
62          'benchmarks': str(sizes_guid),
63      },
64      'sampleValues': [size],
65      'running': [1, size, math.log(size), size, size, size, 0],
66      'description': 'chrome-fuchsia package binary sizes',
67      'summaryOptions': {
68          'avg': True,
69          'count': False,
70          'max': False,
71          'min': False,
72          'std': False,
73          'sum': False,
74      },
75  }
76
77  return histogram
78
79
80def CreateSizesHistogram(package_sizes):
81  """Create a performance dashboard histogram from binary size data."""
82
83  sizes_guid = uuid.uuid1()
84  histogram = [CreateSizesExternalDiagnostic(sizes_guid)]
85  for name, size in package_sizes.items():
86    histogram.append(
87        CreateSizesHistogramItem('%s_%s' % (name, 'compressed'),
88                                 size.compressed, sizes_guid))
89    histogram.append(
90        CreateSizesHistogramItem('%s_%s' % (name, 'uncompressed'),
91                                 size.uncompressed, sizes_guid))
92  return histogram
93
94
95def CreateTestResults(test_status, timestamp):
96  """Create test results data to write to JSON test results file.
97
98  The JSON data format is defined in
99  https://chromium.googlesource.com/chromium/src/+/main/docs/testing/json_test_results_format.md
100  """
101
102  results = {
103      'tests': {},
104      'interrupted': False,
105      'metadata': {
106          'test_name_prefix': 'build/fuchsia/'
107      },
108      'version': 3,
109      'seconds_since_epoch': timestamp,
110  }
111
112  num_failures_by_type = {result: 0 for result in ['FAIL', 'PASS', 'CRASH']}
113  for metric in test_status:
114    actual_status = test_status[metric]
115    num_failures_by_type[actual_status] += 1
116    results['tests'][metric] = {
117        'expected': 'PASS',
118        'actual': actual_status,
119    }
120  results['num_failures_by_type'] = num_failures_by_type
121
122  return results
123
124
125def GetTestStatus(package_sizes, sizes_config, test_completed):
126  """Checks package sizes against size limits.
127
128  Returns a tuple of overall test pass/fail status and a dictionary mapping size
129  limit checks to PASS/FAIL/CRASH status."""
130
131  if not test_completed:
132    test_status = {'binary_sizes': 'CRASH'}
133  else:
134    test_status = {}
135    for metric, limit in sizes_config['size_limits'].items():
136      # Strip the "_compressed" suffix from |metric| if it exists.
137      match = re.match(r'(?P<name>\w+)_compressed', metric)
138      package_name = match.group('name') if match else metric
139      if package_name not in package_sizes:
140        raise Exception('package "%s" not in sizes "%s"' %
141                        (package_name, str(package_sizes)))
142      if package_sizes[package_name].compressed <= limit:
143        test_status[metric] = 'PASS'
144      else:
145        test_status[metric] = 'FAIL'
146
147  all_tests_passed = all(status == 'PASS' for status in test_status.values())
148
149  return all_tests_passed, test_status
150
151
152def WriteSimpleTestResults(results_path, test_completed):
153  """Writes simplified test results file.
154
155  Used when test status is not available.
156  """
157
158  simple_isolated_script_output = {
159      'valid': test_completed,
160      'failures': [],
161      'version': 'simplified',
162  }
163  with open(results_path, 'w') as output_file:
164    json.dump(simple_isolated_script_output, output_file)
165
166
167def WriteTestResults(results_path, test_completed, test_status, timestamp):
168  """Writes test results file containing test PASS/FAIL/CRASH statuses."""
169
170  if test_status:
171    test_results = CreateTestResults(test_status, timestamp)
172    with open(results_path, 'w') as results_file:
173      json.dump(test_results, results_file)
174  else:
175    WriteSimpleTestResults(results_path, test_completed)
176
177
178def WriteGerritPluginSizeData(output_path, package_sizes):
179  """Writes a package size dictionary in json format for the Gerrit binary
180  sizes plugin."""
181
182  with open(output_path, 'w') as sizes_file:
183    sizes_data = {name: size.compressed for name, size in package_sizes.items()}
184    json.dump(sizes_data, sizes_file)
185
186
187def ReadPackageBlobsJson(json_path):
188  """Reads package blob info from json file.
189
190  Opens json file of blob info written by WritePackageBlobsJson,
191  and converts back into package blobs used in this script.
192  """
193  with open(json_path, 'rt') as json_file:
194    formatted_blob_info = json.load(json_file)
195
196  package_blobs = {}
197  for package in formatted_blob_info:
198    package_blobs[package] = {}
199    for blob_info in formatted_blob_info[package]:
200      blob = Blob(name=blob_info['path'],
201                  hash=blob_info['merkle'],
202                  uncompressed=blob_info['bytes'],
203                  compressed=blob_info['size'],
204                  is_counted=blob_info['is_counted'])
205      package_blobs[package][blob.name] = blob
206
207  return package_blobs
208
209
210def WritePackageBlobsJson(json_path, package_blobs):
211  """Writes package blob information in human-readable JSON format.
212
213  The json data is an array of objects containing these keys:
214    'path': string giving blob location in the local file system
215    'merkle': the blob's Merkle hash
216    'bytes': the number of uncompressed bytes in the blod
217    'size': the size of the compressed blob in bytes.  A multiple of the blobfs
218        block size (8192)
219    'is_counted: true if the blob counts towards the package budget, or false
220        if not (for ICU blobs or blobs distributed in the SDK)"""
221
222  formatted_blob_stats_per_package = {}
223  for package in package_blobs:
224    blob_data = []
225    for blob_name in package_blobs[package]:
226      blob = package_blobs[package][blob_name]
227      blob_data.append({
228          'path': str(blob.name),
229          'merkle': str(blob.hash),
230          'bytes': blob.uncompressed,
231          'size': blob.compressed,
232          'is_counted': blob.is_counted
233      })
234    formatted_blob_stats_per_package[package] = blob_data
235
236  with (open(json_path, 'w')) as json_file:
237    json.dump(formatted_blob_stats_per_package, json_file, indent=2)
238
239
240def WritePackageSizesJson(json_path, package_sizes):
241  """Writes package sizes into a human-readable JSON format.
242
243  JSON data is a dictionary of each package name being a key, with
244  the following keys within the sub-object:
245    'compressed': compressed size of the package in bytes.
246    'uncompressed': uncompressed size of the package in bytes.
247  """
248  formatted_package_sizes = {}
249  for package, size_info in package_sizes.items():
250    formatted_package_sizes[package] = {
251        'uncompressed': size_info.uncompressed,
252        'compressed': size_info.compressed
253    }
254  with (open(json_path, 'w')) as json_file:
255    json.dump(formatted_package_sizes, json_file, indent=2)
256
257
258def ReadPackageSizesJson(json_path):
259  """Reads package_sizes from a given JSON file.
260
261  Opens json file of blob info written by WritePackageSizesJson,
262  and converts back into package sizes used in this script.
263  """
264  with open(json_path, 'rt') as json_file:
265    formatted_package_info = json.load(json_file)
266
267  package_sizes = {}
268  for package, size_info in formatted_package_info.items():
269    package_sizes[package] = PackageSizes(
270        compressed=size_info['compressed'],
271        uncompressed=size_info['uncompressed'])
272  return package_sizes
273
274
275def GetCompressedSize(file_path):
276  """Measures file size after blobfs compression."""
277
278  compressor_path = get_host_tool_path('blobfs-compression')
279  try:
280    temp_dir = tempfile.mkdtemp()
281    compressed_file_path = os.path.join(temp_dir, os.path.basename(file_path))
282    compressor_cmd = [
283        compressor_path,
284        '--source_file=%s' % file_path,
285        '--compressed_file=%s' % compressed_file_path
286    ]
287    proc = subprocess.Popen(compressor_cmd,
288                            stdout=subprocess.PIPE,
289                            stderr=subprocess.STDOUT)
290    proc.wait()
291    compressor_output = proc.stdout.read().decode('utf-8')
292    if proc.returncode != 0:
293      print(compressor_output, file=sys.stderr)
294      raise Exception('Error while running %s' % compressor_path)
295  finally:
296    shutil.rmtree(temp_dir)
297
298  # Match a compressed bytes total from blobfs-compression output like
299  # Wrote 360830 bytes (40% compression)
300  blobfs_compressed_bytes_re = r'Wrote\s+(?P<bytes>\d+)\s+bytes'
301
302  match = re.search(blobfs_compressed_bytes_re, compressor_output)
303  if not match:
304    print(compressor_output, file=sys.stderr)
305    raise Exception('Could not get compressed bytes for %s' % file_path)
306
307  # Round the compressed file size up to an integer number of blobfs blocks.
308  BLOBFS_BLOCK_SIZE = 8192  # Fuchsia's blobfs file system uses 8KiB blocks.
309  blob_bytes = int(match.group('bytes'))
310  return int(math.ceil(blob_bytes / BLOBFS_BLOCK_SIZE)) * BLOBFS_BLOCK_SIZE
311
312
313def ExtractFarFile(file_path, extract_dir):
314  """Extracts contents of a Fuchsia archive file to the specified directory."""
315
316  far_tool = get_host_tool_path('far')
317
318  if not os.path.isfile(far_tool):
319    raise Exception('Could not find FAR host tool "%s".' % far_tool)
320  if not os.path.isfile(file_path):
321    raise Exception('Could not find FAR file "%s".' % file_path)
322
323  subprocess.check_call([
324      far_tool, 'extract',
325      '--archive=%s' % file_path,
326      '--output=%s' % extract_dir
327  ])
328
329
330def GetBlobNameHashes(meta_dir):
331  """Returns mapping from Fuchsia pkgfs paths to blob hashes.  The mapping is
332  read from the extracted meta.far archive contained in an extracted package
333  archive."""
334
335  blob_name_hashes = {}
336  contents_path = os.path.join(meta_dir, 'meta', 'contents')
337  with open(contents_path) as lines:
338    for line in lines:
339      (pkgfs_path, blob_hash) = line.strip().split('=')
340      blob_name_hashes[pkgfs_path] = blob_hash
341  return blob_name_hashes
342
343
344# Compiled regular expression matching strings like *.so, *.so.1, *.so.2, ...
345SO_FILENAME_REGEXP = re.compile(r'\.so(\.\d+)?$')
346
347
348def GetSdkModules():
349  """Finds shared objects (.so) under the Fuchsia SDK arch directory in dist or
350  lib subdirectories.
351
352  Returns a set of shared objects' filenames.
353  """
354
355  # Fuchsia SDK arch directory path (contains all shared object files).
356  sdk_arch_dir = os.path.join(SDK_ROOT, 'arch')
357  # Leaf subdirectories containing shared object files.
358  sdk_so_leaf_dirs = ['dist', 'lib']
359  # Match a shared object file name.
360  sdk_so_filename_re = r'\.so(\.\d+)?$'
361
362  lib_names = set()
363  for dirpath, _, file_names in os.walk(sdk_arch_dir):
364    if os.path.basename(dirpath) in sdk_so_leaf_dirs:
365      for name in file_names:
366        if SO_FILENAME_REGEXP.search(name):
367          lib_names.add(name)
368  return lib_names
369
370
371def FarBaseName(name):
372  _, name = os.path.split(name)
373  name = re.sub(r'\.far$', '', name)
374  return name
375
376
377def GetPackageMerkleRoot(far_file_path):
378  """Returns a package's Merkle digest."""
379
380  # The digest is the first word on the first line of the merkle tool's output.
381  merkle_tool = get_host_tool_path('merkleroot')
382  output = subprocess.check_output([merkle_tool, far_file_path])
383  return output.splitlines()[0].split()[0]
384
385
386def GetBlobs(far_file, build_out_dir):
387  """Calculates compressed and uncompressed blob sizes for specified FAR file.
388  Marks ICU blobs and blobs from SDK libraries as not counted."""
389
390  base_name = FarBaseName(far_file)
391
392  extract_dir = tempfile.mkdtemp()
393
394  # Extract files and blobs from the specified Fuchsia archive.
395  far_file_path = os.path.join(build_out_dir, far_file)
396  far_extract_dir = os.path.join(extract_dir, base_name)
397  ExtractFarFile(far_file_path, far_extract_dir)
398
399  # Extract the meta.far archive contained in the specified Fuchsia archive.
400  meta_far_file_path = os.path.join(far_extract_dir, 'meta.far')
401  meta_far_extract_dir = os.path.join(extract_dir, '%s_meta' % base_name)
402  ExtractFarFile(meta_far_file_path, meta_far_extract_dir)
403
404  # Map Linux filesystem blob names to blob hashes.
405  blob_name_hashes = GetBlobNameHashes(meta_far_extract_dir)
406
407  # "System" files whose sizes are not charged against component size budgets.
408  # Fuchsia SDK modules and the ICU icudtl.dat file sizes are not counted.
409  system_files = GetSdkModules() | set(['icudtl.dat'])
410
411  # Add the meta.far file blob.
412  blobs = {}
413  meta_name = 'meta.far'
414  meta_hash = GetPackageMerkleRoot(meta_far_file_path)
415  compressed = GetCompressedSize(meta_far_file_path)
416  uncompressed = os.path.getsize(meta_far_file_path)
417  blobs[meta_name] = Blob(meta_name, meta_hash, compressed, uncompressed, True)
418
419  # Add package blobs.
420  for blob_name, blob_hash in blob_name_hashes.items():
421    extracted_blob_path = os.path.join(far_extract_dir, blob_hash)
422    compressed = GetCompressedSize(extracted_blob_path)
423    uncompressed = os.path.getsize(extracted_blob_path)
424    is_counted = os.path.basename(blob_name) not in system_files
425    blobs[blob_name] = Blob(blob_name, blob_hash, compressed, uncompressed,
426                            is_counted)
427
428  shutil.rmtree(extract_dir)
429
430  return blobs
431
432
433def GetPackageBlobs(far_files, build_out_dir):
434  """Returns dictionary mapping package names to blobs contained in the package.
435
436  Prints package blob size statistics."""
437
438  package_blobs = {}
439  for far_file in far_files:
440    package_name = FarBaseName(far_file)
441    if package_name in package_blobs:
442      raise Exception('Duplicate FAR file base name "%s".' % package_name)
443    package_blobs[package_name] = GetBlobs(far_file, build_out_dir)
444
445  # Print package blob sizes (does not count sharing).
446  for package_name in sorted(package_blobs.keys()):
447    print('Package blob sizes: %s' % package_name)
448    print('%-64s %12s %12s %s' %
449          ('blob hash', 'compressed', 'uncompressed', 'path'))
450    print('%s %s %s %s' % (64 * '-', 12 * '-', 12 * '-', 20 * '-'))
451    for blob_name in sorted(package_blobs[package_name].keys()):
452      blob = package_blobs[package_name][blob_name]
453      if blob.is_counted:
454        print('%64s %12d %12d %s' %
455              (blob.hash, blob.compressed, blob.uncompressed, blob.name))
456
457  return package_blobs
458
459
460def GetPackageSizes(package_blobs):
461  """Calculates compressed and uncompressed package sizes from blob sizes."""
462
463  # TODO(crbug.com/1126177): Use partial sizes for blobs shared by
464  # non Chrome-Fuchsia packages.
465
466  # Count number of packages sharing blobs (a count of 1 is not shared).
467  blob_counts = collections.defaultdict(int)
468  for package_name in package_blobs:
469    for blob_name in package_blobs[package_name]:
470      blob = package_blobs[package_name][blob_name]
471      blob_counts[blob.hash] += 1
472
473  # Package sizes are the sum of blob sizes divided by their share counts.
474  package_sizes = {}
475  for package_name in package_blobs:
476    compressed_total = 0
477    uncompressed_total = 0
478    for blob_name in package_blobs[package_name]:
479      blob = package_blobs[package_name][blob_name]
480      if blob.is_counted:
481        count = blob_counts[blob.hash]
482        compressed_total += blob.compressed // count
483        uncompressed_total += blob.uncompressed // count
484    package_sizes[package_name] = PackageSizes(compressed_total,
485                                               uncompressed_total)
486
487  return package_sizes
488
489
490def GetBinarySizesAndBlobs(args, sizes_config):
491  """Get binary size data and contained blobs for packages specified in args.
492
493  If "total_size_name" is set, then computes a synthetic package size which is
494  the aggregated sizes across all packages."""
495
496  # Calculate compressed and uncompressed package sizes.
497  package_blobs = GetPackageBlobs(sizes_config['far_files'], args.build_out_dir)
498  package_sizes = GetPackageSizes(package_blobs)
499
500  # Optionally calculate total compressed and uncompressed package sizes.
501  if 'far_total_name' in sizes_config:
502    compressed = sum([a.compressed for a in package_sizes.values()])
503    uncompressed = sum([a.uncompressed for a in package_sizes.values()])
504    package_sizes[sizes_config['far_total_name']] = PackageSizes(
505        compressed, uncompressed)
506
507  for name, size in package_sizes.items():
508    print('%s: compressed size %d, uncompressed size %d' %
509          (name, size.compressed, size.uncompressed))
510
511  return package_sizes, package_blobs
512
513
514def main():
515  parser = argparse.ArgumentParser()
516  parser.add_argument(
517      '--build-out-dir',
518      '--output-directory',
519      type=os.path.realpath,
520      required=True,
521      help='Location of the build artifacts.',
522  )
523  parser.add_argument(
524      '--isolated-script-test-output',
525      type=os.path.realpath,
526      help='File to which simplified JSON results will be written.')
527  parser.add_argument(
528      '--size-plugin-json-path',
529      help='Optional path for json size data for the Gerrit binary size plugin',
530  )
531  parser.add_argument(
532      '--sizes-path',
533      default=os.path.join('tools', 'fuchsia', 'size_tests', 'fyi_sizes.json'),
534      help='path to package size limits json file.  The path is relative to '
535      'the workspace src directory')
536  parser.add_argument('--verbose',
537                      '-v',
538                      action='store_true',
539                      help='Enable verbose output')
540  # Accepted to conform to the isolated script interface, but ignored.
541  parser.add_argument('--isolated-script-test-filter', help=argparse.SUPPRESS)
542  parser.add_argument('--isolated-script-test-perf-output',
543                      help=argparse.SUPPRESS)
544  args = parser.parse_args()
545
546  if args.verbose:
547    print('Fuchsia binary sizes')
548    print('Working directory', os.getcwd())
549    print('Args:')
550    for var in vars(args):
551      print('  {}: {}'.format(var, getattr(args, var) or ''))
552
553  if not os.path.isdir(args.build_out_dir):
554    raise Exception('Could not find build output directory "%s".' %
555                    args.build_out_dir)
556
557  with open(os.path.join(DIR_SRC_ROOT, args.sizes_path)) as sizes_file:
558    sizes_config = json.load(sizes_file)
559
560  if args.verbose:
561    print('Sizes Config:')
562    print(json.dumps(sizes_config))
563
564  for far_rel_path in sizes_config['far_files']:
565    far_abs_path = os.path.join(args.build_out_dir, far_rel_path)
566    if not os.path.isfile(far_abs_path):
567      raise Exception('Could not find FAR file "%s".' % far_abs_path)
568
569  test_name = 'sizes'
570  timestamp = time.time()
571  test_completed = False
572  all_tests_passed = False
573  test_status = {}
574  package_sizes = {}
575  package_blobs = {}
576  sizes_histogram = []
577
578  results_directory = None
579  if args.isolated_script_test_output:
580    results_directory = os.path.join(
581        os.path.dirname(args.isolated_script_test_output), test_name)
582    if not os.path.exists(results_directory):
583      os.makedirs(results_directory)
584
585  try:
586    package_sizes, package_blobs = GetBinarySizesAndBlobs(args, sizes_config)
587    sizes_histogram = CreateSizesHistogram(package_sizes)
588    test_completed = True
589  except:
590    _, value, trace = sys.exc_info()
591    traceback.print_tb(trace)
592    print(str(value))
593  finally:
594    all_tests_passed, test_status = GetTestStatus(package_sizes, sizes_config,
595                                                  test_completed)
596
597    if results_directory:
598      WriteTestResults(os.path.join(results_directory, 'test_results.json'),
599                       test_completed, test_status, timestamp)
600      with open(os.path.join(results_directory, 'perf_results.json'), 'w') as f:
601        json.dump(sizes_histogram, f)
602      WritePackageBlobsJson(
603          os.path.join(results_directory, PACKAGES_BLOBS_FILE), package_blobs)
604      WritePackageSizesJson(
605          os.path.join(results_directory, PACKAGES_SIZES_FILE), package_sizes)
606
607    if args.isolated_script_test_output:
608      WriteTestResults(args.isolated_script_test_output, test_completed,
609                       test_status, timestamp)
610
611    if args.size_plugin_json_path:
612      WriteGerritPluginSizeData(args.size_plugin_json_path, package_sizes)
613
614    return 0 if all_tests_passed else 1
615
616
617if __name__ == '__main__':
618  sys.exit(main())
619