Ускоряем Node.js с помощью Rust

в 14:01, , рубрики: c++, node.js, python, Rust

Ускоряем Node.js с помощью Rust - 1

В последнее время в сети довольно часто упоминается «молодой и перспективный» язык Rust. Он пробудил во мне любопытство и желание сделать на нём что-то более-менее полезное, чтобы как-то примерить — впору ли он мне. Это вылилось в достаточно любопытный, как мне кажется, опыт скрещивания ужа с ежом при содействии кукушки.

И так, делал я вот что. Есть проект на node.js. Там есть функционал, который требует считать хэш. При том, довольно часто — почти на каждый входящий запрос. Поскольку хэш этот не является чем-то, что должно уберечь меня от коллизий и вообще нужен не безопасности ради, а удобства для, то используется алгоритм adler32. Он предоставляет короткое выходное значение.

По какой-то нелепости, в node.js его нет. Поясню, почему это нелепо. Этот алгоритм обычно используется в компрессии, в частности его использует gzip. В node.js есть стандартная реализация gzip в модуле zlib. То есть, adler32 там вообще-то есть, но в неявном виде. В Python, для сравнения, в аналогичном модуле он имеется и им можно пользоваться.

Ну, да ладно. Берём сторонний пакет из npm. Я взял вот этот: adler32 — в основном потому, что он умеет интегрироваться с модулем crypto и его можно использовать так же, как и остальные хэш-алгоритмы. Это удобно. О производительности в данном случае я особенно не задумывался. Какой бы она ни была — это копейки. Но поскольку у меня намечался эксперимент, то этот самый adler32 был выбран жертвой.

В общем, приступим. Ставится Rust просто. Документация тоже достаточно внятная как на русском, так и на английском. Rust взят версии 1.15. Забавный факт: документация на русском не является прямым переводом английской и немного отличается по структуре. В частности, в неё добавлен пример работы с потоками.

Кроме самого Rust, стоит так же node.js версии 6.8.0, Visual Studio 2015 и Python 2.7 — это всё понадобится.

Теперь проведём предварительный замер.

Node.js

for (var i=0; i<5000000; i++) {
    var m = crypto.createHash('adler32');
    m.update("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму");
    m.digest('hex');
}

Средний результат трёх запусков: 41,601 секунда. Лучший результат: 40,206

Чтобы с чем-то сравнить, давайте возьмём для начала нативную реализацию хэша в node.js. Скажем, sha1. Выполнив точно тот же самый код, но указав в качестве алгоритма sha1, я получил такие цифры:
Средний результат трёх запусков: 9,737 секунд. Лучший результат: 9,321

Может ну его вообще этот адлер? Но погодите, погодите. Давайте всё-таки попробуем что-нибудь сделать на Rust.

Rust

И так, на Rust есть сторонняя библиотека compress, которая доступна в этом их Cargo. Она тоже умеет gzip и предоставляет возможность считать adler32. Примерно так это выглядит:

for i in 0..5000_000 {
    let mut state = adler::State32::new();
    state.feed("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму".as_bytes())
}

Средний результат трёх запусков: 2,314 секунды. Лучший результат: 2,309

Неплохо!

Node.js и FFI

Поскольку Rust компилируется в код, совместимый с Си, то его можно скомпилировать в динамическую библиотеку и подключать с помощью FFI. У node.js для этого есть специальный пакет, который нужно ставить отдельно:

npm install ffi

Если у вас всё хорошо, то после этого можно будет подключать внешние библиотеки написанные на Си или совместимые с ним.

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

extern crate compress;
extern crate libc;

use libc::c_char;
use std::ffi::CStr;
use std::ffi::CString;
use compress::checksum::adler;

#[no_mangle]
pub extern "C" fn adler(url: *const c_char) -> c_char {
    let c_str = unsafe {
        CStr::from_ptr(url).to_bytes()
    };
    let mut state = adler::State32::new();
    state.feed(c_str);
    let s:String = format!("{:x}", state.result());
    let s = CString::new(s).unwrap();
    s.into_raw()
}

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

Кроме того, в файле Cargo.toml нужно указать, что компилировать нужно в динамическую библиотеку. Там же указываются зависимости:

[package]
name = "adler"
version = "0.1.0"
authors = ["juralis"]

[lib]
name = "adler"
crate-type = ["dylib"]

[dependencies]
compress = "*"
libc = "*"

Вот. Теперь это будет компилироваться в библиотеку. Какого типа — зависит от целевой платформы. У меня на выходе получилась dll, поскольку занимался я всем этим из под Windows и указал соответствующие параметры компиляции:

cargo build --release --target x86_64-pc-windows-msvc

Ну что ж. Хватаем эту самую dll, кладём куда-нибудь ближе к проекту на node.js и кое-что добавляем в код:

var ffi = require('ffi');
var lib = ffi.Library('adler.dll', {
    adler: ['string', ['string']]
})
for (var i=0; i<5000000; i++) {
    lib.adler("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму")
}

Средний результат трёх запусков: 27,882 секунд. Лучший результат: 26,642

Ну… Что-то как-то не то, что хотелось бы. Видимо, все эти радости с внешними вызовами стоят довольно дорого. Тем не менее, это всё-таки работает быстрее. Но можно ли сделать ещё быстрее? Можно.

Node.js и С++ аддон

В node.js, как известно, поддерживаются так называемые аддоны. Почему бы и не попробовать? Единственная проблема, что я вообще говоря в С++ ни в зуб ногой. Впрочем, есть добрые люди, которые написали немного справки. Вот тут примерно рассказано о том, как оно работает. Как оказалось, я не первый, кто решил таким образом поразвлечься. Впрочем, там довольно тривиальный пример с вычислением чисел Фибоначчи и соответственно, там многое остаётся неясным. А поскольку C++ я не знаю, то это конечно представляло проблему.

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

//Header
//This could go into separate header file defining interface:
#ifndef NATIVE_EXTENSION_GRAB_H
#define NATIVE_EXTENSION_GRAB_H

#include <nan.h>
#include <string>
#include <iostream>
#include <node.h>
#include <stdio.h>


using namespace std;
using namespace v8;
using v8::Function;
using v8::Local;
using v8::Number;
using v8::Value;
using Nan::AsyncQueueWorker;
using Nan::AsyncWorker;
using Nan::Callback;
using Nan::New;
using Nan::Null;
using Nan::To;

#endif


/* extern interface for Rust functions */
extern "C" {
  extern "C" char * adler(char * url);
}

 
NAN_METHOD(adler) {
  Nan::HandleScope scope;
  String::Utf8Value cmd_url(info[0]);
  string s_url = string(*cmd_url);
  char *url = (char*) malloc (s_url.length() + 1);
  strcpy(url, s_url.c_str());
  char * result = adler(url);
  info.GetReturnValue().Set(Nan::New<String>(result).ToLocalChecked());
  free(result);
  free(url);
}

NAN_MODULE_INIT(InitAll) {
  Nan::Set(
    target, New("adler").ToLocalChecked(),
    Nan::GetFunction(New<FunctionTemplate>(adler)).ToLocalChecked()
  );
}

NODE_MODULE(addon, InitAll)

Кроме того, у предыдущего товарища я взял пример файла bindings.gyp:

{
    "targets": [{
        "target_name": "adler",
        "sources": ["adler.cc" ],
        "libraries": [
            "/path/to/lib/adler.dll"
        ]
    }]
}

Ещё нужен файл index.js с одержанием:

module.exports = require('./build/Release/addon');

Теперь надо всю эту радость собрать с помощью node-gyp. Но у меня оно компилироваться с наскока отказалось. Пришлось немного поразбираться в том, что там происходит.

Для начала надо поставить пакет nan (Native Abstractions for Node.js ):
npm install nan -g

И добавить путь до него в bindings.gyp (где-нибудь на одном уровне с libraries):


        "include_dirs" : [
            "<!(node -e "require('nan')")"
        ]

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

#include <nan.h>
#include <string>
#include <node.h>

#pragma comment(lib,"Ws2_32.lib")
#pragma comment(lib,"userenv.lib")
using std::string;
using v8::String;
using Nan::New;

extern "C" {
  extern "C" char * adler(char * url);
}

NAN_METHOD(adler) {
  Nan::HandleScope scope;
  String::Utf8Value cmd_url(info[0]);
  string s_url = string(*cmd_url);
  char *url = (char*) malloc (s_url.length() + 1);
  strcpy(url, s_url.c_str());
  char * result = adler(url);
  info.GetReturnValue().Set(Nan::New<String>(result).ToLocalChecked());
  free(result);
  free(url);
}

NAN_MODULE_INIT(InitAll) {
  Nan::Set(
    target, New("adler").ToLocalChecked(),
    Nan::GetFunction(New<FunctionTemplate>(adler)).ToLocalChecked()
  );
}

NODE_MODULE(addon, InitAll)

Впрочем, прежде чем это случилось, обнаружилась ещё одна штука. Библиотека у меня была скомпилирована как динамическая, а node-gyp требовал статическую. Поэтому, в Cargo.toml нужно поменять вот эту строку:
crate-type = [«dylib»]
на вот эту:
crate-type = [«staticlib»]

После чего опять надо откомпилировать:

cargo build --release --target x86_64-pc-windows-msvc

Кроме того, надо не забыть теперь поменять путь до библиотеки в bindings.gyp на lib-версию:

        "libraries": [
            "/path/to/lib/adler.lib"
        ]

И вот тогда-то всё должно собраться и получиться заветный файлик adler.node.

В node опять меняем код для генерации хэша:

var adler = require('/path/to/adler.node');
for (var i=0; i<5000000; i++) {
    adler.adler("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму");
}

Средний результат трёх запусков: 7,802 секунд. Лучший результат: 7,658

О, это уже на пару секунд быстрее, чем даже нативный способ вычисления sha1! Выглядит очень даже симпатично!

В принципе, ведь что такое 5 миллионов раз хэш посчитать и потратить на это 40 секунд? Это примерно как если бы к вам пришло за секунду чуть меньше ста тысяч запросов, а приложение всю эту секунду потратило бы на подсчёт хэшей. То есть, ничем больше оно заниматься бы не успевало. А с таким вот ускорением уже вполне будет успевать заняться и чем-то кроме хэшей. Не думаю, что этот проект когда-нибудь получит такую нагрузку в 100 тысяч запросов в секунду, но тем не менее, опыт считаю достаточно полезным.

Кстати, что там у питона?

В начале статьи упоминался python, почему бы и с ним тоже не попробовать, раз уж всё равно оказался под рукой? Там, как я уже говорил, adler32 можно посчитать прямо из коробки. Примерно такой вот будет код:

# -*- coding: utf-8 -*-
import zlib

st = b'Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму'
for i in range(5000000):
    hex(zlib.adler32(st))[2:]

Средний результат трёх запусков: 2,100 секунды. Лучший результат: 2,072

Нет, это не ошибка и запятая нигде не перепутана. По всей видимости, дело всё в том, что поскольку это часть стандартной библиотеки и по сути просто обёртка над Си-шным GNU zip, то это даёт преимущество в скорости. Иными словами, это сравнивается не Python и Rust, а Cи и Rust. И Си получается немного быстрее.

Выводы

Технология Среднее время, с Лучшее время, с
Node.js 41,601 40,206
Node.js+ffi+Rust 27,882 26,642
Node.js (sha1) 9,737 9,321
Node.js+C++Rust 7,802 7,658
Rust 2,314 2,324
C/Python 2,100 2,072

Автор: Juralis

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js