Cómo creamos Elasticsearch simdvec para hacer una de las búsquedas vectoriales más rápidas del mundo

Cómo construimos Elasticsearch SIMDvec, la biblioteca del kernel SIMD ajustada a mano detrás de cada consulta de búsqueda vectorial en Elasticsearch.

Elasticsearch simdvec es el motor detrás de cada cálculo de distancia vectorial en Elasticsearch. Proporciona kerneles AVX-512 y NEON ajustados manualmente para cada tipo de vector que admite Elasticsearch. Su arquitectura de scoring masivo oculta la latencia de memoria mediante la precarga de datos explícita en x86 y las cargas intercaladas en ARM, por lo que muestra un rendimiento hasta 4 veces mayor que bibliotecas como FAISS y jvector cuando los datos exceden la caché de la CPU. En esta publicación, explicamos por qué lo creamos, qué contiene y cómo hace que la búsqueda vectorial de Elasticsearch sea una de las más rápidas del mundo.

Cómo construimos Elasticsearch SIMDvec

Cada consulta de búsqueda vectorial en Elasticsearch, ya sea un recorrido de mundo pequeño jerárquico navegable (HNSW), un escaneo de archivo invertido (IVF) o una pasada de reclasificación, se reduce al mismo problema: calcular distancias entre vectores, millones de veces por búsqueda. Elasticsearch admite una amplia gama de tipos de datos y estrategias de cuantificación, desde float32 hasta int8, bfloat16, binario y Mejor cuantificación binaria (BBQ). Cada una tiene distintas compensaciones entre memoria, rendimiento y recuperación. Detrás de todo esto hay un único motor: simdvec.

Creamos simdvec para que cada cálculo de distancia sea tan rápido como el hardware lo permita. En esta publicación, explicamos por qué lo creamos, qué contiene y dónde produce el mayor impacto.

Creado como un auto de carreras

Como aficionados a la Fórmula 1, y dado que uno de nosotros trabajó anteriormente con el equipo Ferrari de Fórmula 1, vemos un claro paralelismo. Un auto de Fórmula 1 está diseñado con un solo propósito: lograr el mejor tiempo de vuelta. La potencia del motor, la aerodinámica y el diseño del chasis solo importan en la medida en que contribuyen a ese resultado. Lo mismo ocurre con una base de datos vectorial, donde el rendimiento de indexación, la latencia de búsqueda y la recuperación definen el éxito.

Aunque lo que importa es el resultado final, para alcanzar los máximos niveles de rendimiento es necesario que cada componente funcione a la perfección. No puede ser solo lo suficientemente bueno, tiene que ser el mejor en su categoría. Simdvec está construido con esa mentalidad, que se enfoca en una parte fundamental del sistema: el motor. Es una biblioteca de kernel optimizada para una sola instrucción y múltiples datos (SIMD) diseñada específicamente, que proporciona funciones nativas de distancia en C++ ajustadas a mano, llamadas desde Java a través de la interfaz para funciones externas (FFI) de Panamá. Admite evaluación masiva, precarga de líneas de caché y todos los tipos de vectores y diseños utilizados en Elasticsearch.

Ese es el motor que hay detrás de cada búsqueda.

Por qué creamos nuestro propio motor

Comenzamos en 2023 con la API Panama Vector en Apache Lucene. Funcionaba bien para productos punto float32, pero las necesidades de Elasticsearch superaron rápidamente lo que podía ofrecer. Elasticsearch admite una amplia gama de tipos de vectores cuantificados: int8, int4, bfloat16, de un solo bit y BBQ asimétrico. Cada uno tiene diferentes estrategias SIMD, diseños de empaquetado y requisitos de acumulador. Más allá de la cobertura de tipos, las rutas de puntuación de Elasticsearch exigen algo más que un rendimiento de un solo par: HNSW necesita puntuar varios vecinos del grafo en una sola pasada, IVF necesita puntuar en masa miles de candidatos con precarga y la puntuación basada en disco debe funcionar directamente en la memoria mapeada con mmap sin necesidad de copiar. Examinamos lo que estaba disponible y nada abarcaba el conjunto completo.

Así que construimos simdvec: kerneles C++ nativos ajustados a mano llamados desde Java a través de FFI, con puntuación masiva, precarga y soporte para cada tipo de vector que usa Elasticsearch. Al ser propietarios de la biblioteca, controlamos el stack completo. Cuando añadimos un nuevo tipo de cuantificación, como BBQ, se le asigna un kernel SIMD optimizado que se integra en todo el sistema. No esperamos a que una biblioteca upstream le de soporte y no comprometemos el rendimiento para ningún tipo. Cada consulta vectorial en Elasticsearch, ya sea HNSW, IVF, reranking o híbrida, se ejecuta en este motor, construido en torno a las operaciones y los tipos que realmente usamos.

Simdvec tiene bibliotecas nativas separadas para x86 y ARM, cada una con múltiples niveles de arquitectura de conjunto de instrucciones (ISA) seleccionados al inicio. La sobrecarga de llamadas desde Java vía FFI es muy baja, con nanosegundos de un solo dígito.

El panorama

No somos los únicos que construimos kerneles de distancia vectorial optimizados para SIMD. El ecosistema es rico y queríamos comprender cómo se desempeña simdvec. No para clasificar proyectos, sino para proporcionar contexto y explicar dónde se encuentra el motor de Elasticsearch. Seleccionamos tres proyectos como puntos de referencia, cada uno representando un enfoque diferente:

  • jvector: Una biblioteca de Java de vecino más cercano aproximado (ANN) que emplea la API Panama Vector para el cálculo vectorizado de distancias, con aceleración nativa en C opcional en x86.
  • FAISS: Un marco de trabajo de búsqueda vectorial de open source ampliamente desplegado, con kerneles AVX2/AVX-512 ajustados a mano.
  • NumKong (anteriormente SimSIMD): un conjunto completo de más de 2 000 kerneles SIMD ajustados a mano que abarcan funciones de distancia, operaciones matrices y computación geoespacial.

Cada proyecto cumple un propósito diferente y realiza diferentes concesiones. Incluimos números de referencia de ellos para dar contexto al rendimiento de simdvec en las operaciones específicas que Elasticsearch necesita.

Cómo medimos

Las evaluaciones de simdvec y jvector están escritos en Java con JMH, la microevaluación estándar de JVM, lo que incluye la sobrecarga de FFI. Para las evaluaciones de NumKong y las evaluaciones de FAISS, escribimos pequeños marcos de prueba de C/C++ con Google Benchmark, que es el marco de trabajo estándar para microevaluaciones de C++. Ambos marcos de trabajo reportan nanosegundos por operación con calibración de calentamiento e iteración. Verificamos mediante contadores de rendimiento de hardware que todas las bibliotecas usan SIMD en ambas plataformas. Todo el código de evaluación está disponible públicamente en los repositorios enlazados de GitHub (y, en el caso de simdvec, en el repositorio elasticsearch).

Software: JDK 25.0.2, JMH 1.37, GCC 14, Google Benchmark (última versión).

Un vector a la vez

La operación más básica en la búsqueda vectorial es calcular la distancia entre dos vectores. Cada evaluación de un vecino de HNSW, cada puntaje de un candidato a IVF, cada comparación de reclasificación se reduce a este bucle interno.

Medimos el rendimiento de un solo par en 1024 dimensiones en ambas plataformas, empezando por float32, el tipo base y el donde el ecosistema es más competitivo. Comparamos simdvec con FAISS y jvector; hemos excluido NumKong porque utiliza acumuladores float64 para float32, lo que lo hace entre 3,2 y 5,3 veces más lento (dependiendo de la plataforma), ya que prioriza la precisión numérica por encima del rendimiento. Para mantener la comparación de igual a igual, evaluamos NumKong en int8 en su lugar, donde utiliza la misma estrategia de acumulador que simdvec.

En la arquitectura x86, FAISS AVX-512 es el kernel de par único más rápido, con un tiempo de ejecución de 23 ns. A continuación, se ejecuta Simdvec AVX-512 a los 28 ns, un intervalo que refleja la sobrecarga de la llamada FFI. Ambos emplean FMA de 512 bits con desenrollado de múltiples acumuladores. A nivel AVX2, los dos valores son mucho más similares, 36 ns y 39 ns respectivamente, ambos limitados por el ancho de carga de memoria y registro de 256 bits. jvector tarda 44 ns usando la API de Java Panama Vector. Panamá genera buen código SIMD, pero las funciones intrínsecas de C++ ajustadas manualmente siguen teniendo ventaja.

En ARM, simdvec lidera a 70 ns, muy por delante de jvector a 110 ns y FAISS a 156 ns. Simdvec tiene kernels NEON ajustados a mano para aarch64. Jvector no tiene código ARM nativo y depende de Panama. FAISS se basa en la auto-vectorización del compilador en lugar de en las intrínsecas NEON explícitas, lo que explica la mayor brecha. Esto refleja una ventaja práctica de poseer la biblioteca del kernel: cuando Elasticsearch se expandió a Graviton, agregamos kerneles NEON especialmente diseñados. Ni jvector ni FAISS priorizaron el código nativo de ARM en la misma medida.

Sin embargo, Elasticsearch no solo puntúa float32. La cuantificación Int8 reduce la memoria en 4x, bfloat16 en 2x y BBQ en 32x. Cada tipo necesita su propia estrategia SIMD, y simdvec proporciona kernels nativos ajustados a mano para todos ellos.

De todas las bibliotecas que comparamos, solo NumKong tiene kernels comparables para int8. Medimos el producto escalar int8, la distancia euclidiana al cuadrado y el coseno en 1024 dimensiones.

Puntuación de par único Int8 (1024 dimensiones, ns/vec op — cuanto más bajo, mejor)

En ambas arquitecturas, NumKong es igual o más rápido en dimensiones pequeñas y medias, donde la diferencia se debe en gran parte a una menor sobrecarga de llamadas (llamada C directa vs FFI en Java). En dimensiones mayores, simdvec se pone al día, donde la implementación más eficiente del kernel (que emplea desenrollamiento en cascada) amortiza el costo de llamada: a medida que aumenta la dimensión, esta brecha se cierra y finalmente se revierte. El cruce está en dimensiones entre 768 y 1536, dependiendo de la función y la arquitectura.

A pesar de la sobrecarga ligeramente mayor de Java FFI, simdvec está a la par con las bibliotecas altamente optimizadas de C/C++. No solo es la única librería con kernels optimizados tanto para float32 como para int8, sino que también lidera en ARM y solo está ligeramente por detrás de FAISS en x86 (para float32), y muy cerca de NumKong en ambas arquitecturas (para int8). Y en el caso de bfloat16, int4, binary y BBQ, aunque existen alternativas, simdvec se distingue por su SIMD ajustado manualmente y adaptado a la estructura de datos de cada tipo.

Pero un motor de búsqueda en producción no califica un vector a la vez; califica miles por consulta. La siguiente pregunta es qué sucede a esa escala.

Miles a la vez

El rendimiento de un solo par es solo una parte del panorama. Lo que importa en la práctica es cómo se comportan los sistemas bajo carga. Una sola consulta HNSW puede puntuar cientos de vecinos de grafos. Un escaneo de IVF puede puntuar miles de entradas en la lista de publicaciones. Un paso de reclasificación puede puntuar decenas de miles de candidatos. El rendimiento por par individual es importante, pero lo que más importa es la rapidez con la que puedes puntuar muchos vectores, y cómo se degrada el rendimiento de forma gradual a medida que el conjunto de trabajo se desborda de las cachés de la CPU.

Simdvec proporciona evaluación masiva para todos los tipos de datos. No son solo bucles sobre kernels de un solo par; usan bucles internos de varios acumuladores que cargan el vector de búsqueda una vez por paso de dimensión y lo comparten entre varios vectores de documentos, con precarga explícita de líneas de caché para el siguiente batch. Ni jvector ni FAISS ofrecen un equivalente (en el momento de redacción de esta publicación). Jvector no tiene una API de bulk, así que quien llama puntúa un par a la vez en un bucle. FAISS expone fvec_inner_products_ny que, en el momento de redacción de esta publicación, está implementado como un bucle sobre su función de distancia de un solo par, sin amortización de la búsqueda ni precarga.

Float32. Para medir el impacto a nivel del kernel, puntuamos una sola consulta contra un número creciente de vectores de documentos float32 de 1024 dimensiones con patrones de acceso aleatorio que simulan búsquedas de vecinos de grafos dispersos similares a HNSW. Los tres tamaños de sets de datos, 32, 625 y 32 500 vectores, se eligen para que el conjunto de trabajo supere la caché L1, L2 y L3, respectivamente.

Cuando los datos caben en la caché, simdvec es el más rápido en ambas plataformas, pero las diferencias son modestas, ya que predomina la aritmética del kernel. La separación real se nota cuando el conjunto de trabajo supera el tamaño de L3. En x86, simdvec alcanza los 95 ns por vector, mientras que FAISS necesita 165 ns y jvector 412 ns. En ARM, la tendencia es la misma: simdvec se mantiene en 162 ns, mientras que FAISS sube a 347 ns y jvector a 476 ns. La precarga y la amortización de búsquedas en simdvec mantienen la latencia de memoria oculta de una manera que un bucle simple sobre kerneles de par único no puede coincidir y el beneficio se amplía precisamente donde operan las cargas de búsqueda reales, en lo profundo de la memoria principal.

Int8. Lo mismo ocurre con los tipos cuantificados. Medimos el rendimiento del cálculo del producto escalar int8 en 1024 dimensiones con sets de datos de un tamaño tal que superaran los límites de las cachés L1, L2 y L3, en comparación con el cálculo en bloque de simdvec con el cálculo de pares individuales de NumKong en un bucle.

En x86, simdvec es entre 1,2 y 1,9 veces más rápido, impulsado por la combinación de precarga explícita y procesamiento por lotes. En ARM, simdvec gana de nuevo (1,7 a 1,9 veces más rápido) en todos los tamaños de sets de datos. La ventaja radica en el procesamiento por lotes de cuatro vectores a la vez, lo que proporciona paralelismo a nivel de memoria mediante un patrón de acceso intercalado. En ambos casos, el resultado más llamativo es lo que ocurre en el mayor tamaño de set de datos, donde más importa.

Los resultados para la distancia al cuadrado y el coseno muestran un patrón similar, con aceleraciones de 1,4 a 1,8 veces para ARM, y de 1,3 a 3,0 veces para x86 (detalles aquí).

Cuando la memoria importa

Los índices de vectores de producción normalmente no caben en la caché de la CPU. Un índice de 10M-vectores int8 a 1024 dimensiones es de 10 GB. La evaluación de candidatos implica el procesamiento en flujo continuo de datos desde la DRAM y ahí es donde la arquitectura de evaluación masiva marca la diferencia.

Usamos contadores de rendimiento de hardware para medir lo que sucede dentro de la CPU durante la puntuación masiva y descubrimos que ocultar la latencia de la memoria requiere dos estrategias fundamentalmente diferentes, una por arquitectura.

En x86, la precarga explícita elimina los fallos de caché. El kernel masivo procesa los vectores secuencialmente, uno completamente calculado antes del siguiente, mientras emite instrucciones de precarga para el siguiente batch. Los datos futuros se cargan en la L1 antes de que la CPU los necesite.

En ARM, el mismo enfoque secuencial tuvo un rendimiento deficiente, incluso con precarga. En cambio, el kernel masivo entrelaza las cargas de cuatro vectores en cada posición de zancada, lo que proporciona al motor fuera de orden cuatro flujos de memoria independientes. La CPU no recoge datos más rápido, sino que espera menos al tener siempre algo más que calcular mientras las solicitudes de memoria están en curso. Puedes encontrar un análisis detallado en este ticket de GitHub.

Los números cuentan dos historias diferentes:

  1. En x86, la precarga convierte 139K fallos de caché en 19K y las instrucciones por ciclo (IPC) se duplican más de dos veces. La ventaja de masa crece con el tamaño de los sets de datos, de 1,2 veces en L2 a 2,8 veces más allá de L3, porque la precarga oculta progresivamente viajes de ida y vuelta de DRAM más costosos.
  2. En ARM, los fallos de caché apenas cambian. Lo que cambia es la utilización: las interrupciones del backend disminuyen un 40 % porque el patrón de acceso intercalado mantiene el pipeline alimentado. Esta ventaja se mantiene en un consistente 1,8 veces independientemente del tamaño del sets de datos, porque el paralelismo a nivel de memoria se aplica tanto si los datos provienen de la caché como de la DRAM.

Dos arquitecturas, dos estrategias y un resultado: a escala de producción, simdvec mantiene la pipeline de la CPU ocupada incluso cuando los vectores están dispersos por la memoria principal.

Qué significa esto para los usuarios de Elasticsearch

Estas capacidades a nivel de kernel se potencian entre sí. Una única consulta de búsqueda vectorial puede calcular millones de operaciones de distancia: recorrido del grafo HNSW, puntuación de candidatos, reclasificación. En miles de búsquedas concurrentes, los nanosegundos por operación se traducen directamente en latencia de búsqueda y rendimiento del cluster. Tanto si usas float32, int8, bfloat16 o BBQ, tanto si tu índice está en la memoria como en el disco, simdvec es el motor subyacente, y cada una de esas operaciones pasa por el mismo motor, lo que optimiza hasta el último nanosegundo.

La conclusión clave es que, a escala de producción, el rendimiento de la búsqueda vectorial no está determinado principalmente por el rendimiento bruto de SIMD. Lo que marca la diferencia es la eficiencia con la que el sistema oculta la latencia de la memoria mientras mantiene el rendimiento computacional en millones de operaciones pequeñas.

Los kerneles de simdvec mejoran prácticamente con cada versión de Elasticsearch. Cuando surgen nuevos tipos de cuantización y plataformas de hardware, reciben kerneles ajustados desde el primer día. Y los tipos existentes siguen ganando velocidad a medida que perfeccionamos las implementaciones que ya están disponibles.

¿Te ha sido útil este contenido?

No es útil

Algo útil

Muy útil

Contenido relacionado

¿Estás listo para crear experiencias de búsqueda de última generación?

No se logra una búsqueda suficientemente avanzada con los esfuerzos de uno. Elasticsearch está impulsado por científicos de datos, operaciones de ML, ingenieros y muchos más que son tan apasionados por la búsqueda como tú. Conectemos y trabajemos juntos para crear la experiencia mágica de búsqueda que te dará los resultados que deseas.

Pruébalo tú mismo