Использование Rust в серверах, написанных на других языках, для повышения производительности

от автора

В этой статье мы рассмотрим несколько стратегий по постепенному добавлению 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

GraalWasm

20.3k+

Multiple

wasm3

7.3k+

Go

Wazero

4.9k+

Multiple

extism

4.2k+

Python

wasmer-python

2k+

PHP

wasmer-php

1k+

Ruby

wasmer-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 и затем передать 2 i32 функции 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 для различных языков:

Language

Rust bindgen

Github stars

Python

pyo3

12.2k+

JavaScript

napi-rs

6k+

Erlang

rustler

4.3k+

Multiple

uniffi-rs

2.8k+

Java

jni-rs

1.2k+

Ruby

rutie

900+

PHP

ext-php-rs

500+

Multiple

diplomat

500+

Поскольку изначальный сервер написан на 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(&param.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 достаточно просто и может значительно повлиять на производительность.


ссылка на оригинал статьи https://habr.com/ru/articles/854458/