perf: формат манифеста и результатов, агрегатор
Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
63f464dfae
commit
0b389ee7e4
@ -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
6
perf/perfkit/__init__.py
Normal 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
89
perf/perfkit/aggregate.py
Normal 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
92
perf/perfkit/manifest.py
Normal 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
49
perf/perfkit/results.py
Normal 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
2
perf/pytest.ini
Normal file
@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
41
perf/tests/test_aggregate.py
Normal file
41
perf/tests/test_aggregate.py
Normal 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
|
||||
28
perf/tests/test_manifest.py
Normal file
28
perf/tests/test_manifest.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user