bbtree:一个声称比跳表快达6.83倍的Rust有序映射,以及评论者的质疑
bbtree是一个Rust有序映射(B树),其作者声称在缓存受限的工作负载上优于跳表和std::collections::BTreeMap,报告在四个生产LSM树中用作内存表时加速1.37-6.83倍,作为内存数据库索引插入快2.22倍,从磁盘内存映射时首次回答快160-2500倍。该帖子出现在Hacker News上,标题为“Bbtree: Fastest Ordered Map”(153分,21条评论),托管在bbtree.xiangpeng.systems,由用户aoli-al提交。
bbtree声称能做什么
核心论点是B树支撑着大多数大数据系统,因此更快的B树能改善所有构建在其上的东西:代理内存、LLM训练、RAG、KV存储、状态缓存和向量搜索。帖子展示了三个代表性用例及基准测试数据。
1. 替换LSM树内存表中的跳表
每个LSM树都将最近的写入以有序映射的形式保存在内存中,称为内存表。在Rust中,该映射通常是跳表,如crossbeam-skiplist。帖子报告bbtree以1.37-6.83倍的速度替换它,这是在每个系统自己的基准测试上测量的,单线程,在Ryzen 9 9900X上,仅更改映射:
- fjall:获取快2.47倍,插入快1.61倍,写批处理快3.00倍
- SlateDB:获取快1.71倍,放置快1.65倍,10行写批处理快2.36倍
- Tonbo:1000万行写入快6.83倍,冲突检查事务快4.10倍
- Lance的MemWAL索引(其自己的跳表):点查找快2.19倍,批量查找快3.94倍,批量放置快4.21倍
所述的接口优势是bbtree暴露了类似于crossbeam-skiplist但更简单的API,没有epoch,也无需考虑何时固定或重新进入epoch。内存管理也更可控:从第一天起就支持自定义分配器,分配永远不会在不规则(非2的幂)大小上进行,这避免了外部碎片,并且可以优雅地处理分配失败。
2. 内存数据库索引
替换Turso的跳表,帖子报告插入快2.22倍,索引SELECT快1.90倍。
3. 从磁盘内存映射bbtree
由于bbtree可以直接mmap,重启后无需重建步骤。帖子报告首次回答比使用std的BTreeMap快160-2500倍。
设计背后的成本模型
帖子将所有优化围绕三个成本展开,按优先级排序:
- 触及的链式缓存行数量。缓存未命中大约花费100纳秒。
- 执行的指令数量。一条指令大约花费0.5纳秒。
- 缓存一致性流量,这对并发访问最重要。它强制缓存未命中,成本略高于100纳秒。
由于缓存未命中大约是指令成本的200倍,主要目标是最小化缓存未命中,并且对于每次未命中,尽可能缩小搜索空间。
线性搜索在约16个条目以下优于二分搜索
帖子对一个排序的u64值数组进行了思想实验。二分搜索每次缓存未命中将剩余搜索空间减半。线性搜索每次未命中排除cache_line_size / item_size个条目,对于u64是64 / 8 = 8。因此盈亏平衡点约为16个条目,此时两种策略最多花费两次缓存未命中。两个注意事项将实际盈亏平衡点推高:CPU预取器可能自行获取后续缓存行,有利于线性搜索;线性搜索更简单,允许更多CPU流水线并行。帖子用64个排序的u64值(每缓存行8个)说明:二分搜索需要3次未命中,线性搜索在该大小下需要6次,但在16个条目时两者最多需要2次。
这就是为什么bbtree将节点大小调整为大约16-32个条目,以便始终可以使用线性搜索,这也使SIMD可行。该设计遵循了早期关于缓存感知B+树的工作(Rao和Ross,SIGMOD 2000)。
inline_keys:使堆分配的键缓存友好
字符串键打破了上述算术。String的字节存储在堆上,而不是内联在节点中,因此加载一个键成为其自己的缓存未命中,而不是一次未命中获取8个键。对于String键,二分搜索通常更可取。
bbtree的答案是inline_keys,帖子将其描述为数据库使用了几十年的想法,类似于German字符串和Apache Arrow中的StringView。从每个键派生的固定宽度代码内联存储在节点中。使用固定大小的内联键,一次缓存未命中再次加载8个或更多键,并且仅在比较平局时才加载完整键。
prefix_keys处理长共享前缀
诸如URL和文件路径之类的键通常共享长前缀,因此仅取前8个字节作为内联键可能比较不出有用的内容。bbtree存储一个prefix_keys值:节点中所有键的公共前缀。没有节点需要显式存储这些字节。节点中的每个键在父节点中两侧分隔符之间排序,因此如果这两个分隔符共享前20个字节,则子节点中的每个键都以相同的20个字节开头。因此,每个节点仅存储其前缀的长度,并保留每个键紧随其后的窗口。该技术可追溯到Prefix B-Trees(Bayer和Unterauer,TODS 1977)。
整数键也受益
诸如u64和u128之类的整数键可以使用相同的技巧,前提是它们以大端序存储,以便其字节与数字排序相同。
InlineKey trait和Rust的类型系统
用户可以为自己的键类型(包括复合结构体键)实现InlineKey trait。该trait声明2、4或8的字节宽度,以及一个函数,该函数返回键编码在给定偏移处的8个字节,大端序,超出末尾零填充:
pub trait InlineKey: Ord {
/// 节点保留的每个内联键的字节数:2、4或8。
const WIDTH: InlineWidth = InlineWidth::Eight;
/// 键编码的字节`offset..offset + 8`,大端序,
/// 超出末尾为零,其排序与键相同。
fn inline_key_at(&self, offset: usize) -> InlineWord;
}
帖子将Rust的类型系统确定为最棘手的部分。要求每个键类型实现InlineKey会阻止bbtree成为std的BTreeMap和BTreeSet的直接替代品。Nightly Rust具有专门化(RFC 1210,自2016年以来不稳定)正是为此目的。对于稳定版Rust,bbtree手动专门化常见类型:u64、i64、u128、i128、String、str、&str、Vec
批处理模式:将缓存未命中视为Future
第二类优化是隐藏延迟而不是减少延迟。软件预取有帮助,但对于B树来说还不够,因为B树的内存访问本质上是链式的:在搜索完当前节点之前,无法获取下一个节点。
bbtree利用了B树通常批量读取或构建的事实。批处理API(如get_many_copied和insert_many)接受键数组并返回答案数组。在内部,bbtree将缓存未命中视为Future并等待它,就像Rust代码等待IO操作一样。代码形状大致如下:
fn get_many<const N: usize>(&self, keys: [&K; N]) -> [Option<V>; N] {
let lanes = keys.map(|key| self.search(key));
block_on(join_all(lanes))
}
async fn search(&self, key: &K) -> Option<V> {
let mut node = self.root();
while node.is_internal() {
let edge = node.search(key);
node = node.child(edge);
prefetch(node.header()).await;
}
node.get(key)
}
三个约束塑造了实现。首先,缓存未命中大约花费100纳秒,因此Future必须非常轻量级;像tokio这样的通用运行时在开销上会比缓存未命中本身花费更多,因此bbtree使用自己的微型执行器轮询其通道,该执行器不进行分配且不使用运行时。其次,编译时生成的每函数异步状态机必须尽可能小,以便状态切换廉价。第三,由于无法知道缓存行是否已驻留,bbtree进行猜测:一旦叶子超出L2缓存,叶子几乎总是未命中,而较小的树则一次查找一个键。帖子指出,数据库研究多年来一直以这种方式交错查找,手动和协程方式都有。
正确性
帖子明确指出速度容易而正确性困难:性能是可测量的,LLM可以在工作负载上迭代,但正确性是隐式的,没有提示列出每个DON'T,没有测试套件证明代码正确,也没有人能枚举每个期望的属性。帖子声明bbtree有三个守卫来接近正确性。一位评论者引用了项目自身对该工作的框架:“通过形式验证、模型检查、确定性模拟、模糊测试和大量测试实现生产就绪。”本节引用的参考文献指向Verus(用于验证具有线性幽灵类型的Rust程序)、AWS的Shuttle(用于测试并发Rust代码)和ffs(一种基于fork的确定性并发测试工具)。
声称遭遇反驳之处
Hacker News讨论对“最快”一词明显持怀疑态度,几位评论者提供了反数据。
一个交换后变慢的真实工作负载
评论者virtualritz在3D布尔网格内核中测试了bbtree,仅将std::BTreeMap替换为bbtree,没有其他代码更改,并报告了回退:96到1536个面的网格上的并集和差集操作变慢11%到21%,交集阶段变慢16%到34%。在更大的测试夹具上,一个具有一百万个顶点ID的Nefertiti半身像扫描(最佳3次,秒):
| 笼式操作 | std | bbtree |
|---|---|---|
| 差集 | 84.2 | 83.0 |
| 并集 | 83.2 | 85.6 |
| 交集 | 61.3 | 67.1 |
| 极限曲面差集(最佳2次) | 123.9 | 147.2 |
得出的结论是,bbtree的优势可能仅在特定且可能狭窄的用例集下成立,而像std::BTreeMap这样的通用映射必须做出妥协以在任意工作负载下保持性能。
Trie是真正的竞争对手
评论者nicknash认为B树的自然竞争对手是基于trie的结构,包括突发trie、优化的q-fast和p-fast trie、级别压缩trie、分桶trie和路径压缩trie,以及优化的分层树,如van Emde Boas布局(在键长度允许的情况下)。论点是完全基于比较的结构很少是必要的,因为整数、浮点数和字符串可以通过字典序结构处理,并且trie在均匀随机数据上表现特别好,操作时间为log-log级别。该评论者在对这项工作表示赞赏的同时,仍对“最快”标签持怀疑态度。
字符串键上的内存使用
评论者ytxm测量了从String到usize的映射,使用/usr/share/dict/words,值为字典偏移量,并报告bbtree比std::collections::BTreeMap多使用约40%的RAM。他们指出这是一个数据点而不是反驳,因为帖子关于更低内存的声明专门针对整数键。同一位评论者批评了动画网页论文格式,称动态元素需要跳过阅读的内容与阅读的内容一样多。
对框架的怀疑
其他评论质疑了项目的定位。一位评论者引用了“没有人类能写出比LLM更好的代码”并称之为大胆声明;另一位询问模型检查在此上下文中意味着什么;另一位将文章斥为“AI垃圾”并反对开篇引语;还有一位只是要求提供源代码。
从业者的兴趣
并非所有反应都是负面的。评论者elendilm描述使用BTreeMap在其系统Dip的“智能批处理”的分散阶段去重工作,并表示在大多数批量写入工作负载(单个请求中20万+次插入)中,此去重是最慢的阶段,有时占扇出、数据库写入、提交和收集总时间的3倍。他们计划在那里尝试bbtree。评论者jared_hulbert从事相邻问题,强调了帖子对一致性流量的处理,并表示他们打算将自己的技术应用于bbtree。
关键要点
- bbtree是一个Rust有序映射,作为std::BTreeMap和BTreeSet的直接替代品,也替换LSM树内存表中的跳表。
- 其头条数字来自四个LSM树集成(每个系统自己的基准测试,最高6.83倍)、内存数据库索引(插入2.22倍,索引SELECT 1.90倍)以及mmap磁盘驻留树(首次回答比std的BTreeMap快160-2500倍)。
- 该设计围绕缓存未命中通过三种机制组织:16-32条目节点线性搜索、inline_keys(在节点中保留固定宽度键代码)和prefix_keys(仅存储节点键的共享前缀长度)。
- 批处理API通过将未命中视为可等待的Future并使用专用、无分配的执行器(而非tokio)轮询通道来隐藏缓存未命中延迟。
- 独立测试报告在3D布尔网格内核上出现回退,在String键映射上内存使用高约40%,因此性能声明似乎依赖于工作负载而非普遍适用。
- 该帖子在发布前由Andrew Lamb(InfluxData)、Zac Farrell、Eddie Tejeda和Divya Ranganathan(Hotdata)、Jacopo Tagliabue(bauplan)、Wanxiang Zhong和Junxuan Liao审阅。