r1-H1 was right twice: the rev cache never touched the candidate's migration, and П4 is architecturally quadratic. Profiled instead of guessing: the money is not in deepcopy (3 ms) but in _atomize (663k distance calls), and it runs even for a document that already carries the current catalogue — 815 ms python / 69 ms TS for a no-op migration. Two new cuts follow: §4.5 bucket index for П4 (prototype: 372→44 ms, identical verdicts) and §4.6 current- version documents are used as-is (an explicit revision of the "both sides through one migration" wording, guarded by a new parity case: v9 input gives the same verdict with and without migration). r1-H2: AC4 now rests on the new benchmark that actually exercises the junction code; benchmark_safe_resize is named as a non-proof. r1-M1: §9 adds the mandatory i18n/touch/risks/release sections. Budgets in §5 are recomputed from measured post-fix prototypes with a 2-3x allowance, including an honest row for the one-off cold legacy case. Issue: #330 User-Visible: no
17 KiB
Issue #330 — производительность ограничений стыков (#329)
Статус: ревизия 2 (r1: H1 — бюджеты пересчитаны от профиля, добавлены срезы §4.5 bucket-П4 и §4.6 «документ текущей версии без повторной миграции»; H2 — AC4 опирается на новый бенч; M1 — §9). Родитель: #329 (лимиты выпущены, вердикты корректны — подтверждено паритет-тестом и код-ревью r1–r3). Эта задача меняет ТОЛЬКО стоимость вычисления вердиктов; ни один вердикт не меняется ни на бит. Смежный #331 (пограничная точность) сознательно вне скоупа.
1. Сценарий и пользовательский результат
Замеры на dev 2c20f2dc, синтетическая сетка комнат 300×300 см, стены
15 см, легаси-хранение (худший и одновременно типичный случай: первый
структурный write после обновления карточки):
| План | Бэкенд validate_junction_limits |
из них 1 миграция | П3 | П4 |
|---|---|---|---|---|
| 6×6, 144 атома | 189 мс | 53 мс | 16 мс | 23 мс |
| 12×12, 576 атомов | 2 835 мс | 754 мс | 285 мс | 376 мс |
Вся цепочка валидаторов ws_config_set выполняется синхронно в event loop
HA под write_lock — на 576 атомах каждое сохранение замораживает ВЕСЬ Home
Assistant почти на 3 секунды. MAX_CONFIG_BYTES = 2 МБ допускает документы
ещё на порядок больше. Гайдлайн HA: >50 мс в event loop недопустимо.
Фронтенд, те же 576 атомов: checkSegmentLengths — 289 мс/вызов,
checkNodeDistances — 105 мс; в ресайзе проверка кандидата зовётся на
каждый pointermove, а baseline (клон конфига + миграция + П1–П5 + polyclip
по комнатам) пересчитывается там же, хотя в течение жеста неизменен.
Результат для пользователя: сохранение большого плана не подвешивает HA; ресайз на большом плане держит существующий кадровый бюджет.
2. Корни (по убыванию вклада; профиль cProfile/бенч, 576 атомов)
- Миграция обоих документов на каждую запись. По профилю деньги НЕ в
deepcopy (3 мс): 1.7 с одной миграции — это
_migrate_space → _atomize(663 тыс. вызовов_distance_to_segment, квадратичная атомизация), и она выполняется даже для документа, уже несущего актуальный каталог: migrate(v9) = 815 мс python / 69 мс TS.previousк тому же неизменен между записями — его миграция и подсчёт выбрасываются и повторяются. - П3 квадратичен в обеих реализациях.
collinearRunLengthUnits/collinear_run_length_unitsстроит индекс узлов заново для КАЖДОГО сегмента → O(n²): 285 мс py / 290 мс TS. - П4 квадратичен архитектурно (все пары узлов + узел×сегмент): 372 мс py / 104 мс TS. Линеаризация П3 его не касается (r1-H1).
- Фронтовый baseline без кэша.
_junctionLimitsIntroducedстроит baseline-нарушения из previousConfig на каждый вызов; в жесте ресайза — на каждый move.
3. Границы
- Вердикты П1–П5 байт-идентичны текущим на любом входе. Существующие юниты границ, паритет-тест TS↔Python и все три мутанта #329 проходят без правок их семантики (мутантам разрешено обновить якоря, если строки сместились).
- Спека #329 (наследование по правилу, обе стороны после одной миграции, скоуп применения §3) не меняется.
- Вне скоупа: итеративный walk, точность ключей узлов, 0°-дубль (#331);
optimize/import-лазейка (#333); ускорение самой атомизации
(
_atomize/atomizeквадратичны — общесистемная правка ядра модели с требованием байт-паритета зеркал; отдельная задача, если §4.6 окажется недостаточным).
4. Решение
4.1 Бэкенд: цепочка валидаторов в executor
В ws_config_set вынести CPU-часть цепочки (validate_wall_model_transition,
CONFIG_SCHEMA, marker/passage/host-валидаторы, validate_junction_limits) в
async_add_executor_job одной функцией над данными: вход — msg["config"]
и снапшот data.get("config"), выход — validated config либо типизированная
ошибка. Применение результата (msg["config"].clear()/update) и отправка
ответа остаются в loop. write_lock сохраняется — исключается только
блокировка loop, не сериализация записей. Паттерн в кодовой базе есть:
parse/merge импорта уже выполняются в executor.
ws_plan_optimize — тем же способом (он уже страдает той же синхронной
CONFIG_SCHEMA + миграцией на больших планах).
4.2 Бэкенд: кэш baseline по config_rev
На runtime-объекте — один слот _junction_baseline: (config_rev, counts),
где counts — счётчики нарушений previous ПО ПРАВИЛАМ (то, что и потребляет
сравнение; сами документы не хранятся). Заполняется лениво при первом
validate, инвалидируется несовпадением rev. Повторные записи одного клиента
перестают мигрировать previous вовсе. validate_junction_limits получает
опциональный параметр baseline_counts; его отсутствие — прежний путь
(миграция previous), чтобы прямые вызовы и тесты не изменились.
4.3 Обе реализации: линейный П3
checkSegmentLengths / check_segment_lengths строит byNode-индекс один
раз и передаёт его в прогон коллинеарной цепочки; публичная сигнатура
collinearRunLengthUnits(segment, segments) сохраняется (индекс — третий
опциональный аргумент; без него строится как раньше — для прямых вызовов
из тестов).
4.4 Фронтенд: кэш baseline на конфиг-эпоху
_junctionLimitsIntroduced кэширует baseline-нарушения по ключу
${spaceId}|${cfgEpoch} (один слот, как _wallUnionCache). _cfgEpoch
инкрементится при каждом изменении конфига, поэтому инвалидация бесплатна и
консервативна: любой реальный write сбрасывает кэш. Жест ресайза считает
baseline один раз вместо «на каждый move».
4.5 Обе реализации: bucket-индекс для П4 (r1-H1)
П4 переводится с «все пары узлов + каждый узел × каждый сегмент» на
пространственную сетку с шагом minDistance: узлы и bbox сегментов
(расширенные на порог) раскладываются по ячейкам, кандидаты берутся из
9-окрестности. Прототип на python: 372 → 44 мс на 576 атомах, вердикты
идентичны. Реализуется в обоих зеркалах симметрично; вердикты байт-идентичны
(паритет-тест).
4.6 Документ текущей версии не мигрируется повторно (r1-H1)
Профиль показал: commit_wall_segment_model тратит 0.8 с python / 70 мс TS
даже на документе, который УЖЕ несёт актуальный каталог (model_version == WALL_SEGMENT_MODEL_VERSION) — атомизация гоняется заново ради нулевой
дельты. В путях лимитов такой документ используется как есть, без повторной
миграции; легаси-документ мигрируется прежним путём.
Это ОСОЗНАННАЯ ревизия формулировки «обе стороны после одной миграции» (#329, CONFIG-COMPATIBILITY): её цель — не сравнивать документ без каталога с мигрированным; у v9-документа каталог уже есть, цель достигнута без работы. Риск «клиент прислал неканонический v9» закрывается требованием эквивалентности: паритет-тест дополняется кейсом «v9-вход даёт тот же вердикт с миграцией и без» на фикстурах границ, а барьер модели ниже по конвейеру по-прежнему валидирует документ целиком.
5. Перф-контракт (новый, в CI)
demo/benchmark_junction_limits.mjs по образцу остальных бенчей: сетка
12×12 (576 атомов, легаси и v9-варианты), бюджеты в файле бенча. Бенч меряет
ИМЕННО код лимитов (r1-H2): TS-проверки П1–П5 напрямую и полный
_junctionLimitViolations на смонтированной карточке, плюс python-часть
через дочерний процесс.
Бюджеты — ~2–3× от замеренного после фикса (ловим возврат O(n²), не дрожание раннера); замеры после фикса по прототипам:
| Метрика | После фикса (ожид.) | Бюджет |
|---|---|---|
TS checkSegmentLengths (П3, 576) |
~15 мс | ≤ 40 мс |
TS checkNodeDistances (П4, 576) |
~15 мс | ≤ 40 мс |
| TS полный набор П1–П5 кандидата v9 (без повторной миграции, §4.6) | ~40 мс | ≤ 100 мс |
py validate_junction_limits, тёплый (v9 + rev-кэш) |
~100 мс | ≤ 250 мс |
py validate_junction_limits, холодный (легаси-обе-стороны) |
~1.7 с | ≤ 3.5 с — только в executor (AC1), одноразовый случай первой записи после обновления |
6. Acceptance criteria
- AC1 (event loop). Запись 576-атомного конфига блокирует event loop
≤ 50 мс: backend-тест меряет синхронную часть
ws_config_set(инструментированный вызов); CPU-цепочка живёт в executor. Холодный легаси-случай подчиняется тому же лимиту loop-времени. - AC2 (П3+П4 линейные). Бюджеты §5 для
checkSegmentLengthsиcheckNodeDistancesв обоих зеркалах; вердикты на фикстурах паритет-теста не изменились (существующий тест). - AC3 (rev-кэш). Две подряд записи с одним previous: вторая не считает нарушения previous заново (тест со счётчиком через monkeypatch); смена rev инвалидирует кэш; вердикты идентичны пути без кэша на обеих фикстурах (унаследованное нарушение / новое нарушение).
- AC4 (фронт-кэш). В одном жесте ресайза baseline считается один раз —
юнит со счётчиком на
_junctionLimitsIntroduced; бюджет полного набора П1–П5 кандидата — по НОВОМУ бенчу §5 (r1-H2:benchmark_safe_resizeкод лимитов не вызывает и доказательством не является). - AC5 (эквивалентность §4.6). Для документа текущей версии вердикты «как есть» и «через миграцию» совпадают: паритет-тест дополняется этим кейсом на фикстурах границ (14°/15°, 19/20 см, T-стык, доборный атом); легаси-документ по-прежнему мигрируется (существующий тест H1 из #329 остаётся зелёным).
- AC6 (семантика не тронута). Все существующие юниты #329, паритет-тест,
смоки
smoke_junction_limits/smoke_island_rooms/smoke_room_resizeи мутантыjunction-limit-*зелёные без изменения ожиданий. - AC7 (перф-контракт). Бенч §5 в CI, красный при возврате O(n²) — доказано мутантом «индекс на каждый сегмент».
7. План тестов
Юниты: rev-кэш (заполнение, инвалидация, эквивалентность вердиктов);
baseline-кэш фронта (одно вычисление на эпоху); эквивалентность П3/П4 с
индексами и без на фикстурах границ; эквивалентность §4.6 (v9 как есть ==
v9 через миграцию). Бенч §5 в перф-джобе. Мутанты:
junction-limit-p3-quadratic-again (индекс П3 на каждый сегмент — бенч
красный), junction-limit-p4-bruteforce-again (bucket отключён — бенч
красный), junction-limit-baseline-cache-stale (кэш не инвалидируется по
rev — тест эквивалентности красный). Бэкенд: тест синхронного времени AC1.
8. Откат
Чистый revert: изменения не трогают формат данных, конфиг и контракт WebSocket-команд; кэши — только в памяти процесса.
9. Обязательные разделы (r1-M1)
- i18n: новых ключей нет — задача не меняет ни один пользовательский текст.
- Touch: не задет — меняется стоимость вычислений, не жесты и не пороги; touch-контракт (docs/TOUCH-SUPPORT.md) не упоминает лимиты.
- Риски: (1) executor под write_lock — блокировки loop нет, но время ответа записи сохраняется; смягчение — AC1 меряет именно loop-время; (2) §4.6 меняет представление, на котором считаются лимиты для v9-документов — закрыто AC5-эквивалентностью; (3) bucket-П4 — геометрическая эквивалентность brute-force закрыта юнитами границ (4/5 см) и паритетом.
- Release-артефакты: обычная бета; CHANGELOG (EN+RU) — одна строка user-visible («сохранение и ресайз больших планов быстрее, HA не замирает»); фингерпринт бандла пересобирается штатно; миграций конфига нет, откат §8 — чистый revert.