二分搜索在 Rust 里做极限优化,真正值得聊的并不是把复杂度从 log n 再降一档,而是让算法的执行节奏和 CPU 的行为尽量对齐:分支预测、缓存加载、向量化,一个都跑不掉。不管你是刚开始做 Rust 语言入门,还是在写 tauri + rust 桌面应用里的高频搜索路径,二分搜索都很可能是那个不起眼却反复执行的热点。这个话题适合两类人:一类是写性能敏感模块的工程师,想把手里的查找函数压到极限;另一类是已经把标准库 binary_search 用到顺手、但好奇“继续优化到底还能快多少”的开发者。下面按我实测时的正常顺序来:先定基线,再逐个上优化,最后说什么时候该收手。
1. 先说清楚:这个优化到底在优化什么
很多人一想到二分搜索,第一反应是“比较次数已经很少了,还能怎么优化”。这个判断对了一半。比较次数确实只有 log n 级别,但真正拖慢经典二分的往往不是算术比较本身,而是 CPU 在执行这段循环时付出的隐藏代价。要理解优化目标,先得把二分搜索在真实硬件上发生了什么拆开看。
1.1 从“机械共鸣”说起
“机械共鸣”这个标题听起来比较玄,其实它描述的是一个很具体的现象:如果一段代码的执行模式正好贴合 CPU 的流水线、分支预测器和缓存预取器,这段代码就会跑得异常顺,像机器和你正好在一个节拍上。反过来,如果代码老是让 CPU 猜错方向或者反复访问散落的内存,硬件再强也救不回来。
二分搜索就是一个典型的“硬件不友好”算法。每次迭代都要跳到一个新的 mid 位置,而这个位置大概率不在当前缓存行里。数组越大,cache miss 越频繁。你以为是算法在计算,实际上是 CPU 在等你从内存里把数据拿回来。真正的瓶颈根本不显眼,但它一直卡在循环里。
所以后面所有优化手段,本质都是在跟机器“共振”:减少分支方向的不确定性,提前把可能用到的数据拉进缓存,让编译器生成更贴近当前 CPU 指令集的代码。理解了这一点,再去看 branchless、prefetch、SIMD 预筛选这些方案,就不会觉得它们是花活。
1.2 二分搜索的真实瓶颈不是比较次数
二分搜索里有两个隐藏成本,比比较本身更值得关注。
第一个是缓存缺失。标准库 binary_search 在数组规模一千万以上、查询目标随机时,每一次访问 mid 都几乎是一次缓存未命中。64 字节一个缓存行,数组元素越大,一次缓存行能装下的元素越少,情况越明显。你可以用 perf stat 看 cache-misses 指标,通常会发现它比 branch-misses 高一个数量级。
第二个是分支预测失败。循环里每次都要判断走左边还是右边,如果 target 分布随机,分支预测器基本是猜一半错一半。每猜错一次,流水线就要清空重新来,这个惩罚通常比一次普通比较贵得多。branchless 二分就是冲着这个去的。
这里有个容易混淆的点:标准库 binary_search 不是“慢实现”,它在大多数规模下已经不错,只是它优先保证通用性和正确性。我们自己写优化版,是为了在特定数据分布和特定机器环境下,把上面两个隐藏成本进一步压掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搭环境、建立基线,不要直接改算法
我见过不少人一上来就写无分支版本,结果还跑不过标准库,最后排查半天发现是编译配置或者基准数据有问题。正确的顺序是先做出一个可复现的基线,再决定要不要优化。
2.1 环境准备与依赖配置
先把环境确认好。我这边用的是 Linux + x86-64 机器,rustc 版本建议在 1.70 以上。如果你还没有完成 rust 安装,直接用 rustup 装默认工具链就行,cargo 会一起带上。
bash复制rustc --version
cargo --version
接下来新建一个测试项目:
bash复制cargo new rust-binary-search-bench
cd rust-binary-search-bench
基准测试我用 Criterion,它比手工 println 打时间戳靠谱很多,会自动做预热、统计置信区间和多次采样。Cargo.toml 大致这样写:
toml复制[package]
name = "rust-binary-search-bench"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "binary_search"
harness = false
依赖版本不需要手动锁死,cargo 会按语义化版本解析。这里如果安装失败,先更新索引再重试。
2.2 一个可复现的经典实现
基线的代码越朴素越好,不要放技巧,否则后面对比时搞不清差距来自哪里。先写一个标准的左闭右开二分:
rust复制pub fn binary_search_baseline<T: Ord>(arr: &[T], target: &T) -> Option<usize> {
let mut left = 0usize;
let mut right = arr.len();
while left < right {
let mid = left + (right - left) / 2;
match arr[mid].cmp(target) {
core::cmp::Ordering::Less => left = mid + 1,
core::cmp::Ordering::Greater => right = mid,
core::cmp::Ordering::Equal => return Some(mid),
}
}
None
}
为什么要用 left + (right - left) / 2,而不是 (left + right) / 2?原因很简单:在 usize 下,两个很大的索引相加可能溢出。左闭右开的写法让 right 始终表示边界而不是有效元素,语义上更干净,也更容易保证 right - left 非负。
这个实现里 match arr[mid].cmp(target) 经过编译器优化后会生成分支跳转。这不是坏事,它就是我们要的基线形态。
2.3 测基线时重点看三个指标
基线测出来之后,不要只看“一秒钟能查多少次”,还要关注另外两个东西:延迟分布和硬件计数器。
延迟分布看 p50、p99。二分搜索最怕的不是平均慢,而是单次查询偶发慢很多。缓存未命中、分支预测失败都可能导致长尾抖动。
硬件计数器用 perf 看:
bash复制perf stat -e cache-misses,branch-misses,instructions,cycles ./target/release/bench
测试程序如果体积大,用 perf stat 不方便,可以在 Criterion 里先跑一个长时间循环。重点是看 cache-misses 和 branch-misses 的量级,不要凭直觉猜哪里慢。
Criterion 的最小样例可以先写成这样:
rust复制use criterion::{black_box, Criterion, Throughput};
use rust_binary_search_bench::binary_search_baseline;
fn bench_baseline(c: &mut Criterion) {
let data: Vec<u64> = (0..10_000_000).map(|x| x * 3 + 1).collect();
let queries: Vec<u64> = (0..100_000)
.map(|i| data[(i as usize * 7919) % data.len()])
.collect();
let mut group = c.benchmark_group("binary_search");
group.throughput(Throughput::Elements(queries.len() as u64));
group.bench_function("baseline", |b| {
b.iter(|| {
for q in &queries {
black_box(binary_search_baseline(&data, black_box(q)));
}
})
});
group.finish();
}
criterion::criterion_group!(benches, bench_baseline);
criterion::criterion_main!(benches);
这里有两个关键点。一是查询序列不要用连续的 0..n,否则每次搜索都落在附近区域,cache 命中率异常高,测出来偏快。我一般会生成一组乱序查询,或者用素数取模制造均匀散列。二是 black_box 不能去掉,它防止编译器把输入和结果当成常量直接算完。
3. 几个真正有效的优化方向
基线跑通之后,再开始动算法。下面三个方向是我实际测下来比较常见的优化手段。需要先说清楚:它们都是针对特定瓶颈的,不是每个场景都有效。
3.1 无分支二分:降低分支预测失败
无分支二分的思路是尽量把 if/else 改写成算术选择。当分支预测器频繁猜错时,这样可以避免流水线清空。它尤其适合 target 分布接近随机、数组规模较大的场景。
给一个可以跑的无分支 lower_bound 实现,专门针对 u32 数组:
rust复制pub fn branchless_lower_bound(arr: &[u32], target: u32) -> usize {
let mut base = 0usize;
let mut len = arr.len();
while len > 1 {
let half = len / 2;
let mid = base + half;
let go_right = (arr[mid] < target) as usize;
base += go_right * (half + 1);
len -= go_right * (half + 1) + (1 - go_right) * (len - half);
}
if base < arr.len() && arr[base] < target {
base += 1
}
base
}
这段代码的逻辑是:每次固定从窗口中间取一个元素比较,如果它小于 target,就把 base 跳到右半边起点,同时窗口缩小到右半边;否则 base 不动,窗口缩小到左半边。go_right 只有 0 和 1 两个值,乘法和加减最终把两个分支合并成同一条指令路径。
它返回的是第一个大于等于 target 的位置,也就是标准库里 partition_point 的语义。如果想做等值二分搜索,可以基于这个位置判断 base < arr.len() && arr[base] == target。
这个实现的缺点是可读性明显下降。如果你在代码评审里用了它,旁边一定要写清楚等价语义和测试覆盖,否则三个月后没人敢动。
3.2 缓存行预取:提前把左右区间拉进来
分支预测优化掉之后,下一个瓶颈就是前面说的缓存缺失。经典二分每次跳到一个新位置,这个位置附近的缓存行大概率不在 L1/L2 里。如果能在访问当前 mid 的同时,把左右下一轮可能用到的位置提前加载进缓存,性能会好看一些。
x86-64 下可以用 _mm_prefetch。一个比较保守的做法是预取 mid 前后两个固定偏移:
rust复制#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::{_mm_prefetch, _MM_HINT_T0};
pub fn prefetch_lower_bound(arr: &[u32], target: u32) -> usize {
let mut base = 0usize;
let mut len = arr.len();
while len > 1 {
let half = len / 2;
let mid = base + half;
unsafe {
let left_addr = arr.as_ptr().add(mid.saturating_sub(8)) as *const i8;
let right_addr = arr.as_ptr().add(mid.saturating_add(8)) as *const i8;
_mm_prefetch(left_addr, _MM_HINT_T0);
_mm_prefetch(right_addr, _MM_HINT_T0);
}
let go_right = (arr[mid] < target) as usize;
base += go_right * (half + 1);
len -= go_right * (half + 1) + (1 - go_right) * (len - half);
}
if base < arr.len() && arr[base] < target {
base += 1
}
base
}
注意 _mm_prefetch 是 x86 专用指令,在其他架构上要用条件编译控制。另外,预取只是“提前拉数据”,并不会阻止后续真正的访问。它不一定立刻带来收益,甚至可能因为占用内存带宽而变慢。实际测试时,如果数据规模没有明显超过 L2 缓存,预取优化通常会看到负收益。
实测时建议这样判断:先跑数据量 1 万、100 万、1000 万三档基准,看预取版本是不是只在某个区间有优势。如果没有,直接去掉,不要觉得“指令用了就不能删”。
3.3 SIMD 预筛选:适合特定规模的数据分布
SIMD 在二分搜索里的地位比较尴尬。标准二分每一步只有一次有效比较,SIMD 优势发挥不出来。但如果 target 的分布相对固定,比如大量查询集中在数组靠后的区域,可以先做一个分块预筛选:一次跳过一段连续数据,把窗口快速推到目标附近,再切回经典二分。
这个思路其实更接近“块搜索 + 二分”,而不是纯二分。示意代码如下:
rust复制// 示意:每次快速跳过 16 个元素
const LANES: usize = 16;
pub fn block_search_then_binary(arr: &[u32], target: u32) -> usize {
let mut start = 0usize;
while start + LANES < arr.len() && arr[start + LANES - 1] < target {
start += LANES;
}
start + binary_search_baseline(&arr[start..], &target).unwrap_or(arr.len())
}
这只是块跳过版本,真正的 SIMD 优化会把这 16 个元素的比较换成向量指令。收益如何取决于查询模式。如果 target 均匀分布,块跳过几乎没有意义,反而多跑了一段循环。只有当数据分布明显偏斜,或者查询集中在某个区间时,才值得继续把它向量化。
我在实际项目里很少把 SIMD 直接写进二分搜索,更多是在更高层的数据结构中做批量筛选,比如把多个候选项一起比较。这里给出这个方向,是为了避免一种误判:看到“SIMD 二分”就觉得一定更快。它是有适用边界的,测试时一定要按数据分布分档对比。
4. unsafe 边界、编译器配置与正确性
性能优化进行到一定程度,总会有人尝试 get_unchecked。这一步要非常谨慎,因为它能去掉的边界检查未必是瓶颈,真正引入的风险却可能覆盖整个搜索函数。
4.1 什么时候适合用 get_unchecked
Rust 切片访问自带边界检查,索引越界会直接 panic。这个检查在大多数情况下代价不大,在循环热路径中,它有时会阻止编译器做更深层的优化。
如果你已经像上面 branchless 代码那样,通过循环结构保证了 mid < base + len <= arr.len(),那就可以在注释里写明约束后使用 get_unchecked:
rust复制let current = unsafe { *arr.get_unchecked(mid) };
// 安全条件:mid = base + half,且 half < len,所以 mid < base + len <= arr.len()
这不是建议“所有索引都换 unsafe”,而是只在你通过不变量能证明合法的情况下替换。二分搜索的不变量比较直观,适合做这种替换。但如果你动过循环条件,或者把切片换成了任意集合,这个不变量就可能被破坏。
我一般会同时做两件事:一是单元测试覆盖边界,二是保留一个 debug_assert 在 debug 模式下检查索引:
rust复制debug_assert!(mid < arr.len());
let current = unsafe { *arr.get_unchecked(mid) };
这样 release 跑性能,debug 跑正确性,两边都能照顾到。
4.2 编译器配置比算法改动更容易被忽略
很多性能优化在默认配置下完全看不到效果,因为编译器还没有把该做的内联和跨函数优化打开。建议先检查 release profile:
toml复制[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
debug = false
panic = "abort"
这里影响最大的是 codegen-units = 1。默认情况下编译器会分多个 codegen unit 并行编译,降低编译时间,但代价是跨函数内联受限。对二分搜索这种小而密的函数,内联影响非常明显。
lto = "fat" 会把整个依赖图做链接时优化。如果 benchmark 和库函数在不同 crate 里,这个配置能带来额外收益。panic = "abort" 主要是减小二进制的 panic 展开路径,对热循环未必直接提升,但在服务端场景更稳。
还有一个容易被忽略的点:target-cpu=native。它会让编译器使用当前 CPU 支持的指令集,比如某些向量指令和位运算指令。换一台旧机器运行可能直接非法指令崩溃。如果是发布给用户的项目,建议使用更保守的 x86-64-v3 或者明确的最低 CPU 目标,不要盲目 native。
4.3 正确性验证:不要只依赖基准测试
优化代码发到线上之前,一定要有对拍测试。Rust 标准库提供了一个非常好的参考实现:slice::partition_point,它就是 lower_bound 的标准语义。
rust复制#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lower_bound_matches_reference() {
let arr: Vec<u32> = (0..100_000)
.map(|x| x.wrapping_mul(7) % 100_003)
.collect();
let mut sorted = arr.clone();
sorted.sort_unstable();
for target in 0..100_003u32 {
let mine = branchless_lower_bound(&sorted, target);
let reference = sorted.partition_point(|&x| x < target);
assert_eq!(mine, reference, "target = {}", target);
}
}
}
测试数据要注意边界:空数组、只有一个元素、所有元素相等、target 小于第一个元素、target 大于最后一个元素、target 恰好等于某个值。二分边界问题最容易在这几个位置翻车,光靠随机抽样不一定能查出来。
对拍时不要只测“能否找到”,还要测“找不到时返回的位置是否语义正确”。很多二分 bug 是找不到时多挪了一步或少挪了一步,最终把插入位置放错。
5. 基准测试、火焰图与性能排查链路
算法改完之后,剩下的工作就是数据分析。你一定要先让数据说话,否则两种实现谁快很容易看走眼。尤其是很多优化在置信区间没有明显差异,加了反而增加维护成本。
5.1 用 Criterion 跑稳定对比
接着前面的项目,把 baseline、branchless、prefetch 三个版本放进同一组基准:
rust复制criterion::benchmark_group! {
binary_search_x64,
bench_baseline,
bench_branchless,
bench_prefetch,
}
每次跑完看三样东西:mean 延迟、confidence interval、throughput。Criterion 默认会做多次采样。如果置信区间重叠,说明两者没有显著差异。这时不要硬从数字差异里找结论。
关于查询序列,我建议多准备几组:
- 随机均匀分布:模拟通用场景。
- 全命中:所有 target 都存在。
- 全未命中:所有 target 都落在空隙之间。
- 偏右分布:target 大多集中在数组后 25%。
- 偏左分布:target 大多集中在前 25%。
为什么一定要分这些?因为 branchless 和 prefetch 的收益对查询分布极其敏感。随机均匀分布下 branchless 可能快,偏左分布下它未必能跑赢标准库。
5.2 性能回退时按什么顺序排查
如果优化版本不仅没有变快,反而变慢了,不要急着怀疑“优化思路不对”。我一般按这个顺序排查:
- 先看基准方差。如果两次运行波动超过 5%,先解决机器负载、CPU 频率波动和控制变量。低频或者后台任务会直接污染结果。
- 再看数据是否有序。二分的前提是切片已排序。有一点点乱序会让查询结果错误,但不会直接报错,性能也会出现奇怪的劣化。
- 再看编译配置。codegen-units、lto、target-cpu 前后是否一致。这三个配置不一致,对比就没有意义。
- 然后用 perf 看热点。如果热点已经不在你优化的那个循环里,那问题可能出在基准框架本身,比如 black_box 位置不对、查询数组构建逻辑太重。
- 最后才怀疑分支预测和缓存预取方向。比如 prefetch 版本在数据量小时反而更慢,这属于功能边界,不是 bug。
这个顺序反过来很容易误判。很多“优化失败”最终是基准方法问题,不是算法问题。先排除自身原因,再动算法,会省很多时间。
还需要用到的工具是火焰图。如果项目已经比较大,不能用最小 benchmark 重现问题,可以用 perf record 和 cargo flamegraph 抓调用栈,看看耗时分布。对单个函数做热点分析时,perf stat -e cache-misses,branch-misses 通常比火焰图更直接。
6. 这些优化什么时候不值得做
最后这部分很重要。优化不是越猛越好,二分搜索更是如此。很多场景下,标准库的 binary_search 已经足够好,继续优化只是在给自己的代码库增加维护负担。
6.1 数据规模小、调用频率低,直接跳过
如果数组只有几千个元素,整个数组可以放进 L1/L2 缓存,二分搜索的缓存缺失几乎不存在。这时候经典版本和无分支版本差距可能只有几十纳秒。项目里多一个不直观的 branchless 实现,换来的收益却几乎测不出来,明显不划算。
我的经验是:先把标准库跑成基线,再用 profile 确认它是否真的占据足够高的 CPU 比例。如果一个搜索函数只占整体耗时 1%,即使你让它快一倍,整体也只是从 100% 变成 99.5%。优先级远低于其他热点。
6.2 不同场景的选型建议
下面这张表是我实践里比较常用的判断标准:
| 场景 | 建议方案 | 判断理由 |
|---|---|---|
| 数组小、查询少 | 标准库 binary_search 或 partition_point |
正确性优先,性能差距太小 |
| 数组大、查询随机 | branchless lower_bound | 减少分支预测失败,收益可测 |
| 数组大、查询分布明显偏斜 | 块搜索预筛 + 二分 | 利用分布特征快速缩小窗口 |
| 嵌入式或老 CPU | 经典二分 | branchless 在部分架构上优势不明显 |
| 发布给外部用户部署 | 先保守配置 | 避免 target-cpu=native 在旧机器上出问题 |
| 需要长期维护的公共库 | 标准库实现 | 可读性和正确性优先级最高 |
这张表不是定理,它只是帮你快速判断“要不要继续优化”。真正落地时,还是要回到基准数据。
还有一点值得说:如果项目是用 tauri + rust 做桌面应用,或者 RUST 生态里的搜索索引、工具链组件越来越多,二分搜索往往只是链路里的一小段。与其猜测未来哪个项目会用 Rust 重写,不如先把当前工程里被 profile 点名的那几个热点函数压下来。很多时候,标准库加一个合理的查询模式已经足够。
优化到能交付为止,是我这几年比较认同的态度。代码可以快,但不能快得让人看不懂;可以炫,但不能让边界条件和正确性退步。真正好的二分搜索优化,是在确认瓶颈、验证正确性、留下基准方法之后,才做出的那个小改动。
