blob: 08de08cfd035ca06636846698516560c995988b1 [file]
#!/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()