Релиз v3.0

По крайней мере, за результат не стыдно:

За ориентир приняты бенчмарки, которые описаны здесь - долго мы шли к тому, чтобы по крайней мере базовые тесты не падали, иначе мы даже не попадали на эту вечеринку, однажды я увидел то что могло бы меня демотивировать (но это стало стимулом переписать движок с нуля).

Да, кстати, в репе давно есть два локальных теста на производительность. Я назвал их EXPRESS и DIE HARD, соответственно.

Вот как в них показала себя v3.0.0-alpha.2:

➕ [ START: EXPRESS CHECK ] ➕
💻 [PERFORMANCE DASHBOARD] Сьют: Dynamic Graphs (Экспресс-чек) (Версия: 3.0.0-alpha.2)
⏱️  Текущее время (1000 ит.): 27.72ms
ℹ️ Статус относительно прошлого запуска: [ИНИЦИАЛИЗАЦИЯ ВЕРСИИ]
💬 Комментарий: Стартовый слепок для текущей версии успешно сохранен на диск.
----------------------------------------------------------------------
📊 ТЕНДЕНЦИЯ ВЕРСИИ 3.0.0-alpha.2 (Последние 1 запусков):
   [Меньше — лучше]  ■
   💬 Историческая тенденция текущей версии стабильна.
----------------------------------------------------------------------
📈 СРАВНИТЕЛЬНАЯ МАТРИЦА ПО СВОДКАМ ДРУГИХ ВЕРСИЙ:
  • Сводка по версии [2.6.2]: -79.7% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.3]: -86.3% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.4-alpha.10]: -79.7% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.4-alpha.11]: -80.5% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.4-alpha.9]: -78.9% (⚡ Сверхбыстро)
  • Сводка по версии [2.7.2]: -86.8% (⚡ Сверхбыстро)
  • Сводка по версии [3.0.0-alpha.0]: -39.4% (⚡ Сверхбыстро)
  • Сводка по версии [3.0.0-alpha.1]: -44.8% (⚡ Сверхбыстро)
----------------------------------------------------------------------
📢 ВЕРДИКТ ДЛЯ РЕЛИЗА В БЕНЧМАРК:
✅ ПОДХОДИТ ДЛЯ РЕЛИЗА! Эффективность ядра (27.72 μs/iter) преодолела критический порог.
➕ [ END: EXPRESS CHECK ] ➕

⚠️ [ START: DIE-HARD WORKLOAD ] ⚠️
💻 [PERFORMANCE DASHBOARD] Сьют: Dynamic Graphs (Die-Hard Полный Объем) (Версия: 3.0.0-alpha.2)
⏱️  Текущее время (15000 ит.): 256.59ms
ℹ️ Статус относительно прошлого запуска: [ИНИЦИАЛИЗАЦИЯ ВЕРСИИ]
💬 Комментарий: Стартовый слепок для текущей версии успешно сохранен на диск.
----------------------------------------------------------------------
📊 ТЕНДЕНЦИЯ ВЕРСИИ 3.0.0-alpha.2 (Последние 1 запусков):
   [Меньше — лучше]  ■
   💬 Историческая тенденция текущей версии стабильна.
----------------------------------------------------------------------
📈 СРАВНИТЕЛЬНАЯ МАТРИЦА ПО СВОДКАМ ДРУГИХ ВЕРСИЙ:
  • Сводка по версии [2.6.2]: -86.2% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.3]: -91.3% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.4-alpha.10]: -86.5% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.4-alpha.11]: -85.8% (⚡ Сверхбыстро)
  • Сводка по версии [2.6.4-alpha.9]: -85.1% (⚡ Сверхбыстро)
  • Сводка по версии [2.7.2]: -91.2% (⚡ Сверхбыстро)
  • Сводка по версии [3.0.0-alpha.0]: -50.4% (⚡ Сверхбыстро)
  • Сводка по версии [3.0.0-alpha.1]: -35.7% (⚡ Сверхбыстро)
----------------------------------------------------------------------
📢 ВЕРДИКТ ДЛЯ РЕЛИЗА В БЕНЧМАРК:
✅ ПОДХОДИТ ДЛЯ РЕЛИЗА! Эффективность ядра (17.11 μs/iter) преодолела критический порог.
⚠️ [ END: DIE-HARD WORKLOAD ] ⚠️

Алгоритм дорелизный

  1. Прогоняем локальные тесты:
    yarn test
  2. Прогоняем тесты на производительность:
    yarn test:dynamic
  3. Смотрим работоспособность существующих примеров:
    yarn start
  4. Релизим альфу
  5. Прогоняем альфу на семантику графа
  6. Если все устраивает, прогоняем альфу на производительность
  7. Релизим минор/мажор

В чем новизна мажорного релиза?

Переход с ветки v2.7.2 на v3.0.0 — это не просто оптимизация отдельных методов, а полная смена фундаментальной модели хранения и обхода реактивного графа. Если архитектура 2.x строилась вокруг стандартных коллекций JavaScript (Set и Map), то в 3.0.0 ядро переведено на двунаправленные интрузивные списки (NodeLink), пулинг памяти и курсорный трекинг зависимостей — паттерны, которые используют самые быстрые современные движки (alien-signals, @vue/reactivity 3.5+).

Двунаправленные интрузивные связи (doubly-linked intrusive lists) — это...

Ниже приведена ASCII-схема, показывающая, как два сигнала (S1, S2) и два потребителя (C1, E1) связываются в единую ортогональную сетку через объекты NodeLink без использования Set и Map.

                       ПОТРЕБИТЕЛИ (Consumers)
             [ C1 (Computed) ]          [ E1 (Effect) ]
                    │                          │
           dependenciesHead           dependenciesHead
                    ▼                          ▼
  ┌─────────┐  ┌──────────────────┐       ┌──────────────────┐
  │ S1      │──► NodeLink #1      │◄─────►│ NodeLink #2      │
  │(Signal) │  │ source: S1       │ next/ │ source: S1       │
  │         │  │ target: C1       │ prev  │ target: E1       │
  └─────────┘  │ version: 1       │ Sub   │ version: 1       │
               └────────┬─────────┘       └──────────────────┘
                        │
                  next/prev Dep
                        ▼
  ┌─────────┐  ┌──────────────────┐
  │ S2      │──► NodeLink #3      │
  │(Signal) │  │ source: S2       │
  │         │  │ target: C1       │
  └─────────┘  │ version: 1       │
               └──────────────────┘

Как движок читает эту сетку за O(1)

  • Обход по горизонтали (nextSubscriber / prevSubscriber): Когда меняется сигнал S1, движок берёт S1.subscribersHead (NodeLink #1) и просто шагает вправо по указателю nextSubscriber к NodeLink #2. Так он за один проход уведомляет всех подписчиков S1 (C1 и E1).
  • Обход по вертикали (nextDependency / prevDependency): Когда пересчитывается C1, курсор depsCursor встаёт на C1.dependenciesHead (NodeLink #1) и шагает вниз по nextDependency к NodeLink #3. Так C1 сверяет все свои зависимости (S1 и S2) без пересоздания объектов.
  • Мгновенное удаление из обеих осей: Если C1 перестаёт зависеть от S1 (переключилась ветка if), NodeLink #1 вырезается из горизонтального списка S1 и вертикального списка C1 простой переброской соседних указателей, а сам объект возвращается в LinkPool.

Архитектурные изменения

Ниже разобраны четыре ключевых архитектурных изменения, которые позволили кратно сократить накладные расходы и вывести движок в верхний эшелон мировых бенчмарков (js-reactivity-benchmark).

1. Отказ от Set и Map в пользу двунаправленных рёбер (NodeLink)

  • Как было в v2.7.2: Каждый узел графа (Signal, Computed, Effect) хранил свои связи в хэш-таблицах (subscribers: Set, dependencies: Set, depVersions: Map). При любом динамическом переключении веток движок тратил такты процессора на вычисление хэшей, реаллокацию бакетов и создание итераторов при обходе подписчиков.
  • Как стало в v3.0.0: Каждая связь между источником и потребителем представлена плоским мономорфным объектом NodeLink, который одновременно входит в два двунаправленных связных списка:
    • список подписчиков источника (nextSubscriber / prevSubscriber);
    • список зависимостей потребителя (nextDependency / prevDependency).
  • Что это дало: Обход графа вверх и вниз превратился в линейное чтение указателей в памяти. Нагрузка на кэш процессора снизилась, а V8 перестал деоптимизировать горячие циклы из-за полиморфизма коллекций.

2. Пулинг объектов (LinkPool) и защита от сборки мусора (GC-Free)

  • Как было в v2.7.2: При каждом пересчёте computed или перезапуске effect старые коллекции очищались, а новые ссылки выделялись заново. В динамических сценариях (когда зависимости меняются на каждом тике) это создавало постоянное давление на сборщик мусора (GC).
  • Как стало в v3.0.0: Внедрён статический пул LinkPool. Когда ребро графа разрывается, объект NodeLink не выбрасывается в кучу, а обнуляется и возвращается в пул для мгновенного повторного использования.
  • Что это дало: В длительных стресс-тестах (dynamic и kairo) аллокации новых рёбер в горячем цикле сведены практически к нулю.

3. Курсорное переиспользование связей за O(1)

  • Как было в v2.7.2 (и ранней 3.0.0-alpha.0): Перед каждым пересчётом узла все его старые зависимости разрывались (unlinkDependencies), а затем собирались заново. Если узел зависел от 1000 сигналов, а менялся только один, движок всё равно пересоздавал 1000 связей.
  • Как стало в v3.0.0: Перед запуском функции вычисления курсор (depsCursor) ставится в начало существующего списка зависимостей. Если на i-м шаге код читает тот же сигнал, что и в прошлый раз, курсор просто сдвигается вперёд за O(1) — без поиска, без удаления и без обращения к пулу. Только если ветка исполнения изменилась и в конце остался «хвост» неиспользованных связей, они отрезаются и уходят в LinkPool.
  • Что это дало: В тесте updateComputations1000to1 время выполнения упало с 1070 мс до 18.5 мс (ускорение в 57 раз).

4. Двухуровневая верификация «грязи» и локальные версии

  • Как было в v2.7.2: Версии зависимостей хранились в отдельных Map, а при откатах транзакций (Net-Zero мутации внутри batch) требовались дополнительные проходы для сверки состояний.
  • Как стало в v3.0.0: Каждое ребро NodeLink само хранит снимок версии источника (link.version) в момент последнего чтения. Волна инвалидации делится на два состояния: DIRTY (источник точно изменился) и CHECK (нужно лениво проверить цепочку вверх). Если промежуточный computed пересчитался, но вернул прежнее значение (Object.is), его версия не растёт, и каскад обновлений мгновенно гасится.

Итоговый результат в цифрах

Сохранив 100% обратной совместимости публичного API, DI-контейнера, транзакционного логгера и адаптеров для React/Vue/Angular, версия 3.0.0 показала следующий прогресс:

  • Семантика графа (reactive-framework-test-suite): 159 из 161 пройденных тестов (98.8%, паритет с @vue/reactivity 3.5.45).
  • Производительность (js-reactivity-benchmark):
    • Kairo (broadPropagation, deepPropagation, diamond): быстрее SolidJS, Svelte v5, MobX и @angular/signals.
    • Mol (molBench): 811 мс (1.02× от абсолютного лидера таблицы).
    • CellX (cellx10/20/30): 0.11 – 0.25 мс (наравне с тройкой самых быстрых движков).

Про бандл

📊 [Bundle Size Monitor] — reactive-engine.umd.js:
   ├── Физический размер: 23.06 KB
   └── Сжатый (Gzip):     7.31 KB