Skip to content

Commit 5debb8f

Browse files
committed
isolating the noise from the top movers
1 parent 5f5eafc commit 5debb8f

3 files changed

Lines changed: 293 additions & 49 deletions

File tree

MODULE.bazel.lock

Lines changed: 191 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

benchmark/compare.py

Lines changed: 78 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@
1414

1515
import argparse
1616
import json
17+
import math
1718
import os
1819
import sys
1920
from pathlib import Path
2021
from typing import Any
2122

2223
THRESHOLD_REGRESSION_PCT = 10
2324
EM = "\u2014" # em dash; module constant so it can appear inside f-string expressions
25+
TOTAL_SIGMA = 2.0
26+
FN_SIGMA = 3.0
27+
HOTSPOT_MIN_PCT = 1.0
2428

2529

2630
def write_gh_output(text: str) -> None:
@@ -91,55 +95,93 @@ def _short(name: str, limit: int = 48) -> str:
9195

9296

9397
def _starlark_section(main_result: dict[str, Any], pr_result: dict[str, Any]) -> str:
94-
"""Diagnostic 'where is the problem' diff of per-function Starlark CPU.
95-
96-
Informational only -- does not affect the gate. Requires both main and PR to
97-
carry a starlark_fn breakdown.
98+
"""Diagnostic Starlark CPU diff (PR vs main). Informational, not a gate.
99+
100+
Two layers of noise control so the section stays quiet on a no-op PR:
101+
- Total-level gate: only render the movers table when the PR's total
102+
Starlark CPU is significantly above main's (TOTAL_SIGMA). The total
103+
aggregates every sample, so its variance is small and the test is
104+
robust without the multiple-comparisons problem.
105+
- Per-function flagging: a mover is only marked significant when its delta
106+
exceeds FN_SIGMA * combined stderr, surviving ~100 comparisons.
107+
Hotspots (big, stable functions) are always shown.
98108
"""
99-
main_fn = main_result.get("starlark_fn") or []
100-
pr_fn = pr_result.get("starlark_fn") or []
101-
if not main_fn or not pr_fn:
102-
return ""
103-
104-
main_map = {r["name"]: r["mean_ms"] for r in main_fn}
105-
pr_map = {r["name"]: r["mean_ms"] for r in pr_fn}
106-
pr_pct = {r["name"]: r.get("pct", 0.0) for r in pr_fn}
107-
108-
movers = []
109-
for name, pr_ms in pr_map.items():
110-
m_ms = main_map.get(name, 0.0)
111-
delta = pr_ms - m_ms
112-
if delta > 0:
113-
movers.append((name, m_ms, pr_ms, delta))
114-
movers.sort(key=lambda x: x[3], reverse=True)
109+
main_sf = main_result.get("starlark_fn")
110+
pr_sf = pr_result.get("starlark_fn")
111+
if not isinstance(main_sf, dict) or not isinstance(pr_sf, dict):
112+
return "" # missing or old (pre-stddev) schema -- can't do significance
113+
114+
main_total = main_sf.get("total", {})
115+
pr_total = pr_sf.get("total", {})
116+
main_fns = {r["name"]: r for r in main_sf.get("functions", [])}
117+
pr_fns = {r["name"]: r for r in pr_sf.get("functions", [])}
118+
119+
runs = main_total.get("runs") or pr_total.get("runs") or 1
120+
n = max(runs, 1)
121+
122+
def combined_se(m_std: float, p_std: float) -> float:
123+
# Standard error of the difference of two independent means: each side
124+
# contributes stddev/sqrt(n). Buggy /(n) would shrink the noise band
125+
# ~sqrt(n)x and flag sampling jitter as significant.
126+
sn = math.sqrt(n)
127+
return math.sqrt((m_std / sn) ** 2 + (p_std / sn) ** 2)
128+
129+
total_se = combined_se(main_total.get("stddev_ms", 0.0), pr_total.get("stddev_ms", 0.0))
130+
main_total_ms = main_total.get("mean_ms", 0.0)
131+
pr_total_ms = pr_total.get("mean_ms", 0.0)
132+
delta_total = pr_total_ms - main_total_ms
133+
pct_total = pct(main_total_ms, pr_total_ms) if main_total_ms else 0.0
134+
total_significant = total_se > 0 and delta_total > TOTAL_SIGMA * total_se
115135

116136
out = "\n### \U0001f50d Starlark CPU \u2014 where the problem is (PR vs main)\n\n"
117-
if movers:
118-
out += "**Top movers (biggest regression \u0394ms):**\n\n"
119-
out += "| Function | main ms | PR ms | \u0394 ms | \u0394 % |\n|---|---|---|---|---|\n"
120-
for name, m_ms, pr_ms, delta in movers[:10]:
137+
out += (
138+
f"**Total Starlark CPU:** main {main_total_ms:.0f} ms, PR {pr_total_ms:.0f} ms "
139+
f"(\u0394 {delta_total:+.0f} ms, {pct_total:+.1f}%, "
140+
f"\u00b1{total_se:.0f} ms stderr over {runs} runs).\n\n"
141+
)
142+
143+
if not total_significant:
144+
out += (
145+
"\u2705 **No significant Starlark CPU change** \u2014 the total is within "
146+
"run-to-run noise, so per-function deltas are hidden (they are sampling "
147+
"jitter, not signal).\n"
148+
)
149+
else:
150+
movers = []
151+
for name, pr_r in pr_fns.items():
152+
m_r = main_fns.get(name)
153+
m_ms = m_r["mean_ms"] if m_r else 0.0
154+
d = pr_r["mean_ms"] - m_ms
155+
if d <= 0:
156+
continue
157+
se = combined_se(m_r.get("stddev_ms", 0.0) if m_r else 0.0, pr_r.get("stddev_ms", 0.0))
158+
movers.append((name, m_ms, pr_r["mean_ms"], d, se))
159+
movers.sort(key=lambda x: x[3], reverse=True)
160+
161+
out += "**Top movers (candidates, \u0394 > 3\u03c3 marked):**\n\n"
162+
out += "| Function | main ms | PR ms | \u0394 ms | \u00b1 stderr | \u0394 % |\n|---|---|---|---|---|---|\n"
163+
for name, m_ms, pr_ms, d, se in movers[:10]:
121164
if m_ms > 0:
122-
dpct = f"+{delta / m_ms * 100:.0f}%"
165+
dpct = f"+{d / m_ms * 100:.0f}%"
123166
else:
124167
dpct = "new"
125-
flag = " \u26a0\ufe0f" if (m_ms > 0 and delta / m_ms * 100 > THRESHOLD_REGRESSION_PCT) else ""
168+
flag = " \u26a0\ufe0f" if (se > 0 and d > FN_SIGMA * se) else ""
126169
out += (
127170
f"| `{_short(name)}` | {m_ms:.1f} | {pr_ms:.1f} | "
128-
f"+{delta:.1f} | {dpct}{flag} |\n"
171+
f"+{d:.1f} | \u00b1{se:.1f} | {dpct}{flag} |\n"
129172
)
130-
else:
131-
out += "_No movers (no function got slower in PR)._ \n"
132173

133-
abs_top = sorted(pr_map.items(), key=lambda kv: kv[1], reverse=True)[:10]
134-
out += "\n**Top by absolute time (PR):**\n\n"
135-
out += "| Function | PR ms | % of total |\n|---|---|---|\n"
136-
for name, ms in abs_top:
137-
out += f"| `{_short(name)}` | {ms:.1f} | {pr_pct.get(name, 0.0):.1f}% |\n"
174+
hot = [r for r in pr_fns.values() if r.get("pct", 0.0) >= HOTSPOT_MIN_PCT][:10]
175+
if hot:
176+
out += "\n**Top by absolute time (PR):**\n\n"
177+
out += "| Function | PR ms | % of total |\n|---|---|---|\n"
178+
for r in hot:
179+
out += f"| `{_short(r['name'])}` | {r['mean_ms']:.1f} | {r.get('pct', 0.0):.1f}% |\n"
138180

139181
out += (
140182
"\n> Sample-based attribution (Starlark CPU across the whole `--nobuild` run: "
141-
"bzlmod loading + analysis). Noisy for small functions; builtins are "
142-
"attributed to the caller.\n"
183+
"bzlmod loading + analysis). Significance is run-to-run stderr \u00d7 sigma; "
184+
"builtins are attributed to the caller.\n"
143185
)
144186
return out
145187

benchmark/profile_benchmark.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,26 +71,37 @@ def stats_ms(values_ms: list[float]) -> dict[str, float]:
7171
}
7272

7373

74-
def aggregate_starlark(star_runs: list[dict[str, float]]) -> list[dict[str, Any]]:
75-
"""Aggregate per-run {fn: ms} dicts into per-function mean CPU ms.
74+
def aggregate_starlark(star_runs: list[dict[str, float]]) -> dict[str, Any]:
75+
"""Aggregate per-run {fn: ms} dicts into per-function stats + a run-level total.
7676
77-
Returns ALL functions (sorted desc), not a top-N: truncation happens only at
78-
render time in compare.py. Truncating here would make the comparator treat a
79-
function below one side's cutoff as absent (0.0) and misreport it as new.
77+
Returns {'total': {mean_ms, stddev_ms, runs}, 'functions': [...]}. ALL
78+
functions are kept (truncation is render-time in compare.py) and each carries
79+
its stddev across runs so the comparator can flag only statistically
80+
significant deltas instead of run-to-run sampling noise.
8081
"""
82+
runs = len(star_runs)
8183
names: set[str] = set()
8284
for d in star_runs:
8385
names.update(d.keys())
84-
rows: list[dict[str, Any]] = []
86+
functions: list[dict[str, Any]] = []
8587
for name in names:
8688
series = [d.get(name, 0.0) for d in star_runs]
87-
rows.append({"name": name, "mean_ms": statistics.mean(series)})
88-
total = sum(r["mean_ms"] for r in rows) or 1.0
89-
for r in rows:
90-
r["pct"] = r["mean_ms"] / total * 100.0
91-
r["runs"] = len(star_runs)
92-
rows.sort(key=lambda r: r["mean_ms"], reverse=True)
93-
return rows
89+
functions.append({
90+
"name": name,
91+
"mean_ms": statistics.mean(series),
92+
"stddev_ms": statistics.stdev(series) if len(series) > 1 else 0.0,
93+
})
94+
totals = [sum(d.values()) for d in star_runs]
95+
total_mean = statistics.mean(totals)
96+
total_std = statistics.stdev(totals) if len(totals) > 1 else 0.0
97+
for r in functions:
98+
r["pct"] = (r["mean_ms"] / total_mean * 100.0) if total_mean else 0.0
99+
r["runs"] = runs
100+
functions.sort(key=lambda r: r["mean_ms"], reverse=True)
101+
return {
102+
"total": {"mean_ms": total_mean, "stddev_ms": total_std, "runs": runs},
103+
"functions": functions,
104+
}
94105

95106

96107
def substitute(command: list[str], replacements: dict[str, str]) -> list[str]:

0 commit comments

Comments
 (0)