性能
基准测试、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_box 与 std::hint::black_box 现在功能一致,新代码直接用 std::hint::black_box 即可;criterion::black_box 仍然可用,老示例里常见。
🧠 原理:
black_box(x)是一个恒等函数,但编译器把它当成「读写了未知内存」的屏障:输入侧它假装自己被读过(值不可预测),输出侧假装返回值会被使用(不能被删)。它不保证阻止所有优化,只是极大降低「整段被优化掉」的概率。
⚠️ 陷阱:criterion 默认用
cargo bench的 bench profile(继承自release,但debug-assertions仍为false、overflow-checks一般关闭)。如果你把基准跑在cargo run(dev profile)下,数字会差一个数量级且毫无意义。自定义时显式声明:
toml
[profile.bench]
opt-level = 3
lto = "thin" # 与发布构建保持一致的优化设置,避免测到的不是真实产物
codegen-units = 1
debug = true # 保留符号,方便 perf/flamegraph 看到函数名性能分析:先看火焰图,再动手
| 工具 | 一句话 | 平台 |
|---|---|---|
perf | Linux 内核级采样剖析器,能看到函数级热点与调用栈 | 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⚠️ 注意:
perf和cargo-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 快不快」,不适合回答「这个函数快不快」。两者界限要分清。
基准测试的三个经典陷阱
- 被优化掉:结果没被使用、输入是编译期常量、循环被向量化或删除。对策:
black_box包住输入和输出;用运行时才确定的输入(读文件、命令行参数、随机种子)。 - 测量里混入非目标开销:在
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,
)
});
}- 样本太少 / 环境不干净:只跑 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>的判别式只用了0和1两个值,外层复用剩下的值当自己的 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、&T、Box<T>、Vec<T>都带 niche。所以「把不变量编码进类型」不仅更安全,还常常更省内存——NonZeroU8的Option就是 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 的每一步都会创建一个新 String:a 的所有权被移动、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,比借用检查的复杂度便宜得多。如果T是Vec<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... # 注意:部分高级选项需要 nightlycargo 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.rstoml
# 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 = falserust
// 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²) 拷贝);iter 和 prealloc 接近,说明在这个任务上优化器已经把迭代器版本优化到接近手写——这正是「零成本抽象」的典型案例。结论不是「永远用 naive」,也不是「永远手写」:默认写 iter 版本,只有基准明确指出它不够快时,才降级到 prealloc 并保持一份对照基准。