perf: REPORT, README, design.md — правки по финальному ревью

- 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>
This commit is contained in:
Andrey Epifancev 2026-10-26 13:34:58 +04:00
parent 5f4afc0926
commit bc52212811
4 changed files with 79 additions and 34 deletions

View File

@ -19,6 +19,7 @@
- Не менять `api/src`, `api/alembic`, `web/src`, `web/rsbuild.config.ts`, `web/package.json`, `api/requirements*.txt`, `api/web`. Всё новое — только в `perf/` и строки в корневом `.gitignore`. - Не менять `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`. - Фронт мерить только на 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 — без лимитов 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 16, схема `v3`, миграции — `cd api && alembic upgrade head`, бэк с `DEBUG=true`, `DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/perf`.
- Порты: Postgres 55432, API 8000, фронт 8080. - Порты: Postgres 55432, API 8000, фронт 8080.

View File

@ -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-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 | 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 | Основная метрика в 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 | Места отчёта нумеруются М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 | Контроль 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 и отчётов проектов | | 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 к `<form_type>/<sheet>` через манифест масштаба; незавершённые записи s2 (лимит шагов) берутся из сырого jsonl | form_id различаются между масштабами; группы bench сливают разные формы с одним листом | Нет | | 2026-10-26 | Сравнение масштабов — по каждому URL bench с приведением form_id к `<form_type>/<sheet>` через манифест масштаба; незавершённые записи s2 (лимит шагов) берутся из сырого jsonl | form_id различаются между масштабами; группы bench сливают разные формы с одним листом | Нет |
| 2026-10-26 | «filter did not stabilize» на своде 2 GO (s1) классифицирован как артефакт критерия стабилизации, сценарий не меняли | 30 с наблюдения: 0 мутаций DOM контейнера после ввода фильтра при 6 коммитах React | Если видимые строки должны были измениться — это была бы ошибка фильтра (не проверялась по данным) | | 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 | Иллюстрация «на слабой машине хуже» (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 ## Decisions made with the human

View File

@ -122,9 +122,9 @@ cd .. && .venv/bin/python -m perfkit.memtrend results/mem [--from-cycle 1]
- Любая неудачная правка (нет эха, отказ сервера, нет редактируемых ячеек) - ошибка сценария. - Любая неудачная правка (нет эха, отказ сервера, нет редактируемых ячеек) - ошибка сценария.
Как читать `perfkit.memtrend`: Как читать `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) - индикатор плато: если он заметно меньше общего, рост затухает. - Вторым блоком печатается наклон по второй половине (циклы 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). - Результат прогона 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 повтор, цифры в отчёте помечаются как контрольные): Контроль на CPU 4x (решение человека: только для топ-3–4 находок, 1 повтор, цифры в отчёте помечаются как контрольные):
``` ```
PERF_SCALES=1 PERF_RUN_TAG=20261026 PERF_SKIP_RESET=1 PERF_STEPS=control4x,aggregate \ 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,modal:sheet_select_vsp@big,coauthor:FORM_2/AHR/null' perf/run-all.sh 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:<id>` — модалка, `coauthor:<glob>` — скролл при соавторе, остальное — glob целей скролла; `PERF_CONTROL_ROLES` (`rf,dfip`) — роли скролла. CPU 4, 1 повтор (без trace); записи идут в тот же run_id (группы с `cpu_rate=4`), SQL — в `sql/control4x_*`. - `PERF_CONTROL_TARGETS`: `modal:<id>` — модалка, `coauthor:<glob>` — скролл при соавторе, остальное — glob целей скролла; `PERF_CONTROL_ROLES` (`rf,dfip`) — роли скролла. CPU 4, 1 повтор (без trace); записи идут в run_id `<PERF_RUN_TAG>-s<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. - Контроль выполняется после `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 .. 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/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-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>:<action>` (target как в scroll.spec без оси; action `y`, `x`, `filter`, - `hotspots.spec.ts`: `PERF_HOT_TARGETS` - `<target>:<action>` (target как в scroll.spec без оси; action `y`, `x`, `filter`,
@ -257,6 +260,10 @@ cd ..
вперёд и столько же назад), `PERF_HOT_FILTER_MS` (30 с наблюдения за мутациями DOM после ввода фильтра), `PERF_HOT_CYCLES`, вперёд и столько же назад), `PERF_HOT_FILTER_MS` (30 с наблюдения за мутациями DOM после ввода фильтра), `PERF_HOT_CYCLES`,
`PERF_HOT_SHEET2`, `PERF_HOT_ROLE`. Записи - `hotspot_cpu.jsonl` / `hotspot_react.jsonl`; под профайлером абсолютное время выше, `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 по функциям и пакетам, «ближайший кадр - `cpuprofile_top.py` декодирует source maps сам (VLQ, без зависимостей): self time по функциям и пакетам, «ближайший кадр
`web/src`» в стеке каждого сэмпла (кто в коде приложения вызвал работу библиотек) и inclusive time функций `web/src`. `web/src`» в стеке каждого сэмпла (кто в коде приложения вызвал работу библиотек) и inclusive time функций `web/src`.
- Имена выходных файлов без «trace» (`timeline-breakdown.*`): шаблон `.gitignore` `perf/**/*trace*.json` исключает трассы. - Имена выходных файлов без «trace» (`timeline-breakdown.*`): шаблон `.gitignore` `perf/**/*trace*.json` исключает трассы.

View File

@ -12,9 +12,10 @@
### Ответ на жалобы заказчика ### Ответ на жалобы заказчика
**«Тормозит скролл» — подтвердилось, это главная проблема.** Один шаг колеса мыши (200 px, ~2,5 строки) стоит **«Тормозит скролл» — подтвердилось, это главная проблема.** Один шаг колеса мыши (200 px, ~2,5 строки) стоит
80–95 мс работы главного потока браузера на любом листе любой формы, в отчётах проектов и сводных проектов, 133–139 мс 80–95 мс работы главного потока браузера на больших листах всех форм (71–78 мс на малых), в отчётах проектов и сводных проектов, 133–139 мс
на своде 1 (MAIN, FORM_1) и 184–187 мс на своде 2 (масштаб 1) — при бюджете кадра 16,7 мс. Даже на быстром ноутбуке на своде 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 ячеек. сколько ячеек React перерисовывает на каждый шаг: ~500 видимых ячеек 3–4 раза за шаг, у свода 2 — ~3 900 ячеек.
Контрольный замер на CPU 4× (имитация слабой машины, 1 повтор): 350 мс на шаг листа, 729 мс Контрольный замер на CPU 4× (имитация слабой машины, 1 повтор): 350 мс на шаг листа, 729 мс
@ -34,19 +35,23 @@ DOM-узлов, слушателей и WebSocket не растёт; JS-куча
**Бэк.** Самое дорогое на сервере — правка ячейки: SQL-функция правки пересобирает весь лист дважды (630 мс на правку **Бэк.** Самое дорогое на сервере — правка ячейки: SQL-функция правки пересобирает весь лист дважды (630 мс на правку
на листе 1154 строк, 1,46 с на листе 2 092 строк), а WebSocket-соединение обрабатывает сообщения строго по очереди — на листе 1154 строк, 1,46 с на листе 2 092 строк), а WebSocket-соединение обрабатывает сообщения строго по очереди —
не больше ~1,6 правки в секунду на соединение. Листы FORM_4 собираются рекурсивным агрегатом по jsonb: 0,6 с на лист не больше ~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 одним куском; сборка листов в БД растёт с объёмом всей БД, а не только листа. и 123–195 МБ на масштабе 2 одним куском; сборка листов в БД растёт с объёмом всей БД, а не только листа.
### Топ-5 мест ### Топ-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 | | М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 | | М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 | | М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 | Открытие модалки перерисовывает всю таблицу; справочник ВСП без виртуализации | `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. Методика и окружение
### 2.1 Окружение ### 2.1 Окружение
@ -131,7 +136,7 @@ FORM_2/AHR = 2 092 строки** (1154 до). Bench, модалки, памят
``` ```
perf/stand.sh all # стенд: Postgres, миграции, prod-сборка, api, web 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: # в БД после прогона масштаб 2; для ручного разбора и контроля восстановить масштаб 1:
perf/stand.sh reset && (cd perf && .venv/bin/python -m perfkit.gen.run --scale 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) && .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`) | симптом | доказательство | влияние | приоритет | подтверждено замером | | № | место (`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 | да | | М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 | да | | М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 | да | | М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 | да | | М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 | да (время и рост); причина роста — нет | | М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 | да | | М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 | да (размеры); влияние сети — нет | | М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 | нет | | М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. Подробности ## 4. Подробности
### М1. Скролл любой таблицы листа: 80–95 мс главного потока на шаг колеса ### М1. Скролл любой таблицы листа: 71–95 мс главного потока на шаг колеса
**Цифры** (масштаб 1, rf, CPU 1×, медиана, `report-data/20261026-s1/summary.md`): FORM_2/AHR — 89,0 мс на шаг (456 шагов, **Цифры** (масштаб 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, 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 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`): цена шага от числа строк не зависит. вдвое больших листах (`report-data/20261026-s2/analysis/scale-compare.md`): цена шага от числа строк не зависит.
**Куда уходит время.** Разбивка trace первого повтора (`report-data/20261026-s1/analysis/timeline-breakdown.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). (не замерялось, раздел 5).
**Фильтр свода 2 GO — «filter did not stabilize in 120s» оказался артефактом критерия, а не проблемой приложения.** **Фильтр свода 2 GO — «filter did not stabilize in 120s» оказался артефактом критерия, а не проблемой приложения.**
После ввода «1» в поиск React делает 6 коммитов (18 тыс. рендеров), главный поток занят 0,66–0,80 с, одна long task После ввода «1» в поиск React делает 6 коммитов (18 тыс. рендеров), одна long task 103–137 мс, после чего **в DOM
103–137 мс, после чего **в DOM контейнера таблицы нет ни одной мутации за 30 с** (1 796–1 798 кадров, контейнера таблицы нет ни одной мутации за 30 с** (1 796–1 798 кадров, `analysis/hotspots.md`, раздел «Фильтр»).
`analysis/hotspots.md`, раздел «Фильтр»): видимые строки не изменились. Критерий стабилизации сценария То, что видимые строки не изменились, — вывод из отсутствия мутаций; по данным свода он не проверялся (если строки должны
(`perf/e2e/scenarios/scroll.spec.ts:113-150`) ждёт первой мутации и потому истекал по таймауту. Для сравнения: RF и свод были измениться, это была бы ошибка фильтра). Критерий стабилизации сценария (`perf/e2e/scenarios/scroll.spec.ts:113-150`)
1 MAIN — одна мутация атрибута `class` у `tbody` и 0,60–0,74 с главного потока. На масштабе 2 фильтр GO отработал ждёт первой мутации и потому истекал по таймауту. Для сравнения: RF и свод 1 MAIN — одна мутация атрибута `class` у
(0,19–0,27 с, `20261026-s2/analysis/scale-compare.md`) — там первые строки после фильтра отличались. Цена ввода в поиск `tbody`. На масштабе 2 фильтр GO отработал (`20261026-s2/analysis/scale-compare.md`).
свода 2 — ~0,7 с главного потока; фильтр считается синхронно по всем строкам без debounce
(`Svod2Page.jsx:458-462`). Цена ввода в поиск — по записям основного прогона: свод 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 мс **Контрольный замер, 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 ### М3. Правка ячейки: два полных пересчёта листа и очередь WebSocket
**Цифры** (`20261026-s1/sql/coauthor/sql_top.json`, лист FORM_2/AHR 1154 строки): `v3.upd_form_cell` — 304 вызова по **Цифры** (`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 мс), то есть 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`) `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`) собирает «снимок до» как **Почему.** `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`), применяет правку `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 его функция перехода — рекурсивная SQL-функция `v3.jsonb_numeric_sum` (`:269-292`), которая для каждой пары jsonb
перечисляет ключи обоих (`jsonb_object_keys` + `UNION`) и рекурсивно складывает листья. Агрегат применяется к 10+ перечисляет ключи обоих (`jsonb_object_keys` + `UNION`) и рекурсивно складывает листья. Агрегат применяется к 10+
jsonb-колонкам на трёх уровнях группировки (`0012_del_project.sql:1418-1481`). Цена — O(строк × колонок × ключей) на jsonb-колонкам на трёх уровнях группировки (`0012_del_project.sql:1418-1481`). Цена — O(строк × колонок × ключей) на
каждый GET и дважды на каждую правку (М3); add_line на FORM_4 тоже пересобирает лист (0,2 → 1,6 с на 300 строках, каждый GET и, по коду, дважды на каждую правку (М3; на FORM_4 не замерялось); add_line на FORM_4 тоже пересобирает лист
Task 3). Ещё мелочь там же: поиск раздела для каждой строки рекурсивным CTE (`0012_del_project.sql:1142`, 290 циклов, (0,2 → 1,6 с на 300 строках — замер Task 3 при генерации, комментарий `perf/perfkit/gen/plan.py:27-31`, вне report-data). Ещё мелочь там же: поиск раздела для каждой строки рекурсивным CTE (`0012_del_project.sql:1142`, 290 циклов,
~24 мс). ~24 мс).
### М5. Модалки: перерисовка всей таблицы при открытии ### М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 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 с сообщения (1 правка/с) и 715 мс на 209 (5 правок/с), ≈ 3,4 мс на сообщение; задачи рендера React выросли на 0,96 и 1,84 с
относительно скролла без соавтора при ~39 и ~63 полученных `cell_updated` — ≈ 25–30 мс на обновление. Итого ≈ 30 мс относительно скролла без соавтора при ~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` пересоздаёт конфиг таблицы на каждое обновление (`useRealtimeData.js:78-85`); новая ссылка `data` пересоздаёт конфиг таблицы
(`RealtimeTable.jsx:443-483`) и пересчитывает правила валидации по всем строкам (`hooks/useValidationRules.js:17-26`); (`RealtimeTable.jsx:443-483`) и пересчитывает правила валидации по всем строкам (`hooks/useValidationRules.js:17-26`);
каждое сообщение пишется в консоль (`useWebSocket.js:33`). При реальном потоке (≤ 1,6 правки/с, М3) это до ~5 % главного каждое сообщение пишется в консоль (`useWebSocket.js:33`). При реальном потоке (≤ 1,6 правки/с, М3) это до ~5 % главного
@ -384,8 +397,8 @@ root (раздел 6).
| лист FORM_4/STRUCTURE | 2,9 МБ (775 строк) | 2,9 МБ | | лист FORM_4/STRUCTURE | 2,9 МБ (775 строк) | 2,9 МБ |
| отчёт проекта FORM_3 | 1,5 МБ (1364 строки) | 1,5 МБ | | отчёт проекта FORM_3 | 1,5 МБ (1364 строки) | 1,5 МБ |
Ответы сжимаются gzip уровня 1 (`api/src/main.py:248`): в сетевом логе браузера лист AHR до grow — 329 КБ на проводе Ответы сжимаются gzip уровня 1 (`api/src/main.py:248`): в сетевом логе браузера лист AHR до grow — ~321 КиБ на проводе
при 4,3 МБ распакованных. Оценка «~15 КБ на строку» из Task 3 этим прогоном не подтвердилась — 3,6–3,7 КБ на строку при 4,3 МБ распакованных, свод 2 GO — 2,3 МиБ, RF — 3,4 МиБ (медианы `body_size`, `analysis/netlog-sizes.md`). Оценка «~15 КБ на строку» из Task 3 этим прогоном не подтвердилась — 3,6–3,7 КБ на строку
ответа. Браузер разбирает весь JSON целиком: для свода 2 это ответа. Браузер разбирает весь JSON целиком: для свода 2 это
30–195 МБ. Сеть стенд не эмулировал: время передачи у заказчика — **не проверено замером**. 30–195 МБ. Сеть стенд не эмулировал: время передачи у заказчика — **не проверено замером**.
@ -468,6 +481,27 @@ JS-объектов немного (`Object` +1 121, замыкания, стр
- **Отчёты проектов, сводные проектов, соавтор** на CPU 4× — в контрольный прогон не входили (только топ-находки). - **Отчёты проектов, сводные проектов, соавтор** на CPU 4× — в контрольный прогон не входили (только топ-находки).
- **Вклад отдельных настроек MRT** (overscan, `memoMode`, `sx`-функции) в цену шага по отдельности — без правок кода - **Вклад отдельных настроек MRT** (overscan, `memoMode`, `sx`-функции) в цену шага по отдельности — без правок кода
приложения не измерить; оценка для каждой — в М1. приложения не измерить; оценка для каждой — в М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. Файлы ## 7. Файлы
@ -475,7 +509,7 @@ JS-объектов немного (`Object` +1 121, замыкания, стр
`perf/report-data/20261026-control-s1/summary.{json,md}`; перечень модалок — `modals-inventory.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/`. - 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`, - Разбор: `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`. `report-data/t5-types/summary.md`.
- Скрипты: `perf/analysis/*.py`; сценарий горячих точек — `perf/e2e/scenarios/hotspots.spec.ts`; prod-сборка с source maps — - Скрипты: `perf/analysis/*.py`; сценарий горячих точек — `perf/e2e/scenarios/hotspots.spec.ts`; prod-сборка с source maps —
`perf/stand.sh web-sourcemap` (:8082). `perf/stand.sh web-sourcemap` (:8082).