先给结论:留一半数据,反而最慢

同样是过滤 100 万个浮点数,你猜哪种情况最慢?答案反直觉:只留下一半的时候。不是代码写得差,而是 CPU 的分支预测器被逼成了掷硬币的赌徒——每两个元素就猜错一次,每次猜错罚掉 15-20 个时钟周期。

把那个 if 删掉、换成一行算术,最坏情况直接快 3-4 倍,而且耗时从此和数据分布无关。

这个实验来自 Serhii Potapov 8 月 2 日的博文,在 Hacker News 上拿了 290 多个赞 。光看别人的数据不过瘾,我在自己机器上(Windows 10 x64 / i5-12400F / rustc 1.97.1)完整复现了一遍。结论成立,本文所有标注"实测"的数据都是这台机器跑出来的真实结果。

quote-card

实验:一个谁都会写的过滤器

问题很朴素:过滤一个 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%~10k0.59 ms
25%~250k2.69 ms
50%~500k3.94 ms(最慢)
75%~750k2.75 ms
99%~990k1.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 ms0.73 ms慢 1.5 倍
25%2.92 ms1.11 ms快 2.6 倍
50%5.11 ms1.50 ms快 3.4 倍
75%4.81 ms1.72 ms快 2.8 倍
99%3.84 ms2.02 ms快 1.9 倍

(我的机器实测,release 构建,25 次取最小值;原作者机器上最坏情况快约 3.8 倍,“接近 4 倍"的说法由此而来。)

两个值得注意的地方:

**第一,无分支那一列几乎是平的。**耗时不再依赖数据分布——这正是我们要的效果。它随输出规模缓慢上涨,因为每个元素都要写一次内存。

第二,代价真实存在。留 1% 时普通写法赢了:一个几乎永远猜对的分支约等于免费,而无分支写法傻乎乎地写了一百万次。无分支不是普遍意义上的更快,它是用最好情况换最坏情况

还有个诚实的观察:LLVM 没有自动帮你做这件事——否则两列数据不会差出 3 倍。对这种"写入位置依赖数据"的循环,编译器非常保守,不会擅自改写内存行为。这个优化目前还得靠人。

普通写法 vs 无分支写法实测对比

什么时候该用,什么时候别用

大多数时候,别用。无分支代码更难读,也更容易写错。而且编译器本身会很多花样,很多场景它已经默默处理掉了。

只有一种情况值得出手: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 的朋友,转发给他们——特别是那些filter 写在热路径上的人,说不定能帮他们省下一次深夜 profiling。有什么问题或者想聊的,评论区等你。