diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..57091e0 --- /dev/null +++ b/.clang-format @@ -0,0 +1,32 @@ +BasedOnStyle: LLVM + +# Indentation +IndentWidth: 4 +TabWidth: 4 + +# Braces +BreakBeforeBraces: Attach +AllowShortFunctionsOnASingleLine: InlineOnly + +ColumnLimit: 100 + +# Pointer/reference alignment +PointerAlignment: Left +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignAfterOpenBracket: BlockIndent +AlwaysBreakTemplateDeclarations: Yes + +# Spaces +SpaceBeforeParens: ControlStatements +SpaceAfterCStyleCast: true +SpacesInParentheses: false + + +BinPackParameters: false +AllowAllParametersOfDeclarationOnNextLine: false +AlwaysBreakAfterReturnType: None +PenaltyReturnTypeOnItsOwnLine: 1024 + +BinPackArguments: false +AllowAllArgumentsOnNextLine: true \ No newline at end of file diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..86cf168 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,22 @@ +Checks: > + -*, + bugprone-*, + clang-analyzer-*, + cppcoreguidelines-virtual-class-destructor, + modernize-pass-by-value, + modernize-use-emplace, + modernize-use-nullptr, + modernize-use-override, + modernize-use-using, + performance-*, + readability-redundant-*, + -bugprone-easily-swappable-parameters, + -performance-avoid-endl + +WarningsAsErrors: '' + +CheckOptions: + - key: bugprone-narrowing-conversions.WarnOnEquivalentBitWidth + value: false + +HeaderFilterRegex: 'include/superkmeans/.*' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b91e04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,154 @@ +name: CI + +on: + push: + branches: [main] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' + pull_request: + branches: [main] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' + +jobs: + format-check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install clang-format 18.1.8 + run: pip install clang-format==18.1.8 + + - name: Check C++ formatting + run: | + clang-format --version + ./scripts/format_check.sh + + tidy-check: + runs-on: ubuntu-24.04 + env: + CC: clang-18 + CXX: clang++-18 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang-18 clang-tidy-18 libomp-18-dev libopenblas-dev cmake + sudo ln -sf /usr/bin/clang-tidy-18 /usr/local/bin/clang-tidy + + - name: Configure + run: cmake -B build -DPDX_COMPILE_TESTS=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + + - name: Build + run: cmake --build build -j$(nproc) + + - name: Run clang-tidy + run: | + ln -s build/compile_commands.json compile_commands.json + ./scripts/tidy_check.sh + + cpp-build-and-test: + runs-on: ubuntu-24.04 + env: + CC: clang-18 + CXX: clang++-18 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang-18 libomp-18-dev libopenblas-dev cmake + + - name: Configure + run: cmake -B build -DPDX_COMPILE_TESTS=ON -DCMAKE_BUILD_TYPE=Release + + - name: Build tests + run: cmake --build build -j$(nproc) --target tests + + - name: Run tests + run: ctest --test-dir build --output-on-failure + + python: + runs-on: ubuntu-24.04 + env: + CC: clang-18 + CXX: clang++-18 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang-18 libomp-18-dev libopenblas-dev cmake + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python bindings + run: pip install . + + - name: Verify import + run: python -c "import pdxearch; print('pdxearch imported successfully')" + + sanitizers-asan-ubsan: + runs-on: ubuntu-24.04 + env: + CC: clang-18 + CXX: clang++-18 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang-18 libomp-18-dev libopenblas-dev cmake + + - name: Configure with ASan + UBSan + run: | + cmake -B build_asan -DPDX_COMPILE_TESTS=ON \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" \ + -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" + + - name: Build tests + run: cmake --build build_asan -j$(nproc) --target tests + + - name: Run tests + run: ctest --test-dir build_asan --output-on-failure + + ci-pass: + runs-on: ubuntu-latest + if: always() + needs: + - format-check + - tidy-check + - cpp-build-and-test + - python + - sanitizers-asan-ubsan + steps: + - name: Check all jobs passed + run: | + if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more jobs failed or were cancelled" + exit 1 + fi diff --git a/.gitignore b/.gitignore index 7235362..780116a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,11 @@ venv /cmake-build-debug/ /cmake-build-release/ /cmake-build*/ +/build_debug/ +/build_release/ +/build_relwithdebinfo/ +/tests/cmake_test_discovery_*.json +compile_commands.json /.idea/ /dummy/ /Testing/ @@ -62,10 +67,10 @@ pdxearch.egg-info /benchmarks/core_indexes/faiss_l0/* !/benchmarks/core_indexes/faiss_l0/*.json -/benchmarks/datasets/adsampling_nary /benchmarks/datasets/adsampling_pdx /benchmarks/datasets/downloaded -/benchmarks/datasets/nary +/benchmarks/datasets/raw +/benchmarks/datasets/faiss /benchmarks/datasets/pdx /benchmarks/datasets/purescan /benchmarks/datasets/queries @@ -91,25 +96,12 @@ cmake_install.cmake /benchmarks/milvus/volumes/ /benchmarks/python_scripts/indexes -/benchmarks/BenchmarkNaryIVFADSampling -/benchmarks/BenchmarkNaryIVFADSamplingSIMD -/benchmarks/BenchmarkPDXADSampling -/benchmarks/BenchmarkIVF2ADSampling -/benchmarks/FilteredBenchmarkPDXADSampling -/benchmarks/FilteredBenchmarkU8IVF2ADSampling -/benchmarks/BenchmarkASYM_U8PDXADSampling -/benchmarks/BenchmarkU8PDXADSampling -/benchmarks/BenchmarkLEP8PDXADSampling -/benchmarks/BenchmarkPDXIVFBOND -/benchmarks/BenchmarkPDXBOND -/benchmarks/BenchmarkPDXLinearScan -/benchmarks/BenchmarkU* -/benchmarks/G4* -/benchmarks/BenchmarkNaryIVFLinearScan -/benchmarks/KernelPDXL1 -/benchmarks/KernelPDXL2 -/benchmarks/KernelPDXIP -/benchmarks/KernelNaryL1 -/benchmarks/KernelNaryL2 -/benchmarks/KernelNaryIP -/benchmarks/BenchmarkU8* \ No newline at end of file +/benchmarks/BenchmarkEndToEnd +/benchmarks/BenchmarkSerialization +/benchmarks/BenchmarkPDXIVF +/benchmarks/BenchmarkFiltered +/benchmarks/BenchmarkSpecialFilters + +# Test binaries (but keep the committed test data) +*.bin +!tests/test_data.bin \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 9b9ae47..4f1e28d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -8,3 +8,6 @@ [submodule "extern/findFFTW"] path = extern/findFFTW url = https://github.com/egpbos/findfftw.git +[submodule "extern/SuperKMeans"] + path = extern/SuperKMeans + url = https://github.com/lkuffo/SuperKMeans diff --git a/BENCHMARKING.md b/BENCHMARKING.md index 5a66415..456849b 100644 --- a/BENCHMARKING.md +++ b/BENCHMARKING.md @@ -1,21 +1,49 @@ -# Benchmarking +# Benchmarks + +We present single-threaded **benchmarks** against FAISS+AVX512 on an `r7iz.xlarge` (Intel Sapphire Rapids) instance. + +### Two-Level IVF (IVF2) ![](https://img.shields.io/badge/Fastest%20search%20on%20PDX-red) +IVF2 tackles a bottleneck of IVF indexes: finding the nearest centroids. By clustering the original IVF centroids, we can use PDX to quickly scan them (thanks to pruning) without sacrificing recall. This achieves significant throughput improvements when paired with `8-bit` quantization. Within the codebase, we refer to this index as `PDXTree`. + +

+ PDX Layout +

+ +### Vanilla IVF +Here, PDX, paired with the pruning algorithm ADSampling on `float32`, achieves significant speedups. + +

+ PDX Layout +

-We provide a master script that setups the entire benchmarking suite for you. + +### Exhaustive search + IVF +An exhaustive search scans all the vectors in the collection. Having an IVF index with PDX can **EXTREMELY** accelerate this without sacrificing recall, thanks to the reliable pruning of ADSampling. + +

+ PDX Layout +

+ +The key observation here is that thanks to the underlying IVF index, the exhaustive search starts with the most promising clusters. A tight threshold is found early on, which enables the quick pruning of most candidates. + +### No pruning and no index +Even without pruning, PDX distance kernels can be faster than SIMD ones in most CPU microarchitectures. For detailed information, check Figure 3 of [our publication](https://ir.cwi.nl/pub/35044/35044.pdf). You can also try it yourself in our playground [here](./benchmarks/kernels_playground). + +# Benchmarking ## Setting up Data -To download all the datasets and generate all the indexes needed to run our benchmarking suite, you can use the script [/benchmarks/python_scripts/setup_data.py](/benchmarks/python_scripts/setup_data.py). For this, you need Python 3.11 or higher and install the dependencies in `/benchmarks/python_scripts/requirements.txt`. +To download all the datasets and generate all the indexes needed to run our benchmarking suite, you can use the script [setup_data.py](/benchmarks/python_scripts/setup_data.py). For this, you need Python 3.11 or higher and install the dependencies in `/benchmarks/python_scripts/requirements.txt`. -> [!CAUTION] -> You will need roughly 300GB of disk for ALL the indexes of the datasets used in our paper. +Run the script from the root folder with the script flags `DOWNLOAD` and `GENERATE_IVF` set to `True`. You do not need to generate the `ground_truth` for k <= 100 as it is already present. -Run the script from the root folder with the script flags `DOWNLOAD` and `GENERATE_IVF` set to `True` and the values in the `ALGORITHMS` array uncommented. You do not need to generate the `ground_truth` for k <= 100 as it is already present. +You can specify the datasets you wish to create indexes for on the `DATASETS_TO_USE` array in [setup_data.py](/benchmarks/python_scripts/setup_data.py). -You can specify the datasets you wish to create indexes for on the `DATASETS_TO_USE` array in the master script. ```sh pip install -r ./benchmarks/python_scripts/requirements.txt python ./benchmarks/python_scripts/setup_data.py ``` + The indexes will be created under the `/benchmarks/datasets/` directory. ### Manually downloading data @@ -25,80 +53,56 @@ You can also: Then, run the Master Script with the flag `DOWNLOAD = False`. -You can specify the datasets you wish to create indexes for on the `DATASETS_TO_USE` array in the master script. +You can specify the datasets you wish to create indexes for on the `DATASETS_TO_USE` array in [setup_data.py](/benchmarks/python_scripts/setup_data.py). ### Configuring the IVF indexes -Configure the IVF indexes in [/benchmarks/python_scripts/setup_core_index.py](/benchmarks/python_scripts/setup_core_index.py). The benchmarks presented in our publication use `n_buckets = 2 * sqrt(n)` for the number of inverted lists (buckets) and `n_training_points = 50 * n_buckets`. This will create solid indexes fairly quickly. +Configure the IVF indexes in [/benchmarks/python_scripts/setup_core_index.py](/benchmarks/python_scripts/setup_core_index.py). ## Running Benchmarks Once you have downloaded and created the indexes, you can start benchmarking. -### Requirements -1. Clang++17 or higher. -2. CMake 3.26 or higher. -3. Set CXX variable. E.g., `export CXX="/usr/bin/clang++-18"` +## Prerequisites + +### Clang, CMake, OpenMP and a BLAS implementation +Check [INSTALL.md](./INSTALL.md). ### Building We built our scripts with the proper `march` flags. Below are the flags we used for each microarchitecture: ```sh -# GRAVITON4 -cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS_RELEASE="-O3 -mcpu=neoverse-v2" -# GRAVITON3 -cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS_RELEASE="-O3 -mcpu=neoverse-v1" -# Intel Sapphire Rapids (256 vectors are used if mprefer-vector-width is not specified) -cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS_RELEASE="-O3 -march=sapphirerapids -mtune=sapphirerapids -mprefer-vector-width=512" -# ZEN4 -cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS_RELEASE="-O3 -march=znver4 -mtune=znver4" -# ZEN3 -cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS_RELEASE="-O3 -march=znver3 -mtune=znver3" - -make +cmake . -DPDX_COMPILE_BENCHMARKS=ON +make benchmarks ``` -On the [/benchmarks/CMakeLists.txt](/benchmarks/CMakeLists.txt) file, you can find which `.cpp` files map to which benchmark. - - -## Complete benchmarking scripts list - -### IVF index searches - -- PDX IVF ADSampling: `/benchmarks/BenchmarkPDXADSampling` -- PDX IVF ADSampling + SQ8: `/benchmarks/BenchmarkU8PDXADSampling` -- PDX Two-Level IVF ADSampling: `/benchmarks/BenchmarkIVF2ADSampling` -- PDX Two Level IVF ADSampling + SQ8: `/benchmarks/BenchmarkU8IVF2ADSampling` -- PDX IVF BOND: `/benchmarks/BenchmarkPDXIVFBOND` +## Benchmarking scripts list +- Index Creation and Search: `/benchmarks/BenchmarkEndToEnd` +- PDX IVF: `/benchmarks/BenchmarkPDXIVF` - FAISS IVF: `/benchmarks/python_scripts/ivf_faiss.py` -All of these programs have two optional parameters: -- `` to specify the name of the dataset to use. If not given, it will try to use all the datasets set in [benchmark_utils.hpp](/include/utils/benchmark_utils.hpp) or [benchmark_utils.py](/benchmarks/python_scripts/benchmark_utils.py) in the Python scripts. -- `` to specify the `nprobe` parameter on the IVF index, which controls the recall. If not given or `0`, it will use a series of parameters from 2 to 4096 set in the [benchmark_utils.hpp](/include/utils/benchmark_utils.hpp) or [benchmark_utils.py](/benchmarks/python_scripts/benchmark_utils.py) in the Python scripts. +PDX programs have three parameters: +- `` to specify the type of PDX index to use. We support 4 index types. From least to most performant: + - `pdx_f32`: IVF index with float32 vectors + - `pdx_tree_f32`: Tree IVF index with float32 vectors + - `pdx_u8`: IVF index with 8-bit scalar quantization + - `pdx_tree_u8`: Tree IVF index with 8-bit scalar quantization +- `` to specify the identifier of the dataset to use. If not given, it will try to use all the datasets set in [benchmark_utils.hpp](/include/utils/benchmark_utils.hpp) or [benchmark_utils.py](/benchmarks/python_scripts/benchmark_utils.py) in the Python scripts. +- `` to specify the `nprobe` parameter on the IVF index, which controls the recall. If not given or `0`, it will use a series of parameters from 2 to 4096 set in the [benchmark_utils.hpp](/include/utils/benchmark_utils.hpp) or [benchmark_utils.py](/benchmarks/python_scripts/benchmark_utils.py) in the Python scripts. -PDX IVF BOND has an additional third parameter: -- ``: An integer value. On Intel SPR, we use distance-to-means (`1`). For the other microarchitectures, we use dimension-zones (`5`). Refer to Figure 5 of [our publication](https://ir.cwi.nl/pub/35044/35044.pdf). +
-> [!IMPORTANT] -> Recall that the IVF indexes must be created beforehand by the `setup_data.py` script. - -### Exact Search -- PDX BOND: ```/benchmarks/BenchmarkPDXBOND``` -- USearch: ```python /benchmarks/python_scripts/exact_usearch.py``` -- SKLearn: ```python /benchmarks/python_scripts/exact_sklearn.py``` -- FAISS: ```python /benchmarks/python_scripts/exact_faiss.py``` +List of Datasets -All of these programs have one optional parameter: -- `` to specify the name of the dataset to use. If not given, it will try to use all the datasets set in [benchmark_utils.hpp](/include/utils/benchmark_utils.hpp) or [benchmark_utils.py](/benchmarks/python_scripts/benchmark_utils.py) in the Python scripts. +| Identifier | Dataset HDF5 Name in Google Drive | Embeddings | Model | # Vectors | Dim. | Size (GB) ↑ | +| ------------ | ----------------------------------- | ------------- | ------------ | --------- | ---- | ----------- | +| `arxiv` | `instructorxl-arxiv-768` | Text | InstructorXL | 2,253,000 | 768 | 6.92 | +| `openai` | `openai-1536-angular` | Text | OpenAI | 999,000 | 1536 | 6.14 | +| `wiki` | `simplewiki-openai-3072-normalized` | Text | OpenAI | 260,372 | 3072 | 3.20 | +| `mxbai` | `agnews-mxbai-1024-euclidean` | Text | MXBAI | 769,382 | 1024 | 3.15 | -PDX BOND has an additional second parameter: -- ``: An integer value. On exact-search, we always use distance-to-means (`1`). Refer to Figure 5 of [our publication](https://ir.cwi.nl/pub/35044/35044.pdf). +
-**Notes**: Usearch, SKLearn, and FAISS scripts expect the original `.hdf5` files under the `/downloaded` directory. Furthermore, they require their respective Python packages (`pip install -r ./benchmarks/python_scripts/requirements.txt`). +> [!IMPORTANT] +> Recall that for `BenchmarkPDXIVF` and `ivf_faiss.py` the indexes must be created beforehand by the `setup_data.py` script. ## Output Output is written in a .csv format to the `/benchmarks/results/DEFAULT` directory. Each file contains entries detailing the experiment parameters, such as the dataset, algorithm, kNN, number of queries (`n_queries`), `ivf_nprobe`, and, more importantly, the average runtime per query in ms in the `avg` column. Each benchmarking script will create a file with a different name. - -## Kernels Experiment -Visit our playground for PDX vs SIMD kernels [here](./benchmarks/bench_kernels) - -## SIGMOD'25 -Check the `sigmod` branch. \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index c8d1d3b..22f2eba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,13 +1,55 @@ cmake_minimum_required(VERSION 3.26) -set(CMAKE_CXX_STANDARD 17) project(PDX) +# Default to Release build if not specified +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type (default: Release)" FORCE) +endif() +message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall") +set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3 -march=native") +set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/extern/findFFTW") + add_compile_options(-fPIC) include(FetchContent) include(CheckCXXCompilerFlag) include(CMakePrintHelpers) include(CTest) -# include(ExternalProject) + +include_directories(${CMAKE_CURRENT_SOURCE_DIR}/extern/SuperKMeans/include) + +find_package(OpenMP REQUIRED) + +list(PREPEND CMAKE_PREFIX_PATH /usr/local) + +set(MKL_INTERFACE_FULL "intel_lp64") +find_package(MKL CONFIG QUIET) +if (MKL_FOUND) + message(STATUS "MKL library found") + message(STATUS "MKL targets: ${MKL_IMPORTED_TARGETS}") + get_target_property(mkl_includes MKL::MKL INTERFACE_INCLUDE_DIRECTORIES) + message(STATUS "MKL includes: ${mkl_includes}") + add_definitions(-DEIGEN_USE_MKL_ALL) + set(MKL_COMMON_LIBS MKL::MKL OpenMP::OpenMP_CXX m dl) + set(BLAS_LINK_LIBRARIES "") +else() + set(MKL_COMMON_LIBS "") + message(STATUS "MKL not found. Trying to find a BLAS implementation") + + # On macOS, prefer Apple's Accelerate framework over other BLAS implementations + if(APPLE) + set(BLA_VENDOR Apple) + message(STATUS "macOS detected: prioritizing Apple Accelerate framework") + endif() + + find_package(BLAS REQUIRED) + message(STATUS "BLAS library found: ${BLAS_LIBRARIES}") + add_definitions(-DEIGEN_USE_BLAS) + set(BLAS_LINK_LIBRARIES ${BLAS_LIBRARIES} OpenMP::OpenMP_CXX) +endif() set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "extern/findFFTW") find_package(FFTW QUIET) @@ -16,42 +58,69 @@ if (FFTW_FLOAT_LIB_FOUND) add_definitions(-DHAS_FFTW) include_directories(${FFTW_INCLUDE_DIRS}) else() - message(WARNING "FFTW (+float capabilities) not found, we recommend you install it") -# TODO: Perhaps downloading? -# set(FFTW_INSTALL_DIR ${CMAKE_BINARY_DIR}/fftw-install) -# ExternalProject_Add(fftw -# URL http://www.fftw.org/fftw-3.3.10.tar.gz -# PREFIX fftw-src -# CONFIGURE_COMMAND ./configure --prefix=${FFTW_INSTALL_DIR} --enable-float -# BUILD_COMMAND make -j4 -# INSTALL_COMMAND make install -# ) -# set(FFTW_INCLUDE_DIRS ${FFTW_INSTALL_DIR}/include) -# set(FFTW_FLOAT_LIB ${FFTW_INSTALL_DIR}/lib/libfftw3f.so) -# set(FFTW_FOUND TRUE) -# add_definitions(-DHAS_FFTW) + message(STATUS "FFTW (+float capabilities) not found, we recommend you install them for increased performance") endif() -# Installing FFTW https://www.fftw.org/fftw3_doc/Installation-on-Unix.html --------------------------------------------- -# wget https://www.fftw.org/fftw-3.3.10.tar.gz -# tar -xvzf fftw-3.3.10.tar.gz -# cd fftw-3.3.10 -# ./configure --enable-float --enable-shared # --enabled-shared is required for ctypes.util.find_library("fftw3f") -# sudo make -# sudo make install -# ldconfig - -# CMAKE_SOURCE_DIR: ---------------------------------------------------------------------------------------------------- add_compile_definitions(CMAKE_SOURCE_DIR="${CMAKE_SOURCE_DIR}") -# Gtest: --------------------------------------------------------------------------------------------------------------- -#include(FetchContent) -#FetchContent_Declare( -# googletest -# URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip -#) -#set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) -#FetchContent_MakeAvailable(googletest) - -include_directories(include extern) -add_subdirectory(benchmarks) +include_directories(include extern/Eigen) + +# Benchmarks: disabled by default, enable with -DPDX_COMPILE_BENCHMARKS=ON +set(PDX_COMPILE_BENCHMARKS OFF CACHE BOOL "Whether to compile benchmarks") +if(PDX_COMPILE_BENCHMARKS) + message(STATUS "Benchmarks enabled") + add_subdirectory(benchmarks) +endif() + +# Tests: disabled by default, enable with -DPDX_COMPILE_TESTS=ON +set(PDX_COMPILE_TESTS OFF CACHE BOOL "Whether to compile tests") +if(PDX_COMPILE_TESTS) + message(STATUS "Tests enabled") + add_subdirectory(tests) +endif() + +# Python bindings: only build if pybind11 is available +find_package(Python COMPONENTS Interpreter Development.Module QUIET) +set(PYBIND11_FINDPYTHON ON) +find_package(pybind11 CONFIG QUIET) + +if(Python_FOUND AND pybind11_FOUND) + message(STATUS "Python bindings enabled") + message(STATUS "Python executable: ${Python_EXECUTABLE}") + message(STATUS "pybind11 found: ${pybind11_VERSION}") + + pybind11_add_module(compiled + python/lib.cpp + ) + + target_include_directories(compiled PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/extern/Eigen + ${CMAKE_CURRENT_SOURCE_DIR}/extern/SuperKMeans/include + ) + + if(MKL_FOUND) + target_link_libraries(compiled PRIVATE ${MKL_COMMON_LIBS}) + else() + target_link_libraries(compiled PRIVATE ${BLAS_LINK_LIBRARIES}) + endif() + + if(FFTW_FOUND) + target_link_libraries(compiled PRIVATE ${FFTW_FLOAT_LIB} ${FFTW_FLOAT_OPENMP_LIB}) + endif() + + target_compile_options(compiled PRIVATE + -Wall + -Wno-unknown-pragmas + $<$:-O3 -march=native> + ) + + target_compile_features(compiled PRIVATE cxx_std_17) + + install(TARGETS compiled + LIBRARY DESTINATION pdxearch + COMPONENT python + ) +else() + message(STATUS "Python bindings disabled (pybind11 not found)") +endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7a2922d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +We are actively developing PDX and accepting contributions! Any kind of PR is welcome. + +These are our current priorities: + +**Features**: +- Inserts and Updates (wip). +- Out-of-core execution (disk-based setting). +- Implement multi-threading capabilities. +- Add PDX to the [VIBE benchmark](https://vector-index-bench.github.io/). +- Create a documentation. + +**Improvements**: +- Regression tests on CI. + + +## Getting Started + +1. **Fork the repository** on GitHub and create a feature branch: +```bash +git checkout -b my-feature +``` + +2. **Make your changes.** +3. **Run the test suite** locally before submitting your PR. +4. **Open a Pull Request (PR)** against the `main` branch. + +> [!IMPORTANT] +> Let us know in advance if you plan implementing a big feature! + +## Testing + +All PRs must pass the full test suite in CI. Before submitting a PR, you should run tests locally: + +```bash +# C++ tests +cmake . -DPDX_COMPILE_TESTS=ON +make -j$(nproc) tests +ctest . +``` + +Tests are also prone to bugs. If that is the case, please open an Issue. + +## Submitting a PR + +* Open your PR against the **`main` branch**. +* Make sure your branch is **rebased on top of `main`** before submission. +* Verify that **CI passes**. +* Keep PRs focused — small, logical changes are easier to review and merge. + +## Coding Style +* Function, Class, and Struct names: `PascalCase` +* Variables and Class/Struct member names: `snake_case` +* Constants and magic variables: `UPPER_SNAKE_CASE` +* Avoid `new` and `delete` +* There is a `.clang-format` in the project. Make sure to adhere to it. We have provided scripts to check and format the files within the project: +```bash +pip install clang-format==18.1.8 +./scripts/format_check.sh # Checks the formatting +./scripts/format.sh # Fix the formatting +``` + +## Communication + +* Use GitHub Issues for bug reports and feature requests. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..64bc776 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,142 @@ +# Installation + +### PDX needs: +- Clang 17, CMake 3.26 +- OpenMP +- A BLAS implementation +- Python 3 (only for Python bindings) + +Once you have these requirements, you can install the Python Bindings + +
+ Installing Python Bindings + +```sh +git clone https://github.com/cwida/PDX +cd PDX +git submodule update --init + +# Create a venv if needed +python -m venv ./venv +source venv/bin/activate + +# Set proper clang compiler if needed +export CXX="/usr/bin/clang++-18" + +pip install . +``` +
+ + +## Step by Step +* [Installing Clang](#installing-clang) +* [Installing CMake](#installing-cmake) +* [Installing OpenMP](#installing-openmp) +* [Installing BLAS](#installing-blas) +* [Installing FFTW](#installing-blas) [optional] +* [Troubleshooting](#troubleshooting) + +## Installing Clang +We recommend LLVM +### Linux +```sh +sudo bash -c "$(wget -O - https://apt.llvm.org/llvm.sh)" -- 18 +``` + +### MacOS +```sh +brew install llvm +``` + +## Installing CMake +### Linux +```sh +sudo apt update +sudo apt install make +sudo apt install cmake +``` + +### MacOS +```sh +brew install cmake +``` + +## Installing OpenMP + +### Linux +Most distributions come with OpenMP, or you can install it with: +```sh +sudo apt-get install libomp-dev +``` + +### MacOS +```sh +brew install libomp +``` + + +## Installing BLAS + +BLAS is extremely important to achieve high performance. We recommend [OpenBLAS](https://github.com/OpenMathLib/OpenBLAS). + +### Linux +Most distributions come with [OpenBLAS](https://github.com/OpenMathLib/OpenBLAS), or you may have already installed OpenBLAS via `apt`. **THIS IS SLOW**. We recommend installing OpenBLAS from source with the commands below. + +```sh +git clone https://github.com/OpenMathLib/OpenBLAS.git +cd OpenBLAS +make -j$(nproc) DYNAMIC_ARCH=1 USE_OPENMP=1 NUM_THREADS=128 +make -j$(nproc) PREFIX=/usr/local install +ldconfig +``` + +### MacOS +**Silicon Chips (M1 to M5)**: You don't need to do anything special. We automatically detect [Apple Accelerate](https://developer.apple.com/documentation/accelerate) that uses the [AMX](https://github.com/corsix/amx) unit. + +**Intel Chips (older Macs)**: Install OpenBLAS as detailed above. + +## Installing FFTW +[FFTW](https://www.fftw.org/fftw3_doc/Installation-on-Unix.html) will give you better performance in very high-dimensional datasets (d > 1024). + +```sh +wget https://www.fftw.org/fftw-3.3.10.tar.gz +tar -xvzf fftw-3.3.10.tar.gz +cd fftw-3.3.10 +./configure --enable-float --enable-shared --enable-openmp +sudo make -j$(nproc) +sudo make install +ldconfig +``` + +## Troubleshooting + +### Python bindings installation fails + +Error: +``` +Could NOT find Python (missing: Development.Module) + Reason given by package: + Development: Cannot find the directory "/usr/include/python3.12" +``` + +Solution: Install `python-dev` package: + +```sh +sudo apt install python3-dev +``` + +### I get a bunch of `warnings` when compiling PDX + +If you see a lot of warnings like this one: +```warning: ignoring ‘#pragma clang loop’``` + +You are using GCC instead of Clang. If you installed Clang, you can set the correct compiler by doing the following: +```sh +export CXX="/usr/bin/clang++-18" # Linux + +export CXX="/opt/homebrew/opt/llvm/bin/clang++" # MacOS +``` + +### Does PDX use SIMD? +Yes. We have optimizations for AVX512, AVX2, and NEON. You don't need to do anything special to activate these. If your machine doesn't have any of these, we rely on scalar code. + diff --git a/README.md b/README.md index d076894..cbfd6ec 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,90 @@

- PDX: A Transposed Data Layout for Similarity Search + PDX: A Library for Fast Vector Search and Indexing
Paper + CI License GitHub stars

- -[PDX](https://ir.cwi.nl/pub/35044/35044.pdf) is a data layout that **transposes** vectors in a column-major order. This layout unleashes the true potential of dimension pruning, accelerating vanilla IVF indexes by factors: +

+ Index millions of vectors in seconds. Search them in milliseconds. +

- PDX Layout + PDX Layout

-PDX makes an IVF index, competitive with HNSW: -

- PDX Layout -

+## Why PDX? -### PDX benefits: - -- ⚡ Up to [**10x faster**](#two-level-ivf-ivf2-) **IVF searches** than FAISS+AVX512. -- ⚡ Up to [**30x faster**](#exhaustive-search--ivf) **exhaustive search**. +- ⚡ [**30x faster index building**](https://www.lkuffo.com/superkmeans/) thanks to [SuperKMeans](https://github.com/lkuffo/SuperKMeans). +- ⚡ [**Sub-millisecond similarity search**](https://www.lkuffo.com/sub-milisecond-similarity-search-with-pdx/), up to [**10x faster**](./BENCHMARKING.md#two-level-ivf-ivf2-) than FAISS IVF. +- ⚡ Up to [**30x faster**](./BENCHMARKING.md#exhaustive-search--ivf) exhaustive search. - 🔍 Efficient [**filtered search**](https://github.com/cwida/PDX/issues/7). +- Query latency competitive with HNSW, with the ease of use of IVF. -
- -## Contents -- [Pruning in a nutshell](#pruning-in-a-nutshell) -- [Try PDX](#try-pdx) -- [Use cases (comparison with FAISS)](#use-cases-and-benchmarks) -- [The data layout](#the-data-layout) -- [Roadmap](#roadmap) +## Our secret sauce -## Pruning in a nutshell +[PDX](https://ir.cwi.nl/pub/35044/35044.pdf) is a data layout that **transposes** vectors in a column-major order. This layout unleashes the true potential of dimension pruning. -Pruning means avoiding checking *all* the dimensions of a vector to determine if it is a neighbour of a query. The PDX layout unleashes the true potential of these algorithms (e.g., [ADSampling](https://github.com/gaoj0017/ADSampling/)), accelerating vanilla IVF indexes by factors. +Pruning means avoiding checking *all* the dimensions of a vector to determine if it is a neighbour of a query, accelerating index construction and similarity search by factors. -Pruning is especially effective for large embeddings (`d > 512`) and when targeting high recalls (`> 0.90`) or nearly exact results. +## Use Cases and Benchmarking +Check [./BENCHMARKING.md](./BENCHMARKING.md). -[Down below](#use-cases-and-benchmarks), you will find **benchmarks** against FAISS. +## Usage +```py +from pdxearch import IndexPDXIVFTreeSQ8 +data = ... # Numpy 2D matrix +query = ... # Numpy 1D array +d = 1024 +knn = 20 -## Try PDX -Try PDX with your data using our Python bindings and [examples](/examples). We have implemented PDX on Flat (`float32`) and Quantized (`8-bit`) **IVF indexes** and **exhaustive search** settings. -### Prerequisites -- PDX is available for x86_64 (with AVX512), ARM, and Apple silicon -- Python 3.11 or higher -- [FAISS](https://github.com/facebookresearch/faiss/blob/main/INSTALL.md) with Python Bindings -- Clang++17 or higher -- CMake 3.26 or higher - -### Installation Steps -1. Clone and init submodules -```sh -git clone https://github.com/cwida/PDX -git submodule init -git submodule update -``` +index = IndexPDXIVFTreeSQ8(num_dimensions=d) +index.build(data) -2. *[Optional]* Install [FFTW](https://www.fftw.org/fftw3_doc/Installation-on-Unix.html) (for higher throughput) -```sh -wget https://www.fftw.org/fftw-3.3.10.tar.gz -tar -xvzf fftw-3.3.10.tar.gz -cd fftw-3.3.10 -./configure --enable-float --enable-shared -sudo make -sudo make install -ldconfig -``` +ids, dists = index.search(query, knn) -3. Install Python dependencies and the bindings. -```sh -export CXX="/usr/bin/clang++-18" # Set proper CXX first -pip install -r requirements.txt -python setup.py clean --all -python -m pip install . ``` -4. Run the examples under `/examples` -```sh -# Creates an IVF index with FAISS on random data -# Then, it compares the search performance of PDXearch and FAISS -python ./examples/pdx_simple.py -``` -For more details on the available examples and how to use your own data, refer to [/examples/README.md](./examples/README.md). - -> [!NOTE] -> We heavily rely on [FAISS](https://github.com/facebookresearch/faiss/blob/main/INSTALL.md) to create the underlying IVF indexes. To quickly install it you can do: `pip install faiss-cpu` - -## Use Cases and Benchmarks -We present single-threaded **benchmarks** against FAISS+AVX512 on an `r7iz.xlarge` (Intel Sapphire Rapids) instance. -### Two-Level IVF (IVF2) ![](https://img.shields.io/badge/Fastest%20search%20on%20PDX-red) -IVF2 tackles a bottleneck of IVF indexes: finding the nearest centroids. By clustering the original IVF centroids, we can use PDX to quickly scan them (thanks to pruning) without sacrificing recall. This achieves significant throughput improvements when paired with `8-bit` quantization. +`IndexPDXIVFTreeSQ8` is our fastest index that will give you the best performance. It is a two-level IVF index with 8-bit quantization. -

- PDX Layout -

- -### Vanilla IVF -Here, PDX, paired with the pruning algorithm ADSampling on `float32`, achieves significant speedups. +Check our [examples](./examples/) for fully working examples in Python and our [benchmarks](./benchmarks) for fully working examples in C++. We support Flat (`float32`) and Quantized (`8-bit`) indexes, as well as the most common distance metrics. -

- PDX Layout -

+## Installation +We provide Python bindings for ease of use. Soon, we will be available on PyPI. +### Prerequisites +- Clang 17, CMake 3.26 +- OpenMP +- A BLAS implementation +- Python 3 (only for Python bindings) -### Exhaustive search + IVF -An exhaustive search scans all the vectors in the collection. Having an IVF index with PDX can **EXTREMELY** accelerate this without sacrificing recall, thanks to the reliable pruning of ADSampling. +### Installation Steps +```sh +git clone --recurse-submodules https://github.com/cwida/PDX +cd PDX -

- PDX Layout -

+pip install . -The key observation here is that thanks to the underlying IVF index, the exhaustive search starts with the most promising clusters. A tight threshold is found early on, which enables the quick pruning of most candidates. +# Run the examples under `/examples` +# pdx_simple.py creates an IVF index with FAISS on random data +# Then, it compares the search performance of PDX and FAISS +python ./examples/pdx_simple.py +``` -### Exhaustive search without an index -By creating random clusters with the PDX layout, you can still accelerate exhaustive search without an index. Unlike ADSampling, with BOND (our pruning algorithm), you can use the raw vectors. Gains vary depending on the distribution of the data. +For a more comprehensive installation and compilation guide, check [INSTALL.md](./INSTALL.md). -

- PDX Layout -

+## Getting the Best Performance +Check [INSTALL.md](./INSTALL.md). -### No pruning and no index -Even without pruning, PDX distance kernels can be faster than SIMD ones in most CPU microarchitectures. For detailed information, check Figure 3 of [our publication](https://ir.cwi.nl/pub/35044/35044.pdf). You can also try it yourself in our playground [here](./benchmarks/bench_kernels). +## Roadmap +We are actively developing Super K-Means and accepting contributions! Check [CONTRIBUTING.md](./CONTRIBUTING.md) ## The Data Layout -PDX is a transposed layout (a.k.a. columnar, or decomposed layout), which means that the same dimensions of different vectors are stored sequentially. This decomposition occurs within a block (e.g., a cluster in an IVF index). +PDX is a transposed layout (a.k.a. columnar, or decomposed layout), meaning that the dimensions of different vectors are stored sequentially. This decomposition occurs within a block (e.g., a cluster in an IVF index). We have evolved our layout from the one presented in our publication to reduce random access, and adapted it to work with `8-bit` and (in the future) `1-bit` vectors. @@ -140,27 +97,15 @@ The following image shows this layout. Storage is sequential from left to right,

### `8 bits` -Smaller data types are not friendly to PDX, as we must accumulate distances on wider types, resulting in asymmetry. We can work around this by changing the PDX layout. For `8 bits`, the vertical block is decomposed every 4 dimensions. This allows us to use dot product instructions (`VPDPBUSD` in [x86](https://www.officedaytime.com/simd512e/simdimg/si.php?f=vpdpbusd) and `UDOT/SDOT` in [NEON](https://developer.arm.com/documentation/102651/a/What-are-dot-product-intructions-)) to calculate L2 or IP kernels while still benefiting from PDX. The horizontal block remains decomposed every 64 dimensions. +Smaller data types are not friendly to PDX, as we must accumulate distances on wider types, resulting in asymmetry. We can work around this by changing the PDX layout. For `8 bits`, the vertical block is decomposed every 4 dimensions. This allows us to use dot-product instructions (`VPDPBUSD` on [x86](https://www.officedaytime.com/simd512e/simdimg/si.php?f=vpdpbusd) and `UDOT/SDOT` on [NEON](https://developer.arm.com/documentation/102651/a/What-are-dot-product-intructions-)) to calculate L2 or IP kernels while still benefiting from PDX. The horizontal block remains decomposed every 64 dimensions.

PDX Layout F32

-### `binary` -For Hamming/Jaccard kernels, we use a layout decomposed every 8 dimensions (naturally grouped into bytes). The population count accumulation can be done in `bytes`. If d > 256, we flush the popcounts into a wider type every 32 words (corresponding to 256 dimensions). This has not been implemented in this repository yet, but you can find some promising benchmarks [here](https://github.com/lkuffo/binary-index). - -## Roadmap -- Out-of-core execution (disk-based setting). -- Add unit tests. -- Implement multi-threading capabilities. -- Add PDX to the [VIBE benchmark](https://vector-index-bench.github.io/). -- Adaptive quantization on 8-bit and 4-bit. -- Create a documentation. -> [!IMPORTANT] -> PDX is an ongoing research project. In its current state, it is not production-ready code. + -## Benchmarking -To run our benchmark suite in C++, refer to [BENCHMARKING.md](./BENCHMARKING.md). ## Citation If you use PDX for your research, consider citing us: @@ -177,6 +122,3 @@ If you use PDX for your research, consider citing us: publisher={ACM New York, NY, USA} } ``` - -## SIGMOD -The code used for the experiments presented at SIGMOD'25 can be found in the `sigmod` branch. diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 10506d2..fa95195 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -1,22 +1,29 @@ -# ADSampling -add_executable(BenchmarkPDXADSampling bench_adsampling/pdx_ivf_adsampling.cpp) -add_executable(BenchmarkIVF2ADSampling bench_adsampling/pdx_ivf2_adsampling.cpp) -add_executable(BenchmarkU8PDXADSampling bench_adsampling/pdx_ivf_adsampling_u8.cpp) -add_executable(BenchmarkU8IVF2ADSampling bench_adsampling/pdx_ivf2_adsampling_u8.cpp) -add_executable(FilteredBenchmarkU8IVF2ADSampling bench_adsampling/pdx_ivf2_adsampling_u8_filtered.cpp) -add_executable(FilteredBenchmarkPDXADSampling bench_adsampling/pdx_ivf_adsampling_filtered.cpp) +include_directories(${CMAKE_CURRENT_SOURCE_DIR}) -# BOND -add_executable(BenchmarkPDXIVFBOND ./bench_bond/pdx_bond_ivf.cpp) -add_executable(BenchmarkPDXBOND ./bench_bond/pdx_bond.cpp) +set(BENCH_COMMON_LIBS ${MKL_COMMON_LIBS} ${BLAS_LINK_LIBRARIES}) if (FFTW_FOUND) - target_link_libraries(BenchmarkPDXADSampling ${FFTW_FLOAT_LIB}) - target_link_libraries(FilteredBenchmarkPDXADSampling ${FFTW_FLOAT_LIB}) - target_link_libraries(BenchmarkIVF2ADSampling ${FFTW_FLOAT_LIB}) - target_link_libraries(BenchmarkU8PDXADSampling ${FFTW_FLOAT_LIB}) - target_link_libraries(BenchmarkU8IVF2ADSampling ${FFTW_FLOAT_LIB}) - target_link_libraries(FilteredBenchmarkU8IVF2ADSampling ${FFTW_FLOAT_LIB}) - target_link_libraries(BenchmarkPDXIVFBOND ${FFTW_FLOAT_LIB}) - target_link_libraries(BenchmarkPDXBOND ${FFTW_FLOAT_LIB}) + message(STATUS "Linking FFTW: ${FFTW_FLOAT_LIB} ${FFTW_FLOAT_OPENMP_LIB}") + list(APPEND BENCH_COMMON_LIBS ${FFTW_FLOAT_LIB} ${FFTW_FLOAT_OPENMP_LIB}) endif() + +add_executable(BenchmarkPDXIVF pdx_ivf.cpp) +add_executable(BenchmarkEndToEnd pdx_end_to_end.cpp) +add_executable(BenchmarkSerialization pdx_serialization.cpp) +add_executable(BenchmarkFiltered pdx_filtered.cpp) +add_executable(BenchmarkSpecialFilters pdx_special_filtered.cpp) + +target_link_libraries(BenchmarkPDXIVF ${BENCH_COMMON_LIBS}) +target_link_libraries(BenchmarkEndToEnd ${BENCH_COMMON_LIBS}) +target_link_libraries(BenchmarkSerialization ${BENCH_COMMON_LIBS}) +target_link_libraries(BenchmarkFiltered ${BENCH_COMMON_LIBS}) +target_link_libraries(BenchmarkSpecialFilters ${BENCH_COMMON_LIBS}) + +add_custom_target(benchmarks + DEPENDS + BenchmarkPDXIVF + BenchmarkEndToEnd + BenchmarkSerialization + BenchmarkFiltered + BenchmarkSpecialFilters +) diff --git a/benchmarks/bench_adsampling/pdx_ivf2_adsampling.cpp b/benchmarks/bench_adsampling/pdx_ivf2_adsampling.cpp deleted file mode 100644 index da05572..0000000 --- a/benchmarks/bench_adsampling/pdx_ivf2_adsampling.cpp +++ /dev/null @@ -1,111 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf2.hpp" -#include "pruners/adsampling.hpp" -#include "pdxearch.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - size_t arg_ivf_nprobe = 0; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - arg_ivf_nprobe = atoi(argv[2]); - } - std::cout << "==> PDX IVF ADSampling\n"; - - std::string ALGORITHM = "adsampling"; - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float EPSILON0 = BenchmarkUtils::EPSILON0; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - PDX::DimensionsOrder DIMENSION_ORDER = PDX::SEQUENTIAL; - - std::string RESULTS_PATH; - RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "IVF2_PDX_ADSAMPLING.csv"; - - - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF2 pdx_data = PDX::IndexPDXIVF2(); - pdx_data.Restore(BenchmarkUtils::PDX_ADSAMPLING_DATA + dataset + "-ivf2"); - - std::unique_ptr _matrix_ptr = MmapFile(BenchmarkUtils::NARY_ADSAMPLING_DATA + dataset + "-ivf2-matrix"); - auto *_matrix = reinterpret_cast(_matrix_ptr.get()); - - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - - NUM_QUERIES = 1000; - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + dataset + "_100_norm"); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - PDX::ADSamplingPruner pruner = PDX::ADSamplingPruner(pdx_data.num_dimensions, EPSILON0, _matrix); - PDX::PDXearch searcher = PDX::PDXearch(pdx_data, pruner, 1, DIMENSION_ORDER); - - std::vector nprobes_to_use; - if (arg_ivf_nprobe > 0) { - nprobes_to_use = {arg_ivf_nprobe}; - } else { - nprobes_to_use.assign(std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES)); - } - - for (size_t ivf_nprobe : nprobes_to_use) { - if (pdx_data.num_clusters < ivf_nprobe){ - continue; - } - if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe){ - continue; - } - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - searcher.SetNProbe(ivf_nprobe); - - float recalls = 0; - if (VERIFY_RESULTS) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.Search(query + l * pdx_data.num_dimensions, KNN); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.Search(query + l * pdx_data.num_dimensions, KNN); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - BenchmarkUtils::SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - ivf_nprobe, - KNN, - recalls, - real_selectivity - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } - } - return 0; -} \ No newline at end of file diff --git a/benchmarks/bench_adsampling/pdx_ivf2_adsampling_u8.cpp b/benchmarks/bench_adsampling/pdx_ivf2_adsampling_u8.cpp deleted file mode 100644 index dad758d..0000000 --- a/benchmarks/bench_adsampling/pdx_ivf2_adsampling_u8.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf2.hpp" -#include "pruners/adsampling.hpp" -#include "pdxearch.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - size_t arg_ivf_nprobe = 0; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - arg_ivf_nprobe = atoi(argv[2]); - } - std::cout << "==> PDX IVF ADSampling\n"; - - std::string ALGORITHM = "adsampling"; - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float EPSILON0 = BenchmarkUtils::EPSILON0; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - PDX::DimensionsOrder DIMENSION_ORDER = PDX::SEQUENTIAL; - - std::string RESULTS_PATH; - RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "U8_IVF2_PDX_ADSAMPLING.csv"; - - - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF2 pdx_data = PDX::IndexPDXIVF2(); - pdx_data.Restore(BenchmarkUtils::PDX_ADSAMPLING_DATA + dataset + "-ivf2-u8"); - std::unique_ptr _matrix_ptr = MmapFile(BenchmarkUtils::NARY_ADSAMPLING_DATA + dataset + "-ivf2-u8-matrix"); - auto *_matrix = reinterpret_cast(_matrix_ptr.get()); - - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - NUM_QUERIES = 1000; - - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + dataset + "_100_norm"); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - PDX::ADSamplingPruner pruner = PDX::ADSamplingPruner(pdx_data.num_dimensions, EPSILON0, _matrix); - PDX::PDXearch searcher = PDX::PDXearch>(pdx_data, pruner, 1, DIMENSION_ORDER); - - std::vector nprobes_to_use; - if (arg_ivf_nprobe > 0) { - nprobes_to_use = {arg_ivf_nprobe}; - } else { - nprobes_to_use.assign(std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES)); - } - - for (size_t ivf_nprobe : nprobes_to_use) { - if (pdx_data.num_clusters < ivf_nprobe){ - continue; - } - if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe){ - continue; - } - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - searcher.SetNProbe(ivf_nprobe); - - float recalls = 0; - if (VERIFY_RESULTS) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.Search(query + l * pdx_data.num_dimensions, KNN); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.Search(query + l * pdx_data.num_dimensions, KNN); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - BenchmarkUtils::SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - ivf_nprobe, - KNN, - recalls, - real_selectivity - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } - } - return 0; -} \ No newline at end of file diff --git a/benchmarks/bench_adsampling/pdx_ivf2_adsampling_u8_filtered.cpp b/benchmarks/bench_adsampling/pdx_ivf2_adsampling_u8_filtered.cpp deleted file mode 100644 index e65f246..0000000 --- a/benchmarks/bench_adsampling/pdx_ivf2_adsampling_u8_filtered.cpp +++ /dev/null @@ -1,119 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf2.hpp" -#include "pruners/adsampling.hpp" -#include "pdxearch.hpp" -#include "db_mock/predicate_evaluator.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - std::string arg_selectivity; - size_t arg_ivf_nprobe = 0; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - arg_ivf_nprobe = atoi(argv[2]); - } - if (argc > 3){ - arg_selectivity = argv[3]; - } else { - arg_selectivity = "0_99"; - } - std::cout << "==> PDX IVF ADSampling\n"; - - std::string ALGORITHM = "adsampling"; - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float EPSILON0 = BenchmarkUtils::EPSILON0; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - PDX::DimensionsOrder DIMENSION_ORDER = PDX::SEQUENTIAL; - - std::string RESULTS_PATH; - RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "U8_IMI_PDX_ADSAMPLING_FILTERED.csv"; - - std::cout << "==> SELECTIVITY: " << arg_selectivity << std::endl; - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF2 pdx_data = PDX::IndexPDXIVF2(); - pdx_data.Restore(BenchmarkUtils::PDX_ADSAMPLING_DATA + dataset + "-ivf2-u8"); - std::unique_ptr _matrix_ptr = MmapFile(BenchmarkUtils::NARY_ADSAMPLING_DATA + dataset + "-ivf2-u8-matrix"); - auto *_matrix = reinterpret_cast(_matrix_ptr.get()); - - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - - NUM_QUERIES = 1000; - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::FILTERED_GROUND_TRUTH_DATA + dataset + "_100_norm_" + arg_selectivity); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - PDX::PredicateEvaluator predicate_evaluator = PDX::PredicateEvaluator(pdx_data.num_clusters); - predicate_evaluator.LoadSelectionVectorFromFile(BenchmarkUtils::SELECTION_VECTOR_DATA + dataset + "_" + arg_selectivity + ".bin"); - PDX::ADSamplingPruner pruner = PDX::ADSamplingPruner(pdx_data.num_dimensions, EPSILON0, _matrix); - PDX::PDXearch searcher = PDX::PDXearch>(pdx_data, pruner, 1, DIMENSION_ORDER); - - std::vector nprobes_to_use; - if (arg_ivf_nprobe > 0) { - nprobes_to_use = {arg_ivf_nprobe}; - } else { - nprobes_to_use.assign(std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES)); - } - - for (size_t ivf_nprobe : nprobes_to_use) { - if (pdx_data.num_clusters < ivf_nprobe){ - continue; - } - if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe){ - continue; - } - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - searcher.SetNProbe(ivf_nprobe); - - float recalls = 0; - if (VERIFY_RESULTS) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.FilteredSearch(query + l * pdx_data.num_dimensions, KNN, predicate_evaluator); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.FilteredSearch(query + l * pdx_data.num_dimensions, KNN, predicate_evaluator); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - BenchmarkUtils::SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - ivf_nprobe, - KNN, - recalls, - real_selectivity - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } - } - return 0; -} \ No newline at end of file diff --git a/benchmarks/bench_adsampling/pdx_ivf_adsampling.cpp b/benchmarks/bench_adsampling/pdx_ivf_adsampling.cpp deleted file mode 100644 index 0b62a05..0000000 --- a/benchmarks/bench_adsampling/pdx_ivf_adsampling.cpp +++ /dev/null @@ -1,112 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf.hpp" -#include "pdxearch.hpp" -#include "pruners/adsampling.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - size_t arg_ivf_nprobe = 0; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - arg_ivf_nprobe = atoi(argv[2]); - } - std::cout << "==> PDX IVF ADSampling\n"; - - std::string ALGORITHM = "adsampling"; - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float SELECTIVITY_THRESHOLD = BenchmarkUtils::SELECTIVITY_THRESHOLD; - float EPSILON0 = BenchmarkUtils::EPSILON0; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - PDX::DimensionsOrder DIMENSION_ORDER = PDX::SEQUENTIAL; - - std::string RESULTS_PATH; - RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "IVF_PDX_ADSAMPLING.csv"; - - - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF pdx_data = PDX::IndexPDXIVF(); - pdx_data.Restore(BenchmarkUtils::PDX_ADSAMPLING_DATA + dataset + "-ivf"); - - std::unique_ptr _matrix_ptr = MmapFile(BenchmarkUtils::NARY_ADSAMPLING_DATA + dataset + "-matrix"); - auto *_matrix = reinterpret_cast(_matrix_ptr.get()); - - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - - NUM_QUERIES = 1000; - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + dataset + "_100_norm"); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - PDX::ADSamplingPruner pruner = PDX::ADSamplingPruner(pdx_data.num_dimensions, EPSILON0, _matrix); - PDX::PDXearch searcher = PDX::PDXearch(pdx_data, pruner, 1, DIMENSION_ORDER); - - std::vector nprobes_to_use; - if (arg_ivf_nprobe > 0) { - nprobes_to_use = {arg_ivf_nprobe}; - } else { - nprobes_to_use.assign(std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES)); - } - - for (size_t ivf_nprobe : nprobes_to_use) { - if (pdx_data.num_clusters < ivf_nprobe){ - continue; - } - if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe){ - continue; - } - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - searcher.SetNProbe(ivf_nprobe); - - float recalls = 0; - if (VERIFY_RESULTS) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.Search(query + l * pdx_data.num_dimensions, KNN); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.Search(query + l * pdx_data.num_dimensions, KNN); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - ivf_nprobe, - KNN, - recalls, - real_selectivity - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } - } - return 0; -} \ No newline at end of file diff --git a/benchmarks/bench_adsampling/pdx_ivf_adsampling_filtered.cpp b/benchmarks/bench_adsampling/pdx_ivf_adsampling_filtered.cpp deleted file mode 100644 index b04401c..0000000 --- a/benchmarks/bench_adsampling/pdx_ivf_adsampling_filtered.cpp +++ /dev/null @@ -1,119 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf.hpp" -#include "pdxearch.hpp" -#include "pruners/adsampling.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - std::string arg_selectivity; - size_t arg_ivf_nprobe = 0; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - arg_ivf_nprobe = atoi(argv[2]); - } - if (argc > 3){ - arg_selectivity = argv[3]; - } else { - arg_selectivity = "0_99"; - } - std::cout << "==> PDX IVF ADSampling\n"; - - std::string ALGORITHM = "adsampling"; - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float SELECTIVITY_THRESHOLD = BenchmarkUtils::SELECTIVITY_THRESHOLD; - float EPSILON0 = BenchmarkUtils::EPSILON0; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - PDX::DimensionsOrder DIMENSION_ORDER = PDX::SEQUENTIAL; - - std::string RESULTS_PATH; - RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "IVF_PDX_ADSAMPLING_FILTERED.csv"; - std::cout << "==> SELECTIVITY: " << arg_selectivity << std::endl; - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF pdx_data = PDX::IndexPDXIVF(); - pdx_data.Restore(BenchmarkUtils::PDX_ADSAMPLING_DATA + dataset + "-ivf"); - - std::unique_ptr _matrix_ptr = MmapFile(BenchmarkUtils::NARY_ADSAMPLING_DATA + dataset + "-matrix"); - auto *_matrix = reinterpret_cast(_matrix_ptr.get()); - - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - NUM_QUERIES = 1000; - - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::FILTERED_GROUND_TRUTH_DATA + dataset + "_100_norm_" + arg_selectivity); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - PDX::PredicateEvaluator predicate_evaluator = PDX::PredicateEvaluator(pdx_data.num_clusters); - predicate_evaluator.LoadSelectionVectorFromFile(BenchmarkUtils::SELECTION_VECTOR_DATA + dataset + "_" + arg_selectivity + ".bin"); - PDX::ADSamplingPruner pruner = PDX::ADSamplingPruner(pdx_data.num_dimensions, EPSILON0, _matrix); - PDX::PDXearch searcher = PDX::PDXearch(pdx_data, pruner, 1, DIMENSION_ORDER); - - std::vector nprobes_to_use; - if (arg_ivf_nprobe > 0) { - nprobes_to_use = {arg_ivf_nprobe}; - } else { - nprobes_to_use.assign(std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES)); - } - - for (size_t ivf_nprobe : nprobes_to_use) { - if (pdx_data.num_clusters < ivf_nprobe){ - continue; - } - if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe){ - continue; - } - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - searcher.SetNProbe(ivf_nprobe); - - float recalls = 0; - if (VERIFY_RESULTS) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.FilteredSearch(query + l * pdx_data.num_dimensions, KNN, predicate_evaluator); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.FilteredSearch(query + l * pdx_data.num_dimensions, KNN, predicate_evaluator); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - ivf_nprobe, - KNN, - recalls, - real_selectivity - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } - } - return 0; -} \ No newline at end of file diff --git a/benchmarks/bench_adsampling/pdx_ivf_adsampling_u8.cpp b/benchmarks/bench_adsampling/pdx_ivf_adsampling_u8.cpp deleted file mode 100644 index c8c779f..0000000 --- a/benchmarks/bench_adsampling/pdx_ivf_adsampling_u8.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf.hpp" -#include "pdxearch.hpp" -#include "pruners/adsampling.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - size_t arg_ivf_nprobe = 0; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - arg_ivf_nprobe = atoi(argv[2]); - } - std::cout << "==> PDX IVF ADSampling\n"; - - std::string ALGORITHM = "adsampling"; - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float EPSILON0 = BenchmarkUtils::EPSILON0; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - PDX::DimensionsOrder DIMENSION_ORDER = PDX::SEQUENTIAL; - - std::string RESULTS_PATH; - RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "U8_IVF_PDX_ADSAMPLING.csv"; - - - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF pdx_data = PDX::IndexPDXIVF(); - pdx_data.Restore(BenchmarkUtils::PDX_ADSAMPLING_DATA + dataset + "-ivf-u8"); - std::unique_ptr _matrix_ptr = MmapFile(BenchmarkUtils::NARY_ADSAMPLING_DATA + dataset + "-ivf-u8-matrix"); - auto *_matrix = reinterpret_cast(_matrix_ptr.get()); - - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - - NUM_QUERIES = 1000; - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + dataset + "_100_norm"); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - PDX::ADSamplingPruner pruner = PDX::ADSamplingPruner(pdx_data.num_dimensions, EPSILON0, _matrix); - PDX::PDXearch searcher = PDX::PDXearch(pdx_data, pruner, 1, DIMENSION_ORDER); - - std::vector nprobes_to_use; - if (arg_ivf_nprobe > 0) { - nprobes_to_use = {arg_ivf_nprobe}; - } else { - nprobes_to_use.assign(std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES)); - } - - for (size_t ivf_nprobe : nprobes_to_use) { - if (pdx_data.num_clusters < ivf_nprobe){ - continue; - } - if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe){ - continue; - } - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - searcher.SetNProbe(ivf_nprobe); - - float recalls = 0; - if (VERIFY_RESULTS) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.Search(query + l * pdx_data.num_dimensions, KNN); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.Search(query + l * pdx_data.num_dimensions, KNN); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - BenchmarkUtils::SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - ivf_nprobe, - KNN, - recalls, - real_selectivity - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } - } - return 0; -} \ No newline at end of file diff --git a/benchmarks/bench_bond/pdx_bond.cpp b/benchmarks/bench_bond/pdx_bond.cpp deleted file mode 100644 index 97330d9..0000000 --- a/benchmarks/bench_bond/pdx_bond.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf.hpp" -#include "pruners/bond.hpp" -#include "pdxearch.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - PDX::DimensionsOrder DIMENSION_ORDER = PDX::DISTANCE_TO_MEANS_IMPROVED; - DIMENSION_ORDER = PDX::DISTANCE_TO_MEANS_IMPROVED; - std::string ALGORITHM = "pdx-bond"; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - DIMENSION_ORDER = static_cast(atoi(argv[2])); - if (DIMENSION_ORDER == PDX::DISTANCE_TO_MEANS_IMPROVED){ - ALGORITHM = "pdx-bond"; - } - else if (DIMENSION_ORDER == PDX::DISTANCE_TO_MEANS){ - ALGORITHM = "pdx-bond-dtm"; - } - else if (DIMENSION_ORDER == PDX::DECREASING_IMPROVED){ - ALGORITHM = "pdx-bond-dec"; - } - else if (DIMENSION_ORDER == PDX::DECREASING){ - ALGORITHM = "pdx-bond-dec"; - } - else if (DIMENSION_ORDER == PDX::SEQUENTIAL){ - ALGORITHM = "pdx-bond-sec"; - } - else if (DIMENSION_ORDER == PDX::DIMENSION_ZONES){ - ALGORITHM = "pdx-bond-dz"; - } - } - std::cout << "==> PDX BOND EXACT\n"; - - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float SELECTIVITY_THRESHOLD = BenchmarkUtils::SELECTIVITY_THRESHOLD; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - std::string RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "EXACT_PDX_BOND.csv"; - - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF pdx_data = PDX::IndexPDXIVF(); - - pdx_data.Restore(BenchmarkUtils::PDX_DATA + dataset + "-flat"); - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - - NUM_QUERIES = 1000; - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + dataset + "_100_norm"); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - PDX::IndexPDXIVF nary_data = PDX::IndexPDXIVF(); - - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - - auto pruner = PDX::BondPruner(pdx_data.num_dimensions); - PDX::PDXearch searcher = PDX::PDXearch(pdx_data, pruner, 0, DIMENSION_ORDER); - - float recalls = 0; - if (VERIFY_RESULTS){ - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.Search(query + l * pdx_data.num_dimensions, KNN); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.Search(query + l * pdx_data.num_dimensions, KNN); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - 0, - KNN, - recalls, - real_selectivity, - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } -} diff --git a/benchmarks/bench_bond/pdx_bond_ivf.cpp b/benchmarks/bench_bond/pdx_bond_ivf.cpp deleted file mode 100644 index 53c87ad..0000000 --- a/benchmarks/bench_bond/pdx_bond_ivf.cpp +++ /dev/null @@ -1,126 +0,0 @@ -#ifndef BENCHMARK_TIME -#define BENCHMARK_TIME = true -#endif - -#ifndef PDX_USE_EXPLICIT_SIMD -#define PDX_USE_EXPLICIT_SIMD = true -#endif - -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf.hpp" -#include "pruners/bond.hpp" -#include "pdxearch.hpp" -#include "utils/benchmark_utils.hpp" - -int main(int argc, char *argv[]) { - std::string arg_dataset; - size_t arg_ivf_nprobe = 0; - std::string ALGORITHM = "pdx-bond"; - PDX::DimensionsOrder DIMENSION_ORDER = PDX::SEQUENTIAL; - DIMENSION_ORDER = PDX::DIMENSION_ZONES; - if (argc > 1){ - arg_dataset = argv[1]; - } - if (argc > 2){ - arg_ivf_nprobe = atoi(argv[2]); - } - if (argc > 3){ - // enum PDXearchDimensionsOrder { - // SEQUENTIAL, - // DISTANCE_TO_MEANS, - // DECREASING, - // DISTANCE_TO_MEANS_IMPROVED, - // DECREASING_IMPROVED, - // DIMENSION_ZONES - // }; - DIMENSION_ORDER = static_cast(atoi(argv[3])); - ALGORITHM = "pdx-bond"; - if (DIMENSION_ORDER == PDX::DISTANCE_TO_MEANS){ - ALGORITHM = "pdx-bond-dtm"; - } - else if (DIMENSION_ORDER == PDX::DIMENSION_ZONES){ - ALGORITHM = "pdx-bond-dz"; - } - - } - std::cout << "==> PDX IVF BOND\n"; - - const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; - - uint8_t KNN = BenchmarkUtils::KNN; - float SELECTIVITY_THRESHOLD = BenchmarkUtils::SELECTIVITY_THRESHOLD; - size_t NUM_QUERIES; - size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; - - std::string RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "IVF_PDX_BOND.csv"; - - - for (const auto & dataset : BenchmarkUtils::DATASETS) { - if (arg_dataset.size() > 0 && arg_dataset != dataset){ - continue; - } - PDX::IndexPDXIVF pdx_data = PDX::IndexPDXIVF(); - - pdx_data.Restore(BenchmarkUtils::PDX_DATA + dataset + "-ivf"); - - std::unique_ptr query_ptr = MmapFile(BenchmarkUtils::QUERIES_DATA + dataset); - auto *query = reinterpret_cast(query_ptr.get()); - - NUM_QUERIES = 1000; - std::unique_ptr ground_truth = MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + dataset + "_100_norm"); - auto *int_ground_truth = reinterpret_cast(ground_truth.get()); - query += 1; // skip number of embeddings - - auto pruner = PDX::BondPruner(pdx_data.num_dimensions); - PDX::PDXearch searcher = PDX::PDXearch(pdx_data, pruner, 0, DIMENSION_ORDER); - - std::vector nprobes_to_use; - if (arg_ivf_nprobe > 0) { - nprobes_to_use = {arg_ivf_nprobe}; - } else { - nprobes_to_use.assign(std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES)); - } - - for (size_t ivf_nprobe : nprobes_to_use) { - if (pdx_data.num_clusters < ivf_nprobe) { - continue; - } - if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe){ - continue; - } - std::vector runtimes; - runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); - searcher.SetNProbe(ivf_nprobe); - - float recalls = 0; - if (VERIFY_RESULTS){ - for (size_t l = 0; l < NUM_QUERIES; ++l) { - auto result = searcher.Search(query + l * pdx_data.num_dimensions, KNN); - BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); - } - } - for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { - for (size_t l = 0; l < NUM_QUERIES; ++l) { - searcher.Search(query + l * pdx_data.num_dimensions, KNN); - runtimes[j + l * NUM_MEASURE_RUNS] = { - searcher.end_to_end_clock.accum_time - }; - } - } - float real_selectivity = 1 - SELECTIVITY_THRESHOLD; - BenchmarkMetadata results_metadata = { - dataset, - ALGORITHM, - NUM_MEASURE_RUNS, - NUM_QUERIES, - ivf_nprobe, - KNN, - recalls, - real_selectivity, - }; - BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); - } - } -} diff --git a/benchmarks/benchmark_utils.hpp b/benchmarks/benchmark_utils.hpp new file mode 100644 index 0000000..345a8c9 --- /dev/null +++ b/benchmarks/benchmark_utils.hpp @@ -0,0 +1,371 @@ +#pragma once + +#include "pdx/common.hpp" +#include "pdx/utils.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class TicToc { + public: + size_t accum_time = 0; + std::chrono::high_resolution_clock::time_point start = + std::chrono::high_resolution_clock::now(); + + void Reset() { + accum_time = 0; + start = std::chrono::high_resolution_clock::now(); + } + + inline void Tic() { start = std::chrono::high_resolution_clock::now(); } + + inline void Toc() { + auto end = std::chrono::high_resolution_clock::now(); + accum_time += std::chrono::duration_cast(end - start).count(); + } + + double GetMilliseconds() const { return static_cast(accum_time) / 1e6; } +}; + +// Raw binary data paths (SuperKMeans convention: data_.bin / data__test.bin) +inline std::string RAW_DATA_DIR = std::string{CMAKE_SOURCE_DIR} + "/../SuperKMeans/benchmarks/data"; +inline std::string GROUND_TRUTH_JSON_DIR = + std::string{CMAKE_SOURCE_DIR} + "/../SuperKMeans/benchmarks/ground_truth"; + +struct RawDatasetInfo { + size_t num_embeddings; + size_t num_dimensions; + size_t num_queries; + PDX::DistanceMetric distance_metric; + std::string pdx_dataset_name; // Name used in PDX ground truth / query files +}; + +inline const std::unordered_map RAW_DATASET_PARAMS = { + {"sift", {1000000, 128, 1000, PDX::DistanceMetric::L2SQ, "sift-128-euclidean"}}, + {"yi", {187843, 128, 1000, PDX::DistanceMetric::IP, "yi-128-ip"}}, + {"llama", {256921, 128, 1000, PDX::DistanceMetric::IP, "llama-128-ip"}}, + {"glove200", {1183514, 200, 1000, PDX::DistanceMetric::COSINE, "glove-200-angular"}}, + {"yandex", {1000000, 200, 1000, PDX::DistanceMetric::COSINE, "yandex-200-cosine"}}, + {"yahoo", {677305, 384, 1000, PDX::DistanceMetric::COSINE, "yahoo-minilm-384-normalized"}}, + {"clip", {1281167, 512, 1000, PDX::DistanceMetric::L2SQ, "imagenet-clip-512-normalized"}}, + {"contriever", {990000, 768, 1000, PDX::DistanceMetric::L2SQ, "contriever-768"}}, + {"gist", {1000000, 960, 1000, PDX::DistanceMetric::L2SQ, "gist-960-euclidean"}}, + {"mxbai", {769382, 1024, 1000, PDX::DistanceMetric::L2SQ, "agnews-mxbai-1024-euclidean"}}, + {"openai", {999000, 1536, 1000, PDX::DistanceMetric::L2SQ, "openai-1536-angular"}}, + {"arxiv", {2253000, 768, 1000, PDX::DistanceMetric::L2SQ, "instructorxl-arxiv-768"}}, + {"wiki", {260372, 3072, 1000, PDX::DistanceMetric::L2SQ, "simplewiki-openai-3072-normalized"}}, + {"cohere", {10000000, 1024, 1000, PDX::DistanceMetric::L2SQ, "cohere"}}, +}; + +struct BenchmarkMetadata { + std::string dataset; + std::string algorithm; + size_t num_measure_runs{0}; + size_t num_queries{100}; + size_t ivf_nprobe{0}; + size_t knn{10}; + float recalls{1.0}; + float selectivity_threshold{0.0}; + float epsilon{0.0}; +}; + +struct PhasesRuntime { + size_t end_to_end{0}; +}; + +class BenchmarkUtils { + public: + inline static std::string PDX_DATA = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/pdx/"; + inline static std::string PDX_ADSAMPLING_DATA = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/adsampling_pdx/"; + inline static std::string GROUND_TRUTH_DATA = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/ground_truth/"; + inline static std::string FILTERED_GROUND_TRUTH_DATA = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/ground_truth_filtered/"; + inline static std::string PURESCAN_DATA = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/purescan/"; + inline static std::string QUERIES_DATA = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/queries/"; + inline static std::string SELECTION_VECTOR_DATA = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/selection_vectors/"; + + std::string CPU_ARCHITECTURE = "DEFAULT"; + std::string RESULTS_DIR_PATH = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/results/" + CPU_ARCHITECTURE + "/"; + + explicit BenchmarkUtils() { + CPU_ARCHITECTURE = std::getenv("PDX_ARCH") ? std::getenv("PDX_ARCH") : "DEFAULT"; + RESULTS_DIR_PATH = + std::string{CMAKE_SOURCE_DIR} + "/benchmarks/results/" + CPU_ARCHITECTURE + "/"; + } + + inline static std::string DATASETS[] = { + "sift-128-euclidean", + "yi-128-ip", + "llama-128-ip", + "glove-200-angular", + "yandex-200-cosine", + "word2vec-300", + "yahoo-minilm-384-normalized", + "msong-420", + "imagenet-clip-512-normalized", + "laion-clip-512-normalized", + "imagenet-align-640-normalized", + "codesearchnet-jina-768-cosine", + "landmark-dino-768-cosine", + "landmark-nomic-768-normalized", + "arxiv-nomic-768-normalized", + "ccnews-nomic-768-normalized", + "coco-nomic-768-normalized", + "contriever-768", + "instructorxl-arxiv-768", + "gooaq-distilroberta-768-normalized", + "gist-960-euclidean", + "agnews-mxbai-1024-euclidean", + "cohere", + "openai-1536-angular", + "celeba-resnet-2048-cosine", + "simplewiki-openai-3072-normalized" + }; + + inline static std::string FILTERED_SELECTIVITIES[] = { + "0_000135", + "0_001", + "0_01", + "0_1", + "0_2", + "0_3", + "0_4", + "0_5", + "0_75", + "0_9", + "0_95", + "0_99", + "PART_1", + "PART_30", + "PART+_1", + }; + + inline static size_t IVF_PROBES[] = { + // 4000, 3980, 3967, 2048, 1024, 512, 256,224,192,160,144,128, + 2048, 1536, 1024, 512, 384, 256, 224, 192, 160, 144, 128, 112, 96, 80, 64, 56, 48, + 40, 32, 28, 26, 24, 22, 20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1 + }; + + inline static int POW_10[10] = + {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000}; + + inline static size_t IVF_PROBES_PHASES[] = { + 512, + 256, + 128, + 64, + 32, + 16, + 8, + 4, + 2, + }; + + inline static size_t NUM_MEASURE_RUNS = 1; + inline static float SELECTIVITY_THRESHOLD = 0.80; // more than 20% pruned to pass + inline static bool VERIFY_RESULTS = true; + inline static uint8_t KNN = 20; + + inline static uint8_t GROUND_TRUTH_MAX_K = + 100; // To properly skip on the ground truth file (do not change) + + template + static void VerifyResult( + float& recalls, + const std::vector& result, + size_t knn, + const uint32_t* int_ground_truth, + size_t n_query + ) { + std::unordered_set seen; + for (const auto& val : result) { + if (!seen.insert(val.index).second) { + throw std::runtime_error( + "Duplicates detected in the result set! This is likely a bug on PDXearch" + ); + } + } + if (result.size() < knn) { + std::cerr << "WARNING: Result set is not complete! Set a higher `nbuckets` parameter " + "(Only got " + << result.size() << " results)" << std::endl; + } + if constexpr (MEASURE_RECALL) { + size_t true_positives = 0; + for (size_t j = 0; j < result.size(); ++j) { + for (size_t m = 0; m < knn; ++m) { + if (result[j].index == int_ground_truth[m + n_query * GROUND_TRUTH_MAX_K]) { + true_positives++; + break; + } + } + } + recalls += 1.0 * true_positives / knn; + } else { + for (size_t j = 0; j < knn; ++j) { + if (result[j].index != int_ground_truth[j + n_query * GROUND_TRUTH_MAX_K]) { + std::cout << "WRONG RESULT!\n"; + break; + } + } + } + } + + // We remove extreme outliers on both sides (Q3 + 1.5*IQR & Q1 - 1.5*IQR) + static void SaveResults( + std::vector runtimes, + const std::string& results_path, + const BenchmarkMetadata& metadata + ) { + bool write_header = true; + if (std::filesystem::exists(results_path)) { + write_header = false; + } + std::ofstream file{results_path, std::ios::app}; + size_t min_runtime = std::numeric_limits::max(); + size_t max_runtime = std::numeric_limits::min(); + size_t sum_runtimes = 0; + size_t all_min_runtime = std::numeric_limits::max(); + size_t all_max_runtime = std::numeric_limits::min(); + size_t all_sum_runtimes = 0; + auto const Q1 = runtimes.size() / 4; + auto const Q2 = runtimes.size() / 2; + auto const Q3 = Q1 + Q2; + std::sort(runtimes.begin(), runtimes.end(), [](PhasesRuntime i1, PhasesRuntime i2) { + return i1.end_to_end < i2.end_to_end; + }); + auto const iqr = runtimes[Q3].end_to_end - runtimes[Q1].end_to_end; + size_t accounted_queries = 0; + for (size_t j = 0; j < metadata.num_measure_runs * metadata.num_queries; ++j) { + all_min_runtime = std::min(all_min_runtime, runtimes[j].end_to_end); + all_max_runtime = std::max(all_max_runtime, runtimes[j].end_to_end); + all_sum_runtimes += runtimes[j].end_to_end; + // Removing outliers + if (runtimes[j].end_to_end > runtimes[Q3].end_to_end + 1.5 * iqr) { + continue; + } + if (runtimes[j].end_to_end < runtimes[Q1].end_to_end - 1.5 * iqr) { + continue; + } + min_runtime = std::min(min_runtime, runtimes[j].end_to_end); + max_runtime = std::max(max_runtime, runtimes[j].end_to_end); + sum_runtimes += runtimes[j].end_to_end; + accounted_queries += 1; + } + double all_min_runtime_ms = 1.0 * all_min_runtime / 1000000; + double all_max_runtime_ms = 1.0 * all_max_runtime / 1000000; + double all_avg_runtime_ms = + 1.0 * all_sum_runtimes / (1000000 * (metadata.num_measure_runs * metadata.num_queries)); + double min_runtime_ms = 1.0 * min_runtime / 1000000; + double max_runtime_ms = 1.0 * max_runtime / 1000000; + double avg_runtime_ms = 1.0 * sum_runtimes / (1000000 * accounted_queries); + double avg_recall = metadata.recalls / metadata.num_queries; + + std::cout << metadata.dataset << " --------------\n"; + std::cout << "n_queries: " << metadata.num_queries << "\n"; + if (metadata.ivf_nprobe > 0) { + std::cout << "nprobe: " << metadata.ivf_nprobe << "\n"; + } + std::cout << "avg: " << std::setprecision(6) << avg_runtime_ms << "\n"; + std::cout << "max: " << std::setprecision(6) << max_runtime_ms << "\n"; + std::cout << "min: " << std::setprecision(6) << min_runtime_ms << "\n"; + std::cout << "rec: " << std::setprecision(6) << avg_recall << "\n"; + + if (write_header) { + file << "dataset,algorithm,avg,max,min,recall,ivf_nprobe,epsilon," + "knn,n_queries,selectivity," + "num_measure_runs,avg_all,max_all,min_all" + << "\n"; + } + file << metadata.dataset << "," << metadata.algorithm << "," << std::setprecision(6) + << avg_runtime_ms << "," << std::setprecision(6) << max_runtime_ms << "," + << std::setprecision(6) << min_runtime_ms << "," << avg_recall << "," + << metadata.ivf_nprobe << "," << metadata.epsilon << "," << +metadata.knn << "," + << metadata.num_queries << "," << std::setprecision(4) + << metadata.selectivity_threshold << "," << metadata.num_measure_runs << "," + << all_avg_runtime_ms << "," << all_max_runtime_ms << "," << all_min_runtime_ms + << "\n"; + file.close(); + } +}; + +BenchmarkUtils BENCHMARK_UTILS; + +inline std::unordered_map> ParseGroundTruthJson(const std::string& filename) { + std::unordered_map> gt_map; + std::ifstream file(filename); + if (!file.is_open()) + return gt_map; + + std::string line; + std::getline(file, line); + + size_t pos = 0; + while ((pos = line.find("\"", pos)) != std::string::npos) { + size_t key_start = pos + 1; + size_t key_end = line.find("\"", key_start); + if (key_end == std::string::npos) + break; + + int query_idx = std::stoi(line.substr(key_start, key_end - key_start)); + + size_t arr_start = line.find("[", key_end); + size_t arr_end = line.find("]", arr_start); + if (arr_start == std::string::npos || arr_end == std::string::npos) + break; + + std::string arr_str = line.substr(arr_start + 1, arr_end - arr_start - 1); + std::vector ids; + std::istringstream iss(arr_str); + std::string token; + while (std::getline(iss, token, ',')) { + token.erase(0, token.find_first_not_of(" \t")); + token.erase(token.find_last_not_of(" \t") + 1); + if (!token.empty()) + ids.push_back(std::stoi(token)); + } + gt_map[query_idx] = ids; + pos = arr_end + 1; + } + return gt_map; +} + +inline float ComputeRecallFromJson( + const std::vector& result, + const std::vector& gt_ids, + size_t knn +) { + size_t hits = 0; + size_t gt_count = std::min(knn, gt_ids.size()); + for (size_t i = 0; i < result.size(); i++) { + for (size_t j = 0; j < gt_count; j++) { + if (result[i].index == static_cast(gt_ids[j])) { + hits++; + break; + } + } + } + return static_cast(hits) / static_cast(gt_count); +} diff --git a/benchmarks/bench_kernels/README.md b/benchmarks/kernels_playground/README.md similarity index 100% rename from benchmarks/bench_kernels/README.md rename to benchmarks/kernels_playground/README.md diff --git a/benchmarks/bench_kernels/kernels.cpp b/benchmarks/kernels_playground/kernels.cpp similarity index 66% rename from benchmarks/bench_kernels/kernels.cpp rename to benchmarks/kernels_playground/kernels.cpp index 49f1855..4e59f3d 100644 --- a/benchmarks/bench_kernels/kernels.cpp +++ b/benchmarks/kernels_playground/kernels.cpp @@ -1,9 +1,9 @@ -#include -#include -#include -#include #include +#include +#include +#include #include +#include #include #if defined(__ARM_NEON) @@ -14,47 +14,46 @@ #include #endif - -template +template struct KNNCandidate { uint32_t index; float distance; }; -template<> +template <> struct KNNCandidate { uint32_t index; float distance; }; -template<> +template <> struct KNNCandidate { uint32_t index; uint32_t distance; }; -template +template struct DistanceType { using type = float; // default for f32 }; -template<> +template <> struct DistanceType { using type = uint32_t; }; -template +template using DistanceType_t = typename DistanceType::type; -template +template struct VectorComparator { - bool operator() (const KNNCandidate& a, const KNNCandidate& b) { + bool operator()(const KNNCandidate& a, const KNNCandidate& b) { return a.distance < b.distance; } }; -template +template struct VectorComparatorInverse { - bool operator() (const KNNCandidate& a, const KNNCandidate& b) { + bool operator()(const KNNCandidate& a, const KNNCandidate& b) { return a.distance > b.distance; } }; @@ -84,12 +83,7 @@ static constexpr size_t U8_N_REGISTERS_AVX = 4; // SIMD //////////////// - -inline float f32_simd_ip( - const float *first_vector, - const float *second_vector, - const size_t d -) { +inline float f32_simd_ip(const float* first_vector, const float* second_vector, const size_t d) { #if defined(__APPLE__) float distance = 0.0; #pragma clang loop vectorize(enable) @@ -115,35 +109,32 @@ inline float f32_simd_ip( __m512 a_vec, b_vec; size_t num_dimensions = d; - simsimd_ip_f32_skylake_cycle: - if (num_dimensions < 16) { - __mmask16 mask = (__mmask16)_bzhi_u32(0xFFFFFFFF, num_dimensions); - a_vec = _mm512_maskz_loadu_ps(mask, first_vector); - b_vec = _mm512_maskz_loadu_ps(mask, second_vector); - num_dimensions = 0; - } else { - a_vec = _mm512_loadu_ps(first_vector); - b_vec = _mm512_loadu_ps(second_vector); - first_vector += 16, second_vector += 16, num_dimensions -= 16; - } +simsimd_ip_f32_skylake_cycle: + if (num_dimensions < 16) { + __mmask16 mask = (__mmask16) _bzhi_u32(0xFFFFFFFF, num_dimensions); + a_vec = _mm512_maskz_loadu_ps(mask, first_vector); + b_vec = _mm512_maskz_loadu_ps(mask, second_vector); + num_dimensions = 0; + } else { + a_vec = _mm512_loadu_ps(first_vector); + b_vec = _mm512_loadu_ps(second_vector); + first_vector += 16, second_vector += 16, num_dimensions -= 16; + } d2_vec = _mm512_fmadd_ps(a_vec, b_vec, d2_vec); if (num_dimensions) goto simsimd_ip_f32_skylake_cycle; // _simsimd_reduce_f32x16_skylake __m512 x = _mm512_add_ps(d2_vec, _mm512_shuffle_f32x4(d2_vec, d2_vec, _MM_SHUFFLE(0, 0, 3, 2))); - __m128 r = _mm512_castps512_ps128(_mm512_add_ps(x, _mm512_shuffle_f32x4(x, x, _MM_SHUFFLE(0, 0, 0, 1)))); + __m128 r = + _mm512_castps512_ps128(_mm512_add_ps(x, _mm512_shuffle_f32x4(x, x, _MM_SHUFFLE(0, 0, 0, 1))) + ); r = _mm_hadd_ps(r, r); return _mm_cvtss_f32(_mm_hadd_ps(r, r)); #endif } - -inline float f32_simd_l2( - const float *first_vector, - const float *second_vector, - const size_t d -) { +inline float f32_simd_l2(const float* first_vector, const float* second_vector, const size_t d) { #if defined(__APPLE__) float distance = 0.0; #pragma clang loop vectorize(enable) @@ -173,17 +164,17 @@ inline float f32_simd_l2( __m512 a_vec, b_vec; size_t num_dimensions = d; - simsimd_l2sq_f32_skylake_cycle: - if (d < 16) { - __mmask16 mask = (__mmask16)_bzhi_u32(0xFFFFFFFF, num_dimensions); - a_vec = _mm512_maskz_loadu_ps(mask, first_vector); - b_vec = _mm512_maskz_loadu_ps(mask, second_vector); - num_dimensions = 0; - } else { - a_vec = _mm512_loadu_ps(first_vector); - b_vec = _mm512_loadu_ps(second_vector); - first_vector += 16, second_vector += 16, num_dimensions -= 16; - } +simsimd_l2sq_f32_skylake_cycle: + if (d < 16) { + __mmask16 mask = (__mmask16) _bzhi_u32(0xFFFFFFFF, num_dimensions); + a_vec = _mm512_maskz_loadu_ps(mask, first_vector); + b_vec = _mm512_maskz_loadu_ps(mask, second_vector); + num_dimensions = 0; + } else { + a_vec = _mm512_loadu_ps(first_vector); + b_vec = _mm512_loadu_ps(second_vector); + first_vector += 16, second_vector += 16, num_dimensions -= 16; + } __m512 d_vec = _mm512_sub_ps(a_vec, b_vec); d2_vec = _mm512_fmadd_ps(d_vec, d_vec, d2_vec); if (num_dimensions) @@ -191,16 +182,17 @@ inline float f32_simd_l2( // _simsimd_reduce_f32x16_skylake __m512 x = _mm512_add_ps(d2_vec, _mm512_shuffle_f32x4(d2_vec, d2_vec, _MM_SHUFFLE(0, 0, 3, 2))); - __m128 r = _mm512_castps512_ps128(_mm512_add_ps(x, _mm512_shuffle_f32x4(x, x, _MM_SHUFFLE(0, 0, 0, 1)))); + __m128 r = + _mm512_castps512_ps128(_mm512_add_ps(x, _mm512_shuffle_f32x4(x, x, _MM_SHUFFLE(0, 0, 0, 1))) + ); r = _mm_hadd_ps(r, r); return _mm_cvtss_f32(_mm_hadd_ps(r, r)); #endif } - inline uint32_t u8_simd_l2( - const uint8_t *first_vector, - const uint8_t *second_vector, + const uint8_t* first_vector, + const uint8_t* second_vector, const size_t d ) { #if defined(__ARM_NEON) @@ -223,32 +215,34 @@ inline uint32_t u8_simd_l2( __m512i a_u8_vec, b_u8_vec; size_t num_dimensions = d; - simsimd_l2sq_u8_ice_cycle: - if (num_dimensions < 64) { - const __mmask64 mask = (__mmask64)_bzhi_u64(0xFFFFFFFFFFFFFFFF, num_dimensions); - a_u8_vec = _mm512_maskz_loadu_epi8(mask, first_vector); - b_u8_vec = _mm512_maskz_loadu_epi8(mask, second_vector); - num_dimensions = 0; - } - else { - a_u8_vec = _mm512_loadu_si512(first_vector); - b_u8_vec = _mm512_loadu_si512(second_vector); - first_vector += 64, second_vector += 64, num_dimensions -= 64; - } +simsimd_l2sq_u8_ice_cycle: + if (num_dimensions < 64) { + const __mmask64 mask = (__mmask64) _bzhi_u64(0xFFFFFFFFFFFFFFFF, num_dimensions); + a_u8_vec = _mm512_maskz_loadu_epi8(mask, first_vector); + b_u8_vec = _mm512_maskz_loadu_epi8(mask, second_vector); + num_dimensions = 0; + } else { + a_u8_vec = _mm512_loadu_si512(first_vector); + b_u8_vec = _mm512_loadu_si512(second_vector); + first_vector += 64, second_vector += 64, num_dimensions -= 64; + } // Substracting unsigned vectors in AVX-512 is done by saturating subtraction: - __m512i d_u8_vec = _mm512_or_si512(_mm512_subs_epu8(a_u8_vec, b_u8_vec), _mm512_subs_epu8(b_u8_vec, a_u8_vec)); + __m512i d_u8_vec = + _mm512_or_si512(_mm512_subs_epu8(a_u8_vec, b_u8_vec), _mm512_subs_epu8(b_u8_vec, a_u8_vec)); - // Multiply and accumulate at `int8` level which are actually uint7, accumulate at `int32` level: + // Multiply and accumulate at `int8` level which are actually uint7, accumulate at `int32` + // level: d2_i32_vec = _mm512_dpbusds_epi32(d2_i32_vec, d_u8_vec, d_u8_vec); - if (num_dimensions) goto simsimd_l2sq_u8_ice_cycle; + if (num_dimensions) + goto simsimd_l2sq_u8_ice_cycle; return _mm512_reduce_add_epi32(d2_i32_vec); #endif }; inline uint32_t u8_simd_ip( - const uint8_t *first_vector, - const uint8_t *second_vector, + const uint8_t* first_vector, + const uint8_t* second_vector, const size_t d ) { #if defined(__ARM_NEON) @@ -269,22 +263,23 @@ inline uint32_t u8_simd_ip( __m512i a_u8_vec, b_u8_vec; size_t num_dimensions = d; - simsimd_l2sq_u8_ice_cycle: - if (num_dimensions < 64) { - const __mmask64 mask = (__mmask64)_bzhi_u64(0xFFFFFFFFFFFFFFFF, num_dimensions); - a_u8_vec = _mm512_maskz_loadu_epi8(mask, first_vector); - b_u8_vec = _mm512_maskz_loadu_epi8(mask, second_vector); - num_dimensions = 0; - } - else { - a_u8_vec = _mm512_loadu_si512(first_vector); - b_u8_vec = _mm512_loadu_si512(second_vector); - first_vector += 64, second_vector += 64, num_dimensions -= 64; - } +simsimd_l2sq_u8_ice_cycle: + if (num_dimensions < 64) { + const __mmask64 mask = (__mmask64) _bzhi_u64(0xFFFFFFFFFFFFFFFF, num_dimensions); + a_u8_vec = _mm512_maskz_loadu_epi8(mask, first_vector); + b_u8_vec = _mm512_maskz_loadu_epi8(mask, second_vector); + num_dimensions = 0; + } else { + a_u8_vec = _mm512_loadu_si512(first_vector); + b_u8_vec = _mm512_loadu_si512(second_vector); + first_vector += 64, second_vector += 64, num_dimensions -= 64; + } - // Multiply and accumulate at `int8` level which are actually uint7, accumulate at `int32` level: + // Multiply and accumulate at `int8` level which are actually uint7, accumulate at `int32` + // level: d2_i32_vec = _mm512_dpbusds_epi32(d2_i32_vec, a_u8_vec, b_u8_vec); - if (num_dimensions) goto simsimd_l2sq_u8_ice_cycle; + if (num_dimensions) + goto simsimd_l2sq_u8_ice_cycle; return _mm512_reduce_add_epi32(d2_i32_vec); #endif }; @@ -293,59 +288,45 @@ inline uint32_t u8_simd_ip( // PDX //////////////// - -inline void f32_pdx_ip( - const float *first_vector, - const float *second_vector, - const size_t d -) { +inline void f32_pdx_ip(const float* first_vector, const float* second_vector, const size_t d) { memset((void*) distances_f32, 0.0, F32_PDX_VECTOR_SIZE * sizeof(float)); for (size_t dim_idx = 0; dim_idx < d; dim_idx++) { const size_t dimension_idx = dim_idx; const size_t offset_to_dimension_start = dimension_idx * F32_PDX_VECTOR_SIZE; for (size_t vector_idx = 0; vector_idx < F32_PDX_VECTOR_SIZE; ++vector_idx) { - distances_f32[vector_idx] += second_vector[dimension_idx] * first_vector[offset_to_dimension_start + vector_idx]; + distances_f32[vector_idx] += + second_vector[dimension_idx] * first_vector[offset_to_dimension_start + vector_idx]; } } } -inline void f32_pdx_l1( - const float *first_vector, - const float *second_vector, - const size_t d -) { +inline void f32_pdx_l1(const float* first_vector, const float* second_vector, const size_t d) { memset((void*) distances_f32, 0.0, F32_PDX_VECTOR_SIZE * sizeof(float)); for (size_t dim_idx = 0; dim_idx < d; dim_idx++) { const size_t dimension_idx = dim_idx; const size_t offset_to_dimension_start = dimension_idx * F32_PDX_VECTOR_SIZE; for (size_t vector_idx = 0; vector_idx < F32_PDX_VECTOR_SIZE; ++vector_idx) { - float to_abs = second_vector[dimension_idx] - first_vector[offset_to_dimension_start + vector_idx]; + float to_abs = + second_vector[dimension_idx] - first_vector[offset_to_dimension_start + vector_idx]; distances_f32[vector_idx] += std::fabs(to_abs); } } } -inline void f32_pdx_l2( - const float *first_vector, - const float *second_vector, - const size_t d -) { +inline void f32_pdx_l2(const float* first_vector, const float* second_vector, const size_t d) { memset((void*) distances_f32, 0.0, F32_PDX_VECTOR_SIZE * sizeof(float)); for (size_t dim_idx = 0; dim_idx < d; dim_idx++) { const size_t dimension_idx = dim_idx; const size_t offset_to_dimension_start = dimension_idx * F32_PDX_VECTOR_SIZE; for (size_t vector_idx = 0; vector_idx < F32_PDX_VECTOR_SIZE; ++vector_idx) { - float to_multiply = second_vector[dimension_idx] - first_vector[offset_to_dimension_start + vector_idx]; + float to_multiply = + second_vector[dimension_idx] - first_vector[offset_to_dimension_start + vector_idx]; distances_f32[vector_idx] += to_multiply * to_multiply; } } } -inline void u8_pdx_l2( - const uint8_t *first_vector, - const uint8_t *second_vector, - const size_t d -) { +inline void u8_pdx_l2(const uint8_t* first_vector, const uint8_t* second_vector, const size_t d) { memset((void*) distances_u8, 0, U8_PDX_VECTOR_SIZE * sizeof(uint32_t)); #if defined(__ARM_NEON) uint32x4_t res[U8_N_REGISTERS_NEON]; @@ -355,12 +336,13 @@ inline void u8_pdx_l2( res[i] = vdupq_n_u32(0); } // Compute L2 - for (size_t dim_idx = 0; dim_idx < d; dim_idx+=4) { + for (size_t dim_idx = 0; dim_idx < d; dim_idx += 4) { const uint32_t dimension_idx = dim_idx; const uint8x8_t vals = vld1_u8(&second_vector[dimension_idx]); const uint8x16_t vec1_u8 = vqtbl1q_u8(vcombine_u8(vals, vals), idx); const size_t offset_to_dimension_start = dimension_idx * U8_PDX_VECTOR_SIZE; - for (int i = 0; i < U8_N_REGISTERS_NEON; ++i) { // total: 64 vectors * 4 dimensions each (at 1 byte per value = 2048-bits) + for (int i = 0; i < U8_N_REGISTERS_NEON; + ++i) { // total: 64 vectors * 4 dimensions each (at 1 byte per value = 2048-bits) // Read 16 bytes of data (16 values) with 4 dimensions of 4 vectors const uint8x16_t vec2_u8 = vld1q_u8(&first_vector[offset_to_dimension_start + i * 16]); const uint8x16_t diff_u8 = vabdq_u8(vec1_u8, vec2_u8); @@ -373,23 +355,28 @@ inline void u8_pdx_l2( } #elif defined(__AVX512F__) __m512i res[U8_N_REGISTERS_AVX]; - const uint32_t * query_grouped = (uint32_t *)second_vector; + const uint32_t* query_grouped = (uint32_t*) second_vector; // Load 64 initial values for (size_t i = 0; i < U8_N_REGISTERS_AVX; ++i) { res[i] = _mm512_load_si512(&distances_u8[i * 16]); } // Compute L2 - for (size_t dim_idx = 0; dim_idx < d; dim_idx+=4) { + for (size_t dim_idx = 0; dim_idx < d; dim_idx += 4) { const uint32_t dimension_idx = dim_idx; // To load the query efficiently I will load it as uint32_t (4 bytes packed in 1 word) const uint32_t query_value = query_grouped[dimension_idx / 4]; // And then broadcast it to the register const __m512i vec1_u8 = _mm512_set1_epi32(query_value); const size_t offset_to_dimension_start = dimension_idx * U8_PDX_VECTOR_SIZE; - for (int i = 0; i < U8_N_REGISTERS_AVX; ++i) { // total: 64 vectors (4 iterations of 16 vectors) * 4 dimensions each (at 1 byte per value = 2048-bits) + for (int i = 0; i < U8_N_REGISTERS_AVX; + ++i) { // total: 64 vectors (4 iterations of 16 vectors) * 4 dimensions each (at 1 byte + // per value = 2048-bits) // Read 64 bytes of data (64 values) with 4 dimensions of 16 vectors - const __m512i vec2_u8 = _mm512_loadu_si512(&first_vector[offset_to_dimension_start + i * 64]); - const __m512i diff_u8 = _mm512_or_si512(_mm512_subs_epu8(vec1_u8, vec2_u8), _mm512_subs_epu8(vec2_u8, vec1_u8)); + const __m512i vec2_u8 = + _mm512_loadu_si512(&first_vector[offset_to_dimension_start + i * 64]); + const __m512i diff_u8 = _mm512_or_si512( + _mm512_subs_epu8(vec1_u8, vec2_u8), _mm512_subs_epu8(vec2_u8, vec1_u8) + ); // I can use this asymmetric dot product as my values are actually 7-bit // Hence, the [sign] properties of the second operand is ignored // As results will never be negative, it can be stored on res[i] without issues @@ -403,11 +390,7 @@ inline void u8_pdx_l2( #endif }; -inline void u8_pdx_ip( - const uint8_t *first_vector, - const uint8_t *second_vector, - const size_t d -) { +inline void u8_pdx_ip(const uint8_t* first_vector, const uint8_t* second_vector, const size_t d) { memset((void*) distances_u8, 0, U8_PDX_VECTOR_SIZE * sizeof(uint32_t)); #if defined(__ARM_NEON) uint32x4_t res[U8_N_REGISTERS_NEON]; @@ -417,12 +400,13 @@ inline void u8_pdx_ip( res[i] = vdupq_n_u32(0); } // Compute L2 - for (size_t dim_idx = 0; dim_idx < d; dim_idx+=4) { + for (size_t dim_idx = 0; dim_idx < d; dim_idx += 4) { const uint32_t dimension_idx = dim_idx; const uint8x8_t vals = vld1_u8(&second_vector[dimension_idx]); const uint8x16_t vec1_u8 = vqtbl1q_u8(vcombine_u8(vals, vals), idx); const size_t offset_to_dimension_start = dimension_idx * U8_PDX_VECTOR_SIZE; - for (int i = 0; i < 16; ++i) { // total: 64 vectors * 4 dimensions each (at 1 byte per value = 2048-bits) + for (int i = 0; i < 16; + ++i) { // total: 64 vectors * 4 dimensions each (at 1 byte per value = 2048-bits) // Read 16 bytes of data (16 values) with 4 dimensions of 4 vectors const uint8x16_t vec2_u8 = vld1q_u8(&first_vector[offset_to_dimension_start + i * 16]); res[i] = vdotq_u32(res[i], vec2_u8, vec1_u8); @@ -434,22 +418,25 @@ inline void u8_pdx_ip( } #elif defined(__AVX512F__) __m512i res[U8_N_REGISTERS_AVX]; - const uint32_t * query_grouped = (uint32_t *)second_vector; + const uint32_t* query_grouped = (uint32_t*) second_vector; // Load 64 initial values for (size_t i = 0; i < U8_N_REGISTERS_AVX; ++i) { res[i] = _mm512_load_si512(&distances_u8[i * 16]); } // Compute L2 - for (size_t dim_idx = 0; dim_idx < d; dim_idx+=4) { + for (size_t dim_idx = 0; dim_idx < d; dim_idx += 4) { const uint32_t dimension_idx = dim_idx; // To load the query efficiently I will load it as uint32_t (4 bytes packed in 1 word) const uint32_t query_value = query_grouped[dimension_idx / 4]; // And then broadcast it to the register const __m512i vec1_u8 = _mm512_set1_epi32(query_value); const size_t offset_to_dimension_start = dimension_idx * U8_PDX_VECTOR_SIZE; - for (int i = 0; i < U8_N_REGISTERS_AVX; ++i) { // total: 64 vectors (4 iterations of 16 vectors) * 4 dimensions each (at 1 byte per value = 2048-bits) + for (int i = 0; i < U8_N_REGISTERS_AVX; + ++i) { // total: 64 vectors (4 iterations of 16 vectors) * 4 dimensions each (at 1 byte + // per value = 2048-bits) // Read 64 bytes of data (64 values) with 4 dimensions of 16 vectors - const __m512i vec2_u8 = _mm512_loadu_si512(&first_vector[offset_to_dimension_start + i * 64]); + const __m512i vec2_u8 = + _mm512_loadu_si512(&first_vector[offset_to_dimension_start + i * 64]); // I can use this asymmetric dot product as my values are actually 7-bit // Hence, the [sign] properties of the second operand is ignored // As results will never be negative, it can be stored on res[i] without issues @@ -463,15 +450,15 @@ inline void u8_pdx_ip( #endif }; -template +template std::vector> standalone_simd( - const T *first_vector, - const T *second_vector, + const T* first_vector, + const T* second_vector, const size_t d, const size_t num_queries, const size_t num_vectors, const size_t knn, - const size_t * positions = nullptr + const size_t* positions = nullptr ) { std::vector> result(knn * num_queries); std::vector> all_distances(num_vectors); @@ -484,7 +471,7 @@ std::vector> standalone_simd( data = data + (positions[j] * d); } DistanceType_t current_distance; - if constexpr (kernel == F32_SIMD_IP){ + if constexpr (kernel == F32_SIMD_IP) { current_distance = f32_simd_ip(data, query, d); } else if constexpr (kernel == F32_SIMD_L2) { current_distance = f32_simd_l2(data, query, d); @@ -501,10 +488,7 @@ std::vector> standalone_simd( } // Partial sort to get top-k - if constexpr ( - kernel == F32_SIMD_IP - || kernel == U8_SIMD_IP - ) { + if constexpr (kernel == F32_SIMD_IP || kernel == U8_SIMD_IP) { std::partial_sort( all_distances.begin(), all_distances.begin() + knn, @@ -528,10 +512,10 @@ std::vector> standalone_simd( return result; } -template +template std::vector> standalone_pdx( - const T *first_vector, - const T *second_vector, + const T* first_vector, + const T* second_vector, const size_t d, const size_t num_queries, const size_t num_vectors, @@ -544,8 +528,8 @@ std::vector> standalone_pdx( const T* data = first_vector; // Fill all_distances by direct indexing size_t global_offset = 0; - for (size_t j = 0; j < num_vectors; j+=PDX_BLOCK_SIZE) { - if constexpr (kernel == F32_PDX_IP){ + for (size_t j = 0; j < num_vectors; j += PDX_BLOCK_SIZE) { + if constexpr (kernel == F32_PDX_IP) { f32_pdx_ip(data, query, d); } else if constexpr (kernel == F32_PDX_L2) { f32_pdx_l2(data, query, d); @@ -557,7 +541,7 @@ std::vector> standalone_pdx( // TODO: Ugly (could be a bottleneck on PDX kernels) for (uint32_t z = 0; z < PDX_BLOCK_SIZE; ++z) { all_distances[global_offset].index = global_offset; - if constexpr (std::is_same_v){ + if constexpr (std::is_same_v) { all_distances[global_offset].distance = distances_f32[z]; } else if constexpr (std::is_same_v) { all_distances[global_offset].distance = distances_u8[z]; @@ -568,10 +552,7 @@ std::vector> standalone_pdx( } // Partial sort to get top-k - if constexpr ( - kernel == F32_PDX_IP - || kernel == U8_PDX_IP - ) { + if constexpr (kernel == F32_PDX_IP || kernel == U8_PDX_IP) { std::partial_sort( all_distances.begin(), all_distances.begin() + knn, @@ -598,68 +579,92 @@ std::vector> standalone_pdx( std::vector> standalone_f32( const VectorSearchKernel kernel, - const float *first_vector, - const float *second_vector, + const float* first_vector, + const float* second_vector, const size_t d, const size_t num_queries, const size_t num_vectors, const size_t knn ) { switch (kernel) { - case F32_PDX_IP: - return standalone_pdx(first_vector, second_vector, d, num_queries, num_vectors, knn); - case F32_PDX_L2: - return standalone_pdx(first_vector, second_vector, d, num_queries, num_vectors, knn); - - case F32_SIMD_IP: - return standalone_simd(first_vector, second_vector, d, num_queries, num_vectors, knn); - case F32_SIMD_L2: - return standalone_simd(first_vector, second_vector, d, num_queries, num_vectors, knn); - - default: - return standalone_pdx(first_vector, second_vector, d, num_queries, num_vectors, knn); + case F32_PDX_IP: + return standalone_pdx( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + case F32_PDX_L2: + return standalone_pdx( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + + case F32_SIMD_IP: + return standalone_simd( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + case F32_SIMD_L2: + return standalone_simd( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + + default: + return standalone_pdx( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); } } std::vector> filtered_standalone_u8( const VectorSearchKernel kernel, - const uint8_t *first_vector, - const uint8_t *second_vector, + const uint8_t* first_vector, + const uint8_t* second_vector, const size_t d, const size_t num_queries, const size_t num_vectors, const size_t knn, - const size_t *positions + const size_t* positions ) { switch (kernel) { - case U8_SIMD_L2: - return standalone_simd(first_vector, second_vector, d, num_queries, num_vectors, knn, positions); - default: - return standalone_simd(first_vector, second_vector, d, num_queries, num_vectors, knn, positions); + case U8_SIMD_L2: + return standalone_simd( + first_vector, second_vector, d, num_queries, num_vectors, knn, positions + ); + default: + return standalone_simd( + first_vector, second_vector, d, num_queries, num_vectors, knn, positions + ); } } std::vector> standalone_u8( const VectorSearchKernel kernel, - const uint8_t *first_vector, - const uint8_t *second_vector, + const uint8_t* first_vector, + const uint8_t* second_vector, const size_t d, const size_t num_queries, const size_t num_vectors, const size_t knn ) { switch (kernel) { - case U8_PDX_L2: - return standalone_pdx(first_vector, second_vector, d, num_queries, num_vectors, knn); - case U8_PDX_IP: - return standalone_pdx(first_vector, second_vector, d, num_queries, num_vectors, knn); - - case U8_SIMD_L2: - return standalone_simd(first_vector, second_vector, d, num_queries, num_vectors, knn); - case U8_SIMD_IP: - return standalone_simd(first_vector, second_vector, d, num_queries, num_vectors, knn); - - default: - return standalone_pdx(first_vector, second_vector, d, num_queries, num_vectors, knn); + case U8_PDX_L2: + return standalone_pdx( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + case U8_PDX_IP: + return standalone_pdx( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + + case U8_SIMD_L2: + return standalone_simd( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + case U8_SIMD_IP: + return standalone_simd( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); + + default: + return standalone_pdx( + first_vector, second_vector, d, num_queries, num_vectors, knn + ); } } diff --git a/benchmarks/bench_kernels/kernels.py b/benchmarks/kernels_playground/kernels.py similarity index 100% rename from benchmarks/bench_kernels/kernels.py rename to benchmarks/kernels_playground/kernels.py diff --git a/benchmarks/bench_kernels/requirements.txt b/benchmarks/kernels_playground/requirements.txt similarity index 100% rename from benchmarks/bench_kernels/requirements.txt rename to benchmarks/kernels_playground/requirements.txt diff --git a/benchmarks/pdx_end_to_end.cpp b/benchmarks/pdx_end_to_end.cpp new file mode 100644 index 0000000..927213f --- /dev/null +++ b/benchmarks/pdx_end_to_end.cpp @@ -0,0 +1,210 @@ +#ifndef BENCHMARK_TIME +#define BENCHMARK_TIME = true +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include "benchmark_utils.hpp" +#include "pdx/index.hpp" +#include "pdx/utils.hpp" + +template +void RunBenchmark( + const RawDatasetInfo& info, + const std::string& dataset, + const std::string& algorithm, + const float* data, + const float* queries, + const std::vector& nprobes_to_use +) { + const size_t d = info.num_dimensions; + const size_t n = info.num_embeddings; + const size_t n_queries = info.num_queries; + uint8_t KNN = BenchmarkUtils::KNN; + size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; + std::string RESULTS_PATH = BENCHMARK_UTILS.RESULTS_DIR_PATH + "END_TO_END_PDX_ADSAMPLING.csv"; + + PDX::PDXIndexConfig index_config{ + .num_dimensions = static_cast(d), + .distance_metric = info.distance_metric, + .seed = 42, + .normalize = true, + .sampling_fraction = 1.0f + }; + + std::cout << "Building index (num_clusters=auto)...\n"; + auto build_start = std::chrono::high_resolution_clock::now(); + IndexT pdx_index(index_config); + pdx_index.BuildIndex(data, n); + auto build_end = std::chrono::high_resolution_clock::now(); + double build_ms = std::chrono::duration(build_end - build_start).count(); + std::cout << "Build time: " << build_ms << " ms\n"; + std::cout << "Clusters: " << pdx_index.GetNumClusters() << "\n"; + std::cout << "Index in-memory size: " << std::fixed << std::setprecision(2) + << static_cast(pdx_index.GetInMemorySizeInBytes()) / (1024.0 * 1024.0) + << " MB\n"; + + // Load ground truth + bool use_skmeans_gt = false; + std::unordered_map> gt_map; + std::unique_ptr gt_buffer; + uint32_t* int_ground_truth = nullptr; + + if (use_skmeans_gt) { + std::string gt_path = GROUND_TRUTH_JSON_DIR + "/" + dataset + ".json"; + gt_map = ParseGroundTruthJson(gt_path); + if (gt_map.empty()) { + std::cerr << "No ground truth found at " << gt_path << "\n"; + return; + } + std::cout << "Ground truth loaded (json): " << gt_map.size() << " queries\n"; + } else { + std::string gt_path = + BenchmarkUtils::GROUND_TRUTH_DATA + info.pdx_dataset_name + "_100_norm"; + gt_buffer = MmapFile(gt_path); + int_ground_truth = reinterpret_cast(gt_buffer.get()); + std::cout << "Ground truth loaded (pdx binary): " << gt_path << "\n"; + } + + for (size_t ivf_nprobe : nprobes_to_use) { + if (pdx_index.GetNumClusters() < ivf_nprobe) + continue; + + pdx_index.SetNProbe(ivf_nprobe); + + // Recall pass + float recalls = 0; + if (use_skmeans_gt) { + for (size_t l = 0; l < n_queries; ++l) { + auto result = pdx_index.Search(queries + l * d, KNN); + if (gt_map.count(static_cast(l))) { + recalls += ComputeRecallFromJson(result, gt_map.at(static_cast(l)), KNN); + } + } + } else { + for (size_t l = 0; l < n_queries; ++l) { + auto result = pdx_index.Search(queries + l * d, KNN); + BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); + } + } + + std::vector runtimes; + runtimes.resize(NUM_MEASURE_RUNS * n_queries); + TicToc clock; + for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { + for (size_t l = 0; l < n_queries; ++l) { + clock.Reset(); + clock.Tic(); + pdx_index.Search(queries + l * d, KNN); + clock.Toc(); + runtimes[j + l * NUM_MEASURE_RUNS] = {clock.accum_time}; + } + } + + BenchmarkMetadata results_metadata = { + dataset, + algorithm, + NUM_MEASURE_RUNS, + n_queries, + ivf_nprobe, + KNN, + recalls, + }; + BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); + } +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " [index_type] [nprobe]\n"; + std::cerr << "Index types: pdx_f32 (default), pdx_u8, pdx_tree_f32, pdx_tree_u8\n"; + std::cerr << "Available datasets:"; + for (const auto& [name, _] : RAW_DATASET_PARAMS) { + std::cerr << " " << name; + } + std::cerr << "\n"; + return 1; + } + std::string dataset = argv[1]; + std::string index_type = (argc > 2) ? argv[2] : "pdx_f32"; + size_t arg_ivf_nprobe = (argc > 3) ? std::atoi(argv[3]) : 0; + + auto it = RAW_DATASET_PARAMS.find(dataset); + if (it == RAW_DATASET_PARAMS.end()) { + std::cerr << "Unknown dataset: " << dataset << "\n"; + return 1; + } + const auto& info = it->second; + const size_t n = info.num_embeddings; + const size_t d = info.num_dimensions; + const size_t n_queries = info.num_queries; + + std::cout << "==> PDX End-to-End (Build + Search)\n"; + std::cout << "Dataset: " << dataset << " (n=" << n << ", d=" << d << ")\n"; + std::cout << "Index type: " << index_type << "\n"; + + // Read data + std::string data_path = RAW_DATA_DIR + "/data_" + dataset + ".bin"; + std::string query_path = RAW_DATA_DIR + "/data_" + dataset + "_test.bin"; + + std::vector data(n * d); + { + std::ifstream file(data_path, std::ios::binary); + if (!file) { + std::cerr << "Failed to open " << data_path << "\n"; + return 1; + } + file.read(reinterpret_cast(data.data()), n * d * sizeof(float)); + } + + std::vector queries(n_queries * d); + { + std::ifstream file(query_path, std::ios::binary); + if (!file) { + std::cerr << "Failed to open " << query_path << "\n"; + return 1; + } + file.read(reinterpret_cast(queries.data()), n_queries * d * sizeof(float)); + } + + std::vector nprobes_to_use; + if (arg_ivf_nprobe > 0) { + nprobes_to_use = {arg_ivf_nprobe}; + } else { + nprobes_to_use.assign( + std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES) + ); + } + + std::string algorithm = "end_to_end_" + index_type; + + if (index_type == "pdx_f32") { + RunBenchmark( + info, dataset, algorithm, data.data(), queries.data(), nprobes_to_use + ); + } else if (index_type == "pdx_u8") { + RunBenchmark( + info, dataset, algorithm, data.data(), queries.data(), nprobes_to_use + ); + } else if (index_type == "pdx_tree_f32") { + RunBenchmark( + info, dataset, algorithm, data.data(), queries.data(), nprobes_to_use + ); + } else if (index_type == "pdx_tree_u8") { + RunBenchmark( + info, dataset, algorithm, data.data(), queries.data(), nprobes_to_use + ); + } else { + std::cerr << "Unknown index type: " << index_type << "\n"; + std::cerr << "Valid types: pdx_f32, pdx_u8, pdx_tree_f32, pdx_tree_u8\n"; + return 1; + } + + return 0; +} diff --git a/benchmarks/pdx_filtered.cpp b/benchmarks/pdx_filtered.cpp new file mode 100644 index 0000000..255e2c9 --- /dev/null +++ b/benchmarks/pdx_filtered.cpp @@ -0,0 +1,163 @@ +#include "benchmark_utils.hpp" +#include "pdx/index.hpp" +#include "pdx/utils.hpp" +#include +#include +#include +#include +#include + +std::vector LoadPassingRowIds(const std::string& path) { + auto buffer = MmapFile(path); + char* ptr = buffer.get(); + uint32_t num_ids = *reinterpret_cast(ptr); + ptr += sizeof(uint32_t); + auto* ids = reinterpret_cast(ptr); + std::vector result(num_ids); + for (uint32_t i = 0; i < num_ids; i++) { + result[i] = ids[i]; + } + return result; +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " [index_type] [nprobe] [selectivity]\n"; + std::cerr << "Index types: pdx_f32 (default), pdx_u8, pdx_tree_f32, pdx_tree_u8\n"; + std::cerr << "Available datasets:"; + for (const auto& [name, _] : RAW_DATASET_PARAMS) { + std::cerr << " " << name; + } + std::cerr << "\n"; + return 1; + } + + std::string arg_dataset = argv[1]; + std::string index_type = "pdx_f32"; + size_t arg_ivf_nprobe = 0; + std::string arg_selectivity = "0_99"; + + if (argc > 2) { + index_type = argv[2]; + } + if (argc > 3) { + arg_ivf_nprobe = atoi(argv[3]); + } + if (argc > 4) { + arg_selectivity = argv[4]; + } + + std::cout << "==> PDX IVF ADSampling Filtered (" << index_type << ")\n"; + std::cout << "==> Selectivity: " << arg_selectivity << "\n"; + + std::string ALGORITHM = "adsampling_filtered"; + const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; + uint8_t KNN = BenchmarkUtils::KNN; + size_t NUM_QUERIES; + size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; + + // Build results file name from index type + std::string index_type_upper = index_type; + for (auto& c : index_type_upper) + c = toupper(c); + std::string RESULTS_PATH = + BENCHMARK_UTILS.RESULTS_DIR_PATH + index_type_upper + "_ADSAMPLING_FILTERED.csv"; + + // Parse selectivity string to float for metadata + float selectivity_value = 0.0f; + try { + std::string sel = arg_selectivity; + std::replace(sel.begin(), sel.end(), '_', '.'); + selectivity_value = std::stof(sel); + } catch (...) { + } + + for (const auto& [dataset, info] : RAW_DATASET_PARAMS) { + if (!arg_dataset.empty() && arg_dataset != dataset) { + continue; + } + + std::string index_path = BenchmarkUtils::PDX_DATA + dataset + "-" + index_type; + std::cout << "Loading " << index_path << "...\n"; + auto pdx_index = PDX::LoadPDXIndex(index_path); + std::cout << "Index in-memory size: " << std::fixed << std::setprecision(2) + << static_cast(pdx_index->GetInMemorySizeInBytes()) / (1024.0 * 1024.0) + << " MB\n"; + + // Load queries + std::unique_ptr query_ptr = + MmapFile(BenchmarkUtils::QUERIES_DATA + info.pdx_dataset_name); + auto* query = reinterpret_cast(query_ptr.get()); + NUM_QUERIES = info.num_queries; + query += 1; // skip number of embeddings header + + // Load filtered ground truth + std::unique_ptr ground_truth = MmapFile( + BenchmarkUtils::FILTERED_GROUND_TRUTH_DATA + info.pdx_dataset_name + "_100_norm_" + + arg_selectivity + ); + auto* int_ground_truth = reinterpret_cast(ground_truth.get()); + + // Load passing row IDs (binary format: [uint32 count][uint32[] ids]) + std::vector passing_row_ids = LoadPassingRowIds( + BenchmarkUtils::SELECTION_VECTOR_DATA + info.pdx_dataset_name + "_" + arg_selectivity + + ".bin" + ); + std::cout << "Passing row IDs: " << passing_row_ids.size() << "\n"; + + std::vector nprobes_to_use; + if (arg_ivf_nprobe > 0) { + nprobes_to_use = {arg_ivf_nprobe}; + } else { + nprobes_to_use.assign( + std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES) + ); + } + + for (size_t ivf_nprobe : nprobes_to_use) { + if (pdx_index->GetNumClusters() < ivf_nprobe) { + continue; + } + if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe) { + continue; + } + std::vector runtimes; + runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); + pdx_index->SetNProbe(ivf_nprobe); + + float recalls = 0; + if (VERIFY_RESULTS) { + for (size_t l = 0; l < NUM_QUERIES; ++l) { + auto result = pdx_index->FilteredSearch( + query + l * pdx_index->GetNumDimensions(), KNN, passing_row_ids + ); + BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); + } + } + TicToc clock; + for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { + for (size_t l = 0; l < NUM_QUERIES; ++l) { + clock.Reset(); + clock.Tic(); + pdx_index->FilteredSearch( + query + l * pdx_index->GetNumDimensions(), KNN, passing_row_ids + ); + clock.Toc(); + runtimes[j + l * NUM_MEASURE_RUNS] = {clock.accum_time}; + } + } + BenchmarkMetadata results_metadata = { + dataset, + ALGORITHM, + NUM_MEASURE_RUNS, + NUM_QUERIES, + ivf_nprobe, + KNN, + recalls, + selectivity_value + }; + BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); + } + } + return 0; +} diff --git a/benchmarks/pdx_ivf.cpp b/benchmarks/pdx_ivf.cpp new file mode 100644 index 0000000..19162e1 --- /dev/null +++ b/benchmarks/pdx_ivf.cpp @@ -0,0 +1,120 @@ +#include "benchmark_utils.hpp" +#include "pdx/index.hpp" +#include "pdx/utils.hpp" +#include +#include +#include + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " [index_type] [nprobe]\n"; + std::cerr << "Index types: pdx_f32 (default), pdx_u8, pdx_tree_f32, pdx_tree_u8\n"; + std::cerr << "Available datasets:"; + for (const auto& [name, _] : RAW_DATASET_PARAMS) { + std::cerr << " " << name; + } + std::cerr << "\n"; + return 1; + } + + std::string arg_dataset = argv[1]; + std::string index_type = "pdx_f32"; + size_t arg_ivf_nprobe = 0; + if (argc > 2) { + index_type = argv[2]; + } + if (argc > 3) { + arg_ivf_nprobe = atoi(argv[3]); + } + + std::cout << "==> PDX IVF ADSampling (" << index_type << ")\n"; + + std::string ALGORITHM = "adsampling"; + const bool VERIFY_RESULTS = BenchmarkUtils::VERIFY_RESULTS; + + uint8_t KNN = BenchmarkUtils::KNN; + size_t NUM_QUERIES; + size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; + + // Build results file name from index type (e.g., "pdx_f32" -> "PDX_F32_ADSAMPLING.csv") + std::string index_type_upper = index_type; + for (auto& c : index_type_upper) + c = toupper(c); + std::string RESULTS_PATH = + BENCHMARK_UTILS.RESULTS_DIR_PATH + index_type_upper + "_ADSAMPLING.csv"; + + for (const auto& [dataset, info] : RAW_DATASET_PARAMS) { + if (!arg_dataset.empty() && arg_dataset != dataset) { + continue; + } + + std::string index_path = BenchmarkUtils::PDX_DATA + dataset + "-" + index_type; + std::cout << "Loading " << index_path << "...\n"; + auto pdx_index = PDX::LoadPDXIndex(index_path); + std::cout << "Index in-memory size: " << std::fixed << std::setprecision(2) + << static_cast(pdx_index->GetInMemorySizeInBytes()) / (1024.0 * 1024.0) + << " MB\n"; + + std::unique_ptr query_ptr = + MmapFile(BenchmarkUtils::QUERIES_DATA + info.pdx_dataset_name); + auto* query = reinterpret_cast(query_ptr.get()); + + NUM_QUERIES = info.num_queries; + std::unique_ptr ground_truth = + MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + info.pdx_dataset_name + "_100_norm"); + auto* int_ground_truth = reinterpret_cast(ground_truth.get()); + query += 1; // skip number of embeddings + + std::vector nprobes_to_use; + if (arg_ivf_nprobe > 0) { + nprobes_to_use = {arg_ivf_nprobe}; + } else { + nprobes_to_use.assign( + std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES) + ); + } + + for (size_t ivf_nprobe : nprobes_to_use) { + if (pdx_index->GetNumClusters() < ivf_nprobe) { + continue; + } + if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe) { + continue; + } + std::vector runtimes; + runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); + pdx_index->SetNProbe(ivf_nprobe); + + float recalls = 0; + if (VERIFY_RESULTS) { + for (size_t l = 0; l < NUM_QUERIES; ++l) { + auto result = pdx_index->Search(query + l * pdx_index->GetNumDimensions(), KNN); + BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); + } + } + TicToc clock; + for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { + for (size_t l = 0; l < NUM_QUERIES; ++l) { + clock.Reset(); + clock.Tic(); + pdx_index->Search(query + l * pdx_index->GetNumDimensions(), KNN); + clock.Toc(); + runtimes[j + l * NUM_MEASURE_RUNS] = {clock.accum_time}; + } + } + float real_selectivity = 1 - BenchmarkUtils::SELECTIVITY_THRESHOLD; + BenchmarkMetadata results_metadata = { + dataset, + ALGORITHM, + NUM_MEASURE_RUNS, + NUM_QUERIES, + ivf_nprobe, + KNN, + recalls, + real_selectivity + }; + BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); + } + } + return 0; +} diff --git a/benchmarks/pdx_serialization.cpp b/benchmarks/pdx_serialization.cpp new file mode 100644 index 0000000..d9088be --- /dev/null +++ b/benchmarks/pdx_serialization.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "benchmark_utils.hpp" +#include "pdx/index.hpp" + +template +void BuildAndSave( + const RawDatasetInfo& info, + const std::string& dataset, + const std::string& index_type, + const float* data +) { + const size_t d = info.num_dimensions; + const size_t n = info.num_embeddings; + + PDX::PDXIndexConfig index_config{ + .num_dimensions = static_cast(d), + .distance_metric = info.distance_metric, + .seed = 42, + .normalize = true, + .sampling_fraction = 1.0f + }; + + std::cout << "Building " << index_type << " index...\n"; + auto build_start = std::chrono::high_resolution_clock::now(); + IndexT pdx_index(index_config); + pdx_index.BuildIndex(data, n); + auto build_end = std::chrono::high_resolution_clock::now(); + double build_ms = std::chrono::duration(build_end - build_start).count(); + std::cout << "Build time: " << build_ms << " ms\n"; + std::cout << "Clusters: " << pdx_index.GetNumClusters() << "\n"; + std::cout << "Index in-memory size: " << std::fixed << std::setprecision(2) + << static_cast(pdx_index.GetInMemorySizeInBytes()) / (1024.0 * 1024.0) + << " MB\n"; + + std::string save_path = BenchmarkUtils::PDX_DATA + dataset + "-" + index_type; + std::cout << "Saving to " << save_path << "...\n"; + pdx_index.Save(save_path); + std::cout << "Done.\n"; +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " [index_type]\n"; + std::cerr << "Index types: pdx_f32 (default), pdx_u8, pdx_tree_f32, pdx_tree_u8\n"; + std::cerr << "Available datasets:"; + for (const auto& [name, _] : RAW_DATASET_PARAMS) { + std::cerr << " " << name; + } + std::cerr << "\n"; + return 1; + } + std::string dataset = argv[1]; + std::string index_type = (argc > 2) ? argv[2] : "pdx_f32"; + + auto it = RAW_DATASET_PARAMS.find(dataset); + if (it == RAW_DATASET_PARAMS.end()) { + std::cerr << "Unknown dataset: " << dataset << "\n"; + return 1; + } + const auto& info = it->second; + const size_t n = info.num_embeddings; + const size_t d = info.num_dimensions; + + std::cout << "==> PDX Serialization\n"; + std::cout << "Dataset: " << dataset << " (" << info.pdx_dataset_name << ", n=" << n + << ", d=" << d << ")\n"; + std::cout << "Index type: " << index_type << "\n"; + + // Read raw data + std::string data_path = RAW_DATA_DIR + "/data_" + dataset + ".bin"; + std::vector data(n * d); + { + std::ifstream file(data_path, std::ios::binary); + if (!file) { + std::cerr << "Failed to open " << data_path << "\n"; + return 1; + } + file.read(reinterpret_cast(data.data()), n * d * sizeof(float)); + } + + // Ensure output directory exists + std::filesystem::create_directories(BenchmarkUtils::PDX_DATA); + + std::string save_path = BenchmarkUtils::PDX_DATA + dataset + "-" + index_type; + + if (index_type == "pdx_f32") { + BuildAndSave(info, dataset, index_type, data.data()); + } else if (index_type == "pdx_u8") { + BuildAndSave(info, dataset, index_type, data.data()); + } else if (index_type == "pdx_tree_f32") { + BuildAndSave(info, dataset, index_type, data.data()); + } else if (index_type == "pdx_tree_u8") { + BuildAndSave(info, dataset, index_type, data.data()); + } else { + std::cerr << "Unknown index type: " << index_type << "\n"; + std::cerr << "Valid types: pdx_f32, pdx_u8, pdx_tree_f32, pdx_tree_u8\n"; + return 1; + } + + // Verify: load back without knowing the type and run queries + std::cout << "\n==> Verification: Loading index from " << save_path << "...\n"; + auto loaded_index = PDX::LoadPDXIndex(save_path); + std::cout << "Loaded index in-memory size: " << std::fixed << std::setprecision(2) + << static_cast(loaded_index->GetInMemorySizeInBytes()) / (1024.0 * 1024.0) + << " MB\n"; + + // Load queries + std::unique_ptr query_ptr = + MmapFile(BenchmarkUtils::QUERIES_DATA + info.pdx_dataset_name); + auto* queries = reinterpret_cast(query_ptr.get()); + queries += 1; // skip header + + // Load ground truth + std::unique_ptr gt_buffer = + MmapFile(BenchmarkUtils::GROUND_TRUTH_DATA + info.pdx_dataset_name + "_100_norm"); + auto* int_ground_truth = reinterpret_cast(gt_buffer.get()); + + const size_t n_queries = info.num_queries; + const uint8_t KNN = BenchmarkUtils::KNN; + + loaded_index->SetNProbe(25); + float recalls = 0; + for (size_t l = 0; l < n_queries; ++l) { + auto result = loaded_index->Search(queries + l * d, KNN); + BenchmarkUtils::VerifyResult(recalls, result, KNN, int_ground_truth, l); + } + float avg_recall = recalls / n_queries; + std::cout << "Recall@" << +KNN << " (nprobe=25): " << avg_recall << "\n"; + + std::cout << "Verification complete.\n"; + return 0; +} diff --git a/benchmarks/pdx_special_filtered.cpp b/benchmarks/pdx_special_filtered.cpp new file mode 100644 index 0000000..b16ccb8 --- /dev/null +++ b/benchmarks/pdx_special_filtered.cpp @@ -0,0 +1,169 @@ +#include "benchmark_utils.hpp" +#include "pdx/index.hpp" +#include "pdx/utils.hpp" +#include +#include +#include +#include +#include + +struct SpecialFilter { + std::string name; + uint32_t n_clusters; + bool plus_one_per_remaining; // PART+ mode: add 1 random row from each remaining cluster +}; + +static const SpecialFilter SPECIAL_FILTERS[] = { + {"PART_1", 1, false}, + {"PART_30", 30, false}, + {"PART+_1", 1, true}, +}; + +std::vector BuildSpecialFilterRowIds( + const PDX::IPDXIndex& index, + const SpecialFilter& filter, + uint32_t seed +) { + std::vector passing_row_ids; + uint32_t total_clusters = index.GetNumClusters(); + uint32_t n_full = std::min(filter.n_clusters, total_clusters); + + // All rows from the first n_full clusters + for (uint32_t c = 0; c < n_full; c++) { + auto ids = index.GetClusterRowIds(c); + for (auto id : ids) { + passing_row_ids.push_back(id); + } + } + + // PART+ mode: add 1 random row from each remaining cluster + if (filter.plus_one_per_remaining) { + std::mt19937 gen(seed); + for (uint32_t c = n_full; c < total_clusters; c++) { + uint32_t cluster_size = index.GetClusterSize(c); + if (cluster_size == 0) { + continue; + } + auto ids = index.GetClusterRowIds(c); + std::uniform_int_distribution dist(0, cluster_size - 1); + passing_row_ids.push_back(ids[dist(gen)]); + } + } + + return passing_row_ids; +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " [index_type] [nprobe]\n"; + std::cerr << "Index types: pdx_f32 (default), pdx_u8, pdx_tree_f32, pdx_tree_u8\n"; + std::cerr << "Available datasets:"; + for (const auto& [name, _] : RAW_DATASET_PARAMS) { + std::cerr << " " << name; + } + std::cerr << "\n"; + return 1; + } + + std::string arg_dataset = argv[1]; + std::string index_type = "pdx_f32"; + size_t arg_ivf_nprobe = 0; + if (argc > 2) { + index_type = argv[2]; + } + if (argc > 3) { + arg_ivf_nprobe = atoi(argv[3]); + } + + std::cout << "==> PDX Special Filtered Benchmark (" << index_type << ")\n"; + + std::string ALGORITHM = "special_filtered"; + uint8_t KNN = BenchmarkUtils::KNN; + size_t NUM_QUERIES; + size_t NUM_MEASURE_RUNS = BenchmarkUtils::NUM_MEASURE_RUNS; + + std::string index_type_upper = index_type; + for (auto& c : index_type_upper) + c = toupper(c); + std::string RESULTS_PATH = + BENCHMARK_UTILS.RESULTS_DIR_PATH + index_type_upper + "_SPECIAL_FILTERED.csv"; + + for (const auto& [dataset, info] : RAW_DATASET_PARAMS) { + if (!arg_dataset.empty() && arg_dataset != dataset) { + continue; + } + + std::string index_path = BenchmarkUtils::PDX_DATA + dataset + "-" + index_type; + std::cout << "Loading " << index_path << "...\n"; + auto pdx_index = PDX::LoadPDXIndex(index_path); + std::cout << "Index in-memory size: " << std::fixed << std::setprecision(2) + << static_cast(pdx_index->GetInMemorySizeInBytes()) / (1024.0 * 1024.0) + << " MB\n"; + std::cout << "Num clusters: " << pdx_index->GetNumClusters() << "\n"; + + // Load queries + std::unique_ptr query_ptr = + MmapFile(BenchmarkUtils::QUERIES_DATA + info.pdx_dataset_name); + auto* query = reinterpret_cast(query_ptr.get()); + NUM_QUERIES = info.num_queries; + query += 1; // skip number of embeddings header + + for (const auto& filter : SPECIAL_FILTERS) { + if (filter.n_clusters > pdx_index->GetNumClusters()) { + std::cout << "Skipping " << filter.name << " (needs " << filter.n_clusters + << " clusters, index has " << pdx_index->GetNumClusters() << ")\n"; + continue; + } + + auto passing_row_ids = BuildSpecialFilterRowIds(*pdx_index, filter, 42); + std::cout << "\n--- " << filter.name << ": " << passing_row_ids.size() + << " passing row IDs ---\n"; + + std::vector nprobes_to_use; + if (arg_ivf_nprobe > 0) { + nprobes_to_use = {arg_ivf_nprobe}; + } else { + nprobes_to_use.assign( + std::begin(BenchmarkUtils::IVF_PROBES), std::end(BenchmarkUtils::IVF_PROBES) + ); + } + + for (size_t ivf_nprobe : nprobes_to_use) { + if (pdx_index->GetNumClusters() < ivf_nprobe) { + continue; + } + if (arg_ivf_nprobe > 0 && ivf_nprobe != arg_ivf_nprobe) { + continue; + } + std::vector runtimes; + runtimes.resize(NUM_MEASURE_RUNS * NUM_QUERIES); + pdx_index->SetNProbe(ivf_nprobe); + + TicToc clock; + for (size_t j = 0; j < NUM_MEASURE_RUNS; ++j) { + for (size_t l = 0; l < NUM_QUERIES; ++l) { + clock.Reset(); + clock.Tic(); + pdx_index->FilteredSearch( + query + l * pdx_index->GetNumDimensions(), KNN, passing_row_ids + ); + clock.Toc(); + runtimes[j + l * NUM_MEASURE_RUNS] = {clock.accum_time}; + } + } + BenchmarkMetadata results_metadata = { + dataset, + ALGORITHM + "_" + filter.name, + NUM_MEASURE_RUNS, + NUM_QUERIES, + ivf_nprobe, + KNN, + 0.0f, // no recall (timing-only) + 0.0f + }; + BenchmarkUtils::SaveResults(runtimes, RESULTS_PATH, results_metadata); + } + } + } + return 0; +} diff --git a/benchmarks/python_scripts/WrapperBruteForce.py b/benchmarks/python_scripts/WrapperBruteForce.py index 29253a0..8156f30 100644 --- a/benchmarks/python_scripts/WrapperBruteForce.py +++ b/benchmarks/python_scripts/WrapperBruteForce.py @@ -21,36 +21,32 @@ def query(self, data, v, n): class BruteForceFAISS: - def __init__(self, metric, dimension): - if metric not in ("angular", "euclidean", "hamming", "ip"): + def __init__(self, metric, njobs=-1): + if metric not in ("angular", "euclidean", "ip"): raise NotImplementedError("BruteForce doesn't support metric %s" % metric) self.metric = metric - if metric == "euclidean": - self._metric = faiss.METRIC_L2 - elif metric == "ip": - self._metric = faiss.METRIC_INNER_PRODUCT - else: - self._metric = faiss.METRIC_L2 - - self._metric = metric - self.name = "BruteForceSIMD()" - self._nbrs = None - self.dimension = dimension - faiss.omp_set_num_threads(1) + self.name = "BruteForceFAISS()" def fit(self, X): - if self.metric == "euclidean": - self.index = faiss.IndexFlatL2(self.dimension) - elif self.metric == "ip": - self.index = faiss.IndexFlatIP(self.dimension) + X = numpy.ascontiguousarray(X, dtype=numpy.float32) + dimension = X.shape[1] + if self.metric == "ip": + self.index = faiss.IndexFlatIP(dimension) else: - self.index = faiss.IndexFlatL2(self.dimension) + self.index = faiss.IndexFlatL2(dimension) self.index.add(X) - def query(self, v, n, X=None, force_fit=True): - if force_fit and X is not None: self.fit(X) - points, distances = self.index.search(numpy.array([v]), k=n) - return points, distances + def query(self, v, n): + v = numpy.ascontiguousarray(numpy.array([v]), dtype=numpy.float32) + distances, indices = self.index.search(v, k=n) + return distances[0], indices[0] + + def query_batch(self, v, n): + """Batch query. Returns (distances, indices). + Note: for L2, distances are squared L2 (unlike sklearn which returns L2).""" + v = numpy.ascontiguousarray(v, dtype=numpy.float32) + distances, indices = self.index.search(v, k=n) + return distances, indices class BruteForceSKLearn: def __init__(self, metric, njobs=1): diff --git a/benchmarks/python_scripts/benchmark_utils.py b/benchmarks/python_scripts/benchmark_utils.py index 7faaf41..93f42c8 100644 --- a/benchmarks/python_scripts/benchmark_utils.py +++ b/benchmarks/python_scripts/benchmark_utils.py @@ -7,7 +7,7 @@ SOURCE_DIR = os.getcwd() ARCHITECTURE = os.environ.get('PDX_ARCH', 'DEFAULT') RESULTS_DIRECTORY = os.path.join(SOURCE_DIR, "benchmarks", "results", ARCHITECTURE) -KNN = 100 +KNN = 20 N_MEASURE_RUNS = 1 IVF_NPROBES = [ diff --git a/benchmarks/python_scripts/exact_faiss.py b/benchmarks/python_scripts/exact_faiss.py deleted file mode 100644 index ece1462..0000000 --- a/benchmarks/python_scripts/exact_faiss.py +++ /dev/null @@ -1,41 +0,0 @@ -import sys -from benchmark_utils import * -from setup_utils import * -from setup_settings import * -from WrapperBruteForce import BruteForceFAISS - -disable_multithreading() - -if __name__ == '__main__': - RESULTS_PATH = os.path.join(RESULTS_DIRECTORY, "EXACT_FAISS.csv") - arg_dataset = "" - if len(sys.argv) > 1: - arg_dataset = sys.argv[1] - for dataset in DATASETS: - if len(arg_dataset) and dataset != arg_dataset: - continue - runtimes = [] - clock = TicToc() - - data, queries = read_hdf5_data(dataset) - data = np.ascontiguousarray(data) - - searcher = BruteForceFAISS("euclidean", len(data[0])) - searcher.fit(data) - - for _ in range(N_MEASURE_RUNS): - for q in queries[:100]: - q = np.ascontiguousarray(q) - clock.tic() - searcher.query(q, KNN) - runtimes.append(clock.toc()) - - metadata = { - 'dataset': dataset, - 'n_queries': len(queries), - 'algorithm': 'faiss', - 'recall': 1.0 - } - save_results(runtimes, RESULTS_PATH, metadata) - - diff --git a/benchmarks/python_scripts/exact_usearch.py b/benchmarks/python_scripts/exact_usearch.py deleted file mode 100644 index 6f25e2d..0000000 --- a/benchmarks/python_scripts/exact_usearch.py +++ /dev/null @@ -1,39 +0,0 @@ -import sys -from benchmark_utils import * -from setup_utils import * -from setup_settings import * -from WrapperBruteForce import BruteForceUsearch, BruteForceSKLearn - -disable_multithreading() - -if __name__ == '__main__': - RESULTS_PATH = os.path.join(RESULTS_DIRECTORY, "EXACT_USEARCH.csv") - arg_dataset = "" - if len(sys.argv) > 1: - arg_dataset = sys.argv[1] - for dataset in DATASETS: - if len(arg_dataset) and dataset != arg_dataset: - continue - runtimes = [] - clock = TicToc() - - data, queries = read_hdf5_data(dataset) - data = np.ascontiguousarray(data) - - searcher = BruteForceUsearch("euclidean") - searcher_gt = BruteForceSKLearn("euclidean") - for _ in range(N_MEASURE_RUNS): - for q in queries: - q = np.ascontiguousarray(q) - clock.tic() - searcher.query(data, q, KNN) - runtimes.append(clock.toc()) - metadata = { - 'dataset': dataset, - 'n_queries': len(queries), - 'algorithm': 'usearch', - 'recall': 1.0 - } - save_results(runtimes, RESULTS_PATH, metadata) - - diff --git a/benchmarks/python_scripts/ivf_faiss.py b/benchmarks/python_scripts/ivf_faiss.py index c6c279e..3e33c45 100644 --- a/benchmarks/python_scripts/ivf_faiss.py +++ b/benchmarks/python_scripts/ivf_faiss.py @@ -4,15 +4,13 @@ from sklearn import preprocessing from benchmark_utils import * from setup_utils import * -from setup_settings import * DATASETS_TO_USE = [ - 'openai-1536-angular', - 'agnews-mxbai-1024-euclidean', - 'instructorxl-arxiv-768', - 'simplewiki-openai-3072-normalized', - 'msong-420', - 'llama-128-ip', + 'openai', + 'mxbai', + 'arxiv', + 'wiki', + 'cohere' ] if __name__ == '__main__': RESULTS_PATH = os.path.join(RESULTS_DIRECTORY, "IVF_FAISS.csv") @@ -22,18 +20,18 @@ arg_dataset = sys.argv[1] if len(sys.argv) > 2: IVF_NPROBE = int(sys.argv[2]) # controls recall of search - if not len(DATASETS_TO_USE): DATASETS_TO_USE = DATASETS + if not len(DATASETS_TO_USE): DATASETS_TO_USE = list(DATASET_INFO.keys()) for dataset in DATASETS_TO_USE: if len(arg_dataset) and dataset != arg_dataset: continue - dimensionality = DIMENSIONALITIES[dataset] - index_name = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset)) - gt_name = os.path.join(SEMANTIC_GROUND_TRUTH_PATH, get_ground_truth_filename(dataset, 100)) + hdf5_name, dimensionality = DATASET_INFO[dataset] + index_name = os.path.join(FAISS_DATA, get_core_index_filename(hdf5_name, norm=True)) + gt_name = os.path.join(SEMANTIC_GROUND_TRUTH_PATH, get_ground_truth_filename(hdf5_name, 100)) disable_multithreading() faiss.omp_set_num_threads(1) - queries = read_hdf5_test_data(dataset) + queries = read_hdf5_test_data(hdf5_name) queries = preprocessing.normalize(queries, axis=1, norm='l2') print('Restoring index...') @@ -43,7 +41,7 @@ nprobes_to_use = [] if IVF_NPROBE: nprobes_to_use = [IVF_NPROBE] - else : + else: nprobes_to_use = IVF_NPROBES for ivf_nprobe in nprobes_to_use: @@ -81,5 +79,3 @@ 'ivf_nprobe': ivf_nprobe } save_results(runtimes, RESULTS_PATH, metadata) - - diff --git a/benchmarks/python_scripts/ivf_faiss_sq8.py b/benchmarks/python_scripts/ivf_faiss_sq8.py index 890c237..cdd70cf 100644 --- a/benchmarks/python_scripts/ivf_faiss_sq8.py +++ b/benchmarks/python_scripts/ivf_faiss_sq8.py @@ -1,20 +1,16 @@ import faiss import json import sys -from numpy.random import default_rng from benchmark_utils import * from setup_utils import * -from setup_settings import * from sklearn import preprocessing -BUILD = False DATASETS_TO_USE = [ - 'openai-1536-angular', - 'agnews-mxbai-1024-euclidean', - 'instructorxl-arxiv-768', - 'simplewiki-openai-3072-normalized', - 'msong-420', - 'llama-128-ip', + 'openai', + 'mxbai', + 'arxiv', + 'wiki', + 'cohere' ] # Scalar Quantization in FAISS is EXTREMELY slow in ARM due to lack of SIMD if __name__ == '__main__': @@ -25,52 +21,18 @@ arg_dataset = sys.argv[1] if len(sys.argv) > 2: IVF_NPROBE = int(sys.argv[2]) # controls recall of search - if not len(DATASETS_TO_USE): DATASETS_TO_USE = DATASETS + if not len(DATASETS_TO_USE): DATASETS_TO_USE = list(DATASET_INFO.keys()) for dataset in DATASETS_TO_USE: if len(arg_dataset) and dataset != arg_dataset: continue - dimensionality = DIMENSIONALITIES[dataset] - index_name = os.path.join(CORE_INDEXES_FAISS_U8, get_core_index_filename(dataset)) - gt_name = os.path.join(SEMANTIC_GROUND_TRUTH_PATH, get_ground_truth_filename(dataset, 100)) - - if BUILD: - print('Building FAISS SQ8 index for', dataset) - print('Loading data') - data = read_hdf5_train_data(dataset) - print('Normalizing') - data = preprocessing.normalize(data, axis=1, norm='l2') - num_embeddings = len(data) - if dataset == "simplewiki-openai-3072-normalized": # Special case because it has too many dimensions! - nbuckets = 2048 - elif num_embeddings < 500_000: - nbuckets = math.ceil(2 * math.sqrt(num_embeddings)) - elif num_embeddings < 2_500_000: - nbuckets = math.ceil(4 * math.sqrt(num_embeddings)) - else: # Deep with 10m - nbuckets = math.ceil(8 * math.sqrt(num_embeddings)) - print('Instantiating') - coarse_quantizer = faiss.IndexFlatL2(int(dimensionality)) - index = faiss.IndexIVFScalarQuantizer(coarse_quantizer, int(dimensionality), int(nbuckets), faiss.ScalarQuantizer.QT_8bit) - training_points = nbuckets * 300 - if training_points < num_embeddings: - rng = default_rng() - training_sample_idxs = rng.choice(num_embeddings, size=training_points, replace=False) - training_sample_idxs.sort() - print('Training with', training_points) - index.train(data[training_sample_idxs]) - else: - print('Training with all points') - index.train(data) - print('Building') - index.add(data) - print('Saving') - faiss.write_index(index, index_name) - continue + hdf5_name, dimensionality = DATASET_INFO[dataset] + index_name = os.path.join(FAISS_DATA, get_core_index_filename(hdf5_name, sq8=True)) + gt_name = os.path.join(SEMANTIC_GROUND_TRUTH_PATH, get_ground_truth_filename(hdf5_name, 100)) disable_multithreading() faiss.omp_set_num_threads(1) - queries = read_hdf5_test_data(dataset) + queries = read_hdf5_test_data(hdf5_name) queries = preprocessing.normalize(queries, axis=1, norm='l2') print('Restoring index...') @@ -80,7 +42,7 @@ nprobes_to_use = [] if IVF_NPROBE: nprobes_to_use = [IVF_NPROBE] - else : + else: nprobes_to_use = IVF_NPROBES for ivf_nprobe in nprobes_to_use: @@ -113,10 +75,8 @@ metadata = { 'dataset': dataset, 'n_queries': len(queries), - 'algorithm': 'ivf_faiss', + 'algorithm': 'ivf_faiss_sq8', 'recall': sum(recalls) / float(len(recalls)), 'ivf_nprobe': ivf_nprobe } save_results(runtimes, RESULTS_PATH, metadata) - - diff --git a/benchmarks/python_scripts/ivf_lorann.py b/benchmarks/python_scripts/ivf_lorann.py deleted file mode 100644 index 0188a77..0000000 --- a/benchmarks/python_scripts/ivf_lorann.py +++ /dev/null @@ -1,113 +0,0 @@ -import lorann -import json -import sys -from benchmark_utils import * -from setup_utils import * -from setup_settings import * -from sklearn import preprocessing - -disable_multithreading() - -BUILD = True -DATASETS_TO_USE = [ - 'openai-1536-angular', - 'agnews-mxbai-1024-euclidean', - 'instructorxl-arxiv-768', - 'simplewiki-openai-3072-normalized', - 'msong-420', - 'llama-128-ip', -] -if __name__ == '__main__': - RESULTS_PATH = os.path.join(RESULTS_DIRECTORY, "IVF_LORANN.csv") - arg_dataset = "" - IVF_NPROBE = 0 - if len(sys.argv) > 1: - arg_dataset = sys.argv[1] - if len(sys.argv) > 2: - IVF_NPROBE = int(sys.argv[2]) # controls recall of search - if not len(DATASETS_TO_USE): DATASETS_TO_USE = DATASETS - for dataset in DATASETS_TO_USE: - if len(arg_dataset) and dataset != arg_dataset: - continue - - dimensionality = DIMENSIONALITIES[dataset] - index_name = os.path.join(CORE_INDEXES_LORANN, get_core_index_filename(dataset)) - gt_name = os.path.join(SEMANTIC_GROUND_TRUTH_PATH, get_ground_truth_filename(dataset, 100)) - - if BUILD: - print('Building LoRANN index for', dataset) - print('Loading data') - data = read_hdf5_train_data(dataset) - print('Normalizing') - data = preprocessing.normalize(data, axis=1, norm='l2') - num_embeddings = len(data) - if dataset == "simplewiki-openai-3072-normalized": # Special case because it has too many dimensions! - nbuckets = 2048 - elif num_embeddings < 500_000: - nbuckets = math.ceil(2 * math.sqrt(num_embeddings)) - elif num_embeddings < 2_500_000: - nbuckets = math.ceil(4 * math.sqrt(num_embeddings)) - else: # Deep with 10m - nbuckets = math.ceil(8 * math.sqrt(num_embeddings)) - print('Instantiating') - index = lorann.LorannIndex( - data=data, - n_clusters=nbuckets, - global_dim=192, - quantization_bits=8, - euclidean=True, - ) - print('Building') - index.build(verbose=False) - print('Saving') - index.save(str(index_name)) - continue - - queries = read_hdf5_test_data(dataset) - queries = preprocessing.normalize(queries, axis=1, norm='l2') - - print('Restoring Lorann index...') - index = lorann.LorannIndex.load(str(index_name)) - print('Index restored...') - - nprobes_to_use = [] - if IVF_NPROBE: - nprobes_to_use = [IVF_NPROBE] - else : - nprobes_to_use = IVF_NPROBES - - for ivf_nprobe in nprobes_to_use: - print('Nprobe: ', ivf_nprobe) - if IVF_NPROBE > 0 and IVF_NPROBE != ivf_nprobe: - continue - if ivf_nprobe > index.n_clusters: - continue - runtimes = [] - recalls = [] - clock = TicToc() - print('Querying Measure...') - for i in range(N_MEASURE_RUNS): - for q in queries: - q = np.ascontiguousarray(np.array([q])) - clock.tic() - index.search(q, KNN, clusters_to_search=ivf_nprobe, points_to_rerank=200, return_distances=False, n_threads=1) - runtimes.append(clock.toc()) - - # Measure recall afterwards to not affect cache - gt = json.load(open(gt_name, 'r')) - query_i = 0 - for q in queries: - matches = index.search(np.ascontiguousarray(np.array([q])), KNN, clusters_to_search=ivf_nprobe, points_to_rerank=200, return_distances=False, n_threads=-1) - recalls.append(float(len(set(matches[0]).intersection(set(gt[str(query_i)][:KNN])))) / KNN) - query_i += 1 - - metadata = { - 'dataset': dataset, - 'n_queries': len(queries), - 'algorithm': 'ivf_faiss', - 'recall': sum(recalls) / float(len(recalls)), - 'ivf_nprobe': ivf_nprobe - } - save_results(runtimes, RESULTS_PATH, metadata) - - diff --git a/benchmarks/python_scripts/playground_adsampling.py b/benchmarks/python_scripts/playground_adsampling.py index 776c97d..28bca43 100644 --- a/benchmarks/python_scripts/playground_adsampling.py +++ b/benchmarks/python_scripts/playground_adsampling.py @@ -1,7 +1,6 @@ import json from WrapperBruteForce import BruteForceSKLearn from setup_utils import * -from setup_settings import * from sklearn import preprocessing import math import time diff --git a/benchmarks/python_scripts/requirements.txt b/benchmarks/python_scripts/requirements.txt index d56a057..b6128ed 100644 --- a/benchmarks/python_scripts/requirements.txt +++ b/benchmarks/python_scripts/requirements.txt @@ -2,5 +2,6 @@ numpy~=2.2.2 h5py~=3.12.1 usearch~=2.16.9 scikit-learn~=1.6.1 +faiss-cpu~=1.9.0 gdown==5.1.0 scipy~=1.15.3 \ No newline at end of file diff --git a/benchmarks/python_scripts/setup_adsampling.py b/benchmarks/python_scripts/setup_adsampling.py deleted file mode 100644 index d417c02..0000000 --- a/benchmarks/python_scripts/setup_adsampling.py +++ /dev/null @@ -1,191 +0,0 @@ -import faiss -from setup_utils import * -from setup_settings import * -from pdxearch.index_base import BaseIndexPDXIVF, BaseIndexPDXIVF2 -from pdxearch.preprocessors import ADSampling -from sklearn import preprocessing -from sklearn.preprocessing import MinMaxScaler - - -def generate_adsampling_ivf(dataset_name: str, _type='pdx', normalize=True): - print(dataset_name) - base_idx = BaseIndexPDXIVF(DIMENSIONALITIES[dataset_name], 'l2sq') - # Core index IVF must exist - index_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name, norm=normalize)) - # Reads the core index created by faiss to generate the PDX index - base_idx.core_index.read_index(index_path) - data = read_hdf5_train_data(dataset_name) - if normalize: - print('Normalizing') - data = preprocessing.normalize(data, axis=1, norm='l2') - preprocessor = ADSampling(DIMENSIONALITIES[dataset_name]) - preprocessor.preprocess(data, inplace=True) - print('Saving...') - # PDX - base_idx._to_pdx(data, _type='pdx', centroids_preprocessor=preprocessor, use_original_centroids=True) - base_idx._persist(os.path.join(PDX_ADSAMPLING_DATA, dataset_name + '-ivf')) - - # Store metadata needed by ADSampling - preprocessor.store_metadata(os.path.join(NARY_ADSAMPLING_DATA, dataset_name + '-matrix')) - -def generate_adsampling_ivf_global8(dataset_name: str, normalize=True): - base_idx = BaseIndexPDXIVF(DIMENSIONALITIES[dataset_name], 'l2sq') - # Core index IVF must exist - index_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name, norm=normalize)) - # Reads the core index created by faiss to generate the PDX index - base_idx.core_index.read_index(index_path) - print('Reading train data') - data = read_hdf5_train_data(dataset_name) - preprocessor = ADSampling(DIMENSIONALITIES[dataset_name]) - preprocessor.preprocess(data, inplace=True, normalize=True) - print('Saving') - # PDX FLAT BLOCKIFIED - base_idx._to_pdx(data, _type='pdx-v4-h', quantize=True, centroids_preprocessor=preprocessor, use_original_centroids=True) - base_idx._persist(os.path.join(PDX_ADSAMPLING_DATA, dataset_name + '-ivf-u8')) - - preprocessor.store_metadata(os.path.join(NARY_ADSAMPLING_DATA, dataset_name + '-ivf-u8-matrix')) - -def generate_adsampling_ivf2(dataset_name: str, normalize=True): - base_idx = BaseIndexPDXIVF2(DIMENSIONALITIES[dataset_name], 'l2sq') - # Core index IVF must exist - index_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name, norm=normalize)) - index_path_l0 = os.path.join(CORE_INDEXES_FAISS_L0, get_core_index_filename(dataset_name, norm=normalize)) - # Reads the core index created by faiss to generate the PDX index - base_idx.core_index.read_index(index_path, index_path_l0) - data = read_hdf5_train_data(dataset_name) - preprocessor = ADSampling(DIMENSIONALITIES[dataset_name]) - preprocessor.preprocess(data, inplace=True) - print('Saving...') - # PDX - base_idx._to_pdx(data, _type='pdx', centroids_preprocessor=preprocessor, use_original_centroids=True) - base_idx._persist(os.path.join(PDX_ADSAMPLING_DATA, dataset_name + '-ivf2')) - - # Store metadata needed by ADSampling - preprocessor.store_metadata(os.path.join(NARY_ADSAMPLING_DATA, dataset_name + '-ivf2-matrix')) - -def generate_adsampling_ivf2_global8(dataset_name: str, normalize=True): - base_idx = BaseIndexPDXIVF2(DIMENSIONALITIES[dataset_name], 'l2sq') - # Core index IVF must exist - index_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name, norm=normalize)) - index_path_l0 = os.path.join(CORE_INDEXES_FAISS_L0, get_core_index_filename(dataset_name, norm=normalize)) - # Reads the core index created by faiss to generate the PDX index - print('Reading index') - base_idx.core_index.read_index(index_path, index_path_l0) - # base_idx.core_index.index = faiss.read_index(index_path) - # base_idx.core_index.index_l0 = faiss.read_index(index_path_l0) - print('Reading train data') - data = read_hdf5_train_data(dataset_name) - preprocessor = ADSampling(DIMENSIONALITIES[dataset_name]) - preprocessor.preprocess(data, inplace=True, normalize=True) - print('Saving') - # PDX FLAT BLOCKIFIED - base_idx._to_pdx(data, _type='pdx-v4-h', quantize=True, centroids_preprocessor=preprocessor, use_original_centroids=True) - base_idx._persist(os.path.join(PDX_ADSAMPLING_DATA, dataset_name + '-ivf2-u8')) - - preprocessor.store_metadata(os.path.join(NARY_ADSAMPLING_DATA, dataset_name + '-ivf2-u8-matrix')) - - -if __name__ == "__main__": - # generate_adsampling_ivf('coco-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf('simplewiki-openai-3072-normalized', normalize=True) - # generate_adsampling_ivf('imagenet-align-640-normalized', normalize=True) - # generate_adsampling_ivf('imagenet-clip-512-normalized', normalize=True) - # generate_adsampling_ivf('laion-clip-512-normalized', normalize=True) - # generate_adsampling_ivf('codesearchnet-jina-768-cosine', normalize=True) - # generate_adsampling_ivf('yi-128-ip', normalize=True) - # generate_adsampling_ivf('landmark-dino-768-cosine', normalize=True) - # generate_adsampling_ivf('landmark-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf('arxiv-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf('ccnews-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf('celeba-resnet-2048-cosine', normalize=True) - # generate_adsampling_ivf('llama-128-ip', normalize=True) - # generate_adsampling_ivf('yandex-200-cosine', normalize=True) - # generate_adsampling_ivf('word2vec-300', normalize=True) - # generate_adsampling_ivf('sift-128-euclidean', normalize=True) - # generate_adsampling_ivf('openai-1536-angular', normalize=True) - # generate_adsampling_ivf('msong-420', normalize=True) - # generate_adsampling_ivf('instructorxl-arxiv-768', normalize=True) - # generate_adsampling_ivf('contriever-768', normalize=True) - # generate_adsampling_ivf('gist-960-euclidean', normalize=True) - # generate_adsampling_ivf('yahoo-minilm-384-normalized', normalize=True) - # generate_adsampling_ivf('gooaq-distilroberta-768-normalized', normalize=True) - # generate_adsampling_ivf('agnews-mxbai-1024-euclidean', normalize=True) - # generate_adsampling_ivf('glove-200-angular', normalize=True) - - # generate_adsampling_ivf_global8('coco-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf_global8('simplewiki-openai-3072-normalized', normalize=True) - # generate_adsampling_ivf_global8('imagenet-align-640-normalized', normalize=True) - # generate_adsampling_ivf_global8('imagenet-clip-512-normalized', normalize=True) - # generate_adsampling_ivf_global8('laion-clip-512-normalized', normalize=True) - # generate_adsampling_ivf_global8('codesearchnet-jina-768-cosine', normalize=True) - # generate_adsampling_ivf_global8('yi-128-ip', normalize=True) - # generate_adsampling_ivf_global8('landmark-dino-768-cosine', normalize=True) - # generate_adsampling_ivf_global8('landmark-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf_global8('arxiv-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf_global8('ccnews-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf_global8('celeba-resnet-2048-cosine', normalize=True) - # generate_adsampling_ivf_global8('llama-128-ip', normalize=True) - # generate_adsampling_ivf_global8('yandex-200-cosine', normalize=True) - # generate_adsampling_ivf_global8('word2vec-300', normalize=True) - # generate_adsampling_ivf_global8('sift-128-euclidean', normalize=True) - # generate_adsampling_ivf_global8('openai-1536-angular', normalize=True) - # generate_adsampling_ivf_global8('msong-420', normalize=True) - # generate_adsampling_ivf_global8('instructorxl-arxiv-768', normalize=True) - # generate_adsampling_ivf_global8('contriever-768', normalize=True) - # generate_adsampling_ivf_global8('gist-960-euclidean', normalize=True) - # generate_adsampling_ivf_global8('yahoo-minilm-384-normalized', normalize=True) - # generate_adsampling_ivf_global8('gooaq-distilroberta-768-normalized', normalize=True) - # generate_adsampling_ivf_global8('agnews-mxbai-1024-euclidean', normalize=True) - # generate_adsampling_ivf_global8('glove-200-angular', normalize=True) - - # generate_adsampling_ivf2('coco-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf2('simplewiki-openai-3072-normalized', normalize=True) - # generate_adsampling_ivf2('imagenet-align-640-normalized', normalize=True) - # generate_adsampling_ivf2('imagenet-clip-512-normalized', normalize=True) - # generate_adsampling_ivf2('laion-clip-512-normalized', normalize=True) - # generate_adsampling_ivf2('codesearchnet-jina-768-cosine', normalize=True) - # generate_adsampling_ivf2('yi-128-ip', normalize=True) - # generate_adsampling_ivf2('landmark-dino-768-cosine', normalize=True) - # generate_adsampling_ivf2('landmark-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf2('arxiv-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf2('ccnews-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf2('celeba-resnet-2048-cosine', normalize=True) - # generate_adsampling_ivf2('llama-128-ip', normalize=True) - # generate_adsampling_ivf2('yandex-200-cosine', normalize=True) - # generate_adsampling_ivf2('word2vec-300', normalize=True) - # generate_adsampling_ivf2('sift-128-euclidean', normalize=True) - # generate_adsampling_ivf2('openai-1536-angular', normalize=True) - # generate_adsampling_ivf2('msong-420', normalize=True) - # generate_adsampling_ivf2('instructorxl-arxiv-768', normalize=True) - # generate_adsampling_ivf2('contriever-768', normalize=True) - # generate_adsampling_ivf2('gist-960-euclidean', normalize=True) - # generate_adsampling_ivf2('yahoo-minilm-384-normalized', normalize=True) - # generate_adsampling_ivf2('gooaq-distilroberta-768-normalized', normalize=True) - # generate_adsampling_ivf2('agnews-mxbai-1024-euclidean', normalize=True) - # generate_adsampling_ivf2('glove-200-angular', normalize=True) - # - # generate_adsampling_ivf2_global8('coco-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf2_global8('simplewiki-openai-3072-normalized', normalize=True) - # generate_adsampling_ivf2_global8('imagenet-align-640-normalized', normalize=True) - # generate_adsampling_ivf2_global8('imagenet-clip-512-normalized', normalize=True) - # generate_adsampling_ivf2_global8('laion-clip-512-normalized', normalize=True) - # generate_adsampling_ivf2_global8('codesearchnet-jina-768-cosine', normalize=True) - # generate_adsampling_ivf2_global8('yi-128-ip', normalize=True) - # generate_adsampling_ivf2_global8('landmark-dino-768-cosine', normalize=True) - # generate_adsampling_ivf2_global8('landmark-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf2_global8('arxiv-nomic-768-normalized', normalize=True) - generate_adsampling_ivf2_global8('ccnews-nomic-768-normalized', normalize=True) - # generate_adsampling_ivf2_global8('celeba-resnet-2048-cosine', normalize=True) - # generate_adsampling_ivf2_global8('llama-128-ip', normalize=True) - # generate_adsampling_ivf2_global8('yandex-200-cosine', normalize=True) - # generate_adsampling_ivf2_global8('word2vec-300', normalize=True) - # generate_adsampling_ivf2_global8('sift-128-euclidean', normalize=True) - # generate_adsampling_ivf2_global8('openai-1536-angular', normalize=True) - # generate_adsampling_ivf2_global8('msong-420', normalize=True) - # generate_adsampling_ivf2_global8('instructorxl-arxiv-768', normalize=True) - # generate_adsampling_ivf2_global8('contriever-768', normalize=True) - # generate_adsampling_ivf2_global8('gist-960-euclidean', normalize=True) - # generate_adsampling_ivf2_global8('yahoo-minilm-384-normalized', normalize=True) - # generate_adsampling_ivf2_global8('gooaq-distilroberta-768-normalized', normalize=True) - # generate_adsampling_ivf2_global8('agnews-mxbai-1024-euclidean', normalize=True) - # generate_adsampling_ivf2_global8('glove-200-angular', normalize=True) \ No newline at end of file diff --git a/benchmarks/python_scripts/setup_bond.py b/benchmarks/python_scripts/setup_bond.py deleted file mode 100644 index 534a2fb..0000000 --- a/benchmarks/python_scripts/setup_bond.py +++ /dev/null @@ -1,91 +0,0 @@ -import faiss -from pdxearch.index_base import BaseIndexPDXIVF, BaseIndexPDXFlat -from pdxearch.constants import PDXConstants -from setup_utils import * -from setup_settings import * -from sklearn import preprocessing - - -def generate_bond_ivf(dataset_name: str, normalize=True): - base_idx = BaseIndexPDXIVF(DIMENSIONALITIES[dataset_name], 'l2sq') - index_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name)) - # Reads the core index created by faiss to generate the PDX index - base_idx.core_index.read_index(index_path) - data = read_hdf5_train_data(dataset_name) - if normalize: - print('Normalizing') - data = preprocessing.normalize(data, axis=1, norm='l2') - print('Saving...') - # PDX - base_idx._to_pdx(data, _type='pdx', use_original_centroids=True, bond=True) - base_idx._persist(os.path.join(PDX_DATA, dataset_name + '-ivf')) - - - -def generate_bond_flat(dataset_name: str, normalize=True): - base_idx = BaseIndexPDXFlat(DIMENSIONALITIES[dataset_name], 'l2sq') - print('Reading train data') - data = read_hdf5_train_data(dataset_name) - if normalize: - print('Normalizing') - data = preprocessing.normalize(data, axis=1, norm='l2') - print('Saving') - # PDX FLAT - base_idx._to_pdx(data, size_partition=PDXConstants.PDXEARCH_VECTOR_SIZE, _type='pdx', bond=True) - base_idx._persist(os.path.join(PDX_DATA, dataset_name + '-flat')) - - - - -if __name__ == "__main__": - generate_bond_ivf('coco-nomic-768-normalized', normalize=True) - generate_bond_ivf('simplewiki-openai-3072-normalized', normalize=True) - generate_bond_ivf('imagenet-align-640-normalized', normalize=True) - generate_bond_ivf('imagenet-clip-512-normalized', normalize=True) - generate_bond_ivf('laion-clip-512-normalized', normalize=True) - generate_bond_ivf('codesearchnet-jina-768-cosine', normalize=True) - generate_bond_ivf('yi-128-ip', normalize=True) - generate_bond_ivf('landmark-dino-768-cosine', normalize=True) - generate_bond_ivf('landmark-nomic-768-normalized', normalize=True) - generate_bond_ivf('arxiv-nomic-768-normalized', normalize=True) - generate_bond_ivf('ccnews-nomic-768-normalized', normalize=True) - generate_bond_ivf('celeba-resnet-2048-cosine', normalize=True) - generate_bond_ivf('llama-128-ip', normalize=True) - generate_bond_ivf('yandex-200-cosine', normalize=True) - generate_bond_ivf('word2vec-300', normalize=True) - generate_bond_ivf('sift-128-euclidean', normalize=True) - generate_bond_ivf('openai-1536-angular', normalize=True) - generate_bond_ivf('msong-420', normalize=True) - generate_bond_ivf('instructorxl-arxiv-768', normalize=True) - generate_bond_ivf('contriever-768', normalize=True) - generate_bond_ivf('gist-960-euclidean', normalize=True) - generate_bond_ivf('yahoo-minilm-384-normalized', normalize=True) - generate_bond_ivf('gooaq-distilroberta-768-normalized', normalize=True) - generate_bond_ivf('agnews-mxbai-1024-euclidean', normalize=True) - generate_bond_ivf('glove-200-angular', normalize=True) - - generate_bond_flat('coco-nomic-768-normalized', normalize=True) - generate_bond_flat('simplewiki-openai-3072-normalized', normalize=True) - generate_bond_flat('imagenet-align-640-normalized', normalize=True) - generate_bond_flat('imagenet-clip-512-normalized', normalize=True) - generate_bond_flat('laion-clip-512-normalized', normalize=True) - generate_bond_flat('codesearchnet-jina-768-cosine', normalize=True) - generate_bond_flat('yi-128-ip', normalize=True) - generate_bond_flat('landmark-dino-768-cosine', normalize=True) - generate_bond_flat('landmark-nomic-768-normalized', normalize=True) - generate_bond_flat('arxiv-nomic-768-normalized', normalize=True) - generate_bond_flat('ccnews-nomic-768-normalized', normalize=True) - generate_bond_flat('celeba-resnet-2048-cosine', normalize=True) - generate_bond_flat('llama-128-ip', normalize=True) - generate_bond_flat('yandex-200-cosine', normalize=True) - generate_bond_flat('word2vec-300', normalize=True) - generate_bond_flat('sift-128-euclidean', normalize=True) - generate_bond_flat('openai-1536-angular', normalize=True) - generate_bond_flat('msong-420', normalize=True) - generate_bond_flat('instructorxl-arxiv-768', normalize=True) - generate_bond_flat('contriever-768', normalize=True) - generate_bond_flat('gist-960-euclidean', normalize=True) - generate_bond_flat('yahoo-minilm-384-normalized', normalize=True) - generate_bond_flat('gooaq-distilroberta-768-normalized', normalize=True) - generate_bond_flat('agnews-mxbai-1024-euclidean', normalize=True) - generate_bond_flat('glove-200-angular', normalize=True) diff --git a/benchmarks/python_scripts/setup_core_index.py b/benchmarks/python_scripts/setup_core_index.py deleted file mode 100644 index 2794865..0000000 --- a/benchmarks/python_scripts/setup_core_index.py +++ /dev/null @@ -1,131 +0,0 @@ -import math -from numpy.random import default_rng -from pdxearch.index_core import IVF, IVF2 -from setup_utils import * -from setup_settings import * - -from sklearn import preprocessing - -# Generates core IVF index with FAISS -def generate_core_ivf(dataset_name: str, normalize=True): - idx_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name, normalize)) - data = read_hdf5_train_data(dataset_name) - num_embeddings = len(data) - print('Num embeddings:', num_embeddings) - if dataset_name == "simplewiki-openai-3072-normalized": # Special case because it has too many dimensions! - nbuckets = 2048 - elif num_embeddings < 500_000: # If collection is too small we better use only 1 * SQRT(n) - nbuckets = math.ceil(2 * math.sqrt(num_embeddings)) - elif num_embeddings < 2_500_000: # Faiss recommends 4*sqrt(n), pg_vector 1*sqrt(n), we will take the middle ground - nbuckets = math.ceil(4 * math.sqrt(num_embeddings)) - else: # Deep with 10m - nbuckets = math.ceil(8 * math.sqrt(num_embeddings)) - print('N buckets:', nbuckets) - if normalize: - data = preprocessing.normalize(data, axis=1, norm='l2') - core_idx = IVF(DIMENSIONALITIES[dataset_name], 'l2sq', nbuckets) - training_points = nbuckets * 300 # Our collections do not need that many training points - if training_points < num_embeddings: - rng = default_rng() - training_sample_idxs = rng.choice(num_embeddings, size=training_points, replace=False) - training_sample_idxs.sort() - print('Training with', training_points) - core_idx.train(data[training_sample_idxs]) - else: - print('Training with all points') - core_idx.train(data) - print('Building') - core_idx.add(data) - print('Persisting') - core_idx.persist_core(idx_path) - - -def generate_core_ivf2(dataset_name: str, normalize=True): - idx_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name, normalize)) - idx_path_l0 = os.path.join(CORE_INDEXES_FAISS_L0, get_core_index_filename(dataset_name, normalize)) - data = read_hdf5_train_data(dataset_name) - num_embeddings = len(data) - print('Num embeddings:', num_embeddings) - if dataset_name == "simplewiki-openai-3072-normalized": # Special case because it has too many dimensions! - nbuckets = 2048 - elif num_embeddings < 500_000: # If collection is too small we better use only 1 * SQRT(n) - nbuckets = math.ceil(2 * math.sqrt(num_embeddings)) - elif num_embeddings < 2_500_000: # Faiss recommends 4*sqrt(n), pg_vector 1*sqrt(n), we will take the middle ground - nbuckets = math.ceil(4 * math.sqrt(num_embeddings)) - else: # Deep with 10m - nbuckets = math.ceil(8 * math.sqrt(num_embeddings)) - nbuckets_l0 = 64 # math.ceil(math.sqrt(nbuckets)) - print('N buckets L1:', nbuckets) - print('N buckets L0:', nbuckets_l0) - if normalize: - data = preprocessing.normalize(data, axis=1, norm='l2') - core_idx = IVF2(DIMENSIONALITIES[dataset_name], 'l2sq', nbuckets, nbuckets_l0) - training_points = nbuckets * 300 # Our collections do not need that many training points - if training_points < num_embeddings: - rng = default_rng() - training_sample_idxs = rng.choice(num_embeddings, size=training_points, replace=False) - training_sample_idxs.sort() - print('Training L1 with', training_points) - core_idx.train(data[training_sample_idxs]) - else: - print('Training L1 with all points') - core_idx.train(data) - print('Building L1') - core_idx.add(data) - print('Training and Building L0') - core_idx.train_add_l0() - print('Persisting L1 and L0') - core_idx.persist_core(idx_path, idx_path_l0) - -if __name__ == "__main__": - # generate_core_ivf('word2vec-300', normalize=True) - # generate_core_ivf('openai-1536-angular', normalize=True) - # generate_core_ivf('msong-420', normalize=True) - # generate_core_ivf('instructorxl-arxiv-768', normalize=True) - # generate_core_ivf('contriever-768', normalize=True) - # generate_core_ivf('gist-960-euclidean', normalize=True) - # generate_core_ivf('gooaq-distilroberta-768-normalized', normalize=True) - # generate_core_ivf('agnews-mxbai-1024-euclidean', normalize=True) - # generate_core_ivf('coco-nomic-768-normalized', normalize=True) - # generate_core_ivf('simplewiki-openai-3072-normalized', normalize=True) - # generate_core_ivf('imagenet-align-640-normalized', normalize=True) - # generate_core_ivf('yandex-200-cosine', normalize=True) - # generate_core_ivf('imagenet-clip-512-normalized', normalize=True) - # generate_core_ivf('laion-clip-512-normalized', normalize=True) - # generate_core_ivf('codesearchnet-jina-768-cosine', normalize=True) - # generate_core_ivf('yi-128-ip', normalize=True) - # generate_core_ivf('landmark-dino-768-cosine', normalize=True) - # generate_core_ivf('landmark-nomic-768-normalized', normalize=True) - # generate_core_ivf('arxiv-nomic-768-normalized', normalize=True) - # generate_core_ivf('ccnews-nomic-768-normalized', normalize=True) - # generate_core_ivf('celeba-resnet-2048-cosine', normalize=True) - # generate_core_ivf('llama-128-ip', normalize=True) - # generate_core_ivf('sift-128-euclidean', normalize=True) - # generate_core_ivf('yahoo-minilm-384-normalized', normalize=True) - # generate_core_ivf('glove-200-angular', normalize=True) - - # generate_core_ivf2('coco-nomic-768-normalized', normalize=True) - # generate_core_ivf2('simplewiki-openai-3072-normalized', normalize=True) - # generate_core_ivf2('imagenet-align-640-normalized', normalize=True) - # generate_core_ivf2('imagenet-clip-512-normalized', normalize=True) - # generate_core_ivf2('laion-clip-512-normalized', normalize=True) - # generate_core_ivf2('codesearchnet-jina-768-cosine', normalize=True) - # generate_core_ivf2('yi-128-ip', normalize=True) - # generate_core_ivf2('landmark-dino-768-cosine', normalize=True) - # generate_core_ivf2('landmark-nomic-768-normalized', normalize=True) - # generate_core_ivf2('arxiv-nomic-768-normalized', normalize=True) - generate_core_ivf2('ccnews-nomic-768-normalized', normalize=True) - # generate_core_ivf2('celeba-resnet-2048-cosine', normalize=True) - # generate_core_ivf2('llama-128-ip', normalize=True) - # generate_core_ivf2('yandex-200-cosine', normalize=True) - # generate_core_ivf2('word2vec-300', normalize=True) - # generate_core_ivf2('sift-128-euclidean', normalize=True) - # generate_core_ivf2('openai-1536-angular', normalize=True) - # generate_core_ivf2('msong-420', normalize=True) - # generate_core_ivf2('instructorxl-arxiv-768', normalize=True) - # generate_core_ivf2('contriever-768', normalize=True) - # generate_core_ivf2('gist-960-euclidean', normalize=True) - # generate_core_ivf2('yahoo-minilm-384-normalized', normalize=True) - # generate_core_ivf2('gooaq-distilroberta-768-normalized', normalize=True) - # generate_core_ivf2('agnews-mxbai-1024-euclidean', normalize=True) - # generate_core_ivf2('glove-200-angular', normalize=True) diff --git a/benchmarks/python_scripts/setup_data.py b/benchmarks/python_scripts/setup_data.py index c1aeb56..12aa5e6 100644 --- a/benchmarks/python_scripts/setup_data.py +++ b/benchmarks/python_scripts/setup_data.py @@ -1,28 +1,20 @@ import os import zipfile -from setup_settings import RAW_DATA, DATA_DIRECTORY, DATASETS -from setup_ground_truth import generate_ground_truth -from setup_adsampling import generate_adsampling_ivf, generate_adsampling_ivf_global8, generate_adsampling_ivf2_global8, generate_adsampling_ivf2 -from setup_bond import generate_bond_ivf, generate_bond_flat -from setup_core_index import generate_core_ivf, generate_core_ivf2 -from setup_test_data import generate_test_data +from setup_utils import RAW_DATA, DATA_DIRECTORY, DATASET_INFO +from setup_pdx import generate_ground_truth, generate_index, generate_test_data +from setup_faiss import generate_faiss_index, generate_faiss_sq8_index DOWNLOAD = False # Download raw HDF5 data GENERATE_GT = False # Creates ground truth with sklearn -GENERATE_IVF = True # Creates IVF indexes with FAISS KNN = [100] -ALGORITHMS = [ # Choose the pruning algorithms for which indexes are going to be created - 'adsampling', - # 'bond' -] +SEED = 42 DATASETS_TO_USE = [ - 'openai-1536-angular', - 'agnews-mxbai-1024-euclidean', - 'instructorxl-arxiv-768', - 'simplewiki-openai-3072-normalized', - 'msong-420', - 'llama-128-ip', + 'mxbai', + 'arxiv', + 'openai', + # 'cohere' ] + if __name__ == "__main__": if DOWNLOAD: import gdown @@ -36,9 +28,11 @@ zip_ref.extractall(RAW_DATA) # If you don't define some datasets we will try to use all of them - if not len(DATASETS_TO_USE): DATASETS_TO_USE = DATASETS + if not len(DATASETS_TO_USE): + DATASETS_TO_USE = list(DATASET_INFO.keys()) + for dataset in DATASETS_TO_USE: - print('\n================ PROCESSING:', dataset, '================') + print(f'\n================ PROCESSING: {dataset} ================') if GENERATE_GT: print('==== Generating ground truth...') generate_ground_truth(dataset, KNN) @@ -46,23 +40,12 @@ print('==== Saving queries in a binary format...') generate_test_data(dataset) - if GENERATE_IVF: - print('==== Creating Core ivf2 index with FAISS (this might take a while)...') - generate_core_ivf2(dataset) - - if 'adsampling' in ALGORITHMS: - print('==== Generating ADSampling...') - generate_adsampling_ivf(dataset) - generate_adsampling_ivf2(dataset) - - print('==== Generating ADSampling SQ8...') - generate_adsampling_ivf_global8(dataset) - generate_adsampling_ivf2_global8(dataset) - - # Generate BOND Data - if 'bond' in ALGORITHMS: - print('==== Generating BOND IVF [PDX]') - generate_bond_ivf(dataset) - print('==== Generating BOND Flat (for exact-search)') - generate_bond_flat(dataset) + print('==== Generating PDX indexes...') + generate_index(dataset, 'pdx_f32', normalize=True, seed=SEED) + generate_index(dataset, 'pdx_u8', normalize=True, seed=SEED) + generate_index(dataset, 'pdx_tree_f32', normalize=True, seed=SEED) + generate_index(dataset, 'pdx_tree_u8', normalize=True, seed=SEED) + print('==== Generating FAISS indexes...') + generate_faiss_index(dataset, normalize=True) + generate_faiss_sq8_index(dataset, normalize=True) \ No newline at end of file diff --git a/benchmarks/python_scripts/setup_faiss.py b/benchmarks/python_scripts/setup_faiss.py new file mode 100644 index 0000000..dda98c7 --- /dev/null +++ b/benchmarks/python_scripts/setup_faiss.py @@ -0,0 +1,68 @@ +import math +import numpy as np +import faiss + +from sklearn import preprocessing +from setup_utils import * +from benchmark_utils import TicToc + + +def _compute_nbuckets(num_embeddings): + if num_embeddings < 500_000: + return math.ceil(2 * math.sqrt(num_embeddings)) + elif num_embeddings < 2_500_000: + return math.ceil(4 * math.sqrt(num_embeddings)) + else: + return math.ceil(8 * math.sqrt(num_embeddings)) + + +def _load_and_prepare(dataset_abbrev, normalize): + hdf5_name, dims = DATASET_INFO[dataset_abbrev] + dims = int(dims) + data = read_hdf5_train_data(hdf5_name) + if normalize: + data = preprocessing.normalize(data, axis=1, norm='l2') + data = np.ascontiguousarray(data, dtype=np.float32) + return hdf5_name, dims, data + + +def generate_faiss_index(dataset_abbrev: str, normalize=True): + hdf5_name, dims, data = _load_and_prepare(dataset_abbrev, normalize) + idx_path = os.path.join(FAISS_DATA, get_core_index_filename(hdf5_name, normalize)) + num_embeddings = len(data) + nbuckets = _compute_nbuckets(num_embeddings) + print(f'IVFFlat: {num_embeddings} embeddings, {dims}D, {nbuckets} buckets') + + quantizer = faiss.IndexFlatL2(dims) + index = faiss.IndexIVFFlat(quantizer, dims, nbuckets) + clock = TicToc() + print('Training') + clock.tic() + index.train(data) + print('Adding') + index.add(data) + print(f'Train + Add: {clock.toc():.2f}ms') + print('Persisting') + faiss.write_index(index, idx_path) + + +def generate_faiss_sq8_index(dataset_abbrev: str, normalize=True): + hdf5_name, dims, data = _load_and_prepare(dataset_abbrev, normalize) + idx_path = os.path.join(FAISS_DATA, get_core_index_filename(hdf5_name, sq8=True)) + num_embeddings = len(data) + nbuckets = _compute_nbuckets(num_embeddings) + print(f'IVFSQ8: {num_embeddings} embeddings, {dims}D, {nbuckets} buckets') + + quantizer = faiss.IndexFlatL2(dims) + index = faiss.IndexIVFScalarQuantizer( + quantizer, dims, nbuckets, faiss.ScalarQuantizer.QT_8bit + ) + clock = TicToc() + print('Training') + clock.tic() + index.train(data) + print('Adding') + index.add(data) + print(f'Train + Add: {clock.toc():.2f}ms') + print('Persisting') + faiss.write_index(index, idx_path) diff --git a/benchmarks/python_scripts/setup_filtered_search.py b/benchmarks/python_scripts/setup_filtered_search.py index c99bf1a..a6cb8d2 100644 --- a/benchmarks/python_scripts/setup_filtered_search.py +++ b/benchmarks/python_scripts/setup_filtered_search.py @@ -1,146 +1,87 @@ import numpy as np -np.random.seed(42) - import json -import random +import os +from decimal import Decimal from setup_utils import * -from setup_settings import * -from pdxearch.index_base import BaseIndexPDXIVF, BaseIndexPDXIVF2 from sklearn import preprocessing -from WrapperBruteForce import BruteForceSKLearn +from WrapperBruteForce import BruteForceFAISS -from decimal import Decimal +np.random.seed(42) SELECTIVITIES = [ - 0.0001, - 0.000135, - 0.00015, 0.001, 0.01, + 0.0001, 0.000135, 0.00015, 0.001, 0.01, 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 0.9, 0.95, 0.99, ] -SPECIAL_SELECTIVITIES = [ - "PART 30", - "PART 1", - "PART+ 1", -] - -def generate_ground_truth(dataset, train, test, filtered_ids, selectivity_str, KNNS=(100,), normalize=True): - print('Generating ground truth for', len(train), 'points', 'at', selectivity_str, 'selectivity') - N_QUERIES = len(test) - algo = BruteForceSKLearn("euclidean", njobs=-1) - algo.fit(train) - for knn in KNNS: - gt_filename = f"{dataset}_{knn}_norm_{selectivity_str}.json" - gt_name = os.path.join(SEMANTIC_FILTERED_GROUND_TRUTH_PATH, gt_filename) - gt = {} - index_data = [] - distance_data = [] - print('Querying for GT...') - dist, index = algo.query_batch(test, n=knn) - for i in range(N_QUERIES): - index_data.append(filtered_ids[index[i]]) - distance_data.append(dist[i] ** 2) - gt[i] = filtered_ids[index[i]].tolist() - with open(os.path.join(FILTERED_GROUND_TRUTH_DATA, gt_filename.replace('.json', '')), "wb") as file: - file.write(np.array(index_data, dtype=np.uint32).tobytes("C")) - file.write(np.array(distance_data, dtype=np.float32).tobytes("C")) - with open(gt_name, 'w') as f: - json.dump(gt, f) - -def generate_selection_vector(dataset_name: str, _type='pdx', normalize=True): - print(dataset_name) - - train, test = read_hdf5_data(dataset_name) +KNN = 100 + +def generate_filtered_ground_truth(hdf5_name, train, test, passing_row_ids, selectivity_str): + """Brute-force kNN on filtered subset; write binary GT + JSON GT.""" + filtered_train = train[passing_row_ids] + actual_knn = min(KNN, len(filtered_train)) + if actual_knn == 0: + print(f' WARNING: 0 passing points for {selectivity_str}, skipping GT') + return + + print(f' GT: {len(filtered_train)} filtered points, {len(test)} queries') + algo = BruteForceFAISS("euclidean") + algo.fit(filtered_train) + + dist, index = algo.query_batch(test, n=actual_knn) + + index_data = [] + distance_data = [] + gt = {} + for i in range(len(test)): + original_ids = passing_row_ids[index[i]] + # Pad to KNN for consistent binary format + padded_ids = np.full(KNN, 0xFFFFFFFF, dtype=np.uint32) + padded_ids[:actual_knn] = original_ids + padded_dists = np.full(KNN, np.finfo(np.float32).max, dtype=np.float32) + padded_dists[:actual_knn] = dist[i] + index_data.append(padded_ids) + distance_data.append(padded_dists) + gt[i] = original_ids.tolist() + + gt_filename = f"{hdf5_name}_{KNN}_norm_{selectivity_str}" + with open(os.path.join(FILTERED_GROUND_TRUTH_DATA, gt_filename), "wb") as f: + f.write(np.array(index_data, dtype=np.uint32).tobytes("C")) + f.write(np.array(distance_data, dtype=np.float32).tobytes("C")) + with open(os.path.join(SEMANTIC_FILTERED_GROUND_TRUTH_PATH, gt_filename + ".json"), 'w') as f: + json.dump(gt, f) + + +def generate_filtered_data(dataset_abbrev, normalize=True): + """For each selectivity, generate passing row IDs + filtered ground truth.""" + hdf5_name, dims = DATASET_INFO[dataset_abbrev] + print(f'\n{dataset_abbrev} -> {hdf5_name}') + + train, test = read_hdf5_data(hdf5_name) if normalize: train = preprocessing.normalize(train, axis=1, norm='l2') test = preprocessing.normalize(test, axis=1, norm='l2') - base_idx = BaseIndexPDXIVF(DIMENSIONALITIES[dataset_name], 'l2sq') - # Core index IVF must exist - index_path = os.path.join(CORE_INDEXES_FAISS, get_core_index_filename(dataset_name, norm=normalize)) - # Reads the core index created by faiss to generate the PDX index - base_idx.core_index.read_index(index_path) - - n_clusters = base_idx.core_index.nbuckets - labels = base_idx.core_index.labels - all_ordered_labels = np.concatenate([np.array(sub) for sub in labels]) - assert(n_clusters == len(labels)) - - # The GT is not correctly calculated - - total_points = sum(len(lst) for lst in labels) - print('Total points', total_points) + total_points = len(train) + print(f'Total points: {total_points}') for selectivity in SELECTIVITIES: - selection_per_cluster = [] - selection_vector = (np.random.rand(total_points) < selectivity).astype(np.uint8) - selected = np.sum(selection_vector) - print(f'For selectivity {selectivity}, {selected} points were chosen ({float(selected) / total_points})') - real_selection_vectors = np.array([]).astype(np.uint8) - for l in labels: - l_np = np.array(l) - real_selection_vector = selection_vector[l_np] - selected_on_this_cluster = np.sum(real_selection_vector) - selection_per_cluster.append(selected_on_this_cluster) - real_selection_vectors = np.concatenate((real_selection_vectors, real_selection_vector)) - selection_per_cluster = np.array(selection_per_cluster).astype(np.uint32) - assert(len(selection_per_cluster) == len(labels)) - assert(np.sum(selection_per_cluster) == selected) - assert(total_points == len(real_selection_vectors)) - assert(np.sum(selection_per_cluster) == np.sum(real_selection_vectors)) + mask = np.random.rand(total_points) < selectivity + passing_row_ids = np.where(mask)[0].astype(np.uint32) + num_passing = len(passing_row_ids) + print(f' Selectivity {selectivity}: {num_passing} points ({num_passing / total_points:.6f})') selectivity_str = format(Decimal(str(selectivity)), 'f').replace('.', '_') - selectivity_filename = f'{dataset_name}_{selectivity_str}.bin' - selectivity_filename_path = os.path.join(FILTER_SELECTION_VECTORS, selectivity_filename) - data = bytearray() - data.extend(selection_per_cluster.tobytes("C")) - data.extend(real_selection_vectors.tobytes("C")) - with open(selectivity_filename_path, "wb") as file: - file.write(bytes(data)) - filtered_ids = all_ordered_labels[real_selection_vectors.astype(bool)] - filtered_train = train[all_ordered_labels[real_selection_vectors.astype(bool)]] - generate_ground_truth(dataset_name, filtered_train, test, filtered_ids, selectivity_str, KNNS=(100,), normalize=normalize) - - - for mode in SPECIAL_SELECTIVITIES: - _type, param = mode.split(' ') - n = int(param) - selection_per_cluster = [] - selected = 0 - real_selection_vectors = np.array([]).astype(np.uint8) - for l in labels: - n_points = len(l) - if n <= 0: - real_selection_vector = np.full((n_points), 0, dtype=np.uint8) - if '+' in _type: - real_selection_vector[random.randint(0, n_points - 1)] = 1 - else: - real_selection_vector = np.full((n_points), 1, dtype=np.uint8) - n -= 1 - selected_on_this_cluster = np.sum(real_selection_vector) - selection_per_cluster.append(selected_on_this_cluster) - real_selection_vectors = np.concatenate((real_selection_vectors, real_selection_vector)) - selected += selected_on_this_cluster - selection_per_cluster = np.array(selection_per_cluster).astype(np.uint32) - print(f'For selectivity {_type} {param}, {selected} points were chosen ({float(selected) / total_points * 100})') - assert(len(selection_per_cluster) == len(labels)) - assert(sum(selection_per_cluster) == selected) - assert(total_points == len(real_selection_vectors)) - - selectivity_str = f'{_type}_{param}' - selectivity_filename = f'{dataset_name}_{selectivity_str}.bin' - selectivity_filename_path = os.path.join(FILTER_SELECTION_VECTORS, selectivity_filename) - data = bytearray() - data.extend(selection_per_cluster.tobytes("C")) - data.extend(real_selection_vectors.tobytes("C")) - with open(selectivity_filename_path, "wb") as file: - file.write(bytes(data)) - filtered_ids = all_ordered_labels[real_selection_vectors.astype(bool)] - filtered_train = train[all_ordered_labels[real_selection_vectors.astype(bool)]] - generate_ground_truth(dataset_name, filtered_train, test, filtered_ids, selectivity_str, KNNS=(100,), normalize=normalize) + + # Write passing row IDs as binary: [uint32 count][uint32[] ids] + filename = f'{hdf5_name}_{selectivity_str}.bin' + filepath = os.path.join(FILTER_SELECTION_VECTORS, filename) + with open(filepath, "wb") as f: + f.write(np.uint32(num_passing).tobytes()) + f.write(passing_row_ids.tobytes("C")) + + generate_filtered_ground_truth(hdf5_name, train, test, passing_row_ids, selectivity_str) if __name__ == "__main__": - # generate_selection_vector('agnews-mxbai-1024-euclidean', normalize=True) - generate_selection_vector('instructorxl-arxiv-768', normalize=True) + generate_filtered_data('mxbai', normalize=True) diff --git a/benchmarks/python_scripts/setup_fvecs_to_hdf5.py b/benchmarks/python_scripts/setup_fvecs_to_hdf5.py deleted file mode 100644 index 435f803..0000000 --- a/benchmarks/python_scripts/setup_fvecs_to_hdf5.py +++ /dev/null @@ -1,19 +0,0 @@ -from setup_utils import * - -def generate_hdf5_file(path_data: str, path_query, dataset_name): - data = read_fvecs(path_data) - queries = read_fvecs(path_query) - print('Queries', len(queries)) - print('Vectors', len(data)) - print('D=', len(data[0])) - with h5py.File(dataset_name, 'w') as f: - f.create_dataset("train", data=data) - f.create_dataset("test", data=queries) - - -if __name__ == "__main__": - generate_hdf5_file( - './benchmarks/datasets/downloaded/word2vec_base.fvecs', - './benchmarks/datasets/downloaded/word2vec_query.fvecs', - './benchmarks/datasets/downloaded/word2vec-300.hdf5' - ) \ No newline at end of file diff --git a/benchmarks/python_scripts/setup_ground_truth.py b/benchmarks/python_scripts/setup_ground_truth.py deleted file mode 100644 index 72eecf7..0000000 --- a/benchmarks/python_scripts/setup_ground_truth.py +++ /dev/null @@ -1,73 +0,0 @@ -import json -from WrapperBruteForce import BruteForceSKLearn -from setup_utils import * -from setup_settings import * -from sklearn import preprocessing - -# Generates ground truth with SKLearn -def generate_ground_truth(dataset, KNNS=(10, 100), normalize=False): - if not os.path.exists(GROUND_TRUTH_DATA): - os.makedirs(GROUND_TRUTH_DATA) - train, test = read_hdf5_data(dataset) - N_QUERIES = len(test) - # test = test[:N_QUERIES] - print('N. Queries', N_QUERIES) - - if normalize: - train = preprocessing.normalize(train, axis=1, norm='l2') - test = preprocessing.normalize(test, axis=1, norm='l2') - - algo = BruteForceSKLearn("euclidean", njobs=-1) - algo.fit(train) - for knn in KNNS: - gt_filename = get_ground_truth_filename(dataset, knn, normalize) - gt_name = os.path.join(SEMANTIC_GROUND_TRUTH_PATH, gt_filename) - gt = {} - index_data = [] - distance_data = [] - print('Querying for GT...') - dist, index = algo.query_batch(test, n=knn) - for i in range(N_QUERIES): - index_data.append(index[i]) - distance_data.append(dist[i] ** 2) - gt[i] = index[i].tolist() - with open(os.path.join(GROUND_TRUTH_DATA, gt_filename.replace('.json', '')), "wb") as file: - file.write(np.array(index_data, dtype=np.uint32).tobytes("C")) - file.write(np.array(distance_data, dtype=np.float32).tobytes("C")) - with open(gt_name, 'w') as f: - json.dump(gt, f) - - -if __name__ == "__main__": - ks = [100] - # generate_ground_truth('word2vec-300', ks, normalize=True) - - # generate_ground_truth('gooaq-distilroberta-768-normalized', ks, normalize=True) - # generate_ground_truth('agnews-mxbai-1024-euclidean', ks, normalize=True) - # generate_ground_truth('coco-nomic-768-normalized', ks, normalize=True) - # generate_ground_truth('simplewiki-openai-3072-normalized', ks, normalize=True) - - # generate_ground_truth('imagenet-align-640-normalized', ks, normalize=True) - # generate_ground_truth('yandex-200-cosine', ks, normalize=True) - # generate_ground_truth('imagenet-clip-512-normalized', ks, normalize=True) - # generate_ground_truth('laion-clip-512-normalized', ks, normalize=True) - # generate_ground_truth('codesearchnet-jina-768-cosine', ks, normalize=True) - # generate_ground_truth('yi-128-ip', ks, normalize=True) - # generate_ground_truth('landmark-dino-768-cosine', ks, normalize=True) - # generate_ground_truth('landmark-nomic-768-normalized', ks, normalize=True) - # generate_ground_truth('arxiv-nomic-768-normalized', ks, normalize=True) - # generate_ground_truth('ccnews-nomic-768-normalized', ks, normalize=True) - # generate_ground_truth('celeba-resnet-2048-cosine', ks, normalize=True) - # generate_ground_truth('llama-128-ip', ks, normalize=True) - generate_ground_truth('yahoo-minilm-384-normalized', ks, normalize=True) - - # generate_ground_truth('openai-1536-angular', ks) - # generate_ground_truth('msong-420', ks) - # generate_ground_truth('instructorxl-arxiv-768', ks) - # generate_ground_truth('sift-128-euclidean', ks) - # generate_ground_truth('gist-960-euclidean', ks) - # - # generate_ground_truth('glove-200-angular', ks) - # - # generate_ground_truth('contriever-768', ks) - diff --git a/benchmarks/python_scripts/setup_pdx.py b/benchmarks/python_scripts/setup_pdx.py new file mode 100644 index 0000000..c36fb84 --- /dev/null +++ b/benchmarks/python_scripts/setup_pdx.py @@ -0,0 +1,87 @@ +import json +import sys +from setup_utils import * +from benchmark_utils import TicToc +from pdxearch import IndexPDXIVF, IndexPDXIVFSQ8, IndexPDXIVFTree, IndexPDXIVFTreeSQ8 +from WrapperBruteForce import BruteForceFAISS +from sklearn import preprocessing + +INDEX_CLASSES = { + "pdx_f32": IndexPDXIVF, + "pdx_u8": IndexPDXIVFSQ8, + "pdx_tree_f32": IndexPDXIVFTree, + "pdx_tree_u8": IndexPDXIVFTreeSQ8, +} + + +def generate_ground_truth(dataset_abbrev, KNNS=(100,), normalize=True): + """Generate ground truth with FAISS brute-force search.""" + hdf5_name, dims = DATASET_INFO[dataset_abbrev] + print(f'Generating ground truth: {dataset_abbrev} -> {hdf5_name}') + + train, test = read_hdf5_data(hdf5_name) + N_QUERIES = len(test) + print('N. Queries', N_QUERIES) + + if normalize: + train = preprocessing.normalize(train, axis=1, norm='l2') + test = preprocessing.normalize(test, axis=1, norm='l2') + + algo = BruteForceFAISS("euclidean") + algo.fit(train) + for knn in KNNS: + gt_filename = get_ground_truth_filename(hdf5_name, knn, normalize) + gt_name = os.path.join(SEMANTIC_GROUND_TRUTH_PATH, gt_filename) + gt = {} + index_data = [] + distance_data = [] + print('Querying for GT...') + dist, index = algo.query_batch(test, n=knn) + for i in range(N_QUERIES): + index_data.append(index[i]) + distance_data.append(dist[i]) + gt[i] = index[i].tolist() + with open(os.path.join(GROUND_TRUTH_DATA, gt_filename.replace('.json', '')), "wb") as file: + file.write(np.array(index_data, dtype=np.uint32).tobytes("C")) + file.write(np.array(distance_data, dtype=np.float32).tobytes("C")) + with open(gt_name, 'w') as f: + json.dump(gt, f) + + +def generate_test_data(dataset_abbrev): + """Save queries from HDF5 to binary format for C++ benchmarks.""" + hdf5_name, dims = DATASET_INFO[dataset_abbrev] + print(f'Saving test data: {dataset_abbrev} -> {hdf5_name}') + + test = read_hdf5_test_data(hdf5_name) + N_QUERIES = len(test) + with open(os.path.join(QUERIES_DATA, hdf5_name), "wb") as file: + file.write(N_QUERIES.to_bytes(4, sys.byteorder, signed=False)) + file.write(test.tobytes("C")) + + +def generate_index(dataset_abbrev: str, index_type: str, normalize=True, seed=42): + """Build and save a PDX index.""" + hdf5_name, dims = DATASET_INFO[dataset_abbrev] + print(f'{dataset_abbrev} -> {hdf5_name} ({index_type})') + data = read_hdf5_train_data(hdf5_name) + + cls = INDEX_CLASSES[index_type] + index = cls(num_dimensions=dims, normalize=normalize, seed=seed) + clock = TicToc() + print('Building index...') + clock.tic() + index.build(data) + print(f'Index built: {index.num_clusters} clusters ({clock.toc():.2f}ms)') + + save_path = os.path.join(PDX_DATA, dataset_abbrev + '-' + index_type) + print(f'Saving to {save_path}') + index.save(save_path) + print('Done.') + + +if __name__ == "__main__": + # generate_ground_truth('mxbai', normalize=True) + # generate_test_data('mxbai') + # generate_index('mxbai', 'pdx_f32', normalize=True) + pass diff --git a/benchmarks/python_scripts/setup_settings.py b/benchmarks/python_scripts/setup_settings.py deleted file mode 100644 index 3305ea3..0000000 --- a/benchmarks/python_scripts/setup_settings.py +++ /dev/null @@ -1,108 +0,0 @@ -import os -SOURCE_DIR = os.getcwd() - -""" -Creates the directories needed to run benchmarks -""" - -# `benchmarks` directory should already exist -DATA_DIRECTORY = os.path.join("benchmarks", "datasets") -if not os.path.exists(os.path.join(SOURCE_DIR, DATA_DIRECTORY)): - os.makedirs(os.path.join(SOURCE_DIR, DATA_DIRECTORY)) - -RAW_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "downloaded") -GROUND_TRUTH_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "ground_truth") -FILTERED_GROUND_TRUTH_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "ground_truth_filtered") -SEMANTIC_GROUND_TRUTH_PATH = os.path.join(SOURCE_DIR, "benchmarks", "gt") -SEMANTIC_FILTERED_GROUND_TRUTH_PATH = os.path.join(SOURCE_DIR, "benchmarks", "gt_filtered") - -CORE_INDEXES = os.path.join(SOURCE_DIR, "benchmarks", "core_indexes") -CORE_INDEXES_FAISS = os.path.join(CORE_INDEXES, "faiss") -CORE_INDEXES_FAISS_U8 = os.path.join(CORE_INDEXES, "faiss_sq8") -CORE_INDEXES_FAISS_L0 = os.path.join(CORE_INDEXES, "faiss_l0") -CORE_INDEXES_LORANN = os.path.join(CORE_INDEXES, "lorann") - -PURESCAN_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "purescan") - -QUERIES_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "queries") - -NARY_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "nary") -NARY_ADSAMPLING_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "adsampling_nary") - -PDX_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "pdx") -PDX_ADSAMPLING_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "adsampling_pdx") - -FILTER_SELECTION_VECTORS = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "selection_vectors") - -directories = [ - RAW_DATA, GROUND_TRUTH_DATA, PURESCAN_DATA, - NARY_DATA, NARY_ADSAMPLING_DATA, - PDX_DATA, PDX_ADSAMPLING_DATA, - CORE_INDEXES, CORE_INDEXES_FAISS, CORE_INDEXES_FAISS_U8, - CORE_INDEXES_FAISS_L0, CORE_INDEXES_LORANN, QUERIES_DATA, - SEMANTIC_GROUND_TRUTH_PATH, FILTER_SELECTION_VECTORS, - FILTERED_GROUND_TRUTH_DATA, SEMANTIC_FILTERED_GROUND_TRUTH_PATH -] - -for needed_directory in directories: - if not os.path.exists(needed_directory): - os.makedirs(needed_directory) - -# Datasets to set up and use -DATASETS = [ - "sift-128-euclidean", - "yi-128-ip", - "llama-128-ip", - "glove-200-angular", - "yandex-200-cosine", - "word2vec-300", - "yahoo-minilm-384-normalized", - "msong-420", - "imagenet-clip-512-normalized", - "laion-clip-512-normalized", - "imagenet-align-640-normalized", - "codesearchnet-jina-768-cosine", - "landmark-dino-768-cosine", - "landmark-nomic-768-normalized", - "arxiv-nomic-768-normalized", - "ccnews-nomic-768-normalized", - "coco-nomic-768-normalized", - "contriever-768", - "instructorxl-arxiv-768", - "gooaq-distilroberta-768-normalized", - "gist-960-euclidean", - "agnews-mxbai-1024-euclidean", - "openai-1536-angular", - "celeba-resnet-2048-cosine", - "simplewiki-openai-3072-normalized" -] - -DIMENSIONALITIES = { - 'glove-200-angular': 200, - 'sift-128-euclidean': 128, - 'trevi-4096': 4096, - 'msong-420': 420, - 'contriever-768': 768, - 'stl-9216': 9216, - 'gist-960-euclidean': 960, - 'instructorxl-arxiv-768': 768, - 'openai-1536-angular': 1536, - 'word2vec-300': 300, - 'gooaq-distilroberta-768-normalized': 768, - 'agnews-mxbai-1024-euclidean': 1024, - 'coco-nomic-768-normalized': 768, - 'simplewiki-openai-3072-normalized': 3072, - 'imagenet-align-640-normalized': 640, - 'yandex-200-cosine': 200, - 'imagenet-clip-512-normalized': 512, - 'laion-clip-512-normalized': 512, - 'codesearchnet-jina-768-cosine': 768, - 'yi-128-ip': 128, - 'landmark-dino-768-cosine': 768, - 'landmark-nomic-768-normalized': 768, - 'arxiv-nomic-768-normalized': 768, - 'ccnews-nomic-768-normalized': 768, - 'celeba-resnet-2048-cosine': 2048, - 'llama-128-ip': 128, - 'yahoo-minilm-384-normalized': 384 -} diff --git a/benchmarks/python_scripts/setup_test_data.py b/benchmarks/python_scripts/setup_test_data.py deleted file mode 100644 index 01fb3a0..0000000 --- a/benchmarks/python_scripts/setup_test_data.py +++ /dev/null @@ -1,43 +0,0 @@ -import sys -from setup_utils import * -from setup_settings import * - - -# Transforms the queries from HDF5 format to .fvecs -def generate_test_data(dataset): - if not os.path.exists(GROUND_TRUTH_DATA): - os.makedirs(GROUND_TRUTH_DATA) - test = read_hdf5_test_data(dataset) - N_QUERIES = len(test) - with open(os.path.join(QUERIES_DATA, dataset), "wb") as file: - file.write(N_QUERIES.to_bytes(4, sys.byteorder, signed=False)) - file.write(test.tobytes("C")) - - -if __name__ == "__main__": - # generate_test_data('word2vec-300') - # generate_test_data('msong-420') - # generate_test_data('instructorxl-arxiv-768') - # generate_test_data('gist-960-euclidean') - # generate_test_data('contriever-768') - # generate_test_data('gooaq-distilroberta-768-normalized') - # generate_test_data('agnews-mxbai-1024-euclidean') - # generate_test_data('coco-nomic-768-normalized') - # generate_test_data('simplewiki-openai-3072-normalized') - - generate_test_data('imagenet-align-640-normalized') - - generate_test_data('yandex-200-cosine') - generate_test_data('imagenet-clip-512-normalized') - generate_test_data('laion-clip-512-normalized') - generate_test_data('codesearchnet-jina-768-cosine') - - generate_test_data('yi-128-ip') - generate_test_data('landmark-dino-768-cosine') - generate_test_data('landmark-nomic-768-normalized') - generate_test_data('arxiv-nomic-768-normalized') - - generate_test_data('ccnews-nomic-768-normalized') - generate_test_data('celeba-resnet-2048-cosine') - generate_test_data('llama-128-ip') - generate_test_data('yahoo-minilm-384-normalized') diff --git a/benchmarks/python_scripts/setup_utils.py b/benchmarks/python_scripts/setup_utils.py index d273b08..ee8390e 100644 --- a/benchmarks/python_scripts/setup_utils.py +++ b/benchmarks/python_scripts/setup_utils.py @@ -1,60 +1,83 @@ +import os +import sys import numpy as np import h5py -from setup_settings import * - -def read_hdf5_train_data(dataset): - hdf5_file_name = os.path.join(RAW_DATA, dataset + ".hdf5") +SOURCE_DIR = os.getcwd() + +# ── Directory layout ────────────────────────────────────────────── +DATA_DIRECTORY = os.path.join("benchmarks", "datasets") +if not os.path.exists(os.path.join(SOURCE_DIR, DATA_DIRECTORY)): + os.makedirs(os.path.join(SOURCE_DIR, DATA_DIRECTORY)) + +RAW_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "downloaded") +GROUND_TRUTH_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "ground_truth") +FILTERED_GROUND_TRUTH_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "ground_truth_filtered") +SEMANTIC_GROUND_TRUTH_PATH = os.path.join(SOURCE_DIR, "benchmarks", "gt") +SEMANTIC_FILTERED_GROUND_TRUTH_PATH = os.path.join(SOURCE_DIR, "benchmarks", "gt_filtered") + +QUERIES_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "queries") +PDX_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "pdx") +FAISS_DATA = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "faiss") +FILTER_SELECTION_VECTORS = os.path.join(SOURCE_DIR, DATA_DIRECTORY, "selection_vectors") + +for _d in [RAW_DATA, GROUND_TRUTH_DATA, FILTERED_GROUND_TRUTH_DATA, + SEMANTIC_GROUND_TRUTH_PATH, SEMANTIC_FILTERED_GROUND_TRUTH_PATH, + QUERIES_DATA, PDX_DATA, FAISS_DATA, FILTER_SELECTION_VECTORS]: + os.makedirs(_d, exist_ok=True) + +# ── Dataset registry ────────────────────────────────────────────── +# Abbreviated name -> (hdf5_dataset_name, num_dimensions) +# Matches RAW_DATASET_PARAMS in benchmark_utils.hpp +DATASET_INFO = { + "sift": ("sift-128-euclidean", 128), + "yi": ("yi-128-ip", 128), + "llama": ("llama-128-ip", 128), + "glove200": ("glove-200-angular", 200), + "yandex": ("yandex-200-cosine", 200), + "yahoo": ("yahoo-minilm-384-normalized", 384), + "clip": ("imagenet-clip-512-normalized", 512), + "contriever": ("contriever-768", 768), + "gist": ("gist-960-euclidean", 960), + "mxbai": ("agnews-mxbai-1024-euclidean", 1024), + "openai": ("openai-1536-angular", 1536), + "arxiv": ("instructorxl-arxiv-768", 768), + "wiki": ("simplewiki-openai-3072-normalized", 3072), + "cohere": ("cohere", 1024), +} + +# ── HDF5 I/O ───────────────────────────────────────────────────── + +def read_hdf5_train_data(dataset_hdf5_name): + hdf5_file_name = os.path.join(RAW_DATA, dataset_hdf5_name + ".hdf5") hdf5_file = h5py.File(hdf5_file_name, "r") return np.array(hdf5_file["train"], dtype=np.float32) -def read_ivecs(filename): - a = np.fromfile(filename, dtype="int32") - d = a[0] - print(f"\t{filename} readed") - return a.reshape(-1, d + 1)[:, 1:] - - -def read_fvecs(filename): - return read_ivecs(filename).view("float32") - - -def read_hdf5_test_data(dataset): - hdf5_file_name = os.path.join(RAW_DATA, dataset + ".hdf5") +def read_hdf5_test_data(dataset_hdf5_name): + hdf5_file_name = os.path.join(RAW_DATA, dataset_hdf5_name + ".hdf5") hdf5_file = h5py.File(hdf5_file_name, "r") return np.array(hdf5_file["test"], dtype=np.float32) -def read_hdf5_data(dataset): - hdf5_file_name = os.path.join(RAW_DATA, dataset + ".hdf5") +def read_hdf5_data(dataset_hdf5_name): + hdf5_file_name = os.path.join(RAW_DATA, dataset_hdf5_name + ".hdf5") hdf5_file = h5py.File(hdf5_file_name, "r") return np.array(hdf5_file["train"], dtype=np.float32), np.array(hdf5_file["test"], dtype=np.float32) +# ── Helpers ─────────────────────────────────────────────────────── + def get_ground_truth_filename(file, k, norm=True): if norm: return f"{file}_{k}_norm.json" return f"{file}_{k}.json" -def get_core_index_filename(file, norm=True, balanced=False): - if balanced: - return f"ivf_{file}_norm.index.balanced" - if norm: +def get_core_index_filename(file, norm=True, sq8=False): + if sq8: + return f"ivf_{file}_norm_sq8.index" + elif norm: return f"ivf_{file}_norm.index" - return f"ivf_{file}.index" - - -def get_delta_d(ndim): - delta_d = 32 - if ndim < 128: - delta_d = int(ndim / 4) - return delta_d - - -if __name__ == '__main__': - if not os.path.exists(RAW_DATA): - os.makedirs(RAW_DATA) - if not os.path.exists(NARY_DATA): - os.makedirs(NARY_DATA) + else: + return f"ivf_{file}.index" diff --git a/benchmarks/results/github_opening.png b/benchmarks/results/github_opening.png new file mode 100644 index 0000000..a2c97b2 Binary files /dev/null and b/benchmarks/results/github_opening.png differ diff --git a/benchmarks/results/plotter.ipynb b/benchmarks/results/plotter.ipynb index bf54a1d..7becac0 100644 --- a/benchmarks/results/plotter.ipynb +++ b/benchmarks/results/plotter.ipynb @@ -21,15 +21,288 @@ "matplotlib.rc('font', family='Helvetica') " ] }, + { + "cell_type": "markdown", + "id": "cbf431f2-ffe8-497b-a4ba-9da0b712c061", + "metadata": {}, + "source": [ + "# **NEW**\n", + "---" + ] + }, { "cell_type": "code", - "execution_count": 60, + "execution_count": 96, + "id": "1d9090f5-7a9a-455a-a2f9-14faea195d7e", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAArMAAAEcCAYAAADURTy9AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAgaVJREFUeJztvQm8zPX3+P+yVLSItJAWKqKoVLLvu0RooewqJSWFrBElisqakpQ1IlkqtMiWbCGyFT6yhexZS/N/PM/395r/+86duXdm7tw78545z8djuDPznvf6Oq9zXud1znll8ng8HqMoiqIoiqIoLiRztE9AURRFURRFUcJFjVlFURRFURTFtagxqyiKoiiKorgWNWYVRVEURVEU16LGrKIoiqIoiuJa1JhVFEVRFEVRXIsas4qiKIqiKIprUWNWURRFURRFcS1qzCqKoiiKoiiuRY1ZJaE5cuSIefnll03BggXNRRddZHLmzGmqVq1q5s2bF9J++vTpY6677jqT0bz44osma9as5uDBg8m+O3nypLn44ovN448/Lu+PHj1qWrRoYa644gpz+eWXm/Lly5uFCxdm6Pn+8MMPJlOmTBHf7//+9z/ZL/8HolKlSvKcovm8fDlx4oS59dZbzZ9//pku++/YsaNp2rRpss8PHDhgHnnkEWnvtIeWLVtK+7Dwnvv58MMP+93vmDFj5Pv8+fOn6fw+/vjjsPdx6NAhc8EFF5jff/892XcTJ040hQsXNtmzZzd33HGH+fzzz5N8/9tvv5kHHnhA5IBtypUrZ5YtW+b3OCySSZ/ANulJoOOcOnXKPPXUU+bKK680OXLkMA0aNDC7du1KdX9s07ZtW3P99debCy+80OTJk0d++/333yd7BjxL54vtuW+zZs3ybvf222+bnj17RvCKFSVyqDGrJCwYECVLlhRF17t3b/Pzzz+b2bNnmwIFCphatWqZzz77zMQ6zZo1M+fPnzfTp09P9t2cOXPM6dOnTfPmzb0GytKlS83kyZPFqMyXL5+pU6eO2bZtm0k0nn/+ebNkyZJon4bp27evuf/++8XQiCT//vuv+eqrr8To9Gc01a9f3/zxxx/myy+/lDb/66+/JjNcs2TJIvtgUOTLlClT5PtosXfvXtO+fXu5Tl84ZwZtTz/9tFm5cqVp1aqVGO6LFy/2GocYjX///bdsiyzkypXL1K5d2+zfvz/Z/t56661kBmB6EOg4nD/nyD3n+zNnzpiaNWuaf/75J+C+fvnlF3P33XfLc33vvffM2rVrzfjx42XAzrVPmDAh2W82b94sRv7WrVtlkHvttdeaRo0amS1btsj3zzzzjPxu48aNEb5yRYkAHkVJUBo0aOC57rrrPH/99Vey71q1auXJlSuX59y5c0Htq3fv3p58+fJF9PyCPfbtt9/uqVy5crLPGzVq5MmTJ4/n33//9ezfv9+TOXNmz7Rp07zfnz171pM9e3bP8OHDPRnFggULPOnR7ezYsUP2y/+BqFixojynWGHnzp1y///444+I7nfx4sWeCy64QO4Hr8cffzzJ9/PmzfNkypTJs23bNu9ny5cvl23Xr18v71u0aOG59957PTly5PB8+umnSX5/8OBBT9asWT1Vq1b13HjjjWk617Fjx4a8j9q1a3uvjddvv/2W5PvSpUt7mjdvnuw3yANMnTpVrn/v3r3e70+cOCH37I033kjyu1WrVnly5szpKVOmjKds2bKe9CLQcbZs2SLX+P3333s/+/PPP+X8Z8+e7XdfyPutt97qqVatmueff/5J9v1jjz3mufLKK73f8Qw4hu+2tEs+Hzx4sPezvn37emrVqhWRa1aUSKKeWSUhwbPzxRdfyDRs7ty5/XrMmFazXimmg/H0XH311TLVV7FiRb+evbFjx5qbbrrJZMuWzdx3331mw4YN3u/Y13PPPWeuueYamf6vUKFCkml+PKd4zPAS4ynCawbffvut7It9MjXepUsX8S5ZmEZetGhREq8Sx8Lr9Nhjj4kH7a+//jIlSpQQb42F8ITMmTOL9zYQ27dvNw8++KC57LLLZEq6cePGSabymSLu3LmznDvbcH5Tp04VL9DNN98s58y06Y4dO5Lsd9q0aTINfMkll4h3fMGCBUm+xwtVtGhR8SSxn/79+4sH2vL111+bYsWKyff87+vR4l7gVbr00kvNVVddZXr06CEeSYszzMCGKOCJx+PFtHPevHnN4MGDk+yT9sCULeeMN3XAgAFJpsjx8N95553ye54xz/rs2bMB7+3o0aPlubLPUM4jNe666y6zYsUKs2bNGnPPPfck+572dNttt0k7tdx7773yfOfOnev9jHtLe+RZOGEWAK8dzy1U3nzzTblerq169epm586dIe9j6NChcm3cP1+QU66dEAInHMte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iqIoiuJa1JhVFEVRFEVRXIsas4qiKIqiKIprUWNWURRFURRFcS1qzCqKoihKgrFjxw7Tpk0bU7ZsWVOvXj3z0UcfeVcG3Lhxo2ndurV8V716dTN48GDz77//RvuUFSUgaswqcUuHDh3MxIkTve/r1Kljrr32Wu+rbt268jmddK9evUyxYsVMwYIFTdOmTc3evXv97vPnn382NWvWNDfddJN09J999lmGXY+iKKHx7bffmooVK4q8VqlSxSxYsEA+37JlixhwfF6uXDkzd+5cv7//+++/zZNPPmluueUWU6JECTN58uS4eAT//fef6dSpk7niiivMmDFjTLt27eT/WbNmyTU///zz5vrrr5fVAtmOzydMmBDt01YizJgxY0zx4sWlfTdq1EjkAr766itTqlQpkY/mzZubgwcP+v39jz/+aCpVqiTb8f/XX38dvWfkUZQ4Y8GCBZ5evXp58uXL55kwYYL389tuu81z+vTpZNtPnDjRU7lyZc++ffs8x44d8zzzzDOep556Ktl2//zzj6d48eKet99+W7abO3eu54YbbvCsX7/e4xbOnj3rGTVqlPyvKPHMX3/95bnppps8kyZN8pw4ccLzySefyPvdu3d7SpYs6Rk9erTn+PHjnjlz5nhuueUWz99//51sHy+++KKnWbNmngMHDnh++uknT6FChVwl74HgGkqUKCH9mOW1117zdOrUyfP11197KlWq5Dl37pz3u+HDh3vq168fpbNV0oO1a9d6Chcu7Fm2bJnIR8+ePT01atTw7Ny5U+Rh3rx5IkMdOnTwtG7dOtnvz5w547n99ts948aNk9/PmDHDU6BAAflNNMgaPTNaUdKHdevWmbNnz5qrrrrK+9nRo0dN9uzZTbZs2ZJtf9FFF8n0Gh7aTJkyyd+5cuVKth1TbydPnhSPb+bMmcVDW6RIEbN06VJTtGhRfZwRYN++fWbZsmVyrw8dOiT3mWdx6623iiccj7qiBMPy5cvNDTfcYJo0aSLv8TANHDhQvElXX321eeKJJ+Tz+++/3+TNm1fampNz586ZL774QrxN9CW82HbGjBmul/dTp06J5y1Hjhzez7JmzSrXfOzYMXPXXXeZCy64wPtd7ty5zeHDh6N0tkp6sGjRIgkhoR3AY489ZsaPH28+//xzmcWoUaOGfN65c2dTpkwZaReXX3659/fr168XfdqsWTN5/+CDD5oePXpI+ArtJaNRY1aJOzA24ffff/d+9r///U+MVAzQ7du3mzvvvNP079/fFCpUSIRwypQp5r777pNtc+bM6XfakakUlJtVenTuf/zxhyhCJW3wfIYOHWp++uknMRowQngO8Ndff0l4x7vvvmtKliwpU6JMiylKSqCkR48e7X2PkkUhz5w501xzzTXm8ccfNytXrpS21rt3bxnsOqGfYDqePsLC4BVj2O3Q19n+Dn777TcJyXj66adluvnRRx/1fvfPP//ItDMhWEr80LZtW/kfvYizZ9KkSdImfv31V3PHHXd4t8uXL5/Ixq5du5IYs4QnLF68WP4+ffq0N8QgWu1EjVklIUBYMUb79OkjsWDvvPOOeGoWLlxo3n//ffkeJYWn4pVXXjHt27eXODEnl156qSgz6/3FaOY93holfLj/8+bNMw888IB56aWXpPP0B3FbDDLwFFSoUMF07NhRb7sSEOJBeQFy/uKLL8rA9ZJLLjGffvqpGTVqlPnggw/EuCURihkW52zO8ePHk3gugd8yOxNPVK5c2Zw4cUKMev52Qu4A+QQYu++9917AfTDgxPBX3MeXX34pHlVmJV999VV5j8PmwIED3m0wZnfv3i0zGr7gNMJJBA0bNhSvPzOjoeJv3yERleCGCLNx40ZP7dq1k3xG3AbxPxUqVPDUrVvXM2bMmCTfEy9VvXp1T8WKFT39+vXzG0upuJuGDRsmiZl1cv78eU/BggU969at81SpUkXifSzEzubNm9dz5MiRZL+jnXTr1s1z8803S+ys22JPYzFmlphG4q+ChXOfNm1aup6TEh8cPXpU4t+LFCniGTt2rOe///7zdOnSReJgnZQrV05i4J1s2bJFYmR99caTTz7piSe2b9/uWbx4sadp06ZJ7sv06dM95cuXF/25Zs2aqJ6jkr6cOnVK2j8xr61atfK8//77Sb5HfjZv3hzw98RXb9iwwVOmTBmRkWjges/sn3/+aYYNG5bs865du8oomtH3tm3bzBtvvOHNuMPjRlYqXjqmMgcMGCBTmPxGiU/mzJljLrvsMslshvPnz4sngTbCqNPpVSB2jJGpb3wt0zEtW7YUj813331nbrzxxgy/jnjExjQGwjdW68ILL5SpUEVJCaY+8RQRBkR84JVXXimf44FkytQJ8u8r79ddd51MseOR4m/YunWr6+Nl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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.patches as mpatches\n", + "from matplotlib.lines import Line2D\n", + "\n", + "mpl.rcParams['hatch.linewidth'] = 0.1\n", + "\n", + "# Data\n", + "x_labels = [\n", + " \"PDX\\n8-bit\", \n", + " #\"PDX\\n32-bit\", \n", + " \"FAISS IVF\\n8-bit\", \n", + " #\"FAISS\\nIVF\"\n", + "]\n", + "construction_x_labels = [\"PDX\", \"FAISS\"]\n", + "\n", + "x = np.arange(len(x_labels))\n", + "construction_x = np.arange(len(construction_x_labels))\n", + "\n", + "bar_width = 0.35\n", + "\n", + "construction_bar_width = 0.35\n", + "\n", + "# Old Query time data (FAISS, PDXearch)\n", + "# data = {\n", + "# # \"K=100@0.90\": [\n", + "# # 1.08, 2.70, 5.04, 9.49\n", + "# # ],\n", + "# \"K=20 @ 0.95\": [\n", + "# 1.97, \n", + "# 4.48, \n", + "# 13.32, \n", + "# 22.31\n", + "# ],\n", + "# \"K=20 @ 0.99\": [\n", + "# 4.97, \n", + "# 11.57, \n", + "# 52.56, \n", + "# 96.24\n", + "# ]\n", + "# }\n", + "\n", + "data = {\n", + " \"K=20 @ 0.90\": [\n", + " 3.51, \n", + " 25.97\n", + " ],\n", + " \"K=100 @ 0.90\": [\n", + " 5.40, \n", + " 30.31\n", + " ]\n", + "}\n", + "\n", + "construction_data = {\n", + " \"100,000 clusters\": [\n", + " 256.83 / 60.0, \n", + " 9490.20 / 60.0\n", + " ]\n", + "}\n", + "\n", + "categories = [\n", + " \"PDX\\n8-bit\", \n", + " #\"PDX\\n32-bit\", \n", + " \"FAISS IVF\\n8-bit\", \n", + " #\"FAISS\\nIVF\"\n", + "]\n", + "\n", + "construction_categories = [\n", + " 'PDX', 'FAISS'\n", + "]\n", + "\n", + "colors = [\n", + " #\"#e07b7b\", \n", + " \"#2c7bba\", \n", + " \"#efefef\", \n", + " #\"#efefef\"\n", + "]\n", + "construction_colors = [\n", + " #\"#e07b7b\", \n", + " \"#2c7bba\",\n", + " \"#efefef\"\n", + "]\n", + "\n", + "hatches = [\n", + " \"//\", \n", + " #\"//\", \n", + " \"\\\\\\\\\", \n", + " #\"\\\\\\\\\"\n", + "]\n", + "construction_hatches = [\"//\", \"\\\\\\\\\"]\n", + "\n", + "fig, axes = plt.subplots(\n", + " 1, 4, \n", + " figsize=(7, 3.1), \n", + " sharey=False, \n", + " tight_layout=True,\n", + " gridspec_kw={\n", + " 'width_ratios': [1.3, 0.01, 1.3, 1.3],\n", + " # 'wspace': 0.4\n", + " },\n", + " \n", + ")\n", + "\n", + "label_fontsize = 11\n", + "tick_fontsize = 10\n", + "x_tick_fontsize = 9.5\n", + "bar_label_fontsize = 9\n", + "font_color = \"#333333\"\n", + "tick_fonts_color = '#585858'\n", + "bar_text_color = '#191919'\n", + "\n", + "tick_fonts_color = '#333333'\n", + "bar_text_color = '#191919'\n", + "font_color = \"#383838\"\n", + "\n", + "for ax, (title, values) in zip(axes[0:1], construction_data.items()):\n", + " bars = []\n", + " for i in range(len(construction_categories)):\n", + " bar = ax.bar(\n", + " construction_categories[i], \n", + " values[i], \n", + " width=construction_bar_width, \n", + " color=construction_colors[i], \n", + " hatch=construction_hatches[i], \n", + " label=construction_categories[i])\n", + " bars.append(bar)\n", + " \n", + " # Add value labels on top\n", + " for bar_group in bars:\n", + " bar = bar_group[0]\n", + " height = bar.get_height()\n", + " ax.text(bar.get_x() + bar.get_width()/2, height + 0.1,\n", + " f\"{height:.1f}\", ha='center', va='bottom', fontsize=9, color=bar_text_color)\n", + " \n", + " # Ticks and labels\n", + " ax.set_xticks(construction_x)\n", + " ax.set_xticklabels(construction_x_labels, fontsize=x_tick_fontsize, color=tick_fonts_color)\n", + " ax.set_title(title, fontsize=label_fontsize, color=font_color)\n", + " \n", + " # Grids (horizontal only)\n", + " ax.yaxis.grid(True, linestyle='-', linewidth=0.6, color='gray', alpha=0.2)\n", + " ax.set_axisbelow(True)\n", + " \n", + " # Spine adjustments (remove top and right)\n", + " ax.spines['top'].set_visible(False)\n", + " ax.spines['right'].set_visible(False)\n", + " ax.spines['left'].set_visible(False)\n", + " ax.spines['bottom'].set_color((0, 0, 0, 0.15))\n", + "\n", + " ax.set_ylabel(\"Index Construction\\n Time (minutes)\", fontsize=label_fontsize, color=font_color)\n", + "\n", + " # Set consistent Y limits per dataset\n", + " max_y = max(values)\n", + " ax.set_ylim(0, max_y * 1.15)\n", + " ax.yaxis.set_major_locator(MaxNLocator(nbins=5))\n", + " ax.tick_params(axis='y', colors=tick_fonts_color)\n", + " ax.tick_params(axis='both', length=0)\n", + "\n", + "\n", + "for ax, (title, values) in zip(axes[2:], data.items()):\n", + " bars = []\n", + " for i in range(len(categories)):\n", + " bar = ax.bar(categories[i], values[i], width=bar_width, color=colors[i], hatch=hatches[i], label=categories[i])\n", + " bars.append(bar)\n", + " \n", + " # Add value labels on top\n", + " for bar_group in bars:\n", + " bar = bar_group[0]\n", + " height = bar.get_height()\n", + " ax.text(bar.get_x() + bar.get_width()/2, height + 0.1,\n", + " f\"{height:.1f}\", ha='center', va='bottom', fontsize=9, color=bar_text_color)\n", + " \n", + " # Ticks and labels\n", + " ax.set_xticks(x)\n", + " ax.set_xticklabels(x_labels, fontsize=x_tick_fontsize, color=tick_fonts_color)\n", + " ax.set_title(title, fontsize=label_fontsize, color=font_color)\n", + " \n", + " # Grids (horizontal only)\n", + " ax.yaxis.grid(True, linestyle='-', linewidth=0.6, color='gray', alpha=0.2)\n", + " ax.set_axisbelow(True)\n", + " \n", + " # Spine adjustments (remove top and right)\n", + " ax.spines['top'].set_visible(False)\n", + " ax.spines['right'].set_visible(False)\n", + " ax.spines['left'].set_visible(False)\n", + " ax.spines['bottom'].set_color((0, 0, 0, 0.15))\n", + "\n", + " # Y label only for first plot\n", + " if title == \"K=20 @ 0.90\":\n", + " ax.set_ylabel(\"Avg. query time (ms)\", fontsize=label_fontsize, color=font_color)\n", + "\n", + " # Set consistent Y limits per dataset\n", + " max_y = max(values)\n", + " ax.set_ylim(0, max_y * 1.15)\n", + " ax.yaxis.set_major_locator(MaxNLocator(nbins=5))\n", + " ax.tick_params(axis='y', colors=tick_fonts_color)\n", + " ax.tick_params(axis='both', length=0)\n", + "\n", + "axes[1].set_visible(False)\n", + "\n", + "# Separator\n", + "bbox3 = axes[0].get_position()\n", + "bbox4 = axes[2].get_position()\n", + "x_sep = 0.5 * (bbox3.x1 + bbox4.x0)\n", + "x_sep -= 0.015\n", + "gap = 0.045\n", + "y0, y1 = 0.18, 0.75\n", + "y_mid = 0.5 * (y0 + y1)\n", + "fig.add_artist(Line2D([x_sep, x_sep], [y_mid + gap, y1],\n", + " transform=fig.transFigure, lw=0.8, color='#7e7e7e', alpha=0.8))\n", + "fig.add_artist(Line2D([x_sep, x_sep], [y0, y_mid - gap],\n", + " transform=fig.transFigure, lw=0.8, color='#7e7e7e', alpha=0.8))\n", + "# fig.text(x_sep, y_mid, \"◆\", ha=\"center\", va=\"center\", fontsize=14)\n", + "fig.add_artist(Line2D(\n", + " [x_sep], [y_mid],\n", + " marker='D', markersize=4, color='#7e7e7e', markeredgewidth=0.5, markeredgecolor='white',\n", + " transform=fig.transFigure, linestyle='None'\n", + "))\n", + "\n", + "fig.supxlabel(\n", + " 'Hardware: Intel Granite Rapids (r8i.8xlarge, 32 cores, 256 GB of RAM)', \n", + " x=0.5, y=0.047, fontsize=10, color='#585858', style='italic')\n", + "\n", + "# Shared legend\n", + "handles, labels = axes[0].get_legend_handles_labels()\n", + "# fig.legend(handles, labels, loc=\"lower center\", ncol=4, frameon=False, fontsize=8, bbox_to_anchor=(0.5, -0.1))\n", + "fig.suptitle('CohereV3 embeddings (n=10M, d=1024, ~40 GB)', fontsize=11, x=0.52, y=0.9)\n", + "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", + "# plt.savefig(f'./openai-intel.png', format='png', dpi=600, bbox_inches='tight')\n", + "plt.savefig(f'./github_opening.png', format='png', dpi=600, bbox_inches='tight')" + ] + }, + { + "cell_type": "markdown", + "id": "7ced3b74-8923-4481-9098-2ae701448eb3", + "metadata": {}, + "source": [ + "# **OLD**" + ] + }, + { + "cell_type": "markdown", + "id": "1c5f0ce8-401c-44f7-a17e-212a606c3a8c", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "code", + "execution_count": 2, "id": "dce08b51-6c4a-4bb2-b6ed-0544c72bfb63", "metadata": {}, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA5gAAAFhCAYAAAAVygVFAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjMsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvZiW1igAAAAlwSFlzAAAPYQAAD2EBqD+naQAAdKVJREFUeJzt3Qd0k+XbBvC7jLI3lC2ykSVDZMgQEQGZIsqUKQICggNENrJkCCggQ/ZQBEFAEZGNgGxQUDbI3kuZFch3rsf/my9N05H2Td51/c6J2DRN0zS5+tzPDHG5XC4hIiIiIiIiiqcE8b0DIiIiIiIiImCBSURERERERLpggUlERERERES6YIFJREREREREumCBSURERERERLpggUlERERERES6YIFJREREREREumCBSURERERERLpggUlERERERES6YIFJREREREREumCBSURERERERLpggUlERERERES6YIFJREREREREumCBSURERERERLpggUlERERERES6SKTP3RAROcvNmzfj9HV//fWXujz//PNx/t4bNmyQJ598Ul3Spk0b5/uhuCnWd7X7/12PHsqtgxslzVNVJCRh3P6kht+4IOE3L0jK3KX8+rr9Q6rH6fsREREFEkcwiYiCRO/iMj5at24tISEhUV6+/fZb922//PJLdV3dunV93hd+Jnx+1qxZEa7fsmWL+pqMGTNKkiRJ5IknnpB27drJiRMnIt3HgQMHpEmTJpIlSxYJDQ2VrFmzSuPGjeW3334TM0NRieISRSaKzbgITZdVQtNmldsn90gw4PXn/fvGc47fT+fOneXGjRs+XyMJEiSQFClSSLFixeTjjz+We/fuue/zzp07UqBAAcmWLZtcu3Yt0vf8/vvv1ddPmjRJzADvI/xM+JeIiPTFEUwiIocVlxoUc999953Pz6FY0MyYMUMVFStXrpQzZ85Izpw5Y7zvtWvXSs2aNaVhw4Yybdo0NdJ6/PhxGT16tDz77LOyfft2yZs3r7rtH3/8IeXLl5dy5crJ+PHjJSwsTM6ePav+H9etX79e/WuFIjOuI5koMgFFpr8jmXFRsmRJ+eKLL9wfh4eHy+7du6V3796yd+9e1Tng/Rp5/Pix3Lp1SzZt2iTDhg2TVatWqd9z0qRJVeE5b948ee6556RDhw4ROihOnTolrVq1Uq+FTp06BfxnIyIiY7HA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OK1atXLlypXL5+c6duzoSpgwoevixYtRfn3r1q2xCM518OBB18mTJ9X/z5w5M8Jt1qxZ46pXr54rS5YsrsSJE7vSp0/vqlWrlmv9+vWR7m/37t2uJk2auHLkyOFKkiSJK3Xq1K7nn3/etXjxYh1+WvJUpUoVdYnNawS/V89LihQpXKVLl3aNGjXKdf/+ffdtP/vsM/X50aNH+7yv7du3uxIlSuSqWrWq69GjR+7XR6VKlVzJkiVzpUmTxvXCCy+4fvnlF/fXXLt2zdWsWTNXhgwZXClTpnQVK1ZMfZ+33nrLlTVrVtfDhw+jfO3Br7/+ql5DuP/MmTO72rZt67py5Yr6HF6D+Drv12JsnxsiIopaCP5jdJFLRERERERE1sc1mERERERERKQLFphERERERESkCxaYREREREREpAsWmERERERERKQLFphERERERESkCxaYREREREREpAsWmERERERERKQLFphERERERESkCxaYREREREREpAsWmERERERERKQLFphERERERESki0T63A3ZRXh4uCxcuFB+/vlnOXXqlLouR44cUqNGDWnYsKGkTJlSzGTXrl3SsWNHSZMmjfz000+SOHHiSLf5/vvvZdCgQTJgwACpW7duhM8tWbJEZs+eLcuWLYv0dUOHDlXPwdSpUyN9rmXLlvLnn39Guv6FF16QkSNHuj/euXOnTJ48WY4ePSopUqSQF198UTp16iTJkyeP8HU3btyQiRMnyqZNm+TBgwdSsGBB6dq1qxQrVszv54SI9IX352uvvSaPHz+WRYsWSYYMGSLd5qOPPpJ169bJ9OnTpWjRovLWW2/JhQsXVP74a9u2bfL+++/L+vXrpUKFCtHe1jPXHj58qPIM3/PKlSsqu9944w2pU6dOhK+5fv26fPbZZ7J582a5d++e5MuXTzp06CDPPfec34+ViALfLvvqq69k1apVcubMGUmYMKHkzJlTtSeaNm0qSZIkidAeat++vXo/e7Z/ojN69Gg5fPiwfPnllzE+llKlSkVoE3lmVWhoaITbzpgxQ7777jufGXjgwAGZMmWK/P777yq3ChQoIO3atZOKFStGuN3Zs2dl3Lhx6mf7999/VbaibYR/ydxYYJLb5cuX1Rv3xIkT8vzzz6tGicvlUgGA4gfFGN7oTz75pJjFypUrJVmyZHLr1i3ZuHGjClx/bN++XZ59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iIjIfAwdwbx9+7Zs2LBBevfuLfny5VPX1a5dW44dO6ZGM0NDQ6V58+ZqNLNly5ayb98+2b59u1SpUsXIh01ERERERERmG8E8dOiQJEuWTAoXLuy+rn79+tKxY0dVZBYsWFAVl4B/MYp59OhRAx8xERERERERmXIE8/Lly5IpUybZtGmTLFu2TB4+fKhGJxs0aCA3b95U6y49pUmTRq3HJCIiIiIiIvMxtMDEesqLFy+qNZcdOnRQReW0adPU1NgHDx5IokQRH17ixIlVERqVq1evyuPHj4PwyInI7MLCwox+CERERESOY2iBiXWW9+7dky5duqiRTK1IXL16tWTJkiVSMYkdZ1F8RiVjxowBf8xERERERERkwjWYadOmVaOSWnEJ2bJlk2vXrkn69OnViKYnfJwuXToDHikRERERERGZusDMnz+/GpW8cOGC+7pz586pghO7yh45ckRcLpe6Hv/iY223WSIiIiIiIjIXQwtMjFaWLFlSJk2aJKdOnZLffvtNli9fLtWrV5eyZcvK3bt3Zc6cOXL27Fn1L9ZllitXzsiHTERERERERFEIcWlDhAZBETlr1izZuXOnWl/50ksvScOGDdWxJDiqZPr06WpU84knnpB27dpJ7ty5jXy4REREREREZNYCk4iIiIiIiOzB0CmyREREREREZB8sMImIiIiIiEgXLDA9DBo0SK3xxE61uBQpUkRdP3v2bClTpoza9bZRo0Zqbagvjx49ksGDB0vRokXV1/bv318eP34c5J+CiJzgp59+kueff14KFCggNWvWlB07dqjr27dvL3ny5HHnWI0aNXx+/fHjx+W1115TX4+N1bZt2xbkn4CInGzKlCnSvXt3n5/T8ku75MyZU5o0aRL0x0hEccMC08Off/4pX3zxhSogcfnjjz9k+/btMmbMGFmwYIEcPnxY7WL7wQcf+Pz6yZMnq0baxo0bZdOmTbJlyxZZtGhR0H8OIrK306dPS7du3WT48OFy6NAheeutt6RNmzZy+/ZtlWPLli1z59iqVat8doa1bdtWdYYdOHBAdYbh6z2PjCIiCgTkz/jx41WHfFS0/MJlw4YNkjFjRundu3dQHycRxR0LTA9omBUuXDjCdTguZevWrZI3b165f/++/PPPP5IuXTqfX//VV19Jv379JEOGDOqCkc8qVaoE6dETkVNgZ+1mzZpJ+fLl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", + "image/png": "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LkP7CdP8PetWBBueg+suAb7QZnxhCVZ0y7AwgIHkEtVgEcPQzkK7FLCcCnGcM7qMLZ4D8/SI/IBebwQANFKIiIiIiMjeuOaSiIiIiIiIAsbkkoiIiIiIiALG5JKIiIiIiIgCxjWXREREREREFDCOXBIREREREVHAmFwSERERERFRwJhcEhERERERUcCYXBIREREREVHAmFwSERERERFRwBJKmD158kQWLlwoW7ZsEYfDISVLlpTXXntNEidOLLt27ZIFCxbIP//8I/ny5ZPOnTtLxowZw/2UiYiIiIiIyGhbkSxevFi2bdsmHTt2VF9Pnz5dSpcuLTVr1pQBAwZIq1atpHDhwrJq1So5deqUjBgxQuLH54ArERERERGRkYQ1S3v48KGsXbtWJZbFihVTtxYtWsjRo0dl/fr1Urx4cXnxxRclZ86c6pxz587JsWPHwvmUiYiIiIiIyGjJZWRkpCRIkECKFCniPFaxYkX5+OOP5fDhwyq51GCabK5cueTgwYNherZERERERERkyDWXZ86ckXTp0smKFSvUSCWULVtWjV5euXJF0qdP73Z+mjRp5ObNm2F6tkRERERERGTI5PLu3bty9uxZ2b9/v/Tq1Uvu3bsnc+bMUcfv378vERERbudj9BLHo4OEFAWCiIhY/IuIiIjIRsklagk9fvxY+vTpIylSpFDH/v33Xxk/frwkSZJErcl0hftSpkwZ7eN5jnQSERERERGRDdZcIqHUbpqsWbOqhDNp0qRqCxJX+JoJJBERERERkfGENbnE3pW3bt1ySyKxDhOJJbYjOXTokPP4nTt35K+//lIVZYmIiIiIiMhYwppcYosRVIqdMGGC2n5k3759smDBAqlbt65UrVpVdu/erQr9HD9+XMaNGycFChSQ7Nmzh/MpExERERERkRfxHFj4GEa3b9+W2bNnq0TyqaeeksqVK0urVq3UFiW//vqrfP3113Ljxg0pWrSodOnSJcY1l0RERERERGTT5JKIiIiIiIjML6zTYomIiIiIiMgamFwSERERERFRwJhcusCazixZsjhvJUqUUMc/+eQTteazYMGC0rZtWzl//rzXi4l9OAcMGCCFCxeW4sWLq38/evQo8FeJiMjDhg0bpEqVKpInTx6pXr26bNq0SR2vV6+eWxx7+eWXvV67kydPSpMmTVTV7hdeeEHWrVvHa0xEIdG7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0e4jY2C8Mv61Bcaq4F1fxipzYKwyx/UFxqvgXd/7Jm1bMbkMEIarkQy6wtfJkiWLcm6HDh1UVaiuXbtK586d1R8VeiSQVCZKlEj69OmjqkW1b99eFZkpWbKk+j7cb1d6XV8YPny4vPXWW5IvXz71Zu7WrZvs2bPH1tOO9bq+0T0OeHssCg/GK+NfW2CsCt71ZawyB8Yqc1xfYLwK3vVNYtK2FafFBgjD09hXyhWGtL2t5cN6ysGDB8utW7fUXGn8AXXq1Ely5cql7i9YsKBMnDhRbUmCoXKMrG3dutW2U2L1vr6e1xHz4e0+7Viv63vp0iXnVA7Xx0HwQ3AkY2C8Mse1ZawK3vVlrDIHxirzXF/Gq+Bd30smbVtx5DJA2Mri1KlTaihbg30qixcvHuVclBHGSBl6I1KmTKlKD6dKlUot5EWZYoyq4XHwh4Zh7507d6rHsfPmxnpdXyx+xkgl1rVqTp48qa7t008/LXal1/UtXLiweozTp0+7PU6xYsVC9rtQ7BivgoexKrgYq+yFscoc15dtq+Be38ImbVvZN2vRSe7cudUfwJQpU1SygnWSu3btkho1aqiha8xFx8JbrXdn0aJFqjfjt99+U3vZYA9LJDgZM2ZU86dxLDIyUj3OL7/8IvXr1xc70+v64nESJEggX3zxhaq+tW/fPrXnUOXKlQ27INpM1xcBsVy5cmqjX/z9btq0SdauXSt16tQJ969ILhivgoexKrgYq+yFscoc15dtq+Be35QmbVvFw34k4X4SZnf16lX1B4Q/DPyRtGnTRkqVKqWmtQ4dOlRtpIrjqAaFzVAPHDighr1r164tjRs3dj4O/gDnzJmjejswdI5KpmXLlhW70+v6YnR47ty5KrnEAuny5curx9IWR9uVXtcXvWszZsxw9sA1adJEqlSpEtbfjaJivAoexqrgYqyyF8Yqc1xftq2Ce31vm7BtxeSSiIiIiIiIAsZpsURERERERBQwJpdEREREREQUMCaXREREREREFDAml0RERERERBQwJpdEREREREQUMCaXREREREREFDAml0RERERERBQwJpdEREREREQUMCaXREREREREFDAml0RERERERBQwJpdEREREREQkgfo/mMT8/paXls8AAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -133,18 +406,18 @@ "fig.legend(handles, labels, loc=\"upper center\", ncol=2, frameon=False, fontsize=label_fontsize, bbox_to_anchor=(0.5, 1.02))\n", "\n", "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", - "plt.savefig(f'./BENCHA.png', format='png', dpi=600, bbox_inches='tight')" + "# plt.savefig(f'./BENCHA.png', format='png', dpi=600, bbox_inches='tight')" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "265e46dc-3e6f-44ea-b508-f5ae90037893", "metadata": {}, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAx8AAAEjCAYAAABTg7xaAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjMsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvZiW1igAAAAlwSFlzAAAPYQAAD2EBqD+naQAAbkdJREFUeJztnQncTOX7/y/7TmRLyZqs2bNLaEGWiuxLiGTJVimEKNEmFSGRtRIhJUshpUhC9ki27PuumP/rc/3+Z74z88zzeGaeM2fOOfN5v17jMWfOzNz3zLmvua89mcfj8QghhBBCCCGERJjkkX4DQgghhBBCCAFUPgghhBBCCCGWQOWDEEIIIYQQYglUPgghhBBCCCGWQOWDEEIIIYQQYglUPgghhBBCCCGWQOWDEEIIIYQQYglUPgghhBBCCCGWQOWDEEIIIYQQYgkprXkbQgghhLiVs2fPyvnz5+McT548ueTJkycqYyKE2BN6PgghYXH48GF57rnnpGTJkpIpUybJkCGDFC5cWDp27Ci//fab2JUKFSpIsmTJpG/fvvGeg8dr1aol0QZjsGIcK1eu1DnjbyjnDR06VO/v3r1b7Er9+vVlzJgxlq0JbLaXL18e5zF8j/iscufOLTdu3Aj6/AsXLkj69On1vDZt2tjuOnr66aelevXqQR/r1q2b5M2bN86tSJEifufh2rn//vslS5YsKjdKlCghI0aMkIsXL8Z5zQkTJkipUqUkXbp0cuutt8qjjz4qf/31lz7m8XikdOnS8s033yR5XoQQa6HngxASMqtWrZLGjRurtfPBBx+UFi1aSNq0aWXHjh0yZ84cmTp1qrzyyisyaNAgsRNbtmzxKkazZ8+WN954Q1KkSBHtYTkWbOyzZ88uOXLkEDsyf/58/b7nzp1ryfu99tpruilOiKNHj+r6wQY8kK+++kouX74sdmTPnj26ZqAMBOPPP/+Uhg0bSpcuXfyOp0z5v23G2rVrVV5AkYCRAorYd999J4MHD9bHMH+DIUOGqAyBnGnfvr3s3btXJk+eLNu3b5dNmzZJmjRp5OWXX5YePXrI1q1bVUEhhDgEDyGEhMDff//tyZQpkydz5sye5cuXx3n8yJEjnnLlymEH5pk+fbrHTvTr18+TLFkyT+vWrXV83377bdDz8Nh9993niTYYgxXjWLFihc4Zf804zw5cu3bNU6hQIc8rr7wS0ffZvHmzp2PHjp677rpLPxvcli1bFuc8fI85c+b0ZM2a1dO1a9egr9WkSRNP0aJF9TVwjdrhOnr99dc99erV86RNm1bHVa1ataDn3XLLLZ4xY8Yk+FpNmzb1pEuXzrN//36/4w0aNNDXPnHihN7/66+/PClTpvQ8//zzfufNnDnTkyVLFs93332n92/cuKGf+5AhQ8KaGyEkOjDsihASErBIIrb7vffekzp16sR5PFeuXPLll1+qJwTnGpbg/Pnzy8MPPyzff/+9VK5cWS2VODZq1Kg41uITJ05oGMdtt92m55UrV06mTJnid16HDh3klltukf3798sTTzzhDeNo0qSJhr8E8t9//8nMmTOlatWq8vzzz+uxGTNmJOmzSOw4EUazb98+tQxnzJhRw1Hw+eFzxPPhPUDY2kMPPaTzCQQWYbw2PtM777xTXnzxRbl69arfOQhbGTBggOTLl0/PK168uLz11lvy77//+p23a9cueeSRR/Szypo1q4b3nDp1Ks57Jua8wLAr4z5CY5566invvGrXrq1escDXb9CggT6Oz69fv376feD5f//9t/c8eNIqVqyo48D33ahRozivFQx81/jMfS3xoYwvsRw/fly9AshruPvuuxM8N1WqVBo6BE/M9evX/R7DtfDtt9/qtRwuuO7wnSP8EdcAQiLx+SUFeBUuXboklSpVUm9DfOvgzJkzUrRoUV1nx44d07+BYI20bNlSr39f8P0DhKwBeE7xWeF6xpzw+leuXJFWrVrp++D7Avgu8f2+8847QcO2CCE2JUpKDyHEgfz777+e9OnTe7Jnz+7577//Ejy3VatWas38448/9H6+fPk8t99+u3pNunfv7hk1apRaY3FOnz59vM87ffq0p0iRIp6MGTN6evXq5XnjjTc8jRo10vP69u3rPa99+/Zqjc2bN69aTmGhfeyxx/S8+vXrxxnPV199pY+NGzdO7+M9MmTI4Llw4UJYno9Qxolj+fPn9zRr1swzYsQIT+HChfVY8eLF9TVgne/Zs6cnRYoUntq1a3ufizHAogwrcJs2bTyvvvqqp27duvpcvJevlR8W6VSpUnk6d+6sY8H5yZMn18/E4PDhw55cuXKp9fmZZ57R9y1VqpR+n74ejcSeB4sz7v/5559+92GNrl69umfkyJHqFTDmagDLd44cOfT24osvel5+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6SW1go4fP16tpFWqVLFwJoREl0OHDkmXLl1k3LhxUq1aNV0XTz31lHz33XeSLFky73l79+5VxQTnOREYG2bMmKHzatWqlSokSDKH4pExY8ZEyx0krRPiBn744QcZM2aMyoC8efNKnz59VAbMnz9f1wbCFgsXLqwGvWCewV9//VVGjhypz7/99tulR48eUrt27ZDHkTNnTpNmRAgJ1fA4fPhw2bdvn9xzzz3y1ltvSaFCheTw4cPSv39/+eWXX+TWW2+VXr16BTXSXbp0SeXDt99+q7+p2H+/+uqrkiFDBvMH64kyEyZM8LRo0UJvzzzzjPf4okWLPM8995z3/vXr1/Xx1atXx3mNs2fPelq1auXZs2eP99js2bM9I0aMsGAGhNiHqVOnepo3b+53rEiRIp4tW7bEWXejRo3yOJHt27d7evXq5Wnfvr1n6dKleuyLL77wvP76695z5syZ4xkyZEgUR0mIdZw4ccJTsGBBz6xZszznz5/3fPLJJ3r/l19+8RQoUEDXCY5jjZQtW9Zz+fJlv+dfuXLFU6JECc+0adP0vC+//FKfh9eNFvg9990TgJ07d3oGDBjgadeunWfgwIF+v/lg7ty5ni5dung6duyoMu7q1asWj5qQ6HDw4EFPoUKFPEuWLPFcuHDBM3bsWM/999/vuXHjhqdRo0a6Hz5z5oxn7dq1urb37t0b5zVeeeUVT7169TwHDhzQW926dSP2Oxpy2BW8C1988YVWmkGy56OPPqphUc8995zGW0PDCgU8H9aWpk2b+h3fsWOHlCpVyns/efLkmmS6bdu2oCEk6dKl07AMA8R9b9++HcpVqFMkxLH8+++/GobkC9YAPB2+4P6qVaukfPnyus5efvllTda2O2vXrpURI0ZI9uzZZdSoUfLAAw/o8a1bt2oyert27fT25ZdfqlzA/2EFIsTNYF0gF6ply5bq+cN1nzZtWtm4caNUr15d1wmOw5tx5MgRXRu+YO3gfPym4zx4RfGbGig37BANUbp0abXuFitWTO/DWmuEYC5evFi6du2q1fAw9unTp0dl/IRYzfLly6VChQry4IMPqqeiZ8+e6sXEnhl/4dFAjuS9994rCxcujFOe3lhDzzzzjNxxxx16gzxB1FFUw64gDN59911ZunSppEyZUu666y7dAOTPn18XP1y677//vrp56tSpo26d3Llz3/R1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1NAisUiHDBmiLrEyZcqInXHy/FD3Gq7HFi1axHkMY/vxxx/jNKGC2xiVH7788kv54IMPVIhiQSIZCxU8wMCBA2X27NkyePBg/QxwHIle9erVEytx+/ycgpPXiNvn5uY14ua5OQ2nrxM3z83N68TjwLnZvskgIYQQQgghxB0w7IoQQgghhBBiCVQ+CCGEEEIIIZZA5YMQQgghhBBiCVQ+CCGEEEIIIZbAalckDq+88ooULFhQli9fLt26dZNKlSr5PY4a3qhq0bBhQ2/1i0BQHQFl3iLJtm3btKsnqjGg6Q1Kxo0ePdrvnBUrVmipONQbR03ruXPnxnmdYsWKadWOQLZs2SKfffaZ1rxGs52SJUvqfFHCzvf1ly5dKv/884+kSJFCChQooJ+LUdkDTZdQahDNe65du6Yl6jp37uz3GrH43RF745TriDLAud8dsTdOuY4oA5z53VH5IEHBIkOJNtSH9r1wd+/eLSdPntQa0Xb6oapevbo2OEKN6ttvv917fO3atVK5cmUtI5eQgAnkxIkTMmbMGOnatavcc889cuHCBRVsb7zxhjbvAShRt2zZMmnfvr2UKFFCrl69qsLl7bfflmeeeUbfF7W3t27dKiNHjtTPdPLkydohtHfv3hH7LJz23RF74rTriDLAud8dsSdOu44oA5zz3THsiiS4kNEdE4vJABcyFhga19gJNNZBjeqff/7ZewyCAlaLGjVqhPx6aKaTPXt2qVixotY0h4UCNbFvueUWrXeNhjzz58+XHj166MLOmDGjngNLQuPGjWXWrFly48YNrXWOatb4P8D/M2XKJJHGSd8dsS9Ouo4oA5z73RH74qTriDLAOd9dzHk+4I6Chocv/2buub59+6rmGwga59x3332Jer9z586puw2t6nEBoHV9kyZNpFy5cnHeNyG3nXEOLvTTp0+rVt+uXTvV4CMFNP106dLpxQvt3bAgNGvWTOwIhAs+w6ZNm+r99evXqyAwmh6FQv78+bV768SJE9V6gIZKECxowGO8doYMGYJaCzCOL774Qq8dWBdg9YCbE+A5r732mkQap313VkIZ4N7riDLAud+dlVAGuPc6ogxwxncXc8pHqO65UARMMD788EPVkkeNGqWvuWnTJnn//fe1c2ShQoUS7bbDApg6daq6CiG40OIe7sAJEyZIpEiePLlUrVpVNWWMAe668+fPy7333ut3HuItfalSpYr06tVLrAZjnDZtmsZm4jPCd4nvOb4urQZvvvmm3zVgWFCGDh2q3wvco0ePHtXXbNSokQoSCJT44jXxfRs/OPgeYXnBd5U+fXr55JNPtJvosGHDJJI47buLBpQB7ruOKAOc+91FA8oA911HlAHO+O5iVvnwdc8ZGrLhnkP8m5nJULjAsmTJ4v1SIUBOnTqlQsdw273wwgte7RmaNawdSEyC2w4XCi4iWEIMtx3+4rxIg0WL8cPFiEUM7R+xhL7YIdYTZM6cWS1EWGhZs2bV7xJWIV8SG+uJzxdWjy5duvgJEAh5CB9YLvDdBePYsWP6F+5aPAcWrly5cumxVq1aSffu3fVai/T356TvLhpQBrjvOqIMcO53Fw0oA9x3HVEGOOO7i+mcD7jFcIEaJMU9Fx933XWXXqiwUMCVCh577DGNIQRYGAm57Y4fP67aNS7aBg0a6IKBZQQu3BYtWkikgds3R44c6rbDhRtO3KSVYHwY52+//aZjx49LOMAqhaoYvgLt4YcfVoEDiwrcmUja2rFjh/ccfEb4kcEPGQQWLB+IE4Vr3wA/HMmSJZPUqVNLpHHadxcNKAPcdx1RBjj3u4sGlAHuu44oA+z/3cWs5yOx7jm4S3EzgJUELqpdu3bpwsdFBC0SCUcQHoH06dNHlixZIqtWrVJ3KeLvYMFo27atXniJddvBQrJ48WIVOriYVq5cqeOCgMSCiCS4WFHh4d9//5VSpUqJnUEM7aRJk1RgQEiEC1yqiPOEGxY/CJg7XOVwz+MawI8AXn/s2LHSoUMHtaTAnY4qGDh30KBB+jqwMmAsKHuH7wkl+3DMCqHjtO8uGlAGuO86ogxw7ncXDSgD3HcdUQbY/7uLaeUjMe65+GI94UpDvCY0WsQDIsHp0Ucf9TsHmi4EEo7j9t9//2msIWIHcSG0bt060W47uGQhJI3EMlzwEGY7d+70Wk8iBRbgp59+KvXq1VO3r53BwkfVCQh5uLbDpXz58lrVAnG4iM3E60LYINHMcJ3ihyN37tyaVHbkyBG9FgzLFeqIP//88/LII4+ou3PEiBEaw4sa4ajvbRVO+u6iAWWA+64jygDnfnfRgDLAfdcRZYADvjtPjDFs2DDPnDlzvPd/+eUXT79+/TwrV670DB482O/cHj166PGb8f3333sWLFgQ5/jGjRs9ffr0iXN8yZIlnldffVX/f+TIEU/Lli0927dv9z6+YcMGz9atWz2ff/65Z8CAAXps1qxZnokTJ/q9Dsa9adOmRM2bWIvv90nsBWUAsQLKAPtCGUCsgDIgfmyiAkXXPQeLA1xiga7WxIDKAbB41KpVK85jcIXCyoGSfYjZhOaLutGI+0SHS+Drtlu3bp2+nuG2w5gQ12lo4LDMwCqD10G5vcuXL5sal0rMw/h+if2hDCCRgDLAOVAGkEhAGRA/MR12lVT3HNxnKJ2GWL9g8ZZwtcIlO3v2bK0mAGEBIVOzZk11fxkkxm2H+tIdO3bUeFHEh+bLl0+ee+45PZcQEj6UAYTENpQBhFhLMrg/LH5PV3D9+nWtvw1LRyRjLVFFgdozIfaDMoCQ2IYygJDwoPIRJqtXr1brAywPoGzZslqTmxASG1AGEBLbUAYQEh5UPgghhBBCCCGWEPMJ54QQQgghhBBroPJBCCGEEEIIsQQqH4QQQgghhBBLoPJBCCGEEEIIsQQqH4QQQgghhBBLoPJBCCGEEEIIsQQqH4QQQgghhBBLoPJBCCGEEEIIsQQqH4QQQgghhBBLoPJBCCGEEEIIsQQqH4QQQgghhBCxgv8HEh7d5JFX7JkAAAAASUVORK5CYII=", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAx8AAAEjCAYAAABTg7xaAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAbpxJREFUeJztnQncTOX7/y/7TmSLZE3W7PsS0oIsFdmXEMmSrVIIEVHfkkqhIlkqEVKyFFKKJGSPZMu+74r5vz7X73+mmXnmeTwzz5kz55z5vF+v8ZgzZ2bu+8zc99yf+9qSeTwejxBCCCGEEEJIhEke6TcghBBCCCGEEIoPQgghhBBCiGXQ8kEIIYQQQgixBIoPQgghhBBCiCVQfBBCCCGEEEIsgeKDEEIIIYQQYgkUH4QQQgghhBBLoPgghBBCCCGEWALFByGEEEIIIcQSUlrzNoQQQghxK2fPnpXz58/HOZ48eXLJkydPVNpECLEntHwQQsLi8OHD8swzz0ipUqUkU6ZMkiFDBilSpIh07txZfv31V9te1YoVK0qyZMmkf//+8Z6Dx+vUqSPRBm2woh0rV67UPuNvKOcNHz5c7+/evVvsSsOGDWX8+PGWjQkstpcvXx7nMXyOuFa5c+eWGzduBH3+hQsXJH369Hpeu3btbPc9evLJJ6VmzZpBH+vRo4fky5cvzq1o0aJ+5+G7U7duXcmSJYvOGyVLlpRRo0bJxYsX47zmpEmTpHTp0pIuXTq59dZb5eGHH5Y///xTH/N4PFKmTBn5+uuvk9wvQoi10PJBCAmZVatWSdOmTXW38/7775dWrVpJ2rRpZceOHTJnzhyZNm2avPTSSzJkyBBbXd0tW7Z4hdHs2bPl1VdflRQpUkS7WY4FC/vs2bNLjhw5xI7Mnz9fP++5c+da8n6jR4/WRXFCHD16VMcPFuCBfPnll3L58mWxI3v27NExAzEQjD/++EMaN24s3bp18zueMuV/y4y1a9fqfAEhgU0KCLFvv/1Whg4dqo+h/wbDhg3TOQTzTMeOHWXv3r3ywQcfyPbt22XTpk2SJk0aefHFF6VXr16ydetWFSiEEIfgIYSQEPjrr788mTJl8mTOnNmzfPnyOI8fOXLEU758eazAPB9//LGtru2AAQM8yZIl87Rt21bb98033wQ9D4/dc889nmiDNljRjhUrVmif8deM8+zAtWvXPIULF/a89NJLEX2fzZs3ezp37uy588479drgtmzZsjjn4XPMmTOnJ2vWrJ7u3bsHfa1mzZp5ihUrpq+B76gdvkevvPKKp0GDBp60adNqu2rUqBH0vFtuucUzfvz4BF+refPmnnTp0nn279/vd7xRo0b62idOnND7f/75pydlypSeZ5991u+8mTNnerJkyeL59ttv9f6NGzf0ug8bNiysvhFCogPdrgghIYEdSfh2v/XWW3LvvffGeTxXrlzyxRdfqCUE5xo7wQUKFJAHH3xQvvvuO6lataruVOLY2LFj4+wWnzhxQt04brvtNj2vfPnyMnXqVL/zOnXqJLfccovs379fHnvsMa8bR7NmzdT9JZB///1XZs6cKdWrV5dnn31Wj82YMSNJn35i2wk3mn379unOcMaMGdUdBdcP1xHPh/UAbmsPPPCA9icQ7AjjtXFN77jjDnn++efl6tWrfufAbWXQoEGSP39+Pa9EiRLyv//9T/755x+/83bt2iUPPfSQXqusWbOqe8+pU6fivGdizgt0uzLuwzXmiSee8ParXr16ahULfP1GjRrp47h+AwYM0M8Dz//rr7+858GSVqlSJW0HPu8mTZrEea1g4LPGNffdiQ+lfYnl+PHjahVAXMNdd92V4LmpUqVS1yFYYq5fv+73GL4L33zzjX6XwwXfO3zmcH/EdwAukbh+SQFWhUuXLkmVKlXU2hDfODhz5owUK1ZMx9mxY8f0byAYI61bt9bvvy/4/AFc1gAsp7hW+D6jT3j9K1euSJs2bfR98HkBfJb4fN94442gbluEEJsSJdFDCHEg//zzjyd9+vSe7Nmze/79998Ez23Tpo3uZv7+++96P3/+/J68efOq1aRnz56esWPH6m4szunXr5/3eadPn/YULVrUkzFjRk+fPn08r776qqdJkyZ6Xv/+/b3ndezYUXdj8+XLpzun2KF95JFH9LyGDRvGac+XX36pj02cOFHv4z0yZMjguXDhQliWj1DaiWMFChTwtGjRwjNq1ChPkSJF9FiJEiX0NbA737t3b0+KFCk89erV8z4XbcCOMnaB27Vr53n55Zc99evX1+fivXx3+bEjnSpVKk/Xrl21LTg/efLkek0MDh8+7MmVK5fuPj/11FP6vqVLl9bP09eikdjzsOOM+3/88YfffexG16xZ0zNmzBi1Chh9NcDOd44cOfT2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", "text/plain": [ "
" ] @@ -254,18 +527,18 @@ "# fig.legend(handles, labels, loc=\"lower center\", ncol=4, frameon=False, fontsize=8, bbox_to_anchor=(0.5, -0.1))\n", "fig.suptitle('OpenAI embeddings (n=1M, d=1536)', fontsize=13, x=0.52, y=0.925)\n", "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", - "plt.savefig(f'./openai-intel.png', format='png', dpi=600, bbox_inches='tight')" + "# plt.savefig(f'./openai-intel.png', format='png', dpi=600, bbox_inches='tight')" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "id": "59c8ac4b-9a0d-46ae-be81-788f2c1465bf", "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -392,18 +665,18 @@ "fig.legend(handles, labels, loc=\"upper center\", ncol=2, frameon=False, fontsize=11, bbox_to_anchor=(0.51, 1.05))\n", "\n", "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", - "plt.savefig(f'./ivf2-{arch}.png', format='png', dpi=600, bbox_inches='tight')" + "# plt.savefig(f'./ivf2-{arch}.png', format='png', dpi=600, bbox_inches='tight')" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "793b8092-23b4-4d57-8910-fae70ab344bc", "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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EEIiRsrI/StCnMPly5flxo0bCY6nSZNGUqRIEZUxEUIIIYTYAXpAiE/89NNP8swzz8gDDzwgVapUkfr168uzzz4rK1assMwVfP7556VixYoyceJEj89/8MEH+vzff/+d4LlmzZrJggUL4h1bs2aNnn/t2rUE57dt21Zq1qyZ4LF+/XrXObt375YePXpIrVq1pHbt2tKuXTtZtGhRgvfauXOn9OrVS8/Be3Tr1k2PERItunTpovf+008/7fUczAc4B+f6wtChQ30+16BNmzb6P+bOnev1PfG8O1euXFFZ2rBhQ7zjc+bMkQYNGnh8ry1btkinTp2kevXqOr+9/fbbcunSpXjn7N27V+e9OnXqyL333qvj8yTTBv/++6+0bt1annrqKR8/MSH2Z8eOHbpPaNWqlcf1E/MKnsd5oHHjxjJ48GC//8+mTZtUXs+fPy8XL170+oAc+rPeLlu2TNdrPI/5AvPMiRMnAroWxDv0gJAkee211+Srr76SChUqqLBmz55djh07Jt9//70899xz8vDDD8vAgQMlWbJkUbuap0+fllWrVknKlCll4cKFOk5f2bNnj/zzzz9So0YN17EzZ87IRx995PF8eD6gxPTu3VtKlSoV77lChQq5xoMxpE+fXifbrFmzyrx58/Q6pU6dWic/cODAAT3vzjvvlAEDBuiG55NPPtHrOnv2bEmbNm2AV4SQ4MEG/tSpU5IlS5Z4xyEfa9euDeslxqZg165dLplu2rSpz6/F2CBnZcuWdR07evSofPbZZx7P//PPP9VYUKJECd1sxMXFyYcffih//fWXjBkzRs85fvy4KlCZM2dWJQTeTihGkGnIORQSd/AeMESUKVMmoGtAiB0pWrSoKvMwBk6aNCneevz111/LunXr9BjOA5C5jBkz+v1/oChUrVpVRo4cmcCAaKZz587StWtXn9ZbGFv79u0rjRo1kvbt26vcT5kyRf744w+ZNm2ayj0JDVRASKLMmjVLlQ8ILSYUMy1atJB33nlHPv/8cylSpIi0bNkyalcTGxSADcL7778vv//+u8+LPiax8uXLS6ZMmWTfvn3y8ssvq6XTbDUxA+Xr6tWrqrAUKFDA63jOnj0rU6dOlbx58+oxKB3YRP3www8uBQReGWxeMOZbbrlFj2ET1LNnT9m6datUqlQpoOtBSLBAmT548KAsWbIkgWxDZhBq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KudPPPGEHD9+3OPrzp8/L507d5ZChQpJpUqV5PPPP+cFJ8RiTJ48WcqVK6dy2rx5c9m5c6ceh7zec889us43bNhQfv/990Tf58aNG9K0aVN58803wzZWKiCExDCHDx+WLl26yIABA2TLli1St25d3WTcvHlTunXrJsWLF5fffvtNxo0bJy+//LL8+eefCd7jnXfekd27d8uSJUvk+++/l23btsnbb78dlc9DCPHMTz/9pDI8Z84c17FNmzbJkCFDZNSoUfp73rx55fnnn/f4epx35coVWbNmjc4HQ4cOla1bt/JyE2IRfv/9dxk5cqRMmDBB5Rnr9zPPPCM7duyQl156SV555RXZvHmz1KxZUzp27CiXL1/2+l54D6z94SRlWN+dEGJpFi9eLBUrVpR69erp37169dLNxfbt21U5gWKSPHly9YDMmzdPbrvtNo8bm2effVby5Mmjf7du3TqehTVSnDlzRj7++GOdYGG9gTemU6dOOmZ8lkmTJsm+ffvk9ttvl7Zt20rp0qUjPkZCork5gQKRPXt217EFCxZI48aNVb5B3759pVSpUnL69GnJnDmz67yrV6/KN998IwsXLtTX4wEr6tdff61yRgiJPitWrJAHHnhAqlSpon+3adNGPv30U1m+fLlUr15dnwM9e/aU0aNHy65duzyug/CazJo1Sx566KGwjpceEEJimH///VdSp04d7xi8H5jIChQoIN27d5dixYpJtWrVNDTLkwIChaV27drxNjq5cuWSSDN+/HgNH+nfv7/069dPjh49KhMnTpRr166pRwaKx/Dhw3Ws7777rtdQE0KcGnqFcAqEYHiTf8g+lPeDBw/Gey0UdxwvUqSI6xjmBRwnhFiDrl27qgcEcgwjwowZM9S4gFCqV1991XUevCMwLGJNdAdzAsKoX3/9dUmXLl1Yx+soBQSTYY8ePeIdg4YHK2779u1l0KBBCSbMr776Sr+0p556Sj788EO19BASK8AqgpCKtWvXai4HrCKI9cbvq1evVsVj/fr1MmzYMPVywDPiDjYi6dOnl7Nnz6oFFWFYUAAiyalTpzSE7Mknn9RNElzPiGeHMoTPgZwUeEPy588vDRo0kHz58snPP/8c0TESYjWgjH/33Xcq1+fOnZO33npLj1+/fj3eeZDtjBkzxjt26623MteLEAuROnVqfWBfW6JECZkyZYq0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", "text/plain": [ "
" ] @@ -530,18 +803,18 @@ "fig.legend(handles, labels, loc=\"upper center\", ncol=2, frameon=False, fontsize=11, bbox_to_anchor=(0.51, 1.05))\n", "\n", "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", - "plt.savefig(f'./ivf-{arch}.png', format='png', dpi=600, bbox_inches='tight')" + "# plt.savefig(f'./ivf-{arch}.png', format='png', dpi=600, bbox_inches='tight')" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 6, "id": "3e862226-e72e-45f1-b937-3467322e9e07", "metadata": {}, "outputs": [ { "data": { - "image/png": "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IYTEjPSx+1OEOIsLFy7IlStXktyfKVMmSZcuXVzWRAghhBDidBgBIT5+/vlnefLJJ+XWW2+V2rVrS9OmTeWpp56SxYsXi114+umnpXr16vLRRx8FfPzjjz/Wx/fv35/ksdatW8vs2bMT3ffLL7/o8ZcuXUpy/IMPPigNGzZMcvv11199x2zfvl169OghjRo1ksaNG0uHDh3khx9+SPJaW7dulZ49e+oxeI3HHntM7yPEDnTt2lXl4PHHH0/2GOwNOAbHBsNLL70U9LEG7du317/xzTffJPuaeNyff//9V+VqzZo1ie7/6quvpFmzZgFfa8OGDdKlSxepX7++7nVvvPGGnD9/PtExO3fu1D3wpptukrp16+r6Asm3wX///Sf333+/dO7cOch3TIi72LJli+oP7dq1C3hdxR6Dx3EcaNGihbzwwgsh/53169er7J45c0bOnTuX7A0yGcp1eOHChXodx+PYO7DnHDlyJKzPgqQMIyBEGTZsmMyYMUOqVaumQpk/f345dOiQfPfdd9KnTx+56667ZMCAAZImTZq4rfH48eOybNkySZ8+vcybN0/XGSw7duyQAwcOSIMGDXz3nTx5UsaMGRPweEQ+YMT06tVLKlSokOix66+/3rcerCFbtmy6qebNm1dmzZqln1PGjBl1kwN79uzR44oXLy79+/dXJeezzz7Tz3X69OmSJUuWMD8RQqwFCvyxY8ckT548ie6HrKxcuTKqfxuKwLZt23zy3apVq6Cfi7VB5ipXruy7759//pFJkyYFPP7PP/9Ux0G5cuVUwThx4oSMHj1a/v77bxk5cqQec/jwYTWgcufOrUYIIp8wjCDfkHkYJP7gNeCUqFSpUlifASFO54YbblDDHk7CsWPHJrpOf/3117Jq1Sq9D8cByF+OHDlC/jswFOrUqSNvvvlmEseimUcffVS6desW1HUYTti+ffvKnXfeKQ8//LDuAePGjZM//vhDJkyYoHsAsQ4aIESmTZumxgeEExuHmTZt2shbb70lU6ZMkdKlS8u9994bt3VCKQFQCj788EP57bffgr7QY7OqWrWq5MyZU3bt2iUvvviiejfN3hEzML4uXryoBkuxYsWSXc+pU6dk/PjxUqRIEb0PRgcUp++//95ngCAqA4UFa86cObPeB8XniSeekI0bN0qNGjXC+jwIsRIY1n/99Zf89NNPSeQc8oO0wxIlSkTt70OJgLKPiz8MBxgQV199dVDPxfrgscQaEaGEUrJ79265fPmyFChQIMnxn3zyiSo977zzjk+pgAMBysfatWt1r4Cxcfr0afn888+lYMGCeszNN9+seyIcF/4GCGR58uTJ6rwhxIvgepohQwbp1KmTLFmyRD799FONHpYpU0blGfKGax8eN4DTMxxgLEAXgIMQ2Q3+wHmK2y233BL0dXjq1KlStmxZNYoMIPvIvEDEpVatWmGtlQSGKVgeByFSWPjly5dPYnwY9O7dWxVseAAQFUAKxMyZM+X111+XJk2aaPrRkCFDNBRqBooAXrNevXpy22236fFQ2A2wIUBJRygWHgrjOGwQySkoOOaee+5RL+ncuXODfp9QUAyD4KqrrtJNCX+zZs2aAY/fu3evKjOFCxfWaEigUDLuhxJiGB8A64JCg/oRY0NetGiRGiXY9PCchIQEKVWqlBopND6IXYBc4HwOlGKE+/BY1qxZfU4L7AMTJ070HQODHqkVgwcPTvRceBdx/sNbifQkKA7+QL6gLNx+++3SvHlzlRPD4ZAaMDKg7EDRATBi8Drdu3f3eVnN4LURSUWqqdmjifVBeVq6dKkvIgPng2F8ADyO18R7NQN5h1MDqRvm/YAQJ4PUxnfffVfTpCD/kM1XX33Vd62Hoo60xAULFqiMI5MC4NqJfQDXQ/yEfL/yyiv6E7+bayiNFCz8LegS0Df8wWvDUDBApPTgwYPqILzuuus08mm+4Vr77bffyqBBg6RkyZJBX4cRCcX+YQZOS+OzINZCA8Tj4CKL/EYo/smBzQKeP6QwQWjBe++9p97BgQMHyiOPPKIKyjPPPON7zooVKzTFAcKMTQphUCgeiLKYow64cCO3HBsANqYbb7xRDSJ/JcVIz8BmhQ0BysL8+fMDGgb+wGhCWoRhgMCr2rFjR70lF0GBAQJlq1+/frrx4ob3iaiLuUYEm7PxPvA5wjOKNA5s1ABrxsYFzwvSOLBueGrxf/wNQuwElHJ4+hABNMBFGalZhicRIEKCKAGcCJAvXNBffvlljSLAW2gAzyK8oFBSoKBDphBlWL58eaK/C6UfKY2IfiASgxsMkmBYt26dRisNZwIUDkO+jXRJM1gvHCFQPsxAMSlUqJCmagDUcZg9oQB7Fxwm/pGZ999/X5/P2g/iJhCxQHYEon4wLvATjkAYEwb79u3T+qkHHnhAHQwGMN5Rb4FrIJx9kHkYEcllFMAZAD0DKVpnz5713Y/0J/wN45pqOBQROQmUugUZxV6D66zhlAj2OgyHKtI54WA5evSoyvrbb7+tUc1AtWckMmiAeBwINoAXISVwYQZGMRbyKKFwYMNAriS8Foh4QBkA2JCgEOAnlBooLIiAYCOA4WAAAwIGCCIlUHCwycHLiDQIM9j0cuXKpUVnAEWjyEs3vJUpgc0KYdVg0zmMzwUGFrzCI0aMUEUEXh/UemBD9Oe5555TrysUMqwRkRrz5/XBBx/o5od0Nhhq2Nhg0EC5I8Qu4KIMReDHH3/03YeULHgy8ZgBasFwkQevvfaaXrBR1A2HBM5zAzgsENG877775I477lA5gFMCaYtm4K2EQYBUDUO+UbeF/SI14KyAgyDY/GxD5gzPphkoNIZ3F5EOOEQMUNCK94y9oW3btr77se+h2B3eVnxOhLgFnOtwHuIaDwceUqdwzYdjwQDGwvDhw1UmkKZtBnIPQwGOuypVqmhhekpgj4AzweyggL4Ax4VhTBgyb/7dDPYWo37TINjrMN4fnKF4P9iD4GTE9R41I0b0l1gHDRCipHbhhDIOUOgJjGiCgfE7NiZ4FOBFhMJi7kYBbySKW81RBICwqwGEHIaG8ffM6Rn4Gygcw2PY1KBwBJOGldJmlRxYOwyPoUOHqrcE3TBgXMDLicI6f2BE4Xhs1Ij+PPvss3q/4cnB5ovXgnFihKpR7DtnzpyQ1kVINEEhJs5RswGC/5vTrwyuvfZajXJCWcC5j/Pavy4CCknRokV9v0N+kDZhdMAxN5eA9xGyjZvxOsHKt/9+lBIpRU1hWBn54f5/A8oUlCF4euEJNuQbkVtEWwzjiRC3gIYMUMKR7oQoKJqswMmItEez0e7fqMUAsmxEGPDTfF0PBKIMiDYgpcvsAMH125BLvA6cE4FkHlELpIrjOnzNNdf47g/2OozaMURAEMlBdgNSy6G3oPEEasqItdBd43EMIU0tHQgGRdq0aX25m/ny5Uv0uBEKNVKRACIeuPnj7/X37wIFJcBI9TKnZ6DuBDczeAweS7PX1Qw2Fxg88MyGQqANFZ5bpGz5538DFOfiBuUK7wcdQFDQa3hlYcSYwUaI6IqR7kGIXUDEEqmH8CLi/IXCgYt2IJASifQjpDeYowIGSMkKJEfwchqg1gNGAWTGv7028rNh3GPvCQS8k1A6jMhoMBh7VSBlCHuJuYYDexUMDNSYIOo7atSoRKkYSFGBYgSFBU4WYOSX43c4bBgVIU4F8gcjBNd0ZEEgvRHyC5kzCGSwGyA6iuci1QmygsgCMieSA3KOyAO6ZWGPQMMY6CYwAMzOABSPB2r2YHS984+0BHMdRscrRDKRBmpOI4UzpGXLltpgIlQ9gqQMd0aPg9QkpCLAs2d49QJdlOGRQNTBUPTNOZoAwguwOWXPnl3/j3Ql5In7Eyj1ISWQnoFicP9e4dg0UBCHtQfqggEwwwTpZcnlnQYCCgQ2Xng+/PPEoShhwwLwsmBTxhrMGHnnyDPHuoF/ty0oKHitlDZvQuIB0gcR7UBdF34iJdLcvtoMUhpwLmNfQBQECrq5VTccB/6gvsRI6TTSKytWrJhkBgnSMNHWdvXq1cl2n0F6JVImknNABAKRGygkqAtD2qQBZBQpJ8Zegn0PqaG4D+kcMDL8jYlNmzapZxRpKYGiqGjxifoXQpwGrq9IOUTTFzgBjGsVrsNmAyQ5kLWAPQQpTogawpD48ssvNZqBaGdyQCbReQ61IHAeooud2eg3N5QxAycIOtfBgWLoIAbBXIdR44prP3QiM8jIwJ6BKBCxFqZgeRwoF7hAIo/ZP7pgVjKQ+oRCMgN4BM0YHWvQgQLKPlKtoHyYO1PAs4ji9VAG8BnpGSiSxyZkvmE2Cf5OSmka2KxCTb+CFwaKD+pXzGADwudkKEPwwCDdyr/7FzZOeD7xOcAYgRcYRpI5qoO0FWyYyXXhIiRe4GIM5RnKA85bGCSBZtVAFtDhCoY4FHTjdzObN2/WC7sBHBeIWhpdZ8zNJfzlG17M1LrdhZp+BSCb6NaF92ZWSPA7vK5GrQuUIEQxsQ889NBDASMZqPtASqb5hrQzyD3+D2WIECcCAx2pVvD+G8YHMhx+//33VJ8LJwOiHZAD1JAAFKSjoxwcdikZMKi9QjYBrt1Iv4JBYkRA8TzUmgW6puOairrQQDIXzHUYThE4T9CEwwwipXBChOLEJMHBCAhRBQKKAEKjUPaRzoBIBtKXEAmAJxIhUBgRxoRxeCWRHwnPKJQM5F1iUzC8/+h2YXTKwPPwWrigg0ADvJLDSM9AWNYfbEqoH4HhBOPAnPNpKDtYZ6jTmAHyXrFRomMPCuOwfrQchWcFygiAZwcRFrw+vERI7YBBgjxZGGuGVxYbL4rYoaShlgRpHeiWhU3PKFYnxE7gIo40BFyQ/SN8AA4JpGXBqYDCTTgyIKtwMOCcNiIcUFxQJ4I9BsegWB1KP2omjOgHFPtAEQTIEwwRGBlQfPyjhfDQYqCguYYsWNCND2tChAIRD+xrkEn8H95OAAUIaVfYZ/y7dhn7WKC6D8g9FDfzUERCnAYMAcgmIpuof8I1EClKAE63QDIBoODjeofrL35C7gGiqdAjcD2EboDuUskBowOzemAcmLtfoZUuDAFzXZkBrsVwLgTKusD7SO06jL0OxhaiKIiQQr6xz2E2CPYARjKthwYIUeFErQaMDQgfiq+Qv4zoAtKuMC3Uv58+lG4UksIDiI0F3kr03TeAIMNrik0EYVco7vA6wjAJZeopFBR4QwK10wQI5SJfFMqPebgRgDEFQ8rcySZYYFBgE8IARoScofzAa4v1G33C8brYRPEejc0Um+Pzzz+faIozckrxGcEjinxyvH8YVHiteE6WJyQ5kCttKNKB6itgaMAriA5XRmMK5EfjIg3DxJjlg2gh0qvQwAHeSSjsSNOCJ9RoLoF9Ibm0TBgmMOphhJjTpQwDAbVagepMUgOplahdwftAmgn+PpQsc5QXRgkUEKSfBAIRH0LcCgxxOCUhy7gGwhjHNRYpzZAJyI9/1yvwxRdfaBYAIh/+egP2FUQ7kVaNG/4fCDj9sE9gv0DdSTART0Rm8PeS64YXzHUYBhJmosHQQrQEhgcMGhxvngdErCFNgjkeRUgq4KIM4wJKdnJ1F4QQQgghhCQHa0AIIYQQQgghMYMGCCGEEEIIISRmMAWLEEIIIYQQEjMYASGEEEIIIYTEDBoghBBCCCGEkJhBA4QQQgghhBASM2iAEEIIIYQQQmKGawYRXrlyRYfGYRom6uoxhRaTqjFADgOjJk+erJM8MdAOQ/QKFCgQ7yUTQgghhBDiOVwTAcHkShgaPXv21CmdW7dulalTp+q03pEjR8ptt92m0ywxxfrNN99Ug4UQ4mx27dolPXr0SHTftm3bpH///vLwww/rwEwcQwghhBD74AoD5OLFizJv3jzp3LmzlC9fXm/t2rVTReTHH3+UChUqyO233y5FixbVYzDNe/v27fFeNiEkAo4cOaJRTzNnzpyR4cOHS6VKlWTo0KFStmxZ/f3cuXNxWychhBBCXGiA7Ny5U9KlSyc33nij7746derIK6+8Ilu2bFEDxAApWcWKFZNNmzbFabWEkEgZPXq0Rjs3btyY6P5FixZJnjx51AFx3XXXyf333697w9q1a+O2VkIIIYS4sAZk7969kjdvXpk1a5ZGPED16tVVCYGXNF++fImORxrWqVOn4rRaQkik3HXXXZpWuWbNGlmwYIHvfn+HQ9q0aaV06dKyefNmqV+/fpxWSwghhBDXGSBIr0Ctx4YNG9Qrev78eRk/frzef+HCBcmYMWOi4xEFwf2BgMHC+hBCoocVDSDy58+vtz179iS6//Dhw4kioYbD4Z9//on4bxJCCCHEGlxhgKDr1eXLl6V3796SPXt2ve+///7T4vMsWbJojYgZPJYjR46Ar+UfLSGEOId///03JIcDoNOBkOjCrpOEEFcaIDA6jJtB4cKF1SjJmjWrtt81g9/LlCkTh5USQqJJcg6HbNmyJfscOh0IIYSQ2OKKInTM9jh9+nQiQwN1ITA+qlatqvnfBmfPnpU///xTO2URQtxFrly5AjocUCNGCCGEEHvgCgME7XWR9/3ee+9p693ff/9dBw/ecccd0rhxYy1URXH6jh075N1339Wi1CJFisR72YQQi4FjwexwQBQUhel0OBBCCCH2wRUpWAD1H59++qkMGzZMMmTIIA0bNtROOWjB+fjjj8sXX3whJ0+elHLlykm3bt3ivVxCSBSoW7euTJ8+XW+Ifs6dO1drQDAXhBBCCCH2wBUREIAcb3TAQverMWPGyEMPPaTGhzETBJEPPNa3b99kC9AJcTswvgsVKuS7GYr5q6++qsY5aqM6dOggBw4cCPj85cuXa1SxRIkS+hMDQO2WgvX000/LypUr5aWXXpKjR4/KM88849sLCPEyyAyoXbu2yi/aWK9YsSLJMegk17FjR90Latasqc8hhDiHXr16yaRJk3y/b926VVq2bKlyj3b03333XcDnYZDvo48+qmUNNWrUSDLo13ISCCGe4dZbb03YsWNHovtmz56dUL169YRNmzYlHD9+POHRRx9N6Ny5c5LnXrhwIaFcuXIJEyZMSDh9+nTC119/nVC8ePGEI0eOxPAdEELCYefOnSqv69atSzh//nzCmDFjEipUqJDkuAcffDChV69eCSdPnkxYv359Qvny5fU5hBB7s3DhwoQXXnghoXDhwgmff/653nfx4sWEWrVqqbyfOnVKr/fXX399wpkzZ5I8v0+fPgkPPfRQwqFDhxJWrFiRULp06YQNGzZEbb2uiYAQQlIH83IwIdx/evi9996rdVSIILRp00brJvzBnB2kMyG6iIhj69attevU7t27Y/gOCCHhgChg+vTptS7KaF8PeTeD2VkY7Pncc89ppgAipK1atZKvv/46TqsmhATLb7/9pq3oMSPLAPKMNthdunTRTrHNmzeXqVOn6pBeM+geOXPmTHn++ef1+bVq1dJjoyn7rqkBIYSkDNKR0JK2bdu2snHjRg2zDhkyRIYOHaqbERQSpF+gfgKpF/5UqVJFlixZov/HsE8j/apUqVIxfy+EkNCbtTz22GPSokUL/T1NmjQyduzYRMdcunRJ9wHzLB38TicDIc5IvQJouGSwfv16ufrqq+WBBx6Q1atXqwNy0KBB6jw0s2vXLp2HhSZNBmXLltW062jBCAghHgHGBYwFeDfXrVsnd999t9Z7oIU1GjegTqpy5coyf/58zRcN5EHFprV//34pWbKkPPHEE9KsWbMUZ2wQQuwB6qJgcMyYMUO2b98ugwcPlqeeekodEwaIeqB5w/vvv6/54NgnvvnmG1/UhBDiLI4dOyY//PCDPPjggyrPjzzyiHTu3Fn1ATOnTp1KUh991VVX6eiKaEEDhBCPcMMNN8icOXM0tAqjAcVm11xzjSomRic5tLF++eWXpVOnTjohPBAoXt+zZ4+2toZ3BN3nCCH25ttvv9XoB4rQoVggJQNDOFetWpXoOBgfSLdExLNfv35yyy23JErpIIQ4i0aNGulYCsh9+/btNSKydu3aRMcgHfPChQtJUjL90zSthAYIIR5h8eLFqoSYQUoWUrCMMCsME2xQSMFApMPM7Nmz5ZVXXtH/I2KCrlk333yzDvYkhNgbRC+RYuEf1cTAXjN///23fPbZZxolgecU6ZZwWhBCnMd1112nqZVmsA+gntPMtddeq/oAhngbwCEZzRlaNEAI8QhIo0D6FSIeSK8YPXq0FqxhZs5bb72lGw/Crbgfhog5F9TYyNDKGkXrUEoQzsWcDXhXCCH2Bm134YBYunSpejbRXhf7ANptmhk4cKC2ssdjKEpFhMSoGyGEOIs777xTr/lwIOL6PnHiRC0496/zhCMCRedoyY+0bFznkTGBJhRRI2r9tQghtgOt+KpVq6bt9dq0aZOwbds2bc2Htptot4n727Ztm7B582Y9/q+//kooWLCg/gRTpkxJqFevnrbzrF+/fsLEiRPj/I4IIcEyY8aMhIYNGyaULFkyoUWLFr4Wm5DxZcuW6f/Xrl2b0LRpU5XxJk2aJKxZsybOqyaEhMLdd9/ta8MLINtowY/2u61atUrYsmWL7zGz7B89ejShQ4cOKvu1a9dOmDt3bkI0SYN/omfeEEIIIYQQQsj/YAoWIYQQQgghJGbQACGEEEIIIYTEDBoghBBCCLE9GJbWo0ePRPedOHFCRowYofMNevbsqYXzhBD7w0nohBBCCLE1mEs0ZcqUJPdjgCpaDD///PPayQ/DFgsXLpykuxchxF7QACGEEEKIbUFr8IULF+r/8+TJ47t/69atsnv3bhk1apQaISVKlNAoyebNm2mAEGJzaIAQQgghxLbcddddOsdkzZo1smDBAt/9GzdulIoVK6rxYdCxY8c4rZIQEgo0QAghhBBiW/Lnz6+3PXv2JLp/3759OjQVEZC1a9dK9uzZ5dZbb5U77rgjbmslhAQHDRBCPAAKNYPlzz//1Fvjxo1D+hu5cuUKY2WEkHjIe7hybieZx2Tn1atXS5MmTeS5555TA+Wzzz6TzJkzy0033ZRsLcmVK1divlZCoknGjBmjJuc///yzFCtWTAoVKhTU8QUKFAjqOBoghJBEYKMxNp1INi1CiH1xg5xjjjIKzo20q5IlS8pff/0lixcvTtYAyZcvX4xXSUj8nA7FLJBzPA/Pr1y5slgJ2/ASQgJuWrhh0yGEuBOnyzlSrgoWLJjoPhgkJ0+ejNuaCHGjnDeOgpMi5AjIP//8I0uWLJFVq1bJgQMH5MyZM5IjRw4NuaDrRIMGDZJsCIQQ5+EGDykhxL1yfv3118v8+fM1EpImTRq9D614g00VIcQrFLOhnAcdAUHe5AsvvCAtW7bUoT+HDh3SUGb58uX157Fjx+T999+X1q1by4ABA+TgwYPRXTkhJGj+++8/T3pICSHulfP69eurE3TcuHHajhfrx61Zs2bxXhohtqOYzeQ8qAjI9OnT5eOPP5Z69erJhx9+qG3vMmTIkOS4y5cva//tr7/+Wtq3by8PP/yw3ggh8QUyiVaWgeTWiZ4TQoi1OFHOkYI1cOBANUBefPFFnRHSqVMnufHGG+O9NEJsSTEbyXmaBMQuU2Hw4MHStWvXkFKrEAHBpoBoCCEkvhw+fDgiIySYbhrx7ohDCPmfvEdLzs1Q5glxXqfLcLtjWS3vQRkghBBng80JaVjRNEKojBBin8nh0XQ2GFDmCXGmARKOEWK1vIfVBWvDhg3y/fff6/+PHj0qffv2lYceekjGjx9v6eIIIdYBZQRKCYwQ1oQQ4l4o54QQu8t5yAbIggULpEuXLrJ06VL9/Y033pBff/1Vcy8xjXTy5MnRWCchxAJohBDifijnhHiL/xwo5yEbIGPHjpWbb75Zhg4dqm8YhsjTTz8t7777rhaez5w5MzorJYRYApUTQtwP5ZwQ7/C1A+U8ZANkz5490qRJE/3/77//LhcvXtRWeKBSpUo6G4QQYm+onBDifijnhHiDuxwo5yEbINmyZZNLly7p/zGMsHjx4r7CFNSDpE3L4eqEOAEqJ4S4H8o5Ie4ngwPlPGRroWbNmtped8KECTJ16lRp2LCh3r99+3aZNGmSlCtXLhrrJITYfNMihNgTJyonhBB3y3nIbXgxER01Hxg4WKJECW33h6hI7dq1dSL622+/LWXLlo3eigkhlrfos6JFr51acp49e1a78q1fv14yZswojRo1kjZt2jBCSzwt71a34raTzBPiZU6YZD5aLfdtMwfk9OnTOoXUYNmyZVKlShXJmjWrlesjhMSoR3ikm5adlJF33nlHTp48KQ8++KAcO3ZMHSWtW7eW5s2bx3tphMRV3q1UTiBThBD7yfx/UTBCbDEHBJiND1CvXj0aH4R4PHxrB9AYA/Vp999/v5QsWVJq1KghTZs2lV9++SXeSyMk7jDtkhD3k8EB6VghGyDoctWrVy+1iFAP4n+rVatWVBZKCIk+bjBCzp07JwjsIvXK/L6M5hmEeB2rlBNCiH3JYHMjJH2oTxg0aJDs3LlTWrRowYgHIS7ftCIJ38YLhImLFCmi6+/WrZumYs2fP1+jtIQQd8g5ISQ2ch4tZ0PINSC4iPft25e5n4S4rAbEn1BzSO1UA7Jt2zYZMmSIXLlyRaMhWNubb74pV111VcDGGjiOELdgjv6lRiS54kh3DIYCBQqE9LqEEPc3mgnZAEERZ/fu3eXOO++0dCGEEHsZIKFuWnYxQI4fPy79+vXTlNCbbrpJTp06JZMnT5bcuXPLgAED4r08Qmwn7+EqJ3aReUK8zgkHNpoJuQbk3nvvlU8//VT+/vtvSxdCCIke6GThlZqQlStXSpYsWaRz585ahI7ufPj/hg0b5MyZM/FeHiG2w4lyTghxtpyHXANyxx13qDfxnnvuUWsIF3p/vvnmG6vWRwix0AAJJ5fTabni6dKlS3Jf+vTpJU2aNPqTEOJ8OSeEOFvOQ46ADB48WId81a1bVzteVaxYMcktnuBD7dGjR1zXQIjdQNc6o6e3GzwnKVG5cmWdU4RI7e7du3VoKoYSoh1v5syZ4708QqKOF+ScECKOlvOQa0AaNGigCn67du3Ebuzbt0/69++vM0o++OADvW/48OGycePGRMehMw474hAv5oeilV4kPfxTyiG1Uz749u3bZerUqbJr1y7JlCmTVKtWTdq3b8/OfcQTzJw5M2pybleZJ8TLnHBgo5mQ8xHy588v2bJlE7uBLjaYdoyc70OHDiUySnr27CmFChXy3YdiVEK8Ggkx+nm7OR2rVKlS8vzzz8d7GYTEBa/IOSEkfOIt5yGnYHXt2lXTGdC60k788MMPmt9tjIwHGDx29OhRqVChghQuXNh3oxeUeBkvpWMR4lUo54R4hz8dKOchR0CwSCj1GERYvHjxJH31UeiJSEQsOXz4sMyYMUPrU9D/3+Cff/7R9AukY+H+nDlz6rqRRkaIl6GHlBD3QzknxBv86cBGM2G1hCldurTYibFjx0qzZs2kYMGCiQyQgwcPyr///is33HCD3H333bJp0yb5+OOP9cOtXbt2wNfiUDLilcFkVisn5tTHlOBQMkJiB40QQtxPYwfKechF6HZj8eLFMmfOHBk2bJi231y0aJFMmzZNox4XLlzQm7lw5pNPPtG6kBdffDGu6ybELgVqVhWmIz2TEGJPeY9GAwoWoRNiD044sNFMUDUgv/32W1gv/uuvv0q0QVRj79690qlTJ+nQoYOMGTNGjh07pv/HY/4fWJEiReTkyZNRXxchXssVJ4TYF9aEEOJ+GjtIzoOKgKB9Zb58+aRjx45StWrVVF909erV2oMf6U+IOEST48ePy7lz5xL97e+++05eeOEF+fbbbyVt2rSJPLNIwcIckz59+kR1XYQ4rUVfpJ4TekMJ8U7EE0oKumISQuwn8z87IOIZVA3IxIkTZcqUKaq0o4UteuqXLVtWF4Mi9DNnzqghgIgDoh4YAta9e3e57777JNpgPea2ujt27NBULHS7ql69urzzzjty7bXX6nr/+OMPWbJkCdtzEhKFHFJCiLdyxZl2SYg9aeyAmpCQakCQuvTll19qncXWrVvF/FR0v0Jx+i233CKtW7eOmzfUXAMCFi5cKLNmzdLichS/Ym3sgkW8RihDisL1nDACQoh3Ip7wkDICQoi9Zf5nG0c8wy5CR9QDSj2MEszVQJQhS5Ysli6OEBKfKanhbFo0QAhxlrwz7ZIQd3DCgY1mHN8FixBivQESzqZFZYQQ70Q8AWWeEGfI/M82jHiGPAmdEOINIu2mQQixP5RzQtxPYwu6Y1kNDRBCSLJQOSHE/VDOCXE/jW0m5zRACPEARjcMN2xahBDroZwT4n4a20jOaYAQ4gGQ90kjhBCSEpRzQtxPY5vIedgGCApSNm/eLMuXL9e5H+iKRQixJ0bxGY0QQtwP5ZwQYnc5D8sA+fzzz3Xex8MPPyy9e/eWXbt2Sa9evXToH5tqEWJPaIQQ4g3cKufQNXr06JHovp07d8qgQYNUH+nWrZtMmDBBLl++HLc1EuIUGsdZzkM2QGbPni0jR46Upk2byvDhw30GB4aUYADgpEmTorFOQogF0AghxP24Uc4xd2zKlCmJ7jt37py8/vrrcvXVV8uQIUPUCMF7njNnTtzWSYiTaBxHOQ/ZAIGBcd9998mAAQOkdu3avvvvvPNOadu2rcycOdPqNRJCLMSNygkhxL1yPnr0aOnZs6ds3Lgx0f3r1q2TK1eu6IC0okWLSt26ddU5umDBgritlZB48LMD5TxkA+Svv/6SatWqBXysUqVKcuDAASvWRQiJIm5STggh7pZzZFi8+uqr0qZNm0T3o/a0TJkykj59et99OXPmlJMnT8ZhlYTEj2IOlPOQDZB8+fJpHmYgDh8+LNmyZbNiXYSQKOMW5YQQ4m45xwRmvAfoH2YQ7ejbt6/v90uXLsnSpUs1GkKIlyjmQDn/n9sgSFq2bCnjx4+X6667TmrWrKn3pUmTRrZv367FX9gQCCHOABsWwKaFzScc8Dw8v3LlyhavjhBiNzk3v56dgAP0gw8+kD179sjAgQNTrCVB2hYhbiJjxoxRl/NDhw4F9RoFChQI6rg0CSG2rYLgDh48WObOnSvp0qXTbhNZsmSRCxcuaGrWiBEjJHPmzKG8JCEkypw4cSLFxw2vR7ibFsiVK1fYzyWERF/erZBzKCeGtzXWMr9o0SJtdgNDw8xPP/2k3TmRgYEuWTfccENM10WInWT+T4vl3MBqeQ85ApI2bVo1QO655x5ZtmyZHDt2TIUexke9evU0GkIIcRZWeE4IIfbGag+pHaKeEydOlHnz5kmTJk2kffv26hAlxMsUc0jEM2QDxKBixYp6I4S4AxohhLgfN6VdYhgysjG6dOmiBgghxDlGSFgGyPz58+W3336Ts2fPJhk8iAjIiy++aNX6CCExhEYIIe7HKuUk3qxcuVLrUcuVKycHDx703Y/0cBSuE+Jlitk84hmyAYJp55gFgrSrrFmzWroYQkj8oRFCiPtxg5yjKBajAZ566qlE96Nb1nvvvRe3dRFiF4rZOOIZchE6wpzodPXss89auhBCSPyK0AMRaiEbi9AJcZ68R1KwSpknxB6ccGCjmZDngPz3339So0YNSxdBCHFnX3FCiL2hnBPiforZUM5DNkBq1aolCxYsiM5qCCFRAY4Dt2xahBBroZwT4n6K2UzOQ07BwhAftLpD4Re6YPm3vEMROjpSEELsw+jRo+Wuu+6SDBkyhPX8YMK3dkrHwLyiKVOmyOLFi7VRBnJXH3nkEc4oIp4gnJRLwLRLQpzJiRikXVot7yEbIKNGjZJx48Yl/4Jp0siqVausWBshxMIpwV9//XVUjRA7KSNffvmlLF++XDp37qy/jxkzRqpWrSoPP/xwvJdGSEzkPZrOBjvKPCFe5kSITodwjJC4DyL86quv5NZbb9Ui9OzZs1u6GEJIdIAyAuMjEiPEKV1zLl68qIPJ+vTpI+XLl9f72rVrJ7Nnz4730giJCV6Qc0JI+NhBzkOuAbl8+bJ2woIlhF7bgW6EEHsbIW6uCdm5c6fuQzfeeKPvvjp16sgrr7wS13UREiu8IOeEkMiIt5yHbIAg+sFNiRBn4gUjZO/evZI3b16ZNWuW9OjRQ2+ffvqpnD9/Pt5LIyQmeEHOCSH/w4lyHnINyPTp07UOBKkN6Ih11VVXJTmmVatWVq6REGJxfig2K6trQuySD/7NN9/oPlW6dGm599571fAYP368/g5jJFBjDRStE+IWMmbMGDU59093DIYCBQqE9bcJIe5tNBOyAZLaDBAWoRPijAI1q5UTuxggM2fOlGnTpsnHH3/sq1PDnjRy5Eg1RNKnD7n0jRDHyns0jRC7yDwhXuewAxvNhHwlRlqDFVy4cEHWrVsne/bskTNnzmh7TLy5MmXKSKlSpSz5G4SQ2BWmt27dWuwAjA7jZlC4cGGtXzt9+rTkzp07rusjJJZ4qQEFIV4lgwPlPGQDpGDBghH/0QkTJmgr33PnzmmPfv8IyjXXXCM9e/bUehNCiDM2Lbtw/fXXq6Fx7NgxyZMnj68uJGvWrPTYEk/iROWEEOJuOQ8qBevFF1+UDh066IUd/0/xBdOkkcGDByf7+NSpU+Xtt9+Wtm3bSoMGDaRkyZKSI0cOfR4iIbt375Y5c+bIt99+K0OGDJGmTZuG984IIUH3CLciTcNOyv3LL7+sEY/7779fo62ffPKJ7jdt2rSJ99IIiZu8uzXtkhCvcyIGaZdxqQFp2bKlGhVVqlSRFi1aqLEQbprWPffcI7fddpt069YtxdcYPny4rFmzRg0WQkj0hxRFumnZSRmBMwOdr7CH4L00bNhQ2rdvzzbhRLwu71YqJ3ZJuyTE65xwYKOZkIvQI6VevXoybNgwadSoUYrHIfwzcOBAWbZsWczWRojXp6RGsmnZyQAhxMvEIuIJxaRy5cphrpAQ4vVGMyHPAUEkBIO+ArFjxw558803U3x+oUKFZMWKFan+HXguUQtCCHHW/ABCiDfmhBBC7EsGm88DCqoIff/+/bJv3z79/+zZs6V48eJa4OnPwoULtQf/M888k+xrPfzww2rEnDx5UtO5UFeSM2dOfQyFo9u3b5d58+bprX///uG/M0JI3ArZCCH2hnJOiPvJYONGM0GlYGHAyZgxYxLVfpifhvuN3+vWrSvvvvtuiq83d+5cHWZ48ODBJPUkeB20yezevbt+WISQ2KVgRRK+ZQoWIc6Td6ZdEuJ8Tjiw0UxQBsiBAwc0CoJDYRigRW65cuWSHIep6JjjkVqROsBrbdu2TbZu3aof3KVLl7QbVokSJaRixYocFkZInA2QUDctKiOEOFPew1VOKPOE2IMTDmw0E3IROlKwateuLfny5bN0IYSQ6LF+/fqww6jBblpURgjxTsQTUOYJsQcnHNhoJuQi9DvvvJPGByEOw+hkEQ4sTCfE/VDOCXE/GWwk5yEbIIQQ54EWejRCCPEGlHNCiN3lPOZzQFKbpB7KVHVCSGjhWbTSM9rqhUNK4VumYxBiD2bOnBk1OTdDmSfEHpxwYKOZmBsgAwYMkEWLFukbz5Ytm96SXVyaNNrWNzXQIhhdunbv3q0tfW+++WZp1aqVPh+F7piIjCL6IkWKyCOPPKKF7oR4dXOKlhFCZYQQ+8h7NJ0NBpR5QuzBCQc2mgnZABk3bpw0a9YsoiGBv/76q3bTeuKJJ3QuSCRcuXJF+vbtK9dee620bt1aDQ20De7YsaPUqFFDevfuLbfccou2B16yZIne3n77bcmaNWtEf5cQJ29O0VBOqIwQ4p2IJ6DME2IP1juw0UzINSAfffSRRhcee+wx7Yh1/vz5kP9o9erVdSK6FezatUvbBD/66KM6ILFevXrSoEED/TIQacmTJ4+0a9dOrrvuOrn//vslXbp0snbtWkv+NiFOhTUhhLgfyjkh3uBPB8p5yAbIt99+q3NAzp07p/UZt912m7zwwguyatWqkF6nbdu2Urp0aYmUCxcu6NwQcypX2rRp5eLFi7JlyxapUKFCovvxNzdv3hzx3yXE6VA5IcT9UM4JcT9OlPOIakD27t0rP/zwg8yfP1927NihaVmIjuBNIPIQD/bs2SPDhg2Te++9V9fVqFEjueOOO3yPT5gwQf755x9N2yLEK6SUH2pVmkbXrl0jWCEhJJryzrRLQtzLCQc2mrGkCP3333+XsWPHyi+//KK/Y9HNmzeXHj16pLpg1HAgXQoT1DFJ3f/3UOjSpYucPXtWChYsKIMGDZKXXnpJ55Y0adLEd8y0adN0+jqiNoE4cuSIroEQN5ExY8YUH7di0wp2KylQoEBYf4MQEpnDwWrlhAYIIfbghAMbzYRtgGzatEkjDLghogClAsXpSMnauHGjFquj7uL9999P8XVQQ4IoBbpYVapUKcnvoYBuWFjL9OnTtQMW3hrqQcwRkEmTJsmhQ4fkqaeeCudtE+LaDhmRblpURgjxTsQTykn+/PkjWCUhxMuNZtKH+oSRI0fKTz/9pIXfmTNnlptuukmjDCgsh9IPrr/+esmSJYsMHTo0qNf0t4FCsYmQ74Z6D9R2FC5cWG85cuTQCEf58uXl2LFjiY7H73nz5g369QnxUg4pNi0Q7qZFCHG3nJtzxZl2SYj75fyuIOeERL0I/fPPP9cOVhgo+P3332shOtrdGsaHAd4wUqKiDVr6ojOXmUuXLmm3KxSgmwvOL1++rIXpMEwIIdYXshFCvFOwSgixL41tXpgekgECxR61Fa+//rpGPRDlSA7UcGAWR7SpU6eOpl3BMMIgwt9++00++eQTqV+/vjRs2FAjNUjJQrveUaNGadQm1NQuQrwEjRBC3I8VygkhxDtGSFwNkPTp08vw4cNDbrkbTZBy9cwzz2jdCaIxMD4qV64snTp10ny1p59+WlauXKkF6UePHtVjER0hhCQPjRBC3A/lnBD309imEc+Qa0BQ0I1ZIOgs5Z92FS+qVKmit0CUK1dO3njjjZiviRCnw5oQQtyPG+Qc3S/Hjx+vHTTR8Q+NbNq0aaOzvwghYklNSNwNELS4/fHHH3WqeK1atZKkYcEo6datm5VrJITECTcoJ4QQd8s5umaePHlSnnvuOW00M3r0aG3jj3EAhBB7ynnIBojRVvfUqVOyc+fOJI/TACHEfmDTwebjhk2LEGI9TpVzdMFEWjjSrEuWLKk3DCTGXDIaIITYV85DNkBWr14d0R9EC1/zYMBMmTJpFyu07vX//ZVXXpGBAwdG9PcIIf+30dAIIYS4Tc7PnTunrfvNw1aRLoKmOYQQ+8p52kgHn6Co5cKFC0E/B/M5lixZ8r8FpE0r1apV8009x+/4f4cOHeSbb76JZHmEkP+PMZDI2HTCgQWrhDgDL8k5ms0UKVJEu/TAGEHnSwxIRjMaQoh95TwsAwTCfffdd+vU8/vuu0+2bt0qTzzxhEydOjXV52I6OvI0V6xYEfBxFJI98sgjOtWcQ44IsQ4aIYR4A6/JOWaOrVmzRn/26dNHox8tWrSI97IIsTWN4yznaRJCGTsuotELCHjt2rWlbt268vbbb8vYsWNl3bp1OmcDEQ7MCEkpavLYY4/J3r175Z133tEJ6uDQoUM63BCbSNGiRXWKetmyZSN/h4QQlTsDY8MJNx0LQLkxDBqzJ5IQEn+M7IRoyLkZO8j88ePHpV+/flKzZk256aabtD518uTJkjt3bhkwYECS448cOSJXrlyJy1oJiRYZTSmIVsu5ud4qGAoUKBAdAwTzNRDuHDJkiJw/f16H/cEAwXC/l19+WSePQ/hT2xy7d++uUY6RI0fqfI5hw4bJ6dOntXVer169tBaEEGK9AQKioZzYQRkhhPxP3qNthNhB5r/77juZN2+eOjSN0QBbtmzRuWDojpUtW7Z4L5GQqDNz5syoOhuiIe8hp2Bt27ZNbr755oCPISry119/pfoaeBOIlmCI4OOPPy79+/dX6w3GyLPPPkvjg5Aow3QsQtyPF+Q80GBhDE2GMYKfhHiBYg6U85ANEBgPBw8eDPjYmTNngjYeDCME6VZg0KBBasAQQmKDF5QTQryO2+UcxebInvj0009l9+7dmoWBWtIaNWpI5syZ4708QmJCMQfKecgGyC233CKffPKJbNy40XcfPA1Io5oyZYqmZAULjBCj5S5qRwLNFSGEOGPTsiPojNOjR494L4OQuOJE5SRY8ufPr1kU+/fv19rR9957T98r55ERr1HMYXIecg0IWu727t1b1q5dq4KP4nHUhOBnoUKFdAJpSnlijz76aJL7UDS2a9cuyZEjh5QoUeJ/i0uTRl+PEGJtDYg/VuSK2yEf3AxqzKCYZM+eXT744IN4L4eQuMu71TUhdpN5QrzKCQc2mgk5QRIhzQ8//FB++OEHWb58uUY+cIFv166dtGzZMtWQJ4wKo1DM/KaqVq0a+uoJIZZgbDKRDCu0E+hyA+cFpiLDOUIIsUbOzUPMOGuDEPfLebEoDSsMOQJCCHFfBMQKz4mdvKHojLN69WpNCZ02bRojIMRTxCLiCeWkdevWYT+fEBJdmbd7xDPkCEgw08lbtWoV7noIIXHEDZGQw4cPy4wZM7QNJ7r2EUKi4yElhNiXYjaPeIZsgGDWRyDMaVU0QAhxLk43QjCXqFmzZlKwYMGgDBAOJiNeHEpmhZwHm94Y7GAyQoh9jZC4GyCzZs1K9Dsu3CgixwRzdMGC15EQ4mycaoQsXrxYQ9EtWrQI+jn58uWL6poIsWvKZaRyTsOCEPtTzKYRT0trQJYsWSITJkzQ6aOEEOcpJJHkkNqhBgSzhZYuXeobTgYHyeXLlyVDhgzSq1cvqVatWryXSIjt5D3cXHE7yDwhRIKS+UhrQqyWd0sNEORe33333WqIEELsA2QTSng4BLtp2UEZOX78uJw7d873OwrRUZCOOUN58+blYDLiCcJxOISjnNhB5gkh4shGMyEPIkwtPStr1qxWviQhxKKBfP/991/chhvFity5c0vhwoV9N/yOaAj+T+ODEHfIOSHE+XIecg1I8+bNA94Pr+PZs2elU6dOVqyLEGIhd911lxoh+BlOJMSpNSGEeBE4GyjnhBA7y3nIKVgvvfRSkkGCAJGPKlWqyC233GLl+gghFoVnoZREYoSkFr5lOgYh9gBDOKMl52Yo84R4J+0yl51rQAgh9t6commEUBkhxD41X9F0NhhQ5gmxBycc2GgmZANk7dq1If2BqlWrhromQkgUN6doGSFURgjxTsQTUOYJsQeHHdhoJmQDpEaNGolSsPD0QClZxv2rVq2yZqWEEMu8I9FQTqiMEOKdiCegzBPinbTLXPE2QNBid9CgQdKkSRNp1KiR5MyZU44ePSo//fSTLFy4UJ599lm55pprfMfXrFnT0gUTQqwJz1qtnFAZIcQ7EU9AmSfEO2mXueJtgDz11FPa0vKZZ55J8tjrr78uBw8elBEjRli5RkJIlPJDrVROWrduHeEqCSFOiXgCGiCE2IMTDmw0E/IckDVr1kj16tUDPlarVi19nBDiDLBJGS16I50TQghxv5zbYX4AIcT5ch6yAYJ2u5s3bw742J49eyRTpkxWrIsQ4rBNixBiX5ymnBBC3C3nIRsgLVu2lAkTJsi4ceNk37598u+//8qRI0dk+vTp8sknn0jTpk2js1JCiK03LUKIvXGSckIIcbech1wDcuXKFXnzzTflq6++0k5XBvg/Cs7feustyZw5czTWSgiJco/wSHJImQ9OiDPk3apc8cqVK4e5QkKs4Z9//pGbb75ZRo0aJQ0bNvTd//fff2tZgD/ozgrnuds44cBGM2EPIkSx+cqVK7UDVpYsWaRcuXJSsWJFSxdHCIn9kKJwNy0aIIQ4R96tUE4o8yTePPTQQ+qlnzRpUiIDJNQmSk7nhAMbzaQP94lotduqVStLF0MIsVf4NpJNixBiXyjnxOnA6EBdcsGCBVM9dt68ebJx40bN4PESGSyQ82jVeIZcA0IIcT+sCSHE/VDOiVNBitUHH3wgw4YNS/XYCxcuyIsvvigDBw6UdOnSxWR9diKDTRvN0AAhhASEygkh7odyTpwGKgeQTgWDIm/evKkej5rlokWLpjjl2+1ksKGc0wAhrqNXr14amvXnjz/+UCt+9+7dAZ+3fPly3aBKlCihPxGydQvI33TLpkUIsRbKOXES6MIKw6N58+ZBHT927Fh59NFHxetksJmc0wAhrgGFaAizwtvhD4Std+/ecvHixYDPRTvprl27SufOneX333/XY3v06KFNFtyAUUTmhk2LEGI9lHPiFJYuXSrffvutFCpUSG979+6Vdu3aBazvWLVqla9TFhFbyTkNEOIafvvtNzUk8ufPn+Sxt99+Wxo0aJDsczds2KDto9FRI1u2bNrtAd3dkouWOA1EdGiEEOINKOfEzXz66aeyf/9+3+3aa6+VL774ImB3qyVLlki9evXYZMGGcm6pAbJ27Vp2xiJxTb16/fXXNYXKzLp16+SHH36Qvn37JvvcKlWq6EYFzp8/LzNmzND/lypVStwCjRBCvAHlnHgVRESQTm2A/3NejT3l3PIISJhjRQiJCuh+0adPH3njjTckU6ZMyR6HzhiIeMCbUrJkSXniiSekWbNmGg1xEzRCCHE/lHPiJZBmZcwAwTW8bt26vseQko10amI/ObfUAKlatarMmjVL4sWuXbt4opFEICLSpEkTPTeD9Z7s2bNHfvzxR/WcINTrNqicEOJ+KOeEeIc/HSjnrqkBOXLkiEyZMiXJ/cOHD5cOHTokui1btiwuaySxB2lVH374oa9YDSAfdOrUqYmOmz17trzyyis+gSxXrpwWrYUr1HaHyglxG/Pnz5dGjRppCiZkd+HChckei2uAF9KFKeeEeIM/HSjnIU9CHzx4cLKPpUmTRrJnz64pLLgAxCp9ZfTo0b6LTZ48eRI9tm/fPunZs6dP+QS5c+eOybqIPZQSMzgPoHwUL1480f3XXXed9hWvX7++1KxZU7Zs2SJz586V1157TdysnKBzWLhDhvwnrBISL9Ctrlu3bvLyyy9LixYttIarS5cuGsW8+uqrfcfhAv39999rG89rrrlGvIDVcs5iXkLsR2MHynnIERDk1y1YsEA9xuvXr9d0lV9//VV//+mnn9TjDE/yfffdp63RYgE+rFdffVXatGmT6P5Lly7phalChQpSuHBh3y1r1qwxWRex/yRVGCT4WbFiRRk6dKgONkL048knn1SD5NZbbxU3Y6WHlJB4sXLlSnUi3H///er4QqQbXe3WrFmT6Dhck5CqW6RIEfESjIQQ4n4aO0zO0ySEWDUOQwMpLSNGjJAyZcok6oDVv39/7USEAiAocFDuYulBXrRokUybNk0++OADX/QDcyFuvPFG2bZtm+TMmVO9Yym1YyXEjZw4cSLFx+E5gdckHM8JwGYVqP0xIbHg2LFjerv++uv1d7TPxj6PWQHocOcPUjAnT54s33zzjXhJ3q2Qc7OHNFeuXBGulJDIr1/AULzDnXbu9HP5hOkzslrOo/UZhRwBQVEuhrWZjQ+AIt9OnTrJmDFjdJFt27bVyEg8OXjwoM6FuOGGG+S5557TLgkff/yxrFixIq7rIsSNnhNC4gVSbw3jA44oRMMxyyeQ8eFlnOYhJSRYDIXbSEPyMo0dIufpw1HqEUkIRL58+XTiJMiRI4fOU4gnSKV5//33fVYb8v6xPsyEqF27drLF7FeuXInxSgmJLhkzZox6DumhQ4eCOq5AgQIhvzYhqXHy5El59tlnNQ0YPx9++OF4L8n1ueJdu3YVu4F1ofbPyIQg3sE4n3F+hxsJcQuNHVATErIBgsgH0pwQTTArNVDaEc5GHi7YuHGjFCxYUOIJcoBxM4Pc382bNyf7HBhRhHgxhB3ppkXDgsQLOLvuvvtuveYsXryY+3iMlBO7gbRrKExohkO8CY0Q5zSaCdkAefrpp6V79+7SsmVLqVOnjuTNm1dOnTqlRYAHDhzQmg8UpyNV6/HHH5d48sknn8jly5cTeWmQG4xCdOIu5Tq5nMVQSCmH1On5obHctAiJNZB9pNuiu1Uw0T5ijXJiJ+AERUdMdOEMNhpL3AmNEGdEPNOGk9Y0YcIEqVGjhhodn3/+ubY1RKvDN998U9vvYqI0itFRExJPKlWqpN4wtFOF4YGfCM9jwjVxF1bkLDKH1LocUkJiCSLu6G4F+TVm/uCGYnP8RDte4m45R2p1+vTpPa9wkv+D13Pra0Li3gULNRJ2DW/7d8ECmA+C6exYN1JEUJjILljuTS+KViTE6RGQYFOw/Amlm4bTPyNC3EKo8h5u1xy7yPzhw4e1hTrmlKHjpb8eQJwNvt9oZDbY8VyOpsxH2h3L6s8oZAOkVq1aOqgNUYSbbropSY0FIfEWvGgYIV7YnCLdtOzyGaEYGSmgv//+u6ZllC9fXofS2WV9hNhR3sNRTuwiU5gDVrZsWXUwBnJEmmGjGecxc+bMqKVXG1y8eFGcTMYgU08jMUKC/YyCrQcN2QBBm10MHNy5c6cO9MMX2rx5c03JwiR0QuxwsbXaCLHLhTaSDTyS1IRgNi27fEbDhg2Ts2fPahckDCOFMYJaNbTiJsQLxCLiaReZR5r1nDlzVO7TpUuXqgFCnBkBiWaNp13O5VjJvF0iniEbIAaYgP7jjz/qVPTt27frELI77rhDb0Y/dkLiKXhWGiHwrDkZNIaIZEhTMJuWHTZwDKPr0aOHvPzyy1qMChAJgYcULblhiBBiV1A7iQyDBx54IMljcPw99NBDie6bNGmSZiLEI+JpF5kfNWqULF26VI0PgOgGms9gz8fnWa1atXgvkUQIG83YI+0y7oMIDYoWLappDZgmO2PGDGnVqpV88cUXATdOQpxemO50rCjIc0LBKjZhDKUz2oEDY24RUrOI88F37H+DhxQdkPAz0OPB3GCkI1Jo/B5LIJcvvviifPXVV8keg0YqPXv2lP379/tugYwP4/XcLOdm2rVrJ8OHD1cnA24YQgmZx//RNIe4Azc1msGcmkaNGkmJEiW0cRNqlQPx1ltvScWKFTW98LHHHpMzZ85YtgY7yHnYBgg4d+6cdp748MMP1RBBugMmohPitk3LDXjBCMGGjtQLs4cM7xf5sfGeS0Sih9OVk99++03bCCOTIKWsg2D3IrfLuZncuXNra33jht8RDcH/WaPqLpwu5+Do0aPSrVs3NSgQne/YsaM6840h3ubo5rx58/S9IsKH1tKI9llJvOU85BQseIbwxeG2evVqLUopVaqUpl41bdqUw8hIzAnGWxlp+NZN4dlgu4KEGr6122d04cIFbROO1JX27dtLixYtAh7HolT3FFtamaZRt25diTWdO3fWmkoMVfTniSee0NomrA3v7d5771XFJVDtJT6jaMl5NItSrYA1IO7DTY1mMA7ijTfeSBT1QKQO95lHRNx2223a2Q2REgAD5fTp08mWODix0UzIgwhhZMBmwdyP+++/Xw0PI9eaELviP9HTbkO0nDakye7DCrds2aLeIqRdPfLII3Lrrbcme6xd24oTCflCa4WcG+dzPJxpMBwwxTvQ377qqqtU7mBMIxqC8xqp0A8++GDAzygWcm5HhyMUNkNpI+7ESjmP9bDC2rVrazMnc2olrlPmCD2ioZs3b9bIaL9+/XTY95133qltppPDidfzkFOwMAH9o48+km+//VY9MjQ+iJfCt27BzelYGJCKInQYFq+//nqKxgdxH25Nu0Q3t8cff1y9kBiyi+gHUqC9KufE2zg1HQs1ikYUY9GiRVqzhCY3VapU8R0DgwRR+VWrVukcO8j5hg0bdNh3cjhRzkM2QBASClTngXQH5Ks9+eSTVq2NEMuhEeJu5QR1afAu1alTR/cqRGqJ93CbnB8/flwLrVFnaYD3lS1bNk/KudsJpkgZDRfQahxOYCiv8I6bzw8v4NRGMzAwunXrJt27d9fGEu+9917A45599lmNMl577bV6/JIlS1wl5xEVoSMVa8WKFdq9A6lZ+LlmzRrrVkdIFHCbchIJVm5adgBeIjhDkEuLor2DBw/6bmjNSbyDm+Q8R44cMnXqVBk5cqTmgaN4dfz48XLPPfcE9XwnKideJdgi5eeff17rf3755Rc9N5CVgnPCazgt4nn+/Hmt8Tp79qzOsMH361/Hheg95uyZ3w8iIqk1VXCanKcNN796xIgRWv+BiAesc+S1Ie0Bs0EIsTtuUk4ixapNyw7A6IChMWDAAHnqqacS3TAjhHgLp8t5oUKFZPny5drVCcolUjYqV66ss2769OkjTZo0Cfq1nKaceBWkkKKNOGpsEeHq0KGDKp7+zl3oXdjX4CEvXbq0NtlIyUPuZpwk51gjajzGjRuXbP1h2rRp9f0ghRjOs71792pNY6DmFE6W86C7YOFD+O6777SCH4uCxVa+fHn1OKINb/Xq1aO6UEKSA/37w/VgBNtNw24dnkIlmA4ZkXbNcfpn5OQhdQbLli3TVJ1vvvlG3Eo43V7C6Zrj5PM5pc/Iyu5YMIaItcBRgptRJ4Ai5QYNGmiEw1wngAJlzIdA4wKocaglgCGC+SdelflIumPFSt7hHAsUqRoxYoQalNOnT9cOfEgnHjp0qH7vmTJl0nk3Tz/9tBonwXxG0eiCF5dJ6F27dtWTHSEgnPBIt0KLMFjlyE/8+OOPOf+DxA0MD4skjzOYTcvJykgoG3gkm5bTPyO7govAggUL1GMGj1ggAwTf2ffff6/HXHPNNTRALFBOnHw+p/YZWaWcoHiWRA9EvBDpQk3b+++/n2zUF7UC0NGgrKJewMsyH64R4mR5T+4zstoIicsk9HXr1mkIGB44dOLABRADkwL1HyfR58CBA/odoPgMHlHMOvAHxWho31amTBntmILcYbcSabjQSeHbaBPvIU0kvCF1CNHv2rVLihQpIm6Hch45bkq7dCPBFilPnDhRoyNo0YxOSW40PkKFcu6cdKygDBCEhfAm3n33XS3uRKho+/btli+GBAeK02688UZZu3atekVQ/O9/cuB+FLBhCBsK1BDyg4fUrdAIsQ4aIfYCjh9EPtARJznq16+vx2A4nduhnFsD5dyeBFOkDN555x1ty4qufxi6mJKDwmtQzp3RaCaoQYQYfITb1q1bZfbs2dpud8qUKfqmIBgQFBIbMJxm37590r9/f80FRAQEfaL9Q2OYBIv8QcMj8tBDD8mMGTM0fc6tRDpMx07DCuH9QjjdABcXeMKTA54yRCmTC9M7ZUgTIW6X8wrPp9yo5czutZIxV0HJmPt/g8lCIeHyJVnar05Qx1LO7V2kjPqOQKA+AFkNkyZN0vQsYj85txPFLBpKajUhTUJHOg9u8MgtXbpUFaS///5bnnnmGalQoYLcfvvt2pUjd+7cli+U/C8dDicTFE54RzDUBvmfaAhgAIMQ1iq+E4MbbrhB5syZI27H6cqJAQoP0dEkmEGfkEM4Blq1amXpGqicECtAq3Y4TDC9u2LFivL2228nieYgov7cc89pQwm0nEWKKa4rbpfzaBgfJ/9YJCLBK6WUc3uxceNGTaf0P6/NRcpIuUK7cf82zDBGvvrqK3Eb0GecKueBHA5WyXnOso0kTbr/U+OXPFPDcXIeVhve9OnT6xt46623NBoCgwQWOVIAkKJFoge6Y6AtY7169eTXX3/V4UPYlBAZMTh16pT+xIXcAO38vBKpckM6FqJcaMWYGhhG9dprr2mHjJRw0qRY4h4ws6Jz5846xwApo2jXjhRSf/r27SvVqlVT5euLL76QCRMmaPqo2+U8WkpJqFDO7cOwYcNk//79SW733Xef/kSHJNR1BjrGjcYHoJynbHw4Vc4jGkQIkPqD9KzJkydrMTRawZHogmgGeoPDC3LLLbfoRR1GiUHOnDn1JzwkBjAQnd7lIRScrJxgEBX+Ztu2baVUqVI6b2f16tUBj4WXuHfv3qlO/LZiUqxdNi3iHFAYW7RoUY1oYP9BR58dO3bItm3bEh2HVBN0WURTRqT14mcw+5WT5dwuSokB5ZzYFcq5O+U8YgPEDNKz0KeYRA9czP0FCIPXsmTJ4vsdEzQLFy4sf/zxh+8+XPDNaVpewMpNK5YgqgHDAykpSLlDQSIMziNHjiQ6Dp5iKGrBFB5bMSnWLpuW3dKLbrrpJk0pQktSpE6YQYoqhsn53yCfVg2pszOIaJhTQWFo4LPauXNnouOGDBmiUQ/MPoDc1qhRQyMiXlBOoqGUUM5JvEFmDGpUAgHdBNkyxYsX17SxQJ08/aGcX4rY+LCbnFtqgJDoA2UHaVhjx47VlCp4GNHt6tZbb010HJRS5FrDm47iZbRP9kKHnGhtWrHEqNepVauWps49+uijOtsBE3INEG7H94uhc7HafO2yaTkpvQhtcQOlUiBtMlSQXmGeAWKkY5jBa9tpBgg+I3MqaKB0UDhQMGuqU6dO2ugEA2+xZyWnvLhJOYmWUkI5J/EC5w06c6aUDtajRw+VWcj5G2+8oceb08iTg3LeKGLjw05yTgPEYWTPnl2+/PJLLTrGFFq04UOL3QIFCmhHLLTcBU8++aR6WXEfLuxI08H/vYgVm1YsQXMBcwcsgM0SKXcG2Lgx+wEDQOEJhzGCLmcpfcc0QuKTXmQGNXOICoRjgDgRpIOaU0EDpYNu2rRJa54wtwj7GwrVH374Yfnxx5S7RTldOYmmUkI5d9YAuWBuaNCAobvBHm++2Wlu0T///KNNJ6CTwDmBFuKI+MP5EAyUc+uIt5zTAHEgmAGCjQhCDCUInnKwatUq9YACTKlHn3CkOsA7ixQeLxPNYTpWA48w0q8Q8Thz5oyMHj1aN3R42A1QF2L2qkP5RaoWzoGUoBES+/QiAyji8PQNHDhQWyZ7ASgW5lTQixcvqgyWK1fOd5+RPooaEAN8PmaD221pl9FWSijn8Usvwp6NqDXSCZFKiJEFVuCU7yO1uUVwlsLYwH6J/QDvB/IKh2qwOMkIiZXx8Z8D5ZwGCPEMTjFCkGYHDznC1MiDhycYTR5gVFqR90/lJHbpRWaQkoCIiV1aIMYCGMow1BD5QXc+dPhB1K5gwf9dkKGoIFXt1Vdf1c90y5YtmhPesmVLV6ZdxsojSjmPT3rRoEGD1GH0yy+/6Fyml156SWXACtzwfaDJBOpUAdrMo4kRnKhIMw4FpxghsYp8fO1AOacBQjyFU4wQ1BWgzTI8RUi5gyc5ubx/oxtWKEMIqZzEJr3IDOq24BlNCUS70IQgnDSLUNI0YvkZffTRR/LKK6+ohxPGBSKzwDCmEe1AjRpS12BwI/0KtTWRDE21c9plLNMxKOexTS+CRx/y9/zzz+sxUKybN29uaUTNTd8H5BPzro4fP67t5N0a8YxF2tVdDpRzGiDEczjFCIk2VE6in15kgNQ45D7ffPPNKb6mG78PyBsG16JDGDq3GZ5OszGNbjgTJ05UIwSphzDA3Srnsc4Fp5zHLr0I5zhSCUuXLu27r2zZskm640WKk78PyDfqPwCcD4iCtGjRImw5tXvEM1QSPCTnNEBsjtlrCc9oNDyksWb+/PnSqFEj3cShkC1cuDDF4zH1/YknnrB0DXZVTmKNlZuW1wgmvcgAXj4MD03Ns+7Ei4idcYucR1qIyvMqNmAf8E/LRC1TSkOAvfZ9IA0VTXSghyBiDKfDtGnTVCdwY8QzFBI8Juc0QByE006uQKAtcLdu3bRdKdoHd+zYUb2d8A4HAt2gsFlFA7coJ3Y5r7xGMOlFBvh/sEWWbpBzO+F0ObeqCw7Pq+iD9MtQ0jKBV74PY09EBHTUqFEycuRInU2GBjl33nmnduuMBMq58+Q8TQImmZGohGrREtXct98A3avQ5QjRCHhLcAxy+AMRKEKBkwonF06ycC13Q1AhtLGckD537lzt+22OeiBlBfdhMJEZRHow3A1pGufPn0+2xgGelEgKeyFkqXnxo/UZVXg+tFaj4W5WS56pkeoxkZ5XsTyP3IpZ3q2Wc7d8V+FGbYORc7vJfLhKSUrybuV5hf3Zi9xzzz3addD/+g5jA0o1Wqlfe+21et+zzz6r/0dr/EDgOhctOXeDvIcq86HKuR3kPcEC48Nf5qNx/bD6M7K2qTDRk3/BggXaJSO5mQx9+/bVx9Dp5a+//tIBgVWqVJEmTZqEbOGGe3IZwon1xvIiglayY8aM8f2+e/duOXnyZMC0FRhlyBXds2dPil4Nw1IP1wjB8wxL3wme/Gj1BbfivCLWYbWcx6L7VpkeE+TiiQOSrXjVqHWJCsaYDgTl3PrziiQG3Z1QdI6ObiiqRgt8DJWF481Ncm5nvCjnF48fcOR5xRSsGHfJAOh/jUI1BJ/Qkg4/Q7UsnZq7nydPHu2PDhYtWiRt2rRRAwgGmBkUq+JzCWZ6uxXhQqeEb6M5lCiek2KJ88PpINrGB6CcRw7TLq3FnHI5ePBgnQVSqVIlzXTAsGDUPbhJzu2Ol+T84vEDuu868bxiBCQKqVcAE5GTY8iQIdrj/sMPP9Tfb7/9dm0/6RVPFiIeCEujMBc/0XbTDLrjYLL3rFmzYmqp291zEm2lxICREHvhBE+WQbSNDy9EPCnn9sd/DgiuWWYn22effeZqOY8khTicOTjhRD29IOcX/7/xkdK+a2c9kQZIHKZcd+3aVQuu0NkJKUj4P6aqBqoXcdtFBLUcyJ1FyhXyZPPlyxcwirR3717tKOTfyjSlSd9uNkJipZQ49byKBeaLbbQuHsldaJ2cdmm1UkI5Dz0dIzko5/bCiUZILIdwhorb5fxikBFnuxohTMGKMZs2bZJ9+/ZJv379JHv27FKxYkWNAGDatRfSZrBGpKiNGzcuoPFhtDeFR8m49enTR42WlIwPN6djxdr4cOJ55eaLh9PTLqOhlFDOQ0/HSA7Kub2we9pMPIwPyrk9rh9WwwhIjMmSJYv+RA1I2rRpfcN40Cs8EpziycLcBAxl8leCRowYIU899ZRMnz494KTvUHCTh9SqzQqbbzjvwynnldsvHnb3ZMVDKaGc2+u8chtFWj8Xk+8jUNTTbRHPSCMfbkm7pJwnhhGQGIPhe0WKFNEuGadPn9bZAeiGhZqQSLGjhesPhrWZoxvG7b777ks0Gdm/G1ZyLXjd7CG1crOyYlKsnc8rL1w83PZ9WJGOQTn/P3heWY8bvg87RDwjlXPIB+XcXueVVdAAiXGXDEQ7Pv30U53+icJzpF917txZmjZtasnfsdPJFW+s3LScvllZMSnW6+eVHS4ebvk+rMwFd7JyEi2lhHIeOW6R83gaIFYYH5APQDk/YKvzygpogESxS4a5qNzs3S9evLhMnDhRjZCVK1fqJHArscvJZQes2rTcsFnRCIkMu1w8nP59RKMQ1YnKSTSVEsp5fKCcW298QD4MKOdVXXVesQbE5jg5dz+UFn2RbFapteizc1eQWG9WkebC2uG8ihd2ung49fuwQimJVU1I5cqVJVpQzt0H5Tw6xoe/fDit9suOxoddzitGQGyOFzxZsWjNZ8euIPHylDASEjuiefFw2vdhZTqGk9MuKefug3IeG+PDiZGQWBkffzpQzhkBCZPy/efF5KRa8oy7PVnR9Ig6KRIS6zAtPaT2VEqcHPGMdTpGcljlIY0WlHN3EQvjwynfRyyMD6dFPGMV+fjz/xsgTpJzT0RAjh8/Lq+//rp07NhRevfuLUuXLo34NWMZTnOrJysWHlEnRELilSPq1vMqWjIfC6XEjd9HLJUSO8u5AeXcPfIeS+PD7t+HU+U8Xo1mUsIrcu4JA2TkyJH6c9CgQXLPPffI6NGjZfv27TFfRySblRNPLrt4RO2snMS7QM3K88pOxFPm7fJ9uE3OQ5UPO8m5GafLuR3OKzvIuxXGB+TDDd+H0+XcThkRFz0k5643QHbv3q0bUbdu3bT7VIMGDaRGjRqycOHCmK7Dis3KaSeXHTcruykndihQs+q8sgvxlHk7fR+Uc/vIeaTwvLKXvFtlfEA+nP59UM6t46LH5Nz1BggG/WHwX65cuXz33XDDDbJ58+aYrcHKMK2TTq5YbVaRDmmK56YVb+PDyvPK6zJvt+/DbXIeLnaQ80jgeWUvebfS+IB8OPn7oJxbx0UPyrnri9APHz4s+fLlS3Rf7ty55dSpUwGPv3LlSlCvm5BwJeSTKtjnpLaehg0bqpDhsXAKjjAMsVWrVjqrBCdZsO85VPzfr7FZZch1dVifxf9tVoslZ9mGImnT+l7DeB/hKMHXXXedvv8FCxakGIaN1mdkfh+hEOp5Fcz6Iz2vQvmM0qZN6yqZj1TODfm4cqVa1OTcLB/RlPloyXkkaw9WziP9O8EQymcS7nmV0tqtPK+6du0ad5mPpbxbJedm+YimnEf7XM6Q8+qoyXk46w9HzkP9G6ESzGdz0YLzyn/90TivrL7Gp0lISEgQF4Nc0H///Vd69uzpu2/jxo0ybNgwmTx5cqJj8eHu2LEjDqskxFtcf/31UVNIKPOEeEfmKe+EOFPeXR8ByZIli5w+fTrRfRcvXpSrrroqybH4sPChEUKiSzQjIJR5Qrwj85R3Qpwp7643QBCK3bp1a5KWfXnz5o25YkQIiT6UeUK8A+WdEGfiekksV66c7NmzR86cOeO7b9OmTVKhQoW4rosQEh0o84R4B8o7Ic7E9QYI2vIVLVpUPvroI23XN2vWLPn111+lSZMm8V4aISQKUOYJ8Q6Ud0KcieuL0MHRo0d1c0KYNn/+/PLggw9KlSpV4rIWhIZRNPfHH39o28A2bdpI/fr1kxx36dIlmThxoixfvlwuX74slSpVks6dO0u2bNn08QsXLsiYMWNk3bp1mgPbrFkzad68ubgBqz4jN2PVZ3Ts2DEZO3asegyzZs2qnUPuvfdex6cp2EXmKe+pQ3lPHcq7M+QdUOZThvIeHMc9IPOeMEDsxODBgyVz5szStm1b2bt3r24wL7zwgpQqVSrRcdOnT9cWajiRcNLgBENRXf/+/fXxDz/8UPbv3y8dO3aUkydPyqhRo/TYOnXqiNOx6jP65Zdf9HPxD9f369dPnI5Vn9HQoUP1Z7t27eTEiRO6UeFCh/Z7JHIo76lDeU8dyrtzoMynDOU9OAZ7QeZhgJDYsGvXroQHHngg4fjx4777Ro4cmfDxxx8nObZLly4JS5Ys8f2+e/fuhHbt2iXs378/4eTJkwnt27dP2Llzp+/xKVOmJLz88ssJTseqzwhMnz494YMPPkjYu3ev73bkyJEEp2PVZ7Rnzx79/9GjR32Pz5kzJ+Gxxx6LwbtwP5T31KG8pw7l3TlQ5lOG8h4cuzwi8/GPwXiIYCe2YoASCuqQ12pQqFAh/blt2za9ISRbokSJRK+DUJ3TA1pWfUbg4MGD6i0oXLiw75ZcZxQvfkbwriFMmydPnkSPw0uC4V4kMijvqUN5Tx3Ku3OgzKcM5T04tnhE5l3fhtdOBDuxFWE05OeZ70c+IEAo9ty5cwFfB/l/Z8+edXSOpFWfkbFB4fOYO3eu9oWvWrWq3H///fpcJ2PVZ4SL2/nz5+W///7zTc81P45cahI+lPfUobynDuXdOVDmU4byHhyHPSLzjIDEEBSVZcyYMdF9yPHD/WbSp0+vwvT111/rSQIL9/PPP0/1dYzHnIxVn5GxQaVJk0Z69Oih+ZHwHrz33nvidKz6jOA9yp49u3z55Ze6gcNbgg4yxBoo76lDeU8dyrtzoMynDOU9OC54ROZpgMQQhFRxEgQzsbVTp07a3eCxxx6Trl276okGixgnU3KvAwK9lhc/IzBs2DB5+umndfIthLR79+6yfv16W4Qe7fAZZcqUSXr37q3dM1DoiOK9ypUr6/OMz5CED+U9dSjvqUN5dw6U+ZShvAdHFo/IPFOwbDqxFTl7L730kpw+fVpzPnFSdenSRYoVKyaHDh3yhdHMr4OTEyeuk7HqMwL+4UXkVNol9GiXz6hMmTLy/vvva6s+hHPhRVqyZImjPx+7QHlPHcp76lDenQNlPmUo78GR2yMyzwiITSe2DhgwQK15WKk5cuSQNWvWSM6cObXYqGzZsvoaf//9d6LXKV++vDgdqz4jFOvBI4L8RwMMqUK+5DXXXCNOxqrPCK394EHC62ATQ4h39erV+jp26BHudCjvqUN5Tx3Ku3OgzKcM5T04ynlE5uO/Ag+R0sRWhNeQ04giMwDrdNq0aWoFr1ixQns7o28zThqcZDVq1NC+0Dt37pSFCxfKvHnzpGnTpuJ0rPqM8Drp0qXTXuroBvH777/rUJ+GDRs6toDP6s+oQIECmlOK+3Ae4XWWLVsmLVq0iPdbdAWU99ShvKcO5d05UOZThvIeHMU9IvMcRGiTia0Ii2FgzMiRI/V+dDUYN26cbNy4UUNqt912m9x9992+14FFi4EyhuWLKZmNGjUSN2DVZwTrf8KECbpBoaCrZs2a+lpGMZ+TseozwuY2fvx49bYgvIsuItWrV4/re3MTlPfUobynDuXdOVDmU4byHhxHPSDzNEAIIYQQQgghMYMpWIQQQgghhJCYQQOEEEIIIYQQEjNogBBCCCGEEEJiBg0QQgghhBBCSMygAUIIIYQQQgiJGTRACCGEEEIIITGDBgghhBBCCCEkZtAAIYQQQgghhMQMGiCEEEIIIYSQmEEDhBBCCCGEEBIzaIAQQgghhBBCYgYNEEIIIYQQQojEiv8Hi2uOtAlVfj0AAAAASUVORK5CYII=", + "image/png": "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", "text/plain": [ "
" ] @@ -652,18 +925,18 @@ "fig.legend(handles, labels, loc=\"upper center\", ncol=2, frameon=False, fontsize=11, bbox_to_anchor=(0.51, 1.05))\n", "\n", "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", - "plt.savefig(f'./ivf-{arch}-k10.png', format='png', dpi=600, bbox_inches='tight')" + "# plt.savefig(f'./ivf-{arch}-k10.png', format='png', dpi=600, bbox_inches='tight')" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 7, "id": "dc377a47-ddb6-4b26-82c0-1ecc2a0a0124", "metadata": {}, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAyAAAAEcCAYAAAA/V9CXAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjMsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvZiW1igAAAAlwSFlzAAAPYQAAD2EBqD+naQAAaw5JREFUeJztnQm8TPX7x5+Le+2EkGTf15A9cgsp2SqkQpG1LCm02JNKm5KSZMmSrYSSQqRQ1ij7LiT7vrvu//V5fp35nzt35pq5d5Yz53zer9e83Dln5syZY57nfJ89Kj4+Pl4IIYQQQgghJASkCsWHEEIIIYQQQgigAUIIIYQQQggJGTRACCGEEEIIISGDBgghhBBCCCEkZNAAIYQQQgghhIQMGiCEEEIIIYSQkEEDhBBCCCGEEBIyaIAQQgghhBBCQkaa0H0UIZHN5cuX5caNG4m2p02bVlKnTh2WcyKEEEIIiTQYASFe+fnnn6VHjx5Sv359qV69ujRo0EB69eolv/zyi1iFF198USpXriyffvqpx/1jxozR/f/880+ifc2aNZPvvvsuwbbffvtNX3/9+vVEr2/durXcc889iR5r1651vWbnzp3y3HPPSZ06dSQ2Nlbatm0rCxcuTHSs7du3S/fu3fU1OEaXLl10GyFWoFOnTioHzz77rNfXQDfgNXitLwwePNjn1xo88cQT+hlz5871ekzsd+fKlSsqV+vWrUuw/euvv5aGDRt6PNZff/0lHTp0kFq1aqmue+edd+TSpUsJXrN7927Vgffee6/UrFlTz8+TfBtcu3ZNHn/8cXnmmWd8/MaE2Itt27bp+qFVq1Ye76vQMdiP14HGjRvLgAED/P6cDRs2qOyeP39eLl686PUBmfTnPrx06VK9j2M/dAd0zvHjx5N1LUhCGAEhHnnjjTdk9uzZctddd6lQ5syZU44ePSo//PCDvPDCC/Lwww/Lq6++KlFRUWE7x1OnTsmKFSskTZo0smDBAj1PX9m1a5ccPnxYateu7dp25swZGTt2rMfXI/IBI6Znz55Srly5BPuKFi3qOh+cQ6ZMmVSp5siRQ+bNm6fXKSYmRpUc2L9/v76uUKFC8sorr+gi54svvtDr+tVXX0n69OmTeUUICSxYwJ88eVKyZ8+eYDtkZdWqVUH9bCwEduzY4ZLvpk2b+vxenBtkrkKFCq5tR44ckalTp3p8/b59+9RxUKZMGV1gnD59Wj777DM5cOCAjBw5Ul9z7NgxNaCyZcumRgginzCMIN+QeRgk7uAYcErceeedyboGhEQ6JUuWVMMeTsLPP/88wX36m2++kdWrV+s2vA5A/rJkyeL358BQqFGjhrz77ruJHItmOnbsKJ07d/bpPgwnbJ8+faRRo0by1FNPqQ4YP368bN26VSZNmqQ6gCQfGiAkETNnzlTjA8IJxWGmefPm8t5778m0adOkePHi0qJFi7CdJxYlAIuCTz75RDZu3OjzjR7KqlKlSpI1a1bZs2ePDBw4UL2bZu+IGRhfV69eVYOlYMGCXs/n7NmzMnHiRMmXL59ug9GBhdOPP/7oMkAQlcGCBeecLl063YaFT7du3WTTpk1SpUqVZF0PQgIJDOu///5bfvrpp0RyDvlB2mHhwoWD9vlYRGCxj5s/DAcYELlz5/bpvTg/eCxxjohQYlGyd+9eiYuLk1y5ciV6/bhx43TR88EHH7gWFXAgYPGxfv161RUwNs6dOydTpkyRPHny6Gvuu+8+1YlwXLgbIJDlL7/8Up03hDgR3E+jo6OlXbt28uuvv8qECRM0eliiRAmVZ8gb7n3YbwCnZ3KAsYC1AByEyG5wB85TPOrVq+fzfXjGjBlSqlQpNYoMIPvIvEDEpVq1ask6V/I/mIJFEoAQKSz8smXLJjI+DJ5//nldYMMDgKgAUiDmzJkjw4cPl7p162r60WuvvaahUDNYCOCYd999t9x///36eizYDaAQsEhHKBYeCuN1UBDeFih4zaOPPqpe0u+//97n74kFimEQZMyYUZUSPrNq1aoeX3/w4EFdzOTNm1ejIZ5CydiORYhhfACcFxY0qB8xFPKyZcvUKIHSw3vi4+OlWLFiaqTQ+CBWAXKB37OnFCNsw74MGTK4nBbQA5MnT3a9BgY9UiuGDBmS4L3wLuL3D28l0pOwcHAH8oXFwgMPPCAPPfSQyonhcLgZMDKw2MFCB8CIwXG6du3q8rKawbERSUWqqdmjifPD4mn58uWuiAycD4bxAbAfx8R3NQN5h1MDqRtmfUBIJIPUxg8//FDTpCD/kM0333zTda/HQh1piUuWLFEZRyYFwL0TegD3Q/wL+R42bJj+i+fmGkojBQufhbUE1hvu4NgwFAwQKf3333/VQZg/f36NfJofuNd+++23MmjQIClSpIjP92FEQqE/zMBpaVwLkjJogJAE4CaL/EYs/L0BZQHPH1KYILTgo48+Uu9gv379pH379rpA6d27t+s9v//+u6Y4QJihpBAGxcIDURZz1AE3buSWQwFAMZUuXVoNIvdFipGeAWUFhYDFwuLFiz0aBu7AaEJahGGAwKv69NNP68NbBAUGCBZbL730kipePPA9EXUx14hAORvfA9cRnlGkcUBRA5wzFBc8L0jjwHnDU4u/8RmEWAksyuHpQwTQADdlpGYZnkSACAmiBHAiQL5wQ3/99dc1igBvoQE8i/CCYpGCBTpkClGGlStXJvhcLPqR0ojoByIxeMAg8YU//vhDo5WGMwELDkO+jXRJMzhfOEKw+DCDhcntt9+uqRoAdRxmTyiA7oLDxD0yM2rUKH0/az+InUDEAtkRiPrBuMC/cATCmDA4dOiQ1k89+eST6mAwgPGOegvcA+Hsg8zDiPCWUQBnANYZSNG6cOGCazvSn/AZxj3VcCgicuIpdQsyCl2D+6zhlPD1PgyHKtI54WA5ceKEyvr777+vUU1PtWfEP2iAkARAsAG8CEmBGzMwirGQR4kFBxQGciXhtUDEA4sBAIWEBQH+xaIGCxZEQKAIYDgYwICAAYJICRY4UHLwMiINwgyU3i233KJFZwBFo8hLN7yVSQFlhbCqr+kcxnWBgQWv8IgRI3QhAq8Paj2gEN15+eWX1euKBRnOEZEa8/X6+OOPVfkhnQ2GGhQbDBos7gixCrgpYyGwaNEi1zakZMGTiX0GqAXDTR689dZbesNGUTccEvidG8BhgYjmY489Jg8++KDKAZwSSFs0A28lDAKkahjyjbot6IubAWcFHAS+5mcbMmd4Ns1gQWN4dxHpgEPEAAWt+M7QDS1btnRth95DsTu8rbhOhNgF/NbhPMQ9Hg48pE7hng/HggGMhbfffltlAmnaZiD3MBTguKtYsaIWpicFdAScCWYHBdYLcFwYxoQh8+bnZqBbjPpNA1/vw/h+cIbi+0AHwcmI+z1qRozoL0k+NECIR25248RiHKDQExjRBAPjORQTPArwImLBYu5GAW8kilvNUQSAsKsBhByGhvF55vQMfAYKx7APSg0LDl/SsJJSVt7AucPwGDp0qHpL0A0DxgW8nCiscwdGFF4PRY3oT9++fXW74cmB8sWxYJwYoWoU+86fP9+v8yIkmKAQE79RswGCv83pVwZ33HGHRjmxWMBvH79r97oILEgKFCjgeg75QdqE0QHH3FwC3kfINh7GcXyVb3d9lBRJRU1hWBn54e6fgcUUFkPw9MITbMg3IreIthjGEyF2AQ0ZsAhHuhOioGiyAicj0h7NRrt7oxYDyLIRYcC/5vu6JxBlQLQBKV1mBwju34Zc4jhwTniSeUQtkCqO+/Btt93m2u7rfRi1Y4iAIJKD7AaklmPdgsYTqCkjKYPuGZIAQ0hvlg4EgyJVqlSu3M1bb701wX4jFGqkIgFEPPBwx93r794FCosAI9XLnJ6BuhM8zGAfPJZmr6sZKBcYPPDM+oMnhQrPLVK23PO/AYpz8cDiCt8HHUBQ0Gt4ZWHEmIEiRHTFSPcgxCogYonUQ3gR8fvFggM3bU8gJRLpR0hvMEcFDJCS5UmO4OU0QK0HjALIjHt7beRnw7iH7vEEvJNYdBiRUV8wdJWnxRB0ibmGA7oKBgZqTBD1HT16dIJUDKSoYGGEBQucLMDIL8dzOGwYFSGRCuQPRgju6ciCQHoj5BcyZ+DJYDdAdBTvRaoTZAWRBWROeANyjsgDumVBR6BhDNYmMADMzgAUj3tq9mB0vXOPtPhyH0bHK0QykQZqTiOFM6RJkybaYMLfdQRJCDUhSQBSk5CKAM+e4dXzdFOGRwJRB2Ohb87RBBBeAOWUOXNm/RvpSsgTd8dT6kNSID0DxeDuvcKhNFAQh3P31AUDYIYJ0su85Z16AgsIKF54PtzzxLFQgsIC8LJAKeMczBh558gzx3kD925bWKDgWEkpb0LCAdIHEe1AXRf+RUqkuX21GaQ04LcMvYAoCBbo5lbdcBy4g/oSI6XTSK8sX758ohkkSMNEW9s1a9Z47T6D9EqkTHhzQHgCkRssSFAXhrRJA8goUk4MXQK9h9RQbEM6B4wMd2Ni8+bN6hlFWoqnKCpafKL+hZBIA/dXpByi6QucAMa9CvdhswHiDWQtQIcgxQlRQxgSs2bN0mgGop3egEyi8xxqQeA8RBc7s9FvbihjBk4QdK6DA8VYgxj4ch9GjSvu/VgTmUFGBnQGokAkZTAFiyQAiwvcIJHH7B5dMC8ykPqEQjIDeATNGB1r0IECi32kWmHxYe5MAc8iitf9GcBnpGegSB5KyPzAbBJ8TlJpGlBW/qZfwQuDhQ/qV8xAAeE6GYsheGCQbuXe/QuKE55PXAcYI/ACw0gyR3WQtgKF6a0LFyHhAjdjLJ6xeMDvFgaJp1k1kAV0uIIhjgW68dzMli1b9MZuAMcFopZG1xlzcwl3+YYX82bd7vxNvwKQTXTrwnczL0jwHF5Xo9YFiyBEMaEH2rRp4zGSgboPpGSaH0g7g9zjbyyGCIlEYKAj1Qref8P4QIbDn3/+edP3wsmAaAfkADUkAAXp6CgHh11SBgxqr5BNgHs30q9gkBgRULwPtWae7um4p6Iu1JPM+XIfhlMEzhM04TCDSCmcEP44MYlnGAEhicACAgsBhEax2Ec6AyIZSF9CJACeSIRAYUQYE8bhlUR+JDyjWGQg7xJKwfD+o9uF0SkD78OxcEMHngZ4ecNIz0BY1h0oJdSPwHCCcWDO+TQWOzhPf6cxA+S9QlGiYw8K43D+aDkKzwoWIwCeHURYcHx4iZDaAYMEebIw1gyvLBQvitixSEMtCdI60C0LSs8oVifESuAmjjQE3JDdI3wADgmkZcGpgMJNODIgq3Aw4DdtRDiwcEGdCHQMXoNidSz6UTNhRD+wsPcUQYA8wRCBkYGFj3u0EB5aDBQ015D5Crrx4ZwQoUDEA3oNMom/4e0EWAAh7Qp6xr1rl6HHPNV9QO6xcDMPRSQk0oAhANlEZBP1T7gHIkUJwOnmSSYAFvi43+H+i38h9wDRVKwjcD/E2gDdpbwBowOzemAcmLtfoZUuDAFzXZkB7sVwLnjKusD3uNl9GLoOxhaiKIiQQr6h5zAbBDqAkcyUQwOEeBRO1GrA2IDwofgK+cuILiDtCtNC3fvpY9GNQlJ4AKFY4K1E330DCDK8plAiCLti4Q6vIwwTf6aeYoECb4indpoAoVzki2LxYx5uBGBMwZAyd7LxFRgUUEIYwIiQMxY/8Nri/I0+4TgulCi+o6FMoRz79++fYIozckpxjeARRT45vj8MKhwrnJPlCfEGcqWNhbSn+goYGvAKosOV0ZgC+dG4ScMwMWb5IFqI9Co0cIB3Egt2pGnBE2o0l4Be8JaWCcMERj2MEHO6lGEgoFbLU53JzUBqJWpX8D2QZoLPxyLLHOWFUYIFCNJPPIGIDyF2BYY4nJKQZdwDYYzjHouUZsgE5Me96xWYPn26ZgEg8uG+boBeQbQTadV44G9PwOkHPQF9gboTXyKeiMzg87x1w/PlPgwDCTPRYGghWgLDAwYNXm+eB0SSR1S8Of5EiJ/gpgzjAotsb3UXhBBCCCGEGLAGhBBCCCGEEBIyaIAQQgghhBBCQgZTsAghhBBCCCEhgxEQQgghhBBCSMigAUIIIYQQQggJGTRACCGEEEIIISGDBgghhBBCCCEkZNAAIYQQQgghhIQMGiCEEEIIIYSQkEEDhBBCCCGEEBIyaIAQQgghhBBCQgYNEEIIIYQQQkjIoAFCCCGEEEIICRk0QAghhBBCCCEhgwYIIYQQQgghJGTQACGEEEIIIYSEDBoghBBCCCGEkJBBA4QQQgghhBASMmiAEEIIIYQQQkIGDRBCCCGEEEJIyKABQgghhBBCCAkZNEAIIYQQQgghIYMGCCGEEEIIISRk0AAhxGYcOXJEypQpI7/88os+//7776V69epSuHBhadu2rRw7dizRe5YuXSq33367XL9+3eMxz58/L126dJHixYtLpUqV5N133w369yCEeGfx4sVSp04dlev77rtPZTgpeYdsDxgwQMqVKyfFihWT1q1byz///OPx2Nu3b5eHHnpIj1G/fn1Zv359SL8bIcS3+3vDhg313m08GjVq5HotZN28D/dwTwwdOjTB6/C4fPmyBBsaIITYjN69e8vZs2f177///lt69uwpr732mqxZs0ayZ88uL7/8coLXnzlzRvr27ZvkMfGe6Oho+e233+TLL7+UL774QlasWBHU70EI8cyJEyekc+fOuqD4888/5emnn5YOHTrI2rVrvcr7zJkzVWYXLVok69atkyxZssiQIUMSHTsuLk6eeeYZqVu3rhoeTz75pLRv316uXr0ahm9KCPF2fwf79++XPXv2qDMBj++++063nzt3Tu/ZxnY8Pv30U/HEvn379L5ufm26dOkk2NAAIcRGTJ06VTJkyCB58uTR57Nnz1bv6P333y85cuSQPn36qOcURodBv379pGnTpl6Pefr0afnxxx9l2LBheoySJUvqceFFJYSEnlWrVkn+/Pnl8ccfl0yZMmmkAwsGeEW9yXvatGklPj5eIyFRUVH6d7Zs2RIdG0YMFi+9evWSW265RY2b9OnTy6+//hqW70oI8Xx/x70ZsunJWIBRUaBAAfEFGDGFChWSUEMDhBCbcODAAfn444/ljTfecG3bvHmzlC1b1vU8b968qrDwWiNd4+jRo7qA8QY8rFB4b775ph7rrrvukmXLlkmuXLmC/I0I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", + "image/png": "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", "text/plain": [ "
" ] @@ -781,18 +1054,18 @@ "# fig.legend(handles, labels, loc=\"lower center\", ncol=4, frameon=False, fontsize=8, bbox_to_anchor=(0.5, -0.1))\n", "# fig.suptitle('OpenAI embeddings (n=1M, d=1536)', fontsize=13, x=0.52, y=0.925)\n", "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", - "plt.savefig(f'./ivf-exhaustive-intel.png', format='png', dpi=600, bbox_inches='tight')" + "# plt.savefig(f'./ivf-exhaustive-intel.png', format='png', dpi=600, bbox_inches='tight')" ] }, { "cell_type": "code", - "execution_count": 160, + "execution_count": 8, "id": "53e883fa-0868-40dd-933c-6f1636878e73", "metadata": {}, "outputs": [ { "data": { - "image/png": "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PmbOnCn9+/fXmgekFT366KNy+eWXR9Q8ogMoxJ46dapuwKtVqyZPP/20nHXWWRnee9WqVXLvvffKihUrpHTp0lrofckll4itfP755/L666/r+gTjo1y5cnr/SSedJEeOHAl67l9//SV58+aN+F6hERNCbOLuu++WqlWr6jXbSYsWLaRnz5563Te88MILMm7cOL2GV6pUSXVcqlSpDO/53XffycMPP6y1UWeeeaa+zw033JD0z8IaEEI8BAwDk14AgwKLFTYj3377rRZvPvTQQ0HPR670Aw88kOl74jW5cuWSr7/+WttZvvbaazJv3rykfg5CSGR27dold955p3Tu3Fl+/PFHueOOO7S4Gi1nI2n+7bffVt2iMHvx4sW6CYdBEso///yjxdr169fXjQk2Ou3atcuwkbeFCRMmyNixY9XYgkFljA9w2mmnaftdJ/idRgZxG7Nnz5bHHntMJk+eHHT/9OnT1WD45ptvMhjlI0eOlDFjxqje4WjAmhEKHBNYO9CoYcmSJfo3sI9YunRp0j8TDRBCPMIbb7whJ598snowwJQpUzQaghaUp59+utx///3qNTUFmgDewmbNmkV8z71798qnn34qTz31lL4HLu5433PPPTcln4kQkpEFCxbI2WefLbfccot6/W+//XZNLfryyy8jav6EE06QY8eO6YYjW7Zs+jPqIUKBEXPgwAG55557dAMP4waRhLlz54ptLF++XCM+MJhww3E6ufDCC/U5hkOHDum8ENxPiJv44Ycf5PDhwxkyGGBcQNt58uTJkNmAa/ull16qzgY4K2BU7NmzJ+h50Ad0AccFnoeaqvPPPz8lTkYaIIR4AKRhIP95wIABgfvQ6cV5oS1atKheoPFcgAs30jSweYkEvKswaNCBBu9VuXJlmTNnDgd5EZJGqlevrp5Nw7p169TIQJQykuabN2+umxekb6BzFHTctWvXDO+NdaN8+fJqpBjgeMAQQFsNMRzv1q1bAzfUg4B69erpBg1RnzVr1sjQoUPlvPPOk+LFi6f70AmJCRgISKE655xzgu5//PHH9f5QZwKiHT169Aj8jugGjJTQ9EO8H9Iys2fPHogQInvCODKTCWtACHE58GTCW4loBryeBqRioSWlEyxA8Hag0BJpGu+8806m743FCBfuRo0aaYgXbS1hsCCPFCkahJDUg9QqMw8DhgTqNWBg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oqDO/8U2ndvUovfIkSPy8ccfy7333isXXnih3nfzzTdrkxxEQypUqCBXX3213t++fXt1mK5evZojAIi1/PXXX77QecwRkNdff12jFygm//TTT9WrUKVKlQwTzXHgEHokWrVqJTVq1IjvqImv4abE3kgIIYmCOrdX5zZpfe3atZIjRw654IILAvdhb/HUU0/JypUr1QAxwGmKc4AMDkJs5T2f6DwmAwTTyvv27SuDBg3SqAeiHJGAd+GOO+447nsiFPrdd9/JoUOHwv5OiBNuSuzenBCSCKhzu3VukxGCZjinn366vP/++1p3itsrr7yiIwJ27twpBQsG1/UgDWv//v1pO15Cjsd1PtF5TClYOXPmlKefflpOOOEEadCgQUIO4PDhwzpTZMyYMXLRRRdl+J0QAzcl9qdjEZJVqHP7dW7SNGxIufz999+11uOnn37SGhAYHq+++qre/+eff0ru3LmDno8oCO6PBIwW1oiQdJI7d26rdG7eb/v27VG9LtomDzHXgFxzzTUyffp0qV+/foa0q3gJbcSVgMZcxGNwU+KezQkh8UKdu0fntqwP2C/8888/0rNnTzn11FP1PniOUa+KLA3UiDjBY3nz5o34fqERE0LS0QUrlw9qPGM2QM4880wdRHjLLbdItWrVMqRhwSi58847JZXs27dPQ65oBwzPBQrRUH+ClmHwjKBFMLp0FSlSRFq3bi0VK1YMvPaLL76QKVOmyMGDBzXigiK1zBYnknq4KYkeWxYtQmKFOo8e6vw/YHSYm6Fo0aJqlJx88snaftcJfmcBOnEDuTwe8Yy5CH348OGaP4nCL/TWHjt2bIZbqnnxxRe1T/pDDz2kLYAxrX3kyJFaszJ48GA1PJ544gk9iUOGDNHngqVLl8prr72mHTNQ24Kw7IgRI1J+/CQy3JS4N1ecEDfp/K+/49NKuqDOJdB188CBA0GGBupCYHxccsklsnz58sD9qC1FTrvplkWI7eSyROfJcFLEbIB8++23md4WLlwoqQSLDnI/MXTovPPO004Yt912m/zwww8yf/58XXAQDSlRooQ0atRIzj77bPnqq6/0tWjdh5Naq1YtKVWqlBbN43W7du1K6Wcg9m5K3IotixYhbjE+PvlmmrgN6lz02o7r/gsvvCA///yzZkLAOYp0cVzfFy9erFkba9as0dll2CcUL1483YdNiPhd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", + "image/png": "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", "text/plain": [ "
" ] @@ -903,7 +1176,7 @@ "# fig.legend(handles, labels, loc=\"lower center\", ncol=4, frameon=False, fontsize=8, bbox_to_anchor=(0.5, -0.1))\n", "# fig.suptitle('OpenAI embeddings (n=1M, d=1536)', fontsize=13, x=0.52, y=0.925)\n", "plt.tight_layout(rect=[0, 0, 1, 0.95])\n", - "plt.savefig(f'./bond-intel.png', format='png', dpi=600, bbox_inches='tight')" + "# plt.savefig(f'./bond-intel.png', format='png', dpi=600, bbox_inches='tight')" ] }, { @@ -931,7 +1204,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.3" + "version": "3.11.12" } }, "nbformat": 4, diff --git a/examples/README.md b/examples/README.md index 42c056a..eee0556 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,20 +1,32 @@ # Examples -`pdx_simple.py` shows a plug-ang-play example that creates a random collection with scikit-learn. The rest of the examples read vectors from the `.hdf5` format. For reliable benchmarking we recommend using our C++ [benchmarking suite](/BENCHMARKING.md). + +`pdx_simple.py` shows a plug-and-play example that creates a random collection with scikit-learn. The rest of the examples read vectors from the `.hdf5` format that you can download [here](https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing). + +## Running the examples + +Check [INSTALL.md](/INSTALL.md) for installation instructions. Then, run the examples: + +```sh +python ./examples/pdx_simple.py +``` ## Downloading the data Our examples look for `.hdf5` files in `/benchmarks/datasets/downloaded`. These datasets follow the convention used in the [ANN-Benchmarks](https://github.com/erikbern/ann-benchmarks/) project. One `.hdf5` file with two datasets: `train` and `test`. We have a few ways in which you can download the data we used: - Download and unzip ALL the `.hdf5` datasets from [here](https://drive.google.com/file/d/1ei6DV0goMyInp_wFcrbJG3KV40mAPfAa/view?usp=sharing) (~60GB zipped and ~80GB unzipped). -- Download datasets individually from [here](https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing). +- Download datasets individually from [here](https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing). - Run the script [`/benchmarks/python_scripts/setup_data.py`](/benchmarks/python_scripts/setup_data.py) from the root folder with the script flag `DOWNLOAD = True`. This will download and unzip ALL the `.hdf5` datasets. Make sure you set all the other flags to `False` and comment the elements inside the `ALGORITHMS` array. ## Examples description -- `pdx_simple.py`: Pruned search with [ADSampling](https://github.com/gaoj0017/ADSampling/) with an IVF index (built with FAISS). Plug & Play example that uses a random collection of vectors. -- `pdx_2l_ivf.py`: Pruned search + ADSampling with a **Two-Level IVF index** (built with FAISS). The recall is controlled with the `nprobe` parameter. -- `pdx_2l_ivf_8bit.py`: Pruned search + ADSampling with a **Two-Level IVF index** (built with FAISS) using 8-bit Scalar Quantization. The recall is controlled with the `nprobe` parameter. -- `pdx_ivf.py`: Pruned search + ADSampling with a **vanilla IVF index** (built with FAISS). The recall is controlled with the `nprobe` parameter. -- `pdx_ivf_exhaustive.py`: Pruned search with ADSampling with a IVF index (built with FAISS). This example explore all the clusters (therefore, it is an exhaustive search). This lets the pruning strategy shine and get **up to 13x speedup**. -- `pdx_noindex.py`: Pruned search with ADSampling on the entire collection (no index). This produces nearly exact results. -- `pdx_noindex_bond.py`: Pruned search with BOND on the entire collection (no index). This produces exact results and does not need a preprocessing of the data. -- `pdx_persist.py`: Example to store the PDX index and the metadata of ADSampling in a file to use later. +- **`pdx_simple.py`**: Plug-and-play example using random vectors generated with scikit-learn. Builds a single-level IVF index (`IndexPDXIVF`) and runs approximate nearest-neighbor queries. No external datasets required. + +- **`pdx_ivf.py`**: Single-level IVF index (`IndexPDXIVF`, F32 precision) on vector embeddings. Demonstrates building an index, querying with a configurable `nprobe`, and inspecting index properties like cluster count and in-memory size. + +- **`pdx_ivf_exhaustive.py`**: Exhaustive search using `nprobe=0` (visits all clusters). Compares PDX pruned search against brute-force FAISS (`IndexFlatL2`) to show the speedup from ADSampling pruning even without approximate search. + +- **`pdx_tree_sq8.py`**: Two-level hierarchical IVF index with 8-bit scalar quantization (`IndexPDXIVFTreeSQ8`). The two-level structure allows pruning to happen when finding the most promising clusters. Recall is controlled with `nprobe`. + +- **`pdx_filtered.py`**: Filtered (predicated) search using `IndexPDXIVFTreeSQ8`. Demonstrates how to pass a set of allowed row IDs to `filtered_search()`, restricting results to only those vectors. Includes a correctness check verifying that returned IDs are a subset of the allowed set. + +- **`pdx_persist.py`**: Save and load a PDX index to/from disk. Builds an index, saves it with `index.save()`, then reloads it with `load_index()` and queries the restored index. diff --git a/examples/pdx_2l_ivf.py b/examples/pdx_2l_ivf.py deleted file mode 100644 index 701567c..0000000 --- a/examples/pdx_2l_ivf.py +++ /dev/null @@ -1,60 +0,0 @@ -import math -import os -import numpy as np -from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXIVF2 -np.random.seed(42) - -""" -PDXearch (pruned search) + ADSampling with a Two-Level IVF index (built with FAISS) -Recall is controled with nprobe parameter -Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing -""" -if __name__ == "__main__": - dataset_name = 'agnews-mxbai-1024-euclidean.hdf5' - num_dimensions = 1024 - nprobe = 64 - knn = 100 - print(f'Running example: PDXearch + ADSampling (Two-Level IVF Flat)\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') - train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - nbuckets = 4 * math.ceil(math.sqrt(len(train))) - - index = IndexPDXIVF2(ndim=num_dimensions, nbuckets=nbuckets, normalize=True) - print('Preprocessing') - index.preprocess(train) - print('Training') - training_points = nbuckets * 50 - rng = np.random.default_rng() - training_sample_idxs = rng.choice(len(train), size=training_points, replace=False) - training_sample_idxs.sort() - index.train(train[training_sample_idxs]) - print('PDXifying') - index.add(train) - print(f'{len(queries)} queries with PDX') - times = [] - clock = TicToc() - results = [] - for i in range(len(queries)): - q = np.ascontiguousarray(queries[i]) - clock.tic() - index.search(q, knn, nprobe=nprobe) - times.append(clock.toc()) - print('PDX med. time:', np.median(np.array(times))) - # To check results of first query - results = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=nprobe) - print(results) - - print(f'{len(queries)} queries with FAISS') - times = [] - clock = TicToc() - results = [] - queries = index.preprocess(queries, inplace=False) - index.core_index.index.nprobe = nprobe - for i in range(len(queries)): - q = np.ascontiguousarray(np.array([queries[i]])) - clock.tic() - index.core_index.index.search(q, k=knn) - times.append(clock.toc()) - print('FAISS med. time:', np.median(np.array(times))) - # To check results of first query - print(index.core_index.index.search(np.array([queries[0]]), k=knn)) diff --git a/examples/pdx_2l_ivf_8bit.py b/examples/pdx_2l_ivf_8bit.py deleted file mode 100644 index f938d39..0000000 --- a/examples/pdx_2l_ivf_8bit.py +++ /dev/null @@ -1,73 +0,0 @@ -import math -import os -import numpy as np -from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXIVF2SQ8 -np.random.seed(42) - -""" -PDXearch (pruned search) + ADSampling with a Two-Level IVF index (built with FAISS) + 8-bit Scalar Quantization -Recall is controled with nprobe parameter -Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing -""" -if __name__ == "__main__": - dataset_name = 'agnews-mxbai-1024-euclidean.hdf5' - num_dimensions = 1024 - nprobe = 128 - knn = 100 - print(f'Running example: PDXearch + ADSampling (Two-Level IVF SQ8)\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') - train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - nbuckets = 4 * math.ceil(math.sqrt(len(train))) - - index = IndexPDXIVF2SQ8(ndim=num_dimensions, nbuckets=nbuckets, normalize=True) - print('Preprocessing') - index.preprocess(train) - print('Training') - training_points = nbuckets * 50 - rng = np.random.default_rng() - training_sample_idxs = rng.choice(len(train), size=training_points, replace=False) - training_sample_idxs.sort() - index.train(train[training_sample_idxs]) - print('PDXifying') - index.add(train) - print(f'{len(queries)} queries with PDX') - times = [] - clock = TicToc() - results = [] - for i in range(len(queries)): - clock.tic() - index.search(queries[i], knn, nprobe=nprobe) - times.append(clock.toc()) - print('PDX med. time:', np.median(np.array(times))) - # To check results of first query - results = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=nprobe) - print(results) - - print(f'{len(queries)} queries with FAISS F32') - times = [] - clock = TicToc() - results = [] - queries = index.preprocess(queries, inplace=False) - index.core_index.index.nprobe = nprobe - for i in range(len(queries)): - q = np.ascontiguousarray(np.array([queries[i]])) - clock.tic() - index.core_index.index.search(q, k=knn) - times.append(clock.toc()) - print('FAISS med. time:', np.median(np.array(times))) - # To check results of first query - print(index.core_index.index.search(np.array([queries[0]]), k=knn)) - - # Scalar Quantization in FAISS is EXTREMELY slow in ARM due to lack of SIMD - # print('Training FAISS SQ8') - # f_index = faiss.IndexScalarQuantizer(num_dimensions, faiss.ScalarQuantizer.QT_8bit) - # f_index.train(train[training_sample_idxs]) - # f_index.add(train) - # f_index.nprobe = nprobe - # print(f'{len(queries)} queries with FAISS U8') - # for i in range(len(queries)): - # q = np.ascontiguousarray(np.array([queries[i]])) - # clock.tic() - # f_index.search(q, k=knn) - # times.append(clock.toc()) - # print('FAISS med. time:', np.median(np.array(times))) diff --git a/examples/pdx_filtered.py b/examples/pdx_filtered.py index 81cb5d2..58e0a2b 100644 --- a/examples/pdx_filtered.py +++ b/examples/pdx_filtered.py @@ -1,12 +1,12 @@ -import math import os import numpy as np from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXIVF2SQ8 +from pdxearch import IndexPDXIVFTreeSQ8 + np.random.seed(42) """ -Filtered Search +Filtered Search: only consider a subset of row IDs when searching. Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing """ if __name__ == "__main__": @@ -14,40 +14,38 @@ num_dimensions = 1024 nprobe = 64 knn = 100 - selectivity = 0.1 # From 0 to 1 + selectivity = 0.1 # From 0 to 1 print(f'Running example: Filtered Search\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}\n- selectivity={selectivity}') train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - nbuckets = 4 * math.ceil(math.sqrt(len(train))) - index = IndexPDXIVF2SQ8(ndim=num_dimensions, nbuckets=nbuckets, normalize=True) - print('Preprocessing') - index.preprocess(train) - print('Training') - training_points = nbuckets * 50 - rng = np.random.default_rng() - training_sample_idxs = rng.choice(len(train), size=training_points, replace=False) - training_sample_idxs.sort() - index.train(train[training_sample_idxs]) - print('PDXifying') - index.add(train) - print(f'{len(queries)} queries with PDX') + index = IndexPDXIVFTreeSQ8(num_dimensions=num_dimensions, normalize=True) + print('Building index...') + index.build(train) + print(f'Index built: {index.num_clusters} clusters') + print(f'Index in-memory size: {index.in_memory_size_bytes / (1024 * 1024):.2f} MB') + + print(f'{len(queries)} queries with PDX (filtered)') times = [] clock = TicToc() - results = [] for i in range(len(queries)): q = np.ascontiguousarray(queries[i]) - # We choose random tuples at a certain level of selectivity - passing_tuples = np.random.choice(np.arange(0, len(train)), size=(int(len(train) * selectivity)), replace=False) - # We mock a predicate evaluation - index.evaluate_predicate(passing_tuples) + # Simulate a predicate: randomly choose a subset of row IDs that "pass" + passing_row_ids = np.random.choice( + np.arange(len(train), dtype=np.uint64), + size=int(len(train) * selectivity), + replace=False, + ) clock.tic() - index.filtered_search(q, knn, nprobe=nprobe) + index.filtered_search(q, knn, row_ids=passing_row_ids, nprobe=nprobe) times.append(clock.toc()) - print(f'PDX med. time at {selectivity} selectivity: {np.median(np.array(times))} ') - # We check the filtering correctness by choosing 100 random tuples - passing_tuples = np.random.choice(np.arange(0, len(train)), size=100, replace=False) - index.evaluate_predicate(passing_tuples) - results = index.filtered_search(np.ascontiguousarray(queries[0]), knn, nprobe=nbuckets) - # The same 100 chosen tuples should be returned by PDX - print('Got correct results?', len(set(passing_tuples).intersection(set(results[0]))) == 100) + print(f'PDX med. time at {selectivity} selectivity: {np.median(np.array(times))}') + # Verify correctness: choose 100 random row IDs and search exhaustively + passing_row_ids = np.random.choice( + np.arange(len(train), dtype=np.uint64), size=100, replace=False + ) + ids, dists = index.filtered_search( + np.ascontiguousarray(queries[0]), knn, row_ids=passing_row_ids, nprobe=0 + ) + # All returned IDs should be from the passing set + print('Got correct results?', len(set(passing_row_ids).intersection(set(ids))) == len(ids)) diff --git a/examples/pdx_ivf.py b/examples/pdx_ivf.py index 3d8d63d..9e1d82d 100644 --- a/examples/pdx_ivf.py +++ b/examples/pdx_ivf.py @@ -1,62 +1,40 @@ -import math import os import numpy as np from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXADSamplingIVFFlat +from pdxearch import IndexPDXIVF + np.random.seed(42) """ -PDXearch (pruned search) + ADSampling with an IVF index (built with FAISS) -Recall is controled with nprobe parameter +PDXearch with a single-level IVF index (F32). +Recall is controlled with nprobe parameter. Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing """ if __name__ == "__main__": dataset_name = 'agnews-mxbai-1024-euclidean.hdf5' num_dimensions = 1024 - nprobe = 64 - knn = 100 - print(f'Running example: PDXearch + ADSampling (IVFFlat)\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') + nprobe = 25 + knn = 20 + print(f'Running example: PDXearch IVF (F32)\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - nbuckets = 1 * math.ceil(math.sqrt(len(train))) - index = IndexPDXADSamplingIVFFlat(ndim=num_dimensions, nbuckets=nbuckets, normalize=True) - print('Preprocessing') - index.preprocess(train) - print('Training') - training_points = nbuckets * 50 - rng = np.random.default_rng() - training_sample_idxs = rng.choice(len(train), size=training_points, replace=False) - training_sample_idxs.sort() - index.train(train[training_sample_idxs]) - print('PDXifying') - index.add(train) + index = IndexPDXIVF(num_dimensions=num_dimensions, normalize=True) + print('Building index...') + index.build(train) + print(f'Index built: {index.num_clusters} clusters') + print(f'Index in-memory size: {index.in_memory_size_bytes / (1024 * 1024):.2f} MB') + print(f'{len(queries)} queries with PDX') times = [] clock = TicToc() - results = [] for i in range(len(queries)): q = np.ascontiguousarray(queries[i]) clock.tic() index.search(q, knn, nprobe=nprobe) times.append(clock.toc()) print('PDX med. time:', np.median(np.array(times))) - # To check results of first query - results = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=nprobe) - print(results) - - print(f'{len(queries)} queries with FAISS') - times = [] - clock = TicToc() - results = [] - queries = index.preprocess(queries, inplace=False) - index.core_index.index.nprobe = nprobe - for i in range(len(queries)): - q = np.ascontiguousarray(np.array([queries[i]])) - clock.tic() - index.core_index.index.search(q, k=knn) - times.append(clock.toc()) - print('FAISS med. time:', np.median(np.array(times))) - # To check results of first query - print(index.core_index.index.search(np.array([queries[0]]), k=knn)) - + # Show results of first query + ids, dists = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=nprobe) + print('First query results (ids):', ids[:10]) + print('First query results (dists):', dists[:10]) diff --git a/examples/pdx_ivf_exhaustive.py b/examples/pdx_ivf_exhaustive.py index 3b733d7..fa30098 100644 --- a/examples/pdx_ivf_exhaustive.py +++ b/examples/pdx_ivf_exhaustive.py @@ -1,55 +1,45 @@ -import math import os import faiss import numpy as np from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXADSamplingIVFFlat +from pdxearch import IndexPDXIVF """ -PDXearch (pruned search) + ADSampling with an IVF index (built with FAISS) -We can do exact-search by exploring all the buckets. This lets the pruning strategy shine. +PDXearch with exhaustive search (nprobe=0 visits all clusters). +This lets the pruning strategy shine against brute-force FAISS. Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing """ if __name__ == "__main__": dataset_name = 'openai-1536-angular.hdf5' num_dimensions = 1536 knn = 100 - print(f'Running example: PDXearch + ADSampling (Exhaustive with IVFFlat)\n- D={num_dimensions}\n- k={knn}\n- nprobe=ALL\n- dataset={dataset_name}') + print(f'Running example: PDXearch Exhaustive Search\n- D={num_dimensions}\n- k={knn}\n- nprobe=ALL\n- dataset={dataset_name}') train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - nbuckets = 1 * math.ceil(math.sqrt(len(train))) - index = IndexPDXADSamplingIVFFlat(ndim=num_dimensions, nbuckets=nbuckets) - print('Preprocessing') - index.preprocess(train) - print('Training') - training_points = nbuckets * 50 - rng = np.random.default_rng() - training_sample_idxs = rng.choice(len(train), size=training_points, replace=False) - training_sample_idxs.sort() - index.train(train[training_sample_idxs]) - print('PDXifying') - index.add(train) + index = IndexPDXIVF(num_dimensions=num_dimensions, distance_metric="cosine") + print('Building index...') + index.build(train) + print(f'Index built: {index.num_clusters} clusters') queries = queries[:100] - print(f'{len(queries)} queries with PDX') + print(f'{len(queries)} queries with PDX (exhaustive)') times = [] clock = TicToc() - results = [] for i in range(len(queries)): q = np.ascontiguousarray(queries[i]) clock.tic() - index.search(q, knn, nprobe=0) # To search all buckets + index.search(q, knn, nprobe=0) # nprobe=0 searches all clusters times.append(clock.toc()) print('PDX med. time:', np.median(np.array(times))) - # To check results of first query - results = index.search(queries[0], knn, nprobe=0) - print(results) - print(f'{len(queries)} queries with FAISS') + # Show results of first query + ids, dists = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=0) + print('First query results (ids):', ids[:10]) + print('First query results (dists):', dists[:10]) + + print(f'{len(queries)} queries with FAISS (brute force)') times = [] clock = TicToc() - results = [] - queries = index.preprocess(queries, inplace=False) faiss_index = faiss.IndexFlatL2(num_dimensions) faiss_index.add(train) for i in range(len(queries)): @@ -58,7 +48,3 @@ faiss_index.search(q, k=knn) times.append(clock.toc()) print('FAISS med. time:', np.median(np.array(times))) - # To check results of first query - print(faiss_index.search(np.array([queries[0]]), k=knn)) - - diff --git a/examples/pdx_noindex.py b/examples/pdx_noindex.py deleted file mode 100644 index 5135f2b..0000000 --- a/examples/pdx_noindex.py +++ /dev/null @@ -1,58 +0,0 @@ -import numpy as np -import faiss -import os -from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXADSamplingFlat - -""" -PDXearch (pruned search) + ADSampling on the entire collection (no index) -This produces (almost) exact results -The vectors are transformed to improve pruning efficiency -Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing -""" -if __name__ == "__main__": - dataset_name = 'openai-1536-angular.hdf5' - num_dimensions = 1536 - knn = 100 - print(f'Running example: PDXearch + ADSampling (no index)\n- D={num_dimensions}\n- k={knn}\n- dataset={dataset_name}') - train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - - index = IndexPDXADSamplingFlat(ndim=num_dimensions) - - print('Preprocessing data') - index.preprocess(train, inplace=True) - print('PDXifying') - index.add(train) - - queries = queries[:100] - print(f'{len(queries)} queries with PDX') - times = [] - clock = TicToc() - results = [] - for i in range(len(queries)): - q = np.ascontiguousarray(queries[i]) - clock.tic() - index.search(q, knn) - times.append(clock.toc()) - print('PDX med. time:', np.median(np.array(times))) - # To check results of first query - results = index.search(queries[0], knn) - print(results) - - print(f'{len(queries)} queries with FAISS') - # We need to normalize the queries - index.preprocess(queries, inplace=True) - times = [] - clock = TicToc() - results = [] - faiss_index = faiss.IndexFlatL2(num_dimensions) - faiss_index.add(train) - for i in range(len(queries)): - q = np.ascontiguousarray(np.array([queries[i]])) - clock.tic() - faiss_index.search(q, k=knn) - times.append(clock.toc()) - print('FAISS med. time:', np.median(np.array(times))) - # To check results of first query - print(faiss_index.search(np.array([queries[0]]), k=knn)) - diff --git a/examples/pdx_noindex_bond.py b/examples/pdx_noindex_bond.py deleted file mode 100644 index e91f380..0000000 --- a/examples/pdx_noindex_bond.py +++ /dev/null @@ -1,54 +0,0 @@ -import numpy as np -import faiss -import os -from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXBONDFlat - -""" -PDXearch (pruned search) + BOND on the entire collection (no index) -This produces exact results, and does not need to transform the vectors -Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing -""" -if __name__ == "__main__": - dataset_name = 'msong-420.hdf5' - num_dimensions = 420 - knn = 100 - print(f'Running example: PDXearch + BOND (no index)\n- D={num_dimensions}\n- k={knn}\n- dataset={dataset_name}') - train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - queries = queries[:100] - - index = IndexPDXBONDFlat(ndim=num_dimensions) - print('PDXifying Collection') - index.preprocess(train) - index.add(train) - - print(f'{len(queries)} queries with PDX') - times = [] - clock = TicToc() - results = [] - for i in range(len(queries)): - q = np.ascontiguousarray(queries[i]) - clock.tic() - index.search(q, knn) - times.append(clock.toc()) - print('PDX med. time:', np.median(np.array(times))) - # To check results of first query - results = index.search(queries[0], knn) - print(results) - - print(f'{len(queries)} queries with FAISS') - # We need to normalize the queries - index.preprocess(queries, inplace=True) - times = [] - clock = TicToc() - results = [] - faiss_index = faiss.IndexFlatL2(num_dimensions) - faiss_index.add(train) - for i in range(len(queries)): - q = np.ascontiguousarray(np.array([queries[i]])) - clock.tic() - faiss_index.search(q, k=knn) - times.append(clock.toc()) - print('FAISS med. time:', np.median(np.array(times))) - # To check results of first query - print(faiss_index.search(np.array([queries[0]]), k=knn)) diff --git a/examples/pdx_persist.py b/examples/pdx_persist.py index 195533a..fa43612 100644 --- a/examples/pdx_persist.py +++ b/examples/pdx_persist.py @@ -1,50 +1,48 @@ -import math import os import numpy as np - from examples_utils import TicToc, read_hdf5_data -from pdxearch.index_factory import IndexPDXADSamplingIVFFlat +from pdxearch import IndexPDXIVFTreeSQ8, load_index """ -Example to store the PDX index and the metadata in a file and how to use it later +Example to save a PDX index to a file and reload it later. Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing """ if __name__ == "__main__": - dataset_name = 'yahoo-minilm-384-normalized.hdf5' - num_dimensions = 384 - nprobe = 32 - knn = 10 - print(f'Running example: Persisting PDXADSamplingIVF Index\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') + dataset_name = 'agnews-mxbai-1024-euclidean.hdf5' + num_dimensions = 1024 + nprobe = 25 + knn = 20 + print(f'Running example: Persist and Load PDX Index\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) - nbuckets = 1 * math.ceil(math.sqrt(len(train))) - index = IndexPDXADSamplingIVFFlat(ndim=num_dimensions, nbuckets=nbuckets) - print('Preprocessing') - index.preprocess(train) - print('Training') - index.train(train) - print('PDXifying and Storing') - index_path = f'./examples/my_idx.pdx' - matrix_path = f'./examples/my_idx.matrix' - index.add_persist(train, index_path, matrix_path) - - print('Restoring') + index = IndexPDXIVFTreeSQ8(num_dimensions=num_dimensions, normalize=True) + print('Building index...') + index.build(train) + print(f'Index built: {index.num_clusters} clusters') + + index_path = './examples/my_idx.pdx' + print(f'Saving index to {index_path}') + index.save(index_path) + + print('Loading index from file...') del index - # TODO: Restoring should be a utility and static method that instantiate the appropiate class - index = IndexPDXADSamplingIVFFlat() - index.restore(index_path, matrix_path) + restored_index = load_index(index_path) - print(f'{len(queries)} queries with PDX') + print(f'{len(queries)} queries with restored PDX index') times = [] clock = TicToc() - results = [] + restored_index.set_nprobe(nprobe) for i in range(len(queries)): q = np.ascontiguousarray(queries[i]) clock.tic() - index.search(q, knn, nprobe=nprobe) + restored_index.search(q, knn) times.append(clock.toc()) print('PDX med. time:', np.median(np.array(times))) - results = index.search(queries[0], knn, nprobe=nprobe) - print(results) + ids, dists = restored_index.search(np.ascontiguousarray(queries[0]), knn) + print('First query results (ids):', ids[:10]) + print('First query results (dists):', dists[:10]) + + # Cleanup + os.remove(index_path) diff --git a/examples/pdx_simple.py b/examples/pdx_simple.py index a9ec785..f0b05a0 100644 --- a/examples/pdx_simple.py +++ b/examples/pdx_simple.py @@ -1,62 +1,41 @@ -import math import numpy as np import sklearn.datasets from sklearn.model_selection import train_test_split from examples_utils import TicToc -from pdxearch.index_factory import IndexPDXADSamplingIVFFlat, IndexPDXBONDIVFFlat +from pdxearch import IndexPDXIVF """ -PDXearch (pruned search) + ADSampling with an IVF index (built with FAISS) -This example uses a random collection of vectors +PDXearch with a single-level IVF index on random data. +This example uses a random collection of vectors generated with sklearn. """ if __name__ == "__main__": num_dimensions = 768 - num_embeddings = 1_000_000 + num_embeddings = 100_000 num_query_embeddings = 100 knn = 100 nprobe = 64 - print(f'Running example: PDXearch + ADSampling (IVFFlat)\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset=RANDOM') + print(f'Running example: PDXearch IVF (F32) on random data\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset=RANDOM') X, _ = sklearn.datasets.make_blobs(n_samples=num_embeddings, n_features=num_dimensions, centers=1000, random_state=1) X = X.astype(np.float32) data, queries = train_test_split(X, test_size=num_query_embeddings) - nbuckets = 1 * math.ceil(math.sqrt(num_embeddings)) - index = IndexPDXADSamplingIVFFlat(ndim=num_dimensions, nbuckets=nbuckets) + index = IndexPDXIVF(num_dimensions=num_dimensions, normalize=True) + print('Building index...') + index.build(data) + print(f'Index built: {index.num_clusters} clusters') + print(f'Index in-memory size: {index.in_memory_size_bytes / (1024 * 1024):.2f} MB') - print('Preprocessing') - index.preprocess(data) # Preprocess vectors with ADSampling - print('Training IVF') - index.train(data) # Train IVF with FAISS - print('PDXifying') - index.add(data) # Add vectors and load PDX index in memory print(f'{len(queries)} queries with PDX') times = [] clock = TicToc() - results = [] for i in range(num_query_embeddings): q = np.ascontiguousarray(queries[i]) clock.tic() index.search(q, knn, nprobe=nprobe) times.append(clock.toc()) print('PDX med. time:', np.median(np.array(times))) - # To check results... - results = index.search(queries[0], knn, nprobe=nprobe) - print(results) - - times = [] - clock = TicToc() - results = [] - # We need to normalize the queries - queries = index.preprocess(queries, inplace=False) - index.core_index.index.nprobe = nprobe - print(f'{len(queries)} queries with FAISS') - for i in range(num_query_embeddings): - q = np.ascontiguousarray(np.array([queries[i]])) - clock.tic() - index.core_index.index.search(q, k=knn) - times.append(clock.toc()) - print('FAISS med. time:', np.median(np.array(times))) - # To check results... - print(index.core_index.index.search(np.array([queries[0]]), k=knn)) - + # Show results of first query + ids, dists = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=nprobe) + print('First query results (ids):', ids[:10]) + print('First query results (dists):', dists[:10]) diff --git a/examples/pdx_tree_flat.py b/examples/pdx_tree_flat.py new file mode 100644 index 0000000..3e13407 --- /dev/null +++ b/examples/pdx_tree_flat.py @@ -0,0 +1,40 @@ +import os +import numpy as np +from examples_utils import TicToc, read_hdf5_data +from pdxearch import IndexPDXIVFTree + +np.random.seed(42) + +""" +PDXearch with a two-level IVF index (F32). +Recall is controlled with nprobe parameter. +Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing +""" +if __name__ == "__main__": + dataset_name = 'agnews-mxbai-1024-euclidean.hdf5' + num_dimensions = 1024 + nprobe = 25 + knn = 100 + print(f'Running example: PDXearch Two-Level IVF (F32)\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') + train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) + + index = IndexPDXIVFTree(num_dimensions=num_dimensions, normalize=True) + print('Building index...') + index.build(train) + print(f'Index built: {index.num_clusters} clusters') + print(f'Index in-memory size: {index.in_memory_size_bytes / (1024 * 1024):.2f} MB') + + print(f'{len(queries)} queries with PDX') + times = [] + clock = TicToc() + for i in range(len(queries)): + q = np.ascontiguousarray(queries[i]) + clock.tic() + index.search(q, knn, nprobe=nprobe) + times.append(clock.toc()) + print('PDX med. time:', np.median(np.array(times))) + + # Show results of first query + ids, dists = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=nprobe) + print('First query results (ids):', ids[:10]) + print('First query results (dists):', dists[:10]) diff --git a/examples/pdx_tree_sq8.py b/examples/pdx_tree_sq8.py new file mode 100644 index 0000000..8ade708 --- /dev/null +++ b/examples/pdx_tree_sq8.py @@ -0,0 +1,39 @@ +import os +import numpy as np +from examples_utils import TicToc, read_hdf5_data +from pdxearch import IndexPDXIVFTreeSQ8 + +np.random.seed(42) + +""" +PDXearch with a two-level IVF index + 8-bit scalar quantization (U8). +Recall is controlled with nprobe parameter. +Download the .hdf5 data here: https://drive.google.com/drive/folders/1f76UCrU52N2wToGMFg9ir1MY8ZocrN34?usp=sharing +""" +if __name__ == "__main__": + dataset_name = 'agnews-mxbai-1024-euclidean.hdf5' + num_dimensions = 1024 + nprobe = 25 + knn = 20 + print(f'Running example: PDXearch Two-Level IVF (SQ8)\n- D={num_dimensions}\n- k={knn}\n- nprobe={nprobe}\n- dataset={dataset_name}') + train, queries = read_hdf5_data(os.path.join('./benchmarks/datasets/downloaded', dataset_name)) + + index = IndexPDXIVFTreeSQ8(num_dimensions=num_dimensions, normalize=True) + print('Building index...') + index.build(train) + print(f'Index built: {index.num_clusters} clusters') + print(f'Index in-memory size: {index.in_memory_size_bytes / (1024 * 1024):.2f} MB') + + print(f'{len(queries)} queries with PDX') + times = [] + clock = TicToc() + for i in range(len(queries)): + clock.tic() + index.search(queries[i], knn, nprobe=nprobe) + times.append(clock.toc()) + print('PDX med. time:', np.median(np.array(times))) + + # Show results of first query + ids, dists = index.search(np.ascontiguousarray(queries[0]), knn, nprobe=nprobe) + print('First query results (ids):', ids[:10]) + print('First query results (dists):', dists[:10]) diff --git a/extern/Eigen b/extern/Eigen index 132f281..ac3ef16 160000 --- a/extern/Eigen +++ b/extern/Eigen @@ -1 +1 @@ -Subproject commit 132f281f501380faeba1b5b1fe7e4f237099c2a3 +Subproject commit ac3ef16f30b7708a3c6c39b964e1f294dd16b8a3 diff --git a/extern/SuperKMeans b/extern/SuperKMeans new file mode 160000 index 0000000..4a8ce02 --- /dev/null +++ b/extern/SuperKMeans @@ -0,0 +1 @@ +Subproject commit 4a8ce028f3fef8f276dc187cd46f12e76c75f21e diff --git a/include/common.hpp b/include/common.hpp deleted file mode 100644 index ed6d638..0000000 --- a/include/common.hpp +++ /dev/null @@ -1,115 +0,0 @@ -#ifndef PDX_COMMON_HPP -#define PDX_COMMON_HPP - -#include -#include -#include - - -namespace PDX { - - static inline float PROPORTION_VERTICAL_DIM = 0.75; - static inline size_t D_THRESHOLD_FOR_DCT_ROTATION = 512; - static constexpr size_t PDX_VECTOR_SIZE = 64; - - template - static constexpr uint32_t AlignValue(T n) { - return ((n + (val - 1)) / val) * val; - } - - enum DimensionsOrder { - SEQUENTIAL, - DISTANCE_TO_MEANS, - DECREASING, - DISTANCE_TO_MEANS_IMPROVED, - DECREASING_IMPROVED, - DIMENSION_ZONES - }; - - enum DistanceFunction { - L2, - IP, - L1, - NEGATIVE_L2 // Only the negative term of L2 (-2*q[i]*d[i]) - }; - - enum Quantization { - F32, - U8, - // TODO: - F16, - BF, - U6, - U4, - ASYMMETRIC_U8, - ASYMMETRIC_LEP_U8 - }; - - // TODO: Do the same for indexes? - template - struct DistanceType { - using type = uint32_t; // default for U8, U6, U4 - }; - template<> - struct DistanceType { - using type = float; - }; - template - using DistanceType_t = typename DistanceType::type; - - // TODO: Do the same for indexes? - template - struct DataType { - using type = uint8_t; // default for U8, U6, U4 - }; - template<> - struct DataType { - using type = float; - }; - template - using DataType_t = typename DataType::type; - - - template - struct QuantizedVectorType { - using type = uint8_t; // default for U8, U6, U4 - }; - template<> - struct QuantizedVectorType { - using type = float; - }; - template - using QuantizedVectorType_t = typename QuantizedVectorType::type; - - - template - struct KNNCandidate { - uint32_t index; - float distance; - }; - - template - struct VectorComparator { - bool operator() (const KNNCandidate& a, const KNNCandidate& b) { - return a.distance < b.distance; - } - }; - - template - struct Cluster { // default for U8, U6, U4 - uint32_t num_embeddings{}; - uint32_t *indices = nullptr; - uint8_t *data = nullptr; - }; - - template<> - struct Cluster { - uint32_t num_embeddings{}; - uint32_t *indices = nullptr; - float *data = nullptr; - }; - - -}; - -#endif //PDX_COMMON_HPP diff --git a/include/db_mock/predicate_evaluator.hpp b/include/db_mock/predicate_evaluator.hpp deleted file mode 100644 index e8f99f5..0000000 --- a/include/db_mock/predicate_evaluator.hpp +++ /dev/null @@ -1,61 +0,0 @@ -#ifndef PDX_PREDICATE_EVALUATOR_H -#define PDX_PREDICATE_EVALUATOR_H - -#include -#include -#include -#include -#include "common.hpp" -#include "utils/file_reader.hpp" - -namespace PDX { - -class PredicateEvaluator { - -public: - - std::unique_ptr file_buffer; - - uint32_t * n_passing_tuples = nullptr; - uint8_t * selection_vector = nullptr; - size_t passing_tuples = 0; - size_t n_clusters; - - PredicateEvaluator(size_t n_clusters): n_clusters(n_clusters){}; - - ~PredicateEvaluator() = default; - - void LoadSelectionVectorFromFile(const std::string &filename) { - file_buffer = MmapFile(filename); - LoadSelectionVector(file_buffer.get()); - } - - void LoadSelectionVector(char *input) { - char *next_value = input; - n_passing_tuples = (uint32_t *) next_value; - passing_tuples = 0; - for (size_t i = 0; i < n_clusters; i++) { - passing_tuples += n_passing_tuples[i]; - } - next_value += sizeof(uint32_t) * n_clusters; - selection_vector = (uint8_t *) next_value; - } - - void LoadSelectionVector(uint32_t *n_passing_tuples_p, uint8_t *selection_vector_p) { - n_passing_tuples = n_passing_tuples_p; - passing_tuples = 0; - for (size_t i = 0; i < n_clusters; i++) { - passing_tuples += n_passing_tuples[i]; - } - selection_vector = selection_vector_p; - } - - std::pair GetSelectionVector(const size_t cluster_id, const size_t cluster_offset) const { - return { selection_vector + cluster_offset, n_passing_tuples[cluster_id]}; - } - -}; - -}; // namespace PDX - -#endif //PDX_PREDICATE_EVALUATOR_H diff --git a/include/distance_computers/avx2_computers.hpp b/include/distance_computers/avx2_computers.hpp deleted file mode 100644 index 4740278..0000000 --- a/include/distance_computers/avx2_computers.hpp +++ /dev/null @@ -1,212 +0,0 @@ -#ifndef PDX_AVX2_COMPUTERS_HPP -#define PDX_AVX2_COMPUTERS_HPP - -#include -#include -#include -#include "common.hpp" -#include "distance_computers/scalar_computers.hpp" - -namespace PDX { - -template -class SIMDComputer { -}; - -template<> -class SIMDComputer { - -}; - - -template<> -class SIMDComputer { -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - using scalar_computer = ScalarComputer; - - alignas(64) static DISTANCE_TYPE pruning_distances_tmp[4096]; - - // Defer to the scalar kernel - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE *distances_p, - const uint32_t *pruning_positions = nullptr, - const uint32_t *indices_dimensions = nullptr, - const int32_t *dim_clip_value = nullptr - ) { - size_t dimensions_jump_factor = total_vectors; - for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; ++dimension_idx) { - uint32_t true_dimension_idx = dimension_idx; - if constexpr (USE_DIMENSIONS_REORDER) { - true_dimension_idx = indices_dimensions[dimension_idx]; - } - size_t offset_to_dimension_start = true_dimension_idx * dimensions_jump_factor; - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - auto true_vector_idx = vector_idx; - if constexpr (SKIP_PRUNED) { - true_vector_idx = pruning_positions[vector_idx]; - } - float to_multiply = query[true_dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; - distances_p[true_vector_idx] += to_multiply * to_multiply; - } - } - } - - // Defer to the scalar kernel - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE *distances_p - ) { - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx++) { - size_t dimension_idx = dim_idx; - size_t offset_to_dimension_start = dimension_idx * PDX_VECTOR_SIZE; - for (size_t vector_idx = 0; vector_idx < PDX_VECTOR_SIZE; ++vector_idx) { - float to_multiply = query[dimension_idx] - data[offset_to_dimension_start + vector_idx]; - distances_p[vector_idx] += to_multiply * to_multiply; - } - } - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions - ) { - __m256 d2_vec = _mm256_setzero_ps(); - size_t i = 0; - for (; i + 8 <= num_dimensions; i += 8) { - __m256 a_vec = _mm256_loadu_ps(vector1 + i); - __m256 b_vec = _mm256_loadu_ps(vector2 + i); - __m256 d_vec = _mm256_sub_ps(a_vec, b_vec); - d2_vec = _mm256_fmadd_ps(d_vec, d_vec, d2_vec); - } - - // _simsimd_reduce_f32x8_haswell - // Convert the lower and higher 128-bit lanes of the input vector to double precision - __m128 low_f32 = _mm256_castps256_ps128(d2_vec); - __m128 high_f32 = _mm256_extractf128_ps(d2_vec, 1); - - // Convert single-precision (float) vectors to double-precision (double) vectors - __m256d low_f64 = _mm256_cvtps_pd(low_f32); - __m256d high_f64 = _mm256_cvtps_pd(high_f32); - - // Perform the addition in double-precision - __m256d sum = _mm256_add_pd(low_f64, high_f64); - - // Reduce the double-precision vector to a scalar - // Horizontal add the first and second double-precision values, and third and fourth - __m128d sum_low = _mm256_castpd256_pd128(sum); - __m128d sum_high = _mm256_extractf128_pd(sum, 1); - __m128d sum128 = _mm_add_pd(sum_low, sum_high); - - // Horizontal add again to accumulate all four values into one - sum128 = _mm_hadd_pd(sum128, sum128); - - // Convert the final sum to a scalar double-precision value and return - double d2 = _mm_cvtsd_f64(sum128); - - for (; i < num_dimensions; ++i) { - float d = vector1[i] - vector2[i]; - d2 += d * d; - } - - return static_cast(d2); - }; - -}; - -template <> -class SIMDComputer{ -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - - // Defer to the scalar kernel - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions = nullptr, - const uint32_t * indices_dimensions = nullptr, - const int32_t * dim_clip_value = nullptr - ){ - // TODO - } - - // Defer to the scalar kernel - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p - ){ - // TODO - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions - ){ - __m256 d2_vec = _mm256_setzero_ps(); - size_t i = 0; - for (; i + 8 <= num_dimensions; i += 8) { - __m256 a_vec = _mm256_loadu_ps(vector1 + i); - __m256 b_vec = _mm256_loadu_ps(vector2 + i); - d2_vec = _mm256_fmadd_ps(a_vec, b_vec, d2_vec); - } - - // _simsimd_reduce_f32x8_haswell - // Convert the lower and higher 128-bit lanes of the input vector to double precision - __m128 low_f32 = _mm256_castps256_ps128(d2_vec); - __m128 high_f32 = _mm256_extractf128_ps(d2_vec, 1); - - // Convert single-precision (float) vectors to double-precision (double) vectors - __m256d low_f64 = _mm256_cvtps_pd(low_f32); - __m256d high_f64 = _mm256_cvtps_pd(high_f32); - - // Perform the addition in double-precision - __m256d sum = _mm256_add_pd(low_f64, high_f64); - - // Reduce the double-precision vector to a scalar - // Horizontal add the first and second double-precision values, and third and fourth - __m128d sum_low = _mm256_castpd256_pd128(sum); - __m128d sum_high = _mm256_extractf128_pd(sum, 1); - __m128d sum128 = _mm_add_pd(sum_low, sum_high); - - // Horizontal add again to accumulate all four values into one - sum128 = _mm_hadd_pd(sum128, sum128); - - // Convert the final sum to a scalar double-precision value and return - double d2 = _mm_cvtsd_f64(sum128); - - for (; i < num_dimensions; ++i) { - d2 += vector1[i] * vector2[i]; - } - return static_cast(d2); - }; - -}; - -} - -#endif //PDX_AVX2_COMPUTERS_HPP diff --git a/include/distance_computers/avx512_computers.hpp b/include/distance_computers/avx512_computers.hpp deleted file mode 100644 index 391a1cc..0000000 --- a/include/distance_computers/avx512_computers.hpp +++ /dev/null @@ -1,358 +0,0 @@ -#ifndef PDX_AVX512_COMPUTERS_HPP -#define PDX_AVX512_COMPUTERS_HPP - -#include -#include -#include -#include -#include "common.hpp" -#include "distance_computers/scalar_computers.hpp" - -namespace PDX { - -template -class SIMDComputer {}; - -template <> -class SIMDComputer{ -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions = nullptr, - const uint32_t * indices_dimensions = nullptr, - const int32_t * dim_clip_value = nullptr, - const float * scaling_factors = nullptr - ){ - __m512i res; - __m512i vec2_u8; - __m512i vec1_u8; - __m512i diff_u8; - __m256i y_res; - __m256i y_vec2_u8; - __m256i y_vec1_u8; - __m256i y_diff_u8; - uint32_t * query_grouped = (uint32_t *)query; - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx+=4) { - uint32_t dimension_idx = dim_idx; - if constexpr (USE_DIMENSIONS_REORDER){ - dimension_idx = indices_dimensions[dim_idx]; - } - size_t offset_to_dimension_start = dimension_idx * total_vectors; - size_t i = 0; - if constexpr (!SKIP_PRUNED){ - // To load the query efficiently I will load it as uint32_t (4 bytes packed in 1 word) - uint32_t query_value = query_grouped[dimension_idx / 4]; - // And then broadcast it to the register - vec1_u8 = _mm512_set1_epi32(query_value); - for (; i + 16 <= n_vectors; i+=16) { - // Read 64 bytes of data (64 values) with 4 dimensions of 16 vectors - res = _mm512_load_si512(&distances_p[i]); - vec2_u8 = _mm512_loadu_si512(&data[offset_to_dimension_start + i * 4]); // This 4 is because everytime I read 4 dimensions - diff_u8 = _mm512_or_si512(_mm512_subs_epu8(vec1_u8, vec2_u8), _mm512_subs_epu8(vec2_u8, vec1_u8)); - _mm512_store_epi32(&distances_p[i], _mm512_dpbusds_epi32(res, diff_u8, diff_u8)); - } - y_vec1_u8 = _mm256_set1_epi32(query_value); - for (; i + 8 <= n_vectors; i+=8) { - // Read 32 bytes of data (32 values) with 4 dimensions of 8 vectors - y_res = _mm256_load_epi32(&distances_p[i]); - y_vec2_u8 = _mm256_loadu_epi8(&data[offset_to_dimension_start + i * 4]); // This 4 is because everytime I read 4 dimensions - y_diff_u8 = _mm256_or_si256(_mm256_subs_epu8(y_vec1_u8, y_vec2_u8), _mm256_subs_epu8(y_vec2_u8, y_vec1_u8)); - _mm256_store_epi32(&distances_p[i], _mm256_dpbusds_epi32(y_res, y_diff_u8, y_diff_u8)); - } - } - // rest - for (; i < n_vectors; ++i) { - size_t vector_idx = i; - if constexpr (SKIP_PRUNED){ - vector_idx = pruning_positions[vector_idx]; - } - int to_multiply_a = query[dimension_idx] - data[offset_to_dimension_start + (vector_idx * 4)]; - int to_multiply_b = query[dimension_idx + 1] - data[offset_to_dimension_start + (vector_idx * 4) + 1]; - int to_multiply_c = query[dimension_idx + 2] - data[offset_to_dimension_start + (vector_idx * 4) + 2]; - int to_multiply_d = query[dimension_idx + 3] - data[offset_to_dimension_start + (vector_idx * 4) + 3]; - distances_p[vector_idx] += (to_multiply_a * to_multiply_a) + - (to_multiply_b * to_multiply_b) + - (to_multiply_c * to_multiply_c) + - (to_multiply_d * to_multiply_d); - } - } - } - - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const float * scaling_factors = nullptr - ){ - __m512i res[4]; - const uint32_t * query_grouped = (uint32_t *)query; - // Load 64 initial values - for (size_t i = 0; i < 4; ++i) { - res[i] = _mm512_load_si512(&distances_p[i * 16]); - } - // Compute L2 - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx+=4) { - const uint32_t dimension_idx = dim_idx; - // To load the query efficiently I will load it as uint32_t (4 bytes packed in 1 word) - const uint32_t query_value = query_grouped[dimension_idx / 4]; - // And then broadcast it to the register - const __m512i vec1_u8 = _mm512_set1_epi32(query_value); - const size_t offset_to_dimension_start = dimension_idx * PDX_VECTOR_SIZE; - for (int i = 0; i < 4; ++i) { // total: 64 vectors (4 iterations of 16 vectors) * 4 dimensions each (at 1 byte per value = 2048-bits) - // Read 64 bytes of data (64 values) with 4 dimensions of 16 vectors - const __m512i vec2_u8 = _mm512_loadu_si512(&data[offset_to_dimension_start + i * 64]); - const __m512i diff_u8 = _mm512_or_si512(_mm512_subs_epu8(vec1_u8, vec2_u8), _mm512_subs_epu8(vec2_u8, vec1_u8)); - // I can use this asymmetric dot product as my values are actually 7-bit - // Hence, the [sign] properties of the second operand is ignored - // As results will never be negative, it can be stored on res[i] without issues - // and it saturates to MAX_INT - res[i] = _mm512_dpbusds_epi32(res[i], diff_u8, diff_u8); - } - } - // Store results back - for (int i = 0; i < 4; ++i) { - _mm512_store_epi32(&distances_p[i * 16], res[i]); - } - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions, - const float * scaling_factors = nullptr - ){ - __m512i d2_i32_vec = _mm512_setzero_si512(); - __m512i a_u8_vec, b_u8_vec; - -simsimd_l2sq_u8_ice_cycle: - if (num_dimensions < 64) { - const __mmask64 mask = (__mmask64)_bzhi_u64(0xFFFFFFFFFFFFFFFF, num_dimensions); - a_u8_vec = _mm512_maskz_loadu_epi8(mask, vector1); - b_u8_vec = _mm512_maskz_loadu_epi8(mask, vector2); - num_dimensions = 0; - } - else { - a_u8_vec = _mm512_loadu_si512(vector1); - b_u8_vec = _mm512_loadu_si512(vector2); - vector1 += 64, vector2 += 64, num_dimensions -= 64; - } - - // Substracting unsigned vectors in AVX-512 is done by saturating subtraction: - __m512i d_u8_vec = _mm512_or_si512(_mm512_subs_epu8(a_u8_vec, b_u8_vec), _mm512_subs_epu8(b_u8_vec, a_u8_vec)); - - // Multiply and accumulate at `int8` level which are actually uint7, accumulate at `int32` level: - d2_i32_vec = _mm512_dpbusds_epi32(d2_i32_vec, d_u8_vec, d_u8_vec); - if (num_dimensions) goto simsimd_l2sq_u8_ice_cycle; - return _mm512_reduce_add_epi32(d2_i32_vec); - }; -}; - - -template <> -class SIMDComputer{ -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - using scalar_computer = ScalarComputer; - - alignas(64) static DISTANCE_TYPE pruning_distances_tmp[4096]; - - static void GatherDistances( - size_t n_vectors, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions - ){ - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - auto true_vector_idx = pruning_positions[vector_idx]; - pruning_distances_tmp[vector_idx] = distances_p[true_vector_idx]; - } - } - - template - static void GatherBasedKernel( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions = nullptr, - const uint32_t * indices_dimensions = nullptr, - const int32_t * dim_clip_value = nullptr, - const float * scaling_factors = nullptr - ){ - GatherDistances(n_vectors, distances_p, pruning_positions); - __m512 data_vec, d_vec, cur_dist_vec; - __m256 data_vec_m256, d_vec_m256, cur_dist_vec_m256; - // Then we move data to be sequential - size_t dimensions_jump_factor = total_vectors; - for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; ++dimension_idx) { - uint32_t true_dimension_idx = dimension_idx; - if constexpr (USE_DIMENSIONS_REORDER) { - true_dimension_idx = indices_dimensions[dimension_idx]; - } - __m512 query_vec; - query_vec = _mm512_set1_ps(query[true_dimension_idx]); -// if constexpr (L_ALPHA == IP){ -// query_vec = _mm512_set1_ps(-2 * query[true_dimension_idx]); -// } - size_t offset_to_dimension_start = true_dimension_idx * dimensions_jump_factor; - const float * tmp_data = data + offset_to_dimension_start; - // Now we do the sequential distance calculation loop which would use SIMD - // Up to 16 - size_t i = 0; - for (; i + 16 < n_vectors; i+=16) { - cur_dist_vec = _mm512_load_ps(&pruning_distances_tmp[i]); - data_vec = _mm512_i32gather_ps( - _mm512_load_epi32(&pruning_positions[i]), - tmp_data, sizeof(DISTANCE_TYPE) - ); - d_vec = _mm512_sub_ps(data_vec, query_vec); - cur_dist_vec = _mm512_fmadd_ps(d_vec, d_vec, cur_dist_vec); - _mm512_store_ps(&pruning_distances_tmp[i], cur_dist_vec); - } - __m256 query_vec_m256; - query_vec_m256 = _mm256_set1_ps(query[true_dimension_idx]); -// if constexpr (L_ALPHA == IP){ -// query_vec_m256 = _mm256_set1_ps(-2 * query[true_dimension_idx]); -// } - // Up to 8 - for (; i + 8 < n_vectors; i+=8) { - cur_dist_vec_m256 = _mm256_load_ps(&pruning_distances_tmp[i]); - data_vec_m256 = _mm256_i32gather_ps( - tmp_data, _mm256_load_epi32(&pruning_positions[i]), - sizeof(DISTANCE_TYPE) - ); - d_vec_m256 = _mm256_sub_ps(data_vec_m256, query_vec_m256); - cur_dist_vec_m256 = _mm256_fmadd_ps(d_vec_m256, d_vec_m256, cur_dist_vec_m256); - _mm256_store_ps(&pruning_distances_tmp[i], cur_dist_vec_m256); - } - // Tail - for (; i < n_vectors; i++){ - float to_multiply = query[true_dimension_idx] - tmp_data[pruning_positions[i]]; - pruning_distances_tmp[i] += to_multiply * to_multiply; - } - } - // We now move distances back - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - auto true_vector_idx = pruning_positions[vector_idx]; - distances_p[true_vector_idx] = pruning_distances_tmp[vector_idx]; - } - } - - // Defer to the scalar kernel - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions = nullptr, - const uint32_t * indices_dimensions = nullptr, - const int32_t * dim_clip_value = nullptr, - const float * scaling_factors = nullptr - ){ - // SIMD is less efficient when looping on the array of not-yet pruned vectors - // A way to improve the performance by ~20% is using a GATHER intrinsic. However this only works on Intel microarchs. - // In AMD (Zen 4, Zen 3) using a GATHER is shooting ourselves in the foot (~80 uops) - // __AVX512FP16__ macro let us detect Intel architectures (from Sapphire Rapids onwards) -#if false && defined(__AVX512FP16__) - if (n_vectors >= 8) { - GatherBasedKernel( - query, data, n_vectors, total_vectors, start_dimension, end_dimension, - distances_p, pruning_positions, indices_dimensions - ); - return; - } -#endif - size_t dimensions_jump_factor = total_vectors; - for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; ++dimension_idx) { - uint32_t true_dimension_idx = dimension_idx; - if constexpr (USE_DIMENSIONS_REORDER){ - true_dimension_idx = indices_dimensions[dimension_idx]; - } - size_t offset_to_dimension_start = true_dimension_idx * dimensions_jump_factor; - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - auto true_vector_idx = vector_idx; - if constexpr(SKIP_PRUNED){ - true_vector_idx = pruning_positions[vector_idx]; - } - float to_multiply = query[true_dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; - distances_p[true_vector_idx] += to_multiply * to_multiply; - } - } - } - - // Defer to the scalar kernel - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const float * scaling_factors = nullptr - ){ - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx++) { - size_t dimension_idx = dim_idx; - size_t offset_to_dimension_start = dimension_idx * PDX_VECTOR_SIZE; - for (size_t vector_idx = 0; vector_idx < PDX_VECTOR_SIZE; ++vector_idx) { - float to_multiply = query[dimension_idx] - data[offset_to_dimension_start + vector_idx]; - distances_p[vector_idx] += to_multiply * to_multiply; - } - } - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions, - const float * scaling_factors = nullptr - ){ - __m512 d2_vec = _mm512_setzero(); - __m512 a_vec, b_vec; - simsimd_l2sq_f32_skylake_cycle: - if (num_dimensions < 16) { - __mmask16 mask = (__mmask16)_bzhi_u32(0xFFFFFFFF, num_dimensions); - a_vec = _mm512_maskz_loadu_ps(mask, vector1); - b_vec = _mm512_maskz_loadu_ps(mask, vector2); - num_dimensions = 0; - } else { - a_vec = _mm512_loadu_ps(vector1); - b_vec = _mm512_loadu_ps(vector2); - vector1 += 16, vector2 += 16, num_dimensions -= 16; - } - __m512 d_vec = _mm512_sub_ps(a_vec, b_vec); - d2_vec = _mm512_fmadd_ps(d_vec, d_vec, d2_vec); - if (num_dimensions) - goto simsimd_l2sq_f32_skylake_cycle; - - // _simsimd_reduce_f32x16_skylake - __m512 x = _mm512_add_ps(d2_vec, _mm512_shuffle_f32x4(d2_vec, d2_vec, _MM_SHUFFLE(0, 0, 3, 2))); - __m128 r = _mm512_castps512_ps128(_mm512_add_ps(x, _mm512_shuffle_f32x4(x, x, _MM_SHUFFLE(0, 0, 0, 1)))); - r = _mm_hadd_ps(r, r); - return _mm_cvtss_f32(_mm_hadd_ps(r, r)); - }; -}; - -} - - -#endif //PDX_AVX512_COMPUTERS_HPP diff --git a/include/distance_computers/base_computers.hpp b/include/distance_computers/base_computers.hpp deleted file mode 100644 index f3be17e..0000000 --- a/include/distance_computers/base_computers.hpp +++ /dev/null @@ -1,59 +0,0 @@ -#pragma once -#ifndef PDX_BASE_COMPUTERS_HPP -#define PDX_BASE_COMPUTERS_HPP - -#include -#include -#include -#include "common.hpp" - -#ifdef __ARM_NEON -#include "neon_computers.hpp" -#endif - -#if defined(__AVX2__) && !defined(__AVX512F__) -#include "avx2_computers.hpp" -#endif - -#ifdef __AVX512F__ -#include "avx512_computers.hpp" -#endif - -// TODO: Support SVE - -namespace PDX { - -template -class DistanceComputer {}; - -template<> -class DistanceComputer { - using computer = SIMDComputer; -public: - constexpr static auto VerticalReorderedPruning = computer::VerticalPruning; - constexpr static auto VerticalPruning = computer::VerticalPruning; - constexpr static auto VerticalReordered = computer::VerticalPruning; - constexpr static auto Vertical = computer::VerticalPruning; - - constexpr static auto VerticalBlock = computer::Vertical; - constexpr static auto Horizontal = computer::Horizontal; -}; - -template<> -class DistanceComputer { - using computer = SIMDComputer; -public: - constexpr static auto VerticalReorderedPruning = computer::VerticalPruning; - constexpr static auto VerticalPruning = computer::VerticalPruning; - constexpr static auto VerticalReordered = computer::VerticalPruning; - constexpr static auto Vertical = computer::VerticalPruning; - - constexpr static auto VerticalBlock = computer::Vertical; - constexpr static auto Horizontal = computer::Horizontal; -}; - - -}; // namespace PDX - - -#endif //PDX_BASE_COMPUTERS_HPP \ No newline at end of file diff --git a/include/distance_computers/neon_computers.hpp b/include/distance_computers/neon_computers.hpp deleted file mode 100644 index fc0f256..0000000 --- a/include/distance_computers/neon_computers.hpp +++ /dev/null @@ -1,231 +0,0 @@ -#pragma once -#ifndef PDX_NEON_COMPUTERS_HPP -#define PDX_NEON_COMPUTERS_HPP - -#include -#include -#include "arm_neon.h" -#include -#include -#include "common.hpp" - -namespace PDX { - -template -class SIMDComputer {}; - -template <> -class SIMDComputer{ -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions = nullptr, - const uint32_t * indices_dimensions = nullptr, - const int32_t * dim_clip_value = nullptr, - const float * scaling_factors = nullptr - ){ - // TODO: Handle tail in dimension length, for now im not going to worry on that as all the datasets are divisible by 4 - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx+=4) { - uint32_t dimension_idx = dim_idx; - if constexpr (USE_DIMENSIONS_REORDER){ - dimension_idx = indices_dimensions[dim_idx]; - } - uint8x8_t vals = vld1_u8(&query[dimension_idx]); - size_t offset_to_dimension_start = dimension_idx * total_vectors; - size_t i = 0; - if constexpr (!SKIP_PRUNED){ - const uint8x16_t idx = {0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3}; - const uint8x16_t vec1_u8 = vqtbl1q_u8(vcombine_u8(vals, vals), idx); - for (; i + 4 <= n_vectors; i+=4) { - // Read 16 bytes of data (16 values) with 4 dimensions of 4 vectors - uint32x4_t res = vld1q_u32(&distances_p[i]); - uint8x16_t vec2_u8 = vld1q_u8(&data[offset_to_dimension_start + i * 4]); // This 4 is because everytime I read 4 dimensions - uint8x16_t diff_u8 = vabdq_u8(vec1_u8, vec2_u8); - vst1q_u32(&distances_p[i], vdotq_u32(res, diff_u8, diff_u8)); - } - } - for (; i < n_vectors; ++i) { - size_t vector_idx = i; - if constexpr (SKIP_PRUNED){ - vector_idx = pruning_positions[vector_idx]; - } - // L2 - int to_multiply_a = query[dimension_idx] - data[offset_to_dimension_start + (vector_idx * 4)]; - int to_multiply_b = query[dimension_idx + 1] - data[offset_to_dimension_start + (vector_idx * 4) + 1]; - int to_multiply_c = query[dimension_idx + 2] - data[offset_to_dimension_start + (vector_idx * 4) + 2]; - int to_multiply_d = query[dimension_idx + 3] - data[offset_to_dimension_start + (vector_idx * 4) + 3]; - distances_p[vector_idx] += (to_multiply_a * to_multiply_a) + - (to_multiply_b * to_multiply_b) + - (to_multiply_c * to_multiply_c) + - (to_multiply_d * to_multiply_d); - - } - } - } - - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const float * scaling_factors = nullptr - ){ - uint32x4_t res[16]; - // Load initial values - for (size_t i = 0; i < 16; ++i) { - res[i] = vdupq_n_u32(0); - } - // Compute L2 - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx+=4) { - uint32_t dimension_idx = dim_idx; - uint8x8_t vals = vld1_u8(&query[dimension_idx]); - uint8x16_t idx = {0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3}; - uint8x16_t vec1_u8 = vqtbl1q_u8(vcombine_u8(vals, vals), idx); - size_t offset_to_dimension_start = dimension_idx * PDX_VECTOR_SIZE; - for (int i = 0; i < 16; ++i) { // total: 64 vectors * 4 dimensions each (at 1 byte per value = 2048-bits) - // Read 16 bytes of data (16 values) with 4 dimensions of 4 vectors - uint8x16_t vec2_u8 = vld1q_u8(&data[offset_to_dimension_start + i * 16]); - uint8x16_t diff_u8 = vabdq_u8(vec1_u8, vec2_u8); - res[i] = vdotq_u32(res[i], diff_u8, diff_u8); - } - } - // Store results back - for (int i = 0; i < 16; ++i) { - vst1q_u32(&distances_p[i * 4], res[i]); - } - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions, - const float * scaling_factors = nullptr - ){ - uint32x4_t sum_vec = vdupq_n_u32(0); - size_t i = 0; - for (; i + 16 <= num_dimensions; i += 16) { - uint8x16_t a_vec = vld1q_u8(vector1 + i); - uint8x16_t b_vec = vld1q_u8(vector2 + i); - uint8x16_t d_vec = vabdq_u8(a_vec, b_vec); - sum_vec = vdotq_u32(sum_vec, d_vec, d_vec); - } - DISTANCE_TYPE distance = vaddvq_u32(sum_vec); - for (; i < num_dimensions; ++i) { - int n = (int)vector1[i] - vector2[i]; - distance += n * n; - } - return distance; - }; -}; - - -template <> -class SIMDComputer{ -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - - // Defer to the scalar kernel - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions, - const uint32_t * indices_dimensions, - const int32_t * dim_clip_value, - const float * scaling_factors - ){ - size_t dimensions_jump_factor = total_vectors; - for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; ++dimension_idx) { - uint32_t true_dimension_idx = dimension_idx; - if constexpr (USE_DIMENSIONS_REORDER){ - true_dimension_idx = indices_dimensions[dimension_idx]; - } - size_t offset_to_dimension_start = true_dimension_idx * dimensions_jump_factor; - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - auto true_vector_idx = vector_idx; - if constexpr(SKIP_PRUNED){ - true_vector_idx = pruning_positions[vector_idx]; - } - float to_multiply = query[true_dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; - distances_p[true_vector_idx] += to_multiply * to_multiply; - } - } - } - - // Defer to the scalar kernel - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const float * scaling_factors - ){ - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx++) { - size_t dimension_idx = dim_idx; - size_t offset_to_dimension_start = dimension_idx * PDX_VECTOR_SIZE; - for (size_t vector_idx = 0; vector_idx < PDX_VECTOR_SIZE; ++vector_idx) { - float to_multiply = query[dimension_idx] - data[offset_to_dimension_start + vector_idx]; - distances_p[vector_idx] += to_multiply * to_multiply; - } - } - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions, - const float * scaling_factors = nullptr - ){ -#if defined(__APPLE__) - float distance = 0.0; -#pragma clang loop vectorize(enable) - for (size_t i = 0; i < num_dimensions; ++i) { - float diff = vector1[i] - vector2[i]; - distance += diff * diff; - } - return distance; -#else - float32x4_t sum_vec = vdupq_n_f32(0); - size_t i = 0; - for (; i + 4 <= num_dimensions; i += 4) { - float32x4_t a_vec = vld1q_f32(vector1 + i); - float32x4_t b_vec = vld1q_f32(vector2 + i); - float32x4_t diff_vec = vsubq_f32(a_vec, b_vec); - sum_vec = vfmaq_f32(sum_vec, diff_vec, diff_vec); - } - DISTANCE_TYPE distance = vaddvq_f32(sum_vec); - for (; i < num_dimensions; ++i) { - float diff = vector1[i] - vector2[i]; - distance += diff * diff; - } - return distance; -#endif - }; -}; - - - -} // namespace PDX - - -#endif //PDX_NEON_COMPUTERS_HPP diff --git a/include/distance_computers/scalar_computers.hpp b/include/distance_computers/scalar_computers.hpp deleted file mode 100644 index 28d4ffe..0000000 --- a/include/distance_computers/scalar_computers.hpp +++ /dev/null @@ -1,145 +0,0 @@ -#ifndef PDX_SCALAR_COMPUTERS_HPP -#define PDX_SCALAR_COMPUTERS_HPP - -#include -#include -#include -#include -#include "common.hpp" - -namespace PDX { - -template -class ScalarComputer {}; - -template <> -class ScalarComputer{}; - - -template <> -class ScalarComputer{ -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - - // Defer to the scalar kernel - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions = nullptr, - const uint32_t * indices_dimensions = nullptr, - const int32_t * dim_clip_value = nullptr - ){ - size_t dimensions_jump_factor = total_vectors; - for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; ++dimension_idx) { - uint32_t true_dimension_idx = dimension_idx; - if constexpr (USE_DIMENSIONS_REORDER){ - true_dimension_idx = indices_dimensions[dimension_idx]; - } - size_t offset_to_dimension_start = true_dimension_idx * dimensions_jump_factor; - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - auto true_vector_idx = vector_idx; - if constexpr(SKIP_PRUNED){ - true_vector_idx = pruning_positions[vector_idx]; - } - DISTANCE_TYPE to_multiply = query[true_dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; - distances_p[true_vector_idx] += to_multiply * to_multiply; - } - } - } - - // Defer to the scalar kernel - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p - ){ - for (size_t dim_idx = start_dimension; dim_idx < end_dimension; dim_idx++) { - size_t dimension_idx = dim_idx; - size_t offset_to_dimension_start = dimension_idx * PDX_VECTOR_SIZE; - for (size_t vector_idx = 0; vector_idx < PDX_VECTOR_SIZE; ++vector_idx) { - DISTANCE_TYPE to_multiply = query[dimension_idx] - data[offset_to_dimension_start + vector_idx]; - distances_p[vector_idx] += to_multiply * to_multiply; -// if constexpr (L_ALPHA == IP){ -// distances_p[vector_idx] -= 2 * query[dimension_idx] * data[offset_to_dimension_start + vector_idx]; -// } - } - } - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions - ){ - DISTANCE_TYPE distance = 0.0; - for (size_t dimension_idx = 0; dimension_idx < num_dimensions; ++dimension_idx) { - DISTANCE_TYPE to_multiply = vector1[dimension_idx] - vector2[dimension_idx]; - distance += to_multiply * to_multiply; - } - return distance; - }; - -}; - -template <> -class ScalarComputer{ -public: - using DISTANCE_TYPE = DistanceType_t; - using QUERY_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - - // Defer to the scalar kernel - template - static void VerticalPruning( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t n_vectors, - size_t total_vectors, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p, - const uint32_t * pruning_positions = nullptr, - const uint32_t * indices_dimensions = nullptr, - const int32_t * dim_clip_value = nullptr - ){ - // TODO - } - - // Defer to the scalar kernel - static void Vertical( - const QUERY_TYPE *__restrict query, - const DATA_TYPE *__restrict data, - size_t start_dimension, - size_t end_dimension, - DISTANCE_TYPE * distances_p - ){ - // TODO - } - - static DISTANCE_TYPE Horizontal( - const QUERY_TYPE *__restrict vector1, - const DATA_TYPE *__restrict vector2, - size_t num_dimensions - ){ - DISTANCE_TYPE distance = 0.0; - for (size_t dimension_idx = 0; dimension_idx < num_dimensions; ++dimension_idx) { - distance += vector1[dimension_idx] * vector2[dimension_idx]; - } - return distance; - }; - -}; - -} - -#endif //PDX_SCALAR_COMPUTERS_HPP diff --git a/include/index_base/pdx_ivf.hpp b/include/index_base/pdx_ivf.hpp deleted file mode 100644 index 2ee8eec..0000000 --- a/include/index_base/pdx_ivf.hpp +++ /dev/null @@ -1,158 +0,0 @@ -#ifndef PDX_IVF_HPP -#define PDX_IVF_HPP - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "utils/file_reader.hpp" -#include "common.hpp" - -namespace PDX { - - -/****************************************************************** - * Very rudimentary memory to IVF index reader - ******************************************************************/ -template -class IndexPDXIVF{}; - -template <> -class IndexPDXIVF { -public: - - using CLUSTER_TYPE = Cluster; - - std::unique_ptr file_buffer; - - uint32_t num_dimensions{}; - uint32_t num_clusters{}; - uint32_t num_horizontal_dimensions{}; - uint32_t num_vertical_dimensions{}; - std::vector clusters; - float *means{}; - bool is_ivf{}; - bool is_normalized{}; - float *centroids{}; - float *centroids_pdx{}; - - void Restore(const std::string &filename) { - file_buffer = MmapFile(filename); - Load(file_buffer.get()); - } - - void Load(char *input) { - char *next_value = input; - num_dimensions = ((uint32_t *) input)[0]; - num_vertical_dimensions = ((uint32_t *) input)[1]; - num_horizontal_dimensions = ((uint32_t *) input)[2]; - - next_value += sizeof(uint32_t) * 3; - num_clusters = ((uint32_t *) next_value)[0]; - next_value += sizeof(uint32_t); - auto *nums_embeddings = (uint32_t *) next_value; - next_value += num_clusters * sizeof(uint32_t); - clusters.resize(num_clusters); - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.num_embeddings = nums_embeddings[i]; - cluster.data = (float *) next_value; - next_value += sizeof(float) * cluster.num_embeddings * num_dimensions; - } - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.indices = (uint32_t *) next_value; - next_value += sizeof(uint32_t) * cluster.num_embeddings; - } - means = (float *) next_value; - next_value += sizeof(float) * num_dimensions; - is_normalized = ((char *) next_value)[0]; - next_value += sizeof(char); - is_ivf = ((char *) next_value)[0]; - next_value += sizeof(char); - if (is_ivf) { - centroids = (float *) next_value; - next_value += sizeof(float) * num_clusters * num_dimensions; - centroids_pdx = (float *) next_value; - } - } -}; - -template <> -class IndexPDXIVF { -public: - - using CLUSTER_TYPE = Cluster; - - std::unique_ptr file_buffer; - - uint32_t num_dimensions{}; - uint32_t num_clusters{}; - uint32_t num_horizontal_dimensions{}; - uint32_t num_vertical_dimensions{}; - std::vector> clusters; - float *means{}; - bool is_ivf{}; - bool is_normalized{}; - float *centroids{}; - float *centroids_pdx{}; - - float for_base {}; - float scale_factor {}; - - void Restore(const std::string &filename) { - file_buffer = MmapFile(filename); - Load(file_buffer.get()); - } - - void Load(char *input) { - char *next_value = input; - num_dimensions = ((uint32_t *) input)[0]; - num_vertical_dimensions = ((uint32_t *) input)[1]; - num_horizontal_dimensions = ((uint32_t *) input)[2]; - - next_value += sizeof(uint32_t) * 3; - num_clusters = ((uint32_t *) next_value)[0]; - next_value += sizeof(uint32_t); - auto *nums_embeddings = (uint32_t *) next_value; - next_value += num_clusters * sizeof(uint32_t); - clusters.resize(num_clusters); - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.num_embeddings = nums_embeddings[i]; - cluster.data = (uint8_t *) next_value; - next_value += sizeof(uint8_t) * cluster.num_embeddings * num_dimensions; - } - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.indices = (uint32_t *) next_value; - next_value += sizeof(uint32_t) * cluster.num_embeddings; - } - // means = (float *) next_value; - // next_value += sizeof(float) * num_dimensions; - is_normalized = ((char *) next_value)[0]; - next_value += sizeof(char); - is_ivf = ((char *) next_value)[0]; - next_value += sizeof(char); - if (is_ivf) { - centroids = (float *) next_value; - next_value += sizeof(float) * num_clusters * num_dimensions; - centroids_pdx = (float *) next_value; - } - - for_base = ((float *) next_value)[0]; - next_value += sizeof(float); - scale_factor = ((float *) next_value)[0]; - next_value += sizeof(float); - } -}; - -} // namespace PDX - -#endif //PDX_IVF_HPP diff --git a/include/index_base/pdx_ivf2.hpp b/include/index_base/pdx_ivf2.hpp deleted file mode 100644 index d183b1e..0000000 --- a/include/index_base/pdx_ivf2.hpp +++ /dev/null @@ -1,199 +0,0 @@ -#ifndef PDX_IVF2_HPP -#define PDX_IVF2_HPP - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "utils/file_reader.hpp" -#include "common.hpp" - -namespace PDX { - - -/****************************************************************** - * Very rudimentary memory to IVF2 index reader - ******************************************************************/ -template -class IndexPDXIVF2{}; - -template <> -class IndexPDXIVF2 { -public: - - using CLUSTER_TYPE = Cluster; - using CLUSTER_TYPE_L0 = Cluster; - - std::unique_ptr file_buffer; - - uint32_t num_dimensions{}; - uint32_t num_clusters{}; - uint32_t num_horizontal_dimensions{}; - uint32_t num_vertical_dimensions{}; - - std::vector clusters; - uint32_t num_clusters_l0 {}; - std::vector> clusters_l0; - - float *means{}; - bool is_ivf{}; - bool is_normalized{}; - float *centroids{}; - float *centroids_pdx{}; - - void Restore(const std::string &filename) { - file_buffer = MmapFile(filename); - Load(file_buffer.get()); - } - - void Load(char *input) { - char *next_value = input; - num_dimensions = ((uint32_t *) input)[0]; - num_vertical_dimensions = ((uint32_t *) input)[1]; - num_horizontal_dimensions = ((uint32_t *) input)[2]; - - - next_value += sizeof(uint32_t) * 3; - num_clusters = ((uint32_t *) next_value)[0]; - next_value += sizeof(uint32_t); - - num_clusters_l0 = ((uint32_t *) next_value)[0]; - next_value += sizeof(uint32_t); - - // L0 load - auto *nums_embeddings_l0 = (uint32_t *) next_value; - next_value += num_clusters_l0 * sizeof(uint32_t); - - clusters_l0.resize(num_clusters_l0); - - for (size_t i = 0; i < num_clusters_l0; ++i) { - CLUSTER_TYPE_L0 &cluster_l0 = clusters_l0[i]; - cluster_l0.num_embeddings = nums_embeddings_l0[i]; - cluster_l0.data = (float *) next_value; - next_value += sizeof(float) * cluster_l0.num_embeddings * num_dimensions; - } - for (size_t i = 0; i < num_clusters_l0; ++i) { - CLUSTER_TYPE_L0 &cluster_l0 = clusters_l0[i]; - cluster_l0.indices = (uint32_t *) next_value; - next_value += sizeof(uint32_t) * cluster_l0.num_embeddings; - } - - auto *nums_embeddings = (uint32_t *) next_value; - next_value += num_clusters * sizeof(uint32_t); - clusters.resize(num_clusters); - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.num_embeddings = nums_embeddings[i]; - cluster.data = (float *) next_value; - next_value += sizeof(float) * cluster.num_embeddings * num_dimensions; - } - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.indices = (uint32_t *) next_value; - next_value += sizeof(uint32_t) * cluster.num_embeddings; - } - is_normalized = ((char *) next_value)[0]; - next_value += sizeof(char); - - centroids_pdx = (float *) next_value; - } -}; - -template <> -class IndexPDXIVF2 { -public: - - using CLUSTER_TYPE = Cluster; - using CLUSTER_TYPE_L0 = Cluster; - - std::unique_ptr file_buffer; - - uint32_t num_dimensions {}; - uint32_t num_clusters {}; - uint32_t num_horizontal_dimensions {}; - uint32_t num_vertical_dimensions {}; - - std::vector> clusters; - - uint32_t num_clusters_l0 {}; - std::vector> clusters_l0; - - float *means{}; - bool is_normalized{}; - float *centroids{}; - float *centroids_pdx{}; - - float for_base{}; - float scale_factor{}; - - void Restore(const std::string &filename) { - file_buffer = MmapFile(filename); - Load(file_buffer.get()); - } - - void Load(char *input) { - char *next_value = input; - num_dimensions = ((uint32_t *) input)[0]; - num_vertical_dimensions = ((uint32_t *) input)[1]; - num_horizontal_dimensions = ((uint32_t *) input)[2]; - - next_value += sizeof(uint32_t) * 3; - num_clusters = ((uint32_t *) next_value)[0]; - next_value += sizeof(uint32_t); - num_clusters_l0 = ((uint32_t *) next_value)[0]; - next_value += sizeof(uint32_t); - - // L0 load - auto *nums_embeddings_l0 = (uint32_t *) next_value; - next_value += num_clusters_l0 * sizeof(uint32_t); - - clusters_l0.resize(num_clusters_l0); - - for (size_t i = 0; i < num_clusters_l0; ++i) { - CLUSTER_TYPE_L0 &cluster_l0 = clusters_l0[i]; - cluster_l0.num_embeddings = nums_embeddings_l0[i]; - cluster_l0.data = (float *) next_value; - next_value += sizeof(float) * cluster_l0.num_embeddings * num_dimensions; - } - for (size_t i = 0; i < num_clusters_l0; ++i) { - CLUSTER_TYPE_L0 &cluster_l0 = clusters_l0[i]; - cluster_l0.indices = (uint32_t *) next_value; - next_value += sizeof(uint32_t) * cluster_l0.num_embeddings; - } - - // L1 load - auto *nums_embeddings = (uint32_t *) next_value; - next_value += num_clusters * sizeof(uint32_t); - clusters.resize(num_clusters); - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.num_embeddings = nums_embeddings[i]; - cluster.data = (uint8_t *) next_value; - next_value += sizeof(uint8_t) * cluster.num_embeddings * num_dimensions; - } - for (size_t i = 0; i < num_clusters; ++i) { - CLUSTER_TYPE &cluster = clusters[i]; - cluster.indices = (uint32_t *) next_value; - next_value += sizeof(uint32_t) * cluster.num_embeddings; - } - is_normalized = ((char *) next_value)[0]; - next_value += sizeof(char); - - centroids_pdx = (float *) next_value; - next_value += sizeof(float) * num_clusters_l0 * num_dimensions; - - for_base = ((float *) next_value)[0]; - next_value += sizeof(float); - scale_factor = ((float *) next_value)[0]; - } -}; - -} // namespace PDX - -#endif //PDX_IVF2_HPP diff --git a/include/lib/lib.hpp b/include/lib/lib.hpp deleted file mode 100644 index cd97959..0000000 --- a/include/lib/lib.hpp +++ /dev/null @@ -1,625 +0,0 @@ -#ifndef PDX_LIB -#define PDX_LIB - -#include -#include -#include -#include -#include -#include -#include "utils/file_reader.hpp" -#include "index_base/pdx_ivf.hpp" -#include "index_base/pdx_ivf2.hpp" -#include "pdxearch.hpp" -#include "pruners/bond.hpp" -#include "pruners/adsampling.hpp" - -// TODO: the python wrapper and the core API should not be interleaved -namespace py = pybind11; - -/****************************************************************** - * Very rudimentary wrappers for python bindings - * Probably a lot of room for improvement (TODO) - ******************************************************************/ -namespace PDX { - -class IndexADSamplingIVF2SQ8 { - - using KNNCandidate = KNNCandidate; - using Index = IndexPDXIVF2; - using Pruner = ADSamplingPruner; - using Searcher = PDXearch; - -public: - Index index = Index(); - std::unique_ptr searcher = nullptr; - std::unique_ptr transformation_matrix = nullptr; - constexpr static float epsilon0 = 1.5; - - void Load(const py::bytes& data, const py::array_t& _matrix){ - py::buffer_info info(py::buffer(data).request()); - auto data_ = static_cast(info.ptr); - index.Load(data_); - - auto matrix_buf = _matrix.request(); - auto matrix_ptr = static_cast(matrix_buf.ptr); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, matrix_ptr); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void Restore(const std::string &path, const std::string &matrix_path){ - index.Restore(path); - transformation_matrix = MmapFile(matrix_path); - auto *_matrix = reinterpret_cast(transformation_matrix.get()); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, _matrix); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void SetPruningConfidence(float confidence) const { - searcher->pruner.SetEpsilon0(confidence); - } - - std::vector Search(float *q, uint32_t k) const { - return searcher->Search(q, k); - } - - // Serialize return value - std::pair, py::array_t> - _py_Search(const py::array_t& q, uint32_t k, uint32_t n_probe) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - searcher->SetNProbe(n_probe); - std::vector results = searcher->Search(query, k); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - - std::pair, py::array_t> - _py_FilteredSearch( - const py::array_t& q, uint32_t k, uint32_t n_probe, - const py::array_t& n_passing_tuples, const py::array_t& selection_vector - ) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - auto n_passing_tuples_p = static_cast(n_passing_tuples.request().ptr); - auto selection_vector_p = static_cast(selection_vector.request().ptr); - - PredicateEvaluator pe = PredicateEvaluator(searcher->pdx_data.num_clusters); - pe.LoadSelectionVector(n_passing_tuples_p, selection_vector_p); - - searcher->SetNProbe(n_probe); - std::vector results = searcher->FilteredSearch(query, k, pe); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - -}; - -class IndexADSamplingIVF2Flat { - - using KNNCandidate = KNNCandidate; - using Index = IndexPDXIVF2; - using Pruner = ADSamplingPruner; - using Searcher = PDXearch; - -public: - Index index = Index(); - std::unique_ptr searcher = nullptr; - std::unique_ptr transformation_matrix = nullptr; - constexpr static float epsilon0 = 1.5; - - void Load(const py::bytes& data, const py::array_t& _matrix){ - py::buffer_info info(py::buffer(data).request()); - auto data_ = static_cast(info.ptr); - index.Load(data_); - - auto matrix_buf = _matrix.request(); - auto matrix_ptr = static_cast(matrix_buf.ptr); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, matrix_ptr); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void Restore(const std::string &path, const std::string &matrix_path){ - index.Restore(path); - transformation_matrix = MmapFile(matrix_path); - auto *_matrix = reinterpret_cast(transformation_matrix.get()); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, _matrix); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void SetPruningConfidence(float confidence) const { - searcher->pruner.SetEpsilon0(confidence); - } - - std::vector Search(float *q, uint32_t k) const { - return searcher->Search(q, k); - } - - // Serialize return value - std::pair, py::array_t> - _py_Search(const py::array_t& q, uint32_t k, uint32_t n_probe) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - searcher->SetNProbe(n_probe); - std::vector results = searcher->Search(query, k); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - - std::pair, py::array_t> - _py_FilteredSearch( - const py::array_t& q, uint32_t k, uint32_t n_probe, - const py::array_t& n_passing_tuples, const py::array_t& selection_vector - ) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - auto n_passing_tuples_p = static_cast(n_passing_tuples.request().ptr); - auto selection_vector_p = static_cast(selection_vector.request().ptr); - - PredicateEvaluator pe = PredicateEvaluator(searcher->pdx_data.num_clusters); - pe.LoadSelectionVector(n_passing_tuples_p, selection_vector_p); - - searcher->SetNProbe(n_probe); - std::vector results = searcher->FilteredSearch(query, k, pe); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - -}; - -class IndexADSamplingIVFFlat { - - using KNNCandidate = KNNCandidate; - using Index = IndexPDXIVF; - using Pruner = ADSamplingPruner; - using Searcher = PDXearch; - -public: - Index index = Index(); - std::unique_ptr searcher = nullptr; - std::unique_ptr transformation_matrix = nullptr; - constexpr static float epsilon0 = 1.5; - - void Load(const py::bytes& data, const py::array_t& _matrix){ - py::buffer_info info(py::buffer(data).request()); - auto data_ = static_cast(info.ptr); - index.Load(data_); - - auto matrix_buf = _matrix.request(); - auto matrix_ptr = static_cast(matrix_buf.ptr); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, matrix_ptr); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void Restore(const std::string &path, const std::string &matrix_path){ - index.Restore(path); - transformation_matrix = MmapFile(matrix_path); - auto *_matrix = reinterpret_cast(transformation_matrix.get()); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, _matrix); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void SetPruningConfidence(float confidence) const { - searcher->pruner.SetEpsilon0(confidence); - } - - std::vector Search(float *q, uint32_t k) const { - return searcher->Search(q, k); - } - - // Serialize return value - std::pair, py::array_t> - _py_Search(const py::array_t& q, uint32_t k, uint32_t n_probe) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - searcher->SetNProbe(n_probe); - std::vector results = searcher->Search(query, k); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - - std::pair, py::array_t> - _py_FilteredSearch( - const py::array_t& q, uint32_t k, uint32_t n_probe, - const py::array_t& n_passing_tuples, const py::array_t& selection_vector - ) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - auto n_passing_tuples_p = static_cast(n_passing_tuples.request().ptr); - auto selection_vector_p = static_cast(selection_vector.request().ptr); - - PredicateEvaluator pe = PredicateEvaluator(searcher->pdx_data.num_clusters); - pe.LoadSelectionVector(n_passing_tuples_p, selection_vector_p); - - searcher->SetNProbe(n_probe); - std::vector results = searcher->FilteredSearch(query, k, pe); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - -}; - -class IndexBONDIVFFlat { - using KNNCandidate = KNNCandidate; - using Index = IndexPDXIVF; - using Pruner = BondPruner; - using Searcher = PDXearch, L2, BondPruner>; - -public: - Index index = Index(); - std::unique_ptr searcher = nullptr; - - void Load(const py::bytes& data){ - py::buffer_info info(py::buffer(data).request()); - auto data_ = static_cast(info.ptr); - index.Load(data_); -#if false && defined(__AVX512FP16__) - // In Intel architectures with low bandwidth at L3/DRAM, the DISTANCE_TO_MEANS criteria performs better - pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, DISTANCE_TO_MEANS); -#else - Pruner pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, DIMENSION_ZONES); -#endif - } - - void Restore(const std::string &path){ - index.Restore(path); -#if false && defined(__AVX512FP16__) - // In Intel architectures with low bandwidth at L3/DRAM, the DISTANCE_TO_MEANS criteria performs better - pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, DISTANCE_TO_MEANS); -#else - Pruner pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, DIMENSION_ZONES); -#endif - } - - std::vector Search(float *q, uint32_t k) const { - return searcher->Search(q, k); - } - - // Serialize return value - std::pair, py::array_t> - _py_Search(const py::array_t& q, uint32_t k, uint32_t n_probe) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - searcher->SetNProbe(n_probe); - std::vector results = searcher->Search(query, k); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - - std::pair, py::array_t> - _py_FilteredSearch( - const py::array_t& q, uint32_t k, uint32_t n_probe, - const py::array_t& n_passing_tuples, const py::array_t& selection_vector - ) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - auto n_passing_tuples_p = static_cast(n_passing_tuples.request().ptr); - auto selection_vector_p = static_cast(selection_vector.request().ptr); - - PredicateEvaluator pe = PredicateEvaluator(searcher->pdx_data.num_clusters); - pe.LoadSelectionVector(n_passing_tuples_p, selection_vector_p); - - searcher->SetNProbe(n_probe); - std::vector results = searcher->FilteredSearch(query, k, pe); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - -}; - -class IndexBONDFlat { - using KNNCandidate = KNNCandidate; - using Index = IndexPDXIVF; - using Pruner = BondPruner; - using Searcher = PDXearch, L2, BondPruner>; -public: - Index index = Index(); - std::unique_ptr searcher = nullptr; - - void Load(const py::bytes& data){ - py::buffer_info info(py::buffer(data).request()); - auto data_ = static_cast(info.ptr); - index.Load(data_); - Pruner pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, DISTANCE_TO_MEANS); - } - - void Restore(const std::string &path){ - index.Restore(path); - Pruner pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, DISTANCE_TO_MEANS); - } - - std::vector Search(float *q, uint32_t k) const { - return searcher->Search(q, k); - } - - // Serialize return value - std::pair, py::array_t> - _py_Search(const py::array_t& q, uint32_t k) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - std::vector results = searcher->Search(query, k); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - - std::pair, py::array_t> - _py_FilteredSearch( - const py::array_t& q, uint32_t k, - const py::array_t& n_passing_tuples, const py::array_t& selection_vector - ) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - auto n_passing_tuples_p = static_cast(n_passing_tuples.request().ptr); - auto selection_vector_p = static_cast(selection_vector.request().ptr); - - PredicateEvaluator pe = PredicateEvaluator(searcher->pdx_data.num_clusters); - pe.LoadSelectionVector(n_passing_tuples_p, selection_vector_p); - - std::vector results = searcher->FilteredSearch(query, k, pe); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - -}; - -class IndexADSamplingFlat { - - using KNNCandidate = KNNCandidate; - using Index = IndexPDXIVF; - using Pruner = ADSamplingPruner; - using Searcher = PDXearch; - -public: - Index index = Index(); - std::unique_ptr searcher = nullptr; - std::unique_ptr transformation_matrix = nullptr; - constexpr static float epsilon0 = 1.5; - - void Load(const py::bytes& data, const py::array_t& _matrix){ - py::buffer_info info(py::buffer(data).request()); - auto data_ = static_cast(info.ptr); - index.Load(data_); - - auto matrix_buf = _matrix.request(); - auto matrix_ptr = static_cast(matrix_buf.ptr); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, matrix_ptr); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void Restore(const std::string &path, const std::string &matrix_path){ - index.Restore(path); - transformation_matrix = MmapFile(matrix_path); - auto *_matrix = reinterpret_cast(transformation_matrix.get()); - Pruner pruner = Pruner(index.num_dimensions, epsilon0, _matrix); - searcher = std::make_unique(index, pruner, 1, SEQUENTIAL); - } - - void SetPruningConfidence(float confidence) const { - searcher->pruner.SetEpsilon0(confidence); - } - - std::vector Search(float *q, uint32_t k) const { - return searcher->Search(q, k); - } - - // Serialize return value - std::pair, py::array_t> - _py_Search(const py::array_t& q, uint32_t k) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - std::vector results = searcher->Search(query, k); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - - std::pair, py::array_t> - _py_FilteredSearch( - const py::array_t& q, uint32_t k, - const py::array_t& n_passing_tuples, const py::array_t& selection_vector - ) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - auto n_passing_tuples_p = static_cast(n_passing_tuples.request().ptr); - auto selection_vector_p = static_cast(selection_vector.request().ptr); - - PredicateEvaluator pe = PredicateEvaluator(searcher->pdx_data.num_clusters); - pe.LoadSelectionVector(n_passing_tuples_p, selection_vector_p); - - std::vector results = searcher->FilteredSearch(query, k, pe); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } - -}; - -class IndexPDXFlat { - using KNNCandidate = KNNCandidate; - using Index = IndexPDXIVF; - using Pruner = BondPruner; - using Searcher = PDXearch, L2, BondPruner>; - -public: - Index index = Index(); - std::unique_ptr searcher = nullptr; - - void Load(const py::bytes& data){ - py::buffer_info info(py::buffer(data).request()); - auto data_ = static_cast(info.ptr); - index.Load(data_); - Pruner pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, SEQUENTIAL); - } - - void Restore(const std::string &path){ - index.Restore(path); - Pruner pruner = Pruner(index.num_dimensions); - searcher = std::make_unique(index, pruner, 0, SEQUENTIAL); - } - - std::vector Search(float *q, uint32_t k) const { - return searcher->LinearScan(q, k); - } - - // Serialize return value - std::pair, py::array_t> - _py_Search(const py::array_t& q, uint32_t k) const { - auto buf = q.request(); // Get buffer info - if (buf.ndim != 1) { - throw std::runtime_error("Input should be a 1-D NumPy array"); - } - auto query = static_cast(buf.ptr); - std::vector results = searcher->Search(query, k); - size_t n = results.size(); - py::array_t ids(n); - py::array_t distances(n); - auto ids_ptr = ids.mutable_unchecked<1>(); - auto distances_ptr = distances.mutable_unchecked<1>(); - for (size_t i = 0; i < n; ++i) { - ids_ptr(i) = results[i].index; - distances_ptr(i) = results[i].distance; - } - return {ids, distances}; - } -}; - -} // namespace PDX - -#endif // PDX_LIB \ No newline at end of file diff --git a/include/pdx/clustering.hpp b/include/pdx/clustering.hpp new file mode 100644 index 0000000..48fa3bf --- /dev/null +++ b/include/pdx/clustering.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include "pdx/common.hpp" +#include "superkmeans/hierarchical_superkmeans.h" +#include + +namespace PDX { + +struct KMeansResult { + // Row-major buffer of all centroids (num_clusters * num_dimensions). + std::vector centroids; + + // Mapping from a centroid to its embeddings. + // + // The embeddings are represented as indices into the original `embeddings` array. The + // `embeddings` array was passed as a parameter to the `ComputeKMeans` function. + // + // `assignments[0] -> [1, 3]` means that the 2nd and 4th embeddings in the `embeddings` array + // belong to the 0th cluster/centroid. + std::vector> assignments; + + static constexpr size_t MIN_EMBEDDINGS_TO_SAMPLE = 30720; + + explicit KMeansResult(uint32_t num_clusters) : assignments(num_clusters) {} +}; + +// Compute centroids (clusters) and centroid-to-embedding assignments using SuperKMeans. +[[nodiscard]] inline KMeansResult ComputeKMeans( + const float* const embeddings, + const uint64_t num_embeddings, + const uint32_t num_dimensions, + const uint32_t num_clusters, + const PDX::DistanceMetric distance_metric, + const uint32_t seed, + const bool normalize = false, + const float sampling_fraction = 0.0f, + const uint32_t kmeans_iters = 8, + const bool hierarchical_indexing = true +) { + assert(num_embeddings >= 1); + assert(num_dimensions >= 1); + assert(num_clusters >= 1); + + auto result = KMeansResult(num_clusters); + + if (num_clusters == 1) { + result.centroids = std::vector(embeddings, embeddings + num_dimensions); + for (uint64_t vec_id = 0; vec_id < num_embeddings; vec_id++) { + result.assignments[0].emplace_back(vec_id); + } + return result; + } + + bool is_angular = normalize || distance_metric == PDX::DistanceMetric::COSINE || + distance_metric == PDX::DistanceMetric::IP; + + float chosen_sampling_fraction = 0.3f; + if (sampling_fraction > 0.0f) { + chosen_sampling_fraction = sampling_fraction; + } else if (num_embeddings < KMeansResult::MIN_EMBEDDINGS_TO_SAMPLE) { + chosen_sampling_fraction = 1.0f; + } + + bool use_hierarchical_indexing = + hierarchical_indexing && num_embeddings >= KMeansResult::MIN_EMBEDDINGS_TO_SAMPLE; + + std::vector assignments; + if (use_hierarchical_indexing) { + skmeans::HierarchicalSuperKMeansConfig config; + config.sampling_fraction = 1.0f; // For now we are using all points + config.angular = is_angular; + config.data_already_rotated = true; + config.suppress_warnings = true; + config.iters_mesoclustering = 3; + config.iters_fineclustering = 5; + config.iters_refinement = 0; + config.seed = seed; + // config.verbose = true; + config.n_threads = PDX::g_n_threads; + auto kmeans = skmeans::HierarchicalSuperKMeans(num_clusters, num_dimensions, config); + result.centroids = kmeans.Train(embeddings, num_embeddings); + assignments = + kmeans.FastAssign(embeddings, result.centroids.data(), num_embeddings, num_clusters); + } else { + skmeans::SuperKMeansConfig config; + config.sampling_fraction = chosen_sampling_fraction; + config.angular = is_angular; + config.data_already_rotated = true; + config.suppress_warnings = true; + config.iters = kmeans_iters; + config.seed = seed; + // config.verbose = true; + config.n_threads = PDX::g_n_threads; + auto kmeans = skmeans::SuperKMeans(num_clusters, num_dimensions, config); + result.centroids = kmeans.Train(embeddings, num_embeddings); + assignments = + kmeans.FastAssign(embeddings, result.centroids.data(), num_embeddings, num_clusters); + } + + // Convert from vec_id -> centroid_idx into centroid_idx -> vec_id + result.assignments.resize(num_clusters); + for (uint64_t vec_id = 0; vec_id < num_embeddings; vec_id++) { + result.assignments[assignments[vec_id]].emplace_back(vec_id); + } + + return result; +}; + +} // namespace PDX diff --git a/include/pdx/common.hpp b/include/pdx/common.hpp new file mode 100644 index 0000000..71a9f32 --- /dev/null +++ b/include/pdx/common.hpp @@ -0,0 +1,208 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#ifndef PDX_RESTRICT +#if defined(__GNUC__) || defined(__clang__) +#define PDX_RESTRICT __restrict__ +#elif defined(_MSC_VER) +#define PDX_RESTRICT __restrict +#elif defined(__INTEL_COMPILER) +#define PDX_RESTRICT __restrict__ +#else +#define PDX_RESTRICT +#endif +#endif + +#if defined(__GNUC__) || defined(__clang__) +#define PDX_LIKELY(x) __builtin_expect(!!(x), 1) +#define PDX_UNLIKELY(x) __builtin_expect(!!(x), 0) +#else +#define PDX_LIKELY(x) (x) +#define PDX_UNLIKELY(x) (x) +#endif + +namespace PDX { + +// Global thread count for OpenMP parallel regions and FFTW. +// Set by PDXIndex/PDXTreeIndex constructors. Needed for functions (adsampling, clustering) +// that can't access class members. +inline uint32_t g_n_threads = 1; + +static constexpr float PROPORTION_HORIZONTAL_DIM = 0.75f; +static constexpr size_t D_THRESHOLD_FOR_DCT_ROTATION = 512; + +inline bool IsPowerOf2(const uint32_t x) { + return x > 0 && (x & (x - 1)) == 0; +} +static constexpr size_t PDX_MAX_DIMS = 16384; +static constexpr size_t H_DIM_SIZE = 64; +static constexpr size_t U8_INTERLEAVE_SIZE = 4; +static constexpr uint32_t DIMENSIONS_FETCHING_SIZES[20] = {16, 16, 32, 32, 32, 32, 64, + 64, 64, 64, 128, 128, 128, 128, + 256, 256, 512, 1024, 2048, 16384}; + +static constexpr bool AllFetchingSizesMultipleOfU8InterleaveSize() { + for (auto s : DIMENSIONS_FETCHING_SIZES) { + if (s % U8_INTERLEAVE_SIZE != 0) { + return false; + } + } + return true; +} +static_assert( + AllFetchingSizesMultipleOfU8InterleaveSize(), + "All DIMENSIONS_FETCHING_SIZES must be multiples of U8_INTERLEAVE_SIZE" +); + +// Epsilon0 parameter of ADSampling (Reference: https://dl.acm.org/doi/abs/10.1145/3589282) +static constexpr float ADSAMPLING_PRUNING_AGGRESIVENESS = 1.5f; + +template +static constexpr uint32_t AlignValue(T n) { + return ((n + (val - 1)) / val) * val; +} + +enum class DistanceMetric { L2SQ, COSINE, IP }; + +enum Quantization { F32, U8, F16, BF }; + +enum class PDXIndexType : uint8_t { PDX_F32 = 0, PDX_U8 = 1, PDX_TREE_F32 = 2, PDX_TREE_U8 = 3 }; + +// TODO: Do the same for indexes? +template +struct DistanceType { + using type = uint32_t; +}; +template <> +struct DistanceType { + using type = float; +}; +template +using pdx_distance_t = typename DistanceType::type; + +// TODO: Do the same for indexes? +template +struct DataType { + using type = uint8_t; // U8 +}; +template <> +struct DataType { + using type = float; +}; +template +using pdx_data_t = typename DataType::type; + +template +struct QuantizedVectorType { + using type = uint8_t; // U8 +}; +template <> +struct QuantizedVectorType { + using type = float; +}; +template +using pdx_quantized_embedding_t = typename QuantizedVectorType::type; + +using eigen_matrix_t = Eigen::Matrix; + +struct KNNCandidate { + uint32_t index; + float distance; +}; + +struct VectorComparator { + bool operator()(const KNNCandidate& a, const KNNCandidate& b) { + return a.distance < b.distance; + } +}; + +using Heap = std::priority_queue, VectorComparator>; + +struct PDXDimensionSplit { + const uint32_t horizontal_dimensions; + const uint32_t vertical_dimensions; +}; + +[[nodiscard]] static inline constexpr PDXDimensionSplit GetPDXDimensionSplit( + const uint32_t num_dimensions +) { + auto local_proportion_horizontal_dim = PROPORTION_HORIZONTAL_DIM; + if (num_dimensions <= 128) { + local_proportion_horizontal_dim = 0.25; + } + auto horizontal_d = + static_cast(static_cast(num_dimensions) * local_proportion_horizontal_dim); + auto vertical_d = static_cast(num_dimensions - horizontal_d); + if (horizontal_d % H_DIM_SIZE > 0) { + horizontal_d = ((horizontal_d + H_DIM_SIZE / 2) / H_DIM_SIZE) * H_DIM_SIZE; + vertical_d = num_dimensions - horizontal_d; + } + if (!vertical_d) { + horizontal_d = H_DIM_SIZE; + vertical_d = num_dimensions - horizontal_d; + } + if (num_dimensions <= H_DIM_SIZE) { + horizontal_d = 0; + vertical_d = num_dimensions; + } + + assert(horizontal_d + vertical_d == num_dimensions); + + return {horizontal_d, vertical_d}; +}; + +static_assert(GetPDXDimensionSplit(4).horizontal_dimensions == 0); +static_assert(GetPDXDimensionSplit(4).vertical_dimensions == 4); + +static_assert(GetPDXDimensionSplit(33).horizontal_dimensions == 0); +static_assert(GetPDXDimensionSplit(33).vertical_dimensions == 33); + +static_assert(GetPDXDimensionSplit(64).horizontal_dimensions == 0); +static_assert(GetPDXDimensionSplit(64).vertical_dimensions == 64); + +static_assert(GetPDXDimensionSplit(65).horizontal_dimensions == 0); +static_assert(GetPDXDimensionSplit(65).vertical_dimensions == 65); + +static_assert(GetPDXDimensionSplit(100).horizontal_dimensions == 0); +static_assert(GetPDXDimensionSplit(100).vertical_dimensions == 100); + +static_assert(GetPDXDimensionSplit(127).horizontal_dimensions == 0); +static_assert(GetPDXDimensionSplit(127).vertical_dimensions == 127); + +static_assert(GetPDXDimensionSplit(128).horizontal_dimensions == 64); +static_assert(GetPDXDimensionSplit(128).vertical_dimensions == 64); + +static_assert(GetPDXDimensionSplit(256).horizontal_dimensions == 192); +static_assert(GetPDXDimensionSplit(256).vertical_dimensions == 64); + +static_assert(GetPDXDimensionSplit(1024).horizontal_dimensions == 768); +static_assert(GetPDXDimensionSplit(1024).vertical_dimensions == 256); + +static_assert(GetPDXDimensionSplit(1028).horizontal_dimensions == 768); +static_assert(GetPDXDimensionSplit(1028).vertical_dimensions == 260); + +[[nodiscard]] inline constexpr uint32_t ComputeNumberOfClusters(const uint32_t num_embeddings) { + if (num_embeddings < 500000) { + return std::ceil(2 * std::sqrt(num_embeddings)); + } else if (num_embeddings < 2500000) { + return std::ceil(4 * std::sqrt(num_embeddings)); + } else { + return std::ceil(8 * std::sqrt(num_embeddings)); + } +} + +[[nodiscard]] inline constexpr bool DistanceMetricRequiresNormalization( + const PDX::DistanceMetric distance_metric +) { + return distance_metric == PDX::DistanceMetric::COSINE || + distance_metric == PDX::DistanceMetric::IP; +} + +} // namespace PDX diff --git a/include/pdx/db_mock/predicate_evaluator.hpp b/include/pdx/db_mock/predicate_evaluator.hpp new file mode 100644 index 0000000..239ea34 --- /dev/null +++ b/include/pdx/db_mock/predicate_evaluator.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace PDX { + +class PredicateEvaluator { + + public: + // Number of tuples per cluster that passed the predicate. + std::unique_ptr n_passing_tuples; + // Contiguous array of selection vectors for each cluster. + // [(cluster 1): 0, 1, 1, (cluster 2): 1, 1, 1, 1]. + std::unique_ptr selection_vector; + size_t n_clusters; + + explicit PredicateEvaluator(size_t n_clusters, size_t total_num_embeddings) + : n_clusters(n_clusters) { + n_passing_tuples = std::make_unique(n_clusters); + selection_vector = std::make_unique(total_num_embeddings); + }; + + std::pair GetSelectionVector( + const size_t cluster_id, + const size_t cluster_offset + ) const { + return {&selection_vector[cluster_offset], n_passing_tuples[cluster_id]}; + } +}; + +}; // namespace PDX diff --git a/include/pdx/distance_computers/avx2_computers.hpp b/include/pdx/distance_computers/avx2_computers.hpp new file mode 100644 index 0000000..5ffad50 --- /dev/null +++ b/include/pdx/distance_computers/avx2_computers.hpp @@ -0,0 +1,227 @@ +#pragma once + +#include "pdx/common.hpp" +#include "pdx/distance_computers/scalar_computers.hpp" +#include +#include + +namespace PDX { + +template +class SIMDComputer {}; + +template <> +class SIMDComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + using scalar_computer = ScalarComputer; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + size_t dimensions_jump_factor = total_vectors; + for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; + ++dimension_idx) { + size_t offset_to_dimension_start = dimension_idx * dimensions_jump_factor; + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + auto true_vector_idx = vector_idx; + if constexpr (SKIP_PRUNED) { + true_vector_idx = pruning_positions[vector_idx]; + } + distance_t to_multiply = + query[dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; + distances_p[true_vector_idx] += to_multiply * to_multiply; + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + __m256 d2_vec = _mm256_setzero_ps(); + size_t i = 0; + for (; i + 8 <= num_dimensions; i += 8) { + __m256 a_vec = _mm256_loadu_ps(vector1 + i); + __m256 b_vec = _mm256_loadu_ps(vector2 + i); + __m256 d_vec = _mm256_sub_ps(a_vec, b_vec); + d2_vec = _mm256_fmadd_ps(d_vec, d_vec, d2_vec); + } + + // _simsimd_reduce_f32x8_haswell + // Convert the lower and higher 128-bit lanes of the input vector to double precision + __m128 low_f32 = _mm256_castps256_ps128(d2_vec); + __m128 high_f32 = _mm256_extractf128_ps(d2_vec, 1); + + // Convert single-precision (float) vectors to double-precision (double) vectors + __m256d low_f64 = _mm256_cvtps_pd(low_f32); + __m256d high_f64 = _mm256_cvtps_pd(high_f32); + + // Perform the addition in double-precision + __m256d sum = _mm256_add_pd(low_f64, high_f64); + + // Reduce the double-precision vector to a scalar + // Horizontal add the first and second double-precision values, and third and fourth + __m128d sum_low = _mm256_castpd256_pd128(sum); + __m128d sum_high = _mm256_extractf128_pd(sum, 1); + __m128d sum128 = _mm_add_pd(sum_low, sum_high); + + // Horizontal add again to accumulate all four values into one + sum128 = _mm_hadd_pd(sum128, sum128); + + // Convert the final sum to a scalar double-precision value and return + double d2 = _mm_cvtsd_f64(sum128); + + for (; i < num_dimensions; ++i) { + distance_t d = vector1[i] - vector2[i]; + d2 += d * d; + } + + return static_cast(d2); + }; + + static void FlipSign(const data_t* data, data_t* out, const uint32_t* masks, size_t d) { + size_t j = 0; + for (; j + 8 <= d; j += 8) { + __m256 vec = _mm256_loadu_ps(data + j); + __m256i mask = _mm256_loadu_si256(reinterpret_cast(masks + j)); + __m256i vec_i = _mm256_castps_si256(vec); + vec_i = _mm256_xor_si256(vec_i, mask); + _mm256_storeu_ps(out + j, _mm256_castsi256_ps(vec_i)); + } + auto data_bits = reinterpret_cast(data); + auto out_bits = reinterpret_cast(out); + for (; j < d; ++j) { + out_bits[j] = data_bits[j] ^ masks[j]; + } + } +}; + +template <> +class SIMDComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + auto* query_grouped = reinterpret_cast(query); + size_t dim_idx = start_dimension; + for (; dim_idx + 4 <= end_dimension; dim_idx += 4) { + uint32_t dimension_idx = dim_idx; + size_t offset_to_dimension_start = dimension_idx * total_vectors; + size_t i = 0; + if constexpr (!SKIP_PRUNED) { + uint32_t query_value = query_grouped[dimension_idx / 4]; + __m256i vec1_u8 = _mm256_set1_epi32(query_value); + __m256i zeros = _mm256_setzero_si256(); + for (; i + 8 <= n_vectors; i += 8) { + // Load 8 accumulated distances and 32 bytes of data (4 dims x 8 vectors). + __m256i res = + _mm256_loadu_si256(reinterpret_cast(&distances_p[i])); + __m256i vec2_u8 = _mm256_loadu_si256( + reinterpret_cast(&data[offset_to_dimension_start + i * 4]) + ); + __m256i diff = _mm256_or_si256( + _mm256_subs_epu8(vec1_u8, vec2_u8), _mm256_subs_epu8(vec2_u8, vec1_u8) + ); + // Zero-extend bytes to 16-bit, square, and horizontal-add to 32-bit. + __m256i lo16 = _mm256_unpacklo_epi8(diff, zeros); + __m256i hi16 = _mm256_unpackhi_epi8(diff, zeros); + __m256i sq_lo = _mm256_madd_epi16(lo16, lo16); + __m256i sq_hi = _mm256_madd_epi16(hi16, hi16); + // hadd combines partial sums: [v0, v1, v2, v3, v4, v5, v6, v7]. + __m256i sums = _mm256_hadd_epi32(sq_lo, sq_hi); + _mm256_storeu_si256( + reinterpret_cast<__m256i*>(&distances_p[i]), _mm256_add_epi32(res, sums) + ); + } + } + // Scalar tail (vectors). + for (; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + int da = query[dimension_idx] - data[offset_to_dimension_start + (vector_idx * 4)]; + int db = query[dimension_idx + 1] - + data[offset_to_dimension_start + (vector_idx * 4) + 1]; + int dc = query[dimension_idx + 2] - + data[offset_to_dimension_start + (vector_idx * 4) + 2]; + int dd = query[dimension_idx + 3] - + data[offset_to_dimension_start + (vector_idx * 4) + 3]; + distances_p[vector_idx] += (da * da) + (db * db) + (dc * dc) + (dd * dd); + } + } + if (dim_idx < end_dimension) { + auto remaining = static_cast(end_dimension - dim_idx); + size_t offset = dim_idx * total_vectors; + for (size_t i = 0; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + for (uint32_t k = 0; k < remaining; ++k) { + int diff = query[dim_idx + k] - data[offset + vector_idx * remaining + k]; + distances_p[vector_idx] += diff * diff; + } + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + __m256i d2_vec = _mm256_setzero_si256(); + __m256i zeros = _mm256_setzero_si256(); + size_t i = 0; + for (; i + 32 <= num_dimensions; i += 32) { + __m256i a_vec = _mm256_loadu_si256(reinterpret_cast(vector1 + i)); + __m256i b_vec = _mm256_loadu_si256(reinterpret_cast(vector2 + i)); + __m256i diff = + _mm256_or_si256(_mm256_subs_epu8(a_vec, b_vec), _mm256_subs_epu8(b_vec, a_vec)); + __m256i lo16 = _mm256_unpacklo_epi8(diff, zeros); + __m256i hi16 = _mm256_unpackhi_epi8(diff, zeros); + d2_vec = _mm256_add_epi32(d2_vec, _mm256_madd_epi16(lo16, lo16)); + d2_vec = _mm256_add_epi32(d2_vec, _mm256_madd_epi16(hi16, hi16)); + } + // Reduce 8 x i32 to scalar (simsimd_reduce_i32x8_haswell) + __m128i lo = _mm256_castsi256_si128(d2_vec); + __m128i hi = _mm256_extracti128_si256(d2_vec, 1); + __m128i sum128 = _mm_add_epi32(lo, hi); + sum128 = _mm_hadd_epi32(sum128, sum128); + sum128 = _mm_hadd_epi32(sum128, sum128); + distance_t distance = _mm_cvtsi128_si32(sum128); + // Scalar tail. + for (; i < num_dimensions; ++i) { + int n = static_cast(vector1[i]) - static_cast(vector2[i]); + distance += n * n; + } + return distance; + }; +}; + +} // namespace PDX diff --git a/include/pdx/distance_computers/avx512_computers.hpp b/include/pdx/distance_computers/avx512_computers.hpp new file mode 100644 index 0000000..f29971c --- /dev/null +++ b/include/pdx/distance_computers/avx512_computers.hpp @@ -0,0 +1,245 @@ +#pragma once + +#include "pdx/common.hpp" +#include +#include +#include + +namespace PDX { + +template +class SIMDComputer {}; + +template <> +class SIMDComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + size_t dimensions_jump_factor = total_vectors; + for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; + ++dimension_idx) { + size_t offset_to_dimension_start = dimension_idx * dimensions_jump_factor; + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + auto true_vector_idx = vector_idx; + if constexpr (SKIP_PRUNED) { + true_vector_idx = pruning_positions[vector_idx]; + } + distance_t to_multiply = + query[dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; + distances_p[true_vector_idx] += to_multiply * to_multiply; + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + __m512 d2_vec = _mm512_setzero(); + __m512 a_vec, b_vec; + simsimd_l2sq_f32_skylake_cycle: + if (num_dimensions < 16) { + __mmask16 mask = static_cast<__mmask16>(_bzhi_u32(0xFFFFFFFF, num_dimensions)); + a_vec = _mm512_maskz_loadu_ps(mask, vector1); + b_vec = _mm512_maskz_loadu_ps(mask, vector2); + num_dimensions = 0; + } else { + a_vec = _mm512_loadu_ps(vector1); + b_vec = _mm512_loadu_ps(vector2); + vector1 += 16, vector2 += 16, num_dimensions -= 16; + } + __m512 d_vec = _mm512_sub_ps(a_vec, b_vec); + d2_vec = _mm512_fmadd_ps(d_vec, d_vec, d2_vec); + if (num_dimensions) { + goto simsimd_l2sq_f32_skylake_cycle; + } + + // _simsimd_reduce_f32x16_skylake + __m512 x = + _mm512_add_ps(d2_vec, _mm512_shuffle_f32x4(d2_vec, d2_vec, _MM_SHUFFLE(0, 0, 3, 2))); + __m128 r = _mm512_castps512_ps128( + _mm512_add_ps(x, _mm512_shuffle_f32x4(x, x, _MM_SHUFFLE(0, 0, 0, 1))) + ); + r = _mm_hadd_ps(r, r); + return _mm_cvtss_f32(_mm_hadd_ps(r, r)); + }; + + static void FlipSign(const data_t* data, data_t* out, const uint32_t* masks, size_t d) { + size_t j = 0; + for (; j + 16 <= d; j += 16) { + __m512 vec = _mm512_loadu_ps(data + j); + __m512i mask = _mm512_loadu_si512(reinterpret_cast(masks + j)); + __m512i vec_i = _mm512_castps_si512(vec); + vec_i = _mm512_xor_si512(vec_i, mask); + _mm512_storeu_ps(out + j, _mm512_castsi512_ps(vec_i)); + } + for (; j + 8 <= d; j += 8) { + __m256 vec = _mm256_loadu_ps(data + j); + __m256i mask_avx = _mm256_loadu_si256(reinterpret_cast(masks + j)); + __m256i vec_i = _mm256_castps_si256(vec); + vec_i = _mm256_xor_si256(vec_i, mask_avx); + _mm256_storeu_ps(out + j, _mm256_castsi256_ps(vec_i)); + } + auto data_bits = reinterpret_cast(data); + auto out_bits = reinterpret_cast(out); + for (; j < d; ++j) { + out_bits[j] = data_bits[j] ^ masks[j]; + } + } +}; + +template <> +class SIMDComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + __m512i res; + __m512i vec2_u8; + __m512i vec1_u8; + __m512i diff_u8; + __m256i y_res; + __m256i y_vec2_u8; + __m256i y_vec1_u8; + __m256i y_diff_u8; + const uint32_t* query_grouped = reinterpret_cast(query); + size_t dim_idx = start_dimension; + for (; dim_idx + 4 <= end_dimension; dim_idx += 4) { + uint32_t dimension_idx = dim_idx; + size_t offset_to_dimension_start = dimension_idx * total_vectors; + size_t i = 0; + if constexpr (!SKIP_PRUNED) { + // To load the query efficiently I will load it as uint32_t (4 bytes packed in 1 + // word) + uint32_t query_value = query_grouped[dimension_idx / 4]; + // And then broadcast it to the register + vec1_u8 = _mm512_set1_epi32(query_value); + for (; i + 16 <= n_vectors; i += 16) { + // Read 64 bytes of data (64 values) with 4 dimensions of 16 vectors + res = _mm512_loadu_si512(&distances_p[i]); + vec2_u8 = _mm512_loadu_si512(&data[offset_to_dimension_start + i * 4] + ); // This 4 is because everytime I read 4 dimensions + diff_u8 = _mm512_or_si512( + _mm512_subs_epu8(vec1_u8, vec2_u8), _mm512_subs_epu8(vec2_u8, vec1_u8) + ); + // We can use this asymmetric dot product as our values are mostly 7-bit + // Hence, the [sign] properties of the second operand are ignored + // As results will never be negative, it can be stored on distances_p[i] without + // issues and it saturates to MAX_INT + _mm512_storeu_si512( + &distances_p[i], _mm512_dpbusds_epi32(res, diff_u8, diff_u8) + ); + } + y_vec1_u8 = _mm256_set1_epi32(query_value); + for (; i + 8 <= n_vectors; i += 8) { + // Read 32 bytes of data (32 values) with 4 dimensions of 8 vectors + y_res = _mm256_loadu_si256(reinterpret_cast(&distances_p[i])); + y_vec2_u8 = _mm256_loadu_epi8(&data[offset_to_dimension_start + i * 4] + ); // This 4 is because everytime I read 4 dimensions + y_diff_u8 = _mm256_or_si256( + _mm256_subs_epu8(y_vec1_u8, y_vec2_u8), + _mm256_subs_epu8(y_vec2_u8, y_vec1_u8) + ); + _mm256_storeu_si256( + reinterpret_cast<__m256i*>(&distances_p[i]), + _mm256_dpbusds_epi32(y_res, y_diff_u8, y_diff_u8) + ); + } + } + // Scalar tail (vectors). + for (; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + int to_multiply_a = + query[dimension_idx] - data[offset_to_dimension_start + (vector_idx * 4)]; + int to_multiply_b = query[dimension_idx + 1] - + data[offset_to_dimension_start + (vector_idx * 4) + 1]; + int to_multiply_c = query[dimension_idx + 2] - + data[offset_to_dimension_start + (vector_idx * 4) + 2]; + int to_multiply_d = query[dimension_idx + 3] - + data[offset_to_dimension_start + (vector_idx * 4) + 3]; + distances_p[vector_idx] += + (to_multiply_a * to_multiply_a) + (to_multiply_b * to_multiply_b) + + (to_multiply_c * to_multiply_c) + (to_multiply_d * to_multiply_d); + } + } + if (dim_idx < end_dimension) { + auto remaining = static_cast(end_dimension - dim_idx); + size_t offset = dim_idx * total_vectors; + for (size_t i = 0; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + for (uint32_t k = 0; k < remaining; ++k) { + int diff = query[dim_idx + k] - data[offset + vector_idx * remaining + k]; + distances_p[vector_idx] += diff * diff; + } + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + __m512i d2_i32_vec = _mm512_setzero_si512(); + __m512i a_u8_vec, b_u8_vec; + + simsimd_l2sq_u8_ice_cycle: + if (num_dimensions < 64) { + const __mmask64 mask = + static_cast<__mmask64>(_bzhi_u64(0xFFFFFFFFFFFFFFFF, num_dimensions)); + a_u8_vec = _mm512_maskz_loadu_epi8(mask, vector1); + b_u8_vec = _mm512_maskz_loadu_epi8(mask, vector2); + num_dimensions = 0; + } else { + a_u8_vec = _mm512_loadu_si512(vector1); + b_u8_vec = _mm512_loadu_si512(vector2); + vector1 += 64, vector2 += 64, num_dimensions -= 64; + } + + // Substracting unsigned vectors in AVX-512 is done by saturating subtraction: + __m512i d_u8_vec = _mm512_or_si512( + _mm512_subs_epu8(a_u8_vec, b_u8_vec), _mm512_subs_epu8(b_u8_vec, a_u8_vec) + ); + + // Multiply and accumulate at `int8` level which are actually uint7, accumulate at `int32` + // level: + d2_i32_vec = _mm512_dpbusds_epi32(d2_i32_vec, d_u8_vec, d_u8_vec); + if (num_dimensions) + goto simsimd_l2sq_u8_ice_cycle; + return _mm512_reduce_add_epi32(d2_i32_vec); + }; +}; + +} // namespace PDX diff --git a/include/pdx/distance_computers/base_computers.hpp b/include/pdx/distance_computers/base_computers.hpp new file mode 100644 index 0000000..0c8ae6b --- /dev/null +++ b/include/pdx/distance_computers/base_computers.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "pdx/common.hpp" + +#ifdef __ARM_NEON +#include "pdx/distance_computers/neon_computers.hpp" +#endif + +#if defined(__AVX2__) && !defined(__AVX512F__) +#include "pdx/distance_computers/avx2_computers.hpp" +#endif + +#ifdef __AVX512F__ +#include "pdx/distance_computers/avx512_computers.hpp" +#endif + +// Fallback to scalar computer. +#if !defined(__ARM_NEON) && !defined(__AVX2__) && !defined(__AVX512F__) +#include "pdx/distance_computers/scalar_computers.hpp" +#endif + +// TODO: Support SVE + +namespace PDX { + +template +class DistanceComputer {}; + +template <> +class DistanceComputer { +#if !defined(__ARM_NEON) && !defined(__AVX2__) && !defined(__AVX512F__) + using computer = ScalarComputer; +#else + using computer = SIMDComputer; +#endif + + public: + constexpr static auto VerticalPruning = computer::Vertical; + constexpr static auto Vertical = computer::Vertical; + + constexpr static auto Horizontal = computer::Horizontal; + constexpr static auto FlipSign = computer::FlipSign; +}; + +template <> +class DistanceComputer { +#if !defined(__ARM_NEON) && !defined(__AVX2__) && !defined(__AVX512F__) + using computer = ScalarComputer; +#else + using computer = SIMDComputer; +#endif + + public: + constexpr static auto VerticalPruning = computer::Vertical; + constexpr static auto Vertical = computer::Vertical; + + constexpr static auto Horizontal = computer::Horizontal; +}; + +} // namespace PDX diff --git a/include/pdx/distance_computers/neon_computers.hpp b/include/pdx/distance_computers/neon_computers.hpp new file mode 100644 index 0000000..acf3e79 --- /dev/null +++ b/include/pdx/distance_computers/neon_computers.hpp @@ -0,0 +1,198 @@ +#pragma once + +#include "arm_neon.h" +#include "pdx/common.hpp" +#include + +namespace PDX { + +template +class SIMDComputer {}; + +template <> +class SIMDComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + size_t dimensions_jump_factor = total_vectors; + for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; + ++dimension_idx) { + size_t offset_to_dimension_start = dimension_idx * dimensions_jump_factor; + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + auto true_vector_idx = vector_idx; + if constexpr (SKIP_PRUNED) { + true_vector_idx = pruning_positions[vector_idx]; + } + distance_t to_multiply = + query[dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; + distances_p[true_vector_idx] += to_multiply * to_multiply; + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { +#if defined(__APPLE__) + distance_t distance = 0.0; +#pragma clang loop vectorize(enable) + for (size_t i = 0; i < num_dimensions; ++i) { + distance_t diff = vector1[i] - vector2[i]; + distance += diff * diff; + } + return distance; +#else + float32x4_t sum_vec = vdupq_n_f32(0); + size_t i = 0; + for (; i + 4 <= num_dimensions; i += 4) { + float32x4_t a_vec = vld1q_f32(vector1 + i); + float32x4_t b_vec = vld1q_f32(vector2 + i); + float32x4_t diff_vec = vsubq_f32(a_vec, b_vec); + sum_vec = vfmaq_f32(sum_vec, diff_vec, diff_vec); + } + distance_t distance = vaddvq_f32(sum_vec); + for (; i < num_dimensions; ++i) { + distance_t diff = vector1[i] - vector2[i]; + distance += diff * diff; + } + return distance; +#endif + }; + + static void FlipSign(const data_t* data, data_t* out, const uint32_t* masks, size_t d) { + size_t j = 0; + for (; j + 4 <= d; j += 4) { + float32x4_t vec = vld1q_f32(data + j); + const uint32x4_t mask = vld1q_u32(masks + j); + vec = vreinterpretq_f32_u32(veorq_u32(vreinterpretq_u32_f32(vec), mask)); + vst1q_f32(out + j, vec); + } + auto data_bits = reinterpret_cast(data); + auto out_bits = reinterpret_cast(out); + for (; j < d; ++j) { + out_bits[j] = data_bits[j] ^ masks[j]; + } + } +}; + +// Equivalent of vdotq_u32(acc, a, a) for squared accumulation. +static inline uint32x4_t squared_dot_accumulate(uint32x4_t acc, uint8x16_t a) { +#ifdef __ARM_FEATURE_DOTPROD + return vdotq_u32(acc, a, a); +#else + uint16x8_t sq_lo = vmull_u8(vget_low_u8(a), vget_low_u8(a)); + uint16x8_t sq_hi = vmull_u8(vget_high_u8(a), vget_high_u8(a)); + uint32x4_t partial_lo = vpaddlq_u16(sq_lo); + uint32x4_t partial_hi = vpaddlq_u16(sq_hi); + return vaddq_u32(acc, vpaddq_u32(partial_lo, partial_hi)); +#endif +} + +template <> +class SIMDComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + size_t dim_idx = start_dimension; + for (; dim_idx + 4 <= end_dimension; dim_idx += 4) { + uint32_t dimension_idx = dim_idx; + uint8x8_t vals = vld1_u8(&query[dimension_idx]); + size_t offset_to_dimension_start = dimension_idx * total_vectors; + size_t i = 0; + if constexpr (!SKIP_PRUNED) { + const uint8x16_t idx = {0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3}; + const uint8x16_t vec1_u8 = vqtbl1q_u8(vcombine_u8(vals, vals), idx); + for (; i + 4 <= n_vectors; i += 4) { + // Read 16 bytes of data (16 values) with 4 dimensions of 4 vectors + uint32x4_t res = vld1q_u32(&distances_p[i]); + uint8x16_t vec2_u8 = vld1q_u8(&data[offset_to_dimension_start + i * 4] + ); // This 4 is because everytime I read 4 dimensions + uint8x16_t diff_u8 = vabdq_u8(vec1_u8, vec2_u8); + vst1q_u32(&distances_p[i], squared_dot_accumulate(res, diff_u8)); + } + } + for (; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + int to_multiply_a = + query[dimension_idx] - data[offset_to_dimension_start + (vector_idx * 4)]; + int to_multiply_b = query[dimension_idx + 1] - + data[offset_to_dimension_start + (vector_idx * 4) + 1]; + int to_multiply_c = query[dimension_idx + 2] - + data[offset_to_dimension_start + (vector_idx * 4) + 2]; + int to_multiply_d = query[dimension_idx + 3] - + data[offset_to_dimension_start + (vector_idx * 4) + 3]; + distances_p[vector_idx] += + (to_multiply_a * to_multiply_a) + (to_multiply_b * to_multiply_b) + + (to_multiply_c * to_multiply_c) + (to_multiply_d * to_multiply_d); + } + } + if (dim_idx < end_dimension) { + auto remaining = static_cast(end_dimension - dim_idx); + size_t offset = dim_idx * total_vectors; + for (size_t i = 0; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + for (uint32_t k = 0; k < remaining; ++k) { + int diff = query[dim_idx + k] - data[offset + vector_idx * remaining + k]; + distances_p[vector_idx] += diff * diff; + } + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + uint32x4_t sum_vec = vdupq_n_u32(0); + size_t i = 0; + for (; i + 16 <= num_dimensions; i += 16) { + uint8x16_t a_vec = vld1q_u8(vector1 + i); + uint8x16_t b_vec = vld1q_u8(vector2 + i); + uint8x16_t d_vec = vabdq_u8(a_vec, b_vec); + sum_vec = squared_dot_accumulate(sum_vec, d_vec); + } + distance_t distance = vaddvq_u32(sum_vec); + for (; i < num_dimensions; ++i) { + int n = static_cast(vector1[i]) - vector2[i]; + distance += n * n; + } + return distance; + }; +}; + +} // namespace PDX diff --git a/include/pdx/distance_computers/scalar_computers.hpp b/include/pdx/distance_computers/scalar_computers.hpp new file mode 100644 index 0000000..3701536 --- /dev/null +++ b/include/pdx/distance_computers/scalar_computers.hpp @@ -0,0 +1,163 @@ +#pragma once + +#include "pdx/common.hpp" +#include +#include + +namespace PDX { + +template +class ScalarComputer {}; + +template <> +class ScalarComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + size_t dimensions_jump_factor = total_vectors; + for (size_t dimension_idx = start_dimension; dimension_idx < end_dimension; + ++dimension_idx) { + size_t offset_to_dimension_start = dimension_idx * dimensions_jump_factor; + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + auto true_vector_idx = vector_idx; + if constexpr (SKIP_PRUNED) { + true_vector_idx = pruning_positions[vector_idx]; + } + distance_t to_multiply = + query[dimension_idx] - data[offset_to_dimension_start + true_vector_idx]; + distances_p[true_vector_idx] += to_multiply * to_multiply; + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + distance_t distance = 0.0; +#pragma clang loop vectorize(enable) + for (size_t dimension_idx = 0; dimension_idx < num_dimensions; ++dimension_idx) { + distance_t to_multiply = vector1[dimension_idx] - vector2[dimension_idx]; + distance += to_multiply * to_multiply; + } + return distance; + }; + + static void FlipSign(const data_t* data, data_t* out, const uint32_t* masks, size_t d) { + auto data_bits = reinterpret_cast(data); + auto out_bits = reinterpret_cast(out); +#pragma clang loop vectorize(enable) + for (size_t j = 0; j < d; ++j) { + out_bits[j] = data_bits[j] ^ masks[j]; + } + } +}; + +template <> +class ScalarComputer { + public: + using distance_t = pdx_distance_t; + using query_t = pdx_quantized_embedding_t; + using data_t = pdx_data_t; + + template + static void Vertical( + const query_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + size_t n_vectors, + size_t total_vectors, + size_t start_dimension, + size_t end_dimension, + distance_t* distances_p, + const uint32_t* pruning_positions = nullptr + ) { + size_t dim_idx = start_dimension; + for (; dim_idx + 4 <= end_dimension; dim_idx += 4) { + uint32_t dimension_idx = dim_idx; + size_t offset_to_dimension_start = dimension_idx * total_vectors; + for (size_t i = 0; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + int da = query[dimension_idx] - data[offset_to_dimension_start + (vector_idx * 4)]; + int db = query[dimension_idx + 1] - + data[offset_to_dimension_start + (vector_idx * 4) + 1]; + int dc = query[dimension_idx + 2] - + data[offset_to_dimension_start + (vector_idx * 4) + 2]; + int dd = query[dimension_idx + 3] - + data[offset_to_dimension_start + (vector_idx * 4) + 3]; + distances_p[vector_idx] += (da * da) + (db * db) + (dc * dc) + (dd * dd); + } + } + if (dim_idx < end_dimension) { + auto remaining = static_cast(end_dimension - dim_idx); + size_t offset = dim_idx * total_vectors; + for (size_t i = 0; i < n_vectors; ++i) { + size_t vector_idx = i; + if constexpr (SKIP_PRUNED) { + vector_idx = pruning_positions[vector_idx]; + } + for (uint32_t k = 0; k < remaining; ++k) { + int diff = query[dim_idx + k] - data[offset + vector_idx * remaining + k]; + distances_p[vector_idx] += diff * diff; + } + } + } + } + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + distance_t distance = 0; + for (size_t i = 0; i < num_dimensions; ++i) { + int diff = static_cast(vector1[i]) - static_cast(vector2[i]); + distance += diff * diff; + } + return distance; + }; +}; + +template <> +class ScalarComputer { + public: + using distance_t = float; + using query_t = float; + using data_t = float; + + static distance_t Horizontal( + const query_t* PDX_RESTRICT vector1, + const data_t* PDX_RESTRICT vector2, + size_t num_dimensions + ) { + float dot = 0.0f, norm1 = 0.0f, norm2 = 0.0f; +#pragma clang loop vectorize(enable) + for (size_t i = 0; i < num_dimensions; ++i) { + dot += vector1[i] * vector2[i]; + norm1 += vector1[i] * vector1[i]; + norm2 += vector2[i] * vector2[i]; + } + float denom = std::sqrt(norm1) * std::sqrt(norm2); + if (denom == 0.0f) + return 1.0f; + return 1.0f - (dot / denom); + } +}; + +} // namespace PDX diff --git a/include/pdx/index.hpp b/include/pdx/index.hpp new file mode 100644 index 0000000..6e3b933 --- /dev/null +++ b/include/pdx/index.hpp @@ -0,0 +1,739 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pdx/clustering.hpp" +#include "pdx/common.hpp" +#include "pdx/ivf_wrapper.hpp" +#include "pdx/layout.hpp" +#include "pdx/pruners/adsampling.hpp" +#include "pdx/quantizers/scalar.hpp" +#include "pdx/searcher.hpp" +#include "pdx/utils.hpp" +#include + +namespace PDX { + +struct PDXIndexConfig { + uint32_t num_dimensions; + DistanceMetric distance_metric = DistanceMetric::L2SQ; + uint32_t seed = 42; + uint32_t num_clusters = 0; // 0 = auto-compute from num_embeddings + uint32_t num_meso_clusters = 0; + bool normalize = false; + float sampling_fraction = 0.0f; // 0 = auto (1.0 if small dataset, 0.3 otherwise) + uint32_t kmeans_iters = 10; + bool hierarchical_indexing = true; + uint32_t n_threads = 0; // 0 = omp_get_max_threads() + + void Validate() const { + if (num_dimensions == 0 || num_dimensions > PDX_MAX_DIMS) { + throw std::invalid_argument( + "num_dimensions must be between 1 and " + std::to_string(PDX_MAX_DIMS) + ", got " + + std::to_string(num_dimensions) + ); + } + if (sampling_fraction < 0.0f || sampling_fraction > 1.0f) { + throw std::invalid_argument( + "sampling_fraction must be between 0.0 and 1.0, got " + + std::to_string(sampling_fraction) + ); + } + if (num_meso_clusters > 0 && num_clusters > 0 && num_meso_clusters >= num_clusters) { + throw std::invalid_argument( + "num_meso_clusters (" + std::to_string(num_meso_clusters) + + ") must be smaller than num_clusters (" + std::to_string(num_clusters) + ")" + ); + } + if (kmeans_iters == 0 || kmeans_iters >= 100) { + throw std::invalid_argument( + "kmeans_iters must be between 1 and 99, got " + std::to_string(kmeans_iters) + ); + } + } + + void ValidateNumEmbeddings(size_t num_embeddings) const { + if (num_clusters > 0 && num_clusters > num_embeddings) { + throw std::invalid_argument( + "num_clusters (" + std::to_string(num_clusters) + ") exceeds num_embeddings (" + + std::to_string(num_embeddings) + ")" + ); + } + } +}; + +inline std::unique_ptr NormalizeAndRotate( + const float* embeddings, + size_t num_embeddings, + uint32_t num_dimensions, + bool normalize, + const ADSamplingPruner& pruner +) { + const size_t total_floats = num_embeddings * num_dimensions; + std::unique_ptr normalized; + const float* rotation_input = embeddings; + if (normalize) { + normalized.reset(new float[total_floats]); + Quantizer quantizer(num_dimensions); +#pragma omp parallel for num_threads(PDX::g_n_threads) + for (size_t i = 0; i < num_embeddings; i++) { + quantizer.NormalizeQuery( + embeddings + i * num_dimensions, normalized.get() + i * num_dimensions + ); + } + rotation_input = normalized.get(); + } + std::unique_ptr preprocessed(new float[total_floats]); + pruner.PreprocessEmbeddings(rotation_input, preprocessed.get(), num_embeddings); + return preprocessed; +} + +template +void PopulateIVFClusters( + IVF& ivf, + const KMeansResult& kmeans_result, + const float* source_data, + const size_t* row_ids, + uint32_t num_dimensions, + uint32_t num_clusters, + float quantization_base, + float quantization_scale +) { + using storage_t = pdx_data_t; + + size_t max_cluster_size = 0; + for (size_t i = 0; i < num_clusters; i++) { + max_cluster_size = std::max(max_cluster_size, kmeans_result.assignments[i].size()); + } + + // Pre-allocate all clusters sequentially + for (size_t cluster_idx = 0; cluster_idx < num_clusters; cluster_idx++) { + ivf.clusters.emplace_back(kmeans_result.assignments[cluster_idx].size(), num_dimensions); + } + + // Per-thread tmp buffers for gather + quantize + const uint32_t n_threads = PDX::g_n_threads; + std::vector> tmp_buffers(n_threads); + for (uint32_t t = 0; t < n_threads; t++) { + tmp_buffers[t].reset(new storage_t[static_cast(max_cluster_size) * num_dimensions] + ); + } + +#pragma omp parallel for num_threads(n_threads) + for (size_t cluster_idx = 0; cluster_idx < num_clusters; cluster_idx++) { + const auto cluster_size = kmeans_result.assignments[cluster_idx].size(); + auto& cluster = ivf.clusters[cluster_idx]; + auto* tmp = tmp_buffers[omp_get_thread_num()].get(); + + for (size_t pos = 0; pos < cluster_size; pos++) { + const auto emb_idx = kmeans_result.assignments[cluster_idx][pos]; + cluster.indices[pos] = row_ids[emb_idx]; + + if constexpr (Q == U8) { + ScalarQuantizer quantizer(num_dimensions); + quantizer.QuantizeEmbedding( + source_data + (emb_idx * num_dimensions), + quantization_base, + quantization_scale, + tmp + (pos * num_dimensions) + ); + } else { + std::memcpy( + tmp + (pos * num_dimensions), + source_data + (emb_idx * num_dimensions), + num_dimensions * sizeof(float) + ); + } + } + StoreClusterEmbeddings(cluster, ivf, tmp, cluster_size); + } +} + +class IPDXIndex { + public: + virtual ~IPDXIndex() = default; + virtual std::vector Search(const float* query_embedding, size_t knn) const = 0; + virtual std::vector FilteredSearch( + const float* query_embedding, + size_t knn, + const std::vector& passing_row_ids + ) const = 0; + virtual void BuildIndex(const float* embeddings, size_t num_embeddings) = 0; + virtual void SetNProbe(uint32_t n_probe) const = 0; + virtual void Save(const std::string& path) const = 0; + virtual void Restore(const std::string& path) = 0; + virtual uint32_t GetNumDimensions() const = 0; + virtual uint32_t GetNumClusters() const = 0; + virtual uint32_t GetClusterSize(uint32_t cluster_id) const = 0; + virtual std::vector GetClusterRowIds(uint32_t cluster_id) const = 0; + virtual size_t GetInMemorySizeInBytes() const = 0; +}; + +template +class PDXIndex : public IPDXIndex { + public: + using embedding_storage_t = PDX::pdx_data_t; + + private: + PDXIndexConfig config{}; + PDX::IVF index; + std::unique_ptr pruner; + std::unique_ptr> searcher; + std::vector> row_id_cluster_mapping; + + static constexpr PDXIndexType GetIndexType() { + if constexpr (Q == F32) + return PDXIndexType::PDX_F32; + else + return PDXIndexType::PDX_U8; + } + + void BuildRowIdClusterMapping() { + size_t total = 0; + for (size_t c = 0; c < index.num_clusters; c++) { + total += index.clusters[c].num_embeddings; + } + row_id_cluster_mapping.resize(total); + for (uint32_t c = 0; c < index.num_clusters; c++) { + for (uint32_t p = 0; p < index.clusters[c].num_embeddings; p++) { + row_id_cluster_mapping[index.clusters[c].indices[p]] = {c, p}; + } + } + } + + PDX::PredicateEvaluator CreatePredicateEvaluator(const std::vector& passing_row_ids + ) const { + PDX::PredicateEvaluator evaluator(index.num_clusters, row_id_cluster_mapping.size()); + for (const auto row_id : passing_row_ids) { + const auto& [cluster_id, index_in_cluster] = row_id_cluster_mapping[row_id]; + evaluator.n_passing_tuples[cluster_id]++; + evaluator.selection_vector[searcher->cluster_offsets[cluster_id] + index_in_cluster] = + 1; + } + return evaluator; + } + + public: + PDXIndex() = default; + + explicit PDXIndex(PDXIndexConfig config) : config(config) { + config.Validate(); + PDX::g_n_threads = (config.n_threads == 0) ? omp_get_max_threads() : config.n_threads; + pruner = std::make_unique(config.num_dimensions, config.seed); + } + + void Save(const std::string& path) const override { + std::ofstream out(path, std::ios::binary); + + // Index type flag + uint8_t type_flag = static_cast(GetIndexType()); + out.write(reinterpret_cast(&type_flag), sizeof(uint8_t)); + + // Rotation matrix + const auto& matrix = pruner->GetMatrix(); + uint32_t matrix_rows = static_cast(matrix.rows()); + uint32_t matrix_cols = static_cast(matrix.cols()); + out.write(reinterpret_cast(&matrix_rows), sizeof(uint32_t)); + out.write(reinterpret_cast(&matrix_cols), sizeof(uint32_t)); + out.write( + reinterpret_cast(matrix.data()), sizeof(float) * matrix_rows * matrix_cols + ); + + // IVF data + index.Save(out); + } + + void Restore(const std::string& path) override { + auto buffer = MmapFile(path); + char* ptr = buffer.get(); + + // Index type flag + ptr += sizeof(uint8_t); + + // Rotation matrix (ptr may be misaligned after the uint8_t type flag) + uint32_t matrix_rows, matrix_cols; + std::memcpy(&matrix_rows, ptr, sizeof(uint32_t)); + ptr += sizeof(uint32_t); + std::memcpy(&matrix_cols, ptr, sizeof(uint32_t)); + ptr += sizeof(uint32_t); + const size_t matrix_floats = static_cast(matrix_rows) * matrix_cols; + auto aligned_matrix = std::unique_ptr(new float[matrix_floats]); + std::memcpy(aligned_matrix.get(), ptr, sizeof(float) * matrix_floats); + ptr += sizeof(float) * matrix_floats; + + // Load IVF data + index.Load(ptr); + + // Create pruner and searcher + pruner = + std::make_unique(index.num_dimensions, aligned_matrix.get()); + searcher = std::make_unique>(index, *pruner); + BuildRowIdClusterMapping(); + } + + std::vector Search(const float* query_embedding, size_t knn) const override { + return searcher->Search(query_embedding, knn); + } + + std::vector FilteredSearch( + const float* query_embedding, + size_t knn, + const std::vector& passing_row_ids + ) const override { + auto evaluator = CreatePredicateEvaluator(passing_row_ids); + return searcher->FilteredSearch(query_embedding, knn, evaluator); + } + + void SetNProbe(uint32_t n_probe) const override { searcher->SetNProbe(n_probe); } + + const PDX::PDXearch& GetSearcher() const { return *searcher; } + + uint32_t GetNumDimensions() const override { return index.num_dimensions; } + + uint32_t GetNumClusters() const override { return index.num_clusters; } + + uint32_t GetClusterSize(uint32_t cluster_id) const override { + return index.clusters[cluster_id].num_embeddings; + } + + std::vector GetClusterRowIds(uint32_t cluster_id) const override { + const auto& cluster = index.clusters[cluster_id]; + return {cluster.indices, cluster.indices + cluster.num_embeddings}; + } + + size_t GetInMemorySizeInBytes() const override { + size_t size = sizeof(*this); + // IVF heap allocations (sizeof(IVF) is inline in sizeof(*this)) + size += index.GetInMemorySizeInBytes() - sizeof(index); + // Pruner: rotation matrix or flip_masks (DCT mode) + ratios vector + if (pruner) { + size += sizeof(*pruner); + const auto& m = pruner->GetMatrix(); + // matrix heap data (1 x D for DCT sign vector, D x D for full rotation) + size += static_cast(m.rows()) * m.cols() * sizeof(float); + size += pruner->num_dimensions * sizeof(float); // ratios + if (m.rows() == 1) { + size += pruner->num_dimensions * sizeof(uint32_t); // flip_masks + } + } + // Searcher: cluster_offsets array + if (searcher) { + size += sizeof(*searcher); + size += index.num_clusters * sizeof(size_t); + } + // Row ID to cluster mapping + size += row_id_cluster_mapping.capacity() * sizeof(std::pair); + return size; + } + + void BuildIndex(const float* const embeddings, const size_t num_embeddings) override { + std::vector row_ids(num_embeddings); + std::iota(row_ids.begin(), row_ids.end(), 0); + BuildIndex(row_ids.data(), embeddings, num_embeddings); + } + + void BuildIndex( + const size_t* const row_ids, + const float* const embeddings, + const size_t num_embeddings + ) { + config.ValidateNumEmbeddings(num_embeddings); + + const auto num_dimensions = config.num_dimensions; + auto num_clusters = config.num_clusters; + if (num_clusters == 0) { + num_clusters = ComputeNumberOfClusters(num_embeddings); + } + const bool normalize = + config.normalize || DistanceMetricRequiresNormalization(config.distance_metric); + + assert(num_embeddings > 0); + assert(pruner); + + auto preprocessed = + NormalizeAndRotate(embeddings, num_embeddings, num_dimensions, normalize, *pruner); + + float quantization_base = 0.0f; + float quantization_scale = 1.0f; + if constexpr (Q == PDX::U8) { + const auto params = PDX::ScalarQuantizer::ComputeQuantizationParams( + preprocessed.get(), static_cast(num_embeddings) * num_dimensions + ); + quantization_base = params.quantization_base; + quantization_scale = params.quantization_scale; + index = PDX::IVF( + num_dimensions, + num_embeddings, + num_clusters, + normalize, + quantization_scale, + quantization_base + ); + } else { + index = PDX::IVF(num_dimensions, num_embeddings, num_clusters, normalize); + } + + KMeansResult kmeans_result = ComputeKMeans( + preprocessed.get(), + num_embeddings, + num_dimensions, + num_clusters, + config.distance_metric, + config.seed, + config.normalize, + config.sampling_fraction, + config.kmeans_iters, + config.hierarchical_indexing + ); + index.centroids = std::move(kmeans_result.centroids); + + PopulateIVFClusters( + index, + kmeans_result, + preprocessed.get(), + row_ids, + num_dimensions, + num_clusters, + quantization_base, + quantization_scale + ); + + searcher = std::make_unique>(index, *pruner); + BuildRowIdClusterMapping(); + } +}; + +template +class PDXTreeIndex : public IPDXIndex { + public: + using embedding_storage_t = PDX::pdx_data_t; + + private: + PDXIndexConfig config{}; + PDX::IVFTree index; + std::unique_ptr pruner; + std::unique_ptr> searcher; + std::unique_ptr> top_level_searcher; + std::vector> row_id_cluster_mapping; + + static constexpr PDXIndexType GetIndexType() { + if constexpr (Q == F32) + return PDXIndexType::PDX_TREE_F32; + else + return PDXIndexType::PDX_TREE_U8; + } + + void BuildRowIdClusterMapping() { + size_t total = 0; + for (size_t c = 0; c < index.num_clusters; c++) { + total += index.clusters[c].num_embeddings; + } + row_id_cluster_mapping.resize(total); + for (uint32_t c = 0; c < index.num_clusters; c++) { + for (uint32_t p = 0; p < index.clusters[c].num_embeddings; p++) { + row_id_cluster_mapping[index.clusters[c].indices[p]] = {c, p}; + } + } + } + + PDX::PredicateEvaluator CreatePredicateEvaluator(const std::vector& passing_row_ids + ) const { + PDX::PredicateEvaluator evaluator(index.num_clusters, row_id_cluster_mapping.size()); + for (const auto row_id : passing_row_ids) { + const auto& [cluster_id, index_in_cluster] = row_id_cluster_mapping[row_id]; + evaluator.n_passing_tuples[cluster_id]++; + evaluator.selection_vector[searcher->cluster_offsets[cluster_id] + index_in_cluster] = + 1; + } + return evaluator; + } + + public: + PDXTreeIndex() = default; + + explicit PDXTreeIndex(PDXIndexConfig config) : config(config) { + config.Validate(); + PDX::g_n_threads = (config.n_threads == 0) ? omp_get_max_threads() : config.n_threads; + pruner = std::make_unique(config.num_dimensions, config.seed); + } + + void Save(const std::string& path) const override { + std::ofstream out(path, std::ios::binary); + + // Index type flag + uint8_t type_flag = static_cast(GetIndexType()); + out.write(reinterpret_cast(&type_flag), sizeof(uint8_t)); + + // Rotation matrix + const auto& matrix = pruner->GetMatrix(); + uint32_t matrix_rows = static_cast(matrix.rows()); + uint32_t matrix_cols = static_cast(matrix.cols()); + out.write(reinterpret_cast(&matrix_rows), sizeof(uint32_t)); + out.write(reinterpret_cast(&matrix_cols), sizeof(uint32_t)); + out.write( + reinterpret_cast(matrix.data()), sizeof(float) * matrix_rows * matrix_cols + ); + + // IVFTree data + index.Save(out); + } + + void Restore(const std::string& path) override { + auto buffer = MmapFile(path); + char* ptr = buffer.get(); + + // Index type flag + ptr += sizeof(uint8_t); + + // Rotation matrix (ptr may be misaligned after the uint8_t type flag) + uint32_t matrix_rows, matrix_cols; + std::memcpy(&matrix_rows, ptr, sizeof(uint32_t)); + ptr += sizeof(uint32_t); + std::memcpy(&matrix_cols, ptr, sizeof(uint32_t)); + ptr += sizeof(uint32_t); + const size_t matrix_floats = static_cast(matrix_rows) * matrix_cols; + auto aligned_matrix = std::unique_ptr(new float[matrix_floats]); + std::memcpy(aligned_matrix.get(), ptr, sizeof(float) * matrix_floats); + ptr += sizeof(float) * matrix_floats; + + // Load IVFTree data + index.Load(ptr); + + // Create pruner and searchers + pruner = + std::make_unique(index.num_dimensions, aligned_matrix.get()); + searcher = std::make_unique>(index, *pruner); + top_level_searcher = std::make_unique>(index.l0, *pruner); + BuildRowIdClusterMapping(); + } + + std::vector Search(const float* query_embedding, size_t knn) const override { + auto n_probe_top_level = GetTopLevelNumClusters(); + // We confidently prune half of the search space + if (searcher->GetNProbe() < GetNumClusters() / 2) { + n_probe_top_level /= 2; + } + top_level_searcher->SetNProbe(n_probe_top_level); + auto top_level_results = top_level_searcher->Search(query_embedding, searcher->GetNProbe()); + + std::vector top_level_indexes(top_level_results.size()); + for (size_t i = 0; i < top_level_results.size(); i++) { + top_level_indexes[i] = top_level_results[i].index; + } + searcher->SetClusterAccessOrder(top_level_indexes); + + return searcher->Search(query_embedding, knn); + } + + std::vector FilteredSearch( + const float* query_embedding, + size_t knn, + const std::vector& passing_row_ids + ) const override { + auto evaluator = CreatePredicateEvaluator(passing_row_ids); + return searcher->FilteredSearch(query_embedding, knn, evaluator); + } + + void BuildIndex(const float* const embeddings, const size_t num_embeddings) override { + std::vector row_ids(num_embeddings); + std::iota(row_ids.begin(), row_ids.end(), 0); + BuildIndex(row_ids.data(), embeddings, num_embeddings); + } + + void BuildIndex( + const size_t* const row_ids, + const float* const embeddings, + const size_t num_embeddings + ) { + config.ValidateNumEmbeddings(num_embeddings); + + const auto num_dimensions = config.num_dimensions; + auto num_clusters = config.num_clusters; + if (num_clusters == 0) { + num_clusters = ComputeNumberOfClusters(num_embeddings); + } + const bool normalize = + config.normalize || DistanceMetricRequiresNormalization(config.distance_metric); + + assert(num_embeddings > 0); + assert(pruner); + + auto preprocessed = + NormalizeAndRotate(embeddings, num_embeddings, num_dimensions, normalize, *pruner); + + float quantization_base = 0.0f; + float quantization_scale = 1.0f; + if constexpr (Q == PDX::U8) { + const auto params = PDX::ScalarQuantizer::ComputeQuantizationParams( + preprocessed.get(), static_cast(num_embeddings) * num_dimensions + ); + quantization_base = params.quantization_base; + quantization_scale = params.quantization_scale; + index = PDX::IVFTree( + num_dimensions, + num_embeddings, + num_clusters, + normalize, + quantization_scale, + quantization_base + ); + } else { + index = PDX::IVFTree(num_dimensions, num_embeddings, num_clusters, normalize); + } + + KMeansResult kmeans_result = ComputeKMeans( + preprocessed.get(), + num_embeddings, + num_dimensions, + num_clusters, + config.distance_metric, + config.seed, + config.normalize, + config.sampling_fraction, + config.kmeans_iters, + config.hierarchical_indexing + ); + index.centroids = std::move(kmeans_result.centroids); + + PopulateIVFClusters( + index, + kmeans_result, + preprocessed.get(), + row_ids, + num_dimensions, + num_clusters, + quantization_base, + quantization_scale + ); + + searcher = std::make_unique>(index, *pruner); + + // L0 index (meso-clusters over centroids) + auto l0_num_clusters = config.num_meso_clusters; + if (l0_num_clusters == 0) { + l0_num_clusters = static_cast(std::sqrt(num_clusters)); + } + + index.l0 = PDX::IVF(num_dimensions, num_clusters, l0_num_clusters, normalize); + KMeansResult l0_kmeans_result = ComputeKMeans( + index.centroids.data(), + num_clusters, + num_dimensions, + l0_num_clusters, + config.distance_metric, + config.seed, + config.normalize, + 1.0f, + 10, + false // No hierarchical indexing + ); + index.l0.centroids = std::move(l0_kmeans_result.centroids); + + // L0 row_ids are identity (centroid indices) + std::vector l0_row_ids(num_clusters); + std::iota(l0_row_ids.begin(), l0_row_ids.end(), 0); + PopulateIVFClusters( + index.l0, + l0_kmeans_result, + index.centroids.data(), + l0_row_ids.data(), + num_dimensions, + l0_num_clusters, + 0.0f, + 1.0f + ); + + top_level_searcher = std::make_unique>(index.l0, *pruner); + BuildRowIdClusterMapping(); + } + + void SetNProbe(uint32_t n_probe) const override { searcher->SetNProbe(n_probe); } + + const PDX::PDXearch& GetSearcher() const { return *searcher; } + + uint32_t GetNumDimensions() const override { return index.num_dimensions; } + + uint32_t GetNumClusters() const override { return index.num_clusters; } + + uint32_t GetClusterSize(uint32_t cluster_id) const override { + return index.clusters[cluster_id].num_embeddings; + } + + std::vector GetClusterRowIds(uint32_t cluster_id) const override { + const auto& cluster = index.clusters[cluster_id]; + return {cluster.indices, cluster.indices + cluster.num_embeddings}; + } + + uint32_t GetTopLevelNumClusters() const { return index.l0.num_clusters; } + + size_t GetInMemorySizeInBytes() const override { + size_t size = sizeof(*this); + // IVFTree heap allocations (L1 + L0 clusters and centroids) + size += index.GetInMemorySizeInBytes() - sizeof(index); + // Pruner: rotation matrix or flip_masks (DCT mode) + ratios vector + if (pruner) { + size += sizeof(*pruner); + const auto& m = pruner->GetMatrix(); + // matrix heap data (1 x D for DCT sign vector, D x D for full rotation) + size += static_cast(m.rows()) * m.cols() * sizeof(float); + size += pruner->num_dimensions * sizeof(float); // ratios + if (m.rows() == 1) { + size += pruner->num_dimensions * sizeof(uint32_t); // flip_masks + } + } + // L1 searcher: cluster_offsets array + if (searcher) { + size += sizeof(*searcher); + size += index.num_clusters * sizeof(size_t); + } + // L0 top-level searcher: cluster_offsets array + if (top_level_searcher) { + size += sizeof(*top_level_searcher); + size += index.l0.num_clusters * sizeof(size_t); + } + // Row ID to cluster mapping + size += row_id_cluster_mapping.capacity() * sizeof(std::pair); + return size; + } +}; + +using PDXIndexF32 = PDXIndex; +using PDXIndexU8 = PDXIndex; +using PDXTreeIndexF32 = PDXTreeIndex; +using PDXTreeIndexU8 = PDXTreeIndex; + +inline std::unique_ptr LoadPDXIndex(const std::string& path) { + auto buffer = MmapFile(path); + auto type = static_cast(buffer.get()[0]); + std::unique_ptr idx; + switch (type) { + case PDXIndexType::PDX_F32: + idx = std::make_unique(); + break; + case PDXIndexType::PDX_U8: + idx = std::make_unique(); + break; + case PDXIndexType::PDX_TREE_F32: + idx = std::make_unique(); + break; + case PDXIndexType::PDX_TREE_U8: + idx = std::make_unique(); + break; + default: + throw std::runtime_error( + "Unknown PDX index type: " + std::to_string(static_cast(type)) + ); + } + idx->Restore(path); + return idx; +} + +} // namespace PDX diff --git a/include/pdx/ivf_wrapper.hpp b/include/pdx/ivf_wrapper.hpp new file mode 100644 index 0000000..dd20d05 --- /dev/null +++ b/include/pdx/ivf_wrapper.hpp @@ -0,0 +1,404 @@ +#pragma once + +#include "pdx/common.hpp" +#include "pdx/utils.hpp" +#include +#include +#include +#include +#include +#include + +namespace PDX { + +template +struct Cluster { + using data_t = pdx_data_t; + + Cluster(uint32_t num_embeddings, uint32_t num_dimensions) + : num_embeddings(num_embeddings), num_dimensions(num_dimensions), + indices(new uint32_t[num_embeddings]), + data(new data_t[static_cast(num_embeddings) * num_dimensions]) {} + + ~Cluster() { + delete[] data; + delete[] indices; + } + + uint32_t num_embeddings{}; + const uint32_t num_dimensions{}; + uint32_t* indices = nullptr; + data_t* data = nullptr; + + size_t GetInMemorySizeInBytes() const { + return sizeof(*this) + num_embeddings * sizeof(*indices) + + num_embeddings * static_cast(num_dimensions) * sizeof(*data); + } +}; + +template +class IVF { + public: + using cluster_t = Cluster; + using data_t = pdx_data_t; + + uint32_t num_dimensions{}; + uint64_t total_num_embeddings{}; + uint32_t num_clusters{}; + uint32_t num_vertical_dimensions{}; + uint32_t num_horizontal_dimensions{}; + std::vector clusters; + bool is_normalized{}; + std::vector centroids; + + // U8-specific quantization parameters + float quantization_scale = 1.0f; + float quantization_scale_squared = 1.0f; + float inverse_quantization_scale_squared = 1.0f; + float quantization_base = 0.0f; + + IVF() = default; + ~IVF() = default; + IVF(IVF&&) = default; + IVF& operator=(IVF&&) = default; + + IVF(uint32_t num_dimensions, + uint64_t total_num_embeddings, + uint32_t num_clusters, + bool is_normalized) + : num_dimensions(num_dimensions), total_num_embeddings(total_num_embeddings), + num_clusters(num_clusters), + num_vertical_dimensions(GetPDXDimensionSplit(num_dimensions).vertical_dimensions), + num_horizontal_dimensions(GetPDXDimensionSplit(num_dimensions).horizontal_dimensions), + is_normalized(is_normalized) { + clusters.reserve(num_clusters); + } + + IVF(uint32_t num_dimensions, + uint64_t total_num_embeddings, + uint32_t num_clusters, + bool is_normalized, + float quantization_scale, + float quantization_base) + : num_dimensions(num_dimensions), total_num_embeddings(total_num_embeddings), + num_clusters(num_clusters), + num_vertical_dimensions(GetPDXDimensionSplit(num_dimensions).vertical_dimensions), + num_horizontal_dimensions(GetPDXDimensionSplit(num_dimensions).horizontal_dimensions), + is_normalized(is_normalized), quantization_scale(quantization_scale), + quantization_scale_squared(quantization_scale * quantization_scale), + inverse_quantization_scale_squared(1.0f / (quantization_scale * quantization_scale)), + quantization_base(quantization_base) { + clusters.reserve(num_clusters); + } + + void Load(char* input) { + char* next_value = input; + num_dimensions = ((uint32_t*) input)[0]; + num_vertical_dimensions = ((uint32_t*) input)[1]; + num_horizontal_dimensions = ((uint32_t*) input)[2]; + + next_value += sizeof(uint32_t) * 3; + num_clusters = ((uint32_t*) next_value)[0]; + next_value += sizeof(uint32_t); + auto* nums_embeddings = (uint32_t*) next_value; + next_value += num_clusters * sizeof(uint32_t); + clusters.reserve(num_clusters); + for (size_t i = 0; i < num_clusters; ++i) { + clusters.emplace_back(nums_embeddings[i], num_dimensions); + memcpy( + clusters[i].data, + next_value, + sizeof(data_t) * clusters[i].num_embeddings * num_dimensions + ); + next_value += sizeof(data_t) * clusters[i].num_embeddings * num_dimensions; + } + for (size_t i = 0; i < num_clusters; ++i) { + memcpy(clusters[i].indices, next_value, sizeof(uint32_t) * clusters[i].num_embeddings); + next_value += sizeof(uint32_t) * clusters[i].num_embeddings; + } + + is_normalized = ((char*) next_value)[0]; + next_value += sizeof(char); + + centroids.resize(num_clusters * num_dimensions); + memcpy( + centroids.data(), (float*) next_value, sizeof(float) * num_clusters * num_dimensions + ); + next_value += sizeof(float) * num_clusters * num_dimensions; + + if constexpr (Q == U8) { + quantization_base = ((float*) next_value)[0]; + next_value += sizeof(float); + quantization_scale = ((float*) next_value)[0]; + next_value += sizeof(float); + quantization_scale_squared = quantization_scale * quantization_scale; + inverse_quantization_scale_squared = 1.0f / quantization_scale_squared; + } + } + + void Save(std::ostream& out) const { + out.write(reinterpret_cast(&num_dimensions), sizeof(uint32_t)); + out.write(reinterpret_cast(&num_vertical_dimensions), sizeof(uint32_t)); + out.write(reinterpret_cast(&num_horizontal_dimensions), sizeof(uint32_t)); + out.write(reinterpret_cast(&num_clusters), sizeof(uint32_t)); + + for (size_t i = 0; i < num_clusters; ++i) { + out.write(reinterpret_cast(&clusters[i].num_embeddings), sizeof(uint32_t)); + } + for (size_t i = 0; i < num_clusters; ++i) { + out.write( + reinterpret_cast(clusters[i].data), + sizeof(data_t) * clusters[i].num_embeddings * num_dimensions + ); + } + for (size_t i = 0; i < num_clusters; ++i) { + out.write( + reinterpret_cast(clusters[i].indices), + sizeof(uint32_t) * clusters[i].num_embeddings + ); + } + + char norm = is_normalized; + out.write(&norm, sizeof(char)); + + out.write( + reinterpret_cast(centroids.data()), + sizeof(float) * num_clusters * num_dimensions + ); + + if constexpr (Q == U8) { + out.write(reinterpret_cast(&quantization_base), sizeof(float)); + out.write(reinterpret_cast(&quantization_scale), sizeof(float)); + } + } + + size_t GetInMemorySizeInBytes() const { + size_t in_memory_size_in_bytes = 0; + in_memory_size_in_bytes += sizeof(*this); + for (const auto& cluster : clusters) { + in_memory_size_in_bytes += cluster.GetInMemorySizeInBytes(); + } + in_memory_size_in_bytes += + (clusters.capacity() - clusters.size()) * sizeof(*clusters.data()); + in_memory_size_in_bytes += centroids.capacity() * sizeof(*centroids.data()); + return in_memory_size_in_bytes; + } +}; + +template +class IVFTree : public IVF { + public: + using data_t = pdx_data_t; + + IVF l0; // Meso clusters + + IVFTree() = default; + ~IVFTree() = default; + IVFTree(IVFTree&&) = default; + IVFTree& operator=(IVFTree&&) = default; + + IVFTree( + uint32_t num_dimensions, + uint64_t total_num_embeddings, + uint32_t num_clusters, + bool is_normalized + ) + : IVF(num_dimensions, total_num_embeddings, num_clusters, is_normalized) {} + + IVFTree( + uint32_t num_dimensions, + uint64_t total_num_embeddings, + uint32_t num_clusters, + bool is_normalized, + float quantization_scale, + float quantization_base + ) + : IVF( + num_dimensions, + total_num_embeddings, + num_clusters, + is_normalized, + quantization_scale, + quantization_base + ) {} + + void Load(char* input) { + char* next_value = input; + + // Header + uint32_t dims = ((uint32_t*) input)[0]; + uint32_t v_dims = ((uint32_t*) input)[1]; + uint32_t h_dims = ((uint32_t*) input)[2]; + next_value += sizeof(uint32_t) * 3; + + uint32_t n_clusters_l1 = ((uint32_t*) next_value)[0]; + next_value += sizeof(uint32_t); + uint32_t n_clusters_l0 = ((uint32_t*) next_value)[0]; + next_value += sizeof(uint32_t); + + // === L0 (meso-clusters, always F32) === + l0.num_dimensions = dims; + l0.num_vertical_dimensions = v_dims; + l0.num_horizontal_dimensions = h_dims; + l0.num_clusters = n_clusters_l0; + + auto* nums_embeddings_l0 = (uint32_t*) next_value; + next_value += n_clusters_l0 * sizeof(uint32_t); + + l0.clusters.reserve(n_clusters_l0); + for (size_t i = 0; i < n_clusters_l0; ++i) { + l0.clusters.emplace_back(nums_embeddings_l0[i], dims); + memcpy( + l0.clusters[i].data, + next_value, + sizeof(float) * l0.clusters[i].num_embeddings * dims + ); + next_value += sizeof(float) * l0.clusters[i].num_embeddings * dims; + } + for (size_t i = 0; i < n_clusters_l0; ++i) { + memcpy( + l0.clusters[i].indices, next_value, sizeof(uint32_t) * l0.clusters[i].num_embeddings + ); + next_value += sizeof(uint32_t) * l0.clusters[i].num_embeddings; + } + + // === L1 (data clusters, inherited fields) === + this->num_dimensions = dims; + this->num_vertical_dimensions = v_dims; + this->num_horizontal_dimensions = h_dims; + this->num_clusters = n_clusters_l1; + + auto* nums_embeddings_l1 = (uint32_t*) next_value; + next_value += n_clusters_l1 * sizeof(uint32_t); + + this->clusters.reserve(n_clusters_l1); + for (size_t i = 0; i < n_clusters_l1; ++i) { + this->clusters.emplace_back(nums_embeddings_l1[i], dims); + memcpy( + this->clusters[i].data, + next_value, + sizeof(data_t) * this->clusters[i].num_embeddings * dims + ); + next_value += sizeof(data_t) * this->clusters[i].num_embeddings * dims; + } + for (size_t i = 0; i < n_clusters_l1; ++i) { + memcpy( + this->clusters[i].indices, + next_value, + sizeof(uint32_t) * this->clusters[i].num_embeddings + ); + next_value += sizeof(uint32_t) * this->clusters[i].num_embeddings; + } + + // === Shared metadata === + bool normalized = ((char*) next_value)[0]; + this->is_normalized = normalized; + l0.is_normalized = normalized; + next_value += sizeof(char); + + // === L0 centroids (centroids_pdx from file) === + l0.centroids.resize(n_clusters_l0 * dims); + memcpy(l0.centroids.data(), (float*) next_value, sizeof(float) * n_clusters_l0 * dims); + next_value += sizeof(float) * n_clusters_l0 * dims; + + // === U8 quantization params === + if constexpr (Q == U8) { + this->quantization_base = ((float*) next_value)[0]; + next_value += sizeof(float); + this->quantization_scale = ((float*) next_value)[0]; + next_value += sizeof(float); + this->quantization_scale_squared = this->quantization_scale * this->quantization_scale; + this->inverse_quantization_scale_squared = 1.0f / this->quantization_scale_squared; + } + } + + void Save(std::ostream& out) const { + // Header: dimensions (shared between L0 and L1) + out.write(reinterpret_cast(&this->num_dimensions), sizeof(uint32_t)); + out.write(reinterpret_cast(&this->num_vertical_dimensions), sizeof(uint32_t)); + out.write( + reinterpret_cast(&this->num_horizontal_dimensions), sizeof(uint32_t) + ); + + // Number of clusters: L1 then L0 + out.write(reinterpret_cast(&this->num_clusters), sizeof(uint32_t)); + uint32_t n_clusters_l0 = l0.num_clusters; + out.write(reinterpret_cast(&n_clusters_l0), sizeof(uint32_t)); + + // === L0 (meso-clusters, always F32) === + for (size_t i = 0; i < n_clusters_l0; ++i) { + out.write( + reinterpret_cast(&l0.clusters[i].num_embeddings), sizeof(uint32_t) + ); + } + for (size_t i = 0; i < n_clusters_l0; ++i) { + out.write( + reinterpret_cast(l0.clusters[i].data), + sizeof(float) * l0.clusters[i].num_embeddings * this->num_dimensions + ); + } + for (size_t i = 0; i < n_clusters_l0; ++i) { + out.write( + reinterpret_cast(l0.clusters[i].indices), + sizeof(uint32_t) * l0.clusters[i].num_embeddings + ); + } + + // === L1 (data clusters) === + for (size_t i = 0; i < this->num_clusters; ++i) { + out.write( + reinterpret_cast(&this->clusters[i].num_embeddings), sizeof(uint32_t) + ); + } + for (size_t i = 0; i < this->num_clusters; ++i) { + out.write( + reinterpret_cast(this->clusters[i].data), + sizeof(data_t) * this->clusters[i].num_embeddings * this->num_dimensions + ); + } + for (size_t i = 0; i < this->num_clusters; ++i) { + out.write( + reinterpret_cast(this->clusters[i].indices), + sizeof(uint32_t) * this->clusters[i].num_embeddings + ); + } + + // === Shared metadata === + char norm = this->is_normalized; + out.write(&norm, sizeof(char)); + + // L0 centroids + out.write( + reinterpret_cast(l0.centroids.data()), + sizeof(float) * n_clusters_l0 * this->num_dimensions + ); + + // === U8 quantization params === + if constexpr (Q == U8) { + out.write(reinterpret_cast(&this->quantization_base), sizeof(float)); + out.write(reinterpret_cast(&this->quantization_scale), sizeof(float)); + } + } + + size_t GetInMemorySizeInBytes() const { + size_t size = sizeof(*this); + + // L1 clusters (inherited from base) + for (const auto& cluster : this->clusters) { + size += cluster.GetInMemorySizeInBytes(); + } + size += (this->clusters.capacity() - this->clusters.size()) * sizeof(Cluster); + size += this->centroids.capacity() * sizeof(float); + + // L0 meso-clusters + for (const auto& cluster : l0.clusters) { + size += cluster.GetInMemorySizeInBytes(); + } + size += (l0.clusters.capacity() - l0.clusters.size()) * sizeof(Cluster); + size += l0.centroids.capacity() * sizeof(float); + + return size; + } +}; + +} // namespace PDX diff --git a/include/pdx/layout.hpp b/include/pdx/layout.hpp new file mode 100644 index 0000000..16bf727 --- /dev/null +++ b/include/pdx/layout.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "pdx/common.hpp" +#include "pdx/ivf_wrapper.hpp" + +namespace PDX { + +// Store the embeddings into this cluster's preallocated buffers in the transposed PDX layout. +// +// See the README of the following for a description of the PDX layout: +// https://github.com/cwida/pdx +template +inline void StoreClusterEmbeddings( + typename PDX::IVF::cluster_t& cluster, + const PDX::IVF& index, + const T* embeddings, + const size_t num_embeddings +); + +template <> +inline void StoreClusterEmbeddings( + PDX::IVF::cluster_t& cluster, + const PDX::IVF& index, + const float* const embeddings, + const size_t num_embeddings +) { + using matrix_t = PDX::eigen_matrix_t; + using h_matrix_t = Eigen::Matrix; + + const auto vertical_d = index.num_vertical_dimensions; + const auto horizontal_d = index.num_horizontal_dimensions; + + Eigen::Map in(embeddings, num_embeddings, index.num_dimensions); + + Eigen::Map out(cluster.data, vertical_d, num_embeddings); + out.noalias() = in.leftCols(vertical_d).transpose(); + + float* horizontal_out = cluster.data + num_embeddings * vertical_d; + for (size_t j = 0; j < horizontal_d; j += PDX::H_DIM_SIZE) { + Eigen::Map out_h(horizontal_out, num_embeddings, PDX::H_DIM_SIZE); + out_h.noalias() = in.block(0, vertical_d + j, num_embeddings, PDX::H_DIM_SIZE); + horizontal_out += num_embeddings * PDX::H_DIM_SIZE; + } +} + +template <> +inline void StoreClusterEmbeddings( + PDX::IVF::cluster_t& cluster, + const PDX::IVF& index, + const uint8_t* const embeddings, + const size_t num_embeddings +) { + using u8_matrix_t = Eigen::Matrix; + using u8_v_matrix_t = + Eigen::Matrix; + using u8_h_matrix_t = Eigen::Matrix; + + const auto vertical_d = index.num_vertical_dimensions; + const auto horizontal_d = index.num_horizontal_dimensions; + + Eigen::Map in(embeddings, num_embeddings, index.num_dimensions); + + size_t dim = 0; + for (; dim + PDX::U8_INTERLEAVE_SIZE <= vertical_d; dim += PDX::U8_INTERLEAVE_SIZE) { + Eigen::Map out_v( + cluster.data + dim * num_embeddings, num_embeddings, PDX::U8_INTERLEAVE_SIZE + ); + out_v.noalias() = in.block(0, dim, num_embeddings, PDX::U8_INTERLEAVE_SIZE); + } + if (dim < vertical_d) { + auto remaining = static_cast(vertical_d - dim); + Eigen::Map out_v( + cluster.data + dim * num_embeddings, num_embeddings, remaining + ); + out_v.noalias() = in.block(0, dim, num_embeddings, remaining); + } + + uint8_t* horizontal_out = cluster.data + num_embeddings * vertical_d; + for (size_t j = 0; j < horizontal_d; j += PDX::H_DIM_SIZE) { + Eigen::Map out_h(horizontal_out, num_embeddings, PDX::H_DIM_SIZE); + out_h.noalias() = in.block(0, vertical_d + j, num_embeddings, PDX::H_DIM_SIZE); + horizontal_out += num_embeddings * PDX::H_DIM_SIZE; + } +} + +} // namespace PDX diff --git a/include/pdx/lib/lib.hpp b/include/pdx/lib/lib.hpp new file mode 100644 index 0000000..b6c2cef --- /dev/null +++ b/include/pdx/lib/lib.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "pdx/index.hpp" + +namespace py = pybind11; + +namespace PDX { + +inline DistanceMetric ToDistanceMetric(uint8_t metric) { + switch (metric) { + case 0: + return DistanceMetric::L2SQ; + case 1: + return DistanceMetric::COSINE; + case 2: + return DistanceMetric::IP; + default: + throw std::runtime_error("Unknown distance metric: " + std::to_string(metric)); + } +} + +class PyPDXIndex { + std::unique_ptr index; + + PyPDXIndex() = default; + + public: + PyPDXIndex( + const std::string& index_type, + uint32_t num_dimensions, + uint8_t distance_metric, + uint32_t seed, + uint32_t num_clusters, + uint32_t num_meso_clusters, + bool normalize, + float sampling_fraction, + uint32_t kmeans_iters, + bool hierarchical_indexing, + uint32_t n_threads + ) { + PDXIndexConfig config{ + .num_dimensions = num_dimensions, + .distance_metric = ToDistanceMetric(distance_metric), + .seed = seed, + .num_clusters = num_clusters, + .num_meso_clusters = num_meso_clusters, + .normalize = normalize, + .sampling_fraction = sampling_fraction, + .kmeans_iters = kmeans_iters, + .hierarchical_indexing = hierarchical_indexing, + .n_threads = n_threads, + }; + if (index_type == "pdx_f32") { + index = std::make_unique(config); + } else if (index_type == "pdx_u8") { + index = std::make_unique(config); + } else if (index_type == "pdx_tree_f32") { + index = std::make_unique(config); + } else if (index_type == "pdx_tree_u8") { + index = std::make_unique(config); + } else { + throw std::runtime_error( + "Unknown index type: " + index_type + + ". Valid types: pdx_f32, pdx_u8, pdx_tree_f32, pdx_tree_u8" + ); + } + } + + static PyPDXIndex LoadFromFile(const std::string& path) { + PyPDXIndex self; + self.index = PDX::LoadPDXIndex(path); + return self; + } + + void BuildIndex(const py::array_t& data) { + auto buf = data.request(); + if (buf.ndim != 2) { + throw std::runtime_error("data must be a 2D numpy array (n_embeddings x dimensions)"); + } + auto* ptr = static_cast(buf.ptr); + size_t n = static_cast(buf.shape[0]); + index->BuildIndex(ptr, n); + } + + std::pair, py::array_t> Search( + const py::array_t& query, + uint32_t knn + ) const { + auto buf = query.request(); + if (buf.ndim != 1) { + throw std::runtime_error("query must be a 1D numpy array"); + } + auto* ptr = static_cast(buf.ptr); + auto results = index->Search(ptr, knn); + size_t n = results.size(); + py::array_t ids(n); + py::array_t distances(n); + auto ids_ptr = ids.mutable_unchecked<1>(); + auto distances_ptr = distances.mutable_unchecked<1>(); + for (size_t i = 0; i < n; ++i) { + ids_ptr(i) = results[i].index; + distances_ptr(i) = results[i].distance; + } + return {ids, distances}; + } + + std::pair, py::array_t> FilteredSearch( + const py::array_t& query, + uint32_t knn, + const py::array_t& row_ids + ) const { + auto query_buf = query.request(); + if (query_buf.ndim != 1) { + throw std::runtime_error("query must be a 1D numpy array"); + } + auto row_ids_buf = row_ids.request(); + if (row_ids_buf.ndim != 1) { + throw std::runtime_error("row_ids must be a 1D numpy array"); + } + auto* query_ptr = static_cast(query_buf.ptr); + auto* row_ids_ptr = static_cast(row_ids_buf.ptr); + size_t n_row_ids = static_cast(row_ids_buf.shape[0]); + std::vector passing_row_ids(row_ids_ptr, row_ids_ptr + n_row_ids); + auto results = index->FilteredSearch(query_ptr, knn, passing_row_ids); + size_t n = results.size(); + py::array_t ids(n); + py::array_t distances(n); + auto ids_ptr = ids.mutable_unchecked<1>(); + auto distances_ptr = distances.mutable_unchecked<1>(); + for (size_t i = 0; i < n; ++i) { + ids_ptr(i) = results[i].index; + distances_ptr(i) = results[i].distance; + } + return {ids, distances}; + } + + void SetNProbe(uint32_t n) const { index->SetNProbe(n); } + + void Save(const std::string& path) const { index->Save(path); } + + uint32_t GetNumDimensions() const { return index->GetNumDimensions(); } + + uint32_t GetNumClusters() const { return index->GetNumClusters(); } + + uint32_t GetClusterSize(uint32_t cluster_id) const { return index->GetClusterSize(cluster_id); } + + py::array_t GetClusterRowIds(uint32_t cluster_id) const { + auto ids = index->GetClusterRowIds(cluster_id); + py::array_t result(ids.size()); + auto ptr = result.mutable_unchecked<1>(); + for (size_t i = 0; i < ids.size(); ++i) { + ptr(i) = ids[i]; + } + return result; + } + + size_t GetInMemorySizeInBytes() const { return index->GetInMemorySizeInBytes(); } +}; + +} // namespace PDX diff --git a/include/pdx/pruners/adsampling.hpp b/include/pdx/pruners/adsampling.hpp new file mode 100644 index 0000000..10e001a --- /dev/null +++ b/include/pdx/pruners/adsampling.hpp @@ -0,0 +1,249 @@ +#pragma once + +#include "pdx/common.hpp" +#include "pdx/distance_computers/base_computers.hpp" +#include +#include +#include +#include + +#ifdef HAS_FFTW +#include +#endif + +namespace PDX { + +class ADSamplingPruner { + using matrix_t = eigen_matrix_t; + using flip_sign_fn = DistanceComputer; + + public: + const uint32_t num_dimensions; + + ADSamplingPruner(const uint32_t num_dimensions, const int32_t seed) + : num_dimensions(num_dimensions) { + ratios.resize(num_dimensions); + for (size_t i = 0; i < num_dimensions; ++i) { + ratios[i] = GetRatio(i); + } + std::mt19937 gen(seed); + bool matrix_created = false; +#ifdef HAS_FFTW + if (UsesDCTRotation()) { + fftwf_init_threads(); + matrix.resize(1, num_dimensions); + std::uniform_int_distribution dist(0, 1); + for (size_t i = 0; i < num_dimensions; ++i) { + matrix(i) = dist(gen) ? 1.0f : -1.0f; + } + BuildFlipMasks(); + CacheSingleQueryPlan(); + matrix_created = true; + } +#endif + if (!matrix_created) { + std::normal_distribution normal_dist; + Eigen::MatrixXf random_matrix = Eigen::MatrixXf::Zero( + static_cast(num_dimensions), static_cast(num_dimensions) + ); + for (size_t i = 0; i < num_dimensions; ++i) { + for (size_t j = 0; j < num_dimensions; ++j) { + random_matrix(static_cast(i), static_cast(j)) = + normal_dist(gen); + } + } + const Eigen::HouseholderQR qr(random_matrix); + matrix = qr.householderQ() * Eigen::MatrixXf::Identity(num_dimensions, num_dimensions); + } + } + + ADSamplingPruner(const uint32_t num_dimensions, const float* matrix_p) + : num_dimensions(num_dimensions) { + ratios.resize(num_dimensions); + for (size_t i = 0; i < num_dimensions; ++i) { + ratios[i] = GetRatio(i); + } +#ifdef HAS_FFTW + if (UsesDCTRotation()) { + fftwf_init_threads(); + matrix = Eigen::Map(matrix_p, 1, num_dimensions); + BuildFlipMasks(); + CacheSingleQueryPlan(); + } else { + matrix = Eigen::Map(matrix_p, num_dimensions, num_dimensions); + } +#else + matrix = Eigen::Map(matrix_p, num_dimensions, num_dimensions); +#endif + } + + void SetPruningAggresiveness(const float pruning_aggressiveness) { + ADSamplingPruner::pruning_aggressiveness = pruning_aggressiveness; + for (size_t i = 0; i < num_dimensions; ++i) { + ratios[i] = GetRatio(i); + } + } + + void SetMatrix(const Eigen::MatrixXf& matrix) { ADSamplingPruner::matrix = matrix; } + + const matrix_t& GetMatrix() const { return matrix; } + + float GetPruningThreshold( + uint32_t, + std::priority_queue, VectorComparator>& heap, + const uint32_t current_dimension_idx + ) const { + float ratio = current_dimension_idx == num_dimensions ? 1 : ratios[current_dimension_idx]; + return heap.top().distance * ratio; + } + + void PreprocessQuery( + const float* PDX_RESTRICT const raw_query_embedding, + float* PDX_RESTRICT const output_query_embedding + ) const { + PreprocessEmbeddings(raw_query_embedding, output_query_embedding, 1); + } + + void PreprocessEmbeddings( + const float* PDX_RESTRICT const input_embeddings, + float* PDX_RESTRICT const output_embeddings, + const size_t num_embeddings + ) const { + Rotate(input_embeddings, output_embeddings, num_embeddings); + } + + ~ADSamplingPruner() { +#ifdef HAS_FFTW + if (single_query_plan) { + fftwf_destroy_plan(single_query_plan); + } +#endif + } + + ADSamplingPruner(const ADSamplingPruner&) = delete; + ADSamplingPruner& operator=(const ADSamplingPruner&) = delete; + + private: + float pruning_aggressiveness = ADSAMPLING_PRUNING_AGGRESIVENESS; + matrix_t matrix; + std::vector ratios; + std::vector flip_masks; +#ifdef HAS_FFTW + fftwf_plan single_query_plan = nullptr; +#endif + + bool UsesDCTRotation() const { +#ifdef HAS_FFTW +#ifdef __AVX2__ + return num_dimensions >= D_THRESHOLD_FOR_DCT_ROTATION && IsPowerOf2(num_dimensions); +#else + return num_dimensions >= D_THRESHOLD_FOR_DCT_ROTATION; +#endif +#else + return false; +#endif + } + + float GetRatio(const size_t& visited_dimensions) const { + if (visited_dimensions == 0) { + return 1; + } + if (visited_dimensions == num_dimensions) { + return 1.0; + } + return static_cast(visited_dimensions) / num_dimensions * + (1.0 + pruning_aggressiveness / std::sqrt(visited_dimensions)) * + (1.0 + pruning_aggressiveness / std::sqrt(visited_dimensions)); + } + + void BuildFlipMasks() { + flip_masks.resize(num_dimensions); + for (size_t i = 0; i < num_dimensions; ++i) { + flip_masks[i] = (matrix(i) < 0.0f ? 0x80000000u : 0u); + } + } + +#ifdef HAS_FFTW + void CacheSingleQueryPlan() { + fftwf_plan_with_nthreads(1); + std::unique_ptr tmp(new float[num_dimensions]); + single_query_plan = + fftwf_plan_r2r_1d(num_dimensions, tmp.get(), tmp.get(), FFTW_REDFT10, FFTW_ESTIMATE); + } +#endif + + void FlipSign(const float* data, float* out, const size_t n) const { + if (n <= 1) { + for (size_t i = 0; i < n; ++i) { + const size_t offset = i * num_dimensions; + flip_sign_fn::FlipSign( + data + offset, out + offset, flip_masks.data(), num_dimensions + ); + } + return; + } +#pragma omp parallel for num_threads(PDX::g_n_threads) + for (size_t i = 0; i < n; ++i) { + const size_t offset = i * num_dimensions; + flip_sign_fn::FlipSign(data + offset, out + offset, flip_masks.data(), num_dimensions); + } + } + + void Rotate( + const float* PDX_RESTRICT const embeddings, + float* PDX_RESTRICT const out_buffer, + const size_t n + ) const { +#ifdef HAS_FFTW + if (UsesDCTRotation()) { + Eigen::Map out(out_buffer, n, num_dimensions); + FlipSign(embeddings, out_buffer, n); + const float s0 = std::sqrt(1.0f / (4.0f * num_dimensions)); + const float s = std::sqrt(1.0f / (2.0f * num_dimensions)); + if (n == 1) { + fftwf_execute_r2r(single_query_plan, out.data(), out.data()); + } else { + int n0 = static_cast(num_dimensions); + int howmany = static_cast(n); + fftw_r2r_kind kind[1] = {FFTW_REDFT10}; + auto flag = FFTW_MEASURE; + if (IsPowerOf2(num_dimensions)) { + flag = FFTW_ESTIMATE; + } + fftwf_plan_with_nthreads(static_cast(PDX::g_n_threads)); + fftwf_plan plan = fftwf_plan_many_r2r( + 1, &n0, howmany, out.data(), NULL, 1, n0, out.data(), NULL, 1, n0, kind, flag + ); + fftwf_execute(plan); + fftwf_destroy_plan(plan); + } + out.col(0) *= s0; + out.rightCols(num_dimensions - 1) *= s; + return; + } +#endif + const char trans_a = 'N'; + const char trans_b = 'N'; + const float alpha = 1.0f; + const float beta = 0.0f; + int dim = static_cast(num_dimensions); + int n_blas = static_cast(n); + sgemm_( + &trans_a, + &trans_b, + &dim, + &n_blas, + &dim, + &alpha, + matrix.data(), + &dim, + embeddings, + &dim, + &beta, + out_buffer, + &dim + ); + } +}; + +} // namespace PDX diff --git a/include/pdx/quantizers/scalar.hpp b/include/pdx/quantizers/scalar.hpp new file mode 100644 index 0000000..e37ad10 --- /dev/null +++ b/include/pdx/quantizers/scalar.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include "pdx/common.hpp" +#include +#include +#include +#include + +namespace PDX { + +struct ScalarQuantizationParams { + float quantization_base; + float quantization_scale; +}; + +class Quantizer { + + public: + explicit Quantizer(size_t num_dimensions) : num_dimensions(num_dimensions) {} + virtual ~Quantizer() = default; + + public: + void NormalizeQuery(const float* src, float* out) const { + float sum = 0.0f; +#pragma clang loop vectorize(enable) + for (size_t i = 0; i < num_dimensions; ++i) { + sum += src[i] * src[i]; + } + + if (sum == 0.0f) { + return; + } + + // float inverse_norm = 1.0f / std::sqrt(sum); + float norm = std::sqrt(sum); +#pragma clang loop vectorize(enable) + for (size_t i = 0; i < num_dimensions; ++i) { + out[i] = src[i] / norm; // * inverse_norm; + } + } + + const size_t num_dimensions; +}; + +template +class ScalarQuantizer : public Quantizer { + public: + using quantized_embedding_t = pdx_quantized_embedding_t; + + explicit ScalarQuantizer(size_t num_dimensions) : Quantizer(num_dimensions) {} + +#ifdef __AVX512F__ + // TODO(@lkuffo, low): We rely on _mm512_dpbusds_epi32 that has asymmetric operands + // However, this rarely happens because of the subtraction of L2SQ distance. + static constexpr uint8_t MAX_VALUE = 255; +#else + static constexpr uint8_t MAX_VALUE = 255; +#endif + + static ScalarQuantizationParams ComputeQuantizationParams( + const float* embeddings, + const size_t total_elements + ) { + float global_min = std::numeric_limits::max(); + float global_max = std::numeric_limits::lowest(); +#pragma omp parallel for reduction(min : global_min) reduction(max : global_max) \ + num_threads(PDX::g_n_threads) + for (size_t i = 0; i < total_elements; ++i) { + global_min = std::min(global_min, embeddings[i]); + global_max = std::max(global_max, embeddings[i]); + } + const float range = global_max - global_min; + return {global_min, (range > 0) ? static_cast(MAX_VALUE) / range : 1.0f}; + } + + void QuantizeEmbedding( + const float* embedding, + const float quantization_base, + const float quantization_scale, + quantized_embedding_t* output_quantized_embedding + ) { + for (size_t i = 0; i < num_dimensions; ++i) { + const int rounded = + static_cast(std::round((embedding[i] - quantization_base) * quantization_scale) + ); + if (PDX_UNLIKELY(rounded > MAX_VALUE)) { + output_quantized_embedding[i] = MAX_VALUE; + } else if (PDX_UNLIKELY(rounded < 0)) { + output_quantized_embedding[i] = 0; + } else { + output_quantized_embedding[i] = static_cast(rounded); + } + } + } +}; + +} // namespace PDX diff --git a/include/pdx/searcher.hpp b/include/pdx/searcher.hpp new file mode 100644 index 0000000..4cf9b4f --- /dev/null +++ b/include/pdx/searcher.hpp @@ -0,0 +1,817 @@ +#pragma once + +#include "pdx/common.hpp" +#include "pdx/db_mock/predicate_evaluator.hpp" +#include "pdx/distance_computers/base_computers.hpp" +#include "pdx/ivf_wrapper.hpp" +#include "pdx/pruners/adsampling.hpp" +#include "pdx/quantizers/scalar.hpp" +#include "pdx/utils.hpp" +#include +#include +#include +#include +#include +#include + +namespace PDX { + +template < + Quantization Q = F32, + class Index = IVF, + class Quantizer = ScalarQuantizer, + DistanceMetric alpha = DistanceMetric::L2SQ, + class Pruner = ADSamplingPruner> +class PDXearch { + public: + using distance_t = pdx_distance_t; + using data_t = pdx_data_t; + using quantized_embedding_t = pdx_quantized_embedding_t; + using index_t = Index; + using cluster_t = Cluster; + using distance_computer_t = DistanceComputer; + + Quantizer quantizer; + Pruner& pruner; + index_t& pdx_data; + + PDXearch(index_t& data_index, Pruner& pruner) + : quantizer(data_index.num_dimensions), pruner(pruner), pdx_data(data_index), + cluster_offsets(new size_t[data_index.num_clusters]) { + for (size_t i = 0; i < data_index.num_clusters; ++i) { + cluster_offsets[i] = total_embeddings; + total_embeddings += data_index.clusters[i].num_embeddings; + max_cluster_size = std::max( + max_cluster_size, static_cast(data_index.clusters[i].num_embeddings) + ); + } + } + + void SetNProbe(size_t nprobe) { ivf_nprobe = nprobe; } + + size_t GetNProbe() const { return ivf_nprobe; } + + void SetClusterAccessOrder(const std::vector& cluster_indexes) { + cluster_access_order_size = cluster_indexes.size(); + cluster_indices_in_access_order.reset(new uint32_t[cluster_indexes.size()]); + std::copy( + cluster_indexes.begin(), cluster_indexes.end(), cluster_indices_in_access_order.get() + ); + } + + std::unique_ptr cluster_offsets; + + protected: + float selectivity_threshold = 0.80; + size_t ivf_nprobe = 0; + + size_t total_embeddings{0}; + size_t max_cluster_size{0}; + + // Prioritized list of indices of the clusters to probe. E.g., [0, 2, 1]. + std::unique_ptr cluster_indices_in_access_order; + size_t cluster_access_order_size = 0; + + // Start: State for the current filtered search. + uint32_t k = 0; + quantized_embedding_t* prepared_query = nullptr; + // Predicate evaluator for this rowgroup. + std::unique_ptr predicate_evaluator; + + void ResetPruningDistances(size_t n_vectors, distance_t* pruning_distances) { + std::fill(pruning_distances, pruning_distances + n_vectors, distance_t{0}); + } + + // The pruning threshold by default is the top of the heap + void GetPruningThreshold( + uint32_t k, + std::priority_queue, VectorComparator>& heap, + distance_t& pruning_threshold, + uint32_t current_dimension_idx + ) { + const float float_threshold = pruner.GetPruningThreshold(k, heap, current_dimension_idx); + if constexpr (Q == U8) { + // We need to avoid undefined behaviour when overflow happens + const float scaled = float_threshold * pdx_data.quantization_scale_squared; + pruning_threshold = scaled >= static_cast(std::numeric_limits::max()) + ? std::numeric_limits::max() + : static_cast(scaled); + } else { + pruning_threshold = float_threshold; + } + }; + + void EvaluatePruningPredicateScalar( + uint32_t& n_pruned, + size_t n_vectors, + distance_t* pruning_distances, + const distance_t pruning_threshold + ) { + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + n_pruned += pruning_distances[vector_idx] >= pruning_threshold; + } + }; + + void EvaluatePruningPredicateOnPositionsArray( + size_t n_vectors, + size_t& n_vectors_not_pruned, + uint32_t* pruning_positions, + distance_t pruning_threshold, + distance_t* pruning_distances + ) { + n_vectors_not_pruned = 0; + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + pruning_positions[n_vectors_not_pruned] = pruning_positions[vector_idx]; + n_vectors_not_pruned += + pruning_distances[pruning_positions[vector_idx]] < pruning_threshold; + } + }; + + template + void InitPositionsArray( + size_t n_vectors, + size_t& n_vectors_not_pruned, + uint32_t* pruning_positions, + distance_t pruning_threshold, + distance_t* pruning_distances, + const uint8_t* selection_vector = nullptr + ) { + n_vectors_not_pruned = 0; + if constexpr (IS_FILTERED) { + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + pruning_positions[n_vectors_not_pruned] = vector_idx; + n_vectors_not_pruned += (pruning_distances[vector_idx] < pruning_threshold) && + (selection_vector[vector_idx] == 1); + } + } else { + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + pruning_positions[n_vectors_not_pruned] = vector_idx; + n_vectors_not_pruned += pruning_distances[vector_idx] < pruning_threshold; + } + } + }; + + void InitPositionsArrayFromSelectionVector( + size_t n_vectors, + size_t& n_vectors_not_pruned, + uint32_t* pruning_positions, + const uint8_t* selection_vector + ) { + n_vectors_not_pruned = 0; + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + pruning_positions[n_vectors_not_pruned] = vector_idx; + n_vectors_not_pruned += selection_vector[vector_idx] == 1; + } + }; + + void MaskDistancesWithSelectionVector( + size_t n_vectors, + distance_t* pruning_distances, + const uint8_t* selection_vector + ) { + for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { + if (selection_vector[vector_idx] == 0) { + // Why max()/2? To prevent overflow if distances are still added to these + pruning_distances[vector_idx] = std::numeric_limits::max() / 2; + } + } + }; + + static void GetClustersAccessOrderIVF( + const float* PDX_RESTRICT query, + const index_t& data, + size_t nprobe, + uint32_t* clusters_indices + ) { + std::unique_ptr distances_to_centroids(new float[data.num_clusters]); + for (size_t cluster_idx = 0; cluster_idx < data.num_clusters; cluster_idx++) { + distances_to_centroids[cluster_idx] = + DistanceComputer::Horizontal( + query, + data.centroids.data() + cluster_idx * data.num_dimensions, + data.num_dimensions + ); + } + std::iota(clusters_indices, clusters_indices + data.num_clusters, static_cast(0)); + if (nprobe >= data.num_clusters) { + std::sort( + clusters_indices, + clusters_indices + data.num_clusters, + [&distances_to_centroids](size_t i1, size_t i2) { + return distances_to_centroids[i1] < distances_to_centroids[i2]; + } + ); + } else { + std::partial_sort( + clusters_indices, + clusters_indices + static_cast(nprobe), + clusters_indices + data.num_clusters, + [&distances_to_centroids](size_t i1, size_t i2) { + return distances_to_centroids[i1] < distances_to_centroids[i2]; + } + ); + } + } + + // On the first bucket, we do a full scan (we do not prune vectors) + void Start( + const quantized_embedding_t* PDX_RESTRICT query, + const data_t* data, + const size_t n_vectors, + uint32_t k, + const uint32_t* vector_indices, + uint32_t* pruning_positions, + distance_t* pruning_distances, + std::priority_queue, VectorComparator>& heap + ) { + ResetPruningDistances(n_vectors, pruning_distances); + distance_computer_t::Vertical( + query, + data, + n_vectors, + n_vectors, + 0, + pdx_data.num_vertical_dimensions, + pruning_distances, + pruning_positions + ); + for (size_t horizontal_dimension = 0; + horizontal_dimension < pdx_data.num_horizontal_dimensions; + horizontal_dimension += H_DIM_SIZE) { + for (size_t vector_idx = 0; vector_idx < n_vectors; vector_idx++) { + size_t data_pos = (pdx_data.num_vertical_dimensions * n_vectors) + + (horizontal_dimension * n_vectors) + (vector_idx * H_DIM_SIZE); + pruning_distances[vector_idx] += distance_computer_t::Horizontal( + query + pdx_data.num_vertical_dimensions + horizontal_dimension, + data + data_pos, + H_DIM_SIZE + ); + } + } + size_t max_possible_k = std::min( + static_cast(k) - heap.size(), + n_vectors + ); // Note: Start() should not be called if heap.size() >= k + std::unique_ptr indices_sorted(new size_t[n_vectors]); + std::iota(indices_sorted.get(), indices_sorted.get() + n_vectors, static_cast(0)); + std::partial_sort( + indices_sorted.get(), + indices_sorted.get() + static_cast(max_possible_k), + indices_sorted.get() + n_vectors, + [pruning_distances](size_t i1, size_t i2) { + return pruning_distances[i1] < pruning_distances[i2]; + } + ); + // insert first k results into the heap + for (size_t idx = 0; idx < max_possible_k; ++idx) { + auto embedding = KNNCandidate{}; + size_t index = indices_sorted[idx]; + embedding.index = vector_indices[index]; + embedding.distance = static_cast(pruning_distances[index]); + if constexpr (Q == U8) { + embedding.distance *= pdx_data.inverse_quantization_scale_squared; + } + heap.push(embedding); + } + } + + // On the first bucket, we do a full scan (we do not prune vectors) + void FilteredStart( + const quantized_embedding_t* PDX_RESTRICT query, + const data_t* data, + const size_t n_vectors, + uint32_t k, + const uint32_t* vector_indices, + uint32_t* pruning_positions, + distance_t* pruning_distances, + std::priority_queue, VectorComparator>& heap, + uint8_t* selection_vector, + uint32_t passing_tuples + ) { + ResetPruningDistances(n_vectors, pruning_distances); + size_t n_vectors_not_pruned = 0; + float selection_percentage = + (static_cast(passing_tuples) / static_cast(n_vectors)); + InitPositionsArrayFromSelectionVector( + n_vectors, n_vectors_not_pruned, pruning_positions, selection_vector + ); + // Always start with horizontal block, regardless of selectivity + for (size_t horizontal_dimension = 0; + horizontal_dimension < pdx_data.num_horizontal_dimensions; + horizontal_dimension += H_DIM_SIZE) { + size_t offset_data = + (pdx_data.num_vertical_dimensions * n_vectors) + (horizontal_dimension * n_vectors); + for (size_t vector_idx = 0; vector_idx < n_vectors_not_pruned; vector_idx++) { + size_t v_idx = pruning_positions[vector_idx]; + size_t data_pos = offset_data + (v_idx * H_DIM_SIZE); + pruning_distances[v_idx] += distance_computer_t::Horizontal( + query + pdx_data.num_vertical_dimensions + horizontal_dimension, + data + data_pos, + H_DIM_SIZE + ); + } + } + if (selection_percentage > (1 - selectivity_threshold)) { + // It is then faster to do the full scan (thanks to SIMD) + distance_computer_t::Vertical( + query, + data, + n_vectors, + n_vectors, + 0, + pdx_data.num_vertical_dimensions, + pruning_distances, + pruning_positions + ); + } else { + // We access individual values + distance_computer_t::VerticalPruning( + query, + data, + n_vectors_not_pruned, + n_vectors, + 0, + pdx_data.num_vertical_dimensions, + pruning_distances, + pruning_positions + ); + } + // TODO: Everything down from here is a bottleneck when selection % is ultra low + size_t max_possible_k = + std::min(static_cast(k) - heap.size(), static_cast(passing_tuples)); + MaskDistancesWithSelectionVector(n_vectors, pruning_distances, selection_vector); + std::unique_ptr indices_sorted(new size_t[n_vectors]); + std::iota(indices_sorted.get(), indices_sorted.get() + n_vectors, static_cast(0)); + std::partial_sort( + indices_sorted.get(), + indices_sorted.get() + static_cast(max_possible_k), + indices_sorted.get() + n_vectors, + [pruning_distances](size_t i1, size_t i2) { + return pruning_distances[i1] < pruning_distances[i2]; + } + ); + // insert first k results into the heap + for (size_t idx = 0; idx < max_possible_k; ++idx) { + auto embedding = KNNCandidate{}; + size_t index = indices_sorted[idx]; + embedding.index = vector_indices[index]; + embedding.distance = static_cast(pruning_distances[index]); + if constexpr (Q == U8) { + embedding.distance *= pdx_data.inverse_quantization_scale_squared; + } + heap.push(embedding); + } + } + + // On the warmup phase, we keep scanning dimensions until the amount of not-yet pruned vectors + // is low + template + void Warmup( + const quantized_embedding_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + const size_t n_vectors, + uint32_t k, + float tuples_threshold, + uint32_t* pruning_positions, + distance_t* pruning_distances, + distance_t& pruning_threshold, + std::priority_queue, VectorComparator>& heap, + uint32_t& current_dimension_idx, + size_t& n_vectors_not_pruned, + uint32_t passing_tuples = 0, + uint8_t* selection_vector = nullptr + ) { + current_dimension_idx = 0; + size_t cur_subgrouping_size_idx = 0; + size_t tuples_needed_to_exit = + static_cast(std::ceil(tuples_threshold * static_cast(n_vectors))); + ResetPruningDistances(n_vectors, pruning_distances); + uint32_t n_tuples_to_prune = 0; + if constexpr (FILTERED) { + float selection_percentage = + (static_cast(passing_tuples) / static_cast(n_vectors)); + MaskDistancesWithSelectionVector(n_vectors, pruning_distances, selection_vector); + if (selection_percentage < (1 - tuples_threshold)) { + // Go directly to the PRUNE phase for direct tuples access in the Horizontal block + return; + } + } + GetPruningThreshold(k, heap, pruning_threshold, current_dimension_idx); + while (n_tuples_to_prune < tuples_needed_to_exit && + current_dimension_idx < pdx_data.num_vertical_dimensions) { + size_t last_dimension_to_fetch = std::min( + current_dimension_idx + DIMENSIONS_FETCHING_SIZES[cur_subgrouping_size_idx], + pdx_data.num_vertical_dimensions + ); + distance_computer_t::Vertical( + query, + data, + n_vectors, + n_vectors, + current_dimension_idx, + last_dimension_to_fetch, + pruning_distances, + pruning_positions + ); + current_dimension_idx = last_dimension_to_fetch; + cur_subgrouping_size_idx += 1; + GetPruningThreshold(k, heap, pruning_threshold, current_dimension_idx); + n_tuples_to_prune = 0; + EvaluatePruningPredicateScalar( + n_tuples_to_prune, n_vectors, pruning_distances, pruning_threshold + ); + } + } + + // We scan only the not-yet pruned vectors + template + void Prune( + const quantized_embedding_t* PDX_RESTRICT query, + const data_t* PDX_RESTRICT data, + const size_t n_vectors, + uint32_t k, + uint32_t* pruning_positions, + distance_t* pruning_distances, + distance_t& pruning_threshold, + std::priority_queue, VectorComparator>& heap, + uint32_t& current_dimension_idx, + size_t& n_vectors_not_pruned, + const uint8_t* selection_vector = nullptr + ) { + GetPruningThreshold(k, heap, pruning_threshold, current_dimension_idx); + InitPositionsArray( + n_vectors, + n_vectors_not_pruned, + pruning_positions, + pruning_threshold, + pruning_distances, + selection_vector + ); + size_t cur_n_vectors_not_pruned = 0; + size_t current_vertical_dimension = current_dimension_idx; + size_t current_horizontal_dimension = 0; + while (pdx_data.num_horizontal_dimensions && n_vectors_not_pruned && + current_horizontal_dimension < pdx_data.num_horizontal_dimensions) { + cur_n_vectors_not_pruned = n_vectors_not_pruned; + size_t offset_data = (pdx_data.num_vertical_dimensions * n_vectors) + + (current_horizontal_dimension * n_vectors); + for (size_t vector_idx = 0; vector_idx < n_vectors_not_pruned; vector_idx++) { + size_t v_idx = pruning_positions[vector_idx]; + size_t data_pos = offset_data + (v_idx * H_DIM_SIZE); + __builtin_prefetch(data + data_pos, 0, 3); + } + size_t offset_query = pdx_data.num_vertical_dimensions + current_horizontal_dimension; + for (size_t vector_idx = 0; vector_idx < n_vectors_not_pruned; vector_idx++) { + size_t v_idx = pruning_positions[vector_idx]; + size_t data_pos = offset_data + (v_idx * H_DIM_SIZE); + pruning_distances[v_idx] += distance_computer_t::Horizontal( + query + offset_query, data + data_pos, H_DIM_SIZE + ); + } + // end of clipping + current_horizontal_dimension += H_DIM_SIZE; + current_dimension_idx += H_DIM_SIZE; + GetPruningThreshold(k, heap, pruning_threshold, current_dimension_idx); + assert( + current_dimension_idx == current_vertical_dimension + current_horizontal_dimension + ); + EvaluatePruningPredicateOnPositionsArray( + cur_n_vectors_not_pruned, + n_vectors_not_pruned, + pruning_positions, + pruning_threshold, + pruning_distances + ); + } + // GO THROUGH THE REST IN THE VERTICAL + while (n_vectors_not_pruned && current_vertical_dimension < pdx_data.num_vertical_dimensions + ) { + cur_n_vectors_not_pruned = n_vectors_not_pruned; + size_t last_dimension_to_test_idx = std::min( + current_vertical_dimension + H_DIM_SIZE, + static_cast(pdx_data.num_vertical_dimensions) + ); + distance_computer_t::VerticalPruning( + query, + data, + cur_n_vectors_not_pruned, + n_vectors, + current_vertical_dimension, + last_dimension_to_test_idx, + pruning_distances, + pruning_positions + ); + current_dimension_idx = std::min( + current_dimension_idx + H_DIM_SIZE, static_cast(pdx_data.num_dimensions) + ); + current_vertical_dimension = std::min( + static_cast(current_vertical_dimension + H_DIM_SIZE), + pdx_data.num_vertical_dimensions + ); + assert( + current_dimension_idx == current_vertical_dimension + current_horizontal_dimension + ); + GetPruningThreshold(k, heap, pruning_threshold, current_dimension_idx); + EvaluatePruningPredicateOnPositionsArray( + cur_n_vectors_not_pruned, + n_vectors_not_pruned, + pruning_positions, + pruning_threshold, + pruning_distances + ); + if (current_dimension_idx == pdx_data.num_dimensions) { + break; + } + } + } + + void MergeIntoHeap( + const uint32_t* vector_indices, + const size_t n_vectors, + const uint32_t k, + const uint32_t* pruning_positions, + const distance_t* pruning_distances, + std::priority_queue, VectorComparator>& heap + ) { + for (size_t position_idx = 0; position_idx < n_vectors; ++position_idx) { + const size_t index = pruning_positions[position_idx]; + float current_distance = static_cast(pruning_distances[index]); + if constexpr (Q == U8) { + current_distance *= pdx_data.inverse_quantization_scale_squared; + } + if (heap.size() < k || current_distance < heap.top().distance) { + KNNCandidate embedding{}; + embedding.distance = current_distance; + embedding.index = vector_indices[index]; + if (heap.size() >= k) { + heap.pop(); + } + heap.push(embedding); + } + } + } + + [[nodiscard]] static std::vector BuildResultSetFromHeap( + uint32_t k, + std::priority_queue, VectorComparator>& heap + ) { + // Pop the initialization element from the heap, as it can't be part of the result. + if (!heap.empty() && heap.top().distance == std::numeric_limits::max()) { + heap.pop(); + } + + size_t result_set_size = std::min(heap.size(), static_cast(k)); + std::vector result; + result.resize(result_set_size); + for (size_t i = result_set_size; i > 0; --i) { + result[i - 1] = heap.top(); + heap.pop(); + } + return result; + } + + void GetClustersAccessOrderRandom() { + std::iota( + cluster_indices_in_access_order.get(), + cluster_indices_in_access_order.get() + pdx_data.num_clusters, + 0 + ); + } + + public: + /****************************************************************** + * Search methods + ******************************************************************/ + std::vector Search(const float* PDX_RESTRICT const raw_query, const uint32_t k) { + Heap local_heap{}; + std::unique_ptr query(new float[pdx_data.num_dimensions]); + if (!pdx_data.is_normalized) { + pruner.PreprocessQuery(raw_query, query.get()); + } else { + std::unique_ptr normalized_query(new float[pdx_data.num_dimensions]); + quantizer.NormalizeQuery(raw_query, normalized_query.get()); + pruner.PreprocessQuery(normalized_query.get(), query.get()); + } + size_t clusters_to_visit = (ivf_nprobe == 0 || ivf_nprobe > pdx_data.num_clusters) + ? pdx_data.num_clusters + : ivf_nprobe; + std::unique_ptr local_cluster_order(new uint32_t[pdx_data.num_clusters]); + if (cluster_indices_in_access_order) { + // We only enter here when access order was prioritized by calling + // SetClusterAccessOrder(), in which case we need to check that + // the clusters prioritized is not greater than clusters_to_visit + clusters_to_visit = std::min(clusters_to_visit, cluster_access_order_size); + std::copy( + cluster_indices_in_access_order.get(), + cluster_indices_in_access_order.get() + clusters_to_visit, + local_cluster_order.get() + ); + } else { + GetClustersAccessOrderIVF( + query.get(), pdx_data, clusters_to_visit, local_cluster_order.get() + ); + } + // PDXearch core + std::unique_ptr local_quantized_query( + new quantized_embedding_t[pdx_data.num_dimensions] + ); + quantized_embedding_t* local_prepared_query; + if constexpr (Q == U8) { + quantizer.QuantizeEmbedding( + query.get(), + pdx_data.quantization_base, + pdx_data.quantization_scale, + local_quantized_query.get() + ); + local_prepared_query = local_quantized_query.get(); + } else { + local_prepared_query = query.get(); + } + + std::unique_ptr pruning_distances(new distance_t[max_cluster_size]); + std::unique_ptr pruning_positions(new uint32_t[max_cluster_size]); + + for (size_t cluster_idx = 0; cluster_idx < clusters_to_visit; ++cluster_idx) { + distance_t pruning_threshold = std::numeric_limits::max(); + uint32_t current_dimension_idx = 0; + size_t n_vectors_not_pruned = 0; + + const size_t current_cluster_idx = local_cluster_order[cluster_idx]; + cluster_t& cluster = pdx_data.clusters[current_cluster_idx]; + if (cluster.num_embeddings == 0) { + continue; + } + if (local_heap.size() < k) { + // We cannot prune until we fill the heap + Start( + local_prepared_query, + cluster.data, + cluster.num_embeddings, + k, + cluster.indices, + pruning_positions.get(), + pruning_distances.get(), + local_heap + ); + continue; + } + Warmup( + local_prepared_query, + cluster.data, + cluster.num_embeddings, + k, + selectivity_threshold, + pruning_positions.get(), + pruning_distances.get(), + pruning_threshold, + local_heap, + current_dimension_idx, + n_vectors_not_pruned + ); + Prune( + local_prepared_query, + cluster.data, + cluster.num_embeddings, + k, + pruning_positions.get(), + pruning_distances.get(), + pruning_threshold, + local_heap, + current_dimension_idx, + n_vectors_not_pruned + ); + if (n_vectors_not_pruned) { + MergeIntoHeap( + cluster.indices, + n_vectors_not_pruned, + k, + pruning_positions.get(), + pruning_distances.get(), + local_heap + ); + } + } + std::vector result = BuildResultSetFromHeap(k, local_heap); + return result; + } + + std::vector FilteredSearch( + const float* PDX_RESTRICT const raw_query, + const uint32_t k, + const PredicateEvaluator& predicate_evaluator + ) { + Heap local_heap{}; + std::unique_ptr query(new float[pdx_data.num_dimensions]); + if (!pdx_data.is_normalized) { + pruner.PreprocessQuery(raw_query, query.get()); + } else { + std::unique_ptr normalized_query(new float[pdx_data.num_dimensions]); + quantizer.NormalizeQuery(raw_query, normalized_query.get()); + pruner.PreprocessQuery(normalized_query.get(), query.get()); + } + + size_t clusters_to_visit = (ivf_nprobe == 0 || ivf_nprobe > pdx_data.num_clusters) + ? pdx_data.num_clusters + : ivf_nprobe; + + std::unique_ptr local_cluster_order(new uint32_t[pdx_data.num_clusters]); + GetClustersAccessOrderIVF( + query.get(), pdx_data, clusters_to_visit, local_cluster_order.get() + ); + // PDXearch core + std::unique_ptr local_quantized_query( + new quantized_embedding_t[pdx_data.num_dimensions] + ); + quantized_embedding_t* local_prepared_query; + if constexpr (Q == U8) { + quantizer.QuantizeEmbedding( + query.get(), + pdx_data.quantization_base, + pdx_data.quantization_scale, + local_quantized_query.get() + ); + local_prepared_query = local_quantized_query.get(); + } else { + local_prepared_query = query.get(); + } + + std::unique_ptr pruning_distances(new distance_t[max_cluster_size]); + std::unique_ptr pruning_positions(new uint32_t[max_cluster_size]); + + for (size_t cluster_idx = 0; cluster_idx < clusters_to_visit; ++cluster_idx) { + distance_t pruning_threshold = std::numeric_limits::max(); + uint32_t current_dimension_idx = 0; + size_t n_vectors_not_pruned = 0; + + const size_t current_cluster_idx = local_cluster_order[cluster_idx]; + auto [selection_vector, passing_tuples] = predicate_evaluator.GetSelectionVector( + current_cluster_idx, cluster_offsets[current_cluster_idx] + ); + if (passing_tuples == 0) { + continue; + } + cluster_t& cluster = pdx_data.clusters[current_cluster_idx]; + if (cluster.num_embeddings == 0) { + continue; + } + if (local_heap.size() < k) { + // We cannot prune until we fill the heap + FilteredStart( + local_prepared_query, + cluster.data, + cluster.num_embeddings, + k, + cluster.indices, + pruning_positions.get(), + pruning_distances.get(), + local_heap, + selection_vector, + passing_tuples + ); + continue; + } + Warmup( + local_prepared_query, + cluster.data, + cluster.num_embeddings, + k, + selectivity_threshold, + pruning_positions.get(), + pruning_distances.get(), + pruning_threshold, + local_heap, + current_dimension_idx, + n_vectors_not_pruned, + passing_tuples, + selection_vector + ); + Prune( + local_prepared_query, + cluster.data, + cluster.num_embeddings, + k, + pruning_positions.get(), + pruning_distances.get(), + pruning_threshold, + local_heap, + current_dimension_idx, + n_vectors_not_pruned, + selection_vector + ); + if (n_vectors_not_pruned) { + MergeIntoHeap( + cluster.indices, + n_vectors_not_pruned, + k, + pruning_positions.get(), + pruning_distances.get(), + local_heap + ); + } + } + std::vector result = BuildResultSetFromHeap(k, local_heap); + return result; + } +}; + +} // namespace PDX diff --git a/include/utils/file_reader.hpp b/include/pdx/utils.hpp similarity index 61% rename from include/utils/file_reader.hpp rename to include/pdx/utils.hpp index 666ed06..292a4bc 100644 --- a/include/utils/file_reader.hpp +++ b/include/pdx/utils.hpp @@ -1,11 +1,9 @@ -#ifndef PDX_UTILS_HPP -#define PDX_UTILS_HPP +#pragma once #include -#include #include #include -#include +#include #include #include #include @@ -16,22 +14,18 @@ #include #endif -/****************************************************************** - * File reader - ******************************************************************/ inline std::unique_ptr MmapFile(const std::string& filename) { struct stat file_stats {}; int fd = ::open(filename.c_str(), O_RDONLY); - if (fd == -1) throw std::runtime_error("Failed to open file"); + if (fd == -1) + throw std::runtime_error("Failed to open file"); fstat(fd, &file_stats); size_t file_size = file_stats.st_size; - auto data = std::make_unique(file_size); + std::unique_ptr data(new char[file_size]); std::ifstream input(filename, std::ios::binary); input.read(data.get(), file_size); return data; } - -#endif //PDX_UTILS_HPP diff --git a/include/pdxearch.hpp b/include/pdxearch.hpp deleted file mode 100644 index bbafd1a..0000000 --- a/include/pdxearch.hpp +++ /dev/null @@ -1,971 +0,0 @@ -#ifndef PDX_PDXEARCH_HPP -#define PDX_PDXEARCH_HPP - -#include -#include -#include -#include -#include "common.hpp" -#include "db_mock/predicate_evaluator.hpp" -#include "utils/tictoc.hpp" -#include "distance_computers/base_computers.hpp" -#include "quantizers/global.h" -#include "index_base/pdx_ivf.hpp" -#include "index_base/pdx_ivf2.hpp" -#include "pruners/adsampling.hpp" -#include "pruners/bond.hpp" - -namespace PDX { - -/****************************************************************** - * PDXearch - * Implements our algorithm for vertical pruning - ******************************************************************/ -template< - Quantization q=F32, - class Index=IndexPDXIVF, - class Quantizer=Global8Quantizer, - DistanceFunction alpha=L2, - class Pruner=ADSamplingPruner -> -class PDXearch { -public: - using DISTANCES_TYPE = DistanceType_t; - using QUANTIZED_VECTOR_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - using INDEX_TYPE = Index; - using CLUSTER_TYPE = Cluster; - using KNNCandidate_t = KNNCandidate; - using VectorComparator_t = VectorComparator; - - Quantizer quantizer; - Pruner pruner; - INDEX_TYPE &pdx_data; - uint32_t current_dimension_idx {0}; - - PDXearch( - INDEX_TYPE &data_index, - Pruner &pruner, - int position_prune_distance, - DimensionsOrder dimension_order - ) : pdx_data(data_index), - pruner(pruner), - is_positional_pruning(position_prune_distance), - dimension_order(dimension_order){ - indices_dimensions.resize(pdx_data.num_dimensions); - clusters_indices.resize(pdx_data.num_clusters); - cluster_offsets.resize(pdx_data.num_clusters); - for (size_t i = 0; i < pdx_data.num_clusters; ++i){ - cluster_offsets[i] = total_embeddings; - total_embeddings += pdx_data.clusters[i].num_embeddings; - } - if constexpr(std::is_same_v>) { - pdx_data.num_horizontal_dimensions = 0; - pdx_data.num_vertical_dimensions = pdx_data.num_dimensions; - } - quantizer.SetD(pdx_data.num_dimensions); - } - - void SetNProbe(size_t nprobe){ - ivf_nprobe = nprobe; - } - - TicToc end_to_end_clock = TicToc(); - - void ResetClocks(){ - end_to_end_clock.Reset(); - } - -protected: - float selectivity_threshold = 0.80; - size_t ivf_nprobe = 0; - int is_positional_pruning = false; - size_t current_cluster = 0; - - DimensionsOrder dimension_order = SEQUENTIAL; - // Evaluating the pruning threshold is so fast that we can allow smaller fetching sizes - // to avoid more data access. Super useful in architectures with low bandwidth at L3/DRAM like Intel SPR - static constexpr uint32_t DIMENSIONS_FETCHING_SIZES[24] = { - 4, 4, 8, 8, 8, 16, 16, 32, 32, 32, 32, - 64, 64, 64, 64, 128, 128, 128, 128, 256, - 256, 512, 1024, 2048 - }; - - size_t H_DIM_SIZE = 64; - - size_t cur_subgrouping_size_idx {0}; - size_t total_embeddings {0}; - - std::vector indices_dimensions; - std::vector clusters_indices; - std::vector clusters_indices_l0; - std::vector cluster_offsets; - - size_t n_vectors_not_pruned = 0; - - DISTANCES_TYPE pruning_threshold = std::numeric_limits::max(); - DistanceType_t pruning_threshold_l0 = std::numeric_limits>::max(); - - // For pruning we do not use tight loops of 64. We know that tight loops bring benefits - // to the distance kernels (40% faster), however doing so + PRUNING in the tight block of 64 - // slightly reduces the performance of PDXearch. We are still investigating why. - static constexpr uint16_t PDX_VECTOR_SIZE = 64; - alignas(64) inline static DISTANCES_TYPE distances[PDX_VECTOR_SIZE]; // Used in full scans (no pruning) - alignas(64) inline static DISTANCES_TYPE pruning_distances[10240]; // TODO: Use dynamic arrays. Buckets with more than 10k vectors (rare) overflow - alignas(64) inline static uint32_t pruning_positions[10240]; - std::priority_queue, std::vector>, VectorComparator> best_k{}; - - alignas(64) inline static DistanceType_t centroids_distances[PDX_VECTOR_SIZE]; - alignas(64) inline static DistanceType_t pruning_distances_l0[10240]; - alignas(64) inline static uint32_t pruning_positions_l0[10240]; - std::priority_queue, std::vector>, VectorComparator> best_k_centroids{}; - - void ResetDistancesScalar(size_t n_vectors){ - memset((void*) distances, 0, n_vectors * sizeof(DISTANCES_TYPE)); - } - - template - void ResetPruningDistances( - size_t n_vectors, - DistanceType_t *pruning_distances - ){ - memset((void*) pruning_distances, 0, n_vectors * sizeof(DistanceType_t)); - } - - template - void ResetDistancesVectorized( - DistanceType_t *distances - ){ - memset((void*) distances, 0, PDX_VECTOR_SIZE * sizeof(DistanceType_t)); - } - - // The pruning threshold by default is the top of the heap - template - void GetPruningThreshold( - uint32_t k, std::priority_queue, std::vector>, VectorComparator> &heap, - DistanceType_t &pruning_threshold - ){ - pruning_threshold = pruner.template GetPruningThreshold(k, heap, current_dimension_idx); - }; - - template - void EvaluatePruningPredicateScalar( - uint32_t &n_pruned, - size_t n_vectors, - DistanceType_t *pruning_distances, - const DistanceType_t pruning_threshold - ) { - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - n_pruned += pruning_distances[vector_idx] >= pruning_threshold; - } - }; - - template - void EvaluatePruningPredicateOnPositionsArray( - size_t n_vectors, - size_t &n_vectors_not_pruned, - uint32_t * pruning_positions, - DistanceType_t pruning_threshold, - DistanceType_t * pruning_distances - ){ - n_vectors_not_pruned = 0; - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - pruning_positions[n_vectors_not_pruned] = pruning_positions[vector_idx]; - n_vectors_not_pruned += pruning_distances[pruning_positions[vector_idx]] < pruning_threshold; - } - }; - - template - void EvaluatePruningPredicateVectorized( - uint32_t &n_pruned, - DistanceType_t pruning_threshold, - DistanceType_t * pruning_distances - ) { - for (size_t vector_idx = 0; vector_idx < PDX_VECTOR_SIZE; ++vector_idx) { - n_pruned += pruning_distances[vector_idx] >= pruning_threshold; - } - }; - - template - void InitPositionsArray( - size_t n_vectors, - size_t &n_vectors_not_pruned, - uint32_t * pruning_positions, - DistanceType_t pruning_threshold, - DistanceType_t * pruning_distances - ){ - n_vectors_not_pruned = 0; - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - pruning_positions[n_vectors_not_pruned] = vector_idx; - n_vectors_not_pruned += pruning_distances[vector_idx] < pruning_threshold; - } - }; - - template - void InitPositionsArrayFromSelectionVector( - size_t n_vectors, - size_t &n_vectors_not_pruned, - uint32_t * pruning_positions, - DistanceType_t pruning_threshold, - DistanceType_t * pruning_distances, - const uint8_t * selection_vector - ) { - n_vectors_not_pruned = 0; - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - pruning_positions[n_vectors_not_pruned] = vector_idx; - n_vectors_not_pruned += selection_vector[vector_idx] == 1; - } - }; - - template - void MaskDistancesWithSelectionVector( - size_t n_vectors, - size_t &n_vectors_not_pruned, - uint32_t * pruning_positions, - DistanceType_t pruning_threshold, - DistanceType_t * pruning_distances, - const uint8_t * selection_vector - ) { - for (size_t vector_idx = 0; vector_idx < n_vectors; ++vector_idx) { - if (selection_vector[vector_idx] == 0) { - // Why max()/2? To prevent overflow if distances are still added to these - pruning_distances[vector_idx] = std::numeric_limits>::max() / 2; - } - } - }; - - void GetDimensionsAccessOrder(const float *__restrict query, const float *__restrict means) { - std::iota(indices_dimensions.begin(), indices_dimensions.end(), 0); - if (dimension_order == DISTANCE_TO_MEANS) { - std::sort(indices_dimensions.begin(), indices_dimensions.end(), - [&query, &means](size_t i1, size_t i2) { - return std::abs(query[i1] - means[i1]) > std::abs(query[i2] - means[i2]); - }); - } else if (dimension_order == DISTANCE_TO_MEANS_IMPROVED) { - // Improves Cache performance - auto const top_perc = static_cast(std::floor(indices_dimensions.size() / 4)); - std::partial_sort(indices_dimensions.begin(), indices_dimensions.begin() + top_perc, indices_dimensions.end(), - [&query, &means](size_t i1, size_t i2) { - return std::abs(query[i1] - means[i1]) > std::abs(query[i2] - means[i2]); - }); - // By taking the top 25% dimensions and sorting them ascendingly by index - std::sort(indices_dimensions.begin(), indices_dimensions.begin() + top_perc); - // Then sorting the rest of the dimensions ascendingly by index - std::sort(indices_dimensions.begin() + top_perc, indices_dimensions.end()); - } else if (dimension_order == DIMENSION_ZONES){ - uint16_t dimensions = pdx_data.num_dimensions; - size_t estimated_embeddings_per_vg = total_embeddings / pdx_data.num_clusters; - size_t n_dimensions_per_zone = 8192 / estimated_embeddings_per_vg; - size_t total_zones = dimensions / n_dimensions_per_zone; - std::vector> zones; - std::vector zone_ranking; - std::vector zones_indexes; - zones.resize(total_zones); - zones_indexes.resize(total_zones); - zone_ranking.resize(total_zones); - std::iota(zones_indexes.begin(), zones_indexes.end(), 0); - for (size_t i = 0; i < total_zones; i++){ - uint16_t start = i * n_dimensions_per_zone; - uint16_t end = i * n_dimensions_per_zone + n_dimensions_per_zone; - if (end > dimensions - 1 ){ - end = dimensions - 1; - } - //end = std::min(end, (uint16_t) dimensions - 1); - zones[i] = std::pair(start, end); - zone_ranking[i] = 0.0; - } - for (size_t i = 0; i < total_zones; i++){ - uint16_t zone_start = zones[i].first; - uint16_t zone_end = zones[i].second; - for (size_t d = zone_start; d < zone_end; d++){ - zone_ranking[i] += std::abs(query[d] - means[d]); - } - // Normalizing - zone_ranking[i] = zone_ranking[i] / (1.0 * (zone_end - zone_start)); - } - auto const top_perc = static_cast(std::ceil(zones.size() / 8)); - - std::partial_sort(zones_indexes.begin(), zones_indexes.begin() + top_perc, zones_indexes.end(), - [&zone_ranking](size_t i1, size_t i2) { - return zone_ranking[i1] > zone_ranking[i2]; - }); - // We also prioritize them - std::sort(zones_indexes.begin(), zones_indexes.begin() + top_perc); - // The rest we resort to access them sequentially by zone - std::sort(zones_indexes.begin() + top_perc, zones_indexes.end()); - size_t offset_tmp = 0; - for (size_t i = 0; i < total_zones; i++){ - std::pair priority_zone = zones[zones_indexes[i]]; - for (size_t d = priority_zone.first; d < priority_zone.second; d++){ - indices_dimensions[offset_tmp] = d; - offset_tmp += 1; - } - } - } - } - - static void GetClustersAccessOrderIVF(const float *__restrict query, const INDEX_TYPE &data, size_t nprobe, std::vector &clusters_indices) { - std::vector distances_to_centroids; - distances_to_centroids.resize(data.num_clusters); - for (size_t cluster_idx = 0; cluster_idx < data.num_clusters; cluster_idx++) { - distances_to_centroids[cluster_idx] = - DistanceComputer::Horizontal(query, - data.centroids + - cluster_idx * - data.num_dimensions, - data.num_dimensions, - nullptr); - } - clusters_indices.resize(data.num_clusters); - std::iota(clusters_indices.begin(), clusters_indices.end(), 0); - std::partial_sort(clusters_indices.begin(), clusters_indices.begin() + nprobe, clusters_indices.end(), - [&distances_to_centroids](size_t i1, size_t i2) { - return distances_to_centroids[i1] < distances_to_centroids[i2]; - } - ); - } - - // On the first bucket, we do a full scan (we do not prune vectors) - template - void Start( - const QuantizedVectorType_t *__restrict query, - const DataType_t * data, - const size_t n_vectors, - uint32_t k, - const uint32_t * vector_indices, - uint32_t * pruning_positions, - DistanceType_t * pruning_distances, - std::priority_queue, std::vector>, VectorComparator> &heap - ) { - ResetPruningDistances(n_vectors, pruning_distances); - DistanceComputer::Vertical( - query, data, n_vectors, n_vectors, 0, pdx_data.num_vertical_dimensions, - pruning_distances, pruning_positions, indices_dimensions.data(), quantizer.dim_clip_value, - nullptr); - for (size_t horizontal_dimension = 0; horizontal_dimension < pdx_data.num_horizontal_dimensions; horizontal_dimension+=H_DIM_SIZE) { - for (size_t vector_idx = 0; vector_idx < n_vectors; vector_idx++) { - size_t data_pos = (pdx_data.num_vertical_dimensions * n_vectors) + - (horizontal_dimension * n_vectors) + - (vector_idx * H_DIM_SIZE); - pruning_distances[vector_idx] += DistanceComputer::Horizontal( - query + pdx_data.num_vertical_dimensions + horizontal_dimension, - data + data_pos, - H_DIM_SIZE, - nullptr - ); - } - } - size_t max_possible_k = std::min((size_t) k - heap.size(), n_vectors); // Note: Start() should not be called if heap.size() >= k - std::vector indices_sorted; - indices_sorted.resize(n_vectors); - std::iota(indices_sorted.begin(), indices_sorted.end(), 0); - std::partial_sort(indices_sorted.begin(), indices_sorted.begin() + max_possible_k, indices_sorted.end(), - [pruning_distances](size_t i1, size_t i2) { - return pruning_distances[i1] < pruning_distances[i2]; - }); - // insert first k results into the heap - for (size_t idx = 0; idx < max_possible_k; ++idx) { - auto embedding = KNNCandidate{}; - size_t index = indices_sorted[idx]; - embedding.index = vector_indices[index]; - embedding.distance = pruning_distances[index]; - heap.push(embedding); - } - } - - // On the first bucket, we do a full scan (we do not prune vectors) - template - void FilteredStart( - const QuantizedVectorType_t *__restrict query, - const DataType_t * data, - const size_t n_vectors, - uint32_t k, - const uint32_t * vector_indices, - uint32_t * pruning_positions, - DistanceType_t * pruning_distances, - std::priority_queue, std::vector>, VectorComparator> &heap, - uint8_t * selection_vector, - uint32_t passing_tuples - ) { - ResetPruningDistances(n_vectors, pruning_distances); - n_vectors_not_pruned = 0; - float selection_percentage = (passing_tuples / (float) n_vectors); - InitPositionsArrayFromSelectionVector( - n_vectors, n_vectors_not_pruned, pruning_positions, pruning_threshold, pruning_distances, selection_vector - ); - // Always start with horizontal block, regardless of selectivity - for (size_t horizontal_dimension = 0; horizontal_dimension < pdx_data.num_horizontal_dimensions; horizontal_dimension+=H_DIM_SIZE) { - size_t offset_data = (pdx_data.num_vertical_dimensions * n_vectors) + - (horizontal_dimension * n_vectors); - for (size_t vector_idx = 0; vector_idx < n_vectors_not_pruned; vector_idx++) { - size_t v_idx = pruning_positions[vector_idx]; - size_t data_pos = offset_data + (v_idx * H_DIM_SIZE); - pruning_distances[v_idx] += DistanceComputer::Horizontal( - query + pdx_data.num_vertical_dimensions + horizontal_dimension, - data + data_pos, - H_DIM_SIZE, - nullptr - ); - } - } - if (selection_percentage > 0.20) { // TODO: 0.20 comes from the `selectivity_threshold` - // It is then faster to do the full scan (thanks to SIMD) - DistanceComputer::Vertical( - query, data, n_vectors, n_vectors, 0, pdx_data.num_vertical_dimensions, - pruning_distances, pruning_positions, indices_dimensions.data(), quantizer.dim_clip_value, - nullptr); - } else { - // We access individual values - DistanceComputer::VerticalPruning( - query, data, n_vectors_not_pruned, - n_vectors, 0, pdx_data.num_vertical_dimensions, pruning_distances, - pruning_positions, indices_dimensions.data(), quantizer.dim_clip_value, - nullptr); - } - // TODO: Everything down from here is a bottleneck when selection % is ultra low - size_t max_possible_k = std::min((size_t) k - heap.size(), (size_t) passing_tuples); - MaskDistancesWithSelectionVector( - n_vectors, n_vectors_not_pruned, pruning_positions, pruning_threshold, pruning_distances, selection_vector - ); - std::vector indices_sorted; - indices_sorted.resize(n_vectors); - std::iota(indices_sorted.begin(), indices_sorted.end(), 0); - std::partial_sort(indices_sorted.begin(), indices_sorted.begin() + max_possible_k, indices_sorted.end(), - [pruning_distances](size_t i1, size_t i2) { - return pruning_distances[i1] < pruning_distances[i2]; - }); - // insert first k results into the heap - for (size_t idx = 0; idx < max_possible_k; ++idx) { - auto embedding = KNNCandidate{}; - size_t index = indices_sorted[idx]; - embedding.index = vector_indices[index]; - embedding.distance = pruning_distances[index]; - heap.push(embedding); - } - } - - // On the warmup phase, we keep scanning dimensions until the amount of not-yet pruned vectors is low - template - void Warmup( - const QuantizedVectorType_t *__restrict query, - const DataType_t *__restrict data, - const size_t n_vectors, - uint32_t k, - float tuples_threshold, - uint32_t * pruning_positions, - DistanceType_t * pruning_distances, - DistanceType_t &pruning_threshold, - std::priority_queue, std::vector>, VectorComparator> &heap, - uint32_t passing_tuples = 0, - uint8_t * selection_vector = nullptr - ) { - current_dimension_idx = 0; - cur_subgrouping_size_idx = 0; - size_t tuples_needed_to_exit = std::ceil(1.0 * tuples_threshold * n_vectors); - ResetPruningDistances(n_vectors, pruning_distances); - uint32_t n_tuples_to_prune = 0; - if constexpr (FILTERED) { - float selection_percentage = (passing_tuples / (float) n_vectors); - MaskDistancesWithSelectionVector( - n_vectors, n_vectors_not_pruned, pruning_positions, pruning_threshold, pruning_distances, selection_vector - ); - if (selection_percentage < 0.20) { - // Go directly to the PRUNE phase for direct tuples access in the Horizontal block - return; - } - } - if (!is_positional_pruning) GetPruningThreshold(k, heap, pruning_threshold); - while ( - 1.0 * n_tuples_to_prune < tuples_needed_to_exit && - current_dimension_idx < pdx_data.num_vertical_dimensions) { - size_t last_dimension_to_fetch = std::min(current_dimension_idx + DIMENSIONS_FETCHING_SIZES[cur_subgrouping_size_idx], - pdx_data.num_vertical_dimensions); - if (dimension_order == SEQUENTIAL){ - DistanceComputer::Vertical(query, data, n_vectors, n_vectors, current_dimension_idx, - last_dimension_to_fetch, pruning_distances, - pruning_positions, indices_dimensions.data(), quantizer.dim_clip_value, - nullptr); - } else { - DistanceComputer::VerticalReordered(query, data, n_vectors, n_vectors, current_dimension_idx, - last_dimension_to_fetch, pruning_distances, - pruning_positions, indices_dimensions.data(), quantizer.dim_clip_value, - nullptr); - } - current_dimension_idx = last_dimension_to_fetch; - cur_subgrouping_size_idx += 1; - if (is_positional_pruning) GetPruningThreshold(k, heap, pruning_threshold); - n_tuples_to_prune = 0; - EvaluatePruningPredicateScalar(n_tuples_to_prune, n_vectors, pruning_distances, pruning_threshold); - } - } - - // We scan only the not-yet pruned vectors - template - void Prune( - const QuantizedVectorType_t *__restrict query, - const DataType_t *__restrict data, - const size_t n_vectors, - uint32_t k, - uint32_t * pruning_positions, - DistanceType_t *pruning_distances, - DistanceType_t &pruning_threshold, - std::priority_queue, std::vector>, VectorComparator> &heap - ) { - GetPruningThreshold(k, heap, pruning_threshold); - InitPositionsArray(n_vectors, n_vectors_not_pruned, pruning_positions, pruning_threshold, pruning_distances); - size_t cur_n_vectors_not_pruned = 0; - size_t current_vertical_dimension = current_dimension_idx; - size_t current_horizontal_dimension = 0; - while ( - pdx_data.num_horizontal_dimensions && - n_vectors_not_pruned && - current_horizontal_dimension < pdx_data.num_horizontal_dimensions - ) { - cur_n_vectors_not_pruned = n_vectors_not_pruned; - size_t offset_data = (pdx_data.num_vertical_dimensions * n_vectors) + - (current_horizontal_dimension * n_vectors); - for (size_t vector_idx = 0; vector_idx < n_vectors_not_pruned; vector_idx++) { - size_t v_idx = pruning_positions[vector_idx]; - size_t data_pos = offset_data + (v_idx * H_DIM_SIZE); - __builtin_prefetch(data + data_pos, 0, 3); - } - size_t offset_query = pdx_data.num_vertical_dimensions + current_horizontal_dimension; - for (size_t vector_idx = 0; vector_idx < n_vectors_not_pruned; vector_idx++) { - size_t v_idx = pruning_positions[vector_idx]; - size_t data_pos = offset_data + (v_idx * H_DIM_SIZE); - pruning_distances[v_idx] += DistanceComputer::Horizontal( - query + offset_query, - data + data_pos, - H_DIM_SIZE, - nullptr - ); - } - // end of clipping - current_horizontal_dimension += H_DIM_SIZE; - current_dimension_idx += H_DIM_SIZE; - if (is_positional_pruning) GetPruningThreshold(k, heap, pruning_threshold); - assert(current_dimension_idx == current_vertical_dimension + current_horizontal_dimension); - EvaluatePruningPredicateOnPositionsArray(cur_n_vectors_not_pruned, n_vectors_not_pruned, pruning_positions, pruning_threshold, pruning_distances); - } - // GO THROUGH THE REST IN THE VERTICAL - while ( - n_vectors_not_pruned && - current_vertical_dimension < pdx_data.num_vertical_dimensions - ) { - cur_n_vectors_not_pruned = n_vectors_not_pruned; - size_t last_dimension_to_test_idx = std::min(current_vertical_dimension + H_DIM_SIZE, - (size_t)pdx_data.num_vertical_dimensions); - if (dimension_order == SEQUENTIAL){ - DistanceComputer::VerticalPruning( - query, data, cur_n_vectors_not_pruned, - n_vectors, current_vertical_dimension, - last_dimension_to_test_idx, pruning_distances, - pruning_positions, indices_dimensions.data(), quantizer.dim_clip_value, - nullptr); - } else { - DistanceComputer::VerticalReorderedPruning( - query, data, cur_n_vectors_not_pruned, - n_vectors, current_vertical_dimension, - last_dimension_to_test_idx, pruning_distances, - pruning_positions, indices_dimensions.data(), quantizer.dim_clip_value, - nullptr); - } - current_dimension_idx = std::min(current_dimension_idx+H_DIM_SIZE, (size_t)pdx_data.num_dimensions); - current_vertical_dimension = std::min((uint32_t)(current_vertical_dimension+H_DIM_SIZE), pdx_data.num_vertical_dimensions); - assert(current_dimension_idx == current_vertical_dimension + current_horizontal_dimension); - if (is_positional_pruning) GetPruningThreshold(k, heap, pruning_threshold); - EvaluatePruningPredicateOnPositionsArray(cur_n_vectors_not_pruned, n_vectors_not_pruned, pruning_positions, pruning_threshold, pruning_distances); - if (current_dimension_idx == pdx_data.num_dimensions) break; - } - } - - // TODO: Manage the heap elsewhere - template - void MergeIntoHeap( - const uint32_t * vector_indices, - size_t n_vectors, - uint32_t k, - const uint32_t * pruning_positions, - DistanceType_t *pruning_distances, - DistanceType_t *distances, - std::priority_queue, std::vector>, VectorComparator> &heap - ) { - for (size_t position_idx = 0; position_idx < n_vectors; ++position_idx) { - size_t index = position_idx; - //DISTANCES_TYPE current_distance; - float current_distance; - if constexpr (IS_PRUNING){ - index = pruning_positions[position_idx]; - current_distance = pruning_distances[index]; - } else { - current_distance = distances[index]; - } - if (heap.size() < k || current_distance < heap.top().distance) { - KNNCandidate embedding{}; - embedding.distance = current_distance; - embedding.index = vector_indices[index]; - if (heap.size() >= k) { - heap.pop(); - } - heap.push(embedding); - } - } - } - - std::vector BuildResultSet(uint32_t k){ - size_t result_set_size = std::min(best_k.size(), (size_t) k); - std::vector result; - result.resize(result_set_size); - // We return distances in the original domain (do we need it?) - float inverse_scale_factor; - if constexpr (q == U8) { - inverse_scale_factor = 1.0f / pdx_data.scale_factor; - inverse_scale_factor = inverse_scale_factor * inverse_scale_factor; - } - for (int i = result_set_size - 1; i >= 0; --i) { - const KNNCandidate_t& embedding = best_k.top(); - if constexpr (q == U8) { - result[i].distance = embedding.distance * inverse_scale_factor; - } else if constexpr (q == F32){ - result[i].distance = embedding.distance; - } - result[i].index = embedding.index; - best_k.pop(); - } - return result; - } - - void BuildResultSetCentroids(uint32_t k){ - for (int i = k - 1; i >= 0; --i) { - const KNNCandidate& embedding = best_k_centroids.top(); - clusters_indices[i] = embedding.index; - best_k_centroids.pop(); - } - } - - // We store centroids using PDX in tight blocks of 64 - void GetClustersAccessOrderIVFPDX(const float *__restrict query) { - best_k_centroids = std::priority_queue, std::vector>, VectorComparator>{}; - clusters_indices.resize(pdx_data.num_clusters); - std::iota(clusters_indices.begin(), clusters_indices.end(), 0); - float * tmp_centroids_pdx = pdx_data.centroids_pdx; - uint32_t * tmp_cluster_indices = clusters_indices.data(); - size_t SKIPPING_SIZE = PDX_VECTOR_SIZE * pdx_data.num_dimensions; - size_t remainder_block_size = pdx_data.num_clusters % PDX_VECTOR_SIZE; - size_t full_blocks = std::floor(1.0 * pdx_data.num_clusters / PDX_VECTOR_SIZE); - for (size_t centroid_idx = 0; centroid_idx < full_blocks; ++centroid_idx) { - // TODO: Use another distances array for the centroids so I can use ResetDistancesVectorized() instead of memset - memset((void*) distances, 0, PDX_VECTOR_SIZE * sizeof(float)); - DistanceComputer::VerticalBlock(query, tmp_centroids_pdx, 0, pdx_data.num_dimensions, distances, nullptr); - MergeIntoHeap(tmp_cluster_indices, PDX_VECTOR_SIZE, ivf_nprobe, pruning_positions, pruning_distances, distances, best_k_centroids); - tmp_cluster_indices += PDX_VECTOR_SIZE; - tmp_centroids_pdx += SKIPPING_SIZE; - } - if (remainder_block_size){ - memset((void*) distances, 0, PDX_VECTOR_SIZE * sizeof(float)); - DistanceComputer::Vertical(query, tmp_centroids_pdx, remainder_block_size, remainder_block_size, 0, pdx_data.num_dimensions, distances, nullptr, nullptr, nullptr, nullptr); - MergeIntoHeap(tmp_cluster_indices, remainder_block_size, ivf_nprobe, pruning_positions, pruning_distances, distances, best_k_centroids); - } - for (size_t i = 0; i < ivf_nprobe; ++i){ - const KNNCandidate & c = best_k_centroids.top(); - clusters_indices[ivf_nprobe - i - 1] = c.index; // I need to inverse the allocation - best_k_centroids.pop(); - } - memset((void*) distances, 0, PDX_VECTOR_SIZE * sizeof(float)); - } - - // We store centroids using PDX in tight blocks of 64 - // TODO: Always assumes multiple of 64 - void GetL0ClustersAccessOrderPDX(const float *__restrict query) { - best_k_centroids = std::priority_queue, std::vector>, VectorComparator> {}; - clusters_indices_l0.resize(pdx_data.num_clusters_l0); - std::iota(clusters_indices_l0.begin(), clusters_indices_l0.end(), 0); - float * tmp_centroids_pdx = pdx_data.centroids_pdx; - uint32_t * tmp_cluster_indices = clusters_indices_l0.data(); - size_t SKIPPING_SIZE = PDX_VECTOR_SIZE * pdx_data.num_dimensions; - size_t full_blocks = std::floor(1.0 * pdx_data.num_clusters_l0 / PDX_VECTOR_SIZE); - for (size_t centroid_idx = 0; centroid_idx < full_blocks; ++centroid_idx) { - memset((void*) centroids_distances, 0, PDX_VECTOR_SIZE * sizeof(float)); - DistanceComputer::VerticalBlock(query, tmp_centroids_pdx, 0, pdx_data.num_dimensions, centroids_distances, nullptr); - tmp_cluster_indices += PDX_VECTOR_SIZE; - tmp_centroids_pdx += SKIPPING_SIZE; - } - std::vector indices_sorted; - indices_sorted.resize(pdx_data.num_clusters_l0); - std::iota(indices_sorted.begin(), indices_sorted.end(), 0); - std::partial_sort(indices_sorted.begin(), indices_sorted.begin() + 64, indices_sorted.end(), - [](size_t i1, size_t i2) { - return centroids_distances[i1] < centroids_distances[i2]; - }); - // Sort the distance of the first N centroids to determine access order - for (size_t idx = 0; idx < pdx_data.num_clusters_l0; ++idx) { - clusters_indices_l0[idx] = indices_sorted[idx]; - } - } - - void GetL1ClustersAccessOrderPDX( - const float *__restrict query, - size_t n_buckets, - bool safe_to_prune_space = true - ){ - size_t clusters_to_visit = pdx_data.num_clusters_l0; - if ((n_buckets < pdx_data.num_clusters / 2) && safe_to_prune_space) { - // We prune half of the super-clusters only if the user wants to - // visit less than half of the available clusters - clusters_to_visit = pdx_data.num_clusters_l0 / 2; - } - current_dimension_idx = 0; - for (size_t cluster_idx = 0; cluster_idx < clusters_to_visit; ++cluster_idx) { - current_cluster = clusters_indices_l0[cluster_idx]; - Cluster& cluster = pdx_data.clusters_l0[current_cluster]; - if (best_k_centroids.size() < n_buckets) { - // The heap may not be filled with the first super-cluster - // if the number of clusters probed is high or if the - // number of vectors per super-cluster is small. - Start( - query, cluster.data, cluster.num_embeddings, n_buckets, cluster.indices, - pruning_positions_l0, pruning_distances_l0, best_k_centroids - ); - continue; - } - Warmup(query, cluster.data, cluster.num_embeddings, n_buckets, selectivity_threshold, pruning_positions_l0, pruning_distances_l0, pruning_threshold_l0, best_k_centroids); - Prune(query, cluster.data, cluster.num_embeddings, n_buckets, pruning_positions_l0, pruning_distances_l0, pruning_threshold_l0, best_k_centroids); - if (n_vectors_not_pruned){ - MergeIntoHeap(cluster.indices, n_vectors_not_pruned, n_buckets, pruning_positions_l0, pruning_distances_l0, nullptr, best_k_centroids); - } - } - // Rare case in which half of the space is not enough to fill the best_k_centroids heap - if (best_k_centroids.size() < n_buckets) { - // From 32 to 64 (in the default case) - for (size_t cluster_idx = clusters_to_visit; cluster_idx < pdx_data.num_clusters_l0; ++cluster_idx) { - current_cluster = clusters_indices_l0[cluster_idx]; - Cluster& cluster = pdx_data.clusters_l0[current_cluster]; - Start( - query, cluster.data, cluster.num_embeddings, n_buckets, cluster.indices, - pruning_positions_l0, pruning_distances_l0, best_k_centroids - ); - if (best_k_centroids.size() == n_buckets) { - break; - } - } - } - BuildResultSetCentroids(n_buckets); - } - - void GetClustersAccessOrderRandom() { - std::iota(clusters_indices.begin(), clusters_indices.end(), 0); - } - -public: - /****************************************************************** - * Search methods - ******************************************************************/ - std::vector Search(float *__restrict raw_query, uint32_t k) { -#ifdef BENCHMARK_TIME - this->ResetClocks(); - this->end_to_end_clock.Tic(); -#endif - best_k = std::priority_queue, VectorComparator_t>{}; - alignas(64) float query[pdx_data.num_dimensions]; - if (!pdx_data.is_normalized) { - pruner.PreprocessQuery(raw_query, query); - } else { - alignas(64) float normalized_query[pdx_data.num_dimensions]; - quantizer.NormalizeQuery(raw_query, normalized_query); - pruner.PreprocessQuery(normalized_query, query); - } - GetDimensionsAccessOrder(query, pdx_data.means); - size_t clusters_to_visit = (ivf_nprobe == 0 || ivf_nprobe > pdx_data.num_clusters) - ? pdx_data.num_clusters - : ivf_nprobe; - if constexpr (std::is_same_v>) { - // Multilevel access - GetL0ClustersAccessOrderPDX(query); - GetL1ClustersAccessOrderPDX(query, clusters_to_visit); - } else { - if (pdx_data.is_ivf) { - // TODO: Incorporate this to U8 PDX (no IVF2) - // GetClustersAccessOrderIVFPDX(query); - GetClustersAccessOrderIVF(query, pdx_data, clusters_to_visit, clusters_indices); - } else { - // If there is no index, we just access the clusters in order - GetClustersAccessOrderRandom(); - } - } - // PDXearch core - current_dimension_idx = 0; - QUANTIZED_VECTOR_TYPE *prepared_query; - if constexpr (q == U8) { - quantizer.PrepareQuery(query, pdx_data.for_base, pdx_data.scale_factor); - prepared_query = quantizer.quantized_query; - } else { - prepared_query = query; - } - for (size_t cluster_idx = 0; cluster_idx < clusters_to_visit; ++cluster_idx) { - current_cluster = clusters_indices[cluster_idx]; - CLUSTER_TYPE& cluster = pdx_data.clusters[current_cluster]; - if (best_k.size() < k) { - // We cannot prune until we fill the heap - Start( - prepared_query, cluster.data, cluster.num_embeddings, k, cluster.indices, - pruning_positions, pruning_distances, best_k - ); - continue; - } - Warmup(prepared_query, cluster.data, cluster.num_embeddings, k, selectivity_threshold, pruning_positions, pruning_distances, pruning_threshold, best_k); - Prune(prepared_query, cluster.data, cluster.num_embeddings, k, pruning_positions, pruning_distances, pruning_threshold, best_k); - if (n_vectors_not_pruned){ - MergeIntoHeap(cluster.indices, n_vectors_not_pruned, k, pruning_positions, pruning_distances, nullptr, best_k); - } - } - std::vector result = BuildResultSet(k); -#ifdef BENCHMARK_TIME - this->end_to_end_clock.Toc(); -#endif - return result; - } - - std::vector FilteredSearch(float *__restrict raw_query, uint32_t k, const PredicateEvaluator& predicate_evaluator) { -#ifdef BENCHMARK_TIME - this->ResetClocks(); - this->end_to_end_clock.Tic(); -#endif - best_k = std::priority_queue, VectorComparator_t>{}; - alignas(64) float query[pdx_data.num_dimensions]; - if (!pdx_data.is_normalized) { - pruner.PreprocessQuery(raw_query, query); - } else { - alignas(64) float normalized_query[pdx_data.num_dimensions]; - quantizer.NormalizeQuery(raw_query, normalized_query); - pruner.PreprocessQuery(normalized_query, query); - } - GetDimensionsAccessOrder(query, pdx_data.means); - size_t clusters_to_visit = (ivf_nprobe == 0 || ivf_nprobe > pdx_data.num_clusters) - ? pdx_data.num_clusters - : ivf_nprobe; - if constexpr (std::is_same_v>) { - // Multilevel access - GetL0ClustersAccessOrderPDX(query); - GetL1ClustersAccessOrderPDX(query, clusters_to_visit, false); - } else { - if (pdx_data.is_ivf) { - // GetClustersAccessOrderIVFPDX(query); - GetClustersAccessOrderIVF(query, pdx_data, clusters_to_visit, clusters_indices); - } else { - // If there is no index, we just access the clusters in order - GetClustersAccessOrderRandom(); - } - } - // PDXearch core - current_dimension_idx = 0; - QUANTIZED_VECTOR_TYPE *prepared_query; - if constexpr (q == U8) { - quantizer.PrepareQuery(query, pdx_data.for_base, pdx_data.scale_factor); - prepared_query = quantizer.quantized_query; - } else { - prepared_query = query; - } - for (size_t cluster_idx = 0; cluster_idx < clusters_to_visit; ++cluster_idx) { - current_cluster = clusters_indices[cluster_idx]; - auto [selection_vector, passing_tuples] = predicate_evaluator.GetSelectionVector(current_cluster, cluster_offsets[current_cluster]); - if (passing_tuples == 0) { - continue; - } - CLUSTER_TYPE& cluster = pdx_data.clusters[current_cluster]; - if (best_k.size() < k) { - // We cannot prune until we fill the heap - FilteredStart( - prepared_query, cluster.data, cluster.num_embeddings, k, cluster.indices, - pruning_positions, pruning_distances, best_k, selection_vector, passing_tuples - ); - continue; - } - Warmup(prepared_query, cluster.data, cluster.num_embeddings, k, selectivity_threshold, pruning_positions, pruning_distances, pruning_threshold, best_k, passing_tuples, selection_vector); - Prune(prepared_query, cluster.data, cluster.num_embeddings, k, pruning_positions, pruning_distances, pruning_threshold, best_k); - if (n_vectors_not_pruned){ - MergeIntoHeap(cluster.indices, n_vectors_not_pruned, k, pruning_positions, pruning_distances, nullptr, best_k); - } - } - std::vector result = BuildResultSet(k); -#ifdef BENCHMARK_TIME - this->end_to_end_clock.Toc(); -#endif - return result; - } - - - template = 0> - std::vector LinearScan(float *__restrict raw_query, uint32_t k) { -#ifdef BENCHMARK_TIME - this->ResetClocks(); - this->end_to_end_clock.Tic(); -#endif - std::vector dummy_for_bases(4096, 0); - std::vector dummy_scale_factors(4096, 1); - alignas(64) float query[pdx_data.num_dimensions]; - if (!pdx_data.is_normalized) { - } else { - quantizer.NormalizeQuery(raw_query, query); - } - quantizer.PrepareQuery(query, dummy_for_bases.data(), dummy_scale_factors); - best_k = std::priority_queue, VectorComparator_t>{}; - size_t clusters_to_visit = pdx_data.num_clusters; - GetClustersAccessOrderRandom(); - for (size_t cluster_idx = 0; cluster_idx < clusters_to_visit; ++cluster_idx) { - current_cluster = clusters_indices[cluster_idx]; - CLUSTER_TYPE& cluster = pdx_data.clusters[current_cluster]; - if (cluster.num_embeddings == PDX_VECTOR_SIZE){ - ResetDistancesVectorized(distances); - DistanceComputer::VerticalBlock(query, cluster.data, 0, pdx_data.num_dimensions, distances, nullptr); - MergeIntoHeap(cluster.indices, PDX_VECTOR_SIZE, k, nullptr, nullptr, distances, best_k); - } else if (cluster.num_embeddings < PDX_VECTOR_SIZE) { - ResetDistancesVectorized(distances); - DistanceComputer::Vertical(query, cluster.data, cluster.num_embeddings, cluster.num_embeddings, 0, pdx_data.num_dimensions, distances, - nullptr, nullptr, nullptr, nullptr); - MergeIntoHeap(cluster.indices, cluster.num_embeddings, k, nullptr, nullptr, distances, best_k); - } - } - std::vector result = BuildResultSet(k); -#ifdef BENCHMARK_TIME - this->end_to_end_clock.Toc(); -#endif - return result; - } - - // Full Linear Scans that do not prune vectors - template = 0> - std::vector LinearScan(float *__restrict raw_query, uint32_t k) { -#ifdef BENCHMARK_TIME - this->ResetClocks(); - this->end_to_end_clock.Tic(); -#endif - float query[pdx_data.num_dimensions]; - pruner.PreprocessQuery(raw_query, query); - best_k = std::priority_queue, VectorComparator_t>{}; - size_t clusters_to_visit = pdx_data.num_clusters; - GetClustersAccessOrderRandom(); - for (size_t cluster_idx = 0; cluster_idx < clusters_to_visit; ++cluster_idx) { - current_cluster = clusters_indices[cluster_idx]; - CLUSTER_TYPE& cluster = pdx_data.clusters[current_cluster]; - if (cluster.num_embeddings == PDX_VECTOR_SIZE){ - ResetDistancesVectorized(distances); - DistanceComputer::VerticalBlock(query, cluster.data, 0, pdx_data.num_dimensions, distances, nullptr); - MergeIntoHeap(cluster.indices, PDX_VECTOR_SIZE, k, nullptr, nullptr, distances, best_k); - } else if (cluster.num_embeddings < PDX_VECTOR_SIZE) { - ResetDistancesVectorized(distances); - DistanceComputer::Vertical(query, cluster.data, cluster.num_embeddings, cluster.num_embeddings, 0, pdx_data.num_dimensions, distances, nullptr, nullptr, nullptr, nullptr); - MergeIntoHeap(cluster.indices, cluster.num_embeddings, k, nullptr, nullptr, distances, best_k); - } - } - std::vector result = BuildResultSet(k); -#ifdef BENCHMARK_TIME - this->end_to_end_clock.Toc(); -#endif - return result; - } - -}; - -} // namespace PDX - -#endif //PDX_PDXEARCH_HPP diff --git a/include/pruners/adsampling.hpp b/include/pruners/adsampling.hpp deleted file mode 100644 index 0e1fe04..0000000 --- a/include/pruners/adsampling.hpp +++ /dev/null @@ -1,109 +0,0 @@ -#ifndef PDX_ADSAMPLING_U8_HPP -#define PDX_ADSAMPLING_U8_HPP - -#include -#include -#include -#ifdef HAS_FFTW - #include -#endif - -namespace PDX { - -static std::vector ratios{}; - -/****************************************************************** - * ADSampling pruner - ******************************************************************/ -template -class ADSamplingPruner { - using DISTANCES_TYPE = DistanceType_t; - using KNNCandidate_t = KNNCandidate; - using VectorComparator_t = VectorComparator; - -public: - - uint32_t num_dimensions; - - ADSamplingPruner(uint32_t num_dimensions, float epsilon0, float * matrix_p) - : num_dimensions(num_dimensions), epsilon0(epsilon0){ - ratios.resize(num_dimensions); - for (size_t i = 0; i < num_dimensions; ++i) { - ratios[i] = GetRatio(i); - } -#ifdef HAS_FFTW - if (num_dimensions >= D_THRESHOLD_FOR_DCT_ROTATION) { - matrix = Eigen::Map>(matrix_p, 1, num_dimensions); - } else { - matrix = Eigen::Map>(matrix_p, num_dimensions, num_dimensions); - } -#else - matrix = Eigen::Map>(matrix_p, num_dimensions, num_dimensions); -#endif - } - - void SetEpsilon0(float epsilon0) { - ADSamplingPruner::epsilon0 = epsilon0; - for (size_t i = 0; i < num_dimensions; ++i) { - ratios[i] = GetRatio(i); - } - } - - void SetMatrix(const Eigen::MatrixXf &matrix) { - ADSamplingPruner::matrix = matrix; - } - - template - DistanceType_t GetPruningThreshold( - uint32_t k, - std::priority_queue, std::vector>, VectorComparator> &heap, - const uint32_t current_dimension_idx - ) { - float ratio = current_dimension_idx == num_dimensions ? 1 : ratios[current_dimension_idx]; - //return std::numeric_limits>::max(); - return heap.top().distance * ratio; - } - - void PreprocessQuery(float *raw_query, float * query) { - Multiply(raw_query, query, num_dimensions); - } - -private: - float epsilon0 = 2.1; - Eigen::Matrix matrix; - - float GetRatio(const size_t &visited_dimensions) { - if (visited_dimensions == 0) { - return 1; - } - if (visited_dimensions == (int) num_dimensions) { - return 1.0; - } - return 1.0 * visited_dimensions / ((int) num_dimensions) * - (1.0 + epsilon0 / std::sqrt(visited_dimensions)) * (1.0 + epsilon0 / std::sqrt(visited_dimensions)); - } - - void Multiply(float *raw_query, float *query, uint32_t num_dimensions) { - Eigen::Map query_matrix(raw_query, num_dimensions); - Eigen::Map output(query, num_dimensions); -#ifdef HAS_FFTW - if (num_dimensions >= D_THRESHOLD_FOR_DCT_ROTATION) { - Eigen::RowVectorXf first_row = matrix.row(0); - Eigen::RowVectorXf pre_output = query_matrix.array() * first_row.array(); - fftwf_plan plan = fftwf_plan_r2r_1d(num_dimensions, pre_output.data(), output.data(), FFTW_REDFT10, FFTW_ESTIMATE); - fftwf_execute(plan); - fftwf_destroy_plan(plan); - output[0] *= std::sqrt(1.0 / (4 * num_dimensions)); - for (int i = 1; i < num_dimensions; ++i) - output[i] *= std::sqrt(1.0 / (2 * num_dimensions)); - return; - } -#endif - output.noalias() = query_matrix * matrix; - } - -}; - -} // namespace PDX - -#endif // PDX_ADSAMPLING_U8_HPP diff --git a/include/pruners/bond.hpp b/include/pruners/bond.hpp deleted file mode 100644 index 8c9d0fc..0000000 --- a/include/pruners/bond.hpp +++ /dev/null @@ -1,42 +0,0 @@ -#ifndef PDX_PXD_BOND_SEARCH_HPP -#define PDX_PXD_BOND_SEARCH_HPP - -#include - -namespace PDX { - -/****************************************************************** - * BOND Pruner - ******************************************************************/ -template -class BondPruner { - using DISTANCES_TYPE = DistanceType_t; - using QUANTIZED_VECTOR_TYPE = QuantizedVectorType_t; - using DATA_TYPE = DataType_t; - using CLUSTER_TYPE = Cluster; - using KNNCandidate_t = KNNCandidate; - using VectorComparator_t = VectorComparator; -public: - uint32_t num_dimensions; - - BondPruner(uint32_t num_dimensions) : num_dimensions(num_dimensions) {}; - - // TODO: Do not copy - void PreprocessQuery(float *raw_query, float *query) { - memcpy((void *) query, (void *) raw_query, num_dimensions * sizeof(QUANTIZED_VECTOR_TYPE)); - } - - template - DistanceType_t GetPruningThreshold( - uint32_t k, - std::priority_queue, std::vector>, VectorComparator> &heap, - const uint32_t current_dimension_idx - ) { - return heap.size() == k ? heap.top().distance : std::numeric_limits>::max(); - } - -}; - -} // namespace PDX - -#endif //PDX_PXD_BOND_SEARCH_HPP diff --git a/include/quantizers/global.h b/include/quantizers/global.h deleted file mode 100644 index 0336488..0000000 --- a/include/quantizers/global.h +++ /dev/null @@ -1,72 +0,0 @@ -#ifndef PDX_QUANTIZERS_H -#define PDX_QUANTIZERS_H - -#include -#include -#include -#include "common.hpp" - -namespace PDX { - -class Quantizer { - -public: - virtual ~Quantizer() = default; - -public: - void NormalizeQuery(const float * src, float * out) { - float sum = 0.0f; - for (size_t i = 0; i < num_dimensions; ++i) { - sum += src[i] * src[i]; - } - float norm = std::sqrt(sum); - for (size_t i = 0; i < num_dimensions; ++i) { - out[i] = src[i] / norm; - } - } - size_t num_dimensions = 0; - - void SetD(size_t d){ - num_dimensions = d; - } - - virtual void ScaleQuery(const float * src, int32_t * dst) {}; -}; - -template -class Global8Quantizer : public Quantizer { -public: - using QUANTIZED_QUERY_TYPE = QuantizedVectorType_t; - alignas(64) inline static int32_t dim_clip_value[4096]; - alignas(64) inline static QUANTIZED_QUERY_TYPE quantized_query[4096]; - - Global8Quantizer(){ - if constexpr (q == Quantization::U8){ - MAX_VALUE = 255; - } - } - - uint8_t MAX_VALUE; - - void PrepareQuery( - const float *query, - const float for_base, - const float scale_factor - ){ - for (size_t i = 0; i < num_dimensions; ++i){ - // Scale factor is global in symmetric kernel - int rounded = std::round((query[i] - for_base) * scale_factor); - dim_clip_value[i] = rounded; - if (rounded > MAX_VALUE || rounded < 0) { - quantized_query[i] = 0; - }else { - quantized_query[i] = static_cast(rounded); - } - } - }; - -}; - -}; // namespace PDX - -#endif //PDX_QUANTIZERS_H diff --git a/include/utils/benchmark_utils.hpp b/include/utils/benchmark_utils.hpp deleted file mode 100644 index f93247a..0000000 --- a/include/utils/benchmark_utils.hpp +++ /dev/null @@ -1,242 +0,0 @@ -#ifndef PDX_BENCHMARK_UTILS_HPP -#define PDX_BENCHMARK_UTILS_HPP - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../common.hpp" - - -struct BenchmarkMetadata { - std::string dataset; - std::string algorithm; - size_t num_measure_runs{0}; - size_t num_queries{100}; - size_t ivf_nprobe{0}; - size_t knn{10}; - float recalls{1.0}; - float selectivity_threshold{0.0}; - float epsilon {0.0}; -}; - -struct PhasesRuntime { - size_t end_to_end {0}; -}; - -class BenchmarkUtils { -public: - inline static std::string PDX_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/pdx/"; - inline static std::string NARY_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/nary/"; - inline static std::string PDX_ADSAMPLING_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/adsampling_pdx/"; - inline static std::string NARY_ADSAMPLING_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/adsampling_nary/"; - inline static std::string GROUND_TRUTH_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/ground_truth/"; - inline static std::string FILTERED_GROUND_TRUTH_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/ground_truth_filtered/"; - inline static std::string PURESCAN_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/purescan/"; - inline static std::string QUERIES_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/queries/"; - inline static std::string SELECTION_VECTOR_DATA = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/datasets/selection_vectors/"; - - std::string CPU_ARCHITECTURE = "DEFAULT"; - std::string RESULTS_DIR_PATH = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/results/" + CPU_ARCHITECTURE + "/"; - - explicit BenchmarkUtils(){ - CPU_ARCHITECTURE = std::getenv("PDX_ARCH") ? std::getenv("PDX_ARCH") : "DEFAULT"; - RESULTS_DIR_PATH = std::string{CMAKE_SOURCE_DIR} + "/benchmarks/results/" + CPU_ARCHITECTURE + "/"; - } - - inline static std::string DATASETS[] = { - "sift-128-euclidean", - "yi-128-ip", - "llama-128-ip", - "glove-200-angular", - "yandex-200-cosine", - "word2vec-300", - "yahoo-minilm-384-normalized", - "msong-420", - "imagenet-clip-512-normalized", - "laion-clip-512-normalized", - "imagenet-align-640-normalized", - "codesearchnet-jina-768-cosine", - "landmark-dino-768-cosine", - "landmark-nomic-768-normalized", - "arxiv-nomic-768-normalized", - "ccnews-nomic-768-normalized", - "coco-nomic-768-normalized", - "contriever-768", - "instructorxl-arxiv-768", - "gooaq-distilroberta-768-normalized", - "gist-960-euclidean", - "agnews-mxbai-1024-euclidean", - "openai-1536-angular", - "celeba-resnet-2048-cosine", - "simplewiki-openai-3072-normalized" - }; - - inline static std::string FILTERED_SELECTIVITIES[] = { - "0_000135", - "0_001", - "0_01", - "0_1", - "0_2", - "0_3", - "0_4", - "0_5", - "0_75", - "0_9", - "0_95", - "0_99", - "PART_1", - "PART_30", - "PART+_1", - }; - - inline static size_t IVF_PROBES[] = { - //4000, 3980, 3967, 2048, 1024, 512, 256,224,192,160,144,128, - 2048, 1536, 1024, 512, 384, 256,224,192,160,144,128, - 112,96,80,64,56, 48, 40, - 32,28, 26,24, 22,20, 18,16, 14,12, 10,8,6,4,2, 1 - }; - - inline static int POW_10[10] = { - 1, 10, 100, 1000, 10000, - 100000, 1000000, 10000000, 100000000, 1000000000 - }; - - inline static size_t IVF_PROBES_PHASES[] = { - 512,256,128, 64, 32, 16, 8, 4, 2, - }; - - inline static float SELECTIVITY_THRESHOLDS[] = { - 0.005,0.01,0.05,0.1,0.15,0.2,0.25,0.3,0.35,0.4,0.45,0.5, - 0.55,0.6,0.65,0.7,0.75,0.8,0.85,0.9,0.95,0.99 - }; - - - inline static size_t NUM_MEASURE_RUNS = 1; - inline static float EPSILON0 = 1.5; //2.1; - inline static float SELECTIVITY_THRESHOLD = 0.80; // more than 20% pruned to pass - inline static bool VERIFY_RESULTS = true; - inline static uint8_t KNN = 100; - - inline static uint8_t GROUND_TRUTH_MAX_K = 100; // To properly skip on the ground truth file (do not change) - - template - static void VerifyResult(float &recalls, const std::vector> &result, size_t knn, - const uint32_t *int_ground_truth, size_t n_query) { - std::unordered_set seen; - for (const auto& val : result) { - if (!seen.insert(val.index).second) { - throw std::runtime_error("Duplicates detected in the result set! This is likely a bug on PDXearch"); - } - } - if (result.size() < knn) { - std::cerr << "WARNING: Result set is not complete! Set a higher `nbuckets` parameter (Only got " << result.size() << " results)" << std::endl; - } - if constexpr (MEASURE_RECALL) { - size_t true_positives = 0; - for (size_t j = 0; j < result.size(); ++j) { - for (size_t m = 0; m < knn; ++m) { - if (result[j].index == int_ground_truth[m + n_query * GROUND_TRUTH_MAX_K]) { - true_positives++; - break; - } - } - } - recalls += 1.0 * true_positives / knn; - } else { - for (size_t j = 0; j < knn; ++j) { - if (result[j].index != int_ground_truth[j + n_query * GROUND_TRUTH_MAX_K]) { - std::cout << "WRONG RESULT!\n"; - break; - } - } - } - } - - // We remove extreme outliers on both sides (Q3 + 1.5*IQR & Q1 - 1.5*IQR) - static void SaveResults( - std::vector runtimes, const std::string &results_path, const BenchmarkMetadata &metadata) { - bool write_header = true; - if (std::filesystem::exists(results_path)){ - write_header = false; - } - std::ofstream file{results_path, std::ios::app}; - size_t min_runtime = std::numeric_limits::max(); - size_t max_runtime = std::numeric_limits::min(); - size_t sum_runtimes = 0; - size_t all_min_runtime = std::numeric_limits::max(); - size_t all_max_runtime = std::numeric_limits::min(); - size_t all_sum_runtimes = 0; - auto const Q1 = runtimes.size() / 4; - auto const Q2 = runtimes.size() / 2; - auto const Q3 = Q1 + Q2; - std::sort(runtimes.begin(),runtimes.end(), - [](PhasesRuntime i1, PhasesRuntime i2) { - return i1.end_to_end < i2.end_to_end; - }); - auto const iqr = runtimes[Q3].end_to_end - runtimes[Q1].end_to_end; - size_t accounted_queries = 0; - for (size_t j = 0; j < metadata.num_measure_runs * metadata.num_queries; ++j) { - all_min_runtime = std::min(all_min_runtime, runtimes[j].end_to_end); - all_max_runtime = std::max(all_max_runtime, runtimes[j].end_to_end); - all_sum_runtimes += runtimes[j].end_to_end; - // Removing outliers - if (runtimes[j].end_to_end > runtimes[Q3].end_to_end + 1.5 * iqr){ - continue; - } - if (runtimes[j].end_to_end < runtimes[Q1].end_to_end - 1.5 * iqr){ - continue; - } - min_runtime = std::min(min_runtime, runtimes[j].end_to_end); - max_runtime = std::max(max_runtime, runtimes[j].end_to_end); - sum_runtimes += runtimes[j].end_to_end; - accounted_queries += 1; - } - double all_min_runtime_ms = 1.0 * all_min_runtime / 1000000; - double all_max_runtime_ms = 1.0 * all_max_runtime / 1000000; - double all_avg_runtime_ms = 1.0 * all_sum_runtimes / (1000000 * (metadata.num_measure_runs * metadata.num_queries)); - double min_runtime_ms = 1.0 * min_runtime / 1000000; - double max_runtime_ms = 1.0 * max_runtime / 1000000; - double avg_runtime_ms = 1.0 * sum_runtimes / (1000000 * accounted_queries); - double avg_recall = metadata.recalls / metadata.num_queries; - - std::cout << metadata.dataset << " --------------\n"; - std::cout << "n_queries: " << metadata.num_queries << "\n"; - if (metadata.ivf_nprobe > 0){ - std::cout << "nprobe: " << metadata.ivf_nprobe << "\n"; - } - std::cout << "avg: " << std::setprecision(6) << avg_runtime_ms << "\n"; - std::cout << "max: " << std::setprecision(6) << max_runtime_ms << "\n"; - std::cout << "min: " << std::setprecision(6) << min_runtime_ms << "\n"; - std::cout << "rec: " << std::setprecision(6) << avg_recall << "\n"; - - if (write_header){ - file << "dataset,algorithm,avg,max,min,recall,ivf_nprobe,epsilon," - "knn,n_queries,selectivity," - "num_measure_runs,avg_all,max_all,min_all" << "\n"; - } - file << metadata.dataset << "," << metadata.algorithm << "," << std::setprecision(6) << avg_runtime_ms << "," << - std::setprecision(6) << max_runtime_ms << "," << std::setprecision(6) << min_runtime_ms << "," << - avg_recall << "," << metadata.ivf_nprobe << "," << metadata.epsilon << "," << +metadata.knn << "," << - metadata.num_queries << "," << std::setprecision(4) << metadata.selectivity_threshold << "," << - metadata.num_measure_runs << "," << - all_avg_runtime_ms << "," << all_max_runtime_ms << "," << all_min_runtime_ms << - "\n"; - file.close(); - } - -}; - -BenchmarkUtils BENCHMARK_UTILS; - -#endif //PDX_BENCHMARK_UTILS_HPP diff --git a/include/utils/matrix.h b/include/utils/matrix.h deleted file mode 100644 index 66c1c41..0000000 --- a/include/utils/matrix.h +++ /dev/null @@ -1,111 +0,0 @@ -#pragma once -#ifndef MATRIX_HPP_ -#define MATRIX_HPP_ -#include -#include -#include -#include -#include -#include -#include -#include - -/****************************************************************** - * Eigen Matrix wrapper for ADSampling data transformations - ******************************************************************/ -template -class Matrix { -private: - -public: - T* data; - size_t n; - size_t d; - - Matrix(); // Default - Matrix(char * data_file_path); // IO - Matrix(size_t n, size_t d); // Matrix of size n * d - - // Deconstruction - // ~Matrix(){ delete [] data;} - - Matrix & operator = (const Matrix &X){ - delete [] data; - n = X.n; - d = X.d; - data = new T [n*d]; - memcpy(data, X.data, sizeof(T) * n * d); - return *this; - } - - Matrix & operator = (const float *query) { - delete [] data; - data = new T [n*d]; - memcpy(data, query, sizeof(T) * n * d); - return *this; - } - - void mul(const Matrix &A, Matrix &result) const; - float dist(size_t a, const Matrix &B, size_t b) const; -}; - -template -Matrix::Matrix(){ - n = 0; - d = 0; - data = NULL; -} - -template -Matrix::Matrix(char *data_file_path){ - n = 0; - d = 0; - data = NULL; - printf("%s\n",data_file_path); - std::ifstream in(data_file_path, std::ios::binary); - if (!in.is_open()) { - std::cout << "open file error" << std::endl; - exit(-1); - } - in.read((char*)&d, 4); - - std::cerr << "Dimensionality - " << d < -Matrix::Matrix(size_t _n,size_t _d){ - n = _n; - d = _d; - data = new T [n * d]; -} - -void eigen_mul(const Eigen::MatrixXf &A, const Eigen::MatrixXf &B, Eigen::MatrixXf &C){ - C = A * B; -} - -template -Matrix mul(const Matrix &A, const Matrix &B){ -} - -template -float Matrix::dist(size_t a, const Matrix &B, size_t b)const{ - float dist = 0; - for(size_t i=0;i -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/****************************************************************** - * Clock to benchmark algorithms runtime - ******************************************************************/ -class TicToc { -public: - size_t accum_time = 0; - std::chrono::high_resolution_clock::time_point start = std::chrono::high_resolution_clock::now(); - - void Reset() { - accum_time = 0; - start = std::chrono::high_resolution_clock::now(); - } - - inline void Tic() { - start = std::chrono::high_resolution_clock::now(); - } - - inline void Toc() { - auto end = std::chrono::high_resolution_clock::now(); - accum_time += std::chrono::duration_cast( - end - start).count(); - } -}; - -#endif // PDX_TICTOC_HPP \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a8e5f29..665314b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,39 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel", - "cmake>=3.22", - "pybind11", +requires = ["scikit-build-core>=0.3.3", "pybind11>=2.10.0"] +build-backend = "scikit_build_core.build" + +[project] +name = "pdxearch" +version = "0.4" +description = "PDX: A Library for Fast Vector Search and Indexing" +readme = "README.md" +requires-python = ">=3.8" +authors = [ + {name = "CWI", email = "lxkr@cwi.nl"} +] +license = {text = "Apache-2.0"} +classifiers = [ + "Development Status :: 3 - Alpha", + "Natural Language :: English", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: C++", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: MacOS", + "Operating System :: Unix", + "Topic :: Database :: Database Engines/Servers", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ "numpy", - "scipy" ] -build-backend = "setuptools.build_meta" \ No newline at end of file + +[project.urls] +Homepage = "https://github.com/cwida/PDX" +Repository = "https://github.com/cwida/PDX" + +[tool.scikit-build] +cmake.source-dir = "." +wheel.packages = ["python/pdxearch"] +cmake.build-type = "Release" diff --git a/python/lib.cpp b/python/lib.cpp index dbf5199..60b648c 100644 --- a/python/lib.cpp +++ b/python/lib.cpp @@ -1,92 +1,58 @@ +#include "pdx/lib/lib.hpp" #include -#include "lib/lib.hpp" namespace py = pybind11; -/****************************************************************** - * Wrapper for Python bindings - * TODO: Implement quantized classes - ******************************************************************/ PYBIND11_MODULE(compiled, m) { - - m.doc() = "A library to do vertical pruned vector similarity search"; - - py::class_>(m, "Cluster") - .def(py::init<>()) - .def_readwrite("num_embeddings", &PDX::Cluster::num_embeddings) - .def_readwrite("indices", &PDX::Cluster::indices) - .def_readwrite("data", &PDX::Cluster::data); - - py::class_>(m, "IndexPDXIVFFlat") - .def_readwrite("num_dimensions", &PDX::IndexPDXIVF::num_dimensions) - .def_readwrite("num_clusters", &PDX::IndexPDXIVF::num_clusters) - .def_readwrite("clusters", &PDX::IndexPDXIVF::clusters) - .def_readwrite("means", &PDX::IndexPDXIVF::means) - .def_readwrite("is_ivf", &PDX::IndexPDXIVF::is_ivf) - .def_readwrite("centroids", &PDX::IndexPDXIVF::centroids) - .def_readwrite("centroids_pdx", &PDX::IndexPDXIVF::centroids_pdx) - .def("restore", &PDX::IndexPDXIVF::Restore); - - py::class_>(m, "KNNCandidate") - .def(py::init<>()) - .def_readwrite("index", &PDX::KNNCandidate::index) - .def_readwrite("distance", &PDX::KNNCandidate::distance); - - py::class_>(m, "KNNCandidateSQ8") - .def(py::init<>()) - .def_readwrite("index", &PDX::KNNCandidate::index) - .def_readwrite("distance", &PDX::KNNCandidate::distance); - - py::class_(m, "IndexADSamplingIVF2SQ8") - .def(py::init<>()) - .def("restore", &PDX::IndexADSamplingIVF2SQ8::Restore, py::arg("path"), py::arg("matrix_path")) - .def("load", &PDX::IndexADSamplingIVF2SQ8::Load, py::arg("data"), py::arg("matrix")) - .def("set_pruning_confidence", &PDX::IndexADSamplingIVF2SQ8::SetPruningConfidence, py::arg("confidence")) - .def("search", &PDX::IndexADSamplingIVF2SQ8::_py_Search, py::arg("q"), py::arg("k"), py::arg("n_probe")) - .def("filtered_search", &PDX::IndexADSamplingIVF2SQ8::_py_FilteredSearch, py::arg("q"), py::arg("k"), py::arg("n_probe"), py::arg("n_passing_tuples"), py::arg("selection_vector")); - - py::class_(m, "IndexADSamplingIVF2Flat") - .def(py::init<>()) - .def("restore", &PDX::IndexADSamplingIVF2Flat::Restore, py::arg("path"), py::arg("matrix_path")) - .def("load", &PDX::IndexADSamplingIVF2Flat::Load, py::arg("data"), py::arg("matrix")) - .def("set_pruning_confidence", &PDX::IndexADSamplingIVF2Flat::SetPruningConfidence, py::arg("confidence")) - .def("search", &PDX::IndexADSamplingIVF2Flat::_py_Search, py::arg("q"), py::arg("k"), py::arg("n_probe")) - .def("filtered_search", &PDX::IndexADSamplingIVF2Flat::_py_FilteredSearch, py::arg("q"), py::arg("k"), py::arg("n_probe"), py::arg("n_passing_tuples"), py::arg("selection_vector")); - - py::class_(m, "IndexADSamplingIVFFlat") - .def(py::init<>()) - .def("restore", &PDX::IndexADSamplingIVFFlat::Restore, py::arg("path"), py::arg("matrix_path")) - .def("load", &PDX::IndexADSamplingIVFFlat::Load, py::arg("data"), py::arg("matrix")) - .def("set_pruning_confidence", &PDX::IndexADSamplingIVFFlat::SetPruningConfidence, py::arg("confidence")) - .def("search", &PDX::IndexADSamplingIVFFlat::_py_Search, py::arg("q"), py::arg("k"), py::arg("n_probe")) - .def("filtered_search", &PDX::IndexADSamplingIVFFlat::_py_FilteredSearch, py::arg("q"), py::arg("k"), py::arg("n_probe"), py::arg("n_passing_tuples"), py::arg("selection_vector")); - - py::class_(m, "IndexBONDIVFFlat") - .def(py::init<>()) - .def("restore", &PDX::IndexBONDIVFFlat::Restore, py::arg("path")) - .def("load", &PDX::IndexBONDIVFFlat::Load, py::arg("data")) - .def("search", &PDX::IndexBONDIVFFlat::_py_Search, py::arg("q"), py::arg("k"), py::arg("n_probe")) - .def("filtered_search", &PDX::IndexBONDIVFFlat::_py_FilteredSearch, py::arg("q"), py::arg("k"), py::arg("n_probe"), py::arg("n_passing_tuples"), py::arg("selection_vector")); - - py::class_(m, "IndexBONDFlat") - .def(py::init<>()) - .def("restore", &PDX::IndexBONDFlat::Restore, py::arg("path")) - .def("load", &PDX::IndexBONDFlat::Load, py::arg("data")) - .def("search", &PDX::IndexBONDFlat::_py_Search, py::arg("q"), py::arg("k")) - .def("filtered_search", &PDX::IndexBONDFlat::_py_FilteredSearch, py::arg("q"), py::arg("k"), py::arg("n_passing_tuples"), py::arg("selection_vector")); - - py::class_(m, "IndexADSamplingFlat") - .def(py::init<>()) - .def("restore", &PDX::IndexADSamplingFlat::Restore, py::arg("path"), py::arg("matrix_path")) - .def("load", &PDX::IndexADSamplingFlat::Load, py::arg("data"), py::arg("matrix")) - .def("set_pruning_confidence", &PDX::IndexADSamplingFlat::SetPruningConfidence, py::arg("confidence")) - .def("search", &PDX::IndexADSamplingFlat::_py_Search, py::arg("q"), py::arg("k")) - .def("filtered_search", &PDX::IndexADSamplingFlat::_py_FilteredSearch, py::arg("q"), py::arg("k"), py::arg("n_passing_tuples"), py::arg("selection_vector")); - - py::class_(m, "IndexPDXFlat") - .def(py::init<>()) - .def("restore", &PDX::IndexPDXFlat::Restore, py::arg("path")) - .def("load", &PDX::IndexPDXFlat::Load, py::arg("data")) - .def("search", &PDX::IndexPDXFlat::_py_Search, py::arg("q"), py::arg("k")); - -} \ No newline at end of file + m.doc() = "PDXearch: Faster similarity search with a transposed data layout for vectors"; + + py::class_(m, "PDXIndex") + .def( + py::init< + const std::string&, + uint32_t, + uint8_t, + uint32_t, + uint32_t, + uint32_t, + bool, + float, + uint32_t, + bool, + uint32_t>(), + py::arg("index_type"), + py::arg("num_dimensions"), + py::arg("distance_metric") = 0, + py::arg("seed") = 42, + py::arg("num_clusters") = 0, + py::arg("num_meso_clusters") = 0, + py::arg("normalize") = false, + py::arg("sampling_fraction") = 0.0f, + py::arg("kmeans_iters") = 10, + py::arg("hierarchical_indexing") = true, + py::arg("n_threads") = 0 + ) + .def("build_index", &PDX::PyPDXIndex::BuildIndex, py::arg("data")) + .def("search", &PDX::PyPDXIndex::Search, py::arg("query"), py::arg("knn")) + .def( + "filtered_search", + &PDX::PyPDXIndex::FilteredSearch, + py::arg("query"), + py::arg("knn"), + py::arg("row_ids") + ) + .def("set_nprobe", &PDX::PyPDXIndex::SetNProbe, py::arg("nprobe")) + .def("save", &PDX::PyPDXIndex::Save, py::arg("path")) + .def("get_num_dimensions", &PDX::PyPDXIndex::GetNumDimensions) + .def("get_num_clusters", &PDX::PyPDXIndex::GetNumClusters) + .def("get_cluster_size", &PDX::PyPDXIndex::GetClusterSize, py::arg("cluster_id")) + .def("get_cluster_row_ids", &PDX::PyPDXIndex::GetClusterRowIds, py::arg("cluster_id")) + .def("get_in_memory_size_in_bytes", &PDX::PyPDXIndex::GetInMemorySizeInBytes); + + m.def( + "load_index", + &PDX::PyPDXIndex::LoadFromFile, + py::arg("path"), + "Load a PDX index from a single file (auto-detects type)." + ); +} diff --git a/python/pdxearch/__init__.py b/python/pdxearch/__init__.py index f514c84..74914b6 100644 --- a/python/pdxearch/__init__.py +++ b/python/pdxearch/__init__.py @@ -1,2 +1,7 @@ -import numpy as np -np.random.seed(42) +from pdxearch.index_factory import ( + IndexPDXIVF, + IndexPDXIVFSQ8, + IndexPDXIVFTree, + IndexPDXIVFTreeSQ8, + load_index, +) diff --git a/python/pdxearch/constants.py b/python/pdxearch/constants.py deleted file mode 100644 index 48b679e..0000000 --- a/python/pdxearch/constants.py +++ /dev/null @@ -1,22 +0,0 @@ -from enum import Enum -import ctypes.util - -# Some constants -class PDXConstants: - PDX_VECTOR_SIZE = 64 - PDX_CENTROIDS_VECTOR_SIZE = 64 - D_THRESHOLD_FOR_DCT_ROTATION = 512 - HORIZONTAL_DIMENSIONS_GROUPING = 64 - X4_GROUPING = 4 - U8_MAX = 255 # TODO: Fix for Intel can only by max of 127 (there are mathematical workarounds) - VERTICAL_PROPORTION_DIM = 0.75 - PDXEARCH_VECTOR_SIZE = 10240 # Probably ADSampling can do less and find benefits - SUPPORTED_METRICS = [ - "l2sq" - ] - HAS_FFTW = ctypes.util.find_library("fftw3f") is not None - - -class PDXDistanceMetrics(Enum): - l2sq = 1 - diff --git a/python/pdxearch/index_base.py b/python/pdxearch/index_base.py deleted file mode 100644 index 1520dd5..0000000 --- a/python/pdxearch/index_base.py +++ /dev/null @@ -1,440 +0,0 @@ -import numpy as np -import sys -import math -from typing import List -from pdxearch.index_core import IVF, IVF2 -from pdxearch.constants import PDXConstants - -class Partition: - def __init__(self): - self.num_embeddings = 0 - self.indices = np.array([]) - self.blocks = [] - -class BaseIndexPDXIVF2: - def __init__( - self, - ndim: int, - metric: str, - nbuckets: int = 16, - nbuckets_l0: int = 2, - normalize: bool = True - ): - if ndim < 128: - raise Exception('`ndim` must be >= 128') - if ndim % 4 != 0: - raise Exception('`ndim` must be multiple of 4') - self.ndim = ndim - self.normalize = normalize - self.dtype = np.float32 - self.nbuckets = nbuckets - self.nbuckets_l0 = nbuckets_l0 - self.for_base = 0.0 - self.scale_factor = 0.0 - - self.core_index = IVF2(ndim, metric, nbuckets, nbuckets_l0) - - self.means: np.array = None - self.centroids: np.array = np.array([], dtype=np.float32) - self.partitions: List[Partition] = [] - self.num_partitions: int = 0 - - self.means_l0: np.array = None - self.centroids_l0: np.array = np.array([], dtype=np.float32) - self.partitions_l0: List[Partition] = [] - self.num_partitions_l0: int = 0 - - self.materialized_index = None - - def train(self, data: np.array, **kwargs): - self.core_index.train(data, **kwargs) - - def _add(self, data: np.array, **kwargs): - self.core_index.add(data, **kwargs) - - def _train_add_l0(self, **kwargs): - self.core_index.train_add_l0(**kwargs) - - # Separate the data in the PDX blocks - # TODO: Most probably this can be much more efficient - def _to_pdx(self, data: np.array, _type='pdx', centroids_preprocessor=None, use_original_centroids=False, **kwargs): - use_sq = kwargs.get('quantize', False) - self.partitions = [] - self.partitions_l0 = [] - self.means = data.mean(axis=0, dtype=np.float32) - self.centroids = np.array(self.core_index.centroids, dtype=np.float32) - self.centroids_l0 = np.array(self.core_index.centroids_l0, dtype=np.float32) - if use_original_centroids: - if centroids_preprocessor is not None: - centroids_preprocessor.preprocess(self.centroids, inplace=True) - centroids_preprocessor.preprocess(self.centroids_l0, inplace=True) - for list_id in range(self.core_index.nbuckets_l0): - list_ids = self.core_index.labels_l0[list_id] - num_list_embeddings = len(list_ids) - partition = Partition() - partition.num_embeddings = num_list_embeddings - partition.indices = np.zeros((partition.num_embeddings,), dtype=np.uint32) - for embedding_index in range(partition.num_embeddings): - partition.indices[embedding_index] = list_ids[embedding_index] - partition.blocks.append(self.centroids[partition.indices, :]) - self.partitions_l0.append(partition) - self.num_partitions_l0 = len(self.partitions_l0) - if use_sq: - data_max = data.max() - data_min = data.min() - data_range = data_max - data_min - data = data - data_min - global_scale_factor = float(PDXConstants.U8_MAX) / data_range - self.scale_factor = global_scale_factor - self.for_base = data_min - - for list_id in range(self.core_index.nbuckets): - list_ids = self.core_index.labels[list_id] - num_list_embeddings = len(list_ids) - partition = Partition() - partition.num_embeddings = num_list_embeddings - partition.indices = np.zeros((partition.num_embeddings,), dtype=np.uint32) - for embedding_index in range(partition.num_embeddings): - partition.indices[embedding_index] = list_ids[embedding_index] - if (_type == 'pdx-v4-h') and use_sq: - # TODO: Move outside (?) - pre_data = data[partition.indices, :] - pre_data = pre_data * global_scale_factor - pre_data = pre_data.round(decimals=0).astype(dtype=np.int32) - for_data = pre_data - for_data = for_data.astype(dtype=np.uint8) # Always using np.uint8 - partition.blocks.append(for_data) - else: - partition.blocks.append(data[partition.indices, :]) - self.partitions.append(partition) - self.num_partitions = len(self.partitions) - self._materialize_index(_type, **kwargs) - - # Materialize the index with the given layout - # TODO: Most probably this can be much more efficient - def _materialize_index(self, _type='pdx', **kwargs): - data = bytearray() - data.extend(np.int32(self.ndim).tobytes("C")) - - h_dims = int(self.ndim * PDXConstants.VERTICAL_PROPORTION_DIM) - v_dims = self.ndim - h_dims - if h_dims % PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING != 0: - h_dims = math.floor((h_dims / PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING) + 0.5) * PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING - v_dims = self.ndim - h_dims - h_dims_block = kwargs.get('h_dims_block', PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING) - if v_dims == 0: - h_dims = PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING - v_dims = self.ndim - h_dims - - data.extend(np.int32(v_dims).tobytes("C")) - data.extend(np.int32(h_dims).tobytes("C")) - - data.extend(np.int32(self.num_partitions).tobytes("C")) - data.extend(np.int32(self.num_partitions_l0).tobytes("C")) - # L0 - for i in range(self.num_partitions_l0): - data.extend(np.int32(self.partitions_l0[i].num_embeddings).tobytes("C")) - for i in range(self.num_partitions_l0): - for p in range(len(self.partitions_l0[i].blocks)): - vertical_block = self.partitions_l0[i].blocks[p][:, :v_dims] - rows, _ = vertical_block.shape - data.extend(vertical_block.tobytes("F")) # PDX vertical block - pdx_h_block = self.partitions_l0[i].blocks[p][:, v_dims:].reshape(rows, -1, h_dims_block).transpose(1, 0, 2).reshape(-1) - data.extend(pdx_h_block.tobytes("C")) # PDX horizontal block to improve sequential access - for i in range(self.num_partitions_l0): - data.extend(self.partitions_l0[i].indices.tobytes("C")) - - # L1 - for i in range(self.num_partitions): - data.extend(np.int32(self.partitions[i].num_embeddings).tobytes("C")) - for i in range(self.num_partitions): - for p in range(len(self.partitions[i].blocks)): - assert h_dims % PDXConstants.X4_GROUPING == 0 - assert v_dims % PDXConstants.X4_GROUPING == 0 - assert h_dims + v_dims == self.ndim - assert v_dims != 0 - assert h_dims != 0 - assert h_dims % PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING == 0 - if _type == 'pdx': - vertical_block = self.partitions[i].blocks[p][:, :v_dims] - rows, _ = vertical_block.shape - data.extend(vertical_block.tobytes("F")) # PDX vertical block - pdx_h_block = self.partitions[i].blocks[p][:, v_dims:].reshape(rows, -1, h_dims_block).transpose(1, 0, 2).reshape(-1) - data.extend(pdx_h_block.tobytes("C")) # PDX horizontal block to improve sequential access - elif _type == 'pdx-v4-h': - tmp_block = self.partitions[i].blocks[p][:, :v_dims] - rows, _ = tmp_block.shape - pdx_4_block = tmp_block.reshape(rows, -1, 4).transpose(1, 0, 2).reshape(-1) - assert h_dims % h_dims_block == 0 - data.extend(pdx_4_block.tobytes("C")) - # Horizontal block (rest) - pdx_h_block = self.partitions[i].blocks[p][:, v_dims:].reshape(rows, -1, h_dims_block).transpose(1, 0, 2).reshape(-1) - data.extend(pdx_h_block.tobytes("C")) - - for i in range(self.num_partitions): - data.extend(self.partitions[i].indices.tobytes("C")) - data.extend(self.normalize.to_bytes(1, sys.byteorder)) - # TODO: Support other multiples of 64 - if len(self.centroids_l0) == PDXConstants.PDX_CENTROIDS_VECTOR_SIZE: - data.extend(self.centroids_l0.tobytes("F")) # PDX - else: - data.extend(self.centroids_l0.tobytes("C")) # Nary format - if (_type == 'pdx-v4-h') and kwargs.get('quantize', False): - data.extend(np.float32(self.for_base).tobytes("C")) - data.extend(np.float32(self.scale_factor).tobytes("C")) - self.materialized_index = bytes(data) - - def _persist(self, path: str): - if self.materialized_index is None: - raise Exception('The index have not been created') - with open(path, "wb") as file: - file.write(self.materialized_index) - -class BaseIndexPDXIVF: - def __init__( - self, - ndim: int, - metric: str, - nbuckets: int = 16, - normalize: bool = True - ): - if ndim < 128: - raise Exception('`ndim` must be >= 128') - if ndim % 4 != 0: - raise Exception('`ndim` must be multiple of 4') - self.ndim = ndim - self.normalize = normalize - self.dtype = np.float32 - self.nbuckets = nbuckets - self.core_index = IVF(ndim, metric, nbuckets) - self.means: np.array = None - self.centroids: np.array = np.array([], dtype=np.float32) - self.partitions: List[Partition] = [] - self.num_partitions: int = 0 - self.materialized_index = None - self.for_base = 0.0 - self.scale_factor = 0.0 - - def train(self, data: np.array, **kwargs): - self.core_index.train(data, **kwargs) - - def _add(self, data: np.array, **kwargs): - self.core_index.add(data, **kwargs) - - # Separate the data in the PDX blocks - # TODO: Most probably this can be much more efficient - def _to_pdx(self, data: np.array, _type='pdx', centroids_preprocessor=None, use_original_centroids=False, **kwargs): - use_sq = kwargs.get('quantize', False) - self.partitions = [] - self.means = data.mean(axis=0, dtype=np.float32) - self.centroids = np.array(self.core_index.centroids, dtype=np.float32) - if use_original_centroids and centroids_preprocessor is not None: - centroids_preprocessor.preprocess(self.centroids, inplace=True) - if use_sq: - data_max = data.max() - data_min = data.min() - data_range = data_max - data_min - data = data - data_min - self.for_base = data_min - global_scale_factor = float(PDXConstants.U8_MAX) / data_range - for list_id in range(self.core_index.nbuckets): - list_ids = self.core_index.labels[list_id] - num_list_embeddings = len(list_ids) - partition = Partition() - partition.num_embeddings = num_list_embeddings - partition.indices = np.zeros((partition.num_embeddings,), dtype=np.uint32) - for embedding_index in range(partition.num_embeddings): - partition.indices[embedding_index] = list_ids[embedding_index] - if (_type == 'pdx-v4-h') and use_sq: - # TODO: Get out - pre_data = data[partition.indices, :] - pre_data = pre_data * global_scale_factor - pre_data = pre_data.round(decimals=0).astype(dtype=np.int32) - for_data = pre_data - self.scale_factor = global_scale_factor - for_data = for_data.astype(dtype=np.uint8) # Always using np.uint8 - partition.blocks.append(for_data) - else: - partition.blocks.append(data[partition.indices, :]) - if not use_original_centroids: - partition_centroids = np.mean(data[partition.indices, :], axis=0, dtype=np.float32) - self.centroids = np.append(self.centroids, partition_centroids) - self.partitions.append(partition) - self.num_partitions = len(self.partitions) - self._materialize_index(_type, **kwargs) - - # Materialize the index with the given layout - # TODO: Most probably this can be much more efficient - def _materialize_index(self, _type='pdx', **kwargs): - data = bytearray() - data.extend(np.int32(self.ndim).tobytes("C")) - - h_dims = int(self.ndim * PDXConstants.VERTICAL_PROPORTION_DIM) - v_dims = self.ndim - h_dims - if h_dims % PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING != 0: - h_dims = math.floor((h_dims / PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING) + 0.5) * PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING - v_dims = self.ndim - h_dims - h_dims_block = kwargs.get('h_dims_block', PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING) - if v_dims == 0: - h_dims = PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING - v_dims = self.ndim - h_dims - data.extend(np.int32(v_dims).tobytes("C")) - data.extend(np.int32(h_dims).tobytes("C")) - - data.extend(np.int32(self.num_partitions).tobytes("C")) - - for i in range(self.num_partitions): - data.extend(np.int32(self.partitions[i].num_embeddings).tobytes("C")) - for i in range(self.num_partitions): - for p in range(len(self.partitions[i].blocks)): - assert h_dims % PDXConstants.X4_GROUPING == 0 - assert v_dims % PDXConstants.X4_GROUPING == 0 - assert h_dims + v_dims == self.ndim - assert v_dims != 0 - assert h_dims != 0 - assert h_dims % PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING == 0 - if _type == 'pdx': - if kwargs.get('bond', False): - data.extend(self.partitions[i].blocks[p].tobytes("F")) - else: - vertical_block = self.partitions[i].blocks[p][:, :v_dims] - rows, _ = vertical_block.shape - data.extend(vertical_block.tobytes("F")) # PDX vertical block - pdx_h_block = self.partitions[i].blocks[p][:, v_dims:].reshape(rows, -1, h_dims_block).transpose(1, 0, 2).reshape(-1) - data.extend(pdx_h_block.tobytes("C")) # PDX horizontal block to improve sequential access - elif _type == 'pdx-v4-h': - tmp_block = self.partitions[i].blocks[p][:, :v_dims] - rows, _ = tmp_block.shape - pdx_4_block = tmp_block.reshape(rows, -1, 4).transpose(1, 0, 2).reshape(-1) - assert h_dims % h_dims_block == 0 - # Vertical Dimensions - data.extend(pdx_4_block.tobytes("C")) - # Horizontal block (rest) - pdx_h_block = self.partitions[i].blocks[p][:, v_dims:].reshape(rows, -1, h_dims_block).transpose(1, 0, 2).reshape(-1) - data.extend(pdx_h_block.tobytes("C")) - for i in range(self.num_partitions): - data.extend(self.partitions[i].indices.tobytes("C")) - if _type == 'pdx': - data.extend(self.means.tobytes("C")) - is_ivf = True - data.extend(self.normalize.to_bytes(1, sys.byteorder)) - data.extend(is_ivf.to_bytes(1, sys.byteorder)) - # Since centroids not many, we store them twice to have a dual layout - data.extend(self.centroids.tobytes("C")) # Nary format - if _type == 'pdx': - # PDX format - centroids_written = 0 - reshaped_centroids = np.reshape(self.centroids, (self.num_partitions, self.ndim)) - while centroids_written != self.num_partitions: - if centroids_written + PDXConstants.PDX_CENTROIDS_VECTOR_SIZE > self.num_partitions: - data.extend(reshaped_centroids[centroids_written:, :].tobytes("F")) - centroids_written = self.num_partitions - else: - data.extend( - reshaped_centroids[ - centroids_written: centroids_written + PDXConstants.PDX_CENTROIDS_VECTOR_SIZE, : - ].tobytes("F") - ) - centroids_written += PDXConstants.PDX_CENTROIDS_VECTOR_SIZE - if (_type == 'pdx-v4-h') and kwargs.get('quantize', False): - data.extend(np.float32(self.for_base).tobytes("C")) - data.extend(np.float32(self.scale_factor).tobytes("C")) - self.materialized_index = bytes(data) - - def _persist(self, path: str): - if self.materialized_index is None: - raise Exception('The index have not been created') - with open(path, "wb") as file: - file.write(self.materialized_index) - - -class BaseIndexPDXFlat: - def __init__( - self, - ndim: int, - metric: str, - normalize: bool = True, - ): - if ndim < 128: - raise Exception('`ndim` must be >= 128') - if ndim % 4 != 0: - raise Exception('`ndim` must be multiple of 4') - self.ndim = ndim - self.dtype = np.float32 - self.normalize = normalize - self.means: np.array = None - self.partitions: List[Partition] = [] - self.num_partitions: int = 0 - self.materialized_index = None - - # Separate the data in the PDX blocks - # TODO: Most probably this can be much more efficient - def _to_pdx(self, data: np.array, size_partition: int, _type='pdx', **kwargs): - self.partitions = [] - self.means = data.mean(axis=0, dtype=np.float32) - num_embeddings = len(data) - num_partitions = math.ceil(num_embeddings / size_partition) - indices = np.arange(0, num_embeddings, dtype=np.uint32) - - if kwargs.get('randomize', True): - np.random.shuffle(indices) - shuffle_index = 0 - for partition_index in range(num_partitions): - partition = Partition() - partition.num_embeddings = min(num_embeddings - shuffle_index, size_partition) - partition.indices = indices[shuffle_index: shuffle_index + partition.num_embeddings] - # Each partition is one block - partition.blocks.append(data[partition.indices, :]) - shuffle_index += partition.num_embeddings - self.partitions.append(partition) - self.num_partitions = len(self.partitions) - self._materialize_index(_type, **kwargs) - - # Materialize the index with the given layout - # TODO: Most probably this can be much more efficient - def _materialize_index(self, _type='pdx', **kwargs): - data = bytearray() - data.extend(np.int32(self.ndim).tobytes("C")) - - h_dims = int(self.ndim * PDXConstants.VERTICAL_PROPORTION_DIM) - v_dims = self.ndim - h_dims - if h_dims % PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING != 0: - h_dims = math.floor((h_dims / PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING) + 0.5) * PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING - v_dims = self.ndim - h_dims - h_dims_block = kwargs.get('h_dims_block', PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING) - if v_dims == 0: - h_dims = PDXConstants.HORIZONTAL_DIMENSIONS_GROUPING - v_dims = self.ndim - h_dims - data.extend(np.int32(v_dims).tobytes("C")) - data.extend(np.int32(h_dims).tobytes("C")) - - data.extend(np.int32(self.num_partitions).tobytes("C")) - - for i in range(self.num_partitions): - data.extend(np.int32(self.partitions[i].num_embeddings).tobytes("C")) - for i in range(self.num_partitions): - for p in range(len(self.partitions[i].blocks)): - if _type == 'pdx': - # In Bond we use fully decomposed - if kwargs.get('bond', False): - data.extend(self.partitions[i].blocks[p].tobytes("F")) - else: # In ADSampling we use a hybrid layout - vertical_block = self.partitions[i].blocks[p][:, :v_dims] - rows, _ = vertical_block.shape - data.extend(vertical_block.tobytes("F")) # PDX vertical block - pdx_h_block = self.partitions[i].blocks[p][:, v_dims:].reshape(rows, -1, h_dims_block).transpose(1, 0, 2).reshape(-1) - data.extend(pdx_h_block.tobytes("C")) # PDX horizontal block to improve sequential access - for i in range(self.num_partitions): - data.extend(self.partitions[i].indices.tobytes("C")) - data.extend(self.means.tobytes("C")) - is_ivf = False - data.extend(self.normalize.to_bytes(1, sys.byteorder)) - data.extend(is_ivf.to_bytes(1, sys.byteorder)) - self.materialized_index = bytes(data) - - def _persist(self, path: str): - if self.materialized_index is None: - raise Exception('The index have not been created') - with open(path, "wb") as file: - file.write(self.materialized_index) - diff --git a/python/pdxearch/index_core.py b/python/pdxearch/index_core.py deleted file mode 100644 index 8f7d6eb..0000000 --- a/python/pdxearch/index_core.py +++ /dev/null @@ -1,199 +0,0 @@ -import numpy as np -import struct - -from multiprocessing import cpu_count - -# -# Wrapper of FAISS FlatIVF index -# TODO: Support more distance metrics -# TODO: Replace FAISS implementation with a propietary one -# - -class PartitionsUtils: - @staticmethod - def write_centroids_and_labels(centroids, labels, path): - data = bytearray() - num_centroids = len(centroids) - data.extend(np.int32(num_centroids).tobytes("C")) - data.extend(centroids.tobytes("C")) - for l in labels: - labels_n = len(l) - data.extend(np.int32(labels_n).tobytes("C")) - data.extend(l.tobytes("C")) - with open(path + '.bin', "wb") as file: - file.write(bytes(data)) - - @staticmethod - def read_centroids_and_labels(ndim, f): - # Read number of centroids - num_centroids_bytes = f.read(4) - num_centroids = struct.unpack(' None: - IVF.faiss.omp_set_num_threads(cpu_count()) - if metric not in ["l2sq"]: - raise Exception("Distance metric not supported yet") - self.ndim = ndim - self.labels = [] - self.nbuckets = nbuckets - self.metric = metric - self.centroids = np.array([], dtype=np.float32) - - match self.metric: - case "l2sq": - quantizer = IVF.faiss.IndexFlatL2(int(self.ndim)) - case _: - quantizer = IVF.faiss.IndexFlatL2(int(self.ndim)) - self.index: IVF.faiss.IndexIVFFlat = IVF.faiss.IndexIVFFlat(quantizer, int(self.ndim), int(self.nbuckets)) - - - def train(self, data, **kwargs): - self.index.train(data, **kwargs) - - def add(self, data, **kwargs): - self.index.add(data, **kwargs) - self.centroids = self.index.quantizer.reconstruct_n(0, self.index.nlist) - self.fill_labels() - - def read_index(self, path): - with open(path + '.bin', 'rb') as f: - self.centroids, self.labels = self.read_centroids_and_labels(self.ndim, f) - self.nbuckets = len(self.labels) - # print('L1 buckets: ', self.nbuckets) - - def fill_labels(self): - for i in range(self.nbuckets): - _, cur_labels = self.get_inverted_list_metadata(i) - self.labels.append(cur_labels) - - def get_inverted_list_size(self, list_id): - return self.index.invlists.list_size(list_id) - - def get_inverted_list_ids(self, list_id): - return self.index.invlists.get_ids(list_id) - - def get_inverted_list_metadata(self, list_id): - num_list_embeddings = self.get_inverted_list_size(list_id) - list_ids = IVF.faiss.rev_swig_ptr(self.get_inverted_list_ids(list_id), num_list_embeddings) - return num_list_embeddings, list_ids - - def persist_core(self, path: str): - self.write_centroids_and_labels(self.centroids, self.labels, path) - - -class IVF2(PartitionsUtils): - import faiss - def __init__( - self, - ndim: int = 0, - metric: str = "l2sq", - nbuckets: int = 4, - nbuckets_l0: int = 32 - ) -> None: - IVF2.faiss.omp_set_num_threads(cpu_count()) - if metric not in ["l2sq"]: - raise Exception("Distance metric not supported yet") - self.ndim = ndim - self.nbuckets = nbuckets - self.nbuckets_l0 = nbuckets_l0 - self.metric = metric - self.labels = [] - self.labels_l0 = [] - self.centroids = np.array([], dtype=np.float32) - self.centroids_l0 = np.array([], dtype=np.float32) - - match self.metric: - case "l2sq": - quantizer = IVF2.faiss.IndexFlatL2(int(self.ndim)) - quantizer_l0 = IVF2.faiss.IndexFlatL2(int(self.ndim)) - case _: - quantizer = IVF2.faiss.IndexFlatL2(int(self.ndim)) - quantizer_l0 = IVF2.faiss.IndexFlatL2(int(self.ndim)) - self.index: IVF2.faiss.IndexIVFFlat = IVF2.faiss.IndexIVFFlat(quantizer, int(self.ndim), int(self.nbuckets)) - self.index_l0: IVF2.faiss.IndexIVFFlat = IVF2.faiss.IndexIVFFlat(quantizer_l0, int(self.ndim), int(self.nbuckets_l0)) - - def train(self, data, **kwargs): - self.index.train(data, **kwargs) - - def add(self, data, **kwargs): - self.index.add(data, **kwargs) - self.fill_labels(level=1) - - def read_index(self, path, path_l0): - with open(path + '.bin', 'rb') as f: - self.centroids, self.labels = self.read_centroids_and_labels(self.ndim, f) - self.nbuckets = len(self.labels) - - with open(path_l0 + '.bin', 'rb') as f: - self.centroids_l0, self.labels_l0 = self.read_centroids_and_labels(self.ndim, f) - self.nbuckets_l0 = len(self.labels_l0) - - # print('L0 buckets: ', self.nbuckets_l0) - # print('L1 buckets: ', self.nbuckets) - - def fill_labels(self, level=1): - if level == 1: - for i in range(self.nbuckets): - _, cur_labels = self.get_inverted_list_metadata(i, level=1) - self.labels.append(cur_labels) - else: - for i in range(self.nbuckets_l0): - _, cur_labels = self.get_inverted_list_metadata(i, level=0) - self.labels_l0.append(cur_labels) - - def train_add_l0(self, **kwargs): - self.centroids = self.index.quantizer.reconstruct_n(0, self.index.nlist) - # print(len(self.centroids), self.nbuckets_l0) - self.index_l0.train(self.centroids, **kwargs) - self.index_l0.add(self.centroids, **kwargs) - self.fill_labels(level=0) - self.centroids_l0 = self.index_l0.quantizer.reconstruct_n(0, self.index_l0.nlist) - - def get_inverted_list_size(self, list_id, level=1): - if level == 1: - return self.index.invlists.list_size(list_id) - else: - return self.index_l0.invlists.list_size(list_id) - - def get_inverted_list_ids(self, list_id, level=1): - if level == 1: - return self.index.invlists.get_ids(list_id) - else: - return self.index_l0.invlists.get_ids(list_id) - - def get_inverted_list_metadata(self, list_id, level=1): - num_list_embeddings = self.get_inverted_list_size(list_id, level) - list_ids = IVF2.faiss.rev_swig_ptr(self.get_inverted_list_ids(list_id, level), num_list_embeddings) - return num_list_embeddings, list_ids - - def persist_core(self, path: str, path_l0: str): - self.write_centroids_and_labels(self.centroids, self.labels, path) - self.write_centroids_and_labels(self.centroids_l0, self.labels_l0, path_l0) - IVF2.faiss.write_index(self.index, path) - -class HNSW: - pass diff --git a/python/pdxearch/index_factory.py b/python/pdxearch/index_factory.py index c06ad2a..5d02706 100644 --- a/python/pdxearch/index_factory.py +++ b/python/pdxearch/index_factory.py @@ -1,343 +1,226 @@ import numpy as np -from pdxearch.index_base import ( - BaseIndexPDXIVF, BaseIndexPDXIVF2, BaseIndexPDXFlat -) -from pdxearch.preprocessors import ( - ADSampling, Preprocessor -) -from pdxearch.compiled import ( - IndexADSamplingIVFFlat as _IndexPDXADSamplingIVFFlat, - IndexBONDIVFFlat as _IndexPDXBONDIVFFlat, - IndexBONDFlat as _IndexBONDFlat, - IndexADSamplingFlat as _IndexADSamplingFlat, - IndexPDXFlat as _IndexPDXFlat, - IndexADSamplingIVF2SQ8 as _IndexADSamplingIVF2SQ8, - IndexADSamplingIVF2Flat as _IndexADSamplingIVF2Flat -) -from pdxearch.constants import PDXConstants - -from pdxearch.predicate_evaluator import PredicateEvaluator - -# -# Python wrappers of the C++ lib -# - -class IndexPDXIVF2(BaseIndexPDXIVF2): - def __init__( - self, - *, - ndim: int = 128, - metric: str = "l2sq", - nbuckets: int = 256, - nbuckets_l0: int = 64, - normalize: bool = True - ) -> None: - super().__init__(ndim, metric, nbuckets, nbuckets_l0, normalize) - self.preprocessor = ADSampling(ndim) - self.pdx_index = _IndexADSamplingIVF2Flat() - self.pe = PredicateEvaluator() - - def preprocess(self, data, inplace: bool = True): - return self.preprocessor.preprocess(data, inplace=inplace, normalize=self.normalize) - - # Used in Python API (TODO) - def add_persist(self, data, path: str, matrix_path: str): - self._add(data) - self._train_add_l0() - self._to_pdx(data, _type='pdx', use_original_centroids=True) - self._persist(path) - self.preprocessor.store_metadata(matrix_path) - - # Used in Python API (TODO) - def persist(self, path: str, matrix_path: str): # TODO: Rename - self._persist(path) - self.preprocessor.store_metadata(matrix_path) - - def add(self, data): - self._add(data) - self._train_add_l0() - self._to_pdx(data, _type='pdx', use_original_centroids=True) - self.pdx_index.load(self.materialized_index, self.preprocessor.transformation_matrix) - - # Used in Python API (TODO) - def restore(self, path: str, matrix_path: str): - self.pdx_index.restore(path, matrix_path) - - def search(self, q: np.ndarray, knn: int, nprobe: int = 16): - return self.pdx_index.search(q, knn, nprobe) +from pdxearch.compiled import PDXIndex as _PDXIndex, load_index as _load_index +# from pdxearch.predicate_evaluator import PredicateEvaluator - def evaluate_predicate(self, passing_tuples_ids): - self.pe.evaluate_predicate(passing_tuples_ids, self.core_index.labels) +METRIC_MAP = {"l2sq": 0, "cosine": 1, "ip": 2} +DEFAULT_NPROBE = 32 - def filtered_search(self, q: np.ndarray, knn: int, nprobe: int = 16): - if len(self.pe.n_passing_tuples) == 0 or len(self.pe.selection_vector) == 0: - raise ValueError("Call .evaluate_predicate([]) first to generate the selection_vector of your predicate") - return self.pdx_index.filtered_search(q, knn, nprobe, self.pe.n_passing_tuples, self.pe.selection_vector) - def set_pruning_confidence(self, confidence: float): - self.pdx_index.set_pruning_confidence(confidence) +class IndexPDXIVF: + """Single-level IVF index (F32).""" - -class IndexPDXIVF2SQ8(BaseIndexPDXIVF2): def __init__( - self, - *, - ndim: int = 128, - metric: str = "l2sq", - nbuckets: int = 256, - nbuckets_l0: int = 64, - normalize: bool = True + self, + *, + num_dimensions: int, + distance_metric: str = "l2sq", + normalize: bool = True, + seed: int = 42, + num_clusters: int = 0, + sampling_fraction: float = 0.0, + kmeans_iters: int = 10, + hierarchical_indexing: bool = True, + n_threads: int = 0, ) -> None: - super().__init__(ndim, metric, nbuckets, nbuckets_l0, normalize) - self.preprocessor = ADSampling(ndim) - self.pdx_index = _IndexADSamplingIVF2SQ8() - self.pe = PredicateEvaluator() - - def preprocess(self, data, inplace: bool = True): - return self.preprocessor.preprocess(data, inplace=inplace, normalize=self.normalize) + self._index = _PDXIndex( + "pdx_f32", num_dimensions, METRIC_MAP[distance_metric], + seed, num_clusters, 0, normalize, sampling_fraction, kmeans_iters, + hierarchical_indexing, n_threads, + ) + # self.pe = PredicateEvaluator() - # Used in Python API (TODO) - def add_persist(self, data, path: str, matrix_path: str): - self._add(data) - self._train_add_l0() - self._to_pdx(data, _type='pdx-v4-h', quantize=True, use_original_centroids=True) - self._persist(path) - self.preprocessor.store_metadata(matrix_path) + def build(self, data: np.ndarray) -> None: + self._index.build_index(np.ascontiguousarray(data, dtype=np.float32)) - # Used in Python API (TODO) - def persist(self, path: str, matrix_path: str): # TODO: Rename - self._persist(path) - self.preprocessor.store_metadata(matrix_path) + def search(self, query: np.ndarray, knn: int, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.search(np.ascontiguousarray(query, dtype=np.float32), knn) - def add(self, data): - self._add(data) - self._train_add_l0() - self._to_pdx(data, _type='pdx-v4-h', quantize=True, use_original_centroids=True) - self.pdx_index.load(self.materialized_index, self.preprocessor.transformation_matrix) + def filtered_search(self, query: np.ndarray, knn: int, row_ids: np.ndarray, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.filtered_search( + np.ascontiguousarray(query, dtype=np.float32), knn, + np.ascontiguousarray(row_ids, dtype=np.uint64), + ) - # Used in Python API (TODO) - def restore(self, path: str, matrix_path: str): - self.pdx_index.restore(path, matrix_path) + def save(self, path: str) -> None: + self._index.save(path) - def search(self, q: np.ndarray, knn: int, nprobe: int = 16): - return self.pdx_index.search(q, knn, nprobe) + @property + def num_dimensions(self) -> int: + return self._index.get_num_dimensions() - def evaluate_predicate(self, passing_tuples_ids): - self.pe.evaluate_predicate(passing_tuples_ids, self.core_index.labels) + @property + def num_clusters(self) -> int: + return self._index.get_num_clusters() - def filtered_search(self, q: np.ndarray, knn: int, nprobe: int = 16): - if len(self.pe.n_passing_tuples) == 0 or len(self.pe.selection_vector) == 0: - raise ValueError("Call .evaluate_predicate([]) first to generate the selection_vector of your predicate") - return self.pdx_index.filtered_search(q, knn, nprobe, self.pe.n_passing_tuples, self.pe.selection_vector) + @property + def in_memory_size_bytes(self) -> int: + return self._index.get_in_memory_size_in_bytes() - def set_pruning_confidence(self, confidence: float): - self.pdx_index.set_pruning_confidence(confidence) +class IndexPDXIVFSQ8: + """Single-level IVF index (U8 scalar quantization).""" -class IndexPDXADSamplingIVFFlat(BaseIndexPDXIVF): - def __init__( - self, - *, ndim: int = 128, - metric: str = "l2sq", - nbuckets: int = 256, - normalize: bool = True - ) -> None: - super().__init__(ndim, metric, nbuckets, normalize) - self.preprocessor = ADSampling(ndim) - self.pdx_index = _IndexPDXADSamplingIVFFlat() - self.pe = PredicateEvaluator() - - def preprocess(self, data, inplace: bool = True): - return self.preprocessor.preprocess(data, inplace=inplace, normalize=self.normalize) - - def add_persist(self, data, path: str, matrix_path: str): - self._add(data) - # I don't need to pass the centroid preprocessor here, as the centroids are already rotated - # Because the training was done on the transformed vectors - self._to_pdx(data, _type='pdx', use_original_centroids=True) - self._persist(path) - self.preprocessor.store_metadata(matrix_path) - - def persist(self, path: str, matrix_path: str): # TODO: Rename - self._persist(path) - self.preprocessor.store_metadata(matrix_path) - - def add(self, data): - self._add(data) - # I don't need to pass the centroid preprocessor here, as the centroids are already rotated - # Because the training was done on the transformed vectors - self._to_pdx(data, _type='pdx', use_original_centroids=True) - self.pdx_index.load(self.materialized_index, self.preprocessor.transformation_matrix) - - def restore(self, path: str, matrix_path: str): - self.pdx_index.restore(path, matrix_path) - - def search(self, q: np.ndarray, knn: int, nprobe: int = 16): - return self.pdx_index.search(q, knn, nprobe) - - def evaluate_predicate(self, passing_tuples_ids): - self.pe.evaluate_predicate(passing_tuples_ids, self.core_index.labels) - - def filtered_search(self, q: np.ndarray, knn: int, nprobe: int = 16): - if len(self.pe.n_passing_tuples) == 0 or len(self.pe.selection_vector) == 0: - raise ValueError("Call .evaluate_predicate([]) first to generate the selection_vector of your predicate") - return self.pdx_index.filtered_search(q, knn, nprobe, self.pe.n_passing_tuples, self.pe.selection_vector) - - def set_pruning_confidence(self, confidence: float): - self.pdx_index.set_pruning_confidence(confidence) - -class IndexPDXBONDIVFFlat(BaseIndexPDXIVF): def __init__( - self, - *, ndim: int = 128, - metric: str = "l2sq", - nbuckets: int = 256, - normalize: bool = True + self, + *, + num_dimensions: int, + distance_metric: str = "l2sq", + normalize: bool = True, + seed: int = 42, + num_clusters: int = 0, + sampling_fraction: float = 0.0, + kmeans_iters: int = 10, + hierarchical_indexing: bool = True, + n_threads: int = 0, ) -> None: - super().__init__(ndim, metric, nbuckets, normalize) - self.preprocessor = Preprocessor() - self.pdx_index = _IndexPDXBONDIVFFlat() - self.pe = PredicateEvaluator() + self._index = _PDXIndex( + "pdx_u8", num_dimensions, METRIC_MAP[distance_metric], + seed, num_clusters, 0, normalize, sampling_fraction, kmeans_iters, + hierarchical_indexing, n_threads, + ) + # self.pe = PredicateEvaluator() - def preprocess(self, data, inplace: bool = True): - return self.preprocessor.preprocess(data, inplace=inplace, normalize=self.normalize) + def build(self, data: np.ndarray) -> None: + self._index.build_index(np.ascontiguousarray(data, dtype=np.float32)) - def add_persist(self, data, path: str): - self._add(data) - self._to_pdx(data, _type='pdx', use_original_centroids=True, bond=True) - self._persist(path) + def search(self, query: np.ndarray, knn: int, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.search(np.ascontiguousarray(query, dtype=np.float32), knn) - def persist(self, path: str): # TODO: Rename - self._persist(path) + def filtered_search(self, query: np.ndarray, knn: int, row_ids: np.ndarray, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.filtered_search( + np.ascontiguousarray(query, dtype=np.float32), knn, + np.ascontiguousarray(row_ids, dtype=np.uint64), + ) - def add(self, data): - self._add(data) - self._to_pdx(data, _type='pdx', use_original_centroids=True, bond=True) - self.pdx_index.load(self.materialized_index) + def save(self, path: str) -> None: + self._index.save(path) - def restore(self, path: str, matrix_path: str): - self.pdx_index.restore(path, matrix_path) + @property + def num_dimensions(self) -> int: + return self._index.get_num_dimensions() - def search(self, q: np.ndarray, knn: int, nprobe: int = 16): - return self.pdx_index.search(q, knn, nprobe) + @property + def num_clusters(self) -> int: + return self._index.get_num_clusters() - def evaluate_predicate(self, passing_tuples_ids): - self.pe.evaluate_predicate(passing_tuples_ids, self.core_index.labels) + @property + def in_memory_size_bytes(self) -> int: + return self._index.get_in_memory_size_in_bytes() - def filtered_search(self, q: np.ndarray, knn: int, nprobe: int = 16): - if len(self.pe.n_passing_tuples) == 0 or len(self.pe.selection_vector) == 0: - raise ValueError("Call .evaluate_predicate([]) first to generate the selection_vector of your predicate") - return self.pdx_index.filtered_search(q, knn, nprobe, self.pe.n_passing_tuples, self.pe.selection_vector) +class IndexPDXIVFTree: + """Two-level IVF index (F32).""" -class IndexPDXBONDFlat(BaseIndexPDXFlat): def __init__( - self, *, - ndim: int = 128, - metric: str = "l2sq", - normalize: bool = True + self, + *, + num_dimensions: int, + distance_metric: str = "l2sq", + normalize: bool = True, + seed: int = 42, + num_clusters: int = 0, + num_meso_clusters: int = 0, + sampling_fraction: float = 0.0, + kmeans_iters: int = 10, + hierarchical_indexing: bool = True, + n_threads: int = 0, ) -> None: - super().__init__(ndim=ndim, metric=metric, normalize=normalize) - self.preprocessor = Preprocessor() - self.pdx_index = _IndexBONDFlat() - self.block_sizes = PDXConstants.PDXEARCH_VECTOR_SIZE - self.pe = PredicateEvaluator() - - def preprocess(self, data, inplace: bool = True): - return self.preprocessor.preprocess(data, inplace=inplace, normalize=self.normalize) - - def add_persist(self, data, path: str): - self._to_pdx(data, self.block_sizes, bond=True) - self._persist(path) - - def persist(self, path: str): # TODO: Rename - self._persist(path) - - def add(self, data): - self._to_pdx(data, self.block_sizes, bond=True) - self.pdx_index.load(self.materialized_index) + self._index = _PDXIndex( + "pdx_tree_f32", num_dimensions, METRIC_MAP[distance_metric], + seed, num_clusters, num_meso_clusters, normalize, + sampling_fraction, kmeans_iters, hierarchical_indexing, n_threads, + ) + # self.pe = PredicateEvaluator() - def restore(self, path: str): - self.pdx_index.restore(path) + def build(self, data: np.ndarray) -> None: + self._index.build_index(np.ascontiguousarray(data, dtype=np.float32)) - def search(self, q: np.ndarray, knn: int): - return self.pdx_index.search(q, knn) + def search(self, query: np.ndarray, knn: int, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.search(np.ascontiguousarray(query, dtype=np.float32), knn) - def evaluate_predicate(self, passing_tuples_ids): - self.pe.evaluate_predicate(passing_tuples_ids, [x.indices for x in self.partitions]) + def filtered_search(self, query: np.ndarray, knn: int, row_ids: np.ndarray, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.filtered_search( + np.ascontiguousarray(query, dtype=np.float32), knn, + np.ascontiguousarray(row_ids, dtype=np.uint64), + ) - def filtered_search(self, q: np.ndarray, knn: int, nprobe: int = 16): - if len(self.pe.n_passing_tuples) == 0 or len(self.pe.selection_vector) == 0: - raise ValueError("Call .evaluate_predicate([]) first to generate the selection_vector of your predicate") - return self.pdx_index.filtered_search(q, knn, self.pe.n_passing_tuples, self.pe.selection_vector) + def save(self, path: str) -> None: + self._index.save(path) + @property + def num_dimensions(self) -> int: + return self._index.get_num_dimensions() -class IndexPDXADSamplingFlat(BaseIndexPDXFlat): - def __init__(self, *, ndim: int = 128, metric: str = "l2sq", normalize: bool = True) -> None: - super().__init__(ndim=ndim, metric=metric, normalize=normalize) - self.preprocessor = ADSampling(ndim) - self.pdx_index = _IndexADSamplingFlat() - self.block_sizes = PDXConstants.PDXEARCH_VECTOR_SIZE - self.pe = PredicateEvaluator() + @property + def num_clusters(self) -> int: + return self._index.get_num_clusters() - def preprocess(self, data: np.array, inplace: bool = True): - return self.preprocessor.preprocess(data, inplace=inplace, normalize=self.normalize) + @property + def in_memory_size_bytes(self) -> int: + return self._index.get_in_memory_size_in_bytes() - def add_persist(self, data: np.array, path: str, matrix_path: str): - self._to_pdx(data, self.block_sizes) - self._persist(path) - self.preprocessor.store_metadata(matrix_path) - def persist(self, path: str, matrix_path: str): # TODO: Rename - self._persist(path) - self.preprocessor.store_metadata(matrix_path) +class IndexPDXIVFTreeSQ8: + """Two-level IVF index (U8 scalar quantization).""" - def add(self, data): - self._to_pdx(data, self.block_sizes) - self.pdx_index.load(self.materialized_index, self.preprocessor.transformation_matrix) - - def restore(self, path: str, matrix_path: str): - self.pdx_index.restore(path, matrix_path) - - def search(self, q: np.ndarray, knn: int): - return self.pdx_index.search(q, knn) - - def set_pruning_confidence(self, confidence: float): - self.pdx_index.set_pruning_confidence(confidence) - - def evaluate_predicate(self, passing_tuples_ids): - self.pe.evaluate_predicate(passing_tuples_ids, [x.indices for x in self.partitions]) - - def filtered_search(self, q: np.ndarray, knn: int, nprobe: int = 16): - if len(self.pe.n_passing_tuples) == 0 or len(self.pe.selection_vector) == 0: - raise ValueError("Call .evaluate_predicate([]) first to generate the selection_vector of your predicate") - return self.pdx_index.filtered_search(q, knn, self.pe.n_passing_tuples, self.pe.selection_vector) - - -class IndexPDXFlat(BaseIndexPDXFlat): - def __init__(self, *, ndim: int = 128, metric: str = "l2sq", normalize: bool = True) -> None: - super().__init__(ndim, metric, normalize) - self.pdx_index = _IndexPDXFlat() - self.preprocessor = Preprocessor() - self.block_sizes = PDXConstants.PDX_VECTOR_SIZE - - def add_persist(self, data, path: str): - self._to_pdx(data, self.block_sizes, bond=True) - self._persist(path) - - def preprocess(self, data, inplace: bool = True): - return self.preprocessor.preprocess(data, inplace=inplace, normalize=self.normalize) - - def persist(self, path: str): # TODO: Rename - self._persist(path) - - def add(self, data): - self._to_pdx(data, self.block_sizes, bond=True) - self.pdx_index.load(self.materialized_index) - - def restore(self, path: str): - self.pdx_index.restore(path) - - def search(self, q: np.ndarray, knn: int): - return self.pdx_index.search(q, knn) \ No newline at end of file + def __init__( + self, + *, + num_dimensions: int, + distance_metric: str = "l2sq", + normalize: bool = True, + seed: int = 42, + num_clusters: int = 0, + num_meso_clusters: int = 0, + sampling_fraction: float = 0.0, + kmeans_iters: int = 10, + hierarchical_indexing: bool = True, + n_threads: int = 0, + ) -> None: + self._index = _PDXIndex( + "pdx_tree_u8", num_dimensions, METRIC_MAP[distance_metric], + seed, num_clusters, num_meso_clusters, normalize, + sampling_fraction, kmeans_iters, hierarchical_indexing, n_threads, + ) + # self.pe = PredicateEvaluator() + + def build(self, data: np.ndarray) -> None: + self._index.build_index(np.ascontiguousarray(data, dtype=np.float32)) + + def search(self, query: np.ndarray, knn: int, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.search(np.ascontiguousarray(query, dtype=np.float32), knn) + + def filtered_search(self, query: np.ndarray, knn: int, row_ids: np.ndarray, nprobe: int = DEFAULT_NPROBE): + self._index.set_nprobe(nprobe) + return self._index.filtered_search( + np.ascontiguousarray(query, dtype=np.float32), knn, + np.ascontiguousarray(row_ids, dtype=np.uint64), + ) + + def save(self, path: str) -> None: + self._index.save(path) + + @property + def num_dimensions(self) -> int: + return self._index.get_num_dimensions() + + @property + def num_clusters(self) -> int: + return self._index.get_num_clusters() + + @property + def in_memory_size_bytes(self) -> int: + return self._index.get_in_memory_size_in_bytes() + + +def load_index(path: str): + """Load a PDX index from a single file (auto-detects type).""" + return _load_index(path) diff --git a/python/pdxearch/preprocessors.py b/python/pdxearch/preprocessors.py deleted file mode 100644 index aab51fb..0000000 --- a/python/pdxearch/preprocessors.py +++ /dev/null @@ -1,65 +0,0 @@ -import numpy as np -from scipy.fft import dct -np.random.seed(42) - -from pdxearch.constants import PDXConstants -from abc import ABC, abstractmethod - -# -# Preprocessors (ADSampling and BSA) -# These classes includes the methods for data transformation and storing the respective metadata -# - - -class Preprocessor: - def preprocess(self, data: np.array, inplace=False, normalize=True): - if inplace: - data[:] = self.normalize(data) if normalize else data - else: - return self.normalize(data) if normalize else data - - def store_metadata(self, *args): - pass - - def normalize(self, data): - norms = np.linalg.norm(data, axis=1, keepdims=True) - norms[norms == 0] = 1 - return data/norms - - -class ADSampling(Preprocessor): - def __init__( - self, - ndim - ): - self.d = ndim - if PDXConstants.HAS_FFTW and self.d >= PDXConstants.D_THRESHOLD_FOR_DCT_ROTATION: - self.transformation_matrix = np.random.choice([-1.0, 1.0], size=(ndim)).astype(np.float32) - else: - self.transformation_matrix, _ = np.linalg.qr(np.random.randn(ndim, ndim).astype(np.float32)) - - def fjlt(self, X): - n, _ = X.shape - X = X * self.transformation_matrix - X = dct(X, norm='ortho', axis=1) - return X - - def preprocess(self, data: np.array, inplace=False, normalize=True): - if PDXConstants.HAS_FFTW and self.d >= PDXConstants.D_THRESHOLD_FOR_DCT_ROTATION: - if inplace: - data[:] = self.fjlt(self.normalize(data) if normalize else data) - else: - return self.fjlt(self.normalize(data) if normalize else data) - else: - if inplace: - data[:] = np.dot( - self.normalize(data) if normalize else data, - self.transformation_matrix) - else: - return np.dot( - self.normalize(data) if normalize else data, - self.transformation_matrix) - - def store_metadata(self, path: str): - with open(path, "wb") as file: - file.write(self.transformation_matrix.tobytes("C")) diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..992ead6 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Format C++ files using clang-format +# This script formats all .cpp, .h, and .hpp files in the project + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Formatting C++ files in SuperKMeans project..." +echo "Project root: $PROJECT_ROOT" + +REQUIRED_VERSION="18.1.8" + +# Check if clang-format is available +if ! command -v clang-format &> /dev/null; then + echo "Error: clang-format not found. Please install it first." + exit 1 +fi + +CURRENT_VERSION=$(clang-format --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +if [ "$CURRENT_VERSION" != "$REQUIRED_VERSION" ]; then + echo "Error: clang-format version $REQUIRED_VERSION required, but found $CURRENT_VERSION" + echo "Install the correct version: pip install clang-format==$REQUIRED_VERSION" + exit 1 +fi + +DIRECTORIES=( + "include" + "python" + "tests" + "benchmarks" + "examples" +) + +EXTENSIONS=("cpp" "h" "hpp") + +total_files=0 + +# Format files in each directory +for dir in "${DIRECTORIES[@]}"; do + dir_path="$PROJECT_ROOT/$dir" + + if [ ! -d "$dir_path" ]; then + echo "Warning: Directory $dir does not exist, skipping..." + continue + fi + + echo "Processing directory: $dir" + + for ext in "${EXTENSIONS[@]}"; do + while IFS= read -r -d '' file; do + echo " Formatting: ${file#$PROJECT_ROOT/}" + clang-format -i "$file" + total_files=$((total_files + 1)) + done < <(find "$dir_path" -type f -name "*.$ext" -print0) + done +done + +echo "" +echo "Done! Formatted $total_files file(s)." diff --git a/scripts/format_check.sh b/scripts/format_check.sh new file mode 100755 index 0000000..59354ea --- /dev/null +++ b/scripts/format_check.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# Check C++ file formatting using clang-format +# This script checks if all .cpp, .h, and .hpp files are properly formatted +# Exits with error code 1 if any files need formatting + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Checking C++ formatting in SuperKMeans project..." +echo "Project root: $PROJECT_ROOT" + +REQUIRED_VERSION="18.1.8" + +if ! command -v clang-format &> /dev/null; then + echo "Error: clang-format not found. Please install it first." + exit 1 +fi + +CURRENT_VERSION=$(clang-format --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +if [ "$CURRENT_VERSION" != "$REQUIRED_VERSION" ]; then + echo "Error: clang-format version $REQUIRED_VERSION required, but found $CURRENT_VERSION" + echo "Install the correct version: pip install clang-format==$REQUIRED_VERSION" + exit 1 +fi + +DIRECTORIES=( + "include" + "python" + "tests" + "benchmarks" + "examples" +) + +EXTENSIONS=("cpp" "h" "hpp") + +total_files=0 +unformatted_files=() + +for dir in "${DIRECTORIES[@]}"; do + dir_path="$PROJECT_ROOT/$dir" + + if [ ! -d "$dir_path" ]; then + echo "Warning: Directory $dir does not exist, skipping..." + continue + fi + + echo "Checking directory: $dir" + + for ext in "${EXTENSIONS[@]}"; do + while IFS= read -r -d '' file; do + total_files=$((total_files + 1)) + + # Run clang-format and compare with original + if ! diff -q <(clang-format "$file") "$file" > /dev/null 2>&1; then + echo " ✗ ${file#$PROJECT_ROOT/}" + unformatted_files+=("$file") + fi + done < <(find "$dir_path" -type f -name "*.$ext" -print0) + done +done + +echo "" +echo "Checked $total_files file(s)." + +# Report results +if [ ${#unformatted_files[@]} -eq 0 ]; then + echo "✓ All files are properly formatted!" + exit 0 +else + echo "✗ Found ${#unformatted_files[@]} file(s) that need formatting:" + for file in "${unformatted_files[@]}"; do + echo " - ${file#$PROJECT_ROOT/}" + done + echo "" + echo "Run './scripts/format.sh' to fix formatting issues." + exit 1 +fi diff --git a/scripts/tidy_check.sh b/scripts/tidy_check.sh new file mode 100755 index 0000000..b36d119 --- /dev/null +++ b/scripts/tidy_check.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +# Run clang-tidy on all C++ source files in the project +# Requires compile_commands.json (generated by cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON) +# Exits with error code 1 if any warnings are found + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Allow overriding the clang-tidy binary, e.g.: +# CLANG_TIDY=/opt/homebrew/opt/llvm@18/bin/clang-tidy ./scripts/tidy_check.sh +CLANG_TIDY="${CLANG_TIDY:-clang-tidy}" + +echo "Running clang-tidy on SuperKMeans project..." +echo "Project root: $PROJECT_ROOT" +echo "Using: $CLANG_TIDY ($($CLANG_TIDY --version 2>&1 | head -1))" + +if ! command -v "$CLANG_TIDY" &> /dev/null; then + echo "Error: $CLANG_TIDY not found. Please install it first." + exit 1 +fi + +# Check for compile_commands.json +COMPILE_DB="$PROJECT_ROOT/compile_commands.json" +if [ ! -f "$COMPILE_DB" ]; then + echo "Error: compile_commands.json not found." + echo "Generate it with: cmake . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON" + exit 1 +fi + +DIRECTORIES=( + "tests" + "examples" + "python" +) + +EXTENSIONS=("cpp") + +total_files=0 +files_with_warnings=0 + +for dir in "${DIRECTORIES[@]}"; do + dir_path="$PROJECT_ROOT/$dir" + + if [ ! -d "$dir_path" ]; then + echo "Warning: Directory $dir does not exist, skipping..." + continue + fi + + echo "Checking directory: $dir" + + for ext in "${EXTENSIONS[@]}"; do + while IFS= read -r -d '' file; do + total_files=$((total_files + 1)) + relative_file="${file#$PROJECT_ROOT/}" + + # Only check warnings from headers in include/superkmeans/ + header_warnings=$($CLANG_TIDY -p "$PROJECT_ROOT" "$file" 2>&1 | grep "warning:" | grep "include/superkmeans/" || true) + if [ -z "$header_warnings" ]; then + echo " ✓ $relative_file" + else + echo " ✗ $relative_file" + echo "$header_warnings" | head -50 + files_with_warnings=$((files_with_warnings + 1)) + fi + done < <(find "$dir_path" -type f -name "*.$ext" -not -name "generate_*" -print0) + done +done + +echo "" +echo "Checked $total_files file(s)." + +if [ "$files_with_warnings" -eq 0 ]; then + echo "✓ No clang-tidy warnings found!" + exit 0 +else + echo "✗ Found warnings in $files_with_warnings file(s)." + exit 1 +fi diff --git a/setup.py b/setup.py deleted file mode 100644 index 89f3fc4..0000000 --- a/setup.py +++ /dev/null @@ -1,133 +0,0 @@ -import os -import sys -import ctypes.util -from setuptools import setup - -from pybind11.setup_helpers import Pybind11Extension - -# -# This setup.py was based on USearch setup.py (https://github.com/unum-cloud/usearch/blob/main/setup.py) -# - -cxx = os.environ.get("CXX") -if cxx == "" or cxx is None: - raise Exception('Set CXX variable in your environment') - -compile_args = [] -link_args = [] -macros_args = [] - -is_linux: bool = sys.platform == "linux" -is_macos: bool = sys.platform == "darwin" -is_windows: bool = sys.platform == "win32" - -has_fftw = ctypes.util.find_library("fftw3f") is not None -if has_fftw: - macros_args.append(("HAS_FFTW", "1")) - -if is_windows: - raise Exception('Windows not yet implemented') - -# TODO: in intel sapphirerapids one have to force LLVM to use 512 registers -if is_linux: - compile_args.append("-std=c++17") - compile_args.append("-O3") - compile_args.append("-march=native") - compile_args.append("-fPIC") - compile_args.append("-Wno-unknown-pragmas") - compile_args.append("-fdiagnostics-color=always") - compile_args.append("-Wl,--unresolved-symbols=ignore-in-shared-libs") - link_args.append("-static-libstdc++") - if has_fftw: - # link_args.append("-lfftw3") - link_args.append("-lfftw3f") - -if is_macos: - compile_args.append("-std=c++17") - compile_args.append("-mmacosx-version-min=10.13") - compile_args.append("-O3") - compile_args.append("-march=native") - compile_args.append("-fPIC") - compile_args.append("-arch") - compile_args.append("arm64") # TODO: Currently not supporting non-ARM Macs - compile_args.append("-Wl,") - compile_args.append("-Wno-unknown-pragmas") - if has_fftw: - # link_args.append("-lfftw3") - link_args.append("-lfftw3f") - - -ext_modules = [ - Pybind11Extension( - "pdxearch.compiled", - sources=["python/lib.cpp"], - extra_compile_args=compile_args, - extra_link_args=link_args, - define_macros=macros_args, - language="c++", - ), -] - -include_dirs = [ - "extern", - "include", - "python", -] - -# TODO: We also require FAISS, but installing from PyPI is not ideal -install_requires = [ - "setuptools>=42", - "wheel", - "cmake>=3.22", - "pybind11", - "numpy", - "scipy" -] - -# Taken from Usearch setup.py (https://github.com/unum-cloud/usearch/blob/main/setup.py) -# With Clang, `setuptools` doesn't properly use the `language="c++"` argument we pass. -# The right thing would be to pass down `-x c++` to the compiler, before specifying the source files. -# This nasty workaround overrides the `CC` environment variable with the `CXX` variable. -cc_compiler_variable = os.environ.get("CC") -cxx_compiler_variable = os.environ.get("CXX") -if cxx_compiler_variable: - os.environ["CC"] = cxx_compiler_variable - -description = "Faster similarity search with PDX: A transposed data layout for vectors" -long_description = "" - -setup( - name="pdxearch", - version="0.3", - packages=["pdxearch"], - package_dir={"pdxearch": "python/pdxearch"}, - description=description, - author="CWI", - author_email="lxkr@cwi.nl", - url="https://github.com/cwida/pdxearch", - long_description=long_description, - long_description_content_type="text/markdown", - license="Apache-2.0", - classifiers=[ - "Development Status :: 1 - Planning", - "Natural Language :: English", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: Apache Software License", - "Programming Language :: C++", - "Programming Language :: Python :: 3 :: Only", - "Operating System :: MacOS", - "Operating System :: Unix", - # "Operating System :: Microsoft :: Windows", - "Topic :: Database :: Database Engines/Servers", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - ], - # cmdclass={"build_ext": build_ext}, - include_dirs=include_dirs, - ext_modules=ext_modules, - install_requires=install_requires, -) - -# Reset the CC environment variable, that we overrode earlier. -if cxx_compiler_variable and cc_compiler_variable: - os.environ["CC"] = cc_compiler_variable diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..dc8b128 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,60 @@ +include(FetchContent) +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +enable_testing() + +set(TEST_COMMON_LIBS + gtest + gtest_main + ${MKL_COMMON_LIBS} + ${BLAS_LINK_LIBRARIES} +) + +if (FFTW_FOUND) + list(APPEND TEST_COMMON_LIBS ${FFTW_FLOAT_LIB} ${FFTW_FLOAT_OPENMP_LIB}) +endif() + +# Utilities (not tests) +add_executable(generate_test_data.out generate_test_data.cpp) +target_link_libraries(generate_test_data.out PRIVATE ${MKL_COMMON_LIBS} ${BLAS_LINK_LIBRARIES}) + +add_executable(generate_test_ground_truth.out generate_test_ground_truth.cpp) +target_link_libraries(generate_test_ground_truth.out PRIVATE ${MKL_COMMON_LIBS} ${BLAS_LINK_LIBRARIES}) + +# Test executables +add_executable(test_distance_computers.out test_distance_computers.cpp) +target_link_libraries(test_distance_computers.out PRIVATE ${TEST_COMMON_LIBS}) + +add_executable(test_search.out test_search.cpp) +target_link_libraries(test_search.out PRIVATE ${TEST_COMMON_LIBS}) + +add_executable(test_serialization.out test_serialization.cpp) +target_link_libraries(test_serialization.out PRIVATE ${TEST_COMMON_LIBS}) + +add_executable(test_filtered_search.out test_filtered_search.cpp) +target_link_libraries(test_filtered_search.out PRIVATE ${TEST_COMMON_LIBS}) + +add_executable(test_index_properties.out test_index_properties.cpp) +target_link_libraries(test_index_properties.out PRIVATE ${TEST_COMMON_LIBS}) + +include(GoogleTest) +gtest_discover_tests(test_distance_computers.out) +gtest_discover_tests(test_search.out) +gtest_discover_tests(test_serialization.out) +gtest_discover_tests(test_filtered_search.out) +gtest_discover_tests(test_index_properties.out) + +add_custom_target(tests + DEPENDS + test_distance_computers.out + test_search.out + test_serialization.out + test_filtered_search.out + test_index_properties.out +) diff --git a/tests/generate_test_data.cpp b/tests/generate_test_data.cpp new file mode 100644 index 0000000..17807e9 --- /dev/null +++ b/tests/generate_test_data.cpp @@ -0,0 +1,32 @@ +#include +#include +#include + +#include "superkmeans/pdx/utils.h" + +int main() { + constexpr size_t N_TOTAL = 5500; + constexpr size_t D = 384; + constexpr size_t N_TRUE_CENTERS = 200; + constexpr float CLUSTER_STD = 5.0f; + constexpr float CENTER_SPREAD = 2.0f; + constexpr unsigned int SEED = 42; + + std::cerr << "Generating test data (" << N_TOTAL << " x " << D << ")...\n"; + auto data = + skmeans::MakeBlobs(N_TOTAL, D, N_TRUE_CENTERS, true, CLUSTER_STD, CENTER_SPREAD, SEED); + + std::string out_path = CMAKE_SOURCE_DIR "/tests/test_data.bin"; + std::ofstream out(out_path, std::ios::binary); + if (!out) { + std::cerr << "Error: Could not open " << out_path << " for writing\n"; + return 1; + } + out.write(reinterpret_cast(data.data()), data.size() * sizeof(float)); + out.close(); + + size_t size_mb = data.size() * sizeof(float) / 1024 / 1024; + std::cerr << "Saved " << out_path << " (" << size_mb << " MB)\n"; + std::cerr << "First 5000 rows = train, last 500 = queries\n"; + return 0; +} diff --git a/tests/generate_test_ground_truth.cpp b/tests/generate_test_ground_truth.cpp new file mode 100644 index 0000000..7371094 --- /dev/null +++ b/tests/generate_test_ground_truth.cpp @@ -0,0 +1,60 @@ +#undef HAS_FFTW + +#include +#include +#include + +#include "pdx/index.hpp" +#include "test_utils.hpp" + +int main() { + std::vector index_types = {"pdx_f32", "pdx_u8"}; + // TODO: add "pdx_tree_f32", "pdx_tree_u8" once tree index crash is fixed + std::vector dimensions = {384}; + std::vector nprobes = {1, 2, 4, 8, 16, 32, 64}; + + std::cout << std::fixed << std::setprecision(4); + std::cout << "// Ground truth recall@" << TestUtils::KNN << " values for test_search.cpp\n"; + std::cout << "// Generated by generate_test_ground_truth.out\n"; + std::cout << "// Copy-paste into RECALL_GROUND_TRUTH map:\n\n"; + + for (size_t d : dimensions) { + std::cerr << "Loading test data (d=" << d << ")...\n"; + auto data = TestUtils::LoadTestData(d); + auto gt = TestUtils::ComputeBruteForceKNN( + data.train.data(), + data.queries.data(), + TestUtils::N_TRAIN, + TestUtils::N_QUERIES, + d, + TestUtils::KNN + ); + + for (const auto& index_type : index_types) { + std::cerr << " Building " << index_type << " (d=" << d << ")...\n"; + auto index = + TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + uint32_t num_clusters = index->GetNumClusters(); + std::cerr << " Clusters: " << num_clusters << "\n"; + + std::cout << " // " << index_type << ", d=" << d << ", clusters=" << num_clusters + << "\n"; + + auto all_nprobes = nprobes; + all_nprobes.push_back(num_clusters); + for (size_t nprobe : all_nprobes) { + if (nprobe > num_clusters) + continue; + index->SetNProbe(nprobe); + float recall = TestUtils::ComputeAverageRecall( + *index, data.queries.data(), TestUtils::N_QUERIES, d, TestUtils::KNN, gt + ); + std::cout << " {{\"" << index_type << "\", " << d << ", " << nprobe << "}, " + << recall << "f},\n"; + } + } + } + + std::cerr << "Done.\n"; + return 0; +} diff --git a/tests/test_data.bin b/tests/test_data.bin new file mode 100644 index 0000000..ab5617c Binary files /dev/null and b/tests/test_data.bin differ diff --git a/tests/test_distance_computers.cpp b/tests/test_distance_computers.cpp new file mode 100644 index 0000000..15eaee6 --- /dev/null +++ b/tests/test_distance_computers.cpp @@ -0,0 +1,193 @@ +#include +#include +#include +#include + +#include "pdx/common.hpp" +#include "pdx/distance_computers/base_computers.hpp" +#include "pdx/distance_computers/scalar_computers.hpp" +#include "superkmeans/pdx/utils.h" + +namespace { + +class DistanceComputerTest : public ::testing::Test { + protected: + void SetUp() override {} +}; + +TEST_F(DistanceComputerTest, F32_Horizontal_SIMD_MatchesScalar) { + std::vector dimensions = {1, 7, 8, 15, 16, 31, 32, 63, 64, 128, 256, 384}; + const size_t n_pairs = 100; + + for (size_t d : dimensions) { + SCOPED_TRACE("d=" + std::to_string(d)); + auto v1 = skmeans::GenerateRandomVectors(n_pairs, d, -10.0f, 10.0f, 42); + auto v2 = skmeans::GenerateRandomVectors(n_pairs, d, -10.0f, 10.0f, 123); + + for (size_t i = 0; i < n_pairs; ++i) { + const float* a = v1.data() + i * d; + const float* b = v2.data() + i * d; + + float scalar = + PDX::ScalarComputer::Horizontal(a, b, d); + float simd = + PDX::DistanceComputer::Horizontal(a, b, d); + + float rel_error = std::abs(scalar - simd) / std::max(scalar, 1e-6f); + EXPECT_LT(rel_error, 1e-5f) + << "pair " << i << ": scalar=" << scalar << ", simd=" << simd; + } + } +} + +TEST_F(DistanceComputerTest, F32_Vertical_SIMD_MatchesScalar) { + std::vector dimensions = {64, 128, 256, 384}; + std::vector vector_counts = {32, 64, 128}; + + for (size_t d : dimensions) { + for (size_t n : vector_counts) { + SCOPED_TRACE("d=" + std::to_string(d) + ", n=" + std::to_string(n)); + + auto query_vec = skmeans::GenerateRandomVectors(1, d, -10.0f, 10.0f, 42); + // Transposed layout: d rows of n elements + auto transposed = skmeans::GenerateRandomVectors(d, n, -10.0f, 10.0f, 123); + + std::vector scalar_dists(n, 0.0f); + std::vector simd_dists(n, 0.0f); + + PDX::ScalarComputer::Vertical( + query_vec.data(), transposed.data(), n, n, 0, d, scalar_dists.data() + ); + PDX::DistanceComputer::Vertical( + query_vec.data(), transposed.data(), n, n, 0, d, simd_dists.data(), nullptr + ); + + for (size_t i = 0; i < n; ++i) { + float rel_error = + std::abs(scalar_dists[i] - simd_dists[i]) / std::max(scalar_dists[i], 1e-6f); + EXPECT_LT(rel_error, 1e-5f) + << "vec " << i << ": scalar=" << scalar_dists[i] << ", simd=" << simd_dists[i]; + } + } + } +} + +TEST_F(DistanceComputerTest, U8_Horizontal_SIMD_MatchesScalar) { + std::vector dimensions = {8, 16, 32, 64, 128, 256, 384}; + const size_t n_pairs = 100; + std::mt19937 rng(42); + + for (size_t d : dimensions) { + SCOPED_TRACE("d=" + std::to_string(d)); + + for (size_t i = 0; i < n_pairs; ++i) { + std::vector data(d); + std::vector query(d); + for (size_t j = 0; j < d; ++j) { + data[j] = rng() % 256; + query[j] = rng() % 256; + } + + auto scalar = PDX::ScalarComputer::Horizontal( + query.data(), data.data(), d + ); + auto simd = PDX::DistanceComputer::Horizontal( + query.data(), data.data(), d + ); + + float rel_error = std::abs(static_cast(scalar) - static_cast(simd)) / + std::max(static_cast(scalar), 1.0f); + EXPECT_LT(rel_error, 0.01f) + << "pair " << i << ": scalar=" << scalar << ", simd=" << simd; + } + } +} + +TEST_F(DistanceComputerTest, U8_Vertical_SIMD_MatchesScalar) { + std::vector dimensions = {64, 128, 256, 384}; + std::vector vector_counts = {32, 64, 128}; + std::mt19937 rng(42); + + for (size_t d : dimensions) { + for (size_t n : vector_counts) { + SCOPED_TRACE("d=" + std::to_string(d) + ", n=" + std::to_string(n)); + + std::vector query(d); + for (size_t j = 0; j < d; ++j) { + query[j] = rng() % 256; + } + // Interleaved layout: d * n bytes, groups of 4 dims interleaved per vector + std::vector transposed(d * n); + for (size_t j = 0; j < d * n; ++j) { + transposed[j] = rng() % 256; + } + + std::vector scalar_dists(n, 0); + std::vector simd_dists(n, 0); + + PDX::ScalarComputer::Vertical( + query.data(), transposed.data(), n, n, 0, d, scalar_dists.data(), nullptr + ); + PDX::DistanceComputer::Vertical( + query.data(), transposed.data(), n, n, 0, d, simd_dists.data(), nullptr + ); + + for (size_t i = 0; i < n; ++i) { + float rel_error = + std::abs( + static_cast(scalar_dists[i]) - static_cast(simd_dists[i]) + ) / + std::max(static_cast(scalar_dists[i]), 1.0f); + EXPECT_LT(rel_error, 0.01f) + << "vec " << i << ": scalar=" << scalar_dists[i] << ", simd=" << simd_dists[i]; + } + } + } +} + +TEST_F(DistanceComputerTest, FlipSign_SIMD_MatchesScalar) { + std::vector dimensions = {1, 7, 8, 15, 16, 31, 32, 63, 64, 128, 256, 384}; + + for (size_t d : dimensions) { + SCOPED_TRACE("d=" + std::to_string(d)); + + std::vector data(d); + std::vector masks(d); + skmeans::GenerateRandomDataWithMasks(data.data(), masks.data(), d, 0.5f, 42); + + std::vector scalar_out(d); + std::vector simd_out(d); + + PDX::ScalarComputer::FlipSign( + data.data(), scalar_out.data(), masks.data(), d + ); + PDX::DistanceComputer::FlipSign( + data.data(), simd_out.data(), masks.data(), d + ); + + for (size_t i = 0; i < d; ++i) { + float rel_error = + std::abs(scalar_out[i] - simd_out[i]) / std::max(std::abs(scalar_out[i]), 1e-6f); + EXPECT_LT(rel_error, 0.01f) + << "idx " << i << ": scalar=" << scalar_out[i] << ", simd=" << simd_out[i]; + } + } +} + +TEST_F(DistanceComputerTest, F32_SelfDistanceIsZero) { + std::vector dimensions = {64, 128, 256, 384}; + + for (size_t d : dimensions) { + SCOPED_TRACE("d=" + std::to_string(d)); + auto vectors = skmeans::GenerateRandomVectors(10, d, -10.0f, 10.0f, 42); + + for (size_t i = 0; i < 10; ++i) { + const float* v = vectors.data() + i * d; + float dist = + PDX::DistanceComputer::Horizontal(v, v, d); + EXPECT_LT(dist, 1e-6f) << "self-distance should be ~0 at d=" << d; + } + } +} + +} // namespace diff --git a/tests/test_filtered_search.cpp b/tests/test_filtered_search.cpp new file mode 100644 index 0000000..c481b51 --- /dev/null +++ b/tests/test_filtered_search.cpp @@ -0,0 +1,204 @@ +#undef HAS_FFTW + +#include +#include +#include +#include +#include +#include +#include + +#include "pdx/index.hpp" +#include "test_utils.hpp" + +namespace { + +class FilteredSearchTest : public ::testing::TestWithParam { + protected: + void SetUp() override {} +}; + +TEST_P(FilteredSearchTest, FilteredResultsAreSubsetOfPassingIds) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + index->SetNProbe(16); + + // Random 50% filter + std::mt19937 rng(42); + std::vector passing_ids; + for (size_t i = 0; i < TestUtils::N_TRAIN; ++i) { + if (rng() % 2 == 0) { + passing_ids.push_back(i); + } + } + std::unordered_set passing_set(passing_ids.begin(), passing_ids.end()); + + for (size_t q = 0; q < 50; ++q) { + auto results = + index->FilteredSearch(data.queries.data() + q * d, TestUtils::KNN, passing_ids); + for (const auto& r : results) { + EXPECT_TRUE(passing_set.count(r.index)) + << "Result ID " << r.index << " not in passing set (query " << q << ")"; + } + } +} + +TEST_P(FilteredSearchTest, FilteredSearchMatchesUnfilteredWhenAllPass) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + index->SetNProbe(16); + + // All IDs pass + std::vector all_ids(TestUtils::N_TRAIN); + std::iota(all_ids.begin(), all_ids.end(), 0); + + for (size_t q = 0; q < 20; ++q) { + auto unfiltered = index->Search(data.queries.data() + q * d, TestUtils::KNN); + auto filtered = index->FilteredSearch(data.queries.data() + q * d, TestUtils::KNN, all_ids); + + ASSERT_EQ(unfiltered.size(), filtered.size()) << "Size mismatch for query " << q; + for (size_t i = 0; i < unfiltered.size(); ++i) { + EXPECT_EQ(unfiltered[i].index, filtered[i].index) + << "ID mismatch at query " << q << " position " << i; + } + } +} + +TEST_P(FilteredSearchTest, EmptyFilterReturnsEmpty) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + index->SetNProbe(16); + + std::vector empty_ids; + auto results = index->FilteredSearch(data.queries.data(), TestUtils::KNN, empty_ids); + EXPECT_TRUE(results.empty()); +} + +TEST_P(FilteredSearchTest, FilteredRecallMonotonicallyIncreasesWithNProbe) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + + // 50% random filter + std::mt19937 rng(42); + std::vector passing_ids; + for (size_t i = 0; i < TestUtils::N_TRAIN; ++i) { + if (rng() % 2 == 0) { + passing_ids.push_back(i); + } + } + + // Brute-force GT on filtered subset + std::vector filtered_train(passing_ids.size() * d); + for (size_t i = 0; i < passing_ids.size(); ++i) { + std::memcpy(&filtered_train[i * d], &data.train[passing_ids[i] * d], d * sizeof(float)); + } + auto gt = TestUtils::ComputeBruteForceKNN( + filtered_train.data(), + data.queries.data(), + passing_ids.size(), + TestUtils::N_QUERIES, + d, + TestUtils::KNN + ); + // Map GT indices back to original IDs + for (auto& query_gt : gt.indices) { + for (auto& idx : query_gt) { + idx = static_cast(passing_ids[idx]); + } + } + + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + + uint32_t num_clusters = index->GetNumClusters(); + std::vector nprobes = {1, 4, 16, 64}; + nprobes.push_back(num_clusters); + nprobes.erase( + std::remove_if( + nprobes.begin(), nprobes.end(), [num_clusters](size_t p) { return p > num_clusters; } + ), + nprobes.end() + ); + std::sort(nprobes.begin(), nprobes.end()); + nprobes.erase(std::unique(nprobes.begin(), nprobes.end()), nprobes.end()); + + float prev_recall = 0.0f; + for (size_t nprobe : nprobes) { + index->SetNProbe(nprobe); + float total_recall = 0.0f; + for (size_t q = 0; q < TestUtils::N_QUERIES; ++q) { + auto results = + index->FilteredSearch(data.queries.data() + q * d, TestUtils::KNN, passing_ids); + total_recall += TestUtils::ComputeRecall(results, gt.indices[q], TestUtils::KNN); + } + float recall = total_recall / static_cast(TestUtils::N_QUERIES); + + EXPECT_GE(recall, prev_recall - 0.01f) << "Filtered recall decreased from " << prev_recall + << " to " << recall << " at nprobe=" << nprobe; + prev_recall = recall; + } +} + +TEST_P(FilteredSearchTest, SingleIdFilterReturnsTheId) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + index->SetNProbe(0); + + std::mt19937 rng(42); + std::uniform_int_distribution dist(0, TestUtils::N_TRAIN - 1); + + for (size_t q = 0; q < 50; ++q) { + size_t target_id = dist(rng); + std::vector passing_ids = {target_id}; + auto results = index->FilteredSearch(data.queries.data() + q * d, 20, passing_ids); + ASSERT_EQ(results.size(), 1u) << "Expected exactly 1 result for query " << q; + EXPECT_EQ(results[0].index, static_cast(target_id)) << "Wrong ID for query " << q; + } +} + +TEST_P(FilteredSearchTest, TinyFilterExhaustiveReturnsAllPassingIds) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + index->SetNProbe(0); + + std::mt19937 rng(42); + std::vector passing_ids; + std::uniform_int_distribution dist(0, TestUtils::N_TRAIN - 1); + std::unordered_set seen; + while (passing_ids.size() < 20) { + size_t id = dist(rng); + if (seen.insert(id).second) { + passing_ids.push_back(id); + } + } + std::unordered_set passing_set(passing_ids.begin(), passing_ids.end()); + + for (size_t q = 0; q < 50; ++q) { + auto results = index->FilteredSearch(data.queries.data() + q * d, 20, passing_ids); + EXPECT_EQ(results.size(), 20u) << "Expected all 20 passing IDs for query " << q; + for (const auto& r : results) { + EXPECT_TRUE(passing_set.count(r.index)) + << "Result ID " << r.index << " not in passing set (query " << q << ")"; + } + } +} + +INSTANTIATE_TEST_SUITE_P( + AllIndexTypes, + FilteredSearchTest, + // TODO: add tree indexes once crash is fixed + ::testing::Values("pdx_f32", "pdx_u8"), + [](const ::testing::TestParamInfo& info) { return info.param; } +); + +} // namespace diff --git a/tests/test_index_properties.cpp b/tests/test_index_properties.cpp new file mode 100644 index 0000000..d620d69 --- /dev/null +++ b/tests/test_index_properties.cpp @@ -0,0 +1,298 @@ +#include +#include +#include +#include +#include + +#include "pdx/index.hpp" +#include "superkmeans/pdx/utils.h" +#include "test_utils.hpp" + +namespace { + +class IndexPropertiesTest : public ::testing::TestWithParam { + protected: + void SetUp() override {} +}; + +TEST_P(IndexPropertiesTest, ClusterRowIdsCoverAllPoints) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + + std::unordered_set all_ids; + uint32_t num_clusters = index->GetNumClusters(); + for (uint32_t c = 0; c < num_clusters; ++c) { + auto ids = index->GetClusterRowIds(c); + for (auto id : ids) { + EXPECT_TRUE(all_ids.insert(id).second) + << "Duplicate row ID " << id << " in cluster " << c; + } + } + EXPECT_EQ(all_ids.size(), TestUtils::N_TRAIN); + + for (size_t i = 0; i < TestUtils::N_TRAIN; ++i) { + EXPECT_TRUE(all_ids.count(static_cast(i))) << "Missing row ID " << i; + } +} + +TEST_P(IndexPropertiesTest, ClusterSizeMatchesRowIdCount) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + + uint32_t num_clusters = index->GetNumClusters(); + for (uint32_t c = 0; c < num_clusters; ++c) { + auto ids = index->GetClusterRowIds(c); + EXPECT_EQ(index->GetClusterSize(c), ids.size()) << "Size mismatch for cluster " << c; + } +} + +TEST_P(IndexPropertiesTest, GetNumDimensionsMatchesInput) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + EXPECT_EQ(index->GetNumDimensions(), d); +} + +TEST_P(IndexPropertiesTest, InMemorySizeIsPositive) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + EXPECT_GT(index->GetInMemorySizeInBytes(), 0u); +} + +TEST_P(IndexPropertiesTest, KnnLargerThanDataReturnsAvailable) { + std::string index_type = GetParam(); + size_t d = 128; + + // Build a tiny index with 5 points + auto data = TestUtils::LoadTestData(d); + size_t tiny_n = 5; + auto index = TestUtils::BuildIndex(index_type, data.train.data(), tiny_n, d); + index->SetNProbe(index->GetNumClusters()); + + auto results = index->Search(data.queries.data(), 100); + EXPECT_LE(results.size(), tiny_n); + EXPECT_GT(results.size(), 0u); +} + +TEST_P(IndexPropertiesTest, NumClustersMatchesConfig) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + + for (uint32_t requested : {10, 50, 100}) { + SCOPED_TRACE("num_clusters=" + std::to_string(requested)); + + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(d), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = TestUtils::SEED, + .num_clusters = requested, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + + std::unique_ptr index; + if (index_type == "pdx_f32") { + auto p = std::make_unique(config); + p->BuildIndex(data.train.data(), TestUtils::N_TRAIN); + index = std::move(p); + } else { + auto p = std::make_unique(config); + p->BuildIndex(data.train.data(), TestUtils::N_TRAIN); + index = std::move(p); + } + + EXPECT_EQ(index->GetNumClusters(), requested); + + // nprobe = num_clusters, num_clusters + 1, and 0 (all) must give identical recall + auto gt = TestUtils::ComputeBruteForceKNN( + data.train.data(), data.queries.data(), TestUtils::N_TRAIN, 50, d, TestUtils::KNN + ); + + auto measure_recall = [&](uint32_t nprobe) { + index->SetNProbe(nprobe); + float total = 0.0f; + for (size_t q = 0; q < 50; ++q) { + auto results = index->Search(data.queries.data() + q * d, TestUtils::KNN); + total += TestUtils::ComputeRecall(results, gt.indices[q], TestUtils::KNN); + } + return total; + }; + + float recall_exact = measure_recall(requested); + float recall_over = measure_recall(requested + 1); + float recall_zero = measure_recall(0); + + EXPECT_FLOAT_EQ(recall_exact, recall_over) + << "nprobe=" << requested << " vs nprobe=" << requested + 1; + EXPECT_FLOAT_EQ(recall_exact, recall_zero) << "nprobe=" << requested << " vs nprobe=0"; + } +} + +INSTANTIATE_TEST_SUITE_P( + AllIndexTypes, + IndexPropertiesTest, + ::testing::Values("pdx_f32", "pdx_u8", "pdx_tree_f32", "pdx_tree_u8"), + [](const ::testing::TestParamInfo& info) { return info.param; } +); + +// --- Edge Case Tests --- + +class EdgeCaseTest : public ::testing::TestWithParam {}; + +TEST_P(EdgeCaseTest, SinglePointIndex) { + std::string index_type = GetParam(); + constexpr size_t d = 128; + + auto all_data = skmeans::MakeBlobs(1, d, 1, true, 5.0f, 2.0f, 42); + + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(d), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = TestUtils::SEED, + .num_clusters = 1, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + auto index = TestUtils::BuildIndexWithConfig(index_type, config, all_data.data(), 1); + ASSERT_NE(index, nullptr); + + EXPECT_EQ(index->GetNumClusters(), 1u); + EXPECT_EQ(index->GetClusterSize(0), 1u); + + index->SetNProbe(0); + auto results = index->Search(all_data.data(), 10); + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].index, 0u); +} + +TEST_P(EdgeCaseTest, TwoPointIndex) { + std::string index_type = GetParam(); + constexpr size_t d = 128; + + auto all_data = skmeans::MakeBlobs(2, d, 2, true, 5.0f, 2.0f, 42); + + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(d), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = TestUtils::SEED, + .num_clusters = 1, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + auto index = TestUtils::BuildIndexWithConfig(index_type, config, all_data.data(), 2); + ASSERT_NE(index, nullptr); + + index->SetNProbe(0); + // Use first point as query + auto results = index->Search(all_data.data(), 2); + EXPECT_EQ(results.size(), 2u); + + // Results should be sorted by distance + if (results.size() == 2) { + EXPECT_LE(results[0].distance, results[1].distance + 1e-6f); + } +} + +TEST_P(EdgeCaseTest, NumClustersExceedsNumPointsThrows) { + std::string index_type = GetParam(); + constexpr size_t d = 128; + constexpr size_t n = 10; + + auto all_data = skmeans::MakeBlobs(n, d, 5, true, 5.0f, 2.0f, 42); + + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(d), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = TestUtils::SEED, + .num_clusters = 100, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + + EXPECT_THROW( + TestUtils::BuildIndexWithConfig(index_type, config, all_data.data(), n), + std::invalid_argument + ); +} + +INSTANTIATE_TEST_SUITE_P( + EdgeCases, + EdgeCaseTest, + ::testing::Values("pdx_f32", "pdx_u8"), + [](const ::testing::TestParamInfo& info) { return info.param; } +); + +// --- Config Validation Tests --- + +TEST(ConfigValidationTest, ZeroDimensionsThrows) { + PDX::PDXIndexConfig config{.num_dimensions = 0}; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config); }()), std::invalid_argument); +} + +TEST(ConfigValidationTest, DimensionsExceedsMaxThrows) { + PDX::PDXIndexConfig config{.num_dimensions = static_cast(PDX::PDX_MAX_DIMS + 1)}; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config); }()), std::invalid_argument); +} + +TEST(ConfigValidationTest, InvalidSamplingFractionThrows) { + PDX::PDXIndexConfig config_neg{.num_dimensions = 128, .sampling_fraction = -0.1f}; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config_neg); }()), std::invalid_argument); + + PDX::PDXIndexConfig config_over{.num_dimensions = 128, .sampling_fraction = 1.5f}; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config_over); }()), std::invalid_argument); +} + +TEST(ConfigValidationTest, MesoClustersNotSmallerThanClustersThrows) { + PDX::PDXIndexConfig config{ + .num_dimensions = 128, + .num_clusters = 10, + .num_meso_clusters = 10, + }; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config); }()), std::invalid_argument); + + PDX::PDXIndexConfig config2{ + .num_dimensions = 128, + .num_clusters = 10, + .num_meso_clusters = 15, + }; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config2); }()), std::invalid_argument); +} + +TEST(ConfigValidationTest, ZeroKmeansItersThrows) { + PDX::PDXIndexConfig config{.num_dimensions = 128, .kmeans_iters = 0}; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config); }()), std::invalid_argument); +} + +TEST(ConfigValidationTest, KmeansIters100OrMoreThrows) { + PDX::PDXIndexConfig config{.num_dimensions = 128, .kmeans_iters = 100}; + EXPECT_THROW(([&]() { auto idx = PDX::PDXIndexF32(config); }()), std::invalid_argument); +} + +TEST(ConfigValidationTest, ValidConfigDoesNotThrow) { + PDX::PDXIndexConfig config{ + .num_dimensions = 128, + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = 42, + .num_clusters = 10, + .num_meso_clusters = 3, + .normalize = true, + .sampling_fraction = 0.5f, + .kmeans_iters = 10, + .hierarchical_indexing = true, + }; + EXPECT_NO_THROW(([&]() { auto idx = PDX::PDXIndexF32(config); }())); +} + +} // namespace diff --git a/tests/test_search.cpp b/tests/test_search.cpp new file mode 100644 index 0000000..1676e6f --- /dev/null +++ b/tests/test_search.cpp @@ -0,0 +1,569 @@ +#undef HAS_FFTW + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pdx/index.hpp" +#include "superkmeans/pdx/utils.h" +#include "test_utils.hpp" + +namespace { + +// Generated by generate_test_ground_truth.out — DO NOT EDIT MANUALLY +// clang-format off +const std::map, float> RECALL_GROUND_TRUTH = { + // pdx_f32, d=384, clusters=142 + {{"pdx_f32", 384, 1}, 0.4086f}, + {{"pdx_f32", 384, 2}, 0.4556f}, + {{"pdx_f32", 384, 4}, 0.5040f}, + {{"pdx_f32", 384, 8}, 0.5672f}, + {{"pdx_f32", 384, 16}, 0.6434f}, + {{"pdx_f32", 384, 32}, 0.7514f}, + {{"pdx_f32", 384, 64}, 0.8894f}, + {{"pdx_f32", 384, 142}, 0.9968f}, + // pdx_u8, d=384, clusters=142 + {{"pdx_u8", 384, 1}, 0.4072f}, + {{"pdx_u8", 384, 2}, 0.4542f}, + {{"pdx_u8", 384, 4}, 0.5026f}, + {{"pdx_u8", 384, 8}, 0.5656f}, + {{"pdx_u8", 384, 16}, 0.6416f}, + {{"pdx_u8", 384, 32}, 0.7484f}, + {{"pdx_u8", 384, 64}, 0.8830f}, + {{"pdx_u8", 384, 142}, 0.9780f}, +}; +// clang-format on + +static constexpr size_t D = 384; +static constexpr float RECALL_TOLERANCE = 0.02f; +static constexpr float QUANTIZATION_RECALL_TOLERANCE = 0.05f; + +class SearchTest : public ::testing::TestWithParam { + protected: + void SetUp() override {} +}; + +TEST_P(SearchTest, RecallMatchesGroundTruth) { + std::string index_type = GetParam(); + size_t d = D; + auto data = TestUtils::LoadTestData(d); + auto gt = TestUtils::ComputeBruteForceKNN( + data.train.data(), + data.queries.data(), + TestUtils::N_TRAIN, + TestUtils::N_QUERIES, + d, + TestUtils::KNN + ); + + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + ASSERT_NE(index, nullptr); + index->SetNProbe(16); + + float recall = TestUtils::ComputeAverageRecall( + *index, data.queries.data(), TestUtils::N_QUERIES, d, TestUtils::KNN, gt + ); + + auto key = std::make_tuple(index_type, d, size_t{16}); + auto it = RECALL_GROUND_TRUTH.find(key); + if (it != RECALL_GROUND_TRUTH.end()) { + float expected = it->second; + EXPECT_NEAR(recall, expected, RECALL_TOLERANCE) + << "Recall regression: got " << recall << ", expected ~" << expected; + } +} + +TEST_P(SearchTest, RecallMonotonicallyIncreasesWithNProbe) { + std::string index_type = GetParam(); + size_t d = D; + auto data = TestUtils::LoadTestData(d); + auto gt = TestUtils::ComputeBruteForceKNN( + data.train.data(), + data.queries.data(), + TestUtils::N_TRAIN, + TestUtils::N_QUERIES, + d, + TestUtils::KNN + ); + + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + ASSERT_NE(index, nullptr); + + uint32_t num_clusters = index->GetNumClusters(); + std::vector nprobes = {1, 2, 4, 8, 16, 32, 64}; + nprobes.push_back(num_clusters); + nprobes.erase( + std::remove_if( + nprobes.begin(), nprobes.end(), [num_clusters](size_t p) { return p > num_clusters; } + ), + nprobes.end() + ); + std::sort(nprobes.begin(), nprobes.end()); + nprobes.erase(std::unique(nprobes.begin(), nprobes.end()), nprobes.end()); + + float prev_recall = 0.0f; + for (size_t nprobe : nprobes) { + index->SetNProbe(nprobe); + float recall = TestUtils::ComputeAverageRecall( + *index, data.queries.data(), TestUtils::N_QUERIES, d, TestUtils::KNN, gt + ); + + EXPECT_GE(recall, prev_recall - 0.01f) << "Recall decreased from " << prev_recall << " to " + << recall << " when increasing nprobe to " << nprobe; + prev_recall = recall; + } +} + +TEST_P(SearchTest, ResultsAreSortedByDistance) { + std::string index_type = GetParam(); + size_t d = D; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + index->SetNProbe(16); + + for (size_t q = 0; q < std::min(50, TestUtils::N_QUERIES); ++q) { + auto results = index->Search(data.queries.data() + q * d, TestUtils::KNN); + for (size_t i = 1; i < results.size(); ++i) { + EXPECT_LE(results[i - 1].distance, results[i].distance + 1e-6f) + << "Results not sorted for query " << q << " at position " << i; + } + } +} + +TEST_P(SearchTest, ResultIdsAreUnique) { + std::string index_type = GetParam(); + size_t d = D; + auto data = TestUtils::LoadTestData(d); + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + index->SetNProbe(16); + + for (size_t q = 0; q < std::min(50, TestUtils::N_QUERIES); ++q) { + auto results = index->Search(data.queries.data() + q * d, TestUtils::KNN); + std::unordered_set seen; + for (const auto& r : results) { + EXPECT_TRUE(seen.insert(r.index).second) + << "Duplicate ID " << r.index << " in query " << q; + } + } +} + +TEST_P(SearchTest, ExhaustiveSearchMatchesGroundTruth) { + std::string index_type = GetParam(); + size_t d = D; + auto data = TestUtils::LoadTestData(d); + auto gt = TestUtils::ComputeBruteForceKNN( + data.train.data(), + data.queries.data(), + TestUtils::N_TRAIN, + TestUtils::N_QUERIES, + d, + TestUtils::KNN + ); + + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + uint32_t num_clusters = index->GetNumClusters(); + index->SetNProbe(num_clusters); + float recall = TestUtils::ComputeAverageRecall( + *index, data.queries.data(), TestUtils::N_QUERIES, d, TestUtils::KNN, gt + ); + + auto key = std::make_tuple(index_type, d, static_cast(num_clusters)); + auto it = RECALL_GROUND_TRUTH.find(key); + if (it != RECALL_GROUND_TRUTH.end()) { + EXPECT_NEAR(recall, it->second, RECALL_TOLERANCE) + << "Exhaustive recall regression: got " << recall << ", expected ~" << it->second; + } +} + +// Quantization: U8 recall should be within 5% of F32 +TEST(QuantizationTest, U8RecallWithinBound) { + auto data = TestUtils::LoadTestData(D); + auto gt = TestUtils::ComputeBruteForceKNN( + data.train.data(), + data.queries.data(), + TestUtils::N_TRAIN, + TestUtils::N_QUERIES, + D, + TestUtils::KNN + ); + + auto f32_index = TestUtils::BuildIndex("pdx_f32", data.train.data(), TestUtils::N_TRAIN, D); + auto u8_index = TestUtils::BuildIndex("pdx_u8", data.train.data(), TestUtils::N_TRAIN, D); + f32_index->SetNProbe(16); + u8_index->SetNProbe(16); + + float f32_recall = TestUtils::ComputeAverageRecall( + *f32_index, data.queries.data(), TestUtils::N_QUERIES, D, TestUtils::KNN, gt + ); + float u8_recall = TestUtils::ComputeAverageRecall( + *u8_index, data.queries.data(), TestUtils::N_QUERIES, D, TestUtils::KNN, gt + ); + + EXPECT_GE(u8_recall, f32_recall - QUANTIZATION_RECALL_TOLERANCE) + << "U8 recall (" << u8_recall << ") too far below F32 recall (" << f32_recall << ")"; +} + +INSTANTIATE_TEST_SUITE_P( + AllIndexTypes, + SearchTest, + ::testing::Values("pdx_f32", "pdx_u8", "pdx_tree_f32", "pdx_tree_u8"), + [](const ::testing::TestParamInfo& info) { return info.param; } +); + +class UncommonDimTest : public ::testing::TestWithParam {}; + +TEST_P(UncommonDimTest, RecallMonotonicAndExhaustiveNearPerfect) { + size_t d = GetParam(); + constexpr size_t N_TRAIN = 2000; + constexpr size_t N_QUERIES = 100; + constexpr size_t KNN = 10; + + auto all_data = skmeans::MakeBlobs(N_TRAIN + N_QUERIES, d, 50, true, 5.0f, 2.0f, 42); + const float* train = all_data.data(); + const float* queries = all_data.data() + N_TRAIN * d; + + auto gt = TestUtils::ComputeBruteForceKNN(train, queries, N_TRAIN, N_QUERIES, d, KNN); + + std::vector types = {"pdx_f32"}; + if (d >= 10) { + types.push_back("pdx_u8"); + } + for (const auto& index_type : types) { + SCOPED_TRACE(index_type); + auto index = TestUtils::BuildIndex(index_type, train, N_TRAIN, d); + ASSERT_NE(index, nullptr); + + std::vector nprobes = {1, 8, 24, 32}; + uint32_t num_clusters = index->GetNumClusters(); + // Filter out nprobes that exceed num_clusters + nprobes.erase( + std::remove_if( + nprobes.begin(), + nprobes.end(), + [num_clusters](size_t p) { return p > num_clusters; } + ), + nprobes.end() + ); + nprobes.push_back(0); // 0 = all clusters + + float prev_recall = 0.0f; + for (size_t nprobe : nprobes) { + index->SetNProbe(nprobe); + float total_recall = 0.0f; + for (size_t q = 0; q < N_QUERIES; ++q) { + auto results = index->Search(queries + q * d, KNN); + total_recall += TestUtils::ComputeRecall(results, gt.indices[q], KNN); + } + float recall = total_recall / static_cast(N_QUERIES); + + EXPECT_GE(recall, prev_recall - 0.01f) + << "Recall decreased from " << prev_recall << " to " << recall + << " at nprobe=" << nprobe << " d=" << d; + prev_recall = recall; + } + + // Exhaustive (nprobe=0) F32 should give near-perfect recall + if (std::string(index_type) == "pdx_f32") { + EXPECT_GE(prev_recall, 0.95f) + << "Exhaustive F32 recall too low at d=" << d << ": " << prev_recall; + } + } +} + +INSTANTIATE_TEST_SUITE_P( + UncommonDimensionalities, + UncommonDimTest, + ::testing::Values(3, 5, 9, 13, 17, 63, 127, 200, 339, 500), + [](const ::testing::TestParamInfo& info) { return "d" + std::to_string(info.param); } +); + +// --- Cosine Metric Tests --- + +class CosineTest : public ::testing::TestWithParam {}; + +TEST_P(CosineTest, CosineRecallMatchesL2OnNormalizedData) { + std::string index_type = GetParam(); + constexpr size_t N = 2000; + constexpr size_t NQ = 100; + constexpr size_t K = 10; + + // Normalized data: COSINE and L2SQ should give identical rankings + auto all_data = skmeans::MakeBlobs(N + NQ, D, 50, true, 5.0f, 2.0f, 42); + const float* train = all_data.data(); + const float* queries = all_data.data() + N * D; + + PDX::PDXIndexConfig l2_config{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = TestUtils::SEED, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + PDX::PDXIndexConfig cos_config{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::COSINE, + .seed = TestUtils::SEED, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + + auto l2_index = TestUtils::BuildIndexWithConfig(index_type, l2_config, train, N); + auto cos_index = TestUtils::BuildIndexWithConfig(index_type, cos_config, train, N); + ASSERT_NE(l2_index, nullptr); + ASSERT_NE(cos_index, nullptr); + + l2_index->SetNProbe(0); + cos_index->SetNProbe(0); + + for (size_t q = 0; q < NQ; ++q) { + auto l2_results = l2_index->Search(queries + q * D, K); + auto cos_results = cos_index->Search(queries + q * D, K); + ASSERT_EQ(l2_results.size(), cos_results.size()) << "Size mismatch for query " << q; + for (size_t i = 0; i < l2_results.size(); ++i) { + EXPECT_EQ(l2_results[i].index, cos_results[i].index) + << "ID mismatch at query " << q << " position " << i; + } + } +} + +TEST_P(CosineTest, CosineRecallMonotonicity) { + std::string index_type = GetParam(); + constexpr size_t N = 2000; + constexpr size_t NQ = 100; + constexpr size_t K = 10; + + // Non-normalized data: the index should auto-normalize for cosine + auto all_data = skmeans::MakeBlobs(N + NQ, D, 50, false, 5.0f, 2.0f, 42); + const float* train = all_data.data(); + const float* queries = all_data.data() + N * D; + + auto gt = TestUtils::ComputeBruteForceCosineKNN(train, queries, N, NQ, D, K); + + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::COSINE, + .seed = TestUtils::SEED, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + auto index = TestUtils::BuildIndexWithConfig(index_type, config, train, N); + ASSERT_NE(index, nullptr); + + uint32_t num_clusters = index->GetNumClusters(); + std::vector nprobes = {1, 8, 32}; + nprobes.push_back(num_clusters); + nprobes.erase( + std::remove_if( + nprobes.begin(), nprobes.end(), [num_clusters](size_t p) { return p > num_clusters; } + ), + nprobes.end() + ); + std::sort(nprobes.begin(), nprobes.end()); + nprobes.erase(std::unique(nprobes.begin(), nprobes.end()), nprobes.end()); + + float prev_recall = 0.0f; + for (size_t nprobe : nprobes) { + index->SetNProbe(nprobe); + float total_recall = 0.0f; + for (size_t q = 0; q < NQ; ++q) { + auto results = index->Search(queries + q * D, K); + total_recall += TestUtils::ComputeRecall(results, gt.indices[q], K); + } + float recall = total_recall / static_cast(NQ); + EXPECT_GE(recall, prev_recall - 0.01f) << "Cosine recall decreased from " << prev_recall + << " to " << recall << " at nprobe=" << nprobe; + prev_recall = recall; + } +} + +TEST_P(CosineTest, CosineExhaustiveRecall) { + std::string index_type = GetParam(); + constexpr size_t N = 2000; + constexpr size_t NQ = 100; + constexpr size_t K = 10; + + auto all_data = skmeans::MakeBlobs(N + NQ, D, 50, false, 5.0f, 2.0f, 42); + const float* train = all_data.data(); + const float* queries = all_data.data() + N * D; + + auto gt = TestUtils::ComputeBruteForceCosineKNN(train, queries, N, NQ, D, K); + + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::COSINE, + .seed = TestUtils::SEED, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + auto index = TestUtils::BuildIndexWithConfig(index_type, config, train, N); + ASSERT_NE(index, nullptr); + index->SetNProbe(0); + + float total_recall = 0.0f; + for (size_t q = 0; q < NQ; ++q) { + auto results = index->Search(queries + q * D, K); + total_recall += TestUtils::ComputeRecall(results, gt.indices[q], K); + } + float recall = total_recall / static_cast(NQ); + + if (index_type == "pdx_f32") { + EXPECT_GE(recall, 0.95f) << "Cosine F32 exhaustive recall too low: " << recall; + } else { + EXPECT_GE(recall, 0.90f) << "Cosine U8 exhaustive recall too low: " << recall; + } +} + +INSTANTIATE_TEST_SUITE_P( + CosineMetric, + CosineTest, + ::testing::Values("pdx_f32", "pdx_u8"), + [](const ::testing::TestParamInfo& info) { return info.param; } +); + +// --- Seed Determinism Tests --- + +class SeedTest : public ::testing::TestWithParam {}; + +TEST_P(SeedTest, SameSeedProducesSameResults) { + std::string index_type = GetParam(); + auto data = TestUtils::LoadTestData(D); + + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = 42, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + + auto index1 = + TestUtils::BuildIndexWithConfig(index_type, config, data.train.data(), TestUtils::N_TRAIN); + auto index2 = + TestUtils::BuildIndexWithConfig(index_type, config, data.train.data(), TestUtils::N_TRAIN); + index1->SetNProbe(16); + index2->SetNProbe(16); + + for (size_t q = 0; q < 50; ++q) { + auto r1 = index1->Search(data.queries.data() + q * D, TestUtils::KNN); + auto r2 = index2->Search(data.queries.data() + q * D, TestUtils::KNN); + ASSERT_EQ(r1.size(), r2.size()) << "Size mismatch for query " << q; + for (size_t i = 0; i < r1.size(); ++i) { + EXPECT_EQ(r1[i].index, r2[i].index) + << "ID mismatch at query " << q << " position " << i; + EXPECT_FLOAT_EQ(r1[i].distance, r2[i].distance) + << "Distance mismatch at query " << q << " position " << i; + } + } +} + +TEST_P(SeedTest, DifferentSeedProducesDifferentRotation) { + std::string index_type = GetParam(); + auto data = TestUtils::LoadTestData(D); + + PDX::PDXIndexConfig config1{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = 42, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + PDX::PDXIndexConfig config2{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = 99, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + + auto index1 = + TestUtils::BuildIndexWithConfig(index_type, config1, data.train.data(), TestUtils::N_TRAIN); + auto index2 = + TestUtils::BuildIndexWithConfig(index_type, config2, data.train.data(), TestUtils::N_TRAIN); + index1->SetNProbe(16); + index2->SetNProbe(16); + + size_t different_count = 0; + for (size_t q = 0; q < 50; ++q) { + auto r1 = index1->Search(data.queries.data() + q * D, TestUtils::KNN); + auto r2 = index2->Search(data.queries.data() + q * D, TestUtils::KNN); + if (r1.size() == r2.size()) { + for (size_t i = 0; i < r1.size(); ++i) { + if (r1[i].index != r2[i].index) { + ++different_count; + break; + } + } + } + } + EXPECT_GT(different_count, 0u) + << "Different seeds produced identical results for all 50 queries"; +} + +INSTANTIATE_TEST_SUITE_P( + SeedDeterminism, + SeedTest, + ::testing::Values("pdx_f32", "pdx_u8"), + [](const ::testing::TestParamInfo& info) { return info.param; } +); + +// --- Sampling Fraction Test --- + +TEST(SamplingFractionTest, SamplingFractionDoesNotBreakSearch) { + auto data = TestUtils::LoadTestData(D); + auto gt = TestUtils::ComputeBruteForceKNN( + data.train.data(), + data.queries.data(), + TestUtils::N_TRAIN, + TestUtils::N_QUERIES, + D, + TestUtils::KNN + ); + + PDX::PDXIndexConfig full_config{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = TestUtils::SEED, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + PDX::PDXIndexConfig sampled_config{ + .num_dimensions = static_cast(D), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = TestUtils::SEED, + .normalize = true, + .sampling_fraction = 0.3f, + .hierarchical_indexing = true, + }; + + auto full_index = TestUtils::BuildIndexWithConfig( + "pdx_f32", full_config, data.train.data(), TestUtils::N_TRAIN + ); + auto sampled_index = TestUtils::BuildIndexWithConfig( + "pdx_f32", sampled_config, data.train.data(), TestUtils::N_TRAIN + ); + full_index->SetNProbe(0); + sampled_index->SetNProbe(0); + + float full_recall = TestUtils::ComputeAverageRecall( + *full_index, data.queries.data(), TestUtils::N_QUERIES, D, TestUtils::KNN, gt + ); + float sampled_recall = TestUtils::ComputeAverageRecall( + *sampled_index, data.queries.data(), TestUtils::N_QUERIES, D, TestUtils::KNN, gt + ); + + EXPECT_GE(full_recall, 0.90f) << "Full sampling exhaustive recall too low: " << full_recall; + EXPECT_GE(sampled_recall, 0.90f) + << "Sampled (0.3) exhaustive recall too low: " << sampled_recall; +} + +} // namespace diff --git a/tests/test_serialization.cpp b/tests/test_serialization.cpp new file mode 100644 index 0000000..d49099b --- /dev/null +++ b/tests/test_serialization.cpp @@ -0,0 +1,112 @@ +#undef HAS_FFTW + +#include +#include +#include +#include +#include + +#include "pdx/index.hpp" +#include "test_utils.hpp" + +namespace { + +class SerializationTest : public ::testing::TestWithParam { + protected: + void SetUp() override {} +}; + +TEST_P(SerializationTest, SaveLoadProducesSameSearchResults) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + ASSERT_NE(index, nullptr); + index->SetNProbe(16); + + // Search before save + std::vector> original_results; + for (size_t q = 0; q < 50; ++q) { + original_results.push_back(index->Search(data.queries.data() + q * d, TestUtils::KNN)); + } + + // Save and reload + std::string path = "/tmp/pdx_test_" + index_type; + index->Save(path); + auto loaded = PDX::LoadPDXIndex(path); + ASSERT_NE(loaded, nullptr); + loaded->SetNProbe(16); + + // Search after load + for (size_t q = 0; q < 50; ++q) { + auto loaded_results = loaded->Search(data.queries.data() + q * d, TestUtils::KNN); + ASSERT_EQ(original_results[q].size(), loaded_results.size()) + << "Result count mismatch for query " << q; + + for (size_t i = 0; i < original_results[q].size(); ++i) { + EXPECT_EQ(original_results[q][i].index, loaded_results[i].index) + << "ID mismatch at query " << q << " position " << i; + + float rel_error = + std::abs(original_results[q][i].distance - loaded_results[i].distance) / + std::max(original_results[q][i].distance, 1e-6f); + EXPECT_LT(rel_error, 1e-5f) << "Distance mismatch at query " << q << " position " << i; + } + } + + std::remove(path.c_str()); +} + +TEST_P(SerializationTest, LoadedIndexProperties) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + uint32_t orig_dims = index->GetNumDimensions(); + uint32_t orig_clusters = index->GetNumClusters(); + size_t orig_mem = index->GetInMemorySizeInBytes(); + + std::string path = "/tmp/pdx_test_props_" + index_type; + index->Save(path); + auto loaded = PDX::LoadPDXIndex(path); + + EXPECT_EQ(loaded->GetNumDimensions(), orig_dims); + EXPECT_EQ(loaded->GetNumClusters(), orig_clusters); + + float mem_ratio = + static_cast(loaded->GetInMemorySizeInBytes()) / static_cast(orig_mem); + EXPECT_GT(mem_ratio, 0.99f); + EXPECT_LT(mem_ratio, 1.01f); + + std::remove(path.c_str()); +} + +TEST_P(SerializationTest, LoadAutoDetectsType) { + std::string index_type = GetParam(); + size_t d = 128; + auto data = TestUtils::LoadTestData(d); + + auto index = TestUtils::BuildIndex(index_type, data.train.data(), TestUtils::N_TRAIN, d); + std::string path = "/tmp/pdx_test_autodetect_" + index_type; + index->Save(path); + + // LoadPDXIndex should auto-detect the type from the header byte + auto loaded = PDX::LoadPDXIndex(path); + ASSERT_NE(loaded, nullptr); + EXPECT_EQ(loaded->GetNumDimensions(), index->GetNumDimensions()); + EXPECT_EQ(loaded->GetNumClusters(), index->GetNumClusters()); + + std::remove(path.c_str()); +} + +INSTANTIATE_TEST_SUITE_P( + AllIndexTypes, + SerializationTest, + // TODO: add tree indexes once crash is fixed + ::testing::Values("pdx_f32", "pdx_u8"), + [](const ::testing::TestParamInfo& info) { return info.param; } +); + +} // namespace diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp new file mode 100644 index 0000000..0c3ad75 --- /dev/null +++ b/tests/test_utils.hpp @@ -0,0 +1,243 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pdx/distance_computers/scalar_computers.hpp" +#include "pdx/index.hpp" + +namespace TestUtils { + +static constexpr size_t N_TRAIN = 5000; +static constexpr size_t N_QUERIES = 500; +static constexpr size_t MAX_D = 384; +static constexpr size_t N_TOTAL = N_TRAIN + N_QUERIES; +static constexpr unsigned int SEED = 42; +static constexpr size_t KNN = 10; + +struct TestData { + std::vector train; + std::vector queries; +}; + +inline TestData LoadTestData(size_t d = MAX_D) { + static std::vector full_data; + if (full_data.empty()) { + std::string path = CMAKE_SOURCE_DIR "/tests/test_data.bin"; + std::ifstream in(path, std::ios::binary); + if (!in) { + throw std::runtime_error( + "Could not open test_data.bin. Run generate_test_data.out first." + ); + } + full_data.resize(N_TOTAL * MAX_D); + in.read(reinterpret_cast(full_data.data()), full_data.size() * sizeof(float)); + } + + TestData data; + if (d == MAX_D) { + data.train.assign(full_data.begin(), full_data.begin() + N_TRAIN * MAX_D); + data.queries.assign(full_data.begin() + N_TRAIN * MAX_D, full_data.end()); + } else { + data.train.resize(N_TRAIN * d); + data.queries.resize(N_QUERIES * d); + for (size_t i = 0; i < N_TRAIN; ++i) { + std::memcpy(&data.train[i * d], &full_data[i * MAX_D], d * sizeof(float)); + } + for (size_t i = 0; i < N_QUERIES; ++i) { + std::memcpy(&data.queries[i * d], &full_data[(N_TRAIN + i) * MAX_D], d * sizeof(float)); + } + } + return data; +} + +struct BruteForceResult { + std::vector> indices; + std::vector> distances; +}; + +inline BruteForceResult ComputeBruteForceKNN( + const float* train, + const float* queries, + size_t n_train, + size_t n_queries, + size_t d, + size_t k +) { + BruteForceResult result; + result.indices.resize(n_queries); + result.distances.resize(n_queries); + + for (size_t q = 0; q < n_queries; ++q) { + using Pair = std::pair; + std::priority_queue max_heap; + + for (size_t i = 0; i < n_train; ++i) { + float dist = PDX::ScalarComputer::Horizontal( + queries + q * d, train + i * d, d + ); + if (max_heap.size() < k) { + max_heap.push({dist, static_cast(i)}); + } else if (dist < max_heap.top().first) { + max_heap.pop(); + max_heap.push({dist, static_cast(i)}); + } + } + + size_t actual_k = max_heap.size(); + result.indices[q].resize(actual_k); + result.distances[q].resize(actual_k); + for (size_t i = actual_k; i > 0; --i) { + result.indices[q][i - 1] = max_heap.top().second; + result.distances[q][i - 1] = max_heap.top().first; + max_heap.pop(); + } + } + return result; +} + +inline BruteForceResult ComputeBruteForceCosineKNN( + const float* train, + const float* queries, + size_t n_train, + size_t n_queries, + size_t d, + size_t k +) { + BruteForceResult result; + result.indices.resize(n_queries); + result.distances.resize(n_queries); + + for (size_t q = 0; q < n_queries; ++q) { + using Pair = std::pair; + std::priority_queue max_heap; + + for (size_t i = 0; i < n_train; ++i) { + float dist = PDX::ScalarComputer::Horizontal( + queries + q * d, train + i * d, d + ); + if (max_heap.size() < k) { + max_heap.push({dist, static_cast(i)}); + } else if (dist < max_heap.top().first) { + max_heap.pop(); + max_heap.push({dist, static_cast(i)}); + } + } + + size_t actual_k = max_heap.size(); + result.indices[q].resize(actual_k); + result.distances[q].resize(actual_k); + for (size_t i = actual_k; i > 0; --i) { + result.indices[q][i - 1] = max_heap.top().second; + result.distances[q][i - 1] = max_heap.top().first; + max_heap.pop(); + } + } + return result; +} + +inline float ComputeRecall( + const std::vector& results, + const std::vector& gt_ids, + size_t k +) { + std::unordered_set gt_set( + gt_ids.begin(), gt_ids.begin() + std::min(k, gt_ids.size()) + ); + size_t hits = 0; + for (size_t i = 0; i < std::min(k, results.size()); ++i) { + if (gt_set.count(results[i].index)) { + ++hits; + } + } + return static_cast(hits) / static_cast(std::min(k, gt_set.size())); +} + +inline float ComputeAverageRecall( + PDX::IPDXIndex& index, + const float* queries, + size_t n_queries, + size_t d, + size_t k, + const BruteForceResult& gt +) { + float total_recall = 0.0f; + for (size_t q = 0; q < n_queries; ++q) { + auto results = index.Search(queries + q * d, k); + total_recall += ComputeRecall(results, gt.indices[q], k); + } + return total_recall / static_cast(n_queries); +} + +inline std::unique_ptr BuildIndex( + const std::string& index_type, + const float* data, + size_t n, + size_t d +) { + PDX::PDXIndexConfig config{ + .num_dimensions = static_cast(d), + .distance_metric = PDX::DistanceMetric::L2SQ, + .seed = SEED, + .normalize = true, + .sampling_fraction = 1.0f, + .hierarchical_indexing = true, + }; + + std::unique_ptr index; + if (index_type == "pdx_f32") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } else if (index_type == "pdx_u8") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } else if (index_type == "pdx_tree_f32") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } else if (index_type == "pdx_tree_u8") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } + return index; +} + +inline std::unique_ptr BuildIndexWithConfig( + const std::string& index_type, + const PDX::PDXIndexConfig& config, + const float* data, + size_t n +) { + std::unique_ptr index; + if (index_type == "pdx_f32") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } else if (index_type == "pdx_u8") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } else if (index_type == "pdx_tree_f32") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } else if (index_type == "pdx_tree_u8") { + auto p = std::make_unique(config); + p->BuildIndex(data, n); + index = std::move(p); + } + return index; +} + +} // namespace TestUtils