Andrey Epifancev af0975100b perf: отчёт о проблемах производительности
perf/REPORT.md: резюме и ответы на жалобы заказчика, методика и окружение,
таблица 13 мест (file:line, доказательство, влияние, приоритет, подтверждено
замером), подробности, статический разбор без замера, что не проверено.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:08:12 +04:00

483 lines
64 KiB
Markdown
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.

# Отчёт о производительности: скролл форм, модальные окна, память, бэк (issue #202)
Дата замеров: 2026-10-26. Основные прогоны: `20261026-s1` (масштаб 1, 3 повтора) и `20261026-s2` (масштаб 2, 1 повтор).
Разбор горячих точек: `20261026-hot` (профили на тех же данных масштаба 1). Контроль «на слабой машине»: `20261026-control-s1`
(CPU 4×, 1 повтор). Каждая цифра ниже ссылается на файл в `perf/report-data/<run_id>/`; как получить её заново — раздел 2.5.
Код приложения не менялся: всё, что нужно для замеров, живёт в `perf/`. Исправлений в этом отчёте нет — только места,
доказательства и приоритеты.
## 1. Резюме
### Ответ на жалобы заказчика
**«Тормозит скролл» — подтвердилось, это главная проблема.** Один шаг колеса мыши (200 px, ~2,5 строки) стоит
80–95 мс работы главного потока браузера на любом листе любой формы, в отчётах проектов и сводных проектов, 133–139 мс
на своде 1 (MAIN, FORM_1) и 184–187 мс на своде 2 (масштаб 1) — при бюджете кадра 16,7 мс. Даже на быстром ноутбуке
(Apple M4) каждый шаг прокрутки — это 5–11 «потерянных» кадров; прокрутка всего листа FORM_2/AHR вниз и обратно — 40 с
работы главного потока. От числа строк цена шага почти не зависит (виртуализация строк работает), а зависит от того,
сколько ячеек React перерисовывает на каждый шаг: ~500 видимых ячеек 3–4 раза за шаг, у свода 2 — ~3 900 ячеек.
Контрольный замер на CPU 4× (имитация слабой машины, 1 повтор): 350 мс на шаг листа, 729 мс
на шаг свода 2 — прокрутка идёт рывками по 0,3–0,8 с.
**«Модальное окно открывается не сразу» — на формах подтвердилось слабо, на справочнике ВСП — подтвердилось.**
На листах форм модалки (выбор ВСП, статьи, создание программы/проекта) открываются за 75–93 мс, остальные модалки
задач, проектов и админки — 27–69 мс (масштаб 1). Задержка на листе — оттого, что открытие модалки перерисовывает всю
таблицу листа (11–14 коммитов React, ~6,7 тыс. рендеров компонентов). Модалки справочника ВСП — 150–195 мс при 180 ВСП
и 500–610 мс при 720 ВСП (масштаб 2): таблица справочника не виртуализирована и перерисовывается целиком. На CPU 4×
модалка выбора ВСП на большом листе открывается за 229 мс (контрольный замер). Сеть не эмулировалась.
**Память — утечки масштаба «лист целиком» не найдено.** За 20 циклов «открыть лист → скролл → правки → уйти» число
DOM-узлов, слушателей и WebSocket не растёт; JS-куча растёт на 24–44 КБ за цикл — на 20 циклах это неубедительно.
Растут нативные объекты Blink `MediaQuery*` (~2,3 тыс. на цикл), и время цикла за 20 циклов выросло на 12 % (М13).
**Бэк.** Самое дорогое на сервере — правка ячейки: SQL-функция правки пересобирает весь лист дважды (630 мс на правку
на листе 1154 строк, 1,46 с на листе 2 092 строк), а WebSocket-соединение обрабатывает сообщения строго по очереди —
не больше ~1,6 правки в секунду на соединение. Листы FORM_4 собираются рекурсивным агрегатом по jsonb: 0,6 с на лист
в 110 строк, 1,55–1,7 с на 300 строк, и каждая правка на FORM_4 вдвое дороже. Ответы свода 2 — 31–53 МБ на масштабе 1
и 123–195 МБ на масштабе 2 одним куском; сборка листов в БД растёт с объёмом всей БД, а не только листа.
### Топ-5 мест
| № | Что | Где | Насколько | Приоритет |
|---|---|---|---|---|
| М1 | Каждый шаг скролла таблицы перерисовывает все видимые ячейки 3–4 раза: стили emotion на каждую ячейку, `offsetHeight` в `measureElement`, нет `memoMode` | `web/src/components/RealtimeTable/RealtimeTable.jsx:37-43`, `:344-441`; `web/src/components/RealtimeTable/constants/tableConfig.js:3-62` | 80–95 мс главного потока на шаг 200 px на всех листах (бюджет кадра 16,7 мс); CPU 4×: 350 мс | P1 |
| М2 | Свод 2: нет колоночной виртуализации, ответ целиком 31–195 МБ | `web/src/pages/Svod2Page/Svod2Page.jsx:465-481`, `:25-29`; `api/src/api/v1/summary.py:93-121` | 184–187 мс на шаг (s1), 214–256 мс (s2); API 1,1–1,8 с (s1) → 4,5–7,3 с (s2) | P1 |
| М3 | Правка ячейки пересобирает весь лист дважды; WS обрабатывает сообщения соединения последовательно | `api/alembic/versions/sql/0033_form2_old_sort.sql:1460-1466`, `:1527-1534`; `api/src/api/v1/websocket.py:973-1130` | 630 мс на правку (1154 строки), 1,46 с (2 092 строки); ≤ 1,6 правки/с на соединение | P1 |
| М4 | Листы FORM_4: рекурсивный агрегат `v3.jsonb_sum` на трёх уровнях группировки | `api/alembic/versions/sql/0010_form4.sql:269-297`; `api/alembic/versions/sql/0012_del_project.sql:1418-1481` | GET 0,6 с на 110 строк, 1,55–1,7 с на 300 строк; ~119 тыс. вызовов функции на один GET | P1 |
| М5 | Открытие модалки перерисовывает всю таблицу; справочник ВСП без виртуализации | `RealtimeTable.jsx:106-108`, `:851-871`; `web/src/pages/DictVspPage/components/TableDictVsp.jsx:12-15`, `:238-247` | листы 75–93 мс; справочник ВСП 150–195 мс (180 ВСП) → 500–610 мс (720 ВСП) | P2 |
## 2. Методика и окружение
### 2.1 Окружение
| | |
|---|---|
| Машина | Apple M4, 10 ядер, 16 ГБ, macOS 27.0.1 |
| Браузер | Chromium 156.0.8078.4 (headless shell, Playwright 1.64.1), окно 1600×900, CPU без замедления |
| Фронт | prod-сборка (`perf/web/rsbuild.perf.config.ts` поверх `web/rsbuild.config.ts`), `rsbuild preview` на :8080; React 18.3.1, material-react-table 3.2.1, MUI 9.1.1, @emotion/react 11.14.0, @tanstack/react-virtual 3.11.2 |
| Бэк | uvicorn, один процесс (`api/run_server.py`, `DEBUG=true`), Python 3.12.13, FastAPI 0.111.0, pydantic 2.11.7, SQLAlchemy 2.0.55, asyncpg 0.30.0 |
| БД | PostgreSQL 16.15 в Docker Desktop (VM 10 CPU, 8 ГБ), настройки образа по умолчанию (`work_mem` 4 МБ, `jit` on, `shared_buffers` 128 МБ), `pg_stat_statements` |
| Node | 24.7.0 |
Оговорки к цифрам:
- **Железо быстрое, CPU не замедлялся** (решение человека 2026-10-25): все основные замеры — на CPU 1×, слабые места
видны и так. Контрольный прогон на CPU 4× — только для топ-находок, по 1 повтору; такие цифры помечены
«контрольные, CPU 4×, 1 повтор».
- **Прод-сервер — около 1 vCPU / 4 ГБ.** Бэк и Postgres стенда ничем не ограничены, поэтому серверные цифры
оптимистичнее прода; относительный рейтинг мест от этого не меняется.
- **Сеть не эмулировалась**: фронт, бэк и БД на одной машине. Размеры ответов — отдельной строкой (М10).
- **Яндекс.Браузер не воспроизводился.** Он на Chromium, поэтому цифры главного потока переносимы, но его расширения и
фоновые процессы стенд не воспроизводит.
- **Масштаб 2 — один повтор** (R-4: с тремя прогон занял бы ~13 ч): без разброса и p95; годится для оценки роста с
объёмом, не для точных абсолютных цифр. Разброс между прогонами на масштабе 1 для модалок — до 20–30 %, для шага
скролла — до 5 %; различия меньше этого не интерпретируются.
- Основная роль — исполнитель РФ (`perf_rf`, EXECUTOR_RF) с открытым окном этапа; своды — ДФиП (`perf_dfip`);
admin — контрольный прогон скролла по всем листам (совпал с rf в пределах ~5 %, `report-data/20261026-s1/summary.md`).
### 2.2 Данные
Объём заказчик не сообщил, поэтому генератор параметризован масштабом (`perf/perfkit/gen/plan.py`, обоснование чисел —
в его docstring; ключевое требование — самый большой лист rf не меньше 1000 строк). Данные создаются через те же
HTTP/WS-ручки и SQL-функции `v3.add_*`, что использует приложение.
| | масштаб 1 | масштаб 2 |
|---|---|---|
| ССП / РФ | 3 / 6 | 6 / 12 |
| ВСП на РФ (всего) | 30 (180) | 60 (720) |
| доп. строк на лист (FORM_4 — в 5 раз меньше: add_line на FORM_4 стоит O(n), М4) | 500 (100) | 1000 (200) |
| проектов FORM_3 на РФ | 4 | 8 |
| строк добавлено / ячеек записано | 22 800 / 505 562 | 91 200 / 2 037 701 |
| самый большой лист rf (FORM_2/AHR) до grow | 1154 строки | 1744 строки |
| FORM_4/STRUCTURE / отчёт проекта FORM_3 | 775 / 1364 строки | 775 / 1364 строки |
Источник: `report-data/20261026-s1/{gen-stats-s1.json,manifest-s1.json}`, `report-data/20261026-s2/{gen-stats-s2.json,manifest-s2.json}`.
Порядок прогона: скролл и соавтор — до `grow`, модалки, память и bench — после. `grow` растит FORM_2 AHR_UTILITY и
AHR_SECURITY до 500 строк и FORM_4/CAP до 300 (`grow-s1.json`), а строки сателлитов попадают и в лист AHR — **после grow
FORM_2/AHR = 2 092 строки** (1154 до). Bench, модалки, память, разбор горячих точек и контроль CPU 4× шли на листе в
2 092 строки; скролл и соавтор основного прогона — на 1154.
### 2.3 Метрики
- **Основная метрика (решение человека) — суммарное время главного потока на действие**: `main_thread_ms` = прирост
CDP `Performance.TaskDuration` за действие (скрипт, стили, layout, задачи короче 50 мс тоже); для скролла —
`main_thread_ms_per_step` на один шаг колеса 200 px. Сверка — разбивка trace первого повтора по категориям
(`perf/analysis/timeline_breakdown.py`): сумма задач главного потока совпала с `main_thread_ms` в пределах 2 % в 66 из 68 trace
(`report-data/20261026-s1/analysis/timeline-breakdown.json`, поля `runtask_ms` и `probe_main_thread_ms`); у двух trace свода 2 RF
(по 600 МБ) — на 20 % меньше: по-видимому, trace обрезан, в разборе свода 2 используется GO.
- Модалки: `click_to_visible_ms` — от клика до первого кадра с отрисованной модалкой; `main_thread_ms` в этом окне.
- Медианы: масштаб 1 — 3 повтора (повтор с trace исключается из статистики, поэтому у скролла rf n = 2), модалки — 5
(priority 1) и 3 (priority 2); масштаб 2 — 1 повтор скролла, модалки как на масштабе 1.
- Бэк: bench — 20 повторов каждого GET из сетевого лога сценариев; `pg_stat_statements` по шагам
(`sql/<шаг>/sql_top.json`); EXPLAIN (ANALYZE, BUFFERS) — top-level автоматически (`sql/<шаг>/explain/`), вложенные
запросы функций — вручную (`explain-manual/`, транзакция с ROLLBACK, только `v_*`).
- Профили: CPU-профиль V8 (CDP Profiler, сэмплы 200 мкс) на prod-сборке с source maps (тот же код, что на :8080, с
точностью до хешей имён чанков), привязка к `file:line` через source map (`perf/analysis/cpuprofile_top.py`); счётчики
рендеров React — на профилирующей сборке (`react-dom/profiling`, :8081); heap snapshots — `perf/analysis/heapdiff.py`.
### 2.4 Прогоны
| прогон | что | статус |
|---|---|---|
| `20261026-s1` | скролл 249 тестов (rf 156, admin 61, dfip 32), соавтор 12, модалки 150 + 21 профилирующих, память 20 циклов, bench | все шаги OK, кроме соавтора на FORM_4/STRUCTURE (6 из 12 тестов, раздел 6) — `run-all.status.json` |
| `20261026-s2` | скролл 108 тестов (1 повтор), модалки 150, bench | OK; свод 2 RF — скролл упёрся в лимит 5 000 шагов (раздел 6) |
| `20261026-hot` | CPU-профили и рендеры React при скролле 7 страниц, фильтр сводов, циклы CSS, модалки справочника ВСП | на восстановленных данных масштаба 1 (R-6); сводка — `report-data/20261026-s1/analysis/hotspots.md` |
| `20261026-control-s1` | CPU 4×, 1 повтор: скролл FORM_2/AHR, свод 1 MAIN, свод 2 GO, модалка выбора ВСП на большом листе | `report-data/20261026-control-s1/summary.md` |
### 2.5 Как воспроизвести
Подробности и переменные — `perf/README.md` (раздел «Анализ и отчёт»). Из корня репозитория:
```
perf/stand.sh all # стенд: Postgres, миграции, prod-сборка, api, web
PERF_S2_REPEATS=1 perf/run-all.sh # масштабы 1 и 2 (~6,5 ч + ~6,3 ч) -> results/ и report-data/<дата>-s1|s2
# в БД после прогона масштаб 2; для ручного разбора и контроля восстановить масштаб 1:
perf/stand.sh reset && (cd perf && .venv/bin/python -m perfkit.gen.run --scale 1 \
&& .venv/bin/python -m perfkit.gen.verify --scale 1 && .venv/bin/python -m perfkit.gen.grow --scale 1)
# контроль CPU 4x (отдельный run_id, чтобы не смешивать с основным прогоном):
PERF_SCALES=1 PERF_RUN_TAG=20261026-control PERF_SKIP_RESET=1 PERF_STEPS=control4x,aggregate \
PERF_CONTROL_TARGETS='FORM_2/AHR/null@y,svod/svod1_MAIN,svod/svod2_GO,modal:sheet_select_vsp@big' perf/run-all.sh
```
Скрипты разбора (`perf/analysis/`) и сценарий горячих точек (`perf/e2e/scenarios/hotspots.spec.ts`) — команды в README.
## 3. Таблица проблемных мест
Приоритет (правило этого отчёта, решение без обсуждения — `openspec/changes/perf-profiling/design.md`):
**P1** — основной сценарий (скролл листа или свода, правка, совместная работа) и на быстром стенде ≥ 50 мс главного
потока на элементарное действие или ≥ 0,5 с сервера на действие, либо рост с объёмом данных; **P2** — заметно
(≥ 100 мс на действие) на отдельных страницах, формах или масштабах, либо большие ответы; **P3** — меньше 100 мс на
действие, редкие случаи, гигиена. «Подтверждено замером: да» — эффект измерен этим прогоном; «нет» — найдено чтением кода.
| № | место (`file:line`) | симптом | доказательство | влияние | приоритет | подтверждено замером |
|---|---|---|---|---|---|---|
| М1 | `web/src/components/RealtimeTable/RealtimeTable.jsx:37-43` (`measureElement` читает `offsetHeight`), `:325-441` (конфиг MRT без `memoMode`; `muiTableBodyRowProps` `:362-375` и `muiTableBodyCellProps` `:397-411` создают `sx` на каждую строку и ячейку), `web/src/components/RealtimeTable/constants/tableConfig.js:3-62` | скролл любого листа и отчёта проекта — 80–95 мс главного потока на шаг 200 px | `report-data/20261026-s1/summary.md` (`main_thread_ms_per_step`); `analysis/hotspots.md` (3,3 коммита и 14,8 тыс. рендеров на шаг, из них 1,7 тыс. `Styled(td)`); `analysis/cpuprofile-top.md` (emotion + MUI `sx` ≈ 30 %, `offsetHeight` 14 % self time); `analysis/timeline-breakdown.md` | все листы всех форм, отчёты проектов; CPU 4× — 350 мс на шаг (контрольные, 1 повтор) | P1 | да |
| М2 | `web/src/pages/Svod2Page/Svod2Page.jsx:465-481` (виртуализация только строк), `:25-29` (`measureElement` с `offsetHeight`); загрузка всех организаций без `offset/limit` (`web/src/api/summary.js:6`), хотя `api/src/api/v1/summary.py:99-100` их принимает | свод 2: 184–187 мс на шаг, растёт с объёмом; ответ 31–53 МБ (s1), 123–195 МБ (s2) | `analysis/hotspots.md` (7,8 тыс. рендеров ячеек на шаг); `20261026-s2/analysis/scale-compare.md` (GO 184 → 251 мс/шаг; API GO 1,06 → 4,5–4,8 с, RF 1,8 → 7,1–7,3 с) | ДФиП/админ, своды 2; CPU 4× — 729 мс на шаг | P1 | да |
| М3 | `api/alembic/versions/sql/0033_form2_old_sort.sql:1460-1466` и `:1527-1534` (`v3.v_form_view` всего листа до и после правки в `v3.upd_form_cells`); `api/src/api/v1/websocket.py:973-1130` (цикл соединения: принять → БД → commit → разослать) | правка ячейки 0,63–1,46 с на сервере; эхо соавторам ≤ 1,6 правки/с | `20261026-s1/sql/coauthor/sql_top.json` (304 × 630 мс; вложенные `v_form_view` 316 + 311 мс); `sql/memory/sql_top.json` (60 × 1 460 мс на 2 092 строках); `summary.md`, `FORM_2/AHR/null/coauthor5@y` (62 эха + 6–13 отказов за 45,6–46,1 с при 197–198 отправленных) | каждая правка, совместная работа; FORM_4 — секунды (М4) | P1 | да |
| М4 | `api/alembic/versions/sql/0010_form4.sql:269-297` (`v3.jsonb_numeric_sum`, агрегат `v3.jsonb_sum`); применение — `api/alembic/versions/sql/0012_del_project.sql:1418-1481` (три уровня GROUP BY в `v3.v_form4_sheet_jsonb`) | GET листа FORM_4 0,6 с на 110 строк, 1,55–1,7 с на 300; правки FORM_4 вдвое дороже | `explain-manual/summary.md` (`form4_cap_sheet`: три `Aggregate` по 480–560 мс на 290 строк); `20261026-s1/sql/modals/sql_top.json` (3,56 млн вызовов `jsonb_numeric_sum` на 30 сборок листа); `20261026-s2/analysis/scale-compare.md` | пользователи FORM_4 | P1 | да |
| М5 | `RealtimeTable.jsx:106-108` (состояние модалок в компоненте таблицы), `:851-871`; `web/src/pages/DictVspPage/components/TableDictVsp.jsx:12-15` (состояние модалок), `:238-247` (таблица без виртуализации) | модалка на листе 75–93 мс; справочник ВСП 150–195 мс → 500–610 мс на масштабе 2 | `20261026-s1/summary.md` (группы `modal`); `analysis/hotspots.md` (`dict_vsp_*`: 9–12 коммитов, 25,7–27,3 тыс. рендеров); `analysis/modal-renders.md` (`sheet_*`: 11–14 коммитов, ~6,7 тыс. рендеров); `20261026-s2/analysis/scale-compare.md` (×3,0–3,2) | все форм-модалки; справочник ВСП растёт с числом ВСП; CPU 4× — 229 мс | P2 | да |
| М6 | `web/src/pages/SvodPage/SummaryTable.jsx:69-76` (`new Intl.NumberFormat` в каждой ячейке на каждый рендер), `:10-14` (`measureElement` с `offsetHeight`) | свод 1 MAIN/FORM_1 — 133–139 мс на шаг, сводные проектов — 93 мс | `analysis/cpuprofile-top.md` (`SummaryTable.jsx:69` — 23 % и 14 % self time; `measureElement` 8,5–11,5 %); `summary.md` | ДФиП: своды 1 и сводные проектов; CPU 4× — 555 мс на шаг | P2 | да |
| М7 | `api/alembic/versions/sql/0033_form2_old_sort.sql:500-1155` (запрос «WITH tw» в `v3.v_form2_sheet_sections`), вызов — `api/src/repository/sheet_repository.py:50-67` | GET FORM_2/AHR 0,86 с; на масштабе 2 — 4,8 с (×5,5 при ×1,25 объёма листа); листы с тем же числом строк медленнее в 2,6–2,9 раза | `20261026-s2/analysis/scale-compare.md`; `explain-manual/summary.md`, `pgss_form_view_ahr.json`, `bench.json` | загрузка листов; растёт с объёмом всей БД | P2 | да (время и рост); причина роста — нет |
| М8 | `web/src/components/RealtimeTable/utils/cellUtils.js:46-47` (`structuredClone` всех данных листа на каждое входящее обновление), `hooks/useRealtimeData.js:78-85`; `hooks/useWebSocket.js:30-34` | каждое входящее `cell_updated` соавтора — ~30 мс главного потока у автора | `analysis/timeline-breakdown.md` (`coauthor1`/`coauthor5`: `onmessage` 426/715 мс на 124/209 сообщений, рендер React +0,96/+1,84 с); `summary.md` (89,0 → 90,7/94,0 мс на шаг) | совместная работа; при ≤ 1,6 правки/с — до ~5 % главного потока | P3 | да |
| М9 | `api/src/api/v1/forms.py:27-35`, `:198-201` (`_rows_to_sheet_response`: pydantic-модель на строку и валидация `response_model`), `api/src/api/v1/summary.py:28-37`, `api/src/main.py:248` (gzip) | Python-часть ответа (pydantic, JSON, gzip, передача): свод 2 — 0,34–0,58 с (32 % ответа), листы — 0,02–0,09 с (1–11 %) | `analysis/api-split.md` (полное время против заголовка `X-DB-Time-Ms`, который ставит само приложение) | тяжёлые GET листов и сводов | P2 | да |
| М10 | ответы без постраничной загрузки: лист — все строки (`api/src/api/v1/forms.py:150-201`), свод 2 — все организации (`web/src/api/summary.js:6`, `Svod2Page.jsx:414`: без `offset/limit`) | лист FORM_2/AHR 7,6 МБ, свод 2 GO 30,5 МБ / RF 52,7 МБ (s1), 123 / 195 МБ (s2) | `20261026-s1/summary.md` (группы `api`, `bytes`); `20261026-s2/analysis/scale-compare.md` | сеть не эмулировалась: на медленном канале добавит секунды | P2 | да (размеры); влияние сети — нет |
| М11 | `api/src/api/v1/websocket.py:1067` (новая сессия БД на каждое сообщение), `:1089-1098` (`logger.info` всего сообщения на каждую правку) | — | чтение кода | нагрузка при совместной работе | P3 | нет |
| М12 | `api/alembic/versions/sql/0033_form2_old_sort.sql:500-1155` (план дороже `jit_above_cost`), вложенные `RETURN QUERY` — `0033_form2_old_sort.sql:16-39` и `0015_aggregates_fix.sql:1387` (`v3.v_form_view`); настройки Postgres по умолчанию (`jit` on, `work_mem` 4 МБ) | JIT +210–230 мс на первых 5 вызовах в соединении; 25 МБ временных файлов на каждый GET FORM_2/AHR | `explain-manual/summary.md`, `pgss_form_view_ahr.json`, `bench.json` (в установившемся режиме `jit`/`work_mem` время не меняют) | конфигурация прод-Postgres неизвестна | P3 | да |
| М13 | источник не установлен; удерживает `StyleEngine` документа (путь — `analysis/heapdiff.md`); кандидат по сценарию — редактор ячейки `web/src/components/RealtimeTable/tableColumns.jsx:13-78` (`EditCellPortal`), не проверено | рост нативных объектов Blink `MediaQuery*`: +23 161 объектов каждого из 4 типов за 10 циклов (≈ 3,7 МБ); время цикла +12 % за 20 циклов | `analysis/heapdiff.md`; `memtrend.txt`; `analysis/hotspots.md` (cssgrowth) | длинная сессия без перезагрузки | P3 | частично: рост — да, источник — нет |
## 4. Подробности
### М1. Скролл любой таблицы листа: 80–95 мс главного потока на шаг колеса
**Цифры** (масштаб 1, rf, CPU 1×, медиана, `report-data/20261026-s1/summary.md`): FORM_2/AHR — 89,0 мс на шаг (456 шагов,
40,6 с главного потока на прокрутку вниз и обратно, long tasks 27,4 с, p95 кадра 66,7 мс); FORM_2/CAP — 85,4,
FORM_2/OPER — 88,1, FORM_4/AHR/CAP/OPER — 90–93, FORM_4/STRUCTURE — 89,7, отчёты проектов — 83–85; даже лист AHR_RENT
в 31 строку — 77,4. По оси x — 52–91 мс (на больших листах 78–91). Контроль admin по всем 29 листам — 70–105 мс. Масштаб 2 — те же 71–91 мс при
вдвое больших листах (`report-data/20261026-s2/analysis/scale-compare.md`): цена шага от числа строк не зависит.
**Куда уходит время.** Разбивка trace первого повтора (`report-data/20261026-s1/analysis/timeline-breakdown.md`) на шаг
FORM_2/AHR: scripting 61,9 мс, пересчёт стилей и layout 21,2 мс, GC 4,6 мс. Точка входа — обработчик `scroll`
виртуализатора (`@tanstack/virtual-core/src/index.ts:160`, 4 вызова на шаг; привязка позиции бандла через source map),
который синхронно перерисовывает таблицу, плюс 2 задачи планировщика React на шаг.
Счётчики рендеров на профилирующей сборке (`analysis/hotspots.md`, 80 шагов FORM_2/AHR): **3,3 коммита React и 14,8 тыс.
рендеров компонентов на один шаг колеса**; из них 1 696 — `ForwardRef(Styled(td))`: все ~500 видимых ячеек
перерисовываются при каждом коммите; `Styled(td)` — 48 % self time React. Собственная ячейка приложения `Cell`
(`web/src/components/RealtimeTable/Cell/Cell/Cell.jsx:211`) обёрнута в `React.memo` и в топ не попадает — дорого не
содержимое, а обёртка material-react-table: `MRT_TableBodyCell` → `TableCell` → `Styled(td)` с вычислением стилей.
На листах FORM_4/STRUCTURE и отчёте проекта картина та же (15,9–17,9 тыс. рендеров на шаг).
CPU-профиль того же действия (`analysis/cpuprofile-top.md`, профиль `hot-FORM_2_AHR_null-y`): `@emotion/serialize` 8,7 %,
`@emotion/styled` 5,6 %, `@emotion/hash` 3,6 %, `@mui/system/styleFunctionSx` ~5 %, `sx` в
`material-react-table/src/components/body/MRT_TableBodyCell.tsx:257` — сериализация стилей каждой ячейки на каждый
рендер, ≈ 30 % вместе. Отдельно **14 % — геттер `offsetHeight` из `measureElement`** (`RealtimeTable.jsx:42`): чтение
размера после изменения DOM заставляет браузер синхронно пересчитать layout, хотя высота строки фиксирована
(`TABLE_ROW_HEIGHT = 80`, `constants/tableConfig.js:1`; `height: '5rem'`, `:5`).
**Почему так в коде.** В конфигурации таблицы (`RealtimeTable.jsx:325-441`, база — `constants/tableConfig.js:3-36`) не
задан `memoMode` material-react-table, поэтому MRT перерисовывает все видимые ячейки при каждом рендере таблицы. На каждую
строку и ячейку создаются новые объекты `sx` (`muiTableBodyRowProps`, `RealtimeTable.jsx:362-375`; `muiTableBodyCellProps`,
`:397-411`, который вызывает `getTableBodyCellProps`, `tableConfig.js:51-62`, а тот каждый раз разворачивает
`getTableHeadCellStyles().sx`, `:38-49`) — emotion заново их сериализует и хеширует. Колоночная виртуализация с
`overscan: 10` (`utils/columnLayoutUtils.js:14`) и строчная с `overscan: 5` (`RealtimeTable.jsx:38`) добавляют
невидимые ячейки в каждый коммит (вклад отдельно не замерялся).
**Контрольный замер, CPU 4×, 1 повтор** (`report-data/20261026-control-s1/summary.md`): FORM_2/AHR —
350 мс главного потока на шаг (×3,9 к 1×). Лист в этом прогоне — 2 092 строки (после grow),
сравнивать нужно «на шаг». Иллюстрация из ранних малых прогонов — только как «на слабой машине будет хуже», CPU 6×,
1 повтор (`report-data/t5-types/summary.md`): прокрутка FORM_4/STRUCTURE — 29,7 с на 1× и 181 с на 6×; отчёт проекта —
52,6 с и 316,6 с.
### М2. Свод 2
**Цифры.** Масштаб 1 (dfip, `20261026-s1/summary.md`): шаг скролла — 184 мс (GO) и 187 мс (RF); полная прокрутка — 6,3 мин
(GO) и 9,9 мин (RF) главного потока; trace — scripting 132 мс, стили/layout 31 мс, GC 17 мс на шаг
(`analysis/timeline-breakdown.md`). Масштаб 2 (1 повтор, `20261026-s2/analysis/scale-compare.md`): GO — 251 мс на шаг
(×1,36), RF — 214–256 мс (5 000 шагов, запись незавершённая, раздел 6). Ответ API (bench, распакованный) — 30,5 МБ (GO) и
52,7 МБ (RF) на масштабе 1, 123 и 195 МБ на масштабе 2; время ответа — 1,06 и 1,8 с → 4,5–4,8 и 7,1–7,3 с.
**Почему.** Рендеры на шаг (`analysis/hotspots.md`): 39,8 тыс. рендеров компонентов и 7 810 `Styled(td)` — в 4,6 раза
больше, чем на листе формы: свод 2 виртуализирует только строки (`Svod2Page.jsx:477`, `enableRowVirtualization: true`),
колонки не виртуализированы, поэтому на каждый коммит рендерятся все колонки видимых строк (~3,9 тыс. ячеек).
CPU-профиль (`analysis/cpuprofile-top.md`, `hot-svod_svod2_GO-y`): emotion и MUI `sx` ≈ 40 %, `measureElement` с
`offsetHeight` (`Svod2Page.jsx:28`) — 6 %. Рост шага на масштабе 2 при том же числе видимых строк — вероятно, сборка
мусора на куче со 120–190 МБ данных (GC уже 17 мс на шаг на масштабе 1 против 4–5 мс на листах); trace на масштабе 2 не
снимался (1 повтор), причина роста не подтверждена.
Данные: свод 2 по умолчанию («Все ССП/РФ») загружает все организации одним запросом (`web/src/api/summary.js:6` передаёт
только `org_ids`); бэк поддерживает `offset`/`limit` (`api/src/api/v1/summary.py:99-100`). Ответ кэшируется в контексте
сводов на всю сессию (`web/src/pages/SvodPage/SvodContext.jsx:46-70`) — 30–195 МБ JSON остаются в памяти вкладки
(не замерялось, раздел 5).
**Фильтр свода 2 GO — «filter did not stabilize in 120s» оказался артефактом критерия, а не проблемой приложения.**
После ввода «1» в поиск React делает 6 коммитов (18 тыс. рендеров), главный поток занят 0,66–0,80 с, одна long task
103–137 мс, после чего **в DOM контейнера таблицы нет ни одной мутации за 30 с** (1 796–1 798 кадров,
`analysis/hotspots.md`, раздел «Фильтр»): видимые строки не изменились. Критерий стабилизации сценария
(`perf/e2e/scenarios/scroll.spec.ts:113-150`) ждёт первой мутации и потому истекал по таймауту. Для сравнения: RF и свод
1 MAIN — одна мутация атрибута `class` у `tbody` и 0,60–0,74 с главного потока. На масштабе 2 фильтр GO отработал
(0,19–0,27 с, `20261026-s2/analysis/scale-compare.md`) — там первые строки после фильтра отличались. Цена ввода в поиск
свода 2 — ~0,7 с главного потока; фильтр считается синхронно по всем строкам без debounce
(`Svod2Page.jsx:458-462`).
**Контрольный замер, CPU 4×** (`report-data/20261026-control-s1/summary.md`): шаг скролла GO — 729 мс
(×4,0 к 1×); фильтр GO и здесь «не стабилизировался» за 120 с — тот же артефакт критерия, главный поток за окно — 1,1 с.
### М3. Правка ячейки: два полных пересчёта листа и очередь WebSocket
**Цифры** (`20261026-s1/sql/coauthor/sql_top.json`, лист FORM_2/AHR 1154 строки): `v3.upd_form_cell` — 304 вызова по
630 мс; внутри `v3.upd_form_cells` два вложенных `v3.v_form_view` всего листа — «до» (316 мс) и «после» (311 мс), то есть
`v3.v_form2_view` — 620 вызовов по 310 мс, два на правку. После grow (2 092 строки, шаг памяти, `sql/memory/sql_top.json`)
— 1,46 с на правку. На FORM_4 — порядка 3,4 с на одиночный PATCH (замер Task 3, комментарий в `perf/perfkit/gen/plan.py:27-31`).
**Почему.** `v3.upd_form_cells` (последняя версия — `0033_form2_old_sort.sql:1357-1550`) собирает «снимок до» как
`SELECT ... FROM v3.v_form_view(p_form_id, p_sheet, ...) WHERE <затронутые строки>` (`:1460-1466`), применяет правку
(`v3._apply_form_cell`, 0,6 мс) и собирает «после» тем же способом (`:1527-1534`), затем считает разницу
`v3._jsonb_diff`. `v_form_view` — plpgsql-функция, `WHERE` в неё не проталкивается: чтобы вернуть 3–6 изменённых строк,
каждый раз собирается весь лист. Вызов с проверкой прав — `v3.upd_form_cell` с `p_user_id`
(`0002_functions.sql:2478-2494` → `0031_form2_ahr_satellite_sync.sql:904`).
**Последствие для совместной работы.** Цикл WebSocket-соединения (`api/src/api/v1/websocket.py:973-1130`): принять
сообщение → выполнить в БД (`:1067-1086`) → `commit` → разослать (`:1107-1130`) — следующее сообщение того же
соединения ждёт. Потолок ≈ 1 / 0,63 с ≈ 1,6 правки в секунду. Замер совпал: при 5 правках/с бот получил 62 эха и
6–13 отказов за ~46 с при 197–198 отправленных (`summary.md`, группа `FORM_2/AHR/null/coauthor5@y`: `coauthor_echoed`,
`coauthor_errors`, `coauthor_sent`, `duration_ms`) — ≈ 1,5–1,6 обработанных сообщения в секунду. Пользователь, быстро
вводящий значения, видит, как подтверждения его же правок отстают, и отставание растёт с длиной очереди; на листе в
2 092 строки потолок — ~0,7 правки/с. Текст отказов не разбирался (раздел 6).
Влияние входящих правок на скролл соавтора — небольшое (М8): 89,0 → 90,7 (1 правка/с) → 94,0 мс (5 правок/с) на шаг.
### М4. Листы FORM_4: рекурсивный агрегат по jsonb
**Цифры.** GET листа FORM_4 (bench, `20261026-s2/analysis/scale-compare.md`): AHR и OPER по 110 строк — 0,61–0,65 с;
CAP после grow (300 строк) — 1,73 с; для сравнения FORM_2/CAP в 708 строк — 0,18 с. На масштабе 2 AHR/OPER (210 строк) —
1,3–1,4 с. EXPLAIN ANALYZE тела `v3.v_form4_sheet_jsonb` для CAP (`explain-manual/summary.md`, `form4_cap_sheet`) —
1,55–1,65 с, из них три узла `Aggregate` (по разделу; разделу и программе; разделу, программе и проекту) — по 480–560 мс
на 290 строк, а чтение данных — десятки мс. `20261026-s1/sql/modals/sql_top.json`: 30 сборок листа FORM_4 —
3 563 730 вызовов `v3.jsonb_numeric_sum` (37 с суммарно, ≈ 119 тыс. вызовов на одну сборку); в шаге скролла — 1,26 млн
вызовов на 21 сборку.
**Почему.** Суммы по программам и проектам считает пользовательский агрегат `v3.jsonb_sum` (`0010_form4.sql:294-297`);
его функция перехода — рекурсивная SQL-функция `v3.jsonb_numeric_sum` (`:269-292`), которая для каждой пары jsonb
перечисляет ключи обоих (`jsonb_object_keys` + `UNION`) и рекурсивно складывает листья. Агрегат применяется к 10+
jsonb-колонкам на трёх уровнях группировки (`0012_del_project.sql:1418-1481`). Цена — O(строк × колонок × ключей) на
каждый GET и дважды на каждую правку (М3); add_line на FORM_4 тоже пересобирает лист (0,2 → 1,6 с на 300 строках,
Task 3). Ещё мелочь там же: поиск раздела для каждой строки рекурсивным CTE (`0012_del_project.sql:1142`, 290 циклов,
~24 мс).
### М5. Модалки: перерисовка всей таблицы при открытии
**Листы форм** (rf, 5 повторов, `20261026-s1/summary.md` и `modals-inventory.md`): выбор ВСП — 76 мс от клика до кадра
(на большом листе — 74), выбор статьи — 93 (87), создание программы — 88 (75), создание проекта — 83 (87); дерево колонок
(Popover) — 55–58. От числа строк не зависит — таблица виртуализирована. Профилирующий прогон
(`report-data/20261026-s1/analysis/modal-renders.md`; `commits` и `total_renders` — также в `summary.md`, группы `modal_renders`):
11–14 коммитов и 6,6–6,8 тыс. рендеров компонентов на открытие, из них 472–539 `Styled(td)` — вся видимая таблица. Причина: состояние модалок хранится в
`RealtimeTable` (`RealtimeTable.jsx:106-108`), модалки рендерятся в нём же (`:851-871`), поэтому смена `isOpen`
перерисовывает таблицу, а без `memoMode` (М1) — все её ячейки.
**Справочник ВСП** (admin): добавление 167 мс, редактирование 193, экспорт 159 (масштаб 1, 180 ВСП) и 507 / 609 / 496 мс
на масштабе 2 (720 ВСП) — рост ×3,0–3,2 (`20261026-s2/analysis/scale-compare.md`). Повтор на восстановленных данных —
147–194 мс; 9–12 коммитов и 25,7–27,3 тыс. рендеров, из них 2 896 `Styled(td)` (`20261026-s1/analysis/hotspots.md`).
Причина: таблица справочника рендерит все строки (`TableDictVsp.jsx:238-247`: `enablePagination: false`, виртуализации нет),
а состояние модалок лежит в том же компоненте (`:12-15`). На проде, где ВСП больше, открытие дольше пропорционально.
Остальные модалки задач, проектов и админки — 27–69 мс (`modals-inventory.md`); у `common/Modal` и emotion-модалок нет
`role=dialog` (доступность, Task 7).
**Контрольный замер, CPU 4×**: выбор ВСП на большом листе — 229 мс от клика до кадра
(×3,1 к 1×, `report-data/20261026-control-s1/summary.md`).
### М6. Свод 1 и сводные проектов: форматтер чисел на каждый рендер ячейки
Шаг скролла (dfip, `summary.md`): свод 1 MAIN — 138,6 мс, FORM_1 — 133,4, FORM_2/3/4 — 70–76, сводные проектов — 93,2.
CPU-профиль (`analysis/cpuprofile-top.md`): на своде 1 MAIN 23 % self time — функция `Cell` из `SummaryTable.jsx:69`,
которая на каждый рендер каждой денежной ячейки создаёт `new Intl.NumberFormat('ru-RU', ...)` (`:75`); на сводных
проектов — 14 %. Ещё 8,5–11,5 % — `measureElement` с `offsetHeight` (`SummaryTable.jsx:13`). Для сравнения:
`Svod2Page.jsx:23` создаёт форматтер один раз на модуль.
**Контрольный замер, CPU 4×**: свод 1 MAIN — 555 мс на шаг (×4,0 к 1×).
### М7. Сборка листа FORM_2/AHR в БД и рост с объёмом всей БД
GET FORM_2/AHR (rf, 2 092 строки после grow; bench): 0,86–0,88 с, из них сборка в БД ≈ 0,62 с
(`explain-manual/bench.json`: 10 вызовов `v_form_view`, как из репозитория). На масштабе 2 — 4,8 с (×5,5) при росте листа
×1,25 (9,6 против 7,6 МБ). Листы с одинаковым числом строк на обоих масштабах тоже замедлились: FORM_2/SMETA (134 строки)
96 → 247 мс, FORM_4/SMETA (109) 78 → 225 мс, свод 1 MAIN 0,30 → 0,73 с (`20261026-s2/analysis/scale-compare.md`).
Значит, время сборки листа зависит от объёма всей БД (организаций вдвое больше, строк — вчетверо), а не только от листа.
На масштабе 1 (`explain-manual/summary.md`, `form2_ahr_tw*`): тело вложенного запроса «WITH tw»
(`0033_form2_old_sort.sql:500-1155`) с подставленными параметрами выполняется за 0,24 с без JIT и за 0,48–0,53 с с JIT:
стоимость плана 119 667 выше `jit_above_cost` (100 000), JIT компилирует 467 функций за 210–230 мс. Внутри функции в новом
соединении (`pgss_form_view_ahr.json`) JIT срабатывает на первых пяти вызовах (custom-план: 2 335 функций за 8 вызовов =
5 × 467), дальше Postgres переходит на generic-план без JIT, но не быстрее: 0,64–0,69 с против 0,53–0,58 с. Итого в
установившемся режиме запрос стоит 0,47–0,62 с при 0,24 с «в лучшем случае» — резерв ×2 есть на уровне плана запроса.
В плане — полные проходы по таблицам всей БД (`plan`, `reserve`, `sequestration`, `budget_line_quarter`) через hash join;
на масштабе 1 они стоят 1–2 мс каждый и рост ×5,5 сами не объясняют. План на масштабе 2 не снимался (БД восстановлена в
масштаб 1, R-6), поэтому **причина сверхлинейного роста не подтверждена**.
### М8. Входящие правки соавтора на странице автора
Trace скролла FORM_2/AHR при соавторе (`analysis/timeline-breakdown.md`, раздел «Точки входа»): обработчик сообщений
WebSocket (`t.onmessage`, по source map — `web/src/components/RealtimeTable/hooks/useWebSocket.js:30`) — 426 мс на 124
сообщения (1 правка/с) и 715 мс на 209 (5 правок/с), ≈ 3,4 мс на сообщение; задачи рендера React выросли на 0,96 и 1,84 с
относительно скролла без соавтора при ~39 и ~63 полученных `cell_updated` — ≈ 25–30 мс на обновление. Итого ≈ 30 мс
главного потока на входящую правку. В коде: `updateCells` делает `structuredClone` всех данных листа (`cellUtils.js:47`)
на каждое обновление (`useRealtimeData.js:78-85`); новая ссылка `data` пересоздаёт конфиг таблицы
(`RealtimeTable.jsx:443-483`) и пересчитывает правила валидации по всем строкам (`hooks/useValidationRules.js:17-26`);
каждое сообщение пишется в консоль (`useWebSocket.js:33`). При реальном потоке (≤ 1,6 правки/с, М3) это до ~5 % главного
потока, и оно складывается с М1.
### М9. Python-часть ответа API
Обработчики листов, сводов и отчётов сами ставят заголовок `X-DB-Time-Ms` — время от начала обработчика до получения строк
из БД (поиск формы, SQL-функция, разбор jsonb драйвером; `api/src/api/v1/forms.py:161-184`, `summary.py:113-126`).
Остаток «полное время минус X-DB-Time-Ms» — сборка pydantic-моделей по строке (`_rows_to_sheet_response`,
`forms.py:27-35`, `summary.py:28-37`), валидация `response_model`, JSON, gzip (`api/src/main.py:248`) и передача
(`report-data/20261026-s1/analysis/api-split.md`, 10 повторов, напрямую на :8000):
| запрос | полное, мс | БД (`X-DB-Time-Ms`), мс | остаток, мс | доля остатка |
|---|---|---|---|---|
| лист FORM_2/AHR (2 092 строки, 7,6 МБ) | 813 | 726 | 87 | 11 % |
| лист FORM_4/CAP (300 строк) | 1 757 | 1 732 | 25 | 1 % |
| лист FORM_4/AHR (110 строк) | 643 | 623 | 20 | 3 % |
| отчёт проекта LIMIT (1364 строки) | 343 | 307 | 36 | 11 % |
| свод 1 MAIN | 301 | 288 | 13 | 4 % |
| свод 2 GO (5 563 строки, 30,6 МБ) | 1 057 | 718 | 339 | 32 % |
| свод 2 RF (9 497 строк, 52 МБ) | 1 808 | 1 229 | 578 | 32 % |
Вывод: на листах время — это БД (М3, М4, М7); заметная Python-часть только у свода 2 — 0,34–0,58 с на масштабе 1, и
она растёт с размером ответа (М10). Ни для одного эндпоинта полное время не превышает время БД в 2 раза (максимум
×1,47), поэтому профиль Python по функциям (py-spy) по критерию плана не требовался; он и не запускается на macOS без
root (раздел 6).
### М10. Размеры ответов API (сеть не эмулировалась)
Распакованные размеры (bench: `20261026-s1/summary.md`, группы `api`, метрика `bytes`; масштаб 2 —
`20261026-s2/analysis/scale-compare.md`):
| ответ | масштаб 1 | масштаб 2 |
|---|---|---|
| свод 2 GO, все организации | 30,5 МБ | 123,2 МБ |
| свод 2 RF, все организации | 52,7 МБ | 195,1 МБ |
| лист FORM_2/AHR (rf), после grow | 7,6 МБ (2 092 строки ответа; ~3,6 КБ на строку ответа) | 9,6 МБ |
| лист FORM_2/AHR (rf), до grow (браузер, CDP) | 4,3 МБ (1154 строки, ~3,7 КБ на строку) | 6,5 МБ |
| лист FORM_4/STRUCTURE | 2,9 МБ (775 строк) | 2,9 МБ |
| отчёт проекта FORM_3 | 1,5 МБ (1364 строки) | 1,5 МБ |
Ответы сжимаются gzip уровня 1 (`api/src/main.py:248`): в сетевом логе браузера лист AHR до grow — 329 КБ на проводе
при 4,3 МБ распакованных. Оценка «~15 КБ на строку» из Task 3 этим прогоном не подтвердилась — 3,6–3,7 КБ на строку
ответа. Браузер разбирает весь JSON целиком: для свода 2 это
30–195 МБ. Сеть стенд не эмулировал: время передачи у заказчика — **не проверено замером**.
### М11–М12
М11 — только чтение кода (`websocket.py:1067` — `get_db_session` на каждое сообщение; `:1089-1098` — `logger.info` со
всем содержимым сообщения на каждую правку), замером не подтверждено. М12 — см. М7: JIT срабатывает на первых вызовах в
соединении, временные файлы — 3 270 блоков (≈ 25 МБ) на каждый GET FORM_2/AHR (`pgss_form_view_ahr.json`, tuplestore
`RETURN QUERY` вложенных функций при `work_mem` 4 МБ); в установившемся режиме на стенде `jit=off` и `work_mem=64MB`
время не меняют (`bench.json`: 615–625 мс при любых сочетаниях). Конфигурация прод-Postgres неизвестна.
### М13. Память при длительной работе
20 циклов (`report-data/20261026-s1/memtrend.txt`): с цикла 1 число DOM-узлов (350), слушателей (336), документов (8) и
открытых WebSocket (1) не меняется; JS-куча после GC 11,0 → 11,9 МБ — 0,044 МБ на цикл, во второй половине 0,024. На 20
циклах такой рост неубедителен: утверждать «утечки нет» для JS-кучи нельзя, но порядка «лист целиком» (единицы МБ) нет.
Heap snapshots циклов 10 и 20 (`analysis/heapdiff.md`): self size всех узлов снимка 23,1 → 27,2 МБ. Новых выживших
JS-объектов немного (`Object` +1 121, замыкания, строки стилей emotion, удерживаемые fiber'ами React через
`pendingProps.styles` — текущие компоненты). Почти весь рост — **нативные объекты Blink**: `MediaQueryFeatureExpNode`,
`MediaQuery`, `MediaQuerySet` и их векторы — +23 161 каждого за 10 циклов (≈ 3,7 МБ), удерживаются хеш-таблицей
`StyleEngine` документа (пути удержания — `heapdiff.md`). В `heap_used_mb` (JS-куча) они не попадают. Одновременно время
цикла выросло со 158 до 177 с (+12 %, 1,1 с на цикл) при той же работе (`analysis/memory-cycles.md`); в цикл входит и
время сервера (две загрузки листов, три правки по ~1,5 с), так что рост не обязательно браузерный — не разделялось.
Проверка на упрощённом цикле без правок (`analysis/hotspots.md`, раздел «Рост CSS-правил»): 15 циклов «AHR → скролл 40 шагов → /tasks» и 10 циклов со вторым
листом FORM_4/STRUCTURE, как в сценарии памяти: CSS-правил после первого цикла +17 за 14 циклов и +51 за 9, `@media`-правила
не растут, `window.matchMedia` вызывается 2 раза за цикл (`@mui/system/useMediaQuery/useMediaQuery.mjs:61`, запрос `(max-width:720px)`) без накопления слушателей, шаг
скролла стабилен (75–80 мс). Источник роста `MediaQuery*` на этих циклах не воспроизвёлся; в полном сценарии памяти есть
ещё правки ячеек через редактор (двойной клик, `EditCell`) — следующий кандидат, не проверялся.
Приоритет P3 до выяснения источника.
## 5. Статический разбор без замера — «не подтверждено замером»
Найдено чтением кода по местам, на которые указали профили; эффект отдельно не измерялся.
| место | что | почему может мешать |
|---|---|---|
| `web/src/components/RealtimeTable/hooks/useWebSocket.js:33`, `:88`; `contexts/RealtimeContext.js:108`, `:116`, `:123`, `:149`, `:156`; `hooks/useRealtimeData.js:173` (`console.log(newData)` — все данные листа при удалении строки); `RealtimeTable.jsx:545` | `console.log` в горячих обработчиках WebSocket и навигации поиска | при открытых DevTools — заметные затраты и удержание объектов консолью; в trace их цена входит в 3,4 мс на сообщение (М8) |
| `web/src/pages/SvodPage/SvodContext.jsx:46-70` | ответ свода (30–195 МБ JSON) кэшируется в контексте на всю сессию по ключу «лист + год + организации» | несколько открытых вариантов свода 2 держат в памяти вкладки сотни МБ объектов; сценарий памяти своды не открывал |
| `RealtimeTable.jsx:443-483` | конфиг таблицы пересоздаётся при изменении `data`, `rowSelection`, `editingCell`, `activeSearchMatch`, `globalFilter` | любое из этих изменений перерисовывает всю таблицу (вместе с М1) |
| `tableColumns.jsx:126`, `:128-183` | `structuredClone` конфига колонок при каждом пересчёте `columns`; кэш свойств ячеек — `WeakMap` по объекту строки | после `structuredClone` данных (М8) все объекты строк новые, и кэш промахивается целиком после каждой входящей правки |
| `hooks/useValidationRules.js:17-26` | правила валидации пересчитываются по всем строкам при каждом изменении данных | O(строк) на каждую входящую правку |
| `RealtimeTable.jsx:518-532` | поиск совпадений обходит все строки × все видимые колонки при смене фильтра | O(строк × колонок) на ввод в поиск (с debounce 300 мс, `:125-133`) |
| `hooks/useColumnSettings.js:16-26` | запись в localStorage при каждом изменении ширины колонок | при `columnResizeMode: 'onChange'` (`tableConfig.js:28`) — запись на каждое движение мыши |
| `api/src/api/v1/websocket.py:1067-1098` | новая сессия БД на каждое сообщение, `logger.info` всего сообщения | М11 |
Проверено, проблем не найдено: контексты `RealtimeContext.js` разделены (действия, соединение, ВСП, блокировки), значения
мемоизированы (`:475-519`), блокировки ячеек — внешнее хранилище с подпиской по ключу ячейки (`:27-78`); слушатели и таймеры
в `RealtimeTable.jsx:235-248`, `:287-295`, `:589-607` и `useWebSocket.js:96-99` снимаются в cleanup. Замер памяти это
подтверждает: слушатели и WebSocket не копятся (М13).
## 6. Что не удалось проверить и почему
- **Соавтор на FORM_4/STRUCTURE** (6 из 12 замеров соавтора, R-5). Бот завершался «нет редактируемых ячеек для роли».
Проверено по коду: лист STRUCTURE в БД собирается только из строк `row_type <> 'INPUT'`
(`api/alembic/versions/sql/0033_form2_old_sort.sql:387-395`), а фронт делает редактируемыми только строки `INPUT`
(`web/src/components/RealtimeTable/tableColumns.jsx:132`) — правок `cell_updated` на этом листе в приложении нет; события
`program_updated`/`project_updated` бэк поддерживает (`api/src/api/v1/websocket.py:423-442`), но фронт их не отправляет.
Бот не дорабатывался; замера соавтора на FORM_4 нет — косвенно его заменяют М3, М4 и замер FORM_2/AHR.
- **Свод 2 RF на масштабе 2** — скролл прошёл 5 000 шагов вперёд и упёрся в лимит сценария (`PERF_MAX_STEPS`); обратного
прохода нет, запись `incomplete` и в сводную таблицу не вошла. Цена шага по этим 5 000 шагам — 214–256 мс
(`20261026-s2/analysis/scale-compare.md`). Загрузка свода (190 МБ) прошла.
- **Фильтр свода 2 GO на масштабе 1** в основном прогоне — `incomplete` («filter did not stabilize in 120s»); разобрано в
М2: артефакт критерия, цена ввода измерена отдельно (`analysis/hotspots.md`).
- **py-spy** — на macOS требует root («This program requires root on OSX»), sudo не использовался. Вопрос «API дольше SQL
в 2 раза и больше?» закрыт заголовком `X-DB-Time-Ms`, который ставит само приложение (М9, `analysis/api-split.md`);
профиля Python по функциям нет.
- **Причина сверхлинейного роста GET листа на масштабе 2** (М7) — план на масштабе 2 не снимался: после прогона БД
восстановлена в масштаб 1 (R-6).
- **Обновление токена (refresh) в длинной WS-сессии** — сценарий памяти сам перевыпускает токен через API; как приложение
обновляет токен у открытого WebSocket, не проверялось.
- **Источник роста нативных `MediaQuery*`** (М13) — на упрощённых циклах без правок не воспроизвёлся (CSS-правила, `@media`, `matchMedia` не растут —
`analysis/hotspots.md`); цикл с правками через редактор отдельно не проверялся.
- **Прямое влияние Яндекс.Браузера** (расширения, фоновые процессы) — стенд на Chromium.
- **Сеть** — не эмулировалась; влияние размеров ответов (М10) у заказчика — оценка, не замер.
- **Прод-ресурсы** (≈ 1 vCPU / 4 ГБ) — не воспроизводились (указание владельца не ограничивать стенд).
- **Текст отказов в эхе соавтора** (6–13 на повтор, М3) — не разбирался.
- **Счётчики рендеров модалок админки** — сняты только для priority 1 и справочника ВСП.
- **Отчёты проектов, сводные проектов, соавтор** на CPU 4× — в контрольный прогон не входили (только топ-находки).
- **Вклад отдельных настроек MRT** (overscan, `memoMode`, `sx`-функции) в цену шага по отдельности — без правок кода
приложения не измерить; оценка для каждой — в М1.
## 7. Файлы
- Сводные цифры: `perf/report-data/20261026-s1/summary.{json,md}`, `perf/report-data/20261026-s2/summary.{json,md}`,
`perf/report-data/20261026-control-s1/summary.{json,md}`; перечень модалок — `modals-inventory.md`.
- SQL: `report-data/20261026-s1/sql/<шаг>/{sql_top.json,explain/}`, ручные EXPLAIN — `report-data/20261026-s1/explain-manual/`.
- Разбор: `report-data/20261026-s1/analysis/` (`timeline-breakdown`, `cpuprofile-top`, `hotspots`, `heapdiff`, `api-split`,
`memory-cycles`, рабочие заметки `NOTES.md`), `report-data/20261026-s2/analysis/scale-compare.md`; иллюстрация CPU 6× —
`report-data/t5-types/summary.md`.
- Скрипты: `perf/analysis/*.py`; сценарий горячих точек — `perf/e2e/scenarios/hotspots.spec.ts`; prod-сборка с source maps —
`perf/stand.sh web-sourcemap` (:8082).
- Сырые trace, CPU-профили и heap snapshots — `perf/results/<run_id>/artifacts/` (в git не попадают, 30–600 МБ каждый).