DFiP_Budget_planing/perf/analysis/cpuprofile_top.py
Andrey Epifancev 8e9f53a478 perf: скрипты разбора и сводные данные прогонов 20261026
- perf/analysis: разбор trace (timeline_breakdown), CPU-профилей с source maps
  (cpuprofile_top), heap snapshots (heapdiff), циклов памяти, сравнения масштабов,
  ручной EXPLAIN вложенных запросов (explain_manual), API против X-DB-Time-Ms
  (api_split), сводка горячих точек; тесты ядра в perf/tests/test_analysis.py
- perf/e2e/scenarios/hotspots.spec.ts: CPU-профиль, рендеры React, мутации DOM
  после фильтра, рост CSS-правил по циклам (вне run-all)
- prod-сборка с source maps: PERF_SOURCEMAP=1, perf/stand.sh web-sourcemap (:8082)
- perf/report-data/20261026-s1, 20261026-s2 (summary, sql, analysis, explain-manual),
  t5-types (иллюстрация CPU 6x)
- design.md: решения T10b без обсуждения

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:43:46 +04:00

234 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Топ функций по self time из CPU-профилей (.cpuprofile, CDP Profiler) с привязкой к исходникам через source maps.
cd perf && .venv/bin/python analysis/cpuprofile_top.py results/20261026-hot \
--maps .build/prod-sourcemap --out report-data/20261026-s1/analysis
Профили пишет perf/e2e/scenarios/hotspots.spec.ts (PERF_HOT_MODE=cpu) на prod-сборке с source maps
(`perf/stand.sh web-sourcemap`, :8082; код совпадает с prod-сборкой :8080 с точностью до хешей имён чанков).
Для каждого профиля:
- self time (мс) по функциям: имя, исходный файл:строка (через source map), доля от активного времени;
- self time по пакетам / файлам исходников (`node_modules/<pkg>` или `web/src/...`);
- «приложение»: время каждого сэмпла приписывается ближайшему кадру стека из `web/src` (кто в коде приложения
вызвал дорогую работу библиотек) - топ таких мест file:line;
- inclusive time функций `web/src` (сэмпл считается один раз на функцию).
Специальные узлы `(idle)`, `(program)`, `(garbage collector)` выводятся отдельно; `(idle)` в активное время не входит.
Вывод: <out>/cpuprofile-top.json и cpuprofile-top.md.
"""
from __future__ import annotations
import argparse
import bisect
import json
import re
from collections import Counter, defaultdict
from pathlib import Path
B64 = {c: i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")}
SPECIAL = {"(idle)", "(program)", "(garbage collector)", "(root)"}
def vlq_decode(seg: str) -> list[int]:
out, val, shift = [], 0, 0
for ch in seg:
d = B64[ch]
val += (d & 31) << shift
if d & 32:
shift += 5
continue
out.append(-(val >> 1) if val & 1 else val >> 1)
val, shift = 0, 0
return out
class SourceMap:
def __init__(self, path: Path):
m = json.loads(path.read_text())
self.sources = [norm_source(s) for s in m["sources"]]
self.names = m.get("names", [])
self.lines: list[tuple[list[int], list[tuple]]] = []
src = sline = scol = name = 0
for line in m["mappings"].split(";"):
col = 0
cols, segs = [], []
if line:
for raw in line.split(","):
if not raw:
continue
v = vlq_decode(raw)
col += v[0]
if len(v) >= 4:
src += v[1]; sline += v[2]; scol += v[3]
nm = None
if len(v) >= 5:
name += v[4]; nm = name
cols.append(col); segs.append((src, sline, scol, nm))
self.lines.append((cols, segs))
def lookup(self, line0: int, col0: int):
"""0-based строка/колонка сгенерированного кода -> (source, line 1-based, col, name) или None."""
if line0 < 0 or line0 >= len(self.lines):
return None
cols, segs = self.lines[line0]
i = bisect.bisect_right(cols, col0) - 1
if i < 0:
return None
s, l, c, n = segs[i]
return self.sources[s], l + 1, c, (self.names[n] if n is not None and n < len(self.names) else None)
def norm_source(s: str) -> str:
s = s.replace("\\", "/")
m = re.search(r"(web/(?:node_modules|src)/.*)$", s)
if m:
return m.group(1)
return s.lstrip("./")
def package_of(src: str) -> str:
m = re.match(r"web/node_modules/((?:@[^/]+/)?[^/]+)/", src)
if m:
return "node_modules/" + m.group(1)
if src.startswith("web/src/"):
return src
return src
class Resolver:
def __init__(self, maps_root: Path):
self.root = maps_root
self.cache: dict[str, SourceMap | None] = {}
def frame(self, cf: dict):
url = cf.get("url") or ""
fn = cf.get("functionName") or "(anonymous)"
if not url:
return {"fn": fn, "src": "", "line": None, "gen": ""}
mpath = re.sub(r"^https?://[^/]+/", "", url).split("?")[0]
sm = self.cache.get(mpath, False)
if sm is False:
p = self.root / (mpath + ".map")
sm = SourceMap(p) if p.exists() else None
self.cache[mpath] = sm
gen = f"{mpath.rsplit('/', 1)[-1]}:{cf.get('lineNumber', -1) + 1}:{cf.get('columnNumber', -1) + 1}"
if sm is None:
return {"fn": fn, "src": mpath, "line": None, "gen": gen}
r = sm.lookup(cf.get("lineNumber", -1), cf.get("columnNumber", -1))
if not r:
return {"fn": fn, "src": mpath, "line": None, "gen": gen}
src, line, _col, _name = r
return {"fn": fn, "src": src, "line": line, "gen": gen}
def analyze(path: Path, res: Resolver, top: int = 25) -> dict:
prof = json.loads(path.read_text())
nodes = {n["id"]: n for n in prof["nodes"]}
parent: dict[int, int] = {}
for n in prof["nodes"]:
for c in n.get("children", []):
parent[c] = n["id"]
samples = prof.get("samples", [])
deltas = prof.get("timeDeltas", [])
# длительность сэмпла i = timeDeltas[i+1] (как DevTools); последний - медиана
dur = [deltas[i + 1] if i + 1 < len(deltas) else 0 for i in range(len(samples))]
fr_cache: dict[int, dict] = {}
def fr(nid):
if nid not in fr_cache:
fr_cache[nid] = res.frame(nodes[nid]["callFrame"])
return fr_cache[nid]
special = Counter()
self_fn = Counter()
self_pkg = Counter()
app_attr = Counter()
app_incl = Counter()
active = 0.0
for nid, d in zip(samples, dur):
ms = d / 1000
f = fr(nid)
if f["fn"] in SPECIAL and not f["src"]:
special[f["fn"]] += ms
if f["fn"] != "(idle)":
active += ms
continue
active += ms
key = f"{f['fn']} — {f['src']}:{f['line']}" if f["line"] else f"{f['fn']} — {f['src'] or '?'} ({f['gen']})"
self_fn[key] += ms
self_pkg[package_of(f["src"]) if f["src"] else "(native/unknown)"] += ms
# ближайший кадр приложения (web/src) и inclusive по функциям приложения
cur, attributed, seen = nid, False, set()
while cur is not None:
g = fr(cur)
if g["src"].startswith("web/src/"):
k = f"{g['fn']} — {g['src']}:{g['line']}"
if not attributed:
app_attr[k] += ms
attributed = True
if k not in seen:
app_incl[k] += ms
seen.add(k)
cur = parent.get(cur)
if not attributed:
app_attr["(нет кадра web/src в стеке: библиотека/браузер)"] += ms
total = sum(dur) / 1000
def tbl(c: Counter):
return [[k, round(v, 1), round(100 * v / active, 1) if active else 0] for k, v in c.most_common(top)]
return {
"file": path.name,
"profile_ms": round(total, 1),
"active_ms": round(active, 1),
"special_ms": {k: round(v, 1) for k, v in special.items()},
"self_by_function": tbl(self_fn),
"self_by_package": tbl(self_pkg),
"app_attribution": tbl(app_attr),
"app_inclusive": tbl(app_incl),
}
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("run_dir")
ap.add_argument("--maps", required=True, help="каталог сборки с .map (perf/.build/prod-sourcemap)")
ap.add_argument("--out", required=True)
ap.add_argument("--top", type=int, default=25)
a = ap.parse_args(argv)
run_dir, out = Path(a.run_dir), Path(a.out)
out.mkdir(parents=True, exist_ok=True)
res = Resolver(Path(a.maps))
recs = {}
p = run_dir / "hotspot_cpu.jsonl"
if p.exists():
for line in p.read_text().splitlines():
r = json.loads(line)
for art in r.get("artifacts", []):
recs[Path(art).name] = r
results = []
for f in sorted((run_dir / "artifacts").glob("*.cpuprofile")):
r = analyze(f, res, a.top)
rec = recs.get(f.name)
if rec:
r.update(target=rec["target"], role=rec["role"], steps=rec["metrics"].get("steps"),
main_thread_ms=round(rec["metrics"].get("main_thread_ms", 0), 1))
results.append(r)
print(f"{f.name}: active {r['active_ms']} ms", flush=True)
(out / "cpuprofile-top.json").write_text(json.dumps(results, ensure_ascii=False, indent=1))
md = ["# CPU-профили действий: топ по self time (cpuprofile_top.py)", "",
f"Профили: `{run_dir}/artifacts/*.cpuprofile` (hotspots.spec.ts, PERF_HOT_MODE=cpu, prod-сборка с source maps, сэмплы 200 мкс).",
"Доля - от активного времени профиля (без `(idle)`). Профайлер добавляет накладные расходы: абсолютные мс выше, чем в scroll.spec; сравнивать доли.", ""]
for r in results:
md += [f"## {r['file']} — {r.get('target', '')} ({r.get('role', '')}), шагов {r.get('steps')}, main thread {r.get('main_thread_ms')} мс", "",
f"Активное время {r['active_ms']} мс; служебные: {r['special_ms']}", ""]
for title, key in (("Self time по функциям", "self_by_function"), ("Self time по пакетам/файлам", "self_by_package"),
("Ближайший кадр web/src (кто вызвал работу)", "app_attribution"), ("Inclusive time функций web/src", "app_inclusive")):
md += [f"### {title}", "", "| место | мс | % |", "|---|---|---|"]
md += [f"| {k} | {v} | {pc} |" for k, v, pc in r[key][:15]]
md.append("")
(out / "cpuprofile-top.md").write_text("\n".join(md) + "\n")
if __name__ == "__main__":
main()