Безопасно ускоряем Erlang приложение c помощью NIF на Rust

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


    Выбор языка


    Тесты производительности, основанные на вычислительных задачах, ясно дают понять, в какой лиге играет Erlang.




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

    В реальных системах возникают локальные вычислительные задачи, которые тормозят систему и ухудшают общий UX. Часто бывает так, что тормозит 1% кода, и негативно влияет на остальные 99% системы. Для решения данной проблемы в Erlang, начиная с версии R13B03, существует механизм Native Implemented Functions (NIFs).


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


    Официальные примеры реализации NIF доступны для C. Код на С и C++ довольно легко сделать небезопасным, например, выйдя за границы памяти структуры или массива, или же пропустив операцию освобождения выделенных ресурсов. На мой взгляд, проблема усугубляется фактором переключения контекста: когда программист, в основном разрабатывающий код на Erlang, переключается на низкоуровневый C, вероятность описанных выше проблем возрастает, особенно в рамках горящих сроков.


    Таким образом, хотелось бы получить решение такое же быстрое, как на С/C++, но безопасное и легко поддерживаемое. Давайте посмотрим на самые производительные в вычислительном плане языки.




    С точки зрения требований к языку стоит отметить:
    1. Безопасность. Решение не должно ни при каких условиях ломать Erlang VM
    2. Производительность. Быть сравнимым по производительности с С++
    3. Возможность использования в режиме NIF
    4. Скорость разработки. Необходима хорошая стандартная библиотека и большой набор сторонних библиотек, обеспечивающий удобную экосистему языка.

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


    Следует заметить, что существует другой вариант оптимизации. Если мы можем пренебречь временем и накладными расходами дополнительного вызова через EPMD, то можно выбрать путь написания Erlang Node, вместо NIF. Для решения этой задачи подходит Java, Go, Rust, Ocaml (из личного опыта). Erlang Node может быть запущена на той же машине или вообще на другом конце земли.


    Имплементация


    Обзор существующих решений на Rust


    После быстрого поиска находится сразу несколько библиотек для написания NIF на rust. Рассмотрим их:


    1. rustler. Пожалуй, самая популярная и функциональная библиотека, однако авторы сконцентрировали свои усилия на поддержке Elixir. В https://github.com/hansihe/rustler/issues/127 предлагают тащить mix в erlang проект. Документации по использованию в Erlang нет.
    2. erlang-rust-nif. Представляет из себя низкоуровневую реализацию NIF и подход к сборке расширения. Код выглядит простой трансляцией с C. Сборка имеет граничные условия и не универсальна.
    3. erlang_nif-sys. Низкоуровневая и полнофункциональная связка. Является основой для Rustler. Требует усилий и времени для написания NIF.
    4. bitwise_rust. Демонстрирует работу с планировщиком. Также является оберткой без синтаксического сахара над С api.

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


    Rustler


    Отвечая на вопрос “зачем вообще тащить в erlang проект elixir?” и следуя принципу KISS, решено использовать rustler, но без дополнительных зависимостей. В качестве билд системы используется rebar3. Самым простым и быстрым шагом является определение pre_hooks для компиляции нашего rust кода.


    Для этого допишем в тестовом профиле hook:


    {pre_hooks, [
              {"(linux|darwin|solaris|freebsd)", compile, "sh -c \"cd crates/bloom && cargo build && cp target/debug/libbloom.so ../../priv/\""}
    ]}

    В боевом окружении добавим опцию --release, таким образом в боевой профиль добавляем:


    {pre_hooks, [
              {"(linux|darwin|solaris|freebsd)", compile, "sh -c \"cd crates/bloom && cargo build --release && cp target/release/libbloom.so ../../priv/\""}
    ]}

    После этих манипуляций появляется динамическая библиотека priv/libbloom.so, полностью готовая к загрузке в Erlang VM.
    Подробности и пример использования rustler в erlang проекте можно найти в репозитории проекта https://github.com/Vonmo/erbloom


    Фильтр Блума


    Поскольку экосистема rust предоставляет уже готовые реализации фильтра блума, выбираем подходящую и добавляем в cargo.toml. В данном проекте используется bloomfilter = "0.0.12"


    Расширение реализует следующие функции:


    1. new(bitmap_size, items_count) – инициализация фильтра. bitmap_size и items_count – расчетные значения, существует масса готовых калькуляторов.
    2. serialize() – упаковка фильтра, например, для последующего сохранения на диск или передачи по сети.
    3. deserialize() – восстановление фильтра из сохраненного состояния.
    4. set(key) – добавляет элемент в множество.
    5. check(key) – проверяет принадлежность элемента множеству.
    6. clear() – очищает фильтр.

    Erlang


    Следует отметить, что загрузка расширения в Erlang – это абсолютный прозрачный процесс. После загрузки вашего модуля, происходит вызов on_load, в котором необходимо реализовать загрузку nif через erlang:load_nif/2. При этом обработка вызовов будет прозрачно происходить уже в Rust.


    Правилом хорошего тона является генерация ошибки erlang:nif_error/1 в случае если NIF не загружен.


    Подробное описание окружения для сборки проекта можно найти в данной статье.


    Итоги


    В результате проделанной работы мы получили производительное и безопасное расширение. В наших проектах данное расширение позволяет сократить объем обращений к хранилищу данных в некоторых случаях до 10 раз и обслуживать поток обращений более 500к RPS на машину.


    Исходный код расширения доступен на github.

    • +30
    • 2,4k
    • 5
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 5
    • +3
      очень интересно.

      Та обертка для rust nif, которую я гонял, адски текла и ездила по памяти
      • +1
        Спасибо, интересно. Сам в свое время писал NIF'ы (тынц), правда на C.

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

        Я слышал, что проблему отчасти решили в последних релизах Erlang'а, но, увы, не знаю деталей. Вроде как можно запустить сишный код на отдельной нитке ОС или вроде того.
        • +1
          Если кратко, то существующую проблему пытаются решить через dirty scheduler. Можно выставить флаги ERL_NIF_NORMAL_JOB = 0,
          ERL_NIF_DIRTY_JOB_CPU_BOUND = 1,
          ERL_NIF_DIRTY_JOB_IO_BOUND = 2
          В rustler есть обработка этого функционала через
          pub enum SchedulerFlags {
          Normal = 0,
          DirtyCpu = 1,
          DirtyIo = 2,
          }
          При объявлении наших обработчиков пишем что-то подобное:
          («dirty_cpu», 0, dirty_cpu_fun, SchedulerFlags::DirtyCpu),
          («dirty_io», 0, dirty_io_fun, SchedulerFlags::DirtyIo),
          • 0
            Почитал тут erlang.org/doc/man/erl_nif.html#lengthy_work

            Мне кажется секция Yielding NIF больше похожа на правдоподобное решение.
            • 0
              Думаю, nifs можно разделить на две группы: 1) процесс исполнения можно поделить на мелкие порции укладывающиеся в 1мс по времени. 2) для которых такую оптимизацию применить нельзя.
              Детали реализации первой группы можно найти изучив информацию по enif_schedule_nif и enif_send. Вторые точно нужно выносить под DS, чтобы не случился scheduler-collapse.
              Также, в свое время я находил вот эту публикацию по данной теме: medium.com/@jlouis666/erlang-dirty-scheduler-overhead-6e1219dcc7

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое