“过早优化是万恶之源”——这句话被引用了五十年,几乎总是只引用前半句。Knuth 的原话还有后半句:“但我们不应放过在那关键的 3% 里的机会。“工程师的部分恰恰在后半句:问题从来不是”要不要优化“,而是“怎么找到那 3%”。本篇是系列倒数第二站,也是前十二篇所有知识的变现现场:测量先行的纪律、分配这笔看不见的税、亲手验证“零成本抽象”承诺的方法,以及热路径上那几条被 profiler 反复证实的守则。
测量先行:性能工作里唯一重要的循环
性能直觉是程序员身上最不可靠的器官。热点几乎从不在你以为的地方——它在某个被调用了两百万次的字符串拼接里,在某次多余的克隆里,在某个“就一层封装”的序列化里。所以性能工作只有一个循环:
测量 → 定位热点 → 只改一处 → 再测量,保留或回滚,循环。工具按问题分两类:
- 宏观:“时间去哪了?” —— profiler 与火焰图(
cargo flamegraph、samply、perf)。改代码之前先跑它,这是找到那 3% 的唯一可靠方法。 - 微观:“这个函数变快了吗?” —— 基准测试。
cargo bench配合 criterion crate(统计严谨、防优化器作弊、自动对比历史),是验证第 2、3 步的量杯。
两条铁律焊死在这个循环上:永远用 --release 测量(第 8 篇回收——debug 与 release 差 10–100 倍,拿 debug 数据做优化决策等于蒙眼开车);每次只改一处——同时改两处然后变快,你永远不知道哪处立功,更不知道另一处是不是其实在拖后腿。
分配是看不见的税
Rust 没有 GC 停顿,但堆分配依然是税:一次 malloc 是几百个时钟周期的系统账本,而它在代码里常常隐形——format!、to_string()、Vec::push、每个 .clone()。复利最狠的形式是重复再分配。实测给你看(64 位平台):
let mut v: Vec<i32> = Vec::new();
let mut last = 0;
for i in 0..20 {
v.push(i);
if v.capacity() != last {
println!("len {:>2} → capacity {}", v.len(), v.capacity());
last = v.capacity();
}
}
// len 1 → capacity 4
// len 5 → capacity 8
// len 9 → capacity 16
// len 17 → capacity 32
20 次 push,4 次分配,3 次全体拷贝(4+8+16 个元素搬了三次家)。策略是翻倍——这让单次 push 摊还到 O(1),设计已经很好;但如果你知道要装 20 个,Vec::with_capacity(20) 一次分配、零次拷贝,阶梯整段跳过。同样的税适用于 String、HashMap 和你拥有的每个可增长缓冲区。热路径上的写法因此固定下来:循环外建 buffer、循环内复用;知道尺寸就预分配;拼接字符串用 push_str 而不是循环里 format!(每次 format! 都是一个新分配)。
亲手验证“零成本”
第 5 篇承诺迭代器是零成本抽象。别信——去量,然后读汇编。取一百万个数,过滤偶数、平方、求和,两种写法:
// 迭代器链
data.iter().filter(|x| *x % 2 == 0).map(|x| x * x).sum()
// 手写索引循环
let mut acc = 0u64;
for i in 0..data.len() {
let x = data[i];
if x % 2 == 0 { acc += x * x; }
}
acc
release 模式下实测:迭代器链 4.0ms,手写循环 5.1ms(20 次运行)——链式写法持平甚至更快。原因是个真实的、可见的优化:迭代器内部消除了边界检查,而索引循环里每次 data[i] 都带一次。零成本不是“和手写一样快”的口号,它偶尔是“比手写更快”的事实。
自查工具两件:cargo asm 直接看某个函数编译出的汇编;Compiler Explorer(godbolt.org)把两种写法贴进去左右对比。第一次亲眼看到 filter+map+sum 坍缩成一个紧凑的向量化循环,“零成本”三个字就从信仰变成了证据——这是你对自己代码做性能判断时该有的证据标准。当然,反过来也成立:当你发现某段抽象没有坍缩(典型信号:Box<dyn Trait> 的热调用、不必要的 collect 中间集合),那就是测量驱动优化的入场券。
热路径五条守则
全部来自前十二篇,这里收拢成守则:
- 循环内零分配。 buffer 在循环外创建、循环内复用;知道尺寸就
with_capacity。 - 读引用,不克隆。 第 2 篇的
.clone()泄压阀,在热路径上逐个检修——每一处都是一笔分配税。 - 迭代器优于索引循环。 更易读,还顺带消除边界检查;只有 profiler 证明反例时才回头。
- I/O 必加缓冲。
BufReader/BufWriter把“每行一次系统调用”变成“每 8KB 一次”——这是标准库里性价比最高的一行改动。 unsafe是最后一步,不是第一步。 第 11 篇的门依然在那里——get_unchecked省掉边界检查之前,先让 profiler 作证这次检查真的是瓶颈。十次里有九次,它不是。
练习,以及第 14 篇
- 亲手复现扩容阶梯:跑本篇的 capacity 实验,然后把
Vec::new()换成Vec::with_capacity(20),确认输出只剩一行。再把元素换成String,想想每次再分配的拷贝成本变了没有。 - 复现迭代器 vs 手写循环的基准(记得
--release和black_box)。然后把filter去掉只留map,预测结果再验证——培养“先预测、后测量”的习惯,这是测量纪律的内化。 - 找一个你写过的“循环里
format!拼接”的代码(或现在写一个),改成push_str复用String,用 criterion 或简单计时对比。把省下的纳秒数写在注释里——这是你的第一份性能工程战报。
第 14 篇,收官:生态选型与远航。web 框架、序列化、CLI、日志、错误处理的选型框架,“如何读一个 crate”的尽调清单(下载量之外的五个信号),以及从第 1 篇的 cargo new 到这里——借用检查器从对手变成导师之后,一个 Rustacean 接下来的路。