性能优化
一句话理解
Rust 的性能优势来自零成本抽象 + 无 GC + 明确的内存布局,但这不等于”自动就快”。真正的瓶颈几乎总是这三处:
- 内存分配
- IO 往返(网络、磁盘)
- 锁竞争
所以第一条纪律是:先测量,再优化。凭直觉猜瓶颈的命中率很低。
1. 先测量
| 工具 | 用途 |
|---|---|
| criterion | 函数级基准,带统计显著性 |
| cargo-flamegraph | 生成火焰图,一眼看出热点 |
perf / perf record | Linux 上的系统级采样剖析 |
| hyperfine | 整条命令的耗时对比(CLI 工具必用) |
dhat | 堆分配剖析:谁在分配、分配了多少次 |
| cargo-bloat | 产物里谁占体积 |
Instant + eprintln! | 临时打点,最省事 |
cargo install flamegraph cargo-bloat
cargo flamegraph --release -- your args三个测量纪律
- 必须
--release:用 debug 测性能是最常见的错误(可能差 10 倍以上)- 看相对趋势:机器噪声大,A/B 对比才有意义
- 测真实负载:微基准里
Vec都在缓存中,真实场景常常不是
2. 分配是头号成本
一次堆分配可能比几百次算术还贵。优化顺序:
① 预分配
// ❌ 反复扩容:多次 realloc + 拷贝
let mut v = Vec::new();
for i in 0..n { v.push(i); }
// ✅ 一次分配到位
let mut v = Vec::with_capacity(n);
// ✅ 从迭代器 collect 时给提示(实现 size_hint 即可)
let v: Vec<_> = (0..n).collect();
// 字符串同理
let mut s = String::with_capacity(256);② 复用缓冲,别在循环里新建
// ❌ 每轮都分配
for line in lines {
let s = format!("prefix: {line}");
out.write_all(s.as_bytes())?;
}
// ✅ 复用一个 String(记得 clear 保留容量)
let mut buf = String::new();
for line in lines {
buf.clear();
write!(buf, "prefix: {line}")?;
out.write_all(buf.as_bytes())?;
}③ 避免不必要的 String
fn process(names: &[String]) -> usize // ❌ 调用方必须持有 String
fn process(names: &[&str]) -> usize // ✅ 更通用,无拷贝④ 小对象用栈上容器
| 需求 | crate |
|---|---|
通常很短、偶尔超过阈值的 String | compact_str、smol_str |
元素少但不确定的 Vec | smallvec |
不可变的 String | Box<str>(省掉 capacity 字段,24 → 16 字节) |
3. 集合与哈希
标准库的 HashMap 用 SipHash(抗 HashDoS),但比 FxHash/ahash 慢好几倍:
rustc-hash = "2"use rustc_hash::FxHashMap;
let mut m: FxHashMap<u64, Item> = FxHashMap::default();| 集合 | 适用 |
|---|---|
HashMap | 默认,键来自不可信输入时必须用(抗 HashDoS) |
FxHashMap / AHashMap | 键是整数或内部 ID,追求速度 |
BTreeMap | 需要有序遍历或范围查询 |
IndexMap | 需要保持插入顺序 |
Vec<(K, V)>(小规模) | 元素 < 10 个时,线性查找比哈希更快 |
4. IO 一定要缓冲
use std::io::{BufReader, BufWriter};
// ❌ 逐字节 syscall,慢一个数量级
let f = File::open("big.txt")?;
// ✅
let f = BufReader::new(File::open("big.txt")?);
let out = BufWriter::new(File::create("out.txt")?);网络侧同理:批量、复用连接。数据库的 N+1 查询比任何微优化都致命(见 sqlx 与数据库)。
5. 字符串与格式化
// ❌ format! 每次都分配
for x in items { log(&format!("item {x}")); }
// ✅ 复用缓冲
let mut buf = String::new();
for x in items { buf.clear(); write!(buf, "item {x}")?; log(&buf); }
// ✅ 需要拼接时的正确姿势
let mut s = String::with_capacity(parts.iter().map(|p| p.len()).sum());
for p in parts { s.push_str(p); }format! 在热路径里是隐藏的分配源。日志尤其要注意——用结构化日志(tracing)而不是先拼字符串,因为级别不匹配时它直接跳过格式化。
6. 锁与并发
// ❌ 临界区过大
let guard = m.lock().unwrap();
let result = expensive_compute(&guard); // 别人全在等
drop(guard);
// ✅ 只在临界区里做必要的读写
let snapshot = { m.lock().unwrap().clone() };
let result = expensive_compute(&snapshot);其他要点:
- 分片锁:
dashmap把一个大RwLock<HashMap>拆成多把锁 - 原子类型:简单的计数器用
AtomicUsize比Mutex<usize>快得多 - 读多写少:
RwLock优于Mutex - 数据并行:CPU 密集用
rayon的into_par_iter(),别自己管线程 Arc的原子开销:高频 clone 的Arc会竞争同一缓存行,考虑传引用
7. 编译器与构建选项
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"
strip = "symbols"#[inline] // 跨 crate 的小热点函数
#[cold] // 分支预测提示:很少走到的错误路径lto = "thin"+codegen-units = 1通常能带来 5%~20% 提升(代价是编译变慢)#[inline]不要滥用:它会增加编译时间和体积;只在剖析显示有跨 crate 调用开销时加- PGO / BOLT 是高级手段,收益 5%~15%,构建复杂度高,一般到不了那一步
8. 内存布局的几个事实
- Rust 默认会重排结构体字段以最小化 padding。所以不要为了”好看”加
repr(C)——那会禁掉这个优化(除非是 FFI) - 枚举大小由最大变体决定:大变体用
Box包起来能显著缩小枚举 Option<&T>、Option<Box<T>>、Option<NonZeroU32>都是零开销的(利用 niche 优化),大小与T相同- 字段顺序影响缓存局部性:热字段放前面
// 8 字节而不是 16:NonZero 让 Option 无额外开销
struct Id(Option<std::num::NonZeroU64>);9. unsafe 是最后手段
消除边界检查的正确顺序:
// ① 用迭代器(边界检查通常被完全消除)
for x in &v { sum += x; }
// ② 用 slice 操作一次性拿切片
let s = &v[..n];
// ③ 确实证明必要,才用 get_unchecked(见下一节)
get_unchecked的前置条件它要求你自己保证索引在界内。越界是未定义行为,不是 panic——可能表现为奇怪的静默错误,也可能在某次编译器升级后突然崩溃。详见 unsafe 的正确用法。
10. 常见反模式
| 反模式 | 问题 | 替代 |
|---|---|---|
| debug 构建测性能 | 慢 5~20 倍 | --release |
循环里 format! / clone | 分配爆炸 | 复用缓冲、传引用 |
| 每次请求新建 HTTP client | 丢连接池 | 复用 Client |
| 循环里单条查数据库 | N+1,网络往返主导 | 批量查询 |
unwrap() 满天飞 | 不是性能问题,是稳定性问题 | Result 传播 |
用 Rc<RefCell<T>> 传遍全项目 | 运行时开销 + panic 风险 | 重新设计所有权 |
到处 Vec<u8> 拼接 | 反复扩容 | Vec::with_capacity |
| 过早微优化 | 增加复杂度、降低可读性 | 先剖析,再动手 |
11. 优化流程建议
1. 明确目标:延迟 / 吞吐 / 内存 / 体积?各自的指标是多少?
2. 建立基准:criterion 或 hyperfine,能重复测出同一数字
3. 剖析定位:flamegraph 找 CPU 热点,dhat 找分配热点
4. 只改热点:一次改一处,改完立刻重测
5. 守住正确性:改完跑全部测试(优化常常引入边界 bug)
6. 记下结论:为什么这么改、收益多少,写进 commit message一句话收束
分配、IO、锁是 Rust 程序性能的三个主要战场;测量是唯一的入口。不做测量的优化,本质是在赌。