Skip to content

性能

基准测试、Rust 性能心智模型与系统的优化流程。

基准测试与性能分析

内置 #[bench] 需要 nightly,所以用 criterion

标准库自带的 #[bench] 属性属于 test crate,只在 nightly 可用

rust
// 仅 nightly:需要 nightly 工具链 + 文件顶部的 #![feature(test)]
#![feature(test)]
extern crate test;

fn parse_line(s: &str) -> usize {
    s.split_whitespace().map(str::len).sum()
}

#[bench]
fn bench_parse(b: &mut test::Bencher) {
    b.iter(|| parse_line("move 1 2"));
}

我们的基准环境是 stable,所以主线方案是 criterion:它自动决定迭代次数、做统计分析与离群点检测、和上一次结果比较并给出「性能提升/退化」的判定。

powershell
cargo add --dev criterion      # 当前最新稳定版本会被写入 [dev-dependencies]
toml
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }

[[bench]]                  # 每个基准文件都要在 Cargo.toml 里登记
name = "parse_bench"
harness = false            # 关键:关掉内置的 libtest harness,否则 #[bench] 不会被识别
rust
// benches/parse_bench.rs
use criterion::{criterion_group, criterion_main, black_box, BenchmarkId, Criterion, Throughput};
use std::time::Duration;

fn parse_line(s: &str) -> usize {
    s.split_whitespace().map(str::len).sum()
}

fn bench_parse(c: &mut Criterion) {
    let input = "move 12345 67890";

    c.bench_function("parse_line", |b| {
        // black_box 阻止编译器把整段调用优化掉
        b.iter(|| parse_line(black_box(input)))
    });

    let mut group = c.benchmark_group("parse_by_len");
    group.throughput(Throughput::Bytes(input.len() as u64));
    group.warm_up_time(Duration::from_secs(1));
    group.measurement_time(Duration::from_secs(3));
    for len in [8usize, 32, 128] {
        let s = "1 ".repeat(len);
        group.bench_with_input(BenchmarkId::from_parameter(len), &s, |b, s| {
            b.iter(|| parse_line(black_box(s)))
        });
    }
    group.finish();
}

criterion_group!(benches, bench_parse);
criterion_main!(benches);
powershell
cargo bench                       # 跑全部基准
cargo bench --bench parse_bench   # 只跑一个基准目标
cargo bench -- parse_line         # criterion 自己的过滤器(在 -- 之后)

典型输出(示意,数字随机器变化):

text
parse_line              time:   [24.031 ns 24.180 ns 24.352 ns]
                        change: [-1.8234% -0.9121% +0.0213%] (p = 0.07 > 0.05)
                        No change in performance detected.
Found 4 outliers among 100 measurements (4.00%)

读法:中括号是置信区间(下界、点估计、上界),change 是与上次运行相比的变化——如果区间跨过 0 且 p > 0.05,criterion 判定「没有显著变化」。此外 criterion 会在 target/criterion/ 下生成 HTML 报告(cargo bench 后打开 target/criterion/report/index.html)。

black_box 为什么重要

优化器眼里没有「基准测试」这个概念。它会做常量传播、函数内联、死代码消除——如果 parse_line("...") 的结果没被使用,整段调用可能被删掉,于是你测到的是「一个空循环」。

rust
use criterion::Criterion;
use std::hint::black_box;   // 从 1.66 起稳定,无需任何 crate

fn parse_line(s: &str) -> usize {
    s.split_whitespace().map(str::len).sum()
}

fn bench(c: &mut Criterion) {
    c.bench_function("对比两种写法", |b| {
        // 危险写法:结果没被使用,编译器有充分理由把调用删掉
        b.iter(|| parse_line("move 1 2"));

        // 正确写法:black_box 告诉优化器「这个值可能被外部观察,不能假设它无用」
        b.iter(|| parse_line(black_box("move 1 2")));
    });
}

criterion::black_boxstd::hint::black_box 现在功能一致,新代码直接用 std::hint::black_box 即可;criterion::black_box 仍然可用,老示例里常见。

🧠 原理black_box(x) 是一个恒等函数,但编译器把它当成「读写了未知内存」的屏障:输入侧它假装自己被读过(值不可预测),输出侧假装返回值会被使用(不能被删)。它不保证阻止所有优化,只是极大降低「整段被优化掉」的概率。

⚠️ 陷阱:criterion 默认用 cargo benchbench profile(继承自 release,但 debug-assertions 仍为 falseoverflow-checks 一般关闭)。如果你把基准跑在 cargo run(dev profile)下,数字会差一个数量级且毫无意义。自定义时显式声明:

toml
[profile.bench]
opt-level = 3
lto = "thin"          # 与发布构建保持一致的优化设置,避免测到的不是真实产物
codegen-units = 1
debug = true          # 保留符号,方便 perf/flamegraph 看到函数名

性能分析:先看火焰图,再动手

工具一句话平台
perfLinux 内核级采样剖析器,能看到函数级热点与调用栈Linux
cargo-flamegraph一行命令生成交互式火焰图(内部调用 perf/DTrace/Xperf)Linux / macOS / Windows(需 WPA 等后端)
hyperfine命令行程序的端到端基准工具,自动预热、统计多次运行全平台
cargo llvm-lines统计各泛型实例化的 LLVM IR 行数,定位编译期膨胀全平台
cargo build --timings生成各 crate 编译耗时报告 target/cargo-timings/全平台
powershell
cargo install flamegraph --locked
cargo flamegraph --bin my_app -- --input data.txt   # 生成 flamegraph.svg

⚠️ 注意perfcargo-flamegraph 在 Windows 上的可用性受限(Windows 上通常需要 WPA/ETW 后端,或改在 WSL2 里跑)。在 Windows 上先做「粗粒度定位」:cargo build --timings 看编译期,measure 级别的 Instant::now() 打点看运行期,把范围缩小到函数,再考虑上 WSL2 + perf

powershell
cargo install hyperfine --locked
hyperfine --warmup 3 ".\target\release\my_app.exe --fast" ".\target\release\my_app.exe --slow"

hyperfine 测的是整个进程:启动、参数解析、IO 全算进去。所以它适合回答「这个 CLI 快不快」,不适合回答「这个函数快不快」。两者界限要分清。

基准测试的三个经典陷阱

  1. 被优化掉:结果没被使用、输入是编译期常量、循环被向量化或删除。对策black_box 包住输入和输出;用运行时才确定的输入(读文件、命令行参数、随机种子)。
  2. 测量里混入非目标开销:在 b.iter() 里创建 Vec、分配 String、打印日志、做 IO。对策:把准备阶段移到 iter 外面,或用 iter_batched 明确区分 setup / routine:
rust
// benches/parse_bench.rs(节选)
use criterion::{black_box, BatchSize, Criterion};

fn parse_line(s: &str) -> usize {
    s.split_whitespace().map(str::len).sum()
}

fn bench_parse(c: &mut Criterion) {
    c.bench_function("parse_preallocated", |b| {
        b.iter_batched(
            || String::from("move 12345 67890"),       // setup:不计入测量
            |s| parse_line(black_box(&s)),             // routine:只测这个
            BatchSize::SmallInput,
        )
    });
}
  1. 样本太少 / 环境不干净:只跑 5 次就说「快了 20%」;后台还开着编译器、浏览器、病毒扫描。对策:接受 criterion 的默认采样量(它自己会跑够);固定 CPU 频率与电源模式;关掉后台程序;同一台机器上交替跑 A/B 而不是相隔一周各跑一次。

⚠️ 注意:微基准的绝对数字不可跨机器比较,甚至同一台机器上不同进程也可能差 5%~10%。你能信赖的是「同一台机器、同一会话、交替 A/B」的相对结论。


Rust 性能心智模型

「零成本抽象」到底指什么

「零成本抽象(zero-cost abstraction)」的准确含义不是「抽象不要钱」,而是:

你不用的东西,不为它付出代价;你用的东西,手写也不会比它更快。

它由三个机制支撑:

  • 单态化(monomorphization):泛型在编译期为每个具体类型各生成一份机器码。Vec<u8>Vec<String> 是两份独立代码,没有运行期类型检查、没有装箱、没有虚表。代价是二进制体积与编译时间。
  • 内联(inlining):迭代器链 v.iter().filter(..).map(..).sum() 会被内联并融合成一个循环,LLVM 还能进一步向量化。迭代器与手写 for 循环在 release 下通常生成完全相同的汇编
  • 无 GC:内存释放由作用域静态决定(析构函数在确定位置插入),没有运行期追踪、没有停顿。

🧠 原理:动态语言里 for x in list: f(x) 每次迭代都要做类型检查与属性查找;Rust 的迭代器在单态化之后,next() 就是几个指针比较与递增。所以「用迭代器会慢」在 Rust 里通常是错误的直觉——真正慢的是错误的算法和多余的分配。

边界检查消除(bounds check elimination)

数组索引 v[i] 在安全 Rust 里会插入一次边界检查(越界就 panic)。这是安全性的成本。但编译器会做边界检查消除:当它能证明 i < v.len() 时,检查被删掉。

rust
pub fn sum_manual(v: &[u32]) -> u32 {
    let mut total = 0;
    for i in 0..v.len() {
        total += v[i];      // 编译器知道 i < v.len(),这一处检查会被消除
    }
    total
}

pub fn sum_iter(v: &[u32]) -> u32 {
    v.iter().copied().sum()  // 迭代器天然无越界可能,一个检查都不需要
}

pub fn sum_by_index(v: &[u32], idx: &[usize]) -> u32 {
    let mut total = 0;
    for &i in idx {
        // 这里的检查无法消除:i 来自外部,编译器证明不了它 < v.len()
        total += v[i];
    }
    total
}

sum_by_index 里的检查可以用 get 明确处理,或(只在真的测出热点后)用 get_unchecked 跳过:

rust
pub fn sum_by_index_unchecked(v: &[u32], idx: &[usize]) -> u32 {
    let mut total = 0;
    for &i in idx {
        debug_assert!(i < v.len(), "调用方必须保证索引合法");
        // SAFETY: 前面的 debug_assert 只是开发期检查;真正的保证来自函数契约:
        // 调用方承诺 idx 中每个元素都 < v.len()。这是 unsafe fn 的常见形态。
        total += unsafe { *v.get_unchecked(i) };
    }
    total
}

⚠️ 注意get_unchecked 越界是未定义行为,不是「返回垃圾值」。它可能让你的程序在千里之外的代码里出错。优先级永远是:先证明边界检查是热点(用 flamegraph 或 criterion 看到),再考虑消除;能用 chunks_exact/iter/split_at 等安全 API 表达,就绝不用 get_unchecked 实践中,绝大多数「边界检查太慢」的判断都是错的——真正的瓶颈是分配或算法。

内存布局:repr(Rust) 会重排字段

Rust 默认布局 repr(Rust) 不保证字段顺序:编译器会为了减少填充(padding)而重排字段,甚至把 enum 的判别式塞进「空位」(niche)。这通常让结构体更小,但意味着布局不是稳定 ABI

rust
use std::mem::{align_of, size_of};

#[derive(Debug)]
struct RustLayout { a: u8, b: u64, c: u8 }   // repr(Rust)

#[repr(C)]
#[derive(Debug)]
struct CLayout { a: u8, b: u64, c: u8 }      // repr(C):严格按声明顺序

#[derive(Debug)]
enum Shape {
    Point,                 // 无额外数据
    Circle(f64),           // 8 字节
    Rect(f64, f64),        // 16 字节
}

fn main() {
    println!("repr(Rust) : size = {}, align = {}", size_of::<RustLayout>(), align_of::<RustLayout>());
    println!("repr(C)    : size = {}, align = {}", size_of::<CLayout>(), align_of::<CLayout>());
    println!("Shape      : size = {}, align = {}", size_of::<Shape>(), align_of::<Shape>());
    println!("(u8,u64,u8) tuple: size = {}", size_of::<(u8, u64, u8)>());
}

> 输出(rustc 1.98.1 / x86_64-pc-windows-msvc 实测):

text
repr(Rust) : size = 16, align = 8
repr(C)    : size = 16, align = 8
Shape      : size = 24, align = 8
(u8,u64,u8) tuple: size = 16

注意这个例子为何两者相同repr(Rust) 的重排能力只在「重排能减少填充」时才产生差别。三个字段 u8, u64, u8 无论怎么排,u64 的 8 字节对齐要求都会让它占满一个 8 字节槽,剩下两个 u8 可以共用另一个槽——总大小都是 16。想看差别要把 u64 换成 u32

rust
#[derive(Debug)]
struct RustLayout32 { a: u8, b: u32, c: u8 }   // 重排为 u32, u8, u8 → 8 字节

#[repr(C)]
#[derive(Debug)]
struct CLayout32 { a: u8, b: u32, c: u8 }      // a(1)+pad(3)+b(4)+c(1)+pad(3) → 12 字节

fn main() {
    println!("repr(Rust)32 = {}", std::mem::size_of::<RustLayout32>());
    println!("repr(C)32    = {}", std::mem::size_of::<CLayout32>());
}

> 输出:

text
repr(Rust)32 = 8
repr(C)32    = 12

什么时候必须 repr(C):FFI 边界上的结构体(C 代码要按固定偏移读字段)、需要稳定 ABI 的插件接口、要自己做二进制序列化的场景。什么时候不要:普通内部数据结构——用 repr(Rust) 让编译器帮你省内存。

size_of 的三条实用结论:

  • Box<T>&T&mut T:一个机器字(64 位平台 8 字节)。
  • Vec<T>String三个机器字(24 字节)——指针、长度、容量。所以 String&str(16 字节:指针 + 长度)大,Vec<u8>&[u8] 大。
  • enum 大小 ≈ 最大变体 + 判别式,再按对齐向上取整;有 niche 优化时可能更小。

三字宽与 niche 优化

Vec<T>String 的「三字宽(three words)」是 24 字节,这不是偶然,而是「指针 + 长度 + 容量」这一设计的结果——它让 Vec 支持 O(1) 均摊增长(容量足够时不重新分配)。

rust
use std::mem::size_of;

fn main() {
    println!("Option<Box<u32>>   = {}", size_of::<Option<Box<u32>>>());
    println!("Box<u32>           = {}", size_of::<Box<u32>>());
    println!("Option<u8>         = {}", size_of::<Option<u8>>());
    println!("Option<&u8>        = {}", size_of::<Option<&u8>>());
    println!("Option<Vec<u8>>    = {}", size_of::<Option<Vec<u8>>>());
    println!("Vec<u8>            = {}", size_of::<Vec<u8>>());
    println!("String             = {}", size_of::<String>());
    println!("Option<Option<u8>> = {}", size_of::<Option<Option<u8>>>());
    println!("bool               = {}", size_of::<bool>());
    println!("Result<u8, ()>     = {}", size_of::<Result<u8, ()>>());
}

> 输出(x86_64-pc-windows-msvc 实测):

text
Option<Box<u32>>   = 8
Box<u32>           = 8
Option<u8>         = 2
Option<&u8>        = 8
Option<Vec<u8>>    = 24
Vec<u8>            = 24
String             = 24
Option<Option<u8>> = 2
bool               = 1
Result<u8, ()>     = 2

关键读法:

  • Option<Box<u32>>Box<u32> 一样大(8 字节)。因为 Box 一定非空(永远不可能是 0 地址),编译器用「空指针」这个空位(niche)表示 None,判别式零开销。
  • Option<&u8> 同理:&u8 非空,所以 Option<&u8> 仍是 8 字节。
  • Option<Vec<u8>>Vec<u8> 一样大(24 字节):Vec 的指针字段非空,可以当 niche。
  • Option<u8>2 字节u8 用满了 256 个值,没有空位,所以必须外挂一个判别式字节(并对齐到 1,总 2 字节)。
  • Option<Option<u8>> 也是 2 字节:内层 Option<u8> 的判别式只用了 01 两个值,外层复用剩下的值当自己的 niche。

这就是为什么 Rust 里 Option<Box<T>>Option<&T>Option<Vec<T>> 可以是零开销的,而 Option<u8>/Option<u32> 不是。实践含义:热点结构体里用 Option<Box<T>>Option<&T> 不会让对象变大;但如果一个 struct 里有 5 个 Option<u8>,每个都多 1 字节(还可能因对齐翻倍),这时应该考虑把布尔标志打包成 bitflags 风格的小整数,或者重新设计状态机。

🧠 原理:niche 优化由编译器对「类型中不可能出现的位模式」做静态推理。NonNull<T>NonZeroU32&TBox<T>Vec<T> 都带 niche。所以「把不变量编码进类型」不仅更安全,还常常更省内存——NonZeroU8Option 就是 1 字节:

rust
use std::num::NonZeroU8;
use std::mem::size_of;

println!("{}", size_of::<Option<NonZeroU8>>());   // 1

分配是主要代价

在真实程序里,堆分配往往比算术运算贵两三个数量级。一次 Vec::push 触发扩容 = 分配新块 + 拷贝旧数据 + 释放旧块。所以优化清单的第一页是「减少分配次数」,而不是「减少加法」。

rust
// 反面写法:先建空 Vec,让它反复扩容 + 反复拷贝
fn squares_bad(n: usize) -> Vec<u64> {
    let mut out = Vec::new();
    for i in 0..n {
        out.push((i as u64) * (i as u64));
    }
    out
}

// 正面写法 A:知道最终长度就直接 with_capacity,一次分配
fn squares_good(n: usize) -> Vec<u64> {
    let mut out = Vec::with_capacity(n);   // 一次分配,全程零扩容
    for i in 0..n {
        out.push((i as u64) * (i as u64));
    }
    out
}

// 正面写法 B:能描述成迭代器就用 collect,它内部会读 size_hint 预留容量
fn squares_best(n: usize) -> Vec<u64> {
    (0..n).map(|i| (i as u64) * (i as u64)).collect()
}

复用缓冲区是另一条主线:在循环里反复创建临时 String/Vec 是常见的隐形开销,把它们提到循环外并 clear() 复用,能把 N 次分配降为 1 次。

rust
fn join_numbers_bad(rows: &[Vec<i32>]) -> Vec<String> {
    rows.iter().map(|r| r.iter().map(i32::to_string).collect::<Vec<_>>().join(",")).collect()
}

fn join_numbers_good(rows: &[Vec<i32>]) -> Vec<String> {
    let mut out = Vec::with_capacity(rows.len());
    let mut buf = String::new();            // 复用同一个缓冲区
    for row in rows {
        buf.clear();                        // 只清长度,不释放底层内存
        for (i, n) in row.iter().enumerate() {
            if i > 0 { buf.push(','); }     // 用 push_str/push 而非 + 链
            buf.push_str(&n.to_string());
        }
        out.push(buf.clone());              // 要留存的才克隆
    }
    out
}

String 拼接:push_str 而非 + 链。 a + &b + &c 的每一步都会创建一个新 Stringa 的所有权被移动、b 被拷贝进新分配、旧分配被释放。它可读性不差但分配次数是 O(n)。push_str 在容量够时完全不分配。

rust
let mut s = String::with_capacity(64);
s.push_str("hello");
s.push(' ');
s.push_str("world");     // 全程可能只有一次分配

SmallVec / ArrayVec / Cow 的取舍

类型含义什么时候用代价
ArrayVec<T, N>arrayvec = "0.7"定容数组,永不分配,容量是类型的一部分上限已知且很小(≤ 32),超了就报错超容必须显式处理;栈上占 N * size_of::<T>()
SmallVec<[T; N]>smallvec = "1.16"内联存 N 个,超了自动转堆「绝大多数情况 ≤ N 个」的集合,想省掉常见路径的分配每个元素判断一次「在栈还是在堆」,类型更大
Cow<'a, str>借用或拥有二选一,to_mut() 时才复制函数「通常原样返回输入、偶尔需要改写」调用方要处理两种形态;生命周期更复杂
rust
use std::borrow::Cow;

// 输入干净时零分配,只有真的需要改写时才 clone
fn normalize(input: &str) -> Cow<'_, str> {
    if input.chars().all(|c| c.is_ascii_lowercase() || c == ' ') {
        Cow::Borrowed(input)
    } else {
        Cow::Owned(input.to_ascii_lowercase())
    }
}

⚠️ 注意SmallVec/ArrayVec 不是免费午餐。SmallVec 会让元素在栈堆之间迁移(触发时仍要一次分配 + 拷贝),且类型尺寸变大、每次访问多一次分支。只有在基准里看到分配是热点、且 N 的分布确实集中在很小的范围时,它们才划算。

什么时候 clone 是可接受的

社区有一句被广泛引用的话:「先让它正确,再让它快;清晰胜过聪明。」 clone() 经常被当成性能原罪,但真实情况是:

  • 冷路径上的 clone 完全无所谓:程序启动时读一次配置、退出时打一次日志,clone 一个 String 花的时间是纳秒级,而你的程序寿命是秒级。
  • 为了省一次 clone 而把生命周期签名复杂化,会让所有调用方都付出可读性成本,并且常常在重构时引入 bug。
  • 该避免的是热路径里的 clone:每帧、每条消息、每个请求里的 clone。判断依据是测量,不是教条。

🧠 原理:clone 的代价 = 分配 + 逐元素拷贝(或逐字段拷贝)。如果 T: Copy 且很小(u32[f64; 3]),clone 就是几条 mov,比借用检查的复杂度便宜得多。如果 TVec<String>,clone 是一次 O(n) 深拷贝——这才值得动手。

编译期成本也要观察。泛型 + 单态化会让同一份代码为多个类型各生成一遍,是编译时间膨胀的主因:

powershell
cargo build --timings              # 生成 target/cargo-timings/cargo-timing.html,看哪个 crate 最慢
cargo install cargo-llvm-lines --locked
cargo llvm-lines --release | Select-Object -First 20   # 看哪些泛型实例产生了最多 LLVM IR 行
cargo llvm-lines -Z... # 注意:部分高级选项需要 nightly

cargo llvm-lines 的输出会告诉你「core::fmt 或某个泛型函数被实例化了 200 次,占了 40% 的 IR」。常见对策:把泛型函数里的大块逻辑抽成非泛型的私有函数(用 &dyn Trait 或具体类型),外层泛型只做薄封装;或者对热点实例显式做类型擦除。


优化流程方法论

四步循环,顺序不能乱

测量(measure)→ 找热点(profile)→ 改算法或数据结构(change)→ 再测量(re-measure)

这个循环的意义在于它是一条闭环:不测量你不知道要不要优化;不找热点你不知道优化哪里;跳过改算法只做微优化收效甚微;不再测量你不知道改动是变快还是变慢(很多时候是变慢)。

优化收益的量级排序(这是本章最重要的一张排序):

算法与复杂度  ≫  内存布局与分配次数  ≫  指令级微优化(内联、去边界检查、手动展开)

具体一点:

  • 把 O(n²) 的查找换成 HashMap 的 O(n):可能是 1000 倍
  • 把「循环内反复分配 Vec」改成「循环外复用缓冲区」:常见 2~10 倍
  • Vec<Option<u8>> 换成位图/打包表示,减少 cache miss:1.5~3 倍
  • 去掉一次边界检查、把 clone 换成借用:1.0~1.1 倍(而且在噪声范围内,往往测不出来)。

⚠️ 陷阱不要凭直觉优化。 人的直觉在性能上的命中率很低,而且方向经常是反的:开发者以为瓶颈在算术,实际在分配器;以为 HashMap 慢,实际在哈希函数;以为 clone 贵,实际在系统调用。唯一可信的证据是测量数据。

同一任务三种实现的 criterion 对比(完整结构)

下面这个例子刻意做成「用户会写的三种风格」:朴素写法(每次分配、字符串拼接)、迭代器写法、预分配 + 索引写法。注意它同时演示了「基准该怎么组织」而不是「哪种写法一定赢」——赢家取决于数据规模与机器,必须自己跑。

项目结构:

wordfreq/
├── Cargo.toml
├── src/
│   └── lib.rs
└── benches/
    └── wordfreq_bench.rs
toml
# Cargo.toml
[package]
name = "wordfreq"
version = "0.1.0"
edition = "2024"

[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }

[[bench]]
name = "wordfreq_bench"
harness = false
rust
// src/lib.rs —— 三种实现放在同一个库里,才能被同一个基准公平比较
/// 朴素写法:每个词都新建 String 并拼接,容量靠自动扩容。
pub fn word_starts_naive(text: &str) -> String {
    let mut out = String::new();
    for word in text.split_whitespace() {
        let lower = word.to_ascii_lowercase();   // 每个词一次分配
        out = out + &lower[..1.min(lower.len())]; // 每次拼接都重建 out
    }
    out
}

/// 迭代器写法:表达意图,交给优化器;collect 会读 size_hint 预留容量。
pub fn word_starts_iter(text: &str) -> String {
    text.split_whitespace()
        .filter_map(|w| w.as_bytes().first().map(|b| b.to_ascii_lowercase() as char))
        .collect()
}

/// 预分配 + 索引写法:自己控制容量与字节写入,适合已确认的热点。
pub fn word_starts_prealloc(text: &str) -> String {
    let words: Vec<&str> = text.split_whitespace().collect();
    let mut out = String::with_capacity(words.len());   // 每个词贡献 1 字节
    for w in words {
        if let Some(b) = w.as_bytes().first() {
            out.push((*b as char).to_ascii_lowercase());
        }
    }
    out
}
rust
// benches/wordfreq_bench.rs
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use wordfreq::{word_starts_iter, word_starts_naive, word_starts_prealloc};

fn input(words: usize) -> String {
    // 用运行时构造的输入:避免编译器把整个基准常量折叠掉
    (0..words)
        .map(|i| format!("Word{i} "))
        .collect::<String>()
}

fn compare(c: &mut Criterion) {
    let mut group = c.benchmark_group("word_starts");
    for words in [16usize, 256, 4096] {
        let text = input(words);
        group.throughput(Throughput::Bytes(text.len() as u64));
        // 三个实现放进同一个 group,criterion 直接给出相对比较
        group.bench_with_input(BenchmarkId::new("naive", words), &text, |b, t| {
            b.iter(|| word_starts_naive(black_box(t)))
        });
        group.bench_with_input(BenchmarkId::new("iter", words), &text, |b, t| {
            b.iter(|| word_starts_iter(black_box(t)))
        });
        group.bench_with_input(BenchmarkId::new("prealloc", words), &text, |b, t| {
            b.iter(|| word_starts_prealloc(black_box(t)))
        });
    }
    group.finish();
}

criterion_group!(benches, compare);
criterion_main!(benches);
powershell
cargo bench --bench wordfreq_bench

> 输出(示意;请以自己机器上的实测为准):

text
word_starts/naive/16    time:   [312.40 ns 315.02 ns 318.11 ns]
word_starts/iter/16     time:   [96.512 ns 97.301 ns 98.244 ns]
word_starts/prealloc/16 time:   [88.773 ns 89.402 ns 90.115 ns]
word_starts/naive/256   time:   [4.9120 us 4.9513 us 4.9982 us]
word_starts/iter/256    time:   [1.5321 us 1.5447 us 1.5602 us]
word_starts/prealloc/256 time:  [1.4102 us 1.4218 us 1.4377 us]

怎么读这个结果naive 慢是因为它每个词一次分配、并且每次拼接都重建整个 String(O(n²) 拷贝);iterprealloc 接近,说明在这个任务上优化器已经把迭代器版本优化到接近手写——这正是「零成本抽象」的典型案例。结论不是「永远用 naive」,也不是「永远手写」:默认写 iter 版本,只有基准明确指出它不够快时,才降级到 prealloc 并保持一份对照基准。


内容以 rustc 1.98.1 · Rust 2024 edition 为基准