Как переписать библиотеку с JS на Rust

Posted by

Подружили Rust с JS и рассказываем, к чему это привело. Илья Бобровский, ведущий разработчик IT Test, о том, как играть в пинг-понг между языками и выйти из этой игры победителем.

Зачем нам понадобился Rust

В один из проектов была заложена библиотека с тысячами строк кода инженерных вычислений, написанная на JS. Мы реализовали приложение полностью на JS (frontend – Angular, backend – NestJs, standalone app – Electron), поскольку это позволило запустить проект в короткие сроки и дало возможность переиспользовать общие функции, интерфейсы да и в целом ресурс команды на всех «концах» проекта.

По началу к производительности инженерных расчетов не было вопросов – JS делал свою работу. Однако за несколько лет развития проекта библиотека расчетов серьезно выросла: количество производимых вычислений увеличивалось с каждым новым обновлением. Фактически за каждым вводом данных в форму клиентской части следовали сотни, а то и тысячи строк вычислений.

Мы заметили, что вместо стартовых 200 мс на выполнение вычислений стало уходить до 3 сек в пике, со средним значением выпрыгивающим за секунду. Библиотека не справлялась, делая приложение не самым удобным в эксплуатации. Можно сказать она стала “bottle neck” всего проекта, так как добиться плавной и быстрой работы без ускорения библиотеки было невозможно. Назревало решение о переводе вычислений на другой инструмент.

Подсказка: Как показал “proof of concept”, Rust работал быстрее в три раза, и это еще без параллелизации вычислений. Мы получали мощную типизацию, низкий расход памяти, неплохую гарантию утечек памяти. Ну и компилируемый файл на выходе, что тоже было для нас немаловажно, так как все вычисления крутились в standalone app, а нам нужно было подготовить продукт к выходу на рынок и защитить интеллектуальную собственность.

Конечно, есть Go, Java, всемогущий C++ и множество других языков, которые могли помочь нам ускориться, но мы остановились именно на Rust, потому что на выходе мы получали производительность сравнимую с С++, а типизацию лучше, чем в Java. 

Как подружить Rust с JS

Интеграцию Rust c Node.js решили делать через FFI (foreign function interface) – механизм, с помощью которого языки программирования могут общаться между собой. У Node.js есть свое Node-API для реализации нативных аддонов: из-за этого интеграция Rust в проект казалась идеальной. Плюс не нужно было заморачиваться с установщиком под Electron и легко комбинировать JS и Rust.

В качестве библиотеки, для работы с Node-API был выбран Neon. Для того, чтобы создать проект требуется выполнить команду npm init neon <project_name>. По пути src/lib.rs вы найдете главный файл плагина, в нем уже будет пример с экспортированной функцией hello, которая возвращает строку “hello node”. Теперь мы можем собрать плагин и установить дополнительные пакеты для работы — npm install.

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

Пример работы с Neon

Теперь посмотрим на Neon и его взаимодействие с JS. Для начала изменим файл src/lib.rs в уже созданном проекте.

src/lib.rs

В этом примере прокомментирована каждая строка. Далее комментарии будут только касательно интересных моментов.

А теперь создадим файл main.js в корне проекта для вызова созданной нами функции.

main.js

Так в консоли появится “User has 65 likes total”.

Как не надо делать в плагинах

Очень скоро мы столкнулись с проблемой производительности. Делая замеры после закрытия одного из первых этапов, мы с ужасом обнаружили, что Rust умудряется в некоторых кейсах выполнять свою работу медленнее JS. Дело оказалось в том, что через FFI мы получаем доступ к данным JS напрямую и это достаточно “дорогая” операция. Получается, необходимо экономить количество обращений к JS объектам из Rust, иначе можно легко получить результаты, когда Rust тратит больше времени на выполнение задачи в сравнении с JS.

Мы пересобрали самый тяжелый массив объектов с большой вложенностью в бинарный и передали его в таком виде в Rust. Рассказываем, как удалось это реализовать. 

main.js

В main.js мы изменили формат отправляемых данных, передавая только лайки пользователей, завернутые в TypedArray.

src/lib.rs

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

В итоге за счет того, что мы загнали данные в TypedArray на больших объемах данных (≈300+ элементов в массиве), мы не увидели просадки по скорости.

Neon serde или упрощаем работу с моделями данных

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

src/lib.rs

Как видите, здесь достаточно много пустого кода для выполнения рутинных действий. А что, если у вас десятки, сотни разных структур данных? Тут на помощь может прийти Serde – отличный фреймворк для сериализации и десериализации структур данных в Rust. Для интеграции понадобится написать свой сериалайзер и десериалайзер данных из N-API объектов в структуры Rust.

К сожалению, crate neon-serde, который мог бы помочь с решением этой проблемы, не поддерживается, но есть его клоны, например neon-serde3, которые позволяют работать с последней версией Neon. В нашем случае пришлось уходить в сторону написания своего пакета, так как требовался расширенный функционал. Но для примера будет вполне достаточно пакета по ссылке упомянутого выше форка. Вот что получается.

src/lib.rs

Теперь за всю тяжелую работу отвечает neon-serde, а нам лишь остается развешивать serialize/deserialize макрос.

Пинг-понг между языками

Как же быть, если нужно вернуть управление JS, но очень не хочется терять промежуточное состояние в Rust? Этот вопрос не давал нам покоя с самого начала работ по миграции библиотеки. Оказалось, Neon имеет решение – JsBox. Эта структура позволяет хранить данные Rust в переменных JS. Давайте изменим наш пет-проект, чтобы посмотреть на JsBox в деле.

src/lib.rs

Мы добавили структуру PostDetails, которая теперь у нас содержит детальное описание поста, и получили две выходные функции: preparePosts, proceedpreparePosts сериализует наши JS объекты в вектор структур UserPosts, именно этот список мы хотим сохранить и вернуть JS. Также мы находим все посты, где лайков больше 30, чтобы попросить JS найти дополнительную информацию по таким постам. В результате работы функция возвращает кортеж из ссылки на вектор постов в Rust и массива uid‘ов, которые нам интересны.

После того, как JS получил данные из preparePosts и нашел детальную информацию по нужным постам, можно передавать управление обратно в Rust.

src/main.js

Если мы выведем в консоль содержимое rustUsers, увидим:

Это и есть наша ссылка на данные Rust. 

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

Подведем итоги

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

Однако наша работа по переводу библиотеки вычислений на Rust еще не окончена: мы переписали основную логику вычислений, впереди еще много улучшений. Как минимум, планируем распараллелить вычисления с помощью rayon и декомпозировать библиотеку из большого монолитного черного ящика в более маленькие функции.

А с какими сложностями сталкивались вы?

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *