在非Rust服务器中提升性能的策略与实践

文摘   2024-10-29 09:47   江苏  

unsetunset引言unsetunset

在本文中,我将讨论不同的策略,以逐步将 Rust 引入使用其他语言编写的服务器中,例如 JavaScript、Python、Java、Go、PHP、Ruby 等。你之所以想要这样做,主要是因为你已经对服务器进行了性能分析,发现了一个热点函数,该函数由于 CPU 瓶颈而无法满足你的性能要求,而且由于某种原因,通常的技巧,如记忆函数或改进其算法,在这种情况下不可行或无效。你得出结论,值得研究将函数实现替换为用更高效的 CPU 语言编写的代码,比如 Rust。太好了,那么这篇文章绝对适合你。

这些策略按层次排序,其中“层次”是“Rust 采用层次”的简称。第一层是完全不使用 Rust。最后一层是用 Rust 重写整个服务器。

我们将应用并基准测试的示例服务器将用 JS 实现,在 Node.js 运行时上运行。这些策略可以推广到任何其他语言或运行时。

本文中每个示例的完整源代码都可以在 这个仓库[1] 中找到。

unsetunset策略unsetunset

Tier 0: 不使用 Rust

假设我们有一个 Node.js 服务器,它有一个 HTTP 端点,接受一个文本字符串作为查询参数,并返回一个 200px 乘以 200px 的 PNG 图像,该图像将文本编码为 QR 码。

以下是服务器代码的样子:

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('缺少 "text" 查询参数');
    }

    if (text.length > 512) {
        return res.status(400).send('文本必须 <= 512 字节');
    }

    try {
        const qrCode = await generateQrCode(text);
        res.setHeader('Content-Type''image/png');
        res.send(qrCode);
    } catch (err) {
        res.status(500).send('生成 QR 码失败');
    }
});

app.listen(42069'127.0.0.1');

热点函数看起来像这样:

const QRCode = require('qrcode');

/**
 * @param {string} text - 要编码的文本
 * @returns {Promise<Buffer>|Buffer} - qr 码
 */

module.exports = function generateQrCode(text{
    return QRCode.toBuffer(text, {
        type'png',
        errorCorrectionLevel'L',
        width200,
        rendererOpts: {
            // 这些选项是在测试中选择的,因为它们提供了速度和压缩之间的最佳平衡
            deflateLevel9// 0 - 9
            deflateStrategy3// 1 - 4
        },
    });
}

我们可以通过调用以下方式来访问该端点:

http://localhost:42069/qrcode?text=https://www.reddit.com/r/rustjerk/top/?t=all

这将正确生成这个 QR 码 PNG:

rustjerk 子版块的 QR 码

无论如何,让我们向这个服务器发送数万个请求,持续 30 秒,看看它的性能如何:

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01464 req/sec68 ms96 ms1506 字节1353 MB

由于我还没有描述我的基准测试方法,这些结果本身是没有意义的,我们不能说这是“好”还是“坏”的性能。没关系,因为我们不关心绝对数字,我们将使用这些结果作为基线,与以下所有实现进行比较。每个服务器都在相同的环境下进行测试,所以相对比较将是准确的。

关于异常高的内存使用,这是因为我在“集群模式”下运行 Node.js,它为我的测试机器上的每个 12 个 CPU 核心生成 12 个进程,每个进程都是一个独立的 Node.js 实例,这就是为什么即使我们有一个非常简单的服务器,它也占用了 1300+ MB 的内存。JS 是单线程的,所以如果我们要让 Node.js 服务器充分利用多核 CPU,我们必须这样做。

Tier 1: Rust CLI 工具

对于这个策略,我们用 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)?;
    let img_buf = qr.render::<Luma<u8>>()
        .min_dimensions(200200)
        .build();
    let mut encoded_buf = Vec::with_capacity(512);
    let encoder = PngEncoder::new_with_quality(
        &mut encoded_buf,
        // 这些选项是在测试中选择的,因为它们提供了速度和压缩之间的最佳平衡
        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!("用法: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(())
}

我们可以使用这个 CLI 如下:

qr-cli https://youtu.be/cE0wfjsybIQ?t=74  > crab-rave.png

这将正确生成这个 QR 码 PNG:

crab rave youtube 视频的 QR 码

现在让我们更新主机服务器中的热点函数,以调用这个 CLI:

const { spawn } = require('child_process');
const path = require('path');
const qrCliPath = path.resolve(__dirname, './qr-cli');

/**
 * @param {string} text - 要编码的文本
 * @returns {Promise<Buffer>} - qr 码
 */

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(`生成 qr 码时出错:${data}`));
        });
        qrCli.on('error', (err) => {
            reject(new Error(`启动 qr-cli 失败 ${err}`));
        });
        qrCli.on('close', (code) => {
            if (code === 0) {
                resolve(Buffer.concat(qrCodeData));
            } else {
                reject(new Error('qr-cli 未成功退出'));
            }
        });
    });
};

现在让我们看看这个变化对性能的影响:

绝对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01464 req/sec68 ms96 ms1506 字节1353 MB
Tier 12572 req/sec 🥇39 ms 🥇78 ms 🥇778 字节 🥇1240 MB 🥇

相对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01.00x1.00x1.00x1.00x1.00x
Tier 11.76x 🥇0.57x 🥇0.82x 🥇0.52x 🥇0.92x 🥇

哇,我没想到吞吐量能增加 76%!这是一个非常原始的策略,所以看到它这么有效很有趣。平均响应大小也从 1506 字节减半到 778 字节,Rust 库中的压缩算法必须比 JS 库中的更好。我们每秒提供更多的请求,并返回更小的响应,所以我认为这是一个伟大的结果。

Tier 2: Rust Wasm 模块

对于这个策略,我们将把 Rust 函数编译成 Wasm 模块,然后使用 Wasm 运行时从主机服务器加载和运行它。不同语言的 Wasm 运行时的一些链接:

语言Wasm 运行时Github 星星
JavaScript内置-
JavaGraalWasm[2]20.3k+
多种wasm3[3]7.3k+
GoWazero[4]4.9k+
多种extism[5]4.2k+
Pythonwasmer-python[6]2k+
PHPwasmer-php[7]1k+
Rubywasmer-ruby[8]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 - 要编码的文本
 * @returns {Buffer} - QR 码
 */

module.exports = function generateQrCode(text{
    return Buffer.from(wasm.generateQrCode(text));
};

更新后的基准测试:

绝对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01464 req/sec68 ms96 ms1506 字节1353 MB
Tier 12572 req/sec39 ms78 ms778 字节 🥇1240 MB 🥇
Tier 22978 req/sec 🥇34 ms 🥇63 ms 🥇778 字节 🥇1286 MB

相对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01.00x1.00x1.00x1.00x1.00x
Tier 11.76x0.57x0.82x0.52x 🥇0.92x 🥇
Tier 22.03x 🥇0.50x 🥇0.66x 🥇0.52x 🥇0.95x

使用 Wasm 使吞吐量翻倍,与基线相比!然而,与早期使用 CLI 工具调用的原始策略相比,性能提升比我预期的要小。

无论如何,虽然 wasm-bindgen 是一个优秀的 JS 到 Rust Wasm 绑定生成器,但没有适用于其他语言的等效工具,如 Python、Java、Go、PHP、Ruby 等。我不想让这些人失望,所以我将解释如何手工编写绑定。免责声明:代码会变得丑陋,所以除非你真的对看到香肠是如何制作的感兴趣,否则你可以跳过下一个部分。

手工制作 Wasm 绑定

关于 Wasm 的有趣之处在于,它只支持四种数据类型:i32i64f32f64。然而,对于我们的用例,我们需要从主机传递一个字符串到 Wasm 函数,并且 Wasm 函数需要返回一个数组到主机。Wasm 没有字符串或数组。那么我们如何解决这个问题呢?

答案取决于有几个洞见:

  • Wasm 模块的内存在 Wasm 实例和主机之间共享,两者都可以读取和修改它。
  • Wasm 模块只能请求最多 4GB 的内存,所以每个可能的内存地址都可以编码为 i32,因此这种数据类型也用作内存地址指针。

如果我们想从主机向 Wasm 函数传递一个字符串,主机必须直接将字符串写入 Wasm 模块的内存,然后向 Wasm 函数传递两个 i32:一个指向字符串的内存地址,另一个指定字符串的字节长度。

如果我们想从 Wasm 函数向主机传递一个数组,主机首先需要提供 Wasm 函数一个 i32,指向数组应该被写入的内存地址,然后当 Wasm 函数完成后,它返回一个 i32,表示写入的字节数。

然而,现在我们有了一个新问题:当主机写入 Wasm 模块的内存时,它如何确保不会覆盖 Wasm 模块使用的内存?为了让主机能够安全地写入内存,它必须首先请求 Wasm 模块为其分配空间。

好了,现在所有的上下文都说清楚了,我们终于可以看看这段代码,实际上理解它:

/** qr_wasm/lib.rs **/

use std::{alloc::Layout, mem, slice, str};

// 主机调用此函数以分配空间,其中它可以安全地写入数据
#[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)
}

// 在分配文本缓冲区和输出缓冲区后,主机调用此函数以生成 QR 码 PNG
#[no_mangle]
pub unsafe extern "C" fn generateQrCode(
    text_ptr: *const u8,
    text_len: usize,
    output_ptr: *mut u8,
    output_len: usize,
) -> usize {
    // 从内存中读取文本,主机已将其写入
    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,
        // 错误:无法生成 QR 码
        Err(_) => return 0,
    };

    if qr_code.len() > output_len {
        // 错误:输出缓冲区太小
        return 0;
    }

    // 将生成的 QR 码 PNG 写入输出缓冲区,主机将在此后从此读取
    let output_slice
 = slice::from_raw_parts_mut(output_ptr, qr_code.len());
    output_slice.copy_from_slice(&qr_code);

    // 返回写入的 PNG 数据长度
    qr_code.len()
}

编译这个 Wasm 模块后,以下是我们如何从 JS 使用它:

const path = require('path');
const fs = require('fs');

// 获取 Wasm 文件
const qrWasmPath = path.resolve(__dirname, './qr_wasm.wasm');
const qrWasmBinary = fs.readFileSync(qrWasmPath);

// 实例化 Wasm 模块
const qrWasmModule = new WebAssembly.Module(qrWasmBinary);
const qrWasmInstance = new WebAssembly.Instance(
    qrWasmModule,
    {},
);

// JS 字符串是 UTF16,但我们需要在将它们传递给我们的 Wasm 模块之前将它们重新编码为 UTF8
const textEncoder = new TextEncoder();

// 告诉 Wasm 模块为我们分配两个缓冲区:
// - 第一个缓冲区:一个输入缓冲区,我们将
//              将生成 QR 码函数
//              将读取的 UTF8 字符串写入其中
// - 第二个缓冲区:一个输出缓冲区,生成 QR 码函数将
//              将 QR 码 PNG 字节写入其中,我们将在此后读取
const textMemLen = 1024;
const textMemOffset = qrWasmInstance.exports.alloc(textMemLen);
const outputMemLen = 4096;
const outputMemOffset = qrWasmInstance.exports.alloc(outputMemLen);

/**
 * @param {string} text - 要编码的文本
 * @returns {Buffer} - QR 码
 */

module.exports = function generateQrCode(text{
    // 将 UTF16 JS 字符串转换为 Uint8Array
    let encodedText = textEncoder.encode(text);
    let encodedTextLen = encodedText.length;

    // 将字符串写入 Wasm 内存
    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('未能生成 qr');
    }

    // 从 Wasm 内存中读取 QR 码 PNG 字节并返回
    return Buffer.from(
        qrWasmInstance.exports.memory.buffer,
        outputMemOffset,
        wroteBytes,
    );
};

这就是我们使用像 wasm-bindgen 这样的库时在幕后生成的内容。无论如何,我测试了它,并且手工编写的绑定的性能与生成的绑定的性能几乎相同。

所以编写 Wasm 胶水代码在主机和客户端之间显然不是一件有趣的事情。幸运的是,积极为 Wasm 规范做出贡献的人们意识到了这一点,他们目前正在研究“组件模型”提案,该提案将标准化一个称为 WIT(Wasm 接口类型)的 IDL(接口定义语言),绑定生成器和 Wasm 运行时可以围绕它构建。

目前,有一个名为 wit-bindgen 的 Rust 项目,它将根据 WIT 文件为 Rust 编写的 Wasm 模块生成胶水代码,但你将需要一个单独的工具来生成主机胶水代码,如 jco,它可以在给定 Wasm 和 WIT 文件的情况下生成 JS 胶水代码。

使用 wit-bindgen + jco 将给你一个与仅使用 wasm-bindgen 类似的结果,但希望将来会有更多针对其他语言的 WIT 主机绑定生成器被编写出来,以便 Python、Java、Go、PHP、Ruby 等程序员有一个解决方案,就像今天的 wasm-bindgen 对 JS 程序员一样方便易用。

Tier 3: Rust 原生函数

对于这个策略,我们将在 Rust 中编写函数,将其编译为本地代码,然后从主机运行时加载和执行它。各种语言的 Rust bindgen 库的表格:

语言Rust bindgenGithub 星星
Pythonpyo3[9]12.2k+
JavaScriptnapi-rs[10]6k+
Erlangrustler[11]4.3k+
多种uniffi-rs[12]2.8k+
Javajni-rs[13]1.2k+
Rubyrutie[14]900+
PHPext-php-rs[15]500+
多种diplomat[16]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()))
}

我喜欢它有多容易。在写了前一节的 Rust Wasm 模块之后,我对实现和维护绑定生成器库的人们有了新的认识和尊重。

在构建上述代码后,以下是我们如何从 Node.js 使用它:

const native = require('./qr_napi.node');

/**
 * @param {string} text - 要编码的文本
 * @returns {Buffer} - QR 码
 */

module.exports = function generateQrCode(text{
    return Buffer.from(native.generateQrCode(text));
};

现在让我们看看这个小家伙能不能飞:

绝对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01464 req/sec68 ms96 ms1506 字节1353 MB
Tier 12572 req/sec39 ms78 ms778 字节 🥇1240 MB 🥇
Tier 22978 req/sec34 ms63 ms778 字节 🥇1286 MB
Tier 35490 req/sec 🥇18 ms 🥇37 ms 🥇778 字节 🥇1309 MB

相对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01.00x1.00x1.00x1.00x1.00x
Tier 11.76x0.57x0.82x0.52x 🥇0.92x 🥇
Tier 22.03x0.50x0.66x0.52x 🥇0.95x
Tier 33.75x 🥇0.26x 🥇0.39x 🥇0.52x 🥇0.97x

原来本地代码确实很快!与基线相比,我们的吞吐量几乎翻了两番,与 Wasm 实现相比也翻了一番。

Tier 4: Rust 重写

在这个策略中,我们将用 Rust 重写主机服务器。诚然,对于大多数现实世界的案例来说,这是不切实际的,通常可以看到 100k+ 行服务器代码库。在那些情况下,我们只能重写主机服务器的一个子集。现在大多数人在后端运行所有东西时都会使用反向代理,所以部署一个新的 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"
        )),
    }
}

让我们看看它是否名副其实:

绝对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01464 req/sec68 ms96 ms1506 字节1353 MB
Tier 12572 req/sec39 ms78 ms778 字节 🥇1240 MB
Tier 22978 req/sec34 ms63 ms778 字节 🥇1286 MB
Tier 35490 req/sec18 ms37 ms778 字节 🥇1309 MB
Tier 47212 req/sec 🥇14 ms 🥇27 ms 🥇778 字节 🥇13 MB 🥇

相对测量

层级吞吐量平均延迟p99 延迟平均响应内存
Tier 01.00x1.00x1.00x1.00x1.00x
Tier 11.76x0.57x0.82x0.52x 🥇0.92x
Tier 22.03x0.50x0.66x0.52x 🥇0.95x
Tier 33.75x0.26x0.39x0.52x 🥇0.97x
Tier 44.93x 🥇0.21x 🥇0.28x 🥇0.52x 🥇0.01x 🥇

那不是打字错误。Rust 服务器真的只用了 13 MB 的内存,同时每秒提供 7200+ 请求。我得说它确实名副其实!

unsetunset总结思考unsetunset

我认为所有策略都很好,但 Tier 4 脱颖而出,性价比最高。如果你可以使用现成的绑定生成器库,那么用 Rust 编写原生函数非常容易,并且它对性能有深远的影响。

Tier 4 最难的部分可能是学习 Rust 本身,如果你还不熟悉它,但如果你正处于这种情况下,你应该阅读 2024 年学习 Rust[17].。

参考资料
[1]

这个仓库: https://github.com/pretzelhammer/using-rust-in-non-rust-servers

[2]

GraalWasm: https://github.com/oracle/graal/tree/master/wasm

[3]

wasm3: https://github.com/wasm3/wasm3

[4]

Wazero: https://github.com/tetratelabs/wazero

[5]

extism: https://github.com/extism/extism

[6]

wasmer-python: https://github.com/wasmerio/wasmer-python

[7]

wasmer-php: https://github.com/wasmerio/wasmer-php

[8]

wasmer-ruby: https://github.com/wasmerio/wasmer-ruby

[9]

pyo3: https://github.com/pyo3/pyo3

[10]

napi-rs: https://github.com/napi-rs/napi-rs

[11]

rustler: https://github.com/rusterlium/rustler

[12]

uniffi-rs: https://github.com/mozilla/uniffi-rs

[13]

jni-rs: https://github.com/jni-rs/jni-rs

[14]

rutie: https://github.com/danielpclark/rutie

[15]

ext-php-rs: https://github.com/davidcole1340/ext-php-rs

[16]

diplomat: https://github.com/rust-diplomat/diplomat

[17]

2024 年学习 Rust: ./learning-rust-in-2024.md


编程悟道
自制软件研发、软件商店,全栈,ARTS 、架构,模型,原生系统,后端(Node、React)以及跨平台技术(Flutter、RN).vue.js react.js next.js express koa hapi uniapp Astro
 最新文章