perf: формат манифеста и результатов, агрегатор

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 21:08:45 +04:00
parent 63f464dfae
commit 0b389ee7e4
8 changed files with 310 additions and 0 deletions

View File

@ -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 при расширении формата |

6
perf/perfkit/__init__.py Normal file
View File

@ -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"

89
perf/perfkit/aggregate.py Normal file
View File

@ -0,0 +1,89 @@
"""Агрегатор: python -m perfkit.aggregate <run_dir> -> perf/report-data/<run_id>/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 <run_dir>", 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())

92
perf/perfkit/manifest.py Normal file
View File

@ -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", [])],
)

49
perf/perfkit/results.py Normal file
View File

@ -0,0 +1,49 @@
"""Запись результатов прогонов.
Файл: perf/results/<run_id>/<scenario>.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

2
perf/pytest.ini Normal file
View File

@ -0,0 +1,2 @@
[pytest]
pythonpath = .

View File

@ -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

View File

@ -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"