Everett Pompeii
什么是基准测试?
基准测试(Benchmarking)是一种测试代码性能的做法,用于衡量代码运行速度(延迟)或能完成多少工作量(吞吐量)。这一步骤在软件开发中常常被忽视,但对于创建和维护快速、高性能的代码却至关重要。基准测试为开发者提供了必要的指标,帮助他们理解代码在各种负载和条件下表现如何。
正如你编写单元测试和集成测试以防止功能回归一样,你也应该编写基准测试来防止性能退化。性能缺陷也是缺陷!
用 Rust 编写 FizzBuzz
为了编写基准测试,我们需要一些待测的源代码。我们先从一个非常简单的程序开始:FizzBuzz。
FizzBuzz 的规则如下:
- 编写一个程序,打印从 1 到 100(含)的整数:
- 如果是 3 的倍数,打印
Fizz - 如果是 5 的倍数,打印
Buzz - 如果同时是 3 和 5 的倍数,打印
FizzBuzz - 其他情况,打印该数字
- 如果是 3 的倍数,打印
实现 FizzBuzz 的方式有很多。这里我们采用我最喜欢的一种:
fn main() {
for i in 1..=100 {
match (i % 3, i % 5) {
(0, 0) => println!("FizzBuzz"),
(0, _) => println!("Fizz"),
(_, 0) => println!("Buzz"),
(_, _) => println!("{i}"),
}
}
}
代码说明:
- 创建一个
main函数 - 从 1 到 100(含)进行迭代
- 对每个数字,分别计算除以 3 和 5 的余数(模运算)
- 对这两个余数进行模式匹配:
- 如果两个余数都是 0,则该数是 3 和 5 的公倍数 → 打印
FizzBuzz - 如果只有第一个余数是 0 → 打印
Fizz - 如果只有第二个余数是 0 → 打印
Buzz - 否则,打印该数字本身
- 如果两个余数都是 0,则该数是 3 和 5 的公倍数 → 打印
按步骤操作
要跟随本教程操作,你需要先安装 Rust。
🐰 本文的源代码可在 GitHub 上找到。
安装好 Rust 后,在终端中执行:
cargo init game
然后进入新创建的 game 目录:
game
├── Cargo.toml
└── src
└── main.rs
你应该看到一个名为 src 的目录,其中包含 main.rs 文件,内容如下:
fn main() {
println!("Hello, world!");
}
将其替换为上面的 FizzBuzz 实现,然后运行 cargo run。输出应类似:
$ cargo run
Compiling playground v0.0.1 (/home/bencher)
Finished dev [unoptimized + debuginfo] target(s) in 0.44s
Running `target/debug/game`
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
...
97
98
Fizz
Buzz
🐰 恭喜!你已经通过了编程面试!
此时应已生成一个新的 Cargo.lock 文件:
game
├── Cargo.lock
├── Cargo.toml
└── src
└── main.rs
在继续之前,有必要先讨论 微基准测试(micro-benchmarking) 和 宏基准测试(macro-benchmarking) 的区别。
微基准测试 vs 宏基准测试
软件基准测试主要分为两类:微基准测试 和 宏基准测试。
- 微基准测试 类似于单元测试。例如,对一个函数进行基准测试,该函数仅判断单个数字应输出
Fizz、Buzz还是FizzBuzz,这就是微基准测试。 - 宏基准测试 类似于集成测试。例如,对一个函数进行基准测试,该函数完整运行从 1 到 100 的整个 FizzBuzz 游戏,这就是宏基准测试。
通常,最好在尽可能低的抽象层级上进行测试。就基准测试而言,这样做既便于维护,也有助于减少测量中的噪声。然而,正如端到端测试对于验证整个系统是否按预期协同工作非常有用一样,宏基准测试对于确保软件的关键路径保持高性能也非常有价值。
Rust 中的基准测试
Rust 中有三种流行的基准测试选项:libtest bench、Criterion 和 Iai。
- libtest 是 Rust 内置的单元测试和基准测试框架。虽然属于标准库,但其
bench功能仍被视为不稳定,因此仅在 nightly 编译器版本中可用。若想在稳定版 Rust 上工作,则需使用独立的基准测试框架。目前两者均未积极开发。 - Criterion 是 Rust 生态中最流行的基准测试框架。它同时支持稳定版和 nightly 版 Rust 编译器,并已成为 Rust 社区的事实标准。相比 libtest bench,Criterion 功能更丰富。
- Iai 是 Criterion 作者开发的一个实验性替代方案。但它不测量挂钟时间(wall clock time),而是使用指令计数:CPU 指令数、L1 访问、L2 访问和 RAM 访问。这使得它可以进行单次运行基准测试,因为这些指标在多次运行中几乎完全一致。
这三种工具都受到 Bencher 的支持。那么为什么选择 Criterion?因为它是 Rust 社区的事实标准基准测试框架。建议使用 Criterion 来测量代码的延迟(latency),即挂钟时间。
重构 FizzBuzz
为了对 FizzBuzz 应用进行测试,我们需要将逻辑与 main 函数解耦。基准测试框架无法直接对 main 函数进行基准测试。为此,我们需要做几处修改。
在 src 目录下创建一个新文件 lib.rs:
game
├── Cargo.lock
├── Cargo.toml
└── src
├── lib.rs
└── main.rs
在 lib.rs 中添加以下代码:
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz(n);
if print {
println!("{result}");
}
}
pub fn fizz_buzz(n: u32) -> String {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
play_game:接收一个无符号整数n,调用fizz_buzz并根据print参数决定是否打印结果。fizz_buzz:接收一个无符号整数n,执行实际的 Fizz/Buzz/FizzBuzz/数字逻辑,并返回字符串结果。
然后更新 main.rs:
use game::play_game;
fn main() {
for i in 1..=100 {
play_game(i, true);
}
}
use game::play_game;:从我们刚创建的gamecrate(即lib.rs)导入play_game。main:程序入口,从 1 到 100 迭代,对每个数字调用play_game,并设置print = true。
对 FizzBuzz 进行基准测试
为了对代码进行基准测试,我们需要创建一个 benches 目录,并添加一个包含基准测试的文件 play_game.rs:
game
├── Cargo.lock
├── Cargo.toml
├── benches
│ └── play_game.rs
└── src
├── lib.rs
└── main.rs
在 play_game.rs 中添加以下代码:
use criterion::{criterion_group, criterion_main, Criterion};
use game::play_game;
fn bench_play_game(c: &mut Criterion) {
c.bench_function("bench_play_game", |b| {
b.iter(|| {
std::hint::black_box(for i in 1..=100 {
play_game(i, false)
});
});
});
}
criterion_group!(
benches,
bench_play_game,
);
criterion_main!(benches);
代码说明:
- 导入 Criterion 基准测试运行器
- 从
gamecrate 导入play_game函数 - 创建一个名为
bench_play_game的函数,接收一个可变的Criterion引用 - 使用
c创建一个名为"bench_play_game"的基准测试 - 使用基准测试运行器
b多次运行我们的宏基准测试 - 在
std::hint::black_box中运行宏基准测试,防止编译器优化掉我们的代码 - 从 1 到 100(含)迭代
- 对每个数字调用
play_game,并将print设为false(因为我们只关心性能,不关心输出)
接下来,我们需要配置 game crate 以运行基准测试。
在 Cargo.toml 文件底部添加以下内容:
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "play_game"
harness = false
criterion:将 Criterion 添加为开发依赖项(仅用于性能测试)bench:注册play_game为基准测试,并设置harness = false,因为我们使用 Criterion 作为基准测试框架
现在可以运行基准测试了:
$ cargo bench
Compiling playground v0.0.1 (/home/bencher)
Finished bench [optimized] target(s) in 4.79s
Running unittests src/main.rs (target/release/deps/game-68f58c96f4025bd4)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/release/deps/game-043972c4132076a9)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running benches/play_game.rs (target/release/deps/play_game-e0857103eb02eb56)
bench_play_game time: [3.0020 µs 3.0781 µs 3.1730 µs]
Found 12 outliers among 100 measurements (12.00%)
2 (2.00%) high mild
10 (10.00%) high severe
🐰 太棒了!我们得到了第一个基准测试指标!
现在我们可以安心休息了……开玩笑的!用户又提了新需求!
用 Rust 编写 FizzBuzzFibonacci
我们的关键绩效指标(KPI)下降了,产品经理(PM)要求我们添加一个新功能。经过大量头脑风暴和用户访谈,大家一致认为:传统的 FizzBuzz 已经不够酷了。现在的年轻人想要一个新游戏:FizzBuzzFibonacci。
FizzBuzzFibonacci 规则如下:
- 编写一个程序,打印从 1 到 100(含)的整数:
- 如果是 3 的倍数,打印
Fizz - 如果是 5 的倍数,打印
Buzz - 如果同时是 3 和 5 的倍数,打印
FizzBuzz - 如果该数字属于斐波那契数列,则只打印
Fibonacci - 其他情况,打印该数字
- 如果是 3 的倍数,打印
斐波那契数列是一个每个数字等于前两个数字之和的序列。例如,从 0 和 1 开始,下一个数是 1,然后是 2、3、5、8……这些属于斐波那契数列的数字称为斐波那契数。因此,我们需要编写一个函数来检测斐波那契数。
实现斐波那契数列和检测斐波那契数的方法有很多。这里我们采用我最喜欢的方式:
fn is_fibonacci_number(n: u32) -> bool {
for i in 0..=n {
let (mut previous, mut current) = (0, 1);
while current < i {
let next = previous + current;
previous = current;
current = next;
}
if current == n {
return true;
}
}
false
}
代码说明:
- 创建一个名为
is_fibonacci_number的函数,接收一个无符号整数,返回布尔值 - 从 0 到
n(含)进行迭代 - 初始化斐波那契序列:
previous = 0,current = 1 - 当
current < i时循环:- 计算下一个数:
next = previous + current - 更新
previous = current - 更新
current = next
- 计算下一个数:
- 当
current >= n时退出循环 - 如果
current == n,返回true - 否则返回
false
现在我们需要更新 fizz_buzz 函数:
pub 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(),
}
}
}
- 将
fizz_buzz函数重命名为fizz_buzz_fibonacci,使其更具描述性 - 调用
is_fibonacci_number辅助函数 - 如果结果为
true,返回"Fibonacci" - 否则,执行原有的 Fizz/Buzz/FizzBuzz/数字逻辑
由于我们将 fizz_buzz 重命名了,也需要更新 play_game 函数:
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
main 和 bench_play_game 函数可以保持不变。
对 FizzBuzzFibonacci 进行基准测试
现在重新运行基准测试:
$ cargo bench
Compiling playground v0.0.1 (/home/bencher)
Finished bench [optimized] target(s) in 4.79s
Running benches/play_game.rs (target/release/deps/play_game-e0857103eb02eb56)
bench_play_game time: [20.067 µs 20.107 µs 20.149 µs]
change: [+557.22% +568.69% +577.93%] (p = 0.00 < 0.05)
Performance has regressed.
Found 6 outliers among 100 measurements (6.00%)
4 (4.00%) high mild
2 (2.00%) high severe
哦!Criterion 告诉我们,FizzBuzz 和 FizzBuzzFibonacci 的性能差异为 +568.69%。你的具体数值可能略有不同,但两者差距很可能在 5 倍左右。考虑到我们添加了一个听起来很酷的“斐波那契”功能,这个性能损失似乎可以接受!孩子们一定会喜欢的!
扩展 FizzBuzzFibonacci
我们的游戏大获成功!孩子们确实爱玩 FizzBuzzFibonacci。以至于高管们要求推出续作。但在当今世界,我们需要的是年度经常性收入(ARR),而不是一次性销售!新愿景是:开放世界 FizzBuzzFibonacci——不再局限于 1 到 100!
开放世界 FizzBuzzFibonacci 规则如下:
- 编写一个程序,接收任意正整数并打印:
- 如果是 3 的倍数,打印
Fizz - 如果是 5 的倍数,打印
Buzz - 如果同时是 3 和 5 的倍数,打印
FizzBuzz - 如果该数字属于斐波那契数列,则只打印
Fibonacci - 其他情况,打印该数字
- 如果是 3 的倍数,打印
为了让游戏适用于任意数字,我们需要接受命令行参数。更新 main 函数如下:
fn main() {
let args: Vec<String> = std::env::args().collect();
let i = args
.get(1)
.map(|s| s.parse::<u32>())
.unwrap_or(Ok(15))
.unwrap_or(15);
play_game(i, true);
}
代码说明:
- 收集所有传递给程序的命令行参数
args - 获取第一个参数并尝试解析为
u32类型的i - 如果解析失败或未提供参数,则默认使用 15
- 最后用解析出的
i运行游戏
现在我们可以用任意数字玩游戏了!使用 cargo run -- 后跟参数:
$ cargo run -- 9
Compiling playground v0.0.1 (/home/bencher)
Finished dev [unoptimized + debuginfo] target(s) in 0.44s
Running `target/debug/game 9`
Fizz
$ cargo run -- 10
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/game 10`
Buzz
$ cargo run -- 13
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/game 13`
Fibonacci
如果省略参数或提供无效数字:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/game`
FizzBuzz
$ cargo run -- bad
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/game bad`
FizzBuzz
哇,测试得很彻底!CI 通过了,老板们很高兴。发布吧!🚀
结局
三周后的海绵宝宝
“一切安好”表情包
🐰 ……也许是你职业生涯的终点?
开玩笑啦!其实一切都着火了!🔥
起初一切似乎都很顺利。但在周六凌晨 2:07,我的寻呼机响了:
📟 你的游戏着火了!🔥
我从床上爬起来,试图找出问题。日志很难查,因为系统一直在崩溃。最终我发现问题所在:孩子们太爱我们的游戏了,他们玩到了一百万!
灵光一闪,我立刻添加了两个新基准测试:
fn bench_play_game_100(c: &mut Criterion) {
c.bench_function("bench_play_game_100", |b| {
b.iter(|| std::hint::black_box(play_game(100, false)));
});
}
fn bench_play_game_1_000_000(c: &mut Criterion) {
c.bench_function("bench_play_game_1_000_000", |b| {
b.iter(|| std::hint::black_box(play_game(1_000_000, false)));
});
}
bench_play_game_100:对数字 100 进行微基准测试bench_play_game_1_000_000:对数字 1,000,000 进行微基准测试
运行后得到:
$ cargo bench
...
bench_play_game time: [20.024 µs 20.058 µs 20.096 µs]
change: [-0.0801% +0.1431% +0.3734%] (p = 0.21 > 0.05)
No change in performance detected.
bench_play_game_100 time: [403.00 ns 403.57 ns 404.27 ns]
Wait for it… wait for it…
bench_play_game_1_000_000
time: [9.5865 ms 9.5968 ms 9.6087 ms]
什么!403.57 纳秒 × 1000 应该是 403,570 纳秒,而不是 9,596,800 纳秒(9.5968 毫秒)🤯
虽然斐波那契数列的逻辑功能正确,但显然存在严重的性能缺陷!
修复 FizzBuzzFibonacci
再看一眼 is_fibonacci_number 函数:
fn is_fibonacci_number(n: u32) -> bool {
for i in 0..=n {
let (mut previous, mut current) = (0, 1);
while current < i {
let next = previous + current;
previous = current;
current = next;
}
if current == n {
return true;
}
}
false
}
现在考虑性能,我意识到这里有一个不必要的外层循环。我们可以完全去掉 for i in 0..=n {} 循环,直接将 current 与 n 比较 🤦
修复后的版本:
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
}
修复说明:
- 移除外层
for循环 - 初始化斐波那契序列:
previous = 0,current = 1 - 当
current < n时循环生成斐波那契数 - 一旦
current >= n,退出循环 - 直接返回
current == n
现在重新运行基准测试:
$ cargo bench
...
bench_play_game time: [3.1201 µs 3.1772 µs 3.2536 µs]
change: [-84.469% -84.286% -84.016%] (p = 0.00 < 0.05)
Performance has improved.
bench_play_game_100 time: [24.460 ns 24.555 ns 24.650 ns]
change: [-93.976% -93.950% -93.927%] (p = 0.00 < 0.05)
Performance has improved.
bench_play_game_1_000_000
time: [30.260 ns 30.403 ns 30.564 ns]
change: [-100.000% -100.000% -100.000%] (p = 0.00 < 0.05)
Performance has improved.
哇!bench_play_game 的性能回到了原始 FizzBuzz 的水平。虽然我不记得确切数值了(三周前的事了),但应该差不多!
bench_play_game_100性能提升了近 10 倍(-93.95%)bench_play_game_1_000_000性能提升了 超过 10,000 倍!从 9,596,800 纳秒降到 30.403 纳秒!甚至让 Criterion 的变化率显示达到了极限 -100.000%!
🐰 嘿,至少我们在它上线生产环境前发现了这个性能 bug……哦,等等,好像已经上线了……算了,别提了……