Релиз 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 ] ⚠️Алгоритм дорелизный
- Прогоняем локальные тесты:
yarn test - Прогоняем тесты на производительность:
yarn test:dynamic - Смотрим работоспособность существующих примеров:
yarn start - Релизим альфу
- Прогоняем альфу на семантику графа
- Если все устраивает, прогоняем альфу на производительность
- Релизим минор/мажор
В чем новизна мажорного релиза?
Переход с ветки v2.7.2 на v3.0.0 — это не просто оптимизация отдельных методов, а полная смена фундаментальной модели хранения и обхода реактивного графа. Если архитектура 2.x строилась вокруг стандартных коллекций JavaScript (Set и Map), то в 3.0.0 ядро переведено на двунаправленные интрузивные списки (NodeLink), пулинг памяти и курсорный трекинг зависимостей — паттерны, которые используют самые быстрые современные движки (alien-signals, @vue/reactivity 3.5+).
Ниже приведена 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 мс (наравне с тройкой самых быстрых движков).
- Kairo (
Про бандл
📊 [Bundle Size Monitor] — reactive-engine.umd.js:
├── Физический размер: 23.06 KB
└── Сжатый (Gzip): 7.31 KB