Как стать автором
Обновить

Big State Managers Benchmark

Время на прочтение 9 мин
Количество просмотров 8.7K

Здравствуйте, меня зовут Дмитрий Карловский и я.. большой любитель физики высоких энергий. Сталкиваешь такой совершенно разные вещи между собой, и смотришь на бабахи, уплетая поп-корн.

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

Что ж, давайте возьмём их всех и столкнём лбами друг с другом и с суровой реальностью, чтобы узнать кто же на самом деле тут батя!

Методика измерения

Важно отметить, что все приложения разные, со своими особенностями и требованиями. Так что какой бы тестовый пример мы ни придумали - он будет бесконечно далёк от вашего проекта. Так что дать точную оценку вида "с таким-то менеджером состояний ваш проект будет на 50% быстрее" невозможно. Однако, мы можем дать приблизительную оценку вида "с таким-то менеджером состояний ваш проект будет замедляться сильнее по мере роста проекта, и вот почему".

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

  • Batch Changes. Должна быть возможность разом обновить несколько состояний, не перевычисляя весь граф лишний раз.

  • Order Independent. Порядок внесения изменений не должен влиять на порядок пересчёта узлов графа - он в любом случае должен выполняться в оптимальном порядке.

  • Ignore Unrelated. Порой зависимость в коде не даёт зависимость по данным. В этом случае изменение зависимости не должно приводить к пересчёту зависимого.

  • Collapse Double. Для вычисления значения узла зависимость может потребоваться несколько раз, но это не должно приводить к лишним вычислениям.

  • Skip Untouched. Если зависимость по факту никому сейчас не требуется, то и вычислять её не надо.

  • Skip Redundant. Если новое значение зависимости эквивалентно предыдущему (например, новый массив, но с теми же значениями), то зависимые состояния пересчитывать нет смысла.

  • Reuse Moved. Порядок обращения к зависимостям при вычислении может раз от раза меняться, но это не должно приводить к пересчётам этих зависимостей.

  • Single Source. Множественные подписки с побочными эффектами на одно и то же состояние, не должны приводить ко множественному вычислению этого состояния.

  • Effect Once. Какой бы сложный ни был граф состояний, на одно изменение исходных состояний, побочный эффект должен быть выполнен лишь однажды.

В коде это выглядит примерно так, если не использовать никаких реактивных мемоизаторов и шедулеров:

let A = 0 // unique values: 1 2 3 4 ...
let B = 0 // toggle values: 1 2 1 2 ...
const C = ()=> A % 2 + B % 2 // toggle values
const D = ()=> [ A % 2 - B % 2 ] // same value: [0]
const E = ()=> hard_work( C() + A + D()[0] ) // unique values
const F = ()=> hard_work( D()[0] && B ) // same value
const G = ()=> C() + ( C() || E() % 2 ) + D()[0] + F() // toggle values
const H = ()=> side_effects.push( hard_work( G() ) ) // toggle values
const I = ()=> side_effects.push( G() ) // toggle values
const J = ()=> side_effects.push( hard_work( F() ) ) // single run

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

Если формулы будут тривиальными, то самым быстрым решением окажется то, где идёт полный пересчёт всех состояний. На ультра-малых проектах (привет, Мир!) полный пересчёт может быть как быстрее, так и медленнее, но производительность менеджера состояний в них не особо и важна - так и так будет достаточно быстро. А вот по мере роста проекта лишние пересчёты будут всё сильнее замедлять приложение. И насколько сильно будет это замедление - очень даже важно.

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

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

На графе состояний можно видеть:

  • Исходное состояние A меняется на уникальное значение на каждой итерации.

  • Исходное состояние B переключается между двумя значениями на каждой итерации.

  • Промежуточное состояние C переключается между двумя значениями на каждой итерации.

  • Промежуточное состояние D на каждой итерации выдаёт массив с одними и теми же значениями внутри.

  • Промежуточное состояние E на каждой итерации то участвует в вычислениях, то не участвует. В первом случае это требует тяжёлых вычислений, а в последнем пересчитываться оно не должно.

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

  • Тяжёлый побочный эффект H и лёгкий I должны исполняться на каждой итерации, так как их зависимость постоянно меняется.

  • Тяжёлый побочный эффект J, наоборот, исполниться должен лишь однажды, так как его зависимость не меняется.

При инициализации большинство решений исполняет все 4 тяжёлых вычисления по одному разу: EFHJ. А вот на каждой итерации могут быть более разнообразные варианты. Вот наиболее частые:

  • Оптимальный: H + EH = 3

  • Без проверки на эквивалентность: FH + EFH = 5

  • Полный ленивый пересчёт: FHFFJ + EFHEFFJ = 12

  • Пересчёт каждого пути: HEEEHFHEEHFF + HEEEHFHJHEHEHHFHJF = 30

По последнему пункту у вас может возникнуть резонный вопрос: да кто в своём уме будет такое бестолковое решение использовать? Однако, именно оно используется в одном из веб-фреймворков большой тройки. Но об этом позже..

Анализ результатов

Итак, время пришло, запускаем коллайдер:

Так, что за дела? Почему половина библиотек завалила тесты? А всё просто - они либо выдавали побочные эффекты для грязного состояния, либо выдавали их лишний раз, либо всё вместе. То есть эти библиотеки не пригодны к использованию в продакшене вообще. Прощай Vue, прощай React + Redux, прощай Angular + RxJS.

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

Как читать градусники

  • 🥶 Холодный старт - нет JIT оптимизаций

  • 🥵 Горячий старт - JIT оптимизации возможны

  • 🔠 Оценка размера тестового кода (код после // игнорируется)

  • 🚂 min-zip объём загруженных библиотек

  • Производительность - слева, потребление памяти - справа

Классификация решений

По типу API

  • 🤓 Object. Состояние хранится в объектах, логика работы с ним пишется в методах.

  • 😵 Closure. Каждое состояние инкапсулировано в отдельном реактивном контейнере, который связан с другими через грязные замыкания.

  • 🤯 Functional. Состояние хранится в некотором контексте, а логика описывается как композиция чистых функций.

По энергичности

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

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

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

Лига выдающихся менеджеров состояний

Встречаем лидеров: $mol_wire, MobX, ReAtom, WhatsUp, CellX. Они наиболее оптимально разрулили потоки данных, что дало минимальные накладные расходы: H + EH = 3

$mol_wire

Библиотека ничего особенного не обещает в плане эффективности, однако именно она показала себя лучше всего в нашем бенчмарке. Как ей это удалось? Об этом можно почитать в статье: Проектируем идеальную систему реактивности.

MobX

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

ReAtom

Библиотека показывает отличную эффективность в нашем тестовом примере, не смотря на активное использование иммутабельности под капотом:

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

WhatsUp

Отличное решение, которое обещает нам:

..и отвечает за свои слова.

CellX

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

Менеджеры состояний со скамейки запасных

Библиотеки, которые звёзд с неба не хватают, но всё же проходят тесты: SolidJS, Reactively, Preact, MaverickJS, Spred, FRP-TS, Effector.

Типичные их проблемы: не умение отсекать эквивалентные изменения и пересчёт состояний, которые никому уже не интересны.

SolidJS

Библиотека обещает нам бесплатную производительность за счёт детальной реактивности:

А оказывается, что она не умеет эффективно отсекать нерелевантные вычисления: EH + EH = 4

Reactively

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

На деле, конечно же, чуда не произошло, из-за отсутствия планировщика и отсечения эквивалентных изменений: FH + EFH = 5

Preact

Библиотека обещает быть быстрой на любых размерах приложений:

Однако, без отсечения эквивалентных изменений этого не достичь: FH + EFH = 5

Spred

Библиотека обещает нам отсутствие лишних вычислений и отличную производительность:

Но ведёт себя странно, иногда не отсекая эквивалентные изменения и не пропуская вычисления, результат которых уже никому не нужен: EH + EFH = 5

MaverickJS

Библиотека обещает лишь базовые оптимизации:

Так что не удивительно, что максимальной эффективности она достичь не может: EFH + EFH = 6

FRP-TS

Библиотека обещает лишь избавить нас от глитчей:

И действительно с этим справляется. К сожалению, функциональная природа не позволяет ей сэкономить на вычислениях, что даёт посредственную эффективность: EFH + EFH = 6

Effector

Библиотека обещает нам максимум производительности за счёт некоей статической инициализации:

Но оказывается самой медленной из тех, кто не завалил тесты. Всё же в реальных приложениях довольно много динамики и её становится всё больше по мере роста проекта. Так что крайне важно уметь эффективно работать в рантайме, а не полагаться на ручные оптимизации потоков данных в коде. Вот и получаем: EFH + EFH = 6

Тоже своего рода менеджеры состояний

На доске позора у нас: uSignal, Vue, LegendApp, Jotai, S.js, Redux + Relect, Hydroxide, RxJS. Помимо просто лишней работы, замедляющей приложение, они так же выполняют и лишние побочные эффекты, что может быть источником множества багов.

uSignal

Библиотека грозится дать нам ультралегковесную версию реактивности из Preact и SolidJS:

Но чуда, конечно же, не произошло - анорексия сказывается на эффективности не лучшим образом: FHJ + EFHJ = 7

Vue

Фреймворк представляется нам достаточно производительным:

Тем не менее, в его основе лежит реактивная система сомнительной эффективности: FHJ + EFHJ = 7

LegendApp

Библиотека обещает нам починить React с помощью мелкозернистой реактивности, чтобы его не приходилось постоянно оптимизировать вручную:

Однако, показала она себя в два раза хуже, чем полный пересчёт графа состояний. Кажется она издевательски смеётся над нами, повторяя одни и те же вычисления несколько раз подряд: EHE + HEHE = 7

Jotai

Библиотека обещает починить React, чтобы его можно было использовать и для больших приложений:

Но показывает она почти самые худшие результаты, делая кучу лишней работы: EFHJ + FEHJ = 8

Обратите внимание, что она не только выполняет лишние побочные эффекты, но и производит вычисления в недетерминированном порядке, что является дополнительным источником проблем в больших проектах.

S.js

Библиотека обещает нам быть простой и быстрой:

Но быть эффективной на больших проектах она не обещает, так что ничего удивительного: EFHJ + EFHJ = 8

Redux + Reselect

Reselect обещает нам починить Redux, чтобы хотя бы селекторы в нём стали эффективными:

Но нам же никто не обещал максимальной эффективности, так что получаем: EFHJ + EFHJ = 8

Hydroxide

Фреймворк хвастается своей экстремальной производительностью:

Но вы только гляньте, какую дичь он творит, получая производительность меньше, чем у полного пересчёта всего графа состояний: EFEFHJHJ + EFEFHJHJ = 16

RxJS

Библиотека обещает нам улучшенную производительность относительно оригинальной библиотеки:

И показала наихудшую производительность, на порядок отстав от лидера, и в два раза от полного пересчёта. Всё дело в том, что, вместо оптимизации потоков данных, она тупо пересчитывает весь граф состояний обходя его в глубину по всем возможным путям: HEEEHFHEEHFF + HEEEHFHJHEHEHHFHJF = 30

Сухой остаток

Самые неэффективные технологии зачастую хвастаются своей максимальной производительностью. На микробенчмарках с полным пересчётом всех состояний самое тупое решение легко может показаться самым быстрым. Но эти спринтеры не способны пробежать марафон, падая на обочину уже с тривиальных тестов корректности применения побочных эффектов. Разработчики ищут ключи там, где светло (оптимизации кода), а не там, где их потеряли (оптимизации архитектуры). Для лучшего понимания ключевых проблем в реактивных архитектурах, лучше глянуть мою давнюю лекцию: Main Aspects of Reactivity

Настоящий ужас вызывает то, насколько кривые системы реактивности лежат в основе так называемой большой тройки самых популярных веб-фреймворков: React, починить который не пытался только ленивый; Angular, на полном серьёзе предлагающий использовать RxJS; Vue, дёргающий побочные эффекты по поводу и без. Как мы до этого докатились, я рассказывал недавно в выступлении: Как программисты дурят бизнес?

Если вы хотите добавить ещё какой-то менеджер состояний к сравнению, или обнаружили недостаток в существующем коде, или хотите присоединиться к полузакрытому тихому чату разработчиков js-библиотек, то пишите телеграммы nin_jin.

Текст статьи подготовлен в $hyoo_page.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какой менеджер состояний вы используете?
4.55% $mol_wire 6
40.91% MobX 54
4.55% ReAtom 6
0.76% WhatsUp 1
0% CellX 0
2.27% SolidJS 3
0% Reactively 0
1.52% Preact 2
0% MaverickJS 0
0% Spred 0
0.76% FRP-TS 1
10.61% Effector 14
0% uSignal 0
21.97% Vue 29
0.76% LegendApp 1
1.52% Jotai 2
0% S.js 0
18.18% Redux + Reselect 24
0% Hydroxide 0
13.64% RxJS 18
Проголосовали 132 пользователя. Воздержались 53 пользователя.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+4
Комментарии 77
Комментарии Комментарии 77

Публикации

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн