跳转到内容

Rust 性能优化

Rust 以速度快出名,这个名声是实打实的。但“Rust 很快”并不等于每个 Rust 程序都快。慢的算法用什么语言写都慢,而一些不起眼的小习惯,也可能悄悄拖慢你的程序。

好消息是:效果最明显的那些优化,大多都很简单。用 release 模式构建;动手改之前先测量;只需要读的数据就别复制;选对集合类型。这一页会逐个讲解,需要的地方还附上了真实的测量结果。可以把它想象成调自行车:先检查轮胎有没有打足气(release 模式),然后再去考虑换更轻的零件。

想让程序变快,先得知道它有多慢。std::time::Instant 是标准库自带的秒表。在工作开始前调用 Instant::now(),结束后调用 .elapsed():

use std::time::Instant;
fn main() {
let start = Instant::now();
// 用简单的 xorshift 生成 500 万个伪随机数。
let mut x: u64 = 12345;
let mut numbers = Vec::with_capacity(5_000_000);
for _ in 0..5_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
numbers.push(x % 1_000_000);
}
numbers.sort();
let sum: u64 = numbers.iter().take(1000).sum();
println!("最小的 1000 个数之和:{sum}");
println!("耗时:{:?}", start.elapsed());
}

{:?} 会把 Duration 打印成易读的样子,比如 250.271745ms。这里特意没有放“Try it”按钮:每次运行、每台机器的耗时都不一样,没法和固定的输出对比。请在你自己的电脑上用 cargo run 运行它。

这是 Rust 里最重要的一条性能建议,而它只是一个参数而已。

Terminal window
cargo run # debug 构建:编译快,运行慢
cargo run --release # release 构建:编译慢一些,运行快得多

debug 构建会跳过大部分优化,这样编译得快,也方便调试。它还会检查整数溢出。release 构建则会把优化器开到最大。

下面是一组测量示例:在编写本页用的机器(一台 Intel Xeon CPU 的云服务器)上,把上面的程序分别运行三次的结果。你的数字肯定会不一样,重要的只是两者之间的差距:

debug 构建: 耗时 5.02s、4.27s、4.83s
release 构建: 耗时 250ms、297ms、265ms

只加了一个参数,速度就快了大约 15 到 20 倍。差距大小很依赖代码本身,循环和迭代器受益最多。但这个差距永远不会小到可以忽略。

所以规则很简单:永远不要用 debug 构建来判断速度。如果有人用 cargo run 计时之后说“Rust 好慢”,原因几乎都是这个。

.clone() 会完整地复制一份。对 String 或 Vec 来说,这意味着一次新的堆内存分配,再把每个字节都复制过去。有时候你确实需要一份副本,但很多时候你只是想读一下数据,而借用是零成本的。

最常见的场景是函数参数。如果函数只读取字符串,就让它接收 &str,而不是 String:

fn count_long_words(text: &str, min_len: usize) -> usize {
text.split_whitespace().filter(|w| w.len() >= min_len).count()
}
fn main() {
let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String 会自动变成 &str,不需要复制。
println!("{}", count_long_words(&owned, 5));
// 字符串字面量本来就是 &str。
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// 我们从没把 `owned` 交出去,所以它还能用。
println!("{owned}");
}

输出:

3
2
the quick brown fox jumps over the lazy dog
▶ 动手试一试

这个例子里的字符串保留了英文,因为程序用 .len() 按字节数计算单词长度。一个汉字在 UTF-8 里占 3 个字节,换成中文结果就变了。

&str 参数既能接收 String(用 & 借用),也能接收字符串字面量。如果参数是 String,调用者就只能把自己的字符串交出去,或者克隆一份。列表也是同样的道理:接收 &[T],而不是 Vec<T>。详细内容见借用和 slice。

循环里的克隆也要留意。for name in names.clone() 只是为了看一眼,就把整个列表复制了一遍。for name in &names 借用它,没有任何成本。

Vec 的空间用完时,会申请一块更大的内存,再把所有元素搬过去。如果事先知道大小,Vec::with_capacity 可以一次就预留好。下面的程序在推入 1000 个数时,统计每个 Vec 扩容了多少次:

fn count_growths(mut v: Vec<u32>, n: u32) -> u32 {
let mut growths = 0;
let mut cap = v.capacity();
for i in 0..n {
v.push(i);
if v.capacity() != cap {
growths += 1;
cap = v.capacity();
}
}
growths
}
fn main() {
println!("Vec::new(): 扩容了 {} 次", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity(): 扩容了 {} 次", count_growths(Vec::with_capacity(1000), 1000));
}

输出:

Vec::new(): 扩容了 9 次
Vec::with_capacity(): 扩容了 0 次
▶ 动手试一试

扩容 9 次并不是什么灾难:Vec 每次都会把容量翻倍,所以开销仍然很小。但如果一个热点循环要构建几百万个 vector,这些开销就会积少成多。(具体的扩容方式是标准库的内部细节,将来可能会变。)

更省事的做法是交给 collect() 和 join()。它们通常知道最终的大小,只分配一次:

fn main() {
// collect() 一次就构建好整个 Vec。
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join() 先算出总长度,然后只复制一次。
let colors = ["red", "green", "blue"];
println!("{}", colors.join(", "));
}

输出:

[1, 4, 9, 16, 25]
red, green, blue
▶ 动手试一试

你可能会担心 .iter().filter().map().sum() 比普通循环慢,因为它看起来调用了好多函数。其实不会。在 release 构建里,编译器会把这些小闭包全部内联,得到的机器码和手写循环一样紧凑。Rust 把这叫作零成本抽象(zero-cost abstraction):写法更优雅,却不用付出额外代价。

fn sum_even_squares_loop(nums: &[u64]) -> u64 {
let mut total = 0;
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
total += nums[i] * nums[i];
}
}
total
}
fn sum_even_squares_iter(nums: &[u64]) -> u64 {
nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()
}
fn main() {
let nums: Vec<u64> = (1..=10).collect();
println!("loop: {}", sum_even_squares_loop(&nums));
println!("iter: {}", sum_even_squares_iter(&nums));
}

输出:

loop: 220
iter: 220
▶ 动手试一试

迭代器版本甚至可能还快一点。用 nums[i] 时,Rust 每次访问都要检查 i 有没有越界。迭代器本身就知道不会越界,这些检查也就省掉了。哪种写法更好读就用哪种,通常是迭代器。

选对数据结构,往往比任何细节调优都管用。一个经典的例子:反复检查“这个元素在不在我的列表里?”

  • Vec::contains 会一个一个地查看每个元素。列表越长,查找越慢。
  • HashSet::contains(以及 HashMap::get)几乎能直接跳到答案。不管集合多大,查找都很快。
use std::collections::HashSet;
fn main() {
let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect();
let queries: Vec<u32> = (0..20_000).collect();
// 列表大时很慢:每次 contains() 都要扫描整个 Vec。
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// 很快:先构建一次 HashSet,之后每次查找都很快。
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec 找到 {hits_vec} 个,HashSet 找到 {hits_set} 个");
}

输出:

Vec 找到 6667 个,HashSet 找到 6667 个
▶ 动手试一试

答案一样,代价却天差地别。我们给每一部分加上 Instant 计时,用 release 构建运行,下面是在我们机器上的一组测量示例:

Vec: 6667 hits in 27.97ms
HashSet: 6667 hits in 551.60µs

快了大约 50 倍,而且这还包括构建集合的时间。不过,如果列表只有 10 个元素,用 Vec 完全没问题,甚至可能更快。更多内容见 HashSet 和 HashMap 这两页。

程序员猜测时间花在哪里的能力是出了名地差。所以别猜,去做性能分析(profile)。性能分析器(profiler)会观察程序运行,告诉你哪些函数最耗时。通常只有一两个热点,其余代码根本不重要。

两个常用、免费、支持 Rust 的性能分析器:

Terminal window
# flamegraph:画出时间都花在哪里(Linux、macOS)
cargo install flamegraph
cargo flamegraph
# samply:在浏览器的 Firefox Profiler 里打开结果
cargo install samply
cargo build --release
samply record ./target/release/my_app

性能分析器需要调试符号才能显示函数名。把下面这段加进 Cargo.toml,让 release 构建保留调试符号(这不会让代码变慢,只会让文件变大):

[profile.release]
debug = true

用 Instant 只测一次,结果波动很大。基准测试(benchmark)会把代码运行很多次,再用统计方法给出可靠的数字。Rust 内置的 #[bench] 只能在 nightly 版本上用,所以大多数人会用 criterion(英文)这个 crate。像这样配置:

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

把要测试的代码放进库(src/lib.rs),然后在 benches/fib_bench.rs 里编写基准测试:

use criterion::{criterion_group, criterion_main, Criterion};
use my_crate::fibonacci;
use std::hint::black_box;
fn bench_fib(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);

black_box 可以阻止编译器耍小聪明、提前算出答案,否则基准测试就毫无意义了。用下面的命令运行:

Terminal window
cargo bench

这是在我们机器上的一个结果示例(criterion 会显示下限估计、最佳估计和上限估计):

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

改完代码后再运行一次,criterion 会告诉你代码变快了还是变慢了,以及这个差异是真实的,还是只是噪声。

默认的 release 设置已经很好了。需要再挤一点性能时,Cargo.toml 里常用的选项有这些:

[profile.release]
opt-level = 3 # release 的默认值;"s" 或 "z" 则是针对体积优化
lto = true # 链接时优化:跨所有 crate 优化(构建更慢)
codegen-units = 1 # 只用一个单元 = 更多优化机会,但编译更慢
panic = "abort" # 省掉栈展开代码;二进制更小,但无法捕获 panic

这些设置通常只能带来几个百分点的提升,算不上奇迹,而且会让构建变慢。一次只试一个,每个都要测量。lto = "thin" 是一个很受欢迎的折中方案,构建速度比 lto = true 快。

给 debug 构建计时。前面已经讲过,但这是头号误区:测量时一定要加 --release。

为了让编译器满意而克隆。比如一个函数接收 String,你却传了一个借用:

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

编译器会说:

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

这条错误的意思是:函数要的是 String,你给的却是 &String。这时很容易写成 shout(name.clone())。这样确实能通过编译,但每次调用都会复制一遍字符串。更好的修复方法是把参数改成 text: &str,这样 shout(&name) 就能编译,而且什么都不用复制。

在热点循环里用 println!。每次 println! 都会锁住 stdout,并立刻写出这一行。几行没问题,一百万行就慢了。用 BufWriter 包一层 stdout,让它成块地写出:

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "第 {i} 行").unwrap();
}
}

输出:

第 1 行
第 2 行
第 3 行
第 4 行
第 5 行
▶ 动手试一试

在我们的测量示例里,往文件打印一百万行,用 println! 大约要 570–670ms,用 BufWriter 只要大约 40–60ms(release 构建)。

  • 测量速度时一定要用 cargo run --release,debug 构建可能慢上好多倍。
  • 快速计时用 Instant::now() 和 .elapsed(),可靠的基准测试用 criterion。
  • 只需要读数据时,用借用(&str、&[T])代替克隆。
  • 一次分配到位:Vec::with_capacity、collect() 和 join() 能避免反复扩容。
  • 迭代器是零成本的;需要大量查找时,选 HashSet 或 HashMap。
  • 先做性能分析,再去优化分析器指出的热点。

小测验

1. 用 cargo run 运行时,你觉得程序很慢。应该先试什么?

2. 一个函数只读取字符串。哪种参数类型能避免不必要的复制?

3. 你要检查 50,000 个 ID 是否在一份 50,000 个被封禁 ID 的名单里。被封禁名单用什么类型更好?

4. “迭代器是零成本的”是什么意思?

5. 为什么 criterion 基准测试要用 black_box?

得分: 0 / 5