"""Сравнение масштабов 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 листа сводится к `/` по манифесту масштаба. Вывод: /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 from urllib.parse import parse_qs 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 svod2_key(path: str) -> str: """Ключ свода 2 в таблице API: полный ответ, страница (limit в query; compact/full по view=), поиск (search=) или /facets.""" m = re.match(r"/api/v1/svod/2/(\w+)/", path) base = "GET свод 2 " + m.group(1) route, _, query = path.partition("?") q = parse_qs(query, keep_blank_values=True) if route.endswith("/facets"): return base + " facets" if "limit" in q: base += " страница" if "view" in q: base += " " + q["view"][0] elif "view" in q: base += " " + q["view"][0] if "search" in q: base += " поиск" return base 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 = svod2_key(path) 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()) def sheet_rows(man, run, scale): # скролл снят до grow: для выращенных листов берём число строк «до» из grow-sS.json (манифест - после grow) rows = {f"{s['form_type']}/{s['sheet']}/{s['direction'] or 'null'}": s["rows"] for s in man["sheets"] if s.get("owner", "rf") == "rf"} gp = PERF / "report-data" / run / f"grow-s{scale}.json" if gp.exists(): for g in json.loads(gp.read_text()): rows[f"{g['sheet']}/null"] = g["before"] return rows rows1 = sheet_rows(m1, a.s1, 1) rows2 = sheet_rows(m2, a.s2, 2) 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 - один замер)", "", "Строк - на момент замера скролла (до grow; для AHR_UTILITY/AHR_SECURITY/FORM_4 CAP - значение «до» из grow-sS.json).", "", "| 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; размер - распакованный ответ)", "", "bench снят после grow: FORM_2/AHR - 2 092 строки на s1, AHR_UTILITY/SECURITY - 500, FORM_4/CAP - 300.", "", "| запрос | роль | 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()