Create a trace comparison tool to evaluate the performance impact of a change.

On many occasions, it's useful to understand the performance impact of a
change to GN. In particular, when investigating changes like making ninja
files per directory instead of per target, overall build times can be noisy
and hard to measure reliably.

`tools/compare_traces.py` compares Chrome trace logs from `gn gen --tracelog`
runs to isolate the performance impact on specific build files and phases.
By averaging multiple runs and filtering out noise, it can show when a change
reliably affects a specific part of the build even if the overall wall-clock
difference is small.

Example - I modified gn to sleep for 5 seconds while loading
//chrome/browser/BUILD.gn, then got the following results:

$ tools/compare_traces.py --before before_1.trace before_2.trace \
  before_3.trace --after after_1.trace after_2.trace after_3.trace
======================================================================================
 GN PERFORMANCE IMPACT REPORT
======================================================================================
Samples:  Before: 3 run(s) | After: 3 run(s)

EXECUTIVE VERDICT
--------------------------------------------------------------------------------------
  ⚠️  REGRESSION DETECTED: Slower by +2,420 ms (+67.6%)

  • Wall-clock / run:  Before:    3,577 ± 365 ms  ->  After:     5,997 ± 17 ms
  • Total CPU / run:   Before: 64,738 ± 4,101 ms  ->  After: 76,680 ± 1,848 ms
  • Events / run:      Before:           152,059  ->  After:           152,059  (Δ +0)

AFFECTED GN PHASES (Ranked by impact)
--------------------------------------------------------------------------------------
 Delta (ms)  Delta (%)      Before       After  Name
--------------------------------------------------------------------------------------
  +6,167 ms    +31.5%   19,584 ms   25,752 ms  BUILD.gn Execution
     +26 ms     +4.1%      634 ms      660 ms  External Scripts

AFFECTED SUBSYSTEMS (Ranked by impact)
--------------------------------------------------------------------------------------
 Delta (ms)  Delta (%)      Before       After  Name
--------------------------------------------------------------------------------------
     -22 ms    -65.8%       33 ms       11 ms  //remoting/base

AFFECTED BUILD FILES (Ranked by impact)
--------------------------------------------------------------------------------------
 Delta (ms)  Delta (%)      Before       After  Name
--------------------------------------------------------------------------------------
  +4,940 ms  +7093.8%       70 ms    5,009 ms  //chrome/browser/BUILD.gn
     +25 ms    +19.8%      124 ms      149 ms  //chrome/test/BUILD.gn
     +24 ms    +84.1%       28 ms       52 ms  //components/omnibox/browser/BUILD.gn
     -22 ms    -68.5%       32 ms       10 ms  //remoting/base/BUILD.gn
     -21 ms    -69.8%       30 ms        9 ms  //ui/gl/mojom/BUILD.gn
     -20 ms    -66.8%       30 ms       10 ms  //remoting/protocol/BUILD.gn
     +10 ms    +31.7%       30 ms       40 ms  //extensions/browser/BUILD.gn
     -12 ms    -37.8%       32 ms       20 ms  //ui/accessibility/mojom/BUILD.gn

Change-Id: Ie31079d5836559d850d61692021b1e346a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/25680
Commit-Queue: Matt Stark <msta@google.com>
Reviewed-by: Takuto Ikuta <tikuta@google.com>
diff --git a/tools/compare_traces.py b/tools/compare_traces.py
new file mode 100755
index 0000000..08de08c
--- /dev/null
+++ b/tools/compare_traces.py
@@ -0,0 +1,594 @@
+#!/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()