2024 年 10 月 22 日 · #rust · #wasm · #ffi · #performance
目录
在本文中,我将讨论如何逐步将 Rust 引入到用其他语言(如 JavaScript、Python、Java、Go、PHP、Ruby 等)编写的服务器中。主要原因是,当你对服务器进行性能分析后,发现某个热点函数由于 CPU 瓶颈无法满足性能要求,而通常的优化手段(如函数记忆化或改进算法)在这种情况下不可行或无效时,你可能会考虑将该函数的实现替换为用更高效的 CPU 语言(如 Rust)编写的版本。如果你正面临这种情况,那么这篇文章非常适合你。
这些策略按“层级”排序,其中“层级”指的是“Rust 采用的层级”。第一层是完全不使用 Rust,最后一层则是将整个服务器重写为 Rust。
我们将在一个用 JS 编写的 Node.js 示例服务器上应用和基准测试这些策略。不过,这些策略可以推广到任何其他语言或运行时。
Note
本文中所有示例的完整源代码可以在 此仓库 中找到。
假设我们有一个 Node.js 服务器,它有一个 HTTP 端点,该端点接收一个文本字符串作为查询参数,并返回一个 200px × 200px 的 PNG 图像,图像内容是该文本的二维码。
以下是服务器代码:
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 - 要编码的文本
* @returns {Promise<Buffer>|Buffer} - 二维码
*/
module.exports = function generateQrCode(text) {
return QRCode.toBuffer(text, {
type: "png",
errorCorrectionLevel: "L",
width: 200,
rendererOpts: {
// 这些选项在测试中提供了速度和压缩之间的最佳平衡
deflateLevel: 9, // 0 - 9
deflateStrategy: 3, // 1 - 4
},
});
};
我们可以通过以下方式调用该端点:
http://localhost:42069/qrcode?text=https://www.reddit.com/r/rustjerk/top/?t=all
这将正确生成以下二维码 PNG:
现在,让我们在 30 秒内向该服务器发送数万个请求,看看它的表现如何:
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1464 请求/秒 | 68 毫秒 | 96 毫秒 | 1506 字节 | 1353 MB |
由于我还没有描述我的基准测试方法,这些结果本身是没有意义的,我们无法判断这是“好”还是“坏”的性能。这没关系,因为我们不关心具体的数字,我们将使用这些结果作为基线,与所有后续实现进行比较。每个服务器都在相同的环境中进行测试,因此相对比较是准确的。
关于异常高的内存使用量,这是因为我在“集群模式”下运行 Node.js,该模式根据测试机器上的 12 个 CPU 核心生成了 12 个进程,每个进程都是一个独立的 Node.js 实例,这就是为什么即使我们有一个非常简单的服务器,它也会占用 1300+ MB 的内存。JS 是单线程的,所以如果我们希望 Node.js 服务器充分利用多核 CPU,就必须这样做。
对于此策略,我们将热点函数重写为 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,
// 这些选项在测试中提供了速度和压缩之间的最佳平衡
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(())
}
我们可以像这样使用这个 CLI:
qr-cli https://youtu.be/cE0wfjsybIQ?t=74 > crab-rave.png
这将正确生成以下二维码 PNG:
现在,我们更新主服务器中的热点函数以调用此 CLI:
const { spawn } = require("child_process");
const path = require("path");
const qrCliPath = path.resolve(__dirname, "./qr-cli");
/**
* @param {string} text - 要编码的文本
* @returns {Promise<Buffer>} - 二维码
*/
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"));
}
});
});
};
现在让我们看看这个更改对性能的影响:
绝对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1464 请求/秒 | 68 毫秒 | 96 毫秒 | 1506 字节 | 1353 MB |
第 1 层 | 2572 请求/秒 🥇 | 39 毫秒 🥇 | 78 毫秒 🥇 | 778 字节 🥇 | 1240 MB 🥇 |
相对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
第 1 层 | 1.76x 🥇 | 0.57x 🥇 | 0.82x 🥇 | 0.52x 🥇 | 0.92x 🥇 |
哇,我没想到吞吐量会增加 76%!这是一个非常粗糙的策略,所以看到它如此有效真是有趣。平均响应大小也从 1506 字节减少到 778 字节,Rust 库中的压缩算法一定比 JS 库中的更好。我们每秒处理的请求数显著增加,返回的响应大小显著减小,所以我认为这是一个很好的结果。
对于此策略,我们将 Rust 函数编译为 Wasm 模块,然后使用 Wasm 运行时从主服务器加载并运行它。以下是一些不同语言的 Wasm 运行时链接:
语言 | Wasm 运行时 | GitHub 星数 |
---|---|---|
JavaScript | 内置 | - |
多种语言 | Wasmer | 19.2K+ |
多种语言 | Wasmtime | 15.7K+ |
多种语言 | WasmEdge | 8.7K+ |
多种语言 | wasm3 | 7.4k+ |
Go | Wazero | 5.1k+ |
多种语言 | Extism | 4.6k+ |
Java | Chicory | 560+ |
由于我们正在集成到 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} - 二维码
*/
module.exports = function generateQrCode(text) {
return Buffer.from(wasm.generateQrCode(text));
};
更新后的基准测试:
绝对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1464 请求/秒 | 68 毫秒 | 96 毫秒 | 1506 字节 | 1353 MB |
第 1 层 | 2572 请求/秒 | 39 毫秒 | 78 毫秒 | 778 字节 🥇 | 1240 MB 🥇 |
第 2 层 | 2978 请求/秒 🥇 | 34 毫秒 🥇 | 63 毫秒 🥇 | 778 字节 🥇 | 1286 MB |
相对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
第 1 层 | 1.76x | 0.57x | 0.82x | 0.52x 🥇 | 0.92x 🥇 |
第 2 层 | 2.03x 🥇 | 0.50x 🥇 | 0.66x 🥇 | 0.52x 🥇 | 0.95x |
使用 Wasm 使我们的吞吐量比基线翻了一番!然而,与之前调用 CLI 工具的原始策略相比,性能提升比我预期的要小。
总之,虽然 wasm-bindgen
是一个优秀的 JS 到 Rust Wasm 绑定生成器,但对于其他语言(如 Python、Java、Go、PHP、Ruby 等)没有等效的工具。我不想让这些人失望,所以我会解释如何手动编写绑定。免责声明:代码会变得很丑陋,所以除非你真的对底层实现感兴趣,否则你可以跳过下一节。
Wasm 的一个有趣之处在于它只支持四种数据类型:i32
、i64
、f32
和 f64
。然而,对于我们的用例,我们需要将字符串从主机传递给 Wasm 函数,并且 Wasm 函数需要返回一个数组给主机。Wasm 没有字符串或数组。那么我们该如何解决这个问题呢?
答案在于以下几点:
- Wasm 模块的内存由 Wasm 实例和主机共享,两者都可以读取和修改它。
- Wasm 模块最多只能请求 4GB 的内存,因此每个可能的内存地址都可以编码为
i32
,因此该数据类型也用作内存地址指针。
如果我们想将字符串从主机传递给 Wasm 函数,主机必须直接将字符串写入 Wasm 模块的内存,然后传递两个 i32
给 Wasm 函数:一个指向字符串的内存地址,另一个指定字符串的字节长度。
如果我们想将数组从 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)
}
// 在分配文本缓冲区和输出缓冲区后,主机调用此函数以生成二维码 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,
// 错误:无法生成二维码
Err(_) => return 0,
};
if qr_code.len() > output_len {
// 错误:输出缓冲区太小
return 0;
}
// 将生成的二维码 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,但我们需要将其重新编码为 UTF8 再传递给 Wasm 模块
const textEncoder = new TextEncoder();
// 告诉 Wasm 模块为我们分配两个缓冲区:
// - 第一个缓冲区:输入缓冲区,我们将写入 UTF8 字符串,generateQrCode 函数将从中读取
// - 第二个缓冲区:输出缓冲区,generateQrCode 函数将写入二维码 PNG 字节,我们将从中读取
const textMemLen = 1024;
const textMemOffset = qrWasmInstance.exports.alloc(textMemLen);
const outputMemLen = 4096;
const outputMemOffset = qrWasmInstance.exports.alloc(outputMemLen);
/**
* @param {string} text - 要编码的文本
* @returns {Buffer} - 二维码
*/
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("failed to generate qr");
}
// 从 Wasm 内存中读取二维码 PNG 字节并返回
return Buffer.from(
qrWasmInstance.exports.memory.buffer,
outputMemOffset,
wroteBytes
);
};
这就是当我们使用像 wasm-bindgen
这样的库时,底层生成的代码。总之,我对其进行了基准测试,手写绑定的性能与生成的绑定在此情况下的性能几乎相同。
所以,编写主机和 Wasm 模块之间的粘合代码显然不是一件有趣的事情。幸运的是,积极参与 Wasm 规范制定的人们已经意识到这一点,他们目前正在制定“组件模型 Component Model”提案,该提案将标准化一种称为 WIT(Wasm 接口类型, Wasm Interface Type)的 IDL(接口定义语言, Interface Definition Language),绑定生成器和 Wasm 运行时可以围绕它构建。
目前,有一个名为 wit-bindgen
的 Rust 项目,它可以根据 WIT 文件为用 Rust 编写的 Wasm 模块生成粘合代码,但你需要一个单独的工具来生成主机粘合代码,比如 jco
,它可以根据 Wasm 和 WIT 文件生成 JS 粘合代码。
使用 wit-bingen
+ jco
将给你类似于使用 wasm-bindgen
的结果,但希望未来会有更多针对其他语言的 WIT 主机绑定生成器,这样 Python、Java、Go、PHP、Ruby 等程序员就能拥有像 wasm-bindgen
对 JS 程序员那样方便易用的解决方案。
对于此策略,我们将用 Rust 编写函数,将其编译为原生代码,然后从主机运行时加载并执行它。以下是各种语言的 Rust 绑定生成器库表:
语言 | Rust 绑定生成器 | GitHub 星数 |
---|---|---|
Python | pyo3 | 12.7k+ |
JavaScript | napi-rs | 6.3k+ |
Erlang | rustler | 4.4k+ |
多种语言 | uniffi-rs | 3k+ |
Java | jni-rs | 1.3k+ |
Ruby | rutie | 970+ |
PHP | ext-php-rs | 610+ |
多种语言 | diplomat | 560+ |
由于我们的示例服务器是用 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 - 要编码的文本
* @returns {Buffer} - 二维码
*/
module.exports = function generateQrCode(text) {
return Buffer.from(native.generateQrCode(text));
};
现在让我们看看这个家伙能否飞起来:
绝对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1464 请求/秒 | 68 毫秒 | 96 毫秒 | 1506 字节 | 1353 MB |
第 1 层 | 2572 请求/秒 | 39 毫秒 | 78 毫秒 | 778 字节 🥇 | 1240 MB 🥇 |
第 2 层 | 2978 请求/秒 | 34 毫秒 | 63 毫秒 | 778 字节 🥇 | 1286 MB |
第 3 层 | 5490 请求/秒 🥇 | 18 毫秒 🥇 | 37 毫秒 🥇 | 778 字节 🥇 | 1309 MB |
相对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
第 1 层 | 1.76x | 0.57x | 0.82x | 0.52x 🥇 | 0.92x 🥇 |
第 2 层 | 2.03x | 0.50x | 0.66x | 0.52x 🥇 | 0.95x |
第 3 层 | 3.75x 🥇 | 0.26x 🥇 | 0.39x 🥇 | 0.52x 🥇 | 0.97x |
事实证明,原生代码非常快!我们的吞吐量几乎比基线增加了四倍,比 Wasm 实现增加了一倍。
在此策略中,我们将用 Rust 重写主机服务器。诚然,这对于大多数现实世界的情况来说是不切实际的,因为服务器代码库通常有 10 万行以上的代码。在这些情况下,我们可以只重写主机服务器的一部分。如今,大多数人在后端运行的所有内容都位于反向代理之后,因此部署一个新的 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"
)),
}
}
让我们看看它是否名副其实:
绝对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1464 请求/秒 | 68 毫秒 | 96 毫秒 | 1506 字节 | 1353 MB |
第 1 层 | 2572 请求/秒 | 39 毫秒 | 78 毫秒 | 778 字节 🥇 | 1240 MB |
第 2 层 | 2978 请求/秒 | 34 毫秒 | 63 毫秒 | 778 字节 🥇 | 1286 MB |
第 3 层 | 5490 请求/秒 | 18 毫秒 | 37 毫秒 | 778 字节 🥇 | 1309 MB |
第 4 层 | 7212 请求/秒 🥇 | 14 毫秒 🥇 | 27 毫秒 🥇 | 778 字节 🥇 | 13 MB 🥇 |
相对测量值
层级 | 吞吐量 | 平均延迟 | p99 延迟 | 平均响应大小 | 内存 |
---|---|---|---|---|---|
第 0 层 | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
第 1 层 | 1.76x | 0.57x | 0.82x | 0.52x 🥇 | 0.92x |
第 2 层 | 2.03x | 0.50x | 0.66x | 0.52x 🥇 | 0.95x |
第 3 层 | 3.75x | 0.26x | 0.39x | 0.52x 🥇 | 0.97x |
第 4 层 | 4.93x 🥇 | 0.21x 🥇 | 0.28x 🥇 | 0.52x 🥇 | 0.01x 🥇 |
这不是打字错误。Rust 服务器在处理 7200+ 请求/秒时,真的只使用了 13 MB 的内存。我认为它确实名副其实!
我认为所有这些策略都很好,但第 3 层脱颖而出,性价比最高。如果你可以使用现成的绑定生成器库,那么用 Rust 编写原生函数非常容易,并且它可以对性能产生深远的影响。
第 3 层最困难的部分可能是如果你还不了解 Rust,那么学习 Rust 可能会有些困难,但如果你处于这种情况,你应该阅读 2024 年学习 Rust,它将帮助你弄清楚如何开始。
在以下平台讨论本文:
- Common Rust Lifetime Misconceptions
- Tour of Rust's Standard Library Traits
- Beginner's Guide to Concurrent Programming: Coding a Multithreaded Chat Server using Tokio
- Learning Rust in 2024
- Sizedness in Rust
- RESTful API in Sync & Async Rust
- Learn Assembly with Entirely Too Many Brainfuck Compilers
通过以下方式在新博客文章发布时获得通知:
- 订阅此仓库的 releases RSS feed 或
- 关注此仓库的 releases (点击
Watch
→ 点击Custom
→ 选择Releases
→ 点击Apply
)