Andrey Epifancev 9272ea2527 perf: правки финального ревью фазы 3 (I-4, I-5, M-11, M-13, M-14, T15.2, T17.1, T17.3)
Селектор строк данных свода 2 исключает скелетоны MRT, settle ждёт 0 скелетонов;
first_rows_ms через waitForFunction(raf); счётчики загрузки не пишутся в запись фильтра;
scale_compare различает view/search; api_split не считает строки для facets;
README: порядок до/после и новые метрики.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:46:05 +04:00

105 lines
6.6 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:
res_j = r.json().get("result")
rows = len(res_j) if isinstance(res_j, list) else None # строк ответа: только для списка (facets - dict)
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} строк ответа)" if rows is not None else f"{label} (—)"), "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()