В этой статье мы рассмотрим несколько стратегий по постепенному добавлению Rust в сервер, написанный на другом языке, например JavaScript, Python, Java, Go, PHP, Ruby и т. д. Один из возможных кейсов для подобного добавления — вы профилировали сервер, нашли «горячую» функцию, не соответствующую требованиям производительности из‑за боттлнека по CPU, а обычные техники мемоизации или оптимизации алгоритма были бы невозможны или малоэффективны по той или иной причине. После чего вы пришли к выводу, что стоит посмотреть в сторону реализации данной функции на что‑то написанное на более производительном языке, например на Rust. Отлично, данная статья для вас.
Стратегии расположены по ступеням, где «ступень» — сокращение «ступень на пути к принятию Rust». Первой ступенью будет полное отсутствие Rust в кодовой базе. Последней — полное переписывание сервера на Rust.
В качестве примера, на котором будут производиться опыты и бенчмарки, будет выступать сервер, написанный на JavaScript, рантаймом для него будет Node.js. Несмотря на это, стратегии могут применяться для любого языка или рантайма.
Полный исходный код для каждого из примеров можно найти в этом репозитории.
Стратегии
Ступень 0: Без Rust
Предположим, у нас есть сервер Node.js с HTTP‑эндпоинтом, принимающим строку текста как параметр запроса и возвращающим PNG‑изображение текста, закодированного в виде QR‑кода размером 200 на 200 пикселей.
Код сервера мог бы выглядеть следующим образом:
const express = require('express');
const generateQrCode = require('./generate-qr.js');
const app = express();
app.get('/qrcode', async (req, res) => {
const { text } = req.query;
if (!text) {
return res.status(400).send('missing "text" query param');
}
if (text.length > 512) {
return res.status(400).send('text must be <= 512 bytes');
}
try {
const qrCode = await generateQrCode(text);
res.setHeader('Content-Type', 'image/png');
res.send(qrCode);
} catch (err) {
res.status(500).send('failed generating QR code');
}
});
app.listen(42069, '127.0.0.1');
А вот так выглядит наша «горячая» функция:
const QRCode = require('qrcode');
/**
* @param {string} text - text to encode
* @returns {Promise<Buffer>|Buffer} - qr code
*/
module.exports = function generateQrCode(text) {
return QRCode.toBuffer(text, {
type: 'png',
errorCorrectionLevel: 'L',
width: 200,
rendererOpts: {
// these options were chosen since
// they offered the best balance
// between speed and compression
// during testing
deflateLevel: 9, // 0 - 9
deflateStrategy: 3, // 1 - 4
},
});
};
Мы можем обратиться к эндпоинту, сделав запрос к:
http://localhost:42069/qrcode?text=https://www.reddit.com/r/rustjerk/top/?t=all
Он корректно отдаст нам QR‑код в виде PNG:
Как бы то ни было, давайте отправим десятки тысяч запросов к серверу в течение 30 секунд и посмотрим на производительность:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
Tier 0 |
1464 req/sec |
68 ms |
96 ms |
1506 bytes |
1353 MB |
Поскольку я не описал, как именно выполняется бенчмарк, данные результаты сами по себе не имеют большого смысла, мы не можем сказать «хорошая» или «плохая» ли это производительность. Это нормально, поскольку нас не интересует абсолютный результат, мы будем использовать его в качестве отправной точки и сравнивать результаты будущих реализаций с ним. Каждый сервер тестируется в одном и том же окружении, чтобы относительные сравнения были корректны.
Аномально большое использование памяти связано с запуском Node.js в «режиме кластера», который запускает 12 процессов — по одному на каждое из 12 ядер процессора на тестовой машине, где каждый из них — отдельный процесс Node.js, что и приводит к использованию 1300+ МБ памяти, несмотря на простоту нашего сервера. JS однопоточный, так что это если мы хотим полностью использовать многоядерный процессор, это — необходимое зло.
Ступень 1: CLI-утилита на Rust
В этой реализации мы перепишем горячую функцию на Rust, скомпилируем в качестве CLI‑утилиты и вызовем с нашего сервера.
Начнем с переписывания функции на Rust:
/** qr_lib/lib.rs **/
use qrcode::{QrCode, EcLevel};
use image::Luma;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
pub type StdErr = Box<dyn std::error::Error>;
pub fn generate_qr_code(text: &str) -> Result<Vec<u8>, StdErr> {
let qr = QrCode::with_error_correction_level(text, EcLevel::L)?;
let img_buf = qr.render::<Luma<u8>>()
.min_dimensions(200, 200)
.build();
let mut encoded_buf = Vec::with_capacity(512);
let encoder = PngEncoder::new_with_quality(
&mut encoded_buf,
// these options were chosen since
// they offered the best balance
// between speed and compression
// during testing
CompressionType::Default,
FilterType::NoFilter,
);
img_buf.write_with_encoder(encoder)?;
Ok(encoded_buf)
}
Затем превратим ее в CLI‑утилиту:
/** qr_cli/main.rs **/
use std::{env, process};
use std::io::{self, BufWriter, Write};
use qr_lib::StdErr;
fn main() -> Result<(), StdErr> {
let mut args = env::args();
if args.len() != 2 {
eprintln!("Usage: qr-cli <text>");
process::exit(1);
}
let text = args.nth(1).unwrap();
let qr_png = qr_lib::generate_qr_code(&text)?;
let stdout = io::stdout();
let mut handle = BufWriter::new(stdout.lock());
handle.write_all(&qr_png)?;
Ok(())
}
Мы можем использовать ее следующим образом:
qr-cli https://youtu.be/cE0wfjsybIQ?t=74 > crab-rave.png
Она корректно дает нам следующий QR-код:
Теперь обновим горячую функцию сервера для использование CLI-утилиты:
const { spawn } = require('child_process');
const path = require('path');
const qrCliPath = path.resolve(__dirname, './qr-cli');
/**
* @param {string} text - text to encode
* @returns {Promise<Buffer>} - qr code
*/
module.exports = function generateQrCode(text) {
return new Promise((resolve, reject) => {
const qrCli = spawn(qrCliPath, [text]);
const qrCodeData = [];
qrCli.stdout.on('data', (data) => {
qrCodeData.push(data);
});
qrCli.stderr.on('data', (data) => {
reject(new Error(`error generating qr code: ${data}`));
});
qrCli.on('error', (err) => {
reject(new Error(`failed to start qr-cli ${err}`));
});
qrCli.on('close', (code) => {
if (code === 0) {
resolve(Buffer.concat(qrCodeData));
} else {
reject(new Error('qr-cli exited unsuccessfully'));
}
});
});
};
Давайте посмотрим, как это повлияло на производительность:
Абсолютные замеры
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
Tier 0 |
1464 req/sec |
68 ms |
96 ms |
1506 bytes |
1353 MB |
Tier 1 |
2572 req/sec 🥇 |
39 ms 🥇 |
78 ms 🥇 |
778 bytes 🥇 |
1240 MB 🥇 |
Относительные:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
Tier 0 |
1.00x |
1.00x |
1.00x |
1.00x |
1.00x |
Tier 1 |
1.76x 🥇 |
0.57x 🥇 |
0.82x 🥇 |
0.52x 🥇 |
0.92x 🥇 |
Воу, я не ожидал, что пропускная способность увеличится на 76%! Это очень «пещерный» подход, что делает его эффективность крайне забавной. Средний размер ответа также сократился вдвое, скорее всего алгоритм сжатия в библитеке Rust эффективнее библиотеки JS. Мы обрабатываем значительно больше запросов и возвращаем значительно меньшие по размеру ответы, так что это отличный результат.
Ступень 2: Rust Wasm-модуль
Для этой реализации мы скомпилируем функцию Rust в модуль Wasm, после чего загрузим и выполним его на сервере с использованием рантайма Wasm. Несколько ссылок на рантаймы для различных языков:
Language |
Wasm runtime |
Github stars |
---|---|---|
JavaScript |
built-in |
- |
Java |
20.3k+ |
|
Multiple |
7.3k+ |
|
Go |
4.9k+ |
|
Multiple |
4.2k+ |
|
Python |
2k+ |
|
PHP |
1k+ |
|
Ruby |
500+ |
Поскольку мы интегрируем код в сервер Node.js, воспользуемся wasm‑bindgen
для генерации «клея» для взаимодействия кода Rust Wasm и JS между собой.
Обновленный код Rust:
/** qr_wasm_bindgen/lib.rs **/
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_name = generateQrCode)]
pub fn generate_qr_code(text: &str) -> Result<Vec<u8>, JsError> {
qr_lib::generate_qr_code(text)
.map_err(|e| JsError::new(&e.to_string()))
}
После компиляции кода с использованием wasm-pack
мы можем скопировать артефакты сборки на наш Node.js-сервер и использовать их в нашей горячей функции следующим образом:
const wasm = require('./qr_wasm_bindgen.js');
/**
* @param {string} text - text to encode
* @returns {Buffer} - QR code
*/
module.exports = function generateQrCode(text) {
return Buffer.from(wasm.generateQrCode(text));
};
Обновленные бенчмарки:
Абсолютные:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
---|---|---|---|---|---|
Tier 0 |
1464 req/sec |
68 ms |
96 ms |
1506 bytes |
1353 MB |
Tier 1 |
2572 req/sec |
39 ms |
78 ms |
778 bytes 🥇 |
1240 MB 🥇 |
Tier 2 |
2978 req/sec 🥇 |
34 ms 🥇 |
63 ms 🥇 |
778 bytes 🥇 |
1286 MB |
Относительные:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
---|---|---|---|---|---|
Tier 0 |
1.00x |
1.00x |
1.00x |
1.00x |
1.00x |
Tier 1 |
1.76x |
0.57x |
0.82x |
0.52x 🥇 |
0.92x 🥇 |
Tier 2 |
2.03x 🥇 |
0.50x 🥇 |
0.66x 🥇 |
0.52x 🥇 |
0.95x |
Использование Wasm удвоило пропускную способность по сравнению с отправной точкой! Однако прирост производительности по сравнению с «пещерным» подходом вызова CLI‑утилиты меньше, чем ожидался.
Как бы то ни было, в то время как wasm‑bindgen — отличный генератор «клея» между Rust Wasm и JS, у него нет аналогов для других языков — Python, Java, Go, PHP, Ruby и т. д. Я не хочу обделять тех, кто использует другие языки, так что объясню как писать бинды вручную. Дисклеймер: код будет выглядеть уродливо, так что если вам это не очень интересно, вы можете спокойно пропустить эту секцию.
Написание биндов wasm вручную
В Wasm есть забавный момент — он поддерживает лишь 4 типа данных — i32
, i64
, f32
и f64
. Но для нашей задачи необходимо передать строку с сервера функции Wasm, а Wasm необходимо вернуть массив обратно. В Wasm нет строк или массивов. Так как же нам решить эту проблему?
Ответ заключается в паре деталей:
-
Память модуля Wasm — общая между инстансом Wasm и хостом, оба могут читать ее и записывать в нее.
-
Модуль Wasm может запрашивать до 4 ГБ памяти, так что каждый адрес памяти может быть закодирован в качестве
i32
, поэтому этот тип данных также используется для указателей. Если мы хотим передать строку с хоста в функцию Wasm, хост должен записать строку напрямую в память модуля Wasm и затем передать 2i32
функции Wasm: указатель на адрес в памяти, где расположена строка и длину строки в байтах.
Если мы хотим передать массив из функции Wasm хосту, хост должен передать функции i32
-указатель на адрес памяти, куда должен записываться массив, после чего после завершения выполнения функции она вернет число i32
, обозначающее количество записанных байт.
Однако, теперь есть новая проблема: когда хост записывает данные в память модуля Wasm, как ему убедиться, что он не перезаписывает используемую модулем память? Для обеспечения безопасной записи нам сначала нужно попросить модуль выделить место под это.
Теперь, когда у нас есть весь необходимый контекст, мы можем понять, что происходит в этом коде:
/** qr_wasm/lib.rs **/
use std::{alloc::Layout, mem, slice, str};
// host calls this function to allocate space where
// it can safely write data to
#[no_mangle]
pub unsafe extern "C" fn alloc(size: usize) -> *mut u8 {
let layout = Layout::from_size_align_unchecked(
size * mem::size_of::<u8>(),
mem::align_of::<usize>(),
);
std::alloc::alloc(layout)
}
// after allocating a text buffer and output buffer,
// host calls this function to generate the QR code PNG
#[no_mangle]
pub unsafe extern "C" fn generateQrCode(
text_ptr: *const u8,
text_len: usize,
output_ptr: *mut u8,
output_len: usize,
) -> usize {
// read text from memory, where it was written to by the host
let text_slice = slice::from_raw_parts(text_ptr, text_len);
let text = str::from_utf8_unchecked(text_slice);
let qr_code = match qr_lib::generate_qr_code(text) {
Ok(png_data) => png_data,
// error: unable to generate QR code
Err(_) => return 0,
};
if qr_code.len() > output_len {
// error: output buffer is too small
return 0;
}
// write generated QR code PNG to output buffer,
// where the host will read it from after this
// function returns
let output_slice = slice::from_raw_parts_mut(output_ptr, qr_code.len());
output_slice.copy_from_slice(&qr_code);
// return written length of PNG data
qr_code.len()
}
Вот как мы будем использовать модуль после компиляции:
const path = require('path');
const fs = require('fs');
// fetch Wasm file
const qrWasmPath = path.resolve(__dirname, './qr_wasm.wasm');
const qrWasmBinary = fs.readFileSync(qrWasmPath);
// instantiate Wasm module
const qrWasmModule = new WebAssembly.Module(qrWasmBinary);
const qrWasmInstance = new WebAssembly.Instance(
qrWasmModule,
{},
);
// JS strings are UTF16, but we need to re-encode them
// as UTF8 before passing them to our Wasm module
const textEncoder = new TextEncoder();
// tell Wasm module to allocate two buffers for us:
// - 1st buffer: an input buffer which we'll
// write UTF8 strings into that
// the generateQrCode function
// will read
// - 2nd buffer: an output buffer that the
// generateQrCode function will
// write QR code PNG bytes into
// and that we'll read
const textMemLen = 1024;
const textMemOffset = qrWasmInstance.exports.alloc(textMemLen);
const outputMemLen = 4096;
const outputMemOffset = qrWasmInstance.exports.alloc(outputMemLen);
/**
* @param {string} text - text to encode
* @returns {Buffer} - QR code
*/
module.exports = function generateQrCode(text) {
// convert UTF16 JS string to Uint8Array
let encodedText = textEncoder.encode(text);
let encodedTextLen = encodedText.length;
// write string into Wasm memory
qrWasmMemory = new Uint8Array(qrWasmInstance.exports.memory.buffer);
qrWasmMemory.set(encodedText, textMemOffset);
const wroteBytes = qrWasmInstance.exports.generateQrCode(
textMemOffset,
encodedTextLen,
outputMemOffset,
outputMemLen,
);
if (wroteBytes === 0) {
throw new Error('failed to generate qr');
}
// read QR code PNG bytes from Wasm memory & return
return Buffer.from(
qrWasmInstance.exports.memory.buffer,
outputMemOffset,
wroteBytes,
);
};
Это и есть тот самый код, который генерирует wasm-bindgen
под капотом. Как бы то ни было, я прогнал бенчмарки для него и производительность вручную написанного кода была по сути идентична производительности генерируемого.
Написание клея для взаимодействия Wasm и хоста определенно невесело. Благо, разработчики спецификации Wasm знают об этом и работают над предложением о «модели компонентов», которое стандартизирует IDL (Interface Definition Language) по названием WIT (Wasm Interface Type), который будет использоваться при создании генераторов биндов и рантаймов Wasm.
На данный момент есть проект Rust wit-bindgen
, который может генерировать клей для модулей Wasm, написанных на Rust, если передать файл WIT, однако для генерации клея на стороне хоста потребуется отдельный инструмент, например, jco
, генерирующий JS‑код на основе Wasm и WIT файлов.
Использование wit-bindgen
+ wco
даст похожий на использование wasm-bindgen
результат, но основная надежда на то, что в будущем будут написаны генераторы биндов для хостов для других языков, чтобы у разработчиков Python, Java, Go, PHP, Ruby и т.д было такое же удобное решение, как wasm-bindgen
у JS‑разработчиков сейчас.
Ступень 3: нативная функция на Rust
Для этой реализации мы напишем функцию в Rust, скомпилируем в нативный код и затем загрузим и выполним ее из рантайма хоста.
Таблица генераторов биндов Rust для различных языков:
Поскольку изначальный сервер написан на JS, мы будем использовать napi-rs
. Код на Rust:
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub fn generate_qr_code(text: String) -> Result<Vec<u8>, Status> {
qr_lib::generate_qr_code(&text)
.map_err(|e| Error::from_reason(e.to_string()))
}
Мне нравится эта простота. После написания модуля Wasm с нуля в прошлой секции у меня появилось особое уважение к тем, кто реализует и поддерживает библиотеки генерации биндов.
После сборки кода выше мы можем использовать его в Node.js следующим образом:
const native = require('./qr_napi.node');
/**
* @param {string} text - text to encode
* @returns {Buffer} - QR code
*/
module.exports = function generateQrCode(text) {
return Buffer.from(native.generateQrCode(text));
};
Давайте посмотрим на бенчмарки:
Абсолютные значения:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
---|---|---|---|---|---|
Tier 0 |
1464 req/sec |
68 ms |
96 ms |
1506 bytes |
1353 MB |
Tier 1 |
2572 req/sec |
39 ms |
78 ms |
778 bytes 🥇 |
1240 MB 🥇 |
Tier 2 |
2978 req/sec |
34 ms |
63 ms |
778 bytes 🥇 |
1286 MB |
Tier 3 |
5490 req/sec 🥇 |
18 ms 🥇 |
37 ms 🥇 |
778 bytes 🥇 |
1309 MB |
Относительные:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
---|---|---|---|---|---|
Tier 0 |
1.00x |
1.00x |
1.00x |
1.00x |
1.00x |
Tier 1 |
1.76x |
0.57x |
0.82x |
0.52x 🥇 |
0.92x 🥇 |
Tier 2 |
2.03x |
0.50x |
0.66x |
0.52x 🥇 |
0.95x |
Tier 3 |
3.75x 🥇 |
0.26x 🥇 |
0.39x 🥇 |
0.52x 🥇 |
0.97x |
Оказывается, нативный код быстрый! Кто бы мог подумать. Мы увеличили пропускную способность почти в 4 раза по сравнению с отправной точкой и удвоили по сравнению с Wasm.
Ступень 4: Переписываем на Rust
В этой реализации мы полностью перепишем сервер на Rust. Честно говоря, это не особо практично в большинстве реальных задач с кодовыми базами в сотни тысяч строк. В таких случаях мы могли бы переписать только часть сервера. Сейчас большая часть людей помещает бэкэнд за реверс‑прокси, так что деплой нового сервера на Rust и конфигурация реверс прокси на передачу части запросов на него не вносит большого оверхеда в такие сетапы.
Переписанный на Rust сервер:
/** qr-server/main.rs **/
use std::process;
use axum::{
extract::Query,
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
#[derive(serde::Deserialize)]
struct TextParam {
text: String,
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/qrcode", get(handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:42069")
.await
.unwrap();
println!(
"server {} listening on {}",
process::id(),
listener.local_addr().unwrap(),
);
axum::serve(listener, app).await.unwrap();
}
async fn handler(
Query(param): Query<TextParam>
) -> Result<Response, (StatusCode, &'static str)> {
if param.text.len() > 512 {
return Err((
StatusCode::BAD_REQUEST,
"text must be <= 512 bytes"
));
}
match qr_lib::generate_qr_code(¶m.text) {
Ok(bytes) => Ok((
[(header::CONTENT_TYPE, "image/png"),],
bytes,
).into_response()),
Err(_) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
"failed to generate qr code"
)),
}
}
Ну и стоит ли оно того? Давайте посмотрим:
Абсолютные значения:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
---|---|---|---|---|---|
Tier 0 |
1464 req/sec |
68 ms |
96 ms |
1506 bytes |
1353 MB |
Tier 1 |
2572 req/sec |
39 ms |
78 ms |
778 bytes 🥇 |
1240 MB |
Tier 2 |
2978 req/sec |
34 ms |
63 ms |
778 bytes 🥇 |
1286 MB |
Tier 3 |
5490 req/sec |
18 ms |
37 ms |
778 bytes 🥇 |
1309 MB |
Tier 4 |
7212 req/sec 🥇 |
14 ms 🥇 |
27 ms 🥇 |
778 bytes 🥇 |
13 MB 🥇 |
Относительные значения:
Tier |
Throughput |
Avg Latency |
p99 Latency |
Avg Response |
Memory |
---|---|---|---|---|---|
Tier 0 |
1.00x |
1.00x |
1.00x |
1.00x |
1.00x |
Tier 1 |
1.76x |
0.57x |
0.82x |
0.52x 🥇 |
0.92x |
Tier 2 |
2.03x |
0.50x |
0.66x |
0.52x 🥇 |
0.95x |
Tier 3 |
3.75x |
0.26x |
0.39x |
0.52x 🥇 |
0.97x |
Tier 4 |
4.93x 🥇 |
0.21x 🥇 |
0.28x 🥇 |
0.52x 🥇 |
0.01x 🥇 |
Это не опечатка. Сервер на Rust и правда использовал лишь 13 МБ памяти при обработке 7200+ запросов в секунду. Я считаю, что это точно того стоило!
Заключительные мысли
С моей точки зрения все перечисленные выше подходы имеют право на жизнь и хорошо себя показывают, но лучшим вариантом с точки зрения производительность/затраты это третий вариант. Если вы можете использовать готовую библиотеку для генерации биндов, написание нативной фукнции на Rust достаточно просто и может значительно повлиять на производительность.
Автор: MrPizzly