DFiP_Budget_planing/perf/analysis/scale_compare.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

135 lines
8.2 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.

"""Сравнение масштабов 1 и 2: скролл, фильтры, модалки (summary.json) и время ответа API по листам (api.jsonl).
cd perf && .venv/bin/python analysis/scale_compare.py --s1 20261026-s1 --s2 20261026-s2 \
--out report-data/20261026-s2/analysis
Масштаб 2 снят с 1 повтором (R-4): отношение s2/s1 - оценка роста с объёмом, не точная цифра.
Скролл: роли rf (листы, отчёты проектов) и dfip (своды); модалки - все роли. API: медиана по каждому URL; form_id
различаются между масштабами, поэтому URL листа сводится к `<form_type>/<sheet>` по манифесту масштаба.
Вывод: <out>/scale-compare.json и scale-compare.md.
"""
from __future__ import annotations
import argparse
import json
import re
import statistics
from collections import defaultdict
from pathlib import Path
PERF = Path(__file__).resolve().parents[1]
def groups(run: str) -> dict:
d = json.loads((PERF / "report-data" / run / "summary.json").read_text())
return {(g["scenario"], g["target"], g["role"], g["cpu_rate"]): g for g in d["groups"]}
def med(g, k):
m = g["metrics"].get(k) if g else None
return m["median"] if m else None
def api_by_sheet(run: str, scale: int) -> dict:
man = json.loads((PERF / "report-data" / run / f"manifest-s{scale}.json").read_text())
ft = {s["form_id"]: s["form_type"] for s in man["sheets"]}
vals, size = defaultdict(list), {}
for line in (PERF / "results" / run / "api.jsonl").read_text().splitlines():
r = json.loads(line)
path = re.sub(r"^https?://[^/]+", "", r["url"])
m = re.match(r"/api/v1/form/(\d+)/sheet/(\w+)", path)
if m:
key = f"GET лист {ft.get(int(m.group(1)), '?')}/{m.group(2)}"
elif re.match(r"/api/v1/svod/2/(\w+)/", path):
key = "GET свод 2 " + re.match(r"/api/v1/svod/2/(\w+)/", path).group(1)
elif re.match(r"/api/v1/svod/1/(\w+)/", path):
key = "GET свод 1 " + re.match(r"/api/v1/svod/1/(\w+)/", path).group(1)
elif "plan-summary" in path:
key = "GET сводные проектов"
elif re.match(r"/api/v1/projects/\d+/report/\d+/(\w+)", path):
key = "GET отчёт проекта " + re.match(r"/api/v1/projects/\d+/report/\d+/(\w+)", path).group(1)
else:
continue
k = (key, r["role"])
vals[k].append(r["metrics"]["ms"])
size[k] = max(size.get(k, 0), r["metrics"].get("bytes") or 0)
return {k: (statistics.median(v), size[k], len(v)) for k, v in vals.items()}
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--s1", required=True)
ap.add_argument("--s2", required=True)
ap.add_argument("--out", required=True)
a = ap.parse_args(argv)
out = Path(a.out)
out.mkdir(parents=True, exist_ok=True)
g1, g2 = groups(a.s1), groups(a.s2)
m1 = json.loads((PERF / "report-data" / a.s1 / "manifest-s1.json").read_text())
m2 = json.loads((PERF / "report-data" / a.s2 / "manifest-s2.json").read_text())
rows1 = {f"{s['form_type']}/{s['sheet']}/{s['direction'] or 'null'}": s["rows"] for s in m1["sheets"] if s.get("owner", "rf") == "rf"}
rows2 = {f"{s['form_type']}/{s['sheet']}/{s['direction'] or 'null'}": s["rows"] for s in m2["sheets"] if s.get("owner", "rf") == "rf"}
res = {"scroll": [], "modal": [], "api": []}
for key, a1 in sorted(g1.items()):
sc, target, role, cpu = key
if cpu != 1 or sc not in ("scroll", "modal"):
continue
if sc == "scroll" and not ((role == "rf" and not target.startswith("svod/")) or (role == "dfip" and target.startswith("svod/"))):
continue
b = g2.get(key)
if sc == "scroll":
base = target.split("@")[0]
r = {"target": target, "role": role, "rows_s1": rows1.get(base), "rows_s2": rows2.get(base),
"mt_per_step_s1": med(a1, "main_thread_ms_per_step"), "mt_per_step_s2": med(b, "main_thread_ms_per_step"),
"mt_s1": med(a1, "main_thread_ms"), "mt_s2": med(b, "main_thread_ms"),
"mb_s1": (med(a1, "json_bytes_sheet") or 0) / 1e6, "mb_s2": (med(b, "json_bytes_sheet") or 0) / 1e6,
"n_s1": a1["n"], "n_s2": b["n"] if b else 0, "inc_s2": b["incomplete_n"] if b else None}
res["scroll"].append(r)
else:
res["modal"].append({"target": target, "role": role, "ms_s1": med(a1, "click_to_visible_ms"), "ms_s2": med(b, "click_to_visible_ms"),
"mt_s1": med(a1, "main_thread_ms"), "mt_s2": med(b, "main_thread_ms")})
a1, a2 = api_by_sheet(a.s1, 1), api_by_sheet(a.s2, 2)
for k in sorted(a1, key=lambda k: -a1[k][0]):
if k in a2:
res["api"].append({"endpoint": k[0], "role": k[1], "ms_s1": round(a1[k][0], 1), "ms_s2": round(a2[k][0], 1),
"mb_s1": round(a1[k][1] / 1e6, 2), "mb_s2": round(a2[k][1] / 1e6, 2)})
# незавершённые записи s2 (scroll_incomplete: упёрлись в PERF_MAX_STEPS) в summary не входят - берём из сырого jsonl
res["incomplete_s2"] = []
for line in (PERF / "results" / a.s2 / "scroll.jsonl").read_text().splitlines():
r = json.loads(line)
if r.get("incomplete") and r.get("skip_reason") == "scroll_incomplete":
m = r["metrics"]
res["incomplete_s2"].append({"target": r["target"], "role": r["role"], "steps": m.get("steps"),
"mt_per_step": round(m.get("main_thread_ms_per_step", 0), 1),
"duration_s": round(m.get("duration_ms", 0) / 1000), "mb": round(m.get("json_bytes_sheet", 0) / 1e6, 2)})
(out / "scale-compare.json").write_text(json.dumps(res, ensure_ascii=False, indent=1))
def ratio(x, y):
return f"{y / x:.2f}" if x and y else "—"
def f(x, nd=1):
return "—" if x is None else f"{x:.{nd}f}"
md = [f"# Масштаб 1 ({a.s1}) против масштаба 2 ({a.s2}, 1 повтор - оценка)", "",
"## Скролл: main thread на шаг колеса 200 px, мс (медиана; s2 - один замер)", "",
"| target | роль | строк s1 → s2 | мс/шаг s1 | мс/шаг s2 | × | main thread s1, мс | s2, мс | ответ s1 → s2, МБ |", "|---|---|---|---|---|---|---|---|---|"]
for r in res["scroll"]:
inc = " (s2 incomplete)" if r["inc_s2"] else ""
md.append(f"| {r['target']} | {r['role']} | {r['rows_s1'] or ''} → {r['rows_s2'] or ''} | {f(r['mt_per_step_s1'])} | {f(r['mt_per_step_s2'])}{inc} | "
f"{ratio(r['mt_per_step_s1'], r['mt_per_step_s2'])} | {f(r['mt_s1'], 0)} | {f(r['mt_s2'], 0)} | {r['mb_s1']:.2f} → {r['mb_s2']:.2f} |")
md += ["", "Незавершённые на s2 (скролл упёрся в PERF_MAX_STEPS=5000 шагов вперёд, обратного прохода нет; в summary не входят):", ""]
md += [f"- {r['target']} {r['role']}: {r['steps']} шагов, {r['mt_per_step']} мс/шаг, {r['duration_s']} с, ответ {r['mb']} МБ" for r in res["incomplete_s2"]]
md += ["", "## Модалки: клик → кадр с модалкой, мс (медиана)", "", "| модалка | роль | s1 | s2 | × | main thread s1 | s2 |", "|---|---|---|---|---|---|---|"]
for r in res["modal"]:
md.append(f"| {r['target']} | {r['role']} | {f(r['ms_s1'], 0)} | {f(r['ms_s2'], 0)} | {ratio(r['ms_s1'], r['ms_s2'])} | {f(r['mt_s1'], 0)} | {f(r['mt_s2'], 0)} |")
md += ["", "## API (bench, медиана 20 повторов по каждому URL; размер - распакованный ответ)", "",
"| запрос | роль | s1, мс | s2, мс | × | s1, МБ | s2, МБ |", "|---|---|---|---|---|---|---|"]
for r in res["api"]:
md.append(f"| {r['endpoint']} | {r['role']} | {r['ms_s1']} | {r['ms_s2']} | {ratio(r['ms_s1'], r['ms_s2'])} | {r['mb_s1']} | {r['mb_s2']} |")
(out / "scale-compare.md").write_text("\n".join(md) + "\n")
print("\n".join(md))
if __name__ == "__main__":
main()