- 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>
135 lines
8.2 KiB
Python
135 lines
8.2 KiB
Python
"""Сравнение масштабов 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()
|