From 0b389ee7e46c4e31ddd864f0ba3ae603e199c02d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 21:08:45 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD=D0=B8=D1=84=D0=B5=D1=81=D1=82=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D0=B2,=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #202 Co-Authored-By: Claude Opus 5.5 --- openspec/changes/perf-profiling/design.md | 3 + perf/perfkit/__init__.py | 6 ++ perf/perfkit/aggregate.py | 89 ++++++++++++++++++++++ perf/perfkit/manifest.py | 92 +++++++++++++++++++++++ perf/perfkit/results.py | 49 ++++++++++++ perf/pytest.ini | 2 + perf/tests/test_aggregate.py | 41 ++++++++++ perf/tests/test_manifest.py | 28 +++++++ 8 files changed, 310 insertions(+) create mode 100644 perf/perfkit/__init__.py create mode 100644 perf/perfkit/aggregate.py create mode 100644 perf/perfkit/manifest.py create mode 100644 perf/perfkit/results.py create mode 100644 perf/pytest.ini create mode 100644 perf/tests/test_aggregate.py create mode 100644 perf/tests/test_manifest.py diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index 85d6c9a..acd1819 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -109,3 +109,6 @@ | 2026-10-25 | `perf/web/rsbuild.perf.config.ts` импортирует `@rsbuild/core` по относительному пути `../../web/node_modules/@rsbuild/core` | Конфиг в `perf/` не видит `web/node_modules` через резолв пакетов | Путь ломается при смене раскладки каталогов | | 2026-10-25 | В `.gitignore` шаблон для трейсов `perf/**/*trace*.json` | Формулировка «*.json trace» неоднозначна | Лишний или пропущенный файл в игноре | | 2026-10-25 | В `.gitignore` добавлено исключение `!perf/docker-compose.yml` | Корневой `.gitignore` игнорирует `docker-compose.yml` | Файл стенда не попадёт в коммит | +| 2026-10-25 | В манифесте сегмент «нет direction» в URL листа — литерал `null` | `TableList.jsx` строит путь через `${table?.direction}`; при null из API получается `null` (при undefined было бы `undefined`) | Переходы на листы без direction дадут 404/пустую страницу | +| 2026-10-25 | Агрегатор считает median чётного n как среднее двух центральных, p95 — nearest-rank; нечисловые и bool-метрики игнорируются | Бриф задаёт nearest-rank только для p95; median по стандартному определению | Небольшое расхождение чисел с ручным расчётом | +| 2026-10-25 | `results.append` валидирует наличие всех обязательных ключей записи | Ранний отказ на кривом формате от Node-стороны | Лишний ValueError при расширении формата | diff --git a/perf/perfkit/__init__.py b/perf/perfkit/__init__.py new file mode 100644 index 0000000..d07051f --- /dev/null +++ b/perf/perfkit/__init__.py @@ -0,0 +1,6 @@ +"""perfkit: общий формат манифеста, результатов и агрегатор для perf-стенда.""" +from pathlib import Path + +PERF_DIR = Path(__file__).resolve().parent.parent +RESULTS_DIR = PERF_DIR / "results" +REPORT_DATA_DIR = PERF_DIR / "report-data" diff --git a/perf/perfkit/aggregate.py b/perf/perfkit/aggregate.py new file mode 100644 index 0000000..87d10ce --- /dev/null +++ b/perf/perfkit/aggregate.py @@ -0,0 +1,89 @@ +"""Агрегатор: python -m perfkit.aggregate -> perf/report-data//summary.{json,md}.""" +from __future__ import annotations + +import json +import math +import sys +from pathlib import Path + +from . import REPORT_DATA_DIR +from .results import read_all + +GROUP_KEYS = ("scenario", "target", "role", "scale", "cpu_rate") + + +def _p95(sorted_vals): + """Nearest-rank.""" + k = math.ceil(0.95 * len(sorted_vals)) + return sorted_vals[max(k, 1) - 1] + + +def _median(s): + n = len(s) + return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2 + + +def _num(v): + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def summarize(run_dir) -> dict: + run_dir = Path(run_dir) + groups: dict[tuple, dict] = {} + for r in read_all(run_dir): + key = tuple(r[k] for k in GROUP_KEYS) + g = groups.setdefault(key, {"vals": {}, "n": 0, "incomplete_n": 0}) + if r.get("incomplete"): + g["incomplete_n"] += 1 + continue + g["n"] += 1 + for m, v in r["metrics"].items(): + if _num(v): + g["vals"].setdefault(m, []).append(v) + out = [] + for key in sorted(groups, key=lambda k: tuple(str(x) for x in k)): + g = groups[key] + metrics = {} + for m, vals in sorted(g["vals"].items()): + s = sorted(vals) + metrics[m] = {"median": _median(s), "p95": _p95(s), "min": s[0], "max": s[-1], "n": len(s)} + out.append({**dict(zip(GROUP_KEYS, key)), "n": g["n"], "incomplete_n": g["incomplete_n"], "metrics": metrics}) + return {"run_id": run_dir.name, "groups": out} + + +def _fmt(x): + return f"{x:.1f}" if isinstance(x, float) and not x.is_integer() else str(int(x)) if isinstance(x, float) else str(x) + + +def render_md(summary: dict) -> str: + lines = [f"# Summary: {summary['run_id']}", ""] + for g in summary["groups"]: + lines.append(f"## {g['scenario']} | target={g['target']} | role={g['role']} | scale={g['scale']} | cpu_rate={g['cpu_rate']}") + lines.append("") + lines.append(f"Замеров: {g['n']}, незавершённых: {g['incomplete_n']}") + lines.append("") + lines.append("| metric | median | p95 | min | max | n |") + lines.append("|---|---|---|---|---|---|") + for m, s in g["metrics"].items(): + lines.append(f"| {m} | {_fmt(s['median'])} | {_fmt(s['p95'])} | {_fmt(s['min'])} | {_fmt(s['max'])} | {s['n']} |") + lines.append("") + return "\n".join(lines) + + +def main(argv=None) -> int: + argv = sys.argv[1:] if argv is None else argv + if len(argv) != 1: + print("usage: python -m perfkit.aggregate ", file=sys.stderr) + return 2 + run_dir = Path(argv[0]) + s = summarize(run_dir) + out = REPORT_DATA_DIR / s["run_id"] + out.mkdir(parents=True, exist_ok=True) + (out / "summary.json").write_text(json.dumps(s, ensure_ascii=False, indent=2), encoding="utf-8") + (out / "summary.md").write_text(render_md(s), encoding="utf-8") + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/perf/perfkit/manifest.py b/perf/perfkit/manifest.py new file mode 100644 index 0000000..1c2a73f --- /dev/null +++ b/perf/perfkit/manifest.py @@ -0,0 +1,92 @@ +"""Манифест стенда: что есть в БД и какие URL/пользователей использовать в сценариях. + +Путь: perf/results/manifest-s{scale}.json. + +Сегмент {direction} в URL листа: web/src/components/common/TableList/TableList.jsx +строит путь как `${table?.direction}`, поэтому при direction == null (JSON null из API) +в URL попадает литерал "null" (маршрут в web/src/app/Routes.jsx: +/table/form/:formId/form-type/:formType/:sheetName/:direction/:year). + +Task 3 заполняет `svods` и `modal_pages` по маршрутам Routes.jsx +(/svod, /svod/2, /svods-navigator, /projects/summaries и т.д.) — это просто списки Page(name, url). +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field, asdict +from pathlib import Path + +from . import RESULTS_DIR + +NO_DIRECTION = "null" # см. docstring модуля +USER_KEYS = ("admin", "rf", "rf_coauthor", "dfip") + + +@dataclass +class Sheet: + form_id: int + form_type: str + sheet: str + direction: str | None + year: int + url: str + rows: int + + @staticmethod + def build_url(form_id, form_type, sheet, direction, year) -> str: + d = NO_DIRECTION if direction is None else direction + return f"/table/form/{form_id}/form-type/{form_type}/{sheet}/{d}/{year}" + + +@dataclass +class ProjectReport: + project_id: int + year: int + report_type: str + url: str + rows: int + + +@dataclass +class Page: + name: str + url: str + + +@dataclass +class User: + username: str + password: str + role_id: int + + +@dataclass +class Manifest: + scale: int + users: dict[str, User] = field(default_factory=dict) + sheets: list[Sheet] = field(default_factory=list) + project_reports: list[ProjectReport] = field(default_factory=list) + svods: list[Page] = field(default_factory=list) + modal_pages: list[Page] = field(default_factory=list) + + +def manifest_path(scale: int, results_dir: Path | None = None) -> Path: + return Path(results_dir or RESULTS_DIR) / f"manifest-s{scale}.json" + + +def dump(m: Manifest, path) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(asdict(m), ensure_ascii=False, indent=2), encoding="utf-8") + + +def load(path) -> Manifest: + d = json.loads(Path(path).read_text(encoding="utf-8")) + return Manifest( + scale=d["scale"], + users={k: User(**v) for k, v in d.get("users", {}).items()}, + sheets=[Sheet(**x) for x in d.get("sheets", [])], + project_reports=[ProjectReport(**x) for x in d.get("project_reports", [])], + svods=[Page(**x) for x in d.get("svods", [])], + modal_pages=[Page(**x) for x in d.get("modal_pages", [])], + ) diff --git a/perf/perfkit/results.py b/perf/perfkit/results.py new file mode 100644 index 0000000..8eae60a --- /dev/null +++ b/perf/perfkit/results.py @@ -0,0 +1,49 @@ +"""Запись результатов прогонов. + +Файл: perf/results//.jsonl, одна JSON-строка на один замер (repeat). +Формат строки (Node-сторона, Task 4, пишет тот же формат): + + { + "run_id": "2026-10-25T12-00-00", # str, имя каталога прогона + "scenario": "open-sheet", # str + "target": "dev" | "prod" | ..., # str, какая сборка фронта + "role": "admin", # str, ключ из manifest.users + "scale": 1, # int + "cpu_rate": 1, # int/float, CPU throttling rate + "repeat": 0, # int, номер повтора + "metrics": {"long_task_ms": 123.4}, # {str: number}; нечисловые значения агрегатор игнорирует + "artifacts": ["trace.json"], # list[str], пути относительно каталога прогона + "incomplete": false, # bool; true = замер не завершён, в статистику не входит + "ts": "2026-10-25T12:00:01Z" # str, ISO-8601 UTC + } + +Кодировка UTF-8, без переносов строк внутри записи. +""" +from __future__ import annotations + +import json +from pathlib import Path + +REQUIRED = ("run_id", "scenario", "target", "role", "scale", "cpu_rate", "repeat", + "metrics", "artifacts", "incomplete", "ts") + + +def append(run_dir, record: dict) -> Path: + missing = [k for k in REQUIRED if k not in record] + if missing: + raise ValueError(f"record missing keys: {missing}") + run_dir = Path(run_dir) + run_dir.mkdir(parents=True, exist_ok=True) + f = run_dir / f"{record['scenario']}.jsonl" + with f.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + return f + + +def read_all(run_dir) -> list[dict]: + out = [] + for f in sorted(Path(run_dir).glob("*.jsonl")): + for line in f.read_text(encoding="utf-8").splitlines(): + if line.strip(): + out.append(json.loads(line)) + return out diff --git a/perf/pytest.ini b/perf/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/perf/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/perf/tests/test_aggregate.py b/perf/tests/test_aggregate.py new file mode 100644 index 0000000..4a0e961 --- /dev/null +++ b/perf/tests/test_aggregate.py @@ -0,0 +1,41 @@ +import json +from perfkit import results +from perfkit.aggregate import summarize, render_md + + +def rec(**kw): + r = {"run_id": "r1", "scenario": "s", "target": "t", "role": "admin", "scale": 1, + "cpu_rate": 1, "repeat": 0, "metrics": {}, "artifacts": [], "incomplete": False, "ts": "x"} + r.update(kw) + return r + + +def test_summarize_median_p95(tmp_path): + for v in [10, 20, 30, 40, 100]: + results.append(tmp_path, rec(metrics={"long_task_ms": v})) + g = summarize(tmp_path)["groups"][0] + m = g["metrics"]["long_task_ms"] + assert (m["median"], m["p95"], m["n"], m["min"], m["max"]) == (30, 100, 5, 10, 100) + + +def test_summarize_groups_by_cpu_rate(tmp_path): + results.append(tmp_path, rec(cpu_rate=1, metrics={"a": 1})) + results.append(tmp_path, rec(cpu_rate=6, metrics={"a": 2})) + assert len(summarize(tmp_path)["groups"]) == 2 + + +def test_incomplete_records_counted_separately(tmp_path): + results.append(tmp_path, rec(metrics={"a": 1})) + results.append(tmp_path, rec(metrics={"a": 999}, incomplete=True)) + g = summarize(tmp_path)["groups"][0] + assert g["incomplete_n"] == 1 + assert g["metrics"]["a"]["max"] == 1 and g["metrics"]["a"]["n"] == 1 + + +def test_append_path_and_md(tmp_path): + results.append(tmp_path, rec(metrics={"a": 1.5, "flag": True, "s": "x"})) + assert (tmp_path / "s.jsonl").exists() + s = summarize(tmp_path) + assert "flag" not in s["groups"][0]["metrics"] + md = render_md(s) + assert "| a |" in md and "median" in md diff --git a/perf/tests/test_manifest.py b/perf/tests/test_manifest.py new file mode 100644 index 0000000..9a7c88c --- /dev/null +++ b/perf/tests/test_manifest.py @@ -0,0 +1,28 @@ +from perfkit.manifest import Manifest, Sheet, ProjectReport, Page, User, load, dump, manifest_path + + +def make(): + return Manifest( + scale=1, + users={"admin": User("a", "p", 1), "rf": User("r", "p", 2)}, + sheets=[Sheet(1, "RF", "S1", None, 2025, "", 10), Sheet(2, "RF", "S2", "up", 2025, "", 5)], + project_reports=[ProjectReport(7, 2025, "T", "/x", 3)], + svods=[Page("svod", "/svod")], + modal_pages=[Page("m", "/m")], + ) + + +def test_manifest_roundtrip(tmp_path): + m = make() + p = tmp_path / "m.json" + dump(m, p) + assert load(p) == m + + +def test_sheet_url_null_direction(): + assert Sheet.build_url(1, "RF", "S1", None, 2025) == "/table/form/1/form-type/RF/S1/null/2025" + assert Sheet.build_url(1, "RF", "S1", "up", 2025).endswith("/S1/up/2025") + + +def test_manifest_path(): + assert manifest_path(6).name == "manifest-s6.json"