Comment nous avons construit Elasticsearch simdvec pour faire de la recherche vectorielle l'une des plus rapides au monde

Comment nous avons conçu Elasticsearch simdvec, la bibliothèque de noyaux SIMD optimisée manuellement qui alimente chaque requête de recherche vectorielle dans Elasticsearch.

Elasticsearch simdvec est le moteur de calcul de distance vectorielle dans Elasticsearch. Il fournit des noyaux AVX-512 et NEON ajustés manuellement pour chaque type de vecteur pris en charge par Elasticsearch. Son architecture de calcul par lots masque la latence mémoire grâce à un prefetching explicite sur x86 et un chargement entrelacé sur ARM, surpassant jusqu'à 4 fois les performances de bibliothèques comme FAISS et jvector lorsque le volume de données dépasse la capacité du cache du processeur. Dans cet article, nous expliquons les raisons de sa création, son fonctionnement interne et comment il contribue à faire de la recherche vectorielle d'Elasticsearch l'une des plus rapides au monde.

Comment nous avons conçu Elasticsearch simdvec

Chaque requête de recherche vectorielle dans Elasticsearch, qu'il s'agisse d'un balayage transversal Hierarchical Navigable Small World (HNSW), d'un balayage de fichier inversé (IVF) ou d'une passe de reclassement, se résume au même problème : calculer les distances entre les vecteurs, des millions de fois par requête. Elasticsearch prend en charge un large éventail de types de données et de stratégies de quantification, de float32 à int8, bfloat16, binaire et quantification binaire améliorée (BBQ). Chacune présente des compromis différents entre mémoire, débit et rappel. Derrière tout cela se cache un moteur unique : simdvec.

Nous avons conçu simdvec pour rendre chaque calcul de distance aussi rapide que le permet le matériel. Dans cet article, nous expliquons pourquoi nous l'avons conçu, ce qu'il contient et où il a le plus d'impact.

Construit comme une voiture de course

En tant que passionnés de Formule 1 (l'un d'entre nous a travaillé pour l'écurie Ferrari), nous constatons un parallèle évident. Une Formule 1 est conçue dans un seul but : réaliser le meilleur temps au tour. La puissance du moteur, l'aérodynamisme et la conception du châssis n'ont d'importance que dans la mesure où ils contribuent à cet objectif. Il en va de même pour une base vectorielle, où le débit d'indexation, la latence des requêtes et le rappel sont les facteurs déterminants de sa performance.

Bien que le résultat final soit ce qui compte, atteindre les plus hauts niveaux de performance nécessite que chaque composant contribue de façon optimale. Il ne doit pas être simplement suffisant, mais le meilleur de sa catégorie. Simdvec est conçu dans cet esprit, en se concentrant sur une partie critique du système : le moteur. C'est une bibliothèque de noyaux dédiée, optimisée pour le Single Instruction Multiple Data (SIMD), qui fournit des fonctions de distance C++ natives optimisées et appelées depuis Java via l'interface de fonction étrangère (FFI) Panama. Il prend en charge le scoring en lots, le prefetching des lignes de cache et tous les types et mises en page de vecteurs utilisés dans Elasticsearch.

C'est le moteur derrière chaque requête.

Pourquoi nous avons créé le nôtre

Nous avons commencé en 2023 avec l'API Panama Vector dans Apache Lucene. Elle fonctionnait bien pour les produits scalaires de nombres flottants 32 bits, mais les besoins d'Elasticsearch ont rapidement dépassé ses capacités. Elasticsearch prend en charge une large gamme de types de vecteurs quantifiés : int8, int4, bfloat16, mono-bit et BBQ asymétrique. Chacun possède des stratégies SIMD, des compositions de packs et des exigences d'accumulateur différents. Au-delà de la couverture des types, les méthodes de scoring d'Elasticsearch exigent un débit supérieur à celui d'une simple paire : HNSW doit évaluer plusieurs voisins du graphe en une seule passe, IVF nécessite un scoring en lots de milliers de candidats avec prefetching, et le scoring sur disque doit fonctionner directement sur la mémoire mappée en mémoire sans copie. Nous avons examiné les solutions disponibles, mais aucune ne couvrait l'ensemble des besoins.

Nous avons donc développé simdvec : des noyaux C++ natifs optimisés manuellement, appelés depuis Java via FFI, avec scoring par lots, prefetching et prise en charge de tous les types vectoriels utilisés par Elasticsearch. En étant propriétaires de la bibliothèque, nous maîtrisons l'intégralité de la pile. Lorsque nous ajoutons un nouveau type de quantification comme BBQ, il bénéficie d'un noyau SIMD optimisé intégré à l'ensemble du système. Nous n'attendons pas qu'une bibliothèque tierce le prenne en charge et nous ne faisons aucun compromis sur les performances, quel que soit le type. Chaque requête vectorielle dans Elasticsearch – HNSW, IVF, de reclassement ou hybride – s'exécute sur ce moteur, conçu autour des opérations et des types que nous utilisons réellement.

Simdvec possède des bibliothèques natives distinctes pour x86 et ARM, chacune comportant plusieurs niveaux d'architecture du jeu d'instructions (ISA) sélectionnés au démarrage. La surcharge des appels depuis Java via FFI est très faible, de l'ordre de quelques nanosecondes.

Le panorama

Nous ne sommes pas les seuls à développer des noyaux de distance vectorielle optimisés pour SIMD. L'écosystème est riche et nous souhaitions comprendre les performances de simdvec. Non pas pour classer les projets, mais pour contextualiser et situer le moteur d'Elasticsearch. Nous avons sélectionné trois projets comme points de référence, chacun représentant une approche différente :

  • jvector : une bibliothèque Java de recherche de plus proches voisins approximatifs (ANN) qui utilise l'API Panama Vector pour le calcul de distance vectorisé, avec une accélération C native optionnelle sur x86.
  • FAISS : un framework de recherche vectorielle open source largement déployé, avec des noyaux AVX2/AVX-512 ajustés manuellement.
  • NumKong (anciennement SimSIMD) : une suite complète de plus de 2 000 noyaux SIMD ajustés manuellement, couvrant les fonctions de distance, les opérations matricielles et le calcul géospatial.

Chaque projet répond à un objectif différent et fait l'objet de compromis différents. Nous incluons des numéros de référence provenant d'eux pour donner du contexte à la performance de simdvec sur les opérations spécifiques dont Elasticsearch a besoin.

Comment nous mesurons

Les benchmarks simdvec et jvector sont écrits en Java avec JMH, le harnais de microbenchmark JVM standard, avec la surcharge FFI incluse. Pour les benchmarks NumKong et FAISS, nous avons écrit de petits programmes C/C++ utilisant Google Benchmark, qui est le framework standard de microbenchmark C++. Ces deux frameworks mesurent les temps d'exécution en nanosecondes, après une phase de warmup et un étalonnage des itérations. Nous avons vérifié, grâce à des compteurs de performance matériels, que toutes les bibliothèques utilisent SIMD sur les deux plateformes. L'ensemble du code des benchmarks est disponible publiquement dans les référentiels GitHub associés (et, pour simdvec, dans le référentiel elasticsearch).

Logiciel : JDK 25.0.2, JMH 1.37, GCC 14, Google Benchmark (dernière version).

Un vecteur à la fois

L'opération fondamentale de la recherche vectorielle consiste à calculer la distance entre deux vecteurs. Chaque évaluation de voisinage HNSW, chaque score de candidat IVF, chaque comparaison de reclassement ramène à cette boucle interne.

Nous avons mesuré le débit d'une seule paire à 1 024 dimensions sur les deux plateformes, en commençant par le type float32, le type de référence et celui où l'écosystème est le plus compétitif. Nous avons comparé simdvec à FAISS et jvector ; nous avons exclu NumKong car il utilise des accumulateurs float64 pour float32, ce qui le rend 3,2 à 5,3 fois plus lent (selon la plateforme), privilégiant la précision numérique au débit. Pour une comparaison équitable, nous avons testé NumKong sur int8, où il utilise la même stratégie d'accumulateurs que simdvec.

Sur x86, FAISS AVX-512 est le noyau à paire unique le plus rapide à 23 ns. Simdvec AVX-512 suit à 28 ns, un écart qui reflète la surcharge d'appel FFI. Les deux utilisent le FMA 512 bits avec déroulement par accumulateurs multiples. Au niveau AVX2, les deux sont beaucoup plus proches, 36 ns et 39 ns respectivement, tous deux limités par le registre de 256 bits et les largeurs de chargement en mémoire. jvector arrive à 44 ns grâce à l'API Java Panama Vector. Panama génère un bon code SIMD, mais les intrinsèques C++ optimisées manuellement conservent un avantage.

Sur ARM, simdvec affiche le meilleur temps d'exécution (70 ns), devançant largement jvector (110 ns) et FAISS (156 ns). Simdvec utilise des noyaux NEON optimisés manuellement pour aarch64. Jvector, quant à lui, ne possède aucun code ARM natif et repose sur Panama. FAISS s'appuie sur la vectorisation automatique du compilateur plutôt que sur des fonctions intrinsèques NEON explicites, ce qui explique l'écart plus important. Ceci illustre l'avantage pratique de posséder la bibliothèque de noyaux : lors du passage d'Elasticsearch à Graviton, nous avons intégré des noyaux NEON dédiés. Ni jvector ni FAISS n'ont accordé la même priorité au code natif ARM.

Mais Elasticsearch ne se limite pas aux nombres à virgule flottante 32 bits. La quantification d'Int8 réduit la mémoire d'un facteur 4, la quantification bfloat16 d'un facteur 2 et la quantification BBQ d'un facteur 32. Chaque type nécessite sa propre stratégie SIMD, et simdvec fournit des noyaux natifs optimisés manuellement pour chacun d'entre eux.

Parmi les bibliothèques que nous avons comparées, seule NumKong possède des noyaux comparables pour int8. Nous avons mesuré le produit scalaire int8, la distance euclidienne au carré et le cosinus pour le format int8 sur 1 024 dimensions.

Score Int8 pour une seule paire (1 024 dimensions, ns/vec op – plus bas est mieux)

Sur les deux architectures, NumKong est aussi performant, voire plus rapide, pour les petites et moyennes dimensions, la différence étant principalement due à une surcharge d'appels réduite (appel direct en C contre FFI Java). Pour les grandes dimensions, simdvec rattrape son retard, grâce à une implémentation noyau plus efficace (qui utilise le déroulement en cascade) qui amortit le coût des appels : à mesure que la dimension augmente, cet écart se réduit et finit par s'inverser. Le point de bascule se situe entre 768 et 1 536 dimensions, selon la fonction et l'architecture.

Malgré la surcharge légèrement supérieure de l'interface FFI Java, simdvec rivalise avec les bibliothèques C/C++ fortement optimisées. Non seulement c'est la seule bibliothèque dotée de noyaux optimisés pour float32 et int8, mais elle est également en tête sur ARM et juste derrière FAISS sur x86 (pour float32), et très proche de NumKong sur les deux architectures (pour int8). Enfin, pour bfloat16, int4, binary et BBQ, bien qu'il existe des alternatives, simdvec se distingue par un SIMD ajusté manuellement et adapté à la structure des données de chaque type.

Cependant, un moteur de recherche en production n'évalue pas un vecteur à la fois ; il en évalue des milliers par requête. La question suivante est de savoir ce qui se passe à cette échelle.

Des milliers à la fois

Les performances sur une seule paire ne représentent qu'une partie du problème. En pratique, c'est le comportement des systèmes sous charge qui est important. Une simple requête HNSW peut évaluer des centaines de voisins dans le graphe. Une analyse IVF peut évaluer des milliers d'entrées de listes de publication. Une passe de reclassement peut évaluer des dizaines de milliers de candidats. Le débit sur une seule paire est important, mais ce qui compte davantage, c'est la rapidité avec laquelle il est possible d'évaluer de nombreux vecteurs et la façon dont les performances se dégradent lorsque l'ensemble de travail déborde des caches du processeur.

Simdvec propose un scoring par lots pour tous les types de données. Il ne s'agit pas simplement de boucles sur des noyaux de distance à une seule paire, mais de boucles internes dotées de plusieurs accumulateurs qui chargent le vecteur de requête une fois par pas de dimension et le partagent entre plusieurs vecteurs de documents, avec un prefetching explicite des lignes de cache pour le lot suivant. Ni jvector ni FAISS n'offrent d'équivalent (à l'heure actuelle). Jvector ne dispose pas d'API Bulk ; les appelants calculent donc le score d'une paire à la fois dans une boucle. FAISS expose fvec_inner_products_ny, qui, à l'heure actuelle, est implémenté comme une boucle sur sa fonction de distance à une seule paire, sans amortissement ni prefetching des requêtes.

Float32. Pour mesurer l'impact au niveau du noyau, nous avons évalué une requête unique sur un nombre croissant de vecteurs de documents float32 de 1 024 dimensions, en utilisant des modèles d'accès aléatoire simulant des recherches de voisins dans un graphe dispersé de type HNSW. Les trois tailles d'ensemble de données (32, 625 et 32 500 vecteurs) ont été choisies de manière à ce que l'ensemble de travail dépasse respectivement les caches L1, L2 et L3.

Lorsque les données tiennent dans le cache, simdvec est le plus rapide sur les deux plateformes, mais l'écart reste modeste, car l'arithmétique du noyau est prépondérante. La différence est flagrante lorsque la taille de l'ensemble de travail dépasse le cache L3. Sur x86, simdvec atteint 95 ns par vecteur, contre 165 ns pour FAISS et 412 ns pour jvector. Sur ARM, le constat est identique : simdvec se maintient à 162 ns, tandis que FAISS grimpe à 347 ns et jvector à 476 ns. Le prefetching et l'amortissement des requêtes dans simdvec masquent la latence mémoire, contrairement à une simple boucle sur des noyaux à paire unique. Cet avantage est encore plus manifeste précisément là où les véritables charges de travail de recherche s'exécutent, au cœur de la mémoire principale.

Int8. Le même schéma s'applique aux types quantifiés. Nous avons mesuré le scoring par lots du produit scalaire int8 à 1 024 dimensions avec des tailles d'ensemble de données choisies pour dépasser les mêmes limites de cache L1, L2 et L3, en comparant le scoring par lots de simdvec au scoring de paires individuelles de NumKong dans une boucle.

Sur x86, simdvec est de 1,2 à 1,9 fois plus rapide, grâce à la combinaison du prefetching explicite et du traitement par lots. Sur ARM, simdvec l'emporte également (de 1,7 à 1,9 fois plus rapide) quelle que soit la taille des ensembles de données. Cet avantage provient du traitement par lots de quatre vecteurs simultanément, offrant un parallélisme au niveau mémoire via un modèle d'accès entrelacé. Dans les deux cas, le résultat le plus frappant se situe au niveau des ensembles de données les plus volumineux, là où il est le plus significatif.

Les résultats concernant la distance au carré et le cosinus montrent un schéma similaire, avec des accélérations de 1,4 à 1,8 fois pour ARM, et de 1,3 à 3,0 fois pour x86 (détails ici).

Quand la mémoire est essentielle

Les index vectoriels de production ne tiennent généralement pas dans le cache du processeur. Un index vectoriel de 10 millions d'éléments (int8) à 1 024 dimensions pèse 10 Go. Le scoring des candidats implique le traitement en continu des données depuis la DRAM, et c'est là que l'architecture de scoring par lots fait toute la différence.

Nous avons utilisé des compteurs de performances matérielles pour mesurer ce qui se passe à l'intérieur du processeur pendant le scoring par lots et avons constaté que masquer la latence de la mémoire nécessite deux stratégies fondamentalement différentes, une par architecture.

Sur x86, le prefetching explicite élimine les défauts de cache. Le noyau principal traite les vecteurs séquentiellement, chaque vecteur étant entièrement calculé avant le suivant, tout en émettant des instructions de prefetching pour le lot suivant. Les données futures sont chargées dans le cache L1 avant que le processeur n'en ait besoin.

Sur ARM, la même approche séquentielle s'est avérée peu performante, même avec prefetching. En revanche, le noyau de traitement par lots entrelace les charges de quatre vecteurs à chaque position d'itération, offrant ainsi au moteur hors séquence quatre flux mémoire indépendants. Le processeur ne récupère pas les données plus rapidement, mais le temps d'attente est réduit en ayant toujours une autre opération à traiter pendant que les requêtes mémoire sont en cours de traitement. Une analyse détaillée est disponible dans ce ticket GitHub.

Les chiffres racontent deux histoires différentes :

  1. Sur x86, le prefetching réduit les défauts de cache de 139 000 à 19 000 et double le nombre d'instructions par cycle (IPC). L'avantage en termes de traitement par lots s'accroît avec la taille des données, passant de 1,2 fois pour le cache L2 à 2,8 fois pour les caches au-delà du cache L3, car le prefetching masque les allers-retours DRAM de plus en plus coûteux.
  2. Sur ARM, le nombre d'échecs de cache reste pratiquement inchangé. Ce qui change, c'est le taux d'utilisation : les blocages du backend diminuent de 40 % car le modèle d'accès entrelacé assure l'alimentation continue du pipeline. Cet avantage reste constant, à 1,8 fois, quelle que soit la taille de l'ensemble de données, car le parallélisme au niveau de la mémoire s'applique que les données proviennent du cache ou de la DRAM.

Deux architectures, deux stratégies, un résultat : à l'échelle de la production, simdvec maintient le pipeline du processeur occupé même lorsque les vecteurs sont dispersés dans la mémoire principale.

Ce que cela signifie pour les utilisateurs d'Elasticsearch

Ces capacités au niveau du noyau s'additionnent. Une simple requête de recherche vectorielle peut calculer des millions d'opérations de distance : parcours de graphe HNSW, scoring des candidats, reclassement. Sur des milliers de requêtes simultanées, chaque opération, même en nanosecondes, se traduit directement par une latence de requête et un débit de cluster optimaux. Que vous utilisiez float32, int8, bfloat16 ou BBQ, que votre index soit en mémoire ou sur disque, simdvec est le moteur sous-jacent, et chacune de ces opérations s'exécute sur ce même moteur, optimisé à la nanoseconde près.

L'essentiel à retenir est qu'à l'échelle de la production, les performances de la recherche vectorielle ne sont pas principalement déterminées par le débit SIMD brut. Elles dépendent surtout de la capacité du système à masquer efficacement la latence mémoire tout en maintenant la capacité de calcul sur des millions de petites opérations.

Les noyaux simdvec s'améliorent à quasiment chaque nouvelle version d'Elasticsearch. Dès l'apparition de nouveaux types de quantification et de plateformes matérielles, des noyaux optimisés sont intégrés. De plus, les types existants continuent de gagner en vitesse grâce à l'amélioration des implémentations déjà disponibles.

Ce contenu vous a-t-il été utile ?

Pas utile

Plutôt utile

Très utile

Pour aller plus loin

Prêt à créer des expériences de recherche d'exception ?

Une recherche suffisamment avancée ne se fait pas avec les efforts d'une seule personne. Elasticsearch est alimenté par des data scientists, des ML ops, des ingénieurs et bien d'autres qui sont tout aussi passionnés par la recherche que vous. Mettons-nous en relation et travaillons ensemble pour construire l'expérience de recherche magique qui vous permettra d'obtenir les résultats que vous souhaitez.

Jugez-en par vous-même