Rust 性能优化
Rust 以速度快出名,这个名声是实打实的。但“Rust 很快”并不等于每个 Rust 程序都快。慢的算法用什么语言写都慢,而一些不起眼的小习惯,也可能悄悄拖慢你的程序。
好消息是:效果最明显的那些优化,大多都很简单。用 release 模式构建;动手改之前先测量;只需要读的数据就别复制;选对集合类型。这一页会逐个讲解,需要的地方还附上了真实的测量结果。可以把它想象成调自行车:先检查轮胎有没有打足气(release 模式),然后再去考虑换更轻的零件。
第零步:用 Instant 测量
Section titled “第零步:用 Instant 测量”想让程序变快,先得知道它有多慢。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 运行它。
debug 构建和 release 构建
Section titled “debug 构建和 release 构建”这是 Rust 里最重要的一条性能建议,而它只是一个参数而已。
cargo run # debug 构建:编译快,运行慢cargo run --release # release 构建:编译慢一些,运行快得多debug 构建会跳过大部分优化,这样编译得快,也方便调试。它还会检查整数溢出。release 构建则会把优化器开到最大。
下面是一组测量示例:在编写本页用的机器(一台 Intel Xeon CPU 的云服务器)上,把上面的程序分别运行三次的结果。你的数字肯定会不一样,重要的只是两者之间的差距:
debug 构建: 耗时 5.02s、4.27s、4.83srelease 构建: 耗时 250ms、297ms、265ms只加了一个参数,速度就快了大约 15 到 20 倍。差距大小很依赖代码本身,循环和迭代器受益最多。但这个差距永远不会小到可以忽略。
所以规则很简单:永远不要用 debug 构建来判断速度。如果有人用 cargo run 计时之后说“Rust 好慢”,原因几乎都是这个。
用借用代替克隆
Section titled “用借用代替克隆”.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}");}输出:
32the 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 借用它,没有任何成本。
一次分配到位,别反复分配
Section titled “一次分配到位,别反复分配”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迭代器是零成本的
Section titled “迭代器是零成本的”你可能会担心 .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: 220iter: 220迭代器版本甚至可能还快一点。用 nums[i] 时,Rust 每次访问都要检查 i 有没有越界。迭代器本身就知道不会越界,这些检查也就省掉了。哪种写法更好读就用哪种,通常是迭代器。
选对集合类型
Section titled “选对集合类型”选对数据结构,往往比任何细节调优都管用。一个经典的例子:反复检查“这个元素在不在我的列表里?”
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.97msHashSet: 6667 hits in 551.60µs快了大约 50 倍,而且这还包括构建集合的时间。不过,如果列表只有 10 个元素,用 Vec 完全没问题,甚至可能更快。更多内容见 HashSet 和 HashMap 这两页。
先分析,再优化
Section titled “先分析,再优化”程序员猜测时间花在哪里的能力是出了名地差。所以别猜,去做性能分析(profile)。性能分析器(profiler)会观察程序运行,告诉你哪些函数最耗时。通常只有一两个热点,其余代码根本不重要。
两个常用、免费、支持 Rust 的性能分析器:
# flamegraph:画出时间都花在哪里(Linux、macOS)cargo install flamegraphcargo flamegraph
# samply:在浏览器的 Firefox Profiler 里打开结果cargo install samplycargo build --releasesamply record ./target/release/my_app性能分析器需要调试符号才能显示函数名。把下面这段加进 Cargo.toml,让 release 构建保留调试符号(这不会让代码变慢,只会让文件变大):
[profile.release]debug = true用 criterion 做基准测试
Section titled “用 criterion 做基准测试”用 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 可以阻止编译器耍小聪明、提前算出答案,否则基准测试就毫无意义了。用下面的命令运行:
cargo bench这是在我们机器上的一个结果示例(criterion 会显示下限估计、最佳估计和上限估计):
fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]改完代码后再运行一次,criterion 会告诉你代码变快了还是变慢了,以及这个差异是真实的,还是只是噪声。
release 配置选项
Section titled “release 配置选项”默认的 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
- The Rust Performance Book(英文):一本简短实用的指南,讲构建设置、性能分析和常见的提速方法。
- 性能比较:循环对迭代器 —《Rust 程序设计语言》:为什么迭代器能编译出快速的代码。
- Profiles — The Cargo Book(英文):所有
[profile]设置及其默认值。 std::time::Instant— 标准库文档(英文):标准库自带的秒表。- criterion 文档(英文):上面用到的基准测试 crate。