- REPORT: правки FORM_4 — вывод по коду, не замер (I-1); цена фильтра свода по записям основного прогона, hotspots — верхняя граница (I-2); М6 поднята до P1, пояснение состава топ-5 (I-4); формулировки кадров, малых листов, admin-контроля, М8, время прогона (M-1, M-2, M-9, M-11); раздел 6 дополнен, новый раздел 6а с находками при подготовке стенда (M-10, M-12, T7.3) - README: контроль 4x с отдельным тегом и предупреждением (I-3), команды modal-renders и netlog-sizes (M-4), гипотезы в «Как читать» (T8.7) - design.md: R-7, правило приоритетов и топ-5, решения волны исправлений - план: заметка о замене CPU [1,4,6] решением человека (M-12) Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
71 KiB
Отчёт о производительности: скролл форм, модальные окна, память, бэк (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 мс работы главного потока браузера на больших листах всех форм (71–78 мс на малых), в отчётах проектов и сводных проектов, 133–139 мс
на своде 1 (MAIN, FORM_1) и 184–187 мс на своде 2 (масштаб 1) — при бюджете кадра 16,7 мс. Даже на быстром ноутбуке
(Apple M4) каждый шаг прокрутки — это работа главного потока на 5–11 кадров (измеренный недобор кадров на шаг — 2,6 на листе
FORM_2/AHR, 5,7 на своде 1 MAIN, 8,5 на своде 2 GO: frame_excess_ms / steps, summary.md); прокрутка всего листа 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 по аналогии с М3 (два полных пересчёта листа) должна стоить вдвое больше GET — это вывод по коду, замером на 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 на больших листах, 71–78 на малых (бюджет кадра 16,7 мс); контрольные CPU 4×, 1 повтор: 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 |
Топ-5 выбран по влиянию на жалобы заказчика и охвату пользователей: М6 (свод 1 и сводные проектов, P1 по правилу — 133–139 мс на шаг) в него не вошла, потому что касается только ДФиП на двух страницах, а М5 — вторая жалоба заказчика.
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= прирост CDPPerformance.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 (факт 2026-10-26: ~4 ч 39 мин + ~3 ч 54 мин) -> 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 (71–78 на малых листах) | 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 — по аналогии с М3 вдвое дороже GET (не замерялось) | 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 | да (GET); правки FORM_4 — нет |
| М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 мс на шаг | P1 | да |
| М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 → 94,0 мс на шаг при 5 правках/с; +1,9 % при 1/с — в пределах шума) |
совместная работа; при ≤ 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. Скролл любой таблицы листа: 71–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 — 70–105 мс по 24 из 29 листов (у 5 листов — 1 строка, запись incomplete). Масштаб 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 тыс. рендеров), одна 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. На масштабе 2 фильтр GO отработал (20261026-s2/analysis/scale-compare.md).
Цена ввода в поиск — по записям основного прогона: свод 2 RF 184–215 мс главного потока (масштаб 1), свод 2 GO 187–266 мс и
RF 281–325 мс (масштаб 2), свод 1 MAIN 161–166 мс (20261026-s1/summary.md, 20261026-s2/summary.md, группы .../filter@y),
то есть ≈ 0,2–0,3 с; нижняя граница для GO масштаба 1 — одиночная long task 103–137 мс. Цифры main thread из hotspots.md
(0,60–0,80 с) — окно 30 с под наблюдателем мутаций и V8-профайлером, верхняя граница, а не цена ввода. Фильтр считается
синхронно по всем строкам без debounce (Svod2Page.jsx:458-462).
Контрольный замер, CPU 4× (report-data/20261026-control-s1/summary.md): шаг скролла GO — 729 мс
(×4,0 к 1×); фильтр GO и здесь «не стабилизировался» за 120 с — тот же артефакт критерия (1,1 с главного потока — за всё окно 120 с,
не цена ввода).
М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 в этом прогоне не замерялась (соавтор на STRUCTURE невозможен, R-5; сценарий памяти
FORM_4 не правит); по коду она тоже дважды пересобирает лист (М4) — не подтверждено замером. Ориентир — замер Task 3
при генерации (одиночный PATCH ячейки FORM_4 ~3,4 с): вне report-data, командой из README не воспроизводится.
Почему. 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; на FORM_4 не замерялось); add_line на FORM_4 тоже пересобирает лист
(0,2 → 1,6 с на 300 строках — замер Task 3 при генерации, комментарий perf/perfkit/gen/plan.py:27-31, вне report-data). Ещё мелочь там же: поиск раздела для каждой строки рекурсивным 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 мс
главного потока на входящую правку. Вывод держится на trace (обработчик и рендер) и на дельте при 5 правках/с
(89,0 → 94,0 мс на шаг, +5,6 %); дельта при 1 правке/с (+1,9 %) — ниже порога интерпретации (≤ 5 %, раздел 2.1). В коде: 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 — ~321 КиБ на проводе
при 4,3 МБ распакованных, свод 2 GO — 2,3 МиБ, RF — 3,4 МиБ (медианы body_size, analysis/netlog-sizes.md). Оценка «~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. - Правки ячеек на FORM_4 — не замерялись (М3, М4): «вдвое дороже GET» — вывод по коду.
- Масштаб 2 — 1 повтор (R-4): без разброса и p95, цифры s2 — оценка роста, не точные значения.
- Оверлеи вне перечня модалок —
web/src/components/Stages/Calendar/CalendarInput.jsxиweb/src/pages/ProjectPages/utils/tableHandlers.jsв перечень модалок не вошли и не замерялись (T7.3). - Long tasks в окне открытия модалки (
long_task_n,long_task_ms) в данных 20261026 посчитаны прежним фильтромperf/e2e/lib/modal.ts(задачи, начавшиеся после pointerdown): задача самого клика могла не попасть (на CPU 4× —long_task_n = 0при 229 мс открытия). В выводах отчёта эти метрики не используются (основная —click_to_visible_msиmain_thread_ms); фильтр исправлен на пересечение интервалов (tasksInWindow) для следующих прогонов.
6а. Находки по приложению, обнаруженные при подготовке стенда (не профилирование)
Не влияют на производительность; найдены, когда генератор данных ходил в API (Task 1, Task 3). Код не менялся.
| находка | место | симптом |
|---|---|---|
/api/v1/users/me отдаёт 500 для email с доменом .local |
api/src/api/v1/users.py:27 (response_model=UserAdminListResponse), api/src/domain/schemas.py:88 (EmailStr) |
email-validator отклоняет зарезервированный домен при сериализации ответа; пользователь создаётся, но /users/me падает (стенд использует admin@perf-stand.com) |
гонка technical_number при параллельном POST /projects |
api/alembic/versions/sql/0018_technical_number.sql:41-47 (MAX(...)+1 без блокировки), уникальный индекс :8-9 |
параллельные создания дают 409; генератор создаёт проекты последовательно |
POST /stages/form/{id} отвергает даты с часовым поясом |
api/src/repository/form_phase_repository.py:70-71 (CAST(... AS TIMESTAMP WITHOUT TIME ZONE)) |
asyncpg: «can't subtract offset-naive and offset-aware datetimes» → 400 вместо нормализации |
| логин короче 3 символов отклоняется | api/src/domain/schemas.py:89-91 (min_length=3) |
422 при создании пользователя rf (ограничение, а не ошибка; в документации не указано) |
| лист FORM_4/STRUCTURE не меняется при добавлении строк | api/alembic/versions/sql/0033_form2_old_sort.sql:387-395 (только строки row_type <> 'INPUT') |
775 строк до и после добавления программ/проектов/строк; по-видимому, по замыслу показывает шаблон |
WS program_added с sheet=STRUCTURE → 404 |
api/src/repository/sheet_repository.py:175-185 (sheet передаётся в v3.add_form4_program как section_code) |
«section_code=STRUCTURE не существует»; программу можно добавить только с листов AHR/CAP/OPER |
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,modal-renders,netlog-sizes, рабочие заметки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 МБ каждый).