返回 Qdrant 内部探秘
·
2023 年 6 月 21 日在 Qdrant version 1.3.0 中,我们在基于 Linux 的系统上引入了基于 io_uring 的替代存储后端 async uring。自推出以来,io_uring 已被证明能够在操作系统系统调用开销过高的地方改善异步吞吐量,这通常发生在软件成为 IO 密集型(即主要等待磁盘)的情况下。

输入+输出
大约在 90 年代中期,互联网开始腾飞。最初的服务器采用按请求分配进程的设置,这对于处理数百甚至数千个并发请求非常有效。POSIX 输入+输出 (IO) 以严格同步的方式建模。为每个请求启动一个新进程的开销使得这种模型难以持续。因此,服务器开始放弃进程隔离,转而采用按请求分配线程的模型。但即使是这种模型也遇到了限制。
我清楚地记得有人曾问过服务器是否能处理 1 万个并发连接的问题,当时这会耗尽大多数系统的内存(因为每个线程都必须有自己的堆栈和一些其他元数据,这会迅速填满可用内存)。结果,在 2.5 内核更新期间,同步 IO 被异步 IO 所取代,无论是通过 select
还是 epoll
(后者仅限于 Linux,但效率稍高,因此当时大多数服务器都使用了它)。
然而,即使是这种粗糙形式的异步 IO,每次操作也至少会带来一个系统调用的开销。每个系统调用都会导致一次上下文切换,虽然这个操作本身并不慢,但切换会扰乱缓存。如今的 CPU 比内存快得多,但如果它们的缓存开始丢失数据,所需的内存访问会导致 CPU 的等待时间越来越长。
内存映射 IO
处理文件 IO 的另一种方式(与网络 IO 不同,它没有严格的时间要求)是将部分文件映射到内存中——系统模拟文件的那部分块存在于内存中,因此当您从该位置读取时,内核会中断您的进程以从磁盘加载所需数据,并在完成后恢复您的进程,而写入内存也会通知内核。此外,内核可以在程序运行时预取数据,从而减少中断的可能性。
因此仍然存在一些开销,但(尤其是在异步应用程序中)远低于使用 epoll
。此 API 在 Web 服务器中很少使用的原因在于,Web 服务器通常需要访问各种各样的文件,这与数据库不同,数据库只需一次性将其自身的支持存储映射到内存中即可。
应对 Poll-ution
为了改进情况,进行了多次实验,有些甚至将 HTTP 服务器移入了内核,这当然带来了自身的一系列问题。像英特尔这样的其他公司添加了自己的 API,这些 API 忽略内核并直接在硬件上工作。
最终,Jens Axboe 亲自动手,提出了一个基于环形缓冲区的接口,称为 io_uring。这些缓冲区并非直接用于数据,而是用于操作。用户进程可以设置一个提交队列 (SQ) 和一个完成队列 (CQ),两者在进程和内核之间共享,因此没有复制开销。
除了避免复制开销外,基于队列的架构也适用于多线程,因为项目的插入/提取可以实现无锁,而且一旦队列设置完成,就没有进一步的系统调用会停止任何用户线程。
使用这种方式的服务器可以轻松处理超过 10 万个并发请求。如今,Linux 通过 io_uring 允许对网络、磁盘以及访问其他端口(例如用于打印或录制视频)进行异步 IO。
那 Qdrant 呢?
Qdrant 可以将所有内容存储在内存中,但并非所有数据集都能容纳,这可能需要存储在磁盘上。在 io_uring 出现之前,Qdrant 使用 mmap 进行 IO。这在磁盘延迟的情况下导致了一些适度的开销。内核可能会停止尝试访问映射区域的用户线程,这会带来一些上下文切换开销以及等待磁盘 IO 完成的时间。最终,这与 Qdrant 核心的异步特性非常契合。
Qdrant 提供的一项重要优化是量化(无论是标量 量化还是乘积 量化)。但是,除非集合完全驻留在内存中,否则这种优化方法会产生大量的磁盘 IO,因此它是潜在改进的主要目标。
如果您在 Linux 上运行 Qdrant,可以在配置文件中添加以下内容来启用 io_uring
如果您在 Linux 上运行 Qdrant,您可以通过在配置中添加以下内容来启用 io_uring
# within the storage config
storage:
# enable the async scorer which uses io_uring
async_scorer: true
您可以通过删除 async_scorer
条目或将值设置为 false
来返回基于 mmap 的后端。
基准测试
要运行基准测试,请使用 Qdrant 的测试实例。如有必要,启动一个 docker 容器并加载您想要进行基准测试的集合快照。您可以复制和编辑我们的基准测试脚本来运行基准测试。分别在启用和不启用 storage.async_scorer
的情况下运行脚本。您可以使用另一个控制台中的 iostat
来衡量 IO 使用情况。
对于我们的基准测试,我们选择了 laion 数据集中的 500 万条 768 维条目。我们启用了标量量化 + HNSW,其中 m=16 和 ef_construct=512。我们在 RAM 中进行量化,HNSW 在 RAM 中运行,但将原始向量保留在磁盘上(这是为基准测试从 Hetzner 租用的网络驱动器)。
如果您想重现基准测试,可以获取包含数据集的快照
运行基准测试后,我们得到以下 IOPS、CPU 负载和实际运行时间
过采样 | 并行 | ~最大 IOPS | CPU% (4 核) | 时间 (秒) (3 次平均) | |
---|---|---|---|---|---|
io_uring | 1 | 4 | 4000 | 200 | 12 |
mmap | 1 | 4 | 2000 | 93 | 43 |
io_uring | 1 | 8 | 4000 | 200 | 12 |
mmap | 1 | 8 | 2000 | 90 | 43 |
io_uring | 4 | 8 | 7000 | 100 | 30 |
mmap | 4 | 8 | 2300 | 50 | 145 |
请注意,在这种情况下,由于使用网络磁盘,IO 操作具有相对较高的延迟。因此,内核需要更多时间来满足 mmap 请求,应用程序线程需要等待,这反映在 CPU 百分比中。另一方面,使用 io_uring 后端,应用程序线程可以更好地利用可用核心进行重新评分操作,而不会产生任何 IO 引起的延迟。
过采样是一项新功能,旨在以牺牲部分性能为代价来提高准确性。它允许设置一个因子,在搜索时与 limit
相乘。然后使用原始向量对结果进行重新评分,然后才选择达到 limit 的最佳结果。
讨论
回想过去,磁盘 IO 曾经非常串行化;在移动盘片上重新定位读写头是一项缓慢而麻烦的工作。因此,系统开销并不那么重要,但如今使用 SSD,它们通常甚至可以并行执行操作,同时提供近乎完美的随机访问,开销开始变得相当明显。虽然内存映射 IO 在易用性和性能方面为我们提供了相当大的便利,但我们可以牺牲一些适度的复杂性来改进后者。
io_uring 仍然相当年轻,仅在 2019 年随内核 5.1 引入,因此一些管理员会对其引入持谨慎态度。当然,与性能一样,正确的答案通常是“这取决于具体情况”,因此请评估您个人的风险承受能力并据此采取行动。
最佳实践
如果您的磁盘集合的查询性能对您来说优先级足够高,请启用基于 io_uring 的 async_scorer,以大幅减少磁盘 IO 带来的操作系统开销。另一方面,如果您的集合仅在内存中,则启用它将无效。另请注意,许多查询不是 IO 密集型的,因此开销在您的工作负载中可能或可能不会变得可衡量。最后,设备上的磁盘通常比网络驱动器具有更低的延迟,这也可能影响 mmap 开销。
因此,在推出 io_uring 之前,请使用 mmap 和 io_uring 执行上述或类似的基准测试,并测量实际运行时间和 IOps。基准测试总是高度依赖于用例,因此您的结果可能会有所不同。不过,进行一次这样的基准测试对于可能获得的性能提升来说,是值得付出的少量代价。另外,请将您的基准测试结果告诉我们!