Four cuts, zero verdict changes: the ws_config_set validator chain moves to
the executor, the previous-document violation counts are cached by
config_rev, П3 builds its node index once per check in both mirrors, and the
frontend baseline is cached per config epoch. A new benchmark with budgets
pins the class of regression (O(n²) returning) in CI.
Measured on dev 2c20f2dc: a 576-atom plan costs 2.8 s in the HA event loop
per config write today; the spec's acceptance bar is ≤50 ms of loop time.
Issue: #330
User-Visible: no
11 KiB
Issue #330 — производительность ограничений стыков (#329)
Статус: ревизия 1. Родитель: #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. Корни (по убыванию вклада)
- Две полные миграции на запись.
validate_junction_limitsгонит иprevious, иcandidateчерезcommit_wall_segment_model(deepcopy + каноникализация) — ~1.5 с из 2.8 на 12×12.previousмежду записями не меняется: его миграция и подсчёт нарушений выбрасываются и повторяются. - П3 квадратичен в обеих реализациях.
collinearRunLengthUnits/collinear_run_length_unitsстроит индекс узлов заново для КАЖДОГО сегмента → O(n²) с тяжёлой константой. Зеркала честно скопировали дефект друг друга. - Фронтовый baseline без кэша.
_junctionLimitsIntroducedстроит baseline-нарушения из previousConfig на каждый вызов; в жесте ресайза — на каждый move.
3. Границы
- Вердикты П1–П5 байт-идентичны текущим на любом входе. Существующие юниты границ, паритет-тест TS↔Python и все три мутанта #329 проходят без правок их семантики (мутантам разрешено обновить якоря, если строки сместились).
- Спека #329 (наследование по правилу, обе стороны после одной миграции, скоуп применения §3) не меняется.
- Вне скоупа: итеративный walk, точность ключей узлов, 0°-дубль (#331); optimize/import-лазейка (#333); пропуск миграции для уже-v9 документов — рассмотрен и отклонён: он меняет сравниваемое представление (кандидат от клиента не каноникализован) и рискует паритетом ради экономии, которую даёт rev-кэш.
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».
5. Перф-контракт (новый, в CI)
demo/benchmark_junction_limits.mjs по образцу benchmark_safe_resize.mjs:
сетка 12×12 (576 атомов), бюджеты в файле бенча:
- фронт:
checkSegmentLengths≤ 25 мс, полный_junctionLimitViolationsкандидата ≤ 60 мс (p95 из N прогонов); - бэкенд (запускается тем же бенчем через python3): полный
validate_junction_limitsс тёплым rev-кэшем ≤ 150 мс, холодный ≤ 900 мс.
Бюджеты — 2–3× от замеренного после фикса, чтобы бенч ловил регресс класса «вернули O(n²)», а не дрожание раннера. Включается в перф-джобу CI рядом с существующими бенчами.
6. Acceptance criteria
- AC1 (event loop). Запись 576-атомного конфига блокирует event loop
≤ 50 мс: backend-тест меряет время синхронной части
ws_config_set(инструментированный вызов); полная цепочка живёт в executor. - AC2 (П3 линейный). 576 атомов:
checkSegmentLengths≤ 25 мс (фронт),check_segment_lengths≤ 100 мс (python) — бенч из §5; вердикты на фикстурах паритет-теста не изменились (существующий тест). - AC3 (rev-кэш). Две подряд записи с одним previous: вторая не вызывает
commit_wall_segment_modelдля previous (тест со счётчиком вызовов через monkeypatch); смена rev инвалидирует кэш; вердикты идентичны пути без кэша на обеих фикстурах (унаследованное нарушение / новое нарушение). - AC4 (фронт-кэш). В одном жесте ресайза baseline считается один раз:
юнит на
_junctionLimitsIntroducedсо счётчиком, плюс существующийbenchmark_safe_resizeдержит прежний pointer-бюджет на large-house. - AC5 (семантика не тронута). Все существующие юниты #329, паритет-тест,
смоки
smoke_junction_limits/smoke_island_rooms/smoke_room_resizeи мутантыjunction-limit-*зелёные без изменения ожиданий. - AC6 (перф-контракт). Бенч §5 в CI, красный при возврате O(n²) (проверяется мутантом на «строить индекс на каждый сегмент»).
7. План тестов
Юниты: rev-кэш (заполнение, инвалидация, эквивалентность вердиктов),
baseline-кэш фронта (одно вычисление на эпоху), эквивалентность П3 с
индексом и без на фикстурах границ. Бенч §5. Мутанты: junction-limit-p3- quadratic-again (индекс на каждый сегмент — бенч красный),
junction-limit-baseline-cache-stale (кэш не инвалидируется по rev — тест
эквивалентности красный). Бэкенд: тест синхронного времени AC1.
8. Откат
Чистый revert: изменения не трогают формат данных, конфиг и контракт WebSocket-команд; кэши — только в памяти процесса.