Нерассказанная история о том, как мы делаем векторный поиск в LanceDB быстрым.
В январе команда инженеров Eto приняла решение переписать колоночный формат Lance на Rust. Хотя это решение может показаться обычной тенденцией Rewrite-X-In-Rust, за ним стоит нерассказанная история: нашей целью было добиться отличной производительности в недавно анонсировали SSD-устойчивый векторный поиск в LanceDB. Родная генерация кода Rust, отличный набор инструментов для настройки производительности ( cargo asm
/ cargo flamegraph
), а также огромное количество первоклассных встроенных функций ЦП/поддержка SIMD в стандартной библиотеке (std::arch
) сильно повлияли на наше решение.
Самый фундаментальный запрос в векторном поиске — это «поиск K ближайших соседей (KNN) в многомерном векторном пространстве». Для выполнения этого поиска необходимо вычислить расстояния между векторами. В этой статье мы будем использовать в качестве примера классическое евклидово расстояние (L2). Другие популярные меры расстояния включают косинусное расстояние, скалярное произведение и расстояние Хэмминга.
Первая попытка: наивная реализация и пусть компилятор сделает это
Расстояние L2 довольно просто реализовать в Rust.
pub fn l2(x: &[f32], y: &[f32]) -> f32 { x.iter() .zip(y.iter()) .map(|(a, b)| (a - b).powi(2)) .sum::<f32>() }
Вычисление L2 между одним вектором запроса (размер 1024) и 1 миллионом векторов занимает 1.25s
в экземпляре, оптимизированном для вычислений AWS (C6in.4xlarge
).
Компилятор Rust rustc (1.68)
выполняет довольно приличную генерацию кода для приведенного выше кода, как показано в Compiler Explorer с RUSTFLAGS=-C opt-level=3 -C target-cpu=icelake-server -C target-feature=+avx2
: запускается развертывание цикла:
/// RUSTFLAGS="-C opt-level=3 -C target-cpu=icelake-server" ... .LBB0_5: vmovss xmm1, dword ptr [rdi + 4*rsi] vmovss xmm2, dword ptr [rdi + 4*rsi + 4] vsubss xmm1, xmm1, dword ptr [rdx + 4*rsi] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 vsubss xmm1, xmm2, dword ptr [rdx + 4*rsi + 4] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 vmovss xmm1, dword ptr [rdi + 4*rsi + 8] vsubss xmm1, xmm1, dword ptr [rdx + 4*rsi + 8] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 vmovss xmm1, dword ptr [rdi + 4*rsi + 12] vsubss xmm1, xmm1, dword ptr [rdx + 4*rsi + 12] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 vmovss xmm1, dword ptr [rdi + 4*rsi + 16] vsubss xmm1, xmm1, dword ptr [rdx + 4*rsi + 16] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 vmovss xmm1, dword ptr [rdi + 4*rsi + 20] vsubss xmm1, xmm1, dword ptr [rdx + 4*rsi + 20] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 vmovss xmm1, dword ptr [rdi + 4*rsi + 24] vsubss xmm1, xmm1, dword ptr [rdx + 4*rsi + 24] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 vmovss xmm1, dword ptr [rdi + 4*rsi + 28] lea rax, [rsi + 8] vsubss xmm1, xmm1, dword ptr [rdx + 4*rsi + 28] vmulss xmm1, xmm1, xmm1 vaddss xmm0, xmm0, xmm1 mov rsi, rax cmp rcx, rax jne .LBB0_5 ...
Люди часто говорят: Пусть компилятор оптимизирует код за вас. К сожалению, vsubss/vaddss/vmulss
— это скалярные инструкции для X86_64. Похоже, что LLVM не выполняет автоматическую векторизацию цикла. Было обнаружено, что для LLVM/GCC было бы довольно сложно векторизовать циклы без статической длины во время компиляции. Но наш векторный индекс ДОЛЖЕН поддерживать любое измерение пользовательских векторов, неужели мы оставили какую-то производительность на столе?
Вторая попытка: вычислительное ядро Arrow
LanceDB построен на основе Apache Arrow (Rust). Можем ли мы добиться большего успеха, напрямую используя вычислительные ядра Arrow?
// RUSTFLAGS="-C target-cpu=native -C target-feature=+avx2" // Use arrow arith kernels. use arrow_arith::arithmetic::{multiply, subtract}; use arrow_arith::arity::binary; #[inline] fn l2_arrow_1(x: &Float32Array, y: &Float32Array) -> f32 { let s = subtract(x, y).unwrap(); let m = multiply(&s, &s).unwrap(); sum(&m).unwrap() } #[inline] fn l2_arrow_2(x: &Float32Array, y: &Float32Array) -> f32 { let m: Float32Array = binary(x, y, |a, b| (a - b).powi(2)).unwrap(); sum(&m).unwrap() }
Работая на той же машине c6in.4xlarge
с RUSTFLAGS=”-C target-cpu=native -C target-feature=+avx2”
, мы получили 2.81s
и 2.16s
соответственно. Удивительно, но использование ядра вычислений со стрелками работает медленнее, чем наша наивная реализация. Причина в том, что, в отличие от встроенной функции L2, которая имеет нулевое выделение памяти и сканирует только x
и y
один раз, что делает недействительным кэш L1 только тогда, когда это абсолютно необходимо. l2_arrow_1
требует однократного сканирования массива x, y, m, s
, дважды аннулирования кеша L1/L2 и двух дополнительных выделений памяти для s
и m
. l2_arrow_2
сканирует x
, y
once, что улучшает поведение кэша и требует только одного дополнительного выделения памяти.
Третья попытка: BLAS спасти?
Многие библиотеки научных вычислений используют BLAS для ускорения матричных/векторных вычислений. LanceDB в некоторых местах также использует BLAS!
В частности, numpy считается хорошо оптимизированным для алгоритмов линейной алгебры. Мы используем Numpy + Intel MKL, одну из самых быстрых библиотек BLAS для оценки производительности BLAS.
>>> import numpy as np >>> np.show_config() blas_armpl_info: NOT AVAILABLE blas_mkl_info: libraries = ['mkl_rt', 'pthread'] library_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/lib'] define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)] include_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/include'] blas_opt_info: libraries = ['mkl_rt', 'pthread'] library_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/lib'] define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)] include_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/include'] lapack_armpl_info: NOT AVAILABLE lapack_mkl_info: libraries = ['mkl_rt', 'pthread'] library_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/lib'] define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)] include_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/include'] lapack_opt_info: libraries = ['mkl_rt', 'pthread'] library_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/lib'] define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)] include_dirs = ['/home/ubuntu/miniconda3/envs/np-mkl/include'] Supported SIMD extensions in this NumPy install: baseline = SSE,SSE2,SSE3 found = SSSE3,SSE41,POPCNT,SSE42,AVX,F16C,FMA3,AVX2,AVX512F,AVX512CD,AVX512_SKX,AVX512_CLX,AVX512_CNL,AVX512_ICL not found = AVX512_KNL,AVX512_KNM
Запуск простого скрипта Python на том же экземпляре AWS c6in
занимает 5.498s
import numpy as np import time x = np.random.random((1, 1024)) y = np.random.random((1024*1024, 1024)) total = 10 start = time.time() for i in range(total): d = np.linalg.norm(y - x, axis=1) print("time: {}s".format((time.time() - start) / total))
Последняя попытка: можем ли мы выжать из современного процессора каждую копейку?
Наша философия построения бессерверной базы данных Vector DB LanceDB заключается в полном использовании возможностей современного оборудования.
Современные процессоры необычайно эффективны. Мы твердо верим, что можем лучше использовать ЦП.
Мы переписываем вычисление расстояния L2, используя инструкции Intel AVX-2 в std::arch::x86_64
.
use std::arch::x86_64::*; #[inline] pub(crate) fn l2_f32(from: &[f32], to: &[f32]) -> f32 { unsafe { // Get the potion of the vector that is aligned to 32 bytes. let len = from.len() / 8 * 8; let mut sums = _mm256_setzero_ps(); for i in (0..len).step_by(8) { let left = _mm256_loadu_ps(from.as_ptr().add(i)); let right = _mm256_loadu_ps(to.as_ptr().add(i)); let sub = _mm256_sub_ps(left, right); // sum = sub * sub + sum sums = _mm256_fmadd_ps(sub, sub, sums); } // Shift and add vector, until only 1 value left. // sums = [x0-x7], shift = [x4-x7] let mut shift = _mm256_permute2f128_ps(sums, sums, 1); // [x0+x4, x1+x5, ..] sums = _mm256_add_ps(sums, shift); shift = _mm256_permute_ps(sums, 14); sums = _mm256_add_ps(sums, shift); sums = _mm256_hadd_ps(sums, sums); let mut results: [f32; 8] = [0f32; 8]; _mm256_storeu_ps(results.as_mut_ptr(), sums); // Remaining unaligned values results[0] += l2_scalar(&from[len..], &to[len..]); results[0] } }
На этот раз требуется всего 0.325s
для вычисления расстояний L2 по 1 миллиону векторов, что на 350% быстрее, чем у ближайших альтернатив!
Мы не забываем тебя, Apple Silicon!
Наша команда разделяет любовь к MacBook M1/M2 Apple Silicon. Мы стремимся сделать LanceDB лучшим в своем классе вариантом производительности на машинах, которые мы используем каждый день.
Чипы Apple M1/M2 основаны на архитектуре Arm aarch64
с NEON
инструкциями. К сожалению, расширение Arm Scalable Vector Extension (SVE) для этих чипов пока недоступно. Итак, мы реализовали только NEON версию L2 distance.
use std::arch::aarch64::*; #[inline] pub(crate) fn l2_f32(from: &[f32], to: &[f32]) -> f32 { unsafe { let len = from.len() / 4 * 4; let buf = [0.0_f32; 4]; let mut sum = vld1q_f32(buf.as_ptr()); for i in (0..len).step_by(4) { let left = vld1q_f32(from.as_ptr().add(i)); let right = vld1q_f32(to.as_ptr().add(i)); let sub = vsubq_f32(left, right); sum = vfmaq_f32(sum, sub, sub); } let mut sum = vaddvq_f32(sum); sum += l2_scalar(&from[len..], &to[len..]); sum } }
Эта реализация L2 с ускорением NEON требует всего 0.299s
для вычисления 1 миллиона расстояний на M2 Max MacBook Pro.
Для полноты бенчмаркинга мы также запустили тот же скрипт numpy на Macbook Pro, используя Apple Accelerate Framework для BLAS. Мы видели аналогичное ускорение в 3–15 раз при ручной настройке инструкций SIMD для разных архитектур ЦП ( x86_64
и apple-silicon (aarch64)
).
Как будет выглядеть будущее LanceDB
Несмотря на то, что мы уже значительно ускорили векторные вычисления с помощью нашей реализации SIMD, мы можем сделать гораздо больше, чтобы LanceDB работала еще быстрее:
- Добавьте подпрограммы AVX-512 для расширения векторизации битовой ширины.
- Поддержка векторов
bf16, f64
floats. - CUDA и GPU-ускорение Apple Silicon.
- Повышение эффективности кэширования кода PQ/OPQ.
Как только проект std::simd
/portable SIMD станет доступен в стабильной версии Rust, мы обязательно перейдем на использование std::simd
для простоты.
Кроме того, поскольку LanceDB является устойчивой к SSD и бессерверной векторной базой данных, существует множество дополнительных оптимизаций путей ввода-вывода, которые можно выполнить специально для твердотельных накопителей NVME.
Богатая экосистема Rust дает нам огромные возможности для написания кросс-платформенного аппаратного ускорения с легкостью, за что мы особенно благодарны сообществу Rust за:
std::arch::{x86_64, aarch64}
охватывает большинство наших вариантов использования стабильной цепочки инструментов Rust.cargo bench
+pprof
(https://github.com/tikv/pprof-rs) упрощают поддержку и проверку тестов. Это основа нашей непрерывной работы по сравнительному анализу.cargo asm
(https://github.com/pacak/cargo-show-asm) для проверки генерации кода на процессорахx86_64
иaarch64
, чтобы мы могли проверить, активны ли автовекторизация и другие оптимизации компилятора.Compiler Explorer
(https://godbolt.org/) — еще один инструмент, позволяющий нам лучше понять оптимизацию компилятора.cargo flamegraph
(https://github.com/flamegraph-rs/flamegraph) упрощает работу сflamegraph
. Если вы уже использовалиflamegraph.pl
в проекте C/C++, я уверен, что вы искренне оцените проектflamegraph-rs
.
Попробуйте LanceDB!
Если вам нравится идея LanceDB
, вы можете попробовать ее сегодня, просто pip install lancedb
. И, пожалуйста, поставьте нам 🌟 на нашем Github Repo!
Хотите узнать больше о Lance и LanceDB? Пожалуйста, ознакомьтесь с другими интересными статьями: