Xline中区间树实现小结

news2024/11/20 18:37:07

Table of Contents

  1. 实现区间树的起因
  2. 区间树实现简介
    1. 插入/删除
    2. 查询重叠操作
  3. 使用Safe Rust实现区间树
    1. 问题
    2. Rc<RefCell<T>>
      i. 线程安全问题
    3. 其他智能指针
      i. Arc<Mutex<T>>?
      ii. QCell
    4. 数组模拟指针
  4. 总结

01、实现区间树的起因

在Xline最近的一次重构中, 我们发现有两个在关键路径上的数据结构Speculative Pool和Uncommitted Pool导致了性能瓶颈。这两个数据结构用于在CURP中进行冲突检测。具体来说, 由于CURP协议的要求, 对于每个处理的command, 需要在已经接收的commands中找到所有与当前command相冲突的commands。

例如对于KV操作 put/get_range/delete_range, 我们需要考虑这些操作之间可能的冲突情况。由于每个KV操作都会有一个key的范围, 所以问题就转化为要查询某一个key范围和某个Pool中所有key范围的集合是否有相交。采用朴素遍历整个集合的方法会导致每次查询的时间复杂度为 O(n),从而降低效率并导致性能瓶颈。

为了解决这一问题, 我们需要引入区间树这一数据结构。区间树能够高效支持重叠区间的插入,删除和查询操作, 这三种操作都可以在 O(log(n)) 的时间内完成。因此, 我们可以利用区间树维护key范围的集合, 从而解决性能瓶颈的问题。

02、区间树实现简介

Xline中的区间树是基于 Introduction to Algorithms (3rd ed.) 实现的, 它是由二叉平衡树扩展而来。

区间树以一颗二叉平衡树为基础(例如使用红黑树实现), 将区间本身作为平衡树的key。对于区间 [low, high] , 我们首先按照 low 值进行排序, 如果 low 值相同, 再按照 high 值进行排序, 这样对区间集合能够定义一个全序的关系(如果不处理重复区间则不需要对 high 排序)。同时, 对于平衡树的每一个节点, 我们在这个节点上记录以这个节点为根的子树中 high 的最大值, 记为 max 。

插入/删除

与红黑树的插入/删除相同, 最坏时间复杂度为 O(log(n))

查询重叠操作

给出一个区间 i , 我们需要查询当前树中是否有区间和 i 重合。在Introduction to Algorithms中给出的伪代码如下

有了 max 的定义, 解决这个问题的思路就非常简单了: 对于以 x 为根的子树 T_x , 如果 i 不和 x_i 相交, 那么 i 一定是在 x_i 的左侧或者右侧。

1. 如果 i 在 x_i 的左侧这时可以直接排除右子树, 因为这时 i.high 比 x_i.low 还要小

2. 如果 i 在 x_i 的右侧在这种情况下, 我们无法直接排除左子树, 因为左子树中的节点区间仍然可能和 i 相交。这时候 max 值就派上用场了:

  • 如果 x 的左子树中 high 的最大值仍然小于 i.low 的话, 那么可以直接排除 x 的左子树。
  • 如果 x 的左子树中 high 的最大值大于或等于 i.low 的话, 那么左子树中一定存在和 i 相交的区间, 因为 x 左子树中所有的 low 都小于 x_i.low , 而 i 在 x_i 的右侧, 所以 x 左子树中所有的 low 也小于 i.low , 因此一定有相交。

通过以上两点可以验证上述伪代码的正确性, 并且从代码可以看出查询的最坏时间复杂度为 O(log(n)) 。

03、使用Safe Rust实现区间树

困难点

为了构建区间树, 我们首先需要实现一个红黑树。在红黑树中, 每个树节点需要指向父节点, 这就要求一个节点实例存在多个所有权。

Rc<RefCell<T>>

最初我尝试使用了Rust最常见的多所有权的实现 Rc<RefCell<T>> , 树节点结构类似于以下的代码:

struct Node<T, V> {
    left: Option<NodeRef<T, V>>,
    right: Option<NodeRef<T, V>>,
    parent: Option<NodeRef<T, V>>,
    ...
}

struct NodeRef<T, V>(Rc<RefCell<Node<T, V>>>);

从数据结构定义上看起来还算清晰, 但是实际使用起来相当繁琐, 因为 RefCell 要求用户明确地调用 borrow , 或者 borrow_mut , 我不得不构建很多helper functions来简化实现, 下面是一些例子:

impl<T, V> NodeRef<T, V> {
    fn left<F, R>(&self, op: F) -> R
    where
        F: FnOnce(&NodeRef<T, V>) -> R,
    {
        op(self.borrow().left())
    }

    fn parent<F, R>(&self, op: F) -> R
    where
        F: FnOnce(&NodeRef<T, V>) -> R,
    {
        op(self.borrow().parent())
    }

    fn set_right(&self, node: NodeRef<T, V>) {
        let _ignore = self.borrow_mut().right.replace(node);
    }

    fn set_max(&self, max: T) {
        let _ignore = self.borrow_mut().max.replace(max);
    }
    ...
}

RefCell使用上不符合人体工程学是一点, 更糟糕的是我们在代码中需要使用大量的 Rc::clone , 因为在自上而下遍历树节点时, 我们需要持有一个节点的owned type, 而不是一个引用。例如在之前提到的 INTERVAL-SEARCH 操作中, 每次 x = x.left 或者 x = x.right , 首先需要borrow x本身, 再赋值给x。因此需要先取得左(或右)节点的owned type, 再更新 x 到新值。这样导致大量的节点计数开销。

具体开销到底有多大?我尝试对于我们上面的实现进行benchmark, 使用随机数据插入和删除。我本机环境为Intel 13600KF和DDR4内存。

test bench_interval_tree_insert_100           ... bench:       9,821 ns/iter (+/- 263)
test bench_interval_tree_insert_1000          ... bench:     215,362 ns/iter (+/- 6,536)
test bench_interval_tree_insert_10000         ... bench:   2,999,694 ns/iter (+/- 134,979)
test bench_interval_tree_insert_remove_100    ... bench:      18,395 ns/iter (+/- 750)
test bench_interval_tree_insert_remove_1000   ... bench:     385,858 ns/iter (+/- 7,659)
test bench_interval_tree_insert_remove_10000  ... bench:   5,465,355 ns/iter (+/- 114,735)

使用相同数据和环境, 和etcd的golang区间树实现进行对比:

BenchmarkIntervalTreeInsert100-20                 123747             12250 ns/op
BenchmarkIntervalTreeInsert1000-20                  7119            189613 ns/op
BenchmarkIntervalTreeInsert10_000-20                 340           3237907 ns/op
BenchmarkIntervalTreeInsertRemove100-20            24584             45579 ns/op
BenchmarkIntervalTreeInsertRemove1000-20             344           3462977 ns/op
BenchmarkIntervalTreeInsertRemove10_000-20             3         358284695 ns/op

可以看到我们的Rust实现并无优势, 甚至有时插入操作还会更慢。(注: 这里的etcd的节点删除实现似乎有问题, 观察节点数量从 1000->10000 时耗时的增长, 复杂度可能不是 O(log(n)))

线程安全问题

即使我们勉强接受以上的性能, 一个更严重的问题浮出水面: Rc<RefCell<T>> 无法在多线程环境下使用! 由于Xline是在Rust的Tokio runtime之上构建, 需要在多个线程间共享一个区间树实例。可惜的是, Rc 本身是 !Send , 因为 Rc 内部的引用计数是以非原子的方式递增/减的。那么这就导致整个区间树的数据结构无法发送到其他线程。除非我们采用一个专用线程, 并且通过channel与这个线程进行通信, 我们无法在多线程环境下使用。

其他智能指针

于是我们需要考虑其他智能指针来解决这个问题。一个自然的想法是使用 Arc<RefCell<T>> 。然而, RefCell本身是 !Sync , 因为 RefCell 的borrow checking只能在单线程下使用, 无法同时由多个线程共享, 并且 Arc<T> 是 Send 当且仅当 T 是 Sync , 因为 Arc 本身允许克隆。

Arc<Mutex<T>>?

那么在多线程环境多所有权似乎只能够使用 Arc<mutex<T>> 了。但是显然这对于我们的用例来说是一个anti-pattern, 因为这样我们就需要对每一个节点都加上一把锁, 而树中可能有数十万乃至几百万的节点, 这是不可接受的。

QCell

在使用常规方法无果后, 我们尝试使用了qcell这个crate, 其中 QCell 作为 RefCell 的多线程替代品。作者非常巧妙地解决了多所有权下借用检查的问题。

QCell设计

由于qcell的设计在 GhostCell 的论文中有正式的证明, 这里我就介绍介绍一下 GhostCell 论文中的设计:

在Rust中, 对于数据操作的权限和数据本身是绑定在一起的, 也就是说, 你首先要拥有一个数据, 才能修改它的状态。具体一点, 想要修改数据 T , 你要么有一个 T 本身, 要么有一个 &mut T 。

GhostCell 的设计概念是将对数据操作的权限和数据本身分开, 那么对于一种数据, 数据 T 本身是一个类型, 而它的权限同样也是是一个具体的类型, 记为 P_t 。这种设计相比与Rust现有设计就更加灵活, 因为可以让一个权限类型的实例拥有对一个数据集合的权限, 即一个 P_t 拥有多个 T 。在这种设计下, 只要权限类型实例本身是线程安全的, 它所管理的这一个数据集合也是线程安全的。

在qcell中使用方法如下, 首先需要创建一个 QCellOwner 代表前述的权限, QCell<T> 则表示储存的数据。

let mut owner = QCellOwner::new();
let item = Arc::new(QCell::new(&owner, Vec::<u8>::new()));
owner.rw(&item).push(0);

QCellOwner 拥有注册到它这里的 QCell 的读写权限(通过 QCellOwner::rw 或者 QCellOwner::ro ), 所以只要 QCellOwner 是线程安全, QCell 中的数据也是线程安全的。在这里 QCellOwner 本身是 Send + Sync , QCell 也可以是 Send + Sync 只要 T 满足:

impl<T: ?Sized + Send> Send for QCell<T>
impl<T: ?Sized + Send + Sync> Sync for QCell<T>

使用QCell

得益于它的设计, QCell 本身开销非常小(这里的具体的开销不展开讲了), 因为它借助于Rust类型系统使得borrow checking是在编译期检查的, 而 RefCell 相比之下则是在运行时检查, 因此使用 QCell 不仅能在多线程环境下使用, 还能够提升一部分性能。

接下来就是应用 QCell 到我们的树实现上了。由于 QCell 只提供内部可变性, 要能够使用多重所有权, 我们还需要有 Arc , 结构大致看起来如下:

pub struct IntervalTree {
    node_owner: QCellOwner,
    ...
}

struct NodeRef<T, V>(Arc<QCell<Node<T, V>>>);

看起来不错, 那么性能如何呢?

test bench_interval_tree_insert_100           ... bench:      41,486 ns/iter (+/- 71)
test bench_interval_tree_insert_1000          ... bench:     586,854 ns/iter (+/- 13,947)
test bench_interval_tree_insert_10000         ... bench:   7,726,849 ns/iter (+/- 102,820)
test bench_interval_tree_insert_remove_100    ... bench:      75,569 ns/iter (+/- 325)
test bench_interval_tree_insert_remove_1000   ... bench:   1,135,232 ns/iter (+/- 7,539)
test bench_interval_tree_insert_remove_10000  ... bench:  15,686,474 ns/iter (+/- 194,385)

比较之前的测试结果, 性能竟然下降了1-3倍。这说明最大的开销不是Cell, 而是引用计数, 在我们的区间树用例中, 使用 Arc 比 Rc 慢了非常多。

一个不使用 Arc 的方法是使用arena分配, 即一次性对所有对象分配内存, 并且销毁也是一次性的, 但是这在树的数据结构中并不适用, 因为我们需要动态地分配和销毁节点的内存。

数组模拟指针

性能测试反映出我们的智能指针尝试是失败的。在Rust所有权模型下, 使用智能指针来实现树结构是非常糟糕的。

那么我们可不可以不使用指针来实现呢? 一个自然的想法是使用数组来模拟指针。

于是我们的树结构重新设计如下:

pub struct IntervalTree {
    nodes: Vec<Node>,
    ...
}

pub struct Node {
    left: Option<u32>,
    right: Option<u32>,
    parent: Option<u32>,
    ...
}

可以看出在Rust中数组模拟指针的优势是不需要某个节点的所有权, 只需要记录下某个节点在 Vec 中的位置即可。每次插入新节点即向 nodes 后面push一个节点, 它的模拟指针就是 nodes.len() - 1 。

对于插入操作非常简单, 但是如果我们需要删除节点呢? 如果使用朴素的删除方法: 更新树节点的指针后直接将 Vec 中的对应的节点置为空, 那么这样就会在我们的 Vec 中留下一个“空洞”。这样的话我们需要再额外维护一个链表结构来记录这个“空洞”的位置, 以便在下一次插入的时候能重新使用。而且这种方法会导致 nodes 这个 Vec 的空间难以回收, 即使大部分节点已经被删除。

那么如何解决这个问题呢? 接下来我参照了petgraph中的方法, 在删除一个节点时, 将这个节点与 Vec 中最后一个节点交换再移除, 这样就解决了之前的内存回收的问题。需要注意的是, 我们需要同时更新与最后一个节点有关节点的指针, 因为它的位置发生了变化。在petgraph的图实现中, 这个操作可能是很耗时的, 因为一个节点可能会连接多条边, 但是在我们的树用例中, 我们只需要更新这个节点的父亲/左孩子/右孩子总共3个节点, 因此这个操作是 O(1) 的, 这样就非常高效的解决了节点删除的问题。

我们再来对我们的新实现进行benchmark:

test bench_interval_tree_insert_100           ... bench:       3,333 ns/iter (+/- 87)
test bench_interval_tree_insert_1000          ... bench:      85,477 ns/iter (+/- 3,552)
test bench_interval_tree_insert_10000         ... bench:   1,406,707 ns/iter (+/- 20,796)
test bench_interval_tree_insert_remove_100    ... bench:       7,157 ns/iter (+/- 69)
test bench_interval_tree_insert_remove_1000   ... bench:     189,277 ns/iter (+/- 3,014)
test bench_interval_tree_insert_remove_10000  ... bench:   3,060,029 ns/iter (+/- 50,829)

从结构来看这次的性能提升非常之大, 对比之前的 Rc<RefCell<Node>> 或者是etcd的golang的实现大约快了1-2倍。

使用数组模拟指针不仅轻松解决了所有权的问题, 并且由于数组内存的连续性使其对于缓存更加友好, 比纯指针性能甚至会更高。

04、总结

至此, 我们成功完美解决了使用safe Rust实现区间树的问题。从之前所述的多种尝试来看, 在Rust中使用引用计数智能指针来实现树或者图的数据结构是失败的, 因为这些智能指针并不适用于大量的内存操作。将来如果需要使用safe Rust实现指针类数据结构, 我会优先考虑使用数组而不是智能指针。

往期推荐

1.Xline 源码解读(一) —— 初识 CURP 协议

2.Xline 源码解读(三) —— CURP Server 的实现

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1628959.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

基于PI控制器的DC-DC结构PWM系统simulink建模与仿真

目录 1.课题概述 2.系统仿真结果 3.核心程序与模型 4.系统原理简介 5.完整工程文件 1.课题概述 基于PI控制器的DC-DC结构PWM系统simulink建模与仿真。包括IGBT结构&#xff0c;PI控制器结构&#xff0c;PWM模块等。 2.系统仿真结果 3.核心程序与模型 版本&#xff1a;MA…

【yolov8算法道路-墙面裂缝检测-汽车车身凹陷-抓痕-损伤检测】

yolo算法道路-墙面裂缝检测-汽车车身凹陷-抓痕-损伤检测 1. yolo算法裂缝检测-汽车车身凹陷-抓痕检测-汽车车身损伤检测2. yolo房屋墙面路面裂缝-发霉-油漆脱落-渗水-墙皮脱落检测3. 水泥墙面裂缝检测 YOLOv8算法是一种先进的目标检测技术&#xff0c;它基于YOLO系列算法的改进…

上位机图像处理和嵌入式模块部署(树莓派4b之wifi切换)

【 声明&#xff1a;版权所有&#xff0c;欢迎转载&#xff0c;请勿用于商业用途。 联系信箱&#xff1a;feixiaoxing 163.com】 前期我们在烧录镜像的时候&#xff0c;一般会配置一个默认的、带wifi配置的镜像。这主要是为了通过局域网扫描&#xff0c;或者输入arp -a的方式&a…

PySide6 GUI 学习笔记——Python文件编译打包

前面编写的软件工具都必须运行在Python环境中&#xff0c;且通过命令行的方式运行&#xff0c;通过Python打包工具&#xff0c;我们可以把.py文件封装成对应平台的运行文件&#xff0c;供用户执行。 常见Python打包工具 工具简介官网/文档地址py2exe将Python脚本转换为Window…

Flutter - 折叠面板

demo 地址: https://github.com/iotjin/jh_flutter_demo 代码不定时更新&#xff0c;请前往github查看最新代码 flutter 自定义折叠组件 支持三种类型和两种展示效果可自定义title和被折叠的内容 效果图 示例 import package:flutter/material.dart; import /jh_common/widge…

.net6 webapi 部署到IIS

一、发布.net6 webapi 项目 1.1 visual studio 2022右键发布到文件夹。 二、增加IIS容器 2.1 控制面板 2.2 启用或关闭Windows功能 3.3 勾选Internet Information Services,点击确定进行安装 三、部署webapi到IIS 3.1 安装 dotnet-hosting-6.0.29-win.exe 3.2 创建应用…

企业工厂如何逆风翻盘:VR全景打破多重桎梏

现阶段&#xff0c;制造业工厂面临的困境&#xff0c;就是用着上百万的设备&#xff0c;却赚着几毛钱的利润。传统的工厂参观方式也存在着很多的局限性&#xff0c;例如时间上不方便、不能实地参访、生产线具有隐患等&#xff0c;都会使得参观者不能深入地了解工厂的生产环境和…

【介绍下有那些常见的ssh功能】

&#x1f3a5;博主&#xff1a;程序员不想YY啊 &#x1f4ab;CSDN优质创作者&#xff0c;CSDN实力新星&#xff0c;CSDN博客专家 &#x1f917;点赞&#x1f388;收藏⭐再看&#x1f4ab;养成习惯 ✨希望本文对您有所裨益&#xff0c;如有不足之处&#xff0c;欢迎在评论区提出…

SpringCloud整合Ribbon负载均衡器

目录 一、模块一&#xff1a;提供数据 1.1 首先将第一个实例打包 1.2 使用命令行设置不同权重 1.3 打开图形化界面看看权重是否配置成功。 二、模块二&#xff1a;调用模块一 三、修改默认负载均衡策略 四、自定义规则 ​编辑 五、完整代码 5.1 目录结构 5.2 配置文件 …

【Pytorch】(十三)模型部署: TorchScript

文章目录 &#xff08;十三&#xff09;模型部署: TorchScriptPytorch动态图的优缺点TorchScriptPytorch模型转换为TorchScripttorch.jit.tracetorch.jit.scripttrace和script的区别总结trace 和script 混合使用保存和加载模型 &#xff08;十三&#xff09;模型部署: TorchScr…

Ubuntu解密:Root账户登录问题一网打尽

欢迎来到我的博客&#xff0c;代码的世界里&#xff0c;每一行都是一个故事 Ubuntu解密&#xff1a;Root账户登录问题一网打尽 前言Root用户简介Root账户无法登录的原因重设Root账户密码解决ssh不能root登录问题安全性考虑 前言 Ubuntu作为广受欢迎的Linux发行版&#xff0c;对…

[Android]引导页

使用Kotlin Jetpack Compose创建一个左右滑动的引导页, 效果如图. 1.添加依赖项 androidx.compose.ui最新版本查询:https://maven.google.com/web/index.html com.google.accompanist:accompanist-pager最新版本查询:https://central.sonatype.com/ 确保在 build.gradle (M…

C++|继承(菱形+虚拟)

目录 一、继承的概念及定义 1.1概念 1.2定义 1.2.1定义格式 1.2.2继承关系和访问限定符 1.2.3继承基类成员访问方式的变化 二、基类和派生类对象赋值转换 三、继承中的作用域 四、派生类的默认成员函数 五、继承与友元、静态成员 六、菱形继承与虚拟继承 6.1单继承与…

Redis - Set 集合

目录 前言 命令 SADD 将一个或者多个元素添加到 set 中 语法 SMEMBERS 获取一个 set 中的所有元素 语法 SISMEMBER 判断⼀个元素在不在 set 中 语法 SCARD 获取 set 中的元素个数 语法 SPOP 从 set 中随机删除并返回⼀个或者多个元素 语法 SMOME 将⼀个元素从源 se…

电脑教程1

一、介绍几个桌面上面的软件 1、火绒&#xff1a;主要用于电脑的安全防护和广告拦截 1.1 广告拦截 1.打开火绒软件点击安全工具 点击弹窗拦截 点击截图拦截 拦截具体的小广告 2、向日葵远程控制&#xff1a;可以通过这个软件进行远程协助 可以自己去了解下 这个软件不要…

每日算法之两两交换链表中的节点

题目描述 给你一个链表&#xff0c;两两交换其中相邻的节点&#xff0c;并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题&#xff08;即&#xff0c;只能进行节点交换&#xff09;。 示例 1&#xff1a; 输入&#xff1a;head [1,2,3,4] 输出&…

Apollo 7周年大会自动驾驶生态利剑出鞘

前言 4月22日&#xff0c;百度Apollo在北京车展前夕举办了以“破晓•拥抱智变时刻”为主题的智能汽车产品发布会&#xff0c;围绕汽车智能化&#xff0c;发布了智驾、智舱、智图等全新升级的“驾舱图”系列产品。 1、7周年大会 自2013年百度开始布局自动驾驶&#xff0c;201…

Kotlin基础​​

数据类型 定义变量 var表示定义变量&#xff0c;可以自动推导变量类型&#xff0c;所以Int可以不用写。 定义常量 条件语句 if表达式可以返回值&#xff0c;该值一般写在if里的最后一行 类似switch的用法 区间 循环 a是标签&#xff0c;可以直接break到标签的位置&#xf…

Docker 的数据管理 端口映射 容器互联 镜像创建

一 Docker 的数据管理 1 管理 Docker 容器中数据主要有两种方式&#xff1a; 数据卷&#xff08;Data Volumes&#xff09; 数据卷容器&#xff08;DataVolumes Containers&#xff09;。 1.1 数据卷 数据卷是一个供容器使用的特殊目录&#xff0c;位于容器中。可将宿主机…

【PLC学习十四】TIA.V18无法启动仿真的问题

【PLC学习十四】TIA.V18无法启动仿真的问题 文章目录 【PLC学习十四】TIA.V18无法启动仿真的问题前言一、程序仿真出现的问题二、解决方法1.无法仿真的问题2.因安全问题&#xff0c;无法编译的问题3、在TIA V18内部设置完成PG/PC接口后&#xff0c;下一次打开仍然不能仿真&…