DFiP_Budget_planing/perf/analysis/scale_compare.py
Andrey Epifancev 5f4afc0926 perf: правки по финальному ревью — скрипты, данные, фильтр long tasks
- run-all.sh: копия run-all.status.json в report-data с относительными путями;
  пример control4x с отдельным тегом и предупреждением (I-3, M-8); очищены
  три закоммиченные копии статуса
- lib/modal.ts: long tasks по пересечению окна (tasksInWindow), тест (M-7)
- scale_compare.py: строки листов на момент скролла (до grow), пояснения (M-6)
- hotspots_summary.py: оговорка о разбавлении «на шаг» (M-5)
- netlog_sizes.py + report-data/20261026-s1/analysis/netlog-sizes.md (M-3)

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:34:58 +04:00

146 lines
8.9 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())
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()