Привет, Хабр!

В первой статье мы разобрали четыре способа хранить связи реактивного графа. Это связи между источниками данных и теми, кто от них зависит: например, между сигналом, вычислением и обработчиком обновления.

Читать её перед этой статьёй необязательно, но для полного понимания сути нашего разговора рекомендую. Всем, кто задавал вопросы, - большое спасибо! Благодаря вам я решил сразу ответить на них в формате небольшого демо.

Так что могу назвать этот текст ответами на вопросы и демонстрацией того, какие системы может обслуживать реактивность помимо DOM. А глубокое погружение в устройство движка оставлю для отдельного цикла статей, которые я готовлю больше месяца… Подготовка материала оказалась небыстрым процессом :D

Коротко перескажу вопросы из комментариев, с которых начался этот текст:

  • Зачем ускорять реактивность, если в обычном интерфейсе гораздо больше времени могут занимать запросы и отрисовка компонентов?

  • Почему одной оценки O(1) недостаточно: что происходит с кешем, сборкой мусора, стеком и скрытыми копиями данных?

  • Можно ли хранить связи компактнее, как в $mol_wire, и что тогда меняется в работе системы?

  • Где такая производительность пригодится на практике и что именно позволяют утверждать бенчмарки?

Уже долгое время я разрабатываю собственное реактивное ядро, которое назвал Raph (от Reactive Async Phased grapH). Это конструктор, на основе которого можно собирать UI-библиотеки, графические движки и другие системы с зависимыми вычислениями.

Чтобы ответить на эти вопросы, придётся хотя бы обзорно показать устройство Raph и задачи, для которых я его использую. Подробная статья ещё не готова, поэтому сегодня предлагаю промежуточный формат: краткий обзор ядра, четыре группы графических сцен и результаты тестов реактивности. Без разбора всей реализации. До неё тоже дойдём :D

Комментарий: зачем такие сложности?

В комментариях прозвучала мысль, с которой я согласен: медленный запрос, тяжёлый компонент или неудачно организованная отрисовка часто влияют на приложение гораздо сильнее, чем способ хранения реактивных связей. Но есть нюанс. Когда вы работаете в промышленной среде с серьёзным объёмом данных и большим потоком изменений, реактивность такого приложения тоже может стать узким местом. Конечно, мы не будем говорить про статичные сайты или сайты с маленькой нагрузкой - речь о совершенно другом классе систем.

У меня изначально была немного другая задача. Я хотел использовать одну модель состояния и обновлений в DOM-интерфейсе, интерактивной графике и других приложениях, которым нужно поддерживать зависимости между данными. Реактивный граф в таком случае обслуживает не только текст в кнопке.

Например, изменение состояния может затронуть геометрию объектов, расположение подписей, выделение, фильтры и зависимые вычисления. Если это происходит во время перетаскивания или анимации, работу приходится выполнять регулярно, в пределах времени на кадр.

Уложились в кадр или нет

При 60 Гц интервал между кадрами составляет примерно 16,67 мс, при 120 Гц - 8,33 мс. В этот интервал должна поместиться вся необходимая работа.

Возьмём условный пример двух приложений - A и B:

Работа за один кадр

Вариант A

Вариант B

Остальная работа приложения и отрисовка

10 мс

10 мс

Обновление реактивного графа

5 мс

8 мс

Всего

15 мс

18 мс

Номинальный бюджет 60 Гц

Уложились, осталось 1,67 мс

Не уложились, превышение 1,33 мс

Номинальный бюджет 120 Гц

Не уложились, превышение 6,67 мс

Не уложились, превышение 9,67 мс

Даже при 60 Гц разница всего в три миллисекунды может оказаться критической: в этом примере вариант A укладывается в бюджет кадра, а вариант B уже нет. Если эти миллисекунды уходят на лишние обходы и обслуживание связей графа, выбор структуры данных и алгоритмов работы с ней становится критически важным для времени кадра.

Это арифметика бюджета кадра. Рассчитать реальный FPS Canvas-сцены по сводной оценке микробенчмарков нельзя: в ней нет браузерного layout, подготовки команд рендеринга, работы GPU и пауз конкретного приложения. Даже время CPU ниже 16,67 мс само по себе не гарантирует стабильные 60 FPS.

На бэкенде логика похожая: если поддержание зависимых значений занимает заметную долю обработки, его ускорение может повлиять на пропускную способность и задержки. Если почти всё время уходит на БД или сеть, результат будет другим. Отдельные серверные замеры я здесь не показываю.

И нет, мы не пишем браузерную игру, но создаём промышленную 2D-графику и сложные интерактивные приложения, где задержка кадра означает задержку реакции бизнеса. Для некоторых сфер это критически важно.

Что такое Raph

Raph - ядро, из которого я собираю системы с реактивным состоянием, управляемым выполнением и жизненным циклом ресурсов. Его можно использовать как основу для UI-библиотеки, графического движка или другой системы с реактивными вычислениями.

Сразу поясню, что примеры ниже показывают низкоуровневое API ядра. С ним работают разработчики библиотек и движков, которые строятся на Raph. Прикладная Canvas-библиотека или библиотека для DOM может дать пользователю своё высокоуровневое API: компоненты, шаблоны и привычные операции, за которыми будут стоять эти механизмы.

Я использую эту основу в графических инструментах для разных прикладных задач:

  • таймлайнов с операциями, ресурсами и зависимостями;

  • больших интерактивных графов;

  • таблиц с прокруткой, выделением и редактированием;

  • интерактивных 2D-сцен;

  • географических карт с GeoJSON и тайлами, где детализация зависит от масштаба.

У этих инструментов разная логика отрисовки и взаимодействия, но общие задачи: поддерживать состояние, обновлять затронутые части и управлять ресурсами. Ниже покажу несколько сцен с разными видами нагрузки.

Поэтому мне ближе называть его не библиотекой, а конструктором для ядра библиотеки. Разработчик приложения выбирает, какие части ему нужны и как связать их с отрисовкой или другой работой.

Моя идея - вынести в общее ядро механизмы, которые нужны разным системам: реактивные зависимости, планирование обновлений и управление ресурсами. Поверх этого ядра можно строить системы реактивности и графические движки. Бенчмарки ниже показывают результаты реактивной части, а Nova демонстрирует её применение в 2D-графике.

Обзор компонентов Raph: реактивность, runtime и опциональные машины состояний
Обзор компонентов Raph: реактивность, runtime и опциональные машины состояний

Схема намеренно поверхностная. Она показывает состав ядра; конкретный порядок работы задаёт приложение.

Реактивность

В основе знакомые понятия:

Понятие

Для чего нужно

signal

Хранить изменяемое значение и отслеживать его чтение

computed

Вычислять значение из других значений и поддерживать его актуальность

effect

Выполнять действие, которое зависит от прочитанного состояния

watch

Явно наблюдать выбранный источник и связывать его изменения с обработчиком

Базовые сигналы и вычисления можно использовать самостоятельно. Им не требуется Canvas, DOM или обязательный цикл кадров.

Вот небольшой пример на standalone API Raph. Импорты в примерах опущены; используются существующие публичные функции и классы библиотеки.

const counter = signal(0)
const doubled = computed(() => counter.get() * 2)

const stop = effect(() => {
  console.log(doubled.get())
})

counter.set(1) // Эффект выведет 2
stop()

computed описывает зависимое значение, а effect читает его и выполняет действие. Ни рендерер, ни runtime здесь ещё не нужны.

Runtime: когда выполнить работу и кому она принадлежит

Реактивность отвечает на вопрос «что зависит от изменившихся данных». Runtime добавляет ещё два: «когда выполнить связанную работу» и «кто отвечает за её завершение».

Разработчик движка может задать фазы, например подготовку данных, расчёт геометрии и отрисовку. Планировщик определяет момент выполнения: сразу, через микрозадачу или в цикле requestAnimationFrame. Сам по себе вызов requestAnimationFrame не делает медленную работу быстрой; он только задаёт момент её запуска.

Фазы часто нужны для последовательного и управляемого разделения работы. Например, в DOM-интерфейсе удобно сначала обновить зависимые данные, а затем применить изменения к DOM. В Canvas-движке можно разделить обновление состояния, расчёт преобразований и отрисовку - например, на фазы update/matrix/render.

Фазы помогают явно задать порядок выполнения задач и снизить риск рассогласования данных и картинки. Их названия и состав зависят от устройства движка.

На текущем API Raph это можно записать так:

const runtime = new RaphRuntime({
  scheduler: RaphSchedulerType.AnimationFrame,
})

runtime.definePhases([
  { name: 'update' },
  { name: 'matrix' },
  { name: 'render' },
])

Здесь update - обновление прикладного состояния, matrix - расчёт преобразований, render - отрисовка. Имена выбирает разработчик движка. Фазы определяются до регистрации обработчиков и выполняются в порядке объявления, когда в них есть ожидающая работа. Объявление фаз само по себе не запускает непрерывную анимацию.

Есть и дерево владельцев. Виджет, сцена или другая часть приложения может владеть подписками, задачами и зарегистрированными ресурсами. При уничтожении владельца связанные с ним ресурсы освобождаются. Здесь важно именно зарегистрированное владение: произвольный объект, созданный рядом, автоматически управляемым ресурсом не становится.

Представим привычную страницу: body - общая область, header - шапка, footer - подвал. Создадим для них дерево владельцев и добавим реактивный заголовок шапки:

const body = runtime.node({ id: 'body' })
const header = runtime.node({ id: 'header', parent: body })
const footer = runtime.node({ id: 'footer', parent: body })

const title = header.signal('Мой проект')

header.effect('render', () => {
  console.log(title.get())
})

Здесь id задаёт имя владельца, а parent - его место в дереве. Созданием DOM-элементов занимается слой отрисовки; этот фрагмент показывает владение состоянием и обработчиками. Сигнал title и обработчик фазы принадлежат header. Вызов header.dispose() освобождает их, оставляя footer живым. Вызов body.dispose() освобождает всю эту ветку, включая шапку и подвал. У сигнала нет собственной фазы: к фазе привязан обработчик, который его читает.

Если вы работали с SFC-компонентами, принцип уже знаком. Например, в Vue наблюдатели watch и watchEffect, созданные синхронно в setup или <script setup>, привязаны к экземпляру компонента и автоматически останавливаются при его размонтировании. Документация Vue.

В компонентном слое поверх Raph эту связь тоже устанавливает библиотека. Создавая signal() или effect() во время синхронного выполнения setup компонента, разработчик регистрирует их у его узла-владельца. При уничтожении компонента освобождаются и эти ресурсы. Поэтому у SFC-компонента, который выводит <header>, состояние принадлежит экземпляру компонента; сам тег описывает его представление.

Несколько областей приложения с общим циклом обновления могут работать в одном runtime. Например, DOM-панель и графическая сцена. Если нужны независимые жизненные циклы или планировщики, можно создать отдельные runtime. Прямые реактивные связи между разными runtime в этой модели не допускаются.

Одни данные для DOM и Canvas

Представим DOM-интерфейс, внутри которого находится Canvas. Панель показывает выбранный объект и его свойства, а Canvas рисует этот же объект. У них может быть один источник состояния: изменение модели обновляет оба представления.

Для этого не обязательно хранить отдельную копию бизнес-данных в каждом представлении и писать двустороннюю синхронизацию между ними. Можно связать DOM и Canvas с общей моделью в одном runtime.

DOM-панель и Canvas-сцена читают общую модель, а действия в обоих представлениях изменяют её
DOM-панель и Canvas-сцена читают общую модель, а действия в обоих представлениях изменяют её

Например, выбираем объект на Canvas - DOM-панель показывает его свойства. Меняем свойство в панели - обновляется объект на Canvas. В обоих случаях меняется общая модель, а связанные обработчики обновляют нужные представления в заданных фазах.

Покажу сам принцип на счётчике. Предположим, что DOM-элемент label и Canvas уже созданы, а ctx - его CanvasRenderingContext2D. Используем runtime с фазой render из примера выше:

const sharedNode = runtime.node({ id: 'shared-view' })
const sharedCount = sharedNode.signal(0)

sharedNode.effect('render', () => {
  label.textContent = String(sharedCount.get())
})

sharedNode.effect('render', () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.fillText(String(sharedCount.get()), 10, 20)
})

sharedCount.set(1)

Оба обработчика читают тот же сигнал. Отдельного «состояния DOM» и «состояния Canvas» для счётчика здесь нет. Когда виджет убирается, sharedNode.dispose() освобождает сигнал и обработчики; удаление самих DOM-элементов остаётся задачей их владельца.

На больших данных такой подход может экономить память за счёт исключения дублирующих копий модели и служебной синхронизации. Но у рендереров остаются собственные визуальные объекты, индексы, буферы и кеши. Поэтому величину экономии нужно измерять на конкретном приложении.

Общий runtime требует согласованных фаз и планировщика у прикладных адаптеров. Сам факт использования Raph в двух готовых пакетах ещё не объединяет их runtime автоматически. Адаптеры отрисовки тоже нужны: они переводят общую модель в DOM и графические команды, но могут делать это без отдельной копии бизнес-состояния.

Машины состояний

Отдельный, опциональный слой описывает состояния, события и переходы. Для обычного сигнала его подключать не требуется. Он полезен там, где важно явно задать последовательность действий: открытие окна, загрузку, отмену или смену режима редактирования.

В моём UI-слое машины уже используются для диалоговых окон. У такого окна четыре состояния: closed, opening, open и closing. Важно различать запрос «открыть окно» и его текущее состояние: анимация открытия ещё может продолжаться.

Четыре состояния диалогового окна и переходы при открытии, закрытии и завершении анимаций
Четыре состояния диалогового окна и переходы при открытии, закрытии и завершении анимаций

Пользователь может закрыть окно до завершения открытия или снова открыть его во время закрытия. Машина описывает допустимые переходы, а обработчики компонента прерывают прежнюю анимацию и запускают новую. Обработчики также игнорируют запоздавшие результаты отменённых анимаций, чтобы те не закрыли уже повторно открытое окно.

Вот сокращённая модель переходов такого диалога:

type DialogEvent = {
  type: 'OPEN' | 'CLOSE' | 'ENTERED' | 'EXITED'
}

const dialogModel = defineMachine<null, DialogEvent>({
  id: 'dialog',
  context: null,
  initial: 'closed',
  states: {
    closed: { on: { OPEN: 'opening' } },
    opening: { on: { CLOSE: 'closing', ENTERED: 'open' } },
    open: { on: { CLOSE: 'closing' } },
    closing: { on: { OPEN: 'opening', EXITED: 'closed' } },
  },
})

const dialogNode = runtime.node({ id: 'dialog' })
const dialogMachine = createMachine(dialogNode, dialogModel).start()

dialogMachine.send({ type: 'OPEN' })

После OPEN машина находится в состоянии opening. При обычном завершении анимации обработчик отправляет ENTERED, и окно переходит в open; после закрытия приходит EXITED. Сама модель выше задаёт только переходы. Запуск анимаций, обработка их ошибок и освобождение ресурсов находятся в компоненте. В примере нет данных контекста, поэтому передан null; жизненный цикл машины связан с dialogNode.

В Canvas-редакторе такой же подход можно использовать для перетаскивания объекта: idle - ждём действия, dragging - обновляем положение, завершение или отмена возвращают нас в idle. Начало, завершение и отмена перетаскивания задают переходы, а обработчики взаимодействия управляют захватом указателя и его освобождением. Координаты при этом остаются реактивными данными, машина отвечает за режим взаимодействия.

Где всё это используется

Теперь покажу несколько интерактивных сцен, построенных на Nova. Это мой 2D-движок поверх Raph, который я использую для таймлайнов, карт, графов и других визуальных инструментов.

Raph отвечает за реактивные зависимости, выполнение по фазам и жизненный цикл ресурсов. Nova добавляет дерево сцены, преобразования объектов, обработку ввода и подготовку отрисовки. В примерах ниже используется Canvas с WebGL; у движка есть и backend Canvas2D.

Геометрию, связи, подписи и спрайты в этих сценах рисует Nova внутри Canvas. Вспомогательные панели управления и счётчики сделаны на DOM. В частности, на карте улицы, здания и водоёмы рисует сам движок из векторных данных.

Сначала покажу скриншоты. К каждому примеру добавлю короткое видео, а в конце раздела есть ссылка на демонстрационный проект, где можно самому подвигать сцену, поменять масштаб и посмотреть на её поведение.

На результат влияют также прикладные алгоритмы, подготовка геометрии, графический backend, браузер и GPU. Если сцена загружает данные, добавляются сеть и их обработка. Поэтому FPS всего приложения полезен для оценки сцены, но по нему нельзя выделить скорость одного Raph или пересчитать результаты тестов сигналов в скорость отрисовки.

Как Nova собирает кадр

Nova: 2D-движок поверх Raph, отложенная сборка кадра, рецепты примитивов, запись команд, компиляция, планы батчинга и графические backend-ы
Nova: 2D-движок поверх Raph, отложенная сборка кадра, рецепты примитивов, запись команд, компиляция, планы батчинга и графические backend-ы

Nova сохраняет описание сцены и отделяет подготовку команд от их исполнения. Сначала записывает команды, затем компилирует их в группы и потоки, которые воспроизводит графический backend. Подготовленный кадр хранит упорядоченные сегменты команд, ссылки, версии и данные пакетов.

Рецепт описывает, как вывести определённый примитив. Например, прямоугольник, временной сегмент, подпись или спрайт. Совместимые примитивы объединяются в батчи с учётом порядка отрисовки. Это позволяет передавать однотипную геометрию пакетами и сокращать служебную работу.

Если сцена не изменилась, в режиме постоянной отрисовки можно повторно использовать подготовленный кадр без нового обхода фаз обновления и перекомпиляции команд. При поддержанных изменениях обновляются затронутые данные; структурные изменения и остальные случаи могут потребовать повторной компиляции. Поэтому объём работы зависит и от того, что именно поменялось.

Здесь речь о сохраняемой сцене и отложенном исполнении команд. Термин deferred shading, которым называют способ организации освещения в 3D, к этой схеме не относится.

1. Большие графы

Начну с графа знаний. В первом наборе 172 396 объектов. Можно перемещаться по графу, менять масштаб, работать с группами и фильтрами.

Граф знаний: 172 396 объектов, включая узлы и связи; счётчик в момент снимка показывает 120 FPS
Граф знаний: 172 396 объектов, включая узлы и связи; счётчик в момент снимка показывает 120 FPS

Для второго примера набор увеличен до миллиона узлов и примерно миллиона связей. На снимке счётчик показывает 2 000 783 объекта и 37 FPS.

Большой граф: 2 000 783 объекта, включая миллион узлов и около миллиона связей; счётчик в момент снимка показывает 37 FPS
Большой граф: 2 000 783 объекта, включая миллион узлов и около миллиона связей; счётчик в момент снимка показывает 37 FPS

Это сумма узлов и рёбер в данных сцены. Она не означает, что каждый объект одновременно виден на экране или что у Raph столько же внутренних реактивных узлов.

Значения FPS относятся к моменту съёмки. У этих двух сцен различаются настройки отображения, а расположение узлов подготовлено заранее. В данном примере мы перемещаем камеру по готовому графу; пересчёт силовой раскладки всех узлов на каждом кадре был бы другой нагрузкой.

Это демонстрационная сцена, и сама по себе она ничего явно не доказывает. Для корректной оценки производительности нужно как минимум разделять реактивную и графическую подсистемы.

Как похожие задачи решают другие инструменты

Задачи такого масштаба решают и другие инструменты браузерной графики. Например, Cosmos специализируется на графах и использует GPU для раскладки и отрисовки. В представлении проекта на сайте OpenJS Foundation авторы описывают визуализацию графов с более чем миллионом узлов и связей. А руководство deck.gl по производительности разбирает отрисовку миллионов элементов и влияние обновления GPU-буферов.

Инструмент

Что важно в его подходе

Cosmos

Специализация на графах: раскладка и отрисовка на GPU

deck.gl

Слои для больших массивов визуальных данных, организация и обновление GPU-буферов

Nova в этих примерах

Прикладная 2D-графика, заранее подготовленное расположение графа, пакеты примитивов и повторное использование команд; обновления организованы через Raph

Для меня здесь интересен результат общей архитектуры: тот же движок, который выводит таймлайны и карты, работает и с графом такого масштаба. Nova сейчас не специализированный движок для графов. Специализация могла бы дать другие возможности для оптимизации, но это отдельная работа, результат которой ещё нужно измерить.

С Cosmos и deck.gl я эти сцены в одинаковых условиях не сравнивал. Привожу их, чтобы показать, какие задачи решают другие инструменты и где находится этот эксперимент.

Видео: большие графы

Запись перемещения и масштабирования двух сцен: обычного графа и варианта с миллионом узлов.

2. Обороты поездов на таймлайне

Таймлайн: обзор оборотов поездов и детализация выбранного оборота
Таймлайн: обзор оборотов поездов и детализация выбранного оборота

Сверху - обзор оборотов, снизу - операции и ресурсы выбранного оборота. Выбор в одной панели меняет содержимое другой. Временная шкала, таблица, подписи, выделение и связи операций здесь рисуются на Canvas.

Показываю эту сцену ради согласованного состояния: выбор, временной диапазон и детализация должны обновляться вместе. Для отрисовки важны пакеты однотипных примитивов, выбор видимого диапазона и повторное использование подготовленных данных при прокрутке и масштабировании.

На скриншоте - подробный, читаемый пример. В видео и на отдельной странице будет также более плотный таймлайн, где интересна уже нагрузка от большого количества временных сегментов и подписей.

Данные демонстрационные. Картинка показывает работу визуального инструмента; готовность к промышленному планированию перевозок требует отдельной проверки.

Видео: таймлайны

Запись работы с оборотами поездов и перехода к более нагрузочной сцене.

3. Карта города

Векторная карта Краснодара: улицы, здания, водоёмы, подписи и инструменты взаимодействия; атрибуция источников сохранена
Векторная карта Краснодара: улицы, здания, водоёмы, подписи и инструменты взаимодействия; атрибуция источников сохранена

В этой сцене Nova рисует карту из векторных геоданных. Основной источник - OpenFreeMap: сначала загружается JSON-описание источника TileJSON, затем отдельные векторные тайлы MVT. Они содержат геометрию и свойства объектов. Данные происходят из OpenStreetMap, а слои организованы по схеме OpenMapTiles.

Улицы становятся линиями, здания, парки и водоёмы - полигонами. Для WebGL заливки полигонов, в том числе с отверстиями, преобразуются в треугольники. Затем геометрия, цвета и подписи проходят через пакеты отрисовки Nova. Внешний вид задаётся стилями: можно менять палитру, оформление слоёв и добавлять свои визуальные элементы поверх карты.

GeoJSON тоже используется: например, локальный дорожный граф для демонстрационного маршрута получен из данных OpenStreetMap через запрос участка карты по границам. Это отдельный небольшой набор данных; основная карта на скриншоте загружается в формате MVT.

Здесь недостаточно быстро поменять координату камеры. Нужно подгрузить нужные тайлы, управлять кешем, менять детализацию по масштабу и готовить геометрию. Raph организует состояние и обновления Nova; загрузка, декодирование, триангуляция и работа GPU добавляют собственную нагрузку.

Видео: карта

Запись перемещения, масштабирования, изменения оформления и взаимодействия с объектами.

4. Много спрайтов в движении

Последний пример основан на Bunnymark от Goodboy Digital для PixiJS. Исходный код сценария доступен здесь. Добавляем кроликов, они движутся и отскакивают от границ. Исходный сценарий и спрайты придуманы авторами Bunnymark.

В локальном эталоне используется PixiJS 3.0.0, версия из этого оригинального примера. Поэтому результат относится к ней; для сравнения с актуальным Pixi нужен отдельный участник.

Предел увеличен до 500 000 спрайтов, а добавление идёт порциями по 20 000 с интервалом 250 мс. Используется исходный набор спрайтов из одного атласа. Также добавлена измерительная обвязка: последовательные прогоны участников, запись и повторение последовательности добавлений, общий способ подсчёта FPS и остановка после итогового замера. Исходный файл сценария Pixi сохранён отдельно без изменений.

Кадр из видео завершённого прогона на 500 000 спрайтов: Nova 100,8 FPS, PixiJS 3.0.0 65,5 FPS; после замера обе сцены остановлены
Кадр из видео завершённого прогона на 500 000 спрайтов: Nova 100,8 FPS, PixiJS 3.0.0 65,5 FPS; после замера обе сцены остановлены

В этом прогоне итоговое окно длиной одна секунда дало 100,8 FPS для Nova и 65,5 FPS для PixiJS 3.0.0.

В реализации Nova данные спрайтов собраны в типизированные массивы и передаются одним пакетным узлом. Это позволяет отдельно организовать обновление координат и отрисовку большого набора. Pixi использует свой ParticleContainer.

Пока это один сохранённый результат. Порядок и моменты добавлений повторяются, но случайные начальные параметры и траектории спрайтов не синхронизированы. Такого прогона недостаточно ни для общего вывода о преимуществе движка, ни для оценки величины шума.

PixiJS тоже является 2D-движком отрисовки и применяется не только в играх. У движка много характеристик: поддерживаемая графика, качество вывода, работа с памятью, взаимодействия и удобство API. Этот эксперимент охватывает конкретную сцену с большим количеством движущихся спрайтов.

Видео: Bunnymark

Запись добавления спрайтов и завершения сравнительного прогона.

Сцены, которые можно попробовать самостоятельно

Открыть демонстрационный проект. В нём можно повторить показанные действия и посмотреть, как сцены ведут себя на своём устройстве.

  • Графы: обычный граф и большой вариант с миллионом узлов.

  • Таймлайны: обороты поездов и более плотная нагрузочная сцена.

  • Карта: перемещение, масштабирование, изменение оформления и взаимодействия.

  • Bunnymark: добавление спрайтов и сравнение с указанной версией PixiJS.

Что эти сцены позволяют сказать

Показываю четыре группы примеров, потому что нагрузки и подходы к их оптимизации различаются: большое пространство узлов и связей, согласованные представления таймлайна, тайлы и полигоны, движущиеся спрайты. Все они используют Nova поверх Raph.

Для меня это практическая проверка того, что выбранная модель подходит для прикладной 2D-графики. Хороший результат конкретной сцены говорит о её работе в данных условиях. Для сравнений нужны сопоставимые реализации, одинаковые действия и повторяемые измерения.

Контейнер графа - это только контейнер графа

В первой статье мы остановились на контейнере для связей. Но это очень наивно, потому как целевое решение было получено в результате большого количества разных экспериментов и оптимизаций.

Кратко опишу некоторые оптимизации, которые, может, и не уникальны, но в своей совокупности дали неплохой результат. Честно скажу, что очень долго подбирал их так, чтобы не было конфликтов. И всё ещё не могу сказать, что этот набор окончательный :D Часто одна оптимизация улучшает одну метрику, но ухудшает другую. Поэтому пришлось искать компромисс.

1. Одна связь участвует в двух списках. В интрузивном представлении ребро содержит ссылки для списка зависимостей потребителя и списка подписчиков источника. Не нужно создавать отдельную обёртку для каждого направления. Удаление уже известного ребра занимает O(1); поиск нужного ребра - отдельная задача. (Это самый очевидный пример того, как уменьшить аллокации и расходы на сборку мусора. Такой подход используют многие движки.)

2. Стабильные зависимости переиспользуются. Когда вычисление снова читает источники в прежнем порядке, движок проходит по уже существующим связям. Если набор зависимостей изменился, он сохраняет подходящие связи и удаляет те, которые больше не нужны. Граф не требуется целиком пересоздавать при каждом запуске.

3. Для дорогого поиска есть адаптивный индекс. Последовательный обход удобен, пока зависимости ведут себя предсказуемо. Если их порядок меняется и поиск становится дорогим, индекс помогает находить существующие связи. Это отдельный режим, включённый в адаптере текущего бенчмарка; нельзя считать его обязательным расходом каждого маленького вычисления.

4. «Нужно проверить» отделено от «нужно пересчитать». Изменение источника ещё не означает, что результат всех зависимых вычислений обязательно изменится. Движок проверяет актуальность, лениво обновляет вычисления и учитывает равенство результата. В подходящих случаях это позволяет пропустить повторное выполнение зависимого обработчика.

5. Ожидающая задача не создаётся заново при каждой инвалидации. В runtime привязка обработчика к фазе и владельцу живёт дольше одного изменения. Повторные причины обновления могут объединяться, пока задача ожидает выполнения.

Первые четыре пункта относятся к реактивному ядру. Последний относится к runtime и не измеряется отдельно в приведённом ниже тесте сигналов.

Эти решения не уникальны каждое по отдельности. И я не могу честно сказать, что конкретная оптимизация дала определённый процент в итоговой таблице, пока не сравню варианты с ней и без неё.

А что с компактной моделью $mol_wire?

В комментариях предложили посмотреть на $mol_wire, где используется другое, компактное представление связей. Это хороший пример того, почему нельзя выбрать победителя только по количеству полей или по асимптотике удаления.

Меньший объём данных может быть преимуществом. Но на время выполнения также влияют порядок обхода, проверка актуальности, смена зависимостей, планирование и поведение среды выполнения.

В нашем прогоне $mol_wire есть среди участников. Результат относится к конкретному адаптеру и набору сценариев. Он не доказывает, что Raph быстрее любого приложения на $mol, и не отменяет достоинств его модели.

Для предметного сравнения мне интереснее поставить рядом одинаковые рабочие нагрузки, проверить одинаковый результат и отдельно измерить время и память.

Кеш, GC и другие «невидимые» расходы

Вопрос про кеш и сборку мусора тоже справедлив. Одинаковая сложность алгоритма не означает одинаковый объём выделений, локальность доступа или поведение JIT.

В замерах контейнеров из первой статьи было четыре прогревочных раунда и одиннадцать измеряемых. gc(), при запуске с --expose-gc, вызывался перед подготовкой данных, а затем начинался замер. Между вызовом GC и таймером создавались объекты.

Это не гарантирует отсутствие сборки мусора внутри измеряемого участка. И не позволяет по одному увеличению времени сделать вывод, что причиной было именно перемещение объектов или промахи кеша.

Чтобы проверить такую причинность, нужен отдельный эксперимент: контролировать подготовку данных, смотреть события GC и профиль, сравнивать одинаковые нагрузки. Его результаты нельзя заменить догадкой по таблице времени.

То же относится к стеку рекурсивного обхода, хешированию, представлению чисел, скрытым копиям и служебным структурам. Они могут менять реальные расходы. Общие правила вроде «этот объект всегда занимает столько байт» без привязки к реализации и версии среды я бы здесь не вводил.

Что показал последний прогон

Теперь к цифрам. Ниже результаты локального Node-прогона от 9 октября 2026 года для production-сборки.

Это сравнение реактивных ядер через адаптеры. В него не входят DOM-отрисовка, Canvas, сетевые запросы и весь жизненный цикл приложения.

Есть существенная деталь: адаптер Raph использует низкоуровневые функции доступа к сигналам и вычислениям. Поэтому его строка показывает результат ядра в этой конфигурации, а не все накладные расходы публичного API. Остальные участники тоже запускаются через свои адаптеры, которые стоит учитывать при интерпретации.

Общий рейтинг производительности из актуального HTML-отчёта: геометрическое среднее относительных результатов, меньше - лучше
Общий рейтинг производительности из актуального HTML-отчёта: геометрическое среднее относительных результатов, меньше - лучше

По числу Raph стоит вторым. Разница с alien-signals в сводной оценке составляет около 0,92%. В отчёте оба попадают в одну группу по правилу близости значений в пределах 5%.

Эти 5% я добавил на случай шума в тестах. И 0,92% относятся к агрегату: нельзя сказать, что каждая операция Raph медленнее ровно на столько же.

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

Ниже фрагмент детализации из того же отчёта. Она полезнее одного места в рейтинге: на разных формах графа соотношение меняется. Числа в ячейках - миллисекунды выполнения всей тестовой нагрузки, их нельзя напрямую подставить в бюджет одного кадра.

Фрагмент таблицы сценариев: передача изменений, связи и эффекты, создание графа; время в миллисекундах
Фрагмент таблицы сценариев: передача изменений, связи и эффекты, создание графа; время в миллисекундах

Открыть полный HTML-отчёт: корректность, производительность и память

В HTML можно посмотреть все 17 участников сравнения скорости и отдельные строки сценариев. Наборы участников в разделах скорости и корректности отличаются.

А что с памятью

Здесь результат скромнее: Raph занимает десятую строку из 17. Сводная оценка удерживаемой памяти - около 2,50 при 1,00 у @reactively. Это геометрическое среднее относительных значений в двух сценариях.

Рейтинг retained V8 heap из того же HTML-отчёта: Raph десятый по численной сводной оценке
Рейтинг retained V8 heap из того же HTML-отчёта: Raph десятый по численной сводной оценке

Я в первую очередь концентрировался на времени выполнения. Для моих интерактивных сцен важно укладываться в кадр: несколько миллисекунд могут решить, успеет ли приложение к следующему обновлению экрана. Поэтому дополнительную память я рассматриваю как возможный компромисс ради скорости. Это приоритет этих задач, а не общее правило для всех приложений.

В отчёте измерен прирост удерживаемой кучи V8 после GC в Node. Граф остаётся живым; для каждой строки берётся медиана трёх запусков в свежих процессах. DOM, ресурсы GPU и вся память браузерной вкладки сюда не входят. RSS и external memory показаны отдельно и не влияют на рейтинг.

Чтобы понять масштаб, возьмём shared-static: 128 источников, сигнал направления, 64 вычисления и один эффект. Каждое вычисление читает все источники и направление, эффект - все вычисления. Получается 194 логических узла и 8 320 связей. shared-reordered использует тот же граф, но меняет порядок чтения источников на обратный.

Первая строка следующей таблицы - замер shared-static. Остальные две - условная линейная оценка, если увеличивать граф, сохраняя примерно те же пропорции узлов и связей: измеренный объём × новое число связей / 8 320. Замер включает узлы, замыкания и служебные структуры, поэтому это не измерение байтов на одну связь. При другой форме графа или большем размере зависимость может измениться.

Связи и основание расчёта

@reactively, 1-е место

Raph, 10-е место

lite-signal, 17-е место

Разница между первым и последним

8 320, измерено

0,298 МБ

0,759 МБ

1,011 МБ

0,713 МБ

100 000, линейная оценка

3,58 МБ

9,13 МБ

12,15 МБ

8,56 МБ

500 000, линейная оценка

17,92 МБ

45,63 МБ

60,74 МБ

42,82 МБ

Здесь 1 МБ = 1 000 000 байт; места взяты из общего рейтинга двух сценариев памяти. Для 500 тысяч связей такая арифметика даёт разницу порядка 43 МБ между крайними участниками. Не слишком большая потеря, если мы говорим о быстром рантайме (но это лишь моё мнение).

Для настольного приложения я готов рассматривать дополнительные десятки мегабайт, если измерения самого приложения покажут, что это помогает сохранить плавность. На ограниченном устройстве или при большом числе одновременно живых графов приоритет может быть другим. Память также влияет на сборку мусора, поэтому окончательный выбор нужно проверять в готовом приложении.

У сценариев памяти есть ограничение: они сверяют значения computed, но не проверяют выполнение эффекта после записи. Поэтому рейтинг памяти сам по себе не подтверждает одинаковое поведение участников. Для этого в отчёте есть отдельный раздел корректности. Он проверяет конкретные ожидания, которые могут отличаться от контракта отдельной библиотеки. Каждое расхождение нужно разбирать отдельно - подробнее об этом расскажу ниже.

Откуда взяты тесты

Основа стенда - два открытых репозитория:

  • Reactive Framework Test Suite JohnsonCodeHK. Отсюда взят набор проверок поведения реактивных систем.

  • JS Reactivity Benchmark. Отсюда взята основа сравнительных нагрузок и адаптеров для измерения производительности.

Вокруг них у меня есть общий запуск, адаптер Raph, дополнительные сценарии и собственная политика сводной оценки. Изменены и части измерительной обвязки: прогрев, повторные измерения, изоляция запуска и сбор результатов.

Поэтому формулировка «два репозитория взяты без изменений» здесь была бы неточной. Я использую их как основу и явно отделяю адаптацию стенда от исходных проектов.

Как устроен прогон и сводная оценка

Запуск

  1. Собирается production-вариант стенда для Node. Участники запускаются последовательно в отдельных процессах с --expose-gc.

  2. Выполняются три ротации с циклически изменённым порядком участников. Это уменьшает зависимость результата от одного порядка запуска, но не устраняет все внешние факторы.

  3. В измерительной обвязке используется mitata. Для обычного Full-запуска задан один прогревочный образец; число измерений зависит от сценария: обычно пять с учётом пилотного, для fan-in/fan-out, динамических и дополнительных сценариев - три. Для чрезмерно долгих случаев есть ограничение, которое помечается в исходных данных.

  4. Динамические сценарии дополнительно различают непрогретый и прогретый граф. В прогретых вариантах несколько сотен итераций обновления выполняются до измеряемого участка.

  5. Подготовка данных и построение графа учитываются по правилам конкретного сценария. Например, большие layered-графы строятся вне измеряемого участка; в других динамических сценариях построение может входить в замер.

  6. Для многих сценариев настроен принудительный GC вокруг измерений. Это не доказательство отсутствия естественного GC внутри нагрузки.

  7. Времена агрегируются медианой по ротациям. Для части нагрузок проверяется контрольная сумма и число вычислений. Такие проверки не заменяют отдельный набор тестов поведения.

Какие строки входят в итог

В политике стенда 52 сценария. Из них 38 относятся к основному профилю, ещё 14 - к стрессовым проверкам. Здесь «основной» означает выбранный профиль синтетических нагрузок, а не измерение готового production-приложения.

В общей HTML-таблице итог для 17 участников рассчитан по 31 общей строке. Ещё семь строк основного профиля видны в детализации, но исключены из агрегата:

  • три ниже установленного порога 0,21 мс;

  • четыре не имеют полного набора сопоставимых значений у всех участников.

Порог 0,21 мс - правило этого стенда с учётом коротких измерений и округления времени, а не универсальный предел точности Node.

В исходном прогоне есть ошибки выполнения и незавершённые строки. Они не превращаются в нулевое время или штрафной коэффициент. Сводный результат относится только к пересечению успешно измеренных строк. Если исключённые сценарии важны для вашего приложения, агрегата недостаточно.

Среднее геометрическое

Для каждой строки время участника делится на лучшее время в этой строке. Затем считается среднее геометрическое отношений внутри семейства. После этого - взвешенное среднее геометрическое между семействами.

Так сравниваются относительные результаты, а длительный сценарий не получает весь вес только потому, что в нём больше миллисекунд.

Например, отношения 2 и 0,5 дают геометрическое среднее 1. Такой пример иллюстрирует работу отношений; в самой таблице, нормированной на минимум каждой строки, значения не меньше 1.

Приоритеты заданы политикой стенда: P0 имеет вес 3, P1 - вес 2. Стрессовые P2-сценарии в этот агрегат не входят. Эти веса отражают мой выбор нагрузки и могут не совпадать с вашим.

Память

Память измеряется отдельно: прирост удерживаемой V8-кучи после GC в свежих процессах, медиана трёх образцов. Сводная оценка - геометрическое среднее двух строк heap, каждая нормирована на свой минимум. В отчёте также есть RSS и external memory, но они в эту оценку не входят.

Это диагностические измерения заданных сценариев. Они не описывают всю память браузера, GPU и готового приложения. Сводная оценка памяти считается отдельно от скорости.

Все 52 сценария: что они проверяют

В первом столбце - точные идентификаторы сценариев. HTML показывает 38 основных строк; 31 из них входит в сводную оценку. Ещё 14 стрессовых сценариев есть в политике стенда и исходных данных, но в эту HTML-таблицу и рейтинг они не включены. «Сценарии приложений» моделируют графы зависимостей, в них нет настоящего DOM.

Передача изменений

Сценарий

Что происходит

Профиль

upstream.avoidable-propagation

Источник меняется, промежуточное вычисление сохраняет результат; проверяется пропуск лишней дальнейшей работы

Основной

upstream.diamond

Изменение проходит через две ветки к общему потребителю

Основной

upstream.broad-propagation

Изменение распространяется по широкому графу

Основной

upstream.deep-propagation

Изменение проходит по глубокой цепочке

Основной

upstream.repeated-observers

Одно вычисление 30 раз читает тот же источник; его результат наблюдает эффект

Основной

upstream.mux

Значения собираются в общий объект и разбираются обратно по отдельным зависимым веткам

Стресс

upstream.triangle

Общий потребитель читает источник и несколько уровней одной цепочки вычислений

Стресс

upstream.unstable

Чтения зависимостей меняются при переключении условия

Стресс

Много источников или много потребителей

Сценарий

Что происходит

Профиль

upstream.fan.many-effects

Один источник обслуживает 48 прямых эффектов и ещё 48 через вычисление

Основной

upstream.fan.computed-effect-direct

Один эффект читает сумму 128 источников, другой - каждый 16-й источник напрямую

Основной

upstream.fan.computed-effect

Много источников питают вычисление и зависимый эффект

Основной

upstream.fan.direct-effect

Эффект напрямую читает каждый 16-й источник из 128

Основной

upstream.mol-bench

Смешанный граф, пакетные записи, условные чтения и тяжёлые вычисления

Стресс

Создание графа

Обозначение N-to-M описывает форму связей между источниками и вычислениями.

Сценарий

Что создаётся

Профиль

upstream.creation.signals

Набор сигналов

Основной

upstream.creation.1-to-1

Связи один к одному

Основной

upstream.creation.2-to-1

Вычисления с двумя источниками

Основной

upstream.creation.4-to-1

Вычисления с четырьмя источниками

Основной

upstream.creation.1-to-2

Два вычисления от одного источника

Основной

upstream.creation.1-to-4

Четыре вычисления от одного источника

Основной

upstream.creation.1-to-8

Восемь вычислений от одного источника

Основной

upstream.creation.0-to-1

Вычисления без входных сигналов

Стресс

upstream.creation.1000-to-1

Вычисления с тысячей входов

Стресс

upstream.creation.1-to-1000

Тысяча вычислений от одного источника

Стресс

Обновление стабильного графа

Сценарий

Что обновляется

Профиль

upstream.update.1-to-1

Связи один к одному

Основной

upstream.update.2-to-1

Вычисления с двумя входами

Основной

upstream.update.4-to-1

Вычисления с четырьмя входами

Основной

upstream.update.1-to-2

Два потребителя одного источника

Основной

upstream.update.1-to-4

Четыре потребителя одного источника

Основной

upstream.update.1000-to-1

Вычисление с тысячей входов

Стресс

upstream.update.1-to-1000

Тысяча потребителей одного источника

Стресс

Графы с выборочным чтением

Размеры ниже - параметры синтетического графа, а не число компонентов реального приложения.

Сценарий

Что моделируется

Профиль

upstream.app.dashboard

64×6, чтение 12%; обновления источников и выборочное чтение небольшой части результатов

Основной

upstream.app.editor

24×8, чтение 40%; производное состояние с меняющимися зависимостями

Основной

upstream.app.kanban

120×7, чтение 18%; широкий граф с выборочным чтением результатов

Основной

upstream.app.entity-detail

40×10, чтение 60%; более частое чтение производных значений

Основной

upstream.component.simple

10×5, чтение 20%; небольшой граф со стабильными связями

Основной

upstream.component.dynamic

10×10, чтение 20%; небольшой граф со сменой зависимостей

Основной

upstream.app.large

1000×12; крупный динамический граф

Основной

Большие графы и режимы выполнения

Сценарий

Что происходит

Профиль

upstream.large.layered-cold

Слоистый DAG: 256 источников, ширина 512, 32 уровня; без предварительных обновлений графа

Основной

upstream.large.layered-warm

Тот же граф после 320 прогревочных итераций

Основной

upstream.large.layered-burst

Пакеты из 16 обновлений, чтение после пакета; предварительно 256 итераций

Основной

upstream.large.diamond-mesh

Сетка ромбов с общими зависимостями после 256 прогревочных итераций

Основной

upstream.mode.pull

32×8; актуализация результатов через явное чтение

Основной

upstream.mode.push

32×8; обновление наблюдаемых результатов через эффекты

Основной

upstream.scale.wide-dense

1000×5; широкий плотный граф, меняются 25 источников

Стресс

upstream.scale.deep

5×500; очень глубокий граф

Стресс

Дополнительные сценарии моего стенда

В исходных данных их идентификаторы начинаются с endge.. Этот префикс обозначает происхождение сценария, а не отдельного участника сравнения.

Сценарий

Что происходит

Профиль

endge.observed-writes

20 000 записей в источник с наблюдаемым производным значением

Основной

endge.dynamic-switching

20 000 переключений между двумя зависимостями

Основной

endge.diamond

20 000 обновлений ромбовидного графа

Основной

endge.batch-1000

100 пакетов по 1000 записей с наблюдаемым результатом

Основной

endge.reversal.single

Перестановка порядка чтения 256 источников у одного вычисления

Стресс

endge.reversal.32-consumers

Та же смена порядка у 32 потребителей

Стресс

endge.reversal.adversarial-64

Неудобный порядок чтения зависимостей у 64 потребителей

Стресс

Как читать блок корректности

В этом прогоне Raph и alien-signals прошли все 178 контрактных проверок, без пропусков и незавершённых случаев.

Это полезная проверка, но её нужно правильно понимать. Набор описывает определённые ожидания от реактивной системы: например, как обновляются вычисления и эффекты, как работают пакетные изменения и динамические зависимости.

У разных библиотек эти ожидания могут отличаться. Отклонение может означать ошибку реализации, ошибку адаптера или намеренно другой контракт. Для вывода нужно разобрать конкретный случай.

Поэтому из красной строки нельзя автоматически делать вывод «в этой библиотеке баги». Но и игнорировать её я бы не стал: если ваше приложение рассчитывает на проверяемое поведение, такое расхождение важно.

Отдельно стоит смотреть пропущенные и незавершённые проверки. Таймаут означает, что стенд не получил завершённый результат. Он не равен 178 доказанным ошибкам.

В общей HTML-таблице скорость, корректность и память показаны независимо. Участник с отклонениями в проверках поведения может иметь хороший результат по времени. Это не означает, что он подходит для задачи с другим требуемым контрактом.

Почему пока нет всего стенда в открытом доступе

У первой части уже есть разрешение: автор Reactive Framework Test Suite ответил на запрос и добавил MIT-лицензию.

У второго репозитория на момент подготовки статьи лицензия не указана. Запрос на её добавление пока остаётся открытым.

Поэтому полный адаптированный стенд, включающий этот сторонний код, я пока не выкладываю. Публичный репозиторий без лицензии не даёт мне оснований свободно переопубликовать его исходники.

Результаты и методику можно посмотреть в отчёте, а исходные проекты - по ссылкам выше. При этом одного HTML недостаточно для независимого воспроизведения моего прогона: для него потребуются точная версия стенда, адаптеров, зависимостей и сведения о среде запуска.

Этот вопрос касается стороннего кода стенда. Он сам по себе не определяет, когда и в каком виде будут доступны исходники Raph.

Что дальше

Для меня текущий результат - хороший повод продолжать, но ещё не ответ на вопрос «насколько быстрее будет ваше приложение».

Следующий полезный шаг - измерения законченных сценариев: одинаковые данные, одинаковые действия пользователя, одинаковый визуальный результат. В том числе работа DOM-интерфейса и Canvas-сцены, время кадра, память и поведение при создании и уничтожении частей приложения.

В следующих статьях хочу постепенно разобрать детали ядра и показать такие нагрузки. Подготовка займёт время, поэтому сегодня ограничился обзором и ответами на вопросы. Насчёт исходников Raph + Nova: я позиционирую их как полностью открытые, поэтому с выходом новой статьи и доработкой исходников всё появится в открытом доступе с документацией, тестами и примерами. Мне лично близок подход IT-сообщества: открытость и возможность работать вместе :)

Если интересен какой-то конкретный сценарий или поведение, напишите. Это поможет выбрать, что разбирать дальше.

Еще раз дублирую ссылки: