From 9272ea25276b57f572c59b3d4b183b2d1af01a3b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 00:46:05 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D1=84=D0=B0=D0=B7=D1=8B=203?= =?UTF-8?q?=20(I-4,=20I-5,=20M-11,=20M-13,=20M-14,=20T15.2,=20T17.1,=20T17?= =?UTF-8?q?.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Селектор строк данных свода 2 исключает скелетоны MRT, settle ждёт 0 скелетонов; first_rows_ms через waitForFunction(raf); счётчики загрузки не пишутся в запись фильтра; scale_compare различает view/search; api_split не считает строки для facets; README: порядок до/после и новые метрики. Co-Authored-By: Claude Opus 5.5 --- perf/README.md | 20 +++++++++++++++--- perf/analysis/api_split.py | 5 +++-- perf/analysis/scale_compare.py | 17 +++++++++++---- perf/e2e/lib/svod2.ts | 3 ++- perf/e2e/scenarios/scroll.spec.ts | 28 +++++++++++++++++++------ perf/e2e/scenarios/svod2-memory.spec.ts | 8 +++---- perf/e2e/tests/svod2-metrics.test.ts | 2 +- perf/tests/test_svod2_analysis.py | 7 ++++++- 8 files changed, 68 insertions(+), 22 deletions(-) diff --git a/perf/README.md b/perf/README.md index ba107c6..09cfe6d 100644 --- a/perf/README.md +++ b/perf/README.md @@ -277,9 +277,12 @@ cd .. 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 «до», снять замеры (команды ниже). + Код сценариев один (из ветки PR 3): в временном worktree «до» (вершина 1842) выполнить + `git checkout feat/236-aurora-1843-svod2-server-paging-perf -- perf/` без коммита; иначе «до» снимется старым `scroll.spec.ts` без новых метрик. +3. «До»: `stand.sh reset/gen/verify/api/web-build/web/down` для «до» запускать из worktree «до»: БД остаётся на старой миграции + (иначе 0053 уронит 6-аргументные `v_svod2_go/rf`, и API «до» пойдёт в новые функции). Поднять `api`, `web-build`, `web`, снять замеры (команды ниже). 4. Остановить сервисы. -5. «После»: `alembic upgrade head`, затем `api`, `web-build`, `web`; перед замером выполнить сверку (ниже); снять замеры. +5. «После»: `stand.sh migrate` и все сервисы «после» запускать из worktree «после»: `alembic upgrade head`, затем `api`, `web-build`, `web`; перед замером выполнить сверку (ниже); снять замеры. 6. То же на масштабе 2 (`PERF_S2_REPEATS=1`, см. «Полный прогон»). Между «до» и «после» не вливать AURORA-1917 и AURORA-1836: на стенде не должно быть других изменений кода. @@ -295,6 +298,17 @@ node analysis/svod2_search_oracle.mjs --check --api http://localhost:8000 \ Код выхода 1 или `CHECK_FAILED` означает расхождение с экраном «до»: замер «после» не снимать, сначала чинить код. +**Новые метрики** (в записях `scroll`, цели `svod/svod2_*`; на сборке «до» часть недоступна): + +- `first_rows_ms` - от «Загрузить» до первых 20 строк с данными (без заглушек и скелетонов), опрос каждый кадр; +- `json_bytes_sheet` - прежний смысл: максимум одного ответа; `json_bytes_total` - сумма всех ответов; `json_bytes_first` - сумма до первых строк; +- `pages_requested` - число запросов страниц (`offset=`); на «до» всегда 0 (экран грузит всё одним запросом) - как число страниц с «после» не сравнивать; +- `placeholder_frame_share` - доля кадров прокрутки, где видна строка-заглушка; +- `filter_result_ms` - от ввода в поиск до стабилизации (и ответа сервера, если он был); пишется только в записи фильтра, там же нет `json_bytes_total/first` и `pages_requested` (они включили бы исходную загрузку). + +Сценарий `svod2-memory`: записи `scenario=svod2-memory`, `target=svod2/` (`go`, `rf`, `rf_scrolled` при `PERF_SVOD2_MEM_SCROLL=1`, `final`), +метрики `heap_used_mb`, `nodes`, `documents`, `js_event_listeners`, `rows_visible`. + **Команды замера** (из `perf/e2e`, затем `perfkit.aggregate`; `` и `` подставить по этапу и масштабу): | что | команда | время на сторону (оценка) | @@ -313,6 +327,6 @@ node analysis/svod2_search_oracle.mjs --check --api http://localhost:8000 \ - `--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` (варианты фильтров). +**Сравнение масштабов и разбор.** `scale_compare.py` выводит строки свода 2 ключами: `GET свод 2 <лист>` (полный ответ), `... страница` (`limit=` в query), `... facets` (варианты фильтров); с `view=` добавляется суффикс `compact`/`full`, с `search=` - `поиск`. **Обновить цифры в PR** (`feat/236-aurora-1843-svod2-server-paging-perf`): PR открываются без цифр; цифры дописываются по готовности из `report-data//analysis/`. diff --git a/perf/analysis/api_split.py b/perf/analysis/api_split.py index 035fede..af2dd3a 100644 --- a/perf/analysis/api_split.py +++ b/perf/analysis/api_split.py @@ -76,12 +76,13 @@ def main(argv=None): dt = (time.perf_counter() - t0) * 1000 r.raise_for_status() if i == 0: - rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow) + 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} строк ответа)", "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1), + 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) diff --git a/perf/analysis/scale_compare.py b/perf/analysis/scale_compare.py index 48e8d51..f2655fe 100644 --- a/perf/analysis/scale_compare.py +++ b/perf/analysis/scale_compare.py @@ -16,6 +16,7 @@ import re import statistics from collections import defaultdict from pathlib import Path +from urllib.parse import parse_qs PERF = Path(__file__).resolve().parents[1] @@ -31,13 +32,21 @@ def med(g, k): def svod2_key(path: str) -> str: - """Ключ свода 2 в таблице API: полный ответ, страница (limit в query) или варианты фильтров (/facets).""" + """Ключ свода 2 в таблице API: полный ответ, страница (limit в query; compact/full по view=), поиск (search=) или /facets.""" m = re.match(r"/api/v1/svod/2/(\w+)/", path) base = "GET свод 2 " + m.group(1) - if "/facets" in path: + route, _, query = path.partition("?") + q = parse_qs(query, keep_blank_values=True) + if route.endswith("/facets"): return base + " facets" - if "limit=" in path: - return base + " страница" + if "limit" in q: + base += " страница" + if "view" in q: + base += " " + q["view"][0] + elif "view" in q: + base += " " + q["view"][0] + if "search" in q: + base += " поиск" return base diff --git a/perf/e2e/lib/svod2.ts b/perf/e2e/lib/svod2.ts index 0707a19..5f7abc4 100644 --- a/perf/e2e/lib/svod2.ts +++ b/perf/e2e/lib/svod2.ts @@ -1,6 +1,7 @@ /** Чистые помощники замеров свода 2 (без импортов, стираемый TS: проверяются `node --test`). */ -export const SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder])'; +/** Строка данных: не заглушка окна и без скелетонов MRT (isLoading рисует .MuiSkeleton-root в ячейках даже при непустых данных). */ +export const SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder]):not(:has(.MuiSkeleton-root))'; export const SVOD2_PLACEHOLDER_SELECTOR = 'tbody tr[data-svod2-placeholder]'; /** Ответы свода 2: страница `/svod/2//...` и `/svod/2///facets`. */ diff --git a/perf/e2e/scenarios/scroll.spec.ts b/perf/e2e/scenarios/scroll.spec.ts index 9f229d3..d34ba2e 100644 --- a/perf/e2e/scenarios/scroll.spec.ts +++ b/perf/e2e/scenarios/scroll.spec.ts @@ -204,8 +204,19 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts) let rows: number; let firstRowsMs = 0; try { - rows = await waitRows(page, rowSel); - if (t.kind === 'svod2') { firstRowsMs = Date.now() - tLoad; bytes.markFirstRows(); } + if (t.kind === 'svod2') { + // опрос каждый кадр (raf), а не раз в 500 мс: иначе first_rows_ms квантуется шагом опроса + await page.waitForFunction( + ([sel, rs, min]) => document.querySelectorAll(`${sel} ${rs}`).length >= (min as number), + [CONTAINER, rowSel, MIN_ROWS] as const, + { polling: 'raf', timeout: LOAD_TIMEOUT }, + ); + firstRowsMs = Date.now() - tLoad; + bytes.markFirstRows(); + rows = await waitRows(page, rowSel); + } else { + rows = await waitRows(page, rowSel); + } } catch (e) { if (!(e instanceof RowsError)) throw e; const have = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]).catch(() => -1); @@ -275,10 +286,15 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts) const svod2Metrics = t.kind === 'svod2' ? { first_rows_ms: firstRowsMs, - json_bytes_total: bytes.total, - json_bytes_first: bytes.first, - pages_requested: countPagesRequested(pageUrls), - ...(filter ? { filter_result_ms: filterResultMs ?? 0 } : { placeholder_frame_share: placeholderShare(placeholderSamples) }), + // в записи фильтра счётчики включали бы исходную загрузку (до fill) - пишем их только в записи скролла + ...(filter + ? { filter_result_ms: filterResultMs ?? 0 } + : { + json_bytes_total: bytes.total, + json_bytes_first: bytes.first, + pages_requested: countPagesRequested(pageUrls), + placeholder_frame_share: placeholderShare(placeholderSamples), + }), } : {}; append({ diff --git a/perf/e2e/scenarios/svod2-memory.spec.ts b/perf/e2e/scenarios/svod2-memory.spec.ts index d556f6a..114211b 100644 --- a/perf/e2e/scenarios/svod2-memory.spec.ts +++ b/perf/e2e/scenarios/svod2-memory.spec.ts @@ -1,7 +1,7 @@ /** * Сценарий памяти свода 2 (Task 16): роль dfip, свежий контекст на каждый повтор, одна SPA-сессия внутри повтора. * Точки (lib/svod2.ts memoryPoints): go (вкладка GO, «Все ССП/РФ», «Загрузить») -> rf (вкладка RF, то же) - * [-> rf_scrolled при PERF_SVOD2_MEM_SCROLL=1] -> final (возврат на GO, «Загрузить»). + * [-> rf_scrolled при PERF_SVOD2_MEM_SCROLL=1] -> final (возврат на вкладку GO; «Загрузить» не нажимается: выбор и данные хранятся по вкладкам). * В каждой точке gc -> heapUsed, domCounters. Запись: scenario "svod2-memory", target "svod2/". * Env: PERF_SCALE, PERF_RUN_ID, PERF_REPEATS (3), PERF_SVOD2_MEM_SCROLL, PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS, * PERF_TEST_TIMEOUT_MS. @@ -30,19 +30,19 @@ async function waitRows(page: Page, what: string): Promise { } } -/** Ждём окончания загрузки: число строк и число заглушек не меняются 3 опроса подряд (интервал 300 мс). */ +/** Ждём окончания загрузки: число строк, заглушек и скелетонов не меняются 3 опроса подряд (интервал 300 мс). */ async function settle(page: Page): Promise { let prev = ''; let same = 0; const t0 = Date.now(); for (;;) { const cur = await page.evaluate( - ([sel, rs, ph]) => `${document.querySelectorAll(`${sel} ${rs}`).length}/${document.querySelectorAll(`${sel} ${ph}`).length}`, + ([sel, rs, ph]) => `${document.querySelectorAll(`${sel} ${rs}`).length}/${document.querySelectorAll(`${sel} ${ph}`).length}/${document.querySelectorAll(`${sel} .MuiSkeleton-root`).length}`, [CONTAINER, SVOD2_DATA_ROW_SELECTOR, SVOD2_PLACEHOLDER_SELECTOR], ); same = cur === prev ? same + 1 : 0; prev = cur; - if (same >= 3) return Number(cur.split('/')[0]); + if (same >= 3 && cur.endsWith('/0')) return Number(cur.split('/')[0]); // готово: строки стабильны и в контейнере 0 скелетонов if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`settle: строки не стабилизировались за ${LOAD_TIMEOUT} мс (${cur})`); await page.waitForTimeout(300); } diff --git a/perf/e2e/tests/svod2-metrics.test.ts b/perf/e2e/tests/svod2-metrics.test.ts index 55e7d82..de98367 100644 --- a/perf/e2e/tests/svod2-metrics.test.ts +++ b/perf/e2e/tests/svod2-metrics.test.ts @@ -61,7 +61,7 @@ test('countPagesRequested', () => { }); test('селекторы', () => { - assert.equal(SVOD2_DATA_ROW_SELECTOR, 'tbody tr:not([data-svod2-placeholder])'); + assert.equal(SVOD2_DATA_ROW_SELECTOR, 'tbody tr:not([data-svod2-placeholder]):not(:has(.MuiSkeleton-root))'); assert.equal(SVOD2_PLACEHOLDER_SELECTOR, 'tbody tr[data-svod2-placeholder]'); }); diff --git a/perf/tests/test_svod2_analysis.py b/perf/tests/test_svod2_analysis.py index ef38a69..3352627 100644 --- a/perf/tests/test_svod2_analysis.py +++ b/perf/tests/test_svod2_analysis.py @@ -10,7 +10,12 @@ 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/RF/2026?view=compact&offset=0&limit=300") == "GET свод 2 RF страница compact" + assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=full&offset=0&limit=300") == "GET свод 2 RF страница full" + assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?offset=0&limit=300") == "GET свод 2 RF страница" + assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&limit=300&search=1") == "GET свод 2 RF страница compact поиск" + assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026?view=full") == "GET свод 2 GO full" + assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026?search=1") == "GET свод 2 GO поиск" assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026/facets?search=1") == "GET свод 2 GO facets"