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

我们如何打造 Elasticsearch simdvec——这是 Elasticsearch 中每一次向量搜索查询背后的手动调优 SIMD 内核库。

Elasticsearch simdvec 是 Elasticsearch 中每一次向量距离计算的核心引擎。它为 Elasticsearch 支持的每一种向量类型提供手动调优的 AVX-512 和 NEON 内核。其批量评分架构通过 x86 上的显式预取和 ARM 上的交错加载来隐藏内存延迟;当数据规模超出 CPU 缓存容量时,性能最高可比 FAISS 和 jvector 等库快 4 倍。在本文中,我们将介绍为何要打造它、其内部构成,以及它如何让 Elasticsearch 向量搜索跻身全球最快之列。

我们如何构建 Elasticsearch simdvec

Elasticsearch 中的每一次向量搜索查询,无论是分层导航小世界 (HNSW) 遍历、倒排文件 (IVF) 扫描,还是重排序阶段,最终都会归结为同一个问题:在一次查询中,数百万次计算向量之间的距离。Elasticsearch 支持广泛的数据类型和量化策略,从 float32 到 int8、bfloat16、binary 以及 Better Binary Quantization (BBQ)。每种类型都在内存、吞吐量和召回率之间形成不同取舍。而这一切背后都有一个统一的引擎:simdvec。

我们打造 simdvec,是为了让每一次距离计算都尽可能逼近硬件允许的性能上限。在本文中,我们将介绍为何要打造它、它的内部构成,以及它在哪些场景中影响最大。

设计得如同一辆赛车

作为一级方程式赛车 (F1) 爱好者(我们其中一人曾效力于法拉利 F1 车队),我们看到了一个清晰的相似之处。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:通过 FFI 从 Java 调用手动调优的原生 C++ 内核,具备批量评分和预取能力,并支持 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):一个包含 2,000 多个手动调优 SIMD 内核的综合库,覆盖距离函数、矩阵运算和地理空间计算。

每个项目服务于不同的目标,有着不同的取舍。我们引用它们的参考数据,是为了给 simdvec 在 Elasticsearch 所需特定操作上的性能提供参照。

我们如何衡量

simdvec 和 jvector 基准测试使用 Java 编写,并采用 JMH(标准 JVM 微基准测试框架),测试中包含 FFI 开销。对于 NumKong 基准测试FAISS 基准测试,我们使用 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.2 到 5.3 倍(取决于平台),这是以吞吐量换取数值精度。为了保持同类对比,我们改为在 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++ 内部函数仍然具有优势。

在 ARM 上,simdvec 以 70 ns 领先,明显快于 110 ns 的 jvector 和 156 ns 的 FAISS。simdvec 针对 aarch64 提供手动调优的 NEON 内核。jvector 没有 ARM 原生代码,依赖 Panama。FAISS 依赖编译器自动向量化,而非显式的 NEON 内部函数,这也解释了更大的性能差距。这体现了拥有自有内核库的一个实际优势:当 Elasticsearch 扩展到 Graviton 时,我们添加了专门构建的 NEON 内核。而 jvector 和 FAISS 尚未以同等程度优先投入 ARM 原生代码。

但 Elasticsearch 评分的远不止 float32。int8 量化可将内存占用降至原来的四分之一,bfloat16 降至原来的一半,BBQ 降至原来的三十二分之一。每种类型都需要自己的 SIMD 策略,而 simdvec 为所有这些类型都提供手动调优的原生内核。

在我们比较的库中,只有 NumKong 拥有可用于 int8 对比的内核。我们测量了 1024 维度下的 int8 点积、平方欧几里得距离和余弦计算。

Int8 单对评分(1024 维,ns/vec op – 越低越好)

在两种架构上,NumKong 在中小维度下持平或更快,差异主要来自更低的调用开销(直接 C 调用 vs Java FFI)。在更高维度下,simdvec 迎头赶上,因为更高效的内核实现(使用级联展开)摊薄了调用成本:随着维度增加,这一差距会缩小并最终反转。交叉点出现在 768 到 1536 维之间,具体取决于函数和架构。

尽管 Java FFI 存在略高的开销,simdvec 的表现仍足以媲美高度优化的 C/C++ 库。它不仅是唯一一个同时为 float32 int8 提供优化内核的库,而且在 ARM 上保持领先,在 x86 上的 float32 方面也仅略逊于 FAISS,在 int8 上与 NumKong 在两个架构上都非常接近。对于 bfloat16、int4、binary 和 BBQ,虽然存在其他替代方案,但 simdvec 的优势在于,它能够针对每种类型的数据布局进行手动 SIMD 调优。

然而,生产环境下的搜索引擎不会一次只为一个向量评分,而是会在每次查询中为数千个向量评分。接下来的问题是:在如此规模下性能表现如何?

一次处理数千个向量

单对向量性能只是整体图景的一部分。在实践中,真正重要的是系统在负载下的行为。一次 HNSW 查询可能会为数百个图邻居评分。一次 IVF 扫描可能会为数千个倒排列表条目评分。一次重排序阶段可能会为数万个候选项评分。单对吞吐量固然重要,但更关键的是评分大量向量时的速度,以及当工作集超出 CPU 缓存时性能下降是否平缓。

simdvec 为每一种数据类型都提供了批量评分功能。这绝非简单的单对内核循环,而是使用了多累加器内层循环:在每个维度步长 (stride) 中仅加载一次查询向量,并让多个文档向量共享该向量,同时针对下一批次执行显式的缓存行预取。在本文撰写之时,jvector 和 FAISS 都没有提供等效功能。jvector 没有批量 API,调用者只能在循环中逐对评分。FAISS 暴露了 fvec_inner_products_ny,但在撰写本文时,其实现方式仍是循环调用单对向量距离函数,没有查询向量摊销,也没有预取。

Float32。为了在内核层面衡量影响,我们使用随机访问模式来模拟类似 HNSW 的分散式图邻居查找,并让单个查询对数量不断增加的 1024 维 float32 文档向量进行评分。我们选择了 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 的批量评分与 NumKong 循环调用的单对评分进行了对比。

在 x86 上,simdvec 快 1.2 倍到 1.9 倍,这得益于显式预取和批处理的结合。在 ARM 上,simdvec 再次胜出,在所有数据集大小下快 1.7 倍到 1.9 倍。优势来自每次批处理四个向量,通过交错访问模式提供内存级并行性。在这两种情况下,最引人注目的结果都出现在最大数据集规模上,而这也正是最关键的场景。

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

当内存成为瓶颈

生产环境中的向量索引通常无法放入 CPU 缓存。一个包含 1,000 万个 1024 维 int8 向量的索引大小为 10 GB。为候选项评分意味着需要从 DRAM 流式读取数据,而这正是批量评分架构发挥作用的地方。

我们使用硬件性能计数器来测量批量评分过程中 CPU 内部的实际运行情况,结果发现,隐藏内存延迟需要两种截然不同的策略,每种架构各对应一种。

在 x86 上,显式预取大幅减少了缓存未命中。批量内核会按顺序处理向量,先完整计算一个向量,再处理下一个,同时为下一批数据发出预取指令。未来所需的数据在 CPU 需要之前就被拉入 L1 缓存。

在 ARM 上,即使使用预取,顺序处理方法也表现不佳。取而代之的是,批量内核采用交错加载策略:在每个步幅位置交错加载四个向量的数据,为乱序执行引擎提供四个独立的内存流。CPU 并没有加快取数速度,而是在内存请求在途时,通过始终保持有计算任务可做来减少等待时间。详细分析可参阅此 GitHub issue

这些数字讲述了两个不同的故事:

  1. 在 x86 上,预取将缓存未命中从 13.9 万次降低到 1.9 万次,每周期指令数 (IPC) 提升了一倍以上。批量处理的优势会随着数据集规模增长而扩大:当工作集位于 L2 时为 1.2 倍,超出 L3 后则达到 2.8 倍,因为预取能够掩盖越来越高的 DRAM 往返访问开销。
  2. 在 ARM 上,缓存未命中几乎没有变化。真正变化的是利用率:后端停顿减少 40%,因为交错访问模式让流水线持续有任务可执行。这一优势在不同数据集规模下稳定保持在 1.8 倍,因为内存级并行性无论数据来自缓存还是 DRAM 都同样适用。

两种架构,两种策略,一个结果:在生产规模下,即使向量散落在主内存各处,simdvec 也能让 CPU 流水线保持忙碌。

这对 Elasticsearch 用户意味着什么

这些内核层面的能力会不断叠加。一次向量搜索查询可能会执行数百万次距离操作:HNSW 图遍历、候选项评分、重排序。在数千个并发查询下,每次操作的纳秒级差异都会直接影响查询延迟和集群吞吐量。无论您使用 float32、int8、bfloat16 还是 BBQ,无论您的索引在内存中还是磁盘上,simdvec 都是底层的引擎,而每一次操作都运行在这个引擎上,并经过了哪怕一纳秒都不放过的极致精细调优。

关键结论是,在生产规模下,向量搜索性能并不主要由原始 SIMD 吞吐量决定,而是取决于系统能否在持续处理数百万次小型操作的同时,高效掩盖内存延迟。

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

这些内容对您有多大帮助?

没有帮助

有点帮助

非常有帮助

相关内容

准备好打造最先进的搜索体验了吗?

足够先进的搜索不是一个人的努力就能实现的。Elasticsearch 由数据科学家、ML 操作员、工程师以及更多和您一样对搜索充满热情的人提供支持。让我们联系起来,共同打造神奇的搜索体验,让您获得想要的结果。

亲自试用