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>
This commit is contained in:
Andrey Epifancev 2026-10-29 00:34:11 +04:00
parent 4e5ba53811
commit 558ad815e0
5 changed files with 106 additions and 3 deletions

View File

@ -268,3 +268,51 @@ cd ..
`web/src`» в стеке каждого сэмпла (кто в коде приложения вызвал работу библиотек) и inclusive time функций `web/src`.
- Имена выходных файлов без «trace» (`timeline-breakdown.*`): шаблон `.gitignore` `perf/**/*trace*.json` исключает трассы.
- py-spy (`perf/requirements.txt`) на macOS требует root - не запускается без sudo; вместо него `analysis/api_split.py`.
## Свод 2 после AURORA-1843
Замер «до/после» свода 2 (серверная постраничная загрузка, компактный ответ, варианты фильтров). Цели и критерии: `openspec/changes/aurora-1843-svod2-server-paging/design.md`, раздел «Замер до/после».
**Порядок на одних данных** (масштаб 1, затем масштаб 2):
1. `perf/stand.sh reset`, `perfkit.gen.run --scale 1`, `perfkit.gen.verify --scale 1`. Своды мерить до `grow`.
2. Скопировать `results/manifest-s1.json` в оба worktree («до» из AURORA-1842, «после» из ветки PR 3).
3. «До»: поднять `api`, `web-build`, `web` в worktree «до», снять замеры (команды ниже).
4. Остановить сервисы.
5. «После»: `alembic upgrade head`, затем `api`, `web-build`, `web`; перед замером выполнить сверку (ниже); снять замеры.
6. То же на масштабе 2 (`PERF_S2_REPEATS=1`, см. «Полный прогон»).
Между «до» и «после» не вливать AURORA-1917 и AURORA-1836: на стенде не должно быть других изменений кода.
**Сверка перед замером «после»** (только чтение, на данных стенда):
```
cd perf
.venv/bin/python analysis/svod2_paging_check.py --scale 1 # склейка страниц, total, проекция compact
node analysis/svod2_search_oracle.mjs --check --api http://localhost:8000 \
--username <dfip из results/manifest-s1.json> --password <пароль> --year <год из манифеста> # поиск и варианты фильтров; CHECK_OK
```
Код выхода 1 или `CHECK_FAILED` означает расхождение с экраном «до»: замер «после» не снимать, сначала чинить код.
**Команды замера** (из `perf/e2e`, затем `perfkit.aggregate`; `<before|after>` и `<N>` подставить по этапу и масштабу):
| что | команда | время на сторону (оценка) |
|---|---|---|
| скролл, время до первых строк | `PERF_RUN_ID=1843-<before\|after>-s<N> PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter`; на масштабе 2 ещё `PERF_MAX_STEPS=10000` | s1: «до» ~35 мин, «после» ~15 мин; s2 ×3–4 |
| фильтр | тот же запуск с `--grep filter` | ~5 мин |
| память | `PERF_RUN_ID=1843-mem-<…>-s<N> PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 на s1] npx playwright test scenarios/svod2-memory.spec.ts` | s1 ~10 мин, s2 ~20–30 мин |
| байты и время API | `.venv/bin/python analysis/api_split.py --scale <N>` | ~3–5 мин |
| планы SQL | `.venv/bin/python analysis/explain_manual.py --scale <N> --out results/1843-explain-s<N>` | ~3 мин |
| время API по URL (bench) | `.venv/bin/python -m perfkit.bench results/<run_id> --repeats 10 --only 'GET /api/v1/svod/2/'` | зависит от числа URL |
Скрипты `api_split.py` и `explain_manual.py` работают на живом стенде (порты 8000 и БД `perf`); `scale_compare.py` читает уже снятые прогоны (`--s1 <run> --s2 <run>`).
**Ограничение `perfkit.bench`.** Без фильтра бенч повторяет все уникальные GET прогона. Для свода 2 в одиночку:
- `--only REGEX` — регулярное выражение по строке `METHOD url` (например `'GET /api/v1/svod/2/'`, или `'svod/2/RF.*facets'` для вариантов фильтров);
- `--repeats N` — число повторов после прогрева (по умолчанию 20; для быстрой проверки достаточно 3–5, для замера цифр в PR — 10–20).
**Сравнение масштабов и разбор.** `scale_compare.py` выводит строки свода 2 тремя ключами: `GET свод 2 <лист>` (полный ответ), `GET свод 2 <лист> страница` (`limit=` в query), `GET свод 2 <лист> facets` (варианты фильтров).
**Обновить цифры в PR** (`feat/236-aurora-1843-svod2-server-paging-perf`): PR открываются без цифр; цифры дописываются по готовности из `report-data/<run_id>/analysis/`.

View File

@ -50,6 +50,13 @@ def main(argv=None):
("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 = {}

View File

@ -56,7 +56,8 @@ def subst(sql: str, form_id: int, sheet: str) -> str:
return sql
def queries(f2: int, f4: int) -> list[dict]:
def queries(f2: int, f4: int, year: int) -> list[dict]:
svod2_variants = [("", {}), (".jit_off", {"jit": "off"})]
return [
{"name": "form2_ahr_tw", "what": "FORM_2/AHR: тело v3.v_form2_sheet_sections (запрос «WITH tw», дерево статей + агрегаты)",
"src": "api/alembic/versions/sql/0033_form2_old_sort.sql:500-1155",
@ -78,6 +79,22 @@ def queries(f2: int, f4: int) -> list[dict]:
"src": "api/src/repository (sheet_repository)",
"sql": f"SELECT row_type, depth, sort_order, data FROM v3.v_form_view({f4}, 'CAP', NULL::text[], NULL::varchar)",
"variants": [("", {}), (".jit_off", {"jit": "off"})], "user": True},
{"name": "svod2_go_page", "what": "свод 2 GO: страница v3.v_svod2_go (300 строк, сжатый ответ, сортировка org_name)",
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_go)",
"sql": f"SELECT * FROM v3.v_svod2_go({year}, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)",
"variants": svod2_variants, "user": True},
{"name": "svod2_rf_page", "what": "свод 2 RF: страница v3.v_svod2_rf (300 строк, сжатый ответ, сортировка org_name)",
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_rf)",
"sql": f"SELECT * FROM v3.v_svod2_rf({year}, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)",
"variants": svod2_variants, "user": True},
{"name": "svod2_rf_search", "what": "свод 2 RF: страница с поиском '1' (v3.v_svod2_rf, отбор svod2_lines по тексту)",
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_rf, v3.svod2_lines)",
"sql": f"SELECT * FROM v3.v_svod2_rf({year}, NULL, 0, 300, 'org_name', 'asc', '1', NULL, NULL, NULL, true)",
"variants": svod2_variants, "user": True},
{"name": "svod2_go_facets", "what": "свод 2 GO: варианты фильтров v3.v_svod2_facets (без поиска и фильтров)",
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_facets)",
"sql": f"SELECT * FROM v3.v_svod2_facets('GO', {year}, NULL, NULL, NULL, NULL, NULL)",
"variants": svod2_variants, "user": True},
]
@ -122,7 +139,8 @@ async def run(dsn: str, scale: int, out: Path):
md = ["# EXPLAIN (ANALYZE, BUFFERS) вложенных запросов (explain_manual.py)", "",
f"Postgres {pg}, work_mem по умолчанию {wm}; масштаб {scale}; FORM_2/AHR form_id={f2}, FORM_4/CAP form_id={f4}; "
f"app.user_id={uid} (perf_rf). Каждый запрос - в транзакции с ROLLBACK.", ""]
for q in queries(f2, f4):
year = next(s["year"] for s in rf if s["form_type"] == "FORM_2" and s["sheet"] == "AHR")
for q in queries(f2, f4, year):
sql = q["sql"]
if sql is None:
sql = subst(func_body(*q["func_body"]), q["form_id"], q["sheet"])

View File

@ -30,6 +30,17 @@ def med(g, k):
return m["median"] if m else None
def svod2_key(path: str) -> str:
"""Ключ свода 2 в таблице API: полный ответ, страница (limit в query) или варианты фильтров (/facets)."""
m = re.match(r"/api/v1/svod/2/(\w+)/", path)
base = "GET свод 2 " + m.group(1)
if "/facets" in path:
return base + " facets"
if "limit=" in path:
return base + " страница"
return base
def api_by_sheet(run: str, scale: int) -> dict:
man = json.loads((PERF / "report-data" / run / f"manifest-s{scale}.json").read_text())
ft = {s["form_id"]: s["form_type"] for s in man["sheets"]}
@ -41,7 +52,7 @@ def api_by_sheet(run: str, scale: int) -> dict:
if m:
key = f"GET лист {ft.get(int(m.group(1)), '?')}/{m.group(2)}"
elif re.match(r"/api/v1/svod/2/(\w+)/", path):
key = "GET свод 2 " + re.match(r"/api/v1/svod/2/(\w+)/", path).group(1)
key = svod2_key(path)
elif re.match(r"/api/v1/svod/1/(\w+)/", path):
key = "GET свод 1 " + re.match(r"/api/v1/svod/1/(\w+)/", path).group(1)
elif "plan-summary" in path:

View File

@ -0,0 +1,19 @@
"""Самопроверка разборщиков свода 2 в perf/analysis (без стенда и БД)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis"))
import explain_manual # noqa: E402
import scale_compare # noqa: E402
def test_svod2_key():
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026") == "GET свод 2 GO"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&offset=0&limit=300") == "GET свод 2 RF страница"
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026/facets?search=1") == "GET свод 2 GO facets"
def test_explain_has_svod2_queries():
names = {q["name"] for q in explain_manual.queries(1, 2, 2026)}
assert {"svod2_go_page", "svod2_rf_page", "svod2_rf_search", "svod2_go_facets"} <= names