Andrey Epifancev 558ad815e0 perf: api_split, explain_manual и scale_compare для страниц и вариантов свода 2; README
api_split: GO/RF полный и compact ответ страницей 300, /facets, RF compact с поиском.
explain_manual: svod2_go_page, svod2_rf_page, svod2_rf_search, svod2_go_facets
(год из манифеста). scale_compare: svod2_key разводит полный ответ, страницу и facets.
README: раздел «Свод 2 после AURORA-1843» (порядок до/после, сверка, команды, --only/--repeats).
Тесты: perf/tests/test_svod2_analysis.py.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-29 00:34:11 +04:00

104 lines
6.5 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.

"""Время ответа API: доля БД против Python (сериализация) без py-spy.
cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/20261026-s1/analysis
Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (api/src/api/v1/forms.py:184,
summary.py:74,126, projects.py:128): время от начала обработчика до получения строк из БД (поиск формы + SQL-функция +
разбор jsonb драйвером). Остаток `полное время - X-DB-Time-Ms` - сборка pydantic-моделей (`_rows_to_sheet_response`,
forms.py:27), валидация `response_model`, JSON-кодирование, сжатие GZipMiddleware (api/src/main.py:248, compresslevel=1),
передача и распаковка на клиенте (httpx).
py-spy на macOS требует root - этот способ его заменяет для вопроса «БД или Python».
Запросы: самые тяжёлые из bench (api.jsonl) - листы FORM_2/AHR, FORM_4/CAP и FORM_4/AHR (rf), свод 2 GO и RF и свод 1
MAIN (dfip), отчёт проекта (rf), сводные проектов (dfip). Напрямую на :8000 (без proxy фронта), httpx, тело читается
целиком. Вывод: <out>/api-split.json и api-split.md.
"""
from __future__ import annotations
import argparse
import json
import statistics
import time
from pathlib import Path
import httpx
PERF = Path(__file__).resolve().parents[1]
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=int, default=1)
ap.add_argument("--repeats", type=int, default=10)
ap.add_argument("--api", default="http://localhost:8000")
ap.add_argument("--out", required=True)
a = ap.parse_args(argv)
man = json.loads((PERF / "results" / f"manifest-s{a.scale}.json").read_text())
rf = [s for s in man["sheets"] if s.get("owner", "rf") == "rf"]
def sheet(ft, sh):
return next(s for s in rf if s["form_type"] == ft and s["sheet"] == sh)
f2, f4c, f4a = sheet("FORM_2", "AHR"), sheet("FORM_4", "CAP"), sheet("FORM_4", "AHR")
pr = man["project_reports"][0]
year = f2["year"]
reqs = [
("rf", "лист FORM_2/AHR", f"/api/v1/form/{f2['form_id']}/sheet/AHR"),
("rf", "лист FORM_4/CAP", f"/api/v1/form/{f4c['form_id']}/sheet/CAP"),
("rf", "лист FORM_4/AHR", f"/api/v1/form/{f4a['form_id']}/sheet/AHR"),
("rf", f"отчёт проекта {pr['report_type']}", f"/api/v1/projects/{pr['project_id']}/report/{pr['year']}/{pr['report_type']}"),
("dfip", "свод 1 MAIN", f"/api/v1/svod/1/MAIN/{year}"),
("dfip", "свод 2 GO (все организации)", f"/api/v1/svod/2/GO/{year}"),
("dfip", "свод 2 RF (все организации)", f"/api/v1/svod/2/RF/{year}"),
("dfip", "свод 2 GO полный, страница 300", f"/api/v1/svod/2/GO/{year}?view=full&offset=0&limit=300"),
("dfip", "свод 2 RF полный, страница 300", f"/api/v1/svod/2/RF/{year}?view=full&offset=0&limit=300"),
("dfip", "свод 2 GO compact, страница 300", f"/api/v1/svod/2/GO/{year}?view=compact&offset=0&limit=300"),
("dfip", "свод 2 RF compact, страница 300", f"/api/v1/svod/2/RF/{year}?view=compact&offset=0&limit=300"),
("dfip", "свод 2 GO варианты фильтров", f"/api/v1/svod/2/GO/{year}/facets"),
("dfip", "свод 2 RF варианты фильтров", f"/api/v1/svod/2/RF/{year}/facets"),
("dfip", "свод 2 RF compact, страница 300, поиск 1", f"/api/v1/svod/2/RF/{year}?view=compact&offset=0&limit=300&search=1"),
("dfip", "сводные проектов", f"/api/v1/projects/plan-summary?year={year}"),
]
tokens = {}
with httpx.Client(base_url=a.api, timeout=300) as c:
for role in {r for r, _, _ in reqs}:
u = man["users"][role]
r = c.post("/api/v1/auth/login", json={"username": u["username"], "password": u["password"]})
r.raise_for_status()
tokens[role] = r.json()["access_token"]
res = []
for role, label, path in reqs:
tot, db, size = [], [], 0
for i in range(a.repeats + 1):
t0 = time.perf_counter()
r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"})
body = r.content
dt = (time.perf_counter() - t0) * 1000
r.raise_for_status()
if i == 0:
rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow)
continue # прогрев
tot.append(dt)
db.append(float(r.headers.get("x-db-time-ms", "nan")))
size = len(body)
row = {"role": role, "request": f"{label} ({rows} строк ответа)", "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1),
"db_ms": round(statistics.median(db), 1), "bytes": size, "content_encoding": r.headers.get("content-encoding")}
row["python_ms"] = round(row["total_ms"] - row["db_ms"], 1)
row["python_share"] = round(row["python_ms"] / row["total_ms"], 2)
row["ratio_total_db"] = round(row["total_ms"] / row["db_ms"], 2) if row["db_ms"] else None
res.append(row)
print(row, flush=True)
out = Path(a.out)
out.mkdir(parents=True, exist_ok=True)
(out / "api-split.json").write_text(json.dumps(res, ensure_ascii=False, indent=1))
md = [f"# API: БД против Python (api_split.py, масштаб {a.scale}, медиана {a.repeats} повторов после прогрева, напрямую :8000)", "",
"| запрос | роль | полное, мс | X-DB-Time-Ms | остаток (Python + передача), мс | доля остатка | полное / БД | размер, МБ | Content-Encoding |",
"|---|---|---|---|---|---|---|---|---|"]
md += [f"| {r['request']} | {r['role']} | {r['total_ms']} | {r['db_ms']} | {r['python_ms']} | {r['python_share']} | {r['ratio_total_db']} | "
f"{r['bytes'] / 1e6:.2f} | {r['content_encoding'] or 'нет'} |" for r in res]
(out / "api-split.md").write_text("\n".join(md) + "\n")
if __name__ == "__main__":
main()