| #!/usr/bin/env python3 |
| # Copyright 2025 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Analyses the time spent on #including each file in a build. |
| |
| Requires a full build to be run with the following in args.gn: |
| ``` |
| # Runs with -ftime-trace to profile every build action |
| compiler_timing = true |
| # Rust bindgen gets upset when you try and add -ftime-trace. |
| treat_warnings_as_errors = false |
| # crbug.com/462570809 (bug in clang causes occasional crashes when you use |
| # -ftime-trace with modules) |
| use_clang_modules = false |
| ``` |
| """ |
| |
| from __future__ import annotations |
| |
| import argparse |
| import dataclasses |
| import json |
| import multiprocessing |
| import pathlib |
| |
| import utils |
| |
| ftime_pb2 = utils.import_protobufs('ftime.proto') |
| |
| |
| # This is an optimization technique. path_to_id is expensive to copy, so we |
| # do it here to ensure it copies once per worker instead of once per job |
| def _initialize_worker(path_to_id): |
| global PATH_TO_ID |
| PATH_TO_ID = path_to_id |
| |
| |
| # Parsing has a lot of both i/o and computation, so we use smaller chunks. |
| _PARSE_CHUNKSIZE = 20 |
| # Aggregation is very cheap and involves no I/O, so we want very large chunks. |
| _AGG_CHUNKSIZE = 100 |
| |
| |
| def main(args): |
| out = utils.CapacitorFile(args.out_file) |
| |
| traces = _collect_traces(args.out_dir, args.limit) |
| |
| # Mapping from filename to (ID, each time the source was seen) |
| sources: dict[str, tuple[int, list[Source]]] = {} |
| next_id = 0 |
| total = 0 |
| total_source = 0 |
| n_compiles = 0 |
| with multiprocessing.Pool() as p: |
| print('Note: Parsing and aggregating can take several minutes') |
| print('Parsing traces') |
| parsed = p.imap_unordered(_parse_trace, traces, chunksize=_PARSE_CHUNKSIZE) |
| for compile_total, compile_total_source, compile_sources in parsed: |
| if compile_total_source is not None: |
| total += compile_total |
| total_source += compile_total_source |
| n_compiles += 1 |
| for source in compile_sources: |
| if source.name not in sources: |
| sources[source.name] = (next_id, []) |
| next_id += 1 |
| sources[source.name][1].append(source) |
| |
| print('Aggregating traces') |
| path_to_id = {k: v[0] for k, v in sources.items()} |
| with multiprocessing.Pool(initializer=_initialize_worker, |
| initargs=(path_to_id, )) as p: |
| details = sorted(p.imap_unordered(_aggregate, |
| (v[1] for v in sources.values()), |
| chunksize=_AGG_CHUNKSIZE), |
| key=lambda x: x.id) |
| |
| print(f'Dumping to {out}') |
| out.write( |
| ftime_pb2.Analysis( |
| out_dir=str(args.out_dir), |
| total_us=total, |
| total_source_us=total_source, |
| n_compiles=n_compiles, |
| sources=details, |
| )) |
| |
| |
| @dataclasses.dataclass |
| class Source: |
| """Represents a single compile of a single source file""" |
| name: str |
| transitive_us: int = 0 |
| duration_us: int = 0 |
| includes: list[Source] = dataclasses.field(default_factory=list) |
| |
| # Come up with a representation that isn't recursive on includes. |
| def __repr__(self): |
| return repr({ |
| 'name': self.name, |
| 'transitive': self.transitive_us, |
| 'direct': self.direct_us, |
| 'includes': [i.name for i in self.includes], |
| }) |
| |
| |
| def _collect_traces(out_dir: pathlib.Path, limit: int | None = None): |
| traces = [] |
| print('Finding json files') |
| for d, _, filenames in out_dir.walk(): |
| all_files = set(filenames) |
| for f in filenames: |
| idx = f.find('.json') |
| if idx > 0 and f[:idx] + '.o' in all_files: |
| traces.append(d / f) |
| |
| if limit: |
| # Sort for determinism, since that's useful when debugging. |
| traces = sorted(traces)[:limit] |
| print(f'Found {len(traces)} trace files.') |
| return traces |
| |
| |
| def _aggregate(sources: list[Source]) -> ftime_pb2.SourceFile: |
| """Aggregates many source files into one.""" |
| name = sources[0].name |
| direct_us = 0 |
| transitive_us = 0 |
| includes = set() |
| for source in sources: |
| direct_us += source.direct_us |
| transitive_us += source.transitive_us |
| includes.update([PATH_TO_ID[s.name] for s in source.includes]) |
| return ftime_pb2.SourceFile(name=name, |
| id=PATH_TO_ID[name], |
| direct_us=direct_us, |
| transitive_us=transitive_us, |
| count=len(sources), |
| includes=sorted(includes)) |
| |
| |
| def _parse_trace(path: pathlib.Path) -> tuple[int, int | None, list[Source]]: |
| # Not every trace has a "Total Source". |
| total_source = None |
| events = [] |
| # Filter to only the events we care about. |
| with path.open('r') as f: |
| for event in json.load(f)['traceEvents']: |
| match event['name']: |
| case 'Source': |
| events.append(event) |
| case 'Total Source': |
| total_source = event['dur'] |
| case 'Total ExecuteCompiler': |
| total = event['dur'] |
| |
| # Sort event by timestamp. Currently start and end events are paired next |
| # to one another. |
| # The sort is guaranteed to be stable. |
| events.sort(key=lambda e: e['ts']) |
| |
| sources = [] |
| # The include stack. Contains sources, and their beginning timestamp. |
| stack: list[tuple[Source, int]] = [] |
| for event in events: |
| if event['ph'] == 'b': # begin |
| fname = event['args']['detail'] |
| source = Source(name=fname) |
| sources.append(source) |
| if stack: |
| stack[-1][0].includes.append(source) |
| stack.append((source, event['ts'])) |
| else: |
| source, begin = stack.pop() |
| # All timestamps in traces are measured in microseconds. |
| duration = event['ts'] - begin |
| source.transitive_us = duration |
| for include in source.includes: |
| duration -= include.transitive_us |
| source.direct_us = duration |
| |
| return total, total_source, sources |
| |
| |
| if __name__ == '__main__': |
| parser = argparse.ArgumentParser() |
| parser.add_argument('-C', |
| required=True, |
| dest='out_dir', |
| help='GN output directory', |
| type=pathlib.Path) |
| parser.add_argument('-o', |
| required=True, |
| dest='out_file', |
| help='capacitor file to output', |
| type=pathlib.Path) |
| parser.add_argument( |
| '--limit', |
| type=int, |
| help='Parse a limited number of traces. Useful for debugging.', |
| ) |
| |
| main(parser.parse_args()) |