| #!/usr/bin/env python3 |
| # |
| # Copyright (C) 2022 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| """A tool to print human-readable metrics information regarding the last build. |
| |
| By default, the consumed files will be located in $ANDROID_BUILD_TOP/out/. You |
| may pass in a different directory instead using the metrics_files_dir flag. |
| """ |
| |
| import argparse |
| import json |
| import os |
| import shutil |
| import subprocess |
| import sys |
| import tarfile |
| |
| from bazel_metrics_proto.bazel_metrics_pb2 import BazelMetrics |
| from bp2build_metrics_proto.bp2build_metrics_pb2 import Bp2BuildMetrics, UnconvertedReasonType |
| from google.protobuf import json_format |
| from metrics_proto.metrics_pb2 import MetricsBase, SoongBuildMetrics |
| |
| |
| class Event(object): |
| """Contains nested event data. |
| |
| Fields: |
| name: The short name of this event e.g. the 'b' in an event called a.b. |
| start_time_relative_ns: Time since the epoch that the event started |
| duration_ns: Duration of this event, including time spent in children. |
| """ |
| |
| def __init__(self, name, start_time_relative_ns, duration_ns): |
| self.name = name |
| self.start_time_relative_ns = start_time_relative_ns |
| self.duration_ns = duration_ns |
| |
| |
| def _get_output_file(output_dir, filename): |
| file_base = os.path.splitext(filename)[0] |
| return os.path.join(output_dir, file_base + ".json") |
| |
| |
| def _get_default_out_dir(metrics_dir): |
| return os.path.join(metrics_dir, "analyze_build_output") |
| |
| |
| def _get_default_metrics_dir(): |
| """Returns the filepath for the build output.""" |
| out_dir = os.getenv("OUT_DIR") |
| if out_dir: |
| return out_dir |
| build_top = os.getenv("ANDROID_BUILD_TOP") |
| if not build_top: |
| raise Exception( |
| "$ANDROID_BUILD_TOP not found in environment. Have you run lunch?" |
| ) |
| return os.path.join(build_top, "out") |
| |
| |
| def _write_event(out, event): |
| """Writes an event. See _write_events for args.""" |
| out.write( |
| "%(start)9s %(duration)9s %(name)s\n" |
| % { |
| "start": _format_ns(event.start_time_relative_ns), |
| "duration": _format_ns(event.duration_ns), |
| "name": event.name, |
| } |
| ) |
| |
| |
| def _print_metrics_event_times(description, metrics): |
| # Bail if there are no events |
| raw_events = metrics.events |
| if not raw_events: |
| print("%s: No events to display" % description) |
| return |
| print("-- %s events --" % description) |
| |
| # Update the start times to be based on the first event |
| first_time_ns = min([event.start_time for event in raw_events]) |
| events = [ |
| Event( |
| getattr(e, "description", e.name), |
| e.start_time - first_time_ns, |
| e.real_time, |
| ) |
| for e in raw_events |
| ] |
| |
| # Sort by start time so the nesting also is sorted by time |
| events.sort(key=lambda x: x.start_time_relative_ns) |
| |
| # Output the results |
| print(" start duration") |
| |
| for event in events: |
| _write_event(sys.stdout, event) |
| print() |
| |
| |
| def _format_ns(duration_ns): |
| "Pretty print duration in nanoseconds" |
| return "%.02fs" % (duration_ns / 1_000_000_000) |
| |
| |
| def _read_data(filepath, proto): |
| with open(filepath, "rb") as f: |
| proto.ParseFromString(f.read()) |
| f.close() |
| |
| |
| def _maybe_save_data(proto, filename, args): |
| if args.skip_metrics: |
| return |
| json_out = json_format.MessageToJson(proto) |
| output_filepath = _get_output_file(args.output_dir, filename) |
| _save_file(json_out, output_filepath) |
| |
| |
| def _save_file(data, file): |
| with open(file, "w") as f: |
| f.write(data) |
| f.close() |
| |
| |
| def _handle_missing_metrics(args, filename): |
| """Handles cleanup for a metrics file that doesn't exist. |
| |
| This will delete any output files under the tool's output directory that |
| would have been generated as a result of a metrics file from a previous |
| build. This prevents stale analysis files from polluting the output dir. |
| """ |
| if args.skip_metrics: |
| # If skip_metrics is enabled, then don't write or delete any data. |
| return |
| output_filepath = _get_output_file(args.output_dir, filename) |
| if os.path.exists(output_filepath): |
| os.remove(output_filepath) |
| |
| |
| def process_timing_mode(args): |
| metrics_files_dir = args.metrics_files_dir |
| if not args.skip_metrics: |
| os.makedirs(args.output_dir, exist_ok=True) |
| print("Writing build analysis files to " + args.output_dir, file=sys.stderr) |
| |
| bp2build_file = os.path.join(metrics_files_dir, "bp2build_metrics.pb") |
| if os.path.exists(bp2build_file): |
| bp2build_metrics = Bp2BuildMetrics() |
| _read_data(bp2build_file, bp2build_metrics) |
| _print_metrics_event_times("bp2build", bp2build_metrics) |
| _maybe_save_data(bp2build_metrics, "bp2build_metrics.pb", args) |
| else: |
| _handle_missing_metrics(args, "bp2build_metrics.pb") |
| |
| soong_build_file = os.path.join(metrics_files_dir, "soong_build_metrics.pb") |
| if os.path.exists(soong_build_file): |
| soong_build_metrics = SoongBuildMetrics() |
| _read_data(soong_build_file, soong_build_metrics) |
| _print_metrics_event_times("soong_build", soong_build_metrics) |
| _maybe_save_data(soong_build_metrics, "soong_build_metrics.pb", args) |
| else: |
| _handle_missing_metrics(args, "soong_build_metrics.pb") |
| |
| soong_metrics_file = os.path.join(metrics_files_dir, "soong_metrics") |
| if os.path.exists(soong_metrics_file): |
| metrics_base = MetricsBase() |
| _read_data(soong_metrics_file, metrics_base) |
| _maybe_save_data(metrics_base, "soong_metrics", args) |
| else: |
| _handle_missing_metrics(args, "soong_metrics") |
| |
| bazel_metrics_file = os.path.join(metrics_files_dir, "bazel_metrics.pb") |
| if os.path.exists(bazel_metrics_file): |
| bazel_metrics = BazelMetrics() |
| _read_data(bazel_metrics_file, bazel_metrics) |
| _maybe_save_data(bazel_metrics, "bazel_metrics.pb", args) |
| else: |
| _handle_missing_metrics(args, "bazel_metrics.pb") |
| |
| |
| def process_build_files_mode(args): |
| if args.skip_metrics: |
| raise Exception("build_files mode incompatible with --skip-metrics") |
| os.makedirs(args.output_dir, exist_ok=True) |
| tar_out = os.path.join(args.output_dir, "build_files.tar.gz") |
| |
| os.chdir(args.metrics_files_dir) |
| |
| if os.path.exists(tar_out): |
| os.remove(tar_out) |
| print("adding build files to", tar_out, "...", file=sys.stderr) |
| |
| with tarfile.open(tar_out, "w:gz", dereference=True) as tar: |
| for root, dirs, files in os.walk("."): |
| for file in files: |
| if ( |
| file.endswith(".bzl") |
| or file.endswith("BUILD") |
| or file.endswith("BUILD.bazel") |
| ): |
| tar.add(os.path.join(root, file), arcname=os.path.join(root, file)) |
| |
| |
| def process_bp2build_mode(args): |
| metrics_files_dir = args.metrics_files_dir |
| if not args.skip_metrics: |
| os.makedirs(args.output_dir, exist_ok=True) |
| print("Writing build analysis files to " + args.output_dir, file=sys.stderr) |
| |
| bp2build_file = os.path.join(metrics_files_dir, "bp2build_metrics.pb") |
| if not os.path.exists(bp2build_file): |
| raise Exception("bp2build mode requires that the last build ran bp2build") |
| |
| bp2build_metrics = Bp2BuildMetrics() |
| _read_data(bp2build_file, bp2build_metrics) |
| _maybe_save_data(bp2build_metrics, "bp2build_metrics.pb", args) |
| converted_modules = {} |
| for module in bp2build_metrics.convertedModules: |
| converted_modules[module] = True |
| |
| if len(args.module_names) > 0: |
| modules_to_report = args.module_names |
| else: |
| all_modules = {} |
| for m in converted_modules: |
| all_modules[m] = True |
| for m in bp2build_metrics.unconvertedModules: |
| all_modules[m] = True |
| modules_to_report = sorted(all_modules) |
| |
| for name in modules_to_report: |
| if name in converted_modules: |
| print(name, "converted successfully.") |
| elif name in bp2build_metrics.unconvertedModules: |
| unconverted_summary = name + " not converted: " |
| t = bp2build_metrics.unconvertedModules[name].type |
| if t > -1 and t < len(UnconvertedReasonType.keys()): |
| unconverted_summary += UnconvertedReasonType.keys()[t] |
| else: |
| unconverted_summary += "UNKNOWN_TYPE" |
| if len(bp2build_metrics.unconvertedModules[name].detail) > 0: |
| unconverted_summary += ( |
| " detail: " + bp2build_metrics.unconvertedModules[name].detail |
| ) |
| print(unconverted_summary) |
| else: |
| print(name, "does not exist.") |
| |
| |
| def _define_global_flags(parser, suppress_default=False): |
| """Adds global flags to the given parser object. |
| |
| Global flags should be added to both the global args parser and subcommand |
| parsers. This allows global flags to be specified before or after the |
| subcommand. |
| |
| Subcommand parser binding should pass suppress_default=True. This uses the |
| default value specified in the global parser. |
| """ |
| parser.add_argument( |
| "--metrics_files_dir", |
| default=( |
| argparse.SUPPRESS if suppress_default else _get_default_metrics_dir() |
| ), |
| help="The directory contained metrics files to analyze." |
| + " Defaults to $OUT_DIR if set, $ANDROID_BUILD_TOP/out otherwise.", |
| ) |
| parser.add_argument( |
| "--skip-metrics", |
| action="store_true", |
| default=(argparse.SUPPRESS if suppress_default else None), |
| help="If set, do not save the output of printproto commands.", |
| ) |
| parser.add_argument( |
| "--output_dir", |
| default=(argparse.SUPPRESS if suppress_default else None), |
| help="The directory to save analyzed proto output to. " |
| + "If unspecified, will default to the directory specified with" |
| " --metrics_files_dir + '/analyze_build_output/'", |
| ) |
| |
| |
| def main(): |
| # Parse args |
| parser = argparse.ArgumentParser( |
| description=( |
| "Analyzes build artifacts from the user's most recent build. Prints" |
| " and/or saves data in a user-friendly format. See" |
| " subcommand-specific help for analysis options." |
| ), |
| prog="analyze_build", |
| ) |
| _define_global_flags(parser) |
| subparsers = parser.add_subparsers( |
| title="subcommands", |
| help='types of analysis to run, "timing" by default.', |
| dest="mode", |
| ) |
| timing_parser = subparsers.add_parser( |
| "timing", help="print per-phase build timing information" |
| ) |
| _define_global_flags(timing_parser, True) |
| build_files_parser = subparsers.add_parser( |
| "build_files", |
| help="create a tar containing all bazel-related build files", |
| ) |
| _define_global_flags(build_files_parser, True) |
| bp2build_parser = subparsers.add_parser( |
| "bp2build", |
| help="print whether a module was generated by bp2build", |
| ) |
| _define_global_flags(bp2build_parser, True) |
| bp2build_parser.add_argument( |
| "module_names", |
| metavar="module_name", |
| nargs="*", |
| help="print conversion info about these modules", |
| ) |
| |
| args = parser.parse_args() |
| |
| # Use `timing` as the default build mode. |
| if not args.mode: |
| args.mode = "timing" |
| # Check the metrics dir. |
| if not os.path.exists(args.metrics_files_dir): |
| raise Exception( |
| "Directory " |
| + arg.metrics_files_dir |
| + " not found. Did you run a build?" |
| ) |
| |
| args.output_dir = args.output_dir or _get_default_out_dir( |
| args.metrics_files_dir |
| ) |
| |
| if args.mode == "timing": |
| process_timing_mode(args) |
| elif args.mode == "build_files": |
| process_build_files_mode(args) |
| elif args.mode == "bp2build": |
| process_bp2build_mode(args) |
| |
| |
| if __name__ == "__main__": |
| main() |