先给结论:留一半数据,反而最慢
同样是过滤 100 万个浮点数,你猜哪种情况最慢?答案反直觉:只留下一半的时候。不是代码写得差,而是 CPU 的分支预测器被逼成了掷硬币的赌徒——每两个元素就猜错一次,每次猜错罚掉 15-20 个时钟周期。
把那个 if 删掉、换成一行算术,最坏情况直接快 3-4 倍,而且耗时从此和数据分布无关。
这个实验来自 Serhii Potapov 8 月 2 日的博文,在 Hacker News 上拿了 290 多个赞 。光看别人的数据不过瘾,我在自己机器上(Windows 10 x64 / i5-12400F / rustc 1.97.1)完整复现了一遍。结论成立,本文所有标注"实测"的数据都是这台机器跑出来的真实结果。

实验:一个谁都会写的过滤器
问题很朴素:过滤一个 f64 切片,返回大于阈值的元素。数据库引擎每天就在干这件事。正常写法:
pub fn filter_iter(input: &[f64], threshold: f64) -> Vec<f64> {
input.iter().copied().filter(|&x| x > threshold).collect()
}
输入是 100 万个均匀分布在 0.0..100.0 的随机数。阈值分别取到让过滤器留下 1%、25%、50%、75%、99% 的元素。原作者在 i7-10875H 笔记本上用 criterion 跑出来的结果是:
| 保留比例 | 输出规模 | 耗时 |
|---|---|---|
| 1% | ~10k | 0.59 ms |
| 25% | ~250k | 2.69 ms |
| 50% | ~500k | 3.94 ms(最慢) |
| 75% | ~750k | 2.75 ms |
| 99% | ~990k | 1.49 ms |
我的机器(i5-12400F,release 构建,每组取 25 次最小值)跑出来的形状一样:
| 保留比例 | 耗时 |
|---|---|
| 1% | 0.49 ms |
| 25% | 2.92 ms |
| 50% | 5.11 ms(最慢) |
| 75% | 4.81 ms |
| 99% | 3.84 ms |
盯着 50% 那一行看:只复制一半的元素,却是所有情况里最慢的。留 99% 意味着复制几乎两倍的数据,反而更快。输入一样多,输出多少解释不了这个时间差——有别的东西在作怪。
先排除一个嫌疑人:Vec 扩容
老 Rust 玩家的第一反应:collect() 不知道输出大小,Vec 一路扩容重新分配,先预分配再说:
pub fn filter_prealloc(input: &[f64], threshold: f64) -> Vec<f64> {
let mut out = Vec::with_capacity(input.len());
for &x in input {
if x > threshold {
out.push(x);
}
}
out
}
50% 场景实测:4.42 ms,比普通写法(5.11 ms)快了 14%。有改善,但显然没碰到真正的瓶颈。重新分配确实存在,但它不是主角。
CPU 在你背后"猜"分支
现代 CPU 不是一条一条执行指令的。它有一条很深的流水线:一条指令还在执行,后面几十条已经在取指、译码了。这套机制运转得很好,直到指令流里出现一个岔路口:
if x > threshold { /* 保留 */ } else { /* 跳过 */ }
往哪边走?比较指令跑完之前,CPU 不知道。但它拒绝干等——它会猜,然后沿着猜的那条路投机地往前跑。负责猜测的硬件叫分支预测器(branch predictor)。
猜对了,流水线满载,一切免费。猜错了,代价很大:已经投机执行的部分全部作废,流水线排空重来,在典型的现代 x86 核心上大约 15-20 个时钟周期。而那次比较本身只要 1 个周期左右。
原作者打了个比方:预测器像咖啡师,你刚进门他就开始做你常点的那杯。如果你是熟客,体验完美;如果你每天随机点单,他就不停地把做好的咖啡倒进水槽。
现在那张表说得通了:
- 留 1%:答案几乎永远是"跳过",预测器猜"跳过",99% 的时候都对,几乎免费
- 留 99%:同理,方向反过来而已
- 乱序数据留 50%:没有任何规律可学,预测器退化成掷硬币,每两个元素错一次。50 万次流水线冲刷,每次 15-20 周期——在 4 GHz 的核心上光罚时就接近 2 ms,正好是表格里的差距
注意,罪魁祸首不是分支本身,而是依赖不可预测数据的分支。这个推论指向一个好玩的实验。
实锤:同样的代码,排序后快 2.8 倍
如果预测失败是元凶,那么数据、阈值、代码都不动,只改元素顺序,结果就应该大变。把输入排序(排序本身不计时),重跑 50% 场景:
| 输入 | 耗时 |
|---|---|
| 乱序 | 5.20 ms |
| 已排序 | 1.87 ms |
同样是那 100 万个浮点数,同样的函数,快 2.8 倍(原作者机器上是 4.5 倍)。排序后前半段分支一直说"跳过"、后半段一直说"保留",这种规律连最笨的预测器猜错一次也能学会。
Stack Overflow 上那个 2.7 万赞的著名问题 “Why is processing a sorted array faster than processing an unsorted array?” 说的就是同一个效应。
当然,排序不是解法:排序本身比过滤还贵,而且业务上通常需要保持原始顺序。但现在我们知道该修的到底是什么了——能不能让数据保持乱序,又不给 CPU 掷硬币的机会?

无分支写法:把"决定"变成"算术"
无分支编程(branchless programming)的思路是:把那个不可预测的分支整个删掉,没有什么可猜的。不再"决定要不要写这个元素",而是每个元素都写,用比较结果决定下一个元素写到哪:
pub fn filter_branchless(input: &[f64], threshold: f64) -> Vec<f64> {
let mut out = vec![0.0; input.len()];
let mut n = 0;
for &x in input {
out[n] = x;
n += (x > threshold) as usize;
}
out.truncate(n);
out
}
细品这个技巧:
- 每个元素都无条件写到
out[n] (x > threshold) as usize保留时是 1,丢弃时是 0- 保留,游标
n前进;丢弃,下一次循环直接覆盖掉刚写的值 - 循环结束时
n就是保留元素个数,truncate(n)切掉尾部的垃圾
比较还在,但它的结果现在被当成数值用,而不是决定程序往哪走。用编译器的语言说:我们把控制依赖转成了数据依赖。生成的汇编里,比较变成一条 seta 指令,老老实实产出 0 或 1,不再有岔路,自然也没什么可猜错的。
细心的读者会挑刺:out[n] = x 有边界检查,循环条件也是分支。没错,但这些分支一百万次里走向完全一致,预测器处理它们不收钱。只有那个不可预测的分支必须走。
实测:最坏情况快 3.4 倍,代价在最好情况
| 保留比例 | 普通写法 | 无分支写法 | 倍数 |
|---|---|---|---|
| 1% | 0.49 ms | 0.73 ms | 慢 1.5 倍 |
| 25% | 2.92 ms | 1.11 ms | 快 2.6 倍 |
| 50% | 5.11 ms | 1.50 ms | 快 3.4 倍 |
| 75% | 4.81 ms | 1.72 ms | 快 2.8 倍 |
| 99% | 3.84 ms | 2.02 ms | 快 1.9 倍 |
(我的机器实测,release 构建,25 次取最小值;原作者机器上最坏情况快约 3.8 倍,“接近 4 倍"的说法由此而来。)
两个值得注意的地方:
**第一,无分支那一列几乎是平的。**耗时不再依赖数据分布——这正是我们要的效果。它随输出规模缓慢上涨,因为每个元素都要写一次内存。
第二,代价真实存在。留 1% 时普通写法赢了:一个几乎永远猜对的分支约等于免费,而无分支写法傻乎乎地写了一百万次。无分支不是普遍意义上的更快,它是用最好情况换最坏情况。
还有个诚实的观察:LLVM 没有自动帮你做这件事——否则两列数据不会差出 3 倍。对这种"写入位置依赖数据"的循环,编译器非常保守,不会擅自改写内存行为。这个优化目前还得靠人。

什么时候该用,什么时候别用
大多数时候,别用。无分支代码更难读,也更容易写错。而且编译器本身会很多花样,很多场景它已经默默处理掉了。
只有一种情况值得出手:profiler 明确指向一个热点循环,且循环里有一个依赖不可预测数据的分支。顺序永远是:先测量,再优化。没有 profile 数据的性能优化都是猜。
反过来说,如果你在写数据库引擎、列式扫描、SIMD 过滤这类"数据分布由用户决定"的代码,无分支写法把最坏情况变成常态,是值得放进工具箱的。
对/错示例:两个常见的坑
坑一:假无分支。 不少人会把"消灭 if"理解成这样:
// ❌ 错的:分支还在,还多一次 pop
for &x in input {
out.push(x);
if x <= threshold {
out.pop();
}
}
这个 if 依然是那个依赖不可预测数据的分支,猜错一次照样罚 15-20 周期,pop 还多一次边界检查——它比最朴素的写法还慢。“看起来像无分支"和"真的是无分支"是两回事。真正的无分支,是让写入位置由算术决定,代码里根本没有岔路:
// ✅ 对的:比较结果变成数值,没有分支可猜
out[n] = x;
n += (x > threshold) as usize;
坑二:在可预测的分支上硬用无分支。 如果数据 99% 都会被丢弃,分支几乎永远说"丢”,预测器一猜一个准,普通写法就是最优解,换了反而倒退:
// ❌ 错的:留 1% 场景硬用无分支,0.73 ms
let out = filter_branchless(&input, 99.0);
// ✅ 对的:分支好猜就老实写 if,0.49 ms,快 1.5 倍
let out = filter_iter(&input, 99.0);
判断标准一句话:分支难猜才换算术,分支好猜就老实写 if。
完整参考代码:复制下来就能跑
上面所有数字都不需要相信我。下面这份代码就是本文 benchmark 的本体——不依赖任何第三方 crate,存成 main.rs 一行命令复现:
rustc -O main.rs && ./main
use std::hint::black_box;
use std::time::Instant;
/// 固定种子 xorshift64*,生成 0.0..100.0 均匀分布的 f64
struct Rng(u64);
impl Rng {
fn next_f64(&mut self) -> f64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
let u = x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11;
u as f64 / (1u64 << 53) as f64 * 100.0
}
}
/// 普通写法:迭代器 + collect
pub fn filter_iter(input: &[f64], threshold: f64) -> Vec<f64> {
input.iter().copied().filter(|&x| x > threshold).collect()
}
/// 预分配写法:先排除 Vec 扩容嫌疑
pub fn filter_prealloc(input: &[f64], threshold: f64) -> Vec<f64> {
let mut out = Vec::with_capacity(input.len());
for &x in input {
if x > threshold {
out.push(x);
}
}
out
}
/// 无分支写法:每个元素都写,用比较结果决定写到哪
pub fn filter_branchless(input: &[f64], threshold: f64) -> Vec<f64> {
let mut out = vec![0.0; input.len()];
let mut n = 0;
for &x in input {
out[n] = x;
n += (x > threshold) as usize;
}
out.truncate(n);
out
}
/// 每种写法跑 25 次取最小值,black_box 防止编译器把循环优化掉
fn bench(name: &str, f: fn(&[f64], f64) -> Vec<f64>, input: &[f64], thr: f64) {
let mut best = f64::MAX;
for _ in 0..25 {
let t = Instant::now();
let out = f(black_box(input), black_box(thr));
let el = t.elapsed().as_secs_f64() * 1000.0;
black_box(&out);
if el < best {
best = el;
}
}
println!(" {name:<22}{best:>7.2} ms");
}
fn main() {
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
let input: Vec<f64> = (0..1_000_000).map(|_| rng.next_f64()).collect();
println!("== 乱序数据,不同保留比例 ==");
for keep in [1.0, 25.0, 50.0, 75.0, 99.0] {
let thr = 100.0 - keep; // x > thr 即留下 keep%
println!("留 {keep}% (阈值 {thr}):");
// 先校验:三种写法输出必须逐字节一致,优化错了不叫优化
assert_eq!(filter_iter(&input, thr), filter_branchless(&input, thr));
assert_eq!(filter_iter(&input, thr), filter_prealloc(&input, thr));
bench("filter_iter", filter_iter, &input, thr);
bench("filter_prealloc", filter_prealloc, &input, thr);
bench("filter_branchless", filter_branchless, &input, thr);
}
println!("\n== 同样的代码,排序 vs 乱序(留 50%)==");
let mut sorted = input.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
bench("iter(乱序)", filter_iter, &input, 50.0);
bench("iter(已排序)", filter_iter, &sorted, 50.0);
}
在你的机器上跑一次,抛物线、排序加速、无分支压平曲线,三个现象都会原样出现。我在这台 i5-12400F 上跑这份代码,得到的就是上文表格里的数字。
FAQ
什么是无分支编程(branchless programming)? 把"是否执行"的控制流判断,改写成"永远执行、用计算结果决定数据去向"的写法,让代码里不再存在依赖数据的分支,CPU 分支预测器也就无从猜错。
无分支写法一定更快吗? 不是。分支走向容易预测时(比如保留 1% 或 99% 的数据),普通写法几乎零成本,无分支写法反而因为每个元素都要写一次而更慢。它是用最好情况换最坏情况。
怎么判断我的代码该不该做无分支优化? 先用 profiler 找到热点循环,确认循环里存在"依赖不可预测数据的分支”,再考虑改写。顺序永远是:先测量,再优化。
资料出处
本文的实验创意和"原作者数据"来自 Serhii Potapov 2026 年 8 月 2 日的博文《Branchless Rust: Making a Filter 4x Faster by Removing an if》(发布于 greyblake.com,附 criterion benchmark 仓库 branchless-rust-benchmarks),该帖 2026 年 8 月 10 日登上 Hacker News 首页,获 290 多个赞。分支预测罚时(15-20 周期)等硬件常识性数字来自原文及其引用资料——Daniel Lemire 的分支预测系列文章、Fedor Pikus 在 CppCon 2021 的演讲《Branchless Programming in C++》。“排序后处理更快"的经典讨论,见 Stack Overflow 上 2.7 万赞的问题 “Why is processing a sorted array faster than processing an unsorted array?"。所有标注"实测"的数字,均为本机(Windows 10 x64 / i5-12400F / rustc 1.97.1)运行上一节参考代码的结果。
相关阅读
如果你对 Rust 性能感兴趣,这几篇文章你可能也会喜欢:
- Rust 1.97.1 紧急修复:编译器误编译让程序悄悄段错误,从 1.87 就潜伏 - 编译器优化翻车的真实案例
- 你的 Rust 项目只有 8000 行代码,rust-analyzer 却啃了 250 万行依赖 - 编辑器卡顿根源与提速实战
- Rust性能优化踩坑:当Rust跑输Python的惨痛教训 - 另一个反直觉的性能话题
- Rust性能优化:7个高手都在用的冷门特性 - 进阶 Rust 技巧合集
觉得这篇文章有用? 点个赞让更多人看到,收藏起来下次优化热循环的时候翻出来对照。如果你身边也有写 Rust 的朋友,转发给他们——特别是那些filter 写在热路径上的人,说不定能帮他们省下一次深夜 profiling。有什么问题或者想聊的,评论区等你。
