diff --git a/docs/superpowers/plans/2026-10-25-perf-profiling.md b/docs/superpowers/plans/2026-10-25-perf-profiling.md index 74d870b..90eec86 100644 --- a/docs/superpowers/plans/2026-10-25-perf-profiling.md +++ b/docs/superpowers/plans/2026-10-25-perf-profiling.md @@ -19,6 +19,7 @@ - Не менять `api/src`, `api/alembic`, `web/src`, `web/rsbuild.config.ts`, `web/package.json`, `api/requirements*.txt`, `api/web`. Всё новое — только в `perf/` и строки в корневом `.gitignore`. - Не исправлять код приложения, даже очевидные баги: находка идёт в отчёт. - Фронт мерить только на prod-сборке (`rsbuild build`), режимы CPU — `[1, 4, 6]` через `Emulation.setCPUThrottlingRate`. + - **Заменено решением человека 2026-10-25:** основные замеры без троттлинга (CPU 1×); CPU 4× — только контрольный прогон топ-3–4 находок, 1 повтор (`openspec/changes/perf-profiling/design.md`, «Decisions made with the human»). Упоминания `[1,4,6]` и CPU 6 ниже по плану — исходная редакция. - Бэк и Postgres — без лимитов CPU и памяти. В отчёте пометка: «быстрое железо, прод ≈ 1 vCPU / 4 GB». - Postgres 16, схема `v3`, миграции — `cd api && alembic upgrade head`, бэк с `DEBUG=true`, `DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/perf`. - Порты: Postgres 55432, API 8000, фронт 8080. diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index 2adee99..914dfc3 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -192,7 +192,7 @@ | 2026-10-26 | R-5: бот-соавтор на FORM_4/STRUCTURE не дорабатывается; 6 замеров соавтора на этом листе — в «не проверено» с причиной (нет редактируемых ячеек у роли, программы и проекты создаются через WS, а не cell_updated) | STRUCTURE — лист структуры, не таблица ячеек; перегон потребует reset+gen масштаба 1 ради листа без правок ячеек | Нет замера соавтора на FORM_4; косвенно закрыто O-4/O-10 и замерами FORM_2/AHR | | 2026-10-26 | R-6: перед EXPLAIN вложенных запросов и контрольным прогоном 4× данные масштаба 1 восстанавливаются (reset + gen s1 + grow s1, ~20 мин), т.к. после run-all в БД масштаб 2 | Цифры контроля и планы должны быть сопоставимы с масштабом 1 основного отчёта | ~20 минут; состав данных эквивалентен замеренному, но не побитово (id, значения) | | 2026-10-26 | Основная метрика в REPORT.md — `main_thread_ms` (прирост CDP `Performance.TaskDuration`) и `main_thread_ms_per_step` из probe; разбивка trace первого повтора по категориям (`perf/analysis/timeline_breakdown.py`) — только сверка и объяснение «куда уходит время» | probe есть в каждом повторе, trace — только в одном; в 66 из 68 trace сумма задач главного потока совпала с probe в пределах 2 % (у двух trace свода 2 RF по 600 МБ — на 20 % меньше, по-видимому, trace обрезан) | Задачи вне главного потока (растр, компоновщик) в основную цифру не входят | -| 2026-10-26 | Пороги приоритетов: P1 — основной сценарий (скролл листа/свода, правка, совместная работа) и ≥ 50 мс главного потока на элементарное действие или ≥ 0,5 с сервера, либо рост с объёмом; P2 — ≥ 100 мс на действие на отдельных страницах/формах/масштабах или большие ответы; P3 — меньше 100 мс, редкие случаи, гигиена | Порог 50 мс — граница long task и ~3 кадра; действие — то, что пользователь делает многократно | Другая расстановка P1/P2 у пограничных мест (М5, М7) | +| 2026-10-26 | Пороги приоритетов: P1 — основной сценарий (скролл листа/свода, правка, совместная работа) и ≥ 50 мс главного потока на элементарное действие или ≥ 0,5 с сервера, либо рост с объёмом; P2 — ≥ 100 мс на действие на отдельных страницах/формах/масштабах или большие ответы; P3 — меньше 100 мс, редкие случаи, гигиена | Порог 50 мс — граница long task и ~3 кадра; действие — то, что пользователь делает многократно. Правило применяется без исключений (М6, скролл свода 1, — P1 после финального ревью); топ-5 резюме выбирается из P1/P2 по влиянию на жалобы заказчика и охвату пользователей, поэтому М6 (только ДФиП) в топ-5 не вошла, а М5 (вторая жалоба, P2) вошла | Другая расстановка P1/P2 у пограничных мест (М5, М7); другой состав топ-5 | | 2026-10-26 | Места отчёта нумеруются М1–М13 (маркеры для заказчика и задач на исправление), а не O-n/Tn.k | O-n/Tn.k — внутренние маркеры процесса; в отчёт не копируются, найденное по приложению учтено в М-местах | Нет | | 2026-10-26 | Контроль CPU 4× — в отдельный run_id `20261026-control-s1` (`PERF_RUN_TAG=20261026-control`), а не в `20261026-s1` | Шаг aggregate для `20261026-s1` перезаписал бы копии `manifest-s1.json`, `gen-stats`, `grow` в report-data данными восстановленной генерации и смешал бы в одной сводке данные двух генераций | Сравнение 1× и 4× — по двум summary.md, а не по одному | | 2026-10-26 | Цели контроля 4×: скролл FORM_2/AHR (М1), свод 1 MAIN (М6), свод 2 GO (М2), модалка `sheet_select_vsp@big` (М5, жалоба заказчика); бэк-находки М3/М4/М7 в контроль не входят (CPU браузера на них не влияет), соавтор и отчёты проектов — тоже | Решение человека: топ-3–4 находки, 1 повтор; эти четыре закрывают обе жалобы и самые тяжёлые страницы | Нет контрольных цифр для М8 и отчётов проектов | @@ -203,6 +203,9 @@ | 2026-10-26 | Сравнение масштабов — по каждому URL bench с приведением form_id к `/` через манифест масштаба; незавершённые записи s2 (лимит шагов) берутся из сырого jsonl | form_id различаются между масштабами; группы bench сливают разные формы с одним листом | Нет | | 2026-10-26 | «filter did not stabilize» на своде 2 GO (s1) классифицирован как артефакт критерия стабилизации, сценарий не меняли | 30 с наблюдения: 0 мутаций DOM контейнера после ввода фильтра при 6 коммитах React | Если видимые строки должны были измениться — это была бы ошибка фильтра (не проверялась по данным) | | 2026-10-26 | Иллюстрация «на слабой машине хуже» (CPU 6×) — агрегат раннего прогона `perf/results/t5-types` в `perf/report-data/t5-types/` | Требование: каждая цифра отчёта — из report-data; ранний прогон с другими именами target помечен как иллюстрация | Старые имена target (без года в отчётах проектов) | +| 2026-10-26 | R-7: отдельное ревью задачи T10b на модели sonnet не проводилось — его покрывает финальное opus-ревью всей ветки (одна волна исправлений и один scoped re-review) | T10b — анализ и отчёт без кода приложения; финальное ревью читает те же файлы | Находки T10b, видимые только задачному ревью, могли быть пропущены | +| 2026-10-26 | Копия `run-all.status.json` в report-data пишется с путями относительно корня репозитория (`sed` в шаге aggregate и в конце масштаба); три закоммиченные копии очищены тем же способом | Финальное ревью (M-8): абсолютные пути машины в коммитах | Команды в копии статуса нельзя выполнить «как есть» не из корня репозитория | +| 2026-10-26 | Long tasks в окне открытия модалки считаются по пересечению интервалов (`tasksInWindow` в `perf/e2e/lib/modal.ts`), а не «началась после pointerdown» | Финальное ревью (M-7): задача клика начинается раньше, чем её обработчик ставит t0, и терялась; данные 20261026 не пересчитывались (в выводах не используются) | `long_task_*` модалок новых прогонов несравнимы с 20261026 | ## Decisions made with the human diff --git a/perf/README.md b/perf/README.md index 44342db..184974d 100644 --- a/perf/README.md +++ b/perf/README.md @@ -122,9 +122,9 @@ cd .. && .venv/bin/python -m perfkit.memtrend results/mem [--from-cycle 1] - Любая неудачная правка (нет эха, отказ сервера, нет редактируемых ячеек) - ошибка сценария. Как читать `perfkit.memtrend`: -- Цикл 0 - базовая точка: страница `/tasks` до первого открытия листа (heap 7 МБ, nodes 289). Скачок цикл 0 -> 1 - загрузка кода/данных первого листа, а не рост. Поэтому МНК по умолчанию считается с цикла 1 (`--from-cycle N`); вывод печатает использованные циклы и число точек. +- Цикл 0 - базовая точка: страница `/tasks` до первого открытия листа (heap 7 МБ, nodes 289). Скачок цикл 0 -> 1 - по-видимому, загрузка кода/данных первого листа, а не рост (гипотеза: отдельно не проверялась). Поэтому МНК по умолчанию считается с цикла 1 (`--from-cycle N`); вывод печатает использованные циклы и число точек. - Вторым блоком печатается наклон по второй половине (циклы 11-20) - индикатор плато: если он заметно меньше общего, рост затухает. -- Snapshots на циклах 10 и 20 слегка влияют на heap следующего замера (после снимка остаются служебные структуры/GC), поэтому единичные скачки в точках 11 и 21 не интерпретировать. +- Snapshots на циклах 10 и 20, вероятно, влияют на heap следующего замера (гипотеза: после снимка остаются служебные структуры/GC; не измерялось), поэтому единичные скачки в точках 11 и 21 не интерпретировать. - Результат прогона t8-mem (с цикла 1): heap 0.046 МБ/цикл (вторая половина 0.027), nodes 0, listeners 0, ws_open = 1 всегда. DOM, listeners и WS стабильны; остаточный рост JS-heap ~27-46 КБ/цикл неубедителен на 20 циклах - кандидат на diff retainers снимков 10 и 20 (Task 10). ### Бэк @@ -191,11 +191,12 @@ PERF_SCALES=2 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=scroll,grow,mod Контроль на CPU 4x (решение человека: только для топ-3–4 находок, 1 повтор, цифры в отчёте помечаются как контрольные): ``` -PERF_SCALES=1 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=control4x,aggregate \ - PERF_CONTROL_TARGETS='FORM_2/AHR/null@y,svod/svod1_MAIN,modal:sheet_select_vsp@big,coauthor:FORM_2/AHR/null' perf/run-all.sh +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_CONTROL_TARGETS`: `modal:` — модалка, `coauthor:` — скролл при соавторе, остальное — glob целей скролла; `PERF_CONTROL_ROLES` (`rf,dfip`) — роли скролла. CPU 4, 1 повтор (без trace); записи идут в тот же run_id (группы с `cpu_rate=4`), SQL — в `sql/control4x_*`. +- `PERF_CONTROL_TARGETS`: `modal:` — модалка, `coauthor:` — скролл при соавторе, остальное — glob целей скролла; `PERF_CONTROL_ROLES` (`rf,dfip`) — роли скролла. CPU 4, 1 повтор (без trace); записи идут в run_id `-s` (группы с `cpu_rate=4`), SQL — в `sql/control4x_*`. +- **Внимание:** с тегом основного прогона (`PERF_RUN_TAG=20261026`) control4x допишет записи в `results/20261026-s1` и шаг aggregate пересоберёт `report-data/20261026-s1` (summary, копии манифеста, gen-stats, grow — уже восстановленной генерации). Поэтому контроль — с отдельным тегом (`20261026-control`, как в REPORT §2.5 и design.md). - Контроль выполняется после `grow`: для FORM_2 AHR_UTILITY/AHR_SECURITY и FORM_4 CAP цифры 4x несопоставимы со скроллом 1x. @@ -250,6 +251,8 @@ PERF_RUN_ID=20261026-hot PERF_SQLSTATS=0 PERF_WEB=http://localhost:8081 PERF_PRO cd .. .venv/bin/python analysis/cpuprofile_top.py results/20261026-hot --maps .build/prod-sourcemap --out report-data/20261026-s1/analysis .venv/bin/python analysis/hotspots_summary.py results/20261026-hot --out report-data/20261026-s1/analysis +.venv/bin/python analysis/hotspots_summary.py results/20261026-s1 --renders-only --name modal-renders --out report-data/20261026-s1/analysis # рендеры модалок основного прогона +.venv/bin/python analysis/netlog_sizes.py results/20261026-s1 --out report-data/20261026-s1/analysis # размеры ответов на проводе (сжатые) ``` - `hotspots.spec.ts`: `PERF_HOT_TARGETS` - `:` (target как в scroll.spec без оси; action `y`, `x`, `filter`, @@ -257,6 +260,10 @@ cd .. вперёд и столько же назад), `PERF_HOT_FILTER_MS` (30 с наблюдения за мутациями DOM после ввода фильтра), `PERF_HOT_CYCLES`, `PERF_HOT_SHEET2`, `PERF_HOT_ROLE`. Записи - `hotspot_cpu.jsonl` / `hotspot_react.jsonl`; под профайлером абсолютное время выше, сравнивать доли. +- Шаги в hotspots всегда 40 вперёд + 40 назад без проверки конца прокрутки: на коротких страницах (свод 1 MAIN, AHR_RENT) часть + шагов ничего не прокручивает, «на шаг» разбавлено — с `main_thread_ms_per_step` scroll.spec не сравнивать. +- `long_task_*` модалок в данных 20261026 посчитаны прежним фильтром `lib/modal.ts` (задача клика могла не попасть); сейчас - + пересечение интервалов (`tasksInWindow`, тест в `tests/modal-lib.spec.ts`). - `cpuprofile_top.py` декодирует source maps сам (VLQ, без зависимостей): self time по функциям и пакетам, «ближайший кадр `web/src`» в стеке каждого сэмпла (кто в коде приложения вызвал работу библиотек) и inclusive time функций `web/src`. - Имена выходных файлов без «trace» (`timeline-breakdown.*`): шаблон `.gitignore` `perf/**/*trace*.json` исключает трассы. diff --git a/perf/REPORT.md b/perf/REPORT.md index 698b04f..518e4a6 100644 --- a/perf/REPORT.md +++ b/perf/REPORT.md @@ -12,9 +12,10 @@ ### Ответ на жалобы заказчика **«Тормозит скролл» — подтвердилось, это главная проблема.** Один шаг колеса мыши (200 px, ~2,5 строки) стоит -80–95 мс работы главного потока браузера на любом листе любой формы, в отчётах проектов и сводных проектов, 133–139 мс +80–95 мс работы главного потока браузера на больших листах всех форм (71–78 мс на малых), в отчётах проектов и сводных проектов, 133–139 мс на своде 1 (MAIN, FORM_1) и 184–187 мс на своде 2 (масштаб 1) — при бюджете кадра 16,7 мс. Даже на быстром ноутбуке -(Apple M4) каждый шаг прокрутки — это 5–11 «потерянных» кадров; прокрутка всего листа FORM_2/AHR вниз и обратно — 40 с +(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 мс @@ -34,19 +35,23 @@ DOM-узлов, слушателей и WebSocket не растёт; JS-куча **Бэк.** Самое дорогое на сервере — правка ячейки: 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 +в 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 на всех листах (бюджет кадра 16,7 мс); контрольные CPU 4×, 1 повтор: 350 мс | P1 | +| М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 Окружение @@ -131,7 +136,7 @@ FORM_2/AHR = 2 092 строки** (1154 до). Bench, модалки, памят ``` 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 +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) @@ -152,14 +157,14 @@ PERF_SCALES=1 PERF_RUN_TAG=20261026-control PERF_SKIP_RESET=1 PERF_STEPS=control | № | место (`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 | да | +| М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 вдвое дороже | `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 | да | +| М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 мс на шаг | 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 → 90,7/94,0 мс на шаг) | совместная работа; при ≤ 1,6 правки/с — до ~5 % главного потока | P3 | да | +| М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 | нет | @@ -168,12 +173,12 @@ PERF_SCALES=1 PERF_RUN_TAG=20261026-control PERF_SKIP_RESET=1 PERF_STEPS=control ## 4. Подробности -### М1. Скролл любой таблицы листа: 80–95 мс главного потока на шаг колеса +### М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 по всем 29 листам — 70–105 мс. Масштаб 2 — те же 71–91 мс при +в 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`) на шаг @@ -231,24 +236,31 @@ CPU-профиль (`analysis/cpuprofile-top.md`, `hot-svod_svod2_GO-y`): emotio (не замерялось, раздел 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`). +После ввода «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 с. +(×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 — порядка 3,4 с на одиночный PATCH (замер Task 3, комментарий в `perf/perfkit/gen/plan.py:27-31`). +— 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`), применяет правку @@ -281,8 +293,8 @@ CAP после grow (300 строк) — 1,73 с; для сравнения FORM его функция перехода — рекурсивная 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 циклов, +каждый 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. Модалки: перерисовка всей таблицы при открытии @@ -341,7 +353,8 @@ Trace скролла FORM_2/AHR при соавторе (`analysis/timeline-brea 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`) +главного потока на входящую правку. Вывод держится на 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 % главного @@ -384,8 +397,8 @@ root (раздел 6). | лист 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 КБ на строку +Ответы сжимаются 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 МБ. Сеть стенд не эмулировал: время передачи у заказчика — **не проверено замером**. @@ -468,6 +481,27 @@ JS-объектов немного (`Object` +1 121, замыкания, стр - **Отчёты проектов, сводные проектов, соавтор** на 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. Файлы @@ -475,7 +509,7 @@ JS-объектов немного (`Object` +1 121, замыкания, стр `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× — + `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).