性能优化

一句话理解

Rust 的性能优势来自零成本抽象 + 无 GC + 明确的内存布局,但这不等于”自动就快”。真正的瓶颈几乎总是这三处:

  1. 内存分配
  2. IO 往返(网络、磁盘)
  3. 锁竞争

所以第一条纪律是:先测量,再优化。凭直觉猜瓶颈的命中率很低。

1. 先测量

工具用途
criterion函数级基准,带统计显著性
cargo-flamegraph生成火焰图,一眼看出热点
perf / perf recordLinux 上的系统级采样剖析
hyperfine整条命令的耗时对比(CLI 工具必用)
dhat堆分配剖析:谁在分配、分配了多少次
cargo-bloat产物里谁占体积
Instant + eprintln!临时打点,最省事
cargo install flamegraph cargo-bloat
cargo flamegraph --release -- your args

三个测量纪律

  1. 必须 --release:用 debug 测性能是最常见的错误(可能差 10 倍以上)
  2. 看相对趋势:机器噪声大,A/B 对比才有意义
  3. 测真实负载:微基准里 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
通常很短、偶尔超过阈值的 Stringcompact_str、smol_str
元素少但不确定的 Vecsmallvec
不可变的 StringBox<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 程序性能的三个主要战场;测量是唯一的入口。不做测量的优化,本质是在赌。

相关笔记