如何在 Rust 中构建自定义基准测试框架

Everett Pompeii

Everett Pompeii


什么是基准测试?

基准测试是测试代码性能的实践, 以查看它的速度(延迟)或工作量(吞吐量)。 在软件开发中,这一步通常被忽视, 但对于创建和维护快速且高性能的代码至关重要。 基准测试为开发人员提供了必要的指标, 以了解他们的代码在各种工作负载和条件下的表现。 出于与编写单元测试和集成测试以防止功能回归相同的原因, 你应该编写基准测试以防止性能回归。 性能错误也是错误!

在 Rust 中进行基准测试

在 Rust 中进行基准测试的三个热门选项是: libtest bench, Criterion, 和 Iai

libtest 是 Rust 内置的单元测试和基准测试框架。虽然它是 Rust 标准库的一部分,但 libtest bench 仍被认为是不稳定的,因此仅在 nightly 编译器版本中可用。要在稳定版 Rust 编译器上工作,则需要使用一个单独的基准测试工具 。然而,这两者都没有得到积极的开发。

Rust 生态系统中最流行的基准测试工具是 Criterion。它可以在稳定版和 nightly Rust 编译器版本上工作,并且已经成为 Rust 社区内事实上的标准。相比 libtest bench,Criterion 还具有更多的功能。

Criterion 的一个实验性替代品是 Iai,同样出自 Criterion 的创建者。然而,它使用指令计数而不是墙钟时间:CPU 指令、L1 访问、L2 访问和 RAM 访问。这允许一次性基准测试,因为这些指标在不同运行之间应该几乎保持相同。

考虑到所有这些,如果你想要基准测试代码的墙钟时间,那么你可能应该使用Criterion。 如果你想要在CI中基准测试你的代码,并且使用共享运行器,那么可能值得查看一下Iai。 不过请注意,Iai已经超过3年没有更新了。所以你可能考虑使用Iai-Callgrind来代替。

但是如果你不想基准测试墙钟时间或者指令计数呢? 如果你想跟踪一些完全不同的基准测试怎么办‽ 幸运的是,Rust让创建自定义基准测试工具非常简单。

cargo bench 是如何工作的

在构建自定义基准测试框架之前, 我们需要了解 Rust 基准测试的工作原理。 对于大多数 Rust 开发者来说,这意味着运行 cargo bench 命令cargo bench 命令会编译并执行你的基准测试。 默认情况下,cargo bench 会尝试使用内置的(但不稳定的)libtest 基准测试框架。 libtest 基准测试框架会遍历你的代码并运行所有用 #[bench] 属性标注的函数。 为了使用自定义基准测试框架,我们需要告诉 cargo bench 不要使用 libtest 基准测试框架。

使用定制的基准测试工具与 cargo bench

为了让 cargo bench 不使用 libtest bench, 我们需要在我们的 Cargo.toml 文件中添加以下内容:

Cargo.toml
[[bench]]
harness = false

不幸的是,我们不能在自定义基准测试工具中使用 #[bench] 属性。 也许有一天可以,但不是今天。 相反,我们必须创建一个单独的 benches 目录来存放我们的基准测试。 benches 目录对于基准测试来说,就像 tests 目录 对于集成测试一样。 benches 目录中的每个文件都被视为一个独立的 crate。 因此,被测试的 crate 必须是一个库 crate。 也就是说,它必须有一个 lib.rs 文件。

例如,如果我们有一个名为 game 的基本库 crate, 我们可以在 benches 目录中添加一个名为 play_game 的自定义基准测试文件。 我们的目录结构将如下所示:

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

接下来,我们需要让 cargo bench 知道我们的自定义基准测试 crate play_game。 因此我们更新我们的 Cargo.toml 文件:

Cargo.toml
[[bench]]
name = "play_game"
harness = false

编写基准测试代码

在编写性能测试之前,我们需要一些库代码来进行基准测试。对于我们的例子,我们将玩FizzBuzzFibonacci游戏。

FizzBuzzFibonacci的规则如下:

编写一个程序,打印从 1100(包括)的整数:

  • 对于三的倍数,打印 Fizz
  • 对于五的倍数,打印 Buzz
  • 对于既是三的倍数又是五的倍数,打印 FizzBuzz
  • 对于属于斐波那契数列的数字,只打印 Fibonacci
  • 对于其他所有数字,打印该数字

这是我们在 src/lib.rs 中的实现:

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

创建一个自定义基准测试工具

我们将在 benches/play_game.rs 中创建一个自定义基准测试工具。 这个自定义基准测试工具将使用 dhat-rs crate 来测量堆分配情况。 dhat-rs 是一个跟踪 Rust 程序中堆分配的出色工具, 由 Rust 性能专家 Nicholas Nethercote 创建。 为了帮助我们管理基准测试函数, 我们将使用 inventory crate, 由多产的 David Tolnay 创建。

让我们把 dhat-rsinventory 添加到 Cargo.toml 文件的 dev-dependencies 中:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

创建自定义分配器

由于我们自定义的基准测试工具将测量堆分配,因此我们需要使用自定义堆分配器。Rust 允许通过使用 #[global_allocator] 属性 配置自定义的全局堆分配器。在 benches/play_game.rs 的顶部添加以下内容:

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

这告诉 Rust 使用 dhat::Alloc 作为我们的全局堆分配器。

🐰 你一次只能设置 一个 全局堆分配器。 如果你想在多个全局分配器之间切换,它们必须通过 Rust 功能 进行条件编译管理。

创建自定义基准收集器

要创建一个自定义的基准测试工具, 我们需要一种方法来识别和存储我们的基准测试函数。 我们将使用一个名为 CustomBenchmark 的结构体来封装每个基准测试函数。

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

一个 CustomBenchmark 有一个名字和一个返回 dhat::HeapStats 作为其输出的基准测试函数。

然后我们将使用 inventory crate 来为所有的 CustomBenchmark 创建一个集合:

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

创建一个基准测试函数

现在,我们可以创建一个基准测试函数来玩 FizzBuzzFibonacci 游戏:

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

逐行解释:

  • 创建一个基准测试函数,与 CustomBenchmark 所使用的签名相匹配。
  • 创建一个测试模式的 dhat::Profiler, 以从我们的 dhat::Alloc 自定义全局分配器中收集结果。
  • 在“黑箱”中运行我们的 play_game 函数,这样编译器就不会优化我们的代码。
  • 1 迭代到 100(包括100)。
  • 对于每个数字,调用 play_game,并将 print 设置为 false
  • 返回我们的堆分配统计数据作为 dhat::HeapStats

🐰 我们将 play_game 函数的 print 设置为 false。 这样可以防止 play_game 打印到标准输出。 通过这种方式参数化你的库函数,可以使它们更适合于基准测试。 然而,这意味着我们可能并不是以与生产环境完全相同的方式对库进行基准测试。

在这种情况下,我们需要问自己:

  1. 我们是否关心打印到标准输出所花费的资源?
  2. 打印到标准输出是否是噪音的潜在来源?

对于我们的示例,我们选择:

  1. 不,我们不关心打印到标准输出。
  2. 是的,这很可能是噪音的来源。

因此,我们省略了将打印到标准输出作为此基准测试的一部分。 基准测试是很难的,对于这样的问题往往没有一个正确答案。 视情况而定

注册基准函数

编写好基准函数后,我们需要创建一个 CustomBenchmark 并使用 inventory 将其注册到我们的基准集合中。

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

如果我们有多个基准测试,我们会重复相同的过程:

  1. 创建一个基准函数。
  2. 为基准函数创建一个 CustomBenchmark
  3. 使用 inventory 集合注册 CustomBenchmark

创建自定义基准测试运行器

最后,我们需要为自定义基准测试工具创建一个运行器。 自定义基准测试工具其实就是一个二进制文件, 它为我们运行所有的基准测试并报告其结果。 而基准测试运行器就是负责协调所有这些工作的。

我们希望将结果输出为 Bencher Metric Format (BMF) JSON。 为了实现这一点,我们需要添加最后一个依赖项, 由David Tolnay创建的 serde_json crate

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

接下来,我们将为 CustomBenchmark 实现一个方法来运行其基准测试函数 然后返回BMF JSON格式的结果。

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

BMF JSON结果包含每个基准测试的六个衡量标准

  • 最终块数:基准测试完成时分配的最终块数。
  • 最终字节数:基准测试完成时分配的最终字节数。
  • 最大块数:基准测试运行期间同时分配的最大块数。
  • 最大字节数:基准测试运行期间同时分配的最大字节数。
  • 总块数:基准测试期间分配的总块数。
  • 总字节数:基准测试期间分配的总字节数。

最后,我们可以创建一个 main 函数来运行 inventory 集合中的所有基准测试 并将结果输出为BMF JSON。

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

运行自定义基准测试工具

一切都准备就绪。 我们终于可以运行自定义基准测试工具了。

Terminal window
cargo bench

输出到标准输出和名为 results.json 的文件的内容应如下所示:

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

你看到的确切数字可能会根据你的计算机架构略有不同。 但重要的是你至少应该有最后四个指标的一些值。

跟踪自定义基准测试结果

大多数基准测试结果都是临时的。 当终端滚动到其回滚限制时,它们就会消失。 有些基准测试工具允许您缓存结果,但实现起来非常麻烦。 即使这样,我们也只能本地存储结果。 幸运的是,我们的自定义基准测试工具可以与 Bencher 一起工作! Bencher 是一套连续基准测试工具, 它允许我们随着时间的推移跟踪基准测试的结果, 并在性能回归 进入生产环境之前 就捕获它们。

一旦您使用 Bencher CloudBencher Self-Hosted 完成设置, 您可以通过运行以下命令来跟踪我们的自定义基准测试工具的结果:

Terminal window
bencher run --file results.json "cargo bench"

您还可以阅读更多关于如何使用 Bencher 跟踪自定义基准测试JSON 基准测试适配器 的信息。

总结

我们在本文开始时,探讨了Rust生态系统中最流行的三种基准测试工具: libtest benchCriterion, 和 Iai。 尽管它们可能涵盖了大多数用例, 有时候您可能需要测量不同于挂钟时间或指令计数的东西。 这使我们走上了创建自定义基准测试工具的道路。

我们的自定义基准测试工具使用 dhat-rs 测量堆分配。 基准测试函数使用 inventory 收集。 运行时,我们的基准测试会以Bencher Metric Format (BMF) JSON格式输出结果。 然后我们可以使用Bencher跟踪自定义基准测试结果, 并在CI中捕捉性能回归。

本指南的所有源代码都可在 GitHub 上获得

Bencher: 持续性能基准测试

🐰 Bencher

Bencher是一套持续型的性能基准测试工具。 你是否曾经因为性能回归影响到了你的用户? Bencher可以防止这种情况的发生。 Bencher让你有能力在性能回归进入生产环境 之前 就进行检测和预防。

  • 运行: 使用你喜爱的基准测试工具在本地或CI中执行你的基准测试。bencher CLI简单地包装了你现有的基准测验设备并存储其结果。
  • 追踪: 追踪你的基准测试结果的趋势。根据源分支、测试床和度量,使用Bencher web控制台来监视、查询和绘制结果图表。
  • 捕获: 在CI中捕获性能回归。Bencher使用最先进的、可定制的分析技术在它们进入生产环境之前就检测到性能回归。

基于防止功能回归的原因,在CI中运行单元测试,我们也应该使用Bencher在CI中运行基准测试以防止性能回归。性能问题就是错误!

开始在CI中捕捉性能回归 — 免费试用Bencher Cloud

🤖 该文档由 OpenAI GPT-4 自动生成。 它可能不准确并且可能包含错误。 如果您发现任何错误,请在 GitHub 上提出问题.