| #!/usr/bin/env vpython3 |
| # |
| # Copyright 2020 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| '''Implements Chrome-Fuchsia package binary size checks.''' |
| |
| import argparse |
| import collections |
| import json |
| import math |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| import time |
| import traceback |
| import uuid |
| |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), |
| 'test'))) |
| |
| from common import DIR_SRC_ROOT, SDK_ROOT, get_host_tool_path |
| |
| PACKAGES_BLOBS_FILE = 'package_blobs.json' |
| PACKAGES_SIZES_FILE = 'package_sizes.json' |
| |
| # Structure representing the compressed and uncompressed sizes for a Fuchsia |
| # package. |
| PackageSizes = collections.namedtuple('PackageSizes', |
| ['compressed', 'uncompressed']) |
| |
| # Structure representing a Fuchsia package blob and its compressed and |
| # uncompressed sizes. |
| Blob = collections.namedtuple( |
| 'Blob', ['name', 'hash', 'compressed', 'uncompressed', 'is_counted']) |
| |
| |
| def CreateSizesExternalDiagnostic(sizes_guid): |
| """Creates a histogram external sizes diagnostic.""" |
| |
| benchmark_diagnostic = { |
| 'type': 'GenericSet', |
| 'guid': str(sizes_guid), |
| 'values': ['sizes'], |
| } |
| |
| return benchmark_diagnostic |
| |
| |
| def CreateSizesHistogramItem(name, size, sizes_guid): |
| """Create a performance dashboard histogram from the histogram template and |
| binary size data.""" |
| |
| # Chromium performance dashboard histogram containing binary size data. |
| histogram = { |
| 'name': name, |
| 'unit': 'sizeInBytes_smallerIsBetter', |
| 'diagnostics': { |
| 'benchmarks': str(sizes_guid), |
| }, |
| 'sampleValues': [size], |
| 'running': [1, size, math.log(size), size, size, size, 0], |
| 'description': 'chrome-fuchsia package binary sizes', |
| 'summaryOptions': { |
| 'avg': True, |
| 'count': False, |
| 'max': False, |
| 'min': False, |
| 'std': False, |
| 'sum': False, |
| }, |
| } |
| |
| return histogram |
| |
| |
| def CreateSizesHistogram(package_sizes): |
| """Create a performance dashboard histogram from binary size data.""" |
| |
| sizes_guid = uuid.uuid1() |
| histogram = [CreateSizesExternalDiagnostic(sizes_guid)] |
| for name, size in package_sizes.items(): |
| histogram.append( |
| CreateSizesHistogramItem('%s_%s' % (name, 'compressed'), |
| size.compressed, sizes_guid)) |
| histogram.append( |
| CreateSizesHistogramItem('%s_%s' % (name, 'uncompressed'), |
| size.uncompressed, sizes_guid)) |
| return histogram |
| |
| |
| def CreateTestResults(test_status, timestamp): |
| """Create test results data to write to JSON test results file. |
| |
| The JSON data format is defined in |
| https://chromium.googlesource.com/chromium/src/+/main/docs/testing/json_test_results_format.md |
| """ |
| |
| results = { |
| 'tests': {}, |
| 'interrupted': False, |
| 'metadata': { |
| 'test_name_prefix': 'build/fuchsia/' |
| }, |
| 'version': 3, |
| 'seconds_since_epoch': timestamp, |
| } |
| |
| num_failures_by_type = {result: 0 for result in ['FAIL', 'PASS', 'CRASH']} |
| for metric in test_status: |
| actual_status = test_status[metric] |
| num_failures_by_type[actual_status] += 1 |
| results['tests'][metric] = { |
| 'expected': 'PASS', |
| 'actual': actual_status, |
| } |
| results['num_failures_by_type'] = num_failures_by_type |
| |
| return results |
| |
| |
| def GetTestStatus(package_sizes, sizes_config, test_completed): |
| """Checks package sizes against size limits. |
| |
| Returns a tuple of overall test pass/fail status and a dictionary mapping size |
| limit checks to PASS/FAIL/CRASH status.""" |
| |
| if not test_completed: |
| test_status = {'binary_sizes': 'CRASH'} |
| else: |
| test_status = {} |
| for metric, limit in sizes_config['size_limits'].items(): |
| # Strip the "_compressed" suffix from |metric| if it exists. |
| match = re.match(r'(?P<name>\w+)_compressed', metric) |
| package_name = match.group('name') if match else metric |
| if package_name not in package_sizes: |
| raise Exception('package "%s" not in sizes "%s"' % |
| (package_name, str(package_sizes))) |
| if package_sizes[package_name].compressed <= limit: |
| test_status[metric] = 'PASS' |
| else: |
| test_status[metric] = 'FAIL' |
| |
| all_tests_passed = all(status == 'PASS' for status in test_status.values()) |
| |
| return all_tests_passed, test_status |
| |
| |
| def WriteSimpleTestResults(results_path, test_completed): |
| """Writes simplified test results file. |
| |
| Used when test status is not available. |
| """ |
| |
| simple_isolated_script_output = { |
| 'valid': test_completed, |
| 'failures': [], |
| 'version': 'simplified', |
| } |
| with open(results_path, 'w') as output_file: |
| json.dump(simple_isolated_script_output, output_file) |
| |
| |
| def WriteTestResults(results_path, test_completed, test_status, timestamp): |
| """Writes test results file containing test PASS/FAIL/CRASH statuses.""" |
| |
| if test_status: |
| test_results = CreateTestResults(test_status, timestamp) |
| with open(results_path, 'w') as results_file: |
| json.dump(test_results, results_file) |
| else: |
| WriteSimpleTestResults(results_path, test_completed) |
| |
| |
| def WriteGerritPluginSizeData(output_path, package_sizes): |
| """Writes a package size dictionary in json format for the Gerrit binary |
| sizes plugin.""" |
| |
| with open(output_path, 'w') as sizes_file: |
| sizes_data = {name: size.compressed for name, size in package_sizes.items()} |
| json.dump(sizes_data, sizes_file) |
| |
| |
| def ReadPackageBlobsJson(json_path): |
| """Reads package blob info from json file. |
| |
| Opens json file of blob info written by WritePackageBlobsJson, |
| and converts back into package blobs used in this script. |
| """ |
| with open(json_path, 'rt') as json_file: |
| formatted_blob_info = json.load(json_file) |
| |
| package_blobs = {} |
| for package in formatted_blob_info: |
| package_blobs[package] = {} |
| for blob_info in formatted_blob_info[package]: |
| blob = Blob(name=blob_info['path'], |
| hash=blob_info['merkle'], |
| uncompressed=blob_info['bytes'], |
| compressed=blob_info['size'], |
| is_counted=blob_info['is_counted']) |
| package_blobs[package][blob.name] = blob |
| |
| return package_blobs |
| |
| |
| def WritePackageBlobsJson(json_path, package_blobs): |
| """Writes package blob information in human-readable JSON format. |
| |
| The json data is an array of objects containing these keys: |
| 'path': string giving blob location in the local file system |
| 'merkle': the blob's Merkle hash |
| 'bytes': the number of uncompressed bytes in the blod |
| 'size': the size of the compressed blob in bytes. A multiple of the blobfs |
| block size (8192) |
| 'is_counted: true if the blob counts towards the package budget, or false |
| if not (for ICU blobs or blobs distributed in the SDK)""" |
| |
| formatted_blob_stats_per_package = {} |
| for package in package_blobs: |
| blob_data = [] |
| for blob_name in package_blobs[package]: |
| blob = package_blobs[package][blob_name] |
| blob_data.append({ |
| 'path': str(blob.name), |
| 'merkle': str(blob.hash), |
| 'bytes': blob.uncompressed, |
| 'size': blob.compressed, |
| 'is_counted': blob.is_counted |
| }) |
| formatted_blob_stats_per_package[package] = blob_data |
| |
| with (open(json_path, 'w')) as json_file: |
| json.dump(formatted_blob_stats_per_package, json_file, indent=2) |
| |
| |
| def WritePackageSizesJson(json_path, package_sizes): |
| """Writes package sizes into a human-readable JSON format. |
| |
| JSON data is a dictionary of each package name being a key, with |
| the following keys within the sub-object: |
| 'compressed': compressed size of the package in bytes. |
| 'uncompressed': uncompressed size of the package in bytes. |
| """ |
| formatted_package_sizes = {} |
| for package, size_info in package_sizes.items(): |
| formatted_package_sizes[package] = { |
| 'uncompressed': size_info.uncompressed, |
| 'compressed': size_info.compressed |
| } |
| with (open(json_path, 'w')) as json_file: |
| json.dump(formatted_package_sizes, json_file, indent=2) |
| |
| |
| def ReadPackageSizesJson(json_path): |
| """Reads package_sizes from a given JSON file. |
| |
| Opens json file of blob info written by WritePackageSizesJson, |
| and converts back into package sizes used in this script. |
| """ |
| with open(json_path, 'rt') as json_file: |
| formatted_package_info = json.load(json_file) |
| |
| package_sizes = {} |
| for package, size_info in formatted_package_info.items(): |
| package_sizes[package] = PackageSizes( |
| compressed=size_info['compressed'], |
| uncompressed=size_info['uncompressed']) |
| return package_sizes |
| |
| |
| def GetCompressedSize(file_path): |
| """Measures file size after blobfs compression.""" |
| |
| compressor_path = get_host_tool_path('blobfs-compression') |
| try: |
| temp_dir = tempfile.mkdtemp() |
| compressed_file_path = os.path.join(temp_dir, os.path.basename(file_path)) |
| compressor_cmd = [ |
| compressor_path, |
| '--source_file=%s' % file_path, |
| '--compressed_file=%s' % compressed_file_path |
| ] |
| proc = subprocess.Popen(compressor_cmd, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT) |
| proc.wait() |
| compressor_output = proc.stdout.read().decode('utf-8') |
| if proc.returncode != 0: |
| print(compressor_output, file=sys.stderr) |
| raise Exception('Error while running %s' % compressor_path) |
| finally: |
| shutil.rmtree(temp_dir) |
| |
| # Match a compressed bytes total from blobfs-compression output like |
| # Wrote 360830 bytes (40% compression) |
| blobfs_compressed_bytes_re = r'Wrote\s+(?P<bytes>\d+)\s+bytes' |
| |
| match = re.search(blobfs_compressed_bytes_re, compressor_output) |
| if not match: |
| print(compressor_output, file=sys.stderr) |
| raise Exception('Could not get compressed bytes for %s' % file_path) |
| |
| # Round the compressed file size up to an integer number of blobfs blocks. |
| BLOBFS_BLOCK_SIZE = 8192 # Fuchsia's blobfs file system uses 8KiB blocks. |
| blob_bytes = int(match.group('bytes')) |
| return int(math.ceil(blob_bytes / BLOBFS_BLOCK_SIZE)) * BLOBFS_BLOCK_SIZE |
| |
| |
| def ExtractFarFile(file_path, extract_dir): |
| """Extracts contents of a Fuchsia archive file to the specified directory.""" |
| |
| far_tool = get_host_tool_path('far') |
| |
| if not os.path.isfile(far_tool): |
| raise Exception('Could not find FAR host tool "%s".' % far_tool) |
| if not os.path.isfile(file_path): |
| raise Exception('Could not find FAR file "%s".' % file_path) |
| |
| subprocess.check_call([ |
| far_tool, 'extract', |
| '--archive=%s' % file_path, |
| '--output=%s' % extract_dir |
| ]) |
| |
| |
| def GetBlobNameHashes(meta_dir): |
| """Returns mapping from Fuchsia pkgfs paths to blob hashes. The mapping is |
| read from the extracted meta.far archive contained in an extracted package |
| archive.""" |
| |
| blob_name_hashes = {} |
| contents_path = os.path.join(meta_dir, 'meta', 'contents') |
| with open(contents_path) as lines: |
| for line in lines: |
| (pkgfs_path, blob_hash) = line.strip().split('=') |
| blob_name_hashes[pkgfs_path] = blob_hash |
| return blob_name_hashes |
| |
| |
| # Compiled regular expression matching strings like *.so, *.so.1, *.so.2, ... |
| SO_FILENAME_REGEXP = re.compile(r'\.so(\.\d+)?$') |
| |
| |
| def GetSdkModules(): |
| """Finds shared objects (.so) under the Fuchsia SDK arch directory in dist or |
| lib subdirectories. |
| |
| Returns a set of shared objects' filenames. |
| """ |
| |
| # Fuchsia SDK arch directory path (contains all shared object files). |
| sdk_arch_dir = os.path.join(SDK_ROOT, 'arch') |
| # Leaf subdirectories containing shared object files. |
| sdk_so_leaf_dirs = ['dist', 'lib'] |
| # Match a shared object file name. |
| sdk_so_filename_re = r'\.so(\.\d+)?$' |
| |
| lib_names = set() |
| for dirpath, _, file_names in os.walk(sdk_arch_dir): |
| if os.path.basename(dirpath) in sdk_so_leaf_dirs: |
| for name in file_names: |
| if SO_FILENAME_REGEXP.search(name): |
| lib_names.add(name) |
| return lib_names |
| |
| |
| def FarBaseName(name): |
| _, name = os.path.split(name) |
| name = re.sub(r'\.far$', '', name) |
| return name |
| |
| |
| def GetPackageMerkleRoot(far_file_path): |
| """Returns a package's Merkle digest.""" |
| |
| # The digest is the first word on the first line of the merkle tool's output. |
| merkle_tool = get_host_tool_path('merkleroot') |
| output = subprocess.check_output([merkle_tool, far_file_path]) |
| return output.splitlines()[0].split()[0] |
| |
| |
| def GetBlobs(far_file, build_out_dir): |
| """Calculates compressed and uncompressed blob sizes for specified FAR file. |
| Marks ICU blobs and blobs from SDK libraries as not counted.""" |
| |
| base_name = FarBaseName(far_file) |
| |
| extract_dir = tempfile.mkdtemp() |
| |
| # Extract files and blobs from the specified Fuchsia archive. |
| far_file_path = os.path.join(build_out_dir, far_file) |
| far_extract_dir = os.path.join(extract_dir, base_name) |
| ExtractFarFile(far_file_path, far_extract_dir) |
| |
| # Extract the meta.far archive contained in the specified Fuchsia archive. |
| meta_far_file_path = os.path.join(far_extract_dir, 'meta.far') |
| meta_far_extract_dir = os.path.join(extract_dir, '%s_meta' % base_name) |
| ExtractFarFile(meta_far_file_path, meta_far_extract_dir) |
| |
| # Map Linux filesystem blob names to blob hashes. |
| blob_name_hashes = GetBlobNameHashes(meta_far_extract_dir) |
| |
| # "System" files whose sizes are not charged against component size budgets. |
| # Fuchsia SDK modules and the ICU icudtl.dat file sizes are not counted. |
| system_files = GetSdkModules() | set(['icudtl.dat']) |
| |
| # Add the meta.far file blob. |
| blobs = {} |
| meta_name = 'meta.far' |
| meta_hash = GetPackageMerkleRoot(meta_far_file_path) |
| compressed = GetCompressedSize(meta_far_file_path) |
| uncompressed = os.path.getsize(meta_far_file_path) |
| blobs[meta_name] = Blob(meta_name, meta_hash, compressed, uncompressed, True) |
| |
| # Add package blobs. |
| for blob_name, blob_hash in blob_name_hashes.items(): |
| extracted_blob_path = os.path.join(far_extract_dir, blob_hash) |
| compressed = GetCompressedSize(extracted_blob_path) |
| uncompressed = os.path.getsize(extracted_blob_path) |
| is_counted = os.path.basename(blob_name) not in system_files |
| blobs[blob_name] = Blob(blob_name, blob_hash, compressed, uncompressed, |
| is_counted) |
| |
| shutil.rmtree(extract_dir) |
| |
| return blobs |
| |
| |
| def GetPackageBlobs(far_files, build_out_dir): |
| """Returns dictionary mapping package names to blobs contained in the package. |
| |
| Prints package blob size statistics.""" |
| |
| package_blobs = {} |
| for far_file in far_files: |
| package_name = FarBaseName(far_file) |
| if package_name in package_blobs: |
| raise Exception('Duplicate FAR file base name "%s".' % package_name) |
| package_blobs[package_name] = GetBlobs(far_file, build_out_dir) |
| |
| # Print package blob sizes (does not count sharing). |
| for package_name in sorted(package_blobs.keys()): |
| print('Package blob sizes: %s' % package_name) |
| print('%-64s %12s %12s %s' % |
| ('blob hash', 'compressed', 'uncompressed', 'path')) |
| print('%s %s %s %s' % (64 * '-', 12 * '-', 12 * '-', 20 * '-')) |
| for blob_name in sorted(package_blobs[package_name].keys()): |
| blob = package_blobs[package_name][blob_name] |
| if blob.is_counted: |
| print('%64s %12d %12d %s' % |
| (blob.hash, blob.compressed, blob.uncompressed, blob.name)) |
| |
| return package_blobs |
| |
| |
| def GetPackageSizes(package_blobs): |
| """Calculates compressed and uncompressed package sizes from blob sizes.""" |
| |
| # TODO(crbug.com/1126177): Use partial sizes for blobs shared by |
| # non Chrome-Fuchsia packages. |
| |
| # Count number of packages sharing blobs (a count of 1 is not shared). |
| blob_counts = collections.defaultdict(int) |
| for package_name in package_blobs: |
| for blob_name in package_blobs[package_name]: |
| blob = package_blobs[package_name][blob_name] |
| blob_counts[blob.hash] += 1 |
| |
| # Package sizes are the sum of blob sizes divided by their share counts. |
| package_sizes = {} |
| for package_name in package_blobs: |
| compressed_total = 0 |
| uncompressed_total = 0 |
| for blob_name in package_blobs[package_name]: |
| blob = package_blobs[package_name][blob_name] |
| if blob.is_counted: |
| count = blob_counts[blob.hash] |
| compressed_total += blob.compressed // count |
| uncompressed_total += blob.uncompressed // count |
| package_sizes[package_name] = PackageSizes(compressed_total, |
| uncompressed_total) |
| |
| return package_sizes |
| |
| |
| def GetBinarySizesAndBlobs(args, sizes_config): |
| """Get binary size data and contained blobs for packages specified in args. |
| |
| If "total_size_name" is set, then computes a synthetic package size which is |
| the aggregated sizes across all packages.""" |
| |
| # Calculate compressed and uncompressed package sizes. |
| package_blobs = GetPackageBlobs(sizes_config['far_files'], args.build_out_dir) |
| package_sizes = GetPackageSizes(package_blobs) |
| |
| # Optionally calculate total compressed and uncompressed package sizes. |
| if 'far_total_name' in sizes_config: |
| compressed = sum([a.compressed for a in package_sizes.values()]) |
| uncompressed = sum([a.uncompressed for a in package_sizes.values()]) |
| package_sizes[sizes_config['far_total_name']] = PackageSizes( |
| compressed, uncompressed) |
| |
| for name, size in package_sizes.items(): |
| print('%s: compressed size %d, uncompressed size %d' % |
| (name, size.compressed, size.uncompressed)) |
| |
| return package_sizes, package_blobs |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| '--build-out-dir', |
| '--output-directory', |
| type=os.path.realpath, |
| required=True, |
| help='Location of the build artifacts.', |
| ) |
| parser.add_argument( |
| '--isolated-script-test-output', |
| type=os.path.realpath, |
| help='File to which simplified JSON results will be written.') |
| parser.add_argument( |
| '--size-plugin-json-path', |
| help='Optional path for json size data for the Gerrit binary size plugin', |
| ) |
| parser.add_argument( |
| '--sizes-path', |
| default=os.path.join('tools', 'fuchsia', 'size_tests', 'fyi_sizes.json'), |
| help='path to package size limits json file. The path is relative to ' |
| 'the workspace src directory') |
| parser.add_argument('--verbose', |
| '-v', |
| action='store_true', |
| help='Enable verbose output') |
| # Accepted to conform to the isolated script interface, but ignored. |
| parser.add_argument('--isolated-script-test-filter', help=argparse.SUPPRESS) |
| parser.add_argument('--isolated-script-test-perf-output', |
| help=argparse.SUPPRESS) |
| args = parser.parse_args() |
| |
| if args.verbose: |
| print('Fuchsia binary sizes') |
| print('Working directory', os.getcwd()) |
| print('Args:') |
| for var in vars(args): |
| print(' {}: {}'.format(var, getattr(args, var) or '')) |
| |
| if not os.path.isdir(args.build_out_dir): |
| raise Exception('Could not find build output directory "%s".' % |
| args.build_out_dir) |
| |
| with open(os.path.join(DIR_SRC_ROOT, args.sizes_path)) as sizes_file: |
| sizes_config = json.load(sizes_file) |
| |
| if args.verbose: |
| print('Sizes Config:') |
| print(json.dumps(sizes_config)) |
| |
| for far_rel_path in sizes_config['far_files']: |
| far_abs_path = os.path.join(args.build_out_dir, far_rel_path) |
| if not os.path.isfile(far_abs_path): |
| raise Exception('Could not find FAR file "%s".' % far_abs_path) |
| |
| test_name = 'sizes' |
| timestamp = time.time() |
| test_completed = False |
| all_tests_passed = False |
| test_status = {} |
| package_sizes = {} |
| package_blobs = {} |
| sizes_histogram = [] |
| |
| results_directory = None |
| if args.isolated_script_test_output: |
| results_directory = os.path.join( |
| os.path.dirname(args.isolated_script_test_output), test_name) |
| if not os.path.exists(results_directory): |
| os.makedirs(results_directory) |
| |
| try: |
| package_sizes, package_blobs = GetBinarySizesAndBlobs(args, sizes_config) |
| sizes_histogram = CreateSizesHistogram(package_sizes) |
| test_completed = True |
| except: |
| _, value, trace = sys.exc_info() |
| traceback.print_tb(trace) |
| print(str(value)) |
| finally: |
| all_tests_passed, test_status = GetTestStatus(package_sizes, sizes_config, |
| test_completed) |
| |
| if results_directory: |
| WriteTestResults(os.path.join(results_directory, 'test_results.json'), |
| test_completed, test_status, timestamp) |
| with open(os.path.join(results_directory, 'perf_results.json'), 'w') as f: |
| json.dump(sizes_histogram, f) |
| WritePackageBlobsJson( |
| os.path.join(results_directory, PACKAGES_BLOBS_FILE), package_blobs) |
| WritePackageSizesJson( |
| os.path.join(results_directory, PACKAGES_SIZES_FILE), package_sizes) |
| |
| if args.isolated_script_test_output: |
| WriteTestResults(args.isolated_script_test_output, test_completed, |
| test_status, timestamp) |
| |
| if args.size_plugin_json_path: |
| WriteGerritPluginSizeData(args.size_plugin_json_path, package_sizes) |
| |
| return 0 if all_tests_passed else 1 |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |