Ускоряем Node.js с помощью Rust
В последнее время в сети довольно часто упоминается «молодой и перспективный» язык 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
#include
#include
#include
#include
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(result).ToLocalChecked());
free(result);
free(url);
}
NAN_MODULE_INIT(InitAll) {
Nan::Set(
target, New("adler").ToLocalChecked(),
Nan::GetFunction(New(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" : [
"
Там компилятор будет искать заголовочный файл от этого самого nan.
После этого нужно было ещё немного поковырять плюсовый файл. Вот конечная версия, которая у меня таки соизволила скомпилироваться:
#include
#include
#include
#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(result).ToLocalChecked());
free(result);
free(url);
}
NAN_MODULE_INIT(InitAll) {
Nan::Set(
target, New("adler").ToLocalChecked(),
Nan::GetFunction(New(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 |
Комментарии (3)
2 марта 2017 в 18:04
0↑
↓
Иными словами, это сравнивается не Python и Rust, а Cи и Rust. И Си получается немного быстрее.
тут дело не в языках (rust в общем случае не должен уступать по скорости C), а в разных реализациях. Если сравнить реализации, то в rust-овской compress она самая наивная, а в zlib — сильно оптимизированная.
2 марта 2017 в 18:15
0↑
↓
Я пробовал низкоуровневые вещи написать на rust, все же он пока не готов, всплывают некоторые вещи, например проблемы с alloca.
2 марта 2017 в 18:11
+1↑
↓
Интересный опыт, как насчет опубликовать это как npm пакет?