blob: ac60a58433898659408b16b7756c0e322378e023 [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Collect Ninja trace information for analysis in chrome://tracing."""
import argparse
import dataclasses
import gzip
import json
import os
import subprocess
from concurrent import futures
from pathlib import Path
from typing import Any, Iterable
JsonTrace = dict[str, Any]
NINJA_BUILD_TRACE_BASENAME = "ninja_build_trace.json.gz"
COMPDB_BASENAME = "compdb.json"
GRAPH_BASENAME = "graph.dot"
NINJATRACE_BASENAME = "ninjatrace.json.gz"
NINJA_SUBBUILDS_JSON = "ninja_subbuilds.json"
def _subbuild_ninja_target(build_dir: Path) -> str:
"""Returns the name of the ninja target from the main build, based on the
subbuild directory."""
# LINT.IfChange
return str(build_dir.name) + ".stamp"
# LINT.ThenChange(//build/subbuild.gni)
def load_compressed_trace(trace_path: Path) -> list[JsonTrace]:
with gzip.open(trace_path) as f:
return json.load(f)
def write_compressed_trace(
trace_path: Path, trace_data: list[JsonTrace]
) -> None:
with gzip.open(trace_path, "wt") as f:
json.dump(trace_data, f)
@dataclasses.dataclass
class Tracer:
"""Helper class that closes over some static configuration."""
ninja_path: Path
ninjatrace_path: Path
rbe_rpl_path: Path
rpl2trace_path: Path
save_temps: bool
def trace_build_dir(
self,
build_dir: Path,
) -> Path:
"""Generate the Ninja trace for a single build directory (either the
main build or a subbuild).
The resulting trace will be written to `build_dir / ninjatrace.json`,
but will also be parsed and returned."""
ninja_build_trace = build_dir / NINJA_BUILD_TRACE_BASENAME
trace = build_dir / NINJATRACE_BASENAME
ninjatrace_args: list[str | os.PathLike[str]] = [
self.ninjatrace_path,
"-ninjabuildtrace",
ninja_build_trace,
"-trace-json",
trace,
"-critical-path",
]
if self.rbe_rpl_path:
ninjatrace_args.extend(
[
"-rbe-rpl-path",
self.rbe_rpl_path,
"-rpl2trace-path",
self.rpl2trace_path,
]
)
subprocess.run(ninjatrace_args, check=True)
return trace
def find_and_merge_subbuilds(
self, main_build_dir: Path, main_build_traces: list[JsonTrace]
) -> bool:
"""Given the main build dir and its trace, find any subbuilds referenced
by that trace, build them, load them, and merge them into a single
trace file.
Returns True if the main build trace has been modified with the merged subbuild traces.
"""
# ninja_subbuilds.json is a build API module listing the set of
# directories that _could_ contain subbuilds, but those subbuilds may
# not have actually run as part of the last build.
with (main_build_dir / NINJA_SUBBUILDS_JSON).open() as f:
possible_subbuild_dirs = [
Path(b["build_dir"]) for b in json.load(f)
]
# If there aren't any subbuilds possible, exit early so we don't spend any more time on
# processing the main traces to include the subbuilds.
if not possible_subbuild_dirs:
return False
# Create a list of possible trace event names for the possible subbuilds
# this is used to filter the main build trace events to find the any
# subbuilds that were run.
possible_subbuild_target_names = {
_subbuild_ninja_target(b): b for b in possible_subbuild_dirs
}
# Get the JsonTrace from the main build for each of the subbuilds. These are used to
# establish the start-time for each of the subbuilds' traces in the merged trace file.
# This filtering is so that the main_build_traces list only needs to be iterated over
# once, comparing against a very short list of possible target names.
main_build_traces_by_subbuild_dir: dict[Path, JsonTrace] = {
possible_subbuild_target_names[t["name"]]: t
for t in main_build_traces
if t["name"] in possible_subbuild_target_names
}
# If there weren't any subbuilds in the last build, then exit early
if not main_build_traces_by_subbuild_dir:
return False
# Load the traces for each of the subbuild dirs in parallel
with futures.ThreadPoolExecutor() as pool:
subbuilds_traces: Iterable[list[JsonTrace]] = pool.map(
lambda subbuild_dir:
# Subbuilds don't need extra targets (as of this writing).
load_compressed_trace(
self.trace_build_dir(
build_dir=main_build_dir / subbuild_dir
)
),
main_build_traces_by_subbuild_dir,
)
# For each of the subbuilds, take all the trace events and offset their ts by
# the start time of the corresponding trace from the main build, and set the
# pid field to the name of the subbuild, and add them to the set of main traces
for (
(subbuild_dir, target_in_main_build_trace),
subbuild_traces,
) in zip(main_build_traces_by_subbuild_dir.items(), subbuilds_traces):
subbuild_start = target_in_main_build_trace["ts"]
subbuild_name = subbuild_dir.name
main_build_traces.extend(
[
{
**t,
# Rewrite the trace to set "pid" to indicate the subbuild.
"pid": subbuild_name,
# And offset the time by the start time of the subbuild
# action in the main build./
"ts": t["ts"] + subbuild_start,
}
for t in subbuild_traces
]
)
# We've modified the traces, so return True to signal that the merged output
# needs to be written.
return True
def merge_profile(profile: Path, main_build_traces: list[JsonTrace]) -> bool:
with open(profile) as f:
raw_trace: dict[str, Any] = json.load(f)
main_build_traces.extend(raw_trace["traceEvents"])
return True
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--extra-ninja-targets",
nargs="*",
help="""\
If you ran a full `fx build`, ignore this flag. If you built a specific set of
ninja targets (e.g. `fx build my_target other_target`), some of which aren't
depended on `//:default`, list those targets here. Otherwise they might not show
up in the resulting traces.""",
)
parser.add_argument(
"--save-temps",
action="store_true",
help="""\
if set, keep the intermediate compdb.json and graph.dot files
in each build directory. The are only needed temporarily to produce the
final ninjatrace.json, and can be large at O(100)s of MBs.""",
)
parser.add_argument(
"--fuchsia-build-dir",
type=Path,
required=True,
help="Path to the Fuchsia build directory.",
)
parser.add_argument(
"--ninja-path",
type=Path,
required=True,
help="Path to the prebuilt ninja binary.",
)
parser.add_argument(
"--ninjatrace-path",
type=Path,
required=True,
help="Path to the prebuilt ninjatrace binary.",
)
parser.add_argument(
"--rbe-rpl-path",
help="when provided, interleave remote execution stats from RBE into the main trace",
)
parser.add_argument(
"--rpl2trace-path",
type=Path,
help="Path to the prebuilt rpl2trace tool.",
)
parser.add_argument(
"--subbuilds-in-place",
action="store_true",
help="""\
If set, merge traces from subbuilds with traces from the main build and
include these traces in the main build's ninjatrace.json file. Must not be
specified if --subbuilds-output-path is set.""",
)
parser.add_argument(
"--vmstat-profile",
type=Path,
help="Path to a vmstat profiling log to incorporate into the merged build.",
)
parser.add_argument(
"--ifconfig-profile",
type=Path,
help="Path to an ifconfig profiling log to incorporate into the merged build.",
)
args = parser.parse_args()
tracer = Tracer(
ninja_path=args.ninja_path,
ninjatrace_path=args.ninjatrace_path,
rbe_rpl_path=args.rbe_rpl_path,
rpl2trace_path=args.rpl2trace_path,
save_temps=args.save_temps,
)
fuchsia_build_dir = args.fuchsia_build_dir
# Convert the trace for the main build
outpath: Path = tracer.trace_build_dir(fuchsia_build_dir)
if args.subbuilds_in_place or args.vmstat_profile or args.ifconfig_profile:
# We are merging other trace files, so read in the converted trace
# for the main build.
main_build_traces = load_compressed_trace(
fuchsia_build_dir / NINJATRACE_BASENAME
)
traces_merged = False
if args.subbuilds_in_place:
traces_merged = (
tracer.find_and_merge_subbuilds(
fuchsia_build_dir, main_build_traces
)
or traces_merged
)
if args.vmstat_profile:
traces_merged = (
merge_profile(args.vmstat_profile, main_build_traces)
or traces_merged
)
if args.ifconfig_profile:
traces_merged = (
merge_profile(args.ifconfig_profile, main_build_traces)
or traces_merged
)
if traces_merged:
write_compressed_trace(
fuchsia_build_dir / NINJATRACE_BASENAME, main_build_traces
)
print(f"Now visit chrome://tracing and load {str(outpath)}")
if __name__ == "__main__":
main()