我们如何构建 Elasticsearch simdvec,使向量搜索成为世界上最快之一

0 阅读15分钟

作者:来自 Elastic Chris HegartyLorenzo Dematte 及 Simon Cooper

我们如何构建 Elasticsearch simdvec,这是 Elasticsearch 中每一个向量搜索查询背后的手工优化 SIMD 内核库。

从向量搜索到强大的 REST APIs,Elasticsearch 为开发者提供了最全面的搜索工具集。深入体验 Elasticsearch Labs 仓库中的示例 notebooks 来尝试新功能。你也可以现在开始免费试用,或在本地运行 Elasticsearch。


Elasticsearch simdvec 是 Elasticsearch 中每一次向量距离计算背后的引擎。它为 Elasticsearch 支持的每种向量类型提供手工优化的 AVX-512 和 NEON 内核。其批量评分(bulk scoring)架构通过在 x86 上进行显式预取(prefetching)以及在 ARM 上进行交错加载(interleaved loading)来隐藏内存延迟,当数据超出 CPU cache 时,其性能相比 FAISS 和 jvector 最多可提升 4 倍。在本文中,我们将解释为什么构建它、其内部原理,以及它如何让 Elasticsearch 的向量搜索成为世界上最快之一。

我们如何构建 Elasticsearch simdvec

Elasticsearch 中的每一个向量搜索查询,无论是 Hierarchical Navigable Small World( HNSW )遍历、倒排文件( IVF )扫描,还是重排序(reranking)阶段,本质上都归结为同一个问题:在每次查询中执行数百万次向量之间的距离计算。Elasticsearch 支持多种数据类型和量化策略,从 float32 到 int8、bfloat16、binary,以及 Better Binary Quantization( BBQ )。每种方式在内存、吞吐量和召回率之间都有不同的权衡。而在这一切背后,是同一个核心引擎:simdvec。

我们构建 simdvec 的目标是让每一次距离计算都尽可能接近硬件极限的速度。在本文中,我们将解释为什么要构建它、它的内部结构,以及它在哪些方面带来了最大的性能提升。

像赛车一样打造

作为 Formula 1 的爱好者,而且我们其中一人曾在 Scuderia Ferrari 工作过,我们看到了一个清晰的类比。一辆 F1 赛车的设计只有一个目标:取得最佳单圈成绩。发动机动力、空气动力学以及底盘设计,只有在有助于实现这一目标时才有意义。同样,对于向量数据库来说,索引吞吐量、查询延迟和召回率定义了成功。

虽然最终结果才是关键,但要达到最高水平的性能,就要求每个组件都做到极致。不能只是 “足够好”,而必须是其类别中的最佳。Simdvec 正是基于这种理念构建的,专注于系统中的关键部分:引擎。它是一个专门打造的、基于单指令多数据(SIMD)优化的内核库,提供手工调优的原生 C++ 距离函数,并通过 Panama 外部函数接口(FFI)从 Java 调用。它支持批量评分、缓存行预取,以及 Elasticsearch 中使用的所有向量类型和布局。

这就是每一次查询背后的引擎。

为什么我们选择自己构建?

我们在 2023 年从 Apache Lucene 中的 Panama Vector API 起步。它在 float32 点积上表现良好,但 Elasticsearch 的需求很快就超出了它的能力范围。Elasticsearch 支持多种量化向量类型:int8、int4、bfloat16、单比特以及非对称 BBQ。每种类型都有不同的 SIMD 策略、打包布局和累加器需求。

除了类型覆盖之外,Elasticsearch 的评分路径也不仅仅依赖单对计算吞吐:HNSW 需要在一次遍历中对多个图邻居进行评分,IVF 需要对数千个候选进行带预取的批量评分,而基于磁盘的评分需要直接在 mmap 映射内存上运行而无需拷贝。我们评估了现有方案,没有一个能覆盖全部需求。

因此我们构建了 simdvec:一个手工调优的原生 C++ 内核库,通过 FFI 从 Java 调用,支持批量评分、预取,以及 Elasticsearch 使用的所有向量类型。通过掌控这个库,我们可以控制整个技术栈。当我们引入像 BBQ 这样的新量化类型时,它会配套一个经过优化的 SIMD 内核,并贯穿整个系统。我们无需等待上游库支持,也不需要在任何类型上牺牲性能。Elasticsearch 中的每一次向量查询——无论是 HNSW、IVF、重排序还是混合检索 —— 都运行在这个引擎之上,该引擎围绕我们实际使用的操作和类型构建。

simdvec 为 x86 和 ARM 分别提供独立的原生库,每个库在启动时会选择多个 ISA(指令集架构)层级之一。通过 FFI 从 Java 调用的开销非常低,仅为个位数纳秒级

生态格局

我们并不是唯一在构建 SIMD 优化向量距离内核的团队。整个生态系统非常丰富,我们也希望了解 simdvec 的实际表现 —— 不是为了给项目排名,而是提供一个背景,让大家理解 Elasticsearch 的引擎处于什么位置。我们选择了三个具有代表性的项目作为参考,它们分别代表不同的技术路径:

  • jvector:一个 Java 近似最近邻(ANN)库,使用 Panama Vector API 进行向量化距离计算,并在 x86 上可选使用原生 C 加速。
  • FAISS:一个被广泛部署的开源向量搜索框架,提供手工调优的 AVX2/AVX-512 内核。
  • NumKong(原 SimSIMD):一个包含 2000+ 手工调优 SIMD 内核的综合套件,覆盖距离计算、矩阵运算和地理空间计算。

每个项目都有不同的目标和权衡。我们引用它们的一些基准数据,是为了为 simdvec 在 Elasticsearch 实际需要的特定操作上的性能提供参照。

如何衡量

simdvec 和 jvector 的基准测试使用 Java 编写,基于 Java Microbenchmark Harness(JMH),并包含 FFI 调用开销。对于 NumKongFAISS,我们使用 Google Benchmark 编写了小型的 C/C++ 基准测试程序,这是 C++ 领域的标准微基准框架。

这两种框架都会在预热和迭代校准后,以 “每次操作的纳秒数” 作为指标进行报告。我们还通过硬件性能计数器验证了所有库在两个平台上都确实使用了 SIMD。所有基准测试代码都已公开,可在对应的 GitHub 仓库中查看(simdvec 的代码也包含在 Elasticsearch 仓库中)。

软件环境:JDK 25.0.2、JMH 1.37、GCC 14、Google Benchmark(最新版本)。

一次处理一个向量

向量搜索中最基础的操作,是计算两个向量之间的距离。每一次 HNSW 邻居评估、每一个 IVF 候选打分、每一次重排序比较,最终都归结为这个核心内循环。

我们在两个平台上测量了 1024 维下的单对吞吐量,从 float32 开始 —— 这是基准类型,也是生态中竞争最激烈的一类。我们将 simdvec 与 FAISS 和 jvector 进行对比;而没有纳入 NumKong,是因为它在 float32 上使用 float64 累加器,导致性能降低约 3.2x–5.3x(取决于平台),优先考虑数值精度而非吞吐量。

为了保证对比的公平性,我们改为在 int8 场景下测试 NumKong,因为在这种情况下,它使用与 simdvec 相同的累加策略。

在 x86 平台上,FAISS 的 AVX-512 是最快的单对内核,达到 23 ns。simdvec 的 AVX-512 紧随其后,为 28 ns,这一差距主要来自 FFI 调用开销。两者都使用 512 位 FMA,并结合多累加器展开。

在 AVX2 层级,两者更加接近,分别为 36 ns 和 39 ns,都受限于 256 位寄存器和内存加载宽度。jvector 使用 Java Panama Vector API,结果为 44 ns。Panama 可以生成不错的 SIMD 代码,但手工调优的 C++ intrinsic 仍然略胜一筹。

在 ARM 平台上,simdvec 以 70 ns 领先,明显快于 jvector 的 110 ns 和 FAISS 的 156 ns。simdvec 为 aarch64 提供了手工调优的 NEON 内核;jvector 没有原生 ARM 代码,依赖 Panama;而 FAISS 主要依赖编译器自动向量化,而不是显式的 NEON intrinsic,这也是差距较大的原因。

这体现了“自研内核库”的实际优势:当 Elasticsearch 扩展到 Graviton 平台时,我们可以直接加入专门优化的 NEON 内核。而 jvector 和 FAISS 并没有在 ARM 原生代码上投入同样的优先级。

但 Elasticsearch 并不只处理 float32。int8 量化可以将内存减少 4 倍,bfloat16 减少 2 倍,而 BBQ 可以减少 32 倍。每种类型都需要各自的 SIMD 策略,而 simdvec 为所有这些类型都提供了手工调优的原生内核。

在我们对比的库中,只有 NumKong 在 int8 上提供了可比的内核实现。我们测量了在 1024 维下的 int8 点积、平方欧氏距离以及余弦相似度。

int8 单对评分(1024 维,ns/向量操作 —— 越低越好)

在两种架构上,当维度较小到中等时,NumKong 表现相当或更快,这主要来自更低的调用开销(直接 C 调用 vs Java FFI)。但在更高维度下,simdvec 开始追平并反超,这是因为其更高效的内核实现(使用 cascade unrolling)能够逐步摊薄调用成本:随着维度增加,这一差距不断缩小并最终发生反转。具体的交叉点出现在 768 到 1536 维之间,取决于函数类型和架构。

尽管 Java FFI 带来了一定额外开销,simdvec 仍然与高度优化的 C/C++ 库处于同一水平。它不仅是唯一同时支持 float32 和 int8 优化内核的库,在 ARM 上领先,在 x86 上仅略微落后于 FAISS(针对 float32),并且在 int8 场景下与 NumKong 在两种架构上都非常接近。对于 bfloat16、int4、binary 和 BBQ 等类型,虽然也存在替代实现,但 simdvec 通过针对不同数据布局进行手工 SIMD 优化,使其具有明显差异化优势。

但在生产级搜索引擎中,实际情况并不是“一次只计算一个向量”,而是每次查询要处理成千上万个向量。接下来的问题就是:当规模扩展到这个级别时,会发生什么。

一次处理成千上万个

单对性能只是整体图景的一部分。在真实系统中更关键的是:系统在负载下的表现如何。一条 HNSW 查询可能需要对数百个图邻居打分;一次 IVF 扫描可能要处理数千个倒排列表项;重排序阶段甚至可能涉及数万个候选。单次计算速度固然重要,但更重要的是批量计算能力,以及当工作集逐步溢出 CPU 缓存时,性能衰减是否平滑。

simdvec 为所有数据类型都提供了批量评分能力。这些并不是简单地在单对内核外层套循环,而是采用多累加器内核:每个维度分块时只加载一次查询向量,并在多个文档向量之间共享,同时对下一批数据进行显式缓存行预取。

在我们写作时,jvector 和 FAISS 都没有等价实现。jvector 没有 bulk API,因此调用方只能逐对循环计算;FAISS 提供 fvec_inner_products_ny,但其实现本质上只是单对距离函数的循环封装,没有查询向量摊销,也没有预取优化。

Float32 实验

为了在内核层面衡量影响,我们对一个查询向量与不断增长数量的 1024 维 float32 文档向量进行评分,访问模式为随机访问,以模拟类似 HNSW 图邻居的分散查找。三个数据规模分别为 32、625 和 32,500,用于确保工作集依次超过 L1、L2 和 L3 缓存。

当数据能够完全放入缓存时,两种平台上 simdvec 都是最快的,但优势并不明显,因为此时计算主要由内核算术吞吐主导。真正的差距出现在工作集超过 L3 缓存之后。

在 x86 上,simdvec 达到 95 ns/向量,而 FAISS 需要 165 ns,jvector 为 412 ns。在 ARM 上趋势一致:simdvec 为 162 ns,而 FAISS 上升到 347 ns,jvector 则达到 476 ns。

simdvec 的预取机制与查询摊销策略,能够以一种简单 “逐对循环调用单对内核” 的方式无法实现的方式隐藏内存延迟,并且这种优势恰好在真实搜索负载运行的位置被放大——也就是深入主存访问的阶段。

Int8

同样的模式也出现在量化类型上。我们在 1024 维下测量 int8 点积的批量评分,数据规模同样选择跨越 L1、L2 和 L3 缓存边界,并将 simdvec 的 bulk scoring 与 NumKong 的单对内核循环进行对比。

在 x86 上,simdvec 的速度提升在 1.2x 到 1.9x 之间,主要由显式预取与批处理共同驱动。在 ARM 上,simdvec 在所有数据规模下再次获胜(1.7x 到 1.9x 更快)。这种优势来自一次处理四个向量的批处理能力,通过交错访问模式提供内存级并行性。在两种情况下,最显著的结果都出现在最大数据规模,也正是实际最重要的场景。

平方距离和余弦的结果也呈现类似模式:ARM 上加速比为 1.4x 到 1.8x,x86 上为 1.3x 到 3.0x(详情见此)。

当内存成为关键因素时

生产级向量索引通常无法全部放入 CPU 缓存。一个 1024 维的 1000 万向量 int8 索引大约为 10GB。对候选进行评分意味着从 DRAM 流式读取数据,而这正是批量评分架构发挥差异的地方。

我们使用硬件性能计数器来测量批量评分过程中 CPU 内部发生的情况,并发现隐藏内存延迟需要两种完全不同的策略 —— 每种架构各不相同。

在 x86 上,显式预取可以消除缓存未命中。批量内核按顺序处理向量,每个向量完整计算后再处理下一个,同时为下一批发出预取指令。未来的数据在 CPU 需要之前就已被加载到 L1 缓存中。

在 ARM 上,即使加入预取,相同的顺序执行方式表现仍然较差。相反,批量内核在每个步长位置交错加载四个向量,使乱序执行引擎获得四条独立的内存流。CPU 并没有更快地获取数据,而是通过在内存请求进行中的同时始终保持有其他计算任务,从而减少等待时间。详细分析可以在这个 GitHub issue中找到。

这些数字揭示了两种不同的运行机制:

  1. 在 x86 上,预取将缓存未命中从 139K 降低到 19K,同时每周期指令数(IPC)翻倍以上。批量处理的优势随着数据集规模扩大而增长:从 L2 缓存中的 1.2x,到超过 L3 缓存后的 2.8x,因为预取逐步隐藏了更昂贵的 DRAM 往返延迟。
  2. 在 ARM 上,缓存未命中几乎没有变化,真正改变的是利用率:由于交错访问模式,后端停顿减少了 40%,使流水线始终保持有数据可处理。这一优势在所有数据规模下都保持约 1.8x 的稳定提升,因为内存级并行性在数据来自缓存或 DRAM 时同样有效。

两种架构,两种策略,一个结果:在生产规模下,即使向量分散在主存中,simdvec 也能持续保持 CPU 流水线忙碌运行。

这对 Elasticsearch 用户意味着什么

这些内核级能力会产生叠加效应。一次向量搜索查询可能需要执行数百万次距离计算:包括 HNSW 图遍历、候选评分以及重排序。在成千上万的并发查询中,每一次操作的纳秒级差异会直接转化为查询延迟和集群吞吐量。无论你使用 float32、int8、bfloat16 还是 BBQ,无论索引在内存中还是在磁盘上,simdvec 都是底层引擎,所有这些操作都由同一个经过逐纳秒优化的引擎执行。

关键结论是:在生产规模下,向量搜索性能并不主要由原始 SIMD 吞吐量决定,而是取决于系统在维持计算持续进行的同时,隐藏内存延迟的效率,这一点在数百万次小规模操作中尤为关键。

simdvec 内核几乎随着每一个 Elasticsearch 版本不断改进。当新的量化类型和硬件平台出现时,它们从第一天起就会获得专门优化的内核。而现有类型也会随着我们持续优化已发布实现而不断加速。

原文:www.elastic.co/search-labs…