perf: отчёт о проблемах производительности

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

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 13:08:12 +04:00
parent 9923a7b88d
commit af0975100b

482
perf/REPORT.md Normal file
View File

@ -0,0 +1,482 @@
# Отчёт о производительности: скролл форм, модальные окна, память, бэк (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 МБ каждый).