"""Сводка прогона hotspots.spec.ts (+ модалки справочника ВСП) в небольшой файл для report-data. cd perf && .venv/bin/python analysis/hotspots_summary.py results/20261026-hot --out report-data/20261026-s1/analysis Читает /hotspot_cpu.jsonl, hotspot_react.jsonl, modal.jsonl, modal_renders.jsonl (сырые данные в git не попадают) и пишет /hotspots.json и hotspots.md: - react: на шаг колеса - коммиты, рендеры компонентов, рендеры ForwardRef(Styled(td)); топ-10 компонентов по self_ms; - cpu: main thread на шаг (профайлер включён - абсолютные значения выше, чем в scroll.spec); - filter: хронология мутаций DOM контейнера после ввода фильтра (ответ на «filter did not stabilize»); - cssgrowth: CSS-правила документа, @media, вызовы window.matchMedia и main thread на шаг по циклам; - модалки (если есть): клик -> кадр и рендеры. """ from __future__ import annotations import argparse import json from pathlib import Path def load(p: Path) -> list[dict]: return [json.loads(x) for x in p.read_text().splitlines()] if p.exists() else [] def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("run_dir") ap.add_argument("--out", required=True) ap.add_argument("--name", default="hotspots", help="базовое имя выходных файлов") ap.add_argument("--renders-only", action="store_true", help="только modal_renders (например для основного прогона)") a = ap.parse_args(argv) rd, out = Path(a.run_dir), Path(a.out) out.mkdir(parents=True, exist_ok=True) res = {"react": [], "cpu": [], "filter": [], "cssgrowth": [], "modal": [], "modal_renders": []} md = [f"# Горячие точки: сводка {rd.name} (hotspots.spec.ts, hotspots_summary.py)", ""] for r in load(rd / "hotspot_react.jsonl"): m = r["metrics"] if not m.get("steps"): if m.get("mutations"): res["filter"].append({"mode": "react", "target": r["target"], "role": r["role"], "main_thread_ms": round(m["main_thread_ms"]), "commits": m.get("commits"), "renders": m.get("total_renders"), "mutations": m["mutations"]}) continue td = next((x["renders"] for x in m["renders_top"] if x["component"] == "ForwardRef(Styled(td))"), 0) res["react"].append({"target": r["target"], "role": r["role"], "steps": m["steps"], "commits_per_step": round(m["commits_per_step"], 2), "renders_per_step": round(m["renders_per_step"]), "td_renders_per_step": round(td / m["steps"]), "react_self_ms": round(m["total_self_ms"]), "main_thread_ms_per_step": round(m["main_thread_ms_per_step"], 1), "top": [[x["component"], x["renders"], round(x["self_ms"], 1)] for x in m["renders_top"][:10]]}) for r in load(rd / "hotspot_cpu.jsonl"): m = r["metrics"] if m.get("css_cycles"): res["cssgrowth"].append({"target": r["target"], "cycles": m["css_cycles"]}) elif m.get("mutations"): res["filter"].append({"mode": "cpu", "target": r["target"], "role": r["role"], "main_thread_ms": round(m["main_thread_ms"]), "mutations": m["mutations"]}) elif m.get("steps"): res["cpu"].append({"target": r["target"], "role": r["role"], "steps": m["steps"], "main_thread_ms_per_step": round(m["main_thread_ms_per_step"], 1), "artifacts": r["artifacts"]}) for r in ([] if a.renders_only else load(rd / "modal.jsonl")): res["modal"].append({"target": r["target"], "role": r["role"], "click_to_visible_ms": round(r["metrics"]["click_to_visible_ms"], 1), "main_thread_ms": round(r["metrics"]["main_thread_ms"], 1)}) for r in load(rd / "modal_renders.jsonl"): m = r["metrics"] res["modal_renders"].append({"target": r["target"], "commits": m["commits"], "renders": m["total_renders"], "self_ms": round(m["total_self_ms"], 1), "top": [[x["component"], x["renders"], round(x["self_ms"], 1)] for x in m["renders_top"][:6]]}) (out / f"{a.name}.json").write_text(json.dumps(res, ensure_ascii=False, indent=1)) if a.renders_only: md = [f"# Рендеры React при открытии модалок: {rd.name} (modal_renders.jsonl, hotspots_summary.py --renders-only)", "", "| модалка | коммитов | рендеров | self, мс | топ компонентов (рендеры/self мс) |", "|---|---|---|---|---|"] md += [f"| {r['target']} | {r['commits']} | {r['renders']} | {r['self_ms']} | " + "; ".join(f"{c} {n}/{s_}" for c, n, s_ in r["top"][:6]) + " |" for r in res["modal_renders"]] (out / f"{a.name}.md").write_text("\n".join(md) + "\n") print("\n".join(md)) return md += ["## Рендеры React на шаг колеса 200 px (профилирующая сборка :8081)", "", "| target | роль | шагов | коммитов/шаг | рендеров компонентов/шаг | из них Styled(td)/шаг | self React, мс | main thread/шаг, мс |", "|---|---|---|---|---|---|---|---|"] md += [f"| {r['target']} | {r['role']} | {r['steps']} | {r['commits_per_step']} | {r['renders_per_step']} | {r['td_renders_per_step']} | {r['react_self_ms']} | {r['main_thread_ms_per_step']} |" for r in res["react"]] for r in res["react"]: md.append(f"- {r['target']}: " + "; ".join(f"{c} {n}/{s} мс" for c, n, s in r["top"][:6])) md += ["", "## CPU-профили (prod-сборка с source maps :8082; разбор - cpuprofile-top.md)", "", "| target | роль | шагов | main thread/шаг под профайлером, мс | профиль |", "|---|---|---|---|---|"] md += [f"| {r['target']} | {r['role']} | {r['steps']} | {r['main_thread_ms_per_step']} | `{', '.join(r['artifacts'])}` |" for r in res["cpu"]] md += ["", "## Фильтр свода: мутации DOM контейнера `.MuiTableContainer-root` за окно наблюдения после ввода «1»", "", "| режим | target | main thread за окно, мс | кадров | кадров с мутацией | первая мутация (кадр) | long tasks | виды мутаций | рендеры React |", "|---|---|---|---|---|---|---|---|---|"] for r in res["filter"]: mu = r["mutations"] kinds = "; ".join(f"{k} ×{n}" for k, n in mu["mutation_kinds_top"][:3]) or "нет" md.append(f"| {r['mode']} | {r['target']} | {r['main_thread_ms']} | {mu['frames']} | {mu['frames_with_mutation']} | {mu['first_mutation_frame']} | " f"{mu['long_task_n']} ({', '.join(f'{d} мс' for _, d in mu['long_tasks'][:3])}) | {kinds} | {r.get('commits', '')} коммитов / {r.get('renders', '')} |") for g in res["cssgrowth"]: variant = ("цикл AHR -> скролл -> /tasks -> второй лист -> скролл -> /tasks, со счётчиком matchMedia" if any("matchmedia_calls" in c for c in g["cycles"]) else "цикл AHR -> скролл 40 шагов -> /tasks") md += ["", f"## Рост CSS-правил и matchMedia по циклам: {g['target']} ({variant})", "", "| цикл | main thread/шаг, мс | CSS-правил | @media | style | вызовов matchMedia | нетто listeners matchMedia |", "|---|---|---|---|---|---|---|"] for c in g["cycles"]: md.append(f"| {c['cycle']} | {c.get('scroll_ms_per_step', '')} | {c['css_rules']} | {c['css_media_rules']} | {c['style_elements']} | {c.get('matchmedia_calls', '')} | {c.get('matchmedia_listeners_net', '')} |") last = g["cycles"][-1] if last.get("matchmedia_top"): md += ["", "Топ мест вызова matchMedia (последний цикл, нарастающим итогом):", ""] + [f"- {n} × `{k[:240]}`" for k, n in last["matchmedia_top"]] if res["modal"]: md += ["", "## Модалки (повтор на восстановленных данных)", "", "| модалка | роль | клик → кадр, мс | main thread, мс |", "|---|---|---|---|"] md += [f"| {r['target']} | {r['role']} | {r['click_to_visible_ms']} | {r['main_thread_ms']} |" for r in res["modal"]] if res["modal_renders"]: md += ["", "| модалка | коммитов | рендеров | self, мс | топ |", "|---|---|---|---|---|"] md += [f"| {r['target']} | {r['commits']} | {r['renders']} | {r['self_ms']} | " + "; ".join(f"{c} {n}/{s}" for c, n, s in r["top"][:4]) + " |" for r in res["modal_renders"]] (out / f"{a.name}.md").write_text("\n".join(md) + "\n") print("\n".join(md)) if __name__ == "__main__": main()