| #!/usr/bin/env python3 | |
| # Copyright 2026 The Chromium Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """compare_traces.py - Performance Impact Analysis & Trace Differ for GN. | |
| Loads traces from two sets of `gn gen --tracelog=...` invocations, with each | |
| set potentially containing multiple different tracelog files, and attempts to | |
| aggregate, evaluate, and render the difference in performance between them in a | |
| human-readable format. | |
| """ | |
| import argparse | |
| import collections | |
| import dataclasses | |
| import json | |
| import math | |
| import pathlib | |
| import statistics | |
| from typing import Dict, List, Optional, Tuple | |
| DEFAULT_MIN_DELTA_MS = 5.0 | |
| DEFAULT_ALPHA = 0.05 | |
| REGRESSION_WEIGHT = 1.5 | |
| SUBSYSTEMS_TABLE_LIMIT = 10 | |
| BUILD_FILE_TABLE_LIMIT = 15 | |
| CATEGORY_DESCRIPTIONS = { | |
| 'file_write_ninja': 'Ninja File Emission', | |
| 'file_exec_template': 'Template Invocations', | |
| 'file_exec': 'BUILD.gn Execution', | |
| 'define': 'Target Definitions', | |
| 'onresolved': 'Target Resolution', | |
| 'import_load': 'Import Loading', | |
| 'script_exec': 'External Scripts', | |
| 'parse': 'AST Parsing', | |
| 'load': 'File Loading', | |
| 'import_block': 'Import Block', | |
| 'file_write_generated': 'Generated File Emission', | |
| 'walk_metadata': 'Metadata Walks', | |
| 'setup': 'Initialization & Setup', | |
| } | |
| class Stat: | |
| """Maintains sample statistics (mean, sample stddev, variance) for a metric.""" | |
| def __init__(self, values: List[float]): | |
| self.values = values | |
| self.n = len(values) | |
| self.mean = statistics.mean(values) if values else 0.0 | |
| self.variance = statistics.variance(values) if self.n > 1 else 0.0 | |
| self.stddev = statistics.stdev(values) if self.n > 1 else 0.0 | |
| def format_int(self, show_stddev: bool = True) -> str: | |
| """Formats mean and standard deviation rounded to integer milliseconds.""" | |
| if self.n > 1 and show_stddev and round(self.stddev) > 0: | |
| return f'{round(self.mean):,d} ± {round(self.stddev):,d}' | |
| return f'{round(self.mean):,d}' | |
| @dataclasses.dataclass | |
| class SingleTraceMetrics: | |
| """Extracted metrics from a single Chrome trace JSON run.""" | |
| wall_clock_ms: float | |
| total_cpu_ms: float | |
| total_events: int | |
| categories: Dict[str, dict] | |
| subsystems: Dict[str, dict] | |
| file_execs: Dict[str, dict] | |
| script_execs: Dict[str, dict] | |
| @dataclasses.dataclass | |
| class AggregatedTrace: | |
| """Aggregated statistics across multiple trace runs for a single revision.""" | |
| run_count: int | |
| wall_clock: Stat | |
| cpu_time: Stat | |
| events: Stat | |
| categories: Dict[str, Dict[str, Stat]] | |
| subsystems: Dict[str, Dict[str, Stat]] | |
| file_execs: Dict[str, Dict[str, Stat]] | |
| script_execs: Dict[str, Dict[str, Stat]] | |
| @dataclasses.dataclass | |
| class ChangedItem: | |
| name: str | |
| before_ms: float | |
| after_ms: float | |
| delta_ms: float | |
| delta_pct: float | |
| p_val: Optional[float] | |
| relevance_score: float | |
| def _betacf(a: float, b: float, x: float, max_iter: int = 200) -> float: | |
| """Continued fraction for regularized incomplete beta function (Lentz method).""" | |
| qab = a + b | |
| qap = a + 1.0 | |
| qam = a - 1.0 | |
| c, d = 1.0, 1.0 - qab * x / qap | |
| if abs(d) < 1e-30: | |
| d = 1e-30 | |
| d = 1.0 / d | |
| h = d | |
| for m in range(1, max_iter): | |
| m2 = 2 * m | |
| aa = m * (b - m) * x / ((qam + m2) * (a + m2)) | |
| d = 1.0 + aa * d | |
| if abs(d) < 1e-30: | |
| d = 1e-30 | |
| c = 1.0 + aa / c | |
| if abs(c) < 1e-30: | |
| c = 1e-30 | |
| d = 1.0 / d | |
| h *= d * c | |
| aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2)) | |
| d = 1.0 + aa * d | |
| if abs(d) < 1e-30: | |
| d = 1e-30 | |
| c = 1.0 + aa / c | |
| if abs(c) < 1e-30: | |
| c = 1e-30 | |
| d = 1.0 / d | |
| del_h = d * c | |
| h *= del_h | |
| if abs(del_h - 1.0) < 1e-12: | |
| break | |
| return h | |
| def betainc(a: float, b: float, x: float) -> float: | |
| """Regularized incomplete beta function I_x(a, b).""" | |
| if x <= 0: | |
| return 0.0 | |
| if x >= 1: | |
| return 1.0 | |
| front = math.exp( | |
| math.lgamma(a + b) | |
| - math.lgamma(a) | |
| - math.lgamma(b) | |
| + a * math.log(x) | |
| + b * math.log(1.0 - x) | |
| ) | |
| if x < (a + 1.0) / (a + b + 2.0): | |
| return front * _betacf(a, b, x) / a | |
| else: | |
| return 1.0 - front * _betacf(b, a, 1.0 - x) / b | |
| def compute_p_value(b_stat: Stat, a_stat: Stat) -> Optional[float]: | |
| """Computes two-tailed p-value using Welch's t-test with Welch-Satterthwaite df.""" | |
| if b_stat.n < 2 or a_stat.n < 2: | |
| return None | |
| v1 = b_stat.variance / b_stat.n | |
| v2 = a_stat.variance / a_stat.n | |
| denom = math.sqrt(v1 + v2) | |
| if denom == 0: | |
| return 1.0 if b_stat.mean == a_stat.mean else 0.0 | |
| t = (a_stat.mean - b_stat.mean) / denom | |
| df_num = (v1 + v2) ** 2 | |
| df_den = (v1**2) / (b_stat.n - 1) + (v2**2) / (a_stat.n - 1) | |
| df = df_num / df_den if df_den > 0 else 1.0 | |
| x = df / (df + t * t) | |
| return betainc(0.5 * df, 0.5, x) | |
| def benjamini_hochberg_adjust(p_values: List[float]) -> List[float]: | |
| """Controls False Discovery Rate (FDR) across thousands of parallel comparisons. | |
| When evaluating thousands of files, a standard alpha=0.05 cutoff produces ~5% | |
| false positives by pure chance. The Benjamini-Hochberg procedure adjusts | |
| p-values so the global expected false discovery rate remains below alpha. | |
| """ | |
| m = len(p_values) | |
| if m == 0: | |
| return [] | |
| indexed_p = sorted(enumerate(p_values), key=lambda x: x[1]) | |
| adjusted = [0.0] * m | |
| running_min = 1.0 | |
| for rank_rev, (orig_idx, p) in enumerate(reversed(indexed_p)): | |
| rank = m - rank_rev | |
| adj_p = min(1.0, (m / rank) * p) | |
| running_min = min(running_min, adj_p) | |
| adjusted[orig_idx] = running_min | |
| return adjusted | |
| def compute_relevance_score(delta_ms: float, p_val: Optional[float]) -> float: | |
| """Ranks changes by actionability: magnitude * certainty * regression priority. | |
| Regressions receive a 1.5x weight over speedups so problematic files appear | |
| at the top of the report. | |
| """ | |
| abs_delta = abs(delta_ms) | |
| direction_weight = REGRESSION_WEIGHT if delta_ms > 0 else 1.0 | |
| if p_val is not None: | |
| # Scale certainty by -log10(p): p=0.001 -> 3.0, p=0.01 -> 2.0, p=0.05 -> 1.3 | |
| certainty = -math.log10(max(p_val, 1e-4)) | |
| else: | |
| certainty = 1.0 | |
| return abs_delta * certainty * direction_weight | |
| def load_trace_events(trace_path: pathlib.Path) -> List[dict]: | |
| """Reads Chrome trace JSON file and returns the list of traceEvents.""" | |
| with open(trace_path, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| return data.get('traceEvents', []) | |
| def _get_subsystem(name: str) -> str: | |
| """Extracts the top-level subsystem directory (e.g. //foo/bar).""" | |
| package = name.lstrip('/').split('/')[:-1] | |
| # Eg. //foo/bar/baz/BUILD.gn -> //foo/bar | |
| return '//' + '/'.join(package[:2]) | |
| def extract_single_trace_metrics(events: List[dict]) -> SingleTraceMetrics: | |
| """Processes raw events for one run into categories, subsystems, and files.""" | |
| no_event = lambda: {'dur_us': 0, 'count': 0} | |
| categories = collections.defaultdict(no_event) | |
| by_category = collections.defaultdict( | |
| lambda: collections.defaultdict(no_event) | |
| ) | |
| subsystems = collections.defaultdict(no_event) | |
| by_thread_intervals = collections.defaultdict(list) | |
| def add_event(collection: dict, key: str, dur_us: int): | |
| entry = collection[key] | |
| entry['dur_us'] += dur_us | |
| entry['count'] += 1 | |
| min_ts = None | |
| max_ts = None | |
| total_events = 0 | |
| for event in events: | |
| # Only complete events ('ph': 'X') represent measured function execution | |
| if event.get('ph') != 'X': | |
| continue | |
| total_events += 1 | |
| cat = event.get('cat', 'unknown') | |
| dur = event.get('dur', 0) | |
| ts = event.get('ts', 0) | |
| name = event.get('name', '') | |
| tid = event.get('tid', 0) | |
| min_ts = ts if min_ts is None else min(min_ts, ts) | |
| max_ts = ts + dur if max_ts is None else max(max_ts, ts + dur) | |
| add_event(categories, cat, dur) | |
| add_event(by_category[cat], name, dur) | |
| by_thread_intervals[tid].append((ts, ts + dur)) | |
| if cat in ('file_exec', 'parse'): | |
| add_event(subsystems, _get_subsystem(name), dur) | |
| wall_clock_ms = ( | |
| ((max_ts - min_ts) / 1000.0) | |
| if (min_ts is not None and max_ts is not None) | |
| else 0.0 | |
| ) | |
| # Merge overlapping intervals per thread to eliminate nested event overcounting | |
| total_cpu_us = 0 | |
| for intervals in by_thread_intervals.values(): | |
| intervals.sort() | |
| last = float('-inf') | |
| for start, end in intervals: | |
| start = max(start, last) | |
| total_cpu_us += max(end - start, 0) | |
| last = max(last, end) | |
| return SingleTraceMetrics( | |
| wall_clock_ms=wall_clock_ms, | |
| total_cpu_ms=total_cpu_us / 1000, | |
| total_events=total_events, | |
| categories=categories, | |
| subsystems=subsystems, | |
| file_execs=by_category['file_exec'], | |
| script_execs=by_category['script_exec'], | |
| ) | |
| def _aggregate_metric_map( | |
| runs_metrics: List[Dict[str, dict]], | |
| ) -> Dict[str, Dict[str, Stat]]: | |
| """Aggregates a metric map (categories, files, etc.) across multiple runs into Stats.""" | |
| all_keys = set() | |
| for m in runs_metrics: | |
| all_keys.update(m.keys()) | |
| aggregated = {} | |
| for key in all_keys: | |
| durs = [m.get(key, {}).get('dur_us', 0) / 1000.0 for m in runs_metrics] | |
| counts = [m.get(key, {}).get('count', 0) for m in runs_metrics] | |
| aggregated[key] = { | |
| 'duration_ms': Stat(durs), | |
| 'count': Stat(counts), | |
| } | |
| return aggregated | |
| def aggregate_multiple_traces( | |
| trace_paths: List[pathlib.Path], | |
| ) -> AggregatedTrace: | |
| """Loads multiple trace files for a revision and returns averaged Stats.""" | |
| runs = [ | |
| extract_single_trace_metrics(load_trace_events(p)) for p in trace_paths | |
| ] | |
| return AggregatedTrace( | |
| run_count=len(runs), | |
| wall_clock=Stat([r.wall_clock_ms for r in runs]), | |
| cpu_time=Stat([r.total_cpu_ms for r in runs]), | |
| events=Stat([r.total_events for r in runs]), | |
| categories=_aggregate_metric_map([r.categories for r in runs]), | |
| subsystems=_aggregate_metric_map([r.subsystems for r in runs]), | |
| file_execs=_aggregate_metric_map([r.file_execs for r in runs]), | |
| script_execs=_aggregate_metric_map([r.script_execs for r in runs]), | |
| ) | |
| def find_significant_changes( | |
| b_map: Dict[str, Dict[str, Stat]], | |
| a_map: Dict[str, Dict[str, Stat]], | |
| b_count: int, | |
| a_count: int, | |
| min_delta_ms: float, | |
| alpha: float, | |
| has_multi_run: bool, | |
| ) -> List[ChangedItem]: | |
| """Identifies items with significant deltas, applies FDR correction, and ranks by relevance.""" | |
| all_keys = set(b_map.keys()) | set(a_map.keys()) | |
| candidates = [] | |
| for key in all_keys: | |
| b_stat = b_map.get(key, {}).get('duration_ms', Stat([0.0] * b_count)) | |
| a_stat = a_map.get(key, {}).get('duration_ms', Stat([0.0] * a_count)) | |
| d_ms = a_stat.mean - b_stat.mean | |
| if abs(d_ms) >= min_delta_ms: | |
| p_val = compute_p_value(b_stat, a_stat) if has_multi_run else None | |
| candidates.append((key, b_stat.mean, a_stat.mean, d_ms, p_val)) | |
| if has_multi_run: | |
| testable = [c for c in candidates if c[4] is not None] | |
| adj_p_values = benjamini_hochberg_adjust([c[4] for c in testable]) | |
| significant_items = [ | |
| ChangedItem( | |
| key, | |
| b_ms, | |
| a_ms, | |
| d_ms, | |
| (d_ms / b_ms * 100.0) if b_ms > 0 else 0.0, | |
| adj_p, | |
| compute_relevance_score(d_ms, adj_p), | |
| ) | |
| for (key, b_ms, a_ms, d_ms, _), adj_p in zip(testable, adj_p_values) | |
| if adj_p < alpha | |
| ] | |
| else: | |
| significant_items = [ | |
| ChangedItem( | |
| key, | |
| b_ms, | |
| a_ms, | |
| d_ms, | |
| (d_ms / b_ms * 100.0) if b_ms > 0 else 0.0, | |
| None, | |
| compute_relevance_score(d_ms, None), | |
| ) | |
| for key, b_ms, a_ms, d_ms, _ in candidates | |
| ] | |
| # Rank by Relevance Score (regressions first, then largest speedups) | |
| significant_items.sort(key=lambda item: item.relevance_score, reverse=True) | |
| return significant_items | |
| def print_executive_verdict( | |
| b: AggregatedTrace, a: AggregatedTrace, alpha: float | |
| ) -> None: | |
| """Prints high-level conclusion on whether performance changed.""" | |
| wall_delta = a.wall_clock.mean - b.wall_clock.mean | |
| wall_pct = ( | |
| (wall_delta / b.wall_clock.mean * 100.0) if b.wall_clock.mean > 0 else 0.0 | |
| ) | |
| wall_p = compute_p_value(b.wall_clock, a.wall_clock) | |
| has_multi_run = b.run_count > 1 and a.run_count > 1 | |
| print('\nEXECUTIVE VERDICT') | |
| print('-' * 86) | |
| if not has_multi_run: | |
| sign = '+' if round(wall_delta) > 0 else '' | |
| print( | |
| ' Single-run comparison: Wall-clock delta' | |
| f' {sign}{round(wall_delta):,d} ms ({sign}{wall_pct:.1f}%)' | |
| ) | |
| print( | |
| ' (Tip: Pass multiple --before and --after runs for statistical' | |
| ' confidence testing)' | |
| ) | |
| elif wall_p is not None and wall_p < alpha and abs(wall_pct) >= 1.0: | |
| if wall_delta > 0: | |
| print( | |
| f' ⚠️ REGRESSION DETECTED: Slower by +{round(wall_delta):,d} ms' | |
| f' (+{wall_pct:.1f}%)' | |
| ) | |
| else: | |
| print( | |
| f' 🚀 SPEEDUP DETECTED: Faster by -{abs(round(wall_delta)):,d} ms' | |
| f' ({wall_pct:.1f}%)' | |
| ) | |
| else: | |
| print( | |
| ' ✓ NO MEASURABLE OVERALL IMPACT DETECTED (Wall-clock delta' | |
| f' {round(wall_delta):+d} ms / {wall_pct:+.1f}%)' | |
| ) | |
| b_wc = f'{b.wall_clock.format_int()} ms' | |
| a_wc = f'{a.wall_clock.format_int()} ms' | |
| b_cpu = f'{b.cpu_time.format_int()} ms' | |
| a_cpu = f'{a.cpu_time.format_int()} ms' | |
| b_ev = f'{round(b.events.mean):,d}' | |
| a_ev = f'{round(a.events.mean):,d}' | |
| ev_delta = f'(Δ {round(a.events.mean - b.events.mean):+d})' | |
| print(f'\n • Wall-clock / run: Before: {b_wc:>17} -> After: {a_wc:>17}') | |
| print(f' • Total CPU / run: Before: {b_cpu:>17} -> After: {a_cpu:>17}') | |
| print( | |
| f' • Events / run: Before: {b_ev:>17} -> After: {a_ev:>17} ' | |
| f' {ev_delta}' | |
| ) | |
| print() | |
| def print_changed_items_table( | |
| item_type: str, | |
| items: List[ChangedItem], | |
| max_items: int, | |
| ) -> None: | |
| """Prints a ranked table of changed items, or a clean confirmation if empty.""" | |
| print(f'AFFECTED {item_type.upper()} (Ranked by impact)') | |
| print('-' * 86) | |
| if items: | |
| print( | |
| f'{"Delta (ms)":>11} {"Delta (%)":>10} {"Before":>11} {"After":>11}' | |
| f' {"Name"}' | |
| ) | |
| print('-' * 86) | |
| for item in items[:max_items]: | |
| sign = '+' if round(item.delta_ms) > 0 else '' | |
| pct_sign = '+' if item.delta_pct > 0 else '' | |
| print( | |
| f'{sign + f"{round(item.delta_ms):,d} ms":>11}' | |
| f' {pct_sign + f"{item.delta_pct:.1f}%":>9}' | |
| f' {round(item.before_ms):>8,d} ms {round(item.after_ms):>8,d} ms ' | |
| f' {item.name}' | |
| ) | |
| else: | |
| print( | |
| f' ✓ Discarded all noise: No {item_type} had statistically significant' | |
| ' regressions or speedups.' | |
| ) | |
| print() | |
| def analyze_and_report( | |
| b: AggregatedTrace, | |
| a: AggregatedTrace, | |
| min_delta_ms: float = DEFAULT_MIN_DELTA_MS, | |
| alpha: float = DEFAULT_ALPHA, | |
| ) -> None: | |
| """Main coordinator: evaluates traces and produces the final cleaned report.""" | |
| has_multi_run = b.run_count > 1 and a.run_count > 1 | |
| print('=' * 86) | |
| print(' GN PERFORMANCE IMPACT REPORT') | |
| print('=' * 86) | |
| print(f'Samples: Before: {b.run_count} run(s) | After: {a.run_count} run(s)') | |
| print_executive_verdict(b, a, alpha) | |
| phase_changes = find_significant_changes( | |
| b.categories, | |
| a.categories, | |
| b.run_count, | |
| a.run_count, | |
| min_delta_ms, | |
| alpha, | |
| has_multi_run, | |
| ) | |
| for item in phase_changes: | |
| item.name = CATEGORY_DESCRIPTIONS.get(item.name, item.name) | |
| print_changed_items_table( | |
| 'GN phases', phase_changes, max_items=len(CATEGORY_DESCRIPTIONS) | |
| ) | |
| subsystem_changes = find_significant_changes( | |
| b.subsystems, | |
| a.subsystems, | |
| b.run_count, | |
| a.run_count, | |
| min_delta_ms, | |
| alpha, | |
| has_multi_run, | |
| ) | |
| print_changed_items_table( | |
| 'subsystems', subsystem_changes, max_items=SUBSYSTEMS_TABLE_LIMIT | |
| ) | |
| file_changes = find_significant_changes( | |
| b.file_execs, | |
| a.file_execs, | |
| b.run_count, | |
| a.run_count, | |
| min_delta_ms, | |
| alpha, | |
| has_multi_run, | |
| ) | |
| print_changed_items_table( | |
| 'BUILD files', file_changes, max_items=BUILD_FILE_TABLE_LIMIT | |
| ) | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| 'GN Trace Impact Report: Discards noise, ranks by relevance, and' | |
| ' highlights affected parts.' | |
| ), | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog="""Examples: | |
| compare_traces.py --before 1.trace 2.trace 3.trace --after 4.trace 5.trace 6.trace | |
| compare_traces.py --before before.trace --after after.trace | |
| """, | |
| ) | |
| parser.add_argument( | |
| '--before', | |
| nargs='+', | |
| action='extend', | |
| type=pathlib.Path, | |
| required=True, | |
| help='One or more "before" trace JSON files.', | |
| ) | |
| parser.add_argument( | |
| '--after', | |
| nargs='+', | |
| action='extend', | |
| type=pathlib.Path, | |
| required=True, | |
| help='One or more "after" trace JSON files.', | |
| ) | |
| parser.add_argument( | |
| '--min-delta', | |
| type=float, | |
| default=DEFAULT_MIN_DELTA_MS, | |
| help=( | |
| 'Minimum effect size in ms to consider (default:' | |
| f' {DEFAULT_MIN_DELTA_MS}).' | |
| ), | |
| ) | |
| parser.add_argument( | |
| '--alpha', | |
| type=float, | |
| default=DEFAULT_ALPHA, | |
| help=f'Significance threshold FDR Q-value (default: {DEFAULT_ALPHA}).', | |
| ) | |
| args = parser.parse_args() | |
| before = aggregate_multiple_traces(args.before) | |
| after = aggregate_multiple_traces(args.after) | |
| analyze_and_report( | |
| before, after, min_delta_ms=args.min_delta, alpha=args.alpha | |
| ) | |
| if __name__ == '__main__': | |
| main() |