Deno 基准测试工具 deno_bench_util:为 deno_core 的 op 编写性能基准与火焰图剖析指南
本文围绕 tests/bench_util 目录下的 deno_bench_util 基准测试工具包展开,讲解它如何帮助开发者针对 deno_core 的 op(operations)系统编写高性能基准测试:从最小可用的示例入手,深入其 JS 运行时创建、循环展开、同步/异步两种 bench 入口,以及 bench_or_profile! 宏支持的“基准测试与 CPU 剖析二合一”模式,并给出仓库中真实 bench 用例(如 UTF-8 编解码、TextEncoder/TextDecoder、op 调用开销)的完整实现剖析。读完后你可以直接在任意 Deno crate 中搭建可复现、可剖析的 JS 侧性能基准。
这个 crate 解决什么问题
在 Deno 中,Rust 侧通过 #[op2] 宏定义的 op 会被暴露给 JS 侧的 Deno.core.ops 命名空间。评估一次 JS→Rust 调用的真实开销,需要把 JsRuntime 的构建、op 的注册、JS 脚本的编译与循环执行全部串起来手写一遍——重复且易错。deno_bench_util(crate 名 deno_bench_util,见 tests/bench_util/Cargo.toml)把这套流程封装成几个函数和一个宏,让基准代码只需关注“跑什么 JS”。
从 tests/bench_util/lib.rs 可以看到整个 crate 的对外导出只有三行核心内容:
mod js_runtime;
mod profiling;
pub use bencher;
pub use js_runtime::*;
pub use profiling::*; // Exports bench_or_profile! macro
bencher:直接 re-export 第三方benchercrate(Rust 侧的标准 bench harness),Bencher、benchmark_group!等均来自它;js_runtime::*:即 tests/bench_util/js_runtime.rs,提供运行时创建与bench_js_sync/bench_js_async等入口;profiling::*:即 tests/bench_util/profiling.rs,提供bench_or_profile!宏与PROFILING模式。
依赖项只有三个(见 tests/bench_util/Cargo.toml):bencher、deno_core、tokio。该 crate publish = true,会被 Deno 仓库内的 ext/* 各扩展与 libs/core 的 benches 以 workspace 依赖方式引用。
快速开始:一个最小的 op 基准
下面是 tests/bench_util/README.md 中给出的完整示例,它展示了使用 deno_bench_util 的标准姿势:定义一个 #[op2] op,写一个 setup() 返回 Vec<Extension>,然后用 bench_js_sync 对 Deno.core.ops.op_nop() 的一次 JS 侧调用计时。
use deno_bench_util::bench_js_sync;
use deno_bench_util::bench_or_profile;
use deno_bench_util::bencher::Bencher;
use deno_bench_util::bencher::benchmark_group;
use deno_core::Extension;
#[op2]
#[number]
fn op_nop() -> usize {
9
}
fn setup() -> Vec<Extension> {
vec![Extension {
name: "my_ext",
ops: std::borrow::Cow::Borrowed(&[op_nop::DECL]),
}]
}
fn bench_op_nop(b: &mut Bencher) {
bench_js_sync(b, r#"Deno.core.ops.op_nop();"#, setup);
}
benchmark_group!(benches, bench_op_nop);
bench_or_profile!(benches);
几个要点:
setup()是扩展注入点。deno_core中 op 必须挂在某个Extension上才会注册进运行时;示例里用了结构体字面量构造Extension并借用op_nop::DECL(op2宏会为每个 op 生成DECL声明项)。更常见的写法是用deno_core::extension!宏(见下文 UTF-8 用例),可以一次注入 ops、JS 源码和 state。- bench 函数签名固定为
fn(&mut Bencher),内部把b交给bench_js_sync,由工具包接管计时循环。 benchmark_group!(benches, ...)生成一个返回Vec<TestDescAndFn>的benches()分组函数;bench_or_profile!(benches)生成fn main(),根据环境变量自动选择跑基准还是跑剖析(原理见下文“剖析模式”一节)。注意该宏自行实现了main,所以对应[[bench]]需要harness = false。
运行方式与普通 cargo bench 一致,例如在该 crate 目录内:
cargo bench # 跑基准
PROFILING=1 cargo bench # 进入单次执行的剖析模式
cargo bench -- utf8_encode_12_kb # 按名字过滤单个 bench
过滤逻辑来自 tests/bench_util/profiling.rs:bench_or_profile! 展开的 main 会取第一个非 --bench 的命令行参数作为 TestOpts::filter(第 19~22 行),随后 bencher::run_tests_console 只跑名称包含该过滤串的用例。
源码剖析:运行时创建、循环展开与 BenchOptions
create_js_runtime:每次 bench 一个全新运行时
tests/bench_util/js_runtime.rs 中:
pub fn create_js_runtime(setup: impl FnOnce() -> Vec<Extension>) -> JsRuntime {
JsRuntime::new(RuntimeOptions {
extensions: setup(),
module_loader: None,
..Default::default()
})
}
要点:
extensions: setup()直接把调用方传入的扩展列表装进RuntimeOptions,因此 setup 里挂了哪些扩展,JS 侧就能看到哪些Deno.core成员(ops、内联 JS 等);module_loader: None表明基准只执行execute_script/脚本编译,不涉及 ESM 模块加载——需要 import 语义的 bench 应像 ext/web/benches/encoding.rs 那样在扩展里写esm入口,而不是依赖模块加载器。
loop_code 与迭代次数:让每次计时“有肉”
tests/bench_util/js_runtime.rs 用一个字符串模板把待测源码包进 for 循环:
fn loop_code(iters: u64, src: &str) -> String {
format!(r#"for(let i=0; i < {iters}; i++) {{ {src} }}"#)
}
这是典型的内层循环放大技巧:单次 op 调用可能在纳秒级,直接 b.iter 每次跑一条语句会放大计时噪声与 JS 引擎调用开销,内层循环 1000 次(默认值)后每次外层迭代就是一个可稳定计时的量。
BenchOptions:基准与剖析使用不同的迭代预算
tests/bench_util/js_runtime.rs 定义了控制迭代次数的结构体:
#[derive(Copy, Clone)]
pub struct BenchOptions {
pub benching_inner: u64, // 默认 1_000:非剖析模式下每次 b.iter 内层循环次数
pub profiling_inner: u64, // 默认 1_000:剖析模式的内层循环次数
pub profiling_outer: u64, // 默认 10_000:剖析模式的“执行批次数”
}
bench_js_sync_with(tests/bench_util/js_runtime.rs)中二者的组合逻辑值得细看:
// Increase JS iterations if profiling for nicer flamegraphs
let inner_iters = if is_profiling() {
opts.profiling_inner * opts.profiling_outer
} else {
opts.benching_inner
};
let looped_src = loop_code(inner_iters, src);
let code = v8::String::new(scope, looped_src.as_ref()).unwrap();
let script = v8::Script::compile(scope, code, None).unwrap();
// Run once if profiling, otherwise regular bench loop
if is_profiling() {
script.run(scope).unwrap();
} else {
b.iter(|| {
script.run(scope).unwrap();
});
}
- 基准模式:JS 侧循环
benching_inner次(默认 1000),由bencher的b.iter在 Rust 侧再循环足够多次做统计——两级循环各司其职; - 剖析模式:一次性执行
profiling_inner * profiling_outer(默认 10 万次)条语句。注释说明这是为了让采样型 profiler 采到足够多的样本、画出更漂亮的火焰图(flamegraph);剖析时不走b.iter的统计循环,只跑一次。 - 脚本通过
v8::Script::compile只编译一次,随后反复run,避免把编译时间计入基准。
bench_js_async:把异步 op 放进 tokio 单线程运行时
对涉及 op2(async) 的用例,tests/bench_util/js_runtime.rs 提供 bench_js_async / bench_js_async_with:
let mut runtime = create_js_runtime(setup);
let tokio_runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let looped = loop_code(inner_iters, src);
// Get a &'static str by leaking -- this is fine because it's benchmarking code
let src = Box::leak(looped.into_boxed_str());
if is_profiling() {
for _ in 0..opts.profiling_outer {
tokio_runtime.block_on(inner_async(src, &mut runtime));
}
} else {
b.iter(|| {
tokio_runtime.block_on(inner_async(src, &mut runtime));
});
}
async fn inner_async(src: &'static str, runtime: &mut JsRuntime) {
runtime.execute_script("inner_loop", src).unwrap();
runtime
.run_event_loop(PollEventLoopOptions::default())
.await
.unwrap();
}
与同步版的关键差异:
- 每轮迭代都跑完整事件循环:
execute_script只是把 JS 任务调度起来,异步 op 真正完成还需要run_event_loop轮询 op 回调。基准测的是“JS 发起 + Rust 执行 + 事件循环完成”的端到端耗时,这正是异步 op 的真实成本构成。 new_current_thread单线程 tokio runtime:刻意避免多线程带来的调度噪声,保证计时可复现。Box::leak换取&'static str:注释直言“这是基准代码,泄漏可以接受”——execute_script需要静态生命周期参数,基准进程本来就短命。- 剖析模式的批次不同:同步版一次跑完
inner * outer条语句;异步版则是重复profiling_outer次“执行 + 事件循环”,每批内含profiling_inner次内层循环——因为每批都要等事件循环排空,无法像同步版那样一次性展开成一个巨大的 for 循环。
bench_or_profile! 宏:基准与剖析共用一套代码
tests/bench_util/profiling.rs 是整个 crate 的“调度核心”。is_profiling() 的判断非常简单:
pub fn is_profiling() -> bool {
std::env::var("PROFILING").is_ok()
}
即只要环境变量 PROFILING 存在(取值任意),所有 bench 函数就会自动切换到剖析路径。bench_or_profile! 宏(第 11~40 行)展开后的 main 大致做了三件事:
- 解析命令行第一个非
--bench参数,填入TestOpts::filter,实现按名称过滤; - 收集各
benchmark_group!生成的 bench 列表; - 根据
is_profiling()二选一:
if $crate::is_profiling() {
// Run profiling
$crate::run_profiles(&test_opts, benches);
} else {
// Run benches
run_tests_console(&test_opts, benches).unwrap();
}
剖析路径 run_profiles(第 43~53 行)对每个(过滤后剩下的)用例打印 Profiling <名字>,然后调用 run_profile 用 bencher::bench::run_once 执行该用例恰好一次:
fn run_profile(test: TestDescAndFn) {
match test.testfn {
DynBenchFn(bencher) => {
bencher::bench::run_once(|harness| bencher.run(harness));
}
StaticBenchFn(benchfn) => {
bencher::bench::run_once(benchfn);
}
}
}
这套设计的实际效果是:同一段 bench 代码,cargo bench 给出吞吐统计,PROFILING=1 下则变成一次可被 cargo flamegraph 或 perf 采样采样的长执行。过滤函数 filter_tests(第 67~86 行)按名称子串过滤并按字母序排序,保证每次剖析的用例顺序稳定。
真实用例一:UTF-8 编解码基准(benches/utf8.rs)
tests/bench_util/benches/utf8.rs 是 deno_bench_util 自带的基准,演示了如何用 deno_core::extension! 注入 JS 侧测试数据,并用 bench_js_sync_with 为不同数据规模分别调参。
测试数据准备(第 10~26 行):扩展 bench_setup 内联了一段 JS,构造 12 B、约 12 kB、约 12 MB 三档字符串及其 Deno.core.encode 的字节结果:
fn setup() -> Vec<Extension> {
deno_core::extension!(
bench_setup,
js = ["ext:bench_setup/setup.js" = {
source = r#"
const hello = "hello world\n";
const hello1k = hello.repeat(1e3);
const hello1m = hello.repeat(1e6);
const helloEncoded = Deno.core.encode(hello);
const hello1kEncoded = Deno.core.encode(hello1k);
const hello1mEncoded = Deno.core.encode(hello1m);
"#
}]
);
vec![bench_setup::init()]
}
六个 bench 用例覆盖 encode/decode 两个方向 × 三档数据规模。小数据量用例把 benching_inner 调成 1(12 字节的编码本来就很便宜,放大 1000 次反而失真):
fn bench_utf8_encode_12_b(b: &mut Bencher) {
bench_js_sync_with(
b,
r#"Deno.core.encode(hello);"#,
setup,
BenchOptions {
benching_inner: 1,
..Default::default()
},
);
}
而 12 MB 大对象用例同时调小了剖析参数(第 52~63 行):profiling_inner: 10, profiling_outer: 10。原因是 12 MB 字符串每轮拷贝/编码成本很高,1000 × 10000 = 一千万次 的默认剖析量会长时间不返回,缩小批次让剖析也能在合理时间内完成。这个对照非常实用:bench_js_sync_with 的第四参数就是为“不同数据规模需要不同迭代预算”准备的逃生口,而 bench_js_sync 等价于用 Default::default() 调它(tests/bench_util/js_runtime.rs)。
最后照例用宏收口:
benchmark_group!(
benches,
bench_utf8_encode_12_b,
bench_utf8_encode_12_kb,
bench_utf8_encode_12_mb,
bench_utf8_decode_12_b,
bench_utf8_decode_12_kb,
bench_utf8_decode_12_mb,
);
bench_or_profile!(benches);
对应的 tests/bench_util/Cargo.toml 中登记了 [[bench]] name = "utf8" harness = false——harness = false 是必须的,因为 bench_or_profile! 自带 main,不能与 cargo 默认的 bench harness 共存。
真实用例二:在扩展 crate 中衡量 Web API 与 op
这套工具包在仓库内的主要消费者是各 ext/* crate 的 benches,例如 ext/web/benches/encoding.rs(TextEncoder/TextDecoder)、ext/web/benches/timers_ops.rs、ext/web/benches/url_ops.rs、ext/fetch/benches/headers_methods.rs 等,以及 libs/core/benches/ops/sync.rs 和 libs/core/benches/ops/async.rs 中成体系的 op 调用开销基准(op_void、op_string、op_buffer 等 fast/nofast 变体)。
以 ext/web/benches/encoding.rs 为例,它展示了“被测 API 不是裸 op 而是完整 Web 扩展”时的标准搭法:
setup()用deno_core::extension!声明esm_entry_point = "ext:bench_setup/setup",入口 JS 里import { TextDecoder, TextEncoder } from "ext:deno_web/08_text_encoding.js"并挂到globalThis,同时预先把各档测试数据编码成Uint8Array(globalThis.hello12k = Deno.core.encode("hello world\n".repeat(1e3)););- 还特意放了一条非 ASCII 数据(4 字节 emoji 混合中文,约 2.4 kB),注释写明
// exercises the non-ASCII path,覆盖 UTF-8 多字节路径; setup()返回deno_webidl::init()、deno_web::init(...)与bench_setup::init()三个扩展——被测对象依赖哪些扩展,就得在运行时里装齐;- bench 用例本身一行:
bench_js_sync(b, r#"new TextDecoder().decode(hello12k);"#, setup);,另有dec.decode(...)复用解码器的对照组,用于区分“构造开销”与“解码开销”。
这说明 deno_bench_util 的抽象层次足够高:setup 闭包里可以装任意多个、任意复杂度的扩展组合,工具包只负责运行时创建与计时循环。
使用要点小结
结合 tests/bench_util/README.md、js_runtime.rs 与 profiling.rs,把一份新的 JS 侧基准写进任意 Deno crate 的步骤与注意事项如下:
- 依赖:在
Cargo.toml加deno_bench_util(workspace 依赖),并声明[[bench]] ... harness = false; - 写
setup():优先用deno_core::extension!宏注入 ops(ops = [...]或Cow::Borrowed(&[op::DECL]))、内联 JS(js = [...])、ESM 入口(esm = [...])与state;需要 Web API 时把对应ext扩展一并返回; - 选入口:同步 op / 纯 JS 逻辑用
bench_js_sync(需要调迭代参数用bench_js_sync_with);异步 op 用bench_js_async(每轮迭代都会完整跑run_event_loop,测的是端到端成本); - 调
BenchOptions:默认benching_inner = 1000;数据量大或单次成本高的用例应调小,反之可放大;剖析量按profiling_inner × profiling_outer(同步)或profiling_outer批(异步)估计执行时长; - 收口:
benchmark_group!(benches, ...)+bench_or_profile!(benches); - 运行:
cargo bench出统计结果;cargo bench -- <过滤串>只跑指定用例;PROFILING=1 cargo bench切到单次长执行模式,配合外部采样器拿火焰图。
需要提醒的边界:这套工具假设 bench 进程是短命进程,因此 bench_js_async_with 中 Box::leak 泄漏字符串、剖析模式单次执行等行为都是刻意为基准/剖析场景服务的,不适合作为长期运行代码的参考;基准测的是当前机器上 V8 + Rust 的相对性能,跨环境比较结论需谨慎。
参考文件索引
| 文件 | 内容 |
|---|---|
| tests/bench_util/README.md | 最小示例:op_nop 基准完整代码 |
| tests/bench_util/lib.rs | crate 导出结构(bencher / js_runtime / profiling) |
| tests/bench_util/Cargo.toml | 依赖(bencher、deno_core、tokio)与 [[bench]] utf8 harness = false |
| tests/bench_util/js_runtime.rs | create_js_runtime、BenchOptions、bench_js_sync(_with)、bench_js_async(_with) 实现 |
| tests/bench_util/profiling.rs | is_profiling、bench_or_profile! 宏、run_profiles 单次执行剖析 |
| tests/bench_util/benches/utf8.rs | UTF-8 encode/decode 三档数据规模的完整 bench 用例 |
| ext/web/benches/encoding.rs | 在扩展 crate 中 bench Web API 的真实示例 |
| libs/core/benches/ops/sync.rs、libs/core/benches/ops/async.rs | deno_core op 调用开销(fast/nofast 等变体)的基准体系 |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00