Rust 经常提到「零成本抽象」,意思是高层抽象在编译后和手写底层代码性能一致。这篇记录几个实际的优化技巧和性能验证方法。
零成本抽象到底是什么
C++ 之父 Bjarne Stroustrup 提出的原则:你不用的东西不会产生开销,你用的东西不可能手写出更好的代码。Rust 把这个原则贯彻得非常彻底。
最典型的例子是迭代器。直觉上链式调用 .iter().map().filter().collect() 应该比手写 for 循环慢(因为多了中间层),但实际上编译器会把整个迭代器链内联展开,生成的机器码和手写循环几乎一样。
迭代器链优化
来看一个实际例子——从一组数据中筛选、变换、求和:
// 方式1:迭代器链
fn sum_even_squares_iter(data: &[i64]) -> i64 {
data.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.sum()
}
// 方式2:手写循环
fn sum_even_squares_loop(data: &[i64]) -> i64 {
let mut sum = 0i64;
for &x in data {
if x % 2 == 0 {
sum += x * x;
}
}
sum
}
用 cargo-show-asm 看生成的汇编,两者在 --release 下几乎完全相同。编译器甚至可能自动向量化(SIMD),把循环展开成 AVX2 指令一次处理多个元素。
关键在于迭代器的 next() 方法是内联展开的,不会真的产生函数调用。这就是零成本抽象。
#[inline] 的使用
Rust 编译器通常会自动判断是否内联,但在某些场景下需要手动提示:
// 跨 crate 调用时,编译器默认不会内联
// 加 #[inline] 允许在调用方 crate 进行内联
#[inline]
pub fn fast_hash(key: &[u8]) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for &b in key {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
// #[inline(always)] 强制内联,谨慎使用
// 适合非常小的热点函数
#[inline(always)]
pub fn is_ascii_digit(b: u8) -> bool {
b >= b'0' && b <= b'9'
}
注意事项:
- 同一 crate 内的私有函数,编译器通常能自己判断
- 跨 crate 的公共函数,如果是热路径上的小函数,加
#[inline] #[inline(always)]会增大二进制体积,只用在确定是瓶颈的极小函数上
编译器优化级别
Cargo.toml 中可以细粒度控制优化:
[profile.release]
opt-level = 3 # 最高优化级别
lto = "fat" # 链接时优化,跨 crate 内联
codegen-units = 1 # 单 codegen unit,允许更多全局优化
panic = "abort" # 去掉 unwind 开销
target-cpu = "native" # 针对本机 CPU 指令集优化
# 开发模式下对依赖开优化(加速 debug 构建中依赖的性能)
[profile.dev.package."*"]
opt-level = 2
lto = "fat" + codegen-units = 1 的组合效果最好,但会显著增加编译时间(可能 3~5 倍)。日常开发不要开,CI 和 release 构建再用。
避免不必要的分配
堆分配是性能杀手之一。几个常见的优化手法:
// Bad: 每次调用都分配新 String
fn format_name_bad(first: &str, last: &str) -> String {
let mut s = String::new();
s.push_str(first);
s.push(' ');
s.push_str(last);
s
}
// Good: 预分配足够容量
fn format_name_good(first: &str, last: &str) -> String {
let mut s = String::with_capacity(first.len() + 1 + last.len());
s.push_str(first);
s.push(' ');
s.push_str(last);
s
}
// 用 Cow 避免不必要的克隆
use std::borrow::Cow;
fn normalize_path(path: &str) -> Cow<str> {
if path.contains("\\") {
Cow::Owned(path.replace("\\", "/"))
} else {
Cow::Borrowed(path) // 不含反斜杠时零分配
}
}
// 用 SmallVec 避免小数组的堆分配
use smallvec::SmallVec;
fn collect_digits(n: u64) -> SmallVec<[u8; 20]> {
let mut digits = SmallVec::new();
let mut n = n;
while n > 0 {
digits.push((n % 10) as u8);
n /= 10;
}
digits.reverse();
digits
}
性能基准测试:criterion
用 criterion 做性能对比测试,结果稳定且有统计意义:
// benches/my_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn bench_sum_strategies(c: &mut Criterion) {
let data: Vec<i64> = (0..10000).collect();
let mut group = c.benchmark_group("sum_even_squares");
group.bench_with_input(
BenchmarkId::new("iterator", data.len()),
&data,
|b, d| b.iter(|| sum_even_squares_iter(black_box(d))),
);
group.bench_with_input(
BenchmarkId::new("for_loop", data.len()),
&data,
|b, d| b.iter(|| sum_even_squares_loop(black_box(d))),
);
group.finish();
}
criterion_group!(benches, bench_sum_strategies);
criterion_main!(benches);
cargo bench
输出大致如下:
sum_even_squares/iterator/10000
time: [1.234 µs 1.238 µs 1.243 µs]
sum_even_squares/for_loop/10000
time: [1.231 µs 1.236 µs 1.241 µs]
两者几乎没有差异,验证了零成本抽象。black_box 用来防止编译器把整个计算优化掉。
实用建议
- 先写清晰的代码,再优化。Rust 的零成本抽象意味着大部分情况下清晰的写法就是高效的写法。
- 用
cargo flamegraph找瓶颈,不要凭直觉优化。 #[inline]主要在跨 crate 场景有意义,同 crate 内的效果不大。- release 构建开 LTO,尤其是性能敏感的项目。
- 避免在热路径上做堆分配,优先考虑栈上数据和预分配。