Rust:零成本抽象与性能优化技巧

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 用来防止编译器把整个计算优化掉。

实用建议

  1. 先写清晰的代码,再优化。Rust 的零成本抽象意味着大部分情况下清晰的写法就是高效的写法。
  2. cargo flamegraph 找瓶颈,不要凭直觉优化。
  3. #[inline] 主要在跨 crate 场景有意义,同 crate 内的效果不大。
  4. release 构建开 LTO,尤其是性能敏感的项目。
  5. 避免在热路径上做堆分配,优先考虑栈上数据和预分配。