diff --git a/.github/workflows/run-bench.yml b/.github/workflows/run-bench.yml index 131a6d5bb..3841b5e7c 100644 --- a/.github/workflows/run-bench.yml +++ b/.github/workflows/run-bench.yml @@ -92,9 +92,9 @@ jobs: - name: Set up GCC run: | sudo apt install -y gcc - - name: Install Meson and Ninja + - name: Install native build dependencies run: | - sudo apt update && sudo apt install -y meson ninja-build + sudo apt update && sudo apt install -y cmake meson ninja-build - uses: actions/checkout@v4 - name: Set up JDK ${{ matrix.jdk }} uses: actions/setup-java@v3 @@ -131,6 +131,8 @@ jobs: ref: ${{ matrix.branch }} fetch-depth: 0 + # Branches from before the Highway Meson wrap still use a submodule; + # this step is a no-op on newer ones. - name: Initialize Git Submodules run: git submodule update --init @@ -154,7 +156,7 @@ jobs: # Build the branch - name: Build branch - run: mvn -B -Punix-amd64-profile package --file pom.xml + run: mvn -B -Punix-amd64-profile -Dnative.onednn=true package --file pom.xml # Run the benchmark if jvector-examples exists - name: Run benchmark @@ -255,9 +257,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - - name: Download all benchmark results uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/run-compaction.yml b/.github/workflows/run-compaction.yml index 0f9496779..96e7bda49 100644 --- a/.github/workflows/run-compaction.yml +++ b/.github/workflows/run-compaction.yml @@ -57,9 +57,9 @@ jobs: steps: - name: Set up GCC run: sudo apt install -y gcc - - name: Install Meson and Ninja + - name: Install native build dependencies run: | - sudo apt update && sudo apt install -y meson ninja-build + sudo apt update && sudo apt install -y cmake meson ninja-build - uses: actions/checkout@v4 - name: Set up JDK ${{ matrix.jdk }} uses: actions/setup-java@v3 @@ -74,11 +74,13 @@ jobs: ref: ${{ matrix.branch }} fetch-depth: 0 + # Branches from before the Highway Meson wrap still use a submodule; + # this step is a no-op on newer ones. - name: Initialize Git Submodules run: git submodule update --init - name: Build branch - run: mvn -B -Punix-amd64-profile package --file pom.xml + run: mvn -B -Punix-amd64-profile -Dnative.onednn=true package --file pom.xml - name: Run CompactorBenchmark id: run-benchmark diff --git a/.github/workflows/unit-tests-arm64.yaml b/.github/workflows/unit-tests-arm64.yaml index a54844fc6..8836d1e3f 100644 --- a/.github/workflows/unit-tests-arm64.yaml +++ b/.github/workflows/unit-tests-arm64.yaml @@ -53,15 +53,12 @@ jobs: run: | sudo apt install -y gcc g++ - - name: Install Meson, Ninja, and GTest + - name: Install native build dependencies and GTest run: | - sudo apt update && sudo apt install -y meson ninja-build pkg-config libgtest-dev + sudo apt update && sudo apt install -y cmake meson ninja-build pkg-config libgtest-dev - uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - - name: Build test_simd_kernels (native C++) # Meson detects aarch64 and compiles all three ISA variants (neon/sve/sve2) # regardless of what the host CPU supports at runtime. @@ -110,7 +107,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile -pl jvector-tests -am test + mvn -B -Punix-amd64-profile -Dnative.onednn=true -pl jvector-tests -am test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestVectorizationProvider @@ -119,7 +116,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile test + mvn -B -Punix-amd64-profile -Dnative.onednn=true test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider - name: Test Summary for (ARM64/max:${{ matrix.max_isa }},JDK${{ matrix.jdk }}) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 2fce3141e..3453d57af 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -50,13 +50,10 @@ jobs: - name: Set up GCC run: | sudo apt install -y gcc - - name: Install Meson, Ninja, and GTest + - name: Install native build dependencies and GTest run: | - sudo apt update && sudo apt install -y meson ninja-build pkg-config libgtest-dev + sudo apt update && sudo apt install -y cmake meson ninja-build pkg-config libgtest-dev - uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - - name: Build test_simd_kernels (native C++) working-directory: jvector-native/src/main/native run: | @@ -108,7 +105,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile -pl jvector-tests -am test + mvn -B -Punix-amd64-profile -Dnative.onednn=true -pl jvector-tests -am test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestVectorizationProvider @@ -118,7 +115,7 @@ jobs: env: JVECTOR_MAX_ISA: ${{ matrix.max_isa }} run: >- - mvn -B -Punix-amd64-profile test + mvn -B -Punix-amd64-profile -Dnative.onednn=true test -DTest_RequireSpecificVectorizationProvider=NativeVectorizationProvider - name: Test Summary for (ISA:isa-avx512f/max:${{ matrix.max_isa }},JDK${{ matrix.jdk }}) @@ -147,8 +144,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Initialize Git Submodules - run: git submodule update --init - name: Set up JDK uses: actions/setup-java@v3 with: @@ -159,12 +154,12 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt install -y gcc - - name: Install Meson and Ninja + - name: Install native build dependencies if: matrix.os == 'ubuntu-latest' run: | - sudo apt update && sudo apt install -y meson ninja-build + sudo apt update && sudo apt install -y cmake meson ninja-build - name: Compile, run tests, and package (JDK 22) - run: mvn -B verify + run: mvn -B "-Dnative.onednn=true" verify if: matrix.jdk == '22' - name: Compile, run tests, and package (JDK 20) run: mvn -B -Pjdk20 -am -pl jvector-tests test diff --git a/.gitignore b/.gitignore index 70cede6f9..37abe9532 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dataset_ **results**.jsonl ### Bench caches +compressor_cache/ pq_cache/ index_cache/ dataset_cache/ @@ -64,3 +65,6 @@ dependency-reduced-pom.xml results.csv **/datasets/custom/** **/dataset_cache/** + +### Benchmark server profiles (machine-specific) ### +jvector-examples/bench-profiles/local/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 9ea15e3e5..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "jvector-native/src/main/native/third_party/highway"] - path = jvector-native/src/main/native/third_party/highway - url = https://github.com/google/highway.git diff --git a/.run/ASH vs PQ single JVM - MPNet 10M 2-bit ASH.run.xml b/.run/ASH vs PQ single JVM - MPNet 10M 2-bit ASH.run.xml new file mode 100644 index 000000000..06276bcf7 --- /dev/null +++ b/.run/ASH vs PQ single JVM - MPNet 10M 2-bit ASH.run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/README.md b/README.md index a0a1bec56..2d4f799e8 100644 --- a/README.md +++ b/README.md @@ -1,200 +1,221 @@ -## Introduction to approximate nearest neighbor search +# JVector + +JVector is a Java library for approximate nearest neighbor (ANN) vector search. It builds graph +indexes that combine an HNSW-style hierarchy with Vamana (the algorithm behind DiskANN) in every +layer, can be built concurrently and incrementally, and can be searched from disk with compressed +vectors. JVector targets Java 11 and uses SIMD acceleration on newer JVMs. + +- [Why graph indexes](#why-graph-indexes) +- [How JVector searches](#how-jvector-searches) +- [Getting started](#getting-started) +- [Choosing a quantizer](#choosing-a-quantizer) +- [Benchmarking with BenchYAML](#benchmarking-with-benchyaml) +- [SIMD and native acceleration](#simd-and-native-acceleration) +- [Research](#research) +- [Building and testing](#building-and-testing) + +## Why graph indexes + +Exact nearest neighbor search is prohibitively expensive at high dimension: methods that partition +space well in two or three dimensions, such as k-d trees, degrade to linear scans as dimension +grows ("[the curse of dimensionality](https://en.wikipedia.org/wiki/Curse_of_dimensionality)"). For +large datasets, an approximate answer in logarithmic time is usually more useful than an exact +answer in linear time. + +ANN indexes are either partition-based, like [LSH or IVF](https://www.datastax.com/guides/what-is-a-vector-index) +and [ScaNN](https://github.com/google-research/google-research/tree/master/scann), or graph-based, +like [HNSW](https://arxiv.org/abs/1603.09320) and +[DiskANN](https://www.microsoft.com/en-us/research/project/project-akupara-approximate-nearest-neighbor-search-for-large-scale-semantic-search/). +Graph indexes tend to be faster and, more importantly, can be built and updated incrementally, which +makes them a better fit for a general-purpose index than partitioning schemes that need the whole +dataset up front. + +## How JVector searches + +JVector's graph has an in-memory hierarchy of upper layers for fast navigation and a base layer that +can live on disk. Construction uses nonblocking concurrency control and scales linearly with cores: -Exact nearest neighbor search (k-nearest-neighbor or KNN) is prohibitively expensive at higher dimensions, because approaches to segment the search space that work in 2D or 3D like quadtree or k-d tree devolve to linear scans at higher dimensions. This is one aspect of what is called “[the curse of dimensionality](https://en.wikipedia.org/wiki/Curse_of_dimensionality).” - -With larger datasets, it is almost always more useful to get an approximate answer in logarithmic time, than the exact answer in linear time. This is abbreviated as ANN (approximate nearest neighbor) search. - -There are two broad categories of ANN index: -* Partition-based indexes, like [LSH or IVF](https://www.datastax.com/guides/what-is-a-vector-index) or [SCANN](https://github.com/google-research/google-research/tree/master/scann) -* Graph indexes, like [HNSW](https://arxiv.org/abs/1603.09320) or [DiskANN](https://www.microsoft.com/en-us/research/project/project-akupara-approximate-nearest-neighbor-search-for-large-scale-semantic-search/) - -Graph-based indexes tend to be simpler to implement and faster, but more importantly they can be constructed and updated incrementally. This makes them a much better fit for a general-purpose index than partitioning approaches that only work on static datasets that are completely specified up front. That is why all the major commercial vector indexes use graph approaches. - -JVector is a graph index that merges the DiskANN and HNSW family trees. -JVector borrows the hierarchical structure from HNSW, and uses Vamana (the algorithm behind DiskANN) within each layer. - - -## JVector Architecture - -JVector is a graph-based index that builds on the HNSW and DiskANN designs with composable extensions. - -JVector implements a multi-layer graph with nonblocking concurrency control, allowing construction to scale linearly with the number of cores: ![JVector scales linearly as thread count increases](https://github.com/jbellis/jvector/assets/42158/f0127bfc-6c45-48b9-96ea-95b2120da0d9) -The upper layers of the hierarchy are represented by an in-memory adjacency list per node. This allows for quick navigation with no IOs. -The bottom layer of the graph is represented by an on-disk adjacency list per node. JVector uses additional data stored inline to support two-pass searches, with the first pass powered by lossily compressed representations of the vectors kept in memory, and the second by a more accurate representation read from disk. The first pass can be performed with -* Product quantization (PQ), optionally with [anisotropic weighting](https://arxiv.org/abs/1908.10396) -* [Binary quantization](https://huggingface.co/blog/embedding-quantization) (BQ) -* Fused PQ, where PQ codebooks are written inline with the graph adjacency list +Searches run in two passes. The first pass traverses the graph using compact, approximate scores +from a quantizer; the second reranks the best candidates with a more accurate representation stored +in the index: -The second pass can be performed with -* Full resolution float32 vectors -* NVQ, which uses a non-uniform technique to quantize vectors with high-accuracy +| Pass | Options | +| --- | --- | +| Traversal | Product quantization (PQ), optionally with anisotropic weighting; binary quantization (BQ); Asymmetric Scalar Hashing (ASH); or full-precision vectors | +| Fused traversal | Fused PQ or fused ASH: each node's adjacency list carries its neighbors' codes, so the codes are read with the graph instead of held in memory | +| Reranking | Full-precision (FP32) vectors or NVQ, a high-accuracy non-uniform quantization | -[This two-pass design reduces memory usage and reduces latency while preserving accuracy](https://thenewstack.io/why-vector-size-matters/). +[This two-pass design reduces memory use and latency while preserving accuracy](https://thenewstack.io/why-vector-size-matters/). +JVector can also use two-pass searches while *building* an index, so indexes larger than memory can +be constructed, and searches stay logarithmic within a single index instead of merging results +from many: -Additionally, JVector is unique in offering the ability to construct the index itself using two-pass searches, allowing larger-than-memory indexes to be built: ![Much larger indexes](https://github.com/jbellis/jvector/assets/42158/34cb8094-68fa-4dc3-b3ce-4582fdbd77e1) -This is important because it allows you to take advantage of logarithmic search within a single index, instead of spilling over to linear-time merging of results from multiple indexes. - +## Getting started -## Getting started with JVector - -Introductory tutorials for JVector are available in [docs/tutorials](./docs/tutorials/). Start with the [basic tutorial](./docs/tutorials/1-intro-tutorial.md) or review [VectorIntro.java](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/tutorial/VectorIntro.java) for a simple example using JVector. - -For file-backed input datasets, see [Batched input vector access](./docs/batched-vector-access.md), -including runnable 100k-vector examples and optional integration with PQ and NVQ. - -For writing completed graphs with FP or NVQ features, see [Deferred batch serialization](./docs/deferred-batch-writer.md). It works with application vector sources and preloaded data; dataset prefetching is optional. - -The older step-by-step guide for JV can be found [here](./docs/legacy/jvector-step-by-step.md). New users should start with the tutorials mentioned earler, but the step-by-step guide contains useful commentary for advanced users. - - -## SIMD provider selection - -On Java 22+ with the Vector API enabled, JVector automatically prefers its Google -Highway native provider when the native library is available. Native libraries are -bundled for Linux x86-64 (SSE4.2 or newer) and Linux AArch64. Use these JVM options: +JVector is published to Maven Central as `io.github.jbellis:jvector`. Run with these JVM options to +enable SIMD acceleration (see [SIMD and native acceleration](#simd-and-native-acceleration)): ```text --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED ``` -Omitting native access can produce restricted-method warnings. If native access is -denied by the runtime or JVM options, automatic selection falls back to Panama; -an explicitly forced native provider fails during initialization. For named-module -deployments, enable native access for the module containing JVector's native code. +The fastest way in is the hello-world programs in [jvector-examples](./jvector-examples). They download +small public datasets on first use. Build them once from the repository root: -Operations implemented by Highway use native kernels; remaining operations use the -inherited Panama/Java Vector API implementation with native vector storage. Native -ISA selection remains automatic and respects the existing `JVECTOR_MAX_ISA` cap. +```bash +mvn -pl jvector-examples -am -DskipTests package -Dnative.onednn=true +JAR=$(ls jvector-examples/target/jvector-examples-*-jar-with-dependencies.jar) +``` -| JVM property | Effect | +| Program | What it shows | | --- | --- | -| No provider property | Prefer native on Java 22+; use Panama when native is unavailable | -| `-Djvector.enable_native_vectorization=false` | Disable automatic native selection | -| `-Djvector.vectorization_provider=panama` | Explicitly select Panama | -| `-Djvector.vectorization_provider=native` | Explicitly require native; fail if it cannot load | -| `-Djvector.vectorization_provider=default` | Explicitly select the scalar provider | - -The explicit `jvector.vectorization_provider` property takes precedence over the -native-enable property. The former -`jvector.experimental.enable_native_vectorization` name is a deprecated compatibility -alias; the new `jvector.enable_native_vectorization` name takes precedence when both -are supplied. Both names default to `true` when neither is supplied. Provider -selection happens once per JVM. Java 20/21 uses -Panama when available. Older runtimes, or runtimes without the Vector API module, -retain the scalar fallback. Neither Panama nor native is enabled by these defaults -when the existing runtime compatibility checks reject SIMD support. - -Native selection also changes the concrete vector type to `MemorySegmentVectorFloat`. -Use the `VectorFloat` interface instead of casting to `ArrayVectorFloat` or assuming -that `VectorFloat.get()` returns a `float[]`. Both built-in float vector types support -`FloatArray.array()` for array access and `copyFrom` between their storage types. -Native vector operations still require vectors created by the selected provider; -copy external vectors into provider-created vectors before passing them to those -operations. To retain the previous provider and storage choice, disable automatic -native selection with `-Djvector.enable_native_vectorization=false`. - -## The research behind the algorithms - -* Foundational work: [HNSW](https://ieeexplore.ieee.org/abstract/document/8594636) and [DiskANN](https://suhasjs.github.io/files/diskann_neurips19.pdf) papers, and [a higher level explainer](https://www.datastax.com/guides/hierarchical-navigable-small-worlds) -* [Anisotropic PQ paper](https://arxiv.org/abs/1908.10396) -* [Quicker ADC paper](https://arxiv.org/abs/1812.09162) -* [NVQ paper](https://arxiv.org/abs/2509.18471) - -## Developing and Testing -This project is organized as a [multimodule Maven build](https://maven.apache.org/guides/mini/guide-multiple-modules.html). The intent is to produce a multirelease jar suitable for use as -a dependency from any Java 11 code. When run on a Java 20+ JVM with the Vector module enabled, optimized vector -providers will be used. In general, the project is structured to be built with JDK 20+, but when `JAVA_HOME` is set to -Java 11 -> Java 19, certain build features will still be available. - -### Cloning - -This repository uses a Git submodule for [Google Highway](https://github.com/google/highway), located at -`jvector-native/src/main/native/third_party/highway`. After cloning, initialise it with: +| [HelloVectorWorld](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java) | Build an in-memory graph and search it, in about twenty lines | +| [HelloASHWorldFp16](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java) | Train ASH, build a graph, write a fused ASH index with NVQ reranking, reopen it, and search synchronously and through the queued API | +| [HelloASHWorldInt8](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java) | The same with INT8 query projection, for processors with VNNI or AMX | +| [HelloBatchedVectors](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloBatchedVectors.java) and [HelloBatchedQuantization](./jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloBatchedQuantization.java) | Batched access to file-backed vectors, and encoding them with PQ and NVQ | ```bash -git submodule update --init +java --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED \ + -cp "$JAR" io.github.jbellis.jvector.example.HelloVectorWorld ``` -Or clone with submodules in one step: +The [examples README](./jvector-examples/README.md) covers each program, and the +[tutorials](./docs/tutorials/) walk through the API step by step, starting with +[the introduction](./docs/tutorials/1-intro-tutorial.md). -```bash -git clone --recurse-submodules -``` +Integrators should also read: -### Building native libraries +- [Batched input vector access](./docs/batched-vector-access.md) for file-backed input datasets. +- [Deferred batch serialization](./docs/deferred-batch-writer.md) for writing completed graphs. +- [UPGRADING.md](./UPGRADING.md) for API and on-disk format changes between releases. -The native SIMD library (`libjvector.so`) is built with [Meson](https://mesonbuild.com/) + [Ninja](https://ninja-build.org/) -and requires **g++ 11+**. Supported platforms: **Linux x86-64** (SSE4.2, AVX2, AVX-512) and **Linux AArch64** (NEON, SVE, SVE2). -The entry-point script is `jvector-native/src/main/native/src/build_native_lib.sh`. Run it from that directory. +## Choosing a quantizer -```bash -cd jvector-native/src/main/native/src -bash build_native_lib.sh -``` +| Quantizer | Use it for | Notes | +| --- | --- | --- | +| **ASH** | Graph traversal at high compression | Most accurate at a given code size; fused or in memory; dot product only | +| **PQ** | Graph traversal at moderate compression | Fused or in memory; any similarity; optional anisotropic weighting | +| **BQ** | Very fast, coarse traversal | One bit per dimension | +| **NVQ** | Reranking | Stored in the graph; high accuracy at about a quarter of FP32's size | -On a fresh Ubuntu machine you can install all required dependencies (g++, meson, ninja) and build in one step: +### ASH -```bash -cd jvector-native/src/main/native/src -bash build_native_lib.sh --auto-install-deps -``` +ASH (Asymmetric Scalar Hashing) reduces the dimension of database vectors with a learned orthonormal +projection, then scalar-quantizes each projected dimension to a few bits, while keeping queries in +their original form. At the same code size it is more accurate than additive and scalar quantizers, +trains and encodes quickly, and scores with SIMD lookups. JVector is the first graph index to support +it. On 6M and 10M datasets at matched code sizes, fused ASH built faster than fused PQ, was more +accurate in every case, and was faster in nearly every case; see [ASH performance](./docs/ash-performance.md). -For other distributions the script will print the install commands needed. See -[`jvector-native/src/main/native/README.md`](./jvector-native/src/main/native/README.md) for full build options, -Maven integration, ISA dispatch details, and how to add new kernels. -The [native ABI compatibility guide](./jvector-native/src/main/native/README.md#native-abi-compatibility) -explains the supported ABI set, startup checks, and how to preserve compatibility -when changing native entry points. +Start with the HelloASHWorld programs above, then: ---- +- [ASH configuration and defaults](./docs/ash-configuration.md): model parameters, construction + scoring, precision and JVM settings. +- [Integrating ASH queries into a database](./docs/ash-query-integration.md): concurrency, bounded + admission and batched query projection. +- [Choosing ASH query-projection precision](./docs/ash-projection-precision.md): FP16, FP32 or INT8 + by processor. -Base code is in [jvector-base](./jvector-base) and will be built for Java 11 releases, restricting language features and APIs -appropriately. Code in [jvector-twenty](./jvector-twenty) will be compiled for Java 20 language features/APIs and included in the final -multirelease jar targeting supported JVMs. [jvector-multirelease](./jvector-multirelease) packages [jvector-base](./jvector-base) and [jvector-twenty](./jvector-twenty) as a -multirelease jar for release. [jvector-examples](./jvector-examples) is an additional sibling module that uses the reactor-representation of -jvector-base/jvector-twenty to run example code. [jvector-tests](./jvector-tests) contains tests for the project, capable of running against -both Java 11 and Java 20+ JVMs. +ASH scores dot product only, so use `DOT_PRODUCT` with normalized vectors for cosine similarity. +Fused ASH requires on-disk format version 7, which earlier JVector releases cannot read. Training +uses the system BLAS and LAPACK (MKL or OpenBLAS) when installed, with a slower Java fallback. -To run tests, use `mvn test`. To run tests against Java 20+, use `mvn test`. To run tests against Java 11, use `mvn -Pjdk11 test`. -To run a single test class, use the Maven Surefire test filtering capability, e.g., -`mvn -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestNeighborArray test`. -You may also use method-level filtering and patterns, e.g., -`mvn -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestNeighborArray#testRetain* test`. -(The `failIfNoSpecifiedTests` option works around a quirk of surefire: it is happy to run `test` with submodules with empty test sets, -but as soon as you supply a filter, it wants at least one match in every submodule.) +## Benchmarking with BenchYAML -You can run `SiftSmall` and `Bench` directly to get an idea of what all is going on here. `Bench` will automatically download required datasets to the `dataset_cache` directory. -The files used by `SiftSmall` can be found in the [siftsmall directory](./siftsmall) in the project root. +BenchYAML builds and measures indexes from YAML descriptions of datasets, index parameters and +measurements. From the repository root, this runs the default set of small public datasets, which +download on first use: -To run either class, you can use the Maven exec-plugin via the following incantations: +```bash +mvn compile exec:exec@bench -pl jvector-examples -am +``` -> `mvn compile exec:exec@bench` +Select datasets by name with `-DbenchArgs="cohere ada002"`, or run one index configuration by path, +such as an [example pipeline](./jvector-examples/yaml-configs/index-parameters/README.md). Compressed +datasets use fused ASH by default. -or for Sift: +- [How to run benchmarks](./docs/benchmarking.md): datasets, parameters, and output. +- [BenchYAML Guide](./docs/benchyaml-guide.md): every construction and search field. +- [Benchmark launcher](./jvector-examples/bench-profiles/README.md): rebuild and run one experiment + in one JVM from the command line or IntelliJ. +- [Reproducing ASH versus PQ](./docs/ash-pq-benchmark.md): the published comparison. -> `mvn compile exec:exec@sift` +## SIMD and native acceleration -`Bench` takes an optional `benchArgs` argument that can be set to a list of whitespace-separated regexes. If any of the -provided regexes match within a dataset name, that dataset will be included in the benchmark. For example, to run only the glove -and nytimes datasets, you could use: +On Java 22 and later, JVector prefers its native provider, built on +[Google Highway](https://github.com/google/highway), when the native library is available. Native +libraries are bundled for Linux x86-64 (SSE4.2 or newer) and Linux AArch64. Java 20 and 21 use the +Panama Vector API, and older runtimes use a scalar Java implementation. -> `mvn compile exec:exec@bench -DbenchArgs="glove nytimes"` +| JVM property | Effect | +| --- | --- | +| None | Prefer native on Java 22+, else Panama, else scalar | +| `-Djvector.enable_native_vectorization=false` | Disable automatic native selection | +| `-Djvector.vectorization_provider=panama` | Select Panama | +| `-Djvector.vectorization_provider=native` | Require native; fail if it cannot load | +| `-Djvector.vectorization_provider=default` | Select the scalar provider | + +If native access is denied, automatic selection falls back to Panama. For named-module deployments, +enable native access for the module containing JVector's native code. Native ISA selection is +automatic and can be capped with `JVECTOR_MAX_ISA`. + +The native provider stores vectors as `MemorySegmentVectorFloat`. Use the `VectorFloat` interface +rather than casting to `ArrayVectorFloat`; `FloatArray.array()` gives array access, and native +operations need vectors created by the selected provider. + +ASH uses native kernels for query projection and scoring, and an optional add-on built on +[oneDNN](https://github.com/uxlfoundation/oneDNN) for FP16 and INT8 query projection and batch +encoding. Each falls back to a portable implementation when unavailable. -To run Sift/Bench without the JVM vector module available, you can use the following invocations: +## Research -> `mvn -Pjdk11 compile exec:exec@bench` +- Foundations: the [HNSW](https://ieeexplore.ieee.org/abstract/document/8594636) and + [DiskANN](https://suhasjs.github.io/files/diskann_neurips19.pdf) papers, and + [a higher-level explainer](https://www.datastax.com/guides/hierarchical-navigable-small-worlds) +- [ASH: Asymmetric Scalar Hashing With Learned Dimensionality Reduction for High-Fidelity Vector Quantization](https://arxiv.org/abs/2606.07870) (CIKM 2026) +- [NVQ](https://arxiv.org/abs/2509.18471) +- [Anisotropic PQ](https://arxiv.org/abs/1908.10396) +- [Quicker ADC](https://arxiv.org/abs/1812.09162) -> `mvn -Pjdk11 compile exec:exec@sift` +## Building and testing -The `... -Pjdk11` invocations will also work with `JAVA_HOME` pointing at a Java 11 installation. +JVector is a [multimodule Maven build](https://maven.apache.org/guides/mini/guide-multiple-modules.html) +that produces a multirelease JAR usable from Java 11: -For more information on running benchmarks, go through [docs/benchmarking.md](./docs/benchmarking.md). +| Module | Contents | +| --- | --- | +| [jvector-base](./jvector-base) | Core library, compiled for Java 11 | +| [jvector-twenty](./jvector-twenty) | Panama Vector API provider, compiled for Java 20 | +| [jvector-native](./jvector-native) | Native provider and libraries, Java 22 | +| [jvector-multirelease](./jvector-multirelease) | Packages the three modules above as the published `jvector` JAR | +| [jvector-examples](./jvector-examples) | Examples, BenchYAML and benchmarks | +| [jvector-tests](./jvector-tests) | Tests, runnable on Java 11 and newer | + +Build with JDK 22 or newer. The native library is built with [Meson](https://mesonbuild.com/) and +[Ninja](https://ninja-build.org/) and needs g++ 11 or newer; Meson fetches the pinned Google Highway +source on first build. The optional oneDNN add-on for ASH query projection is a separate Meson +project; it also needs CMake 3.13 or newer and takes a few minutes to build, so Maven builds it only +with `-Dnative.onednn=true` (the `release` profile and CI turn it on). The [native library README](./jvector-native/src/main/native/README.md) covers build +options, ISA dispatch, adding kernels and +[ABI compatibility](./jvector-native/src/main/native/README.md#native-abi-compatibility). +On Ubuntu, `bash build_native_lib.sh --auto-install-deps` (in `jvector-native/src/main/native/src`) +installs the toolchain and builds the library. + +Run the tests with: + +```bash +mvn test # all modules, with the SIMD providers on JDK 20+ +mvn -DskipScalar=false test # also run the scalar provider tests +mvn -Pjdk11 test # against Java 11 +mvn -Dsurefire.failIfNoSpecifiedTests=false -Dtest=TestNeighborArray test # one class +``` -To release, configure `~/.m2/settings.xml` to point to OSSRH and run `mvn -Prelease clean deploy`. +`-Dtest` also accepts methods and patterns, such as `TestNeighborArray#testRetain*`. +`failIfNoSpecifiedTests=false` is needed because Surefire otherwise requires a match in every module. ---- +To release, configure `~/.m2/settings.xml` for OSSRH and run `mvn -Prelease clean deploy`. diff --git a/UPGRADING.md b/UPGRADING.md index c99de606f..8a2c81203 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,31 @@ +# Upgrading from 4.0.x to 4.1.x + +## On-disk format version 7 +- Version 7 of the on-disk graph format adds one feature, fused ASH (`FeatureId.FUSED_ASH`): ASH codes + stored in the graph alongside each node's neighbors. It also stores the largest vector length that + adaptive dot-product stopping needs: one float at the end of each serialized PQ model, and of the + inline-vector and separated-vector feature headers. Otherwise a version 7 file has the version 6 layout. + ASH models (`AsymmetricHashing`, `ASHVectors`) also require version 7 or later when they are written or + loaded. +- Dot-product searches over indexes and PQ models written before version 7 stop by the standard rule, + since their largest vector length is unknown; rewriting them at version 7 does not change that for codes + encoded before, so a PQ model loaded from an older format keeps it unknown. +- JVector 4.0.x reads formats up to 6 and rejects version 7 files. `OnDiskGraphIndex.CURRENT_VERSION`, + the default for `OnDiskGraphIndexWriter.Builder` and for `CompressedVectors.write(out)`, is now 7. +- To keep a rollback to 4.0.x possible, pass the version explicitly until every reader runs 4.1: + `withVersion(6)` on graph writer builders and `write(out, 6)` for compressors and compressed vectors. + Versions 6 and below still write inline vectors, NVQ, separated vectors and fused PQ exactly as before, + and 4.0.x reads them. A version 6 writer rejects fused ASH, and ASH models reject version 6. +- `OnDiskGraphIndexCompactor` always writes `CURRENT_VERSION`; it has no option to write an older version. + +## New features +- Asymmetric Hashing (ASH) quantization, stored separately or fused into the graph (format version 7), with + native FP16, FP32 and INT8 query projection and bounded queued query execution. See + [ASH configuration and defaults](docs/ash-configuration.md) and + [Integrating ASH queries into a database](docs/ash-query-integration.md). +- JVector sets the native BLAS thread count for ASH training: one thread by default, or the size of the + executor passed to `AsymmetricHashing.initialize(..., ForkJoinPool executor)`. + # Upgrading from 3.0.x to 4.0.x ## New features diff --git a/docs/ash-configuration.md b/docs/ash-configuration.md new file mode 100644 index 000000000..098443b4c --- /dev/null +++ b/docs/ash-configuration.md @@ -0,0 +1,232 @@ +# ASH configuration and defaults + +ASH has independent settings for stored codes, construction, query projection, +and query execution. This reference covers library parameters and BenchYAML +controls. The [BenchYAML Guide](benchyaml-guide.md) defines the index YAML +fields. Recipes specify their own graph and compression values. + +Set JVM properties before the affected model, provider or executor is initialized. +Query views and pools are cached; changing a property later does not reconfigure +existing objects. CPU-count properties do not pin threads. + +## Stored codes and training + +BenchYAML puts these under `compression[].parameters` with `type: ASH`. +Construction compression controls graph scoring. Search compression selects +the codes stored with a fused ASH graph. + +| Parameter | Default / requirement | Optional values and definition | +| --- | --- | --- | +| `optimizer` | Required | `ITQ` (`1`): iterative-quantization transform. `RANDOM` (`2`): random transform. | +| `encodedBits` | Exactly one of this and `compressionRatio` | Total stored bits including the 40-bit / five-byte header. The positive payload `encodedBits - 40` must be divisible by `bitsPerDimension`. | +| `compressionRatio` | Alternative to `encodedBits` | BenchYAML accepts `8`, `16`, `32`, `64`, `128`, `256`. Resolves a payload near the requested FP32-to-code ratio, with a minimum 64-bit payload and whole projected dimensions; inspect the printed resolved size. | +| `bitsPerDimension` (`b`) | `1` | Integer `1` through `9`, all supported by fused ASH. `1`, `2` and `4` have the most optimized kernels (see below). Stored-code precision is independent of query-projection arithmetic. | +| `landmarkCount` (`C`) | `1` | Integer `1` through `256` for the quantizer. Symmetric graph-construction scoring currently requires `C=1` and dot-product similarity. | +| Projected dimension (`d`) | Derived, not a separate YAML field | `d = (encodedBits - 40) / b`, with `1 <= d <= D` for input dimension `D`. Increasing `d` or `b` increases code size. | + +For an exact payload, use `encodedBits = 40 + d*b`. For example, a 256-D +input projected to 128 dimensions at four bits uses `encodedBits: 552`. +Include the header when comparing memory budgets with other quantizers. + +### Choosing the projected dimension for speed + +For the fastest construction and search, use `b` = 1, 2 or 4 and make `d` a +multiple of 64. The native kernels then process whole 64-bit words and whole +SIMD vectors, with no leftover dimensions: + +- Construction compares stored codes with each other. At 1 bit, the kernel + works in words of 64 dimensions. At 2 bits, it works in words of 32 + dimensions. At 4 bits, it works in vectors of up to 128 dimensions. +- Search builds per-query lookup tables in 16-byte chunks of code and projects + queries 32 output dimensions at a time. + +Other values of `d` are correct and handled in one extra masked step, so the +penalty is small. In the measurements below, a 2-bit code at `d = 380` costs +about 20-30% more per construction comparison than at `d = 384`, and search +QPS differs by about 1.5%. When a byte budget lands just short of an aligned +`d`, prefer the aligned value. For example, with a 384-D input and a +100-byte budget, `d = 384` at 2 bits (101 bytes, no dimension reduction) is a +better choice than `d = 380` (100 bytes). + +The other widths, `b` = 3 and 5 through 9, store each dimension as an unsigned +`b`-bit field. Search scores their graph neighborhoods with the projected query +rounded to INT8 and integer dot products (VNNI on x86 processors that have it), +so make `d` a multiple of 8. Their other asymmetric scoring uses the same +4-bit query tables as 2 and 4 bits. Construction still compares their codes +with a portable per-dimension path, so building is slower than at 1, 2 or 4 +bits; native code-to-code kernels for these widths are left for future work. + +Construction comparison cost, in ns per code pair, on one pinned core with +the native provider, on an Intel Xeon 6985P-C and on an AWS m6id.4xlarge +(Ice Lake). + +| `b` | `d` | Xeon 6985P-C | m6id.4xlarge | +| --- | --- | --- | --- | +| 2 | 192 | 9.3 | 16.7 | +| 2 | 380 | 16.6 | 30.0 | +| 2 | 384 | 13.8 | 22.5 | +| 4 | 190 | 15.2 | 24.0 | +| 4 | 192 | 14.5 | 24.1 | +| 4 | 384 | 18.4 | 28.5 | + +Training and base encoding retain their FP32 projection path; some training +statistics and factorizations use FP64 internally. Query precision does not +change the trained model, base encoding, stored-code layout or index format. + +## Graph construction + +BenchYAML selects construction scoring through `compression[].scoring` on +an ASH construction entry. Omission uses the JVM policy, which defaults to +`symmetric-all`. For example, this JVM property selects mixed scoring: + +```text +-Djvector.bench.ashConstruction=symmetric +``` + +| Property value | Insertion search | Existing-node / diversity scoring | +| --- | --- | --- | +| `symmetric-all` **(default)** | Encoded vector against encoded vectors; no dense insertion-query projection | Symmetric encoded scoring | +| `symmetric` | Original vector used as an asymmetric query | Symmetric encoded scoring | +| `symmetric-scalar` | Original vector used as an asymmetric query | Scalar symmetric scoring; diagnostic control | +| `asymmetric` | Original vector used as an asymmetric query | Original vector used as an asymmetric query; diagnostic control | +| `asymmetric-cached` | Original vector used as an asymmetric query | Cached asymmetric query state per node; diagnostic control with extra memory use | + +`jvector.bench.ashConstruction` controls **BenchYAML/Grid**, not every library +builder. For library integration, obtain a provider using +`BuildScoreProvider.ashBuildScoreProvider(similarity, encoded)`. Use +`builder.addGraphNode(node, provider.searchProviderFor(node))` for fully symmetric +insertion. The overload taking an original vector retains asymmetric query +preparation. Both policies use the same stored ASH vectors; application queries +continue to use asymmetric scoring. + +Construction policy can change the graph, recall and QPS. Use fresh indexes +when comparing modes: the graph cache key does not include this JVM property, +so changing it does not invalidate an existing cached graph. + +### In-memory code layout + +`ASHVectors` keeps one copy of the codes, in compact pages. Each vector's code +body sits back to back with the others: sign words at 1 bit, sign-magnitude +projection codes at 2 and 4 bits, and offset-binary fields at the other widths +(dimension `j` is the unsigned field at bits `[j*b, (j+1)*b)`, with value +`field - (2^b - 1)/2`). Every stored offset includes the landmark term +`-scale * `, so asymmetric scoring needs only `Aq`. Bodies have +no per-vector object, array or padding. Scale, offset and landmark are decoded +once into three parallel arrays. Every scorer, symmetric and +asymmetric, reads the codes in place, and the encoder writes straight into the +pages. + +Retained memory per vector is the serialized size plus 4 bytes, because the two +binary16 header fields are held as floats: for example 101 bytes on disk and +105 bytes in memory for 768-D at 32x. Pages hold up to 256 KiB, which keeps +them below G1's smallest humongous-object threshold so the collector never +rounds them up to whole regions. `ASHVectors.get(i)` returns an allocated copy; +`copyTo(i, dest)` fills reusable storage. Optional caches built for block +scoring (`blockScorerFor` with a block size) hold a second, interleaved copy and +are charged in `ramBytesUsed()` when created; graph construction and search do +not build them. + +## Query projection and scoring + +| JVM property | Default | Optional values and definition | +| --- | --- | --- | +| `jvector.ash.queryProjection` | `auto` | `auto`: prefer INT8 where an integer dot-product backend exists, then optimized FP16, falling back to FP32. `fp32`: FP32. `fp16`: require optimized FP16 with FP32 accumulation/output. `int8`: require INT8 projection with integer accumulation and FP32 output. Explicit unsupported precisions fail; `auto` never selects INT8. | +| `jvector.ash.singleKernel` | `auto` | `auto`, `scalar`, `simd`; single-vector ASH scoring kernels, subject to provider/format support. | +| `jvector.ash.blockKernel` | `auto` | `auto`, `scalar`, `simd`; blocked/fused ASH scoring kernels, subject to provider/format support. | +| `jvector.ash.queryBatchSize` | `8` | Positive integer below `Integer.MAX_VALUE`; maximum opportunistic batch for model-owned synchronous query preparation. Separate from the queued batch limit below. | + +See [Choosing ASH query-projection precision](ash-projection-precision.md) for +processor guidance and FP16/INT8 requirements. Under `symmetric-all`, query +precision affects application queries, not insertion search. Under mixed or +asymmetric construction, it can affect insertion too. + +### BenchYAML precision selection + +Set `queryProjection` in the `parameters` map of an ASH compression entry. +See [ASH query parameters](benchyaml-guide.md#ash-query-parameters) for values +and JVM precedence. +ASH CSV rows record `ash_query_projection_requested` and +`ash_query_projection_effective`; the latter is read from the prepared view, +with `fp16+fp32` indicating observed automatic FP32 range fallback. Non-ASH +rows have blank precision columns; an unused projection has blank effective +precision. See the [precision guide](ash-projection-precision.md#benchyaml-runs). + +## Providers, CPU budgets and serialization + +| Setting | Default | Optional values and definition | +| --- | --- | --- | +| `jvector.vectorization_provider` | Automatic selection | `native`, `panama`, `default` (scalar). Supported Java 22+ runtimes prefer native, with existing fallbacks. Explicit `native` requires successful loading. | +| `jvector.enable_native_vectorization` | `true` | `true` / `false`; automatic native selection. Explicit provider selection overrides it. Deprecated `jvector.experimental.enable_native_vectorization` is a lower-precedence alias. | +| `jvector.physical_core_count` | OS topology detection bounded by available processors; fallback heuristic | Positive physical-core budget for JVector's bounded pool and queued workers. Construction and queries can use separate budgets/processes. | +| `-XX:ActiveProcessorCount` | JVM/container detection | Positive processor count exposed to the JVM; coordinate with allocated physical cores. Does not set affinity. | +| `--add-modules=jdk.incubator.vector` | Not added by the application | Enables the Vector API module needed by SIMD providers on supported runtimes. | +| `--enable-native-access=ALL-UNNAMED` | JVM native-access policy | Native access for classpath deployments. Named-module deployments should enable the relevant module. | +| `-Xms`, `-Xmx` | JVM ergonomics | Initial / maximum heap; account for resident input, model and construction memory. | +| `-ea`, `-da` | Assertions normally disabled | Enable / disable Java assertions; independent of numerical precision. | + +### BLAS threads + +ASH training is the only BLAS and LAPACK user. JVector sets the native BLAS +thread count for its own calls, so a host does not need to configure MKL or +OpenBLAS: training uses one BLAS thread, or as many as the executor passed to +`AsymmetricHashing.initialize(..., ForkJoinPool executor)`. Queries never call +BLAS. With the native provider, the count is applied per calling thread through +MKL's `MKL_Set_Num_Threads_Local` or OpenBLAS's `openblas_set_num_threads_local`; +OpenBLAS releases before 0.3.27 only have a process-wide setting, which JVector +sets for the duration of training. Other providers leave BLAS threading to +`MKL_NUM_THREADS`, `OMP_NUM_THREADS` and `OPENBLAS_NUM_THREADS`. +`io.github.jbellis.jvector.util.BlasThreads` applies the same limit to a host's +own BLAS work. + +BenchYAML trains in its construction pool, so BLAS uses `construction.workers` +threads (all physical cores by default). Set `MKL_DYNAMIC=FALSE` so MKL uses the +count it is given. The ASH versus PQ study scripts also set the thread variables, +to one for queries and the core budget for construction, as in the published +runs. OS affinity (`numactl --physcpubind`) and memory placement (`--membind`, +`--interleave`) are deployment choices. + +## Graph and search parameters in BenchYAML + +Use the [BenchYAML Guide](benchyaml-guide.md) for index type, graph parameters, +writer selection, compression, and reranking. + +## Query delivery and measurement controls + +Set BenchYAML query delivery and batch limits in the +[ASH search parameters](benchyaml-guide.md#ash-query-parameters). Database +integrations select their own admission policy and executors. See +[Integrating ASH queries into a database](ash-query-integration.md). + +| Setting | Default | Optional values / definition | +| --- | --- | --- | +| `jvector.bench.queryThreads` | `0` | Direct caller count: `0` uses common pool, `1` is serial, larger positive values use a dedicated caller pool. Does not set queued worker count. | +| `jvector.bench.verifyConcurrentResults` | `false` | `true` / `false`; validate concurrent results against reference results. | +| `jvector.bench.throughputMinSeconds` | `1.0` | Minimum throughput sample duration in seconds. | +| `jvector.bench.runConfig` | `jvector-examples/yaml-configs/run-config.yml` | Run YAML path for measured benchmarks, console and CSV fields. | +| `jvector.bench.sourceRef` | Unset | Source identity for provenance; no numerical effect. | + +Run YAML `benchmarks`, `console` and `logging` select computed/reported throughput, +recall, latency and visit counts. Construction separately reports quantizer +training, encoding, NVQ training and graph/write. Queued execution can also +report loaded response times and mean batch size. Unloaded latency and loaded +response time describe different measurements. Independent build/query JVM +repetitions are launch choices, not model defaults; preserve individual CSVs. + +The ASH versus PQ case script (`run-ash-pq-benchmark.sh`) accepts `JVECTOR_BENCH_DATA_DIR` (required local input +directory), `JVECTOR_BENCH_CATALOG` (`catalog-1m.yml`; optionally +`catalog-large.yml`), `JVECTOR_BENCH_HEAP` (`128g`), and +`JVECTOR_BENCH_NUMA_POLICY` (`bind:0`; alternatively `interleave:`). +These defaults belong to the script, not the library. See the +[Reproducing ASH versus PQ](ash-pq-benchmark.md) for launch and artifact details. + +## Diagnostics + +`jvector.ash.debugSymmetric` defaults to `false`. Its optional construction +check is bounded by `jvector.ash.debugSymmetric.pairs` (`2000`), +`jvector.ash.debugSymmetric.subsetN` (`50000`) and +`jvector.ash.debugSymmetric.topK` (`100`). Keep diagnostics off for normal timing. + +Implementation references: [YAML compression parsing](../jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java), +[ASH model](../jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java), +[construction modes](../jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java), +and [run configuration](../jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java). diff --git a/docs/ash-neighborhood-benchmark.md b/docs/ash-neighborhood-benchmark.md new file mode 100644 index 000000000..695fe6283 --- /dev/null +++ b/docs/ash-neighborhood-benchmark.md @@ -0,0 +1,275 @@ +# ASH neighborhood scoring and isolated full-scan benchmarks + +The graph decoder and `DistancesASH` use the same `ASHNeighborhoodScorer`. +It prepares a query once, then scores complete serialized neighborhoods, including +binary16 headers, landmark correction, and DOT_PRODUCT similarity conversion. +The graph path adds neighborhood I/O and traversal around this scorer. The +benchmark invokes the scorer directly, so those graph operations are untimed. + +## Code organization and ownership + +| Component | Responsibility | +| --- | --- | +| `ASHNeighborhoodScorer` | Immutable projection/LUT state for one query; selects scalar, Panama, or native execution | +| `FusedASHDecoder` | Thread-confined graph I/O, upper-layer source scoring, reusable scratch, and origin cache | +| `FusedASHLayout` | Shared packed layout, checked byte-size calculations, and canonical-to-fused conversion | +| `NativeVectorUtilSupport` | Native provider capability and delegation | +| `NativeASHKernels` | Package-private, checked FFM binding and native memory lifetime | +| `ash_neighborhood-inl.h` | Highway lookup, accumulation, header decoding, and score conversion | +| `ASHNeighborhoodBenchmark` | DistancesASH options, diagnostics, and timing/reporting | +| `ASHNeighborhoodFixture` | Untimed input loading, model/encoding creation, serialization, and replay | +| `ASHNeighborhoodValidator` | Untimed double-precision decoded-component oracle | +| `ASHNeighborhoodPipeline` | Persistent scoring workers and preparation/scoring overlap | + +The prepared scorer can be shared across threads if inputs remain immutable and +outputs are separate. A graph decoder owns mutable scratch and a cached origin, +so each decoder belongs to one graph view/search thread. Upper-layer scoring and +L0 neighborhoods share one query projection. A failed neighborhood read or score +invalidates the decoder's cached origin before any output can be reused. + +Canonical and fused scoring both use `Aq`: every stored offset already includes +the landmark term `-scale * `, so `FusedASHLayout.packForFused` +copies the header as stored. The writer and fixture builder use that shared +helper rather than maintaining separate scoring formulas. + +## Native C=1 execution + +With `jvector.vectorization_provider=native` and +`jvector.ash.blockKernel=auto` or `simd`, C=1 neighborhoods use Highway. The native +path supports every bit width (see below for 3 and 5 to 9 bits), block widths 8/16/32, partial +blocks, and neighborhoods containing multiple blocks. C>1 retains the existing +scorer. Selecting the Panama provider retains the Panama kernel; selecting +`blockKernel=scalar` disables acceleration for the LUT scoring path. + +The unit passed across FFM is **one entire neighborhood**. Its native entry, +`ash_score_neighborhood_c1_f32`, reads packed codes and the existing float LUT, +decodes headers, applies landmark correction and similarity conversion, and writes +all configured lanes. It retains the disk format and float scoring formula. +No additional byte-LUT quantization is introduced. + +The AVX-512 B32 path shares each pair of 16-float register lookup tables across +both 16-lane halves and uses four independent accumulators. Headers are stored as +big-endian binary16 arrays and decoded with vector byte swaps and half-to-float +conversion. AVX2 uses indexed float LUT loads; the SSE software conversion includes +explicit handling for NaN and infinity. AVX3_DL and AVX3_SPR inherit the AVX3 ASH +slot, so an `avx3_spr` dispatch report currently executes that AVX-512 implementation. + +Offset-binary widths (3 and 5 to 9 bits) use a second entry, +`ash_score_neighborhood_i8_c1`, over field blocks (`[unit][lane][b bytes]`, eight +dimensions per unit). The projected query is rounded to INT8 once per query. Each +lane's eight fields are gathered into one 64-bit word, rotated into separate bytes +(`vpmultishiftqb` on AVX-512 VBMI) and multiplied with the query by integer +quad-dot instructions (`vpdpbusd` with VNNI); Highway supplies the equivalent +operations on other targets. Sums are exact integers; only the query rounding +differs from FP32 scoring, which moved exhaustive recall@10 on 1M datasets by at +most 0.0003. The header finish is shared with the LUT entry. + +### FFM safety and lifetime + +`NativeASHKernels` lives outside the jextract-generated `cnative` package and +exposes no public raw-pointer interface. The provider detects the optional ASH +symbol when its ASH capability is queried, after library loading. Libraries without +that symbol retain the existing Java LUT path; unrelated native operations do not +acquire a dependency on this kernel. An explicitly requested native benchmark +fails if that capability is absent, preventing a mislabeled fallback measurement. +The binding validates dimensions, lengths, and LUT/output non-aliasing before C++. Native output is unspecified on error. Status values are part +of the ABI: 0 means success, 1 an invalid C=1 landmark, and 2 an invalid shape. + +Normal graph neighborhoods use a short critical downcall with direct heap-array +access. No copies, Java callbacks, pointer retention, or native allocation occur +on that path. Critical work is bounded at 65,536 charged lane/group contributions; +charging includes full serialized blocks and at least 16 SIMD slots per block. +This bounds arithmetic work even for a tiny output with an enormous LUT. The +budget is independent of CPU model and dataset; it is a conservative safety limit, +not a measured wall-clock guarantee or an optimized copying crossover. For example, +B32 / 512 projected dimensions uses at most 16,384 contributions. + +Larger legal workloads use one ordinary downcall with temporary confined native +buffers. Their copy/allocation cost remains part of scoring, and the JVM can reach +safepoints during the longer call. Both paths retain the one-neighborhood-per-call +contract. Enable `jextract.trace.downcalls=true` only for untimed diagnosis. + +Native float vectors are heap-backed. Their Vector API stores write directly to +the backing float array, which avoids a HotSpot 22 `intoMemorySegment` compiler +crash without compiler exclusions. The regression suite exercises compiled stores at every +float vector width and nonzero offsets. + +## Build and launch with JDK 22 + +Use JDK 22 for measurements; the benchmark does not check the runtime version. +The examples module keeps the repository's Java 16 source compatibility, and the +native module uses the JDK 22 FFM API. + +The native build uses the repository's Meson targets and compiler policy. The +Sapphire Rapids target requires GCC 12+ or Clang 14+; ASH adds no ISA target or +compiler requirement. If the default compiler is older, set `CXX`, for example +`CXX=/usr/bin/g++-12`. The build script omits `--wipe` when no Meson tree is +configured yet, so first builds work on older Meson releases. A failed build +stops Maven and leaves no stale resource library to package. + +In IntelliJ, run `io.github.jbellis.jvector.example.DistancesASH` with JDK 22 and +these VM options, changing the fixture path as appropriate: + +```text +--add-modules=jdk.incubator.vector +--enable-native-access=ALL-UNNAMED +-Xms2g -Xmx2g +-XX:ActiveProcessorCount=1 +-XX:UseAVX=3 +-Djvector.vectorization_provider=native +-Djvector.ash.blockKernel=simd +-Djvector.bench.neighborhood-scoring=true +-Djvector.bench.neighborhood-scaling=true +-Djvector.bench.scoringThreads=1 +-Djvector.bench.neighborhoodAccess=shuffled +-Djvector.bench.neighborhoodFixture=/path/to/cohere-c1-b32-2bit.ashn +-Djvector.bench.neighborhoodSize=32 +-Djvector.bench.blockSize=32 +-Djvector.ash.bitsPerDimension=2 +-Djvector.ash.quantizedDimensions=512 +-Djvector.ash.landmarkCount=1 +-Djvector.ash.optimizer=random +-Djvector.bench.maxVectors=99685 +-Djvector.bench.maxQueries=4 +-Djvector.bench.scoringPasses=5 +-Djvector.bench.scoringMinSeconds=0.75 +-Djvector.bench.scoringWarmupSeconds=2 +``` + +To create a fixture, supply `base.fvecs query.fvecs` as program arguments. An +optional third ground-truth argument is accepted and ignored. Input loading reads only the requested prefixes; queries are normalized +and base vectors retain their original values. Models, encoded vectors, and packed +neighborhoods are created outside timing. Replaying an existing fixture needs no +program arguments and performs no training, encoding, or packing. + +Fixtures are published only after a complete write; existing destinations are +preserved. Separate names are required for different bit widths or shapes. +Replay checks version, lengths, model dimensions, query values, payload sizes, and +truncation/trailing data. The printed SHA-256 identifies the exact stored inputs. +The fixture format is version 1. + +For command-line launches, build the reactor and resolve a classpath with the +repository's Maven tooling. Set CPU affinity explicitly, for example +`taskset -c 2 "$JAVA_HOME/bin/java" ...`, using the same VM options and project +classpath as IntelliJ. `ActiveProcessorCount` changes JVM ergonomics; affinity +sets the physical CPU budget. + +## Measurement contract + +One full scan prepares the selected saved query exactly once, then scores every +saved neighborhood exactly once. Headers, score correction, similarity conversion, +and checksum consumption are included. The last neighborhood is padded to capacity +and fully scored. Output reports both real base-vector throughput and scored-lane +throughput, so padding is visible. + +With `neighborhood-scaling=true`, a fixed two-scan pipeline prepares subsequent +queries while persistent workers score earlier scans. Every scan owns independent +prepared state. Pool threads own their output scratch. Timing begins before the +first preparation and ends after all submitted scans complete: preparation, task +submission, completion, checksums, pipeline fill, and drain are included. + +`ns_per_scan` in this mode is total elapsed time divided by completed full scans, +a throughput metric. `ns_preparation_per_scan` is an overlapping diagnostic from +inside that same interval and must not be added again. The same selected saved +query is repeated across scans; no multi-query SIMD batching is used. The regular +non-scaling mode measures serial full scans on the calling thread instead. + +The preparation coordinator, scoring workers, and JVM helper threads share the +stated CPU affinity. One-core experiments have no additional preparation core. +For scaling, select one hardware thread per physical core and keep NUMA placement +consistent. A full scan partitions the same neighborhood sequence at each worker +count; input work does not grow with worker count. Actual preparation bottlenecks, +contention, and scheduling overhead stay visible in the measured result. + +Validation is independent of the LUT kernel and projects queries in double +precision. It checks serialized header effects and uses the tolerance +`3e-5 + 3e-5 * abs(expected)`. Both expected and actual scores must be finite. +Validation, fixture I/O, and worker creation are outside the timed scan stream. + +### Comparison controls + +Use fresh JVMs for `panama/scalar`, `panama/simd`, and `native/simd`. The scalar +comparator selects the scalar LUT kernel; its query preparation still uses the +Panama provider. Keep fixture hash, query index, bit width, block shape, access +order, JDK, heap, warmup, and affinity identical. The `Scorer:` line identifies the +actual execution path; the native provider's kernel description identifies its ISA. + +Small fixtures can fit in a CPU's shared last-level cache. These measurements +describe repeated in-memory scoring, without disk I/O. Results are +specific to the tested workload and hardware. Pipelined full-scan throughput does +not by itself describe adaptive graph traversal latency or graph-search scaling. + +| Property suffix under `jvector.bench.` | Default | Meaning | +| --- | --- | --- | +| `neighborhoodSize` | 32 | Scored capacity when creating a fixture | +| `blockSize` | Chosen from capacity | Serialized width: 8/16/32 | +| `neighborhoodAccess` | sequential | Base order; `shuffled` uses a reproducible permutation | +| `queryIndex` | 0 | Saved query to repeat; validation uses all saved queries | +| `maxVectors` | 8192 | Base input prefix used during fixture creation | +| `maxQueries` | 8 | Query input prefix saved during fixture creation | +| `scoringThreads` | 1 | Persistent scoring workers in pipelined mode | +| `scoringPasses` | 5 | Samples per JVM | +| `scoringMinSeconds` | 0.5 | Minimum sample duration before complete drain | +| `scoringWarmupSeconds` | 1.0 | Warmup duration | +| `validationNeighborhoods` | 1024 | Evenly spaced neighborhoods checked per saved query | + +A full scan always covers the complete fixture. Malformed integer options fail +rather than selecting a silent fallback value. + +## Regression checks + +Run the ASH regression classes with native, Panama, and the default scalar +provider. The focused set is: + +```text +NativeVectorStoreTest +TestASHNeighborhoodScorer +TestASHNativeScoring +TestASHNeighborhoodBenchmark +TestASHLutScoring +TestASHStandaloneScoring +TestASHStandaloneVsFusedScoring +TestASHGraphSearch +TestFusedASHBlockPacking +TestASHScoringDispatch +``` + +For example, with JDK 22 and an appropriate `CXX`: + +```bash +mvn -B -pl jvector-tests -am test \ + -DskipSIMD=false -DskipScalar=true \ + -Djvector.vectorization_provider=native \ + -Dtest=NativeVectorStoreTest,TestASHNeighborhoodScorer,TestASHNativeScoring,TestASHNeighborhoodBenchmark,TestASHLutScoring,TestASHStandaloneScoring,TestASHStandaloneVsFusedScoring,TestASHGraphSearch,TestFusedASHBlockPacking,TestASHScoringDispatch \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +Native-only ABI checks are skipped when another provider is selected. To test +lower native dispatch tiers on a capable host, use fresh JVMs with +`JVECTOR_MAX_ISA=avx2` or `JVECTOR_MAX_ISA=sse42`. The tests cover critical and ordinary +FFM calls, tails, multiple blocks, invalid shapes, aliasing, canaries, IEEE headers, +query lifetimes, failed workers, interruption, fixture replay, and real graph use. + +## Full-scan recall@10 + +Use the same DistancesASH neighborhood entry point with `jvector.bench.recall=true`. +Set `jvector.bench.recall.groundTruth=/path/to/matching.ivecs` and optionally +`jvector.bench.recall.output=/path/to/new-results.tsv`. Output files are not overwritten. +`scoringThreads` now controls parallel independent queries; each query is prepared +once and scans every stored neighborhood. This mode returns before throughput timing. +It uses all saved queries rather than `queryIndex`; set `maxQueries` at fixture +creation to retain the complete query set. Use the entire base set for recall. + +The top ten are selected directly from approximate scores without exact reranking. +Padding is scored but excluded from top-k, and equal scores prefer the smaller +original base ordinal in every backend. Recall is the intersection with the first +ten unique ground-truth IDs divided by ten, averaged over queries. Little-endian +ivecs rows are validated against the real base cardinality. The optional TSV +retains each query's hit count, ranked IDs and scores, enabling comparison beyond +the aggregate recall. An independent decoded-component check precedes evaluation +on up to four queries; recall itself uses every query and every real base vector. + +Create each fixture once, then replay the identical SHA-256 under scalar, Panama +and native providers. In particular, the legacy DistancesASH canonical-vector +recall mode does not exercise the native whole-neighborhood Highway entry. The +neighborhood recall mode is required to validate that implementation. diff --git a/docs/ash-pca-native-svd.md b/docs/ash-pca-native-svd.md new file mode 100644 index 000000000..cd8afb84c --- /dev/null +++ b/docs/ash-pca-native-svd.md @@ -0,0 +1,40 @@ +# ASH PCA correctness + +PCA first computes FP64 covariance with DSYRK and its eigendecomposition with +DSYEVD. This avoids computing and discarding a full tall left-singular-vector +matrix. The input is read-only. Finite values, covariance trace, eigenvalue energy, +the retained tail, the retained/discarded spectral gap and selected eigenpair +residuals are validated before the leading directions are accepted. + +Covariance squares the condition number. A retained eigenvalue or cutoff gap +at the scale of accumulated FP64 error causes fallback to direct DGESDD. Zero +energy, wide input and eigenvalue nonconvergence also use direct SVD. Its full +right basis preserves null-space completion for underdetermined training. +Invalid input, invalid native arguments and invalid workspace sizes are reported; +allocation failures are not swallowed. Model and graph formats are unchanged. + +Tests compare subspace projectors with an independent direct SVD, including +repeated eigenvalues inside the retained subspace, ties at its boundary, +ill-conditioned and rank-deficient matrices, wide inputs and input preservation. + +## Why PCA avoids the QR SVD driver + +Some LAPACK builds return incorrect results from the multi-threaded, +optimal-workspace QR SVD path (DGESVD). With MKL 2020.4, a finite, well-formed +15,360 × 768 training matrix produced either `DGESVD info=767` (QR +nonconvergence; see +[the LAPACK contract](https://www.netlib.org/lapack/explore-html/d1/d7f/group__gesvd_gac6bd5d4e645049e49bb70691180abf07.html)) +or a successful return with a 1.7% spectral-energy error. The same matrix was +accurate to about 1e-13 with DGESDD, with DGESVD on minimum workspace, or with +one thread. + +PCA therefore never calls DGESVD. The covariance path above is the primary +method, and the divide-and-conquer DGESDD driver, also used by ITQ +orthogonalization, is the fallback. Tall inputs overwrite a scratch copy with the +unused left vectors; wide inputs request full right vectors so projections can +include null-space directions when training has fewer rows than dimensions. +There are no CPU-, vendor-, bit-width- or JDK-specific branches or thread-count +workarounds. + +Saved models are not rewritten. Retrain the ASH compressor and rebuild its graph +to adopt the current PCA. diff --git a/docs/ash-performance.md b/docs/ash-performance.md new file mode 100644 index 000000000..b8077f359 --- /dev/null +++ b/docs/ash-performance.md @@ -0,0 +1,136 @@ +# ASH performance + +This page compares fused ASH and fused PQ graph indexes on three large datasets, +at matched code sizes: both store the same bytes per vector, including ASH's +five-byte header. [Reproducing ASH versus PQ](ash-pq-benchmark.md) explains how +to run the same study on your own hardware. + +## Summary + +ASH wins total construction in 6/6 configurations, recall in 12/12 cases and QPS +in 22/24 cases. The sum of all six construction times is 48.5% lower with ASH. +The better value in each comparison is in bold. + +### Total construction seconds (lower wins) + +| Dataset | Compression | PQ | ASH | ASH change | Faster | +| --- | --- | ---: | ---: | ---: | --- | +| MPNet 10M | 32x (b=2) | 343.7 | **238.1** | −30.7% | ASH | +| MPNet 10M | 16x (b=4) | 568.9 | **301.2** | −47.1% | ASH | +| Cohere 10M | 32x (b=2) | 478.0 | **263.3** | −44.9% | ASH | +| Cohere 10M | 16x (b=4) | 771.2 | **335.8** | −56.5% | ASH | +| CAP 6M | 32x (b=2) | 295.6 | **190.2** | −35.6% | ASH | +| CAP 6M | 16x (b=4) | 630.7 | **261.8** | −58.5% | ASH | +| **Sum: six configurations** | | 3,088.1 | **1,590.5** | −48.5% | ASH | + +### Recall and throughput (higher wins) + +| Dataset | Compression | Rerank | Recall@10 PQ | Recall@10 ASH | QPS 1 core PQ | QPS 1 core ASH | QPS 8 cores PQ | QPS 8 cores ASH | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| MPNet 10M | 32x (b=2) | 1x | 0.54043 | **0.75532** | **9,349** | 9,012 | 77,609 | **81,421** | +| MPNet 10M | 32x (b=2) | 2x | 0.71512 | **0.88903** | **7,540** | 7,266 | 61,759 | **62,913** | +| MPNet 10M | 16x (b=4) | 1x | 0.72452 | **0.81330** | 7,224 | **9,860** | 63,221 | **76,619** | +| MPNet 10M | 16x (b=4) | 2x | 0.86195 | **0.90050** | 5,661 | **7,397** | 48,894 | **57,709** | +| Cohere 10M | 32x (b=2) | 1x | 0.52124 | **0.58682** | 7,642 | **9,067** | 62,520 | **72,500** | +| Cohere 10M | 32x (b=2) | 2x | 0.70611 | **0.76181** | 5,826 | **6,875** | 48,494 | **55,340** | +| Cohere 10M | 16x (b=4) | 1x | 0.59860 | **0.66033** | 6,145 | **8,339** | 50,152 | **65,660** | +| Cohere 10M | 16x (b=4) | 2x | 0.75846 | **0.78864** | 4,684 | **6,122** | 38,369 | **49,100** | +| CAP 6M | 32x (b=2) | 1x | 0.41240 | **0.72119** | 5,815 | **7,295** | 51,746 | **62,544** | +| CAP 6M | 32x (b=2) | 2x | 0.59705 | **0.88574** | 4,748 | **5,756** | 41,022 | **48,482** | +| CAP 6M | 16x (b=4) | 1x | 0.65664 | **0.80468** | 4,436 | **6,295** | 39,786 | **58,394** | +| CAP 6M | 16x (b=4) | 2x | 0.83966 | **0.90749** | 3,550 | **4,774** | 31,393 | **41,777** | + +Compression ratios are nominal; ASH and PQ store the same bytes per vector, +including the ASH header. b is ASH bits per projected dimension. + +## Setup and method + +**Machine.** One Intel Xeon 6985P-C socket at 2.30 GHz base: 48 physical cores, +2 threads per core (96 hardware threads), 504 MiB L3. 354 GiB RAM in two NUMA +nodes of about 177 GiB each. Debian 12, Linux 6.1. Transparent huge pages: +enabled=always, defrag=madvise. + +**Storage.** Datasets, model caches and indexes are on local NVMe SSDs in Linux +md RAID 10 (512 KiB chunk, two near-copies), formatted XFS. The page cache was +not dropped between runs. + +**JVM.** OpenJDK 22.0.2 (Eclipse Temurin). Flags: +`--add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -da -Xms2g -Xmx128g -XX:ActiveProcessorCount=`. +No collector is selected, so the JVM uses its default: G1 for the 8- and 48-core +runs and Serial for the 1-core runs, because `ActiveProcessorCount=1`. +`JAVA_TOOL_OPTIONS` and `JDK_JAVA_OPTIONS` are empty. + +**JVector system properties.** `-Djvector.vectorization_provider=native +-Djvector.parallel_writer=true -Djvector.physical_core_count= +-Djvector.bench.queryThreads=<4 × cores> -Djvector.bench.verifyConcurrentResults=true +-Djvector.bench.throughputMinSeconds=3.0`. ASH construction scoring is +symmetric-all, the default; no other ASH properties are set. + +**Native thread pools.** `MKL_NUM_THREADS`, `OMP_NUM_THREADS`, +`OPENBLAS_NUM_THREADS` and `GOTO_NUM_THREADS` equal the core count for +construction and 1 for queries; `MKL_DYNAMIC=FALSE`. + +**CPU and memory placement.** Each JVM starts under `numactl`. Construction uses +one hardware thread on each of the 48 physical cores, with memory interleaved +across both NUMA nodes. Queries use 1 or 8 physical cores on node 0, with memory +bound to node 0. + +**Datasets.** All use dot-product similarity on normalized vectors, 10,000 +queries and exact top-100 ground truth. MPNet 10M: 9,997,697 base vectors × 768 +dimensions. Cohere 10M (English v3): 10,000,000 × 1,024. CAP 6M (gte-Qwen2-1.5B +case law): 6,070,488 × 1,536. + +**Index parameters.** On-disk index format 7, fused graph, parallel writer. +Degree (M) 32, `efConstruction` 100, neighbor overflow 1.2, alpha 1.2, hierarchy +and final refinement on. ASH: ITQ optimizer, one landmark (C=1), symmetric-all +construction scoring, and b = 2 or 4 bits per projected dimension over D/2 +projected dimensions. Code size per vector, including the five-byte header: +MPNet 101 / 197 bytes, Cohere 133 / 261 bytes and CAP 197 / 389 bytes for +b = 2 / 4. PQ uses the same number of bytes as 8-bit subspaces with 256 +centroids. Reranking uses NVQ vectors stored in the graph. + +**Query parameters.** Top 10 returned. Overquery 1.0 and 2.0 (rerank 1x and 2x) +rescore 10 and 20 candidates with NVQ. Search pruning off. ASH uses queued +execution with a maximum batch of 8 and automatic query projection, which +resolves to FP16 on this CPU: one ASH worker per core (1 or 8), queue capacity 8 +per core, fed by 4 × cores caller threads (4 or 32); the mean batch size is 8.0. +PQ uses direct execution with 4 × cores caller threads. Concurrent results are +verified against single-threaded results. + +**Measurement protocol.** Each configuration was built twice from empty caches. +Construction time is quantizer training, vector encoding, NVQ training, and +graph construction and writing; dataset loading and setup are excluded. Each +construction figure is the mean of the two builds: + +| Configuration | ASH build 1 (s) | ASH build 2 (s) | +| --- | ---: | ---: | +| MPNet 10M 32x (b=2) | 227.3 | 249.0 | +| MPNet 10M 16x (b=4) | 317.6 | 284.8 | +| Cohere 10M 32x (b=2) | 291.2 | 235.3 | +| Cohere 10M 16x (b=4) | 348.2 | 323.4 | +| CAP 6M 32x (b=2) | 192.6 | 187.8 | +| CAP 6M 16x (b=4) | 260.7 | 262.9 | + +Query JVMs reuse the first build's index, one JVM for 1 core and one for 8 cores. +QPS is the mean of three timed samples of at least three seconds each; each JVM +runs about 90,000 rerank-1x queries in total. Recall@10 is the fraction of the +exact top 10 returned. Jobs ran one at a time, with no other benchmark process on +the machine. PQ was measured in an earlier run on the same machine, JDK, +placement, input files and graph parameters, with PQ codes sized to the same +stored bytes per vector as ASH. + +**Single-core variation.** Single-core QPS varies between graphs built from the +same settings. On an earlier MPNet 10M 32x graph, ASH measured 10,414 / 7,838 +QPS on one core (rerank 1x / 2x). Three further runs of the current code and of +the earlier code on the reported graph averaged 9,229 / 7,460 and 9,124 / 7,334 +QPS with identical recall, so the code performs the same; on this graph, ASH's +single-core figures for that case are below PQ's 9,349 / 7,540. + +**ASH memory.** Encoded ASH vectors are stored back to back in shared pages and +scored in place, without per-vector objects. Measured on 2M MPNet vectors as live +heap with and without the encoded vectors (Serial and G1 agree within 1%), ASH +retains 109 bytes per vector at b = 2 and 205 at b = 4, compared with 189 and 285 +for the earlier per-vector storage: 42% and 28% less. The codes occupy 101 and +197 bytes per vector on disk; the remaining 8 bytes hold the header scale and +offset as floats and a per-vector construction term. The library's own +accounting (`ramBytesUsed`) reports the same 109 and 205 bytes. diff --git a/docs/ash-pq-benchmark.md b/docs/ash-pq-benchmark.md new file mode 100644 index 000000000..e48e6fc00 --- /dev/null +++ b/docs/ash-pq-benchmark.md @@ -0,0 +1,193 @@ +# Reproducing ASH versus PQ + +This study compares fused ASH and fused PQ graph indexes at matched code sizes: +both store the same bytes per vector, including ASH's five-byte header. It uses +ordinary packaged JVector code and `io.github.jbellis.jvector.example.BenchYAML`; +no separate query harness, source overlay or profiling agent is involved. + +Each case comes in two forms. The published results used +[separate JVMs](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq) +for construction and for each query core budget, run by `run-ash-pq-benchmark.sh` +(one case) and `run-ash-pq-study.py` (a whole study). Future runs use +[one JVM per case](../jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm): +it builds the index once and queries it with 1 and 8 search workers. The +[run configurations and dataset catalogs](../jvector-examples/yaml-configs/ash-pq) +define what is measured and where the input files are. The [BenchYAML Guide](benchyaml-guide.md) describes +the index configuration, and [ASH configuration and defaults](ash-configuration.md) +describes model and runtime settings. + +## Results + +[ASH performance](ash-performance.md) reports the 6M and 10M results: ASH wins +total construction in 6/6 configurations, recall in 12/12 cases and QPS in 22/24 +cases. It also describes the machine, JVM, placement and protocol used, so you +can note any differences in your own runs. The 1M recipes (MPNet, Cohere, Ada002 +and OpenAI-3072) use the same parameters and protocol. + +## Build the checkout + +Use JDK 22 for performance measurements. Follow the +[native prerequisites](../jvector-native/src/main/native/README.md), including the +compiler, Meson, Ninja and CMake. Native packaging fetches the pinned oneDNN +source and includes the statically linked ASH add-on. ASH training also uses +the installed BLAS/LAPACK provider; record it with the results, since a Java +fallback does not reproduce native training throughput. + +```bash +export JAVA_HOME=/path/to/jdk-22 +export PATH="$JAVA_HOME/bin:$PATH" +mvn -B -pl jvector-examples -am -DskipTests package -Dnative.onednn=true +``` + +This writes `jvector-examples/target/jvector-examples--jar-with-dependencies.jar`. +Build once, before any benchmark, and keep the checkout and JAR fixed throughout +a comparison. The scripts require a clean checkout so each run records an exact +commit. The run command requires the native provider, so a failed native +installation cannot silently become a scalar measurement. + +## Inputs and index parameters + +Obtain the normalized fvecs/ivecs files named in +[`catalog-1m.yml`](../jvector-examples/yaml-configs/ash-pq/catalog-1m.yml) and +[`catalog-large.yml`](../jvector-examples/yaml-configs/ash-pq/catalog-large.yml). +Cohere 1M, Ada002 1M and OpenAI-3072 1M are in the public dataset catalog +(see [dataset catalogs](../jvector-examples/yaml-configs/dataset-catalogs/datasets.md)); +the others must be obtained separately. Set `JVECTOR_BENCH_DATA_DIR` to the +absolute directory that holds them, keeping the relative file names in the +catalog. All use `DOT_PRODUCT` and `NO_SCRUB`. Use the exact base, query and +top-100 ground-truth files: a deduplicated base file without rebuilt ground +truth would change the experiment. + +| Dataset | Recipe prefix | Base vectors | Dimensions | Queries | Code bytes, b=2 / b=4 | +| --- | --- | ---: | ---: | ---: | ---: | +| MPNet 1M | `mpnet` | 999,812 | 768 | 10,000 | 101 / 197 | +| Cohere 1M | `cohere` | 1,000,000 | 1024 | 10,000 | 133 / 261 | +| Ada002 1M | `ada002` | 982,790 | 1536 | 10,000 | 197 / 389 | +| OpenAI-3072 1M | `openai3072` | 999,000 | 3072 | 1,000 | 389 / 773 | +| MPNet 10M | `mpnet10m` | 9,997,697 | 768 | 10,000 | 101 / 197 | +| Cohere 10M | `cohere10m` | 10,000,000 | 1024 | 10,000 | 133 / 261 | +| CAP 6M | `cap6m` | 6,070,488 | 1536 | 10,000 | 197 / 389 | + +ASH uses one landmark, an ITQ-trained transform to D/2 dimensions, and 2 or 4 +bits per projected dimension. PQ uses as many 8-bit subspaces as ASH's code +bytes, 256 centroids, no centering, and non-anisotropic training. The 2-bit and +4-bit labels describe the ASH cases, not PQ centroid precision. + +Both use an on-disk index (format 7) with fused graph features, NVQ reranking, +degree 32, `efConstruction=100`, neighbor overflow 1.2, hierarchy, final +refinement and the parallel writer, and return the top 10 at overquery 1 and 2. +ASH queries use queued execution with a maximum batch of 8 and automatic query +projection, which selects FP16 on supported CPUs. PQ queries use direct execution. + +## Run a study + +`run-ash-pq-study.py` runs every case of one dataset set in sequence, each in a +fresh JVM, and aggregates the CSV files. It contains no query loop or timer. + +```bash +export JVECTOR_BENCH_DATA_DIR=/data/vectors +python3 jvector-examples/run-ash-pq-study.py --dataset-set large \ + --data "$JVECTOR_BENCH_DATA_DIR" --output /data/jvector-bench/ash-pq-large +``` + +`--dataset-set 1m` (the default) runs the four 1M datasets. By default, each +configuration is built twice, in separate case directories, and one query JVM per +core count reuses the first build's index; PQ and ASH alternate order. CPU and +memory placement come from `lscpu` on the host: construction uses one thread on +every physical core with memory interleaved across all NUMA nodes, and queries +use the first 1 and 8 physical cores of one node with memory on that node. +`--help` lists the options for other placements and repetition counts, and +`--plan-only` prints the job list without running it. + +`plan.json` fixes the commit, JAR checksum and settings. `status.json`, +`results.json` and `summary.json` are updated after each invocation, and an +interrupted study resumes only with the same source and JAR. A process watchdog +(3 hours for large construction, 30 minutes otherwise) detects a hung +invocation; its wall clock is never reported as a measurement. Extra +construction repetitions release their caches after recording results. + +## Run one case + +`run-ash-pq-benchmark.sh` prepares a case directory, records the command and runs +standalone BenchYAML. It checks that the CPU list names the requested number of +distinct online physical cores, so two SMT siblings cannot count as two cores. + +```bash +export JVECTOR_BENCH_DATA_DIR=/data/vectors +export JVECTOR_BENCH_HEAP=128g +BENCH_CASE=/data/jvector-bench/mpnet-2bit-ash-r1 + +# Construction: every physical core, memory on all nodes. Pick lists from lscpu. +JVECTOR_BENCH_NUMA_POLICY=interleave:0,1 \ + bash jvector-examples/run-ash-pq-benchmark.sh \ + mpnet-2bit-ash.yml construction 48 0-47 "$BENCH_CASE" + +# Queries reuse that model and graph, each in a fresh JVM. +bash jvector-examples/run-ash-pq-benchmark.sh mpnet-2bit-ash.yml queued 1 0 "$BENCH_CASE" +bash jvector-examples/run-ash-pq-benchmark.sh mpnet-2bit-ash.yml queued 8 0-7 "$BENCH_CASE" +``` + +Query memory defaults to NUMA node 0 (`JVECTOR_BENCH_NUMA_POLICY=bind:0`). For the +matching PQ case, replace `ash` with `pq`, use `direct` instead of `queued`, and +use a separate case directory. For the larger datasets, set +`JVECTOR_BENCH_CATALOG=catalog-large.yml` for every invocation of the case. + +Construction refuses nonempty model and index caches, and query runs require +them. The recipes set `useSavedIndexIfExists: Yes` so the graph outlives its +construction JVM; nothing is deleted automatically. Do not run cases at the same +time or share their CPUs with other work. Report any heap, CPU, NUMA or library +differences from the measurement environment in +[ASH performance](ash-performance.md). + +Each run writes a complete `runs//command.sh`, including its +environment, so the Java command can be rerun directly. The case directory holds +copies of the catalog, dataset metadata and dataset list, because BenchYAML +resolves them relative to its working directory. + +## Run one case in one JVM + +`benchyaml-play.sh` builds the checkout, then runs one recipe in a single JVM on one +hardware thread of each physical core, with machine settings from a +[server profile](../jvector-examples/bench-profiles/README.md): + +```bash +BENCH_RUN_CONFIG=jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml \ + jvector-examples/benchyaml-play.sh \ + jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml +``` + +Each recipe sets `construction.workers: max` and `search.workers: [1, 8]`. +Construction, including BLAS during training, uses every physical core. Queries +then use one and eight search threads: direct PQ search runs on that many caller +threads, and queued ASH search on that many workers. All of the JVM's CPUs stay +available to other threads, such as garbage collection, so a 1-worker measurement +is not confined to one core as it is in the separate-JVM protocol. Compare +one-JVM results with each other, and check them against the published results +before replacing those. + +## Measurements and artifacts + +* **Construction:** BenchYAML's training, encoding, NVQ and graph-write timers; + dataset loading and setup are excluded. Each construction invocation reports + two overquery rows from one build. Cached-index invocations report no + construction time. +* **Throughput:** random-query warmups, then timed full query passes including + preparation, traversal and reranking. Each sample lasts at least three seconds. + Direct mode uses 4 × cores callers; queued mode uses at most one worker per + core, admission 8 × cores and a maximum batch of 8. +* **Unloaded latency:** one query at a time through the same execution path, + after three warmup passes of at least three seconds. It is not latency under a + controlled arrival rate. +* **Queued response:** sampled during throughput, from admission to the worker's + completion callback. It measures a bounded backlog, not an arrival-rate curve. +* **Recall:** the same inputs and ground truth for every mode. Concurrent results + are checked against single-threaded searches with the same prepared query. + +CSV results, system information and dataset information go to each case's +`logging` directory. The scripts also record the source commit, JAR checksum, +index and run YAML, Java version, CPU topology, console output and exit code +under `runs`. Keep these artifacts together with any reported numbers. + +Hardware, OS scheduling and parallel graph construction cause run-to-run +variation; reproducing the study means the same protocol and comparable +distributions, not identical numbers. diff --git a/docs/ash-projection-precision.md b/docs/ash-projection-precision.md new file mode 100644 index 000000000..678e81c5d --- /dev/null +++ b/docs/ash-projection-precision.md @@ -0,0 +1,110 @@ +# Choosing ASH query-projection precision + +Query-projection precision is independent of ASH's code parameters `b` and `d`. +Selecting INT8 projection does not change the stored two-bit or four-bit ASH +codes. Training and base-vector encoding retain their existing FP32 behavior. + +## Recommended setting + +These recommendations concern CPU query throughput and latency. Validate recall +on your data before deploying FP16 or INT8. + +| Processor family | Setting | Why | +|---|---|---| +| Intel Xeon Cascade Lake, Cooper Lake, Ice Lake | `auto` (INT8) | Fast integer projection with 256-bit tiles; the required FP16 path is unavailable. | +| Intel Xeon Sapphire Rapids, Emerald Rapids, Granite Rapids | `auto` (INT8) | INT8 with 512-bit tiles is faster than AMX FP16; use `fp16` where recall at low overquery matters more. | +| Intel Xeon Sierra Forest | `auto` (INT8) | Integer acceleration (AVX-VNNI through oneDNN) is the useful low-precision path. | +| AMD Zen 4 and Zen 5: EPYC, Ryzen, Threadripper | `auto` (INT8) | AVX-512 VNNI integer acceleration; these CPUs lack the FP16 instructions required by this backend. | +| Older Intel Xeon and AMD Zen 1–3 | `auto` (FP32) | Without VNNI or FP16 support, `auto` keeps the established FP32 path. | +| Arm Neoverse / AWS Graviton and Apple Silicon | `auto` (FP32) | The established supported path for this release. | + +For query throughput and latency on Intel Ice Lake: + +```text +-Djvector.ash.queryProjection=int8 +``` + +## Available settings + +Set `jvector.ash.queryProjection` before the model's first query use: + +| Setting | Behavior | +|---|---| +| `auto` (default) | Use INT8 where an integer dot-product backend is available (x86 VNNI or AMX); otherwise try optimized FP16, with the existing FP32 fallback if FP16 is unavailable or outside its supported range. | +| `fp32` | Use the existing FP32 projection. | +| `fp16` | Require optimized FP16 projection with FP32 accumulation and output. Report an error if unavailable or outside the supported range. | +| `int8` | Require optimized INT8 projection with signed 32-bit integer accumulation and FP32 output. Report an error if unavailable; never silently substitute FP16 or FP32. | + +`auto` selects INT8 first because it was the fastest projection on every CPU +measured: 1.03-1.10x FP16 on an AMX CPU and 1.2-1.5x FP32 on Ice Lake class CPUs, +across nine 1M datasets. The query is quantized to two INT8 digits with one scale: +a coarse digit and its rounding remainder, times 254. Nearly all INT8 projection +error came from rounding the query; with the second digit, exhaustive recall +matches FP32 projection, while the matrix keeps one INT8 digit per row. + +On AVX-512 VNNI CPUs, INT8 uses JVector's exact-integer tile kernel for single queries +and batches: 512-bit tiles on CPUs with AMX, and 256-bit tiles otherwise, where wide +integer dot products slow the graph search that follows. oneDNN serves INT8 on other +CPUs and chooses the CPU implementation within FP16 at runtime. +Explicit FP16 and INT8 settings fail clearly if the required backend is unavailable, including +when a virtual machine does not expose the needed hardware features. + +The model caches its query-projection view on first use. Changing the property +after initialization does not reconfigure an existing view. + +## BenchYAML runs + +Set `queryProjection` in the `parameters` map of an ASH compression entry. +The [BenchYAML Guide](benchyaml-guide.md#ash-query-parameters) defines the +accepted values. An explicit `-Djvector.ash.queryProjection=...` overrides YAML. +The default is `auto`. Use one projection mode per JVM. + +`experiments.csv` adds two columns for ASH cases: + +- `ash_query_projection_requested`: the selected setting after JVM/YAML precedence. +- `ash_query_projection_effective`: the prepared projection's actual precision. + `auto` can resolve to `int8`, `fp16` or `fp32`; `fp16+fp32` means the automatic FP16 + view has used its FP32 range fallback. + +The columns are blank for non-ASH cases. Effective precision is blank if no +query projection was initialized. Reporting reads the existing view after +measurement; it does not prepare another view or infer precision from the CPU. + +## Effect on graph construction + +BenchYAML defaults to `symmetric-all` construction: insertion and diversity use +encoded symmetric scoring, without dense query projection for insertion. Query +precision therefore affects application queries, not insertion under this default. + +Select `scoring: mixed` on the ASH construction entry to use asymmetric +insertion and symmetric diversity scoring. Query projection then applies +during insertion. +Library callers explicitly choose an encoded node score provider or an +original-vector insertion overload for their graph builders. See [ASH configuration and defaults](ash-configuration.md#graph-construction). + +Neither construction mode changes training or base-encoding precision. To +measure query-projection effects independently, query the same saved graph +under each precision in separate JVM runs. + +## Deployment + +FP16 and INT8 require the native vector provider and the oneDNN add-on included +by the standard native build. See the [native development guide](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package) +for build commands, supported runtime behavior, diagnostics and tests. + +## Validate a selection + +Use `DistancesASH` to check projection throughput and numerical behavior. +Use standard BenchYAML configurations to measure graph QPS, recall and latency +at the application's physical-core budget. Compare query precisions on the +same graph; measure construction separately when insertion precision changes. +A projection-throughput gain does not imply the same graph-query speedup. + +## References + +- [Processor instruction sets](https://gcc.gnu.org/onlinedocs/gcc-15.1.0/gcc/x86-Options.html). +- [oneDNN CPU runtime dispatch](https://github.com/uxlfoundation/oneDNN/blob/main/README.md). +- [oneDNN INT8 arithmetic](https://uxlfoundation.github.io/oneDNN/dev_guide_int8_computations.html). + +The repository pins its oneDNN version in +`jvector-native/src/main/onednn/subprojects/onednn.wrap`. diff --git a/docs/ash-query-batching-design.md b/docs/ash-query-batching-design.md new file mode 100644 index 000000000..26ee34d4f --- /dev/null +++ b/docs/ash-query-batching-design.md @@ -0,0 +1,122 @@ +# ASH query batching: maintainer design + +Database integrators should use [the caller integration guide](ash-query-integration.md). +They do not need to know or configure the coordination described here. + +## Projection and policy are separate + +The selected `AshProjection` receives independent FP32 query rows and caller-owned +FP32 output rows. FP16 uses converted FP16 operands with FP32 accumulation/output; +explicit INT8 uses quantized operands with integer accumulation and FP32 output. +Both native query backends use oneDNN, accept arbitrary batch counts and execute +bounded tiles of up to eight rows. Training and base-vector encoding continue +through the separate FP32 projection object. Precision selection belongs to the +[precision guide](ash-projection-precision.md), not to batching policy. + +`CallerBatchProcessor` is a generic synchronous computation combiner, independent +of ASH, FFM and oneDNN. Its `Operation` accepts borrowed input/output objects and +supports direct and batch operations. It creates no executor, worker, timer, +future or per-call request object after each thread's first use. It can be reused +for other computations that benefit from combining independent caller work. + +## Fixed capacity and a pressure bypass + +For ASH, C is the smaller of the JVM processor limit and the existing physical +core budget, with a minimum of one. The processor has C direct slots and +L = max(1, floor(C / 4)) combining contexts. Capacity follows the CPU budget, +not a caller-thread count that the library does not own. Neither value is learned +or matrix-specific. The sole optional ASH policy property is +`jvector.ash.queryBatchSize` (default 8, 1 disables coordination). + +The recommended caller-side worker capacity is 4C for every model. With eight +allocated physical cores this gives 32 caller workers and two combining contexts. +The library derives its internal capacity; database integrations do not select a +leader count. Keep the worker/core ratio fixed for throughput comparisons. A +single active query is the light-traffic scenario within that configuration. + +When no work is queued or executing as a multi-query batch, a caller claims a direct slot and runs +the original singleton operation immediately. No queue publication or batch-list +mutation is involved. Only actual overlap exhausting that direct budget sends +work through the combining path. This avoids throttling lightly overlapping, +short projections to L operations. + +A combining caller claims a free context and immediately takes existing FIFO +requests, up to the configured capacity. It does not wait for a full batch. +While requests are pending or a genuine multi-query batch is executing, new +arrivals join that path, preserving FIFO progress and the shared matrix +computation. Once the queue and real batches drain, free direct slots are usable +immediately. An overflowing singleton alone cannot suppress free direct capacity: +it must not create a queue phase after a brief overlap spike. The distinction +uses the actual batch width, not time measurements or prediction. + +Direct calls already executing are outside the combining limit. There can be at +most C + L executing projection operations, all on existing caller threads. This +is an operation bound, not an extra thread or CPU reservation. The policy does +not promise a full batch under heavy overall search traffic: backlog must occur +at projection, not merely elsewhere in search. + +## Publication, completion and failure + +Producers publish reusable thread-local request records to a CAS stack. A caller +with free combining capacity detaches and reverses a chain under a short +nonblocking drain gate, preserving FIFO publication order. Context capacity is +never reserved for a parked caller. No queue monitor is held during native work. + +Request state publishes assignment and completion. A parked-flag handshake avoids +unnecessary wakeups while preventing a completion/park race from losing a signal. +A caller may help drain work while waiting. Inputs, outputs and failure references +are cleared before reuse. A failed batch completes every member with the same +failure and releases capacity, allowing subsequent calls to proceed. Interrupts +are restored after completion; borrowed data is never abandoned mid-computation. +Reentry into the same processor is rejected. Each participating caller has at +most one outstanding request in this processor. The application's admission +budget bounds total pending work; maximum batch size is not a queue-depth limit. + +Input/output row-reference arrays used to bridge a batch to FFM are reused per +executing thread, grow only when needed, and are cleared in a finally block. +They do not retain the model. Native per-thread workspaces similarly do not own +the projection or its packed weights. Native batch kernels/weights are lazily +cached on the model and shared safely across callers. Exceptions cannot cross +the C ABI; status handling remains at the Java/native boundary. + +## Capability and memory ownership + +A projection explicitly advertises bulk support. Providers without a real batch +kernel bypass coordination. A missing batch ABI preserves compatibility with +older native libraries. A native UNSUPPORTED response disables subsequent batch +attempts on that FP16 projection and uses the existing singleton fallback. +Explicit INT8 requires its batch ABI and a qualified exact-integer primitive; +unsupported INT8 calls remain errors rather than changing precision. Other +failures remain errors rather than being silently hidden. + +The query-specific projection and coordinator are initialized together on first +query use and published through the volatile query projection. Encoding/training +alone allocates neither. ASH accounts for the coordinator, its operation object, +owned slot/context arrays, and query projection storage once per model. A query +view that aliases the primary FP32 projection is not charged twice. Native packed +weight growth updates the existing projection retained-byte estimate. Caller TLS +requests and scratch belong to the caller budget, matching existing query-scratch +ownership; they are not multiplied into each model's retained total. + +Batch histograms are diagnostic snapshots, exact when the processor is idle and +weakly consistent during concurrent writes. Updates belong to exclusive slots, +without a globally contended per-query counter. Snapshot allocation is outside +the search hot path. Statistics must not influence dispatch decisions. + +## Regression gates + +Tests cover singleton execution at batch capacity eight, pressure-triggered +combining, FIFO progress, bounded operation counts, repeated record reuse, +interruption, reentry, batch/direct failures, completion/park races, and steady +state allocation. Native tests compare singleton and batch results with an +independent FP16-rounded reference, including tails and counts above eight, +aliasing, validation, fallback and retained-memory growth. INT8 uses an independent +integer oracle and tests concurrent first use, model switching, numeric range +checks and missing/unsupported add-ons. + +Use BenchYAML for graph measurements. Compare batch size 1 and the default under +matched affinity, CPU budget, callers, index/query data and warmup. Check isolated, +saturated and intermediate arrival scenarios, actual recall and acceptance-to-return +latency. Short projections must not pay persistent queue latency when overlap +fits the direct budget; projection-heavy searches must demonstrate end-to-end +benefit. Keep compute-only kernel audits separate from graph performance claims. diff --git a/docs/ash-query-integration.md b/docs/ash-query-integration.md new file mode 100644 index 000000000..d73bf49d0 --- /dev/null +++ b/docs/ash-query-integration.md @@ -0,0 +1,172 @@ +# Integrating ASH queries into a database + +The database selects the physical-core budget **C** for search. Queries may be +submitted independently without blocking a producer thread for every outstanding +request. JVector prepares currently waiting compatible queries together, then +executes each graph search independently. It never waits for a timer to fill a +batch. An isolated request starts as soon as its execution capacity is available. + +The existing synchronous `GraphSearcher` API remains available. The prepared-query +and bounded-execution APIs are optional and do not change index formats, write +ordering, FP32 encoding/training, or the automatic query-projection precision policy. + +See [Choosing ASH query-projection precision](ash-projection-precision.md) for +processor-family recommendations and projection settings. +The [ASH configuration reference](ash-configuration.md) lists model parameters, +construction modes, runtime flags and their defaults. +The [BenchYAML Guide](benchyaml-guide.md#ash-query-parameters) describes +projection and query delivery settings. The precision guide explains the +requested and effective precision columns in `experiments.csv`. + +## Recommended asynchronous allocation + +Use these defaults for the search CPU budget assigned by the database: + +* At most **C** execution workers, shared across the indexes competing for those + cores. Producers do not need a worker thread for every submitted query. +* Maximum preparation batch **8** and admission **8C**. These are fixed defaults, + not settings to tune for particular datasets. Admission includes pending, + preparing and executing requests; completion callbacks are excluded. +* One immutable model representation per loaded model, with reusable prepared + state and exclusive search state for each active request. + +| Physical search cores C | Maximum execution workers | Request admission | +| --- | ---: | ---: | +| 1 | 1 | 8 | +| 2 | 2 | 16 | +| 4 | 4 | 32 | +| 8 | 8 | 64 | +| 16 | 16 | 128 | +| 32 | 32 | 256 | +| 48 | 48 | 384 | + +The integration needs to select only C. Do not expose internal batching policy +as database configuration. Larger explicit batches remain supported by the +numerical API; this recommendation does not impose an eight-row API limit. +The defaults bound memory and waiting but cannot guarantee a time limit under +arbitrary overload. Reject or backpressure arrivals when admission is exhausted. + +Choose physical cores, not both SMT siblings counted separately. Reserve a +separate budget for construction, compaction and other database work. Tell +JVector the allocation before initialization using `jvector.physical_core_count`; +use `-XX:ActiveProcessorCount` consistently when appropriate. Neither property +pins threads. Use the database's scheduler, OS affinity or container controls, +and place query execution and index memory together on NUMA systems. + +For an integration retaining only synchronous searches, the caller +recommendation is 4C bounded caller threads, with the same physical CPU budget. +That does not mean an asynchronous integration needs 4C execution workers. +[Reproducing ASH versus PQ](ash-pq-benchmark.md) measures both execution modes +separately. + +## Library responsibilities and host responsibilities + +`ImmutableGraphIndex.queryPreparer(similarity)` returns an optional +`QueryPreparer`. Cache it with the loaded model. `newState()` creates reusable +`PreparedQuery` buffers. `prepare` and `prepareBatch` snapshot FP32 query inputs, +compute the projection and scoring data, and retain no traversal state. ASH +uses its selected native query representation; encoding and training remain +FP32. Unsupported providers retain their existing fallback behavior. + +`BatchingExecutor` is a reusable two-stage executor, independent of ASH and +graph types. Its operation supplies a compatibility key, preparation, execution +and completion. A key is an immutable identity for the numerical model, not a +dataset name or mutable query buffer. Use null when preparation cannot benefit +from batching, as with the current PQ query path. Distinct keys never share a +preparation call. Mixed-model requests retain FIFO pending order; grouping only +collects compatible requests currently adjacent at the front. + +The host owns the executor and maps its request objects to these operations: + +```java +int searchCores = databaseSearchBudget; +int maximumBatch = 8; +int admission = Math.multiplyExact(searchCores, maximumBatch); +var searches = new BatchingExecutor(searchCores, admission, maximumBatch, + databaseSearchExecutor, operation); +if (!searches.trySubmit(request)) { + // The host still owns this request: reject or apply bounded backpressure. +} +``` + +The supplied executor must schedule asynchronously and explicitly reject work +it cannot accept. Do not use direct execution, caller-runs fallback or silent +discard. The service validates known unsuitable thread-pool policies. It submits +at most C reusable drain tasks and releases their execution capacity when idle; +it does not permanently park C tasks in the database's pool. A dedicated host +pool may use an array-backed work queue to avoid a queue-node allocation for +each task submission. Do not submit synchronous waiters to a pool whose entire +capacity they would occupy while waiting for this same pool to run searches. + +The execution operation borrows an exclusive `GraphSearcher`/view and binds the +prepared state through `ScoringView.scoreProviderFor(prepared, true)`. It then +uses the existing search, filtering and reranking API. Models without a preparer +use ordinary scoring. Numerical batches call the native batch kernel directly, +without a second scheduling layer. The preparing worker executes the first +search and publishes the others, allowing parallel traversal without waiting +for the whole batch to finish. + +## Input, result and resource ownership + +An accepted request owns stable input data until preparation completes; keeping +it stable through completion is the simplest contract. Source APIs that reuse +a scratch vector must copy into request-owned reusable storage before fetching +the next vector. A batch must not consist of several references to one source +buffer that is overwritten on each fetch. The database may queue many independent +requests from one producer, provided each request's inputs have this lifetime. + +Each active traversal exclusively owns its searcher, view and mutable scoring +state. `PreparedQuery` owns the query snapshot and numerical buffers; its +`ramBytesUsed()` excludes shared model weights. Do not prepare it again until +every search and resumable iterator borrowing it has finished. Publishing it +to another thread requires a normal queue/executor handoff. Generation checks +detect reuse of a prepared state that is still bound to a scoring provider. + +Completion returns each result or error through that request's callback. Use +the database's request identifier/promise/response destination to correlate it; +results need not return on the original worker or finish in submission order. +Completion callbacks must return promptly and must not wait for other work that +needs the same execution budget. Admission is released before completion so +callbacks can recycle a bounded request record safely. + +For a completed top-K `SearchResult`, request state can be recycled once the +callback has copied or transferred its result. A resumable iterator instead +retains an exclusive search session: keep its searcher, view and prepared state +leased until iteration finishes or closes. Do not return a worker's reusable +searcher to the pool while such an iterator is live. Budget retained sessions +and application-held results separately from the executor's admission count. + +This executor schedules read-only query work. It does not reorder additions, +deletions, flushes or publication of indexes. Retain the database's existing +ordering, visibility and view-consistency rules for those operations. + +A request timeout can stop the client waiting; it does not cancel a native call +or release borrowed buffers. Drain accepted work before closing model/index +resources. Interruption is preserved by close, which waits for callbacks too. +Close the batching service before shutting down its host executor; forcibly +discarding already accepted host tasks violates the execution contract. + +Preparation, search and explicit dispatch failures are delivered once to each +accepted request. A throwing completion callback is contained: inspect +`completionFailureCount()` and `firstCompletionFailure()` for monitoring. Later +submissions remain usable; graceful close reports retained callback failures +after draining. Call close from the lifecycle owner, not a service operation +or callback. Pool only bounded request/state storage, and include per-request +scratch, host task queues and retained sessions in the integration's memory budget. + +## Deployment and measurement + +Build/package native dependencies through the repository's +[oneDNN instructions](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +Enable Vector API modules and native access as described in the provider guide. +The preferred query implementation runs on the host workers without a nested +native thread pool; avoid adding native worker counts on top of that CPU budget. + +Validate production-like CPU/NUMA placement and actual traffic. Isolated queries +must launch without deliberate batching delay; sustained backlog should create +larger batches; intermediate arrivals should neither require a different +configuration nor an unbounded queue. Include preparation, traversal, reranking, +admission and dispatch in the metrics where they occur. Compare distributions, +serious stalls and achieved QPS, with unchanged recall and explicit overload +behavior. Use the checked-in BenchYAML recipe for reproducible library comparisons; +its unloaded latency and bounded-backlog response samples are different metrics. diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 5809b9152..5105f12a9 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -1,6 +1,7 @@ # How to run benchmarks -JVector comes with a built-in benchmarking system in `jvector-examples/.../BenchYAML.java`. +JVector runs dataset benchmarks with BenchYAML. See the +[BenchYAML Guide](benchyaml-guide.md) for the construction and search fields. To run a benchmark - Decide which dataset(s) you want to benchmark. A dataset consists of @@ -50,15 +51,16 @@ some-other-category: `jvector-examples/yaml-configs/index-parameters/default.yml` specifies the default index construction and search parameters to be used by `bench` for all datasets. -You can specify a custom set of a parameters for any given dataset by creating a file called `.yml`, with `` replaced by the actual name of the dataset. This is the same as the identifier used in `datasets.yml`. The format of this file is exactly the same as `default.yml`. +You can specify a custom set of a parameters for any given dataset by creating a file called `.yml`, with `` replaced by the actual name of the dataset. This is the same as the identifier used in `datasets.yml`. The format of this file is exactly the same as `default.yml`. The name must match exactly, including case, and the file's `dataset:` field must name the same dataset; BenchYAML stops with an error otherwise. Place the file directly in `index-parameters`, not a subfolder. Subfolders hold [example pipelines](../jvector-examples/yaml-configs/index-parameters/README.md) that you run by path. -Refer to `default.yml` for a list of all options. +See the [BenchYAML Guide](benchyaml-guide.md) for the field reference and an +example index configuration. Most parameters can be specified as an array. For these parameters, a separate benchmark is run for each value of the parameter. If multiple parameters are specified as arrays, a benchmark is run for each combination (i.e. taking the Cartesian product). For example: ```yaml construction: - M: [32, 64] - ef: [100, 200] + outDegree: [32, 64] + efConstruction: [100, 200] ``` will build and benchmark four graphs, one for each combination of M and ef in {(32, 100), (64, 100), (32, 200), (64, 200)}. This is particularly useful when running a Grid search to identify the best performing parameters. @@ -118,8 +120,15 @@ For remote datasets, use `base_url` to specify where files should be downloaded ## Final index serialization -BenchYAML and AutoBenchYAML default to parallel complete-record serialization -(default batch size 64) after graph cleanup. `-Djvector.parallel_writer=false` -selects the sequential path, which encodes FP/NVQ features during insertion. -Dataset prefetching is a separate option and is not required. See -[Deferred batch serialization](deferred-batch-writer.md) for activation and caller examples. +For an on-disk index, set `construction.writerMode` to `sequential` or +`parallel` in the index parameters. Both writers serialize the completed graph +after cleanup. The parallel writer defaults to 64 records per batch. See the +[BenchYAML Guide](benchyaml-guide.md#construction) for configuration and +[Deferred batch serialization](deferred-batch-writer.md) for writer details. + +## Fused PQ and ASH comparison + +[Reproducing ASH versus PQ](ash-pq-benchmark.md) provides byte-matched recipes +for the 1M and the 6M/10M datasets, scripts that run one case or a whole study, +and the measurement protocol. [ASH performance](ash-performance.md) reports the +results. diff --git a/docs/benchyaml-guide.md b/docs/benchyaml-guide.md new file mode 100644 index 000000000..024377dbc --- /dev/null +++ b/docs/benchyaml-guide.md @@ -0,0 +1,191 @@ +# BenchYAML Guide + +BenchYAML uses dataset configuration to build an index and measure search. +Each case produces one index. Construction scoring determines the graph +topology, search scoring determines how queries traverse that graph, and +reranking optionally rescores the candidates. + +## Configuration files + +- [Index parameters](../jvector-examples/yaml-configs/index-parameters) describe + construction and search for a dataset. Running BenchYAML with a dataset name + loads `index-parameters/.yml`, named exactly like the dataset and + declaring it, or `default.yml` when the dataset has no file. Subdirectories + hold [example pipelines](../jvector-examples/yaml-configs/index-parameters/README.md), + such as in-memory graphs and in-memory builds flushed to fused on-disk graphs, + and published studies. Run them by passing their paths to BenchYAML. +- [Dataset catalogs](../jvector-examples/yaml-configs/dataset-catalogs) locate + the base vectors, query vectors, and ground truth. + [Dataset metadata](../jvector-examples/yaml-configs/dataset-metadata.yml) + supplies the similarity function. Vector types come from the dataset. +- [Run configuration](../jvector-examples/yaml-configs/run-config.yml) selects + which measurements to compute and which to display or log. + +Each index-parameter file sets `yamlSchemaVersion: 3`, the current +`onDiskIndexVersion`, and its `dataset` name. + +See [How to run benchmarks](benchmarking.md) for dataset setup and commands. + +## Construction + +Choose `inMemory` or `onDisk` for the case. To measure both index forms, use +separate cases with matching graph settings. The graph settings and +`fusedGraph` are lists of sweep values. `useSavedIndexIfExists` is a scalar. +Each `compression` list entry has a `type` and its own settings. + +| Field | Values | Description | +| --- | --- | --- | +| `type` | `inMemory`, `onDisk` | `inMemory` keeps the graph in memory for search. `onDisk` serializes the completed graph and reopens it for search. | +| `writerMode` | `sequential`, `parallel` | Selects the writer after graph cleanup for `onDisk`. Defaults to `parallel`. | +| `useSavedIndexIfExists` | `Yes`, `No` | Controls reuse of a compatible `onDisk` index. `Yes` saves each built index in the working directory's `index_cache` and reuses it on later runs; a cache hit skips fresh construction and serialization measurements. Saved indexes are never deleted automatically, so starting-point configurations use `No`; recipes that query an index from a later JVM, such as the ASH/PQ comparison, use `Yes`. | +| `workers` | Positive integer or `max` | Optional. Threads for quantizer training, encoding, graph insertion and the parallel writer. `max` is the platform's physical core count, which is also the default. | +| `outDegree` | Positive integers | Maximum final neighborhood degree, also called M. | +| `efConstruction` | Positive integers | Search width used to find candidates during insertion. | +| `neighborOverflow` | Finite numbers ≥ 1.0 | Temporary neighborhood capacity relative to final degree. Java float suffixes such as `1.2f` are accepted. | +| `addHierarchy` | `Yes`, `No` | Builds upper graph layers. | +| `refineFinalGraph` | `Yes`, `No` | Refines the graph during final cleanup. | +| `fusedGraph` | `Yes`, `No` | Applies to `onDisk`. `Yes` stores ASH or PQ neighbor codes alongside the graph records. `search.compression` selects the codec. | +| `compression[].type` | `None`, `ASH`, `PQ`, `BQ` | Selects the representation for construction scoring. `None` uses source vectors. | +| `compression[].parameters` | [Quantizer parameters](#quantizer-parameters) | Supplies training and encoding settings for this entry. | +| `compression[].scoring` | `symmetric`, `asymmetric`, `mixed` | Optional for ASH construction. `symmetric` scores encoded vectors during insertion and pruning. `asymmetric` scores source queries against codes for both operations. `mixed` uses asymmetric insertion and symmetric pruning. | +| `reranking.type` | `None`, `FP32`, `NVQ` | `FP32` rescores candidates with source vectors. `NVQ` rescores them with NVQ codes. `None` or omission skips reranking where the scorer and layout allow it. | + +ASH construction uses symmetric scoring by default. When `scoring` is omitted, +the configured ASH construction policy applies. See +[ASH configuration and defaults](ash-configuration.md#graph-construction) +for the JVM setting. + +For `onDisk` builds, three construction metrics split the build: `graph_build_time_s` +covers graph construction and cleanup, `flush_time_s` covers writing the completed +graph and its features, and `reopen_time_s` covers reopening the written files. +Graph build plus flush equals `index_build_time_s`; quantizer training and encoding +are reported separately under `index_quant_time_s`. Select them under +`metrics.construction` in the run YAML. A cached index reports none of them. + +## Search + +Search uses the index selected by `construction.type`. Changes to stored features +define separate index cases. Search sweeps such as `topKOverquery` use the index +for their case. When CSV logging is enabled, the row also records the index +type, writer mode, reranker, ASH construction mode, and query delivery settings +including the number of callers or workers. A run with mixed delivery policies +reports thread counts per case in the CSV. + +| Field | Values | Description | +| --- | --- | --- | +| `topKOverquery` | Map of positive integer K to lists of finite multipliers ≥ 1 | Required for search. The integer part of K × multiplier is the candidate budget. Return up to K results, with a second scoring pass when configured. | +| `useSearchPruning` | List of `Yes` and `No` values | Enables traversal pruning for supported scorers and similarity functions. | +| `workers` | List of positive integers or `max` | Optional sweep. Direct search uses this many calling threads; queued ASH search uses this many workers to prepare and execute queries. Each value queries the same index, so `[1, 8]` reports 1 and 8 workers from one build. Omitted, the JVM-wide settings apply. | +| `compression[].type` | `None`, `ASH`, `PQ`, `BQ` | Selects the traversal representation. `None` scores source vectors. | +| `compression[].parameters` | [Quantizer parameters](#quantizer-parameters) | Supplies encoding settings for traversal. Reuse matching construction models and codes where possible. | + +For a fused disk case, Grid writes the selected search codes over the completed +topology. Matching ASH or PQ settings reuse the construction model and codes. +With a non-fused layout, compressed traversal uses a separate in-memory code +store. With source-vector disk search and no reranker, the inline source +vectors are the sole vector feature. + +### ASH query parameters + +Place `queryProjection` in the `parameters` map of an ASH compression entry. +Place `queryExecution` and `maxBatchSize` in that map on an ASH **search** entry. + +| Field | Values | Description | +| --- | --- | --- | +| `queryProjection` | `auto`, `fp32`, `fp16`, `int8` | Optional. `auto` is the default and selects INT8 where an integer dot-product backend exists, then FP16 where supported, with FP32 fallback. `fp32` selects FP32 projection. `fp16` and `int8` require their selected paths. The mode applies to ASH search and asymmetric construction scoring. Explicit ASH settings in one JVM must agree. Entries that omit it inherit the JVM mode, which defaults to `auto`. The `jvector.ash.queryProjection` JVM property overrides YAML. | +| `queryExecution` | `direct`, `queued` | `direct` runs searches on the calling threads. `queued` sends searches to workers and batches query preparation when supported. Defaults to `direct`. | +| `maxBatchSize` | Positive integer | Caps the number of queries prepared together in queued mode. Defaults to 8. The value must fit the executor limits at the selected worker count. | + +FP16 projection uses FP32 accumulation and output. INT8 projection uses integer +accumulation and FP32 output. Fully symmetric construction scores encoded +vectors directly. See the [ASH precision guide](ash-projection-precision.md) +for provider and hardware requirements. + +### Validate the index and scorer + +Grid validates the selected scorer, layout, similarity function, and precision +before training or construction. Disk writer, cache, and fused layout settings +apply to `onDisk` cases. Fused disk search requires ASH or PQ search compression. + +With `reranking.type: None` or an omitted reranker, search returns up to K results +ordered by traversal score, including compressed scores where supported. If the +selected scorer or layout requires a reranker, Grid throws an exception during +configuration validation. The fused ASH and PQ scorer setup requires an FP32 or +NVQ scoring feature. + +Grid rejects settings that do not apply to the case. Search type `None` scores +with full-precision vectors, so it requires `reranking.type: None`. Explicit precision +requests require support on the selected provider and machine. + +## Quantizer parameters + +The fields below belong to the `parameters` map of their compression entry. +D is the dataset dimension. + +| Quantizer and field | Values | Description | +| --- | --- | --- | +| ASH `optimizer` | `ITQ`, `RANDOM` | Training transform. Required for a new model. | +| ASH `compressionRatio` | 8, 16, 32, 64, 128, 256 | Nominal FP32-to-code ratio. The parser rounds to whole projected dimensions and uses a minimum payload. Specify either `compressionRatio` or `encodedBits`. | +| ASH `encodedBits` | Positive integer bit budget | Includes a 40-bit header. The payload must be divisible by `bitsPerDimension` and yield 1 through D projected dimensions. | +| ASH `bitsPerDimension` | 1–9 in core ASH. 1, 2, or 4 for fused ASH. | Bits per projected dimension in the stored codes. Defaults to 1. | +| ASH `landmarkCount` | 1–256 | Number of landmarks. Defaults to 1. ASH graph scoring uses dot-product similarity. Symmetric and mixed construction require one landmark. | +| PQ `m` | Integer from 1 through D | Number of subspaces. Specify either `m` or `mFactor`. | +| PQ `mFactor` | Positive integer yielding a valid `m` | Chooses the subspace count by integer division of D by `mFactor`. | +| PQ `k` | Integer from 1 through 256 | Centroids per subspace. Defaults to 256. Training requires at least k vectors. Fused PQ requires 256. | +| PQ `centerData` | `"Yes"`, `"No"` | Centers data before encoding. Omission enables centering for Euclidean similarity only. Quote these values in the YAML parameter map. | +| PQ `anisotropicThreshold` | −1.0 ≤ value < 1.0 | Controls anisotropic training on unit-normalized input. The default −1 disables anisotropic weighting. | +| BQ parameters | Omitted | Uses sign codes and Hamming scoring to approximate cosine similarity. | + +NVQ reranking uses the built-in encoder settings. BenchYAML chooses the +subvector count and uses eight bits per dimension. + +## Example index parameters + +These sections describe source-vector graph construction followed by fused ASH +disk search with NVQ reranking on a 768-dimensional dot-product dataset. + +```yaml +construction: + type: onDisk + writerMode: parallel + useSavedIndexIfExists: No + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + optimizer: ITQ + encodedBits: 808 + bitsPerDimension: 2 + landmarkCount: 1 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 +``` + +For a source-vector memory case, select `inMemory`, use `None` compression in +both sections, and omit the disk settings. Use `reranking.type: None` to +measure search with traversal scores alone. + +## Related guides + +- [How to run benchmarks](benchmarking.md) covers dataset setup and invocation. +- [Fused PQ and ASH comparison](ash-pq-benchmark.md) specifies the matched + compression budgets, machine setup, and measurement procedure. +- [ASH configuration and defaults](ash-configuration.md) covers model and + runtime settings. +- [Deferred batch serialization](deferred-batch-writer.md) explains writer + batching and feature production. diff --git a/docs/deferred-batch-writer.md b/docs/deferred-batch-writer.md index b36015074..b2bff17bf 100644 --- a/docs/deferred-batch-writer.md +++ b/docs/deferred-batch-writer.md @@ -104,10 +104,9 @@ remains caller-owned and must outlive the write. ## Dataset prefetching in Grid -When the parallel writer is selected, Grid defers its inline NVQ or FP feature serialization -until after cleanup. Its final serialization suppliers consume worker-local ranges through -`VectorAccess.openRange`. With the sequential writer, which calls suppliers on one -thread, Grid keeps encoding these features on the insertion threads and writing them inline. +For a parallel write, Grid supplies NVQ or FP32 features after cleanup. +Final serialization suppliers consume worker-local ranges through +`VectorAccess.openRange`. Sources implementing the buffered-access capability use that implementation; ordinary sources retain the ordinary-access fallback. Borrowed vectors are consumed before advancing a cursor, and ranges are released after the workers finish. @@ -119,10 +118,9 @@ final write; their existing vector-source and incremental-write APIs remain vali ## Running the benchmark writer paths -BenchYAML and AutoBenchYAML select the parallel writer by default. To use the -sequential writer instead, add `-Djvector.parallel_writer=false` to the Java VM -options. In that mode, Grid encodes FP/NVQ features during insertion and preserves -the earlier inline-write path. No YAML setting is needed. +Select `construction.writerMode` in the +[BenchYAML index parameters](benchyaml-guide.md#construction). The default is +`parallel`. Both writer modes serialize the graph after cleanup. Grid sizes its writer pool from `PhysicalCoreExecutor`; no machine-specific worker count is embedded in this change. Dataset prefetching is a separate optional setting diff --git a/docs/native-ash-preparation.md b/docs/native-ash-preparation.md new file mode 100644 index 000000000..b9e951f9e --- /dev/null +++ b/docs/native-ash-preparation.md @@ -0,0 +1,339 @@ +# Native ASH projection and symmetric construction scoring + +ASH models prepare an immutable provider-specific projection when their Stiefel +transform is constructed or loaded. The default implementation keeps the +existing row-dot implementation, including Panama SIMD. The native provider +copies the row-major float matrix once into automatically managed shared native +memory. The extra matrix storage is included in `ramBytesUsed()` and is released +when its model is no longer reachable. It is not serialized; the model and graph +formats are unchanged. + +`ash_project_f32` computes the entire matrix-vector product in one Highway +dispatch. Four rows share query loads, with independent FMA chains and masked +tails. Every graph query still pays for its own projection. There is no batch of +unrelated queries, BLAS thread pool, per-row FFM call, or per-query matrix copy. +Small bounded products use a synchronous critical downcall with heap query and +output arrays. Larger products use an ordinary downcall with temporary native +query/output buffers so the JVM can reach safepoints during the dense multiply. +The matrix is shared read-only; native staging buffers are reused per calling +thread and grow only when needed. + +`ash_symmetric_dot` evaluates encoded pairs used by graph construction: 1-bit +XOR/popcount; 2-bit weighted popcounts over packed sign/magnitude fields; 4-bit +unsigned-magnitude by signed-magnitude byte dot products. Integer accumulation +precedes a single factor of one quarter for 2/4-bit codes. Tail padding is ignored. +The Ice Lake vtable selects the same Highway source compiled with native +population-count and VNNI support, and Sapphire Rapids inherits that slot. +AVX-512 without those instructions, AVX2, scalar and ARM retain the portable +Highway implementations. The Java scorer still combines scales, centered +offsets, landmark terms and the existing similarity conversion. + +`ash_symmetric_lut_i16` handles byte-interleaved ranges in one call, retaining +the existing exact signed-short LUT. It uses 32-bit partial sums and bounded +chunks accumulated into 64-bit totals, rather than letting a short or integer +sum overflow. The graph builder's pair-oriented API uses the pair kernel; +callers of `ASHSymmetricScorer.blockScorerFor` use the native LUT kernel. Existing +C>1 formulas and the graph-neighborhood scorer are unchanged. + +Projection and symmetric symbols are optional and looked up independently. Loading an older +compatible library preserves Java projection and symmetric implementations. +Java validates shapes, array bounds and output aliasing before passing pointers +to native code. Native kernels do not allocate, retain pointers or call Java. +Scorer diagnostics identify whether each operation is Highway or Java. + +## Query-projection precision + +See [Choosing ASH query-projection precision](ash-projection-precision.md) for hardware recommendations and selection behavior. Explicit INT8 uses the existing immutable query-view interface, batch validation, caller-owned scheduling and Java thread-local staging. Its model-owned native context holds quantized weights, scales and distinct oneDNN packed layouts; compatible batch shapes share packed weights. Calling-thread scratch retains no model. + +The optional oneDNN ABI and status structure are shared with FP16. INT8 checks ABI compatibility before binding, distinguishes unsupported hardware, invalid operands/ranges, allocation failures and runtime failures, and never silently substitutes another precision. Full-range integer dot products require a qualified VNNI or AMX implementation; legacy intermediate saturation is rejected. Scales, quantization and output rescaling retain the prototype’s FP32 arithmetic. Nonfinite operands and a nonzero operand whose scale rounds to zero are rejected before integer conversion. Output rescaling retains ordinary FP32 overflow behavior. INT8 has no unconditional native console output. The original FP16 and FP32 implementations and automatic-selection policy are unchanged. + +## Direct-node scoring and query scratch + +Fused 2-bit direct-node scoring uses `ash_canonical_lut_dot_f32` and reuses the +neighborhood scorer's FP32 nibble LUT. One lookup combines two projected +coordinates. For 4-bit codes each nibble represents only one coordinate, so its +LUT is 16 times larger than the projected query. The preferred native +`ash_projection_dot_4bit_f32` instead decodes packed nibbles in registers, +loads contiguous query coordinates and performs one final SIMD reduction. This +also supplies the native provider's existing 4-bit projection-code dot API. +Neither operation materializes decoded vectors. Java applies the existing +scale, landmark correction, offset and graph similarity conversion. + +Normal codes use one bounded synchronous native call per direct node with heap +access, no native copy and no output array. Unusually large legal codes use an +ordinary call with native staging so the JVM can reach safepoints. Both symbols +are optional independently: older native libraries and other providers retain +the previous projection-code scorer. Forced scalar scoring remains an +independent reference. The 4-bit choice follows the representation's working +set and does not depend on dataset names, dimensions or benchmark settings. + +Each on-disk graph view lazily owns its ASH input, projection, landmark terms, +LUT, neighborhood bytes and output arrays. Subsequent queries refill this storage. +Projection codes skip the generic centered-query arrays and sums, which their +scoring formula does not consume. Query projection precision and model/index +formats are unchanged. Separate views have independent scratch even on the same +thread. A view is confined to one active search, including any `resume()` calls; +starting another query invalidates the previous decoder, whose scoring methods +then throw `IllegalStateException` instead of reading overwritten query data. +Public standalone scorers retain independent prepared state and can be retained +across subsequent queries. The reusable path still allocates small scorer objects; +it does not claim an allocation-free search API. + +## Base-vector encoding + +`encode()` and `encodeTo()` normalize the centered residual, project it through +`StiefelTransform.project()` in FP32, and apply the existing code quantizer and +header adjustment. This uses the prepared Highway projection with the native +provider and Panama SIMD with the Panama provider. Encoding never requests the +query-only FP16 or INT8 view. Header scale and offset retain their existing FP16 storage +format; database projection precision remains FP32. + +`encodeAll()` replaces per-vector projection with bounded batches on each worker +of the caller's `ForkJoinPool`. Each worker gathers up to 32 non-null vectors, +normalizes them immediately into reusable buffers, calls `projectBatch()` once, +and quantizes/packs the projected outputs in their original ordinal positions. +Immediate gathering is necessary because a vector source may lend the same +mutable vector object on each read. Missing vectors keep the existing all-zero +encoded representation. Empty sources and partial final batches are supported. +Every submitted task is joined before returning or propagating a worker error. + +The Highway `ash_project_batch_f32` kernel is a blocked matrix multiply with +FP32 operands and FP32 accumulators. Its SIMD microkernel shares matrix weights +across four inputs; output tiles are reused over the full caller batch. It has +no internal threads. A second native FP32 matrix, tiled by up to 32 output +coordinates, is packed lazily once per model and shared read-only. The final +tile has no padding, so this backing is exactly the original matrix size and +is included in `ramBytesUsed()`. Single-vector queries do not allocate it. +Input and output staging is reused per calling thread. Bulk calls use ordinary +FFM to allow JVM safepoints during the multiply. + +The worker batch size of 32 is an internal working-set bound, not an API or ISA +limit. `AshProjection.projectBatch()` accepts any nonnegative count that fits +the supplied row arrays. The default provider implementation calls its own +FP32 single-vector projector; the normal worker batch needs no temporary +validation collection or projection arrays. This preserves Panama fallback. An older native library without the batch symbol uses its available +single-vector projection. There are no leader groups, arrival queues, timers, +public tuning controls, or new dependencies. + +Changing SIMD reduction order can produce small FP32 differences near code +boundaries; FP32 does not imply bit-identical arithmetic across providers. +Regression tests use a double-precision projection oracle and exercise native +and Panama batch tails, input/alias rejection, concurrent models, and FP32 +operand precision. Encoding tests cover missing/borrowed vectors, supported bit +widths, multiple landmarks, zero residuals, destination reuse, source failure, +and independence from the query precision setting. Existing model and graph +formats are unchanged. + +## Training sample preparation + +For a single landmark (`C=1`), `AsymmetricHashing.initialize()` computes the mean, +PCA inputs and ITQ training inputs from one uniform reservoir sample of +`min(N, 20 * D)` vectors, where `D` is the original dimension. The sample size +is independent of the projected dimension and encoded bit width. Selection uses +the existing deterministic seed, 103, and selected ordinals are sorted before +reading. Only selected source vectors are fetched, once each; encoding performs +the full pass over the source vectors afterward. + +Each selected vector is copied into one reusable normalization buffer before +another source read, so borrowed source storage is never retained or modified. +ITQ retains normalized sample values in a flat FP32 array. The mean is accumulated +in FP64 and stored as the existing FP32 landmark. Centered residuals are normalized +in FP64 and copied in bounded tiles directly into the column-major training +matrix, avoiding an array of per-vector objects and a second full-size FP64 +matrix conversion. Preparation retains bounded serial transpose tiles. +The existing BLAS/LAPACK backend is retained. PCA uses checked +FP64 covariance eigendecomposition, with guarded direct SVD fallback, as described +in the [PCA validation contract](ash-pca-native-svd.md). + +The `RANDOM` optimizer estimates its single landmark from the same deterministic +sample, using an independent sampler so the Gaussian projection's existing random +sequence is preserved. Multi-landmark training retains its clustering and full +input-materialization path. + +Empty sources, invalid dimensions/configurations, and training matrices that +cannot fit a Java array are rejected before gathering vectors. Selected inputs +must be non-null, have the declared dimension, and have a finite norm under the +provider's FP32 normalization. Non-finite values and FP32 norm overflow are +rejected with the offending ordinal. Zero and small-norm vectors retain the +existing normalization behavior. Source read failures propagate to the caller. +Training validates selected vectors only; it does not fetch unsampled vectors +for validation. Allocation failures are not swallowed or retried with a different +training sample. + +Saved model and graph formats are unchanged. Loading an existing model preserves +its stored landmark and projection. Retrain the compressor and rebuild its graph +cache to adopt sampled-mean training; the new mean is used consistently by +encoding and scoring. + +## Reference implementation review + +Reviewed IVF ASH Fastscan at commit +[`56d78d0ff3aaaa61a02b94c6f49a97789706a717`](https://github.com/tlwillke/ivf-ash-fastscan/tree/56d78d0ff3aaaa61a02b94c6f49a97789706a717). +Its [`AshProjectionModel::transform_residual_batch`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_transform.cpp#L727) +uses a contiguous input-major projection and BLAS matrix multiplication. Its +[`compute_lut_uint8`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_query.cpp#L347) +path batches projections before building query-local LUTs. The +[`AVX-512 scan`](https://github.com/tlwillke/ivf-ash-fastscan/blob/56d78d0ff3aaaa61a02b94c6f49a97789706a717/ash_fastscan_avx512.cpp) +uses packed nibbles, byte shuffles and independent integer accumulators. + +The transferable principles here are preparing contiguous immutable weights, +amortizing native dispatch over a complete operation, reusing query loads, and +accumulating compact codes without materializing decoded vectors. JVector +implements a single-query Highway matrix-vector operation because graph query +latency must include preparation for each query. The IVF implementation does +not provide a graph-construction symmetric scorer. Its quantized uint8 query +LUTs are not substituted for the graph's existing exact symmetric integer LUTs. +JVector's kernels are independent implementations of these operations. + +## Validation + +Targeted regression checks compare projection with a double-precision oracle, +packed pair dots with an independent decoder, and integer LUT ranges with +64-bit scalar accumulation. They cover dirty tail bits, irregular dimensions, +both FFM modes, concurrent projections, bounds/alias rejection and absent native +symbols. Both JDK 22 and JDK 25 are checked with normal dispatch and the existing +AVX3_DL, AVX3, AVX2 and SSE/scalar ISA caps. The generic kernel translation unit +also compiles for ARM NEON on Apple Silicon. SVE/SVE2 execution is not yet validated. + +## Batched FP32 encoding + +`encodeAll()` projects worker-local batches with Highway FP32 instead of +projecting one vector at a time. On four 1M-vector datasets (ITQ, C=1, D/2, +48 physical cores) this reduced BenchYAML's ASH encoding time by 1.2x to 5.7x, +with the largest gains at higher dimensions, and produced byte-identical trained +models. Training and graph writing are measured separately. + +## oneDNN FP32 encoding projection + +The native provider automatically prefers the existing sequential oneDNN add-on +for FP32 `projectBatch()` when its entry point is available. It executes row-major +SGEMM as `[B,D] * transpose([d,D]) -> [B,d]`, with FP32 inputs, weights and outputs. +The adapter shares the immutable Highway matrix, avoiding a second packed model +copy, and reuses the existing calling-thread native input/output buffers. Its +batch API accepts arbitrary positive counts; no eight-vector cap or padding +controls were introduced. The library's existing `encodeAll()` bound remains 32. +The sequential-runtime build assertion prevents nested oneDNN thread pools. + +Single-vector `encode()` retains Highway FP32 projection. The wrapper delegates +query precision selection to the same original projection: automatic mode prefers +INT8, then FP16 with its existing FP32 fallback. Database +encoding never converts to FP16. Code selection and landmark correction stay in +Java. + +The optional library retains its existing package/resource name for compatibility. +Both FP32 and FP16 loaders validate ABI 1, handle missing/denied/unsupported +libraries, and retain Highway when optional support is absent. An older add-on +with ABI 1 but without the FP32 symbol also keeps Highway. Unsupported oneDNN +FP32 execution falls back to Highway batch projection; invalid arguments, +allocation failures and unexpected runtime errors propagate. FP16 additionally +checks representable range, and explicit `fp16` selection requires support. +There is no selection flag; the `-Dnative.onednn=true` build packages both +operations. + +BLAS kernels can use different FP32 reduction orders for different batch shapes. +The 9-bit reconstruction check therefore uses its existing per-component code +step error bound for worker/batch comparisons as well as singleton comparisons, +and compares the code-independent centered offset within FP16 rounding; the full +1/2/3/4-bit code/header checks remain exact in those tests. Separate tests compare oneDNN output to a double-precision oracle, +cover FP32 values outside FP16 precision/range, caller aliasing and invalid +shapes, batch tails/counts above 32, immutable model copies and concurrent model +switching. A direct ABI test requires successful oneDNN SGEMM, preventing a +Highway fallback from passing as a oneDNN test. + +In repeated BenchYAML construction runs, oneDNN FP32 encoding was 1.13x faster +than Highway for MPNet 1M at 2 bits and 1.03x faster for OpenAI-3072 1M at +4 bits, with equivalent graph recall. + +## ITQ training workspaces + +ITQ keeps the residual and PCA-reduced inputs in column-major FP64 arrays for +BLAS/LAPACK. Its iterative projection writes contiguous training vectors directly: +`(Xld * R)^T = R^T * Xld^T`, evaluated by one DGEMM. The result is viewed as +row-major `[N,d]` by the direction encoder. Quantization replaces each projected +row in place; the unquantized result is not needed by the update or loss. A second +DGEMM consumes this layout to overwrite the existing update matrix with +`Xld^T * Xenc`. No explicit transpose pass or second `[N,d]` output is required. + +Each trainer owns an `ASHFloatOrthogonalizer` with one thin-SGESDD workspace for +repeated polar steps. It rounds the small update matrix to FP32 into reusable +scratch, decomposes it, multiplies `U * VT` with SGEMM, and widens the rotation into +the trainer's FP64 buffer. Its workspace is queried once; singular vectors, +values and native work arrays are reused. The destroyed SVD input also holds +the polar product, avoiding another output array. + +After the last update, the trainer computes a fresh FP64 polar factor with +`ASHOrthogonalizer` before forming the final projection. This preserves the +final transform's orthogonality contract. Random projection initialization also +retains FP64. Both implementations use the existing portable BLAS/LAPACK provider; +there is no new native dependency, vendor selection or precision control. + +The direction encoder retains scratch for its logical workers across iterations. +Workers operate on disjoint rows in the shared physical-core executor, without +the previous fixed 16-worker cap. Each trainer owns its direction and polar +workspaces; scaling-event buffers remain thread-local. Parallel task submission +still occurs each iteration; this is not an allocation-free training API. + +PCA forms the upper triangle of `X^T X` with FP64 DSYRK and obtains eigenvectors +with DSYEVD. Spectral energy, retained-tail conditioning, the cutoff gap and +selected eigenpair residuals are checked before accepting the result. Uncertain +or underdetermined inputs use the guarded FP64 DGESDD fallback, including its +complete null-space basis for wide matrices. No full left-singular-vector matrix +is computed in the common covariance path. + +Large ITQ projection/update products, norms, update storage, loss and the stopping +criterion remain FP64. The scaling-factor search retains its existing FP32 +arithmetic and event ordering. The 1-bit sign rule and 2–9-bit direction quantization are +unchanged; tests compare the new in-place encoder to the former column-major +algorithm bit for bit on identical projected inputs. + +Different DGEMM orientations can change reduction order, so retraining need not +produce a byte-identical matrix. Model serialization, database encoding and query +projection precision are unchanged. Existing saved models remain compatible. +This structural change does not select a different BLAS/LAPACK backend. + + +## Retained-memory accounting + +`AsymmetricHashing.ramBytesUsed()` counts the retained model: landmarks and +auxiliary landmark arrays, the Stiefel matrices (including row/outer arrays), +model objects, provider-owned native FP32 backing and its adapter/delegate objects, +optional Highway batch backing, +and a distinct FP16 or INT8 query representation when initialized. Immutable Java sizes +are cached during construction; reading the estimate does not scan matrices or +create them. FP32 query selection and automatic FP16 fallback preserve the +original projection view's identity, so shared FP32 backing is charged once. +Dense row and block matrices use their corresponding storage layouts; other +`RealMatrix` implementations retain a dense storage estimate without calling +`getData()`, which could materialize a matrix copy. + +`ASHVectors.ramBytesUsed()` includes its compressor once, its compact code +pages and decoded scale/offset/landmark arrays, and any retained packed or +symmetric-scoring caches. It uses heap array layout estimates, not serialized +code sizes. Code storage is sized when the pages are allocated. Packed-block +totals are charged when a layout is successfully created and include a small +conservative allowance for map bookkeeping. Polling the estimate is +constant-time with respect to vector count, even after packed caches are +created. Vectors passed to the public constructor are copied, so repeated +ordinals sharing one input vector are charged per ordinal. The model arrays must remain immutable after construction, as required by scoring. + +Cached fused ASH sources report their owned source object and quantized vector, +including its primitive arrays, without charging the shared compressor. Shared +graph-cache bookkeeping is accounted by `OnDiskGraphIndex`, not by the ASH source. +Callers such as Cassandra add the compressor or full compressed collection +separately. This ownership split avoids charging the same model for every source. + +As with PQ, these retained model/collection estimates exclude worker-owned query +buffers, native staging/oneDNN execution scratch, training temporaries and OS file +cache. Search concurrency and training memory require separate budgeting. They +are estimates of retained ownership, not an allocator reservation or a total +process-RSS bound. VM/native-runtime bookkeeping is approximate. Formats, +compression byte budgets and scoring arithmetic are unchanged. + +The accounting regression tests derive storage expectations from the allocated +arrays and the running JVM's layout, rather than fixed dataset-specific byte +totals. They cover code widths 1, 2, 3, 4 and 9, empty collections, padded capacities, +multiple landmarks, lazy query/batch/symmetric representations, repeated and +concurrent cache creation, and allocation-free polling. When ASH storage or cache +ownership changes, update its allocation-site totals and the corresponding tests +together. Shared JVM layout and graph bookkeeping remain centralized in their +common implementations. diff --git a/docs/release notes/4.1.0/796.enhancement.md b/docs/release notes/4.1.0/796.enhancement.md new file mode 100644 index 000000000..696a6fe7b --- /dev/null +++ b/docs/release notes/4.1.0/796.enhancement.md @@ -0,0 +1,15 @@ +### BenchYAML: ASH defaults, explicit pipelines and one-JVM experiments + +**Description** + +BenchYAML's index YAML schema 3 states each pipeline explicitly: index type, construction and +search compression, construction scoring, reranking, query execution (`direct` or `queued`), and +construction and search worker counts. Compressed dataset defaults now use fused ASH, and +`benchyaml-play.sh` builds the checkout and runs one experiment in one JVM from the command line or +IntelliJ. + +**How to Enable** + +Set `yamlSchemaVersion: 3` to use the new fields. See the [BenchYAML Guide](../../benchyaml-guide.md), +the [example pipelines](../../../jvector-examples/yaml-configs/index-parameters/README.md) and the +[benchmark launcher](../../../jvector-examples/bench-profiles/README.md). diff --git a/docs/release notes/4.1.0/796.feature.md b/docs/release notes/4.1.0/796.feature.md new file mode 100644 index 000000000..4f2559e10 --- /dev/null +++ b/docs/release notes/4.1.0/796.feature.md @@ -0,0 +1,112 @@ +### Asymmetric Scalar Hashing (ASH) quantization + +**Description** + +JVector adds ASH (Asymmetric Scalar Hashing), introduced in +[ASH: Asymmetric Scalar Hashing With Learned Dimensionality Reduction for High-Fidelity Vector Quantization](https://arxiv.org/abs/2606.07870) +(Tepper and Willke, CIKM 2026). ASH is a data-driven encoder-decoder framework that applies +dimensionality reduction to database vectors via a learned orthonormal projection, followed by +scalar quantization, while keeping queries in their original form. This asymmetric design enables +higher accuracy than the best additive and scalar quantizers at iso-compression, while admitting +highly efficient similarity computations via SIMD operations. ASH has short learning and encoding +times, making it attractive for real-world deployment. + +This release brings ASH to a graph index for the first time. JVector stores each projected +dimension in 1 to 4 bits (up to 9 outside fused graphs), projects each query once, and scores it +against the stored codes with SIMD lookups. [ASH performance](../../ash-performance.md) compares +ASH with product quantization (PQ) in JVector graphs on 6M and 10M datasets. + +JVector supports ASH wherever it supports PQ: + +- **Training and encoding.** `AsymmetricHashing.initialize` trains a model with an ITQ-learned or + random projection, a number of landmarks, and a chosen bit budget. `encodeAll` produces + `ASHVectors`, which implement `CompressedVectors` and keep all codes in compact shared pages. + The quantizer accepts 1 to 256 landmarks; JVector's graph indexes currently use one. +- **In-memory graphs.** `ASHVectors` score graph construction and search like other compressed + vectors. `BuildScoreProvider.ashBuildScoreProvider` builds the graph by comparing stored codes + with each other (symmetric scoring), so insertion needs no full-precision query projection. +- **Fused ASH on disk.** The `FusedASH` feature (`FeatureId.FUSED_ASH`) stores each node's + neighbors' ASH codes with its adjacency list and the model in the index header, so on-disk + search reads no separate code file. Fused ASH supports 1, 2 and 4 bits per dimension and + reranks with NVQ or full-precision vectors stored in the graph. +- **Separate ASH codes.** An on-disk graph with full-precision vectors can be searched with + `ASHVectors` held in memory, as with PQ. +- **Native query projection.** With the native vectorization provider, queries are projected in + FP16 where the CPU supports it, falling back to FP32. INT8 projection is available on request + for x86 processors with VNNI or AMX. Native builds include the oneDNN projection engine by + default; base-vector encoding uses FP32. +- **Prepared and queued queries.** Each ASH query is projected once before its graph search, and + projecting several queries together in one matrix multiply costs less per query than projecting + them one at a time. `QueryPreparer` and `PreparedQuery` therefore separate projection from graph + search, and `BatchingExecutor` queues concurrent searches on host-owned workers with bounded + admission so that queries waiting at the same time share one batched projection. Concurrent + synchronous searches also share projections, up to `jvector.ash.queryBatchSize` (default 8). + +**How to Enable** + +ASH is opt-in; existing PQ, BQ and NVQ indexes and their defaults are unchanged. A fused ASH index +with NVQ reranking is built and searched as follows; the complete program is +[HelloASHWorldFp16](../../../jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java). + +```java +int b = 2, d = dimension / 2; // 2 bits for each of D/2 projected dimensions +var ash = AsymmetricHashing.initialize(vectors, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + b * d, 1, b, PhysicalCoreExecutor.pool()); +var codes = ash.encodeAll(vectors, PhysicalCoreExecutor.pool()); +var scores = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, codes); +// Build with GraphIndexBuilder and builder.addGraphNode(node, scores.searchProviderFor(node)), then write: +var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .withVersion(7).with(new FusedASH(maxDegree, ash)).with(new NVQ(nvq)).build(); + +// Search: prepare the query once, then search with NVQ reranking. +var preparer = index.queryPreparer(VectorSimilarityFunction.DOT_PRODUCT).orElseThrow(); +var prepared = preparer.newState(); +preparer.prepare(query, prepared); +var result = searcher.search(view.scoreProviderFor(prepared, true), 10, 20, 0.0f, 0.0f, Bits.ALL); +``` + +Query projection precision is selected with `-Djvector.ash.queryProjection` (`auto`, the default, +or `fp32`, `fp16`, `int8`). + +**Notes** + +- **On-disk format version 7.** Fused ASH and serialized ASH models require format version 7, + which this release introduces and which JVector 4.0.x cannot read. `CURRENT_VERSION` and the + writer defaults are now 7. Hosts that must be able to roll back should keep passing + `withVersion(6)` and `write(out, 6)` until every reader runs this release; version 6 and older + files are written and read as before. See [UPGRADING.md](../../../UPGRADING.md). +- **Similarity.** ASH scores dot product only, on vectors of any length: its similarity is + `(1 + dot) / 2` without a lower bound, the scale exact dot-product scoring uses, so vectors rank by + their full dot products. Use `DOT_PRODUCT` with normalized vectors for cosine similarity. +- **Concurrency.** A prepared query, its searcher and its view belong to one in-flight search; + reuse them only after it completes. For asynchronous serving, size execution to the physical + cores allocated to search and use bounded admission; see + [Integrating ASH queries into a database](../../ash-query-integration.md). +- **Adaptive termination (experimental, off by default).** `GraphSearcher.useAdaptiveTermination(true)` + makes top-K searches stop when the best unexpanded candidate is more than `1 + gamma` times as far + from the query as the k-th best result found (gamma about 0.005), reading scores as true distances for + dot-product, cosine and Euclidean similarity on vectors of any length. For dot products the distance is + measured from the query's length times the largest stored vector length, which ASH and PQ models and + the full-precision vector features record and store at format version 7. A search expands at least the + nodes the standard rule would and at most the nodes a standard search with twice the beam would. With + compressed scores and reranking, raising the overquery has so far given more recall at the same + throughput, so it stays off unless enabled. Graph construction with ASH keeps using it. +- **BLAS threads.** JVector sets the native BLAS thread count for ASH training: one thread, or + the size of the executor passed to `AsymmetricHashing.initialize`. Hosts no longer need + `MKL_NUM_THREADS` to keep MKL or OpenBLAS from starting a thread per CPU. +- **Compaction.** `OnDiskGraphIndexCompactor` does not yet compact fused ASH indexes. + +**Documentation** + +- [ASH configuration and defaults](../../ash-configuration.md): model parameters, construction + scoring, precision, providers and JVM settings. +- [Integrating ASH queries into a database](../../ash-query-integration.md): caller concurrency, + bounded admission, state ownership and latency validation. +- [Choosing ASH query-projection precision](../../ash-projection-precision.md): FP16, FP32 and + INT8 by processor family. +- [ASH query batching: maintainer design](../../ash-query-batching-design.md). +- [ASH performance](../../ash-performance.md) and + [Reproducing ASH versus PQ](../../ash-pq-benchmark.md). +- [Native build and oneDNN packaging](../../../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +- [Hello ASH examples](../../../jvector-examples/README.md#hello-ash-world): INT8 and FP16 + projection, synchronous and queued search. diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java index 9d02269d7..c61eecc56 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteBufferReader.java @@ -73,6 +73,11 @@ public void readFully(long[] vector) { } } + @Override + public byte readByte() { + return bb.get(); + } + @Override public int readInt() { return bb.getInt(); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteReadable.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteReadable.java new file mode 100644 index 000000000..f907dc45a --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ByteReadable.java @@ -0,0 +1,30 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.disk; + +import java.io.IOException; + +/** + * Optional extension for RandomAccessReader implementations that + * support byte-level reads. + * + * This is used by ASH only and MUST NOT be assumed by general readers. + */ +public interface ByteReadable { + byte readByte() throws IOException; +} + diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java index 181cd82a8..20bdd6e19 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/MappedChunkReader.java @@ -288,6 +288,18 @@ public void read(float[] floats, int offset, int count) { tmp.flip().asFloatBuffer().get(floats, offset, count); } + @Override + public byte readByte() { + try { + ensureAvailable(1); + byte v = currentBuffer.get(); + position++; + return v; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + /** * Returns the total length of the file. * diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java index 09e751e1a..1c362d8d0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/RandomAccessReader.java @@ -31,7 +31,7 @@ * Thus, RandomAccessReader implementations are expected to be stateful and NOT threadsafe; JVector * uses the ReaderSupplier API to create a RandomAccessReader per thread, as needed. */ -public interface RandomAccessReader extends AutoCloseable { +public interface RandomAccessReader extends AutoCloseable, ByteReadable { /** * Seeks to the specified offset. * @param offset the offset to seek to @@ -46,6 +46,20 @@ public interface RandomAccessReader extends AutoCloseable { */ long getPosition() throws IOException; + /** + * Reads a single byte. + * + *

The default implementation preserves compatibility for custom + * RandomAccessReader implementations. Production readers should override this + * with a zero-allocation implementation.

+ */ + @Override + default byte readByte() throws IOException { + byte[] one = new byte[1]; + readFully(one); + return one[0]; + } + /** * Reads an integer. * @return the integer value diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java index 659c44dc7..392b0d121 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/SimpleReader.java @@ -47,6 +47,11 @@ public void seek(long offset) throws IOException { public long getPosition() throws IOException { return raf.getFilePointer(); } + + @Override + public byte readByte() throws IOException { + return raf.readByte(); + } @Override public int readInt() throws IOException { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java new file mode 100644 index 000000000..7dfc09428 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/AdaptiveTermination.java @@ -0,0 +1,213 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; + +/** + * Distance-adaptive termination: a top-K search stops when the best unexpanded candidate is farther from the query + * than the k-th best result found, by a relative margin: + * + *
+ *   d(q, candidate) > (1 + gamma) * d(q, k-th best)
+ * 
+ * + *

{@code d} is a true metric distance, so {@code gamma} means the same thing for every similarity function and + * whatever the lengths of the vectors. Scores are read back as distances as follows; the rule compares squared + * distances against {@code (1 + gamma)^2}, and any positive scale factor cancels: + * + *

    + *
  • EUCLIDEAN: the score is {@code 1 / (1 + d^2)}, so {@code d^2 = 1 / score - 1}.
  • + *
  • COSINE: the score is {@code (1 + cos) / 2}, and {@code d^2 = 2 - 2 cos} is proportional to {@code 1 - score}.
  • + *
  • DOT_PRODUCT: the score is {@code (1 + q.x) / 2}. With {@code B} an upper bound on {@code q.x}, such as the + * query's length times the largest stored vector length, {@code B - q.x} is proportional to the squared Euclidean + * distance between the query and the vector after the standard reduction of inner-product search to nearest-neighbor + * search (each vector scaled into the unit ball and lifted onto the unit sphere by one extra coordinate, the query + * scaled to unit length). In score units that is {@code top - score} with {@code top = (1 + B) / 2}. An approximate + * score above {@code top} raises {@code top} to it, which only loosens the margin. On unit-length vectors + * {@code B = 1} and the rule reads {@code 1 - score}, as cosine does.
  • + *
+ * + *

The search never stops earlier than the standard rule would, and never later than a standard search with + * {@value #CAP_FACTOR} times the beam would: the searcher keeps the {@value #CAP_FACTOR}{@code k}-th best score found + * and passes it to {@link #raiseCap}, and candidates scoring below it stop the search whatever the margin. The cap + * bounds the work on data whose distances are concentrated, where a relative margin admits many candidates. + * + *

A non-finite score, or a dot-product search whose bound is unknown, leaves the standard rule to decide. + */ +final class AdaptiveTermination { + /** The default margin on the metric distance: 1% on squared distances. */ + static final float DEFAULT_GAMMA = (float) (Math.sqrt(1.01d) - 1.0d); + + /** Adaptive stopping searches no further than a standard search with this many times the beam. */ + static final int CAP_FACTOR = 2; + + // Room for float rounding in the scores, relative to the scale of the distances compared. + private static final double EPSILON = 1e-6d; + + enum Decision { + CONTINUE, + TERMINATE, + FALL_BACK_TO_STANDARD + } + + private int k; + /** (1 + gamma)^2: the margin on squared distances. */ + private double factor; + /** Euclidean scores are read as 1 / score - 1; the others as top - score. */ + private boolean euclidean; + /** In score units, the score of a distance of zero (linear forms only). Rises with any higher score observed. */ + private float top; + /** The cap: the CAP_FACTOR * k-th best score found, once known. Only rises. */ + private float capScore; + /** + * The highest bar {@link #lowestContinuableScore} has returned. Callers skip nodes scored below a bar, so the rule + * never continues a candidate below it; otherwise a rising {@code top} would lower the bar after such a skip. + */ + private float floor; + private boolean unsupportedScoreObserved; + + AdaptiveTermination() { + reset(1, DEFAULT_GAMMA, VectorSimilarityFunction.COSINE, Float.NaN); + } + + /** + * Prepares for a search of the {@code k} best results scored by {@code similarity}. {@code dotProductBound} is an + * upper bound on the query's dot product with any stored vector, needed for DOT_PRODUCT and ignored otherwise. + * + * @return whether this rule can decide the search; if not, the standard rule must be used + */ + boolean reset(int k, float gamma, VectorSimilarityFunction similarity, float dotProductBound) { + if (k <= 0) { + throw new IllegalArgumentException("k must be > 0"); + } + validateGamma(gamma); + this.k = k; + this.factor = (1.0d + gamma) * (1.0d + gamma); + this.unsupportedScoreObserved = false; + this.capScore = Float.NEGATIVE_INFINITY; + this.floor = Float.NEGATIVE_INFINITY; + switch (similarity) { + case EUCLIDEAN: + euclidean = true; + top = Float.NaN; + return true; + case COSINE: + euclidean = false; + top = 1.0f; + return true; + case DOT_PRODUCT: + euclidean = false; + if (!Float.isFinite(dotProductBound) || dotProductBound < 0) { + unsupportedScoreObserved = true; + return false; + } + top = (1.0f + dotProductBound) / 2.0f; + return true; + default: + unsupportedScoreObserved = true; + return false; + } + } + + static void validateGamma(float gamma) { + if (!Float.isFinite(gamma) || gamma < 0.0f) { + throw new IllegalArgumentException("gamma must be finite and >= 0"); + } + } + + /** + * Records a score among the best found. A score above the current zero-distance point raises it, so every + * distance stays nonnegative. + */ + void observe(float score) { + if (!euclidean && score > top && score != Float.POSITIVE_INFINITY) { + top = score; + } + } + + /** Raises the cap to {@code capScore}, the {@value #CAP_FACTOR}{@code k}-th best score found. */ + void raiseCap(float capScore) { + if (capScore > this.capScore) this.capScore = capScore; + } + + /** + * Decides whether the current best unexpanded candidate should terminate + * the search under the distance-adaptive rule. + */ + Decision shouldTerminate(float candidateScore, NodeQueue discoveredResults) { + if (unsupportedScoreObserved) { + return Decision.FALL_BACK_TO_STANDARD; + } + if (discoveredResults.size() < k) { + return Decision.CONTINUE; + } + float kthScore = discoveredResults.topScore(); + if (Float.isNaN(candidateScore) || Float.isNaN(kthScore) || kthScore == Float.POSITIVE_INFINITY) { + unsupportedScoreObserved = true; + return Decision.FALL_BACK_TO_STANDARD; + } + if (kthScore == Float.NEGATIVE_INFINITY || candidateScore == Float.POSITIVE_INFINITY) { + return Decision.CONTINUE; + } + if (candidateScore == Float.NEGATIVE_INFINITY || candidateScore < capScore || candidateScore < floor) { + return Decision.TERMINATE; + } + if (euclidean) { + if (!(kthScore > 0.0f)) { + return Decision.CONTINUE; + } + if (!(candidateScore > 0.0f)) { + return Decision.TERMINATE; + } + double kthDistance = Math.max(0.0d, 1.0d / kthScore - 1.0d); + double candidateDistance = 1.0d / candidateScore - 1.0d; + return candidateDistance > factor * kthDistance + EPSILON * (1.0d + kthDistance) + ? Decision.TERMINATE : Decision.CONTINUE; + } + double t = top; + return t - candidateScore > factor * (t - kthScore) + slack(t) + ? Decision.TERMINATE : Decision.CONTINUE; + } + + /** + * The lowest score a candidate may have and still be continued when the k-th best score found is + * {@code kthScore}, the cap included. Rounded down, so it never excludes a candidate {@link #shouldTerminate} + * would continue, and it never falls during a search. When this rule cannot interpret the scores it returns + * negative infinity, leaving the standard rule to decide. + */ + float lowestContinuableScore(float kthScore) { + if (unsupportedScoreObserved || !Float.isFinite(kthScore)) { + return Float.NEGATIVE_INFINITY; + } + double score; + if (euclidean) { + if (!(kthScore > 0.0f)) return Float.NEGATIVE_INFINITY; + double kthDistance = Math.max(0.0d, 1.0d / kthScore - 1.0d); + score = 1.0d / (1.0d + factor * kthDistance + EPSILON * (1.0d + kthDistance)); + } else { + double t = top; + score = t - factor * (t - kthScore) - slack(t); + } + float bar = Math.max(capScore, Math.nextDown(Math.nextDown((float) score))); + if (bar > floor) floor = bar; + return floor; + } + + private static double slack(double top) { + return EPSILON * Math.max(1.0d, Math.abs(top)); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java index 0339dd233..2b2e8dc4f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ConcurrentNeighborMap.java @@ -41,7 +41,7 @@ public class ConcurrentNeighborMap { public final int maxOverflowDegree; public ConcurrentNeighborMap(DiversityProvider diversityProvider, int maxDegree, int maxOverflowDegree) { - this(new DenseIntMap<>(1024), diversityProvider, maxDegree, maxOverflowDegree); + this(OnHeapGraphIndex.newDenseNodeMap(1024), diversityProvider, maxDegree, maxOverflowDegree); } public ConcurrentNeighborMap(IntMap neighbors, DiversityProvider diversityProvider, int maxDegree, int maxOverflowDegree) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java index ec7ad410a..70fed19b7 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java @@ -539,6 +539,9 @@ private GraphIndexBuilder(BuildScoreProvider scoreProvider, this.searchers = ExplicitThreadLocal.withInitial(() -> { var gs = new GraphSearcher(graph); gs.usePruning(false); + // Construction keeps adaptive stopping where the build provider asks for it, as ASH's does; the other + // build providers name no similarity function, so their searches keep the standard rule. + gs.useAdaptiveTermination(true); return gs; }); @@ -633,6 +636,9 @@ private GraphIndexBuilder(BuildScoreProvider buildScoreProvider, int dimension, this.searchers = ExplicitThreadLocal.withInitial(() -> { var gs = new GraphSearcher(graph); gs.usePruning(false); + // Construction keeps adaptive stopping where the build provider asks for it, as ASH's does; the other + // build providers name no similarity function, so their searches keep the standard rule. + gs.useAdaptiveTermination(true); return gs; }); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java index 4d41feef3..4cfbe65fa 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphSearcher.java @@ -55,6 +55,9 @@ public class GraphSearcher implements Closeable { private final NodeQueue rerankedResults; private final IntHashSet visited; private final NodesUnsorted evictedResults; + // Scored nodes that the current layer search can no longer expand (see searchOneLayer); they return to the + // candidates at the next layer search, so a later layer or resume() sees the same candidates as before. + private final NodesUnsorted deferredCandidates; // Search parameters that we save here for use by resume() private Bits acceptOrds; @@ -62,6 +65,19 @@ public class GraphSearcher implements Closeable { private CachingReranker cachingReranker; private boolean pruneSearch; + private boolean boundCandidates = true; + private boolean adaptiveTerminationEnabled = false; + private float adaptiveTerminationGamma = AdaptiveTermination.DEFAULT_GAMMA; + private final AdaptiveTermination adaptiveTermination = new AdaptiveTermination(); + // The best rerankK accepted nodes scored so far in the current layer search, worst on top. + private final NodeQueue bestScored = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); + // Adaptively: the best CAP_FACTOR * rerankK accepted nodes scored so far, whose worst score caps the search. + private final NodeQueue capScored = new NodeQueue(new BoundedLongHeap(AdaptiveTermination.CAP_FACTOR * 100), + NodeQueue.Order.MIN_HEAP); + private int capSize; + // Once bestScored is full: the lowest score a newly scored node may have and still be expanded. + private float expandBar; + private boolean adaptiveBar; private final ScoreTracker.ScoreTrackerFactory scoreTrackerFactory; private int visitedCount; @@ -82,6 +98,7 @@ protected GraphSearcher(ImmutableGraphIndex.View view) { this.view = view; this.candidates = new NodeQueue(new GrowableLongHeap(100), NodeQueue.Order.MAX_HEAP); this.evictedResults = new NodesUnsorted(100); + this.deferredCandidates = new NodesUnsorted(100); this.approximateResults = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); this.rerankedResults = new NodeQueue(new BoundedLongHeap(100), NodeQueue.Order.MIN_HEAP); this.visited = new IntHashSet(); @@ -138,6 +155,35 @@ public void usePruning(boolean usage) { pruneSearch = false; } + /** For tests: whether layer searches defer candidates they can no longer expand (the default). */ + void boundCandidates(boolean enabled) { + this.boundCandidates = enabled; + } + + /** + * Experimental: enable or disable adaptive stopping for top-K searches whose score providers name their similarity + * function ({@link SearchScoreProvider#adaptiveSimilarity}) or whose score functions require it. Disabled by + * default. A search that stops adaptively expands at least what the standard rule would and at most what a + * standard search with twice the beam would. With compressed scores and reranking, raising the overquery has so + * far given more recall at the same throughput. + */ + @Experimental + public void useAdaptiveTermination(boolean enabled) { + adaptiveTerminationEnabled = enabled; + } + + /** + * As {@link #useAdaptiveTermination(boolean)}, with {@code gamma} the relative margin on the metric distance: a + * search stops when the best unexpanded candidate is more than {@code 1 + gamma} times as far from the query as + * the k-th best result found. The default is about 0.005. + */ + @Experimental + public void useAdaptiveTermination(boolean enabled, float gamma) { + AdaptiveTermination.validateGamma(gamma); + adaptiveTerminationEnabled = enabled; + adaptiveTerminationGamma = gamma; + } + /** * Convenience function for simple one-off searches. It is caller's responsibility to make sure that it * is the unique owner of the vectors instance passed in here. @@ -351,6 +397,7 @@ public void initializeInternal(SearchScoreProvider scoreProvider, NodeAtLevel en // reset the scratch data structures approximateResults.clear(); evictedResults.clear(); + deferredCandidates.clear(); candidates.clear(); visited.clear(); @@ -364,20 +411,18 @@ public void initializeInternal(SearchScoreProvider scoreProvider, NodeAtLevel en expandedCountBaseLayer = 0; } - private boolean stopSearch(NodeQueue localCandidates, ScoreTracker scoreTracker, int rerankK, float threshold) { - float topCandidateScore = localCandidates.topScore(); - - // we're done when we have K results and the best candidate is worse than the worst result so far - if (approximateResults.size() >= rerankK && topCandidateScore < approximateResults.topScore()) { - return true; + private boolean stopSearch(NodeQueue localCandidates, ScoreTracker scoreTracker, int rerankK, + float threshold, boolean adaptive) { + if (adaptive && approximateResults.size() >= rerankK) { + var decision = adaptiveTermination.shouldTerminate(localCandidates.topScore(), bestScored); + if (decision == AdaptiveTermination.Decision.CONTINUE) return false; + // A candidate among the best scored nodes is still expanded. + if (decision == AdaptiveTermination.Decision.TERMINATE) + return localCandidates.topScore() < bestScored.topScore(); } - - // preserve legacy threshold early termination - if (threshold > 0 && scoreTracker.shouldStop()) { - return true; - } - - return false; + if (approximateResults.size() >= rerankK + && localCandidates.topScore() < approximateResults.topScore()) return true; + return threshold > 0 && scoreTracker.shouldStop(); } /** @@ -424,14 +469,51 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, try { assert approximateResults.size() == 0; // should be cleared by setEntryPointsFromPreviousLayer approximateResults.setMaxSize(rerankK); + // When enabled, ASH score functions stop adaptively whatever provider wraps them. + boolean adaptive = adaptiveTerminationEnabled && level == 0 && threshold == 0.0f && rerankK > 0 + && startAdaptiveTermination(scoreProvider, rerankK); + // Bounded candidates: a scored node is expanded only while it ranks among the best rerankK accepted + // nodes scored so far (or, adaptively, within the cutoff of the rerankK-th), and that bar only rises. + // So a node that misses it when scored can never be expanded by this layer search: it is deferred + // instead of entering the candidate heap. Threshold searches keep every candidate. + boolean bounded = boundCandidates && threshold <= 0.0f && rerankK > 0; + boolean trackBest = bounded || adaptive; + deferredCandidates.foreach(candidates::push); + deferredCandidates.clear(); + bestScored.clear(); + capScored.clear(); + capSize = AdaptiveTermination.CAP_FACTOR * rerankK; + if (adaptive) capScored.setMaxSize(capSize); + expandBar = Float.NEGATIVE_INFINITY; + adaptiveBar = adaptive; + if (trackBest) { + bestScored.setMaxSize(rerankK); + candidates.foreach((node, score) -> { + if (acceptOrdsThisLayer.get(node) && score >= threshold) offerScored(node, score, rerankK); + }); + } // TopK and filtered pruning are disabled. Threshold searches retain their // legacy threshold early-termination path inside ScoreTrackerFactory. var scoreTracker = scoreTrackerFactory.getScoreTracker(false, rerankK, threshold); + var scoreFunction = scoreProvider.scoreFunction(); + ImmutableGraphIndex.NeighborProcessor neighborProcessor = (node2, score) -> { + visitedCount++; + scoreTracker.track(score); + if (trackBest && acceptOrdsThisLayer.get(node2) && score >= threshold) offerScored(node2, score, rerankK); + if (bounded) { + if (!mayExpand(score, rerankK)) { + deferredCandidates.add(node2, score); + return; + } + } + candidates.push(node2, score); + }; + // the main search loop while (candidates.size() > 0) { - if (stopSearch(candidates, scoreTracker, rerankK, threshold)) { + if (stopSearch(candidates, scoreTracker, rerankK, threshold, adaptive)) { break; } @@ -453,12 +535,6 @@ public void searchOneLayer(SearchScoreProvider scoreProvider, expandedCount++; // score the neighbors of the top candidate and add them to the queue - var scoreFunction = scoreProvider.scoreFunction(); - ImmutableGraphIndex.NeighborProcessor neighborProcessor = (node2, score) -> { - scoreTracker.track(score); - candidates.push(node2, score); - visitedCount++; - }; view.processNeighbors(level, topCandidateNode, scoreFunction, visited::add, neighborProcessor); } } catch (Throwable t) { @@ -523,6 +599,56 @@ SearchResult resume(int topK, int rerankK, float threshold, float rerankFloor) { return reranking(topK, rerankK, rerankFloor); } + /** + * Keeps {@code node} if it is among the best {@code rerankK} accepted nodes scored so far, and raises the + * expansion bar when the rerankK-th best score rises. + */ + private void offerScored(int node, float score, int rerankK) { + // Every score above the best so far passes through here, so adaptive stopping sees the highest score found. + boolean changed = false; + if (adaptiveBar && (capScored.size() < capSize || score > capScored.topScore())) { + capScored.push(node, score); + changed = true; + } + if (bestScored.size() < rerankK || score > bestScored.topScore()) { + bestScored.push(node, score); + if (adaptiveBar) adaptiveTermination.observe(score); + changed = true; + } + // The bar depends on both the rerankK-th best and the cap, so it is refreshed when either moves. + if (changed && bestScored.size() == rerankK) updateExpandBar(); + } + + /** + * Prepares adaptive stopping for this layer search when the provider asks for it, or its score function requires + * it (scoring DOT_PRODUCT), and the rule can read the scores as distances; otherwise the standard rule decides. + */ + private boolean startAdaptiveTermination(SearchScoreProvider scoreProvider, int rerankK) { + var scoreFunction = scoreProvider.scoreFunction(); + var similarity = scoreProvider.adaptiveSimilarity(); + if (similarity == null && scoreFunction.requiresAdaptiveTermination()) { + similarity = VectorSimilarityFunction.DOT_PRODUCT; + } + if (similarity == null) return false; + float bound = similarity == VectorSimilarityFunction.DOT_PRODUCT ? scoreProvider.dotProductBound() : Float.NaN; + return adaptiveTermination.reset(rerankK, adaptiveTerminationGamma, similarity, bound); + } + + /** The rerankK-th best score, or adaptively the lowest score within its cutoff and the cap, whichever is lower. */ + private void updateExpandBar() { + float kth = bestScored.topScore(); + if (adaptiveBar && capScored.size() >= capSize) { + adaptiveTermination.raiseCap(capScored.topScore()); + } + expandBar = adaptiveBar ? Math.min(kth, adaptiveTermination.lowestContinuableScore(kth)) : kth; + } + + /** Whether a node just scored can still be expanded by the current layer search; see searchOneLayer. */ + private boolean mayExpand(float score, int rerankK) { + // A NaN score compares false against every bar, as it does in the stopping rule, so it is kept. + return bestScored.size() < rerankK || !(score < expandBar); + } + @SuppressWarnings("StatementWithEmptyBody") private void addTopCandidate(int topCandidateNode, float topCandidateScore, int rerankK) { // add the new node to the results queue, and any evicted node to evictedResults in case we resume later diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java index 6482431cd..2074dfc84 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java @@ -51,6 +51,16 @@ public interface ImmutableGraphIndex extends AutoCloseable, Accountable { /** Marks entry node as absent (fe, empty graph) */ int ENTRY_NODE_ABSENT = -1; + /** + * Optional numerical query preparation, independent of a mutable graph view. + * Obtain and reuse one preparer per loaded model. Returned preparers and query + * states belong to the caller; their byte counts exclude shared model weights. + */ + default java.util.Optional queryPreparer( + VectorSimilarityFunction similarity) { + return java.util.Optional.empty(); + } + /** Returns the number of nodes in the graph */ @Deprecated default int size() { @@ -216,6 +226,16 @@ default int getIdUpperBound() { * except for OnHeapGraphIndex.ConcurrentGraphIndexView.) */ interface ScoringView extends View { + /** + * Binds prepared numerical state to this view without projecting again. + * Keep the state unchanged through all uses of the provider, including + * resumed searches. Providers without this capability reject the call. + */ + default io.github.jbellis.jvector.graph.similarity.SearchScoreProvider scoreProviderFor( + io.github.jbellis.jvector.graph.similarity.PreparedQuery query, boolean rerank) { + throw new UnsupportedOperationException("Prepared query scoring is unavailable"); + } + ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat queryVector, VectorSimilarityFunction vsf); ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor(VectorFloat queryVector, VectorSimilarityFunction vsf); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java index c1b44e991..9c05f0917 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java @@ -32,7 +32,8 @@ import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.util.BitSet; import io.github.jbellis.jvector.util.Bits; -import io.github.jbellis.jvector.util.DenseIntMap; +import io.github.jbellis.jvector.util.IntMap; +import io.github.jbellis.jvector.util.PagedIntMap; import io.github.jbellis.jvector.util.RamUsageEstimator; import io.github.jbellis.jvector.util.SparseIntMap; import io.github.jbellis.jvector.util.ThreadSafeGrowableBitSet; @@ -83,6 +84,15 @@ public class OnHeapGraphIndex implements MutableGraphIndex { private final boolean isHierarchical; + /** + * Dense node maps use {@link PagedIntMap}: slots never move, so updates are a single CAS with no + * lock. A read-write-locked map ({@link io.github.jbellis.jvector.util.DenseIntMap}) made every + * concurrent neighbor update take a shared lock, whose contention caused large build-time swings. + */ + static IntMap newDenseNodeMap(int initialCapacity) { + return new PagedIntMap<>(initialCapacity); + } + OnHeapGraphIndex(List maxDegrees, int dimension, double overflowRatio, DiversityProvider diversityProvider, boolean isHierarchical) { this.overflowRatio = overflowRatio; this.maxDegrees = new IntArrayList(); @@ -92,7 +102,7 @@ public class OnHeapGraphIndex implements MutableGraphIndex { this.completions = new CompletionTracker(1024); // Initialize the base layer (layer 0) with a dense map. this.layers.add(new ConcurrentNeighborMap( - new DenseIntMap<>(1024), + newDenseNodeMap(1024), diversityProvider, getDegree(0), (int) (getDegree(0) * overflowRatio)) @@ -464,6 +474,24 @@ public boolean hasNext() { } } + /** Per-thread buffers for scoring one node's unvisited neighbors as a batch; they grow to the largest list. */ + private static final ThreadLocal BATCH_SCRATCH = ThreadLocal.withInitial(BatchScratch::new); + + private static final class BatchScratch { + int[] nodes = new int[64]; + float[] scores = new float[64]; + + /** Stores {@code node} at {@code index}, growing both buffers if needed; returns the new count. */ + int add(int index, int node) { + if (index == nodes.length) { + nodes = java.util.Arrays.copyOf(nodes, 2 * index); + scores = new float[2 * index]; + } + nodes[index] = node; + return index + 1; + } + } + private class FrozenView implements View { @Override public NodesIterator getNeighborsIterator(int level, int node) { @@ -473,6 +501,18 @@ public NodesIterator getNeighborsIterator(int level, int node) { @Override public void processNeighbors(int level, int node, ScoreFunction scoreFunction, IntMarker visited, NeighborProcessor neighborProcessor) { + if (scoreFunction.supportsBatchScoring()) { + // Gather the unvisited neighbors, score them in one batch, then hand them on in neighbor order. + var batch = BATCH_SCRATCH.get(); + int count = 0; + for (var it = getNeighborsIterator(level, node); it.hasNext(); ) { + var friendOrd = it.nextInt(); + if (visited.mark(friendOrd)) count = batch.add(count, friendOrd); + } + scoreFunction.similarityTo(batch.nodes, count, batch.scores); + for (int i = 0; i < count; i++) neighborProcessor.process(batch.nodes[i], batch.scores[i]); + return; + } for (var it = getNeighborsIterator(level, node); it.hasNext(); ) { var friendOrd = it.nextInt(); if (visited.mark(friendOrd)) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/VectorMean.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/VectorMean.java new file mode 100644 index 000000000..6a93fe644 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/VectorMean.java @@ -0,0 +1,168 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Random; + +/** + * Collects an original-coordinate mean during an existing, explicit input scan. + * This utility does not read vectors. Database integrations may feed already-read + * vectors to {@link #accept}; bulk encoders can use {@link #duringScan} instead. + * Neither loaders nor vector compressors need to implement a statistics interface. + */ +public final class VectorMean { + /** A bounded uniform sample, independent of the input size and quantizer. */ + public static final int DEFAULT_SAMPLE_SIZE = 16384; + + private final int size; + private final int dimension; + private final int[] ordinals; + private final boolean[] seen; + private final double[] sum; + private int visited; + private int count; + + public VectorMean(int size, int dimension) { + this(size, dimension, DEFAULT_SAMPLE_SIZE, 0); + } + + /** maxSamples >= size collects the full mean; otherwise samples without replacement. */ + public VectorMean(int size, int dimension, int maxSamples, long seed) { + if (size <= 0 || dimension <= 0 || maxSamples <= 0) + throw new IllegalArgumentException("Positive size, dimension and sample limit required"); + this.size = size; + this.dimension = dimension; + int n = Math.min(size, maxSamples); + ordinals = new int[n]; + // Floyd's algorithm selects in O(sample size), without scanning all ordinals. + var selected = new HashSet(); + var random = new Random(seed); + int i = 0; + for (int j = size - n; j < size; j++) { + int ordinal = random.nextInt(j + 1); + if (!selected.add(ordinal)) { + selected.add(j); + ordinal = j; + } + ordinals[i++] = ordinal; + } + Arrays.sort(ordinals); + seen = new boolean[n]; + sum = new double[dimension]; + } + + /** + * Observe an already-read, untransformed vector at its source ordinal. A borrowed + * buffer is consumed immediately, never retained. Repeated ordinals are ignored. + * Null/missing vectors are excluded from the mean, but count toward scan completion. + */ + public void accept(int ordinal, VectorFloat vector) { + Objects.checkIndex(ordinal, size); + int sample = Arrays.binarySearch(ordinals, ordinal); + if (sample >= 0) acceptSample(sample, vector); + } + + private synchronized void acceptSample(int sample, VectorFloat vector) { + if (seen[sample]) return; + if (vector != null) { + if (vector.length() != dimension) throw new IllegalArgumentException("Vector dimension mismatch"); + // Validate before mutating state, including on a failed/retried scan. + for (int d = 0; d < dimension; d++) { + if (!Float.isFinite(vector.get(d))) throw new IllegalArgumentException("Non-finite vector component"); + } + for (int d = 0; d < dimension; d++) sum[d] += vector.get(d); + count++; + } + seen[sample] = true; + visited++; + } + + public synchronized boolean isComplete() { return visited == ordinals.length; } + public synchronized int count() { return count; } + public int sampleSize() { return ordinals.length; } + + /** Return an owned snapshot. Fails if the selected input scan is incomplete or empty. */ + public synchronized VectorFloat mean() { + if (!isComplete()) throw new IllegalStateException("Incomplete mean collection"); + if (count == 0) throw new IllegalStateException("No present vectors in mean sample"); + var mean = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimension); + for (int d = 0; d < dimension; d++) mean.set(d, (float) (sum[d] / count)); + return mean; + } + + /** + * Scope collection to ONE explicit bulk scan. Range/selection cursors observe + * vectors before handing them to the consumer. Point reads are delegated without + * collection, so arbitrary training/search accesses cannot become statistics. + * The wrapper does not own the source; closing a cursor still releases its reads. + * Do not pass this wrapper to training or reuse it for another dataset. + */ + public BatchedVectorValues duringScan(RandomAccessVectorValues source) { + if (source.size() != size || source.dimension() != dimension) + throw new IllegalArgumentException("Mean/source shape mismatch"); + return new BatchedVectorValues() { + public int size() { return source.size(); } + public int dimension() { return source.dimension(); } + public boolean isValueShared() { return source.isValueShared(); } + public VectorFloat getVector(int ordinal) { return source.getVector(ordinal); } + public void getVectorInto(int ordinal, VectorFloat destination, int offset) { + source.getVectorInto(ordinal, destination, offset); + } + public RandomAccessVectorValues copy() { return duringScan(source.copy()); } + public VectorCursor openRange(int start, int end) { + var cursor = VectorAccess.openRange(source, start, end); + int first = Arrays.binarySearch(ordinals, start); + return observe(cursor, first >= 0 ? first : -first - 1, true); + } + public VectorCursor openSelection(int[] requested, int offset, int count) { + return observe(VectorAccess.openSelection(source, requested, offset, count), 0, false); + } + }; + } + + private VectorCursor observe(VectorCursor cursor, int firstSample, boolean range) { + return new VectorCursor() { + int nextSample = firstSample; + public boolean next() { + try { + if (!cursor.next()) return false; + int ordinal = cursor.ordinal(); + if (range) { + if (nextSample < ordinals.length && ordinals[nextSample] == ordinal) + acceptSample(nextSample++, cursor.vector()); + } else { + accept(ordinal, cursor.vector()); + } + return true; + } catch (RuntimeException | Error failure) { + try { close(); } catch (RuntimeException | Error closing) { failure.addSuppressed(closing); } + throw failure; + } + } + public int ordinal() { return cursor.ordinal(); } + public VectorFloat vector() { return cursor.vector(); } + public boolean isValueShared() { return cursor.isValueShared(); } + public void close() { cursor.close(); } + }; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java index d43497944..18854ecdc 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexFormat.java @@ -381,7 +381,7 @@ public int commonHeaderSize() { public void writeHeaderFeatures(IndexWriter out, Map features) throws IOException { // we restrict pre-version-3 writers to INLINE_VECTORS features, so we don't need additional version-handling here for (Feature writer : features.values()) { - writer.writeHeader(out); + writer.writeHeader(out, getVersion()); } } @@ -389,7 +389,7 @@ public void writeHeaderFeatures(IndexWriter out, Map features) { int size = this.commonHeaderSize(); - size += features.values().stream().mapToInt(Feature::headerSize).sum(); + size += features.values().stream().mapToInt(f -> f.headerSize(getVersion())).sum(); return size; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java index 73da527d8..29f2149f6 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CompactWriter.java @@ -51,6 +51,7 @@ final class CompactWriter implements AutoCloseable { private final RandomAccessWriter writer; private final int recordSize; private final long startOffset; + private final InlineVectors inlineVectorFeature; private final int headerSize; private final Header header; private final FusedFeature fusedFeature; @@ -111,7 +112,7 @@ final class CompactWriter implements AutoCloseable { this.entryNodePqCode = null; Map featureMap = new LinkedHashMap<>(); - InlineVectors inlineVectorFeature = new InlineVectors(dimension); + this.inlineVectorFeature = new InlineVectors(dimension); featureMap.put(FeatureId.INLINE_VECTORS, inlineVectorFeature); if (fusedFeature != null) { featureMap.put(fusedFeature.id(), fusedFeature); @@ -310,6 +311,8 @@ public WriteResult writeInlineNodeRecord(int ordinal, VectorFloat vec, Select for(int i = 0; i < vec.length(); ++i) { bwriter.writeFloat(vec.get(i)); } + // The footer's copy of the header, which loading reads, then stores the largest length written. + inlineVectorFeature.recordWritten(vec); // write fused PQ // since we build a graph in a streaming way, diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatFactory.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatFactory.java index 12010bc1e..b7c0014a1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatFactory.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatFactory.java @@ -35,7 +35,8 @@ public class GraphIndexFormatFactory { 3, new GraphIndexFormatV3(), 4, new GraphIndexFormatV4(), 5, new GraphIndexFormatV5(), - 6, new GraphIndexFormatV6() + 6, new GraphIndexFormatV6(), + 7, new GraphIndexFormatV7() ); /** diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java index c9d965aa4..0d53356ca 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV6.java @@ -26,6 +26,7 @@ import java.util.EnumMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.function.IntFunction; import java.util.stream.Collectors; @@ -44,7 +45,12 @@ class GraphIndexFormatV6 extends GraphIndexFormatV5 { /** Creates the singleton format for version 6. */ GraphIndexFormatV6() { - super(6, allFeatures()); + this(6, allFeatures()); + } + + /** Allows later feature extensions to reuse the V6 record and hierarchy layout. */ + protected GraphIndexFormatV6(int version, Set supportedFeatures) { + super(version, supportedFeatures); } /** Places fused features last so non-fused inline features occupy a contiguous prefix. */ @@ -112,7 +118,7 @@ public void writeHeaderFeatures(IndexWriter out, Map features) { // In V6, this accounts for the number of features and the ordinal of each feature size += Integer.BYTES + features.size() * Integer.BYTES; - size += features.values().stream().mapToInt(Feature::headerSize).sum(); + size += features.values().stream().mapToInt(f -> f.headerSize(getVersion())).sum(); return size; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java new file mode 100644 index 000000000..b0a703116 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/GraphIndexFormatV7.java @@ -0,0 +1,39 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.graph.disk; + +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Version 7 adds ASH feature payloads while retaining the V6 graph record layout. + * Feature headers describe the quantizer and the fused source representation. + */ +final class GraphIndexFormatV7 extends GraphIndexFormatV6 { + GraphIndexFormatV7() { + super(7, supportedFeatures()); + } + + /** The extension is restricted to V7; earlier formats retain their feature sets. */ + private static Set supportedFeatures() { + var features = EnumSet.copyOf(allFeatures()); + features.add(FeatureId.FUSED_ASH); + return features; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index f43357c27..c2aa46cc1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -25,6 +25,7 @@ import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.graph.disk.feature.FeatureSource; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; @@ -45,11 +46,13 @@ import java.io.UncheckedIOException; import java.nio.file.Path; import java.util.EnumMap; +import java.util.Objects; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,7 +71,7 @@ public class OnDiskGraphIndex implements ImmutableGraphIndex, AutoCloseable, Accountable { private static final Logger logger = LoggerFactory.getLogger(OnDiskGraphIndex.class); - public static final int CURRENT_VERSION = 6; + public static final int CURRENT_VERSION = 7; static final int MAGIC = 0xFFFF0D61; // FFFF to distinguish from old graphs, which should never start with a negative size "ODGI" static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); final ReaderSupplier readerSupplier; @@ -191,40 +194,41 @@ private Int2ObjectHashMap loadInMemoryFeatures(Random CommonHeader.LayerInfo info = layerInfo.get(lvl); inMemorySize += Integer.BYTES * info.size * (1L + 1L + info.degree); } + in.seek(neighborsOffset + L0size + inMemorySize); - // Fused features for the in-memory hierarchy are written in a block after the top layers of the graph, - // for formats that support fused features. - if (format.supportsFeature(FeatureId.FUSED_PQ)) { + FusedFeature fusedFeature = null; + for (var feature : features.values()) { + if (feature.isFused()) { + fusedFeature = (FusedFeature) feature; + break; + } + } + + if (fusedFeature == null) { + return hierarchyFeatures; + } + + // V6+ layout: fused source features for the in-memory hierarchy are written + // after the upper-layer graph data. Level 1 contains all higher-level nodes, + // so reading level 1 is sufficient. + if (format.supportsFeature(fusedFeature.id())) { if (layerInfo.size() >= 2) { int level = 1; CommonHeader.LayerInfo info = layerInfo.get(level); + for (int i = 0; i < info.size; i++) { int nodeId = in.readInt(); - - // There should be only one fused feature per node. This is checked in AbstractGraphIndexWriter. - for (var feature : features.values()) { - if (feature.isFused()) { - var fusedFeature = (FusedFeature) feature; - var inlineSource = fusedFeature.loadSourceFeature(in); - hierarchyFeatures.put(nodeId, inlineSource); - } - } + var inlineSource = fusedFeature.loadSourceFeature(in); + hierarchyFeatures.put(nodeId, inlineSource); } } else { - // read the entry node int nodeId = in.readInt(); - - // There should be only one fused feature per node. This is checked in AbstractGraphIndexWriter. - for (var feature : features.values()) { - if (feature.isFused()) { - var fusedFeature = (FusedFeature) feature; - var inlineSource = fusedFeature.loadSourceFeature(in); - hierarchyFeatures.put(nodeId, inlineSource); - } - } + var inlineSource = fusedFeature.loadSourceFeature(in); + hierarchyFeatures.put(nodeId, inlineSource); } } + return hierarchyFeatures; } @@ -482,10 +486,43 @@ public double getAverageDegree(int level) { return (double) sum / it.size(); } + @Override + public java.util.Optional queryPreparer( + VectorSimilarityFunction similarity) { + var feature = features.get(FeatureId.FUSED_ASH); + if (feature == null) return java.util.Optional.empty(); + if (similarity != VectorSimilarityFunction.DOT_PRODUCT) + throw new UnsupportedOperationException("FusedASH supports DOT_PRODUCT only"); + return java.util.Optional.of(new io.github.jbellis.jvector.quantization.ASHQueryPreparer( + ((FusedASH) feature).getASH())); + } + public class View implements FeatureSource, ScoringView, RandomAccessVectorValues { protected final RandomAccessReader reader; private final int[] neighbors; private int nodeDegree; + private EnumMap featureScratch; + + /** + * Returns scratch confined to this view, allocating it on first use only. + * Views and their scratch are not thread safe. A feature may reuse its scratch + * for the next query only after the current query (including resume) is finished. + * The feature owns any invalidation needed for previously returned scorers. + */ + public T getOrCreateFeatureScratch(FeatureId id, Class type, Supplier factory) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(factory, "factory"); + if (!features.containsKey(id)) { + throw new IllegalArgumentException("Feature is not present in this graph: " + id); + } + Object scratch = featureScratch == null ? null : featureScratch.get(id); + if (scratch != null) return type.cast(scratch); + T created = type.cast(Objects.requireNonNull(factory.get(), "feature scratch")); + if (featureScratch == null) featureScratch = new EnumMap<>(FeatureId.class); + featureScratch.put(id, created); + return created; + } public View(RandomAccessReader reader) { this.reader = reader; @@ -696,13 +733,31 @@ public Bits liveNodes() { @Override public void close() throws IOException { + featureScratch = null; reader.close(); } @Override public ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat queryVector, VectorSimilarityFunction vsf) { if (features.containsKey(FeatureId.INLINE_VECTORS)) { - return RandomAccessVectorValues.super.rerankerFor(queryVector, vsf); + // Exact scores of the inline vectors, reporting the dot-product bound adaptive stopping needs: the + // query's length times the largest stored length (NaN when the header did not store it). + float maxNorm = ((InlineVectors) features.get(FeatureId.INLINE_VECTORS)).maxVectorNorm(); + return new ScoreFunction.ExactScoreFunction() { + private final VectorFloat scratch = vectorTypeSupport.createFloatVector(dimension); + + @Override + public float similarityTo(int node2) { + getVectorInto(node2, scratch, 0); + return vsf.compare(queryVector, scratch); + } + + @Override + public float dotProductBound() { + return (float) Math.sqrt(io.github.jbellis.jvector.vector.VectorUtil.dotProduct(queryVector, queryVector)) + * maxNorm; + } + }; } else if (features.containsKey(FeatureId.NVQ_VECTORS)) { return ((NVQ) features.get(FeatureId.NVQ_VECTORS)).rerankerFor(queryVector, vsf, this); } else { @@ -710,10 +765,22 @@ public ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat queryVector, } } + @Override + public io.github.jbellis.jvector.graph.similarity.SearchScoreProvider scoreProviderFor( + io.github.jbellis.jvector.graph.similarity.PreparedQuery prepared, boolean rerank) { + var feature = (FusedASH) features.get(FeatureId.FUSED_ASH); + if (feature == null) throw new UnsupportedOperationException("Prepared query scoring requires FusedASH"); + var asf = feature.approximateScoreFunctionFor(prepared, this); + var rr = rerank ? rerankerFor(prepared.queryVector(), VectorSimilarityFunction.DOT_PRODUCT) : null; + return new io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider(asf, rr, io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); + } + @Override public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor(VectorFloat queryVector, VectorSimilarityFunction vsf) { if (features.containsKey(FeatureId.FUSED_PQ)) { return ((FusedPQ) features.get(FeatureId.FUSED_PQ)).approximateScoreFunctionFor(queryVector, vsf, this, rerankerFor(queryVector, vsf)); + } else if (features.containsKey(FeatureId.FUSED_ASH)) { + return ((FusedASH) features.get(FeatureId.FUSED_ASH)).approximateScoreFunctionFor(queryVector, vsf, this, rerankerFor(queryVector, vsf)); } else { throw new UnsupportedOperationException("No approximate score function available for this graph"); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java index 03dd2ec98..16b4e145b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/Feature.java @@ -34,10 +34,34 @@ default boolean isFused() { int headerSize(); + /** The bytes {@link #writeHeader(IndexWriter, int)} writes at {@code version}. */ + default int headerSize(int version) { + return headerSize(); + } + int featureSize(); + /** + * Writes this feature's header at {@link io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex#CURRENT_VERSION}. + * + * @deprecated a header embedded in a graph must be written at the graph's version; use + * {@link #writeHeader(IndexWriter, int)} + */ + @Deprecated void writeHeader(IndexWriter out) throws IOException; + /** + * Writes this feature's header for a graph written at {@code version}. Features that serialize a + * compressor write it at this version, so a graph written at an older version is laid out as that + * version wrote it. The default ignores the version, for headers that don't depend on it. + * + * @param out the output to write to + * @param version the on-disk format version of the graph being written + */ + default void writeHeader(IndexWriter out, int version) throws IOException { + writeHeader(out); + } + default void writeInline(IndexWriter out, State state) throws IOException { // default no-op } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java index 131c4c8ee..7b25517d1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FeatureId.java @@ -33,7 +33,8 @@ public enum FeatureId { FUSED_PQ(FusedPQ::load), NVQ_VECTORS(NVQ::load), SEPARATED_VECTORS(SeparatedVectors::load), - SEPARATED_NVQ(SeparatedNVQ::load); + SEPARATED_NVQ(SeparatedNVQ::load), + FUSED_ASH(FusedASH::load); private final BiFunction loader; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java new file mode 100644 index 000000000..73f962b94 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedASH.java @@ -0,0 +1,368 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.graph.disk.feature; + +import io.github.jbellis.jvector.annotations.Experimental; +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.disk.CommonHeader; +import io.github.jbellis.jvector.graph.disk.CompactionContext; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.QuantizationCompactionStrategy; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.FusedASHDecoder; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.quantization.VectorCompressor; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.ByteSequence; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.function.IntFunction; +import java.util.function.Supplier; + +/** + * Fuses ASH-encoded neighbors into an {@link OnDiskGraphIndex} for block-oriented + * graph-neighborhood scoring. + * + *

Unlike regular {@link ASHVectors}, this feature stores each node's layer-0 + * neighbor codes in a neighborhood-local block layout. The decoder reads and + * scores the whole neighborhood in {@code enableSimilarityToNeighbors(origin)}, + * then {@code similarityToNeighbor(origin, i)} is a cheap score lookup.

+ * + *

The fused layout supports only {@code bitsPerDimension in {1,2,4}} because + * it is based on 4-bit projection groups and a 16-entry query LUT per group. + * Core standalone ASH may support additional bit widths, but those are not part + * of the fused graph hot path.

+ */ +@Experimental +public class FusedASH extends AbstractFeature implements FusedFeature { + private static final long INLINE_SOURCE_BYTES = RamUsageEstimator.shallowSizeOfInstance(FusedASHInlineSource.class); + private final int maxDegree; + private final int blockSize; + private final AsymmetricHashing ash; + private final int featureSize; + + private final Supplier queryScratchFactory; + private final ThreadLocal writeScratch; + private final ThreadLocal packingRefs; + // Reusable per-thread copies of neighbor codes read from compact ASHVectors storage. + private final ThreadLocal neighborCopies; + + public FusedASH(int maxDegree, AsymmetricHashing ash) { + this(maxDegree, ash, FusedASHLayout.chooseBlockSize(maxDegree)); + } + + public FusedASH(int maxDegree, AsymmetricHashing ash, int blockSize) { + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + if (ash == null) { + throw new NullPointerException("ash"); + } + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + + this.maxDegree = maxDegree; + this.ash = ash; + this.blockSize = blockSize; + this.featureSize = FusedASHLayout.featureSize( + maxDegree, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize); + + this.queryScratchFactory = () -> new FusedASHDecoder.QueryScratch(ash, maxDegree, blockSize); + this.writeScratch = ThreadLocal.withInitial(() -> new byte[featureSize]); + this.packingRefs = ThreadLocal.withInitial(() -> new AsymmetricHashing.QuantizedVector[maxDegree]); + this.neighborCopies = ThreadLocal.withInitial(() -> { + var copies = new AsymmetricHashing.QuantizedVector[maxDegree]; + for (int i = 0; i < maxDegree; i++) + copies[i] = AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, ash.bitsPerDimension); + return copies; + }); + } + + @Override + public FeatureId id() { + return FeatureId.FUSED_ASH; + } + + public AsymmetricHashing getASH() { + return ash; + } + + // ASH uses QuantizedVector codes and a block-packed layout that the current + // byte-sequence compaction pipeline does not support. + @Override + public int codeSize() { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + + @Override + public VectorCompressor> getCompressor() { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + + @Override + public FusedFeature withCompressor(VectorCompressor> newCompressor, int maxDegree) { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + + @Override + public QuantizationCompactionStrategy createCompactionStrategy(CompactionContext ctx) { + throw new UnsupportedOperationException("FusedASH compaction is not supported yet"); + } + + public int blockSize() { + return blockSize; + } + + @Override + public int headerSize() { + return ash.compressorSize() + Integer.BYTES; // blockSize + } + + @Override + public int featureSize() { + return featureSize; + } + + static FusedASH load(CommonHeader header, RandomAccessReader reader) { + try { + AsymmetricHashing ash = AsymmetricHashing.load(reader); + int blockSize = reader.readInt(); + return new FusedASH(header.layerInfo.get(0).degree, ash, blockSize); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Deprecated + @Override + public void writeHeader(IndexWriter out) throws IOException { + writeHeader(out, OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public void writeHeader(IndexWriter out, int version) throws IOException { + ash.write(out, version); + out.writeInt(blockSize); + } + + /** + * Writes a fused, block-packed ASH payload for {@code state.nodeId}'s layer-0 + * neighbors. Neighbor lanes are stored in graph neighbor iterator order and + * padded with zeros up to {@code maxDegree}. + */ + @Override + public void writeInline(IndexWriter out, Feature.State state_) throws IOException { + var state = (FusedASH.State) state_; + byte[] scratch = writeScratch.get(); + Arrays.fill(scratch, (byte) 0); + + var refs = ash.bitsPerDimension == 1 ? null : packingRefs.get(); + var copies = state.vectors == null ? null : neighborCopies.get(); + var neighbors = state.view.getNeighborsIterator(0, state.nodeId); + int count = 0; + try { + while (neighbors.hasNext()) { + int node = neighbors.nextInt(); + if (count >= maxDegree) { + throw new IOException("Node " + state.nodeId + " has more than maxDegree=" + maxDegree + " neighbors"); + } + + AsymmetricHashing.QuantizedVector compressed; + if (copies != null) { + // Copy out of the compact store; no allocation per neighbor. + compressed = copies[count]; + state.vectors.copyTo(node, compressed); + } else { + compressed = state.compressedVectorFunction.apply(node); + } + if (ash.bitsPerDimension == 1) { + packForFused(scratch, count, compressed); + } else { + refs[count] = compressed; + } + count++; + } + + if (ash.bitsPerDimension != 1) { + for (int first = 0; first < count; first += blockSize) { + int offset = FusedASHLayout.blockOffset(first / blockSize, ash.quantizedDim, ash.bitsPerDimension, blockSize); + io.github.jbellis.jvector.quantization.FusedASHBlockPacker.pack(scratch, offset, refs, first, + Math.min(blockSize, count - first), ash.quantizedDim, ash.bitsPerDimension, blockSize); + } + + } + } finally { + // Do not retain vectors if a supplier or packing operation fails. + if (refs != null) { + Arrays.fill(refs, 0, count, null); + } + } + writeBytes(out, scratch, featureSize); + } + + private void packForFused(byte[] dest, int neighborIndex, AsymmetricHashing.QuantizedVector vector) { + int blockIndex = neighborIndex / blockSize; + int lane = neighborIndex % blockSize; + int blockOffset = FusedASHLayout.blockOffset( + blockIndex, + ash.quantizedDim, + ash.bitsPerDimension, + blockSize); + + // The canonical vector already stores the projection-mode offset used by FusedASH scoring. + FusedASHLayout.packForFused(dest, blockOffset, lane, vector, ash, blockSize); + } + + private static void writeBytes(IndexWriter out, byte[] bytes, int length) throws IOException { + out.write(bytes, 0, length); + } + + public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor( + VectorFloat queryVector, + VectorSimilarityFunction vsf, + OnDiskGraphIndex.View view, + ScoreFunction.ExactScoreFunction esf) { + + var neighborhoods = new PackedNeighborhoods(view); + var hierarchyCachedFeatures = view.getInlineSourceFeatures(); + + return FusedASHDecoder.newDecoder( + neighborhoods, + ash, + hierarchyCachedFeatures, + source -> ((FusedASHInlineSource) source).getVector(), + queryVector, + view.getOrCreateFeatureScratch(id(), FusedASHDecoder.QueryScratch.class, queryScratchFactory), + blockSize, + vsf); + } + + /** Bind preparation to the view while retaining the established decoder lifetime checks. */ + public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor( + io.github.jbellis.jvector.graph.similarity.PreparedQuery query, + OnDiskGraphIndex.View view) { + return FusedASHDecoder.newDecoder(new PackedNeighborhoods(view), ash, + view.getInlineSourceFeatures(), source -> ((FusedASHInlineSource) source).getVector(), + query, view.getOrCreateFeatureScratch(id(), FusedASHDecoder.QueryScratch.class, + queryScratchFactory), blockSize); + } + + @Override + public void writeSourceFeature(IndexWriter out, Feature.State state_) throws IOException { + var state = (FusedASH.State) state_; + var compressed = state.compressedVectorFunction.apply(state.nodeId); + compressed.write(out, ash.quantizedDim, ash.bitsPerDimension); + } + + @Override + public InlineSource loadSourceFeature(RandomAccessReader in) throws IOException { + var vector = AsymmetricHashing.QuantizedVector.load(in, ash.quantizedDim, ash.bitsPerDimension); + return new FusedASHInlineSource(vector); + } + + public class FusedASHInlineSource implements InlineSource { + private final AsymmetricHashing.QuantizedVector vector; + + FusedASHInlineSource(AsymmetricHashing.QuantizedVector vector) { + this.vector = vector; + } + + public AsymmetricHashing.QuantizedVector getVector() { + return vector; + } + + @Override + public long ramBytesUsed() { + return INLINE_SOURCE_BYTES + vector.ramBytesUsed(); + } + } + + public class PackedNeighborhoods implements FusedASHDecoder.PackedNeighborhoods { + private final OnDiskGraphIndex.View view; + + PackedNeighborhoods(OnDiskGraphIndex.View view) { + this.view = view; + } + + @Override + public void readInto(int origin, byte[] dest) { + if (dest.length < featureSize) { + throw new IllegalArgumentException("Destination buffer is smaller than FusedASH feature size"); + } + + try { + view.getPackedNeighbors(origin, FeatureId.FUSED_ASH, reader -> { + try { + reader.readFully(dest); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public int maxDegree() { + return maxDegree; + } + + @Override + public int featureSize() { + return featureSize; + } + } + + public static class State implements Feature.State { + public final ImmutableGraphIndex.View view; + public final IntFunction compressedVectorFunction; + public final int nodeId; + /** When non-null, neighbor codes are copied from here into reusable storage instead. */ + final ASHVectors vectors; + + public State(ImmutableGraphIndex.View view, ASHVectors ashVectors, int nodeId) { + this(view, ashVectors::get, nodeId, ashVectors); + } + + public State( + ImmutableGraphIndex.View view, + IntFunction compressedVectorFunction, + int nodeId) { + this(view, compressedVectorFunction, nodeId, null); + } + + private State(ImmutableGraphIndex.View view, IntFunction compressedVectorFunction, + int nodeId, ASHVectors vectors) { + this.view = view; + this.compressedVectorFunction = compressedVectorFunction; + this.nodeId = nodeId; + this.vectors = vectors; + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java index 3dc404481..4067a33fb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/FusedPQ.java @@ -95,6 +95,11 @@ public int headerSize() { return pq.compressorSize(); } + @Override + public int headerSize(int version) { + return pq.compressorSize(version); + } + @Override public int featureSize() { return pq.compressedVectorSize() * maxDegree; @@ -135,9 +140,15 @@ public QuantizationCompactionStrategy createCompactionStrategy(CompactionContext return new FusedCompactionStrategy(ctx, this, retrainer); } + @Deprecated @Override public void writeHeader(IndexWriter out) throws IOException { - pq.write(out, OnDiskGraphIndex.CURRENT_VERSION); + writeHeader(out, OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public void writeHeader(IndexWriter out, int version) throws IOException { + pq.write(out, version); } // this is an awkward fit for the Feature.State design since we need to diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java index 005103ddb..efb40b11c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/InlineVectors.java @@ -19,11 +19,13 @@ import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.disk.CommonHeader; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorFloat; import io.github.jbellis.jvector.vector.types.VectorTypeSupport; import java.io.IOException; +import java.io.UncheckedIOException; /** * Implements the storage of full-resolution vectors inline into an OnDiskGraphIndex. These can be used for exact scoring. @@ -31,9 +33,26 @@ public class InlineVectors extends AbstractFeature { private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); private final int dimension; + // The largest length of the vectors this feature has written, for the header. + private final MaxVectorNorm written = new MaxVectorNorm(); + // The largest length stored in a loaded index's header; NaN when unknown. + private final float stored; public InlineVectors(int dimension) { + this(dimension, Float.NaN); + } + + private InlineVectors(int dimension, float stored) { this.dimension = dimension; + this.stored = stored; + } + + /** + * The largest length of the stored vectors: from the header of a loaded index, or of the vectors written so far. + * NaN when unknown, as for an index written before format version 7 or by a writer that wrote its header first. + */ + public float maxVectorNorm() { + return Float.isNaN(stored) ? written.get() : stored; } @Override @@ -43,7 +62,12 @@ public FeatureId id() { @Override public int headerSize() { - return 0; + return headerSize(OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public int headerSize(int version) { + return MaxVectorNorm.headerSize(version); } public int featureSize() { @@ -55,17 +79,38 @@ public int dimension() { } static InlineVectors load(CommonHeader header, RandomAccessReader reader) { - return new InlineVectors(header.dimension); + try { + return new InlineVectors(header.dimension, MaxVectorNorm.read(reader, header.version)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Deprecated + @Override + public void writeHeader(IndexWriter out) throws IOException { + writeHeader(out, OnDiskGraphIndex.CURRENT_VERSION); } @Override - public void writeHeader(IndexWriter out) { - // common header contains dimension, which is sufficient + public void writeHeader(IndexWriter out, int version) throws IOException { + // The common header holds the dimension; from version 7 the header also stores the largest vector length. + written.write(out, version); + } + + /** + * Counts {@code vector} in the largest length this feature's header stores, for writers that serialize inline + * vectors themselves instead of through {@link #writeInline}. + */ + public void recordWritten(VectorFloat vector) { + written.record(vector); } @Override public void writeInline(IndexWriter out, Feature.State state) throws IOException { - vectorTypeSupport.writeFloatVector(out, ((InlineVectors.State) state).vector); + var vector = ((InlineVectors.State) state).vector; + written.record(vector); + vectorTypeSupport.writeFloatVector(out, vector); } public static class State implements Feature.State { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/MaxVectorNorm.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/MaxVectorNorm.java new file mode 100644 index 000000000..beb538209 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/MaxVectorNorm.java @@ -0,0 +1,60 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph.disk.feature; + +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import io.github.jbellis.jvector.util.MaxVectorLength; + +import java.io.IOException; + +/** + * The largest length of the full-precision vectors a feature stores, kept in its header from format version + * {@value #VERSION}. Dot-product searches that stop adaptively need it; without it they stop by the standard rule. + * + *

A feature records each vector as it writes it. A header written before any vector, as a sequential writer writes + * it, stores NaN (unknown); a writer that rewrites the header after the vectors stores their largest length. + */ +final class MaxVectorNorm { + /** The first on-disk format version whose full-precision vector headers store the largest length. */ + static final int VERSION = 7; + + private final MaxVectorLength written = new MaxVectorLength(); + + void record(VectorFloat vector) { + written.record(vector); + } + + /** The largest length recorded, or NaN if none was. */ + float get() { + return written.get(); + } + + static int headerSize(int version) { + return version >= VERSION ? Float.BYTES : 0; + } + + void write(IndexWriter out, int version) throws IOException { + if (version >= VERSION) out.writeFloat(get()); + } + + /** The stored largest length, NaN when unknown or when {@code version} predates it. */ + static float read(RandomAccessReader in, int version) throws IOException { + return version >= VERSION ? in.readFloat() : Float.NaN; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/NVQ.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/NVQ.java index 492bb0dfe..863780bf1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/NVQ.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/NVQ.java @@ -69,9 +69,15 @@ static NVQ load(CommonHeader header, RandomAccessReader reader) { } } + @Deprecated @Override public void writeHeader(IndexWriter out) throws IOException { - nvq.write(out, OnDiskGraphIndex.CURRENT_VERSION); + writeHeader(out, OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public void writeHeader(IndexWriter out, int version) throws IOException { + nvq.write(out, version); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedNVQ.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedNVQ.java index b65370632..a0e3ddd21 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedNVQ.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedNVQ.java @@ -67,9 +67,15 @@ public int featureSize() { return nvq.compressedVectorSize(); } + @Deprecated @Override public void writeHeader(IndexWriter out) throws IOException { - nvq.write(out, OnDiskGraphIndex.CURRENT_VERSION); + writeHeader(out, OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public void writeHeader(IndexWriter out, int version) throws IOException { + nvq.write(out, version); out.writeLong(offset); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java index 017aef53f..af1861a6f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/feature/SeparatedVectors.java @@ -19,6 +19,7 @@ import io.github.jbellis.jvector.disk.IndexWriter; import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.graph.disk.CommonHeader; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.types.VectorTypeSupport; @@ -29,10 +30,27 @@ public class SeparatedVectors extends AbstractSeparatedFeature { private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); private final int dimension; private long offset; + // The largest length of the vectors this feature has written, for the header. + private final MaxVectorNorm written = new MaxVectorNorm(); + // The largest length stored in a loaded index's header; NaN when unknown. + private final float stored; public SeparatedVectors(int dimension, long offset) { + this(dimension, offset, Float.NaN); + } + + private SeparatedVectors(int dimension, long offset, float stored) { this.dimension = dimension; this.offset = offset; + this.stored = stored; + } + + /** + * The largest length of the stored vectors: from the header of a loaded index, or of the vectors written so far. + * NaN when unknown, as for an index written before format version 7. + */ + public float maxVectorNorm() { + return Float.isNaN(stored) ? written.get() : stored; } @Override @@ -52,7 +70,12 @@ public FeatureId id() { @Override public int headerSize() { - return Long.BYTES; + return headerSize(OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public int headerSize(int version) { + return Long.BYTES + MaxVectorNorm.headerSize(version); } @Override @@ -60,15 +83,23 @@ public int featureSize() { return dimension * Float.BYTES; } + @Deprecated @Override public void writeHeader(IndexWriter out) throws IOException { + writeHeader(out, OnDiskGraphIndex.CURRENT_VERSION); + } + + @Override + public void writeHeader(IndexWriter out, int version) throws IOException { out.writeLong(offset); + written.write(out, version); } @Override public void writeSeparately(IndexWriter out, State state_) throws IOException { var state = (InlineVectors.State) state_; if (state.vector != null) { + written.record(state.vector); vectorTypeSupport.writeFloatVector(out, state.vector); } else { // Write zeros for missing vector @@ -83,7 +114,7 @@ public void writeSeparately(IndexWriter out, State state_) throws IOException { static SeparatedVectors load(CommonHeader header, RandomAccessReader reader) { try { long offset = reader.readLong(); - return new SeparatedVectors(header.dimension, offset); + return new SeparatedVectors(header.dimension, offset, MaxVectorNorm.read(reader, header.version)); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java index 8e1f91cf8..e8ff4c516 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/BuildScoreProvider.java @@ -18,6 +18,9 @@ import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.RemappedRandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.ASHSymmetricScorer; import io.github.jbellis.jvector.quantization.BQVectors; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; @@ -153,8 +156,9 @@ public VectorFloat approximateCentroid() { @Override public SearchScoreProvider searchProviderFor(VectorFloat vector) { + // Construction keeps the standard stopping rule for full-precision scores. var vc = vectorsCopy.get(); - return DefaultSearchScoreProvider.exact(vector, similarityFunction, vc); + return new DefaultSearchScoreProvider(DefaultSearchScoreProvider.exactScoreFunction(vector, similarityFunction, vc)); } @Override @@ -270,4 +274,63 @@ public float similarityTo(int node2) { } }; } + + /** + * Returns a BSP that performs approximate score comparisons using the given ASHVectors. + * + *

Construction (node-node) scoring: Uses symmetric ASH–ASH dot-product approximation + * computed from the complete encoded payload + per-vector header fields (scale, offset). + * + *

Query (vector-node) scoring: Delegates to ASHVectors' query-time score function + * (asymmetric float query → ASH vector). + * + *

Constraint: This provider requires {@code C=1} (single landmark) and + * {@link VectorSimilarityFunction#DOT_PRODUCT}. + */ + static BuildScoreProvider ashBuildScoreProvider(VectorSimilarityFunction vsf, ASHVectors ashv) { + if (vsf != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("ASH build scoring supports DOT_PRODUCT only"); + } + + final AsymmetricHashing ash = ashv.getCompressor(); + if (ash.landmarkCount != 1) { + throw new IllegalArgumentException("ASH build scoring requires landmarkCount==1, got " + ash.landmarkCount); + } + + final var symmetric = new ASHSymmetricScorer(ashv); + + return new BuildScoreProvider() { + @Override + public boolean isExact() { + return false; + } + + @Override + public VectorFloat approximateCentroid() { + // In the C=1 case, ASH uses the dataset mean as its single landmark. + // Return a copy so callers can't mutate ASH's internal landmark. + return ash.landmarks[0].copy(); + } + + @Override + public SearchScoreProvider searchProviderFor(VectorFloat vector) { + // Query-time asymmetric scoring is already implemented by ASHVectors. + return new DefaultSearchScoreProvider(ashv.precomputedScoreFunctionFor(vector, vsf), null, VectorSimilarityFunction.DOT_PRODUCT); + } + + @Override + public SearchScoreProvider searchProviderFor(int node1) { + // A stored node's length is not kept; the largest encoded length bounds it. + float m = ash.maxEncodedNorm(); + return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node1), null, + VectorSimilarityFunction.DOT_PRODUCT, m * m); + } + + @Override + public SearchScoreProvider diversityProviderFor(int node1) { + // Keep search/diversity consistent for approximate scoring. + return searchProviderFor(node1); + } + }; + } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java index de46762b2..640e55019 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/DefaultSearchScoreProvider.java @@ -24,6 +24,8 @@ public final class DefaultSearchScoreProvider implements SearchScoreProvider { private final ScoreFunction scoreFunction; private final ScoreFunction.ExactScoreFunction reranker; + private final VectorSimilarityFunction adaptiveSimilarity; + private final float dotProductBound; /** * @param scoreFunction the primary, fast scoring function @@ -42,11 +44,54 @@ public DefaultSearchScoreProvider(ScoreFunction scoreFunction) { *

* Most often it will be convenient to get the reranker either using `RandomAccessVectorValues.rerankerFor` * or `ScoringView.rerankerFor`. + *

+ * Searches with this provider stop by the standard rule, since it cannot tell what its scores measure; use + * {@link #forSimilarity} to let top-K searches stop adaptively where the searcher enables it. */ public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker) { + this(scoreFunction, reranker, null); + } + + /** + * @param adaptiveSimilarity the similarity function {@code scoreFunction} scores by, letting top-K searches stop + * adaptively; null keeps the standard rule + */ + public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, + VectorSimilarityFunction adaptiveSimilarity) { + this(scoreFunction, reranker, adaptiveSimilarity, Float.NaN); + } + + /** + * @param dotProductBound for DOT_PRODUCT scores, an upper bound on the dot product between the query and any + * stored vector, used in place of the score function's own; NaN defers to it + */ + public DefaultSearchScoreProvider(ScoreFunction scoreFunction, ScoreFunction.ExactScoreFunction reranker, + VectorSimilarityFunction adaptiveSimilarity, float dotProductBound) { assert scoreFunction != null; this.scoreFunction = scoreFunction; this.reranker = reranker; + this.adaptiveSimilarity = adaptiveSimilarity; + this.dotProductBound = dotProductBound; + } + + /** + * A provider for scores of {@code similarity} whose top-K searches stop adaptively when the searcher enables the + * experimental adaptive stopping ({@link io.github.jbellis.jvector.graph.GraphSearcher#useAdaptiveTermination}). + * Dot-product searches need the score function's {@link ScoreFunction#dotProductBound} and keep the standard rule + * without one. + */ + public static DefaultSearchScoreProvider forSimilarity(ScoreFunction scoreFunction, + ScoreFunction.ExactScoreFunction reranker, + VectorSimilarityFunction similarity) { + return new DefaultSearchScoreProvider(scoreFunction, reranker, similarity); + } + + @Override + public VectorSimilarityFunction adaptiveSimilarity() { return adaptiveSimilarity; } + + @Override + public float dotProductBound() { + return Float.isNaN(dotProductBound) ? scoreFunction.dotProductBound() : dotProductBound; } public ScoreFunction scoreFunction() { @@ -64,19 +109,25 @@ public ScoreFunction.ExactScoreFunction exactScoreFunction() { } /** - * A SearchScoreProvider for a single-pass search based on exact similarity. + * A SearchScoreProvider for a single-pass search based on exact similarity, which may stop adaptively where the + * searcher enables it (see {@link #forSimilarity}). * Generally only suitable when your RandomAccessVectorValues is entirely in-memory, * e.g. during construction. */ public static DefaultSearchScoreProvider exact(VectorFloat v, VectorSimilarityFunction vsf, RandomAccessVectorValues ravv) { + return forSimilarity(exactScoreFunction(v, vsf, ravv), null, vsf); + } + + /** Exact scores of {@code v} against {@code ravv}, safe for concurrent use. */ + static ScoreFunction.ExactScoreFunction exactScoreFunction(VectorFloat v, VectorSimilarityFunction vsf, + RandomAccessVectorValues ravv) { // don't use ESF.reranker, we need thread safety here - var sf = new ScoreFunction.ExactScoreFunction() { + return new ScoreFunction.ExactScoreFunction() { @Override public float similarityTo(int node2) { return vsf.compare(v, ravv.getVector(node2)); } }; - return new DefaultSearchScoreProvider(sf); } /** @@ -92,6 +143,6 @@ public float similarityTo(int node2) { return vsf.compare(v, ravv.getVector(graphToRavvOrdMap[node2])); } }; - return new DefaultSearchScoreProvider(sf); + return forSimilarity(sf, null, vsf); } } \ No newline at end of file diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java new file mode 100644 index 000000000..7711321ab --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/PreparedQuery.java @@ -0,0 +1,36 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph.similarity; + +import io.github.jbellis.jvector.annotations.Experimental; +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** + * Reusable, model-specific numerical preparation for one query. This state owns + * its query snapshot and scoring buffers, but no graph view or traversal state. + * + *

After preparation it can be read by searches using the same model. The + * owner must not prepare it again until all searches and resumable result + * iterators borrowing it have finished. Publishing it to another thread requires + * the usual executor, queue or other happens-before handoff. No executor or + * request queue is created by this API. + */ +@Experimental +public interface PreparedQuery extends Accountable { + /** Borrowed FP32 query snapshot for reranking; callers must not modify it. */ + VectorFloat queryVector(); +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java new file mode 100644 index 000000000..6f8d84e54 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/QueryPreparer.java @@ -0,0 +1,46 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph.similarity; + +import io.github.jbellis.jvector.annotations.Experimental; +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** + * Prepares independent queries against one immutable model on the calling + * thread. Distinct output states may be prepared concurrently. The caller owns + * scheduling, admission, input lifetime during each call, and output reuse. + */ +@Experimental +public interface QueryPreparer extends Accountable { + /** Allocates one reusable state. Shared model weights are not copied. */ + PreparedQuery newState(); + + /** Whether the current backend has a genuine multi-vector preparation path. */ + boolean supportsBatchPreparation(); + + /** Snapshots and prepares one input, without participating in caller batching. */ + void prepare(VectorFloat query, PreparedQuery preparedState); + + /** + * Snapshots and prepares the first {@code count} inputs into distinct states + * from this model. Zero is a no-op. Counts above eight are supported. + * Structural validation precedes modification; a computation failure leaves + * affected states unusable until successfully prepared again. Unused array + * elements are ignored. Inputs must remain unmodified until this call returns. + */ + void prepareBatch(VectorFloat[] queries, PreparedQuery[] preparedStates, int count); +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java index 9ae58fab1..4b49c4b47 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/ScoreFunction.java @@ -40,6 +40,23 @@ public interface ScoreFunction { */ float similarityTo(int node2); + /** + * Scores {@code count} nodes: {@code scores[i]} is {@code similarityTo(nodes[i])} for {@code 0 <= i < count}. + * Implementations that report {@link #supportsBatchScoring()} score the nodes together, for example in one + * native call, and must give the same scores as {@link #similarityTo(int)}. + */ + default void similarityTo(int[] nodes, int count, float[] scores) { + for (int i = 0; i < count; i++) scores[i] = similarityTo(nodes[i]); + } + + /** + * @return true if {@link #similarityTo(int[], int, float[])} is faster than one {@link #similarityTo(int)} per + * node, so callers should gather the nodes they need before scoring them + */ + default boolean supportsBatchScoring() { + return false; + } + /** * Computes the similarity to the neighborIndex-th neighbor of origin. * Before calling this function, enableSimilarityToNeighbors must be called first with the same origin. @@ -65,6 +82,24 @@ default boolean supportsSimilarityToNeighbors() { return false; } + /** + * Whether a top-K search scored by this function stops adaptively whatever {@link SearchScoreProvider} wraps it, + * when the searcher's experimental adaptive stopping is enabled ({@code GraphSearcher.useAdaptiveTermination}). + */ + default boolean requiresAdaptiveTermination() { + return false; + } + + /** + * For DOT_PRODUCT scores, an upper bound on the dot product this function can score between its query and any + * stored vector, such as the query's length times the largest stored vector length; NaN when unknown. Adaptive + * stopping reads dot-product scores as distances only when it is known, so it must hold for every vector the + * search can reach. Called once per search. + */ + default float dotProductBound() { + return Float.NaN; + } + interface ExactScoreFunction extends ScoreFunction { default boolean isExact() { @@ -77,4 +112,15 @@ default boolean isExact() { return false; } } + + /** + * An approximate score function whose top-K searches stop adaptively, when the searcher's experimental adaptive + * stopping is enabled, whatever provider wraps it. Every ASH score function is one. + */ + interface AdaptiveApproximateScoreFunction extends ApproximateScoreFunction { + @Override + default boolean requiresAdaptiveTermination() { + return true; + } + } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java index 4122f7105..5beacdd22 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/similarity/SearchScoreProvider.java @@ -16,6 +16,8 @@ package io.github.jbellis.jvector.graph.similarity; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; + /** Encapsulates comparing node distances to a specific vector for GraphSearcher. */ public interface SearchScoreProvider { @@ -24,4 +26,19 @@ public interface SearchScoreProvider { ScoreFunction.ExactScoreFunction reranker(); ScoreFunction.ExactScoreFunction exactScoreFunction(); + + /** + * The similarity function whose scores {@link #scoreFunction} returns, when top-K searches with this provider may + * stop adaptively; null keeps the standard rule. Adaptive stopping is experimental and applies only where the + * searcher enables it ({@code GraphSearcher.useAdaptiveTermination}). Score functions that require adaptive + * stopping ({@link ScoreFunction#requiresAdaptiveTermination}) score DOT_PRODUCT and stop adaptively regardless. + */ + default VectorSimilarityFunction adaptiveSimilarity() { return null; } + + /** + * For DOT_PRODUCT scores, an upper bound on the dot product between the query and any vector the search can + * reach; NaN when unknown, which keeps the standard rule. By default the score function's + * {@link ScoreFunction#dotProductBound}. + */ + default float dotProductBound() { return scoreFunction().dotProductBound(); } } \ No newline at end of file diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java new file mode 100644 index 000000000..9a1c058f0 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHBlockScorer.java @@ -0,0 +1,40 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +/** + * Block-oriented scorer for ASH vectors. + * A block scorer computes scores for a contiguous range of vector ordinals + * into a caller-provided output buffer. + * This interface exists to support: + * - Blocked SIMD scoring + * - Query reuse across multiple neighbors + * - Graph-local scoring (e.g., FusedASH) + */ +public interface ASHBlockScorer { + + /** Human-readable implementation and dispatch details for benchmark diagnostics. */ + default String description() { return getClass().getSimpleName(); } + + /** + * Score {@code count} vectors starting at {@code start}. + * Results are written to {@code out[0..count-1]}. + * @param start starting ordinal + * @param count number of vectors to score + * @param out output buffer (must have length ≥ count) + */ + void scoreRange(int start, int count, float[] out); +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCodes.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCodes.java new file mode 100644 index 000000000..0f41e8a7b --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCodes.java @@ -0,0 +1,265 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.disk.ByteReadable; +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.util.RamUsageEstimator; + +import java.io.IOException; +import java.util.Arrays; + +/** + * The in-memory store of an {@link ASHVectors} collection: every stored code, and nothing else. + * + *

Codes sit back to back, at a fixed stride and with no padding, in pages of primitive arrays. There is + * no per-vector object or array, so the heap cost per vector is its code body plus the decoded header + * ({@value #HEADER_RAM_BYTES} bytes in the parallel {@link #scales}, {@link #offsets} and {@link #landmarks} + * arrays) and a negligible share of the page headers.

+ * + *
    + *
  • Sign words, used by 1-bit codes and by the generic sign-plus-extra-bit widths (3, 5..9 bits), are in + * {@code long} pages, {@link #signWords} per vector.
  • + *
  • Byte bodies, which are the canonical projection codes at 2 and 4 bits and the extra bits of the + * generic widths, are in {@code byte} pages, {@link #bodyBytes} per vector.
  • + *
+ * + *

Both kinds of page hold the same power-of-two number of vectors, so vector {@code i} is in page + * {@code i >>> pageShift} at slot {@code i & pageMask}. Pages are kept under G1's smallest humongous + * threshold (half of a 1 MiB region) so the collector never rounds them up to whole regions; the last + * page is trimmed to the vectors it holds.

+ * + *

Concurrency: a store is filled once, by the encoder (tasks own disjoint vectors and are joined + * before the store is published) or by the loader, and is read-only afterward.

+ */ +final class ASHCodes implements Accountable { + /** Target page size in bytes; see the class comment. */ + static final int DEFAULT_PAGE_BYTES = 256 << 10; + + /** Decoded header bytes per vector: a float scale, a float offset and a byte landmark. */ + static final int HEADER_RAM_BYTES = Float.BYTES + Float.BYTES + Byte.BYTES; + + final int size; + final int quantizedDim; + final int bitsPerDimension; + /** Sign words per vector; zero for the 2/4-bit projection codes. */ + final int signWords; + /** Body bytes per vector; zero for 1-bit codes. */ + final int bodyBytes; + final int pageShift; + final int pageMask; + /** Null when {@link #signWords} is zero. */ + final long[][] signPages; + /** Null when {@link #bodyBytes} is zero. */ + final byte[][] bodyPages; + /** Binary16-rounded values decoded to float, as serialized. */ + final float[] scales; + final float[] offsets; + final byte[] landmarks; + private final long ramBytesUsed; + + private ASHCodes(int size, int quantizedDim, int bitsPerDimension, int pageBytes) { + if (size < 0) throw new IllegalArgumentException("size must be nonnegative"); + if (pageBytes <= 0) throw new IllegalArgumentException("pageBytes must be positive"); + this.size = size; + this.quantizedDim = quantizedDim; + this.bitsPerDimension = bitsPerDimension; + this.signWords = AsymmetricHashing.QuantizedVector.signWordsForDims(quantizedDim, bitsPerDimension); + this.bodyBytes = AsymmetricHashing.QuantizedVector.bodyBytesForDims(quantizedDim, bitsPerDimension); + int strideBytes = Math.max(1, Math.max(Math.multiplyExact(signWords, Long.BYTES), bodyBytes)); + // A power of two, so locating a vector is a shift and a mask. + this.pageShift = 31 - Integer.numberOfLeadingZeros(Math.max(1, pageBytes / strideBytes)); + this.pageMask = (1 << pageShift) - 1; + int pages = Math.toIntExact((size + (long) pageMask) >>> pageShift); + this.signPages = signWords == 0 ? null : new long[pages][]; + this.bodyPages = bodyBytes == 0 ? null : new byte[pages][]; + long bytes = RamUsageEstimator.shallowSizeOfInstance(ASHCodes.class); + for (int p = 0; p < pages; p++) { + int vectors = (int) Math.min(pageMask + 1L, size - ((long) p << pageShift)); + if (signPages != null) { + signPages[p] = new long[Math.multiplyExact(vectors, signWords)]; + bytes += RamUsageEstimator.sizeOf(signPages[p]); + } + if (bodyPages != null) { + bodyPages[p] = new byte[Math.multiplyExact(vectors, bodyBytes)]; + bytes += RamUsageEstimator.sizeOf(bodyPages[p]); + } + } + this.scales = new float[size]; + this.offsets = new float[size]; + this.landmarks = new byte[size]; + if (signPages != null) bytes += RamUsageEstimator.shallowSizeOf(signPages); + if (bodyPages != null) bytes += RamUsageEstimator.shallowSizeOf(bodyPages); + bytes += RamUsageEstimator.sizeOf(scales) + RamUsageEstimator.sizeOf(offsets) + + RamUsageEstimator.sizeOf(landmarks); + this.ramBytesUsed = bytes; + } + + /** An all-zero store for {@code size} vectors, to be filled with {@link #set}. */ + static ASHCodes allocate(int size, int quantizedDim, int bitsPerDimension) { + return allocate(size, quantizedDim, bitsPerDimension, DEFAULT_PAGE_BYTES); + } + + /** As {@link #allocate(int, int, int)} with a chosen page size; tests use small pages. */ + static ASHCodes allocate(int size, int quantizedDim, int bitsPerDimension, int pageBytes) { + return new ASHCodes(size, quantizedDim, bitsPerDimension, pageBytes); + } + + /** Copies the given vectors, which must all have this model's shape. Vectors are not retained. */ + static ASHCodes copyOf(AsymmetricHashing.QuantizedVector[] vectors, int quantizedDim, int bitsPerDimension) { + ASHCodes codes = allocate(vectors.length, quantizedDim, bitsPerDimension); + for (int i = 0; i < vectors.length; i++) codes.set(i, vectors[i]); + return codes; + } + + long[] signPage(int node) { return signPages[node >>> pageShift]; } + + int signOffset(int node) { return (node & pageMask) * signWords; } + + byte[] bodyPage(int node) { return bodyPages[node >>> pageShift]; } + + int bodyOffset(int node) { return (node & pageMask) * bodyBytes; } + + /** Stores {@code vector} at {@code node}. For filling only; see the class comment. */ + void set(int node, AsymmetricHashing.QuantizedVector vector) { + java.util.Objects.checkIndex(node, size); + if (vector.binaryVector.length < signWords || vector.extraBits.length < bodyBytes) + throw new IllegalArgumentException("ASH vector " + node + " does not match the model's code shape"); + if (signWords != 0) System.arraycopy(vector.binaryVector, 0, signPage(node), signOffset(node), signWords); + if (bodyBytes != 0) System.arraycopy(vector.extraBits, 0, bodyPage(node), bodyOffset(node), bodyBytes); + scales[node] = vector.scale; + offsets[node] = vector.offset; + landmarks[node] = vector.landmark; + } + + /** Copies the stored vector into {@code dest}, replacing its arrays if their lengths differ. */ + void copyTo(int node, AsymmetricHashing.QuantizedVector dest) { + java.util.Objects.checkIndex(node, size); + if (dest.binaryVector == null || dest.binaryVector.length != signWords) dest.binaryVector = new long[signWords]; + if (dest.extraBits == null || dest.extraBits.length != bodyBytes) dest.extraBits = new byte[bodyBytes]; + if (signWords != 0) System.arraycopy(signPage(node), signOffset(node), dest.binaryVector, 0, signWords); + if (bodyBytes != 0) System.arraycopy(bodyPage(node), bodyOffset(node), dest.extraBits, 0, bodyBytes); + dest.scale = scales[node]; + dest.offset = offsets[node]; + dest.landmark = landmarks[node]; + } + + /** A new, independent copy of the stored vector. */ + AsymmetricHashing.QuantizedVector get(int node) { + var vector = AsymmetricHashing.QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + copyTo(node, vector); + return vector; + } + + /** A store holding vector {@code newToOld[i]} at {@code i}. */ + ASHCodes permute(int[] newToOld) { + if (newToOld.length != size) throw new IllegalArgumentException("Permutation must cover every vector"); + ASHCodes result = new ASHCodes(size, quantizedDim, bitsPerDimension, (pageMask + 1) * strideBytes()); + for (int i = 0; i < size; i++) { + int old = newToOld[i]; + if (signWords != 0) + System.arraycopy(signPage(old), signOffset(old), result.signPage(i), result.signOffset(i), signWords); + if (bodyBytes != 0) + System.arraycopy(bodyPage(old), bodyOffset(old), result.bodyPage(i), result.bodyOffset(i), bodyBytes); + result.scales[i] = scales[old]; + result.offsets[i] = offsets[old]; + result.landmarks[i] = landmarks[old]; + } + return result; + } + + private int strideBytes() { + return Math.max(1, Math.max(signWords * Long.BYTES, bodyBytes)); + } + + /** Writes every vector in the same format as {@link AsymmetricHashing.QuantizedVector#write}. */ + void write(IndexWriter out) throws IOException { + for (int i = 0; i < size; i++) { + AsymmetricHashing.writeFloat16(out, scales[i]); + AsymmetricHashing.writeFloat16(out, offsets[i]); + out.writeByte(landmarks[i]); + if (signWords != 0) { + long[] page = signPage(i); + for (int w = 0, at = signOffset(i); w < signWords; w++) out.writeLong(page[at + w]); + } + if (bodyBytes != 0) out.write(bodyPage(i), bodyOffset(i), bodyBytes); + } + } + + /** Reads {@code size} vectors written by {@link #write} or {@link AsymmetricHashing.QuantizedVector#write}. */ + static ASHCodes load(RandomAccessReader in, int size, int quantizedDim, int bitsPerDimension) throws IOException { + if (!(in instanceof ByteReadable)) throw new IOException("ASH requires ByteReadable reader for landmark"); + ASHCodes codes = allocate(size, quantizedDim, bitsPerDimension); + byte[] body = new byte[codes.bodyBytes]; + for (int i = 0; i < size; i++) { + codes.scales[i] = AsymmetricHashing.readFloat16(in); + codes.offsets[i] = AsymmetricHashing.readFloat16(in); + codes.landmarks[i] = ((ByteReadable) in).readByte(); + if (codes.signWords != 0) { + long[] page = codes.signPage(i); + for (int w = 0, at = codes.signOffset(i); w < codes.signWords; w++) page[at + w] = in.readLong(); + } + if (codes.bodyBytes != 0) { + in.readFully(body); + System.arraycopy(body, 0, codes.bodyPage(i), codes.bodyOffset(i), codes.bodyBytes); + } + } + return codes; + } + + /** Fixed at allocation: pages, header arrays and this object. */ + @Override + public long ramBytesUsed() { + return ramBytesUsed; + } + + /** Same vectors in the same order, comparing headers by their float bits. */ + boolean contentEquals(ASHCodes that) { + if (size != that.size || signWords != that.signWords || bodyBytes != that.bodyBytes) return false; + if (!Arrays.equals(landmarks, that.landmarks)) return false; + for (int i = 0; i < size; i++) { + if (Float.floatToIntBits(scales[i]) != Float.floatToIntBits(that.scales[i]) + || Float.floatToIntBits(offsets[i]) != Float.floatToIntBits(that.offsets[i])) return false; + if (signWords != 0 && !Arrays.equals(signPage(i), signOffset(i), signOffset(i) + signWords, + that.signPage(i), that.signOffset(i), that.signOffset(i) + signWords)) return false; + if (bodyBytes != 0 && !Arrays.equals(bodyPage(i), bodyOffset(i), bodyOffset(i) + bodyBytes, + that.bodyPage(i), that.bodyOffset(i), that.bodyOffset(i) + bodyBytes)) return false; + } + return true; + } + + /** Consistent with {@link #contentEquals}, independent of page size. */ + int contentHash() { + int h = size; + for (int i = 0; i < size; i++) { + h = 31 * h + Float.floatToIntBits(scales[i]); + h = 31 * h + Float.floatToIntBits(offsets[i]); + h = 31 * h + landmarks[i]; + if (signWords != 0) { + long[] page = signPage(i); + for (int w = 0, at = signOffset(i); w < signWords; w++) h = 31 * h + Long.hashCode(page[at + w]); + } + if (bodyBytes != 0) { + byte[] page = bodyPage(i); + for (int b = 0, at = bodyOffset(i); b < bodyBytes; b++) h = 31 * h + page[at + b]; + } + } + return h; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java new file mode 100644 index 000000000..7f60f4a6d --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHCovariancePCA.java @@ -0,0 +1,145 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; +import org.netlib.util.intW; + +import io.github.jbellis.jvector.util.BlasThreads; + +import java.util.Objects; + +/** + * FP64 PCA from a symmetric rank-k product. Avoids the unused tall left-singular + * matrix. An uncertain retained subspace or a failed numerical check returns + * null so the trainer can use its guarded direct SVD instead. No source is mutated. + */ +final class ASHCovariancePCA { + private ASHCovariancePCA() {} + + /** + * Returns the leading {@code latent} PCA directions, largest eigenvalue + * first, as a column-major {@code [dims, latent]} matrix. The input is + * column-major {@code [rows, dims]} and remains unchanged. + * + *

Returns {@code null} when direct SVD is needed: underdetermined input, + * unresolved retained directions or cutoff, nonconvergence, or a failed + * numerical check. Invalid input and native argument/workspace errors are + * reported rather than hidden by the fallback.

+ */ + static double[] projection(double[] x, int rows, int dims, int latent) { + try (var blas = BlasThreads.singleUnlessLimited()) { + return computeProjection(x, rows, dims, latent); + } + } + + private static double[] computeProjection(double[] x, int rows, int dims, int latent) { + Objects.requireNonNull(x, "x"); + if (rows <= 0 || dims <= 0 || latent <= 0 || latent > dims + || (long) rows * dims > Integer.MAX_VALUE || (long) dims * dims > Integer.MAX_VALUE + || x.length != rows * dims) { + throw new IllegalArgumentException("Invalid PCA matrix dimensions"); + } + double energy = 0; + for (double value : x) { + if (!Double.isFinite(value)) throw new IllegalArgumentException("Non-finite PCA input"); + energy += value * value; + } + if (!Double.isFinite(energy)) throw new IllegalArgumentException("PCA input energy overflows FP64"); + // Preserve direct SVD's full null-space basis for underdetermined inputs. + if (rows < dims || energy == 0) return null; + BLAS blas = BLAS.getInstance(); + double[] covariance = new double[dims * dims]; + blas.dsyrk("U", "T", dims, rows, 1.0, x, rows, 0.0, covariance, dims); + double trace = 0; + for (int col = 0; col < dims; col++) { + for (int row = 0; row <= col; row++) { + if (!Double.isFinite(covariance[row + col * dims])) return null; + } + trace += covariance[col + col * dims]; + } + double energyTolerance = 1e-8 * Math.max(1.0, energy); + if (!Double.isFinite(trace) || Math.abs(trace - energy) > energyTolerance) return null; + + // Retain just the small covariance for independent eigenpair checks. + // Workspace queries do not need another input copy. + double[] eigenvectors = covariance.clone(); + double[] values = new double[dims], query = new double[1]; + int[] integerQuery = new int[1]; + intW info = new intW(0); + LAPACK lapack = LAPACK.getInstance(); + lapack.dsyevd("V", "U", dims, eigenvectors, dims, values, query, -1, integerQuery, -1, info); + if (info.val != 0) throw new IllegalStateException("PCA eigen workspace query failed: " + info.val); + if (!Double.isFinite(query[0]) || query[0] < 1 || query[0] > Integer.MAX_VALUE || integerQuery[0] < 1) { + throw new IllegalStateException("Invalid PCA eigen workspace size"); + } + double[] work = new double[(int) Math.ceil(query[0])]; + int[] integerWork = new int[integerQuery[0]]; + info.val = 0; + lapack.dsyevd("V", "U", dims, eigenvectors, dims, values, work, work.length, + integerWork, integerWork.length, info); + if (info.val < 0) throw new IllegalStateException("Invalid native PCA eigen argument: " + info.val); + if (info.val > 0) return null; + double sum = 0; + double previous = Double.NEGATIVE_INFINITY; + for (double value : values) { + if (!Double.isFinite(value)) return null; + if (value < previous) return null; + previous = value; + sum += value; + } + if (Math.abs(sum - energy) > energyTolerance) return null; + double largest = values[dims - 1]; + // Covariance squares conditioning. Reject a retained tail or boundary + // gap at the scale of accumulated FP64 error, including rank deficiency. + double uncertainty = 128.0 * dims * Math.ulp(1.0) * Math.max(1.0, largest); + int cutoff = dims - latent; + if (!(largest > 0) || values[0] < -uncertainty || values[cutoff] <= uncertainty + || (cutoff > 0 && values[cutoff] - values[cutoff - 1] <= uncertainty)) { + return null; + } + + double[] vector = new double[dims], product = new double[dims]; + int[] probes = {dims - 1, dims - 1 - latent / 2, cutoff}; + for (int p = 0; p < probes.length; p++) { + int column = probes[p]; + if ((p > 0 && column == probes[0]) || (p > 1 && column == probes[1])) continue; + System.arraycopy(eigenvectors, column * dims, vector, 0, dims); + blas.dsymv("U", dims, 1.0, covariance, dims, vector, 1, 0.0, product, 1); + double residual = 0; + for (int i = 0; i < dims; i++) { + double difference = product[i] - values[column] * vector[i]; + residual += difference * difference; + } + if (!Double.isFinite(residual) || Math.sqrt(residual) > 1e-8 * Math.max(1.0, largest)) return null; + } + double[] basis = new double[dims * latent]; + for (int col = 0; col < latent; col++) { + int source = (dims - 1 - col) * dims; + double norm = 0; + for (int row = 0; row < dims; row++) { + double value = eigenvectors[source + row]; + if (!Double.isFinite(value)) return null; + basis[col * dims + row] = value; + norm += value * value; + } + if (Math.abs(norm - 1.0) > 1e-8) return null; + } + return basis; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java new file mode 100644 index 000000000..467aba56d --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHFloatOrthogonalizer.java @@ -0,0 +1,126 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; +import org.netlib.util.intW; + +import io.github.jbellis.jvector.util.BlasThreads; + +import java.util.Objects; + +/** + * Reusable FP32 thin-SVD workspace for the polar factor U * VT. + * FP64 caller buffers keep the rest of the trainer independent of this choice. + * + *

One instance belongs to one trainer. It is not thread-safe and retains no + * caller buffers. Input is copied only for the destructive decomposition; the + * workspace query is performed once, using internal scratch.

+ */ +final class ASHFloatOrthogonalizer { + private final int rows; + private final int cols; + private final int rank; + private final int elements; + private final float[] input; + private final float[] u; + private final float[] vt; + private final float[] singularValues; + private final int[] iwork; + private final float[] work; + private final intW info = new intW(0); + private final LAPACK lapack = LAPACK.getInstance(); + private final BLAS blas = BLAS.getInstance(); + + ASHFloatOrthogonalizer(int rows, int cols) { + if (rows <= 0 || cols <= 0 || (long) rows * cols > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid ASH orthogonalization dimensions"); + } + this.rows = rows; + this.cols = cols; + rank = Math.min(rows, cols); + elements = rows * cols; + input = new float[elements]; + u = new float[Math.multiplyExact(rows, rank)]; + vt = new float[Math.multiplyExact(rank, cols)]; + singularValues = new float[rank]; + iwork = new int[Math.multiplyExact(8, rank)]; + float[] query = new float[1]; + lapack.sgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, query, -1, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("FP32 ASH SVD workspace query failed with info=" + info.val); + } + if (!Float.isFinite(query[0]) || query[0] < 1 || (double) query[0] > Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid FP32 ASH SVD workspace size: " + query[0]); + } + // A FP32 query may round a large integer workspace size downward. + // One representable step rounds it conservatively upward. + double required = Math.nextUp(query[0]); + if (required > Integer.MAX_VALUE) { + throw new IllegalStateException("FP32 ASH SVD workspace exceeds the Java array limit"); + } + work = new float[(int) Math.ceil(required)]; + } + + /** + * Writes the polar factor in column-major order. Buffers must have the + * constructor's shape and must be distinct. The source is never modified, + * including on failure; every successful call overwrites the whole output. + */ + void orthogonalize(double[] source, double[] destination) { + try (var blas = BlasThreads.singleUnlessLimited()) { + orthogonalizeInScope(source, destination); + } + } + + private void orthogonalizeInScope(double[] source, double[] destination) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(destination, "destination"); + if (source.length != elements || destination.length != elements || source == destination) { + throw new IllegalArgumentException("Invalid ASH orthogonalization buffers"); + } + for (int i = 0; i < elements; i++) { + float value = (float) source[i]; + if (!Float.isFinite(value)) { + throw new IllegalArgumentException("ASH orthogonalization input must be finite and representable in FP32"); + } + input[i] = value; + } + info.val = 0; + lapack.sgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, work, work.length, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("FP32 ASH SVD failed with info=" + info.val); + } + for (float value : singularValues) { + if (!Float.isFinite(value) || value < 0) { + throw new IllegalStateException("FP32 ASH SVD returned an invalid singular value"); + } + } + // The destructive SVD input is dead and can hold the FP32 polar factor. + blas.sgemm("N", "N", rows, cols, rank, 1.0f, u, rows, + vt, rank, 0.0f, input, rows); + for (int i = 0; i < elements; i++) { + if (!Float.isFinite(input[i])) { + throw new IllegalStateException("FP32 ASH polar factor contains a non-finite value"); + } + destination[i] = input[i]; + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHInt8Query.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHInt8Query.java new file mode 100644 index 000000000..8902d9a8d --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHInt8Query.java @@ -0,0 +1,88 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +/** + * A projected query {@code Aq} rounded to INT8 for scoring offset-binary codes (3 and 5 to 9 bits) in graph + * neighborhoods. With {@code s = 127 / max|Aq|}, the dot product with a code's unsigned fields {@code u} is + * exact integer arithmetic followed by {@code ( - center) * invScale}, where + * {@code center = (2^b - 1)/2 * sum(q)} removes the offset-binary bias. Rounding the query changes scores by + * well under a code step; exhaustive recall@10 on 1M datasets moved by at most 0.0003. + * + *

Instances are query-owned scratch: {@link #prepare} overwrites them for the next query.

+ */ +final class ASHInt8Query { + final int quantizedDim; + final int bits; + final int units; + /** The rounded query, {@code units * 8} entries; zero past {@code quantizedDim}. */ + final byte[] digits; + float invScale; + float center; + + ASHInt8Query(int quantizedDim, int bits) { + if (!AsymmetricHashing.usesOffsetBinaryCode(bits)) { + throw new IllegalArgumentException("INT8 neighborhood queries require offset-binary codes; got " + bits); + } + this.quantizedDim = quantizedDim; + this.bits = bits; + this.units = FusedASHLayout.fieldUnits(quantizedDim); + this.digits = new byte[units * 8]; + } + + /** Quantizes {@code qProj[0, quantizedDim)}. A non-finite component makes every score NaN. */ + void prepare(float[] qProj) { + float max = 0f; + for (int j = 0; j < quantizedDim; j++) max = Math.max(max, Math.abs(qProj[j])); + java.util.Arrays.fill(digits, (byte) 0); + if (!Float.isFinite(max)) { + invScale = Float.NaN; + center = 0f; + return; + } + if (max == 0f) { + invScale = 0f; + center = 0f; + return; + } + float scale = 127f / max; + long sum = 0; + for (int j = 0; j < quantizedDim; j++) { + int q = Math.max(-127, Math.min(127, Math.round(qProj[j] * scale))); + digits[j] = (byte) q; + sum += q; + } + invScale = max / 127f; + center = (float) (((1 << bits) - 1) * 0.5 * sum); + } + + /** + * The dot product of the query with one lane of a field-block neighborhood block, with the native + * kernel's arithmetic: an exact integer sum, then {@code (sum - center) * invScale}. + */ + float laneDot(byte[] block, int blockOffset, int lane, int blockSize) { + int b = bits; + long dot = 0; + for (int unit = 0; unit < units; unit++) { + int base = blockOffset + (unit * blockSize + lane) * b; + for (int k = 0; k < 8; k++) { + dot += digits[unit * 8 + k] * AsymmetricHashing.offsetBinaryField(block, base, k, b); + } + } + return ((float) dot - center) * invScale; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java new file mode 100644 index 000000000..dbc8cd524 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHLutKernel.java @@ -0,0 +1,65 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.vector.ASHLutScoring; +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import java.util.Locale; + +/** Query-lifetime dispatch, shared by standalone and fused nibble-block scoring. */ +final class ASHLutKernel { + private final VectorUtilSupport backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + private final boolean simd; + + ASHLutKernel() { + String mode = System.getProperty("jvector.ash.blockKernel", "auto").trim().toLowerCase(Locale.ROOT); + switch (mode) { + case "scalar": simd = false; break; + case "auto": simd = backend.supportsAshLutScoring(); break; + case "simd": + if (!backend.supportsAshLutScoring()) { + throw new IllegalStateException("ASH SIMD LUT scoring requested, but backend " + + backend.getClass().getName() + " does not support it"); + } + simd = true; + break; + default: throw new IllegalArgumentException("jvector.ash.blockKernel must be auto, scalar or simd: " + mode); + } + } + + ASHLutKernel(boolean simd) { + if (simd && !backend.supportsAshLutScoring()) { + throw new IllegalStateException("ASH SIMD LUT scoring is unavailable"); + } + this.simd = simd; + } + + void score(byte[] codes, int offset, int groups, int stride, int lane, int count, + float[] lut, float[] out, int outOffset) { + if (simd) backend.ashLutScore(codes, offset, groups, stride, lane, count, lut, out, outOffset); + else ASHLutScoring.score(codes, offset, groups, stride, lane, count, lut, out, outOffset); + } + + /** Whether the query requested accelerated execution; scalar selection must also disable native scoring. */ + boolean isSimd() { + return simd; + } + + @Override + public String toString() { return simd ? "SIMD ASH LUT (" + backend.getClass().getSimpleName() + ")" : "scalar ASH LUT"; } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java new file mode 100644 index 000000000..5a60bf06a --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHNeighborhoodScorer.java @@ -0,0 +1,179 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.util.Objects; + +/** + * Prepared query state for scoring serialized {@link FusedASHLayout} neighborhoods. + * + *

Construction computes the query projection, landmark terms, and nibble LUT once. Each + * {@link #score} call then performs lookup, binary16 header decoding, landmark correction, and + * conversion to the graph's DOT_PRODUCT similarity, (1 + dot) / 2, unclamped. The graph decoder and isolated + * benchmark share this implementation.

+ * + *

Publicly constructed prepared state is independently owned and immutable. Such instances may be shared across threads with independent output + * arrays and immutable packed inputs. Scalar/Panama execution supports all model landmarks; + * a capable native provider handles C=1 with one call per entire neighborhood.

+ */ +public final class ASHNeighborhoodScorer { + private final int maxDegree; + private final int blockSize; + private final int blocks; + private final int dimensions; + private final int bits; + private final int groups; + private final int featureSize; + private final float[] lut; + private final float[] dotQMu; + private final VectorUtilSupport neighborhoodBackend; + /** Offset-binary widths: the query as two INT8 digits for field-block neighborhoods; otherwise null. */ + private final ASHInt8Query int8Query; + private final VectorUtilSupport int8Backend; + private final ASHLutKernel kernel = new ASHLutKernel(); + + /** + * Prepares a query for neighborhoods with a fixed serialized shape. + * + * @param ash compressor describing the projection, bit width, and landmarks + * @param query original-dimensional query; callers apply any desired normalization + * @param maxDegree positive scored capacity, including padded graph lanes + * @param blockSize serialized block width: 8, 16, or 32 + * @throws IllegalArgumentException for a dimension mismatch or invalid shape + * @throws ArithmeticException if the serialized shape cannot fit a Java array + */ + public ASHNeighborhoodScorer(AsymmetricHashing ash, VectorFloat query, int maxDegree, int blockSize) { + this(ash, prepareQuery(ash, query, maxDegree, blockSize), maxDegree, blockSize); + } + + private static ASHScorer.QueryPrecompute prepareQuery( + AsymmetricHashing ash, VectorFloat query, int maxDegree, int blockSize) { + Objects.requireNonNull(ash, "ash"); + Objects.requireNonNull(query, "query"); + if (query.length() != ash.originalDimension) { + throw new IllegalArgumentException("ASH query dimension must be " + ash.originalDimension); + } + FusedASHLayout.featureSize(maxDegree, ash.quantizedDim, ash.bitsPerDimension, blockSize); + return new ASHScorer(ash).precomputeQuery(query); + } + + /** Reuses preparation already shared with upper-layer scoring in {@link FusedASHDecoder}. */ + ASHNeighborhoodScorer(AsymmetricHashing ash, ASHScorer.QueryPrecompute query, int maxDegree, int blockSize) { + this(ash, query, maxDegree, blockSize, null); + } + + /** Borrows a view-owned LUT; its owner prevents use after the next query. */ + ASHNeighborhoodScorer(AsymmetricHashing ash, ASHScorer.QueryPrecompute query, + int maxDegree, int blockSize, float[] reusableLut) { + this(ash, query, maxDegree, blockSize, reusableLut, false); + } + + /** An externally prepared LUT is immutable until all borrowing searches finish. */ + ASHNeighborhoodScorer(AsymmetricHashing ash, ASHScorer.QueryPrecompute query, + int maxDegree, int blockSize, float[] reusableLut, boolean alreadyPrepared) { + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + this.maxDegree = maxDegree; + this.blockSize = blockSize; + this.dimensions = ash.quantizedDim; + this.bits = ash.bitsPerDimension; + this.groups = FusedASHLayout.codeGroups(dimensions, bits); + this.blocks = FusedASHLayout.blocksPerNode(maxDegree, blockSize); + this.featureSize = FusedASHLayout.featureSize(maxDegree, dimensions, bits, blockSize); + this.dotQMu = query.dotQMuByLandmark; + int lutSize = Math.multiplyExact(groups, 16); + if (reusableLut != null && reusableLut.length != lutSize) { + throw new IllegalArgumentException("LUT size does not match the ASH model"); + } + this.lut = reusableLut == null ? new float[lutSize] : reusableLut; + if (!alreadyPrepared) FusedASHLayout.buildQueryLut(query.qProj, dimensions, bits, lut); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (FusedASHLayout.usesFieldBlocks(bits)) { + // The LUT above still serves canonical scoring of upper-layer nodes. + this.int8Query = new ASHInt8Query(dimensions, bits); + this.int8Query.prepare(query.qProj); + this.int8Backend = kernel.isSimd() && dotQMu.length == 1 && backend.supportsAshInt8NeighborhoodScoring() + ? backend : null; + this.neighborhoodBackend = null; + } else { + this.int8Query = null; + this.int8Backend = null; + this.neighborhoodBackend = + kernel.isSimd() && dotQMu.length == 1 && backend.supportsAshNeighborhoodScoring() ? backend : null; + } + } + + /** + * Scores the configured capacity, including padded neighbors, in serialized lane order. + * + *

On failure, output contents are unspecified. The graph decoder invalidates its cached + * origin before this call, so partially written scores cannot be reused.

+ * + * @param packed complete neighborhood bytes in the constructor's layout; not modified + * @param out receives one similarity per configured lane; later elements remain unchanged + * @throws IndexOutOfBoundsException if either array is too small + * @throws IllegalStateException if a scored lane refers to an invalid landmark + */ + public void score(byte[] packed, float[] out) { + Objects.checkFromIndexSize(0, featureSize, packed.length); + Objects.checkFromIndexSize(0, maxDegree, out.length); + if (neighborhoodBackend != null) { + neighborhoodBackend.ashScoreNeighborhood(packed, groups, blockSize, maxDegree, lut, dotQMu[0], out); + return; + } + if (int8Backend != null) { + int8Backend.ashScoreNeighborhoodInt8(packed, bits, int8Query.units, blockSize, maxDegree, int8Query.digits, + int8Query.invScale, int8Query.center, dotQMu[0], out); + return; + } + for (int block = 0; block < blocks; block++) { + int offset = FusedASHLayout.blockOffset(block, dimensions, bits, blockSize); + int base = block * blockSize; + int count = Math.min(blockSize, maxDegree - base); + if (int8Query != null) { + for (int lane = 0; lane < count; lane++) out[base + lane] = int8Query.laneDot(packed, offset, lane, blockSize); + } else { + kernel.score(packed, offset, groups, blockSize, 0, count, lut, out, base); + } + for (int lane = 0; lane < count; lane++) { + int landmark = FusedASHLayout.readLandmark(packed, offset, lane, dimensions, bits, blockSize); + if (landmark >= dotQMu.length) { + throw new IllegalStateException("Invalid FusedASH landmark: " + landmark); + } + out[base + lane] = ASHScorer.toSimilarity( + FusedASHLayout.readScale(packed, offset, lane, dimensions, bits, blockSize) * out[base + lane] + + dotQMu[landmark] + + FusedASHLayout.readOffset(packed, offset, lane, dimensions, bits, blockSize)); + } + } + } + + @Override + public String toString() { + Object implementation = neighborhoodBackend != null ? "native Highway whole-neighborhood FFM" + : int8Backend != null ? "native Highway whole-neighborhood FFM, INT8 field blocks" + : int8Query != null ? "Java INT8 field blocks" : kernel; + return "ASHNeighborhoodScorer[" + implementation + ", blockSize=" + blockSize + ", capacity=" + maxDegree + "]"; + } + + /** Query-lifetime table shared with canonical direct-node scoring. */ + float[] queryLut() { return lut; } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java new file mode 100644 index 000000000..1fd33a490 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHOrthogonalizer.java @@ -0,0 +1,112 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; +import org.netlib.util.intW; + +import io.github.jbellis.jvector.util.BlasThreads; + +import java.util.Objects; + +/** + * Reusable FP64 thin-SVD workspace for the polar factor U * VT. + * + *

One instance belongs to one trainer. It is not thread-safe and retains no + * caller buffers. Input is copied only for the destructive decomposition; the + * workspace query is performed once, using internal scratch.

+ */ +final class ASHOrthogonalizer { + private final int rows; + private final int cols; + private final int rank; + private final int elements; + private final double[] input; + private final double[] u; + private final double[] vt; + private final double[] singularValues; + private final int[] iwork; + private final double[] work; + private final intW info = new intW(0); + private final LAPACK lapack = LAPACK.getInstance(); + private final BLAS blas = BLAS.getInstance(); + + ASHOrthogonalizer(int rows, int cols) { + if (rows <= 0 || cols <= 0 || (long) rows * cols > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid ASH orthogonalization dimensions"); + } + this.rows = rows; + this.cols = cols; + rank = Math.min(rows, cols); + elements = rows * cols; + input = new double[elements]; + u = new double[Math.multiplyExact(rows, rank)]; + vt = new double[Math.multiplyExact(rank, cols)]; + singularValues = new double[rank]; + iwork = new int[Math.multiplyExact(8, rank)]; + double[] query = new double[1]; + lapack.dgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, query, -1, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("ASH SVD workspace query failed with info=" + info.val); + } + if (!Double.isFinite(query[0]) || query[0] < 1 || query[0] > Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid ASH SVD workspace size: " + query[0]); + } + work = new double[(int) Math.ceil(query[0])]; + } + + /** + * Writes the polar factor in column-major order. Buffers must have the + * constructor's shape and must be distinct. The source is never modified, + * including on failure; every successful call overwrites the whole output. + */ + void orthogonalize(double[] source, double[] destination) { + try (var blas = BlasThreads.singleUnlessLimited()) { + orthogonalizeInScope(source, destination); + } + } + + private void orthogonalizeInScope(double[] source, double[] destination) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(destination, "destination"); + if (source.length != elements || destination.length != elements || source == destination) { + throw new IllegalArgumentException("Invalid ASH orthogonalization buffers"); + } + for (int i = 0; i < elements; i++) { + double value = source[i]; + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("ASH orthogonalization input must be finite"); + } + input[i] = value; + } + info.val = 0; + lapack.dgesdd("S", rows, cols, input, rows, singularValues, u, rows, + vt, rank, work, work.length, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("ASH SVD failed with info=" + info.val); + } + for (double value : singularValues) { + if (!Double.isFinite(value) || value < 0) { + throw new IllegalStateException("ASH SVD returned an invalid singular value"); + } + } + blas.dgemm("N", "N", rows, cols, rank, 1.0, u, rows, + vt, rank, 0.0, destination, rows); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java new file mode 100644 index 000000000..f79bcec00 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHQueryPreparer.java @@ -0,0 +1,199 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.annotations.Experimental; +import io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.graph.similarity.QueryPreparer; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.FloatArray; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.Arrays; +import java.util.Objects; + +/** + * Caller-scheduled ASH preparation. Uses the same query precision and native + * matrix as synchronous scoring, and calls an already formed batch directly. + * Encoding and training representations are unaffected. + */ +@Experimental +public final class ASHQueryPreparer implements QueryPreparer { + private final AsymmetricHashing ash; + private final ASHScorer scorer; + private static final class Rows { + float[][] input = new float[8][]; + float[][] output = new float[8][]; + void ensure(int count) { + if (count > input.length) { + input = new float[count][]; + output = new float[count][]; + } + } + } + // Values contain only cleared row-reference arrays, never a model or query. + private final ThreadLocal rows = ThreadLocal.withInitial(Rows::new); + + public ASHQueryPreparer(AsymmetricHashing ash) { + this.ash = Objects.requireNonNull(ash, "ash"); + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + this.scorer = new ASHScorer(ash); + } + + @Override + public PreparedQuery newState() { return new State(ash, scorer.newQueryPrecompute()); } + + @Override + public boolean supportsBatchPreparation() { + return ash.stiefelTransform.supportsQueryBatchProjection(); + } + + @Override + public void prepare(VectorFloat query, PreparedQuery preparedState) { + State state = state(ash, preparedState, false); + validate(query, state); + state.invalidate(); + state.copy(query); + ash.stiefelTransform.projectQueryDirect(state.input, state.prepared.qProj); + finish(state); + } + + @Override + public void prepareBatch(VectorFloat[] queries, PreparedQuery[] preparedStates, int count) { + Objects.requireNonNull(queries, "queries"); + Objects.requireNonNull(preparedStates, "preparedStates"); + Objects.checkFromIndexSize(0, count, queries.length); + Objects.checkFromIndexSize(0, count, preparedStates.length); + if (count == 0) return; + if (count == 1) { + // Preserve each provider's direct kernel and avoid batch staging. + // prepare() validates before invalidating or overwriting the state. + prepare(queries[0], preparedStates[0]); + return; + } + validateBatch(queries, preparedStates, count); + Rows buffers = rows.get(); + buffers.ensure(count); + // Invalidate all outputs before any computation can fail. + for (int i = 0; i < count; i++) ((State) preparedStates[i]).invalidate(); + try { + for (int i = 0; i < count; i++) { + State state = (State) preparedStates[i]; + state.copy(queries[i]); + buffers.input[i] = state.input; + buffers.output[i] = state.prepared.qProj; + } + ash.stiefelTransform.projectQueryBatch(buffers.input, buffers.output, count); + for (int i = 0; i < count; i++) finish((State) preparedStates[i]); + } catch (RuntimeException | Error failure) { + for (int i = 0; i < count; i++) ((State) preparedStates[i]).ready = false; + throw failure; + } finally { + Arrays.fill(buffers.input, 0, count, null); + Arrays.fill(buffers.output, 0, count, null); + } + } + + private void validateBatch(VectorFloat[] queries, PreparedQuery[] preparedStates, int count) { + for (int i = 0; i < count; i++) { + State state = state(ash, preparedStates[i], false); + validate(queries[i], state); + for (int j = 0; j < i; j++) + if (preparedStates[j] == state) + throw new IllegalArgumentException("Batch output states must be distinct"); + } + for (int i = 0; i < count; i++) { + // Resolve an array-backed input once, rather than once per output. + // Other implementations are copied through VectorFloat's accessor. + if (!(queries[i] instanceof FloatArray)) continue; + float[] input = ((FloatArray) queries[i]).array(); + for (int j = 0; j < count; j++) { + if (i != j && input == ((State) preparedStates[j]).input) + throw new IllegalArgumentException("Batch input aliases another output state"); + } + } + } + + private void validate(VectorFloat query, State state) { + scorer.validateQuery(query); + if (state.ash != ash) throw new IllegalArgumentException("Prepared query belongs to another ASH model"); + } + + private void finish(State state) { + scorer.finishQuery(state.query, state.prepared); + FusedASHLayout.buildQueryLut(state.prepared.qProj, ash.quantizedDim, ash.bitsPerDimension, state.lut); + state.ready = true; + } + + static State state(AsymmetricHashing ash, PreparedQuery query, boolean requireReady) { + Objects.requireNonNull(query, "preparedQuery"); + if (!(query instanceof State) || ((State) query).ash != ash) + throw new IllegalArgumentException("Prepared query belongs to another ASH model"); + State state = (State) query; + if (requireReady) state.check(state.generation); + return state; + } + + @Override + public long ramBytesUsed() { + return RamUsageEstimator.shallowSizeOf(this) + RamUsageEstimator.shallowSizeOf(scorer) + + RamUsageEstimator.shallowSizeOf(rows); + } + + static final class State implements PreparedQuery { + final AsymmetricHashing ash; + final float[] input; + final VectorFloat query; + final ASHScorer.QueryPrecompute prepared; + final float[] lut; + final long bytes; + long generation; + boolean ready; + + State(AsymmetricHashing ash, ASHScorer.QueryPrecompute prepared) { + this.ash = ash; + this.input = new float[ash.originalDimension]; + this.query = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(input); + this.prepared = prepared; + this.lut = new float[Math.multiplyExact(FusedASHLayout.codeGroups(ash.quantizedDim, ash.bitsPerDimension), 16)]; + // Initialize a provider's lazy view once, outside preparation/search. + Object backing = query.get(); + long sum = RamUsageEstimator.shallowSizeOf(this) + RamUsageEstimator.sizeOf(input) + + RamUsageEstimator.shallowSizeOf(query) + RamUsageEstimator.shallowSizeOf(prepared) + + RamUsageEstimator.sizeOf(prepared.qProj) + RamUsageEstimator.sizeOf(lut) + + RamUsageEstimator.sizeOf(prepared.dotQMuByLandmark); + if (backing != input) sum += RamUsageEstimator.shallowSizeOf(backing); + this.bytes = sum; + } + + void invalidate() { ready = false; generation++; } + void copy(VectorFloat source) { + if (source instanceof FloatArray) { + System.arraycopy(((FloatArray) source).array(), 0, input, 0, input.length); + } else { + for (int i = 0; i < input.length; i++) input[i] = source.get(i); + } + } + void check(long expectedGeneration) { + if (!ready || generation != expectedGeneration) + throw new IllegalStateException("Prepared query is unprepared or has been reused"); + } + @Override + public VectorFloat queryVector() { check(generation); return query; } + @Override + public long ramBytesUsed() { return bytes; } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java new file mode 100644 index 000000000..a602e4310 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHScorer.java @@ -0,0 +1,378 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.vector.VectorizationProvider; + +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** + * Query-time raw dot-product scorer for ASH vectors, with scalar and SIMD paths. + * Graph-facing scorers convert its estimates using {@link #toSimilarity(float)}. + * + *

+ * The 1-bit path preserves the original ASH decomposition: + *

+ * + *
+ *   score = scale * (2 * <A(q - mu), b> - <A(q - mu), 1>)
+ *         + <q, mu>
+ *         + offset
+ * 
+ * + *

+ * Fast-scan projection codes, currently bitsPerDimension in {2,4}, use the + * C++ projection-mode decomposition. During encoding, {@code offset} already + * includes {@code -scale * <A(mu), code>}, so scoring uses only {@code A(q)}: + *

+ * + *
+ *   score = scale * <A(q), code> + <q, mu> + offset
+ * 
+ * + *

+ * Other multibit widths keep the generic sign+extra-bit reference scorer used + * for standalone ASH and future ASH reranking experiments. + *

+ * + * NOTE: Only DOT_PRODUCT is supported for now. + */ +public final class ASHScorer { + /** + * Converts a raw ASH dot-product estimate to the graph's DOT_PRODUCT similarity scale, (1 + dot) / 2, the same + * scale exact DOT_PRODUCT scoring uses. The estimate is not clamped: like an exact dot product of vectors that are + * not unit length, it can fall below -1 or above 1, and ranking needs those values. Graph search starts hierarchy + * descent from negative infinity, so negative similarities do not lose the entry point. Kernels and this class's + * ASHScoreFunction retain raw dot products; node and block scorer adapters must apply this conversion exactly once. + */ + public static float toSimilarity(float dotProduct) { + return (1f + dotProduct) / 2f; + } + + private final AsymmetricHashing ash; + + public ASHScorer(AsymmetricHashing ash) { + this.ash = ash; + + final int d = ash.quantizedDim; + final int C = ash.landmarkCount; + + assert ash.landmarkProj != null; + assert ash.landmarkProjSum != null; + assert ash.landmarkProj.length == C : + "landmarkProj length mismatch"; + assert ash.landmarkProjSum.length == C : + "landmarkProjSum length mismatch"; + + for (int c = 0; c < C; c++) { + assert ash.landmarkProj[c].length == d : + "landmarkProj[" + c + "] length != quantizedDim"; + } + + assert ash.stiefelTransform.rows == ash.quantizedDim + : "Stiefel rows must equal quantizedDim"; + } + + public ASHScoreFunction scoreFunctionFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException( + "ASH scorer supports DOT_PRODUCT only (requested " + similarityFunction + ")" + ); + } + return scoreFunctionFor(precomputeQuery(query)); + } + + /** + * Scorer for dot product approximation. + */ + ASHScoreFunction scoreFunctionFor(QueryPrecompute qp) { + return forVectors(codeScorerFor(qp)); + } + + /** As {@link #scoreFunctionFor(QueryPrecompute)}, scoring codes in place. */ + CodeScorer codeScorerFor(QueryPrecompute qp) { + if (ash.bitsPerDimension == 1) { + return oneBitDotProductScorer(qp); + } + + if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { + return fastScanProjectionDotProductScorer(qp); + } + var mode = ASHVectors.AshSingleKernel.fromProperty(); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (mode == ASHVectors.AshSingleKernel.SIMD && !backend.supportsAshCanonicalLutScoring()) { + throw new IllegalStateException("ASH single-vector SIMD requested, but backend " + + backend.getClass().getName() + " does not support canonical LUT scoring"); + } + return genericMultibitDotProductScorer(qp, + mode != ASHVectors.AshSingleKernel.SCALAR && backend.supportsAshCanonicalLutScoring()); + } + + /** Reuses a fused scorer's table for direct nodes when the backend supports it. */ + ASHScoreFunction scoreFunctionFor(QueryPrecompute qp, float[] lut) { + var mode = ASHVectors.AshSingleKernel.fromProperty(); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + // Two coordinates share each 2-bit LUT entry. Four-bit codes instead + // use the backend's contiguous projection dot, avoiding a 16x larger table. + if ((ash.bitsPerDimension == 2 || AsymmetricHashing.usesOffsetBinaryCode(ash.bitsPerDimension)) + && mode != ASHVectors.AshSingleKernel.SCALAR + && backend.supportsAshCanonicalLutScoring()) { + int groups = FusedASHLayout.codeGroups(qp.d, ash.bitsPerDimension); + return v -> v.scale * backend.ashCanonicalLutDot(v.extraBits, groups, lut) + + qp.dotQMuByLandmark[v.landmark & 255] + v.offset; + } + return scoreFunctionFor(qp); + } + + /** Prepared encoded-query state; uses the same arithmetic as the raw-query scorer. */ + ASHScoreFunction scoreFunctionFor(QueryPrecompute qp, boolean simd) { + return forVectors(codeScorerFor(qp, simd)); + } + + /** As {@link #scoreFunctionFor(QueryPrecompute, boolean)}, scoring codes in place. */ + CodeScorer codeScorerFor(QueryPrecompute qp, boolean simd) { + if (ash.bitsPerDimension == 1) return oneBitDotProductScorer(qp); + if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { + return fastScanProjectionDotProductScorer(qp, simd); + } + return genericMultibitDotProductScorer(qp, simd); + } + + /** Adapts a code scorer to standalone vectors, whose arrays start at offset zero. */ + static ASHScoreFunction forVectors(CodeScorer scorer) { + return v -> scorer.dot(v.binaryVector, 0, v.extraBits, 0, v.scale, v.offset, v.landmark & 0xFF); + } + + private CodeScorer oneBitDotProductScorer(QueryPrecompute qp) { + final int d = qp.d; + final int C = qp.C; + final int words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + return (bits, bitsOffset, body, bodyOffset, scale, offset, c) -> { + assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; + + // maskedAdd = , where b ∈ {0,1}^d is stored as packed longs. + float maskedAdd = 0f; + + int dimBase = 0; + for (int w = 0; w < words && dimBase < d; w++, dimBase += 64) { + long word = bits[bitsOffset + w]; + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = dimBase + bit; + if (idx < d) { + maskedAdd += qp.qProj[idx]; + } + word &= (word - 1); + } + } + + // 1-bit ASH dot-product approximation, with folded into the offset: + // scale * + + offset, where = 2 - + return scale * (2f * maskedAdd - qp.sumQProj) + + qp.dotQMuByLandmark[c] + + offset; + }; + } + + /** Independent scalar oracle for the reference block-scoring API. */ + ASHScoreFunction scalarScoreFunctionFor(VectorFloat query) { + return forVectors(scalarCodeScorerFor(query)); + } + + /** As {@link #scalarScoreFunctionFor}, scoring codes in place. */ + CodeScorer scalarCodeScorerFor(VectorFloat query) { + QueryPrecompute qp = precomputeQuery(query); + if (ash.bitsPerDimension == 1) return oneBitDotProductScorer(qp); + if (AsymmetricHashing.usesFastScanProjectionCode(ash.bitsPerDimension)) { + return fastScanProjectionDotProductScorer(qp, false); + } + return genericMultibitDotProductScorer(qp, false); + } + + private CodeScorer fastScanProjectionDotProductScorer(QueryPrecompute qp) { + final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + final var mode = ASHVectors.AshSingleKernel.fromProperty(); + if (mode == ASHVectors.AshSingleKernel.SIMD && !backend.supportsAshProjectionScoring()) { + throw new IllegalStateException("ASH single-vector SIMD requested, but backend " + + backend.getClass().getName() + " does not support projection scoring"); + } + final boolean simd = mode != ASHVectors.AshSingleKernel.SCALAR && backend.supportsAshProjectionScoring(); + return fastScanProjectionDotProductScorer(qp, simd); + } + + private CodeScorer fastScanProjectionDotProductScorer(QueryPrecompute qp, boolean simd) { + final int d = qp.d; + final int C = qp.C; + final int bitsPerDimension = ash.bitsPerDimension; + final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + + if (simd && "native".equalsIgnoreCase( + System.getProperty("jvector.vectorization_provider", "").trim())) { + backend.requireNativeAshSingleScoring(bitsPerDimension); + } + // The native two-bit kernel consumes a nibble LUT, prepared once per scorer. + // Keep it query-owned: prepared scorers may outlive this thread or run concurrently. + if (simd && bitsPerDimension == 2 && backend.supportsNativeAshCanonicalLutScoring()) { + final int groups = FusedASHLayout.codeGroups(d, bitsPerDimension); + final float[] lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(qp.qProj, d, bitsPerDimension, lut); + return (bits, bitsOffset, code, codeOffset, scale, offset, c) -> + scale * backend.ashCanonicalLutDot(code, codeOffset, groups, lut) + + qp.dotQMuByLandmark[c] + offset; + } + + // Keep tuning dispatch out of the per-vector default kernel. Its compact + // compilation shape matters for end-to-end scoring throughput on HotSpot. + if (simd && backend.usesAshProjectionTuning()) { + return (bits, bitsOffset, code, codeOffset, scale, offset, c) -> + scale * backend.ashProjectionDotTuned(qp.qProj, code, codeOffset, d, bitsPerDimension) + + qp.dotQMuByLandmark[c] + offset; + } + + return (bits, bitsOffset, code, codeOffset, scale, offset, c) -> { + assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; + + // C++ projection-mode scoring: + // score = scale * + + stored_offset + // stored_offset already includes -scale * . + float ip = simd ? backend.ashProjectionDot(qp.qProj, code, codeOffset, d, bitsPerDimension) + : AsymmetricHashing.dotProjectionCode(qp.qProj, code, codeOffset, d, bitsPerDimension); + + return scale * ip + qp.dotQMuByLandmark[c] + offset; + }; + } + + /** + * Offset-binary codes (3 and 5 to 9 bits): {@code scale * + + offset}. The SIMD + * path sums the code's 4-bit groups through a query LUT prepared once per scorer; the scalar path + * decodes each field and is the independent reference. + */ + private CodeScorer genericMultibitDotProductScorer(QueryPrecompute qp, boolean simd) { + final int d = qp.d; + final int C = qp.C; + final int bitsPerDimension = ash.bitsPerDimension; + if (simd) { + final var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + final int groups = FusedASHLayout.codeGroups(d, bitsPerDimension); + // Query-owned: prepared scorers may outlive this thread or run concurrently. + final float[] lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(qp.qProj, d, bitsPerDimension, lut); + return (signBits, signOffset, code, codeOffset, scale, offset, c) -> + scale * backend.ashCanonicalLutDot(code, codeOffset, groups, lut) + qp.dotQMuByLandmark[c] + offset; + } + return (signBits, signOffset, code, codeOffset, scale, offset, c) -> { + assert c < C : "Invalid landmark id " + c + " for landmarkCount=" + C; + return scale * AsymmetricHashing.dotOffsetBinaryCode(qp.qProj, code, codeOffset, d, bitsPerDimension) + + qp.dotQMuByLandmark[c] + offset; + }; + } + + /** Allocates the preparation terms of one query: {@code Aq} and the landmark dots. */ + QueryPrecompute newQueryPrecompute() { + return new QueryPrecompute(ash.quantizedDim, ash.landmarkCount, new float[ash.quantizedDim], + new float[ash.landmarkCount]); + } + + /** Independently owned preparation for retained standalone scorers. */ + QueryPrecompute precomputeQuery(VectorFloat query) { + validateQuery(query); + QueryPrecompute prepared = newQueryPrecompute(); + prepareQuery(query, new float[ash.originalDimension], prepared); + return prepared; + } + + void validateQuery(VectorFloat query) { + java.util.Objects.requireNonNull(query, "query"); + if (query.length() != ash.originalDimension) { + throw new IllegalArgumentException("ASH query dimension must be " + ash.originalDimension); + } + } + + /** Refills model-owned scratch; callers must enforce its query lifetime. */ + void prepareQuery(VectorFloat query, float[] input, QueryPrecompute prepared) { + validateQuery(query); + // Scratch is constructed with this model and never resized on the hot path. + for (int k = 0; k < input.length; k++) input[k] = query.get(k); + ash.stiefelTransform.projectQuery(input, prepared.qProj); + finishQuery(query, prepared); + } + + /** Completes the non-projection terms after direct or batched query projection. */ + void finishQuery(VectorFloat query, QueryPrecompute prepared) { + for (int c = 0; c < ash.landmarkCount; c++) { + prepared.dotQMuByLandmark[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); + } + // Stored offsets include the landmark term, so every scorer needs only Aq and, for 1-bit + // codes, its sum. + prepared.sumQProj = sum(prepared.qProj); + prepared.queryNorm = (float) Math.sqrt(VectorUtil.dotProduct(query, query)); + } + + /** + * An upper bound on the dot products ASH estimates for {@code prepared}'s query: its length times the largest + * encoded vector length. A query prepared from a stored code, whose length is not kept, is bounded by the + * largest length. Estimates above it raise adaptive stopping's bound, which only loosens the margin. + */ + float dotProductBound(QueryPrecompute prepared) { + float m = ash.maxEncodedNorm(); + return (Float.isNaN(prepared.queryNorm) ? m : prepared.queryNorm) * m; + } + + /** Left-to-right FP32 sum, the order every 1-bit scorer uses for {@code }. */ + static float sum(float[] values) { + float total = 0f; + for (float value : values) total += value; + return total; + } + + static final class QueryPrecompute { + final int d; + final int C; + final float[] qProj; // [d], Aq + final float[] dotQMuByLandmark; // [C] + float sumQProj; // , set with qProj + float queryNorm = Float.NaN; // ||q||, when prepared from a raw query + + QueryPrecompute(int d, int C, float[] qProj, float[] dotQMuByLandmark) { + this.d = d; + this.C = C; + this.qProj = qProj; + this.dotQMuByLandmark = dotQMuByLandmark; + } + } + + @FunctionalInterface + public interface ASHScoreFunction { + float similarityTo(AsymmetricHashing.QuantizedVector vector2); + } + + /** + * Raw dot product of a prepared query with one code read in place: sign words from {@code bits} + * at {@code bitsOffset} and the byte body from {@code body} at {@code bodyOffset} (either is + * unused when the bit width stores none), with the code's decoded header and unsigned landmark. + * Lets {@link ASHVectors} score its compact pages and standalone vectors with one formula. + */ + @FunctionalInterface + interface CodeScorer { + float dot(long[] bits, int bitsOffset, byte[] body, int bodyOffset, float scale, float offset, int landmark); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java new file mode 100644 index 000000000..220f55916 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHSymmetricScorer.java @@ -0,0 +1,432 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorUtil; + +/** + * Symmetric ASH scoring from already encoded operands. Graph construction remains C=1. + * C=1 uses Appendix B directly. Multiple landmarks use the same header-calibrated + * decoded dot product, including cross-landmark terms, without projecting raw vectors. + * + *

Stored operands are read in place from the {@link ASHCodes} pages of their + * {@link ASHVectors}; standalone {@link AsymmetricHashing.QuantizedVector} operands are read + * from their own arrays. Both go through the same kernels, so scores do not depend on where + * an operand is stored.

+ */ +public final class ASHSymmetricScorer { + public enum Kernel { AUTO, SCALAR, SIMD } + private static final java.lang.invoke.VarHandle SYMMETRIC_WORD = java.lang.invoke.MethodHandles.byteArrayViewVarHandle(long[].class, java.nio.ByteOrder.LITTLE_ENDIAN); + private static final short[] DOT_4 = nibbleProducts(4); + private final ASHVectors vectors; + private final ASHCodes codes; + private final AsymmetricHashing ash; + private final int dimensions; + private final int bits; + private final int landmarks; + private final float[][] landmarkDots; + private final float[] centeredOffsets; + private final boolean simd; + private final long retainedBytes; + private final io.github.jbellis.jvector.vector.VectorUtilSupport backend = + io.github.jbellis.jvector.vector.VectorizationProvider.getInstance().getVectorUtilSupport(); + + public ASHSymmetricScorer(ASHVectors vectors) { + this(vectors, Kernel.valueOf(System.getProperty("jvector.ash.singleKernel", "auto") + .trim().toUpperCase(java.util.Locale.ROOT))); + } + + public ASHSymmetricScorer(ASHVectors vectors, Kernel kernel) { + this.vectors = vectors; + codes = vectors.codes; + ash = vectors.getCompressor(); + dimensions = ash.quantizedDim; + bits = ash.bitsPerDimension; + landmarks = ash.landmarkCount; + if (landmarks < 1 || landmarks > 256) throw new IllegalArgumentException("ASH requires 1..256 landmarks"); + simd = selectSimd(kernel); + landmarkDots = new float[landmarks][landmarks]; + for (int a = 0; a < landmarks; a++) { + for (int b = 0; b <= a; b++) { + landmarkDots[a][b] = landmarkDots[b][a] = VectorUtil.dotProduct(ash.landmarks[a], ash.landmarks[b]); + } + } + // Stored offsets include -scale *
; symmetric scoring adds it back. + if (vectors.symmetricOffsets != null) { + centeredOffsets = vectors.symmetricOffsets; + } else { + centeredOffsets = new float[codes.size]; + for (int i = 0; i < centeredOffsets.length; i++) { + centeredOffsets[i] = ash.centeredOffset(codes.scales[i], codes.offsets[i], codes.landmarks[i], + signs(i), codes.signOffset(i), body(i), codes.bodyOffset(i)); + } + } + retainedBytes = RamUsageEstimator.shallowSizeOfInstance(ASHSymmetricScorer.class) + + AsymmetricHashing.estimateArray2DBytes(landmarkDots) + + (vectors.symmetricOffsets == null ? RamUsageEstimator.shallowSizeOf(centeredOffsets) : 0); + } + + /** Owned cache storage only; the shared compressor and vectors are charged by ASHVectors. */ + long ramBytesUsed() { return retainedBytes; } + + private boolean selectSimd(Kernel kernel) { + boolean supported = (bits == 1 || bits == 2 || bits == 4) && backend.supportsAshSymmetricScoring(); + // Pair kernels read stored codes at page offsets. + if (bits == 2 || bits == 4) supported &= backend.supportsAshSymmetricDotAt(); + if (landmarks != 1) supported &= bits == 1 ? backend.supportsAshMaskedLoad() : backend.supportsAshProjectionScoring(); + if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric SIMD requires a vector backend and bits 1, 2, or 4"); + return kernel != Kernel.SCALAR && supported; + } + + public String description() { return "symmetric " + (simd ? "SIMD" : "scalar") + ", bits=" + bits + ", C=" + landmarks; } + + private float centeredOffset(AsymmetricHashing.QuantizedVector vector) { + return ash.centeredOffset(vector); + } + + private float offset(int node) { return centeredOffsets[node]; } + + /** Per-thread buffer that batch scoring gathers codes into; it grows to the largest batch. */ + private static final class GatherBuffer { + private static final ThreadLocal PER_THREAD = ThreadLocal.withInitial(GatherBuffer::new); + private byte[] bytes = new byte[0]; + } + + private static byte[] gatherBuffer(int bytes) { + var buffer = GatherBuffer.PER_THREAD.get(); + if (buffer.bytes.length < bytes) buffer.bytes = new byte[Math.max(bytes, 2 * buffer.bytes.length)]; + return buffer.bytes; + } + + private long[] signs(int node) { return codes.signWords == 0 ? null : codes.signPage(node); } + + private byte[] body(int node) { return codes.bodyBytes == 0 ? null : codes.bodyPage(node); } + + /** Packed-code dot product of two codes, each given by its sign-word and body arrays and offsets. */ + private float pairDot(long[] aSigns, int aSignOffset, byte[] aBody, int aBodyOffset, + long[] bSigns, int bSignOffset, byte[] bBody, int bBodyOffset) { + if (simd) { + return bits == 1 ? backend.ashSymmetricBinaryDotAt(aSigns, aSignOffset, bSigns, bSignOffset, dimensions) + : backend.ashSymmetricDotAt(aBody, aBodyOffset, bBody, bBodyOffset, dimensions, bits); + } + return codeDot(aSigns, aSignOffset, aBody, aBodyOffset, bSigns, bSignOffset, bBody, bBodyOffset, dimensions, bits); + } + + /** Raw symmetric dot product. No encoding or original-dimensional projection. */ + public float dotProduct(int a, int b) { + long[] as = signs(a), bs = signs(b); + byte[] ab = body(a), bb = body(b); + int aso = codes.signOffset(a), abo = codes.bodyOffset(a), bso = codes.signOffset(b), bbo = codes.bodyOffset(b); + int ca = codes.landmarks[a] & 255, cb = codes.landmarks[b] & 255; + float crossA = 0, crossB = 0; + if (ca != cb) { + for (int j = 0; j < dimensions; j++) { + float delta = ash.landmarkProj[cb][j] - ash.landmarkProj[ca][j]; + crossA += component(as, aso, ab, abo, j, bits) * delta; + crossB += component(bs, bso, bb, bbo, j, bits) * -delta; + } + } + float xs = codes.scales[a], ys = codes.scales[b]; + return (xs * ys) * pairDot(as, aso, ab, abo, bs, bso, bb, bbo) + + ((xs * crossA + ys * crossB) + (offset(a) + offset(b))) + landmarkDots[ca][cb]; + } + + public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor(int node) { + if (landmarks != 1) return scoreFunctionFor(vectors.get(node)); + return c1ScoreFunction(signs(node), codes.signOffset(node), body(node), codes.bodyOffset(node), + codes.scales[node], offset(node)); + } + + /** The operand must already be encoded with this instance's compressor. */ + public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor(AsymmetricHashing.QuantizedVector query) { + if (landmarks != 1) return vectors.scoreFunctionFor(preparedState(query), simd); + return c1ScoreFunction(query.binaryVector, 0, query.extraBits, 0, query.scale, centeredOffset(query)); + } + + private ScoreFunction.AdaptiveApproximateScoreFunction c1ScoreFunction(long[] querySigns, int querySignOffset, + byte[] queryBody, int queryBodyOffset, + float queryScale, float queryOffset) { + final ASHCodes c = codes; + final float[] scales = c.scales; + final float landmarkDot = landmarkDots[0][0]; + if (simd && (bits == 2 || bits == 4)) { + // The graph-construction hot path: one offset kernel call per target, no object loads. + final byte[][] pages = c.bodyPages; + final int shift = c.pageShift, mask = c.pageMask, stride = c.bodyBytes; + final float[] offsets = centeredOffsets; + final int d = dimensions, b = bits; + final io.github.jbellis.jvector.vector.VectorUtilSupport vu = backend; + final boolean batch = vu.supportsAshSymmetricDotRows(); + return new ScoreFunction.AdaptiveApproximateScoreFunction() { + @Override + public float similarityTo(int node) { + float dot = (queryScale * scales[node]) + * vu.ashSymmetricDotAt(queryBody, queryBodyOffset, pages[node >>> shift], (node & mask) * stride, d, b) + + (queryOffset + offsets[node]) + landmarkDot; + return ASHScorer.toSimilarity(dot); + } + + @Override + public boolean supportsBatchScoring() { + return batch; + } + + /** Gathers the codes into one contiguous buffer and scores them in one call; same scores as above. */ + @Override + public void similarityTo(int[] nodes, int count, float[] scores) { + byte[] rows = gatherBuffer(count * stride); + for (int i = 0; i < count; i++) { + int node = nodes[i]; + System.arraycopy(pages[node >>> shift], (node & mask) * stride, rows, i * stride, stride); + } + vu.ashSymmetricDotRows(queryBody, queryBodyOffset, rows, stride, count, d, b, scores); + for (int i = 0; i < count; i++) { + int node = nodes[i]; + float dot = (queryScale * scales[node]) * scores[i] + (queryOffset + offsets[node]) + landmarkDot; + scores[i] = ASHScorer.toSimilarity(dot); + } + } + }; + } + return node -> { + float dot = (queryScale * scales[node]) + * pairDot(querySigns, querySignOffset, queryBody, queryBodyOffset, + signs(node), c.signOffset(node), body(node), c.bodyOffset(node)) + + (queryOffset + offset(node)) + landmarkDot; + return ASHScorer.toSimilarity(dot); + }; + } + + /** Score encoded pairs in ranges; C=1 uses integer kernels, C>1 reuses projected-state kernels. */ + public ASHBlockScorer blockScorerFor(AsymmetricHashing.QuantizedVector query, int blockSize) { + Kernel kernel = Kernel.valueOf(System.getProperty("jvector.ash.blockKernel", "auto") + .trim().toUpperCase(java.util.Locale.ROOT)); + return blockScorerFor(query, blockSize, kernel); + } + + public ASHBlockScorer blockScorerFor(AsymmetricHashing.QuantizedVector query, int blockSize, Kernel kernel) { + if (blockSize <= 0) throw new IllegalArgumentException("blockSize must be positive"); + if (landmarks == 1 && (bits == 2 || bits == 4)) { + if (kernel == Kernel.SIMD && !backend.supportsAshSymmetricScoring()) throw new UnsupportedOperationException("Symmetric SIMD is unavailable"); + final boolean useVector = kernel != Kernel.SCALAR && backend.supportsAshSymmetricScoring(); + final byte[][] blocks = vectors.symmetricProjectionBlocks(blockSize); + final int components = 4 / bits; + final int groups = (dimensions + components - 1) / components; + final short[] lut = new short[Math.multiplyExact(groups, 32)]; + for (int g = 0; g < groups; g++) { + int q0 = (int)(2*component(query, g*components, bits)); + if (bits == 4) { + for (int magnitude = 0; magnitude < 8; magnitude++) { + int product = q0*(2*magnitude+1); + lut[g*32+magnitude] = (short)-product; + lut[g*32+magnitude+8] = (short)product; + } + } else { + int q1 = g*components+1 < dimensions ? (int)(2*component(query,g*components+1,bits)) : 0; + for (int value = 0; value < 16; value++) { + int a = 2*(value&1)+1, b = 2*((value>>>2)&1)+1; + if ((value&2)==0) a = -a; + if ((value&8)==0) b = -b; + lut[g*32+value] = (short)(q0*a+q1*b); + } + } + System.arraycopy(lut,g*32,lut,g*32+16,16); + } + final float queryOffset = centeredOffset(query); + final float[] scales = codes.scales; + return new ASHBlockScorer() { + public String description() { return "symmetric exact integer LUT " + (useVector ? "SIMD" : "scalar"); } + public void scoreRange(int start, int count, float[] out) { + java.util.Objects.checkFromIndexSize(start, count, vectors.count()); + java.util.Objects.checkFromIndexSize(0, count, out.length); + for (int done = 0; done < count;) { + int ordinal = start + done, lane = ordinal % blockSize; + int lanes = Math.min(count - done, blockSize - lane); + byte[] body = blocks[ordinal / blockSize]; + if (useVector) backend.ashSymmetricLutScore(body, groups, blockSize, lane, lanes, lut, out, done); + else for (int i = 0; i < lanes; i++) { + long sum = 0; + for (int g = 0; g < groups; g++) sum += lut[g * 32 + ((body[(g / 2)*blockSize+lane+i] >>> (4*(g%2))) & 15)]; + out[done+i] = sum*.25f; + } + for (int i = 0; i < lanes; i++) { + out[done+i] = ASHScorer.toSimilarity((query.scale*scales[ordinal+i])*out[done+i] + +(queryOffset+offset(ordinal+i))+landmarkDots[0][0]); + } + done += lanes; + } + } + }; + } + if (bits == 1 && landmarks == 1) { + boolean supported = backend.supportsAshSymmetricScoring(); + if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric SIMD is unavailable"); + boolean vector = kernel != Kernel.SCALAR && supported; + float queryOffset = centeredOffset(query); + return new ASHBlockScorer() { + public String description() { return "symmetric binary packed-pair range " + (vector ? "SIMD" : "scalar"); } + public void scoreRange(int start, int count, float[] out) { + java.util.Objects.checkFromIndexSize(start, count, vectors.count()); + java.util.Objects.checkFromIndexSize(0, count, out.length); + for (int i = 0; i < count; i++) { + int node = start + i; + long[] page = codes.signPage(node); + int at = codes.signOffset(node); + float dot = vector ? backend.ashSymmetricBinaryDotAt(query.binaryVector, 0, page, at, dimensions) + : codeDot(query.binaryVector, 0, null, 0, page, at, null, 0, dimensions, bits); + out[i] = ASHScorer.toSimilarity((query.scale * codes.scales[node]) * dot + + (queryOffset + offset(node)) + landmarkDots[0][0]); + } + } + }; + } + boolean supported = bits == 1 ? backend.supportsAshMaskedLoad() : backend.supportsAshLutScoring(); + if (kernel == Kernel.SIMD && !supported) throw new UnsupportedOperationException("Symmetric block SIMD is unavailable for this backend/bit width"); + return vectors.blockScorerFor(preparedState(query), blockSize, kernel != Kernel.SCALAR && supported); + } + + // qProj is derived from encoded coordinates, NOT from W times a raw D-dimensional query. + // Header calibration adds the query's stored reconstruction correction to each centroid dot. + private ASHScorer.QueryPrecompute preparedState(AsymmetricHashing.QuantizedVector query) { + int cq = query.landmark & 255; + if (cq >= landmarks) throw new IllegalArgumentException("Invalid query landmark " + cq); + float[] code = new float[dimensions]; + float[] qProj = new float[dimensions]; + for (int j = 0; j < dimensions; j++) { + code[j] = component(query, j, bits); + qProj[j] = ash.landmarkProj[cq][j] + query.scale * code[j]; + } + // The query's stored offset already includes its own landmark term. + float correction = query.offset; + float[] dots = new float[landmarks]; + for (int c = 0; c < landmarks; c++) { + float dot = 0; + for (int j = 0; j < dimensions; j++) dot += code[j] * ash.landmarkProj[c][j]; + dots[c] = landmarkDots[cq][c] + query.scale * dot + correction; + } + var prepared = new ASHScorer.QueryPrecompute(dimensions, landmarks, qProj, dots); + prepared.sumQProj = ASHScorer.sum(qProj); + return prepared; + } + + private static float component(AsymmetricHashing.QuantizedVector v, int j, int bits) { + return component(v.binaryVector, 0, v.extraBits, 0, j, bits); + } + + /** Decoded component {@code j} of a code given by its sign-word and body arrays and offsets. */ + private static float component(long[] signs, int signOffset, byte[] body, int bodyOffset, int j, int bits) { + if (bits == 1) return AsymmetricHashing.QuantizedVector.getBit(signs, signOffset, j) ? 1f : -1f; + if (bits == 2 || bits == 4) return AsymmetricHashing.projectionComponent(body, bodyOffset, j, bits); + return AsymmetricHashing.decodeOffsetBinary(AsymmetricHashing.offsetBinaryField(body, bodyOffset, j, bits), bits); + } + + private static float symmetric2ScalarWords(byte[] ac, int aOffset, byte[] bc, int bOffset, int d) { + long sum = 0; + for (int start = 0; start < d; start += 32) { + int active = Math.min(32, d - start); + long a = 0, b = 0; + if (active == 32) { + a = (long) SYMMETRIC_WORD.get(ac, aOffset + start / 4); + b = (long) SYMMETRIC_WORD.get(bc, bOffset + start / 4); + } else { + for (int j = 0; j < (active + 3) / 4; j++) { + a |= (long) (ac[aOffset + start / 4 + j] & 255) << (8*j); + b |= (long) (bc[bOffset + start / 4 + j] & 255) << (8*j); + } + } + long valid = 0x5555555555555555L; + if (active < 32) valid &= (1L << (2*active)) - 1; + long neg = ((a ^ b) >>> 1) & valid, ma = a & valid, mb = b & valid, both = ma & mb; + sum += active - 2*Long.bitCount(neg) + 2*(Long.bitCount(ma)+Long.bitCount(mb)) + -4*(Long.bitCount(ma & neg)+Long.bitCount(mb & neg)) + +4*Long.bitCount(both)-8*Long.bitCount(both & neg); + } + return sum * .25f; + } + + static float codeDot(AsymmetricHashing.QuantizedVector a, + AsymmetricHashing.QuantizedVector b, int d, int bits) { + return codeDot(a.binaryVector, 0, a.extraBits, 0, b.binaryVector, 0, b.extraBits, 0, d, bits); + } + + /** Portable packed-code dot product; each code is given by its sign-word and body arrays and offsets. */ + static float codeDot(long[] aSigns, int aSignOffset, byte[] aBody, int aBodyOffset, + long[] bSigns, int bSignOffset, byte[] bBody, int bBodyOffset, int d, int bits) { + if (bits == 1) { + long mismatches = 0; + int words = d >>> 6; + for (int w = 0; w < words; w++) { + mismatches += Long.bitCount(aSigns[aSignOffset + w] ^ bSigns[bSignOffset + w]); + } + int tail = d & 63; + if (tail != 0) { + mismatches += Long.bitCount((aSigns[aSignOffset + words] ^ bSigns[bSignOffset + words]) + & ((1L << tail) - 1)); + } + return d - 2f * mismatches; + } + if (bits == 2) return symmetric2ScalarWords(aBody, aBodyOffset, bBody, bBodyOffset, d); + if (bits == 4) { + // Each table entry sums products of doubled (odd integer) code values. + // 256 entries = 512 bytes; shared across all nodes and threads. + short[] table = DOT_4; + int dimsPerByte = 8 / bits; + int bytes = d / dimsPerByte; + long sum = 0; + for (int i = 0; i < bytes; i++) { + int x = aBody[aBodyOffset + i] & 255; + int y = bBody[bBodyOffset + i] & 255; + sum += table[((x & 15) << 4) | (y & 15)] + + table[(x & 240) | (y >>> 4)]; + } + for (int j = bytes * dimsPerByte; j < d; j++) { + int x = (int) (2 * AsymmetricHashing.projectionComponent(aBody, aBodyOffset, j, bits)); + int y = (int) (2 * AsymmetricHashing.projectionComponent(bBody, bBodyOffset, j, bits)); + sum += x * y; + } + return sum * 0.25f; + } + // Offset-binary fields (3 and 5 to 9 bits): products of doubled values are exact integers. + int bias = (1 << bits) - 1; + long sum = 0; + for (int j = 0; j < d; j++) { + int x = AsymmetricHashing.offsetBinaryField(aBody, aBodyOffset, j, bits); + int y = AsymmetricHashing.offsetBinaryField(bBody, bBodyOffset, j, bits); + sum += (long) (2 * x - bias) * (2 * y - bias); + } + return sum * 0.25f; + } + + private static short[] nibbleProducts(int bits) { + short[] table = new short[256]; + int mask = (1 << bits) - 1; + for (int a = 0; a < 16; a++) { + for (int b = 0; b < 16; b++) { + int sum = 0; + for (int shift = 0; shift < 4; shift += bits) { + int x = (int) (2 * AsymmetricHashing.decodeProjectionComponent((a >>> shift) & mask, bits)); + int y = (int) (2 * AsymmetricHashing.decodeProjectionComponent((b >>> shift) & mask, bits)); + sum += x * y; + } + table[(a << 4) | b] = (short) sum; + } + } + return table; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java new file mode 100644 index 000000000..526ba0d29 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ASHVectors.java @@ -0,0 +1,1134 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.annotations.Experimental; +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.util.Objects; + +/** + * Container for ASH-compressed vectors with scalar, SIMD, and block scoring. + * All public node and block scorers return DOT_PRODUCT similarities on the exact scale, + * {@code (1 + rawDotProduct) / 2} (unclamped), rather than raw ASH dot products. + * + *

+ * This class implements the functionality required for: + *

    + *
  • Scalar scoring
  • + *
  • Block scoring
  • + *
  • Vectorized versions of scoring
  • + *
+ * + *

Codes are held in one compact {@link ASHCodes} store with no per-vector objects; + * every scorer reads them in place. Fused graph neighborhoods are scored separately by + * {@link FusedASHDecoder}, from the on-disk index, using the same similarity scale and + * shared numerical kernels.

+ */ +@Experimental +public class ASHVectors implements CompressedVectors { + + final AsymmetricHashing ash; + /** The only copy of the stored codes and headers; see {@link ASHCodes}. */ + final ASHCodes codes; + final ASHScorer scorer; + private final long containerBytes; + // Immutable, optional construction cache produced by the bounded encoding workers. + // Raw input vectors are never retained. Loaded/individually encoded collections use + // the scorer's existing on-demand preparation instead. + final float[] symmetricOffsets; + private final java.util.concurrent.atomic.AtomicLong projectionCacheBytes = new java.util.concurrent.atomic.AtomicLong(); + + // Conservatively include a key, node and initial-sized table per cached layout. + // Arrays dominate this charge; no map traversal is needed when Cassandra polls it. + private static final long PROJECTION_CACHE_ENTRY_BYTES = + RamUsageEstimator.shallowSizeOfInstance(Integer.class) + + RamUsageEstimator.alignObjectSize(RamUsageEstimator.NUM_BYTES_OBJECT_HEADER + + Integer.BYTES + 4L * RamUsageEstimator.NUM_BYTES_OBJECT_REF) + + RamUsageEstimator.alignObjectSize(RamUsageEstimator.NUM_BYTES_ARRAY_HEADER + + 16L * RamUsageEstimator.NUM_BYTES_OBJECT_REF); + + private volatile ASHSymmetricScorer diversityScorer; + + enum AshSingleKernel { + AUTO, + SCALAR, + SIMD; + + static AshSingleKernel fromProperty() { + String v = System.getProperty("jvector.ash.singleKernel", "auto").trim().toLowerCase(java.util.Locale.ROOT); + if ("scalar".equals(v)) return SCALAR; + if ("simd".equals(v)) return SIMD; + if ("auto".equals(v)) return AUTO; + throw new IllegalArgumentException("jvector.ash.singleKernel must be auto, scalar or simd: " + v); + } + } + + enum AshBlockKernel { + AUTO, + SCALAR, + SIMD; + + static AshBlockKernel fromProperty() { + String v = System.getProperty("jvector.ash.blockKernel", "auto").trim().toLowerCase(java.util.Locale.ROOT); + if ("scalar".equals(v)) return SCALAR; + if ("simd".equals(v)) return SIMD; + if ("auto".equals(v)) return AUTO; + throw new IllegalArgumentException("jvector.ash.blockKernel must be auto, scalar or simd: " + v); + } + } + + // Decoded headers, owned by codes; aliased for the scorers' hot loops. + private final float[] scales; // scale_i = ||x_i − μ|| / ||code_i|| + private final float[] offsets; // offset_i = − ||μ||^2 + private final byte[] landmarks; + + // Projected dimensions and sign-bit word count + private final int d; + private final int words; + + // Packed block-column-major sign bits (built lazily per blockSize, 1-bit path only). Published + // whole, after it is filled, so concurrent queries never see a partial array or another size. + private static final class PackedBits { + final int blockSize; + final long[] words; + + PackedBits(int blockSize, long[] words) { + this.blockSize = blockSize; + this.words = words; + } + } + + private volatile PackedBits packedBits; + private final Object packedBitsLock = new Object(); + + // Immutable, body-only nibble blocks shared by queries. Header arrays contain + // decoded binary16 values; the serialized header remains 40 bits per vector. + private final java.util.concurrent.ConcurrentMap projectionBlocks = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Initialize ASHVectors by copying an array of ASH-compressed vectors into compact storage. + * The vectors are not retained, so later changes to them are not seen. + * + * @throws IllegalArgumentException if a vector does not have this compressor's code shape + */ + public ASHVectors(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector[] compressedVectors) { + this(ash, ASHCodes.copyOf(compressedVectors, ash.quantizedDim, ash.bitsPerDimension), null); + // The codes may come from another instance of the model, so the lengths they encode are counted here. + ash.recordEncodedCodes(compressedVectors); + } + + /** Takes ownership of {@code codes}, which must not change afterward. */ + ASHVectors(AsymmetricHashing ash, ASHCodes codes, float[] symmetricOffsets) { + if (codes.quantizedDim != ash.quantizedDim || codes.bitsPerDimension != ash.bitsPerDimension) + throw new IllegalArgumentException("ASH codes do not match the compressor's code shape"); + if (symmetricOffsets != null && symmetricOffsets.length != codes.size) + throw new IllegalArgumentException("Symmetric offsets must match encoded vector count"); + this.symmetricOffsets = symmetricOffsets; + this.ash = ash; + this.codes = codes; + this.scorer = new ASHScorer(ash); + + this.d = ash.quantizedDim; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + this.scales = codes.scales; + this.offsets = codes.offsets; + this.landmarks = codes.landmarks; + this.containerBytes = containerBytes(); + } + + @Override + public int count() { + return codes.size; + } + + /** + * Serialize the compressor followed by the compressed vectors. + * + *

+ * NOTE: + * This format is intentionally simple and versioned only at the compressor level. + * Additional per-vector metadata (e.g., multiple landmarks) must be versioned + * carefully when introduced. + */ + @Override + public void write(IndexWriter out, int version) throws IOException { + // Write ASH compressor first + ash.write(out, version); + + // Write vector count + out.writeInt(codes.size); + + // Write vectors, each in the AsymmetricHashing.QuantizedVector format + codes.write(out); + } + + public static ASHVectors load(RandomAccessReader in) throws IOException { + var ash = AsymmetricHashing.load(in); + + int size = in.readInt(); + if (size < 0) { + throw new IOException("Invalid compressed vector count " + size); + } + + return new ASHVectors(ash, ASHCodes.load(in, size, ash.quantizedDim, ash.bitsPerDimension), null); + } + + @Override + public ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("ASH scorer supports DOT_PRODUCT only"); + } + + // ASHScorer dispatches canonical 2/4-bit projection scoring to SIMD when available. + // The packed masked-add path below is specific to one-bit codes. + if (ash.bitsPerDimension != 1) { + return nodeScoreFunction(scorer.precomputeQuery(query)); + } + + final AshSingleKernel kernel = AshSingleKernel.fromProperty(); + final VectorUtilSupport vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); + + // Scalar forced + if (kernel == AshSingleKernel.SCALAR) { + return nodeScoreFunction(scorer.precomputeQuery(query)); + } + + // AUTO/SIMD: use SIMD scorer only when backend supports masked-load kernel; + // otherwise fall back to the existing scalar behavior. + if ((kernel == AshSingleKernel.SIMD || kernel == AshSingleKernel.AUTO) + && vecUtil.supportsAshMaskedLoad()) { + final QueryPrecompute qp = precomputeQuery(query); + return new SimdASHScoreFunction(qp); + } + + // Fallback: existing scalar behavior (stable reference) + if (kernel == AshSingleKernel.SIMD) { + throw new IllegalStateException("ASH single-vector SIMD is unavailable on " + vecUtil.getClass().getName()); + } + return nodeScoreFunction(scorer.precomputeQuery(query)); + } + + /** + * Applies {@code f} to stored codes in place, in graph similarity units. One lambda per storage + * shape keeps each call site free of null checks for the array a bit width does not use. + */ + private ScoreFunction.AdaptiveApproximateScoreFunction nodeScoreFunction(ASHScorer.QueryPrecompute qp) { + return nodeScoreFunction(scorer.codeScorerFor(qp), qp); + } + + private ScoreFunction.AdaptiveApproximateScoreFunction nodeScoreFunction(ASHScorer.CodeScorer f, + ASHScorer.QueryPrecompute qp) { + final ASHCodes c = codes; + if (c.signWords == 0) { + return new BoundedScoreFunction(qp) { + @Override + public float similarityTo(int node) { + return ASHScorer.toSimilarity(f.dot(null, 0, c.bodyPage(node), c.bodyOffset(node), + scales[node], offsets[node], landmarks[node] & 0xFF)); + } + }; + } + if (c.bodyBytes == 0) { + return new BoundedScoreFunction(qp) { + @Override + public float similarityTo(int node) { + return ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), null, 0, + scales[node], offsets[node], landmarks[node] & 0xFF)); + } + }; + } + return new BoundedScoreFunction(qp) { + @Override + public float similarityTo(int node) { + return ASHScorer.toSimilarity(f.dot(c.signPage(node), c.signOffset(node), + c.bodyPage(node), c.bodyOffset(node), scales[node], offsets[node], landmarks[node] & 0xFF)); + } + }; + } + + /** An ASH score function for one prepared query, reporting the query's dot-product bound. */ + private abstract class BoundedScoreFunction implements ScoreFunction.AdaptiveApproximateScoreFunction { + private final ASHScorer.QueryPrecompute qp; + + /** {@code qp} null leaves the bound unknown. */ + BoundedScoreFunction(ASHScorer.QueryPrecompute qp) { + this.qp = qp; + } + + @Override + public float dotProductBound() { + return qp == null ? Float.NaN : scorer.dotProductBound(qp); + } + } + + // ============================================================================ + // ASH single vector scoring (1-bit only, reads the stored sign pages in place) + // ============================================================================ + + private final class SimdASHScoreFunction implements ScoreFunction.AdaptiveApproximateScoreFunction { + private final QueryPrecompute qp; + private final VectorUtilSupport vecUtil; + + // Query state and the stored sign pages, read in place + private final float[] qProj; + private final ASHCodes codes; + + private final int d; + private final int words; + + SimdASHScoreFunction(QueryPrecompute qp) { + if (ash.bitsPerDimension != 1) { + throw new IllegalStateException("SIMD ASH scorer supports only 1-bit ASH"); + } + + this.qp = qp; + this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); + this.d = qp.d; + this.words = ASHVectors.this.words; // access outer class field + + this.qProj = qp.qProj; + + this.codes = ASHVectors.this.codes; + } + + @Override + public float dotProductBound() { + float m = ash.maxEncodedNorm(); + return (Float.isNaN(qp.queryNorm) ? m : qp.queryNorm) * m; + } + + @Override + public float similarityTo(int node2) { + + final int c = landmarks[node2] & 0xFF; + + // Run existing optimized 1-bit kernel on the node's sign words in its page. + final float maskedAdd = vecUtil.ashMaskedAdd_512( + qProj, + 0, + codes.signPage(node2), + codes.signOffset(node2), + d, + words + ); + + return ASHScorer.toSimilarity(scales[node2] * (2f * maskedAdd - qp.sumQProj) + + qp.dotQMu[c] + offsets[node2]); + } + + @Override + public boolean isExact() { + return false; + } + } + + // ============================================================================ + // ASH block-oriented scoring + // ============================================================================ + + /** + * Scalar reference block scorer. + * + *

+ * This scorer computes ASH scores by invoking the scalar per-vector + * score function for each vector in the requested range. + *

+ * + *

+ * It serves as a correctness reference and baseline implementation. + * No block-level optimizations are applied. + *

+ */ + private static final class LegacyASHBlockScorer implements ASHBlockScorer { + + /** Per-node scorer already in graph similarity units. */ + private final ScoreFunction.ApproximateScoreFunction scorer; + private final int size; + + LegacyASHBlockScorer(ScoreFunction.ApproximateScoreFunction scorer, int size) { + this.scorer = scorer; + this.size = size; + } + + @Override + public void scoreRange(int start, int count, float[] out) { + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (start < 0 || start + count > size) { + throw new IllegalArgumentException( + "Range out of bounds: [" + start + ", " + (start + count) + ")" + ); + } + if (out.length < count) { + throw new IllegalArgumentException("out.length < count"); + } + if (count == 0) return; + + final int end = start + count; + for (int i = start, o = 0; i < end; i++, o++) { + out[o] = scorer.similarityTo(i); + } + } + } + + /** + * Returns the independent scalar reference scorer, regardless of kernel properties. + * Use the overload accepting a block size for automatic or forced SIMD dispatch. + */ + public ASHBlockScorer blockScorerFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException( + "ASH block scorer supports DOT_PRODUCT only"); + } + + return new LegacyASHBlockScorer(nodeScoreFunction(scorer.scalarCodeScorerFor(query), null), codes.size); + } + + /** + * Returns a block-oriented ASH scorer for contiguous vectors. + * + *

+ * This factory selects between a portable scalar implementation and a + * SIMD implementation. One-bit codes use masked adds; 2/4-bit codes use + * nibble lookup tables over interleaved blocks of 8, 16 or 32 vectors. + *

+ * + *

+ * Selection is controlled by the JVM property: + *

+ * + *
+     *   -Djvector.ash.blockKernel=auto|scalar|simd
+     * 
+ * + *
    + *
  • auto (default): use SIMD when supported by the active backend
  • + *
  • scalar: force portable scalar accumulation
  • + *
  • simd: require SIMD; fail if unavailable
  • + *
+ * + *

+ * Scorers are numerically equivalent within floating-point accumulation error. + * Packed multi-bit bodies are cached per block size and shared across queries. + *

+ */ + public ASHBlockScorer blockScorerFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction, + int blockSize) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("DOT_PRODUCT only"); + } + if (blockSize <= 0) { + throw new IllegalArgumentException("blockSize must be > 0"); + } + + // Every multibit width scores 4-bit groups of its canonical code through the query LUT. + if (ash.bitsPerDimension != 1) { + FusedASHLayout.validateBlockSize(blockSize); + return new LutASHBlockScorer(query, blockSize); + } + + final AshBlockKernel kernel = AshBlockKernel.fromProperty(); + final boolean supportsSimd = VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshMaskedLoad(); + if (kernel == AshBlockKernel.SIMD && !supportsSimd) { + throw new IllegalStateException("ASH masked-add SIMD is unavailable on the active backend"); + } + + // Compute query precompute ONCE (shared by scalar + simd) + final QueryPrecompute qp = precomputeQuery(query); + + // ------------------------------------------------------------ + // Forced scalar path (portable register-accumulator) + // ------------------------------------------------------------ + if (kernel == AshBlockKernel.SCALAR || !supportsSimd) { + return new ScalarASHBlockScorer( + codes, + scales, + offsets, + landmarks, + qp + ); + } + + // ------------------------------------------------------------ + // SIMD path + // ------------------------------------------------------------ + return new SimdASHBlockScorer( + codes.size, + scales, + offsets, + landmarks, + packedBitsFor(blockSize), + blockSize, + qp + ); + } + + // Package-private prepared-state entry points shared by symmetric scoring. + ScoreFunction.AdaptiveApproximateScoreFunction scoreFunctionFor(ASHScorer.QueryPrecompute qp, boolean simd) { + if (ash.bitsPerDimension == 1 && simd) { + return new SimdASHScoreFunction(new QueryPrecompute(qp.d, qp.C, qp.qProj, + qp.sumQProj, qp.dotQMuByLandmark, qp.queryNorm)); + } + return nodeScoreFunction(scorer.codeScorerFor(qp, simd), qp); + } + + ASHBlockScorer blockScorerFor(ASHScorer.QueryPrecompute prepared, int blockSize, boolean simd) { + if (blockSize <= 0) throw new IllegalArgumentException("blockSize must be > 0"); + if (ash.bitsPerDimension != 1) { + FusedASHLayout.validateBlockSize(blockSize); + return new LutASHBlockScorer(prepared, blockSize, simd); + } + var qp = new QueryPrecompute(prepared.d, prepared.C, prepared.qProj, + prepared.sumQProj, prepared.dotQMuByLandmark); + if (!simd) return new ScalarASHBlockScorer(codes, scales, offsets, landmarks, qp); + return new SimdASHBlockScorer(codes.size, scales, offsets, landmarks, packedBitsFor(blockSize), blockSize, qp); + } + + private byte[][] packProjectionBlocks(int blockSize) { + int count = codes.size; + int bytes = FusedASHLayout.canonicalCodeBytes(d, ash.bitsPerDimension); + if (bytes > codes.bodyBytes) throw new IllegalStateException("ASH projection codes are shorter than their layout"); + byte[][] blocks = new byte[count / blockSize + (count % blockSize == 0 ? 0 : 1)][]; + long bytesUsed = RamUsageEstimator.shallowSizeOf(blocks); + for (int block = 0; block < blocks.length; block++) { + byte[] body = new byte[Math.multiplyExact(bytes, blockSize)]; + int base = block * blockSize; + for (int lane = 0; lane < Math.min(blockSize, count - base); lane++) { + byte[] page = codes.bodyPage(base + lane); + int at = codes.bodyOffset(base + lane); + for (int i = 0; i < bytes; i++) body[i * blockSize + lane] = page[at + i]; + } + blocks[block] = body; + bytesUsed = Math.addExact(bytesUsed, RamUsageEstimator.sizeOf(body)); + } + // computeIfAbsent serializes creation for a layout. Failed packing is not charged. + projectionCacheBytes.accumulateAndGet(Math.addExact(bytesUsed, PROJECTION_CACHE_ENTRY_BYTES), Math::addExact); + return blocks; + } + + byte[][] symmetricProjectionBlocks(int blockSize) { + if (blockSize <= 0) throw new IllegalArgumentException("blockSize must be positive"); + return projectionBlocks.computeIfAbsent(blockSize, this::packProjectionBlocks); + } + + /** Query-local LUT and dispatch over immutable, container-owned packed blocks. */ + private final class LutASHBlockScorer implements ASHBlockScorer { + private final ASHLutKernel kernel; + private final int blockSize; + private final byte[][] blocks; + private final float[] lut; + private final float[] dotQMu; + private final int groups = FusedASHLayout.codeGroups(d, ash.bitsPerDimension); + + LutASHBlockScorer(VectorFloat query, int blockSize) { + this.kernel = new ASHLutKernel(); + this.blockSize = blockSize; + this.blocks = projectionBlocks.computeIfAbsent(blockSize, ASHVectors.this::packProjectionBlocks); + float[] q = new float[ash.originalDimension]; + for (int i = 0; i < q.length; i++) q[i] = query.get(i); + float[] projected = new float[d]; + ash.stiefelTransform.projectQuery(q, projected); + lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(projected, d, ash.bitsPerDimension, lut); + dotQMu = new float[ash.landmarkCount]; + for (int i = 0; i < dotQMu.length; i++) dotQMu[i] = VectorUtil.dotProduct(query, ash.landmarks[i]); + } + + LutASHBlockScorer(ASHScorer.QueryPrecompute prepared, int blockSize, boolean simd) { + this.kernel = new ASHLutKernel(simd); + this.blockSize = blockSize; + this.blocks = projectionBlocks.computeIfAbsent(blockSize, ASHVectors.this::packProjectionBlocks); + this.lut = new float[Math.multiplyExact(groups, 16)]; + FusedASHLayout.buildQueryLut(prepared.qProj, d, ash.bitsPerDimension, lut); + this.dotQMu = prepared.dotQMuByLandmark; + } + + @Override + public void scoreRange(int start, int count, float[] out) { + Objects.checkFromIndexSize(start, count, codes.size); + Objects.checkFromIndexSize(0, count, out.length); + for (int done = 0; done < count;) { + int ordinal = start + done; + int lane = ordinal % blockSize; + int lanes = Math.min(count - done, blockSize - lane); + kernel.score(blocks[ordinal / blockSize], 0, groups, blockSize, + lane, lanes, lut, out, done); + for (int i = 0; i < lanes; i++) { + int node = ordinal + i; + out[done + i] = ASHScorer.toSimilarity(scales[node] * out[done + i] + + dotQMu[landmarks[node] & 255] + offsets[node]); + } + done += lanes; + } + } + + @Override + public String toString() { return "LutASHBlockScorer[" + kernel + ", blockSize=" + blockSize + "]"; } + + @Override + public String description() { return toString(); } + } + + /** The packed sign words for {@code blockSize}; callers score with exactly the array returned. */ + private long[] packedBitsFor(int blockSize) { + if (ash.bitsPerDimension != 1) { + throw new IllegalStateException("Packed sign-bit block cache supports only 1-bit ASH"); + } + if (blockSize <= 0) { + throw new IllegalArgumentException("blockSize must be > 0"); + } + PackedBits current = packedBits; + if (current != null && current.blockSize == blockSize) { + return current.words; + } + synchronized (packedBitsLock) { + current = packedBits; + if (current == null || current.blockSize != blockSize) { + current = new PackedBits(blockSize, packBits(blockSize)); + packedBits = current; + } + return current.words; + } + } + + private long[] packBits(int blockSize) { + final int n = codes.size; + final int packedBlockCount = (n + blockSize - 1) / blockSize; + final long[] packedBits = new long[packedBlockCount * words * blockSize]; + + for (int b = 0; b < packedBlockCount; b++) { + int baseOrd = b * blockSize; + int blockLen = Math.min(blockSize, n - baseOrd); + + for (int w = 0; w < words; w++) { + int dstBase = (b * words + w) * blockSize; + for (int lane = 0; lane < blockLen; lane++) { + int node = baseOrd + lane; + packedBits[dstBase + lane] = codes.signPage(node)[codes.signOffset(node) + w]; + } + // remaining lanes default to 0 + } + } + return packedBits; + } + + /** + * Block-oriented 1-bit ASH scorer using register-local accumulation. + * + *

+ * This is the preferred scalar block implementation for 1-bit ASH. It avoids + * temporary per-block arrays and accumulates the masked-add term directly + * into scalar registers. + *

+ */ + private static final class ScalarASHBlockScorer implements ASHBlockScorer { + + private final ASHCodes codes; + private final float[] scales; + private final float[] offsets; + private final byte[] landmarks; + + private final float[] qProj; // [d] + private final float sumQProj; + private final float[] dotQMu; // [C] + private final int d; + private final int words; + + ScalarASHBlockScorer( + ASHCodes codes, + float[] scales, + float[] offsets, + byte[] landmarks, + QueryPrecompute qp) + { + + this.codes = codes; + this.scales = scales; + this.offsets = offsets; + this.landmarks = landmarks; + + this.d = qp.d; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + this.qProj = qp.qProj; + this.sumQProj = qp.sumQProj; + this.dotQMu = qp.dotQMu; + } + + @Override + public void scoreRange(int start, int count, float[] out) { + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (start < 0 || start + count > codes.size) { + throw new IllegalArgumentException("Range out of bounds: [" + start + ", " + (start + count) + ")"); + } + if (out.length < count) throw new IllegalArgumentException("out.length < count"); + if (count == 0) return; + + for (int i = 0; i < count; i++) { + final int idx = start + i; + + final int c = landmarks[idx] & 0xFF; // unsigned [0,C) + + float maskedAdd = 0f; + + // bit-walk against Aq; the landmark term is folded into the offset + final long[] bits = codes.signPage(idx); + final int bitsOffset = codes.signOffset(idx); + int baseDim = 0; + for (int w = 0; w < words && baseDim < d; w++, baseDim += 64) { + long word = bits[bitsOffset + w]; + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int j = baseDim + bit; + if (j < d) maskedAdd += qProj[j]; + word &= (word - 1); + } + } + + out[i] = ASHScorer.toSimilarity( + scales[idx] * (2f * maskedAdd - sumQProj) + + dotQMu[c] + + offsets[idx]); + } + } + } + + /** + * Block-oriented 1-bit ASH scorer using SIMD masked-loads with register-local + * accumulation. + * + *

+ * This scorer is a faithful implementation of Figure 1 + * and Table 1 kernel from the ASH paper + *

+ * + *
    + *
  • binary codes are stored in block column-major layout
  • + *
  • SIMD lanes correspond to dimensions
  • + *
  • masked loads select active dimensions
  • + *
  • horizontal sums are accumulated per vector
  • + *
+ * + *

+ * This implementation accumulates masked-add results directly into scalar registers, + * avoiding per-lane memory traffic and restoring performance. + *

+ */ + private static final class SimdASHBlockScorer implements ASHBlockScorer { + + private final int size; + private final float[] scales; + private final float[] offsets; + private final byte[] landmarks; + + private final long[] packedBits; + private final int blockSize; + + private final int d; + private final int words; + + private final float[] maskedAdds; // [blockSize] + + private final float[] qProj; // qp.qProj [d] + private final float sumQProj; + private final float[] dotQMu; // qp.dotQMu [C] + + private final VectorUtilSupport vecUtil; + + SimdASHBlockScorer( + int size, + float[] scales, + float[] offsets, + byte[] landmarks, + long[] packedBits, + int blockSize, + QueryPrecompute qp) { + + this.vecUtil = VectorizationProvider.getInstance().getVectorUtilSupport(); + + this.size = size; + this.scales = scales; + this.offsets = offsets; + this.landmarks = landmarks; + + this.packedBits = packedBits; + this.blockSize = blockSize; + + this.d = qp.d; + this.words = AsymmetricHashing.QuantizedVector.wordsForDims(d); + + this.maskedAdds = new float[blockSize]; + + this.qProj = qp.qProj; + this.sumQProj = qp.sumQProj; + this.dotQMu = qp.dotQMu; + } + + // The masked add reads only Aq, so one kernel call covers each packed block's lanes in the range, + // whatever their landmarks; each lane adds its own landmark's . + @Override + public void scoreRange(int start, int count, float[] out) { + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (start < 0 || start + count > size) { + throw new IllegalArgumentException("Range out of bounds: [" + start + ", " + (start + count) + ")"); + } + if (out.length < count) throw new IllegalArgumentException("out.length < count"); + if (count == 0) return; + + int ord = start; + final int end = start + count; + + while (ord < end) { + final int blockId = ord / blockSize; + final int laneStart = ord - blockId * blockSize; + final int runLen = Math.min(blockSize - laneStart, end - ord); + final int blockWordBase = blockId * words * blockSize; + + vecUtil.ashMaskedAddBlockAllWords( + qProj, + d, + packedBits, + blockWordBase, + words, + blockSize, + laneStart, + runLen, + maskedAdds + ); + + for (int lane = 0; lane < runLen; lane++) { + int idx = ord + lane; + out[idx - start] = ASHScorer.toSimilarity( + // scale the 0/1 stored bits to +/-1 for processing. + scales[idx] * (2f * maskedAdds[lane] - sumQProj) + + dotQMu[landmarks[idx] & 0xFF] + + offsets[idx]); + } + + ord += runLen; + } + } + } + + private QueryPrecompute precomputeQuery(VectorFloat query) { + if (ash.bitsPerDimension != 1) { + throw new IllegalStateException("ASHVectors precomputeQuery supports only 1-bit ASH"); + } + + final int d = ash.quantizedDim; + final int D = ash.originalDimension; + final int C = ash.landmarkCount; + + // q materialized once + final float[] qArr = new float[D]; + for (int i = 0; i < D; i++) { + qArr[i] = query.get(i); + } + + // Aq + final float[] qProj = new float[d]; + ash.stiefelTransform.projectQuery(qArr, qProj); + final float[] dotQMu = new float[C]; + for (int c = 0; c < C; c++) { + dotQMu[c] = VectorUtil.dotProduct(query, ash.landmarks[c]); + } + + return new QueryPrecompute(d, C, qProj, ASHScorer.sum(qProj), dotQMu, + (float) Math.sqrt(VectorUtil.dotProduct(query, query))); + } + + static final class QueryPrecompute { + final int d; + final int C; + final float[] qProj; // [d], Aq + final float sumQProj; // + final float[] dotQMu; // [C] + final float queryNorm; // ||q||, or NaN when prepared from a stored code + + QueryPrecompute(int d, int C, + float[] qProj, + float sumQProj, + float[] dotQMu) { + this(d, C, qProj, sumQProj, dotQMu, Float.NaN); + } + + QueryPrecompute(int d, int C, + float[] qProj, + float sumQProj, + float[] dotQMu, + float queryNorm) { + this.d = d; + this.C = C; + this.qProj = qProj; + this.sumQProj = sumQProj; + this.dotQMu = dotQMu; + this.queryNorm = queryNorm; + } + } + + public static final class LandmarkOrder { + public final ASHVectors vectors; + public final int[] newToOld; + public final int[] oldToNew; + public final int[] landmarkOffsets; // length C+1, last == n + + LandmarkOrder(ASHVectors vectors, int[] newToOld, int[] oldToNew, int[] landmarkOffsets) { + this.vectors = vectors; + this.newToOld = newToOld; + this.oldToNew = oldToNew; + this.landmarkOffsets = landmarkOffsets; + } + } + + /** + * Stable, linear-time reorder by landmark id ({@code C <= 256}). + * The result holds its own reordered copy of the codes. + */ + public LandmarkOrder reorderByLandmarkFast() { + final int n = codes.size; + final int C = ash.landmarkCount; + + // Count per landmark + final int[] counts = new int[C]; + for (int i = 0; i < n; i++) { + int c = this.landmarks[i] & 0xFF; + if (c >= C) { + throw new IllegalStateException("Invalid landmark id " + c + " at ordinal " + i + " (C=" + C + ")"); + } + counts[c]++; + } + + // Prefix sums: landmarkOffsets[c] is start of landmark c, length C+1 + final int[] landmarkOffsets = new int[C + 1]; + landmarkOffsets[0] = 0; + for (int c = 0; c < C; c++) { + landmarkOffsets[c + 1] = landmarkOffsets[c] + counts[c]; + } + if (landmarkOffsets[C] != n) { + throw new IllegalStateException("landmarkOffsets[C] != n: " + landmarkOffsets[C] + " != " + n); + } + + final int[] writePos = landmarkOffsets.clone(); + + final float[] newSymmetricOffsets = symmetricOffsets == null ? null : new float[n]; + final int[] newToOld = new int[n]; + + // Preserve an ordinal map so we can go back and forth + for (int oldOrd = 0; oldOrd < n; oldOrd++) { + final int c = this.landmarks[oldOrd] & 0xFF; + final int newOrd = writePos[c]++; + if (newSymmetricOffsets != null) newSymmetricOffsets[newOrd] = symmetricOffsets[oldOrd]; + newToOld[newOrd] = oldOrd; + } + + final int[] oldToNew = new int[n]; + for (int newOrd = 0; newOrd < n; newOrd++) { + oldToNew[newToOld[newOrd]] = newOrd; + } + + ASHVectors reordered = new ASHVectors(ash, codes.permute(newToOld), newSymmetricOffsets); + return new LandmarkOrder(reordered, newToOld, oldToNew, landmarkOffsets); + } + + public static final class LandmarkRunStats { + public final int n; + public final int runCount; + public final double avgRunLength; + public final int maxRunLength; + public final double stddevRunLength; + + LandmarkRunStats(int n, int runCount, double avgRunLength, int maxRunLength, double stddevRunLength) { + this.n = n; + this.runCount = runCount; + this.avgRunLength = avgRunLength; + this.maxRunLength = maxRunLength; + this.stddevRunLength = stddevRunLength; + } + + @Override + public String toString() { + return String.format( + java.util.Locale.ROOT, + "runs=%d, avgRun=%.2f, maxRun=%d, stddev=%.2f (n=%d)", + runCount, avgRunLength, maxRunLength, stddevRunLength, n + ); + } + } + + public LandmarkRunStats landmarkRunStats() { + return computeLandmarkRunStats(this.landmarks); + } + + public static LandmarkRunStats computeLandmarkRunStats(byte[] landmarks) { + final int n = landmarks.length; + if (n == 0) { + return new LandmarkRunStats(0, 0, 0.0, 0, 0.0); + } + + int runCount = 0; + long sum = 0; + long sumSq = 0; + int max = 0; + + int i = 0; + while (i < n) { + final byte c = landmarks[i]; + int j = i + 1; + while (j < n && landmarks[j] == c) j++; + final int len = j - i; + + runCount++; + sum += len; + sumSq += (long) len * (long) len; + if (len > max) max = len; + + i = j; + } + + final double avg = (double) sum / (double) runCount; + final double meanSq = (double) sumSq / (double) runCount; + final double var = Math.max(0.0, meanSq - avg * avg); + final double stddev = Math.sqrt(var); + + return new LandmarkRunStats(n, runCount, avg, max, stddev); + } + + /** + * Same as {@link #scoreFunctionFor}: ASH prepares the query once, projection included, + * when the score function is created, so there is nothing further to precompute. + */ + @Override + public ScoreFunction.AdaptiveApproximateScoreFunction precomputedScoreFunctionFor( + VectorFloat query, + VectorSimilarityFunction similarityFunction) { + return scoreFunctionFor(query, similarityFunction); + } + + /** Symmetric node-to-node dot-product similarity from the stored encoded vectors. */ + @Override + public ScoreFunction.AdaptiveApproximateScoreFunction diversityFunctionFor( + int node1, + VectorSimilarityFunction similarityFunction) { + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) + throw new UnsupportedOperationException("ASH diversity scoring supports DOT_PRODUCT only"); + ASHSymmetricScorer symmetric = diversityScorer; + if (symmetric == null) { + synchronized (this) { + symmetric = diversityScorer; + if (symmetric == null) diversityScorer = symmetric = new ASHSymmetricScorer(this); + } + } + return symmetric.scoreFunctionFor(node1); + } + + /** + * A new, independent copy of the stored vector. Allocates; for repeated reads into reusable + * storage use {@link #copyTo}. Scorers read the stored codes in place. + */ + public AsymmetricHashing.QuantizedVector get(int ordinal) { + return codes.get(ordinal); + } + + /** + * Copies the stored vector into {@code dest}, replacing its arrays only if their lengths do not + * match this compressor's code shape. + */ + public void copyTo(int ordinal, AsymmetricHashing.QuantizedVector dest) { + codes.copyTo(ordinal, dest); + } + + @Override + public int getOriginalSize() { + return ash.originalDimension * Float.BYTES; + } + + @Override + public int getCompressedSize() { + return ash.compressedVectorSize(); + } + + @Override + public AsymmetricHashing getCompressor() { + return ash; + } + + private long containerBytes() { + return RamUsageEstimator.shallowSizeOfInstance(ASHVectors.class) + + RamUsageEstimator.shallowSizeOf(scorer) + + RamUsageEstimator.shallowSizeOf(symmetricOffsets) + + RamUsageEstimator.shallowSizeOf(projectionBlocks) + + RamUsageEstimator.shallowSizeOf(projectionCacheBytes); + } + + /** + * Retained compressor, code storage and published caches, analogous to PQVectors. + * Query-local scorers and worker scratch are owned by callers and excluded here. + * Code storage is fixed at construction; lazy cache totals are added at allocation. + */ + @Override + public long ramBytesUsed() { + ASHSymmetricScorer symmetric = diversityScorer; + PackedBits packed = packedBits; + return ash.ramBytesUsed() + containerBytes + codes.ramBytesUsed() + projectionCacheBytes.get() + + (packed == null ? 0 : RamUsageEstimator.shallowSizeOf(packed.words)) + + (symmetric == null ? 0 : symmetric.ramBytesUsed()); + } + + /** Equal compressors and the same stored vectors in the same order. */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ASHVectors that = (ASHVectors) o; + return Objects.equals(ash, that.ash) && codes.contentEquals(that.codes); + } + + @Override + public int hashCode() { + int result = Objects.hash(ash); + result = 31 * result + codes.contentHash(); + return result; + } + + @Override + public String toString() { + return "ASHVectors{count=" + codes.size + + ", ash=" + ash + '}'; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java new file mode 100644 index 000000000..c28cbb0ed --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/AsymmetricHashing.java @@ -0,0 +1,3125 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.annotations.Experimental; +import io.github.jbellis.jvector.annotations.VisibleForTesting; +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.disk.RandomAccessReader; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.VectorAccess; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.util.Accountable; +import io.github.jbellis.jvector.util.CallerBatchProcessor; +import io.github.jbellis.jvector.util.BlasThreads; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorUtilSupport; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.ByteSequence; +import io.github.jbellis.jvector.vector.types.FloatArray; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; + +import dev.ludovic.netlib.blas.BLAS; +import dev.ludovic.netlib.lapack.LAPACK; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ForkJoinPool; +import java.util.Random; +import java.util.stream.IntStream; +import org.apache.commons.math3.linear.RealMatrix; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.netlib.util.intW; + +/** + * Asymmetric Hashing (ASH) for float vectors. + * Encodes each vector into a fixed-length code using a learned or random + * orthonormal projection. The 1-bit case stores only sign bits; multibit ASH + * stores either generic sign/extra bits or the C++-style fast-scan projection code for 2/4-bit ASH. + */ +@Experimental +public class AsymmetricHashing implements VectorCompressor, Accountable { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(AsymmetricHashing.class); + public static final int ITQ = 1, RANDOM = 2; + private static final int MAGIC = 0x75EC4015; + /** The first on-disk graph format version that can store ASH (version 7, which added FUSED_ASH). */ + public static final int MIN_FORMAT_VERSION = 7; + + // --------------------------------------------------------------------- + // Training configuration (defaults used for all published results) + // --------------------------------------------------------------------- + + /** + * training_size = min(N, D * training_factor). + */ + private static final int ITQ_TRAINING_FACTOR = 20; + + /** Shared by sample selection and the existing random projection initialization. */ + private static final long TRAINING_SEED = 103; + + /** Bounds transpose scratch while keeping writes to the training matrix contiguous. */ + private static final int TRAINING_TRANSPOSE_TILE_ROWS = 32; + + /** ITQ iterations. */ + private static final int TRAINING_ITERS = 25; + + /** Default number of stored bits per projected dimension. */ + private static final int DEFAULT_BITS_PER_DIMENSION = 2; + + // Physical header size, reflecting actual stored fields: + // - scale: fp16 (16 bits), where scale = ||x − μ|| / ||code|| + // - offset: fp16 (16 bits), where offset = − ||μ||_2^2 + // - landmark id: byte (8 bits) in [0, C) + public static final int HEADER_BYTES = Short.BYTES + Short.BYTES + Byte.BYTES; + public static final int HEADER_BITS = HEADER_BYTES * Byte.SIZE; + + private static final VectorTypeSupport vectorTypeSupport = + VectorizationProvider.getInstance().getVectorTypeSupport(); + private static final VectorUtilSupport vectorUtilSupport = + VectorizationProvider.getInstance().getVectorUtilSupport(); + + // --------------------------------------------------------------------- + // Index-wide immutable state + // --------------------------------------------------------------------- + + /** Number of landmarks (clusters), {@code C <= 256}. */ + public final int landmarkCount; + + /** Landmark centroids μ_0... μ_{C−1}. */ + public final VectorFloat[] landmarks; + + // One entry per landmark + private final float[] landmarkNormSq; + + /** Original (uncompressed) dimensionality. */ + public final int originalDimension; + + /** Total bits per encoded vector (header + body). */ + public final int encodedBits; + + /** Number of bits in the encoded body. */ + public final int bodyBits; + + /** Number of projected dimensions in the ASH body. */ + public final int quantizedDim; + + /** Bits stored per projected dimension. */ + public final int bitsPerDimension; + + /** Optimizer / learning mode. */ + public final int optimizer; + + /** Learned or random Stiefel transform. */ + public final StiefelTransform stiefelTransform; + + /** + * Precomputed landmark projections in the reduced space: + * landmarkProj[c][j] = {@code } where A is [d][D] and μ_c is length D. + * + * Shape: [landmarkCount][quantizedDim] + */ + public final float[][] landmarkProj; + + /** + * Precomputed sums of the landmark projections: + * landmarkProjSum[c] = Σ_j landmarkProj[c][j] + * + * Shape: [landmarkCount] + */ + public final float[] landmarkProjSum; + + /** Debug hook to disable learning paths. */ + @VisibleForTesting + public boolean learn = true; + + // --------------------------------------------------------------------- + // Quantizer instances (cached) + // --------------------------------------------------------------------- + + // Immutable retained Java storage; native/query representations are charged lazily. + private final long heapBytes; + + // The largest length of any vector this model has encoded or holds codes for, or was saved with. Not part of the + // model's identity. + private final io.github.jbellis.jvector.util.MaxVectorLength maxEncoded = new io.github.jbellis.jvector.util.MaxVectorLength(); + + // Half-precision headers estimate a squared length to within about 0.1%; codes recorded from their headers are + // counted this much longer, so the recorded length stays an upper bound. + private static final float HEADER_NORM_MARGIN = 1f + 1f / 128f; + + void recordEncodedSquaredNorm(float squaredNorm) { + maxEncoded.recordSquared(squaredNorm); + } + + /** + * The largest length of any vector this model has encoded, including vectors encoded before it was saved and + * loaded, and of any vector whose codes {@link ASHVectors} has wrapped with it (estimated from the codes, with a + * margin for their half-precision headers). A vector's length is recorded before its encoding returns. With a + * query's length, it bounds the dot products ASH estimates for that query, which adaptive stopping needs to read + * ASH scores as distances. NaN until the model encodes or holds a vector, when dot-product searches stop by the + * standard rule. + */ + public float maxEncodedNorm() { + return maxEncoded.get(); + } + + // Both optimizers quantize the projected body the same way; they differ only in the + // learned transform. + private final BinaryQuantizer quantizer = new ProjectedBinaryQuantizer(); + + private BinaryQuantizer quantizer() { + if (optimizer != RANDOM && optimizer != ITQ) { + throw new IllegalStateException("Unknown optimizer " + optimizer); + } + return quantizer; + } + + /** + * Class constructor. + * @param originalDim the dimensionality of the vectors to be quantized + * @param landmarks the landmarks using for vectors (up to C in landmarkCount) + */ + private AsymmetricHashing(int originalDim, + int encodedBits, + int quantizedDim, + int bitsPerDimension, + int optimizer, + VectorFloat[] landmarks, + StiefelTransform stiefelTransform, + float[][] landmarkProj, + float[] landmarkProjSum) { + this.originalDimension = originalDim; + this.encodedBits = encodedBits; + this.quantizedDim = quantizedDim; + this.bitsPerDimension = bitsPerDimension; + this.bodyBits = encodedBits - HEADER_BITS; + + validateBitsPerDimension(bitsPerDimension); + + long expectedBodyBits = (long) quantizedDim * (long) bitsPerDimension; + if (bodyBits != expectedBodyBits) { + throw new IllegalArgumentException( + "Invalid ASH configuration: encodedBits=" + encodedBits + + ", quantizedDim=" + quantizedDim + + ", bitsPerDimension=" + bitsPerDimension + + ", HEADER_BITS=" + HEADER_BITS + + ", bodyBits=" + bodyBits + ); + } + + this.optimizer = optimizer; + this.stiefelTransform = Objects.requireNonNull(stiefelTransform); + + this.landmarks = Objects.requireNonNull(landmarks); + assert landmarks.length > 0; + this.landmarkCount = landmarks.length; + + // Precompute ||μ_c||^2 for each landmark (even when C = 1) + this.landmarkNormSq = new float[landmarkCount]; + for (int c = 0; c < landmarkCount; c++) { + this.landmarkNormSq[c] = + VectorUtil.dotProduct(landmarks[c], landmarks[c]); + } + + this.landmarkProj = Objects.requireNonNull(landmarkProj, "landmarkProj"); + this.landmarkProjSum = Objects.requireNonNull(landmarkProjSum, "landmarkProjSum"); + + if (landmarkProj.length != landmarkCount) { + throw new IllegalArgumentException("landmarkProj outer dimension mismatch: " + + landmarkProj.length + " != landmarkCount=" + landmarkCount); + } + if (landmarkProjSum.length != landmarkCount) { + throw new IllegalArgumentException("landmarkProjSum length mismatch: " + + landmarkProjSum.length + " != landmarkCount=" + landmarkCount); + } + for (int c = 0; c < landmarkCount; c++) { + if (landmarkProj[c] == null || landmarkProj[c].length != quantizedDim) { + throw new IllegalArgumentException("landmarkProj[" + c + "] length mismatch: " + + (landmarkProj[c] == null ? "null" : landmarkProj[c].length) + + " != quantizedDim=" + quantizedDim); + } + } + + if (landmarkNormSq.length != landmarkCount) { + throw new IllegalArgumentException("landmarkNormSq length mismatch"); + } + + if (landmarkCount < 1 || landmarkCount > 256) { + throw new IllegalArgumentException( + "landmarkCount must be in [1,256], got " + landmarkCount); + } + + for (VectorFloat mu : landmarks) { + if (mu.length() != originalDimension) { + throw new IllegalArgumentException( + "Landmark dimensionality does not match original dimensionality"); + } + } + + if (quantizedDim < 1) { + throw new IllegalArgumentException("quantizedDim must be > 0"); + } + if (quantizedDim > originalDimension) { + throw new IllegalArgumentException( + "quantizedDim (" + quantizedDim + ") exceeds original dimension (" + originalDimension + ")"); + } + long bytes = RamUsageEstimator.shallowSizeOfInstance(AsymmetricHashing.class) + + RamUsageEstimator.shallowSizeOf(landmarks) + + RamUsageEstimator.sizeOf(landmarkNormSq) + + estimateArray2DBytes(landmarkProj) + + RamUsageEstimator.sizeOf(landmarkProjSum) + + stiefelTransform.heapBytes + + RamUsageEstimator.shallowSizeOf(quantizer); + for (VectorFloat landmark : landmarks) bytes = Math.addExact(bytes, landmark.ramBytesUsed()); + this.heapBytes = bytes; + } + + /** + * Initialize ASH index-wide parameters using the legacy 1-bit body layout. + * + * @param ravv the vectors to quantize + * @param optimizer the optimizer to use + * @param encodedBits the number of bits used to encode vector, including the header + */ + public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits, + int landmarkCount) throws IOException { + return initialize(ravv, optimizer, encodedBits, landmarkCount, DEFAULT_BITS_PER_DIMENSION); + } + + /** + * Initialize ASH index-wide parameters. + * + *

With one landmark, the mean and learned projection use the same + * deterministic sample of {@code min(ravv.size(), 20 * ravv.dimension())} + * vectors. Training fetches each sampled vector once and leaves the full + * dataset pass to encoding. The sample size uses the original dimension, + * independently of the projected dimension and stored bit width.

+ * + * @param ravv the vectors to quantize + * @param optimizer the optimizer to use + * @param encodedBits the number of bits used to encode vector, including the header + * @param landmarkCount number of landmarks + * @param bitsPerDimension number of stored bits per projected dimension + * @throws IllegalArgumentException if the configuration is invalid, the source + * is empty, or a sampled vector has an invalid dimension or non-finite norm + * @throws NullPointerException if the source or a sampled vector is null + */ + public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits, + int landmarkCount, + int bitsPerDimension) throws IOException { + try (var blas = BlasThreads.singleUnlessLimited()) { + return train(ravv, optimizer, encodedBits, landmarkCount, bitsPerDimension); + } + } + + /** + * Initialize ASH index-wide parameters, letting native BLAS and LAPACK use as many threads as + * {@code executor} has. Without an executor, training uses one BLAS thread (see + * {@link BlasThreads}); other training work uses the library's physical-core pool either way. + * + * @param executor the pool whose parallelism sets the BLAS thread count + * @see #initialize(RandomAccessVectorValues, int, int, int, int) + */ + public static AsymmetricHashing initialize(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits, + int landmarkCount, + int bitsPerDimension, + ForkJoinPool executor) throws IOException { + Objects.requireNonNull(executor, "executor"); + try (var blas = BlasThreads.limit(Math.max(1, executor.getParallelism()))) { + return train(ravv, optimizer, encodedBits, landmarkCount, bitsPerDimension); + } + } + + private static AsymmetricHashing train(RandomAccessVectorValues ravv, + int optimizer, + int encodedBits, + int landmarkCount, + int bitsPerDimension) throws IOException { + validateBitsPerDimension(bitsPerDimension); + + if (landmarkCount < 1 || landmarkCount > 256) { + throw new IllegalArgumentException( + "landmarkCount must be in [1,256], got " + landmarkCount); + } + + Objects.requireNonNull(ravv, "ravv"); + if (ravv.size() <= 0) { + throw new IllegalArgumentException("ASH training requires at least one vector"); + } + if (optimizer != RANDOM && optimizer != ITQ) { + throw new IllegalArgumentException("Unknown optimizer " + optimizer); + } + int originalDim = ravv.dimension(); + if (originalDim <= 0) { + throw new IllegalArgumentException("ASH training requires a positive vector dimension"); + } + final int quantizedDim = validateEncodedBits(encodedBits, HEADER_BITS, originalDim, bitsPerDimension); + var ravvCopy = ravv.threadLocalSupplier().get(); + + LOG.info("ASH training: {} landmark(s), {} projected dimensions, {} bit(s) per dimension", + landmarkCount, quantizedDim, bitsPerDimension); + + // C=1 uses the same sample for its mean and learned projection. + // Sample selection preserves ITQ's existing RNG sequence. + final Random rng = new Random(TRAINING_SEED); + VectorFloat[] landmarks; + TrainingData trainingData = null; + if (landmarkCount == 1) { + SingleLandmarkTraining prepared = prepareSingleLandmarkTraining( + ravvCopy, originalDim, rng, optimizer == ITQ); + landmarks = new VectorFloat[]{prepared.landmark}; + trainingData = prepared.trainingData; + } else { + // Multi-landmark clustering still needs its materialized input. + VectorFloat[] points = new VectorFloat[ravvCopy.size()]; + for (int i = 0; i < points.length; i++) { + VectorFloat v = ravvCopy.getVector(i).copy(); + float norm = (float) Math.sqrt(VectorUtil.dotProduct(v, v)); + if (norm > 1e-10f) VectorUtil.scale(v, 1.0f / norm); + points[i] = v; + } + long kmStart = System.nanoTime(); + MiniBatchKMeansClusterer km = new MiniBatchKMeansClusterer(points, landmarkCount, 1024); + VectorFloat packed = km.cluster(/*unweightedIterations*/ 20, /*anisotropicIterations*/ 0); + long kmEnd = System.nanoTime(); + if (LOG.isInfoEnabled()) { + LOG.info("ASH landmark k-means ({} landmarks, {} vectors, {} dimensions) finished in {} s", + landmarkCount, points.length, originalDim, + String.format(java.util.Locale.ROOT, "%.1f", (kmEnd - kmStart) / 1e9)); + } + landmarks = new VectorFloat[landmarkCount]; + for (int c = 0; c < landmarkCount; c++) { + VectorFloat mu = vectorTypeSupport.createFloatVector(originalDim); + mu.copyFrom(packed, c * originalDim, 0, originalDim); + landmarks[c] = mu; + } + if (optimizer == ITQ) trainingData = prepareTrainingData(points, landmarks, originalDim, rng); + } + + StiefelTransform stiefelTransform = optimizer == RANDOM + ? runWithoutTraining(originalDim, quantizedDim, rng) + : runItqTrainer(trainingData, quantizedDim, bitsPerDimension, rng, TRAINING_ITERS); + + LandmarkProjections lp = + computeLandmarkProjections( + landmarks, + stiefelTransform, + quantizedDim, + originalDim + ); + + return new AsymmetricHashing( + originalDim, + encodedBits, + quantizedDim, + bitsPerDimension, + optimizer, + landmarks, + stiefelTransform, + lp.proj, + lp.sum + ); + } + + private static void validateBitsPerDimension(int bitsPerDimension) { + if (bitsPerDimension < 1 || bitsPerDimension > 9) { + throw new IllegalArgumentException( + "bitsPerDimension must be in [1,9], got " + bitsPerDimension); + } + } + + /** + * Validates encodedBits and returns the projected dimension count. + */ + private static int validateEncodedBits( + int encodedBits, + int headerBits, + int originalDim, + int bitsPerDimension + ) { + validateBitsPerDimension(bitsPerDimension); + + int payloadBits = encodedBits - headerBits; + + if (payloadBits < 1) { + throw new IllegalArgumentException( + "Illegal ASH payload bits: " + payloadBits + + " (encodedBits=" + encodedBits + ", headerBits=" + headerBits + ")" + ); + } + + if (payloadBits % bitsPerDimension != 0) { + throw new IllegalArgumentException( + "Invalid encodedBits=" + encodedBits + + ". ASH requires (encodedBits - headerBits) to be divisible by bitsPerDimension. " + + "Got payloadBits=" + payloadBits + + ", bitsPerDimension=" + bitsPerDimension + "." + ); + } + + int projectedDims = payloadBits / bitsPerDimension; + if (projectedDims < 1) { + throw new IllegalArgumentException("ASH projected dimensions must be > 0"); + } + if (projectedDims > originalDim) { + throw new IllegalArgumentException( + "Invalid ASH projectedDims=" + projectedDims + + " exceeds originalDim=" + originalDim + ); + } + + return projectedDims; + } + + /** + * Precompute landmark projections in the reduced space: + * + * landmarkProj[c][j] = {@code } + * landmarkProjSum[c] = Σ_j landmarkProj[c][j] + * + * This is used by both initialize() and load() to ensure + * identical query-time math regardless of construction path. + */ + private static LandmarkProjections computeLandmarkProjections( + VectorFloat[] landmarks, + StiefelTransform stiefelTransform, + int quantizedDim, + int originalDim + ) { + final int C = landmarks.length; + // Use a double-precision matrix for the projection + final double[][] A = stiefelTransform.AData; // [d][D] + + final float[][] landmarkProj = new float[C][quantizedDim]; + final float[] landmarkProjSum = new float[C]; + + // Materialize μ as double[] to maintain precision during the dot product + final double[] muArr = new double[originalDim]; + + for (int c = 0; c < C; c++) { + VectorFloat mu = landmarks[c]; + + for (int i = 0; i < originalDim; i++) { + muArr[i] = mu.get(i); + } + + double totalSum = 0.0; + float[] proj = landmarkProj[c]; + + for (int j = 0; j < quantizedDim; j++) { + double rowDot = 0.0; + double[] Arow = A[j]; + + // Double-precision dot product + for (int k = 0; k < originalDim; k++) { + rowDot += Arow[k] * muArr[k]; + } + + proj[j] = (float) rowDot; + totalSum += rowDot; + } + + landmarkProjSum[c] = (float) totalSum; + } + + return new LandmarkProjections(landmarkProj, landmarkProjSum); + } + + private static final class LandmarkProjections { + final float[][] proj; // [C][d] + final float[] sum; // [C] + + LandmarkProjections(float[][] proj, float[] sum) { + this.proj = proj; + this.sum = sum; + } + } + + /** + * Loads an ASH instance from a RandomAccessReader. + * This must mirror the ASH header writer exactly. + */ + private static void checkFormatVersion(int version) { + if (version < MIN_FORMAT_VERSION || version > OnDiskGraphIndex.CURRENT_VERSION) { + throw new IllegalArgumentException("ASH requires on-disk format version " + MIN_FORMAT_VERSION + + " to " + OnDiskGraphIndex.CURRENT_VERSION + ", got " + version); + } + } + + public static AsymmetricHashing load(RandomAccessReader in) throws IOException { + + int magic = in.readInt(); + if (magic != MAGIC) { + throw new IllegalArgumentException( + String.format("Invalid ASH magic: 0x%08X", magic) + ); + } + + checkFormatVersion(in.readInt()); + + int originalDimension = in.readInt(); + int encodedBits = in.readInt(); + int quantizedDim = in.readInt(); + int bitsPerDimension = in.readInt(); + + int expectedQuantizedDim = validateEncodedBits( + encodedBits, + HEADER_BITS, + originalDimension, + bitsPerDimension); + if (quantizedDim != expectedQuantizedDim) { + throw new IOException( + "ASH format mismatch: encodedBits=" + encodedBits + + ", quantizedDim=" + quantizedDim + + ", bitsPerDimension=" + bitsPerDimension + + ", but runtime HEADER_BITS=" + HEADER_BITS + + " implies quantizedDim=" + expectedQuantizedDim + ); + } + + int optimizer = in.readInt(); + + // Multi-landmark support + int landmarkCount = in.readInt(); + if (landmarkCount < 1 || landmarkCount > 256) { + throw new IOException("Invalid landmarkCount=" + landmarkCount); + } + + VectorFloat[] landmarks = new VectorFloat[landmarkCount]; + for (int c = 0; c < landmarkCount; c++) { + int dim = in.readInt(); + if (dim != originalDimension) { + throw new IOException( + "Landmark dimension mismatch: expected " + + originalDimension + ", got " + dim); + } + landmarks[c] = vectorTypeSupport.readFloatVector(in, dim); + } + + // Load StiefelTransform. + StiefelTransform stiefelTransform = readStiefelTransform(in, quantizedDim, originalDimension); + + LandmarkProjections lp = + computeLandmarkProjections( + landmarks, + stiefelTransform, + quantizedDim, + originalDimension + ); + + float maxEncodedNorm = in.readFloat(); + var ash = new AsymmetricHashing( + originalDimension, + encodedBits, + quantizedDim, + bitsPerDimension, + optimizer, + landmarks, + stiefelTransform, + lp.proj, + lp.sum + ); + ash.maxEncoded.restore(maxEncodedNorm); + return ash; + } + + + /** + * Writes the ASH model: the ASH magic number ({@code 0x75EC4015}), the on-disk graph format + * version, the dimensions, bit widths and optimizer, the landmarks, and the Stiefel transform. + * {@link #load} checks the magic number and version before reading the rest. + * + * @param out the IndexWriter into which to write the object + * @param version the on-disk graph format version, from {@link #MIN_FORMAT_VERSION} to + * {@link OnDiskGraphIndex#CURRENT_VERSION} + * @throws IllegalArgumentException if the version cannot store ASH + * @throws IOException if there is a problem writing to out. + */ + public void write(IndexWriter out, int version) throws IOException { + checkFormatVersion(version); + + out.writeInt(MAGIC); + out.writeInt(version); + + out.writeInt(originalDimension); + out.writeInt(encodedBits); + out.writeInt(quantizedDim); + out.writeInt(bitsPerDimension); + out.writeInt(optimizer); + + // Multi-landmark serialization + out.writeInt(landmarkCount); + + for (int c = 0; c < landmarkCount; c++) { + out.writeInt(landmarks[c].length()); + vectorTypeSupport.writeFloatVector(out, landmarks[c]); + } + + // Stiefel transform serialization: + // Serialize A = W^T in float precision as a [d][D] row-major matrix. + writeStiefelTransform(out, stiefelTransform); + out.writeFloat(maxEncodedNorm()); + } + + // --------------------------------------------------------------------- + // Index-level quantization entry point + // --------------------------------------------------------------------- + + public float quantizeVector(VectorFloat vector, + float residualNorm, + byte landmark, + QuantizedVector dest) { + + if (quantizedDim > originalDimension) { + throw new IllegalArgumentException( + "quantizedDim (" + quantizedDim + + ") exceeds original dimension (" + originalDimension + ")"); + } + + if ((landmark & 0xFF) >= landmarkCount) { + throw new IllegalArgumentException("Invalid landmark id " + (landmark & 0xFF) + + " for landmarkCount=" + landmarkCount); + } + + // Landmark-specific mean (C=1 → landmarks[0] is the sampled mean) + final VectorFloat mu = landmarks[landmark & 0xFF]; + + return QuantizedVector.quantizeTo( + vector, + residualNorm, + quantizedDim, + bitsPerDimension, + mu, + stiefelTransform, + quantizer(), + dest + ); + } + + + // --------------------------------------------------------------------- + // Projection-code layout helpers + // --------------------------------------------------------------------- + + /** + * Fast-scan projection codes use C++-compatible 4-bit projection groups. + * Core ASH supports bitsPerDimension in [1,9], but this layout is only + * defined for the nibble-friendly multibit cases used by FusedASH. + */ + static boolean usesFastScanProjectionCode(int bitsPerDimension) { + return bitsPerDimension == 2 || bitsPerDimension == 4; + } + + /** + * Whether codes are offset-binary fields: dimension {@code j} is the unsigned {@code b}-bit field at bits + * {@code [j*b, (j+1)*b)} of the little-endian body, with value {@code field - (2^b - 1) / 2}. Every width + * except 1 bit (sign words) and the 2/4-bit sign-magnitude projection codes uses this layout. + */ + static boolean usesOffsetBinaryCode(int bitsPerDimension) { + return bitsPerDimension != 1 && !usesFastScanProjectionCode(bitsPerDimension); + } + + /** Field {@code j} of an offset-binary code whose body starts at {@code codeOffset}. */ + static int offsetBinaryField(byte[] code, int codeOffset, int j, int bitsPerDimension) { + int bit = j * bitsPerDimension, at = codeOffset + (bit >>> 3), shift = bit & 7; + int window = code[at] & 0xFF; + if (shift + bitsPerDimension > 8) window |= (code[at + 1] & 0xFF) << 8; + if (shift + bitsPerDimension > 16) window |= (code[at + 2] & 0xFF) << 16; + return (window >>> shift) & ((1 << bitsPerDimension) - 1); + } + + /** ORs {@code field} into dimension {@code j} of a zeroed offset-binary code. */ + static void setOffsetBinaryField(byte[] code, int j, int bitsPerDimension, int field) { + int bit = j * bitsPerDimension; + for (int t = 0; t < bitsPerDimension; t++, bit++) { + if ((field & (1 << t)) != 0) code[bit >>> 3] |= (byte) (1 << (bit & 7)); + } + } + + /** The value of an offset-binary field: an odd multiple of one half, centered on zero. */ + static float decodeOffsetBinary(int field, int bitsPerDimension) { + return field - ((1 << bitsPerDimension) - 1) * 0.5f; + } + + /** {@code } for an offset-binary code starting at {@code codeOffset}. */ + static float dotOffsetBinaryCode(float[] v, byte[] code, int codeOffset, int quantizedDim, int bitsPerDimension) { + float sum = 0f; + for (int j = 0; j < quantizedDim; j++) { + sum += v[j] * decodeOffsetBinary(offsetBinaryField(code, codeOffset, j, bitsPerDimension), bitsPerDimension); + } + return sum; + } + + /** + * {@code
}, the landmark term that every stored offset includes as + * {@code -scale * }, so that asymmetric scoring needs only {@code Aq}. + */ + float landmarkCodeDot(int landmark, long[] signs, int signOffset, byte[] body, int bodyOffset) { + float[] projected = landmarkProj[landmark]; + if (usesFastScanProjectionCode(bitsPerDimension)) { + return dotProjectionCode(projected, body, bodyOffset, quantizedDim, bitsPerDimension); + } + if (bitsPerDimension != 1) return dotOffsetBinaryCode(projected, body, bodyOffset, quantizedDim, bitsPerDimension); + // Signs s = 2b - 1, so = 2 * (sum of A mu over set bits) - sum(A mu). + float positive = 0f; + int words = QuantizedVector.wordsForDims(quantizedDim); + for (int w = 0, base = 0; w < words; w++, base += 64) { + long word = signs[signOffset + w]; + while (word != 0L) { + int j = base + Long.numberOfTrailingZeros(word); + if (j < quantizedDim) positive += projected[j]; + word &= word - 1; + } + } + return 2f * positive - landmarkProjSum[landmark]; + } + + static int projectionDimsPerNibble(int bitsPerDimension) { + if (bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException( + "projection fast-scan layout supports bitsPerDimension in {1,2,4}, got " + + bitsPerDimension); + } + return 4 / bitsPerDimension; + } + + static int projectionCodeGroups(int quantizedDim, int bitsPerDimension) { + int groupDims = projectionDimsPerNibble(bitsPerDimension); + return (quantizedDim + groupDims - 1) / groupDims; + } + + static int projectionCodeBytesForDims(int quantizedDim, int bitsPerDimension) { + return (projectionCodeGroups(quantizedDim, bitsPerDimension) + 1) >>> 1; + } + + static void setFlatNibble(byte[] code, int group, int value) { + int idx = group >>> 1; + int v = value & 0x0F; + if ((group & 1) == 0) { + code[idx] = (byte) ((code[idx] & 0xF0) | v); + } else { + code[idx] = (byte) ((code[idx] & 0x0F) | (v << 4)); + } + } + + static int flatNibble(byte[] code, int group) { + return flatNibble(code, 0, group); + } + + /** Nibble {@code group} of the code starting at {@code codeOffset}. */ + static int flatNibble(byte[] code, int codeOffset, int group) { + int v = code[codeOffset + (group >>> 1)] & 0xFF; + return ((group & 1) == 0) ? (v & 0x0F) : ((v >>> 4) & 0x0F); + } + + static int projectionFieldForDimension( + byte[] projectionCode, + int dimension, + int bitsPerDimension + ) { + return projectionFieldForDimension(projectionCode, 0, dimension, bitsPerDimension); + } + + static int projectionFieldForDimension(byte[] projectionCode, int codeOffset, int dimension, int bitsPerDimension) { + int groupDims = projectionDimsPerNibble(bitsPerDimension); + int group = dimension / groupDims; + int slot = dimension - group * groupDims; + int mask = (1 << bitsPerDimension) - 1; + return (flatNibble(projectionCode, codeOffset, group) >>> (slot * bitsPerDimension)) & mask; + } + + static float decodeProjectionComponent(int field, int bitsPerDimension) { + if (bitsPerDimension == 1) { + return field != 0 ? 1.0f : -1.0f; + } + + int exBits = bitsPerDimension - 1; + int magMask = (1 << exBits) - 1; + boolean positive = ((field >>> exBits) & 1) != 0; + float mag = (field & magMask) + 0.5f; + return positive ? mag : -mag; + } + + static float projectionComponent( + byte[] projectionCode, + int dimension, + int bitsPerDimension + ) { + return projectionComponent(projectionCode, 0, dimension, bitsPerDimension); + } + + /** Component {@code dimension} of the code starting at {@code codeOffset}. */ + static float projectionComponent(byte[] projectionCode, int codeOffset, int dimension, int bitsPerDimension) { + return decodeProjectionComponent( + projectionFieldForDimension(projectionCode, codeOffset, dimension, bitsPerDimension), + bitsPerDimension); + } + + static float dotProjectionCode( + float[] projectedVector, + byte[] projectionCode, + int quantizedDim, + int bitsPerDimension + ) { + return dotProjectionCode(projectedVector, projectionCode, 0, quantizedDim, bitsPerDimension); + } + + /** Scalar projection dot with the code starting at {@code codeOffset}. */ + static float dotProjectionCode(float[] projectedVector, byte[] projectionCode, int codeOffset, + int quantizedDim, int bitsPerDimension) { + int groupDims = projectionDimsPerNibble(bitsPerDimension); + int groups = projectionCodeGroups(quantizedDim, bitsPerDimension); + float sum = 0.0f; + + for (int group = 0; group < groups; group++) { + int nibble = flatNibble(projectionCode, codeOffset, group); + int baseDim = group * groupDims; + for (int slot = 0; slot < groupDims; slot++) { + int dim = baseDim + slot; + if (dim >= quantizedDim) { + break; + } + int field = (nibble >>> (slot * bitsPerDimension)) & ((1 << bitsPerDimension) - 1); + sum += projectedVector[dim] * decodeProjectionComponent(field, bitsPerDimension); + } + } + + return sum; + } + + /** Header-calibrated correction for symmetric scoring, using the stored code/header. */ + float centeredOffset(QuantizedVector vector) { + return centeredOffset(vector.scale, vector.offset, vector.landmark, vector.binaryVector, 0, vector.extraBits, 0); + } + + /** The squared length of a code's decoded components: the norm its scale is divided by. */ + float codeSquaredNorm(QuantizedVector vector) { + if (bitsPerDimension == 1) return quantizedDim; + boolean projection = usesFastScanProjectionCode(bitsPerDimension); + double sum = 0; + for (int j = 0; j < quantizedDim; j++) { + float v = projection ? projectionComponent(vector.extraBits, 0, j, bitsPerDimension) + : decodeOffsetBinary(offsetBinaryField(vector.extraBits, 0, j, bitsPerDimension), bitsPerDimension); + sum += v * v; + } + return (float) sum; + } + + /** + * The squared length of the vector {@code vector} encodes, from its stored terms: + * {@code ||x||^2 = ||x - mu||^2 + 2 ( - ||mu||^2) + ||mu||^2}, with {@code ||x - mu|| = scale * ||code||}. + * Exact up to the half-precision rounding of the header. + */ + float encodedSquaredNorm(QuantizedVector vector) { + int c = vector.landmark & 255; + return vector.scale * vector.scale * codeSquaredNorm(vector) + 2f * centeredOffset(vector) + landmarkNormSq[c]; + } + + /** + * Counts vectors encoded by another instance of this model in {@link #maxEncodedNorm}, from their codes, with a + * margin for the half-precision headers. + */ + void recordEncodedCodes(QuantizedVector[] vectors) { + for (var vector : vectors) { + if (vector != null) recordEncodedSquaredNorm(encodedSquaredNorm(vector) * HEADER_NORM_MARGIN); + } + } + + /** + * As {@link #centeredOffset(QuantizedVector)} for a header with sign words at {@code signOffset} + * and a body at {@code bodyOffset}; only the array the bit width stores is read. + */ + float centeredOffset(float scale, float offset, byte landmark, long[] signs, int signOffset, + byte[] body, int bodyOffset) { + int c = landmark & 255; + if (c >= landmarkCount) throw new IllegalArgumentException("Invalid landmark " + c); + return offset + scale * landmarkCodeDot(c, signs, signOffset, body, bodyOffset); + } + + // --------------------------------------------------------------------- + // Quantization strategies + // --------------------------------------------------------------------- + + public interface BinaryQuantizer { + float quantizeBody(VectorFloat vector, + VectorFloat mu, + float residualNorm, + int quantizedDim, + int bitsPerDimension, + StiefelTransform stiefel, + long[] outSignWords, + byte[] outExtraBits); + } + + /** Quantizes the projected residual body into sign words and extra magnitude bits. */ + public static final class ProjectedBinaryQuantizer implements BinaryQuantizer { + // ThreadLocal to hold the reusable buffers. + // Using a simple container class to avoid multiple ThreadLocal lookups. + private static final ThreadLocal WORKSPACE = ThreadLocal.withInitial(Workspace::new); + + private static final class Workspace { + float[] xhat; + float[] proj; + double[] absNorm; + + void ensureCapacity(int originalDim, int quantizedDim) { + if (xhat == null || xhat.length != originalDim) { + xhat = new float[originalDim]; + } + if (proj == null || proj.length != quantizedDim) { + proj = new float[quantizedDim]; + absNorm = new double[quantizedDim]; + } + } + } + + @Override + public float quantizeBody(VectorFloat vector, + VectorFloat mu, + float residualNorm, + int quantizedDim, + int bitsPerDimension, + StiefelTransform stiefel, + long[] outSignWords, + byte[] outExtraBits) { + + Workspace ws = WORKSPACE.get(); + ws.ensureCapacity(stiefel.cols, quantizedDim); + normalizeResidual(vector, mu, residualNorm, ws.xhat); + // Encoding always uses the original FP32 projection, independently + // of the model's query-only FP16 view. + stiefel.project(ws.xhat, ws.proj); + return quantizeProjected(ws.proj, bitsPerDimension, outSignWords, + outExtraBits, ws.absNorm); + } + + /** Shared code generation for singleton and bulk projections. */ + private static float quantizeProjected(float[] proj, int bitsPerDimension, + long[] outSignWords, byte[] outExtraBits, + double[] absNorm) { + final int quantizedDim = proj.length; + if (bitsPerDimension == 1) { + // Binarize directly from per-row projection. + final int words = QuantizedVector.wordsForDims(quantizedDim); + for (int w = 0; w < words; w++) { + long bits = 0L; + int base = w << 6; + int rem = Math.min(64, quantizedDim - base); + + for (int j = 0; j < rem; j++) { + int bitIndex = base + j; + float acc = proj[bitIndex]; + if (acc > 0.0f) { + bits |= (1L << j); + } + } + outSignWords[w] = bits; + } + + return (float) Math.sqrt(quantizedDim); + } + + final int exBits = bitsPerDimension - 1; + final int maxCode = (1 << exBits) - 1; + double projNorm2 = 0.0; + for (int j = 0; j < quantizedDim; j++) { + float acc = proj[j]; + projNorm2 += (double) acc * (double) acc; + } + + final double invProjNorm = 1.0 / Math.max(Math.sqrt(projNorm2), 1e-20); + for (int j = 0; j < quantizedDim; j++) { + absNorm[j] = Math.abs((double) proj[j]) * invProjNorm; + } + + final double t = computeOptimalScalingFactor(absNorm, quantizedDim, bitsPerDimension); + + if (usesFastScanProjectionCode(bitsPerDimension)) { + return quantizeFastScanProjectionCode( + proj, + absNorm, + t, + quantizedDim, + bitsPerDimension, + outExtraBits); + } + + // Offset-binary: magnitude index q is field 2^(b-1) + q when positive, 2^(b-1) - 1 - q otherwise. + final int half = 1 << exBits; + double codeNorm2 = 0.0; + for (int j = 0; j < quantizedDim; j++) { + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); + double mag = q + 0.5; + codeNorm2 += mag * mag; + setOffsetBinaryField(outExtraBits, j, bitsPerDimension, proj[j] >= 0.0f ? half + q : half - 1 - q); + } + + return (float) Math.sqrt(Math.max(codeNorm2, 1e-20)); + } + + private static float quantizeFastScanProjectionCode( + float[] proj, + double[] absNorm, + double t, + int quantizedDim, + int bitsPerDimension, + byte[] outProjectionCode + ) { + final int groupDims = projectionDimsPerNibble(bitsPerDimension); + final int groups = projectionCodeGroups(quantizedDim, bitsPerDimension); + final int exBits = bitsPerDimension - 1; + final int maxCode = (1 << exBits) - 1; + + double codeNorm2 = 0.0; + for (int group = 0; group < groups; group++) { + int nibble = 0; + int baseDim = group * groupDims; + + for (int slot = 0; slot < groupDims; slot++) { + int j = baseDim + slot; + if (j >= quantizedDim) { + break; + } + + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); + boolean positive = proj[j] >= 0.0f; + int field = (positive ? (1 << exBits) : 0) | q; + nibble |= field << (slot * bitsPerDimension); + + double mag = q + 0.5; + codeNorm2 += mag * mag; + } + + setFlatNibble(outProjectionCode, group, nibble); + } + + return (float) Math.sqrt(Math.max(codeNorm2, 1e-20)); + } + } + + // --------------------------------------------------------------------- + // Quantized vector (per-vector data) + // --------------------------------------------------------------------- + + public static class QuantizedVector implements Accountable { + private static final long SHALLOW_BYTES = RamUsageEstimator.shallowSizeOfInstance(QuantizedVector.class); + + /** Owned heap storage, including empty arrays and their headers; not the disk size. */ + @Override + public long ramBytesUsed() { + return SHALLOW_BYTES + RamUsageEstimator.shallowSizeOf(binaryVector) + RamUsageEstimator.shallowSizeOf(extraBits); + } + + // Decoded arithmetic values, rounded to binary16 by encoding/loading. + // They are serialized as two bytes each, not as Java float32 fields. + public float scale; // ||x_i - μ_i*||_2 / ||encoded body||_2 + public float offset; // offset_i = - ||μ_i*||^2 + public byte landmark; // c_i*, unsigned [0, C) + public long[] binaryVector; // 1-bit codes only: sign bits; bit=1 means positive code value + public byte[] extraBits; // multibit codes only: packed fields (see usesOffsetBinaryCode) + + private QuantizedVector(float scale, + float offset, + byte landmark, + long[] binaryVector, + byte[] extraBits) { + this.scale = scale; + this.offset = offset; + this.landmark = landmark; + this.binaryVector = binaryVector; + this.extraBits = extraBits; + } + + public static int wordsForDims(int quantizedDim) { + return (quantizedDim + 63) >>> 6; + } + + /** Sign words: only 1-bit codes store them. */ + public static int signWordsForDims(int quantizedDim, int bitsPerDimension) { + return bitsPerDimension == 1 ? wordsForDims(quantizedDim) : 0; + } + + /** Body bytes: the packed fields of every multibit code, none for 1-bit codes. */ + public static int bodyBytesForDims(int quantizedDim, int bitsPerDimension) { + if (bitsPerDimension == 1) return 0; + if (usesFastScanProjectionCode(bitsPerDimension)) { + return projectionCodeBytesForDims(quantizedDim, bitsPerDimension); + } + return Math.toIntExact(((long) quantizedDim * bitsPerDimension + 7) >>> 3); + } + + public static QuantizedVector createEmpty(int quantizedDim) { + return createEmpty(quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static QuantizedVector createEmpty(int quantizedDim, int bitsPerDimension) { + return new QuantizedVector( + Float.NaN, + Float.NaN, + (byte) 0, + new long[signWordsForDims(quantizedDim, bitsPerDimension)], + new byte[bodyBytesForDims(quantizedDim, bitsPerDimension)] + ); + } + + public static int serializedSizeBytes(int quantizedDim) { + return serializedSizeBytes(quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static int serializedSizeBytes(int quantizedDim, int bitsPerDimension) { + return HEADER_BYTES + + signWordsForDims(quantizedDim, bitsPerDimension) * Long.BYTES + + bodyBytesForDims(quantizedDim, bitsPerDimension); + } + + /** + * Writes only the encoded body into {@code dest}. + * + *

Header fields ({@code offset}, {@code landmark}) must be set by the caller + * before calling this method. {@code scale} is set by the caller after this method + * returns the encoded body norm.

+ * + * @param residualNorm ||x - μ||_2, required for Eq. 6 normalization during encoding + * @return norm of the stored centered code body + */ + static float quantizeTo(VectorFloat vector, + float residualNorm, + int quantizedDim, + int bitsPerDimension, + VectorFloat mu, + StiefelTransform stiefel, + BinaryQuantizer quantizer, + QuantizedVector dest) { + + assert Float.isFinite(dest.offset) : "offset is not finite"; + + int words = signWordsForDims(quantizedDim, bitsPerDimension); + if (dest.binaryVector == null || dest.binaryVector.length < words) { + throw new IllegalArgumentException("binaryVector too short"); + } + + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + if (dest.extraBits == null || dest.extraBits.length < bodyBytes) { + throw new IllegalArgumentException("extraBits/projectionCode too short"); + } + + if (words > 0) { + Arrays.fill(dest.binaryVector, 0, words, 0L); + } + if (bodyBytes > 0) { + Arrays.fill(dest.extraBits, 0, bodyBytes, (byte) 0); + } + + return quantizer.quantizeBody( + vector, + mu, + residualNorm, + quantizedDim, + bitsPerDimension, + stiefel, + dest.binaryVector, + dest.extraBits + ); + } + + public void write(IndexWriter out, int quantizedDim) throws IOException { + write(out, quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public void write(IndexWriter out, int quantizedDim, int bitsPerDimension) throws IOException { + writeFloat16(out, scale); + writeFloat16(out, offset); + out.writeByte(landmark); + + int words = signWordsForDims(quantizedDim, bitsPerDimension); + for (int i = 0; i < words; i++) { + out.writeLong(binaryVector[i]); + } + + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + for (int i = 0; i < bodyBytes; i++) { + out.writeByte(extraBits[i]); + } + } + + public static QuantizedVector load(RandomAccessReader in, + int quantizedDim) throws IOException { + return load(in, quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static QuantizedVector load(RandomAccessReader in, + int quantizedDim, + int bitsPerDimension) throws IOException { + float scale = readFloat16(in); + float offset = readFloat16(in); + + byte landmark; + if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { + landmark = ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte(); + } else { + throw new IOException("ASH requires ByteReadable reader for landmark"); + } + + int words = signWordsForDims(quantizedDim, bitsPerDimension); + long[] signWords = new long[words]; + for (int i = 0; i < words; i++) { + signWords[i] = in.readLong(); + } + + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + byte[] body = new byte[bodyBytes]; + if (bodyBytes > 0) { + in.readFully(body); + } + + return new QuantizedVector(scale, offset, landmark, signWords, body); + } + + public static void loadInto(RandomAccessReader in, + QuantizedVector dest, + int quantizedDim) throws IOException { + loadInto(in, dest, quantizedDim, DEFAULT_BITS_PER_DIMENSION); + } + + public static void loadInto(RandomAccessReader in, + QuantizedVector dest, + int quantizedDim, + int bitsPerDimension) throws IOException { + dest.scale = readFloat16(in); + dest.offset = readFloat16(in); + + if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { + dest.landmark = ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte(); + } else { + throw new IOException("ASH requires ByteReadable reader for landmark"); + } + + int words = signWordsForDims(quantizedDim, bitsPerDimension); + if (dest.binaryVector == null || dest.binaryVector.length != words) { + dest.binaryVector = new long[words]; + } + for (int i = 0; i < words; i++) { + dest.binaryVector[i] = in.readLong(); + } + + int bodyBytes = bodyBytesForDims(quantizedDim, bitsPerDimension); + if (dest.extraBits == null || dest.extraBits.length != bodyBytes) { + dest.extraBits = new byte[bodyBytes]; + } + if (bodyBytes > 0) { + in.readFully(dest.extraBits); + } + } + + static void setBit(long[] words, int bitIndex) { + words[bitIndex >>> 6] |= 1L << (bitIndex & 63); + } + + static boolean getBit(long[] words, int bitIndex) { + return getBit(words, 0, bitIndex); + } + + /** Bit {@code bitIndex} of the sign words starting at {@code wordOffset}. */ + static boolean getBit(long[] words, int wordOffset, int bitIndex) { + return ((words[wordOffset + (bitIndex >>> 6)] >>> (bitIndex & 63)) & 1L) != 0L; + } + + /** + * Compares two ASH quantized vectors for exact encoded equality. + * + *

+ * Equality is defined as bitwise equality of all encoded components: + *

    + *
  • Sign-code payload
  • + *
  • Extra-bit payload
  • + *
  • Associated landmark index
  • + *
  • Auxiliary scalar values
  • + *
+ * + *

+ * Floating-point values are compared using their raw bit representations + * rather than tolerance-based comparisons. + *

+ */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + QuantizedVector that = (QuantizedVector) o; + return Float.floatToIntBits(scale) == Float.floatToIntBits(that.scale) + && Float.floatToIntBits(offset) == Float.floatToIntBits(that.offset) + && landmark == that.landmark + && Arrays.equals(binaryVector, that.binaryVector) + && Arrays.equals(extraBits, that.extraBits); + } + + /** + * Hash code consistent with {@link #equals(Object)}. + */ + @Override + public int hashCode() { + int result = Integer.hashCode(Float.floatToIntBits(scale)); + result = 31 * result + Integer.hashCode(Float.floatToIntBits(offset)); + result = 31 * result + Short.hashCode(landmark); + result = 31 * result + Arrays.hashCode(binaryVector); + result = 31 * result + Arrays.hashCode(extraBits); + return result; + } + } + + /** + * Private helper to provide byte storage for binary body. + * @param nBits the number of bits to store + * @return the byte sequence + */ + private static ByteSequence createBodyByteSequenceForBits(int nBits) { + int nBytes = (nBits + 7) >>> 3; + return vectorTypeSupport.createByteSequence(nBytes); + } + + /** + * Orthonormal linear transform on the Stiefel manifold. + * W : originalDim × quantizedDim + * A : quantizedDim × originalDim, where A = Wᵀ + * Concurrent query projection and caller CPU budgeting are described in + * {@code docs/ash-query-integration.md}. + * The transform is applied as: + * y = A · (x − μ) + */ + public static final class StiefelTransform { + + /** Forward projection (column-orthonormal). Shape: [originalDim × quantizedDim]. */ + public final RealMatrix W; + + /** + * Cached row-major backing for A in double precision (A = Wᵀ). + * Used for training, reference, and correctness-sensitive math. + * + * AData must not be mutated after construction. + * Shape: [quantizedDim × originalDim]. + */ + public final double[][] AData; + + /** + * Cached row-major backing for A in float precision (A = Wᵀ). + * Used for encoding and scoring fast paths. + * AFloat must not be mutated after construction. + * Shape: [quantizedDim × originalDim]. + */ + public final float[][] AFloat; + + // Prepared once per immutable model, including models loaded from disk. + private final VectorUtilSupport.AshProjection projection; + + // Prepared only when this model is used for queries. Encoding-only work + // must not allocate an extra matrix or change its arithmetic precision. + private volatile VectorUtilSupport.AshProjection queryProjection; + private long queryProjectionHeapBytes; + private final long heapBytes; + + // Default group capacity. Larger configured groups are supported by + // grow-once caller scratch; native code controls its own micro-tiling. + private static final int QUERY_BATCH_SIZE = 8; + // Published together with queryProjection. Training/encoding never + // initializes query-only dispatch or its retained storage. + private CallerBatchProcessor queryBatches; + private final int queryBatchSize; + private static final class QueryBatchRows { + float[][] input = new float[QUERY_BATCH_SIZE][]; + float[][] output = new float[QUERY_BATCH_SIZE][]; + void ensure(int count) { + if (input.length >= count) return; + float[][] nextInput = new float[count][]; + float[][] nextOutput = new float[count][]; + input = nextInput; + output = nextOutput; + } + } + // Scratch holds row references only during a batch and never a model. + private static final ThreadLocal QUERY_BATCH_ROWS = + ThreadLocal.withInitial(QueryBatchRows::new); + + /** Projects a search query; encoding and training continue through project(). */ + public void projectQuery(float[] query, float[] out) { + var projection = queryProjection(); // Acquire the matching dispatcher too. + var batches = queryBatches; + if (batches == null || !projection.supportsBatchProjection()) projection.project(query, out); + else batches.process(query, out); + } + + /** Prepared-query callers already own scheduling; avoid a second combining layer. */ + public void projectQueryDirect(float[] query, float[] out) { + queryProjection().project(query, out); + } + + /** Executes a caller-formed batch with the query precision, never the encoding view. */ + public void projectQueryBatch(float[][] queries, float[][] out, int count) { + queryProjection().projectBatch(queries, out, count); + } + + /** Capability inspection may lazily prepare the immutable query representation. */ + public boolean supportsQueryBatchProjection() { + return queryProjection().supportsBatchProjection(); + } + + /** Snapshot of completed/claimed batch counts, or null when combining is disabled. */ + public long[] queryProjectionBatchHistogram() { + var projection = queryProjection; // Inspection must not prepare a query view. + return projection == null || queryBatches == null ? null : queryBatches.batchHistogram(); + } + + /** Reports the prepared view without initializing or changing query execution. */ + public String initializedQueryProjectionPrecision() { + var ready = queryProjection; + return ready == null ? null : ready.operandPrecision(); + } + + private VectorUtilSupport.AshProjection queryProjection() { + var ready = queryProjection; + if (ready == null) { + synchronized (this) { + ready = queryProjection; + if (ready == null) { + ready = projection.forQueryScoring(); + long retained = ready == projection ? 0 : RamUsageEstimator.shallowSizeOf(ready); + if (queryBatchSize > 1 && ready.supportsBatchProjection()) { + final var target = ready; + var operation = new CallerBatchProcessor.Operation() { + @Override + public void apply(float[] input, float[] output) { target.project(input, output); } + @Override + public void applyBatch(List inputs, List outputs) { + QueryBatchRows rows = QUERY_BATCH_ROWS.get(); + int count = inputs.size(); + rows.ensure(count); + try { + for (int i = 0; i < count; i++) { + rows.input[i] = inputs.get(i); + rows.output[i] = outputs.get(i); + } + target.projectBatch(rows.input, rows.output, count); + } finally { + Arrays.fill(rows.input, 0, count, null); + Arrays.fill(rows.output, 0, count, null); + } + } + }; + int budget = Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), + PhysicalCoreExecutor.getPhysicalCoreCount())); + queryBatches = CallerBatchProcessor.opportunistic( + budget, queryBatchSize, operation); + retained += queryBatches.ramBytesUsed() + RamUsageEstimator.shallowSizeOf(operation); + } + queryProjectionHeapBytes = retained; + queryProjection = ready; // Publish the representation and its heap charge together. + } + } + } + return ready; + } + + /** Projects one vector with the original FP32 representation, including encoding/training. */ + public void project(float[] query, float[] out) { + projection.project(query, out); + } + + /** Projects base vectors in FP32; query precision selection does not apply. */ + public void projectBatch(float[][] vectors, float[][] out, int count) { + projection.projectBatch(vectors, out, count); + } + + /** Number of rows in A (quantizedDim = d). */ + public final int rows; + + /** Number of columns in A (originalDim = D). */ + public final int cols; + + public StiefelTransform(RealMatrix W) { + this.W = Objects.requireNonNull(W, "W"); + String batchSize = System.getProperty("jvector.ash.queryBatchSize", "8"); + try { queryBatchSize = Integer.parseInt(batchSize); } + catch (NumberFormatException invalid) { + throw new IllegalArgumentException("jvector.ash.queryBatchSize must be a positive integer: " + batchSize, invalid); + } + if (queryBatchSize < 1 || queryBatchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("jvector.ash.queryBatchSize must be between 1 and " + (Integer.MAX_VALUE - 1)); + + // Dimensions: W is [D × d] + final int originalDim = W.getRowDimension(); // D + final int quantizedDim = W.getColumnDimension(); // d + + this.rows = quantizedDim; + this.cols = originalDim; + + // Explicitly materialize A = Wᵀ into row-major arrays. Models persist A in FP32, so AData holds + // those FP32 values: a trained model and its reloaded copy then derive identical landmark terms. + this.AData = new double[quantizedDim][originalDim]; + for (int i = 0; i < quantizedDim; i++) { + final double[] row = this.AData[i]; + for (int j = 0; j < originalDim; j++) { + // A[i][j] = W[j][i] + row[j] = (float) W.getEntry(j, i); + } + } + + this.AFloat = new float[quantizedDim][originalDim]; + for (int i = 0; i < quantizedDim; i++) { + final double[] src = this.AData[i]; + final float[] dst = this.AFloat[i]; + for (int j = 0; j < originalDim; j++) { + dst[j] = (float) src[j]; + } + } + this.projection = VectorizationProvider.getInstance().getVectorUtilSupport() + .prepareAshProjection(AFloat); + + // Hard invariants: these are programmer-contract checks, not user input checks. + if (AFloat.length != rows) { + throw new AssertionError("AFloat rows " + AFloat.length + " != quantizedDim " + rows); + } + if (rows > 0 && AFloat[0].length != cols) { + throw new AssertionError("AFloat cols " + AFloat[0].length + " != originalDim " + cols); + } + if (AData.length != rows) { + throw new AssertionError("AData rows " + AData.length + " != quantizedDim " + rows); + } + if (rows > 0 && AData[0].length != cols) { + throw new AssertionError("AData cols " + AData[0].length + " != originalDim " + cols); + } + heapBytes = RamUsageEstimator.shallowSizeOfInstance(StiefelTransform.class) + + estimateMatrixBytes(W) + estimateArray2DBytes(AData) + estimateArray2DBytes(AFloat) + + RamUsageEstimator.shallowSizeOf(projection); + } + } + + /** + * RANDOM (untrained) Stiefel initialization. + * + *

Samples a Gaussian matrix G ∈ R^{D×d} and computes its polar factor + * W = U·Vᵀ via a thin SVD (G = U·S·Vᵀ). The resulting W has orthonormal + * columns (WᵀW = I) and can be used as an orthonormal projection matrix + * (e.g., y = Wᵀx maps R^D → R^d). + * + * @param D dimensionality of the input vectors + * @param d dimensionality of the projected space + * @param rng random number generator used to sample G + * @return a {@link StiefelTransform} containing W ∈ R^{D×d} with orthonormal columns + * @throws IllegalArgumentException if {@code d <= 0 or d > D} + * @throws RuntimeException if the native SVD fails + */ + public static StiefelTransform runWithoutTraining(int D, int d, Random rng) { + try (var blas = BlasThreads.singleUnlessLimited()) { + return randomTransform(D, d, rng); + } + } + + private static StiefelTransform randomTransform(int D, int d, Random rng) { + if (d <= 0 || d > D) { + throw new IllegalArgumentException("Invalid d=" + d + " for D=" + D); + } + + // gData is interpreted as a [D x d] column-major matrix by LAPACK. + double[] gData = new double[D * d]; + for (int i = 0; i < gData.length; i++) { + gData[i] = rng.nextGaussian(); + } + + double[] wData = orthogonalize(gData, D, d); + RealMatrix W = new Array2DRowRealMatrix(deserializeColumnMajor(wData, D, d), false); + return new StiefelTransform(W); + } + + @Override + @Deprecated + public CompressedVectors createCompressedVectors(Object[] compressedVectors) { + throw new UnsupportedOperationException("Deprecated createCompressedVectors() method not supported"); + } + + + // An internal working-set bound, not a projection API limit. Each task owns + // at most this many normalized vectors and projected results, reused until + // its range is complete. Native code supplies the SIMD/cache micro-tiling. + private static final int ENCODING_BATCH_SIZE = 32; + + /** + * Encodes all base vectors in bounded FP32 projection batches. The supplied + * pool controls parallelism; native calls execute on the calling worker. + * Missing vectors retain the existing all-zero encoded representation. + */ + @Override + public ASHVectors encodeAll(RandomAccessVectorValues ravv, ForkJoinPool simdExecutor) { + Objects.requireNonNull(ravv, "ravv"); + Objects.requireNonNull(simdExecutor, "simdExecutor"); + if (ravv.dimension() != originalDimension) + throw new IllegalArgumentException("ASH encoding vector dimension mismatch"); + final int n = ravv.size(); + final int parallelism = Math.max(1, simdExecutor.getParallelism()); + final int chunkSize = (int) (((long) n + parallelism - 1) / parallelism); + // Encoded straight into the compact store; no per-vector objects are retained. + final ASHCodes out = ASHCodes.allocate(n, quantizedDim, bitsPerDimension); + // Fill while each encoded vector is hot; never reread the raw input for setup. + final float[] symmetricOffsets = new float[n]; + final var ravvSupplier = ravv.threadLocalSupplier(); + var tasks = new java.util.ArrayList>(parallelism); + Throwable failure = null; + try { + for (int start = 0; start < n;) { + final int first = start; + final int end = (int) Math.min((long) n, (long) first + chunkSize); + tasks.add(simdExecutor.submit(() -> encodeRange(ravvSupplier.get(), out, symmetricOffsets, first, end))); + start = end; + } + } catch (RuntimeException | Error error) { + failure = error; + } + // Await every submitted task, including when the pool rejected a later + // submission. The caller may close its source when this method exits. + for (var task : tasks) { + try { task.join(); } + catch (RuntimeException | Error error) { + if (failure == null) failure = error; + else if (failure != error) failure.addSuppressed(error); + } + } + if (failure instanceof Error) throw (Error) failure; + if (failure != null) throw (RuntimeException) failure; + return new ASHVectors(this, out, symmetricOffsets); + } + + private void encodeRange(RandomAccessVectorValues source, ASHCodes out, + float[] symmetricOffsets, int start, int end) { + int capacity = Math.min(ENCODING_BATCH_SIZE, end - start); + float[][] inputs = new float[capacity][originalDimension]; + float[][] projected = new float[capacity][quantizedDim]; + float[] residualNorms = new float[capacity]; + int[] ordinals = new int[capacity]; + double[] absNorm = new double[quantizedDim]; + // Per-batch scratch, copied into the store once complete. Never modified: the code for missing vectors. + QuantizedVector[] scratch = new QuantizedVector[capacity]; + for (int k = 0; k < capacity; k++) scratch[k] = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + final QuantizedVector missing = QuantizedVector.createEmpty(quantizedDim, bitsPerDimension); + final float missingOffset = symmetricOffsets == null ? 0f : centeredOffset(missing); + int count = 0; + try (var cursor = VectorAccess.openRange(source, start, end)) { + int expected = start; + while (cursor.next()) { + int i = cursor.ordinal(); + if (i != expected++) throw new IllegalStateException("Unexpected encoding ordinal"); + VectorFloat vector = cursor.vector(); + if (vector == null) { + out.set(i, missing); + if (symmetricOffsets != null) symmetricOffsets[i] = missingOffset; + continue; + } + QuantizedVector dest = scratch[count]; + float residualNorm = prepareEncoding(vector, dest); + normalizeResidual(vector, landmarks[dest.landmark & 0xFF], residualNorm, inputs[count]); + residualNorms[count] = residualNorm; + ordinals[count] = i; + if (++count == capacity) { + encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, scratch, out, symmetricOffsets); + count = 0; + } + } + if (expected != end) throw new IllegalStateException("Incomplete encoding range"); + } + if (count != 0) encodeBatch(inputs, projected, residualNorms, ordinals, count, absNorm, scratch, out, symmetricOffsets); + } + + private void encodeBatch(float[][] inputs, float[][] projected, float[] residualNorms, + int[] ordinals, int count, double[] absNorm, QuantizedVector[] scratch, + ASHCodes out, float[] symmetricOffsets) { + stiefelTransform.projectBatch(inputs, projected, count); + for (int i = 0; i < count; i++) { + QuantizedVector dest = scratch[i]; + // Quantizers OR bits and nibbles into place, so reused scratch starts from zero. + Arrays.fill(dest.binaryVector, 0L); + Arrays.fill(dest.extraBits, (byte) 0); + float codeNorm = ProjectedBinaryQuantizer.quantizeProjected(projected[i], bitsPerDimension, + dest.binaryVector, dest.extraBits, absNorm); + float correction = finishEncoding(dest, residualNorms[i], codeNorm); + out.set(ordinals[i], dest); + if (symmetricOffsets != null) symmetricOffsets[ordinals[i]] = correction; + } + } + + private static void normalizeResidual(VectorFloat vector, VectorFloat mean, + float residualNorm, float[] normalized) { + if (vector.length() != normalized.length || mean.length() != normalized.length) + throw new IllegalArgumentException("ASH encoding vector dimension mismatch"); + float invNorm = residualNorm > 0f ? 1f / residualNorm : 0f; + if (vector instanceof FloatArray + && mean instanceof FloatArray) { + // Resolve heap backing once, including MemorySegment views. The + // simple array loop avoids per-component virtual/scope checks and + // permits JVM SIMD without changing the FP32 operation order. + float[] values = ((FloatArray) vector).array(); + float[] center = ((FloatArray) mean).array(); + for (int d = 0; d < normalized.length; d++) + normalized[d] = (values[d] - center[d]) * invNorm; + } else { + for (int d = 0; d < normalized.length; d++) + normalized[d] = (vector.get(d) - mean.get(d)) * invNorm; + } + } + + /** + * Encodes the input vector using ASH. + * @return the header and encoded vector payload + */ + @Override + public QuantizedVector encode(VectorFloat vector) { + var qv = QuantizedVector.createEmpty(this.quantizedDim, this.bitsPerDimension); + encodeTo(vector, qv); + return qv; + } + + @Override + public void encodeTo(VectorFloat vector, QuantizedVector dest) { + float residualNorm = prepareEncoding(vector, dest); + float codeNorm = quantizeVector(vector, residualNorm, dest.landmark, dest); + finishEncoding(dest, residualNorm, codeNorm); + } + + /** Sets landmark and raw offset; returns the residual norm needed by both paths. */ + private float prepareEncoding(VectorFloat vector, QuantizedVector dest) { + Objects.requireNonNull(vector, "vector"); + Objects.requireNonNull(dest, "dest"); + if (vector.length() != originalDimension) + throw new IllegalArgumentException("ASH encoding vector dimension mismatch"); + if (dest.binaryVector == null || dest.binaryVector.length < QuantizedVector.signWordsForDims(quantizedDim, bitsPerDimension) + || dest.extraBits == null || dest.extraBits.length < QuantizedVector.bodyBytesForDims(quantizedDim, bitsPerDimension)) + throw new IllegalArgumentException("ASH encoding destination too short"); + // Landmark assignment: nearest centroid by L2 distance. + byte landmark = 0; + float bestDist = Float.POSITIVE_INFINITY; + + for (int c = 0; c < landmarkCount; c++) { + float dist = VectorUtil.squareL2Distance(vector, landmarks[c]); + if (dist < bestDist) { + bestDist = dist; + landmark = (byte) c; + } + } + + final int c = landmark & 0xFF; + final VectorFloat mu = landmarks[c]; + + // Compute and ||x_i − μ_i*||_2. + final float dotXMu = VectorUtil.dotProduct(vector, mu); + final float sqDist = VectorUtil.squareL2Distance(vector, mu); + final float residualNorm = (float) Math.sqrt(sqDist); + + assert quantizedDim > 0; + + final float rawOffset = dotXMu - landmarkNormSq[c]; + // ||x||^2 = ||x - mu||^2 + 2 - ||mu||^2, from the terms already computed. + recordEncodedSquaredNorm(sqDist + 2f * dotXMu - landmarkNormSq[c]); + + // Header fields needed before quantization. The final offset may be + // adjusted after quantization for C++-style fast-scan projection codes. + dest.offset = rawOffset; + dest.landmark = landmark; + assert Float.isFinite(rawOffset) : "offset is not finite"; + + return residualNorm; + } + + /** Applies the existing code-dependent header adjustment and FP16 storage rounding. */ + /** Returns the symmetric correction using the same dot product and stored half headers. */ + private float finishEncoding(QuantizedVector dest, float residualNorm, float codeNorm) { + final int c = dest.landmark & 0xFF; + final float rawOffset = dest.offset; + final float scale = codeNorm > 0f ? residualNorm / codeNorm : 0f; + + // Projection-mode offset: offset = - ||μ||² - scale * , so query-time + // scoring can use Aq instead of A(q - μ). A 1-bit code is its ±1 signs. + final float landmarkDot = landmarkCodeDot(c, dest.binaryVector, 0, dest.extraBits, 0); + dest.scale = roundToFloat16(scale); + dest.offset = roundToFloat16(rawOffset - scale * landmarkDot); + return dest.offset + dest.scale * landmarkDot; + } + + // --------------------------------------------------------------------- + // Training of projection matrix + // --------------------------------------------------------------------- + + /** Sampled, normalized residuals in the column-major layout consumed by LAPACK. */ + private static final class TrainingData { + final double[] xCol; + final int rows; + final int columns; + + TrainingData(double[] xCol, int rows, int columns) { + this.xCol = xCol; + this.rows = rows; + this.columns = columns; + } + + TrainingData(double[][] rows) { + this(serializeColumnMajor(rows), rows.length, rows[0].length); + } + } + + private static final class SingleLandmarkTraining { + final VectorFloat landmark; + final TrainingData trainingData; + + SingleLandmarkTraining(VectorFloat landmark, TrainingData trainingData) { + this.landmark = landmark; + this.trainingData = trainingData; + } + } + + private static int trainingSampleSize(int count, int dimensions) { + return (int) Math.min((long) count, Math.max(1L, (long) dimensions * ITQ_TRAINING_FACTOR)); + } + + /** + * Computes the C=1 landmark and ITQ inputs from the same deterministic + * sample. Only sampled source vectors are read; normalized sample values + * are retained until the mean is known. One reusable vector protects + * borrowed source storage. Double sums follow sorted ordinal order, and + * normalization retains the provider's FP32 dot/scale operations. + */ + private static SingleLandmarkTraining prepareSingleLandmarkTraining( + RandomAccessVectorValues source, int dimensions, Random rng, boolean learn) { + final int count = source.size(); + if (count <= 0 || dimensions <= 0) { + throw new IllegalArgumentException("ASH training requires positive size and dimension"); + } + final int sampleSize = trainingSampleSize(count, dimensions); + // Validate the flat-array representation before allocating ordinal or + // normalization buffers. + long sampleElements = (long) sampleSize * dimensions; + if (learn && sampleElements > Integer.MAX_VALUE) { + throw new IllegalArgumentException("ASH training sample exceeds the Java array size limit: " + + sampleSize + " rows x " + dimensions + " dimensions"); + } + // The existing PCA driver also requires a full D-by-D right basis, + // including null-space directions when the training matrix is wide. + if (learn && (long) dimensions * dimensions > Integer.MAX_VALUE) { + throw new IllegalArgumentException("ASH PCA basis exceeds the Java array size limit: " + + dimensions + " x " + dimensions); + } + // RANDOM projections keep their existing Gaussian RNG sequence. ITQ + // consumes exactly the same reservoir-sampling draws as before. + final Random sampleRandom = learn ? rng : new Random(TRAINING_SEED); + final int[] ordinals = reservoirSampleOrdinals(count, sampleSize, sampleRandom); + Arrays.sort(ordinals); + final float[] sample = learn ? new float[(int) sampleElements] : null; + final double[] accumulator = new double[dimensions]; + final VectorFloat normalized = vectorTypeSupport.createFloatVector(dimensions); + final float[] values = normalized instanceof FloatArray ? ((FloatArray) normalized).array() : null; + for (int row = 0; row < sampleSize; row++) { + int ordinal = ordinals[row]; + VectorFloat input = source.getVector(ordinal); + if (input == null) { + throw new NullPointerException("Null ASH training vector at ordinal " + ordinal); + } + if (input.length() != dimensions) { + throw new IllegalArgumentException("ASH training vector at ordinal " + ordinal + + " has dimension " + input.length() + "; expected " + dimensions); + } + normalized.copyFrom(input, 0, 0, dimensions); + // Dimensions are already checked. Call the provider directly so invalid + // norms reach argument validation even when VectorUtil assertions are enabled. + float norm = (float) Math.sqrt(vectorUtilSupport.dotProduct(normalized, normalized)); + if (!Float.isFinite(norm)) { + throw new IllegalArgumentException("ASH training vector at ordinal " + ordinal + + " has a non-finite FP32 L2 norm"); + } + if (norm > 1e-10f) { + VectorUtil.scale(normalized, 1.0f / norm); + } + if (values != null) { + for (int d = 0; d < dimensions; d++) accumulator[d] += values[d]; + } else { + for (int d = 0; d < dimensions; d++) accumulator[d] += normalized.get(d); + } + if (learn) { + int offset = row * dimensions; + if (values != null) { + System.arraycopy(values, 0, sample, offset, dimensions); + } else { + for (int d = 0; d < dimensions; d++) sample[offset + d] = normalized.get(d); + } + } + } + VectorFloat mean = vectorTypeSupport.createFloatVector(dimensions); + double invCount = 1.0 / sampleSize; + for (int d = 0; d < dimensions; d++) mean.set(d, (float) (accumulator[d] * invCount)); + if (!learn) return new SingleLandmarkTraining(mean, null); + return new SingleLandmarkTraining(mean, centerTrainingSample(sample, sampleSize, dimensions, mean)); + } + + /** + * Centers/normalizes contiguous sample rows in FP64, then writes small tiles + * into the final column-major matrix. No full-size double row matrix or + * strided, element-at-a-time whole-matrix conversion is needed. Tile width + * bounds scratch only; it does not change arithmetic or sample boundaries. + */ + private static TrainingData centerTrainingSample(float[] sample, int rows, int dimensions, + VectorFloat mean) { + final int tileRows = Math.min(TRAINING_TRANSPOSE_TILE_ROWS, rows); + double[] tile = new double[Math.multiplyExact(tileRows, dimensions)]; + double[] columnMajor = new double[Math.multiplyExact(rows, dimensions)]; + float[] center = new float[dimensions]; + for (int d = 0; d < dimensions; d++) center[d] = mean.get(d); + for (int first = 0; first < rows; first += tileRows) { + int rowsInTile = Math.min(tileRows, rows - first); + for (int row = 0; row < rowsInTile; row++) { + int sampleOffset = (first + row) * dimensions; + int tileOffset = row * dimensions; + double norm2 = 0.0; + for (int d = 0; d < dimensions; d++) { + double value = (double) sample[sampleOffset + d] - (double) center[d]; + tile[tileOffset + d] = value; + norm2 += value * value; + } + double inverse = 1.0 / Math.max(Math.sqrt(norm2), 1e-20); + for (int d = 0; d < dimensions; d++) tile[tileOffset + d] *= inverse; + } + for (int d = 0; d < dimensions; d++) { + int columnOffset = d * rows + first; + for (int row = 0; row < rowsInTile; row++) { + columnMajor[columnOffset + row] = tile[row * dimensions + d]; + } + } + } + return new TrainingData(columnMajor, rows, dimensions); + } + + /** + * Builds normalized centered residuals for training: + * - Choose N_train = min(N, D * training_factor). + * - Sample ordinals uniformly at random (deterministic RNG). + * - For each sampled ordinal i: + * c = argmin ||x_i - mu_c||^2 (same rule as encoding) + * r = x_i - mu_c + * r_hat = r / ||r|| + * store r_hat into xTrainNorm + */ + private static TrainingData prepareTrainingData( + VectorFloat[] points, + VectorFloat[] landmarks, + int originalDim, + Random rng + ) { + final int N = points.length; + + final int nTrain = trainingSampleSize(N, originalDim); + + final int[] sample = reservoirSampleOrdinals(N, nTrain, rng); + Arrays.sort(sample); // deterministic order for reproducibility + + final double[][] xTrainNorm = new double[nTrain][originalDim]; + + int row = 0; + int samplePos = 0; + + for (int i = 0; i < N && samplePos < nTrain; i++) { + if (sample[samplePos] != i) { + continue; + } + samplePos++; + + final VectorFloat x = points[i]; + + // Nearest landmark assignment (same semantics as encodeTo). + byte best = 0; + float bestDist = Float.POSITIVE_INFINITY; + for (int c = 0; c < landmarks.length; c++) { + float dist = VectorUtil.squareL2Distance(x, landmarks[c]); + if (dist < bestDist) { + bestDist = dist; + best = (byte) c; + } + } + + final VectorFloat mu = landmarks[best & 0xFF]; + + // Center and normalize into double precision row. + final double[] out = xTrainNorm[row++]; + + double ss = 0.0; + for (int d = 0; d < originalDim; d++) { + double v = (double) x.get(d) - (double) mu.get(d); + out[d] = v; + ss += v * v; + } + + // Normalize residuals while guarding against zero residuals. + double inv = 1.0 / Math.max(Math.sqrt(ss), 1e-20); + for (int d = 0; d < originalDim; d++) { + out[d] *= inv; + } + } + + if (row != nTrain) { + throw new IllegalStateException("Training sample fill mismatch: got " + row + ", expected " + nTrain); + } + + return new TrainingData(xTrainNorm); + } + + /** + * Reservoir sample k ordinals uniformly from [0, N). + * Deterministic given rng. + */ + private static int[] reservoirSampleOrdinals(int N, int k, Random rng) { + if (k < 0 || k > N) throw new IllegalArgumentException("k out of range: " + k + " for N=" + N); + int[] r = new int[k]; + for (int i = 0; i < k; i++) r[i] = i; + for (int i = k; i < N; i++) { + int j = rng.nextInt(i + 1); + if (j < k) r[j] = i; + } + return r; + } + + /** + * ITQ / learned projection trainer. + * + * Mirrors the current learned_projection path: + * - PCA basis P from checked FP64 covariance PCA, with direct SVD fallback + * - Xld = X @ P + * - iterative Procrustes: + * R = polar(M) + * Xtr = Xld @ R + * Xenc = encodeTrainingDirections(Xtr, bitsPerDimension) + * M = Xld^T @ Xenc + * - W = P @ R + */ + private static StiefelTransform runItqTrainer( + TrainingData trainingData, // [N x D] normalized residuals, column-major + int latentDim, // projected dimensions, d + int bitsPerDimension, + Random rng, + int nTrainingIterations + ) { + LOG.info("ASH ITQ training started"); + + validateBitsPerDimension(bitsPerDimension); + + final int N = trainingData.rows; + final int D = trainingData.columns; + final int d = latentDim; + + if (d <= 0 || d > D) { + throw new IllegalArgumentException("Invalid latentDim=" + d + " for D=" + D); + } + + // X is [N x D], flat column-major for BLAS/LAPACK. + double[] xCol = trainingData.xCol; + + LOG.debug("ASH ITQ: PCA started"); + double[] pCol = computePcaBasis(xCol, N, D, d); + LOG.debug("ASH ITQ: PCA finished"); + + // Xld = X @ P, shape [N x d]. + double[] xldCol = nativeMultiply(xCol, N, D, pCol, d); + + // Initial random update matrix M, shape [d x d]. + double[] mCol = gaussianMatrix(d, d, rng); + + // Each projected vector is contiguous. Quantization replaces it in place: + // the loss uses the update matrix, so unquantized rows need not be retained. + double[] projectedRows = new double[Math.multiplyExact(N, d)]; + + LOG.debug("ASH ITQ: training iterations started"); + long startTime = System.nanoTime(); + // Repeated polar steps need FP32 accuracy; the accumulated update, + // projection products and loss retain FP64. Final W uses a FP64 polar + // factor so the stored transform keeps its orthogonality contract. + ASHFloatOrthogonalizer orthogonalizer = new ASHFloatOrthogonalizer(d, d); + double[] rCol = new double[Math.multiplyExact(d, d)]; + TrainingDirectionEncoder encoder = new TrainingDirectionEncoder(N, d, bitsPerDimension); + + double[] recentLosses = new double[3]; + for (int epoch = 0; epoch < nTrainingIterations; epoch++) { + orthogonalizer.orthogonalize(mCol, rCol); + + projectTrainingRows(xldCol, N, d, rCol, projectedRows); + encoder.encodeInPlace(projectedRows); + + // ITQ update: M = Xld^T @ Xenc. Reuse the initial Gaussian buffer. + updateTrainingMatrix(xldCol, N, d, projectedRows, mCol); + + // C++-style cheap loss: trace(R^T M) / N. + ProjectionTrainingLoss loss = computeTrainingLossFromUpdateMatrix(rCol, mCol, N); + printTrainingLoss(epoch, loss); + if (epoch >= 8 && recentLosses[(epoch - 3) % 3] - loss.loss <= 0.01 * loss.loss) { + LOG.info("ASH ITQ training converged at epoch {}", epoch); + break; + } + recentLosses[epoch % 3] = loss.loss; + } + + // Recompute from the final update, in FP64, rather than retaining the + // previous epoch's rotation or widening a FP32 polar factor. + new ASHOrthogonalizer(d, d).orthogonalize(mCol, rCol); + + double loopTime = (System.nanoTime() - startTime) / 1e9; + if (LOG.isInfoEnabled()) { + LOG.info("ASH ITQ training finished in {} s", String.format(java.util.Locale.ROOT, "%.1f", loopTime)); + } + + // W = P @ R, shape [D x d]. + double[] wCol = nativeMultiply(pCol, D, d, rCol, d); + + RealMatrix W = new Array2DRowRealMatrix(deserializeColumnMajor(wCol, D, d), false); + return new StiefelTransform(W); + } + + private static final class ProjectionTrainingLoss { + final double ip; + final double loss; + + ProjectionTrainingLoss(double ip, double loss) { + this.ip = ip; + this.loss = loss; + } + } + + /** + * Java equivalent of C++ compute_training_loss_from_update_matrix(R, M). + * + * R and M are [d x d] column-major. + */ + private static ProjectionTrainingLoss computeTrainingLossFromUpdateMatrix( + double[] rCol, + double[] mCol, + int rows + ) { + double ipSum = 0.0; + for (int i = 0; i < rCol.length; i++) { + ipSum += rCol[i] * mCol[i]; + } + + double ip = ipSum / rows; + return new ProjectionTrainingLoss(ip, Math.max(0.0, 1.0 - ip)); + } + + private static void printTrainingLoss(int epoch, ProjectionTrainingLoss loss) { + if (LOG.isDebugEnabled()) { + LOG.debug(String.format(java.util.Locale.ROOT, + "ASH ITQ: epoch %2d inner-product loss %.5f, loss %.5f", epoch, loss.ip, loss.loss)); + } + } + + private static final float[] K_TIGHT_START = { + 0.0f, + 0.15f, + 0.20f, + 0.52f, + 0.59f, + 0.71f, + 0.75f, + 0.77f, + 0.81f + }; + + private static final double K_EPS = 1e-5; + + private static final ThreadLocal SCALING_WORKSPACE = + ThreadLocal.withInitial(ScalingWorkspace::new); + + private static final class ScalingWorkspace { + float[] invOAbs = new float[0]; + int[] curOBar = new int[0]; + long[] events = new long[0]; + + void ensureCapacity(int d, int maxEvents) { + if (invOAbs.length < d) { + invOAbs = new float[d]; + } + if (curOBar.length < d) { + curOBar = new int[d]; + } + if (events.length < maxEvents) { + events = new long[maxEvents]; + } + } + } + + /** + * Java port of ash::compute_optimal_scaling_factor. + * + * The input array is double because the Java trainer stores training matrices + * as double, but this method intentionally performs the search in float + * precision to match the C++ implementation. + */ + private static double computeOptimalScalingFactor( + double[] oAbs, + int d, + int bitsPerDimension + ) { + final int exBits = bitsPerDimension - 1; + if (exBits < 1 || exBits > 8) { + throw new IllegalArgumentException("exBits must be in [1,8], got " + exBits); + } + if (d <= 0 || d > oAbs.length) { + throw new IllegalArgumentException("Invalid d=" + d + " for oAbs.length=" + oAbs.length); + } + + final int kNEnum = 10; + final int maxCode = (1 << exBits) - 1; + + float maxO = 0.0f; + for (int i = 0; i < d; i++) { + float v = (float) oAbs[i]; + if (v > maxO) { + maxO = v; + } + } + + if (!(maxO > 0.0f)) { + return 0.0; + } + + final float tEnd = (float) (maxCode + kNEnum) / maxO; + final float tStart = tEnd * K_TIGHT_START[exBits]; + + final int maxEvents = Math.multiplyExact(d, maxCode + 1); + ScalingWorkspace ws = SCALING_WORKSPACE.get(); + ws.ensureCapacity(d, maxEvents); + + float sqrDenominator = (float) d * 0.25f; + float numerator = 0.0f; + + for (int i = 0; i < d; i++) { + float oi = (float) oAbs[i]; + + ws.invOAbs[i] = 1.0f / oi; + + int cur = (int) ((double) (tStart * oi) + K_EPS); + ws.curOBar[i] = cur; + + sqrDenominator += (float) (cur * cur + cur); + numerator += (cur + 0.5f) * oi; + } + + float invSqrtDenom = 1.0f / (float) Math.sqrt(sqrDenominator); + + int eventCount = 0; + for (int i = 0; i < d; i++) { + final float invO = ws.invOAbs[i]; + final int first = ws.curOBar[i] + 1; + + // Match C++: always consider the first event, then only generate + // follow-on events while k <= maxCode. + float tn = (float) first * invO; + if (tn >= tEnd) { + continue; + } + + ws.events[eventCount++] = packScalingEvent(tn, i); + + for (int k = first + 1; k <= maxCode; k++) { + tn = (float) k * invO; + if (tn >= tEnd) { + break; + } + + ws.events[eventCount++] = packScalingEvent(tn, i); + } + } + + Arrays.sort(ws.events, 0, eventCount); + + float maxIp = 0.0f; + float t = 0.0f; + + for (int e = 0; e < eventCount; e++) { + long event = ws.events[e]; + int id = scalingEventId(event); + + ws.curOBar[id]++; + int update = ws.curOBar[id]; + + float delta = 2.0f * update; + sqrDenominator += delta; + numerator += (float) oAbs[id]; + + float oldDenom = sqrDenominator - delta; + invSqrtDenom *= + (1.0f - 0.5f * delta / (oldDenom + delta * 0.5f)); + + float curIp = numerator * invSqrtDenom; + if (curIp > maxIp) { + maxIp = curIp; + t = scalingEventT(event); + } + } + + return t; + } + + /** + * Packs event sort key as: + * high 32 bits: positive float t bits + * low 32 bits: dimension id + * + * For positive finite t, raw float bits sort in the same order as numeric t. + * The low bits reproduce the C++ tie-breaker by id. + */ + private static long packScalingEvent(float t, int id) { + return ((long) Float.floatToRawIntBits(t) << 32) + | (id & 0xFFFF_FFFFL); + } + + private static float scalingEventT(long event) { + return Float.intBitsToFloat((int) (event >>> 32)); + } + + private static int scalingEventId(long event) { + return (int) event; + } + + /** + * Samples a Gaussian matrix in flat column-major storage. + * + * Values are assigned in row-major logical order. + */ + private static double[] gaussianMatrix(int rows, int cols, Random rng) { + double[] data = new double[rows * cols]; + for (int r = 0; r < rows; r++) { + int dst = r; + for (int c = 0; c < cols; c++, dst += rows) { + data[dst] = rng.nextGaussian(); + } + } + return data; + } + + /** Converts a row-major double[][] to a flat column-major array for BLAS/LAPACK. */ + private static double[] serializeColumnMajor(double[][] matrix) { + int rows = matrix.length; + int cols = matrix[0].length; + double[] data = new double[rows * cols]; + + for (int r = 0; r < rows; r++) { + double[] row = matrix[r]; + int dst = r; + for (int c = 0; c < cols; c++, dst += rows) { + data[dst] = row[c]; + } + } + return data; + } + + /** Converts a flat column-major array back into a row-major double[][] for Commons Math. */ + private static double[][] deserializeColumnMajor(double[] data, int rows, int cols) { + double[][] matrix = new double[rows][cols]; + for (int r = 0; r < rows; r++) { + double[] row = matrix[r]; + int src = r; + for (int c = 0; c < cols; c++, src += rows) { + row[c] = data[src]; + } + } + return matrix; + } + + /** + * Extracts M = V[:, :d] from VT, where VT is [dim x dim] in flat column-major layout. + * Returns M as [dim x d], also in flat column-major layout. + */ + private static double[] extractPcaBasis(double[] vtCol, int dim, int d) { + double[] mCol = new double[dim * d]; + + for (int col = 0; col < d; col++) { + int dstOffset = col * dim; + for (int row = 0; row < dim; row++) { + // M(row, col) = V(row, col) = VT(col, row) + mCol[dstOffset + row] = vtCol[col + row * dim]; + } + } + + return mCol; + } + + /** Native DGEMM for flat column-major matrices: C = A @ B. */ + private static double[] nativeMultiply(double[] aCol, int m, int k, double[] bCol, int n) { + double[] cCol = new double[m * n]; + + BLAS.getInstance().dgemm( + "N", "N", + m, n, k, + 1.0, + aCol, m, + bCol, k, + 0.0, + cCol, m + ); + + return cCol; + } + + /** + * Produces contiguous projected vectors without a separate transpose pass. + * Xld is [rows x dims] column-major; output rows are its product with R, + * stored as [dims x rows] column-major: output = R^T * Xld^T. + */ + private static void projectTrainingRows(double[] xldCol, int rows, int dims, + double[] rCol, double[] outRows) { + checkTrainingProduct(xldCol, rows, dims, rCol, outRows); + if (outRows == xldCol || outRows == rCol) { + throw new IllegalArgumentException("ITQ projection output must not alias an input"); + } + BLAS.getInstance().dgemm("T", "T", dims, rows, dims, 1.0, + rCol, dims, xldCol, rows, 0.0, outRows, dims); + } + + /** + * Recomputes M = Xld^T * Xenc into an existing square matrix. Encoded rows + * are viewed as [dims x rows] column-major, hence the second transpose. + */ + private static void updateTrainingMatrix(double[] xldCol, int rows, int dims, + double[] encodedRows, double[] mCol) { + checkTrainingProduct(xldCol, rows, dims, mCol, encodedRows); + if (mCol == xldCol || mCol == encodedRows) { + throw new IllegalArgumentException("ITQ update output must not alias an input"); + } + BLAS.getInstance().dgemm("T", "T", dims, dims, rows, 1.0, + xldCol, rows, encodedRows, dims, 0.0, mCol, dims); + } + + private static void checkTrainingProduct(double[] xldCol, int rows, int dims, + double[] square, double[] rowMajor) { + Objects.requireNonNull(xldCol, "xldCol"); + Objects.requireNonNull(square, "square"); + Objects.requireNonNull(rowMajor, "rowMajor"); + if (rows <= 0 || dims <= 0 || (long) rows * dims > Integer.MAX_VALUE + || (long) dims * dims > Integer.MAX_VALUE + || xldCol.length != rows * dims || rowMajor.length != rows * dims + || square.length != dims * dims) { + throw new IllegalArgumentException("Invalid ITQ matrix dimensions"); + } + } + + /** + * Computes all right singular vectors with the divide-and-conquer LAPACK driver. + * Input X is [rows x cols] in column-major order; the returned VT is [cols x cols]. + * + *

For tall matrices, overwrite the scratch input with the unused left singular + * vectors instead of allocating another rows-by-cols array. Wide matrices need + * the full VT, including its null-space basis, because the requested projection + * dimension can exceed the number of training rows.

+ * + *

Check the singular-value energy before accepting the basis. A native backend + * can return success with an inaccurate decomposition; its status alone does not + * establish that the PCA input was decomposed correctly.

+ */ + private static double[] computeNativeVt(double[] xCol, int rows, int cols) { + if (rows <= 0 || cols <= 0 || xCol.length != Math.multiplyExact(rows, cols)) { + throw new IllegalArgumentException("Invalid PCA matrix dimensions"); + } + double inputEnergy = 0.0; + for (double value : xCol) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("PCA training input contains a non-finite value"); + } + inputEnergy += value * value; + } + + int k = Math.min(rows, cols); + boolean tall = rows >= cols; + String job = tall ? "O" : "A"; + int ldu = tall ? 1 : rows; + double[] u = new double[tall ? 1 : Math.multiplyExact(rows, rows)]; + double[] s = new double[k]; + double[] vt = new double[Math.multiplyExact(cols, cols)]; + int[] iwork = new int[Math.multiplyExact(8, k)]; + intW info = new intW(0); + double[] workQuery = new double[1]; + // LAPACK's workspace query does not modify input matrix values. + LAPACK.getInstance().dgesdd(job, rows, cols, xCol, rows, s, u, ldu, + vt, cols, workQuery, -1, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("PCA SVD workspace query failed with info=" + info.val); + } + if (!Double.isFinite(workQuery[0]) || workQuery[0] < 1 || workQuery[0] > Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid PCA SVD workspace size: " + workQuery[0]); + } + double[] work = new double[(int) Math.ceil(workQuery[0])]; + double[] a = xCol.clone(); // The actual decomposition overwrites its input. + LAPACK.getInstance().dgesdd(job, rows, cols, a, rows, s, u, ldu, + vt, cols, work, work.length, iwork, info); + if (info.val != 0) { + throw new IllegalStateException("PCA SVD failed with info=" + info.val); + } + + double singularEnergy = 0.0; + for (double value : s) { + if (!Double.isFinite(value) || value < 0.0) { + throw new IllegalStateException("PCA SVD returned an invalid singular value"); + } + singularEnergy += value * value; + } + // Training residuals are normalized; allow accumulation roundoff while rejecting + // materially incorrect decompositions, including ones reported as successful. + double tolerance = 1e-8 * Math.max(1.0, inputEnergy); + if (!Double.isFinite(singularEnergy) || Math.abs(singularEnergy - inputEnergy) > tolerance) { + throw new IllegalStateException("PCA SVD failed its energy check: input=" + inputEnergy + + ", singular values=" + singularEnergy); + } + return vt; + } + + /** Prefer checked covariance PCA; preserve direct SVD for uncertain subspaces. */ + private static double[] computePcaBasis(double[] xCol, int rows, int dims, int latent) { + double[] basis = ASHCovariancePCA.projection(xCol, rows, dims, latent); + if (basis != null) { + LOG.debug("ASH ITQ: PCA used FP64 covariance eigendecomposition"); + return basis; + } + LOG.debug("ASH ITQ: PCA used guarded direct SVD"); + return extractPcaBasis(computeNativeVt(xCol, rows, dims), dims, latent); + } + + /** Single-use convenience path for random projection initialization. */ + private static double[] orthogonalize(double[] mCol, int rows, int cols) { + ASHOrthogonalizer workspace = new ASHOrthogonalizer(rows, cols); + double[] result = new double[Math.multiplyExact(rows, cols)]; + workspace.orthogonalize(mCol, result); + return result; + } + + /** + * Quantizes contiguous training vectors in place, retaining the existing + * FP64 norms and FP32 scaling-factor search. Scratch belongs to logical + * workers and is reused across epochs, regardless of which pool threads + * execute them. One encoder belongs to one trainer and is not thread-safe. + */ + private static final class TrainingDirectionEncoder { + private final int rows; + private final int dims; + private final int elements; + private final int bits; + private final DirectionScratch[] workers; + + TrainingDirectionEncoder(int rows, int dims, int bits) { + validateBitsPerDimension(bits); + if (rows <= 0 || dims <= 0 || (long) rows * dims > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Invalid ITQ direction dimensions"); + } + this.rows = rows; + this.dims = dims; + this.elements = rows * dims; + this.bits = bits; + int count = bits == 1 ? 0 + : Math.min(rows, PhysicalCoreExecutor.getPhysicalCoreCount()); + workers = new DirectionScratch[count]; + for (int i = 0; i < count; i++) workers[i] = new DirectionScratch(dims); + } + + void encodeInPlace(double[] vectors) { + Objects.requireNonNull(vectors, "vectors"); + if (vectors.length != elements) { + throw new IllegalArgumentException("Invalid ITQ direction buffer length"); + } + if (bits == 1) { + double inverse = 1.0 / Math.sqrt(dims); + for (int i = 0; i < elements; i++) { + vectors[i] = vectors[i] >= 0.0 ? inverse : -inverse; + } + return; + } + int maxCode = (1 << (bits - 1)) - 1; + if (workers.length == 1) { + encodeRows(vectors, workers[0], 0, rows, maxCode); + } else { + // Share the library's bounded physical-core pool. A trainer's + // logical workers own their scratch and disjoint output rows. + PhysicalCoreExecutor.instance().execute(() -> + IntStream.range(0, workers.length).parallel().forEach(worker -> { + int first = (int) ((long) worker * rows / workers.length); + int end = (int) ((long) (worker + 1) * rows / workers.length); + encodeRows(vectors, workers[worker], first, end, maxCode); + })); + } + } + + private void encodeRows(double[] vectors, DirectionScratch scratch, int first, int end, int maxCode) { + double[] absNorm = scratch.absNorm; + double[] mag = scratch.mag; + for (int r = first; r < end; r++) { + int offset = r * dims; + double norm2 = 0.0; + for (int j = 0; j < dims; j++) { + double value = vectors[offset + j]; + norm2 += value * value; + } + double inverse = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); + for (int j = 0; j < dims; j++) { + absNorm[j] = Math.abs(vectors[offset + j]) * inverse; + } + double scale = computeOptimalScalingFactor(absNorm, dims, bits); + double encodedNorm2 = 0.0; + for (int j = 0; j < dims; j++) { + int q = Math.min((int) (scale * absNorm[j] + K_EPS), maxCode); + double magnitude = q + 0.5; + mag[j] = magnitude; + encodedNorm2 += magnitude * magnitude; + } + double encodedInverse = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + for (int j = 0; j < dims; j++) { + double signed = vectors[offset + j] < 0.0 ? -mag[j] : mag[j]; + vectors[offset + j] = signed * encodedInverse; + } + } + } + } + + private static final class DirectionScratch { + final double[] absNorm; + final double[] mag; + + DirectionScratch(int dims) { + absNorm = new double[dims]; + mag = new double[dims]; + } + } + + private static void writeStiefelTransform(IndexWriter out, StiefelTransform st) throws IOException { + // Serialize A = W^T in float precision as a [d][D] row-major matrix. + // This keeps the format compact and matches the encoding/scoring fast paths. + out.writeInt(st.rows); + out.writeInt(st.cols); + + for (int i = 0; i < st.rows; i++) { + float[] row = st.AFloat[i]; + if (row.length != st.cols) { + throw new IOException("Invalid StiefelTransform AFloat row length: " + row.length + " != " + st.cols); + } + for (int j = 0; j < st.cols; j++) { + out.writeFloat(row[j]); + } + } + } + + private static StiefelTransform readStiefelTransform(RandomAccessReader in, int expectedRows, int expectedCols) throws IOException { + int rows = in.readInt(); + int cols = in.readInt(); + + if (rows != expectedRows) { + throw new IOException("StiefelTransform rows mismatch: expected " + expectedRows + ", got " + rows); + } + if (cols != expectedCols) { + throw new IOException("StiefelTransform cols mismatch: expected " + expectedCols + ", got " + cols); + } + + // Read A = W^T as [rows][cols] (row-major) in float precision. + float[][] aFloat = new float[rows][cols]; + for (int i = 0; i < rows; i++) { + float[] row = aFloat[i]; + for (int j = 0; j < cols; j++) { + row[j] = in.readFloat(); + } + } + + // Reconstruct W [D x d] so we can build StiefelTransform(W). + // W[j][i] = A[i][j] + double[][] wData = new double[cols][rows]; + for (int i = 0; i < rows; i++) { + float[] aRow = aFloat[i]; + for (int j = 0; j < cols; j++) { + wData[j][i] = aRow[j]; + } + } + + RealMatrix W = new Array2DRowRealMatrix(wData, false); + return new StiefelTransform(W); + } + + @Override + public int compressorSize() { + // Must exactly match write(out, version) + int size = 0; + size += Integer.BYTES; // MAGIC + size += Integer.BYTES; // version + size += Integer.BYTES; // originalDimension + size += Integer.BYTES; // encodedBits + size += Integer.BYTES; // quantizedDim + size += Integer.BYTES; // bitsPerDimension + size += Integer.BYTES; // optimizer + size += Integer.BYTES; // landmarkCount + for (int c = 0; c < landmarkCount; c++) { + size += Integer.BYTES; // landmark dimension + size += Float.BYTES * landmarks[c].length(); + } + size += Integer.BYTES; // stiefel rows + size += Integer.BYTES; // stiefel cols + size += Float.BYTES * quantizedDim * originalDimension; + size += Float.BYTES; // largest encoded vector length + return size; + } + + private static float roundToFloat16(float value) { + return halfBitsToFloat(floatToHalfBits(value)); + } + + static void writeFloat16(IndexWriter out, float value) throws IOException { + int bits = floatToHalfBits(value); + + // Explicit big-endian byte order, matching Java DataOutput-style primitives. + out.writeByte((byte) ((bits >>> 8) & 0xFF)); + out.writeByte((byte) (bits & 0xFF)); + } + + static float readFloat16(RandomAccessReader in) throws IOException { + int hi = readUnsignedByte(in); + int lo = readUnsignedByte(in); + return halfBitsToFloat((hi << 8) | lo); + } + + private static int readUnsignedByte(RandomAccessReader in) throws IOException { + if (in instanceof io.github.jbellis.jvector.disk.ByteReadable) { + return ((io.github.jbellis.jvector.disk.ByteReadable) in).readByte() & 0xFF; + } + + byte[] one = ONE_BYTE.get(); + in.readFully(one); + return one[0] & 0xFF; + } + + private static final ThreadLocal ONE_BYTE = + ThreadLocal.withInitial(() -> new byte[1]); + + /** + * IEEE-754 binary16 round-to-nearest-even conversion. + */ + private static int floatToHalfBits(float value) { + int bits = Float.floatToRawIntBits(value); + int sign = (bits >>> 16) & 0x8000; + int abs = bits & 0x7FFF_FFFF; + + // NaN / infinity + if (abs >= 0x7F80_0000) { + if ((abs & 0x007F_FFFF) == 0) { + return sign | 0x7C00; + } + return sign | 0x7E00; // canonical quiet NaN + } + + int exp = ((abs >>> 23) & 0xFF) - 127 + 15; + int mant = abs & 0x007F_FFFF; + + // Overflow to infinity. + if (exp >= 31) { + return sign | 0x7C00; + } + + // Subnormal / underflow. + if (exp <= 0) { + if (exp < -10) { + return sign; + } + + mant |= 0x0080_0000; + int shift = 14 - exp; + int halfMant = mant >>> shift; + + int roundBit = 1 << (shift - 1); + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + } + + return sign | halfMant; + } + + // Normalized. + int halfMant = mant >>> 13; + int roundBit = 0x0000_1000; + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + + if (halfMant == 0x0400) { + halfMant = 0; + exp++; + + if (exp >= 31) { + return sign | 0x7C00; + } + } + } + + return sign | (exp << 10) | halfMant; + } + + private static float halfBitsToFloat(int half) { + int h = half & 0xFFFF; + int sign = (h & 0x8000) << 16; + int exp = (h >>> 10) & 0x1F; + int mant = h & 0x03FF; + + if (exp == 0) { + if (mant == 0) { + return Float.intBitsToFloat(sign); + } + + // Normalize half subnormal. + while ((mant & 0x0400) == 0) { + mant <<= 1; + exp--; + } + + exp++; + mant &= 0x03FF; + } else if (exp == 31) { + return Float.intBitsToFloat(sign | 0x7F80_0000 | (mant << 13)); + } + + int floatExp = exp + (127 - 15); + int bits = sign | (floatExp << 23) | (mant << 13); + return Float.intBitsToFloat(bits); + } + + @Override + public int compressedVectorSize() { + return QuantizedVector.serializedSizeBytes(quantizedDim, bitsPerDimension); + } + + @Override + public double reconstructionError(VectorFloat vector) { + throw new UnsupportedOperationException("Reconstruction error not defined for ASH"); + } + + /** + * Retained model storage, including provider-owned native weights and lazy query weights. + * Like PQ's compressor accounting, this excludes worker scratch and training temporaries. + * Immutable Java backing is measured once; querying this estimate does not scan matrices. + */ + @Override + public long ramBytesUsed() { + long bytes = heapBytes + stiefelTransform.projection.ramBytesUsed(); + var queryProjection = stiefelTransform.queryProjection; + if (queryProjection != null) { + bytes += stiefelTransform.queryProjectionHeapBytes; + if (queryProjection != stiefelTransform.projection) bytes += queryProjection.ramBytesUsed(); + } + return bytes; + } + + static long estimateArray2DBytes(double[][] a) { + if (a == null) return 0L; + long bytes = RamUsageEstimator.shallowSizeOf(a); + for (double[] row : a) bytes = Math.addExact(bytes, RamUsageEstimator.shallowSizeOf(row)); + return bytes; + } + + static long estimateArray2DBytes(float[][] a) { + if (a == null) return 0L; + long bytes = RamUsageEstimator.shallowSizeOf(a); + for (float[] row : a) bytes = Math.addExact(bytes, RamUsageEstimator.shallowSizeOf(row)); + return bytes; + } + + private static long estimateMatrixBytes(RealMatrix matrix) { + long bytes = RamUsageEstimator.shallowSizeOf(matrix); + if (matrix instanceof Array2DRowRealMatrix) { + return bytes + estimateArray2DBytes(((Array2DRowRealMatrix) matrix).getDataRef()); + } + int rows = matrix.getRowDimension(), cols = matrix.getColumnDimension(); + if (matrix instanceof org.apache.commons.math3.linear.BlockRealMatrix) { + int width = org.apache.commons.math3.linear.BlockRealMatrix.BLOCK_SIZE; + long blocks = ((rows - 1L) / width + 1) * ((cols - 1L) / width + 1); + bytes += RamUsageEstimator.alignObjectSize(RamUsageEstimator.NUM_BYTES_ARRAY_HEADER + + blocks * RamUsageEstimator.NUM_BYTES_OBJECT_REF); + // Tail blocks have their own alignment padding; aggregating their + // payloads before alignment undercounts some 16-byte-aligned layouts. + for (long firstRow = 0; firstRow < rows; firstRow += width) { + long blockRows = Math.min(width, rows - firstRow); + for (long firstColumn = 0; firstColumn < cols; firstColumn += width) { + long blockColumns = Math.min(width, cols - firstColumn); + bytes = Math.addExact(bytes, RamUsageEstimator.alignObjectSize( + RamUsageEstimator.NUM_BYTES_ARRAY_HEADER + + blockRows * blockColumns * Double.BYTES)); + } + } + return bytes; + } + // Other RealMatrix implementations retain the existing dense estimate. + // Avoid getData(), which can allocate a full matrix copy just to measure it. + return bytes + RamUsageEstimator.alignObjectSize(RamUsageEstimator.NUM_BYTES_ARRAY_HEADER + + (long) rows * RamUsageEstimator.NUM_BYTES_OBJECT_REF) + + rows * RamUsageEstimator.alignObjectSize(RamUsageEstimator.NUM_BYTES_ARRAY_HEADER + + (long) cols * Double.BYTES); + } + + // Exact, element-wise comparison of VectorFloat contents. + private static boolean vectorEquals(VectorFloat a, VectorFloat b) { + if (a == b) return true; + if (a == null || b == null) return false; + if (a.length() != b.length()) return false; + + for (int i = 0; i < a.length(); i++) { + if (Float.floatToIntBits(a.get(i)) != Float.floatToIntBits(b.get(i))) { + return false; + } + } + return true; + } + + // Hash based on the raw bit representation of each float. + private static int vectorHash(VectorFloat v) { + if (v == null) return 0; + int h = 1; + for (int i = 0; i < v.length(); i++) { + h = 31 * h + Integer.hashCode(Float.floatToIntBits(v.get(i))); + } + return h; + } + + // Exact comparison of two RealMatrix instances by dimensions and entries. + private static boolean matrixEquals(RealMatrix a, RealMatrix b) { + if (a == b) return true; + if (a == null || b == null) return false; + if (a.getRowDimension() != b.getRowDimension() + || a.getColumnDimension() != b.getColumnDimension()) { + return false; + } + + for (int i = 0; i < a.getRowDimension(); i++) { + for (int j = 0; j < a.getColumnDimension(); j++) { + if (Double.doubleToLongBits(a.getEntry(i, j)) + != Double.doubleToLongBits(b.getEntry(i, j))) { + return false; + } + } + } + return true; + } + + // Hash code derived from matrix dimensions and raw double bits. + private static int matrixHash(RealMatrix m) { + if (m == null) return 0; + int h = 1; + h = 31 * h + m.getRowDimension(); + h = 31 * h + m.getColumnDimension(); + + for (int i = 0; i < m.getRowDimension(); i++) { + for (int j = 0; j < m.getColumnDimension(); j++) { + long bits = Double.doubleToLongBits(m.getEntry(i, j)); + h = 31 * h + (int) (bits ^ (bits >>> 32)); + } + } + return h; + } + + /** + * Compares this ASH compressor to another for encoding equivalence. + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AsymmetricHashing that = (AsymmetricHashing) o; + return originalDimension == that.originalDimension + && encodedBits == that.encodedBits + && bodyBits == that.bodyBits + && quantizedDim == that.quantizedDim + && bitsPerDimension == that.bitsPerDimension + && optimizer == that.optimizer + && landmarksEqual(this.landmarks, that.landmarks) + && matrixEquals( + this.stiefelTransform == null ? null : this.stiefelTransform.W, + that.stiefelTransform == null ? null : that.stiefelTransform.W + ); + } + + private static boolean landmarksEqual(VectorFloat[] a, VectorFloat[] b) { + if (a == b) return true; + if (a == null || b == null) return false; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (!vectorEquals(a[i], b[i])) return false; + } + return true; + } + + /** + * Hash code consistent with {@link #equals(Object)}. + */ + @Override + public int hashCode() { + int result = Integer.hashCode(originalDimension); + result = 31 * result + Integer.hashCode(encodedBits); + result = 31 * result + Integer.hashCode(bodyBits); + result = 31 * result + Integer.hashCode(quantizedDim); + result = 31 * result + Integer.hashCode(bitsPerDimension); + result = 31 * result + Integer.hashCode(optimizer); + result = 31 * result + landmarksHash(landmarks); + result = 31 * result + matrixHash( + stiefelTransform == null ? null : stiefelTransform.W + ); + return result; + } + + private static int landmarksHash(VectorFloat[] a) { + if (a == null) return 0; + int h = 1; + for (VectorFloat v : a) { + h = 31 * h + vectorHash(v); + } + return h; + } + + @Override + public String toString() { + String optimizerName; + switch (optimizer) { + case RANDOM: + optimizerName = "RANDOM"; + break; + case ITQ: + optimizerName = "ITQ"; + break; + default: + optimizerName = "UNKNOWN(" + optimizer + ")"; + } + + return String.format( + "AsymmetricHashing[origDim=%d, encodedBits=%d, bodyBits=%d, quantizedDim=%d, bitsPerDimension=%d, optimizer=%s, landmarks=%d]", + originalDimension, + encodedBits, + bodyBits, + quantizedDim, + bitsPerDimension, + optimizerName, + landmarkCount + ); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java new file mode 100644 index 000000000..17c5fe9c1 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHBlockPacker.java @@ -0,0 +1,103 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.ByteOrder; + +/** Packs canonical ASH codes into the existing fused neighborhood layout. */ +public final class FusedASHBlockPacker { + private static final VarHandle LONGS = + MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN); + private static final ThreadLocal WORK = ThreadLocal.withInitial(() -> new long[8]); + + private FusedASHBlockPacker() {} + + /** + * Packs up to {@code blockSize} vectors from {@code vectors[first]} into a + * zero-initialized block. Supported block sizes are 8, 16 and 32. + * Header encoding and lane order are identical to packQuantizedVector. + */ + public static void pack(byte[] destination, int offset, + AsymmetricHashing.QuantizedVector[] vectors, int first, int count, + int dimension, int bits, int blockSize) { + if (bits == 1 || FusedASHLayout.usesFieldBlocks(bits)) { + for (int lane = 0; lane < count; lane++) { + FusedASHLayout.packQuantizedVector(destination, offset, lane, + vectors[first + lane], dimension, bits, blockSize); + } + return; + } + int groups = FusedASHLayout.codeGroups(dimension, bits); + int fullBytes = groups / 2; + for (int lane = 0; lane < count; lane++) { + byte[] code = vectors[first + lane].extraBits; + if (code == null || code.length < (groups + 1) / 2) { + throw new IllegalArgumentException("Missing or truncated ASH code"); + } + } + + long[] work = WORK.get(); + int column = 0; + // Transpose an 8-neighbor by 8-byte tile using three swap stages. + // Explicit little-endian loads/stores preserve the format on either endianness. + for (; column + 8 <= fullBytes; column += 8) { + for (int laneBase = 0; laneBase < blockSize; laneBase += 8) { + for (int i = 0; i < 8; i++) { + work[i] = laneBase + i < count + ? (long) LONGS.get(vectors[first + laneBase + i].extraBits, column) + : 0; + } + for (int step = 1; step <= 4; step *= 2) { + int shift = step * 8; + long mask = step == 1 ? 0x00ff00ff00ff00ffL + : step == 2 ? 0x0000ffff0000ffffL : 0x00000000ffffffffL; + for (int tile = 0; tile < 8; tile += 2 * step) { + for (int j = 0; j < step; j++) { + long a = work[tile + j]; + long b = work[tile + j + step]; + work[tile + j] = (a & mask) | ((b & mask) << shift); + work[tile + j + step] = ((a >>> shift) & mask) | (b & ~mask); + } + } + } + for (int i = 0; i < 8; i++) { + LONGS.set(destination, offset + (column + i) * blockSize + laneBase, work[i]); + } + } + } + for (; column < fullBytes; column++) { + for (int lane = 0; lane < count; lane++) { + destination[offset + column * blockSize + lane] = vectors[first + lane].extraBits[column]; + } + } + // An odd group count leaves a high padding nibble; preserve it. + if ((groups & 1) != 0) { + for (int lane = 0; lane < count; lane++) { + int at = offset + fullBytes * blockSize + lane; + destination[at] = (byte) ((destination[at] & 0xf0) + | (vectors[first + lane].extraBits[fullBytes] & 15)); + } + } + for (int lane = 0; lane < count; lane++) { + var vector = vectors[first + lane]; + FusedASHLayout.writeLaneHeader(destination, offset, lane, dimension, bits, blockSize, + vector.scale, vector.offset, vector.landmark); + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java new file mode 100644 index 000000000..e1c860948 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHDecoder.java @@ -0,0 +1,393 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.agrona.collections.Int2ObjectHashMap; + +import java.util.Objects; + +/** + * Scores FusedASH neighborhood blocks. + * + *

The fused layout is intentionally block-oriented: {@link #enableSimilarityToNeighbors(int)} + * reads and scores the whole packed neighborhood for one origin node, and + * {@link #similarityToNeighbor(int, int)} becomes a cheap array lookup. This preserves the + * existing fused-feature search API while avoiding one-neighbor-at-a-time decode work in the + * hot graph-expansion loop.

+ * + *

Each decoder and its mutable scratch/cache are confined to one graph view/search thread. + * A decoder borrowing view-owned query scratch is invalidated by the next query on that view. + * Standalone constructors own independent preparation state.

+ * + *

Scalar and SIMD execution share the same packed layout. The property + * {@code jvector.ash.blockKernel=auto|scalar|simd} selects execution once per decoder; + * forced SIMD fails at construction if the backend cannot provide it.

+ * + *

FusedASH has two primary scoring paths:

+ * + *
    + *
  • L0 neighborhood scoring uses fused packed ASH blocks via + * {@link #enableSimilarityToNeighbors(int)} and + * {@link #similarityToNeighbor(int, int)}.
  • + *
  • Upper-layer/direct node scoring uses canonical per-node ASH source + * features via {@link #similarityTo(int)}. Upper layers are not fused.
  • + *
+ * + *

FusedASH lane headers are expected to contain projection-adjusted offsets:

+ * + *
+ *   offset = <x, μ> - ||μ||² - scale * <Aμ, code>
+ * 
+ * + *

Given that encode-time adjustment, the raw dot-product estimate is:

+ * + *
+ *   rawDotProduct = scale * <Aq, code> + <q, μ> + offset
+ *   similarity = (1 + rawDotProduct) / 2   (unclamped, the exact DOT_PRODUCT scale)
+ * 
+ */ +public final class FusedASHDecoder implements ScoreFunction.AdaptiveApproximateScoreFunction { + + /** + * Source of fused neighborhood bytes. The implementation is provided by the graph feature, + * typically by reading the FusedASH inline payload for {@code origin} into {@code dest}. + */ + public interface PackedNeighborhoods { + /** Replaces the complete serialized payload for {@code origin} in caller-owned storage. */ + void readInto(int origin, byte[] dest); + + /** Returns the scored capacity, including padding, of each neighborhood. */ + int maxDegree(); + + /** Returns the complete serialized byte count for the configured layout. */ + int featureSize(); + } + + /** + * Converts an in-memory upper-layer/source feature into the canonical ASH vector + * used for primary direct-node ASH scoring via {@link #similarityTo(int)}. + */ + @FunctionalInterface + public interface InlineSourceAccessor { + AsymmetricHashing.QuantizedVector vector(FusedFeature.InlineSource source); + } + + /** + * Reusable preparation and scoring arrays for one graph view and one ASH model. + * Starting a new query invalidates that view's previous decoder; use a separate + * view for overlapping searches. Standalone constructors retain independent state. + */ + public static final class QueryScratch { + private final AsymmetricHashing ash; + private final ASHScorer scorer; + private final ASHScorer.QueryPrecompute prepared; + private final float[] input; + private final float[] lut; + private final byte[] neighborhoodBytes; + private final float[] scores; + private final int blockSize; + private long generation; + + public QueryScratch(AsymmetricHashing ash, int maxDegree, int blockSize) { + this.ash = Objects.requireNonNull(ash, "ash"); + int size = FusedASHLayout.featureSize(maxDegree, ash.quantizedDim, ash.bitsPerDimension, blockSize); + this.blockSize = blockSize; + this.scorer = new ASHScorer(ash); + this.prepared = scorer.newQueryPrecompute(); + this.input = new float[ash.originalDimension]; + this.lut = new float[Math.multiplyExact(FusedASHLayout.codeGroups(ash.quantizedDim, ash.bitsPerDimension), 16)]; + this.neighborhoodBytes = new byte[size]; + this.scores = new float[maxDegree]; + } + } + + private final QueryScratch queryScratch; + private final long generation; + private final ASHQueryPreparer.State externalPrepared; + private final long preparedGeneration; + + private final PackedNeighborhoods packedNeighborhoods; + private final Int2ObjectHashMap hierarchyCachedFeatures; + private final InlineSourceAccessor inlineSourceAccessor; + private final ASHScorer.ASHScoreFunction sourceScorer; + private final float dotProductBound; + + private final int maxDegree; + + private final byte[] neighborhoodScratch; + private final float[] neighborScores; + + private final ASHNeighborhoodScorer neighborhoodScorer; + + private int origin = -1; + + /** + * Creates a query-local decoder and shares one projection between L0 and upper-layer scoring. + * + * @param packedNeighborhoods source of complete fused payloads with a fixed layout + * @param ash compressor that encoded the graph's ASH features + * @param hierarchyCachedFeatures canonical features available for direct/upper-layer scoring + * @param inlineSourceAccessor reads a canonical ASH vector from a cached feature + * @param query original-dimensional query, normalized by the caller when appropriate + * @param reusableNeighborhoodBytes decoder-owned scratch, at least the complete feature size + * @param reusableScores decoder-owned scratch, at least the source's maximum degree + * @param blockSize serialized block width: 8, 16, or 32 + * @param similarityFunction must be DOT_PRODUCT + * @throws IllegalArgumentException if dimensions, payload size, or scratch capacity disagree + * @throws UnsupportedOperationException for another similarity function + */ + public FusedASHDecoder( + PackedNeighborhoods packedNeighborhoods, + AsymmetricHashing ash, + Int2ObjectHashMap hierarchyCachedFeatures, + InlineSourceAccessor inlineSourceAccessor, + VectorFloat query, + byte[] reusableNeighborhoodBytes, + float[] reusableScores, + int blockSize, + VectorSimilarityFunction similarityFunction) { + + this(packedNeighborhoods, ash, hierarchyCachedFeatures, inlineSourceAccessor, query, + reusableNeighborhoodBytes, reusableScores, blockSize, similarityFunction, null, null); + } + + private FusedASHDecoder( + PackedNeighborhoods packedNeighborhoods, AsymmetricHashing ash, + Int2ObjectHashMap hierarchyCachedFeatures, + InlineSourceAccessor inlineSourceAccessor, VectorFloat query, + byte[] reusableNeighborhoodBytes, float[] reusableScores, int blockSize, + VectorSimilarityFunction similarityFunction, QueryScratch scratch, + ASHQueryPreparer.State externalPrepared) { + + if (similarityFunction != VectorSimilarityFunction.DOT_PRODUCT) { + throw new UnsupportedOperationException("FusedASH supports DOT_PRODUCT only"); + } + + this.packedNeighborhoods = Objects.requireNonNull(packedNeighborhoods, "packedNeighborhoods"); + Objects.requireNonNull(ash, "ash"); + this.hierarchyCachedFeatures = Objects.requireNonNull( + hierarchyCachedFeatures, + "hierarchyCachedFeatures" + ); + this.inlineSourceAccessor = Objects.requireNonNull( + inlineSourceAccessor, + "inlineSourceAccessor" + ); + Objects.requireNonNull(query, "query"); + if (query.length() != ash.originalDimension) { + throw new IllegalArgumentException("ASH query dimension must be " + ash.originalDimension); + } + + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + + this.maxDegree = packedNeighborhoods.maxDegree(); + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + + int quantizedDim = ash.quantizedDim; + int bitsPerDimension = ash.bitsPerDimension; + + int expectedFeatureSize = FusedASHLayout.featureSize( + maxDegree, + quantizedDim, + bitsPerDimension, + blockSize + ); + if (packedNeighborhoods.featureSize() != expectedFeatureSize) { + throw new IllegalArgumentException( + "FusedASH feature size mismatch: expected " + expectedFeatureSize + + ", got " + packedNeighborhoods.featureSize()); + } + if (reusableNeighborhoodBytes.length < expectedFeatureSize) { + throw new IllegalArgumentException("reusableNeighborhoodBytes is smaller than FusedASH feature size"); + } + if (reusableScores.length < maxDegree) { + throw new IllegalArgumentException("reusableScores is smaller than maxDegree"); + } + + this.neighborhoodScratch = reusableNeighborhoodBytes; + this.neighborScores = reusableScores; + + // Upper-layer source scoring and L0 block scoring use the same Aq and + // values. Prepare them once, then build each scoring form. + if (scratch != null && (scratch.ash != ash || scratch.blockSize != blockSize + || scratch.scores.length != maxDegree)) { + throw new IllegalArgumentException("Query scratch belongs to a different ASH model or graph layout"); + } + this.queryScratch = scratch; + this.externalPrepared = externalPrepared; + this.preparedGeneration = externalPrepared == null ? 0 : externalPrepared.generation; + var scorer = scratch == null ? new ASHScorer(ash) : scratch.scorer; + ASHScorer.QueryPrecompute preparedQuery; + if (externalPrepared != null) { + externalPrepared.check(preparedGeneration); + this.generation = scratch == null ? 0 : ++scratch.generation; + preparedQuery = externalPrepared.prepared; + } else if (scratch == null) { + this.generation = 0; + preparedQuery = scorer.precomputeQuery(query); + } else { + // Invalidate before projection: failure must not leave a usable old decoder + // whose query buffers have been partially overwritten. + this.generation = ++scratch.generation; + preparedQuery = scratch.prepared; + scorer.prepareQuery(query, scratch.input, preparedQuery); + } + this.neighborhoodScorer = new ASHNeighborhoodScorer(ash, preparedQuery, maxDegree, blockSize, + externalPrepared == null ? (scratch == null ? null : scratch.lut) : externalPrepared.lut, + externalPrepared != null); + this.sourceScorer = scorer.scoreFunctionFor(preparedQuery, neighborhoodScorer.queryLut()); + this.dotProductBound = scorer.dotProductBound(preparedQuery); + } + + /** The query's length times the largest encoded vector length, taken when this decoder was prepared. */ + @Override + public float dotProductBound() { + return dotProductBound; + } + + public static FusedASHDecoder newDecoder( + PackedNeighborhoods packedNeighborhoods, + AsymmetricHashing ash, + Int2ObjectHashMap hierarchyCachedFeatures, + InlineSourceAccessor inlineSourceAccessor, + VectorFloat query, + byte[] reusableNeighborhoodBytes, + float[] reusableScores, + int blockSize, + VectorSimilarityFunction similarityFunction) { + return new FusedASHDecoder( + packedNeighborhoods, + ash, + hierarchyCachedFeatures, + inlineSourceAccessor, + query, + reusableNeighborhoodBytes, + reusableScores, + blockSize, + similarityFunction); + } + + /** Creates a query decoder using scratch owned by its graph view. */ + public static FusedASHDecoder newDecoder( + PackedNeighborhoods neighborhoods, AsymmetricHashing ash, + Int2ObjectHashMap hierarchyFeatures, + InlineSourceAccessor sourceAccessor, VectorFloat query, QueryScratch scratch, + int blockSize, VectorSimilarityFunction similarityFunction) { + Objects.requireNonNull(scratch, "scratch"); + return new FusedASHDecoder(neighborhoods, ash, hierarchyFeatures, sourceAccessor, query, + scratch.neighborhoodBytes, scratch.scores, blockSize, similarityFunction, scratch, null); + } + + /** Binds caller-owned preparation; no projection or LUT construction is repeated. */ + public static FusedASHDecoder newDecoder( + PackedNeighborhoods neighborhoods, AsymmetricHashing ash, + Int2ObjectHashMap hierarchyFeatures, + InlineSourceAccessor sourceAccessor, + io.github.jbellis.jvector.graph.similarity.PreparedQuery preparedQuery, + QueryScratch scratch, int blockSize) { + Objects.requireNonNull(scratch, "scratch"); + ASHQueryPreparer.State prepared = ASHQueryPreparer.state(ash, preparedQuery, true); + return new FusedASHDecoder(neighborhoods, ash, hierarchyFeatures, sourceAccessor, + prepared.queryVector(), scratch.neighborhoodBytes, scratch.scores, blockSize, + VectorSimilarityFunction.DOT_PRODUCT, scratch, prepared); + } + + private void checkQueryLifetime() { + if (externalPrepared != null) externalPrepared.check(preparedGeneration); + if (queryScratch != null && generation != queryScratch.generation) { + throw new IllegalStateException("FusedASH decoder was invalidated by a new query on the same graph view"); + } + } + + @Override + public boolean supportsSimilarityToNeighbors() { + return true; + } + + @Override + public void enableSimilarityToNeighbors(int origin) { + checkQueryLifetime(); + if (this.origin == origin) { + return; + } + + // Invalidate before I/O: a failed read must not leave partially replaced scores cached. + this.origin = -1; + packedNeighborhoods.readInto(origin, neighborhoodScratch); + scoreNeighborhood(); + this.origin = origin; + } + + /** + * Scores a directly-addressed node using its canonical ASH source feature. + * + *

This method is used for upper-layer graph traversal and other direct-node + * primary scoring paths. It must not call the configured reranker. Missing + * source features indicate an invalid fused graph/source-feature layout.

+ */ + @Override + public float similarityTo(int node2) { + checkQueryLifetime(); + FusedFeature.InlineSource source = hierarchyCachedFeatures.get(node2); + if (source == null) { + throw new IllegalStateException( + "Missing FusedASH source feature for direct ASH scoring of node " + node2 + + ". FusedASH requires canonical ASH source features for every upper-layer node " + + "that can be scored directly." + ); + } + + AsymmetricHashing.QuantizedVector vector = inlineSourceAccessor.vector(source); + if (vector == null) { + throw new IllegalStateException( + "FusedASH source accessor returned null for node " + node2 + ); + } + + return ASHScorer.toSimilarity(sourceScorer.similarityTo(vector)); + } + + @Override + public float similarityToNeighbor(int origin, int neighborIndex) { + checkQueryLifetime(); + if (this.origin != origin) { + throw new IllegalArgumentException( + "origin must be the same as the origin used to enable similarityToNeighbor"); + } + if (neighborIndex < 0 || neighborIndex >= maxDegree) { + throw new IllegalArgumentException("neighborIndex out of range: " + neighborIndex); + } + return neighborScores[neighborIndex]; + } + + /** Uses the same prepared scorer as the isolated benchmark; graph I/O stays in this decoder. */ + private void scoreNeighborhood() { + neighborhoodScorer.score(neighborhoodScratch, neighborScores); + } + + @Override + public String toString() { + return "FusedASHDecoder[" + neighborhoodScorer + "]"; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java new file mode 100644 index 000000000..e24c758fa --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedASHLayout.java @@ -0,0 +1,613 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +/** + * Layout and scalar reference helpers for FusedASH neighborhood blocks. + * + *

The canonical ASH vector format remains owned by {@link AsymmetricHashing}. + * FusedASH stores each origin node's neighbor codes in a neighborhood-local block + * layout optimized for scoring the whole adjacency list at once. The projection + * code itself follows the C++ fast-scan nibble model for bits-per-dimension + * values {@code 1}, {@code 2}, and {@code 4}:

+ * + *
    + *
  • 1 bit/dim: four projected dimensions per nibble
  • + *
  • 2 bits/dim: two projected dimensions per nibble
  • + *
  • 4 bits/dim: one projected dimension per nibble
  • + *
+ * + *

Within a fused block, bytes are stored by code-group pair first and lane + * second:

+ * + *
+ *   byteIndex = blockOffset + (group / 2) * blockSize + lane
+ * 
+ * + *

The low nibble stores even groups and the high nibble stores odd groups. + * This is deliberately graph-neighborhood-oriented: it supports block sizes + * {@code 8}, {@code 16}, and {@code 32} without copying the IVF-specific B32 + * addressing scheme.

+ * + *

Offset-binary codes (3 and 5 to 9 bits per dimension) use field blocks instead, scored with an + * INT8 query: each unit of eight dimensions holds every lane's {@code b} code bytes contiguously,

+ * + *
+ *   byteIndex = blockOffset + (unit * blockSize + lane) * b + k,   k in [0, b)
+ * 
+ * + *

so one 64-bit word per lane holds that unit's eight fields. Both layouts are followed by the + * same per-lane headers.

+ * + *

Block and neighborhood byte-size calculations use checked arithmetic. Shapes whose + * serialized storage cannot fit a Java array fail with {@link ArithmeticException}.

+ */ +public final class FusedASHLayout { + private FusedASHLayout() {} + + public static final int SCALE_BYTES = Short.BYTES; + public static final int OFFSET_BYTES = Short.BYTES; + public static final int LANDMARK_BYTES = Byte.BYTES; + public static final int HEADER_BYTES_PER_LANE = AsymmetricHashing.HEADER_BYTES; + + /** + * Every ASH width is supported. Each lane's canonical code is a bit stream scanned in 4-bit + * groups: 1-bit signs and 2/4-bit sign-magnitude fields fill whole groups, and the offset-binary + * fields of the other widths are linear in their bits, so any group of 4 bits has a 16-entry + * query table. + */ + public static boolean supportsBitsPerDimension(int bitsPerDimension) { + return bitsPerDimension >= 1 && bitsPerDimension <= 9; + } + + public static void validateBitsPerDimension(int bitsPerDimension) { + if (!supportsBitsPerDimension(bitsPerDimension)) { + throw new IllegalArgumentException( + "FusedASH supports bitsPerDimension 1 through 9; got " + bitsPerDimension); + } + } + + public static void validateBlockSize(int blockSize) { + if (blockSize != 8 && blockSize != 16 && blockSize != 32) { + throw new IllegalArgumentException( + "FusedASH blockSize must be one of {8,16,32}; got " + blockSize); + } + } + + public static int chooseBlockSize(int maxDegree) { + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + if (maxDegree <= 8) return 8; + if (maxDegree <= 16) return 16; + return 32; + } + + /** Dimensions per 4-bit group for the widths whose fields fill whole groups: 1, 2 and 4 bits. */ + public static int projectionDimsPerNibble(int bitsPerDimension) { + if (bitsPerDimension != 1 && bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException("Whole-nibble fields require 1, 2 or 4 bits; got " + bitsPerDimension); + } + return 4 / bitsPerDimension; + } + + /** 4-bit groups in one canonical code: {@code ceil(quantizedDim * bits / 4)}. */ + public static int codeGroups(int quantizedDim, int bitsPerDimension) { + if (quantizedDim <= 0) { + throw new IllegalArgumentException("quantizedDim must be > 0"); + } + validateBitsPerDimension(bitsPerDimension); + return Math.toIntExact(((long) quantizedDim * bitsPerDimension + 3) / 4); + } + + public static int canonicalCodeBytes(int quantizedDim, int bitsPerDimension) { + return (codeGroups(quantizedDim, bitsPerDimension) - 1) / 2 + 1; + } + + /** + * Whether the graph neighborhood body uses field blocks: offset-binary codes (3 and 5 to 9 bits) are stored + * as {@code [unit][lane][bits bytes]}, where a unit is eight dimensions, so each lane's eight fields are + * contiguous for INT8 dot products. Other widths store byte {@code p} of every lane's code in row {@code p}. + */ + public static boolean usesFieldBlocks(int bitsPerDimension) { + return AsymmetricHashing.usesOffsetBinaryCode(bitsPerDimension); + } + + /** Eight-dimension units in a field-block code. */ + public static int fieldUnits(int quantizedDim) { + if (quantizedDim <= 0) { + throw new IllegalArgumentException("quantizedDim must be > 0"); + } + return (quantizedDim - 1) / 8 + 1; + } + + /** Body bytes of one graph neighborhood block, in this width's layout. */ + public static int blockBodyBytes(int quantizedDim, int bitsPerDimension, int blockSize) { + validateBlockSize(blockSize); + if (usesFieldBlocks(bitsPerDimension)) { + return Math.multiplyExact(Math.multiplyExact(fieldUnits(quantizedDim), bitsPerDimension), blockSize); + } + return nibbleBlockBodyBytes(quantizedDim, bitsPerDimension, blockSize); + } + + /** Body bytes of one block whose rows hold byte {@code p} of every lane's code (the LUT layout). */ + public static int nibbleBlockBodyBytes(int quantizedDim, int bitsPerDimension, int blockSize) { + validateBlockSize(blockSize); + return Math.multiplyExact(canonicalCodeBytes(quantizedDim, bitsPerDimension), blockSize); + } + + /** + * Copies one lane's canonical code out of a graph neighborhood block into {@code dest}, which must hold + * {@link #canonicalCodeBytes} bytes. For 1-bit codes these are the packed sign nibbles. + */ + public static void readLaneCode(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, + int blockSize, byte[] dest) { + validateLane(lane, blockSize); + int bytes = canonicalCodeBytes(quantizedDim, bitsPerDimension); + if (usesFieldBlocks(bitsPerDimension)) { + int b = bitsPerDimension; + for (int i = 0; i < bytes; i++) { + int unit = i / b; + dest[i] = block[blockOffset + (unit * blockSize + lane) * b + i % b]; + } + return; + } + for (int i = 0; i < bytes; i++) dest[i] = block[blockOffset + i * blockSize + lane]; + } + + public static int blockHeaderBytes(int blockSize) { + validateBlockSize(blockSize); + return HEADER_BYTES_PER_LANE * blockSize; + } + + public static int blockBytes(int quantizedDim, int bitsPerDimension, int blockSize) { + return Math.addExact(blockBodyBytes(quantizedDim, bitsPerDimension, blockSize), blockHeaderBytes(blockSize)); + } + + public static int blocksPerNode(int maxDegree, int blockSize) { + if (maxDegree <= 0) { + throw new IllegalArgumentException("maxDegree must be > 0"); + } + validateBlockSize(blockSize); + return (maxDegree - 1) / blockSize + 1; + } + + public static int featureSize(int maxDegree, int quantizedDim, int bitsPerDimension, int blockSize) { + return Math.multiplyExact(blocksPerNode(maxDegree, blockSize), blockBytes(quantizedDim, bitsPerDimension, blockSize)); + } + + public static int blockOffset(int blockIndex, int quantizedDim, int bitsPerDimension, int blockSize) { + if (blockIndex < 0) { + throw new IllegalArgumentException("blockIndex must be nonnegative"); + } + return Math.multiplyExact(blockIndex, blockBytes(quantizedDim, bitsPerDimension, blockSize)); + } + + public static int scaleOffset(int blockOffset, int quantizedDim, int bitsPerDimension, int blockSize) { + return blockOffset + blockBodyBytes(quantizedDim, bitsPerDimension, blockSize); + } + + public static int offsetOffset(int blockOffset, int quantizedDim, int bitsPerDimension, int blockSize) { + return scaleOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + SCALE_BYTES * blockSize; + } + + public static int landmarkOffset(int blockOffset, int quantizedDim, int bitsPerDimension, int blockSize) { + return offsetOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + OFFSET_BYTES * blockSize; + } + + public static int getPackedNibble(byte[] block, int blockOffset, int lane, int group, int blockSize) { + validateLane(lane, blockSize); + int byteIndex = blockOffset + (group >>> 1) * blockSize + lane; + int value = block[byteIndex] & 0xFF; + return ((group & 1) == 0) ? (value & 0x0F) : ((value >>> 4) & 0x0F); + } + + public static void setPackedNibble(byte[] block, int blockOffset, int lane, int group, int blockSize, int nibble) { + validateLane(lane, blockSize); + int byteIndex = blockOffset + (group >>> 1) * blockSize + lane; + int old = block[byteIndex] & 0xFF; + int n = nibble & 0x0F; + block[byteIndex] = (byte) (((group & 1) == 0) + ? ((old & 0xF0) | n) + : ((old & 0x0F) | (n << 4))); + } + + public static int flatNibble(byte[] code, int group) { + int b = code[group >>> 1] & 0xFF; + return ((group & 1) == 0) ? (b & 0x0F) : ((b >>> 4) & 0x0F); + } + + public static void setFlatNibble(byte[] code, int group, int nibble) { + int byteIndex = group >>> 1; + int old = code[byteIndex] & 0xFF; + int n = nibble & 0x0F; + code[byteIndex] = (byte) (((group & 1) == 0) + ? ((old & 0xF0) | n) + : ((old & 0x0F) | (n << 4))); + } + + /** Value of a 1-bit sign or a 2/4-bit sign-magnitude field. */ + public static float decodeProjectionComponent(int field, int bitsPerDimension) { + projectionDimsPerNibble(bitsPerDimension); + if (bitsPerDimension == 1) { + return field != 0 ? 1.0f : -1.0f; + } + + int exBits = bitsPerDimension - 1; + int magMask = (1 << exBits) - 1; + boolean positive = ((field >>> exBits) & 1) != 0; + float mag = (field & magMask) + 0.5f; + return positive ? mag : -mag; + } + + public static int signNibbleFromWords(long[] signWords, int group, int quantizedDim) { + int baseDim = group << 2; + int nibble = 0; + for (int slot = 0; slot < 4; slot++) { + int dim = baseDim + slot; + if (dim >= quantizedDim) break; + if (((signWords[dim >>> 6] >>> (dim & 63)) & 1L) != 0L) { + nibble |= 1 << slot; + } + } + return nibble; + } + + public static void packQuantizedVector( + byte[] block, + int blockOffset, + int lane, + AsymmetricHashing.QuantizedVector vector, + int quantizedDim, + int bitsPerDimension, + int blockSize) { + validateBitsPerDimension(bitsPerDimension); + validateLane(lane, blockSize); + + int groups = codeGroups(quantizedDim, bitsPerDimension); + if (bitsPerDimension == 1) { + for (int group = 0; group < groups; group++) { + setPackedNibble(block, blockOffset, lane, group, blockSize, + signNibbleFromWords(vector.binaryVector, group, quantizedDim)); + } + } else if (usesFieldBlocks(bitsPerDimension)) { + byte[] code = vector.extraBits; + int bytes = canonicalCodeBytes(quantizedDim, bitsPerDimension); + if (code == null || code.length < bytes) { + throw new IllegalArgumentException("ASH projection code is missing or too short"); + } + // Bytes past the canonical code, in the last unit, are zero padding. + int b = bitsPerDimension, units = fieldUnits(quantizedDim); + for (int unit = 0; unit < units; unit++) { + int destination = blockOffset + (unit * blockSize + lane) * b; + for (int k = 0; k < b; k++) { + int i = unit * b + k; + block[destination + k] = i < bytes ? code[i] : 0; + } + } + } else { + byte[] code = vector.extraBits; + int bytes = canonicalCodeBytes(quantizedDim, bitsPerDimension); + if (code == null || code.length < bytes) { + throw new IllegalArgumentException("ASH projection code is missing or too short"); + } + int fullBytes = groups >>> 1; + int destination = blockOffset + lane; + for (int i = 0; i < fullBytes; i++, destination += blockSize) { + block[destination] = code[i]; + } + if ((groups & 1) != 0) { + block[destination] = (byte) ((block[destination] & 0xF0) | (code[fullBytes] & 0x0F)); + } + } + + writeLaneHeader(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize, + vector.scale, vector.offset, vector.landmark); + } + + /** + * Packs one canonical ASH vector into the graph's fused neighborhood representation. + * + *

Fused scoring uses {@code Aq} for every bit width. Canonical 1/2/4-bit offsets already + * contain the landmark adjustment {@code -scale * dot(A(mu), code)}, so the header is copied as + * stored. Benchmark fixtures call this to use the graph writer's exact bytes.

+ * + * @param block destination containing a complete serialized block + * @param blockOffset byte offset of that block + * @param lane destination lane within the block + * @param vector canonical quantized vector, whose landmark belongs to {@code ash} + * @param ash compressor that owns the vector's projection and landmarks + * @param blockSize serialized block width: 8, 16, or 32 + * @throws IllegalArgumentException if the landmark or block shape is invalid + */ + public static void packForFused(byte[] block, int blockOffset, int lane, + AsymmetricHashing.QuantizedVector vector, + AsymmetricHashing ash, int blockSize) { + int landmark = vector.landmark & 0xFF; + if (landmark >= ash.landmarkCount) { + throw new IllegalArgumentException("Invalid ASH landmark: " + landmark); + } + packQuantizedVector(block, blockOffset, lane, vector, + ash.quantizedDim, ash.bitsPerDimension, blockSize); + } + + public static void writeLaneHeader( + byte[] block, + int blockOffset, + int lane, + int quantizedDim, + int bitsPerDimension, + int blockSize, + float scale, + float offset, + byte landmark) { + validateLane(lane, blockSize); + writeFloat16(block, scaleOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * SCALE_BYTES, scale); + writeFloat16(block, offsetOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * OFFSET_BYTES, offset); + block[landmarkOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane] = landmark; + } + + public static float readScale(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, int blockSize) { + validateLane(lane, blockSize); + return readFloat16(block, scaleOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * SCALE_BYTES); + } + + public static float readOffset(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, int blockSize) { + validateLane(lane, blockSize); + return readFloat16(block, offsetOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane * OFFSET_BYTES); + } + + public static int readLandmark(byte[] block, int blockOffset, int lane, int quantizedDim, int bitsPerDimension, int blockSize) { + validateLane(lane, blockSize); + return block[landmarkOffset(blockOffset, quantizedDim, bitsPerDimension, blockSize) + lane] & 0xFF; + } + + public static void buildQueryLut(float[] qProj, int quantizedDim, int bitsPerDimension, float[] lut) { + if (AsymmetricHashing.usesOffsetBinaryCode(bitsPerDimension)) { + buildOffsetBinaryLut(qProj, quantizedDim, bitsPerDimension, lut); + return; + } + validateBitsPerDimension(bitsPerDimension); + int groupDims = projectionDimsPerNibble(bitsPerDimension); + int groups = codeGroups(quantizedDim, bitsPerDimension); + if (qProj.length < quantizedDim) { + throw new IllegalArgumentException("qProj length is smaller than quantizedDim"); + } + if (lut.length < groups * 16) { + throw new IllegalArgumentException("lut length is smaller than groups * 16"); + } + + // Every entry is still 0 + q[d0]*c0 + q[d1]*c1 + ..., added in slot order, + // so the table is bit-identical to the per-entry definition. Each product + // is formed once per dimension instead of once per entry, and the field + // decode is a constant table rather than a branch per term. + int complete = quantizedDim / groupDims; + if (bitsPerDimension == 2) { + for (int group = 0; group < complete; group++) lut2(qProj, group << 1, lut, group << 4); + } else if (bitsPerDimension == 1) { + for (int group = 0; group < complete; group++) lut1(qProj, group << 2, lut, group << 4); + } else { + for (int group = 0; group < complete; group++) { + float q = qProj[group]; + int base = group << 4; + for (int field = 0; field < 16; field++) lut[base + field] = 0f + q * DECODE_4BIT[field]; + } + } + // A partial final group adds only the dimensions that exist. + for (int group = complete; group < groups; group++) { + int dimBase = group * groupDims; + int lutBase = group << 4; + for (int nibble = 0; nibble < 16; nibble++) { + float sum = 0f; + for (int slot = 0; slot < groupDims; slot++) { + int dim = dimBase + slot; + if (dim >= quantizedDim) break; + int mask = (1 << bitsPerDimension) - 1; + int field = (nibble >>> (slot * bitsPerDimension)) & mask; + sum += qProj[dim] * decodeProjectionComponent(field, bitsPerDimension); + } + lut[lutBase + nibble] = sum; + } + } + } + + /** + * Table for offset-binary codes. Group {@code k} covers code bits {@code 4k..4k+3}; bit {@code i} is + * bit {@code i % b} of dimension {@code i / b} and adds {@code 2^(i % b) * q[i / b]}. Each dimension's + * centering term {@code -(2^b - 1)/2 * q[j]} is added to every entry of the group holding its bit 0, + * so each lookup sum includes it exactly once. + */ + private static void buildOffsetBinaryLut(float[] qProj, int quantizedDim, int bits, float[] lut) { + int groups = codeGroups(quantizedDim, bits); + if (qProj.length < quantizedDim) { + throw new IllegalArgumentException("qProj length is smaller than quantizedDim"); + } + if (lut.length < groups * 16) { + throw new IllegalArgumentException("lut length is smaller than groups * 16"); + } + float center = ((1 << bits) - 1) * 0.5f; + float[] weight = new float[4]; + for (int group = 0; group < groups; group++) { + float base = 0f; + for (int slot = 0; slot < 4; slot++) { + int bit = 4 * group + slot, dim = bit / bits, power = bit % bits; + weight[slot] = dim < quantizedDim ? qProj[dim] * (float) (1 << power) : 0f; + if (dim < quantizedDim && power == 0) base -= center * qProj[dim]; + } + int at = group << 4; + lut[at] = base; + for (int n = 1; n < 16; n++) lut[at + n] = lut[at + (n & (n - 1))] + weight[Integer.numberOfTrailingZeros(n)]; + } + } + + private static final float[] DECODE_4BIT = new float[16]; + static { + for (int field = 0; field < 16; field++) DECODE_4BIT[field] = decodeProjectionComponent(field, 4); + } + + // Two 2-bit fields per nibble: entry = (0 + a[n & 3]) + b[n >>> 2]. + // Field values -1.5, -0.5, 0.5, 1.5 (sign bit high, magnitude low). + private static void lut2(float[] qProj, int dim, float[] lut, int base) { + float q0 = qProj[dim], q1 = qProj[dim + 1]; + float a0 = 0f + q0 * -0.5f, a1 = 0f + q0 * -1.5f, a2 = 0f + q0 * 0.5f, a3 = 0f + q0 * 1.5f; + float b0 = q1 * -0.5f, b1 = q1 * -1.5f, b2 = q1 * 0.5f, b3 = q1 * 1.5f; + lut[base] = a0 + b0; lut[base + 1] = a1 + b0; lut[base + 2] = a2 + b0; lut[base + 3] = a3 + b0; + lut[base + 4] = a0 + b1; lut[base + 5] = a1 + b1; lut[base + 6] = a2 + b1; lut[base + 7] = a3 + b1; + lut[base + 8] = a0 + b2; lut[base + 9] = a1 + b2; lut[base + 10] = a2 + b2; lut[base + 11] = a3 + b2; + lut[base + 12] = a0 + b3; lut[base + 13] = a1 + b3; lut[base + 14] = a2 + b3; lut[base + 15] = a3 + b3; + } + + // Four sign bits per nibble: entry = (((0 + ±q0) + ±q1) + ±q2) + ±q3. + private static void lut1(float[] qProj, int dim, float[] lut, int base) { + float p0 = qProj[dim] * 1.0f, n0 = qProj[dim] * -1.0f; + float p1 = qProj[dim + 1] * 1.0f, n1 = qProj[dim + 1] * -1.0f; + float p2 = qProj[dim + 2] * 1.0f, n2 = qProj[dim + 2] * -1.0f; + float p3 = qProj[dim + 3] * 1.0f, n3 = qProj[dim + 3] * -1.0f; + for (int nibble = 0; nibble < 16; nibble++) { + float sum = 0f + ((nibble & 1) != 0 ? p0 : n0); + sum += (nibble & 2) != 0 ? p1 : n1; + sum += (nibble & 4) != 0 ? p2 : n2; + sum += (nibble & 8) != 0 ? p3 : n3; + lut[base + nibble] = sum; + } + } + + public static float scoreLane( + byte[] block, + int blockOffset, + int lane, + int quantizedDim, + int bitsPerDimension, + int blockSize, + float[] lut, + float[] dotQMuByLandmark) { + validateLane(lane, blockSize); + int groups = codeGroups(quantizedDim, bitsPerDimension); + float ip = 0f; + for (int group = 0; group < groups; group++) { + int nibble = getPackedNibble(block, blockOffset, lane, group, blockSize); + ip += lut[(group << 4) + nibble]; + } + + int landmark = readLandmark(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize); + float dotQMu = landmark < dotQMuByLandmark.length ? dotQMuByLandmark[landmark] : 0f; + return readScale(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize) * ip + + dotQMu + + readOffset(block, blockOffset, lane, quantizedDim, bitsPerDimension, blockSize); + } + + private static void validateLane(int lane, int blockSize) { + validateBlockSize(blockSize); + if (lane < 0 || lane >= blockSize) { + throw new IllegalArgumentException("lane out of range: " + lane + " for blockSize=" + blockSize); + } + } + + private static void writeFloat16(byte[] out, int offset, float value) { + int bits = floatToHalfBits(value); + out[offset] = (byte) ((bits >>> 8) & 0xFF); + out[offset + 1] = (byte) (bits & 0xFF); + } + + private static float readFloat16(byte[] in, int offset) { + int hi = in[offset] & 0xFF; + int lo = in[offset + 1] & 0xFF; + return halfBitsToFloat((hi << 8) | lo); + } + + /** IEEE-754 binary16 round-to-nearest-even conversion. */ + private static int floatToHalfBits(float value) { + int bits = Float.floatToRawIntBits(value); + int sign = (bits >>> 16) & 0x8000; + int abs = bits & 0x7FFF_FFFF; + + if (abs >= 0x7F80_0000) { + if ((abs & 0x007F_FFFF) == 0) { + return sign | 0x7C00; + } + return sign | 0x7E00; + } + + int exp = ((abs >>> 23) & 0xFF) - 127 + 15; + int mant = abs & 0x007F_FFFF; + + if (exp >= 31) { + return sign | 0x7C00; + } + + if (exp <= 0) { + if (exp < -10) { + return sign; + } + + mant |= 0x0080_0000; + int shift = 14 - exp; + int halfMant = mant >>> shift; + + int roundBit = 1 << (shift - 1); + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + } + + return sign | halfMant; + } + + int halfMant = mant >>> 13; + int roundBit = 0x0000_1000; + int remainder = mant & (roundBit - 1); + + if ((mant & roundBit) != 0 && (remainder != 0 || (halfMant & 1) != 0)) { + halfMant++; + if (halfMant == 0x0400) { + halfMant = 0; + exp++; + if (exp >= 31) { + return sign | 0x7C00; + } + } + } + + return sign | (exp << 10) | halfMant; + } + + private static float halfBitsToFloat(int half) { + int h = half & 0xFFFF; + int sign = (h & 0x8000) << 16; + int exp = (h >>> 10) & 0x1F; + int mant = h & 0x03FF; + + if (exp == 0) { + if (mant == 0) { + return Float.intBitsToFloat(sign); + } + + while ((mant & 0x0400) == 0) { + mant <<= 1; + exp--; + } + exp++; + mant &= 0x03FF; + } else if (exp == 31) { + return Float.intBitsToFloat(sign | 0x7F80_0000 | (mant << 13)); + } + + int floatExp = exp + (127 - 15); + return Float.intBitsToFloat(sign | (floatExp << 23) | (mant << 13)); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java index b52d0ca66..282da9db1 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/FusedPQDecoder.java @@ -124,6 +124,17 @@ public DotProductDecoder(FusedPQ.PackedNeighbors neighbors, ProductQuantization protected float distanceToScore(float distance) { return (distance + 1) / 2; } + + /** PQ scores the centered query against centered reconstructions, so their lengths bound the score. */ + @Override + public float dotProductBound() { + var center = pq.globalCentroid; + double squared = VectorUtil.dotProduct(query, query); + if (center != null) { + squared += VectorUtil.dotProduct(center, center) - 2.0 * VectorUtil.dotProduct(query, center); + } + return (float) Math.sqrt(Math.max(0.0, squared)) * pq.maxEncodedNorm(); + } } static class EuclideanDecoder extends FusedPQDecoder { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java index 8df05b675..e8afe756a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ImmutablePQVectors.java @@ -37,11 +37,24 @@ public class ImmutablePQVectors extends PQVectors { * @param vectorsPerChunk the number of vectors per chunk */ public ImmutablePQVectors(ProductQuantization pq, ByteSequence[] compressedDataChunks, int vectorCount, int vectorsPerChunk) { + this(pq, compressedDataChunks, vectorCount, vectorsPerChunk, true); + } + + /** + * @param recordCodes whether {@code pq} must count these codes' lengths in its + * {@link ProductQuantization#maxEncodedNorm}: true unless it encoded them, or they were saved + * with it + */ + ImmutablePQVectors(ProductQuantization pq, ByteSequence[] compressedDataChunks, int vectorCount, + int vectorsPerChunk, boolean recordCodes) { super(pq); this.compressedDataChunks = compressedDataChunks; this.vectorCount = vectorCount; this.vectorsPerChunk = vectorsPerChunk; this.codebookPartialSumsMap = new ConcurrentHashMap<>(); + if (recordCodes) { + for (int i = 0; i < vectorCount; i++) pq.recordCode(getChunk(i), getOffsetInChunk(i)); + } } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java index 29f0e884a..dc05709b8 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/KMeansPlusPlusClusterer.java @@ -37,8 +37,17 @@ * A KMeans++ implementation for float vectors. Optimizes to use SIMD vector instructions if available. */ public class KMeansPlusPlusClusterer { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(KMeansPlusPlusClusterer.class); + private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); + // Observability flags (disabled by default) + private static final boolean VERBOSE_KMEANS = + Boolean.getBoolean("jvector.kmeans.verbose"); + + private static final boolean VERBOSE_KMEANS_TIMING = + Boolean.getBoolean("jvector.kmeans.timing"); + public static final float UNWEIGHTED = -1.0f; // number of centroids to compute @@ -130,18 +139,70 @@ static float computeParallelCostMultiplier(double threshold, int dimensions) { * @return a VectorFloat of cluster centroids. */ public VectorFloat cluster(int unweightedIterations, int anisotropicIterations) { - // Always cluster unweighted first, it is significantly faster + + final int n = points.length; + final int threshold = Math.max(1, (int) (0.01 * n)); + + // ------------------------------------------------------------ + // Unweighted (isotropic) k-means phase + // ------------------------------------------------------------ for (int i = 0; i < unweightedIterations; i++) { + + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; int changedCount = clusterOnceUnweighted(); - if (changedCount <= 0.01 * points.length) { + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + if (VERBOSE_KMEANS) { + LOG.info(String.format("KMeans unweighted iter %d: %d/%d reassigned (%.3f%%)", + i, + changedCount, + n, + 100.0 * changedCount / n) + ); + } + + if (VERBOSE_KMEANS_TIMING) { + LOG.info(String.format(" time = %.3f ms", + (t1 - t0) * 1e-6) + ); + } + + if (changedCount <= threshold) { + if (VERBOSE_KMEANS) { + LOG.info("KMeans unweighted converged early"); + } break; } } - // Optionally, refine using anisotropic clustering + // ------------------------------------------------------------ + // Anisotropic refinement phase + // ------------------------------------------------------------ for (int i = 0; i < anisotropicIterations; i++) { + + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; int changedCount = clusterOnceAnisotropic(); - if (changedCount <= 0.01 * points.length) { + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + if (VERBOSE_KMEANS) { + LOG.info(String.format("KMeans anisotropic iter %d: %d/%d reassigned (%.3f%%)", + i, + changedCount, + n, + 100.0 * changedCount / n) + ); + } + + if (VERBOSE_KMEANS_TIMING) { + LOG.info(String.format(" time = %.3f ms", + (t1 - t0) * 1e-6) + ); + } + + if (changedCount <= threshold) { + if (VERBOSE_KMEANS) { + LOG.info("KMeans anisotropic converged early"); + } break; } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java new file mode 100644 index 000000000..ccf1d42dd --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MiniBatchKMeansClusterer.java @@ -0,0 +1,419 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.vector.Matrix; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import io.github.jbellis.jvector.vector.types.VectorTypeSupport; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import static io.github.jbellis.jvector.util.MathUtil.square; +import static io.github.jbellis.jvector.vector.VectorUtil.*; +import static java.lang.Math.max; + +/** + * A MiniBatch KMeans implementation for float vectors. + * Drop-in replacement for KMeansPlusPlusClusterer, optimized for large datasets (10M+). + * * Includes: + * 1. Mini-Batch Stochastic Updates (fast convergence). + * 2. K-Means++ Subsampling Initialization (fast startup). + */ +public class MiniBatchKMeansClusterer { + private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); + + private static final boolean VERBOSE_KMEANS = Boolean.getBoolean("jvector.kmeans.verbose"); + private static final boolean VERBOSE_KMEANS_TIMING = Boolean.getBoolean("jvector.kmeans.timing"); + + public static final float UNWEIGHTED = -1.0f; + + private final int k; + private final VectorFloat[] points; + private final int[] assignments; + private final VectorFloat centroids; + private final float anisotropicThreshold; + + // MiniBatch specific + private final int batchSize; + private final int[] centroidCounts; + + /** + * @param batchSize Recommended: 1024 or 2048 for large datasets + */ + public MiniBatchKMeansClusterer(VectorFloat[] points, int k, int batchSize) { + this(points, chooseInitialCentroids(points, k), UNWEIGHTED, batchSize); + } + + public MiniBatchKMeansClusterer(VectorFloat[] points, int k, float anisotropicThreshold, int batchSize) { + this(points, chooseInitialCentroids(points, k), anisotropicThreshold, batchSize); + } + + public MiniBatchKMeansClusterer(VectorFloat[] points, VectorFloat centroids, float anisotropicThreshold, int batchSize) { + if (Float.isNaN(anisotropicThreshold) || anisotropicThreshold < -1.0 || anisotropicThreshold >= 1.0) { + throw new IllegalArgumentException("Valid range for anisotropic threshold T is -1.0 <= t < 1.0"); + } + + this.points = points; + this.k = centroids.length() / points[0].length(); + this.centroids = centroids.copy(); + this.anisotropicThreshold = anisotropicThreshold; + this.batchSize = Math.min(batchSize, points.length); + + this.centroidCounts = new int[k]; + this.assignments = new int[points.length]; + Arrays.fill(this.assignments, -1); + } + + static float computeParallelCostMultiplier(double threshold, int dimensions) { + assert Double.isFinite(threshold) : "threshold=" + threshold; + double parallelCost = threshold * threshold; + double perpendicularCost = (1 - parallelCost) / (dimensions - 1); + return (float) max(1.0, (parallelCost / perpendicularCost)); + } + + /** + * Performs clustering. + * @param unweightedIterations If small ({@code < 100}), interpreted as EPOCHS (full passes). + * If large, interpreted as TOTAL BATCH UPDATES. + * @param anisotropicIterations Number of full-batch anisotropic refinement steps. + */ + public VectorFloat cluster(int unweightedIterations, int anisotropicIterations) { + + // ------------------------------------------------------------ + // Mini-Batch Unweighted (isotropic) k-means phase + // ------------------------------------------------------------ + int totalBatches = unweightedIterations; + // Interpret small iteration counts as Epochs to ensure sufficient data coverage + if (unweightedIterations < 100) { + int batchesPerEpoch = Math.max(1, points.length / batchSize); + totalBatches = unweightedIterations * batchesPerEpoch; + } + + long tTotal0 = System.nanoTime(); + + for (int i = 0; i < totalBatches; i++) { + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + clusterOnceMiniBatch(); + + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + if (VERBOSE_KMEANS_TIMING && i % 100 == 0) { + System.out.printf("MiniBatch iter %d time = %.3f ms%n", i, (t1 - t0) * 1e-6); + } + } + + if (VERBOSE_KMEANS) { + System.out.printf("MiniBatch unweighted phase finished. Total batches: %d. Total time: %.3f s%n", + totalBatches, (System.nanoTime() - tTotal0) * 1e-9); + } + + // ------------------------------------------------------------ + // Anisotropic refinement phase (Full Batch) + // ------------------------------------------------------------ + if (anisotropicIterations > 0) { + // MiniBatch leaves assignments in flux; we must perform a full assignment pass + // so the anisotropic phase has valid global clusters. + assignAllPoints(); + + final int threshold = Math.max(1, (int) (0.01 * points.length)); + + for (int i = 0; i < anisotropicIterations; i++) { + long t0 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + int changedCount = clusterOnceAnisotropic(); + long t1 = VERBOSE_KMEANS_TIMING ? System.nanoTime() : 0L; + + if (VERBOSE_KMEANS) { + System.out.printf("KMeans anisotropic iter %d: %d/%d reassigned (%.3f%%)%n", + i, changedCount, points.length, 100.0 * changedCount / points.length); + } + + if (VERBOSE_KMEANS_TIMING) { + System.out.printf(" time = %.3f ms%n", (t1 - t0) * 1e-6); + } + + if (changedCount <= threshold) { + if (VERBOSE_KMEANS) System.out.println("KMeans anisotropic converged early"); + break; + } + } + } + + return centroids; + } + + /** + * Performs a single MiniBatch update step: + * 1. Sample batch + * 2. Find nearest centroids + * 3. Update centroids immediately using learning rate + */ + public void clusterOnceMiniBatch() { + var random = ThreadLocalRandom.current(); + + // 1. Sample indices + int[] batchIndices = new int[batchSize]; + for (int i = 0; i < batchSize; i++) { + batchIndices[i] = random.nextInt(points.length); + } + + // 2. Compute assignments for the batch + int[] batchAssignments = new int[batchSize]; + for(int i = 0; i < batchSize; i++) { + batchAssignments[i] = getNearestCluster(points[batchIndices[i]]); + } + + // 3. Update Centroids (Online) + int dim = centroids.length() / k; + VectorFloat tempDiff = vectorTypeSupport.createFloatVector(dim); + + for (int i = 0; i < batchSize; i++) { + int pointIdx = batchIndices[i]; + int clusterIdx = batchAssignments[i]; + VectorFloat point = points[pointIdx]; + + centroidCounts[clusterIdx]++; + float learningRate = 1.0f / centroidCounts[clusterIdx]; + + // tempDiff = X + tempDiff.copyFrom(point, 0, 0, dim); + // tempDiff = X - C_old + subFromOffset(tempDiff, centroids, clusterIdx * dim); + + // tempDiff = lr * (X - C_old) + scale(tempDiff, learningRate); + + // C_new = C_old + tempDiff + // Pass the offset explicitly as the second argument + addToOffset(centroids, clusterIdx * dim, tempDiff); + } + } + + // --- Helpers --- + + private void assignAllPoints() { + for (int i = 0; i < points.length; i++) { + assignments[i] = getNearestCluster(points[i]); + } + } + + public int clusterOnceAnisotropic() { + updateCentroidsAnisotropic(); + return updateAssignedPointsAnisotropic(); + } + + // addToOffset: Adds a SMALL vector (source) into a LARGE vector (dest) at a specific offset. + private static void addToOffset(VectorFloat dest, int destOffset, VectorFloat source) { + // Fix: Iterate over source.length() (Small Temp Vector) + for (int i = 0; i < source.length(); i++) { + dest.set(destOffset + i, dest.get(destOffset + i) + source.get(i)); + } + } + + // subFromOffset: Subtracts a slice of a LARGE vector (source) from a SMALL vector (dest). + // (This logic was actually correct, but verifying strictly ensures safety) + private static void subFromOffset(VectorFloat dest, VectorFloat source, int sourceOffset) { + for (int i = 0; i < dest.length(); i++) { + dest.set(i, dest.get(i) - source.get(sourceOffset + i)); + } + } + + // --- Optimized Initialization Logic --- + + /** + * Chooses the initial centroids for clustering using K-Means++. + * If the dataset is large, uses a random subsample to accelerate initialization. + */ + private static VectorFloat chooseInitialCentroids(VectorFloat[] points, int k) { + if (k <= 0) throw new IllegalArgumentException("Number of clusters must be positive."); + if (k > points.length) throw new IllegalArgumentException("K cannot exceed N"); + + // Heuristic: If N is massive, run KMeans++ on a subset only. + // Cap at 50,000 points or 10*k to ensure statistical representation without performance penalty. + int maxInitSamples = Math.max(k * 10, 50_000); + + if (points.length <= maxInitSamples) { + return runStandardKMeansPlusPlus(points, k); + } else { + if (VERBOSE_KMEANS) { + System.out.printf("Subsampling %d points for K-Means++ initialization...%n", maxInitSamples); + } + var random = ThreadLocalRandom.current(); + VectorFloat[] subset = new VectorFloat[maxInitSamples]; + for (int i = 0; i < maxInitSamples; i++) { + subset[i] = points[random.nextInt(points.length)]; + } + return runStandardKMeansPlusPlus(subset, k); + } + } + + /** + * Standard KMeans++ implementation. + * When called by chooseInitialCentroids, 'points' may be the full set or a subset. + */ + private static VectorFloat runStandardKMeansPlusPlus(VectorFloat[] points, int k) { + var random = ThreadLocalRandom.current(); + VectorFloat centroids = vectorTypeSupport.createFloatVector(k * points[0].length()); + + float[] distances = new float[points.length]; + Arrays.fill(distances, Float.MAX_VALUE); + VectorFloat distancesVector = vectorTypeSupport.createFloatVector(distances); + + VectorFloat firstCentroid = points[random.nextInt(points.length)]; + centroids.copyFrom(firstCentroid, 0, 0, firstCentroid.length()); + + VectorFloat newDistancesVector = vectorTypeSupport.createFloatVector(points.length); + for (int i = 0; i < points.length; i++) { + newDistancesVector.set(i, squareL2Distance(points[i], firstCentroid)); + } + VectorUtil.minInPlace(distancesVector, newDistancesVector); + + for (int i = 1; i < k; i++) { + float totalDistance = VectorUtil.sum(distancesVector); + float r = random.nextFloat() * totalDistance; + int selectedIdx = -1; + + for (int j = 0; j < points.length; j++) { + r -= distancesVector.get(j); + if (r < 1e-6) { + selectedIdx = j; + break; + } + } + if (selectedIdx == -1) selectedIdx = random.nextInt(points.length); + + VectorFloat nextCentroid = points[selectedIdx]; + centroids.copyFrom(nextCentroid, 0, i * nextCentroid.length(), nextCentroid.length()); + + for (int j = 0; j < points.length; j++) { + newDistancesVector.set(j, squareL2Distance(points[j], nextCentroid)); + } + VectorUtil.minInPlace(distancesVector, newDistancesVector); + } + return centroids; + } + + // --- Standard Distance and Update Logic --- + + private int getNearestCluster(VectorFloat point) { + float minDistance = Float.MAX_VALUE; + int nearestCluster = 0; + int dim = point.length(); + + for (int i = 0; i < k; i++) { + float distance = squareL2Distance(point, 0, centroids, i * dim, dim); + if (distance < minDistance) { + minDistance = distance; + nearestCluster = i; + } + } + return nearestCluster; + } + + private int updateAssignedPointsAnisotropic() { + float pcm = computeParallelCostMultiplier(anisotropicThreshold, points[0].length()); + int dim = points[0].length(); + + float[] cNormSquared = new float[k]; + for (int i = 0; i < k; i++) { + cNormSquared[i] = dotProduct(centroids, i * dim, centroids, i * dim, dim); + } + + int changedCount = 0; + for (int i = 0; i < points.length; i++) { + var x = points[i]; + var xNormSquared = dotProduct(x, x); + + int index = assignments[i]; + float minDist = Float.MAX_VALUE; + for (int j = 0; j < k; j++) { + float dist = weightedDistance(x, j, pcm, cNormSquared[j], xNormSquared); + if (dist < minDist) { + minDist = dist; + index = j; + } + } + + if (index != assignments[i]) { + changedCount++; + assignments[i] = index; + } + } + return changedCount; + } + + private float weightedDistance(VectorFloat x, int centroid, float parallelCostMultiplier, float cNormSquared, float xNormSquared) { + float cDotX = VectorUtil.dotProduct(centroids, centroid * x.length(), x, 0, x.length()); + float parallelErrorSubtotal = cDotX - xNormSquared; + float residualSquaredNorm = cNormSquared - 2 * cDotX + xNormSquared; + float parallelError = square(parallelErrorSubtotal); + float perpendicularError = residualSquaredNorm - parallelError; + return parallelCostMultiplier * parallelError + perpendicularError; + } + + private void updateCentroidsAnisotropic() { + int dimensions = points[0].length(); + float pcm = computeParallelCostMultiplier(anisotropicThreshold, dimensions); + float orthogonalCostMultiplier = 1.0f / pcm; + + var pointsByCluster = new HashMap>(); + for (int i = 0; i < assignments.length; i++) { + pointsByCluster.computeIfAbsent(assignments[i], __ -> new ArrayList<>()).add(i); + } + + for (int i = 0; i < k; i++) { + var L = pointsByCluster.getOrDefault(i, List.of()); + if (L.isEmpty()) { + initializeCentroidToRandomPoint(i); + continue; + } + + var mean = vectorTypeSupport.createFloatVector(dimensions); + var outerProdSums = new Matrix(dimensions, dimensions); + for (int j : L) { + var point = points[j]; + // Offset is 0 because 'mean' is a fresh vector of the same dimension as 'point' + addToOffset(mean, 0, point); + float denom = dotProduct(point, point); + if (denom > 0) { + var op = Matrix.outerProduct(point, point); + op.scale(1.0f / denom); + outerProdSums.addInPlace(op); + } + } + outerProdSums.scale((1 - orthogonalCostMultiplier) / L.size()); + scale(mean, 1.0f / L.size()); + + for (int j = 0; j < dimensions; j++) { + outerProdSums.addTo(j, j, orthogonalCostMultiplier); + } + + var invertedMatrix = outerProdSums.invert(); + centroids.copyFrom(invertedMatrix.multiply(mean), 0, i * dimensions, dimensions); + } + } + + private void initializeCentroidToRandomPoint(int i) { + var random = ThreadLocalRandom.current(); + centroids.copyFrom(points[random.nextInt(points.length)], 0, i * points[0].length(), points[0].length()); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java index b47d2b9ce..c4ceb75d4 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/MutablePQVectors.java @@ -63,6 +63,8 @@ public void setZero(int ordinal) { // increase count first so get() works vectorCount.updateAndGet(current -> max(current, ordinal + 1)); get(ordinal).zero(); + // A zero code reconstructs to each subspace's first centroid, which searches can reach. + pq.recordCode(get(ordinal), 0); } private synchronized void ensureChunkCapacity(int ordinal) { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java index 6ebc11701..151446070 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/NVQuantization.java @@ -158,7 +158,10 @@ public static NVQuantization compute(RandomAccessVectorValues ravv, int nSubVect /** * Creates a NVQuantization instance by using the global mean and computing the data structures used to divide each - * vector into subvectors. + * vector into subvectors. The mean can be collected during any earlier input scan, + * for example with {@link io.github.jbellis.jvector.graph.VectorMean}, independently + * of the primary quantizer or loader. This method does not read input vectors. + * Use {@link #compute} when no precomputed mean is available. * * @param scaledGlobalMean the mean of the graph (its average vector) * @param nSubVectors number of subvectors diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java index ced0124fd..73cf40c28 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQDecoder.java @@ -59,8 +59,22 @@ protected float decodedSimilarity(ByteSequence encoded, int offset, int lengt } static class DotProductDecoder extends CachingDecoder { + private final VectorFloat query; + public DotProductDecoder(PQVectors cv, VectorFloat query) { super(cv, query, VectorSimilarityFunction.DOT_PRODUCT); + this.query = query; + } + + /** PQ scores the centered query against centered reconstructions, so their lengths bound the score. */ + @Override + public float dotProductBound() { + var center = cv.pq.globalCentroid; + double squared = VectorUtil.dotProduct(query, query); + if (center != null) { + squared += VectorUtil.dotProduct(center, center) - 2.0 * VectorUtil.dotProduct(query, center); + } + return (float) Math.sqrt(Math.max(0.0, squared)) * cv.maxReconstructedNorm(); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java index 5e5f172dc..0581d77cc 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/PQVectors.java @@ -52,6 +52,15 @@ protected PQVectors(ProductQuantization pq) { this.pq = pq; } + /** + * The largest length of any stored vector's reconstruction, relative to the global centroid if any, or more: + * the PQ model's {@link ProductQuantization#maxEncodedNorm}, which counts every code it encoded or holds. With the + * centered query's length it bounds every dot product PQ scores. NaN when unknown. + */ + public float maxReconstructedNorm() { + return pq.maxEncodedNorm(); + } + public static ImmutablePQVectors load(RandomAccessReader in) throws IOException { // pq codebooks var pq = ProductQuantization.load(in); @@ -72,7 +81,7 @@ public static ImmutablePQVectors load(RandomAccessReader in) throws IOException chunks[layout.fullSizeChunks] = vectorTypeSupport.readByteSequence(in, layout.lastChunkBytes); } - return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors); + return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors, false); } public static PQVectors load(RandomAccessReader in, long offset) throws IOException { @@ -146,7 +155,7 @@ private static ImmutablePQVectors encodeAndBuild(ProductQuantization pq, int vec else VectorAccess.forEach(ravv, vectorCount, ordinalsMapping, simdExecutor, encode); - return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors); + return new ImmutablePQVectors(pq, chunks, vectorCount, layout.fullChunkVectors, false); } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java index ba96d8f2f..bc80c310f 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java @@ -72,6 +72,11 @@ public class ProductQuantization implements VectorCompressor>, A final int[][] subvectorSizesAndOffsets; final float anisotropicThreshold; // parallel cost multiplier private final float[][] centroidNormsSquared; // precomputed norms of the centroids, for encoding + // The largest length of any reconstruction this model has encoded or holds codes for. Unknown for a model loaded + // from a format that did not store it, since it cannot know the lengths of the codes encoded before. + private final io.github.jbellis.jvector.util.MaxVectorLength maxEncoded = new io.github.jbellis.jvector.util.MaxVectorLength(); + /** The first on-disk format version that stores {@link #maxEncodedNorm}. */ + static final int MAX_NORM_VERSION = 7; private final ThreadLocal> partialSums; // for dot product, euclidean, and cosine partials private final AtomicReference> partialSquaredMagnitudes; // for cosine partials @@ -439,6 +444,31 @@ public void encodeTo(VectorFloat vector, ByteSequence dest) { encodeAnisotropic(vector, dest); else encodeUnweighted(vector, dest); + recordCode(dest, 0); + } + + /** The squared length of the reconstruction of the code at {@code offset}, relative to the global centroid. */ + float reconstructedSquaredNorm(ByteSequence code, int offset) { + float sum = 0; + for (int m = 0; m < M; m++) sum += centroidNormsSquared[m][Byte.toUnsignedInt(code.get(offset + m))]; + return sum; + } + + /** Counts the code at {@code offset} in {@link #maxEncodedNorm}. */ + void recordCode(ByteSequence code, int offset) { + maxEncoded.recordSquared(reconstructedSquaredNorm(code, offset)); + } + + /** + * The largest length of the reconstruction, relative to the global centroid if any, of any vector this model has + * encoded or holds codes for, including vectors encoded before it was saved and loaded. With the centered query's + * length it bounds every dot product PQ scores, which adaptive stopping needs to read PQ scores as distances. + * NaN until the model encodes or holds a code; positive infinity when unknown, for a model loaded from a format + * older than version {@value #MAX_NORM_VERSION}, which did not store it. In both cases dot-product searches stop + * by the standard rule. + */ + public float maxEncodedNorm() { + return maxEncoded.get(); } /** @@ -608,6 +638,10 @@ public void write(IndexWriter out, int version) throws IOException assert codebook.length() == clusterCount * subvectorSizesAndOffsets[i][0]; vectorTypeSupport.writeFloatVector(out, codebook); } + + if (version >= MAX_NORM_VERSION) { + out.writeFloat(maxEncodedNorm()); + } } /** @@ -641,7 +675,12 @@ public VectorFloat createCodebookPartialSums(VectorSimilarityFunction vectorS @Override public int compressorSize() { - int size = 0; + return compressorSize(OnDiskGraphIndex.CURRENT_VERSION); + } + + /** The bytes {@link #write} writes at {@code version}, which must be 3 or later. */ + public int compressorSize(int version) { + int size = version >= MAX_NORM_VERSION ? Float.BYTES : 0; // largest encoded length size += Integer.BYTES; // MAGIC size += Integer.BYTES; // STORAGE_VERSION size += Integer.BYTES; // globalCentroidLength @@ -701,7 +740,14 @@ public static ProductQuantization load(RandomAccessReader in) throws IOException codebooks[m] = codebook; } - return new ProductQuantization(codebooks, clusters, subvectorSizes, globalCentroid, anisotropicThreshold); + var pq = new ProductQuantization(codebooks, clusters, subvectorSizes, globalCentroid, anisotropicThreshold); + if (version >= MAX_NORM_VERSION) { + pq.maxEncoded.restore(in.readFloat()); + } else { + // Older formats did not store the largest length, so codes encoded with this model have unknown lengths. + pq.maxEncoded.markUnknown(); + } + return pq; } @Override diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java new file mode 100644 index 000000000..fa0fa0952 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BatchingExecutor.java @@ -0,0 +1,394 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.util; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicLongArray; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Bounded asynchronous execution with optional preparation of compatible requests + * in batches. Batches contain only requests already waiting; there is no fill timer. + * Prepared work takes priority, and the preparing worker executes its first request + * while other workers may execute the rest. + * + *

The supplied executor must schedule asynchronously, reject explicitly when it + * cannot accept work, and remain operational until this service closes. Direct + * executors, caller-runs rejection policies and silent-discard policies are not + * supported. At most {@code workerCount} reusable drain tasks are submitted at a + * time. Each exits when the queues are empty, releasing shared execution capacity. + * This class creates no threads and does not shut down the supplied executor. + * + *

Requests and their buffers remain caller-owned. An accepted request must not + * be modified or submitted again until its completion callback. Rejection at the + * admission limit leaves ownership with the caller. Accepted work completes once, + * including preparation or dispatch failure. Completion may run on a submitting + * thread if the executor rejects dispatch; successful work never runs inline. + * Queue arrays, worker runnables and batch lists are allocated at construction. + * + *

Close rejects new submissions and waits for accepted work and callbacks. + * Do not close from a callback or block a host executor thread needed to drain this + * service. The host must not cancel or discard already accepted drain tasks. + */ +public final class BatchingExecutor implements AutoCloseable { + /** Callbacks must be thread-safe. Completion must return promptly. */ + public interface Operation { + /** + * Immutable identity key for compatible preparation, or null to prepare + * alone. Called on the submitting thread, outside internal locks. + */ + Object batchKey(T request); + /** Borrowed read-only list, valid only during this call; never retain it. */ + void prepareBatch(List requests) throws Exception; + /** Executes one prepared request, without a batch-wide completion barrier. */ + void execute(T request) throws Exception; + /** + * Called exactly once for accepted work. The request may be recycled here. + * Exceptions are retained for inspection and close, without poisoning work. + */ + void complete(T request, Throwable failure); + } + + // Keep the entry on host threads but clear its value on exit. Removing it + // would allocate a fresh ThreadLocalMap entry for every isolated drain task. + private static final ThreadLocal> CURRENT = new ThreadLocal<>(); + + private final ReentrantLock lock = new ReentrantLock(); + private final Condition drained = lock.newCondition(); + private final Executor executor; + private final Operation operation; + private final int capacity, batchSize, workerLimit; + private final Object[] pending, pendingKeys, ready; + private final Throwable[] pendingFailures; + private int pendingHead, pendingTail, pendingSize; + private int pendingGroups, tailGroupSize; + private int readyHead, readyTail, readySize; + private final ArrayDeque idleWorkers; + private final List workers; + private int activeWorkers, busyWorkers, callbacks; + private volatile int outstanding; + private volatile boolean closed; + private final AtomicLong callbackFailures = new AtomicLong(); + private final AtomicReference firstCallbackFailure = new AtomicReference<>(); + + /** + * @param workerCount maximum concurrent preparation/execution drain tasks + * @param capacity maximum requests queued, preparing or executing; excludes callbacks + * @param batchSize maximum currently waiting compatible requests per preparation + * @param executor caller-owned asynchronous execution capacity + * @param operation request preparation, execution and completion operations + */ + public BatchingExecutor(int workerCount, int capacity, int batchSize, + Executor executor, Operation operation) { + if (workerCount <= 0 || capacity <= 0 || batchSize <= 0 + || batchSize > capacity || batchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("Workers, capacity and batch size must be positive; " + + "batch size must fit its histogram and not exceed capacity"); + this.executor = Objects.requireNonNull(executor, "executor"); + this.operation = Objects.requireNonNull(operation, "operation"); + if (executor instanceof ThreadPoolExecutor) { + var policy = ((ThreadPoolExecutor) executor).getRejectedExecutionHandler(); + if (policy instanceof ThreadPoolExecutor.DiscardPolicy + || policy instanceof ThreadPoolExecutor.DiscardOldestPolicy + || policy instanceof ThreadPoolExecutor.CallerRunsPolicy) + throw new IllegalArgumentException("Executor must reject explicitly without discarding or running inline"); + } + this.capacity = capacity; + this.batchSize = batchSize; + this.workerLimit = Math.min(workerCount, capacity); + pending = new Object[capacity]; + pendingKeys = new Object[capacity]; + pendingFailures = new Throwable[capacity]; + ready = new Object[capacity]; + idleWorkers = new ArrayDeque<>(workerLimit); + workers = new ArrayList<>(workerLimit); + for (int i = 0; i < workerLimit; i++) { + Worker worker = new Worker(); + workers.add(worker); + idleWorkers.addLast(worker); + } + } + + /** + * Accepts without waiting for capacity. Returns false at the admission limit; + * throws after close. A true return transfers ownership until completion. + */ + public boolean trySubmit(T request) { + Objects.requireNonNull(request, "request"); + if (closed) throw new RejectedExecutionException("Batching executor is closed"); + if (outstanding == capacity) return false; + Object key = null; + Throwable failure = null; + try { key = operation.batchKey(request); } + catch (Throwable error) { failure = error; } + Worker launches; + lock.lock(); + try { + if (closed) throw new RejectedExecutionException("Batching executor is closed"); + if (outstanding == capacity) return false; + int previousTail = pendingTail == 0 ? capacity - 1 : pendingTail - 1; + if (pendingSize != 0 && failure == null && key != null + && pendingFailures[previousTail] == null && pendingKeys[previousTail] == key + && tailGroupSize < batchSize) { + tailGroupSize++; + } else { + pendingGroups++; + tailGroupSize = 1; + } + pending[pendingTail] = request; + pendingKeys[pendingTail] = key; + pendingFailures[pendingTail] = failure; + pendingTail = advance(pendingTail); + pendingSize++; + outstanding++; + launches = reserveWorkers(); + } finally { lock.unlock(); } + launch(launches); + return true; + } + + private int advance(int position) { return position == capacity - 1 ? 0 : position + 1; } + + /** Called under lock; links reusable tasks rather than allocating a launch list. */ + private Worker reserveWorkers() { + // Track compatible groups at admission rather than scanning the queue or + // treating unrelated models as one batch and underutilizing the budget. + int target = (int) Math.min(workerLimit, (long) busyWorkers + readySize + pendingGroups); + Worker first = null, last = null; + while (activeWorkers < target) { + Worker worker = idleWorkers.removeFirst(); + activeWorkers++; + if (last == null) first = worker; + else last.nextLaunch = worker; + last = worker; + } + return first; + } + + /** Executor calls and operation callbacks are always outside lock. */ + private void launch(Worker first) { + while (first != null) { + Worker worker = first; + first = worker.nextLaunch; + worker.nextLaunch = null; + worker.launchingThread.set(Thread.currentThread()); + try { executor.execute(worker); } + catch (RuntimeException | Error failure) { dispatchFailed(worker, failure); } + finally { + // A host worker may later dequeue a task that it submitted. + // Only execution on the submitting call stack is inline. CAS + // cannot clear the marker of a reused task launched elsewhere. + worker.launchingThread.compareAndSet(Thread.currentThread(), null); + } + } + } + + private void dispatchFailed(Worker worker, Throwable failure) { + // Allocation is confined to dispatch failure, never ordinary submission. + List failed = null; + lock.lock(); + try { + worker.launchingThread.set(null); + activeWorkers--; + idleWorkers.addLast(worker); + if (activeWorkers == 0) { + failed = new ArrayList<>(pendingSize + readySize); + while (readySize != 0) failed.add(takeReady()); + while (pendingSize != 0) failed.add(takePending()); + pendingGroups = 0; + } + signalDrained(); + } finally { lock.unlock(); } + if (failed != null) for (T request : failed) finish(request, failure); + // Another accepted drain task, if present, will process the queues. + } + + @SuppressWarnings("unchecked") + private T takePending() { + T value = (T) pending[pendingHead]; + pending[pendingHead] = null; + pendingKeys[pendingHead] = null; + pendingFailures[pendingHead] = null; + pendingHead = advance(pendingHead); + pendingSize--; + if (pendingSize == 0) tailGroupSize = 0; + return value; + } + + @SuppressWarnings("unchecked") + private T takeReady() { + T value = (T) ready[readyHead]; + ready[readyHead] = null; + readyHead = advance(readyHead); + readySize--; + return value; + } + + private final class Worker implements Runnable { + final ArrayList requests = new ArrayList<>(batchSize); + final List borrowedRequests = Collections.unmodifiableList(requests); + final AtomicLongArray histogram = new AtomicLongArray(batchSize + 1); + Worker nextLaunch; + final AtomicReference launchingThread = new AtomicReference<>(); + boolean busy, alreadyPrepared; + Throwable preparationFailure; + + /** Acquires the next work, or atomically releases this task's worker lease. */ + boolean takeWork() { + lock.lock(); + try { + if (busy) { busyWorkers--; busy = false; } + requests.clear(); + preparationFailure = null; + if (readySize != 0) { + requests.add(takeReady()); + alreadyPrepared = true; + } else if (pendingSize != 0) { + Object key = pendingKeys[pendingHead]; + preparationFailure = pendingFailures[pendingHead]; + requests.add(takePending()); + alreadyPrepared = false; + while (preparationFailure == null && key != null + && requests.size() < batchSize && pendingSize != 0 + && pendingKeys[pendingHead] == key && pendingFailures[pendingHead] == null) + requests.add(takePending()); + pendingGroups--; + } else { + activeWorkers--; + idleWorkers.addLast(this); + signalDrained(); + return false; + } + busyWorkers++; + busy = true; + return true; + } finally { lock.unlock(); } + } + + @Override + public void run() { + if (Thread.currentThread() == launchingThread.get()) + throw new RejectedExecutionException("Executor must schedule work asynchronously"); + launchingThread.set(null); + BatchingExecutor previous = CURRENT.get(); + CURRENT.set(BatchingExecutor.this); + try { + while (takeWork()) { + Throwable failure = preparationFailure; + if (!alreadyPrepared && failure == null) { + try { + operation.prepareBatch(borrowedRequests); + histogram.incrementAndGet(requests.size()); + } catch (Throwable error) { failure = error; } + } + if (failure != null) { + for (T request : requests) finish(request, failure); + continue; + } + if (requests.size() > 1) { + Worker launches; + lock.lock(); + try { + for (int i = 1; i < requests.size(); i++) { + ready[readyTail] = requests.get(i); + readyTail = advance(readyTail); + readySize++; + } + launches = reserveWorkers(); + } finally { lock.unlock(); } + launch(launches); + } + T first = requests.get(0); + try { operation.execute(first); } + catch (Throwable error) { failure = error; } + finish(first, failure); + } + } finally { + CURRENT.set(previous); // Do not retain the service/model on host threads. + } + } + } + + private void finish(T request, Throwable failure) { + lock.lock(); + try { + // Release admission before the callback may recycle the request. + callbacks++; + outstanding--; + } finally { lock.unlock(); } + BatchingExecutor previous = CURRENT.get(); + boolean entering = previous != this; + if (entering) CURRENT.set(this); + try { operation.complete(request, failure); } + catch (Throwable error) { + firstCallbackFailure.compareAndSet(null, error); + callbackFailures.incrementAndGet(); + } finally { + if (entering) CURRENT.set(previous); + lock.lock(); + try { callbacks--; signalDrained(); } + finally { lock.unlock(); } + } + } + + private void signalDrained() { + if (closed && outstanding == 0 && activeWorkers == 0 && callbacks == 0) + drained.signalAll(); + } + + /** Atomic snapshot; exact after all accepted requests have completed. */ + public long[] batchHistogram() { + long[] result = new long[batchSize + 1]; + for (Worker worker : workers) + for (int i = 1; i < result.length; i++) result[i] += worker.histogram.get(i); + return result; + } + + /** Accepted work, excluding completion callbacks. */ + public int outstanding() { return outstanding; } + + /** Number of completion callbacks that threw, without blocking later submissions. */ + public long completionFailureCount() { return callbackFailures.get(); } + + /** First callback exception for monitoring, or null when no callback failed. */ + public Throwable firstCompletionFailure() { return firstCallbackFailure.get(); } + + /** Drains accepted work, preserves interrupts, then reports any callback failure. */ + @Override + public void close() { + if (CURRENT.get() == this) + throw new IllegalStateException("Cannot close a batching executor from its operation or callback"); + lock.lock(); + try { + closed = true; + while (outstanding != 0 || activeWorkers != 0 || callbacks != 0) + drained.awaitUninterruptibly(); + } finally { lock.unlock(); } + Throwable failure = firstCallbackFailure.get(); + if (failure != null) + throw new IllegalStateException(completionFailureCount() + " completion callbacks failed", failure); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java new file mode 100644 index 000000000..144bf16a4 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/BlasThreads.java @@ -0,0 +1,76 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.util; + +import io.github.jbellis.jvector.annotations.Experimental; +import io.github.jbellis.jvector.vector.VectorizationProvider; + +/** + * Sets how many threads native BLAS and LAPACK may use for calls made on the current thread. + * + *

Native BLAS libraries such as MKL and OpenBLAS otherwise start one thread per CPU on their + * first call, regardless of the host's own executors. JVector's BLAS calls therefore run inside a + * scope: one thread by default, or the size of the executor a caller passes to the operation. + * The native vectorization provider applies the limit through the library's per-thread setting + * (MKL {@code MKL_Set_Num_Threads_Local}, OpenBLAS {@code openblas_set_num_threads_local}), or, + * for OpenBLAS releases without it, the process-wide setting. Other providers leave BLAS threading + * to the library's own configuration, such as {@code MKL_NUM_THREADS}.

+ * + *

Scopes nest: closing one restores the count that was in effect when it opened.

+ */ +@Experimental +public final class BlasThreads { + private static final ThreadLocal ACTIVE = new ThreadLocal<>(); + private static final Scope NONE = () -> { }; + + private BlasThreads() { + } + + /** Restores the previous BLAS thread count when closed. */ + public interface Scope extends AutoCloseable { + @Override + void close(); + } + + /** + * Uses {@code threads} BLAS threads for native calls on this thread until the scope closes. + * + * @throws IllegalArgumentException if {@code threads} is not positive + */ + public static Scope limit(int threads) { + if (threads < 1) throw new IllegalArgumentException("BLAS thread count must be positive, got " + threads); + var support = VectorizationProvider.getInstance().getVectorUtilSupport(); + Integer outer = ACTIVE.get(); + int previous = support.setBlasThreadsLocal(threads); + ACTIVE.set(threads); + return () -> { + if (previous >= 0) support.setBlasThreadsLocal(previous); + if (outer == null) ACTIVE.remove(); + else ACTIVE.set(outer); + }; + } + + /** One BLAS thread, unless an enclosing {@link #limit} scope on this thread already sets the count. */ + public static Scope singleUnlessLimited() { + return ACTIVE.get() != null ? NONE : limit(1); + } + + /** The count set by the innermost open scope on this thread, or 0 if none is open. */ + public static int current() { + Integer active = ACTIVE.get(); + return active == null ? 0 : active; + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/CallerBatchProcessor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/CallerBatchProcessor.java new file mode 100644 index 000000000..e0bc62351 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/CallerBatchProcessor.java @@ -0,0 +1,472 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.util; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.locks.LockSupport; + +/** + * Synchronously combines work from concurrent callers, using only their threads. + * The strict constructor permits at most {@code maxLeaders} operations at once; + * {@link #opportunistic} additionally supplies a direct pass-through budget. + * An available + * leader starts immediately; it never waits for a batch to fill. Waiting requests + * are taken in FIFO order by a caller that acquires free capacity. Capacity is + * never reserved for a parked caller. Reused request records are published + * without a blocking queue lock; only batch removal needs brief exclusion. + * + *

Input and output belong to the submitting caller but must be accessible to + * other threads until {@link #process} returns. The operation must not retain + * them or reenter this processor. A batch finishes before any of its callers + * returns; subsequent application work is entirely outside this processor. + * Interrupts are preserved, but do not abandon an input that another caller may + * already be using. A failed operation fails its batch and still hands off work. + * + *

Request slots, batch lists and callbacks are reused. This class creates no + * executor, worker, completion future, timer, or per-operation request object. + * A slot is allocated on each participating thread's first use of this instance. + */ +public final class CallerBatchProcessor implements Accountable { + /** An operation that writes caller-owned outputs before returning. */ + public interface Operation { + /** Handles a single input without wrapping it in a batch. */ + void apply(I input, O output) throws Exception; + + /** Borrowed read-only lists; neither the lists nor their rows may escape. */ + void applyBatch(List inputs, List outputs) throws Exception; + } + + private static final int WAITING = 0, ASSIGNED = 1, DONE = 2; + private static final int CLAIM_BUSY = 0, CLAIM_SKIPPED = 1, CLAIM_READY = 2; + private static final VarHandle INCOMING, DRAINING; + static { + try { + var lookup = MethodHandles.lookup(); + INCOMING = lookup.findVarHandle(CallerBatchProcessor.class, "incoming", Request.class); + DRAINING = lookup.findVarHandle(CallerBatchProcessor.class, "draining", int.class); + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + private final int maxBatchSize; + private final Operation operation; + private final Context[] contexts; + private final DirectSlot[] directSlots; + private final ThreadLocal> local = ThreadLocal.withInitial(Request::new); + // Producers publish their reusable records with one successful CAS. A + // consumer reverses a detached chain once, preserving FIFO publication order. + // ready and incoming are disjoint; only the drain owner modifies ready. + private volatile Request incoming, ready; + private volatile int draining; + private final long retainedBytes; + + /** + * @param maxLeaders maximum concurrent operations, without creating threads + * @param maxBatchSize maximum existing requests claimed by one leader + * @param operation computation; all its methods must support concurrent calls + */ + public CallerBatchProcessor(int maxLeaders, int maxBatchSize, Operation operation) { + this(maxLeaders, maxBatchSize, 0, operation); + } + + /** + * Creates an opportunistic processor for a caller-owned CPU budget. Up to + * {@code parallelism} overlapping calls pass through directly while no work + * is queued or executing as a multi-input batch. Only overlap beyond that budget starts + * combining; it does not throttle ordinary calls to the combiner limit. + * + *

The fixed internal limit is one combiner per four budgeted CPUs, with + * a minimum of one. No thread is created. Direct calls are outside that + * combiner limit; total concurrent operations cannot exceed the direct budget + * plus the combiner limit. Emptying the queue and completing its multi-input + * batches restores + * the direct path. An overflowing singleton never suppresses free direct + * capacity on its own. There are no timing thresholds, + * learned decisions, dataset-dependent rules or batch-fill delays. + * + * @param parallelism CPU capacity available to the callers, positive + * @param maxBatchSize maximum ready inputs passed to one batch operation + * @param operation operation whose methods support concurrent calls + */ + public static CallerBatchProcessor opportunistic( + int parallelism, int maxBatchSize, Operation operation) { + if (parallelism < 1) throw new IllegalArgumentException("Parallelism must be positive"); + return new CallerBatchProcessor<>(Math.max(1, parallelism / 4), maxBatchSize, parallelism, operation); + } + + @SuppressWarnings("unchecked") + private CallerBatchProcessor(int maxLeaders, int maxBatchSize, int directParallelism, + Operation operation) { + if (maxLeaders < 1 || maxBatchSize < 1 || maxBatchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("Leader and batch limits must be positive"); + this.maxBatchSize = maxBatchSize; + this.operation = Objects.requireNonNull(operation); + directSlots = new DirectSlot[directParallelism]; + for (int i = 0; i < directSlots.length; i++) directSlots[i] = new DirectSlot(i); + contexts = (Context[]) new Context[maxLeaders]; + for (int i = 0; i < maxLeaders; i++) contexts[i] = new Context<>(maxBatchSize, i); + long bytes = RamUsageEstimator.shallowSizeOf(this) + + RamUsageEstimator.shallowSizeOf(local) + RamUsageEstimator.shallowSizeOf(contexts) + + RamUsageEstimator.shallowSizeOf(directSlots); + for (DirectSlot slot : directSlots) bytes = Math.addExact(bytes, RamUsageEstimator.shallowSizeOf(slot)); + for (Context context : contexts) { + bytes = Math.addExact(bytes, RamUsageEstimator.shallowSizeOf(context) + + listBytes(context.requests, maxBatchSize) + + RamUsageEstimator.shallowSizeOf(context.inputs) + + RamUsageEstimator.shallowSizeOf(context.outputs) + + RamUsageEstimator.shallowSizeOf(context.histogram)); + } + retainedBytes = bytes; + } + + private static long listBytes(ArrayList list, int capacity) { + return RamUsageEstimator.shallowSizeOf(list) + + RamUsageEstimator.alignObjectSize(RamUsageEstimator.NUM_BYTES_ARRAY_HEADER + + (long) capacity * RamUsageEstimator.NUM_BYTES_OBJECT_REF); + } + + /** Owned coordinator storage; the operation and caller-owned request slots are excluded. */ + @Override + public long ramBytesUsed() { return retainedBytes; } + + private static final class Request { + final Thread thread = Thread.currentThread(); + volatile int state; + volatile boolean parked; + boolean inUse; + int preferred = (int) (thread.getId() ^ (thread.getId() >>> 32)) & Integer.MAX_VALUE; + int preferredDirect = preferred; + I input; + O output; + Throwable failure; + Request next; + } + + private static final class DirectSlot { + private static final VarHandle CLAIMED, COUNT; + static { + try { + var lookup = MethodHandles.lookup(); + CLAIMED = lookup.findVarHandle(DirectSlot.class, "claimed", int.class); + COUNT = lookup.findVarHandle(DirectSlot.class, "calls", long.class); + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + final int index; + volatile int claimed; + private long calls; + DirectSlot(int index) { this.index = index; } + void record() { COUNT.setOpaque(this, (long) COUNT.getOpaque(this) + 1); } + } + + // Static slots/contexts do not retain the processor (and its model) in an + // idle thread's ThreadLocal value after the application releases the model. + private static final class Context { + private static final VarHandle CLAIMED; + private static final VarHandle COUNT = MethodHandles.arrayElementVarHandle(long[].class); + static { + try { + CLAIMED = MethodHandles.lookup().findVarHandle(Context.class, "claimed", int.class); + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + volatile int claimed; + final int slot; + final long[] histogram; + final ArrayList> requests; + final List inputs; + final List outputs; + Context(int capacity, int slot) { + this.slot = slot; + histogram = new long[capacity + 1]; + requests = new ArrayList<>(capacity); + inputs = new AbstractList<>() { + public int size() { return requests.size(); } + public I get(int i) { return requests.get(i).input; } + }; + outputs = new AbstractList<>() { + public int size() { return requests.size(); } + public O get(int i) { return requests.get(i).output; } + }; + } + + // Only the owning leader writes these counts. Opaque access makes the + // diagnostic snapshot non-tearing without a shared atomic increment. + void record(int size) { + COUNT.setOpaque(histogram, size, (long) COUNT.getOpaque(histogram, size) + 1); + } + } + + /** Executes synchronously, preserving interruption and caller buffer lifetime. */ + public void process(I input, O output) { + Objects.requireNonNull(input); Objects.requireNonNull(output); + Request request = local.get(); + if (request.inUse) throw new IllegalStateException("Reentrant batch processing is unsupported"); + request.inUse = true; + boolean interrupted = Thread.interrupted(); + try { + if (directSlots.length != 0 && !hasPending() && !isBatching()) { + DirectSlot slot = acquireDirect(request.preferredDirect % directSlots.length); + if (slot != null) { + try { + // Honor older requests published while capacity was acquired. + // A singleton overflow must not suppress free direct + // capacity. Real batches retain sharing until completion. + if (!hasPending() && !isBatching()) { + request.preferredDirect = slot.index; + slot.record(); + operation.apply(input, output); + return; + } + } finally { DirectSlot.CLAIMED.setVolatile(slot, 0); } + } + } + // An idle caller executes directly. Only saturated callers enter the + // FIFO; no monitor, list mutation or request publication is needed. + if (!hasPending()) { + Context context = acquire(request.preferred % contexts.length); + if (context != null) { + if (!hasPending()) { + request.preferred = context.slot; + context.record(1); + try { operation.apply(input, output); } + finally { release(context); } + return; + } + // A waiter arrived while we acquired capacity; give it priority. + release(context); + } + } + request.input = input; + request.output = output; + request.failure = null; + request.state = WAITING; + publish(request); + for (;;) { + int state = request.state; // Acquire publishes outputs and failures. + if (state == DONE) break; + if (state == WAITING) { + Context context = acquire(request.preferred % contexts.length); + if (context != null) { + request.preferred = context.slot; + int claim = take(context, request); + if (claim == CLAIM_READY) { run(context); continue; } + if (claim == CLAIM_SKIPPED) { release(context); continue; } + // The drain owner will signal pending work after release. + // Publish this slot first, then recheck the drain gate to + // cover an unlock that observed the slot still occupied. + free(context); + if ((int) DRAINING.getVolatile(this) == 0) continue; + } + } + request.parked = true; + // Completion publishes DONE before checking parked. Rechecking + // after this volatile store ensures an early completion either + // prevents parking or supplies the unpark permit. + int latest = request.state; + if (latest != DONE && (latest != WAITING + || (int) DRAINING.getVolatile(this) != 0 || !hasFreeCapacity())) { + LockSupport.park(this); + if (Thread.interrupted()) interrupted = true; + } + request.parked = false; + } + if (request.failure instanceof Error) throw (Error) request.failure; + if (request.failure instanceof RuntimeException) throw (RuntimeException) request.failure; + if (request.failure != null) throw new RuntimeException("Batch operation failed", request.failure); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Exception failure) { + if (failure instanceof InterruptedException) interrupted = true; + throw new RuntimeException("Batch operation failed", failure); + } finally { + request.input = null; + request.output = null; + request.failure = null; + request.parked = false; + request.inUse = false; + if (interrupted) Thread.currentThread().interrupt(); + } + } + + private DirectSlot acquireDirect(int preferred) { + int index = preferred; + do { + DirectSlot slot = directSlots[index]; + if ((int) DirectSlot.CLAIMED.getVolatile(slot) == 0 + && DirectSlot.CLAIMED.compareAndSet(slot, 0, 1)) return slot; + if (++index == directSlots.length) index = 0; + } while (index != preferred); + return null; + } + + private boolean isBatching() { + for (Context context : contexts) + if ((int) Context.CLAIMED.getVolatile(context) > 1) return true; + return false; + } + + // Each slot has its own admission word. Callers start at their last slot, + // avoiding a global CAS counter and repeated competition for the first slot. + private Context acquire(int preferred) { + int slot = preferred; + do { + Context context = contexts[slot]; + if ((int) Context.CLAIMED.getVolatile(context) == 0 + && Context.CLAIMED.compareAndSet(context, 0, 1)) return context; + if (++slot == contexts.length) slot = 0; + } while (slot != preferred); + return null; + } + + private void free(Context context) { + // Volatile publication orders the subsequent waiter check after freeing + // capacity, closing the enqueue-versus-release lost-wakeup race. + Context.CLAIMED.setVolatile(context, 0); + } + + private boolean hasFreeCapacity() { + for (Context context : contexts) + if ((int) Context.CLAIMED.getVolatile(context) == 0) return true; + return false; + } + + private boolean hasPending() { return ready != null || incoming != null; } + + private void publish(Request request) { + Request previous; + do { + previous = incoming; + request.next = previous; + } while (!INCOMING.compareAndSet(this, previous, request)); + } + + private void signalPending() { + Request next = ready; + if (next == null) next = incoming; + // Any pending caller can act as combiner; take() still serves FIFO. + // A concurrent completion can make this a harmless spurious wakeup. + if (next != null) LockSupport.unpark(next.thread); + } + + private void release(Context context) { + free(context); + if (hasPending()) signalPending(); + } + + private void releaseDrain() { + // A failed drainer frees its slot before checking this publication. The + // two checks ensure it either retries or receives a wakeup, never sleeps + // with free capacity and no owner responsible for further progress. + DRAINING.setVolatile(this, 0); + if (hasPending() && hasFreeCapacity()) signalPending(); + } + + @SuppressWarnings("unchecked") + private int take(Context context, Request owner) { + if (!DRAINING.compareAndSet(this, 0, 1)) return CLAIM_BUSY; + try { + if (owner.state != WAITING) return CLAIM_SKIPPED; + Request first = ready; + if (first == null) { + Request stack = (Request) INCOMING.getAndSet(this, null); + while (stack != null) { + Request next = stack.next; + stack.next = first; + first = stack; + stack = next; + } + } + if (first == null) return CLAIM_SKIPPED; + do { + Request next = first.next; + first.next = null; + first.state = ASSIGNED; + context.requests.add(first); + first = next; + } while (first != null && context.requests.size() < maxBatchSize); + ready = first; + return CLAIM_READY; + } finally { releaseDrain(); } + } + + private void run(Context context) { + int size = context.requests.size(); + // Admission distinguishes a true shared computation from a singleton + // overflow. Zero is free; one is reserved/singleton; larger values are + // executing batch widths. All occupied states retain the same limit. + if (size > 1) Context.CLAIMED.setVolatile(context, size); + context.record(size); + Throwable failure = null; + try { + if (context.requests.size() == 1) { + Request only = context.requests.get(0); + operation.apply(only.input, only.output); + } else operation.applyBatch(context.inputs, context.outputs); + } catch (Throwable error) { + failure = error; + if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + } finally { + // This context is exclusively owned until release. Publish results + // and wake callers without holding the drain gate. + for (int i = 0; i < context.requests.size(); i++) { + Request request = context.requests.get(i); + request.failure = failure; + request.state = DONE; // Release before followers access their output. + if (request.parked) LockSupport.unpark(request.thread); + } + context.requests.clear(); + release(context); + } + } + + /** + * Diagnostic snapshot: index is batch size, value is batch count. Concurrent + * calls may observe an in-progress operation; totals are exact when idle. + */ + public long[] batchHistogram() { + long[] snapshot = new long[maxBatchSize + 1]; + for (DirectSlot slot : directSlots) snapshot[1] += (long) DirectSlot.COUNT.getOpaque(slot); + for (Context context : contexts) + for (int i = 1; i < snapshot.length; i++) + snapshot[i] += (long) Context.COUNT.getOpaque(context.histogram, i); + return snapshot; + } + + /** + * Diagnostic snapshot of waiting requests, excluding executing batches. The + * short drain exclusion is used only by this explicit inspection method. + */ + public int waitingCount() { + while (!DRAINING.compareAndSet(this, 0, 1)) Thread.yield(); + try { + int count = 0; + for (Request r = ready; r != null; r = r.next) count = Math.incrementExact(count); + // Producers can prepend records, but cannot change this chain while + // the drain gate is held. This is a snapshot at the incoming read. + for (Request r = incoming; r != null; r = r.next) count = Math.incrementExact(count); + return count; + } finally { releaseDrain(); } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/MaxVectorLength.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/MaxVectorLength.java new file mode 100644 index 000000000..228564e58 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/MaxVectorLength.java @@ -0,0 +1,74 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.util; + +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The largest length of a set of vectors, recorded as they are added, from many threads at once. Dot-product searches + * that stop adaptively bound their scores with it. It is in one of three states: + * + *

    + *
  • none recorded yet: {@link #get} is NaN, and the first recorded length sets it;
  • + *
  • known: {@link #get} is the largest length recorded, and longer lengths raise it;
  • + *
  • unknown: {@link #get} is positive infinity, and it stays so. Use it when vectors exist whose lengths were not + * recorded, such as vectors encoded by a model loaded from a format that did not store their largest length; a + * length recorded later could not bound them.
  • + *
+ * + *

{@link #get} and {@link #restore} round-trip all three states through one float, for storage. + */ +public final class MaxVectorLength { + private static final int NONE = -1; + private static final int UNKNOWN = Float.floatToIntBits(Float.POSITIVE_INFINITY); + + // The largest squared length as float bits: nonnegative floats order like their bits, NONE orders below every + // length and UNKNOWN above, so one compare-and-set loop keeps the maximum in every state. + private final AtomicInteger squaredBits = new AtomicInteger(NONE); + + /** Records a vector's length. */ + public void record(VectorFloat vector) { + recordSquared(VectorUtil.dotProduct(vector, vector)); + } + + /** Records a squared length; a NaN length makes the largest length unknown. */ + public void recordSquared(float squaredLength) { + int bits = Float.isNaN(squaredLength) ? UNKNOWN : Float.floatToIntBits(Math.max(0f, squaredLength)); + int current; + while (bits > (current = squaredBits.get())) { + if (squaredBits.compareAndSet(current, bits)) return; + } + } + + /** Makes the largest length unknown for good. */ + public void markUnknown() { + squaredBits.set(UNKNOWN); + } + + /** The largest length recorded: NaN if none was, positive infinity if unknown. */ + public float get() { + int bits = squaredBits.get(); + return bits == NONE ? Float.NaN : (float) Math.sqrt(Float.intBitsToFloat(bits)); + } + + /** Sets the state from a value {@link #get} returned, as when loading a saved one. */ + public void restore(float stored) { + squaredBits.set(Float.isNaN(stored) ? NONE : Float.floatToIntBits(Math.abs(stored) * Math.abs(stored))); + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/util/PagedIntMap.java b/jvector-base/src/main/java/io/github/jbellis/jvector/util/PagedIntMap.java new file mode 100644 index 000000000..5bdbdcbc7 --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/util/PagedIntMap.java @@ -0,0 +1,149 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.util; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; + +/** + * A growable map of dense-ish int keys to values, like {@link DenseIntMap}, whose updates take no lock. + *

+ * Values live in fixed-size pages. A page, once installed, is never copied or replaced, so a slot + * has one location for the life of the map. Growing installs new pages and, rarely, replaces the + * spine (the array of page references) with a larger copy that refers to the same page objects. + * Because no slot ever moves, a compare-and-set can never be lost to a concurrent resize, which is + * the race {@link DenseIntMap}'s read-write lock exists to prevent. {@link #compareAndPut} is + * therefore a single slot CAS, and readers take no locks. + *

+ * Publication: pages are installed with a volatile store into the spine, and a replaced spine is + * published through a volatile field after it holds every existing page. A thread that observes a + * value written into a page therefore also observes that page through the spine. + *

+ * As with {@link DenseIntMap}, space is allocated for all keys from 0 to the highest page in use, + * and gap keys map to null. + */ +public final class PagedIntMap implements IntMap { + static final int PAGE_BITS = 12; + static final int PAGE_SIZE = 1 << PAGE_BITS; + private static final int PAGE_MASK = PAGE_SIZE - 1; + + /** Spine of pages. Entries are installed once, under {@link #growLock}, and never change. */ + private volatile AtomicReferenceArray> spine; + private final Object growLock = new Object(); + private final AtomicInteger size = new AtomicInteger(); + + public PagedIntMap(int initialCapacity) { + if (initialCapacity < 0) throw new IllegalArgumentException("initialCapacity must be nonnegative"); + int pages = Math.max(1, (int) ((initialCapacity + (long) PAGE_SIZE - 1) >>> PAGE_BITS)); + var s = new AtomicReferenceArray>(pages); + for (int p = 0; p < pages; p++) s.set(p, new AtomicReferenceArray<>(PAGE_SIZE)); + spine = s; + } + + @Override + public boolean compareAndPut(int key, T existing, T value) { + if (value == null) { + throw new IllegalArgumentException("compareAndPut() value cannot be null -- use remove() instead"); + } + if (key < 0) throw new IndexOutOfBoundsException("negative key " + key); + boolean success = pageForWrite(key).compareAndSet(key & PAGE_MASK, existing, value); + if (success && existing == null) size.incrementAndGet(); + return success; + } + + @Override + public int size() { + return size.get(); + } + + @Override + public T get(int key) { + var page = pageOrNull(key); + return page == null ? null : page.get(key & PAGE_MASK); + } + + @Override + public T remove(int key) { + var page = pageOrNull(key); + if (page == null) return null; + int slot = key & PAGE_MASK; + T old = page.get(slot); + if (old == null) return null; + if (page.compareAndSet(slot, old, null)) { + size.decrementAndGet(); + return old; + } + return null; + } + + @Override + public boolean containsKey(int key) { + return get(key) != null; + } + + @Override + public void forEach(IntBiConsumer consumer) { + var s = spine; + for (int p = 0; p < s.length(); p++) { + var page = s.get(p); + if (page == null) continue; + int base = p << PAGE_BITS; + for (int i = 0; i < PAGE_SIZE; i++) { + T value = page.get(i); + if (value != null) consumer.consume(base + i, value); + } + } + } + + private AtomicReferenceArray pageOrNull(int key) { + if (key < 0) throw new IndexOutOfBoundsException("negative key " + key); + int p = key >>> PAGE_BITS; + var s = spine; + return p < s.length() ? s.get(p) : null; + } + + private AtomicReferenceArray pageForWrite(int key) { + int p = key >>> PAGE_BITS; + var s = spine; + if (p < s.length()) { + var page = s.get(p); + if (page != null) return page; + } + return installPage(p); + } + + /** Slow path, once per new page: install it, growing the spine geometrically if needed. */ + private AtomicReferenceArray installPage(int p) { + synchronized (growLock) { + var s = spine; + if (p >= s.length()) { + // Keys up to Integer.MAX_VALUE need (MAX_VALUE >>> PAGE_BITS) + 1 pages. + int newLength = (int) Math.min((Integer.MAX_VALUE >>> PAGE_BITS) + 1L, + Math.max(p + 1L, s.length() + (s.length() >> 1) + 1L)); + var grown = new AtomicReferenceArray>(newLength); + for (int i = 0; i < s.length(); i++) grown.set(i, s.get(i)); + spine = grown; // volatile publish after copying every existing page + s = grown; + } + var page = s.get(p); + if (page == null) { + page = new AtomicReferenceArray<>(PAGE_SIZE); + s.set(p, page); + } + return page; + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java new file mode 100644 index 000000000..27a63597c --- /dev/null +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/ASHLutScoring.java @@ -0,0 +1,82 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import java.util.Objects; + +/** Portable reference and bounds contract for interleaved ASH nibble blocks. */ +public final class ASHLutScoring { + private ASHLutScoring() {} + + /** Checks a canonical packed code against a 16-entry table for each nibble. */ + public static void checkCanonicalBounds(byte[] code, int groups, float[] lut) { + checkCanonicalBounds(code, 0, groups, lut); + } + + /** As {@link #checkCanonicalBounds(byte[], int, float[])} for the code starting at {@code codeOffset}. */ + public static void checkCanonicalBounds(byte[] code, int codeOffset, int groups, float[] lut) { + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(lut, "lut"); + if (groups <= 0) throw new IllegalArgumentException("groups must be positive"); + if (codeOffset < 0 || codeOffset + (groups + 1L) / 2 > code.length || (long) groups * 16 > lut.length) { + throw new IndexOutOfBoundsException("Canonical ASH code or LUT is too small"); + } + } + + /** Scalar reference; the unused high nibble of an odd final group is ignored. */ + public static float dotCanonical(byte[] code, int groups, float[] lut) { + return dotCanonical(code, 0, groups, lut); + } + + /** As {@link #dotCanonical(byte[], int, float[])} for the code starting at {@code codeOffset}. */ + public static float dotCanonical(byte[] code, int codeOffset, int groups, float[] lut) { + checkCanonicalBounds(code, codeOffset, groups, lut); + float sum = 0; + for (int group = 0; group < groups; group++) { + int nibble = (code[codeOffset + (group >>> 1)] >>> ((group & 1) * 4)) & 15; + sum += lut[group * 16 + nibble]; + } + return sum; + } + + public static void checkBounds(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + if (groups < 0 || stride <= 0) { + throw new IllegalArgumentException("groups must be nonnegative and stride must be positive"); + } + Objects.checkFromIndexSize(lane, count, stride); + Objects.checkFromIndexSize(outOffset, count, out.length); + Objects.checkFromIndexSize(0, Math.multiplyExact(groups, 16), lut.length); + int bytes = Math.multiplyExact(groups / 2 + groups % 2, stride); + Objects.checkFromIndexSize(offset, bytes, codes.length); + } + + /** Returns projection dot products only; callers apply scale, offset and landmark terms. */ + public static void score(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + checkBounds(codes, offset, groups, stride, lane, count, lut, out, outOffset); + for (int i = 0; i < count; i++) { + float sum = 0; + for (int group = 0; group < groups; group++) { + int packed = codes[offset + (group >>> 1) * stride + lane + i] & 255; + int nibble = (packed >>> ((group & 1) * 4)) & 15; + sum += lut[group * 16 + nibble]; + } + out[outOffset + i] = sum; + } + } +} diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java index 5843dc5f6..92b12ab92 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/DefaultVectorUtilSupport.java @@ -547,4 +547,6 @@ public float nvqUniformLoss(VectorFloat vector, float minValue, float maxValu return squaredSum; } + + } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java index 9d663d10c..d3c6ec76c 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/vector/VectorUtilSupport.java @@ -35,6 +35,200 @@ */ public interface VectorUtilSupport { + /** Effective ASH kernel tuning for benchmark diagnostics. */ + default String ashKernelDescription() { return "portable scalar ASH kernels"; } + + /** Query factories select the tuned entry point once, outside the scoring hot loop. */ + default boolean usesAshProjectionTuning() { return false; } + + /** Explicitly tuned projection kernel; the ordinary entry point retains its compact default loop. */ + default float ashProjectionDotTuned(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + return ashProjectionDotTuned(query, code, 0, dimensions, bitsPerDimension); + } + + /** As {@link #ashProjectionDotTuned(float[], byte[], int, int)} for the code starting at {@code codeOffset}. */ + default float ashProjectionDotTuned(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { + return ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); + } + + /** Whether ASH nibble lookup and accumulation are implemented with SIMD. */ + default boolean supportsAshLutScoring() { return false; } + + /** Exact integer lookup for encoded C=1 query pairs. LUT stride is 32 (two repeated 16-entry tables). */ + default void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + for (int i = 0; i < count; i++) { + long sum = 0; + for (int g = 0; g < groups; g++) { + int nibble = (codes[(g / 2) * stride + lane + i] >>> (4 * (g % 2))) & 15; + sum += lut[g * 32 + nibble]; + } + out[outOffset + i] = sum * .25f; + } + } + + /** Packed-to-packed integer dot products for symmetric ASH. */ + default boolean supportsAshSymmetricScoring() { return false; } + + default float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, + int dimensions, int bits) { + throw new UnsupportedOperationException("Symmetric ASH SIMD is unavailable on this backend"); + } + + /** Whether {@link #ashSymmetricDotAt} is implemented (2- and 4-bit codes at array offsets). */ + default boolean supportsAshSymmetricDotAt() { return false; } + + /** + * Symmetric 2/4-bit packed dot product of codes stored at offsets inside larger arrays. + * Same result as {@link #ashSymmetricDot} on the corresponding standalone code arrays. + */ + default float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, + int dimensions, int bits) { + throw new UnsupportedOperationException("Offset symmetric ASH SIMD is unavailable on this backend"); + } + + /** Whether {@link #ashSymmetricDotRows} scores all rows in one call rather than one call per row. */ + default boolean supportsAshSymmetricDotRows() { return false; } + + /** + * Symmetric 2/4-bit dot products of one code against {@code count} codes stored {@code stride} bytes apart from + * the start of {@code rows}: {@code out[i]} equals {@link #ashSymmetricDotAt} of the query and row {@code i}. + * Requires {@link #supportsAshSymmetricDotAt()}. + */ + default void ashSymmetricDotRows(byte[] query, int queryOffset, byte[] rows, int stride, int count, + int dimensions, int bits, float[] out) { + for (int i = 0; i < count; i++) out[i] = ashSymmetricDotAt(query, queryOffset, rows, i * stride, dimensions, bits); + } + + /** + * Symmetric 1-bit dot product, {@code dimensions - 2 * mismatches}, of sign words stored at offsets + * inside larger arrays. Same result as {@link #ashSymmetricDot} with {@code bits == 1}. + * Available whenever {@link #supportsAshSymmetricScoring()} is. + */ + default float ashSymmetricBinaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + throw new UnsupportedOperationException("Symmetric ASH SIMD is unavailable on this backend"); + } + + /** Whether canonical 2/4-bit projection codes have a single-vector SIMD kernel. */ + default boolean supportsAshProjectionScoring() { return false; } + + /** Whether canonical ASH codes can use an already prepared nibble LUT in SIMD. */ + default boolean supportsAshCanonicalLutScoring() { return false; } + + /** Whether canonical LUT scoring is implemented by the native backend. */ + default boolean supportsNativeAshCanonicalLutScoring() { return false; } + + /** Fail before scoring when the caller explicitly requires a native ASH single scorer. */ + default void requireNativeAshSingleScoring(int bitsPerDimension) { + throw new IllegalStateException("Native ASH single scoring is unavailable for b=" + bitsPerDimension); + } + + /** Projection dot only; callers apply the vector's scale, offset and landmark term. */ + default float ashCanonicalLutDot(byte[] code, int groups, float[] lut) { + return ashCanonicalLutDot(code, 0, groups, lut); + } + + /** As {@link #ashCanonicalLutDot(byte[], int, float[])} for the code starting at {@code codeOffset}. */ + default float ashCanonicalLutDot(byte[] code, int codeOffset, int groups, float[] lut) { + return ASHLutScoring.dotCanonical(code, codeOffset, groups, lut); + } + + /** Dot product of a projected query with a canonical signed-magnitude 2/4-bit code. */ + default float ashProjectionDot(float[] query, byte[] code, int dimensions, int bitsPerDimension) { + return ashProjectionDot(query, code, 0, dimensions, bitsPerDimension); + } + + /** As {@link #ashProjectionDot(float[], byte[], int, int)} for the code starting at {@code codeOffset}. */ + default float ashProjectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { + if (bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); + } + java.util.Objects.checkFromIndexSize(0, dimensions, query.length); + int perByte = 8 / bitsPerDimension; + java.util.Objects.checkFromIndexSize(codeOffset, dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1), code.length); + int sign = 1 << (bitsPerDimension - 1); + float sum = 0; + for (int i = 0; i < dimensions; i++) { + int field = (code[codeOffset + i / perByte] & 255) >>> ((i % perByte) * bitsPerDimension); + float magnitude = (field & (sign - 1)) + 0.5f; + sum += query[i] * ((field & sign) == 0 ? -magnitude : magnitude); + } + return sum; + } + + /** + * Scores contiguous lanes in a byte-interleaved ASH block using a 16-entry LUT per nibble. + * Even groups occupy low nibbles; odd groups occupy high nibbles. Each byte row has + * {@code stride} lanes. Writes projection dot products, without per-vector headers. + */ + default void ashLutScore(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + ASHLutScoring.score(codes, offset, groups, stride, lane, count, lut, out, outOffset); + } + + /** + * Whether this provider implements complete FusedASH neighborhood scoring for one landmark. + * Query factories use this capability independently of the standalone LUT primitive. + * + * @return {@code true} when {@link #ashScoreNeighborhood} is implemented + */ + default boolean supportsAshNeighborhoodScoring() { return false; } + + /** + * Scores one complete C=1 FusedASH neighborhood, including all serialized blocks and headers. + * The result is {@code (1 + rawDotProduct) / 2}, unclamped, matching graph DOT_PRODUCT similarity. + * + *

Inputs are read-only for the call; output storage must be distinct from {@code lut}. + * Calls may share immutable inputs across threads when their output arrays are separate. + * On failure, output contents are unspecified. Implementations validate sizes before native access.

+ * + * @param codes complete packed neighborhood, with full serialized storage for its final block + * @param groups positive nibble group count, including a possible partial final group + * @param blockSize serialized block width: 8, 16, or 32 + * @param capacity positive number of lanes to score, including graph padding + * @param lut query LUT with at least {@code 16 * groups} floats + * @param landmarkDot query dot product with the single landmark + * @param out receives {@code capacity} similarities; later elements are left unchanged + * @throws UnsupportedOperationException if this provider has no implementation + * @throws IllegalArgumentException for an invalid shape or aliased LUT/output + * @throws IndexOutOfBoundsException if an array is too small + * @throws IllegalStateException if a scored lane contains a nonzero landmark ID + */ + default void ashScoreNeighborhood(byte[] codes, int groups, int blockSize, int capacity, + float[] lut, float landmarkDot, float[] out) { + throw new UnsupportedOperationException("Native ASH neighborhood scoring is unavailable"); + } + + /** Whether {@link #ashScoreNeighborhoodInt8} is implemented. */ + default boolean supportsAshInt8NeighborhoodScoring() { return false; } + + /** + * Scores one complete C=1 FusedASH neighborhood of offset-binary codes (3 and 5 to 9 bits) with an + * INT8 query. Each block's body is {@code [unit][lane][bits bytes]} for {@code units} units of + * eight dimensions, followed by the usual headers. Per lane, + * {@code dot = ( - center) * invScale}, and the result is + * {@code (1 + scale * dot + landmarkDot + offset) / 2}, unclamped. + * + * @param codes complete packed neighborhood, with full serialized storage for its final block + * @param bits bits per dimension: 3, 5, 6, 7, 8 or 9 + * @param units positive number of eight-dimension units + * @param blockSize serialized block width: 8, 16, or 32 + * @param capacity positive number of lanes to score, including graph padding + * @param q INT8 query, {@code units * 8} entries + * @param invScale reciprocal of the query quantization scale + * @param center {@code (2^bits - 1)/2 * sum(q)} + * @param landmarkDot query dot product with the single landmark + * @param out receives {@code capacity} similarities; later elements are left unchanged + * @throws UnsupportedOperationException if this provider has no implementation + * @throws IllegalArgumentException for an invalid shape + * @throws IndexOutOfBoundsException if an array is too small + * @throws IllegalStateException if a scored lane contains a nonzero landmark ID + */ + default void ashScoreNeighborhoodInt8(byte[] codes, int bits, int units, int blockSize, int capacity, + byte[] q, float invScale, float center, float landmarkDot, float[] out) { + throw new UnsupportedOperationException("Native INT8 ASH neighborhood scoring is unavailable"); + } + /** Calculates the dot product of the given float arrays. */ float dotProduct(VectorFloat a, VectorFloat b); @@ -343,6 +537,214 @@ default long nvqCosine8bitPacked(VectorFloat vector, ByteSequence bytes, f */ float nvqUniformLoss(VectorFloat vector, float minValue, float maxValue, int nBits); + // ------------------------------------------------------------------ + // ASH kernels (default scalar implementations) + // ------------------------------------------------------------------ + + /** + * @return true if this backend provides a SIMD implementation of the masked-load kernel as described in the ASH paper. + */ + default boolean supportsAshMaskedLoad() { + return false; + } + + /** Immutable provider-specific projection, safe to share between query threads. */ + interface AshProjection { + /** Writes the full matrix-vector product; input and output must not alias. */ + void project(float[] query, float[] out); + + /** + * Projects the first {@code count} vectors, each stored in its own row. + * All input rows must have the model's original dimension and output rows + * its projected dimension. Output rows must be distinct and must not alias + * any input row. Rows beyond count are ignored; count zero is a no-op. + * Providers may batch the multiply; the default retains their single-vector + * arithmetic without allocating temporary arrays. + */ + default void projectBatch(float[][] inputs, float[][] outputs, int count) { + validateBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) project(inputs[i], outputs[i]); + } + + /** + * Whether this view has a genuine multi-vector projection implementation. + * Query dispatch uses this capability to avoid coordinating providers whose + * batch method simply repeats singleton calls. This is a capability flag, + * not a performance promise, and may become false after a native fallback. + */ + default boolean supportsBatchProjection() { return false; } + + /** Validates a batch before any output is changed. */ + static void validateBatch(float[][] inputs, float[][] outputs, int count) { + java.util.Objects.requireNonNull(inputs, "inputs"); + java.util.Objects.requireNonNull(outputs, "outputs"); + if (count < 0 || count > inputs.length || count > outputs.length) + throw new IllegalArgumentException("Invalid ASH projection batch count: " + count); + for (int i = 0; i < count; i++) { + java.util.Objects.requireNonNull(inputs[i], "input row"); + java.util.Objects.requireNonNull(outputs[i], "output row"); + if (inputs[i].length != inputs[0].length || outputs[i].length != outputs[0].length) + throw new IllegalArgumentException("Ragged ASH projection batch"); + } + if (count <= 32) { + // The normal encoding batch needs no temporary collection. For larger + // API requests use linear validation rather than an unbounded scan. + for (int i = 0; i < count; i++) + for (int j = 0; j < count; j++) + if (outputs[i] == inputs[j] || (j < i && outputs[i] == outputs[j])) + throw new IllegalArgumentException("Aliased ASH projection batch"); + } else { + var used = new java.util.IdentityHashMap(count); + for (int i = 0; i < count; i++) used.put(inputs[i], Boolean.TRUE); + for (int i = 0; i < count; i++) + if (used.put(outputs[i], Boolean.TRUE) != null) + throw new IllegalArgumentException("Aliased ASH projection batch"); + } + } + + /** + * Returns an immutable view for query scoring, which may use lower operand + * precision. Encoding/training must continue to use this original object. + * The caller caches the returned view for the model's lifetime. Providers + * without a query-specific representation return this same object. + */ + default AshProjection forQueryScoring() { + String mode = System.getProperty("jvector.ash.queryProjection", "auto"); + switch (mode) { + case "auto": + case "fp32": return this; + case "fp16": throw new UnsupportedOperationException( + "This provider does not support FP16 ASH query projection"); + case "int8": throw new UnsupportedOperationException( + "This provider does not support INT8 ASH query projection"); + default: throw new IllegalArgumentException( + "jvector.ash.queryProjection must be auto, fp32, fp16 or int8: " + mode); + } + } + + /** Operand precision of this prepared view; reporting must not initialize another view. */ + default String operandPrecision() { return "fp32"; } + + /** Additional backing storage, excluding the caller-owned matrix. */ + default long ramBytesUsed() { return 0L; } + } + + /** + * Prepares an immutable [projected dimension][original dimension] matrix once. + * The caller must not mutate its rows after preparation. Providers may cache a + * packed representation; each project call still prepares exactly one query. + */ + default AshProjection prepareAshProjection(float[][] matrix) { + java.util.Objects.requireNonNull(matrix, "matrix"); + if (matrix.length == 0 || matrix[0].length == 0) + throw new IllegalArgumentException("ASH projection dimensions must be positive"); + int columns = matrix[0].length; + for (float[] row : matrix) { + if (row.length != columns) throw new IllegalArgumentException("Ragged ASH projection"); + } + return (query, out) -> { + if (query.length != columns || out.length != matrix.length || query == out) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + for (int row = 0; row < matrix.length; row++) out[row] = ashDotRow(matrix[row], query); + }; + } + + /** + * Dot product between a matrix row and a dense vector. + * Used by ASH projection: {@code y_i = }. + * + * Contract: Arow.length == x.length. + * Default is scalar; SIMD backends may override. + */ + default float ashDotRow(float[] Arow, float[] x) { + assert Arow.length == x.length : "Arow.length != x.length"; + float acc = 0.0f; + for (int i = 0; i < x.length; i++) { + acc += Arow[i] * x[i]; + } + return acc; + } + + /** + * Sums {@code tildeQ[qOffset + i]} over the set bits {@code i < d} of one vector's packed sign words. + * The default is portable scalar code; SIMD backends override it. + */ + default float ashMaskedAdd_512(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + float sum = 0.0f; + int baseDim = 0; + + for (int w = 0; w < words; w++) { + long word = allPackedVectors[packedBase + w]; + + // Standard bit-twiddling iteration (iterate only set bits) + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + + // Bounds check required for the last word + if (idx < d) { + sum += tildeQ[qOffset + idx]; + } + + // Clear the lowest set bit + word &= (word - 1); + } + + baseDim += 64; + } + return sum; + } + + /** + * Computes maskedAdd for a block slice of vectors stored in packed block-column-major form. + * For each lane in [0, blockLen), writes: + * {@code outMaskedAdd[lane] = } + * packedBits layout: + * packedBits[blockWordBase + w*blockSize + laneIndex] is 64-bit word w for that lane. + * Default implementation is scalar bit-walk with a register accumulator per lane. + * SIMD backends may override. + */ + default void ashMaskedAddBlockAllWords( + float[] tildeQ, + int d, + long[] packedBits, + int packedBase, // blockId * nWords * blockSize + int nWords, + int blockSize, + int laneStart, + int blockLen, + float[] outMaskedAdd + ) { + assert laneStart >= 0; + assert blockLen >= 0; + assert laneStart + blockLen <= blockSize; + assert outMaskedAdd.length >= blockLen : "outMaskedAdd too small"; + + for (int lane = 0; lane < blockLen; lane++) { + float maskedAdd = 0f; // register accumulator (critical!) + int laneIndex = laneStart + lane; + + for (int w = 0; w < nWords; w++) { + long word = packedBits[packedBase + (w * blockSize) + laneIndex]; + int baseDim = w * 64; + + while (word != 0L) { + int bit = Long.numberOfTrailingZeros(word); + int idx = baseDim + bit; + if (idx < d) maskedAdd += tildeQ[idx]; + word &= (word - 1); + } + } + + outMaskedAdd[lane] = maskedAdd; + } + } + /** * Optionally performs the existing coarse/fine NVQ growth-rate search in one * call. Returns NaN when unsupported; the caller then uses its Java search. @@ -352,4 +754,13 @@ default float tryNvqGrowthRate(VectorFloat vector, float minValue, float maxV return Float.NaN; } + /** + * Sets the native BLAS thread count for calls made on the current thread, as + * {@link io.github.jbellis.jvector.util.BlasThreads} requests. Returns the setting to restore + * afterward, or -1 when this provider cannot control BLAS threads. + */ + default int setBlasThreadsLocal(int threads) { + return -1; + } + } diff --git a/jvector-examples/README.md b/jvector-examples/README.md index 46990445b..1cce8a61f 100644 --- a/jvector-examples/README.md +++ b/jvector-examples/README.md @@ -2,6 +2,60 @@ JVector comes with the following sample programs to try: +### Hello ASH world + +Two self-contained introductions build and query a fused ASH disk index over the +public `e5-small-v2-100k` dataset (downloaded and cached automatically): + +- [HelloASHWorldInt8](src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java): + INT8 query projection for Intel Ice Lake and other supported INT8 processors. +- [HelloASHWorldFp16](src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java): + native FP16 query projection for Intel Sapphire Rapids and newer FP16 processors. + +Both teach the same six steps: load vectors, train/encode ASH, construct the graph, +write fused ASH neighborhoods with NVQ reranking vectors, reopen and search +synchronously, then submit individual queries through the queued API. +They use ASH(b=2,d=192) for 384-dimensional inputs. Stored-code precision (`b`) +is independent of query-projection precision; training and encoding remain FP32. +NVQ's mean is collected during ASH encoding, avoiding another input scan. + +Build the examples with the native provider and oneDNN support described in the +[native build guide](../jvector-native/src/main/native/README.md#recommended-onednn-build-and-package). +From the repository root, using JDK 22 or newer: + +```bash +mvn package -DskipTests +java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Xmx8g \ + -cp jvector-examples/target/jvector-examples-4.1.0-SNAPSHOT-jar-with-dependencies.jar \ + io.github.jbellis.jvector.example.HelloASHWorldInt8 +java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Xmx8g \ + -cp jvector-examples/target/jvector-examples-4.1.0-SNAPSHOT-jar-with-dependencies.jar \ + io.github.jbellis.jvector.example.HelloASHWorldFp16 +``` + +Run the program matching your CPU; explicit unsupported precisions fail instead +of silently changing precision. Each saves `ash-int8.graph` or `ash-fp16.graph` +and prints top-ten vector ordinals, similarity scores and visited-node counts +for the first held-out query using both synchronous and queued search. An optional +argument selects a new output path; +existing files are never overwritten. Original vectors are not needed to reopen +the finished index. + +The synchronous section introduces preparation and graph search for one query. +The queued section submits eight independent queries using two application-assigned +workers, admission 16 and maximum preparation batch 8. The shared +[HelloASHQueuedSearch](src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java) +shows the host integration explicitly: compatible-query preparation, worker-owned +searchers, completion futures, admission rejection and orderly shutdown. Callers +submit individual requests; the queue prepares currently waiting compatible work +together, without a fill timer. Batch sizes depend on what is waiting at that moment. +Each request owns its input snapshot and prepared state through completion. + +For clarity, this small example allocates request records and preparation arrays. +Services can reuse bounded storage as described in +[bounded query batching and state ownership](../docs/ash-query-integration.md). +For more hardware choices, see [projection precision](../docs/ash-projection-precision.md). + ### Batched file-backed vectors Two hello-world programs use public-catalog `e5-small-v2-100k`, or accept an existing @@ -18,7 +72,7 @@ and vector ownership; the second trains and encodes with PQ and NVQ. See source lifecycle, and integration guidance. ### SiftSmall -A simple benchmark for the sift dataset located in the [siftsmall](./siftsmall) directory in the project root. +A simple benchmark for the sift dataset located in the [siftsmall](../siftsmall) directory in the project root. > `mvn compile exec:exec@sift` @@ -29,8 +83,6 @@ the best tradeoffs between recall and throughput. This benchmark requires `fvecs' versions of datasets from [https://github.com/erikbern/ann-benchmarks](https://github.com/erikbern/ann-benchmarks/blob/main/README.md#data-sets) to be downloaded to `dataset_cache` directory under the project root. -You can use [`plot_output.py`](./plot_output.py) to graph the [pareto-optimal points](https://en.wikipedia.org/wiki/Pareto_efficiency) found by `Bench`. - > `mvn compile exec:exec@bench` Some sample KNN datasets for testing based on ada-002 embeddings generated on wikipedia data are available in ivec/fvec format for testing at: diff --git a/jvector-examples/bench-profiles/README.md b/jvector-examples/bench-profiles/README.md new file mode 100644 index 000000000..73071fd04 --- /dev/null +++ b/jvector-examples/bench-profiles/README.md @@ -0,0 +1,44 @@ +# Benchmark launcher and server profiles + +`jvector-examples/benchyaml-play.sh` runs one +[index-parameters](../yaml-configs/index-parameters) YAML with BenchYAML in one JVM. Every launch +first rebuilds the checkout with Maven, including the native libraries, so results always come +from the current source. The run records the commit, with `-dirty` when tracked files have +uncommitted changes, and the JAR checksum. + +```bash +BENCH_RUN_CONFIG=jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml \ + jvector-examples/benchyaml-play.sh \ + jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml +``` + +The index YAML sets concurrency: `construction.workers` for training, encoding, graph insertion +and writing, and `search.workers` for queries, each a number or `max` (the platform's physical +cores). `search.workers` is a sweep, so `[1, 8]` queries the same index with 1 and then 8 workers. +The launcher sets where the JVM may run, and the run YAML sets what is printed and logged. +JVector sets native BLAS threads itself (the construction pool's size during training), so the +launcher clears `MKL_NUM_THREADS` and the other BLAS thread variables. + +`BENCH_RUN_CONFIG` selects the run YAML (default `yaml-configs/run-config.yml`). +`BENCH_DEBUG=` waits for a debugger on that port. + +Long construction phases print progress every 10 seconds: vectors inserted with an estimate of +the time left, then graph cleanup and index writing. Set `-Djvector.bench.progressSeconds=0` +to print only each phase's start and finish. + +## Server profiles + +A profile describes one machine: JDK, working directory root, dataset catalogs and, optionally, +cores and NUMA nodes. CPU lists come from `lscpu`, one hardware thread per physical core. +Copy [example.env](example.env) to `local/profile.env`, or keep several profiles in +`local/.env` and pick one with `BENCH_PROFILE=`. The `local/` directory is ignored +by git because profiles hold machine paths. Each recipe runs in its own directory under +`BENCH_WORK_ROOT`. + +## IntelliJ + +The repository's `.run/` directory holds run configurations for experiments. Each names a recipe +and a run YAML, and takes machine settings from your profile. To add one for another recipe, +copy an existing file, then change its name and the recipe path in `SCRIPT_OPTIONS`; every case in +[ash-vs-pq-single-jvm](../yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm) runs with +`run-single-jvm.yml`. diff --git a/jvector-examples/bench-profiles/example.env b/jvector-examples/bench-profiles/example.env new file mode 100644 index 000000000..1235deedd --- /dev/null +++ b/jvector-examples/bench-profiles/example.env @@ -0,0 +1,31 @@ +# Server profile for jvector-examples/benchyaml-play.sh. +# Copy to bench-profiles/local/profile.env (ignored by git) and edit for your machine, +# or keep several in bench-profiles/local/.env and select one with BENCH_PROFILE=. + +# JDK used to build and run BenchYAML. +BENCH_JDK=$HOME/.jdks/jdk-22.0.2+9 + +# Parent of each experiment's working directory (indexes, models, logs). Put it on fast local +# storage: queries read the index from here, including indexes that are not saved. +BENCH_WORK_ROOT=/data/jvector-bench + +# Dataset catalogs, one per line: . +# Repository paths are relative to the checkout. Each catalog's ${JVECTOR_BENCH_DATA_DIR} +# is replaced by its directory. List each dataset name in only one catalog. +BENCH_CATALOGS=" +jvector-examples/yaml-configs/ash-pq/catalog-large.yml /data/vectors/large +jvector-examples/yaml-configs/ash-pq/catalog-1m.yml /data/vectors/1m +" + +# Optional. The JVM runs on one hardware thread of each of the first BENCH_CORES physical +# cores (default: all), with memory interleaved across BENCH_NODES (default: all NUMA nodes). +# The index YAML's construction.workers and search.workers set how many threads work. +#BENCH_CORES=48 +#BENCH_NODES=0,1 + +# Optional. Where indexes that are not saved are written and reopened for queries +# (default: each experiment's tmp directory under BENCH_WORK_ROOT). Use fast local storage. +#BENCH_TMP_DIR=/data/jvector-bench/tmp + +# Optional. Maximum Java heap (default 128g). +#BENCH_HEAP=128g diff --git a/jvector-examples/benchyaml-play.sh b/jvector-examples/benchyaml-play.sh new file mode 100755 index 000000000..c0f9618c4 --- /dev/null +++ b/jvector-examples/benchyaml-play.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Build BenchYAML from this checkout, then run one index-parameters YAML in one JVM. +# Every launch rebuilds Java and native code, so a run always reflects the current +# source. The YAML's construction.workers and search.workers set concurrency; this +# script sets where the JVM may run. See jvector-examples/bench-profiles/README.md. +# +# benchyaml-play.sh build, then run +# benchyaml-play.sh --build-only build and print JAR=, SOURCE_REF= and JAR_SHA256= +# +# BENCH_RUN_CONFIG selects the run YAML (default jvector-examples/yaml-configs/run-config.yml). +# Machine settings come from the server profile (BENCH_PROFILE, or bench-profiles/local/profile.env). +set -euo pipefail + +repo=$(git -C "$(dirname -- "$0")" rev-parse --show-toplevel) +profiles="$repo/jvector-examples/bench-profiles" + +profile=${BENCH_PROFILE:-$profiles/local/profile.env} +[[ $profile == */* ]] || profile="$profiles/local/$profile.env" +[[ -f $profile ]] || { echo "Server profile not found: $profile. Copy bench-profiles/example.env to bench-profiles/local/profile.env and edit it." >&2; exit 2; } +# shellcheck source=/dev/null +source "$profile" +jdk=${BENCH_JDK:?The server profile must set BENCH_JDK.} + +# Maven and native build output goes to build.log under the work root; the console gets one line. +build() { + local marker start jars log + marker=$(mktemp) + start=$(date +%s) + log="${BENCH_WORK_ROOT:-$repo/jvector-examples/target}/build.log" + mkdir -p "$(dirname "$log")" + printf 'Building BenchYAML from %s ... ' "$repo" >&2 + if ! (cd "$repo" && JAVA_HOME="$jdk" PATH="$jdk/bin:$PATH" JAVA_TOOL_OPTIONS= JDK_JAVA_OPTIONS= \ + mvn -B -q package -DskipTests -pl jvector-examples -am) > "$log" 2>&1; then + printf 'failed. Last lines of %s:\n' "$log" >&2 + tail -40 "$log" >&2 + exit 1 + fi + printf 'done in %s s (log: %s)\n' "$(( $(date +%s) - start ))" "$log" >&2 + mapfile -t jars < <(find "$repo/jvector-examples/target" -maxdepth 1 -name '*-jar-with-dependencies.jar' -newer "$marker") + rm -f "$marker" + if [[ ${#jars[@]} != 1 ]]; then + echo "Expected the build to write one jar-with-dependencies in jvector-examples/target; found ${#jars[@]}." >&2 + exit 1 + fi + JAR=${jars[0]} + SOURCE_REF=$(git -C "$repo" rev-parse HEAD) + [[ -z $(git -C "$repo" status --porcelain --untracked-files=no) ]] || SOURCE_REF="$SOURCE_REF-dirty" + JAR_SHA256=$(sha256sum "$JAR" | cut -d' ' -f1) +} + +if [[ ${1:-} == --build-only ]]; then + build + printf 'JAR=%q\nSOURCE_REF=%q\nJAR_SHA256=%q\n' "$JAR" "$SOURCE_REF" "$JAR_SHA256" + exit 0 +fi + +index=${1:?Pass an index-parameters YAML file.} +[[ $index == /* ]] || index="$repo/$index" +[[ -f $index ]] || { echo "Index config not found: $index" >&2; exit 2; } +run_config=${BENCH_RUN_CONFIG:-jvector-examples/yaml-configs/run-config.yml} +[[ $run_config == /* ]] || run_config="$repo/$run_config" +[[ -f $run_config ]] || { echo "Run config not found: $run_config" >&2; exit 2; } +work_root=${BENCH_WORK_ROOT:?The server profile must set BENCH_WORK_ROOT.} + +# One hardware thread on each of the first BENCH_CORES physical cores, in CPU order. +mapfile -t physical < <(lscpu -p=CPU,CORE,SOCKET | awk -F, '!/^#/ && !seen[$3":"$2]++ {print $1}') +cores=${BENCH_CORES:-${#physical[@]}} +(( cores >= 1 && cores <= ${#physical[@]} )) || { echo "BENCH_CORES=$cores must be between 1 and the ${#physical[@]} physical cores." >&2; exit 2; } +cpus=$(printf '%s\n' "${physical[@]:0:cores}" | paste -sd, -) +# Print CPU lists as ranges, for example 0-47. +cpu_ranges=$(printf '%s\n' "${physical[@]:0:cores}" | sort -n | awk 'NR==1{a=b=$1; next} $1==b+1{b=$1; next} {printf "%s%s,", a, (a==b?"":"-"b); a=b=$1} END{printf "%s%s", a, (a==b?"":"-"b)}') +nodes=${BENCH_NODES:-$(lscpu -p=NODE | grep -v '^#' | sort -un | paste -sd, -)} + +# Each recipe gets its own working directory, so caches and logs never mix between experiments. +# Unsaved indexes are written and reopened for queries under BENCH_TMP_DIR (java.io.tmpdir), +# by default the experiment's tmp directory on the same storage as BENCH_WORK_ROOT. +recipe=${index#"$repo/jvector-examples/yaml-configs/index-parameters/"} +[[ $recipe != /* ]] || recipe="other/$(basename "$recipe")" +work="$work_root/${recipe%.yml}" + +mkdir -p "$work_root" +exec 9>"$work_root/benchyaml.lock" +flock -n 9 || { echo "Another BenchYAML launch holds $work_root/benchyaml.lock." >&2; exit 1; } +if pgrep -f '[.]example[.]BenchYAML' >/dev/null; then echo "A BenchYAML JVM is already running on this machine." >&2; exit 1; fi + +build + +# BenchYAML reads dataset metadata and catalogs relative to its working directory. +catalogs="$work/jvector-examples/yaml-configs/dataset-catalogs" +tmp=${BENCH_TMP_DIR:-$work/tmp} +mkdir -p "$catalogs" "$tmp" +cp "$repo/jvector-examples/yaml-configs/dataset-metadata.yml" "$repo/jvector-examples/yaml-configs/datasets.yml" "$work/jvector-examples/yaml-configs/" +find "$catalogs" -mindepth 1 -delete +# A recipe that does not save its index also starts without saved models, so every run +# measures training and encoding. Recipes that reuse indexes keep both caches. +if ! grep -Eq '^[[:space:]]+useSavedIndexIfExists:[[:space:]]*Yes' "$index"; then + for cache in compressor_cache index_cache; do + if [[ -d $work/$cache ]]; then find "$work/$cache" -mindepth 1 -delete; fi + done +fi +n=0 +while read -r catalog data; do + [[ -n $catalog ]] || continue + [[ $catalog == /* ]] || catalog="$repo/$catalog" + n=$((n + 1)) + sed "s|\${JVECTOR_BENCH_DATA_DIR}|$data|g" "$catalog" > "$catalogs/$n-$(basename "${catalog%.*}").yml" +done <<< "${BENCH_CATALOGS:?The server profile must list BENCH_CATALOGS.}" + +printf '\nRecipe %s\nCPUs %s (%s physical cores), memory interleaved on NUMA nodes %s\nSource %s\nJAR %s\n sha256 %s\nRun YAML %s\nWork %s\nIndexes %s\n\n' \ + "$recipe" "$cpu_ranges" "$cores" "$nodes" "$SOURCE_REF" "$JAR" "$JAR_SHA256" "$run_config" "$work" "$tmp" +debug=() +[[ -z ${BENCH_DEBUG:-} ]] || debug=("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$BENCH_DEBUG") +cd "$work" +# JVector sets BLAS threads for its own calls (one, or the construction pool's size), so the +# BLAS thread variables are cleared rather than set here. +exec env -u JAVA_TOOL_OPTIONS -u JDK_JAVA_OPTIONS -u _JAVA_OPTIONS \ + -u MKL_NUM_THREADS -u OMP_NUM_THREADS -u OPENBLAS_NUM_THREADS -u GOTO_NUM_THREADS \ + MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 \ + numactl --physcpubind="$cpus" --interleave="$nodes" "$jdk/bin/java" "${debug[@]}" \ + --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED -da -Xms2g "-Xmx${BENCH_HEAP:-128g}" \ + "-XX:ActiveProcessorCount=$cores" "-Djava.io.tmpdir=$tmp" '-Djava.util.logging.SimpleFormatter.format=%5$s%6$s%n' \ + -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 \ + -Djvector.vectorization_provider=native -Djvector.parallel_writer=true \ + "-Djvector.physical_core_count=$cores" \ + -Djvector.bench.verifyConcurrentResults=true -Djvector.bench.throughputMinSeconds=3.0 \ + "-Djvector.bench.sourceRef=$SOURCE_REF" "-Djvector.bench.runConfig=$run_config" \ + -cp "$JAR" io.github.jbellis.jvector.example.BenchYAML "$index" diff --git a/jvector-examples/run-ash-pq-benchmark.sh b/jvector-examples/run-ash-pq-benchmark.sh new file mode 100755 index 000000000..6cd9a977f --- /dev/null +++ b/jvector-examples/run-ash-pq-benchmark.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +set -euo pipefail + +if [[ $# != 5 ]]; then + echo "Usage: $0 " >&2 + echo "Example: $0 mpnet-2bit-ash.yml queued 8 0-7 /data/bench/mpnet-2bit-ash" >&2 + exit 2 +fi +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" +CONFIG_NAME="$1" +MODE="$2" +CORES="$3" +CPU_LIST="$4" +CASE_DIRECTORY="$5" +case "$MODE" in construction|direct|queued) ;; *) echo "Invalid mode: $MODE" >&2; exit 2;; esac +if ! [[ "$CORES" =~ ^[1-9][0-9]*$ ]]; then echo "Physical core count must be positive" >&2; exit 2; fi +for command in java numactl lscpu sha256sum awk; do + command -v "$command" >/dev/null || { echo "Required command not found: $command" >&2; exit 2; } +done +if ! [[ "$CPU_LIST" =~ ^[0-9]+(-[0-9]+)?(,[0-9]+(-[0-9]+)?)*$ ]]; then + echo "CPU list must contain CPU numbers or ranges, such as 0 or 0-7." >&2 + exit 2 +fi +lscpu --parse=CPU,CORE,SOCKET,ONLINE | awk -F, -v selection="$CPU_LIST" -v budget="$CORES" ' + BEGIN { + count = split(selection, pieces, ",") + for (i = 1; i <= count; i++) { + n = split(pieces[i], bounds, "-") + first = bounds[1] + 0; last = n == 1 ? first : bounds[2] + 0 + if (last < first || last - first + 1 > budget) { bad = 1; exit 2 } + for (cpu = first; cpu <= last; cpu++) chosen[cpu] = 1 + } + } + !/^#/ && ($1 in chosen) { + if ($4 == "N") { bad = 1; exit 2 } + seen[$1] = 1; physical[$3 ":" $2] = 1 + } + END { + for (cpu in chosen) wanted++ + for (cpu in seen) found++ + for (core in physical) cores++ + if (bad || wanted != budget || found != budget || cores != budget) { + print "CPU selection must contain exactly the budgeted number of distinct online physical cores." > "/dev/stderr" + exit 2 + } + }' +DATA_ROOT="${JVECTOR_BENCH_DATA_DIR:-}" +if [[ "$DATA_ROOT" != /* || ! -d "$DATA_ROOT" ]]; then + echo "Set JVECTOR_BENCH_DATA_DIR to the absolute local dataset directory; see the catalog template." >&2 + exit 2 +fi +if [[ -n "${JAVA_TOOL_OPTIONS:-}" || -n "${JDK_JAVA_OPTIONS:-}" ]]; then + echo "Clear JAVA_TOOL_OPTIONS/JDK_JAVA_OPTIONS so the recorded Java command is complete." >&2 + exit 2 +fi +git -C "$REPO_ROOT" diff --quiet +git -C "$REPO_ROOT" diff --cached --quiet +SOURCE_REF="$(git -C "$REPO_ROOT" rev-parse HEAD)" +JAVA_BIN="$(command -v java)" +CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/$CONFIG_NAME" +RUN_CONFIG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/run-$MODE.yml" +CATALOG_NAME="${JVECTOR_BENCH_CATALOG:-catalog-1m.yml}" +case "$CATALOG_NAME" in catalog-1m.yml|catalog-large.yml) ;; *) echo "Unknown dataset catalog: $CATALOG_NAME" >&2; exit 2;; esac +CATALOG="$REPO_ROOT/jvector-examples/yaml-configs/ash-pq/$CATALOG_NAME" +[[ -f "$CATALOG" ]] || { echo "Dataset catalog not found: $CATALOG" >&2; exit 2; } +[[ -f "$CONFIG" && "$CONFIG_NAME" != run-* && "$CONFIG_NAME" != catalog-* && "$CONFIG_NAME" != */* ]] || { + echo "Unknown index config: $CONFIG_NAME" >&2; exit 2; +} + +if [[ "$MODE" != construction ]]; then + # The checked-in recipes put delivery under the ASH search parameters. + EXECUTION="$(sed -nE 's/^[[:space:]]+queryExecution:[[:space:]]*(direct|queued)[[:space:]]*(#.*)?$/\1/p' "$CONFIG")" + if [[ "${EXECUTION:-direct}" != "$MODE" ]]; then + echo "The index YAML selects ${EXECUTION:-direct}. Use its matching query report selection." >&2 + exit 2 + fi +fi + +shopt -s nullglob +JARS=("$REPO_ROOT"/jvector-examples/target/jvector-examples-*-jar-with-dependencies.jar) +if [[ ${#JARS[@]} != 1 ]]; then + echo "Build exactly one examples assembly with Maven; see docs/ash-pq-benchmark.md." >&2 + exit 2 +fi +JAR="${JARS[0]}" +mkdir -p "$CASE_DIRECTORY" +CASE_DIRECTORY="$(cd "$CASE_DIRECTORY" && pwd)" +CASE_ID="$(sha256sum "$CONFIG" | cut -d ' ' -f 1)" +if [[ -f "$CASE_DIRECTORY/case-id.txt" && "$(cat "$CASE_DIRECTORY/case-id.txt")" != "$CASE_ID" ]]; then + echo "Case directory belongs to a different index configuration; use its matching YAML." >&2 + exit 2 +fi +if [[ "$MODE" == construction ]]; then + for cache in compressor_cache index_cache; do + if [[ -d "$CASE_DIRECTORY/$cache" ]] && [[ -n "$(ls -A "$CASE_DIRECTORY/$cache")" ]]; then + echo "Fresh construction requires empty caches; use a new case directory." >&2 + exit 2 + fi + done +else + [[ -d "$CASE_DIRECTORY/index_cache" && -d "$CASE_DIRECTORY/compressor_cache" ]] || { + echo "Construct this case first; query runs must reuse its model and graph." >&2; exit 2; + } +fi + +# The standard loader resolves metadata/catalogs relative to the working +# directory. Install only the checked-in local mapping, avoiding ambiguous +# duplicate aliases from other dataset catalogs. Numerical code is all in the JAR. +CASE_CONFIG="$CASE_DIRECTORY/jvector-examples/yaml-configs" +mkdir -p "$CASE_CONFIG/dataset-catalogs" +cp "$REPO_ROOT/jvector-examples/yaml-configs/dataset-metadata.yml" "$CASE_CONFIG/" +cp "$REPO_ROOT/jvector-examples/yaml-configs/datasets.yml" "$CASE_CONFIG/" +cp "$CATALOG" "$CASE_CONFIG/dataset-catalogs/ash-pq-local.yml" +printf '%s\n' "$CASE_ID" > "$CASE_DIRECTORY/case-id.txt" +mkdir -p "$CASE_DIRECTORY/runs" +RUN_DIRECTORY="$(mktemp -d "$CASE_DIRECTORY/runs/$MODE-c$CORES-XXXXXX")" +cp "$CONFIG" "$RUN_DIRECTORY/index.yml" +cp "$RUN_CONFIG" "$RUN_DIRECTORY/run.yml" +cp "$CATALOG" "$RUN_DIRECTORY/catalog.yml" +printf '%s\n' "$SOURCE_REF" > "$RUN_DIRECTORY/source-ref.txt" +sha256sum "$JAR" > "$RUN_DIRECTORY/jar.sha256" +"$JAVA_BIN" -version > "$RUN_DIRECTORY/java-version.txt" 2>&1 +lscpu --extended=CPU,CORE,SOCKET,NODE,ONLINE > "$RUN_DIRECTORY/cpu-topology.txt" + +# Native query kernels execute on the Java search workers. Construction has +# its own CPU budget for training and encoding. Avoid nested query thread pools. +NATIVE_THREADS=1 +if [[ "$MODE" == construction ]]; then NATIVE_THREADS="$CORES"; fi +export MKL_NUM_THREADS="$NATIVE_THREADS" OMP_NUM_THREADS="$NATIVE_THREADS" +export OPENBLAS_NUM_THREADS="$NATIVE_THREADS" GOTO_NUM_THREADS="$NATIVE_THREADS" +export MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 + +NUMA_POLICY="${JVECTOR_BENCH_NUMA_POLICY:-bind:0}" +case "$NUMA_POLICY" in + bind:*) MEMORY_ARGS=("--membind=${NUMA_POLICY#bind:}");; + interleave:*) MEMORY_ARGS=("--interleave=${NUMA_POLICY#interleave:}");; + *) echo "JVECTOR_BENCH_NUMA_POLICY must be bind: or interleave:" >&2; exit 2;; +esac +JAVA_ARGS=(--add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED + -da -Xms2g "-Xmx${JVECTOR_BENCH_HEAP:-128g}" "-XX:ActiveProcessorCount=$CORES" + -Djvector.vectorization_provider=native -Djvector.parallel_writer=true + "-Djvector.physical_core_count=$CORES" "-Djvector.bench.queryThreads=$((4 * CORES))" + -Djvector.bench.verifyConcurrentResults=true -Djvector.bench.throughputMinSeconds=3.0 + "-Djvector.bench.sourceRef=$SOURCE_REF" "-Djvector.bench.runConfig=$RUN_CONFIG" + -cp "$JAR" io.github.jbellis.jvector.example.BenchYAML "$CONFIG") +LAUNCH=(numactl "--physcpubind=$CPU_LIST" "${MEMORY_ARGS[@]}" "$JAVA_BIN" "${JAVA_ARGS[@]}") +{ + printf 'cd %q\n' "$CASE_DIRECTORY" + printf 'JVECTOR_BENCH_DATA_DIR=%q ' "$DATA_ROOT" + printf 'MKL_NUM_THREADS=%q OMP_NUM_THREADS=%q OPENBLAS_NUM_THREADS=%q GOTO_NUM_THREADS=%q MKL_DYNAMIC=FALSE ONEDNN_VERBOSE=0 ' \ + "$MKL_NUM_THREADS" "$OMP_NUM_THREADS" "$OPENBLAS_NUM_THREADS" "$GOTO_NUM_THREADS" + printf '%q ' "${LAUNCH[@]}" + printf '\n' +} > "$RUN_DIRECTORY/command.sh" +echo "Artifacts: $RUN_DIRECTORY" +cd "$CASE_DIRECTORY" +"${LAUNCH[@]}" > "$RUN_DIRECTORY/console.log" 2>&1 & +JVM_PID=$! +printf '%s\n' "$JVM_PID" > "$RUN_DIRECTORY/jvm.pid" +trap 'kill "$JVM_PID" 2>/dev/null || true; wait "$JVM_PID" 2>/dev/null || true; exit 130' INT TERM +STATUS=0 +wait "$JVM_PID" || STATUS=$? +trap - INT TERM +cat "$RUN_DIRECTORY/console.log" +printf '%s\n' "$STATUS" > "$RUN_DIRECTORY/exit-code.txt" +if [[ "$STATUS" != 0 ]]; then exit "$STATUS"; fi +if [[ "$MODE" != construction ]] && grep -q 'Building graph index' "$RUN_DIRECTORY/console.log"; then + echo "Query invocation rebuilt an index; do not treat this as a cached-index result." >&2 + exit 1 +fi diff --git a/jvector-examples/run-ash-pq-study.py b/jvector-examples/run-ash-pq-study.py new file mode 100755 index 000000000..b1fcbc850 --- /dev/null +++ b/jvector-examples/run-ash-pq-study.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Orchestrate the checked-in standalone BenchYAML recipe; no query loop or performance measurement code.""" +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from pathlib import Path +import argparse +import collections +import csv +import hashlib +import json +import os +import re +import shutil +import signal +import statistics +import sys +import subprocess +import time + + + +def physical_cores(): + """One online hardware thread per physical core, in CPU order, as (cpu, node) pairs.""" + seen, cores = set(), [] + for line in subprocess.check_output(["lscpu", "-p=CPU,CORE,SOCKET,NODE"], text=True).splitlines(): + if line.startswith("#"): + continue + cpu, core, socket, node = (field or "0" for field in line.split(",")) + if (socket, core) not in seen: + seen.add((socket, core)) + cores.append((int(cpu), int(node))) + return cores + + +def cpu_ranges(cpus): + """Format CPU numbers as a numactl list, for example 0-7,48.""" + ranges, cpus = [], sorted(cpus) + start = end = cpus[0] + for cpu in cpus[1:] + [None]: + if cpu is not None and cpu == end + 1: + end = cpu + continue + ranges.append(str(start) if start == end else f"{start}-{end}") + if cpu is not None: + start = end = cpu + return ",".join(ranges) + + +# Defaults follow the published protocol on whatever machine runs it: construction on +# one thread of every physical core with memory interleaved across all NUMA nodes, and +# 1- and 8-core queries on the first physical cores of one node, with memory on that node. +cores = physical_cores() +query_node = cores[0][1] +query_cpus = [cpu for cpu, node in cores if node == query_node] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--output", type=Path, required=True) +parser.add_argument("--data", type=Path, required=True) +parser.add_argument("--build-cores", type=int, default=len(cores)) +parser.add_argument("--build-cpus", default=cpu_ranges([cpu for cpu, _ in cores])) +parser.add_argument("--build-nodes", default=",".join(str(n) for n in sorted({node for _, node in cores}))) +parser.add_argument("--one-core-cpu", default=str(query_cpus[0])) +parser.add_argument("--eight-core-cpus", default=cpu_ranges(query_cpus[:8]) if len(query_cpus) >= 8 else None) +parser.add_argument("--query-node", default=str(query_node)) +parser.add_argument("--build-repetitions", type=int, default=2) +parser.add_argument("--query-repetitions", type=int, default=1) +parser.add_argument("--dataset-set", choices=("1m", "large"), default="1m") +parser.add_argument("--algorithms", nargs="+", choices=("pq", "ash"), default=["pq", "ash"]) +parser.add_argument("--query-modes", nargs="+", choices=("direct", "queued"), default=["direct", "queued"]) +parser.add_argument("--plan-only", action="store_true") +args = parser.parse_args() +if min(args.build_cores, args.build_repetitions, args.query_repetitions) <= 0: + parser.error("Core and repetition counts must be positive") +if args.eight_core_cpus is None: + parser.error("NUMA node %s has fewer than 8 physical cores; pass --eight-core-cpus and --query-node" % args.query_node) +repo = Path(__file__).resolve().parent.parent +helper = repo / "jvector-examples/run-ash-pq-benchmark.sh" +source_ref = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() +jars = list((repo / "jvector-examples/target").glob("jvector-examples-*-jar-with-dependencies.jar")) +if len(jars) != 1: + parser.error("Build exactly one standalone examples assembly before starting") +jar_sha = hashlib.sha256(jars[0].read_bytes()).hexdigest() +output = args.output.resolve() +data = args.data.resolve() +if not data.is_dir(): + parser.error("Dataset directory does not exist") +jobs = [] +prefixes = ("openai3072", "mpnet", "cohere", "ada002") if args.dataset_set == "1m" else ("mpnet10m", "cohere10m", "cap6m") +for group, (prefix, bits) in enumerate((p, b) for p in prefixes for b in (2, 4)): + for repetition in range(1, args.build_repetitions + 1): + algorithms = ("pq", "ash") if (group + repetition) % 2 else ("ash", "pq") + for algorithm in algorithms: + jobs.append(dict(prefix=prefix, bits=bits, algorithm=algorithm, mode="construction", + cores=args.build_cores, cpus=args.build_cpus, repetition=repetition, + numa="interleave:" + args.build_nodes)) + for repetition in range(1, args.query_repetitions + 1): + algorithms = ("pq", "ash") if (group + repetition) % 2 else ("ash", "pq") + for cores, cpus in ((1, args.one_core_cpu), (8, args.eight_core_cpus)): + for algorithm in algorithms: + mode = "queued" if algorithm == "ash" else "direct" + jobs.append(dict(prefix=prefix, bits=bits, algorithm=algorithm, mode=mode, + cores=cores, cpus=cpus, repetition=repetition, + numa="bind:" + args.query_node)) +jobs = [job for job in jobs if job["algorithm"] in args.algorithms + and (job["mode"] == "construction" or job["mode"] in args.query_modes)] +for job in jobs: + job["id"] = "{prefix}-{bits}bit-{algorithm}-{mode}-c{cores}-r{repetition}".format(**job) + case_repetition = job["repetition"] if job["mode"] == "construction" else 1 + job["case"] = str(output / "cases" / f"{job['prefix']}-{job['bits']}bit-{job['algorithm']}-r{case_repetition}") + job["config"] = f"{job['prefix']}-{job['bits']}bit-{job['algorithm']}.yml" +watchdog_minutes = {"construction": 180 if args.dataset_set == "large" else 30, "query": 30} +plan = dict(source_ref=source_ref, jar_sha256=jar_sha, data=str(data), dataset_set=args.dataset_set, watchdog_minutes=watchdog_minutes, jobs=jobs, + construction_jobs=sum(j["mode"] == "construction" for j in jobs), + query_jobs=sum(j["mode"] != "construction" for j in jobs)) +if args.plan_only: + print(json.dumps(plan, indent=2)) + raise SystemExit(0) +output.mkdir(parents=True, exist_ok=True) + + +def save(path, value): + temp = path.with_suffix(path.suffix + ".tmp") + temp.write_text(json.dumps(value, indent=2) + "\n") + temp.replace(path) + + +if (output / "plan.json").exists(): + if json.loads((output / "plan.json").read_text()) != plan: + raise RuntimeError("Existing campaign has different source/artifact/settings; use a new output directory") +else: + save(output / "plan.json", plan) +results_file = output / "results.json" +results = json.loads(results_file.read_text()) if results_file.exists() else [] +complete_ids = {result["job"]["id"] for result in results} +process = None + + +def stop(signum, frame): + if process is not None and process.poll() is None: + process.send_signal(signal.SIGTERM) # The checked-in shell forwards it to its JVM. + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + save(output / "status.json", dict(state="stopped", completed=len(results), planned=len(jobs))) + raise SystemExit(128 + signum) + + +signal.signal(signal.SIGINT, stop) +signal.signal(signal.SIGTERM, stop) + + +def report_failure(kind, error, traceback): + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + save(output / "status.json", dict(state="failed", error=str(error), + current=globals().get("job"), completed=len(results), planned=len(jobs))) + sys.__excepthook__(kind, error, traceback) + + +sys.excepthook = report_failure + + +def cpu_set(specification): + result = set() + for item in specification.split(","): + limits = item.split("-") + first, last = int(limits[0]), int(limits[-1]) + result.update(range(first, last + 1)) + return result + + +def wait_idle(): + """Read-only check between JVMs; never gates compilation inside a benchmark.""" + while True: + conflicts = [] + for directory in Path("/proc").iterdir(): + if not directory.name.isdigit() or int(directory.name) == os.getpid(): + continue + try: + command = (directory / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace") + if "/JetBrains/" not in command and re.search( + r"BenchYAML|DistancesASH|DistancesPQ|org\.junit\.runner|surefirebooter|(?:^|/)cc1plus(?: |$)", command): + conflicts.append(int(directory.name)) + except (FileNotFoundError, PermissionError, ProcessLookupError): + pass + if not conflicts: + return + save(output / "status.json", dict(state="waiting_for_other_work", pids=conflicts, + completed=len(results), planned=len(jobs))) + time.sleep(5) + + +def summarize(): + groups = collections.defaultdict(list) + for result in results: + job = result["job"] + for row in result["rows"]: + if job["mode"] == "construction" and float(row["overquery"]) != 1: + continue # Two overquery rows describe one construction invocation. + key = (job["prefix"], job["bits"], job["algorithm"], job["mode"], job["cores"], row["overquery"]) + groups[key].append(row) + records = [] + for key, rows in sorted(groups.items()): + record = dict(zip(("dataset", "bits", "algorithm", "mode", "cores", "overquery"), key)) + record["jvms"] = len(rows) + metrics = {} + for name in rows[0]: + if not name.startswith(("construction.", "search.")): + continue + if not all(row.get(name) for row in rows): + continue + values = [float(row[name]) for row in rows] + if any(marker in name for marker in ("p999", "p95", "p99")): + metrics[name] = dict(median_of_jvm_values=statistics.median(values), + minimum=min(values), maximum=max(values)) + else: + metrics[name] = dict(mean=statistics.mean(values), + sample_sd=statistics.stdev(values) if len(values) > 1 else None, + minimum=min(values), maximum=max(values)) + record["metrics"] = metrics + records.append(record) + save(output / "summary.json", records) + + +for job in jobs: + if job["id"] in complete_ids: + continue + wait_idle() + assert subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() == source_ref, "Source changed during the campaign" + save(output / "status.json", dict(state="running", current=job, + completed=len(results), planned=len(jobs))) + case = Path(job["case"]) + before = set((case / "runs").glob("*")) if (case / "runs").exists() else set() + argv = ["bash", str(helper), job["config"], job["mode"], str(job["cores"]), job["cpus"], str(case)] + env = os.environ.copy() + env.update(JVECTOR_BENCH_DATA_DIR=str(data), JVECTOR_BENCH_NUMA_POLICY=job["numa"], + JVECTOR_BENCH_CATALOG="catalog-large.yml" if args.dataset_set == "large" else "catalog-1m.yml") + log_path = output / (job["id"] + ".log") + print("START " + job["id"], flush=True) + libraries = set() + run_directory = None + with log_path.open("w") as log: + process = subprocess.Popen(argv, cwd=repo, env=env, stdout=log, stderr=subprocess.STDOUT) + started = time.monotonic() + while process.poll() is None: + limit_minutes = watchdog_minutes["construction" if job["mode"] == "construction" else "query"] + if time.monotonic() - started > limit_minutes * 60: + process.terminate() + process.wait(timeout=15) + raise RuntimeError(f"BenchYAML exceeded the {limit_minutes}-minute process watchdog; inspect {log_path}") + if run_directory is None: + created = set((case / "runs").glob("*")) - before + if len(created) == 1: + run_directory = created.pop() + if run_directory is not None and (run_directory / "jvm.pid").exists(): + pid = (run_directory / "jvm.pid").read_text().strip() + try: + proc = Path("/proc") / pid + status = (proc / "status").read_text() + if "\nName:\tjava\n" in "\n" + status: + mask = next(line.split(":", 1)[1].strip() for line in status.splitlines() + if line.startswith("Cpus_allowed_list:")) + assert cpu_set(mask) == cpu_set(job["cpus"]), (mask, job["cpus"]) + libraries.update(line.split(maxsplit=5)[-1] for line in (proc / "maps").read_text().splitlines() + if any(token in line for token in ("libjvector", "libdnnl", "libmkl", "libopenblas", "libnetlib"))) + with (run_directory / "placement.jsonl").open("a") as placement: + placement.write(json.dumps(dict(cpu_mask=mask, libraries=sorted(libraries))) + "\n") + except (FileNotFoundError, ProcessLookupError): + pass + time.sleep(1) + if process.returncode: + save(output / "status.json", dict(state="failed", job=job, log=str(log_path), exit_code=process.returncode)) + raise RuntimeError("Standalone BenchYAML failed: " + str(log_path)) + created = set((case / "runs").glob("*")) - before + assert len(created) == 1 + run_directory = created.pop() + assert (run_directory / "source-ref.txt").read_text().strip() == source_ref + assert (run_directory / "jar.sha256").read_text().split()[0] == jar_sha, "Artifact changed during the campaign" + # A new invocation contributes exactly one CSV, identified by its run mode + # and the source reference recorded by the normal reporting system. + csvs = sorted((case / "logging").rglob("experiments.csv"), key=lambda path: path.stat().st_mtime_ns) + assert csvs + rows = list(csv.DictReader(csvs[-1].open())) + assert len(rows) == 2 and {float(row["overquery"]) for row in rows} == {1, 2} + assert all("FUSED_" + job["algorithm"].upper() in row["feature_set"] and "NVQ_VECTORS" in row["feature_set"] for row in rows) + assert any("libjvector-" in library for library in libraries), "Native provider was not observed" + if job["mode"] == "construction": + assert all(row.get("construction.index_build_time_s") for row in rows) + else: + assert all(row["query_execution"] == job["mode"] for row in rows) + assert all(row.get("search.throughput.avg_qps") and row.get("search.latency.mean_ms") for row in rows) + reference = next(result for result in results if result["job"]["prefix"] == job["prefix"] + and result["job"]["bits"] == job["bits"] and result["job"]["algorithm"] == job["algorithm"] + and result["job"]["mode"] == "construction" and result["job"]["repetition"] == 1) + actual_recall = [float(row["search.accuracy.recall_at_10"]) for row in rows] + reference_recall = [float(row["search.accuracy.recall_at_10"]) for row in reference["rows"]] + if job["algorithm"] == "ash" and job["mode"] == "queued": + # Batch GEMM may differ from singleton GEMV by FP32 reduction rounding. + # Keep the actual values; an improvement is not a regression. Stop on + # any decline for investigation rather than silently applying a tolerance. + assert all(actual >= expected for actual, expected in zip(actual_recall, reference_recall)), "Execution-policy recall regression" + else: + assert actual_recall == reference_recall, "Execution-policy recall regression" + result = dict(job=job, rows=rows, csv=str(csvs[-1]), artifacts=str(run_directory), libraries=sorted(libraries)) + results.append(result) + complete_ids.add(job["id"]) + save(results_file, results) + summarize() + if job["mode"] == "construction" and job["repetition"] != 1: + # Only these extra graphs have no future use. Keep first-build indexes, + # raw CSVs, metadata and console logs; remove no unrelated workspace. + assert case.parent == output / "cases" + for name in ("index_cache", "compressor_cache"): + shutil.rmtree(case / name) + print("PASS " + job["id"], flush=True) +save(output / "complete.json", dict(source_ref=source_ref, jar_sha256=jar_sha, + construction_jobs=plan["construction_jobs"], query_jobs=plan["query_jobs"], completed=len(results))) +save(output / "status.json", dict(state="complete", completed=len(results), planned=len(jobs))) +print("COMPLETE", flush=True) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java new file mode 100644 index 000000000..b359e4964 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodBenchmark.java @@ -0,0 +1,199 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import java.util.Random; + +/** + * DistancesASH entry point for isolated full scans through the production neighborhood scorer. + * Fixtures, the independent oracle, and the pipelined workload have separate owners. The timed + * paths include exactly one preparation per complete scan; input loading and graph I/O are untimed. + */ +final class ASHNeighborhoodBenchmark { + private static volatile double sink; + + private ASHNeighborhoodBenchmark() {} + + static void run(String[] args) throws IOException { + String filename = System.getProperty("jvector.bench.neighborhoodFixture"); + if (filename == null) + throw new IllegalArgumentException("Set -Djvector.bench.neighborhoodFixture=/path/to/fixture.ashn"); + Path path = Path.of(filename); + ASHNeighborhoodFixture fixture; + if (Files.exists(path)) + fixture = ASHNeighborhoodFixture.read(path); + else { + if (args.length != 2 && args.length != 3) + throw new IllegalArgumentException( + "Creating a fixture requires base.fvecs query.fvecs [ground-truth.ivecs]"); + fixture = ASHNeighborhoodFixture.create(args[0], args[1]); + ASHNeighborhoodFixture.write(path, fixture); + } + checkSetting("jvector.ash.bitsPerDimension", fixture.ash.bitsPerDimension); + checkSetting("jvector.ash.quantizedDimensions", fixture.ash.quantizedDim); + checkSetting("jvector.ash.landmarkCount", 1); + checkSetting("jvector.bench.neighborhoodSize", fixture.capacity); + checkSetting("jvector.bench.blockSize", fixture.blockSize); + boolean scaling = Boolean.getBoolean("jvector.bench.neighborhood-scaling"); + int threads = positive("jvector.bench.scoringThreads", 1); + if (!scaling && !Boolean.getBoolean("jvector.bench.recall") && threads != 1) + throw new IllegalArgumentException("Multiple scoring threads require neighborhood-scaling=true"); + int queryIndex = Integer.parseInt(System.getProperty("jvector.bench.queryIndex", "0")); + Objects.checkIndex(queryIndex, fixture.queries.size()); + int passes = positive("jvector.bench.scoringPasses", 5); + if (System.getProperty("jvector.bench.neighborhoodsPerQuery") != null) { + throw new IllegalArgumentException( + "A full scan scores every saved neighborhood; remove neighborhoodsPerQuery"); + } + double seconds = seconds("jvector.bench.scoringMinSeconds", .5); + double warmup = seconds("jvector.bench.scoringWarmupSeconds", 1); + String access = System.getProperty("jvector.bench.neighborhoodAccess", "sequential"); + int[] order = accessOrder(fixture.neighborhoods.length, access); + VectorFloat query = fixture.queries.get(queryIndex); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + // A requested native comparison must fail clearly rather than time the Java fallback. + if ("native".equals(System.getProperty("jvector.vectorization_provider", "").trim().toLowerCase(Locale.ROOT)) + && !"scalar".equals( + System.getProperty("jvector.ash.blockKernel", "auto").trim().toLowerCase(Locale.ROOT)) + && !backend.supportsAshNeighborhoodScoring()) { + throw new IllegalStateException( + "Native ASH comparison requested, but the library lacks the neighborhood kernel"); + } + System.out.printf(Locale.ROOT, + "Neighborhood benchmark: java=%s, C=1, singleQuery=%s, bits=%d, dimensions=%d, capacity=%d, blockSize=%d%n", + Runtime.version(), !Boolean.getBoolean("jvector.bench.recall"), fixture.ash.bitsPerDimension, + fixture.ash.quantizedDim, fixture.capacity, fixture.blockSize); + System.out.printf(Locale.ROOT, + "Fixture: sha256=%s, queries=%d, queryIndex=%d, baseVectors=%d, neighborhoods=%d, packedBytes=%d, access=%s%n", + ASHNeighborhoodFixture.sha256(path), fixture.queries.size(), queryIndex, fixture.baseCount, + order.length, (long) order.length * fixture.neighborhoods[0].length, access); + System.out.printf("Backend: %s; %s%n", backend.getClass().getSimpleName(), backend.ashKernelDescription()); + var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + System.out.println("Scorer: " + scorer); + if (Boolean.getBoolean("jvector.bench.recall")) { + ASHNeighborhoodRecall.run(fixture, threads); + return; + } + ASHNeighborhoodValidator.validate(fixture); + if (scaling) { + runPipelinedScans(fixture, query, order, threads, passes, warmup, seconds); + return; + } + System.out.println( + "Full scan: query preparation once per scan, all neighborhoods, headers, similarity conversion and checksum consumption are timed. Loading, packing, graph traversal and I/O are outside timing."); + float[] scores = new float[fixture.capacity]; + Runnable fullScan = () -> { + var current = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + double checksum = 0; + for (int n : order) { + current.score(fixture.neighborhoods[n], scores); + for (float score : scores) checksum += score; + } + sink = checksum; + }; + measure(fullScan, warmup); + System.out.println( + "mode,pass,ns_per_scan,ns_per_neighborhood,base_vectors_per_second,scored_lanes_per_second,checksum"); + for (int pass = 1; pass <= passes; pass++) { + double ns = measure(fullScan, seconds); + System.out.printf(Locale.ROOT, "full_scan,%d,%.3f,%.3f,%.3f,%.3f,%.9f%n", pass, ns, ns / order.length, + fixture.baseCount * 1e9 / ns, order.length * (double) fixture.capacity * 1e9 / ns, sink); + } + } + + private static void runPipelinedScans(ASHNeighborhoodFixture fixture, VectorFloat query, int[] order, + int threads, int passes, double warmup, double seconds) { + System.out.printf(Locale.ROOT, + "Strong scaling: workers=%d, fixedNeighborhoodsPerSweep=%d, fixedScoredLanesPerSweep=%d; one shared query, disjoint neighborhood partitions%n", + threads, order.length, (long) order.length * fixture.capacity); + System.out.println( + "Pipelined full scans: prepare each query once while workers score preceding scans. At most two scans are in flight. Preparation, submission, completion, checksum consumption, fill and drain are timed. Persistent-worker creation is outside timing; all threads share the stated CPU affinity."); + try (var workload = new ASHNeighborhoodPipeline(fixture, order, threads)) { + workload.measure(query, warmup); + System.out.println( + "workers,pass,ns_per_scan,ns_per_neighborhood,base_vectors_per_second,scored_lanes_per_second,ns_preparation_per_scan,checksum"); + for (int pass = 1; pass <= passes; pass++) { + ASHNeighborhoodPipeline.Timing timing = workload.measure(query, seconds); + double nanos = timing.nanosPerScan; + System.out.printf(Locale.ROOT, "%d,%d,%.3f,%.3f,%.3f,%.3f,%.3f,%.9f%n", threads, pass, nanos, + nanos / order.length, fixture.baseCount * 1e9 / nanos, + order.length * (double) fixture.capacity * 1e9 / nanos, timing.preparationNanosPerScan, + timing.checksum); + } + } + } + + private static double measure(Runnable task, double seconds) { + long start = System.nanoTime(), elapsed; + long count = 0; + do { + task.run(); + count++; + elapsed = System.nanoTime() - start; + } while (elapsed < seconds * 1e9); + return (double) elapsed / count; + } + + static int[] accessOrder(int count, String access) { + if (!access.equals("sequential") && !access.equals("shuffled")) + throw new IllegalArgumentException("neighborhoodAccess must be sequential or shuffled"); + int[] order = new int[count]; + for (int i = 0; i < count; i++) order[i] = i; + if (access.equals("shuffled")) { + Random random = new Random(87342); + for (int i = count - 1; i > 0; i--) { + int j = random.nextInt(i + 1), swap = order[i]; + order[i] = order[j]; + order[j] = swap; + } + } + return order; + } + + static int positive(String name, int fallback) { + int value; + try { + value = Integer.parseInt(System.getProperty(name, Integer.toString(fallback))); + } catch (NumberFormatException error) { + throw new IllegalArgumentException(name + " must be an integer", error); + } + if (value <= 0) + throw new IllegalArgumentException(name + " must be positive"); + return value; + } + + private static double seconds(String name, double fallback) { + double value = Double.parseDouble(System.getProperty(name, Double.toString(fallback))); + if (!Double.isFinite(value) || value <= 0) + throw new IllegalArgumentException(name + " must be finite and positive"); + return value; + } + + static void checkSetting(String name, int expected) { + String value = System.getProperty(name); + if (value != null && Integer.parseInt(value) != expected) + throw new IllegalArgumentException(name + " disagrees with saved fixture: " + expected); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java new file mode 100644 index 000000000..2198640b0 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodFixture.java @@ -0,0 +1,312 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.disk.ByteBufferIndexWriter; +import io.github.jbellis.jvector.disk.ByteBufferReader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Untimed fixture creation and replay for the production ASH neighborhood layout. + * + *

Version 1 stores big-endian ints/floats: magic, version, capacity, block width, base count, + * model byte count and model, query count and original-dimensional queries, then neighborhood + * count, bytes per neighborhood, and packed payloads. fvecs input remains little-endian. + * Model metadata and lengths are checked before allocating dimensions or payload arrays.

+ * + *

Fixture bytes and query vectors are owned by the fixture and treated as immutable during + * replay. Queries are normalized on creation, matching the inputs passed to the graph scorer. + * The final neighborhood is padded to capacity and remains part of the scored work.

+ */ +final class ASHNeighborhoodFixture { + private static final int MAGIC = 0x4153484e; + private static final int VERSION = 1; + private static final int MAX_FVECS_DIMENSION = 100_000; + private static final int FIXED_BYTES = 9 * Integer.BYTES; + + final AsymmetricHashing ash; + final List> queries; + final byte[][] neighborhoods; + final int capacity; + final int blockSize; + final int baseCount; + + ASHNeighborhoodFixture(AsymmetricHashing ash, List> queries, byte[][] neighborhoods, int capacity, + int blockSize, int baseCount) { + this.ash = Objects.requireNonNull(ash, "ash"); + if (ash.landmarkCount != 1) { + throw new IllegalArgumentException("Neighborhood benchmark requires C=1"); + } + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + FusedASHLayout.validateBlockSize(blockSize); + if (capacity < 1 || baseCount < 1 || queries.isEmpty()) { + throw new IllegalArgumentException("Empty fixture"); + } + if (neighborhoods.length != (baseCount - 1) / capacity + 1) { + throw new IllegalArgumentException("Neighborhood count mismatch"); + } + int bytes = FusedASHLayout.featureSize(capacity, ash.quantizedDim, ash.bitsPerDimension, blockSize); + for (byte[] neighborhood : neighborhoods) { + if (neighborhood.length != bytes) { + throw new IllegalArgumentException("Payload size mismatch"); + } + } + for (VectorFloat query : queries) { + if (query.length() != ash.originalDimension) { + throw new IllegalArgumentException("Query dimension mismatch"); + } + } + this.queries = List.copyOf(queries); + this.neighborhoods = neighborhoods; + this.capacity = capacity; + this.blockSize = blockSize; + this.baseCount = baseCount; + } + + static ASHNeighborhoodFixture create(String baseFile, String queryFile) throws IOException { + int capacity = ASHNeighborhoodBenchmark.positive("jvector.bench.neighborhoodSize", 32); + int blockSize = + ASHNeighborhoodBenchmark.positive("jvector.bench.blockSize", FusedASHLayout.chooseBlockSize(capacity)); + FusedASHLayout.validateBlockSize(blockSize); + ASHNeighborhoodBenchmark.checkSetting("jvector.ash.landmarkCount", 1); + List> base = + readFvecs(baseFile, ASHNeighborhoodBenchmark.positive("jvector.bench.maxVectors", 8192)); + List> queries = + readFvecs(queryFile, ASHNeighborhoodBenchmark.positive("jvector.bench.maxQueries", 8)); + if (base.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Empty vector input"); + } + int dimensions = base.get(0).length(); + for (VectorFloat query : queries) { + if (query.length() != dimensions) { + throw new IllegalArgumentException("Base/query dimensions differ"); + } + VectorUtil.l2normalize(query); + } + int bits = ASHNeighborhoodBenchmark.positive("jvector.ash.bitsPerDimension", 4); + FusedASHLayout.validateBitsPerDimension(bits); + int projected = ASHNeighborhoodBenchmark.positive("jvector.ash.quantizedDimensions", dimensions / 2); + if (projected > dimensions) { + throw new IllegalArgumentException("Projected dimensions exceed original dimensions"); + } + var values = new ListRandomAccessVectorValues(base, dimensions); + var ash = AsymmetricHashing.initialize(values, DistancesASH.parseOptimizerFromProperty(), + Math.addExact(AsymmetricHashing.HEADER_BITS, Math.multiplyExact(projected, bits)), 1, bits); + ASHVectors encoded = (ASHVectors) ash.encodeAll(values); + int count = (base.size() - 1) / capacity + 1; + byte[][] packed = new byte[count][FusedASHLayout.featureSize(capacity, projected, bits, blockSize)]; + for (int i = 0; i < base.size(); i++) { + int neighbor = i % capacity; + int offset = FusedASHLayout.blockOffset(neighbor / blockSize, projected, bits, blockSize); + FusedASHLayout.packForFused( + packed[i / capacity], offset, neighbor % blockSize, encoded.get(i), ash, blockSize); + } + return new ASHNeighborhoodFixture(ash, queries, packed, capacity, blockSize, base.size()); + } + + /** Read only the requested prefix, rejecting truncated, non-finite, or inconsistent vectors. */ + static List> readFvecs(String filename, int limit) throws IOException { + if (limit < 1) { + throw new IllegalArgumentException("Vector limit must be positive"); + } + List> result = new ArrayList<>(); + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(Path.of(filename))))) { + while (result.size() < limit) { + int first = input.read(); + if (first == -1) { + break; + } + int dimensions = first | input.readUnsignedByte() << 8 | input.readUnsignedByte() << 16 + | input.readUnsignedByte() << 24; + if (dimensions < 1 || dimensions > MAX_FVECS_DIMENSION + || (!result.isEmpty() && dimensions != result.get(0).length())) { + throw new IOException("Invalid fvecs dimension: " + dimensions); + } + float[] vector = new float[dimensions]; + for (int i = 0; i < dimensions; i++) { + vector[i] = Float.intBitsToFloat(Integer.reverseBytes(input.readInt())); + if (!Float.isFinite(vector[i])) { + throw new IOException("Non-finite fvecs value at row " + result.size() + ", dimension " + i); + } + } + result.add(types.createFloatVector(vector)); + } + } + return result; + } + + /** Publish a complete fixture; a failed write or existing destination never leaves a partial file. */ + static void write(Path path, ASHNeighborhoodFixture fixture) throws IOException { + Path absolute = path.toAbsolutePath(); + Path temporary = Files.createTempFile(absolute.getParent(), ".ash-neighborhood-", ".tmp"); + try { + writeComplete(temporary, fixture); + Files.move(temporary, absolute); // No REPLACE_EXISTING: preserve existing fixture inputs. + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void writeComplete(Path path, ASHNeighborhoodFixture fixture) throws IOException { + try (var model = ByteBufferIndexWriter.create(fixture.ash.compressorSize(), false)) { + fixture.ash.write(model, OnDiskGraphIndex.CURRENT_VERSION); + ByteBuffer buffer = model.getWrittenData(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + try (var out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(path)))) { + out.writeInt(MAGIC); + out.writeInt(VERSION); + out.writeInt(fixture.capacity); + out.writeInt(fixture.blockSize); + out.writeInt(fixture.baseCount); + out.writeInt(bytes.length); + out.write(bytes); + out.writeInt(fixture.queries.size()); + for (VectorFloat query : fixture.queries) { + for (int d = 0; d < query.length(); d++) { + out.writeFloat(query.get(d)); + } + } + out.writeInt(fixture.neighborhoods.length); + out.writeInt(fixture.neighborhoods[0].length); + for (byte[] block : fixture.neighborhoods) { + out.write(block); + } + } + } + } + + static ASHNeighborhoodFixture read(Path path) throws IOException { + long fileBytes = Files.size(path); + try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) { + if (input.readInt() != MAGIC || input.readInt() != VERSION) { + throw new IOException("Invalid neighborhood fixture header"); + } + int capacity = input.readInt(); + int blockSize = input.readInt(); + int baseCount = input.readInt(); + int modelSize = input.readInt(); + if (capacity < 1 || baseCount < 1 || modelSize < 1 || modelSize > fileBytes - FIXED_BYTES) { + throw new IOException("Invalid fixture sizes"); + } + FusedASHLayout.validateBlockSize(blockSize); + byte[] model = new byte[modelSize]; + input.readFully(model); + validateModelEnvelope(model); + var modelBuffer = ByteBuffer.wrap(model).order(ByteOrder.BIG_ENDIAN); + var ash = AsymmetricHashing.load(new ByteBufferReader(modelBuffer)); + if (modelBuffer.hasRemaining()) { + throw new IOException("Trailing bytes in fixture model"); + } + FusedASHLayout.validateBitsPerDimension(ash.bitsPerDimension); + int queries = input.readInt(); + long queryBytes = (long) queries * ash.originalDimension * Float.BYTES; + long payloadStart = FIXED_BYTES + (long) modelSize + queryBytes; + if (queries < 1 || queryBytes < 0 || payloadStart > fileBytes) { + throw new IOException("Invalid query count"); + } + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + List> vectors = new ArrayList<>(); + for (int i = 0; i < queries; i++) { + float[] query = new float[ash.originalDimension]; + for (int d = 0; d < query.length; d++) { + query[d] = input.readFloat(); + if (!Float.isFinite(query[d])) { + throw new IOException("Non-finite fixture query value"); + } + } + vectors.add(types.createFloatVector(query)); + } + int count = input.readInt(); + int bytes = input.readInt(); + int expected = FusedASHLayout.featureSize(capacity, ash.quantizedDim, ash.bitsPerDimension, blockSize); + if (count != (baseCount - 1) / capacity + 1 || bytes != expected + || payloadStart + (long) count * bytes != fileBytes) { + throw new IOException("Invalid packed payload sizes or trailing bytes"); + } + byte[][] blocks = new byte[count][bytes]; + for (byte[] block : blocks) { + input.readFully(block); + } + return new ASHNeighborhoodFixture(ash, vectors, blocks, capacity, blockSize, baseCount); + } catch (IllegalArgumentException | ArithmeticException | BufferUnderflowException error) { + throw new IOException("Invalid ASH neighborhood fixture: " + path, error); + } + } + + private static void validateModelEnvelope(byte[] model) throws IOException { + // Fixture V1 embeds ASH's eight-int prefix, one landmark, its row-major transform, and the largest + // encoded vector length. Check declared dimensions against the bytes before ASH.load allocates model arrays. + int fixedModelBytes = 11 * Integer.BYTES + Float.BYTES; + if (model.length < fixedModelBytes) { + throw new IOException("Truncated fixture model"); + } + ByteBuffer header = ByteBuffer.wrap(model).order(ByteOrder.BIG_ENDIAN); + int original = header.getInt(2 * Integer.BYTES); + int projected = header.getInt(4 * Integer.BYTES); + int landmarks = header.getInt(7 * Integer.BYTES); + long floats = (long) original * (projected + 1L); + if (original < 1 || projected < 1 || projected > original || landmarks != 1 + || floats > (model.length - fixedModelBytes) / Float.BYTES + || fixedModelBytes + floats * Float.BYTES != model.length) { + throw new IOException("Invalid C=1 fixture model dimensions"); + } + } + + static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (var input = new DigestInputStream(Files.newInputStream(path), digest)) { + input.transferTo(OutputStream.nullOutputStream()); + } + byte[] hash = digest.digest(); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte value : hash) { + hex.append(Character.forDigit((value >>> 4) & 0xF, 16)); + hex.append(Character.forDigit(value & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException error) { + throw new AssertionError("SHA-256 is required by the Java platform", error); + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodPipeline.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodPipeline.java new file mode 100644 index 000000000..05bd1f132 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodPipeline.java @@ -0,0 +1,208 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.ArrayDeque; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Bounded benchmark pipeline: one coordinator prepares queries while a persistent pool scores. + * + *

Submit, measure and close belong to the coordinator thread. At most two scans are submitted + * by {@link #measure}; every scan owns its prepared query and every pool thread owns its scratch. + * Timing includes preparation, scheduling, score consumption, pipeline fill and complete drain. + * Average elapsed time per completed scan is a throughput metric, not per-query latency.

+ */ +final class ASHNeighborhoodPipeline implements AutoCloseable { + private static final int MAX_IN_FLIGHT = 2; + private static volatile double sink; + private final ASHNeighborhoodFixture fixture; + private final int[] order; + private final int threads; + private final ThreadPoolExecutor pool; + private final ThreadLocal output; + private long preparationNanos; + + ASHNeighborhoodPipeline(ASHNeighborhoodFixture fixture, int[] order, int threads) { + if (threads < 1 || threads > order.length) + throw new IllegalArgumentException("Worker count must be in [1, neighborhood count]"); + this.fixture = Objects.requireNonNull(fixture, "fixture"); + validateOrder(order, fixture.neighborhoods.length); + this.order = order.clone(); + this.threads = threads; + this.output = ThreadLocal.withInitial(() -> new float[fixture.capacity]); + var workerIds = new AtomicInteger(); + pool = (ThreadPoolExecutor) Executors.newFixedThreadPool(threads, task -> { + var worker = new Thread(task, "ash-neighborhood-" + workerIds.incrementAndGet()); + worker.setDaemon(true); // An aborted benchmark must not leave its process alive. + return worker; + }); + pool.prestartAllCoreThreads(); + } + + /** Prepare exactly once, then publish the immutable scorer with each partition task. */ + Scan submit(VectorFloat query) { + if (pool.isShutdown()) { + throw new IllegalStateException("ASH pipeline is closed"); + } + long start = System.nanoTime(); + var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + preparationNanos += System.nanoTime() - start; + var scan = new Scan(threads); + for (int worker = 0; worker < threads; worker++) { + final int slot = worker; + final int first = (int) ((long) worker * order.length / threads); + final int last = (int) ((long) (worker + 1) * order.length / threads); + var partition = new FutureTask(() -> { + float[] scores = output.get(); + double checksum = 0; + for (int n = first; n < last; n++) { + scorer.score(fixture.neighborhoods[order[n]], scores); + for (float score : scores) + checksum += score; + } + scan.checksums[slot] = checksum; + return null; + }) { + @Override + protected void done() { + // Also complete the scan when shutdown cancels a queued partition. + try { + get(); // Already terminal: no waiting here. + } catch (ExecutionException error) { + scan.failure.compareAndSet(null, error.getCause()); + } catch (CancellationException error) { + scan.failure.compareAndSet(null, error); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + scan.failure.compareAndSet(null, error); + } finally { + scan.done.countDown(); + } + } + }; + pool.execute(partition); + } + return scan; + } + + @Override + public void close() { + pool.shutdown(); + boolean interrupted = false; + try { + while (!pool.isTerminated()) { + try { + pool.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException error) { + interrupted = true; + for (Runnable queued : pool.shutdownNow()) { + ((Future) queued).cancel(false); + } + } + } + } finally { + if (interrupted) + Thread.currentThread().interrupt(); + } + } + + private static void validateOrder(int[] order, int count) { + if (order.length != count) { + throw new IllegalArgumentException("A full scan must include every neighborhood exactly once"); + } + boolean[] seen = new boolean[count]; + for (int index : order) { + if (index < 0 || index >= count || seen[index]) { + throw new IllegalArgumentException("Neighborhood order must be a permutation"); + } + seen[index] = true; + } + } + + /** Completion state for one full scan; the latch publishes each partition's checksum. */ + static final class Scan { + private final CountDownLatch done; + private final double[] checksums; + private final AtomicReference failure = new AtomicReference<>(); + + Scan(int threads) { + done = new CountDownLatch(threads); + checksums = new double[threads]; + } + + double await() { + try { + done.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while scoring", e); + } + if (failure.get() != null) + throw new IllegalStateException("Neighborhood scoring worker failed", failure.get()); + double sum = 0; + for (double checksum : checksums) + sum += checksum; + return sum; + } + } + + static final class Timing { + final double nanosPerScan, preparationNanosPerScan, checksum; + Timing(double nanos, double preparation, double checksum) { + this.nanosPerScan = nanos; + this.preparationNanosPerScan = preparation; + this.checksum = checksum; + } + } + + Timing measure(VectorFloat query, double seconds) { + var pending = new ArrayDeque(MAX_IN_FLIGHT); + long preparationStart = preparationNanos; + long start = System.nanoTime(); + long completed = 0; + double checksum = 0; + do { + if (pending.size() == MAX_IN_FLIGHT) { + checksum += pending.removeFirst().await(); + completed++; + } + // Preparation of this scan overlaps scoring of earlier submitted scans. + pending.addLast(submit(query)); + } while (System.nanoTime() - start < seconds * 1e9); + while (!pending.isEmpty()) { + checksum += pending.removeFirst().await(); + completed++; + } + long elapsed = System.nanoTime() - start; + sink = checksum / completed; + return new Timing( + (double) elapsed / completed, (double) (preparationNanos - preparationStart) / completed, sink); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodRecall.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodRecall.java new file mode 100644 index 000000000..484bd3699 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodRecall.java @@ -0,0 +1,254 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.PriorityQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Exact full-scan recall@10 evaluation through the production neighborhood scorer. + * + *

Each query is prepared once and scores every serialized neighborhood. Only real base + * ordinals enter top-k; padded lanes are scored but excluded from ranking. Ties prefer the + * smaller original ordinal in every backend. Query parallelism changes neither the scan work + * nor its reduction order. This is an accuracy check, not a kernel throughput benchmark.

+ */ +final class ASHNeighborhoodRecall { + static final int K = 10; + + private ASHNeighborhoodRecall() {} + + static void run(ASHNeighborhoodFixture fixture, int threads) throws IOException { + String groundTruthName = System.getProperty("jvector.bench.recall.groundTruth"); + if (groundTruthName == null) { + throw new IllegalArgumentException("Set jvector.bench.recall.groundTruth to the matching .ivecs file"); + } + int[][] truth = readGroundTruth(Path.of(groundTruthName), fixture.queries.size(), fixture.baseCount); + String outputName = System.getProperty("jvector.bench.recall.output"); + if (outputName != null && Files.exists(Path.of(outputName))) { + throw new IOException("Recall output already exists: " + outputName); + } + // Keep independent component validation bounded; recall itself uses every saved query. + var validation = + new ASHNeighborhoodFixture(fixture.ash, fixture.queries.subList(0, Math.min(4, fixture.queries.size())), + fixture.neighborhoods, fixture.capacity, fixture.blockSize, fixture.baseCount); + ASHNeighborhoodValidator.validate(validation); + System.out.printf(Locale.ROOT, + "Full-scan recall@10: queries=%d baseVectors=%d neighborhoods=%d queryWorkers=%d; prepare once per query; no reranking; padding excluded from top-k%n", + fixture.queries.size(), fixture.baseCount, fixture.neighborhoods.length, threads); + var pool = Executors.newFixedThreadPool(threads); + List results = new ArrayList<>(fixture.queries.size()); + try { + var finished = new AtomicInteger(); + List> tasks = new ArrayList<>(fixture.queries.size()); + for (int q = 0; q < fixture.queries.size(); q++) { + final int query = q; + tasks.add(() -> { + Result result = scan(fixture, query, truth[query]); + int count = finished.incrementAndGet(); + if (count % 100 == 0 || count == fixture.queries.size()) { + System.out.printf(Locale.ROOT, "Recall progress: %d/%d queries complete%n", count, + fixture.queries.size()); + } + return result; + }); + } + int completed = 0; + for (var future : pool.invokeAll(tasks)) { + results.add(future.get()); + completed++; + } + if (completed != fixture.queries.size()) { + throw new AssertionError("Incomplete full-scan recall"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted during full-scan recall", error); + } catch (ExecutionException error) { + throw new IllegalStateException("Full-scan recall worker failed", error.getCause()); + } finally { + pool.shutdownNow(); + boolean interrupted = false; + try { + while (!pool.isTerminated()) { + try { + pool.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException error) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + long hits = 0; + for (var result : results) { + hits += result.hits; + } + if (outputName != null) { + try (var output = Files.newBufferedWriter( + Path.of(outputName), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + output.write("query\thits_at_10\tids_best_first\tscores_best_first\n"); + for (var result : results) { + output.write(result.query + "\t" + result.hits + "\t" + Arrays.toString(result.ids) + "\t" + + Arrays.toString(result.scores) + "\n"); + } + } + } + System.out.printf(Locale.ROOT, + "NEIGHBORHOOD_RECALL queries=%d baseVectors=%d hits=%d denominator=%d recall_at_10=%.9f%n", + results.size(), fixture.baseCount, hits, (long) K * results.size(), + hits / ((double) K * results.size())); + } + + /** Scan exactly once; a worker owns the scorer, scratch, and top-k state for its query. */ + static Result scan(ASHNeighborhoodFixture fixture, int query, int[] truth) { + var scorer = + new ASHNeighborhoodScorer(fixture.ash, fixture.queries.get(query), fixture.capacity, fixture.blockSize); + float[] scores = new float[fixture.capacity]; + var top = new TopK(); + for (int n = 0; n < fixture.neighborhoods.length; n++) { + if (Thread.currentThread().isInterrupted()) { + throw new IllegalStateException("Recall scan cancelled"); + } + scorer.score(fixture.neighborhoods[n], scores); + int base = n * fixture.capacity; + int count = Math.min(fixture.capacity, fixture.baseCount - base); + for (int lane = 0; lane < count; lane++) { + float score = scores[lane]; + if (!Float.isFinite(score)) { + throw new IllegalStateException( + "Non-finite recall score at query=" + query + " ordinal=" + (base + lane)); + } + top.offer(score, base + lane); + } + } + Candidate[] ranked = top.ranked(); + int[] ids = new int[K]; + float[] bestScores = new float[K]; + int hits = 0; + for (int rank = 0; rank < K; rank++) { + ids[rank] = ranked[rank].ordinal; + bestScores[rank] = ranked[rank].score; + for (int expected : truth) { + if (ids[rank] == expected) { + hits++; + break; + } + } + } + return new Result(query, ids, bestScores, hits); + } + + /** Read little-endian ivecs; top ten must be unique, in-range original base ordinals. */ + static int[][] readGroundTruth(Path path, int queries, int baseCount) throws IOException { + if (queries < 1 || baseCount < K) { + throw new IllegalArgumentException("Recall@10 needs queries and at least ten real base vectors"); + } + int[][] result = new int[queries][K]; + try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) { + for (int q = 0; q < queries; q++) { + int width = Integer.reverseBytes(input.readInt()); + if (width < K || width > 100_000) { + throw new IOException("Invalid ground-truth width: " + width); + } + for (int rank = 0; rank < width; rank++) { + int id = Integer.reverseBytes(input.readInt()); + if (id < 0 || id >= baseCount) { + throw new IOException("Ground-truth ordinal outside base set: " + id); + } + if (rank < K) { + for (int prior = 0; prior < rank; prior++) { + if (result[q][prior] == id) { + throw new IOException("Duplicate top-ten ground-truth ordinal at query " + q); + } + } + result[q][rank] = id; + } + } + } + } + return result; + } + + static final class Result { + final int query; + final int[] ids; + final float[] scores; + final int hits; + + Result(int query, int[] ids, float[] scores, int hits) { + this.query = query; + this.ids = ids; + this.scores = scores; + this.hits = hits; + } + } + + static final class Candidate { + final float score; + final int ordinal; + + Candidate(float score, int ordinal) { + this.score = score; + this.ordinal = ordinal; + } + } + + /** Worst retained candidate first; equal scores evict the largest ordinal first. */ + static final class TopK { + private static final Comparator WORST_FIRST = (a, b) -> { + int comparison = Float.compare(a.score, b.score); + return comparison != 0 ? comparison : Integer.compare(b.ordinal, a.ordinal); + }; + private final PriorityQueue heap = new PriorityQueue<>(K, WORST_FIRST); + + void offer(float score, int ordinal) { + Candidate worst = heap.peek(); + if (heap.size() < K) { + heap.add(new Candidate(score, ordinal)); + } else if (score > worst.score || (score == worst.score && ordinal < worst.ordinal)) { + heap.poll(); + heap.add(new Candidate(score, ordinal)); + } + } + + Candidate[] ranked() { + Candidate[] ranked = heap.toArray(new Candidate[0]); + Arrays.sort(ranked, WORST_FIRST.reversed()); + return ranked; + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java new file mode 100644 index 000000000..796c73e7d --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ASHNeighborhoodValidator.java @@ -0,0 +1,102 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.quantization.ASHScorer; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import java.util.Locale; + +/** + * Untimed decoded-component oracle, independent of the scoring LUT and block-scoring implementation. + * It uses the model's selected query precision and checks the serialized graph headers. + * Projection arithmetic is covered separately by the provider's FP32 and FP16 oracle tests. + * Offset-binary (3 and 5 to 9 bit) neighborhoods score an INT8-rounded query, which the oracle applies too. + * The tolerance permits FP32 reduction-order differences, without introducing LUT quantization. + */ +final class ASHNeighborhoodValidator { + private ASHNeighborhoodValidator() {} + + static void validate(ASHNeighborhoodFixture fixture) { + double maximum = 0; + int neighborhoods = Math.min(fixture.neighborhoods.length, + ASHNeighborhoodBenchmark.positive("jvector.bench.validationNeighborhoods", 1024)); + for (var query : fixture.queries) { + var scorer = new ASHNeighborhoodScorer(fixture.ash, query, fixture.capacity, fixture.blockSize); + // Share the query precision, then independently decode and accumulate + // components in double precision instead of using the nibble LUT kernel. + float[] input = new float[query.length()]; + double dotMu = 0; + for (int d = 0; d < input.length; d++) { + input[d] = query.get(d); + dotMu += (double) input[d] * fixture.ash.landmarks[0].get(d); + } + float[] projected = new float[fixture.ash.quantizedDim]; + fixture.ash.stiefelTransform.projectQuery(input, projected); + if (FusedASHLayout.usesFieldBlocks(fixture.ash.bitsPerDimension)) { + // Offset-binary neighborhoods score an INT8 query: round to 127 / max|Aq| steps, as the format does. + float max = 0f; + for (float v : projected) max = Math.max(max, Math.abs(v)); + if (max > 0f) { + for (int d = 0; d < projected.length; d++) { + projected[d] = Math.max(-127, Math.min(127, Math.round(projected[d] * (127f / max)))) * (max / 127f); + } + } + } + float[] scores = new float[fixture.capacity]; + for (int n = 0; n < neighborhoods; n++) { + int index = (int) ((long) n * fixture.neighborhoods.length / neighborhoods); + byte[] packed = fixture.neighborhoods[index]; + scorer.score(packed, scores); + for (int i = 0; i < fixture.capacity; i++) { + int offset = FusedASHLayout.blockOffset( + i / fixture.blockSize, projected.length, fixture.ash.bitsPerDimension, fixture.blockSize); + int lane = i % fixture.blockSize, bits = fixture.ash.bitsPerDimension; + double dot = 0; + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(projected.length, bits)]; + FusedASHLayout.readLaneCode(packed, offset, lane, projected.length, bits, fixture.blockSize, code); + for (int d = 0; d < projected.length; d++) { + // Field d is bits [d*b, (d+1)*b) of the lane's canonical code. + int field = 0; + for (int k = 0; k < bits; k++) { + int bit = d * bits + k; + field |= ((code[bit >>> 3] >>> (bit & 7)) & 1) << k; + } + dot += (double) projected[d] * (bits == 1 || bits == 2 || bits == 4 + ? FusedASHLayout.decodeProjectionComponent(field, bits) + : field - ((1 << bits) - 1) / 2.0); + } + float expected = ASHScorer.toSimilarity((float) (FusedASHLayout.readScale(packed, offset, lane, + projected.length, bits, fixture.blockSize) + * dot + + dotMu + + FusedASHLayout.readOffset( + packed, offset, lane, projected.length, bits, fixture.blockSize))); + double error = Math.abs(scores[i] - expected); + if (!Float.isFinite(expected) || !Float.isFinite(scores[i]) + || error > 3e-5 + Math.abs(expected) * 3e-5) + throw new AssertionError("Neighborhood score mismatch at " + index + "/" + i + ": " + scores[i] + + " vs " + expected); + maximum = Math.max(maximum, error); + } + } + } + System.out.printf(Locale.ROOT, + "Validated %d queries x %d neighborhoods against decoded-component oracle; maxAbsError=%.9g%n", + fixture.queries.size(), neighborhoods, maximum); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java index 2d48791bc..d81ad3bb1 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/AutoBenchYAML.java @@ -24,6 +24,7 @@ import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; import io.github.jbellis.jvector.example.yaml.DatasetCollection; import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,9 +53,15 @@ */ public class AutoBenchYAML { private static final Logger logger = LoggerFactory.getLogger(AutoBenchYAML.class); - private static final String REGRESSION_TEST_KEY = "regression-tests"; + private static final String REGRESSION_TEST_KEY = DatasetCollection.REGRESSION_TESTS; public static void main(String[] args) throws IOException { + try (var projectionSetting = new SearchParameters.AshQueryProjectionSetting()) { + run(args, projectionSetting); + } + } + + private static void run(String[] args, SearchParameters.AshQueryProjectionSetting projectionSetting) throws IOException { // Check for --output argument (required for this class) String outputPath = null; for (int i = 0; i < args.length - 1; i++) { @@ -114,32 +121,30 @@ public static void main(String[] args) throws IOException { // Add results from checkpoint if present results.addAll(checkpointManager.getCompletedResults()); - // Process datasets from regex patterns - if (!datasetNames.isEmpty()) { - for (var datasetName : datasetNames) { - // Skip already completed datasets - if (checkpointManager.isDatasetCompleted(datasetName)) { - logger.info("Skipping already completed dataset: {}", datasetName); - continue; - } + // Resolve the process-wide ASH projection before any regression builds start. + var configs = new LinkedHashMap(); + for (var datasetName : datasetNames) { + if (checkpointManager.isDatasetCompleted(datasetName)) { + logger.info("Skipping already completed dataset: {}", datasetName); + continue; + } + try { + var name = datasetName.endsWith(".hdf5") + ? datasetName.substring(0, datasetName.length() - ".hdf5".length()) : datasetName; + var config = finalConfigPath == null ? MultiConfig.getDefaultConfig(name) : MultiConfig.getConfig(finalConfigPath); + if (finalConfigPath == null || config.dataset == null || config.dataset.isEmpty()) config.dataset = name; + configs.put(datasetName, config); + } catch (Exception e) { + logger.error("Exception while processing dataset {}", datasetName, e); + } + } + projectionSetting.apply(configs.values().stream().filter(MultiConfig::usesSchema3).collect(Collectors.toList())); + if (!configs.isEmpty()) { + for (var entry : configs.entrySet()) { + var datasetName = entry.getKey(); + var config = entry.getValue(); try { - String normalizedDatasetName = datasetName; - if (normalizedDatasetName.endsWith(".hdf5")) { - normalizedDatasetName = normalizedDatasetName.substring(0, normalizedDatasetName.length() - ".hdf5".length()); - } - - MultiConfig config; - if (finalConfigPath != null) { - config = MultiConfig.getConfig(finalConfigPath); - // Override dataset name if not specified in custom config - if (config.dataset == null || config.dataset.isEmpty()) { - config.dataset = normalizedDatasetName; - } - } else { - config = MultiConfig.getDefaultConfig(normalizedDatasetName); - config.dataset = normalizedDatasetName; - } logger.info("Using configuration: {}", config); var loading = BaseVectorLoading.forBenchmark(config); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java index 8eb6ee09f..769787a70 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BenchYAML.java @@ -23,6 +23,7 @@ import io.github.jbellis.jvector.example.yaml.DatasetCollection; import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; import java.io.IOException; import java.security.InvalidParameterException; @@ -36,6 +37,12 @@ public class BenchYAML { public static void main(String[] args) throws IOException { + try (var projectionSetting = new SearchParameters.AshQueryProjectionSetting()) { + run(args, projectionSetting); + } + } + + private static void run(String[] args, SearchParameters.AshQueryProjectionSetting projectionSetting) throws IOException { // args is one of: // - a list of regexes, possibly needing to be split by whitespace. // - a list of YAML files @@ -45,17 +52,26 @@ public static void main(String[] args) throws IOException { } Grid.configureWriterForBenchmark(); + // A file path is not a dataset regular expression (and may contain + // spaces, brackets or platform-specific path separators). + List configNames = Arrays.stream(args).filter(Objects::nonNull) + .filter(s -> s.endsWith(".yml")).collect(Collectors.toList()); + String regex = Arrays.stream(args) .filter(Objects::nonNull) + .filter(s -> !s.endsWith(".yml")) .flatMap(s -> Arrays.stream(s.split("\\s"))) .filter(s -> !s.isEmpty()) .collect(Collectors.joining("|")); var pattern = Pattern.compile(regex.isEmpty() ? ".*" : regex); - System.out.println("Heap space available is " + Runtime.getRuntime().maxMemory()); + System.out.printf(java.util.Locale.ROOT, "Maximum heap: %.1f GiB%n", Runtime.getRuntime().maxMemory() / 1073741824.0); var datasetCollection = DatasetCollection.load(); - var datasetNames = datasetCollection.getAll().stream().filter(dn -> pattern.matcher(dn).find()).collect(Collectors.toList()); + var datasetNames = regex.isEmpty() && !configNames.isEmpty() ? List.of() + : regex.isEmpty() + ? datasetCollection.getAllExcept(DatasetCollection.REGRESSION_TESTS) + : datasetCollection.getAll().stream().filter(dn -> pattern.matcher(dn).find()).collect(Collectors.toList()); List allConfigs = new ArrayList<>(); @@ -68,19 +84,13 @@ public static void main(String[] args) throws IOException { String name = raw.endsWith(HDF5) ? raw.substring(0, raw.length() - HDF5.length()) : raw; allConfigs.add(MultiConfig.getDefaultConfig(name)); } - - MultiConfig.printDefaultConfigUsageSummary(); } // get the list of YAML files from args - List configNames = (args == null) ? Collections.emptyList() : Arrays.stream(args) - .filter(Objects::nonNull) - .filter(s -> s.endsWith(".yml")) - .collect(Collectors.toList()); if (!configNames.isEmpty()) { for (var configName : configNames) { - MultiConfig config = MultiConfig.getDefaultConfig(configName); + MultiConfig config = MultiConfig.getConfig(configName); allConfigs.add(config); } } @@ -90,12 +100,15 @@ public static void main(String[] args) throws IOException { return; } + projectionSetting.apply(allConfigs); + // Set up reporting for run RunArtifacts artifacts; try { RunConfig runCfg = RunConfig.loadDefault(); artifacts = RunArtifacts.open(runCfg, allConfigs); } catch (java.io.FileNotFoundException e) { + if (System.getProperty("jvector.bench.runConfig") != null) throw e; // Legacy yamlSchemaVersion "0" behavior: no run-config.yml // - logging disabled // - console shows compute selection @@ -121,6 +134,9 @@ public static void main(String[] args) throws IOException { var loading = BaseVectorLoading.forBenchmark(config); String datasetName = config.dataset; + String pipeline = config.describePipeline(); + if (pipeline != null) ConsoleSections.pipeline(datasetName, pipeline); + ConsoleSections.stage("Dataset"); try (var ds = DataSets.loadDataSet(datasetName, loading).orElseThrow( () -> new RuntimeException("Could not load dataset:" + datasetName) ).getDataSet()) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java index 44effda63..eae17699a 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/BuildConfig.java @@ -43,6 +43,13 @@ public final class BuildConfig { public final boolean refineFinalGraph; /** Compression used for graph construction, or null to build from full-precision vectors. */ public final Function buildCompressor; + /** Explicit index settings for schema 3. Legacy callers leave indexType unset. */ + public final String indexType; + public final String writerMode; + public final String rerankingType; + public final String ashConstructionMode; + /** Construction threads for training, encoding, insertion and writing; null for the platform default. */ + public final Integer workers; private BuildConfig(Builder b) { this.M = b.M; @@ -52,6 +59,11 @@ private BuildConfig(Builder b) { this.addHierarchy = b.addHierarchy; this.refineFinalGraph = b.refineFinalGraph; this.buildCompressor = b.buildCompressor; + this.indexType = b.indexType; + this.writerMode = b.writerMode; + this.rerankingType = b.rerankingType; + this.ashConstructionMode = b.ashConstructionMode; + this.workers = b.workers; } /** @@ -69,6 +81,11 @@ public Map reportedParameters() { params.put("neighborOverflow", neighborOverflow); params.put("addHierarchy", addHierarchy); params.put("refineFinalGraph", refineFinalGraph); + if (indexType != null) params.put("type", indexType); + if (writerMode != null) params.put("writerMode", writerMode); + if (rerankingType != null) params.put("reranking", rerankingType); + if (ashConstructionMode != null) params.put("ashConstruction", ashConstructionMode); + if (workers != null) params.put("constructionWorkers", workers); return Collections.unmodifiableMap(params); } @@ -84,6 +101,11 @@ public static final class Builder { private Boolean addHierarchy; private Boolean refineFinalGraph; private Function buildCompressor; + private String indexType; + private String writerMode; + private String rerankingType; + private String ashConstructionMode; + private Integer workers; private Builder() {} @@ -94,6 +116,11 @@ private Builder() {} public Builder addHierarchy(boolean addHierarchy) { this.addHierarchy = addHierarchy; return this; } public Builder refineFinalGraph(boolean refineFinalGraph) { this.refineFinalGraph = refineFinalGraph; return this; } public Builder buildCompressor(Function buildCompressor) { this.buildCompressor = buildCompressor; return this; } + public Builder indexType(String indexType) { this.indexType = indexType; return this; } + public Builder writerMode(String writerMode) { this.writerMode = writerMode; return this; } + public Builder rerankingType(String rerankingType) { this.rerankingType = rerankingType; return this; } + public Builder ashConstructionMode(String mode) { this.ashConstructionMode = mode; return this; } + public Builder workers(Integer workers) { this.workers = workers; return this; } /** @throws IllegalStateException if a parameter without a default was not set */ public BuildConfig build() { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java index bdc27d796..4369de0d2 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/CompressedOnDiskBuild.java @@ -21,12 +21,16 @@ import io.github.jbellis.jvector.example.util.OnDiskGraphIndexCache; import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.VectorAccess; +import io.github.jbellis.jvector.graph.VectorMean; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.graph.disk.OnDiskParallelGraphIndexWriter; import io.github.jbellis.jvector.graph.disk.RandomAccessOnDiskGraphIndexWriter; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.quantization.BQVectors; +import io.github.jbellis.jvector.quantization.CompressedVectors; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.quantization.ProductQuantization; import io.github.jbellis.jvector.quantization.VectorCompressor; @@ -43,30 +47,52 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.IntFunction; +import java.util.function.Supplier; import java.util.stream.IntStream; /** - * Builds the graph from PQ- or BQ-encoded vectors while writing each feature set to disk. Feature sets + * Builds the graph from PQ-, BQ- or ASH-encoded vectors while writing each feature set to disk. Feature sets * already in the index cache are loaded, and only the rest are built. */ final class CompressedOnDiskBuild implements IndexBuildStrategy { private final OnDiskGraphIndexCache cache; private final Path workDirectory; - private final VectorCompressor buildCompressor; + private VectorCompressor buildCompressor; + private final Supplier> buildCodec; private final ConstructionMetrics constructionMetrics; + private final Supplier> fusedCompressor; + private final String cacheIdentity; /** @param constructionMetrics construction metrics sink, or null when not reporting */ CompressedOnDiskBuild(OnDiskGraphIndexCache cache, Path workDirectory, VectorCompressor buildCompressor, ConstructionMetrics constructionMetrics) { + this(cache, workDirectory, buildCompressor, constructionMetrics, null, null, null); + } + + CompressedOnDiskBuild(OnDiskGraphIndexCache cache, Path workDirectory, Supplier> buildCodec, + ConstructionMetrics constructionMetrics, Supplier> fusedCompressor, + String cacheIdentity) { + this(cache, workDirectory, null, constructionMetrics, buildCodec, fusedCompressor, cacheIdentity); + } + + private CompressedOnDiskBuild(OnDiskGraphIndexCache cache, Path workDirectory, VectorCompressor buildCompressor, + ConstructionMetrics constructionMetrics, Supplier> buildCodec, + Supplier> fusedCompressor, String cacheIdentity) { + this.buildCodec = buildCodec; + this.fusedCompressor = fusedCompressor; + this.cacheIdentity = cacheIdentity; this.cache = cache; this.workDirectory = workDirectory; this.buildCompressor = buildCompressor; this.constructionMetrics = constructionMetrics; } + VectorCompressor buildCompressor() { + return buildCompressor; + } + @Override public BuiltIndexes build(List> featureSets, BuildConfig build, DataSet ds) throws IOException { Map, ImmutableGraphIndex> indexes = new LinkedHashMap<>(); @@ -79,45 +105,51 @@ public BuiltIndexes build(List> featureSets, BuildConfi // Map feature sets to their cache handles for the build phase Map, OnDiskGraphIndexCache.WriteHandle> handles = new LinkedHashMap<>(); - for (Set fs : featureSets) { - var key = cache.key(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, build.alpha, - build.addHierarchy, build.refineFinalGraph, buildCompressor); - var cached = cache.tryLoad(key); + try { + int baseCount = ds.getBaseRavv().size(), dimension = ds.getDimension(); + for (Set fs : featureSets) { + int nvq = fs.contains(FeatureId.NVQ_VECTORS) ? NVQProvider.subvectors(dimension) : 0; + var key = cacheIdentity == null + ? cache.key(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, build.alpha, + build.addHierarchy, build.refineFinalGraph, buildCompressor, baseCount, dimension, nvq) + : cache.keyWithCompressorId(ds.getName(), fs, build.M, build.efConstruction, build.neighborOverflow, + build.alpha, build.addHierarchy, build.refineFinalGraph, cacheIdentity, + baseCount, dimension, nvq); + var cached = cache.tryLoad(key); - if (cached.isPresent()) { - System.out.printf("%s: Using cached graph index for %s%n", key.datasetName, fs); - indexes.put(fs, cached.get()); - try { fileSizes.put(fs, Files.size(cache.resolve(key).finalPath)); } catch (IOException ignored) {} - } else { - missing.add(fs); - if (cache.isEnabled()) { - var handle = cache.beginWrite(key, OnDiskGraphIndexCache.Overwrite.ALLOW); - // Log cache miss / build start - System.out.printf("%s: Building graph index (cached enabled) for %s%n", key.datasetName, fs); - // Prepare the atomic write handle immediately - handles.put(fs, handle); + if (cached.isPresent()) { + System.out.printf("%s: Using cached graph index for %s%n", key.datasetName, fs); + indexes.put(fs, cached.get()); + try { fileSizes.put(fs, Files.size(cache.resolve(key).finalPath)); } catch (IOException ignored) {} } else { - System.out.printf("%s: Building graph index (cache disabled) for %s%n", key.datasetName, fs); + missing.add(fs); + if (cache.isEnabled()) { + var handle = cache.beginWrite(key, OnDiskGraphIndexCache.Overwrite.ALLOW); + // Log cache miss / build start + System.out.printf("%s: Building graph index (cached enabled) for %s%n", key.datasetName, fs); + // Prepare the atomic write handle immediately + handles.put(fs, handle); + } else { + System.out.printf("%s: Building graph index (cache disabled) for %s%n", key.datasetName, fs); + } } } - } - Double buildTimeS = null; - if (!missing.isEmpty()) { - // At least one index needs to be built (b/c not in cache or cache is disabled) - // We pass the handles map so buildMissing knows exactly where to write - try { - buildTimeS = buildMissing(missing, build, ds, outputDir, handles, indexes, fileSizes); - } catch (IOException | RuntimeException e) { - // A rejected or failed build must not leave the cache's temp files behind. - for (var handle : handles.values()) { - try { handle.close(); } - catch (IOException suppressed) { e.addSuppressed(suppressed); } - } - throw e; + Double buildTimeS = missing.isEmpty() ? null + : buildMissing(missing, build, ds, outputDir, handles, indexes, fileSizes); + return new BuiltIndexes(indexes, fileSizes, buildTimeS); + } catch (IOException | RuntimeException e) { + // A failed build must release cached readers and discard uncommitted writes. + for (var handle : handles.values()) { + try { handle.close(); } + catch (IOException suppressed) { e.addSuppressed(suppressed); } + } + for (var index : indexes.values()) { + try { index.close(); } + catch (IOException suppressed) { e.addSuppressed(suppressed); } } + throw e; } - return new BuiltIndexes(indexes, fileSizes, buildTimeS); } /** Builds one graph for {@code featureSets}, loads each written index into {@code indexes}, and returns the build time. */ @@ -131,27 +163,46 @@ private double buildMissing(List> featureSets, { Files.createDirectories(outputDir); + if (build.indexType != null) { + buildCompressor = buildCodec == null ? null : GraphBuilderFactory.inConstructionPool(build, buildCodec::get); + double seconds = buildAndFlush(featureSets, build, ds, outputDir, handles); + long reopenStart = System.nanoTime(); + reopen(featureSets, outputDir, handles, indexes, fileSizes); + if (constructionMetrics != null) constructionMetrics.reopenTimeS = (System.nanoTime() - reopenStart) / 1e9; + return seconds; + } + var floatVectors = ds.getBaseRavv(); - // Record the encoding time if asked for.... - var encoded = (constructionMetrics != null) - ? constructionMetrics.index(buildCompressor instanceof ProductQuantization ? "PQ" : "BQ") - .timeEncode(() -> buildCompressor.encodeAll(floatVectors)) - : buildCompressor.encodeAll(floatVectors); - if (!(encoded instanceof PQVectors) && !(encoded instanceof BQVectors)) - throw new IllegalArgumentException("Disk construction supports PQ or BQ build compression"); + String quantType = buildCompressor instanceof ProductQuantization ? "PQ" + : buildCompressor instanceof AsymmetricHashing ? "ASH" : "BQ"; + VectorMean mean = featureSets.stream().anyMatch(f -> f.contains(FeatureId.NVQ_VECTORS)) + ? new VectorMean(floatVectors.size(), floatVectors.dimension()) : null; + var encodingVectors = mean == null ? floatVectors : mean.duringScan(floatVectors); + var encoded = constructionMetrics == null ? buildCompressor.encodeAll(encodingVectors) + : constructionMetrics.index(quantType).timeEncode(() -> buildCompressor.encodeAll(encodingVectors)); + if (!(encoded instanceof PQVectors) && !(encoded instanceof BQVectors) && !(encoded instanceof ASHVectors)) + throw new IllegalArgumentException("Disk construction supports PQ, BQ or ASH build compression"); + var inputMean = mean != null && mean.isComplete() ? mean.mean() : null; PQVectors pq = encoded instanceof PQVectors ? (PQVectors) encoded : null; - for (var features : featureSets) { - for (var id : features) { - var provider = FeatureProviders.get(id); - if (provider != null) provider.checkCompressedBuild(pq); - } + ASHVectors ash = encoded instanceof ASHVectors ? (ASHVectors) encoded : null; + if (ash != null && Boolean.getBoolean("jvector.ash.debugSymmetric")) { + io.github.jbellis.jvector.example.diagnostics.ASHScoreDebug.run(floatVectors, ash, + Integer.getInteger("jvector.ash.debugSymmetric.pairs", 2000), + Integer.getInteger("jvector.ash.debugSymmetric.subsetN", 50000), + Integer.getInteger("jvector.ash.debugSymmetric.topK", 100), new java.util.Random(123)); } + for (var features : featureSets) for (var id : features) FeatureProviders.require(id).checkCompressedBuild(encoded); + boolean allSymmetric = ash != null && "symmetric-all".equals( + System.getProperty("jvector.bench.ashConstruction", "symmetric-all")); // Compressed scores only guide construction; searches rerank with a feature's stored vectors. if (featureSets.stream().noneMatch(fs -> FeatureProviders.anyMatch(fs, FeatureProvider::providesRerankVectors))) { throw new IllegalStateException("Bench looks for either NVQ_VECTORS or INLINE_VECTORS feature set for scoring compressed builds."); } - var builder = GraphBuilderFactory.forCompressed(encoded, ds.getSimilarityFunction(), floatVectors.dimension(), build); + long providerStart = System.nanoTime(); + var bsp = GraphBuilderFactory.compressedScores(encoded, ds.getSimilarityFunction(), floatVectors); + var builder = GraphBuilderFactory.forCompressed(bsp, floatVectors.dimension(), build); + System.out.printf("Build score provider setup: %.6f seconds%n", (System.nanoTime() - providerStart) / 1e9); Map, RandomAccessOnDiskGraphIndexWriter> writers = new LinkedHashMap<>(); Map, FeatureWriteContext> contexts = new LinkedHashMap<>(); @@ -165,7 +216,7 @@ private double buildMissing(List> featureSets, } else { graphPath = outputDir.resolve("graph" + n++); } - var ctx = FeatureProviders.configureWriter(features, builder.getGraph(), graphPath, floatVectors, pq, constructionMetrics); + var ctx = FeatureProviders.configureWriter(features, builder.getGraph(), graphPath, floatVectors, pq, ash, inputMean, constructionMetrics); writers.put(features, ctx.buildWriter()); contexts.put(features, ctx); } @@ -187,7 +238,8 @@ private double buildMissing(List> featureSets, if (ds.useDatasetPrefetching()) { VectorAccess.forEach(floatVectors, PhysicalCoreExecutor.pool(), (node, vector) -> { if (!deferFeatures) writeInlineStates(writers, contexts, node, vector); - builder.addGraphNode(node, vector); + if (allSymmetric) builder.addGraphNode(node, bsp.searchProviderFor(node)); + else builder.addGraphNode(node, vector); }); } else { var vv = floatVectors.threadLocalSupplier(); @@ -195,11 +247,14 @@ private double buildMissing(List> featureSets, IntStream.range(0, floatVectors.size()).parallel().forEach(node -> { var vector = vv.get().getVector(node); if (!deferFeatures) writeInlineStates(writers, contexts, node, vector); - builder.addGraphNode(node, vector); + if (allSymmetric) builder.addGraphNode(node, bsp.searchProviderFor(node)); + else builder.addGraphNode(node, vector); }); }).join(); } + long insertionEnd = System.nanoTime(); builder.cleanup(); + long cleanupEnd = System.nanoTime(); // With deferred features, supply every feature so the parallel writer emits complete records. writers.entrySet().stream().parallel().forEach(entry -> { @@ -208,21 +263,72 @@ private double buildMissing(List> featureSets, try (var vectors = new FinalWriteVectors(floatVectors); var view = builder.getGraph().getView(); var closeWriter = writer) { - Map> writeSuppliers = new EnumMap<>(FeatureId.class); - if (deferFeatures) { - ctx.vectorSuppliers().forEach((id, supplier) -> - writeSuppliers.put(id, ordinal -> supplier.apply(vectors.get(ordinal)))); - } - ctx.afterBuildSuppliers().forEach((id, supplier) -> writeSuppliers.put(id, supplier.apply(view))); - writer.write(writeSuppliers); + writer.write(ctx.completedSuppliers(view, deferFeatures ? vectors::get : null)); } catch (IOException e) { throw new UncheckedIOException(e); } }); builder.close(); double totalTime = (System.nanoTime() - startTime) / 1_000_000_000.0; + System.out.printf("Graph phases: insertion=%.6f cleanup=%.6f flush=%.6f seconds%n", + (insertionEnd-startTime)/1e9, (cleanupEnd-insertionEnd)/1e9, + (System.nanoTime()-cleanupEnd)/1e9); System.out.format("Build and write %s in %ss%n", featureSets, totalTime); + reopen(featureSets, outputDir, handles, indexes, fileSizes); + return totalTime; + } + + /** Builds the topology before preparing search features and writing the disk index. */ + private double buildAndFlush(List> featureSets, BuildConfig build, DataSet ds, Path outputDir, + Map, OnDiskGraphIndexCache.WriteHandle> handles) throws IOException { + var vectors = ds.getBaseRavv(); + var mean = featureSets.stream().anyMatch(features -> features.contains(FeatureId.NVQ_VECTORS)) + ? new VectorMean(vectors.size(), vectors.dimension()) : null; + var buildVectors = mean == null ? vectors : mean.duringScan(vectors); + var encoded = Grid.encodeForConstruction(buildCompressor, buildVectors, constructionMetrics, + GraphBuilderFactory.constructionPool(build)); + + long start = System.nanoTime(); + try (var graph = GraphBuilderFactory.buildGraph(ds, build, encoded)) { + double buildSeconds = (System.nanoTime() - start) / 1e9; + var codec = fusedCompressor == null ? null + : GraphBuilderFactory.inConstructionPool(build, fusedCompressor::get); + CompressedVectors searchCodes = codec == null ? null : codec == buildCompressor + ? encoded + : Grid.encodeForConstruction(codec, mean != null && !mean.isComplete() ? buildVectors : vectors, + constructionMetrics, GraphBuilderFactory.constructionPool(build)); + var inputMean = mean != null && mean.isComplete() ? mean.mean() : null; + double flushSeconds = 0; + int n = 0; + for (var features : featureSets) { + Path path = handles.containsKey(features) ? handles.get(features).writePath() : outputDir.resolve("graph" + n++); + var ctx = FeatureProviders.configureWriter(features, graph, path, vectors, + searchCodes instanceof PQVectors ? (PQVectors) searchCodes : null, + searchCodes instanceof ASHVectors ? (ASHVectors) searchCodes : null, + inputMean, constructionMetrics, build.writerMode, GraphBuilderFactory.constructionThreads(build)); + start = System.nanoTime(); + try (var progress = PhaseProgress.timed("Index write " + features); + var input = new FinalWriteVectors(vectors); + var view = graph.getView(); + var writer = ctx.buildWriter()) { + writer.write(ctx.completedSuppliers(view, input::get)); + } + flushSeconds += (System.nanoTime() - start) / 1e9; + } + double totalTime = buildSeconds + flushSeconds; + if (constructionMetrics != null) { + constructionMetrics.graphBuildTimeS = buildSeconds; + constructionMetrics.flushTimeS = flushSeconds; + } + return totalTime; + } + } + + private static void reopen(List> featureSets, Path outputDir, + Map, OnDiskGraphIndexCache.WriteHandle> handles, + Map, ImmutableGraphIndex> indexes, + Map, Long> fileSizes) throws IOException { // Commit the index to cache atomically for (var entry : handles.entrySet()) { System.out.printf("Committing %s build to graph index cache...%n", entry.getKey()); @@ -230,7 +336,7 @@ private double buildMissing(List> featureSets, } // open indexes and capture per-feature-set file sizes - n = 0; + int n = 0; for (var features : featureSets) { Path loadPath = handles.containsKey(features) ? handles.get(features).finalPath() @@ -239,7 +345,6 @@ private double buildMissing(List> featureSets, indexes.put(features, OnDiskGraphIndex.load(ReaderSupplierFactory.open(loadPath))); fileSizes.put(features, Files.size(loadPath)); } - return totalTime; } /// Write one node's original-vector features to every writer during insertion. The borrowed diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleReportListener.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleReportListener.java index 07c605674..1e134109d 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleReportListener.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleReportListener.java @@ -23,12 +23,15 @@ import java.util.LinkedHashMap; import java.util.Map; +import java.util.ArrayList; +import java.util.List; /** Prints a results table per pruning setting and topK, showing the console selection from run-config.yml. */ final class ConsoleReportListener implements GridListener { private final RunArtifacts artifacts; // Per (index, search compressor), so missing-metric warnings repeat for each configuration. + private final List rows = new ArrayList<>(); private SearchSelection selection; private BenchmarkTablePrinter printer; private ReportingSelectionResolver.ResolvedSelection resolved; @@ -50,7 +53,10 @@ public void onPruningStart(SearchContext ctx, boolean usePruning) { Map indexConfig = new LinkedHashMap<>(); indexConfig.put("featureSetForIndex", ctx.indexFeatures); indexConfig.putAll(ctx.build.reportedParameters()); - printer.printConfig(indexConfig, Map.of("usePruning", usePruning)); + var queryConfig = new LinkedHashMap(); + queryConfig.put("usePruning", usePruning); + queryConfig.putAll(ctx.queryParameters); + printer.printConfig(indexConfig, queryConfig); } @Override @@ -59,17 +65,19 @@ public void onTopKStart(SearchContext ctx, boolean usePruning, int topK) { resolved = selection.resolveForTopK(topK); // Force header to re-print for this topK (columns change) printer.resetTable(); + rows.clear(); } @Override public void onQueryResult(SearchContext ctx, QueryRow row) { // Best-effort runtime availability warnings (warn once per key) selection.warnMissing(row.metrics, resolved); - printer.printRow(row.overquery, selection.apply(row.metrics, resolved)); + rows.add(row); } @Override public void onTopKEnd(SearchContext ctx, boolean usePruning, int topK) { + for (var row : rows) printer.printRow(row.overquery, selection.apply(row.metrics, resolved)); printer.printFooter(); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleSections.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleSections.java new file mode 100644 index 000000000..66fb5ba07 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConsoleSections.java @@ -0,0 +1,38 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +/** Console structure for BenchYAML runs: a banner per experiment and a header per stage. */ +public final class ConsoleSections { + private static final String RULE = "=".repeat(80); + + private ConsoleSections() {} + + /** The experiment's pipeline, framed so it stands out at the top of its output. */ + public static void pipeline(String dataset, String pipeline) { + System.out.println(); + System.out.println(RULE); + System.out.println(" PIPELINE " + dataset + ": " + pipeline); + System.out.println(RULE); + } + + /** Starts a stage of the run, for example Dataset, Construction or Search. */ + public static void stage(String title) { + System.out.println(); + System.out.println("== " + title + " =="); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java index 809ffa3e5..9105a5b75 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ConstructionMetrics.java @@ -36,6 +36,11 @@ final class ConstructionMetrics { // null means “not applicable / not measured” public Double indexBuildTimeS; public Long indexFileSizeBytes; + // Schema 3 on-disk phases: graph construction and cleanup, writing the completed graph, + // and reopening the written files. Graph build plus flush equals indexBuildTimeS. + public Double graphBuildTimeS; + public Double flushTimeS; + public Double reopenTimeS; // Index-construction phase (single pass per run) private final Map indexByType = new HashMap<>(); @@ -43,7 +48,13 @@ final class ConstructionMetrics { // Search phase (reset per evaluated config) private final Map searchByType = new HashMap<>(); - ConstructionMetrics() {} + private final boolean sumIndexQuantTimes; + + ConstructionMetrics() { this(false); } + + ConstructionMetrics(boolean sumIndexQuantTimes) { + this.sumIndexQuantTimes = sumIndexQuantTimes; + } /** Clear search-phase quant timings for the next evaluated (index, compression) configuration. */ void resetSearch() { @@ -70,17 +81,25 @@ void appendTo(List out) { "Index file size (MB)", ".2f", indexFileSizeBytes / 1024.0 / 1024.0)); } + appendPhase(out, "construction.graph_build_time_s", "Graph build time (s)", graphBuildTimeS); + appendPhase(out, "construction.flush_time_s", "Flush time (s)", flushTimeS); + appendPhase(out, "construction.reopen_time_s", "Reopen time (s)", reopenTimeS); + // Index-construction quant timings - appendQuant(out, "construction.index_quant_time_s", "Idx", indexByType); + appendQuant(out, "construction.index_quant_time_s", "Idx", indexByType, sumIndexQuantTimes); // Search-phase quant timings (note: search.* namespace matches other query-time metrics) - appendQuant(out, "search.search_quant_time_s", "Search", searchByType); + appendQuant(out, "search.search_quant_time_s", "Search", searchByType, false); + } + + private static void appendPhase(List out, String key, String header, Double seconds) { + if (seconds != null) out.add(Metric.of(key, header, ".2f", seconds)); } private static void appendQuant(List out, String prefix, String phaseLabel, - Map byType) { + Map byType, boolean sumTimes) { final String hPrefix = (phaseLabel == null || phaseLabel.isBlank()) ? "" : (phaseLabel + " "); for (var e : byType.entrySet()) { @@ -88,16 +107,19 @@ private static void appendQuant(List out, QuantStats qs = e.getValue(); // Compute - if (qs.computeTimeS != null) { + if (!qs.computeTimesS.isEmpty()) { + double computeTime = sumTimes ? qs.computeTimesS.stream().mapToDouble(Double::doubleValue).sum() + : qs.computeTimesS.get(qs.computeTimesS.size() - 1); out.add(Metric.of(prefix + "." + qt + ".compute_time_s", - hPrefix + qt + " compute (s)", ".2f", qs.computeTimeS)); + hPrefix + qt + " compute (s)", ".2f", computeTime)); } // Encoding (usually single; suffix only if multiple) int n = qs.encodeTimesS.size(); - if (n == 1) { + if (n > 0 && (n == 1 || sumTimes)) { + double encodeTime = qs.encodeTimesS.stream().mapToDouble(Double::doubleValue).sum(); out.add(Metric.of(prefix + "." + qt + ".encoding_time_s", - hPrefix + qt + " encoding (s)", ".2f", qs.encodeTimesS.get(0))); + hPrefix + qt + " encoding (s)", ".2f", encodeTime)); } else if (n > 1) { for (int i = 0; i < n; i++) { out.add(Metric.of(prefix + "." + qt + ".encoding_time_s." + i, @@ -108,7 +130,7 @@ private static void appendQuant(List out, } private static final class QuantStats { - Double computeTimeS; + final List computeTimesS = new ArrayList<>(); final List encodeTimesS = new ArrayList<>(); } @@ -125,7 +147,7 @@ T timeCompute(java.util.function.Supplier f) { if (qs == null) return f.get(); long t0 = System.nanoTime(); try { return f.get(); } - finally { qs.computeTimeS = (System.nanoTime() - t0) / 1_000_000_000.0; } + finally { qs.computeTimesS.add((System.nanoTime() - t0) / 1_000_000_000.0); } } T timeEncode(java.util.function.Supplier f) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java new file mode 100644 index 000000000..3c9cad4b4 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesASH.java @@ -0,0 +1,1217 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; +import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.ASHBlockScorer; +import io.github.jbellis.jvector.quantization.CompressedVectors; + +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.quantization.ASHVectors; + +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.util.List; +import java.util.ArrayList; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; + +/** + * ASH scoring benchmark. Pass a dataset name from the public dataset catalog (default + * {@value #DEFAULT_DATASET}; it is downloaded and cached on first use), or base, query and + * ground-truth files ({@code .fvecs}, {@code .fvecs}, {@code .ivecs}). ASH scores inner products, so the + * dataset should use dot-product similarity, or cosine similarity with normalized vectors. + * + *

The compressor is set by {@code jvector.ash.bitsPerDimension} (1 to 9, default 4), + * {@code jvector.ash.quantizedDimensions} (default half the input dimension), + * {@code jvector.ash.landmarkCount} (default 1) and {@code jvector.ash.optimizer} ({@code random} or + * {@code itq}). Every width uses the production scorers: 1-bit codes use masked adds, and the other + * widths sum 4-bit groups of their codes through a query table. Measurements are selected with {@code jvector.bench.} + * properties: {@code recall} (exhaustive recall against the ground truth; add + * {@code recall.block-scoring} to rank with block scoring), {@code single-scoring} and + * {@code block-scoring} (throughput), {@code symmetric-scoring} (code-to-code scoring), + * {@code maxQueries}, {@code recall.k} and {@code scoringThreads}.

+ */ +public class DistancesASH { + + static final String DEFAULT_DATASET = "e5-small-v2-100k"; + + static final String blockMode = System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + static final String singleMode = System.getProperty("jvector.ash.singleKernel", "auto").toLowerCase(); + + private static void printScorerInfo(String label, Object scorer) { + var backend = + io.github.jbellis.jvector.vector.VectorizationProvider.getInstance() + .getVectorUtilSupport(); + + System.out.println( + "\t[" + label + "] scorer implementation = " + + (scorer instanceof ASHBlockScorer + ? ((ASHBlockScorer) scorer).description() : scorer.getClass().getName()) + + " (singleKernel=" + singleMode + + ", blockKernel=" + blockMode + + ", supportsAshMaskedLoad=" + backend.supportsAshMaskedLoad() + + ", supportsAshLutScoring=" + backend.supportsAshLutScoring() + + ", supportsAshProjectionScoring=" + backend.supportsAshProjectionScoring() + + ", " + backend.ashKernelDescription() + + ")" + ); + } + + private static void logProgress(String msg) { + System.out.println(msg); + System.out.flush(); + } + + /** Warm each measured path independently; recall only warms single-vector scoring. */ + private static void warmupASH(ASHVectors vectors, List> queries, + ForkJoinPool executor, int blockSize) { + int requested = Integer.parseInt(System.getProperty("jvector.bench.scoringWarmupQueries", "128")); + if (requested < 0) throw new IllegalArgumentException("scoringWarmupQueries must be nonnegative"); + int queryCount = Math.min(requested, queries.size()); + if (queryCount == 0) return; + int workers = Math.min(executor.getParallelism(), queryCount); + List> tasks = new ArrayList<>(); + for (int worker = 0; worker < workers; worker++) { + final int first = worker; + tasks.add(executor.submit(() -> { + double checksum = 0; + float[] scores = blockSize == 0 ? null : new float[blockSize]; + for (int q = first; q < queryCount; q += workers) { + if (blockSize == 0) { + var scorer = vectors.scoreFunctionFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < vectors.count(); i++) checksum += scorer.similarityTo(i); + } else { + var scorer = vectors.blockScorerFor(queries.get(q), VectorSimilarityFunction.DOT_PRODUCT, blockSize); + for (int start = 0; start < vectors.count(); start += blockSize) { + int count = Math.min(blockSize, vectors.count() - start); + scorer.scoreRange(start, count, scores); + for (int i = 0; i < count; i++) checksum += scores[i]; + } + } + } + return checksum; + })); + } + double checksum = 0; + for (var task : tasks) checksum += task.join(); + System.out.printf(java.util.Locale.ROOT, "\t%s warmup: %d queries, checksum=%.6f (excluded from timing)%n", + blockSize == 0 ? "Single" : "Block", queryCount, checksum); + } + + /** Shared by the original scan modes and reproducible neighborhood fixture creation. */ + static int parseOptimizerFromProperty() { + String opt = System.getProperty("jvector.ash.optimizer", "random").trim().toLowerCase(java.util.Locale.ROOT); + + if ("random".equals(opt)) return AsymmetricHashing.RANDOM; + if ("itq".equals(opt)) return AsymmetricHashing.ITQ; + + throw new IllegalArgumentException( + "Unknown jvector.ash.optimizer=" + opt + " (expected random|itq)" + ); + } + + private static String optimizerName(int optimizer) { + if (optimizer == AsymmetricHashing.RANDOM) return "RANDOM"; + if (optimizer == AsymmetricHashing.ITQ) return "ITQ"; + return "UNKNOWN(" + optimizer + ")"; + } + + private static void validateRecallScorers(ASHVectors vectors, List> queries, int blockSize) { + float maxBlockVsScalar = 0f; + float maxBlockVsSingle = 0f; + for (int qi : new int[]{0, queries.size() / 2}) { + VectorFloat query = queries.get(qi); + ASHBlockScorer block = vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, blockSize); + ASHBlockScorer scalar = vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT); + ScoreFunction.ApproximateScoreFunction single = + vectors.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("recall-block", block); + float[] blockScores = new float[blockSize]; + float[] scalarScores = new float[blockSize]; + for (int start = 0; start < vectors.count(); start += blockSize) { + int count = Math.min(blockSize, vectors.count() - start); + block.scoreRange(start, count, blockScores); + scalar.scoreRange(start, count, scalarScores); + for (int lane = 0; lane < count; lane++) { + float actual = blockScores[lane]; + float reference = scalarScores[lane]; + float singleScore = single.similarityTo(start + lane); + if (!Float.isFinite(actual) || !Float.isFinite(reference) || !Float.isFinite(singleScore)) { + throw new AssertionError("Non-finite ASH score at query=" + qi + " ordinal=" + (start + lane)); + } + maxBlockVsScalar = Math.max(maxBlockVsScalar, Math.abs(actual - reference)); + maxBlockVsSingle = Math.max(maxBlockVsSingle, Math.abs(actual - singleScore)); + } + } + } + System.out.printf(java.util.Locale.ROOT, + "\tRecall scorer validation: max block/scalar=%.8f, max block/single=%.8f%n", + maxBlockVsScalar, maxBlockVsSingle); + if (maxBlockVsScalar > 0.005f || maxBlockVsSingle > 0.005f) { + throw new AssertionError("ASH block recall scorer disagrees with scalar or single scoring"); + } + } + + private static void offerTop(java.util.PriorityQueue candidates, int capacity, + float score, int ordinal) { + if (candidates.size() < capacity) { + candidates.add(new long[]{Float.floatToRawIntBits(score), ordinal}); + } else if (score > Float.intBitsToFloat((int) candidates.peek()[0])) { + candidates.poll(); + candidates.add(new long[]{Float.floatToRawIntBits(score), ordinal}); + } + } + + private static int[] rankedOrdinals(java.util.PriorityQueue candidates) { + int[] ranked = new int[candidates.size()]; + for (int rank = ranked.length - 1; rank >= 0; rank--) ranked[rank] = (int) candidates.poll()[1]; + return ranked; + } + + private static void addRecall(int[] ranked, int[] groundTruth, int[] newToOld, + int recallK, int[] atValues, double[] totals, int offset) { + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + int at = atValues[aIdx]; + java.util.Set topAtSet = new java.util.HashSet<>(); + for (int rank = 0; rank < Math.min(at, ranked.length); rank++) { + topAtSet.add(newToOld == null ? ranked[rank] : newToOld[ranked[rank]]); + } + int matches = 0; + java.util.HashSet gtSeen = new java.util.HashSet<>(recallK * 2); + for (int g = 0; g < recallK; g++) { + int gtId = groundTruth[g]; + if (gtSeen.add(gtId) && topAtSet.contains(gtId)) matches++; + } + totals[offset + aIdx] += (double) matches / recallK; + } + } + + /** Independent component decoder for validation; never used in timed scoring. */ + private static double symmetricComponent(AsymmetricHashing.QuantizedVector v, int j, int bits) { + if (bits == 1) return ((v.binaryVector[j / 64] >>> (j % 64)) & 1L) == 0 ? -1 : 1; + if (bits != 2 && bits != 4) { + // Offset-binary fields: bits [j*b, (j+1)*b) little-endian, value field - (2^b - 1) / 2. + int field = 0; + for (int k = 0; k < bits; k++) { + int pos = j * bits + k; + field |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; + } + return field - ((1 << bits) - 1) / 2.0; + } + int field = (v.extraBits[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); + double magnitude = (field & ((1 << (bits - 1)) - 1)) + 0.5; + return (field & (1 << (bits - 1))) == 0 ? -magnitude : magnitude; + } + + private static double decodedSymmetric(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector a, + AsymmetricHashing.QuantizedVector b) { + int ca = a.landmark & 255, cb = b.landmark & 255; + double dot = 0, crossA = 0, crossB = 0; + for (int j = 0; j < ash.quantizedDim; j++) { + double x = symmetricComponent(a, j, ash.bitsPerDimension); + double y = symmetricComponent(b, j, ash.bitsPerDimension); + dot += x * y; + crossA += x * ash.landmarkProj[cb][j]; + crossB += y * ash.landmarkProj[ca][j]; + } + // Stored offsets already include each code's own landmark term. + double correctionA = a.offset, correctionB = b.offset; + double centroidDot = 0; + for (int j = 0; j < ash.originalDimension; j++) { + centroidDot += (double) ash.landmarks[ca].get(j) * ash.landmarks[cb].get(j); + } + // Graph DOT_PRODUCT similarity, unclamped as the library scores it. + return (1 + (double) a.scale * b.scale * dot + a.scale * crossA + + b.scale * crossB + correctionA + correctionB + centroidDot) / 2; + } + + private static final class ScanScorer { + final ScoreFunction single; + final ASHBlockScorer block; + ScanScorer(ScoreFunction single) { this.single = single; this.block = null; } + ScanScorer(ASHBlockScorer block) { this.single = null; this.block = block; } + void fill(int start, int count, float[] out) { + if (block != null) block.scoreRange(start,count,out); + else for (int i = 0; i < count; i++) out[i] = single.similarityTo(start+i); + } + double scan(int count, float[] scratch) { + double sum = 0; + if (single != null) { + for (int node = 0; node < count; node++) sum += single.similarityTo(node); + } else { + for (int start = 0; start < count; start += scratch.length) { + int n = Math.min(scratch.length, count-start); + block.scoreRange(start,n,scratch); + for (int i = 0; i < n; i++) sum += scratch[i]; + } + } + return sum; + } + } + + /** Only query encoding is excluded. Every timed query creates its actual scoring state. */ + private static void benchmarkSymmetric(ASHVectors vectors, List> queries, + List> groundTruth, int[] newToOld, + int recallK, boolean recall, ForkJoinPool executor) { + var ash = vectors.getCompressor(); + int passes = Integer.getInteger("jvector.bench.scoringPasses", 5); + int timingQueries = Math.min(queries.size(), Integer.getInteger("jvector.bench.scoringQueries", 128)); + int warmupQueries = Math.min(timingQueries, Integer.getInteger("jvector.bench.scoringWarmupQueries", 32)); + int timingVectors = Math.min(vectors.count(), Integer.getInteger("jvector.bench.scoringVectors", vectors.count())); + if (passes < 1 || timingQueries < 1 || warmupQueries < 0 || timingVectors < 1) { + throw new IllegalArgumentException("Invalid scoring pass/query/vector counts"); + } + long start = System.nanoTime(); + var encodedQueries = new AsymmetricHashing.QuantizedVector[queries.size()]; + for (int q = 0; q < queries.size(); q++) encodedQueries[q] = ash.encode(queries.get(q)); + System.out.printf(java.util.Locale.ROOT, "QUERY_ENCODING seconds=%.6f queries=%d (excluded)%n", + (System.nanoTime()-start)/1e9, queries.size()); + var scalar = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(vectors, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SCALAR); + var backend = io.github.jbellis.jvector.vector.VectorizationProvider.getInstance().getVectorUtilSupport(); + boolean hasSimd = (ash.bitsPerDimension == 1 || ash.bitsPerDimension == 2 || ash.bitsPerDimension == 4) + && backend.supportsAshSymmetricScoring() + && (ash.landmarkCount == 1 || (ash.bitsPerDimension == 1 + ? backend.supportsAshMaskedLoad() : backend.supportsAshProjectionScoring())); + boolean hasBlockSimd = ash.bitsPerDimension == 1 ? backend.supportsAshMaskedLoad() + : backend.supportsAshLutScoring(); + List names = new ArrayList<>(); + List> factories = new ArrayList<>(); + names.add("symmetric-single-scalar"); + factories.add(q -> new ScanScorer(scalar.scoreFunctionFor(encodedQueries[q]))); + if (hasSimd) { + var simd = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(vectors, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SIMD); + names.add("symmetric-single-simd"); + factories.add(q -> new ScanScorer(simd.scoreFunctionFor(encodedQueries[q]))); + } + names.add("symmetric-block-scalar"); + factories.add(q -> new ScanScorer(scalar.blockScorerFor(encodedQueries[q],32, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SCALAR))); + if (hasBlockSimd) { + names.add("symmetric-block-simd"); + factories.add(q -> new ScanScorer(scalar.blockScorerFor(encodedQueries[q],32, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SIMD))); + } + names.add("asymmetric-single-" + singleMode); + factories.add(q -> new ScanScorer(vectors.scoreFunctionFor(queries.get(q),VectorSimilarityFunction.DOT_PRODUCT))); + names.add("asymmetric-block-" + blockMode); + factories.add(q -> new ScanScorer(vectors.blockScorerFor(queries.get(q),VectorSimilarityFunction.DOT_PRODUCT,32))); + + String requestedModes = System.getProperty("jvector.bench.scoringModes", "").trim(); + if (!requestedModes.isEmpty()) { + var selectedModes = new java.util.HashSet<>(java.util.Arrays.asList(requestedModes.split(","))); + for (String name : selectedModes) if (!names.contains(name)) throw new IllegalArgumentException("Unavailable scoring mode: " + name); + for (int i = names.size()-1; i >= 0; i--) if (!selectedModes.contains(names.get(i))) { + names.remove(i); factories.remove(i); + } + } + // Validate selected symmetric modes against an independent decoded/header formula. + for (int mode = 0; mode < names.size(); mode++) { + if (!names.get(mode).startsWith("symmetric-")) continue; + double maxError = 0; + for (int q = 0; q < Math.min(4,queries.size()); q++) { + var scorer = factories.get(mode).apply(q); + float[] one = new float[1]; + int samples = Math.min(512,vectors.count()); + for (int sample = 0; sample < samples; sample++) { + int node = samples == 1 ? 0 : (int) ((long) sample*(vectors.count()-1)/(samples-1)); + double expected = decodedSymmetric(ash,encodedQueries[q],vectors.get(node)); + scorer.fill(node,1,one); + double error = Math.abs(one[0]-expected); + if (!Float.isFinite(one[0]) || !Double.isFinite(expected) + || error > 5e-5*Math.max(1,Math.abs(expected))) { + throw new AssertionError(names.get(mode)+" decoder mismatch q="+q+" node="+node + +" expected="+expected+" actual="+one[0]); + } + maxError = Math.max(maxError,error); + } + } + System.out.printf(java.util.Locale.ROOT,"SYMMETRIC_VALIDATION mode=%s max_abs_error=%.9g%n",names.get(mode),maxError); + } + if (recall) { + if (recallK < 1 || recallK > vectors.count() || groundTruth.size() < queries.size()) { + throw new IllegalArgumentException("Invalid recall size or missing ground truth"); + } + double[] totals = new double[names.size()]; + float[] scores = new float[32]; + for (int q = 0; q < queries.size(); q++) { + int[] gt = groundTruth.get(q).stream().mapToInt(Integer::intValue).toArray(); + if (gt.length < recallK) throw new IllegalArgumentException("Ground truth row too short"); + for (int mode = 0; mode < names.size(); mode++) { + var scorer = factories.get(mode).apply(q); + var heap = new java.util.PriorityQueue((a,b) -> Float.compare( + Float.intBitsToFloat((int)a[0]),Float.intBitsToFloat((int)b[0]))); + for (int node = 0; node < vectors.count(); node += scores.length) { + int n = Math.min(scores.length,vectors.count()-node); + scorer.fill(node,n,scores); + for (int i = 0; i < n; i++) offerTop(heap,recallK,scores[i],node+i); + } + addRecall(rankedOrdinals(heap),gt,newToOld,recallK,new int[]{recallK},totals,mode); + } + } + for (int mode = 0; mode < names.size(); mode++) { + System.out.printf(java.util.Locale.ROOT,"SCORING_RECALL mode=%s queries=%d %d-recall@%d=%.6f%n", + names.get(mode),queries.size(),recallK,recallK,totals[mode]/queries.size()); + } + } + System.out.println("SCORING_TIMING query encoding excluded; asymmetric projection and all scorer setup included once per scan; symmetric input already encoded"); + long minNanos = (long) (Double.parseDouble(System.getProperty("jvector.bench.scoringMinSeconds", "0.25")) * 1e9); + if (minNanos < 0) throw new IllegalArgumentException("scoringMinSeconds must be nonnegative"); + for (var factory : factories) { + if (warmupQueries == 0) continue; + long until = System.nanoTime() + Math.max(minNanos, 1_000_000_000L); + do { scanWithSetup(factory,warmupQueries,timingVectors,executor); } + while (System.nanoTime() < until); + } + double[][] times = new double[names.size()][passes]; + for (int pass = 0; pass < passes; pass++) { + for (int order = 0; order < names.size(); order++) { + int mode = pass % 2 == 0 ? order : names.size()-1-order; + start = System.nanoTime(); + double checksum = 0; + int repetitions = 0; + long elapsed; + do { + checksum += scanWithSetup(factories.get(mode),timingQueries,timingVectors,executor); + repetitions++; + elapsed = System.nanoTime()-start; + } while (elapsed < minNanos); + times[mode][pass] = elapsed / (double) repetitions; + System.out.printf(java.util.Locale.ROOT,"SCORING_PASS mode=%s pass=%d seconds=%.6f repetitions=%d checksum=%.6f%n", + names.get(mode),pass+1,elapsed/1e9,repetitions,checksum); + } + } + long pairs = (long) timingQueries*timingVectors; + for (int mode = 0; mode < names.size(); mode++) { + java.util.Arrays.sort(times[mode]); + double median = times[mode][passes/2]; + System.out.printf(java.util.Locale.ROOT, + "SCORING_THROUGHPUT mode=%s threads=%d queries=%d vectors=%d passes=%d median_Mdot_s=%.6f median_ns_pair=%.3f min_scan_s=%.6f max_scan_s=%.6f%n", + names.get(mode),executor.getParallelism(),timingQueries,timingVectors,passes, + pairs*1000.0/median,median/pairs,times[mode][0]/1e9,times[mode][passes-1]/1e9); + } + } + + private static double scanWithSetup(java.util.function.IntFunction factory, + int queries, int count, ForkJoinPool executor) { + if (queries == 0) return 0; + int workers = Math.min(executor.getParallelism(),queries); + List> tasks = new ArrayList<>(); + for (int worker = 0; worker < workers; worker++) { + final int first = worker; + tasks.add(executor.submit(() -> { + double sum = 0; + float[] scratch = new float[32]; + for (int q = first; q < queries; q += workers) sum += factory.apply(q).scan(count,scratch); + return sum; + })); + } + double sum = 0; + for (var task : tasks) sum += task.join(); + return sum; + } + + + private static volatile double projectionSink; + + /** Paired projection comparison; optional recall uses the normal full-scan scorers. */ + private static void benchmarkInt8Projection(ASHVectors encoded, List> queries, + List> truth, int[] newToOld, ForkJoinPool executor) throws IOException { + var outputDir=java.nio.file.Path.of(System.getProperty("jvector.bench.outputDir", ".")); + java.nio.file.Files.createDirectories(outputDir); + var model=outputDir.resolve("model-b"+encoded.getCompressor().bitsPerDimension+".ash"); + try(var writer=new io.github.jbellis.jvector.disk.SimpleWriter(model)) { + encoded.getCompressor().write(writer,io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); + } + final AsymmetricHashing copy; + try(var reader=new io.github.jbellis.jvector.disk.SimpleReader(model)) {copy=AsymmetricHashing.load(reader);} + if(!java.util.Arrays.deepEquals(copy.stiefelTransform.AFloat,encoded.getCompressor().stiefelTransform.AFloat)) + throw new AssertionError("Projection matrix changed on model round trip"); + var sharedCodes=new AsymmetricHashing.QuantizedVector[encoded.count()]; + for(int i=0;i1e-6+Math.abs(oracle)*1e-5)throw new AssertionError("VNNI integer-dot oracle mismatch"); + } + } + } + System.out.printf(java.util.Locale.ROOT, + "PROJECTION_ACCURACY queries=%d relative_l2_error=%.9f max_absolute_error=%.9f max_integer_oracle_error=%.9f%n", + inputs.length,Math.sqrt(squared/referenceSquared),maxError,maxOracleError); + try(var csv=new java.io.PrintWriter(outputDir.resolve("projection-b"+copy.bitsPerDimension+".csv").toFile())) { + csv.println("mode,batch,sample,queries,seconds,queries_per_second,ns_per_query,checksum"); + if(Boolean.parseBoolean(System.getProperty("jvector.bench.projectionTiming","true"))) { + long minimum=(long)(Double.parseDouble(System.getProperty("jvector.bench.projectionSeconds","3"))*1e9); + for(int batch:new int[]{1,2,4,8}) { + float[][] in=new float[batch][],out=new float[batch][copy.quantizedDim]; + // Batch/single quantization must produce the same projected state. + for(int i=0;i1e-6) + throw new AssertionError("INT8 batch/single mismatch"); + } + for(int pass=-1;pass<3;pass++)for(int order=0;order<2;order++) { + int mode=(order+Math.max(0,pass))%2; + long start=System.nanoTime(),elapsed;long count=0;int position=0;double checksum=0; + do { + for(int chunk=0;chunk<128;chunk++) { + for(int i=0;i=0) { + double seconds=elapsed/1e9; + csv.printf(java.util.Locale.ROOT,"%s,%d,%d,%d,%.9f,%.6f,%.3f,%.9f%n", + names[mode],batch,pass+1,count,seconds,count/seconds,(double)elapsed/count,checksum);csv.flush(); + System.out.printf(java.util.Locale.ROOT,"PROJECTION_THROUGHPUT mode=%s batch=%d sample=%d queries_per_second=%.3f ns_per_query=%.3f%n", + names[mode],batch,pass+1,count/seconds,(double)elapsed/count); + } + } + } + } + } + // Recall is optional, as in the other DistancesASH modes. Projection + // timing still uses every selected query when exhaustive scans are off. + if (!truth.isEmpty()) { + // Every selected query scans ALL encoded base vectors; no ANN traversal or reranking. + int[] atValues={10,20};int capacity=20; + try(var csv=new java.io.PrintWriter(outputDir.resolve("scan-b"+copy.bitsPerDimension+".csv").toFile())) { + csv.println("mode,bits,dimensions,threads,base_vectors,queries,seconds,scan_queries_per_second,recall10_at10,recall10_at20"); + // Warm the actual fused full-scan and top-k code on both arms. + for(int mode=0;mode<2;mode++)scanProjectionRecall(arms[mode],queries,truth,newToOld,Math.min(32,queries.size()),executor); + for(int mode=0;mode<2;mode++) { + long start=System.nanoTime(); + double[] totals=scanProjectionRecall(arms[mode],queries,truth,newToOld,queries.size(),executor); + double seconds=(System.nanoTime()-start)/1e9; + csv.printf(java.util.Locale.ROOT,"%s,%d,%d,%d,%d,%d,%.9f,%.6f,%.9f,%.9f%n", + names[mode],copy.bitsPerDimension,copy.quantizedDim,executor.getParallelism(),encoded.count(),queries.size(), + seconds,queries.size()/seconds,totals[0]/queries.size(),totals[1]/queries.size());csv.flush(); + System.out.printf(java.util.Locale.ROOT,"PROJECTION_SCAN mode=%s queries=%d vectors=%d seconds=%.3f recall10_at10=%.6f recall10_at20=%.6f%n", + names[mode],queries.size(),encoded.count(),seconds,totals[0]/queries.size(),totals[1]/queries.size()); + } + } + } + System.out.println("PROJECTION_TIMING one calling thread; INT8 quantization, FFM staging, matmul and rescaling included; immutable matrix packing excluded"); + } + + private static double[] scanProjectionRecall(ASHVectors vectors,List> queries, + List> truth,int[] newToOld,int count,ForkJoinPool executor) { + var tasks=new ArrayList>(); + for(int worker=0;worker{ + double[] totals=new double[2];float[] scores=new float[32]; + for(int q=first;q((a,b)->Float.compare(Float.intBitsToFloat((int)a[0]),Float.intBitsToFloat((int)b[0]))); + for(int start=0;start> vectors, List> allQueries, + List> groundTruth) throws IOException { + // ------------------------------------------------------------ + // Benchmark configuration (runtime flags) + // ------------------------------------------------------------ + final boolean RUN_SANITY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.sanity-check", "false")); + + final boolean RUN_RECALL_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); + + final boolean RUN_BLOCK_RECALL = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall.block-scoring", "false")); + + final boolean COMPARE_ALL_RECALL = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall.compare-all", "false")); + + final boolean RECALL_ONLY_TEN = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall.only10", "false")); + + if (COMPARE_ALL_RECALL && !RUN_BLOCK_RECALL) { + throw new IllegalArgumentException("compare-all requires recall.block-scoring=true"); + } + + final boolean RUN_BLOCK_TIMING = + Boolean.parseBoolean(System.getProperty("jvector.bench.block-scoring", "true")); + + final int RECALL_K = + Integer.getInteger("jvector.bench.recall.k", 10); + + final boolean RUN_ACCURACY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy", "false")); + + final boolean RUN_SINGLE_SCORING = Boolean.parseBoolean(System.getProperty( + "jvector.bench.single-scoring", System.getProperty("jvector.bench.scalar-scoring", "true"))); + + final boolean RUN_FLOAT_SCORING = + Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); + + // ASH header bits + final int HEADER_BITS = AsymmetricHashing.HEADER_BITS; + System.out.println("\tASH header: " + HEADER_BITS + " bits (scale=16, offset=16, landmark=8)"); + + // Block sizes to benchmark + final int[] BLOCK_SIZES = {32}; // Supported fused/multi-bit block capacities: 8, 16, 32. + + // How many ASH landmarks to use, C = [1, 256] + final int landmarkCount = Integer.getInteger("jvector.ash.landmarkCount", 1); + + int maxQueries = Integer.getInteger("jvector.bench.maxQueries", allQueries.size()); + if (maxQueries <= 0) throw new IllegalArgumentException("maxQueries must be positive"); + List> queries = allQueries.subList(0, Math.min(maxQueries, allQueries.size())); + if (RUN_RECALL_CHECK && groundTruth.size() < queries.size()) { + throw new IllegalArgumentException("Recall requires ground truth for every query"); + } + + // ASH normalization policy: + // + // - Base vectors x are NOT globally normalized. + // - For encoding, we compute μ on raw x. + // - The residual (x − μ) is normalized ONCE inside the binarizer, + // producing \hat{x} = (x − μ) / ||x − μ|| (ASH Paper, Eq. 6). + // - Queries may be L2-normalized in the benchmark (standard practice), + // but are NOT normalized inside the encoder. + // - No other normalization steps are applied. + + if (vectors.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Base and query vectors must be nonempty"); + } + int dimension = vectors.get(0).length(); + + final int bitsPerDimension = + Integer.getInteger("jvector.ash.bitsPerDimension", 4); + + final int quantizedDimensions = + Integer.getInteger("jvector.ash.quantizedDimensions", dimension / 2); + + if (bitsPerDimension < 1 || bitsPerDimension > 9) { + throw new IllegalArgumentException("bitsPerDimension must be 1 through 9: " + bitsPerDimension); + } + + if (quantizedDimensions <= 0 || quantizedDimensions > dimension) { + throw new IllegalArgumentException( + "Invalid quantizedDimensions=" + quantizedDimensions + + " for original dimension=" + dimension); + } + + int payloadBits = Math.multiplyExact(quantizedDimensions, bitsPerDimension); + int encodedBits = Math.addExact(HEADER_BITS, payloadBits); + + System.out.println( + "\toriginalDim=" + dimension + + ", quantizedDim=" + quantizedDimensions + + ", bitsPerDimension=" + bitsPerDimension + + ", payloadBits=" + payloadBits + + ", encodedBits=" + encodedBits); + + final List> finalQueries = queries; + final List> finalVectors = vectors; + + System.out.format("\t%d base and %d query vectors loaded, dimension=%d%n", + vectors.size(), queries.size(), dimension); + + // ------------------------------------------------------------------ + // Build ASH (centroids + optional training), then encode + // ------------------------------------------------------------------ + var ravv = new ListRandomAccessVectorValues(vectors, dimension); + + // Choose optimizer at runtime: + // -Djvector.ash.optimizer=random|itq + final int optimizer = parseOptimizerFromProperty(); + + logProgress("\tASH optimizer = " + optimizerName(optimizer)); + + logProgress("\t[stage] ASH initialize: starting (centroids + training)"); + long initStart = System.nanoTime(); + var ash = AsymmetricHashing.initialize( + ravv, optimizer, encodedBits, landmarkCount, bitsPerDimension); + long initEnd = System.nanoTime(); + logProgress("\t[stage] ASH initialize: done in " + (initEnd - initStart) / 1e9 + " seconds"); + + logProgress("\t[stage] ASH encodeAll: starting"); + long startTime = System.nanoTime(); + CompressedVectors ashVecs = ash.encodeAll(ravv); + long endTime = System.nanoTime(); + logProgress("\t[stage] ASH encodeAll: done in " + (endTime - startTime) / 1e9 + " seconds"); + + + double encSeconds = (endTime - startTime) / 1e9; + double encThroughput = vectors.size() / encSeconds; + + System.out.println( + "\tEncoding throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", encThroughput) + + " vectors/sec" + ); + + ASHVectors ashVectors = (ASHVectors) ashVecs; + VectorFloat q0 = finalQueries.get(0); + + // ------------------------------------------------------------------ + // Landmark reorder (debug + timing) + // ------------------------------------------------------------------ + final boolean REORDER_BY_LANDMARK = + Boolean.parseBoolean(System.getProperty("jvector.ash.reorderByLandmark", "true")); + + final ASHVectors ashVectorsFinal; + final CompressedVectors ashVecsFinal; + final int[] newToOldFinal; + + if (REORDER_BY_LANDMARK) { + long stat0 = System.nanoTime(); + var beforeStats = ashVectors.landmarkRunStats(); + long stat1 = System.nanoTime(); + + System.out.println("\tLandmark run stats BEFORE reorder: " + beforeStats + + " (computed in " + (stat1 - stat0) / 1e9 + " s)"); + + long t0 = System.nanoTime(); + ASHVectors.LandmarkOrder order = ashVectors.reorderByLandmarkFast(); + long t1 = System.nanoTime(); + + ASHVectors reordered = order.vectors; + + long stat2 = System.nanoTime(); + var afterStats = reordered.landmarkRunStats(); + long stat3 = System.nanoTime(); + + System.out.println("\tLandmark reorder took " + (t1 - t0) / 1e9 + " s"); + System.out.println("\tLandmark run stats AFTER reorder: " + afterStats + + " (computed in " + (stat3 - stat2) / 1e9 + " s)"); + System.out.println("--"); + + ashVectorsFinal = reordered; + ashVecsFinal = reordered; // CompressedVectors view used by scoreFunctionFor + newToOldFinal = order.newToOld; // map reordered ordinal -> original base ordinal + } else { + System.out.println("\tLandmark reorder disabled (-Djvector.ash.reorderByLandmark=false)"); + ashVectorsFinal = ashVectors; + ashVecsFinal = ashVectors; + newToOldFinal = null; + } + + if (Boolean.getBoolean("jvector.bench.int8-projection")) { + var executor = new ForkJoinPool(Integer.getInteger("jvector.bench.scoringThreads", 8)); + try { + benchmarkInt8Projection(ashVectorsFinal, queries, groundTruth, newToOldFinal, executor); + } finally { + executor.shutdown(); + } + return; + } + + { + ScoreFunction.ApproximateScoreFunction single = + ashVecsFinal.scoreFunctionFor(q0, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("single", single); + } + + if(RUN_SANITY_CHECK) { + + ASHBlockScorer ref = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT); + printScorerInfo("scalar-ref", ref); + + final int sanityBlockSize = BLOCK_SIZES[0]; + ASHBlockScorer blk = ashVectors.blockScorerFor(q0, VectorSimilarityFunction.DOT_PRODUCT, sanityBlockSize); + printScorerInfo("block", blk); + + + float[] a = new float[128]; + float[] b = new float[128]; + ref.scoreRange(0, 128, a); + blk.scoreRange(0, 128, b); + + final float EPS = 1e-6f; + + for (int i = 0; i < 128; i++) { + float diff = Math.abs(a[i] - b[i]); + if (diff > EPS) { + throw new AssertionError( + "Mismatch at " + i + + ": ref=" + a[i] + + ", blk=" + b[i] + + ", diff=" + diff); + } + } + } + + // ------------------------------------------------------------------ + // Shared parallelization setup (executor-honoring) + // ------------------------------------------------------------------ + // Default to one scoring thread for kernel/accumulator comparisons. + // For aggregate throughput, set -Djvector.bench.scoringThreads=N, + // where N is the number of physical cores available to this process. + // This controls accuracy, recall, and scoring tasks, not encodeAll. + final int scoringThreads = + Integer.parseInt( + System.getProperty("jvector.bench.scoringThreads", "1").trim()); + + if (scoringThreads <= 0) { + throw new IllegalArgumentException( + "scoringThreads must be positive: " + scoringThreads); + } + + final ForkJoinPool simdExecutor = new ForkJoinPool(scoringThreads); + + int parallelism = simdExecutor.getParallelism(); + int chunkSize = Math.max(1, (queries.size() + parallelism - 1) / parallelism); + + System.out.println("\tScoring parallelism = " + parallelism); + + if (Boolean.getBoolean("jvector.bench.symmetric-scoring")) { + // A separate comparison mode uses identical scan loops and prepared scorers. + // Encoding, validation, recall, and warmup are excluded. Projection/setup are timed. + try { + benchmarkSymmetric(ashVectorsFinal, finalQueries, groundTruth, newToOldFinal, + RECALL_K, RUN_RECALL_CHECK, simdExecutor); + } finally { + simdExecutor.shutdown(); + } + return; + } + + // ================================================================== + // [1] Accuracy run (NOT timed) + // ================================================================== + if (RUN_ACCURACY_CHECK) { + List> errorTasks = new ArrayList<>(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + errorTasks.add(simdExecutor.submit(() -> { + double localError = 0.0; + long localCount = 0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < vectors.size(); j++) { + final int baseOrd = (newToOldFinal == null) ? j : newToOldFinal[j]; + VectorFloat v = finalVectors.get(baseOrd); + + float trueSimilarity = VectorSimilarityFunction.DOT_PRODUCT.compare(q, v); + float approxSimilarity = f.similarityTo(j); + + localError += Math.abs(approxSimilarity - trueSimilarity); + localCount++; + } + + } + return new double[]{localError, localCount}; + })); + } + + double distanceError = 0.0; + long count = 0; + + for (ForkJoinTask t : errorTasks) { + double[] r = t.join(); + distanceError += r[0]; + count += (long) r[1]; + } + + distanceError /= count; + System.out.println("\tAverage absolute DOT_PRODUCT similarity error = " + distanceError); + } + + // ================================================================== + // [1b] Recall@K run (Parallelized) + // ================================================================== + if (RUN_RECALL_CHECK) { + int[] atValues = RECALL_ONLY_TEN ? new int[]{10} : new int[]{10, 15, 20, 30, 40, 50}; + int maxAt = atValues[atValues.length - 1]; + List> recallTasks = new ArrayList<>(); + + if (RUN_BLOCK_RECALL) { + validateRecallScorers(ashVectorsFinal, finalQueries, BLOCK_SIZES[0]); + } + + logProgress("\t[stage] Computing " + RECALL_K + "-Recall@K with " + + (COMPARE_ALL_RECALL ? "ASH block, single-vector and scalar" + : RUN_BLOCK_RECALL ? "ASH block" : "ASH single-vector") + " scoring..."); + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + recallTasks.add(simdExecutor.submit(() -> { + double[] localTotalRecall = new double[atValues.length * (COMPARE_ALL_RECALL ? 3 : 1)]; + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = RUN_BLOCK_RECALL && !COMPARE_ALL_RECALL ? null + : ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + ASHBlockScorer block = RUN_BLOCK_RECALL + ? ashVectorsFinal.blockScorerFor(q, VectorSimilarityFunction.DOT_PRODUCT, BLOCK_SIZES[0]) + : null; + ASHBlockScorer scalar = COMPARE_ALL_RECALL + ? ashVectorsFinal.blockScorerFor(q, VectorSimilarityFunction.DOT_PRODUCT) + : null; + float[] blockScores = RUN_BLOCK_RECALL ? new float[BLOCK_SIZES[0]] : null; + float[] scalarScores = COMPARE_ALL_RECALL ? new float[BLOCK_SIZES[0]] : null; + + // Min-heaps retain the top candidates from each scoring path. + var topCandidates = new java.util.PriorityQueue((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))); + java.util.PriorityQueue singleCandidates = COMPARE_ALL_RECALL + ? new java.util.PriorityQueue<>((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))) : null; + java.util.PriorityQueue scalarCandidates = COMPARE_ALL_RECALL + ? new java.util.PriorityQueue<>((a, b) -> Float.compare(Float.intBitsToFloat((int) a[0]), Float.intBitsToFloat((int) b[0]))) : null; + for (int startOrdinal = 0; startOrdinal < vectors.size(); startOrdinal += BLOCK_SIZES[0]) { + int count = Math.min(BLOCK_SIZES[0], vectors.size() - startOrdinal); + if (RUN_BLOCK_RECALL) block.scoreRange(startOrdinal, count, blockScores); + if (COMPARE_ALL_RECALL) scalar.scoreRange(startOrdinal, count, scalarScores); + for (int lane = 0; lane < count; lane++) { + int ordinal = startOrdinal + lane; + float score = RUN_BLOCK_RECALL ? blockScores[lane] : f.similarityTo(ordinal); + offerTop(topCandidates, maxAt, score, ordinal); + if (COMPARE_ALL_RECALL) { + offerTop(singleCandidates, maxAt, f.similarityTo(ordinal), ordinal); + offerTop(scalarCandidates, maxAt, scalarScores[lane], ordinal); + } + } + } + + int[] queryGT = groundTruth.get(i).stream() + .mapToInt(Integer::intValue) + .toArray(); + addRecall(rankedOrdinals(topCandidates), queryGT, newToOldFinal, + RECALL_K, atValues, localTotalRecall, 0); + if (COMPARE_ALL_RECALL) { + addRecall(rankedOrdinals(singleCandidates), queryGT, newToOldFinal, + RECALL_K, atValues, localTotalRecall, atValues.length); + addRecall(rankedOrdinals(scalarCandidates), queryGT, newToOldFinal, + RECALL_K, atValues, localTotalRecall, 2 * atValues.length); + } + } + return localTotalRecall; + })); + } + + double[] totalRecall = new double[atValues.length * (COMPARE_ALL_RECALL ? 3 : 1)]; + for (ForkJoinTask t : recallTasks) { + double[] local = t.join(); + for (int aIdx = 0; aIdx < totalRecall.length; aIdx++) totalRecall[aIdx] += local[aIdx]; + } + + String[] scorerNames = COMPARE_ALL_RECALL ? new String[]{"block", "single", "scalar"} + : new String[]{RUN_BLOCK_RECALL ? "block" : "single"}; + for (int scorer = 0; scorer < scorerNames.length; scorer++) { + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + System.out.format(java.util.Locale.ROOT, "\tASH %s %d-recall@%d = %.6f%n", + scorerNames[scorer], RECALL_K, atValues[aIdx], + totalRecall[scorer * atValues.length + aIdx] / queries.size()); + } + } + } + + // ================================================================== + // [2] Single-vector ASH scoring, using the selected singleKernel + // ================================================================== + if (RUN_SINGLE_SCORING) { + warmupASH(ashVectorsFinal, finalQueries, simdExecutor, 0); + List> ashTasks = new ArrayList<>(); + + long ashStart = System.nanoTime(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + ashTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + ScoreFunction.ApproximateScoreFunction f = + ashVecsFinal.scoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < vectors.size(); j++) { + localSum += f.similarityTo(j); + } + } + return localSum; + })); + } + + double ashDummy = 0.0; + for (ForkJoinTask t : ashTasks) { + ashDummy += t.join(); + } + + long ashEnd = System.nanoTime(); + + double singleSeconds = (ashEnd - ashStart) / 1e9; + long totalDotProducts = (long) queries.size() * (long) vectors.size(); + double singleThroughput = totalDotProducts / singleSeconds; + + System.out.println("\tSingle " + singleMode + " scoring took " + + singleSeconds + " seconds"); + + System.out.println( + "\tSingle " + singleMode + " throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", singleThroughput / 1e6) + + " Mdot/s)" + ); + + System.out.println("\tdummyAccumulator = " + (float) (ashDummy)); + System.out.println("--"); + + } + + // ================================================================== + // [3] Float dot-product scoring (ground truth baseline) + // ================================================================== + if (RUN_FLOAT_SCORING) { + List> floatTasks = new ArrayList<>(); + + long floatStart = System.nanoTime(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + floatTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + + for (int i = s; i < e; i++) { + VectorFloat q = finalQueries.get(i); + for (int j = 0; j < vectors.size(); j++) { + localSum += VectorUtil.dotProduct(q, finalVectors.get(j)); + } + } + return localSum; + })); + } + + double floatDummy = 0.0; + for (ForkJoinTask t : floatTasks) { + floatDummy += t.join(); + } + + long floatEnd = System.nanoTime(); + + System.out.println("\tFloat dot-product computations took " + + (floatEnd - floatStart) / 1e9 + " seconds"); + + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (floatDummy)); + System.out.println("--"); + } + + // ================================================================== + // [4] Block scoring timing (single mode per JVM run) + // NOTE: Choose the block kernel using: + // -Djvector.ash.blockKernel=scalar|simd|auto + // ================================================================== + final String kernelMode = + System.getProperty("jvector.ash.blockKernel", "auto").toLowerCase(); + + if (RUN_BLOCK_TIMING) for (int blockSize : BLOCK_SIZES) { + + // Print scorer implementation once per blockSize (diagnostic) + { + ASHBlockScorer scorer = + ashVectorsFinal.blockScorerFor( + finalQueries.get(0), + VectorSimilarityFunction.DOT_PRODUCT, + blockSize + ); + + printScorerInfo(kernelMode + " (blockSize=" + blockSize + ")", scorer); + } + + warmupASH(ashVectorsFinal, finalQueries, simdExecutor, blockSize); + List> blockTasks = new ArrayList<>(); + long blockStart = System.nanoTime(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + blockTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + final float[] scores = new float[blockSize]; + + for (int qi = s; qi < e; qi++) { + ASHBlockScorer scorer = + ashVectorsFinal.blockScorerFor( + finalQueries.get(qi), + VectorSimilarityFunction.DOT_PRODUCT, + blockSize + ); + + int j = 0; + while (j + blockSize <= vectors.size()) { + scorer.scoreRange(j, blockSize, scores); + for (int k = 0; k < blockSize; k++) { + localSum += scores[k]; + } + j += blockSize; + } + + // Exercise the selected block kernel for partial batches as well. + if (j < vectors.size()) { + int count = vectors.size() - j; + scorer.scoreRange(j, count, scores); + for (int k = 0; k < count; k++) localSum += scores[k]; + } + } + + return localSum; + })); + } + + double blockDummy = 0.0; + for (ForkJoinTask t : blockTasks) { + blockDummy += t.join(); + } + + long blockEnd = System.nanoTime(); + + System.out.println( + "\tBlock " + kernelMode + " (blockSize=" + blockSize + ") took " + + (blockEnd - blockStart) / 1e9 + " seconds" + ); + + double blockSeconds = (blockEnd - blockStart) / 1e9; + long totalDotProducts = (long) queries.size() * (long) vectors.size(); + + double scoreThroughput = totalDotProducts / blockSeconds; + + System.out.println( + "\tBlock " + kernelMode + " throughput (blockSize=" + blockSize + ") = " + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) + + " Mdot/s)" + ); + + System.out.println("\tdummyAccumulator = " + (float) (blockDummy)); + System.out.println("--"); + } + } + + public static void main(String[] args) throws IOException { + // Dispatch before the dataset modes: saved fixtures need no positional inputs. + if (Boolean.getBoolean("jvector.bench.neighborhood-scoring")) { + ASHNeighborhoodBenchmark.run(args); + return; + } + if (args.length == 3) { + testASHEncodings(args[0], args[1], args[2]); + return; + } + if (args.length > 1) { + throw new IllegalArgumentException( + "Usage: DistancesASH [dataset-name] | "); + } + String name = args.length == 0 ? DEFAULT_DATASET : args[0]; + try (DataSet dataset = DataSets.loadDataSet(name) + .orElseThrow(() -> new IllegalArgumentException("Unknown dataset: " + name)).getDataSet()) { + var similarity = dataset.getSimilarityFunction(); + if (similarity != VectorSimilarityFunction.DOT_PRODUCT && similarity != VectorSimilarityFunction.COSINE) { + throw new IllegalArgumentException("ASH scores inner products; dataset " + name + " uses " + similarity); + } + System.out.println("Running " + dataset.getName() + ", similarity " + similarity); + testASHEncodings(dataset.getBaseVectors(), dataset.getQueryVectors(), dataset.getGroundTruth()); + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java new file mode 100644 index 000000000..4e6b97168 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesFP.java @@ -0,0 +1,308 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import com.sun.management.ThreadMXBean; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.SplittableRandom; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import java.util.stream.IntStream; + +/** + * FP32 distance benchmark. Pass a dataset name from the public dataset catalog (default + * {@value #DEFAULT_DATASET}; it is downloaded and cached on first use) or {@code --synthetic dimensions} + * for random unit vectors. Use separate JVMs with {@code jvector.vectorization_provider} set to + * {@code native}, {@code panama}, or {@code default} to compare backends. + * + *

Timing mode (the default) runs on one thread. Properties (prefix {@code jvector.bench.}): + * {@code maxBaseVectors} (65536), {@code maxQueries} (16), {@code candidatesPerQuery} (entire base), + * {@code scoringWarmupPasses} (3), and {@code scoringSamples} (3). A smaller candidate count gives + * each query a different window of the selected traversal order. The direct mode calls the + * production similarity. The construction mode creates the same exact scorer used for graph + * insertion once per query and includes that setup in the measurement. Input loading and shuffling + * happen outside the measurements. The first pass is reported separately because it can initialize + * native vector views and compile code. Allocation measurements count Java heap bytes on the + * scoring thread.

+ * + *

Recall mode ({@code -Djvector.bench.recall=true}), like DistancesASH and DistancesPQ, scores + * every query against the whole base with the dataset's similarity and reports exact FP32 + * {@code recall.k}-recall@N against the dataset's ground truth for N = 10, 15, 20, 30, 40 and 50. + * It uses all base vectors and all queries (or {@code maxQueries}, if set), {@code recall.k} + * (default 10), and {@code scoringThreads} threads (default 1), and skips the timing measurements. + * A {@code recall.k}-recall@{@code recall.k} below 1.0 means the ground truth disagrees with an exact scan.

+ */ +public final class DistancesFP { + private DistancesFP() {} + + static final String DEFAULT_DATASET = "e5-small-v2-100k"; + + public static void main(String[] args) throws IOException { + if (args.length == 2 && args[0].equals("--synthetic")) { + int dimension = Integer.parseInt(args[1]); + if (dimension <= 0) throw new IllegalArgumentException("Dimensions must be positive"); + int maxBase = positiveProperty("maxBaseVectors", 65_536); + int maxQueries = positiveProperty("maxQueries", 16); + var random = new SplittableRandom(42); + var provider = VectorizationProvider.getInstance(); + timing(syntheticVectors(provider, random, maxBase, dimension), + syntheticVectors(provider, random, maxQueries, dimension), VectorSimilarityFunction.DOT_PRODUCT); + return; + } + if (args.length > 1) { + throw new IllegalArgumentException("Usage: DistancesFP [dataset-name] | --synthetic "); + } + String name = args.length == 0 ? DEFAULT_DATASET : args[0]; + try (DataSet dataset = DataSets.loadDataSet(name) + .orElseThrow(() -> new IllegalArgumentException("Unknown dataset: " + name)).getDataSet()) { + System.out.printf(Locale.ROOT, "Dataset %s, similarity %s%n", dataset.getName(), dataset.getSimilarityFunction()); + if (Boolean.getBoolean("jvector.bench.recall")) { + recall(dataset); + } else { + var base = dataset.getBaseVectors(); + var queries = dataset.getQueryVectors(); + timing(base.subList(0, Math.min(base.size(), positiveProperty("maxBaseVectors", 65_536))), + queries.subList(0, Math.min(queries.size(), positiveProperty("maxQueries", 16))), + dataset.getSimilarityFunction()); + } + } + } + + private static void timing(List> base, List> queries, VectorSimilarityFunction similarity) { + int warmups = positiveProperty("scoringWarmupPasses", 3); + int samples = positiveProperty("scoringSamples", 3); + var provider = VectorizationProvider.getInstance(); + if (base.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Base and query vectors must be nonempty"); + } + int dimensions = base.get(0).length(); + if (base.stream().anyMatch(v -> v.length() != dimensions) + || queries.stream().anyMatch(v -> v.length() != dimensions)) { + throw new IllegalArgumentException("All base and query vectors must have the same dimension"); + } + int candidates = positiveProperty("candidatesPerQuery", base.size()); + if (candidates > base.size()) throw new IllegalArgumentException("candidatesPerQuery exceeds the base size"); + + var alloc = ManagementFactory.getPlatformMXBean(ThreadMXBean.class); + if (alloc == null || !alloc.isThreadAllocatedMemorySupported()) { + throw new IllegalStateException("This JVM does not support per-thread heap allocation measurements"); + } + if (!alloc.isThreadAllocatedMemoryEnabled()) alloc.setThreadAllocatedMemoryEnabled(true); + var scoreProvider = BuildScoreProvider.randomAccessScoreProvider( + new ListRandomAccessVectorValues(base, dimensions), similarity); + var sequential = IntStream.range(0, base.size()).toArray(); + var shuffled = sequential.clone(); + var random = new SplittableRandom(43); + for (int i = shuffled.length - 1; i > 0; i--) { + int j = random.nextInt(i + 1); + int saved = shuffled[i]; + shuffled[i] = shuffled[j]; + shuffled[j] = saved; + } + + System.out.printf(Locale.ROOT, "DistancesFP provider=%s dimensions=%d base=%d queries=%d candidates/query=%d%n", + provider.getClass().getSimpleName(), dimensions, base.size(), queries.size(), candidates); + System.out.println("phase,access,scorer,sample,scores,seconds,ns_per_score,million_scores_per_s," + + "allocated_bytes,bytes_per_score,bytes_per_query,checksum"); + run("sequential", sequential, base, queries, candidates, similarity, scoreProvider, alloc, warmups, samples); + run("shuffled", shuffled, base, queries, candidates, similarity, scoreProvider, alloc, warmups, samples); + } + + private static void run(String access, int[] order, List> base, List> queries, + int candidates, VectorSimilarityFunction similarity, BuildScoreProvider provider, + ThreadMXBean alloc, int warmups, int samples) { + long thread = Thread.currentThread().getId(); + long comparisons = (long) candidates * queries.size(); + double expected = Double.NaN; + for (String scorer : List.of("direct", "construction")) { + boolean construction = scorer.equals("construction"); + for (int sample = -warmups; sample < samples; sample++) { + long beforeBytes = alloc.getThreadAllocatedBytes(thread); + long start = System.nanoTime(); + double checksum = score(order, base, queries, candidates, similarity, provider, construction); + long elapsed = System.nanoTime() - start; + long bytes = alloc.getThreadAllocatedBytes(thread) - beforeBytes; + if (!Double.isFinite(checksum)) throw new IllegalStateException("Non-finite score checksum"); + if (sample >= 0) { + if (Double.isNaN(expected)) expected = checksum; + if (checksum != expected) throw new IllegalStateException("Direct and construction scores differ"); + } + if (sample == -warmups || sample >= 0) { + System.out.printf(Locale.ROOT, "%s,%s,%s,%d,%d,%.6f,%.2f,%.3f,%d,%.6f,%.2f,%.9f%n", + sample < 0 ? "first-pass" : "steady", access, scorer, sample < 0 ? 1 : sample + 1, + comparisons, elapsed / 1e9, (double) elapsed / comparisons, + comparisons * 1e3 / elapsed, bytes, (double) bytes / comparisons, + (double) bytes / queries.size(), checksum); + } + } + } + } + + private static double score(int[] order, List> base, List> queries, + int candidates, VectorSimilarityFunction similarity, BuildScoreProvider provider, + boolean construction) { + double checksum = 0; + for (int q = 0; q < queries.size(); q++) { + var query = queries.get(q); + int start = (int) ((long) q * candidates % (order.length - candidates + 1)); + int end = start + candidates; + if (construction) { + var scorer = provider.searchProviderFor(query).scoreFunction(); + for (int i = start; i < end; i++) checksum += scorer.similarityTo(order[i]); + } else { + for (int i = start; i < end; i++) checksum += similarity.compare(query, base.get(order[i])); + } + } + return checksum; + } + + /** Exact FP32 recall against the dataset's ground truth, scoring every query against the whole base. */ + private static void recall(DataSet dataset) { + int recallK = positiveProperty("recall.k", 10); + int threads = positiveProperty("scoringThreads", 1); + int[] atValues = {10, 15, 20, 30, 40, 50}; + int maxAt = atValues[atValues.length - 1]; + var similarity = dataset.getSimilarityFunction(); + List> base = dataset.getBaseVectors(); + List> allQueries = dataset.getQueryVectors(); + List> queries = allQueries.subList(0, + Math.min(allQueries.size(), positiveProperty("maxQueries", Integer.MAX_VALUE))); + List> groundTruth = dataset.getGroundTruth(); + if (base.isEmpty() || queries.isEmpty()) throw new IllegalArgumentException("Base and query vectors must be nonempty"); + if (groundTruth.size() < queries.size()) + throw new IllegalArgumentException("Ground truth has " + groundTruth.size() + " rows for " + queries.size() + " queries"); + if (maxAt > base.size()) throw new IllegalArgumentException("Recall needs at least " + maxAt + " base vectors"); + for (int q = 0; q < queries.size(); q++) { + if (groundTruth.get(q).size() < recallK) + throw new IllegalArgumentException("Ground truth row " + q + " has fewer than recall.k=" + recallK + " entries"); + } + int dimensions = base.get(0).length(); + System.out.printf(Locale.ROOT, "DistancesFP recall provider=%s dimensions=%d base=%d queries=%d recall.k=%d threads=%d%n", + VectorizationProvider.getInstance().getClass().getSimpleName(), dimensions, base.size(), + queries.size(), recallK, threads); + long start = System.nanoTime(); + double[] totals = new double[atValues.length]; + var pool = new ForkJoinPool(threads); + try { + int chunk = (queries.size() + threads - 1) / threads; + var tasks = new ArrayList>(); + for (int first = 0; first < queries.size(); first += chunk) { + int from = first, to = Math.min(first + chunk, queries.size()); + tasks.add(pool.submit(() -> { + double[] local = new double[atValues.length]; + int[][] ids = new int[QUERY_BLOCK][maxAt]; + float[][] scores = new float[QUERY_BLOCK][maxAt]; + int[] sizes = new int[QUERY_BLOCK]; + for (int block = from; block < to; block += QUERY_BLOCK) { + int count = Math.min(QUERY_BLOCK, to - block); + topScores(queries.subList(block, block + count), base, similarity, maxAt, ids, scores, sizes); + for (int b = 0; b < count; b++) { + var expected = new HashSet(groundTruth.get(block + b).subList(0, recallK)); + for (int a = 0; a < atValues.length; a++) { + int matches = 0; + for (int r = 0; r < Math.min(atValues[a], sizes[b]); r++) { + if (expected.contains(ids[b][r])) matches++; + } + local[a] += (double) matches / expected.size(); + } + } + } + return local; + })); + } + for (var task : tasks) { + double[] local = task.join(); + for (int a = 0; a < totals.length; a++) totals[a] += local[a]; + } + } finally { + pool.shutdown(); + } + for (int a = 0; a < atValues.length; a++) { + System.out.printf(Locale.ROOT, "\tFP32 exact %d-recall@%d = %.6f%n", recallK, atValues[a], totals[a] / queries.size()); + } + System.out.printf(Locale.ROOT, "\tRecall scan time: %.1f s%n", (System.nanoTime() - start) / 1e9); + } + + /** Queries scored per pass over the base, so each base vector is read from memory once per block. */ + private static final int QUERY_BLOCK = 32; + + /** + * For each query, fills ids[q]/scores[q] with its n highest similarity scores, best first, and sizes[q] + * with how many were filled. All queries share one pass over the base. + */ + private static void topScores(List> queries, List> base, + VectorSimilarityFunction similarity, int n, int[][] ids, float[][] scores, int[] sizes) { + Arrays.fill(sizes, 0); + for (int i = 0; i < base.size(); i++) { + var vector = base.get(i); + for (int q = 0; q < queries.size(); q++) { + float score = similarity.compare(queries.get(q), vector); + float[] top = scores[q]; + int size = sizes[q]; + if (size == n && score <= top[n - 1]) continue; + int[] topIds = ids[q]; + int pos = size < n ? size++ : n - 1; + while (pos > 0 && top[pos - 1] < score) { + top[pos] = top[pos - 1]; + topIds[pos] = topIds[pos - 1]; + pos--; + } + top[pos] = score; + topIds[pos] = i; + sizes[q] = size; + } + } + } + + private static List> syntheticVectors(VectorizationProvider provider, SplittableRandom random, + int count, int dimensions) { + var support = provider.getVectorTypeSupport(); + var vectors = new ArrayList>(count); + for (int i = 0; i < count; i++) { + var values = new float[dimensions]; + double squaredNorm = 0; + for (int d = 0; d < dimensions; d++) { + values[d] = (float) random.nextDouble(-1, 1); + squaredNorm += (double) values[d] * values[d]; + } + double scale = 1 / Math.sqrt(squaredNorm); + for (int d = 0; d < dimensions; d++) values[d] = (float) (values[d] * scale); + vectors.add(support.createFloatVector(values)); + } + return vectors; + } + + private static int positiveProperty(String name, int defaultValue) { + int value = Integer.parseInt(System.getProperty("jvector.bench." + name, Integer.toString(defaultValue))); + if (value <= 0) throw new IllegalArgumentException(name + " must be positive"); + return value; + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java new file mode 100644 index 000000000..cbe7746e7 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/DistancesPQ.java @@ -0,0 +1,535 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.util.SiftLoader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.quantization.CompressedVectors; +import io.github.jbellis.jvector.quantization.PQVectors; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; + +public class DistancesPQ { + + /** + * PQ benchmark aligned with DistancesASH: + * + * - Same datasets + * - Same loaded query vectors + * - Same brute-force scan pattern + * - Configurable training and single-thread scoring executors + * - Compression budget selected with jvector.pq.subspaces (exact bytes), or jvector.pq.mFactor + * + * Fast PQ path = PQDecoder (precomputedScoreFunctionFor). + */ + public static void testPQEncodings(String filenameBase, String filenameQueries, String filenameGT) throws IOException { + // ------------------------------------------------------------ + // Benchmark configuration (runtime flags) + // ------------------------------------------------------------ + final boolean RUN_RECALL_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.recall", "false")); + + final int RECALL_K = + Integer.getInteger("jvector.bench.recall.k", 10); + + final boolean RUN_ACCURACY_CHECK = + Boolean.parseBoolean(System.getProperty("jvector.bench.accuracy-check", "false")); + final boolean RUN_FLOAT_SCORING = + Boolean.parseBoolean(System.getProperty("jvector.bench.float-scoring", "false")); + + List> vectors = SiftLoader.readFvecs(filenameBase); + List> allQueries = SiftLoader.readFvecs(filenameQueries); + int maxQueries = Integer.getInteger("jvector.bench.maxQueries", allQueries.size()); + if (maxQueries <= 0) throw new IllegalArgumentException("maxQueries must be positive"); + final List> queries = allQueries.subList(0, Math.min(maxQueries, allQueries.size())); + if (vectors.isEmpty() || queries.isEmpty()) { + throw new IllegalArgumentException("Base and query vectors must be nonempty"); + } + System.out.format("\t%d base and %d measured query vectors loaded%n", vectors.size(), queries.size()); + + final List> groundTruth; + if (RUN_RECALL_CHECK) { + if (filenameGT == null || filenameGT.trim().isEmpty()) { + throw new IllegalArgumentException( + "Recall is enabled but no ground-truth ivec file was provided" + ); + } + + groundTruth = SiftLoader.readIvecs(filenameGT); + + if (groundTruth.size() < queries.size()) { + throw new IllegalArgumentException( + "Ground truth contains " + groundTruth.size() + + " rows but there are " + queries.size() + " queries" + ); + } + + for (int i = 0; i < queries.size(); i++) { + if (groundTruth.get(i).size() < RECALL_K) { + throw new IllegalArgumentException( + "Ground truth row " + i + " has only " + + groundTruth.get(i).size() + + " entries, but recall.k=" + RECALL_K + ); + } + } + } else { + groundTruth = java.util.Collections.emptyList(); + } + + // ------------------------------------------------------------ + // Parallel executor (identical to DistancesASH) + // ------------------------------------------------------------ + int trainingThreads = Integer.getInteger("jvector.bench.trainingThreads", 48); + int scoringThreads = Integer.getInteger("jvector.bench.scoringThreads", 1); + if (trainingThreads <= 0 || scoringThreads <= 0) throw new IllegalArgumentException("Thread counts must be positive"); + ForkJoinPool trainingExecutor = new ForkJoinPool(trainingThreads); + ForkJoinPool simdExecutor = new ForkJoinPool(scoringThreads); + int parallelism = simdExecutor.getParallelism(); + int chunkSize = Math.max(1, (queries.size() + parallelism - 1) / parallelism); + System.out.println("\tTraining parallelism = " + trainingThreads + "; scoring parallelism = " + parallelism); + + // ------------------------------------------------------------ + // PQ parameters + // ------------------------------------------------------------ + // K=256 stores one byte per subspace. An explicit subspace count matches + // any byte budget up to the input dimension, including uneven partitions. + // The legacy mFactor option instead requires evenly divisible dimensions. + int dimension = vectors.get(0).length(); + + final int M = subspaceCount(dimension); + final int K = 256; // 1 byte per subspace + final boolean globallyCenter = false; + final float anisotropicThreshold = -1.0f; + + final int inputBytesPerVector = dimension * Float.BYTES; + final int compressedBytesPerVector = M; + final double compressionRatio = + (double) inputBytesPerVector / compressedBytesPerVector; + + System.out.println("\tPQ params:"); + System.out.println("\t dimension = " + dimension); + System.out.println("\t budget selection = " + (System.getProperty("jvector.pq.subspaces") == null + ? "dimension / mFactor" : "explicit subspaces (uneven partitions supported)")); + System.out.println("\t M = " + M + " subspaces"); + System.out.println("\t K = " + K + " clusters"); + System.out.println("\t Input size = " + inputBytesPerVector + " bytes/vector"); + System.out.println("\t PQ size = " + compressedBytesPerVector + " bytes/vector"); + System.out.println( + "\t Compression ratio = " + + String.format(java.util.Locale.ROOT, "%.3f", compressionRatio) + + "x" + ); + + // ------------------------------------------------------------ + // Train PQ + encode (timed) + // ------------------------------------------------------------ + RandomAccessVectorValues ravv = + new ListRandomAccessVectorValues(vectors, dimension); + + long pqTrainStart = System.nanoTime(); + ProductQuantization pq = ProductQuantization.compute( + ravv, + M, + K, + globallyCenter, + anisotropicThreshold, + trainingExecutor, + ForkJoinPool.commonPool() + ); + long pqTrainEnd = System.nanoTime(); + + long pqEncodeStart = System.nanoTime(); + CompressedVectors pqAny = pq.encodeAll(ravv, trainingExecutor); + long pqEncodeEnd = System.nanoTime(); + + PQVectors pqVecs = (PQVectors) pqAny; + trainingExecutor.shutdown(); + + System.out.println("\tPQ training took " + (pqTrainEnd - pqTrainStart) / 1e9 + " seconds"); + System.out.println("\tPQ encoding took " + (pqEncodeEnd - pqEncodeStart) / 1e9 + " seconds"); + double encSeconds = (pqEncodeEnd - pqEncodeStart) / 1e9; + double encThroughput = vectors.size() / encSeconds; + + System.out.println( + "\tEncoding throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", encThroughput) + + " vectors/sec" + ); + + + System.out.println("\tShared PQ compressor size = " + pq.compressorSize() + " bytes"); + + // ============================================================ + // [1] Accuracy (NOT timed) + // Compare unscaled dot products: + // PQ returns (1 + dp)/2 -> dp = 2*score - 1 + // ============================================================ + if (RUN_ACCURACY_CHECK) { + List> errorTasks = new ArrayList<>(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + errorTasks.add(simdExecutor.submit(() -> { + double localError = 0.0; + long localCount = 0; + + for (int i = s; i < e; i++) { + VectorFloat q = queries.get(i); + ScoreFunction.ApproximateScoreFunction f = + pqVecs.precomputedScoreFunctionFor(q, VectorSimilarityFunction.DOT_PRODUCT); + + for (int j = 0; j < vectors.size(); j++) { + float trueDot = VectorUtil.dotProduct(q, vectors.get(j)); + float approxScaled = f.similarityTo(j); + float approxDot = 2.0f * approxScaled - 1.0f; + + localError += Math.abs(approxDot - trueDot); + localCount++; + } + } + return new double[]{localError, localCount}; + })); + } + + double totalError = 0.0; + long totalCount = 0; + for (ForkJoinTask t : errorTasks) { + double[] r = t.join(); + totalError += r[0]; + totalCount += (long) r[1]; + } + + System.out.println("\tAverage absolute dot-product error = " + (totalError / totalCount)); + } + + // ============================================================ + // [1b] Recall@K run (NOT timed) + // ============================================================ + if (RUN_RECALL_CHECK) { + final int[] atValues = {10, 15, 20, 30, 40, 50}; + final int maxAt = atValues[atValues.length - 1]; + + List> recallTasks = new ArrayList<>(); + + System.out.println("\t[stage] Computing " + RECALL_K + "-Recall@K..."); + System.out.flush(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + recallTasks.add(simdExecutor.submit(() -> { + double[] localTotalRecall = new double[atValues.length]; + + for (int i = s; i < e; i++) { + ScoreFunction.ApproximateScoreFunction f = + pqVecs.precomputedScoreFunctionFor( + queries.get(i), + VectorSimilarityFunction.DOT_PRODUCT + ); + + // Min-heap keeping the top maxAt approximate results. + var topCandidates = + new java.util.PriorityQueue((a, b) -> + Float.compare( + Float.intBitsToFloat((int) a[0]), + Float.intBitsToFloat((int) b[0]) + )); + + for (int j = 0; j < vectors.size(); j++) { + float score = f.similarityTo(j); + + if (topCandidates.size() < maxAt) { + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } else if (score > Float.intBitsToFloat((int) topCandidates.peek()[0])) { + topCandidates.poll(); + topCandidates.add(new long[]{Float.floatToRawIntBits(score), j}); + } + } + + int[] topIndices = new int[topCandidates.size()]; + for (int rank = topCandidates.size() - 1; rank >= 0; rank--) { + topIndices[rank] = (int) topCandidates.poll()[1]; + } + + int[] queryGT = groundTruth.get(i).stream() + .mapToInt(Integer::intValue) + .toArray(); + + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + int at = atValues[aIdx]; + + java.util.Set topAtSet = new java.util.HashSet<>(); + for (int r = 0; r < Math.min(at, topIndices.length); r++) { + topAtSet.add(topIndices[r]); + } + + int matches = 0; + java.util.Set gtSeen = new java.util.HashSet<>(RECALL_K * 2); + + // Avoid double-counting duplicate GT ids. + for (int g = 0; g < RECALL_K; g++) { + int gtId = queryGT[g]; + if (gtSeen.add(gtId) && topAtSet.contains(gtId)) { + matches++; + } + } + + localTotalRecall[aIdx] += (double) matches / RECALL_K; + } + } + + return localTotalRecall; + })); + } + + double[] totalRecall = new double[atValues.length]; + for (ForkJoinTask t : recallTasks) { + double[] local = t.join(); + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + totalRecall[aIdx] += local[aIdx]; + } + } + + for (int aIdx = 0; aIdx < atValues.length; aIdx++) { + System.out.format( + "\tPQ %d-recall@%d = %.4f%n", + RECALL_K, + atValues[aIdx], + totalRecall[aIdx] / queries.size() + ); + } + } + + // ============================================================ + // [2] Fast PQ scan timing (PQDecoder / ADC) + // ============================================================ + int warmupQueries = Integer.getInteger("jvector.bench.scoringWarmupQueries", 32); + if (warmupQueries < 0) throw new IllegalArgumentException("scoringWarmupQueries must be nonnegative"); + double warmupSum = 0; + for (int i = 0; i < Math.min(warmupQueries, queries.size()); i++) { + var f = pqVecs.precomputedScoreFunctionFor(queries.get(i), VectorSimilarityFunction.DOT_PRODUCT); + for (int j = 0; j < vectors.size(); j++) warmupSum += f.similarityTo(j); + } + System.out.printf(java.util.Locale.ROOT, "\tPQ warmup: %d queries, checksum=%.6f (excluded from timing)%n", + Math.min(warmupQueries, queries.size()), warmupSum); + List> pqTasks = new ArrayList<>(); + long pqStart = System.nanoTime(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + pqTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + for (int i = s; i < e; i++) { + ScoreFunction.ApproximateScoreFunction f = + pqVecs.precomputedScoreFunctionFor( + queries.get(i), + VectorSimilarityFunction.DOT_PRODUCT + ); + for (int j = 0; j < vectors.size(); j++) { + localSum += f.similarityTo(j); + } + } + return localSum; + })); + } + + double pqDummy = 0.0; + for (ForkJoinTask t : pqTasks) { + pqDummy += t.join(); + } + long pqEnd = System.nanoTime(); + // Prevent dead-code elimination outside the measured interval. + System.out.println("\tdummyAccumulator = " + (float) (pqDummy)); + System.out.println("--"); + System.out.println("\tPQDecoder scan took " + (pqEnd - pqStart) / 1e9 + " seconds"); + + double blockSeconds = (pqEnd - pqStart) / 1e9; + long totalDotProducts = (long) queries.size() * (long) vectors.size(); + + double scoreThroughput = totalDotProducts / blockSeconds; + + System.out.println( + "\tDecoder scan throughput = " + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput) + + " dot-products/sec" + + " (" + + String.format(java.util.Locale.ROOT, "%.3f", scoreThroughput / 1e6) + + " Mdot/s)" + ); + + // ============================================================ + // [3] Float dot-product baseline + // ============================================================ + if (RUN_FLOAT_SCORING) { + List> floatTasks = new ArrayList<>(); + long floatStart = System.nanoTime(); + + for (int start = 0; start < queries.size(); start += chunkSize) { + final int s = start; + final int e = Math.min(start + chunkSize, queries.size()); + + floatTasks.add(simdExecutor.submit(() -> { + double localSum = 0.0; + for (int i = s; i < e; i++) { + VectorFloat q = queries.get(i); + for (int j = 0; j < vectors.size(); j++) { + localSum += VectorUtil.dotProduct(q, vectors.get(j)); + } + } + return localSum; + })); + } + + double floatDummy = 0.0; + for (ForkJoinTask t : floatTasks) { + floatDummy += t.join(); + } + + long floatEnd = System.nanoTime(); + System.out.println("\tFloat dot-product scan took " + (floatEnd - floatStart) / 1e9 + " seconds"); + + // Prevent dead-code elimination + System.out.println("\tdummyAccumulator = " + (float) (floatDummy)); + System.out.println("--"); + } + } + + /** Resolve an exact byte budget, or retain the legacy evenly divisible mFactor setting. */ + static int subspaceCount(int dimension) { + String exact = System.getProperty("jvector.pq.subspaces"); + if (exact != null) { + if (System.getProperty("jvector.pq.mFactor") != null) { + throw new IllegalArgumentException("Set either jvector.pq.subspaces or jvector.pq.mFactor"); + } + int count = Integer.parseInt(exact); + if (count < 1 || count > dimension) { + throw new IllegalArgumentException("PQ subspaces must be in [1, dimension]"); + } + return count; + } + int factor = Integer.parseInt(System.getProperty("jvector.pq.mFactor", "8")); + if (factor < 1 || dimension % factor != 0) { + throw new IllegalArgumentException("dimension must be evenly divisible by positive jvector.pq.mFactor"); + } + return dimension / factor; + } + + // ------------------------------------------------------------------ + // Dataset runners (mirrors DistancesASH) + // ------------------------------------------------------------------ + + public static void runCohere100k() throws IOException { + System.out.println("Running Cohere-100k"); + + var baseVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_base_vectors_100000.fvec"; + var queryVectors = "./fvec/cohere-100k/cohere_embed-english-v3.0_1024_query_vectors_10000.fvec"; + var gtVectors = ""; // Set this to the GT file for this exact base/query ordering before enabling recall. + + testPQEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runADA() throws IOException { + System.out.println("Running ada_002"); + + var baseVectors = "./fvec/ada-002/ada_002_100000_base_vectors.fvec"; + var queryVectors = "./fvec/ada-002/ada_002_100000_query_vectors_10000.fvec"; + var gtVectors = "./fvec/ada-002/ada_002_100000_indices_query_10000.ivec"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runADANoZeros() throws IOException { + System.out.println("Running ada-002-no-zeros"); + + var baseVectors = "./fvec/ada-002-no-zeros/ada_002_100000_base_vectors_no_zeros.fvec"; + var queryVectors = "./fvec/ada-002-no-zeros/ada_002_100000_query_vectors_10000_no_zeros.fvec"; + var gtVectors = "./fvec/ada-002-no-zeros/ada-002_gt_no_zeros.ivec"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runOpenai1536() throws IOException { + System.out.println("Running text-embedding-3-large_1536"); + + var baseVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_base_98716.fvecs"; + var queryVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_query_10000.fvecs"; + var gtVectors = "./fvec/openai-v3-large-1536-100k/openai_v3_large_1536_100k_gt_ip_100.ivecs"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runOpenai3072() throws IOException { + System.out.println("Running text-embedding-3-large_3072"); + + var baseVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_base_vectors.fvec"; + var queryVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_query_vectors_10000.fvec"; + var gtVectors = "./fvec/openai-v3-large-3072-100k/text-embedding-3-large_3072_100000_indices_query_10000.ivec"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runCap6m() throws IOException { + System.out.println("Running cap-6m"); + + var baseVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs"; + var queryVectors = "./fvec/cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs"; + var gtVectors = "./fvec/cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void runCohere10m() throws IOException { + System.out.println("Running cohere-10m"); + + var baseVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs"; + var queryVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs"; + var gtVectors = "./fvec/cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs"; + + testPQEncodings(baseVectors, queryVectors, gtVectors); + } + + public static void main(String[] args) throws IOException { + if (args.length == 3) { + testPQEncodings(args[0], args[1], args[2]); + return; + } + if (args.length != 0) throw new IllegalArgumentException("Expected base, query, ground-truth paths"); +// runCohere100k(); +// runADA(); +// runADANoZeros(); + runOpenai1536(); +// runOpenai3072(); +// runCap6m(); +// runCohere10m(); + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java index 1d96e4a85..4e401edde 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/ExperimentsLogListener.java @@ -59,6 +59,8 @@ public void onQueryResult(SearchContext ctx, QueryRow row) { row.topK, row.overquery, row.rerankK, + ctx.ashQueryProjectionModel, + ctx.queryParameters, selection.apply(row.metrics, resolved) ); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProvider.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProvider.java index c478d21a1..2b3fb4205 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProvider.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProvider.java @@ -50,6 +50,10 @@ default boolean providesApproximateScoring() { * * @param buildPQ the PQ-encoded build vectors, or null when the build used another compressor */ + default void checkCompressedBuild(io.github.jbellis.jvector.quantization.CompressedVectors encoded) { + checkCompressedBuild(encoded instanceof PQVectors ? (PQVectors) encoded : null); + } + default void checkCompressedBuild(PQVectors buildPQ) { } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java index 5a138a4bf..6308a5b5f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureProviders.java @@ -20,6 +20,8 @@ import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.quantization.PQVectors; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.vector.types.VectorFloat; import java.io.FileNotFoundException; import java.nio.file.Path; @@ -36,6 +38,7 @@ final class FeatureProviders { register(new InlineVectorsProvider()); register(new NVQProvider()); register(new FusedPQProvider()); + register(new FusedASHProvider()); } private FeatureProviders() {} @@ -89,8 +92,32 @@ static FeatureWriteContext configureWriter(Set features, RandomAccessVectorValues vectors, PQVectors buildPQ, ConstructionMetrics metrics) throws FileNotFoundException { - var writer = GraphBuilderFactory.writerBuilder(graph, outPath, vectors.size()); - var ctx = new FeatureWriteContext(writer, graph, vectors, buildPQ, metrics); + return configureWriter(features, graph, outPath, vectors, buildPQ, null, null, metrics); + } + + static FeatureWriteContext configureWriter(Set features, ImmutableGraphIndex graph, + Path outPath, RandomAccessVectorValues vectors, + PQVectors buildPQ, ASHVectors buildASH, + VectorFloat inputMean, ConstructionMetrics metrics) throws FileNotFoundException { + return configureWriter(features, graph, outPath, vectors, buildPQ, buildASH, inputMean, metrics, null); + } + + static FeatureWriteContext configureWriter(Set features, ImmutableGraphIndex graph, + Path outPath, RandomAccessVectorValues vectors, + PQVectors buildPQ, ASHVectors buildASH, VectorFloat inputMean, + ConstructionMetrics metrics, String writerMode) throws FileNotFoundException { + return configureWriter(features, graph, outPath, vectors, buildPQ, buildASH, inputMean, metrics, writerMode, + io.github.jbellis.jvector.util.PhysicalCoreExecutor.getPhysicalCoreCount()); + } + + /** As above, with {@code writerThreads} parallel writer workers. */ + static FeatureWriteContext configureWriter(Set features, ImmutableGraphIndex graph, + Path outPath, RandomAccessVectorValues vectors, + PQVectors buildPQ, ASHVectors buildASH, VectorFloat inputMean, + ConstructionMetrics metrics, String writerMode, int writerThreads) + throws FileNotFoundException { + var writer = GraphBuilderFactory.writerBuilder(graph, outPath, vectors.size(), writerMode, writerThreads); + var ctx = new FeatureWriteContext(writer, graph, vectors, buildPQ, buildASH, inputMean, metrics); for (var id : features) { require(id).configure(ctx); } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java index 583318656..b99281366 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FeatureWriteContext.java @@ -18,10 +18,12 @@ import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.AbstractGraphIndexWriter; import io.github.jbellis.jvector.graph.disk.RandomAccessOnDiskGraphIndexWriter; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.quantization.PQVectors; +import io.github.jbellis.jvector.quantization.ASHVectors; import io.github.jbellis.jvector.vector.types.VectorFloat; import java.io.IOException; @@ -33,15 +35,16 @@ /** What a {@link FeatureProvider} needs to add its feature to one graph index writer. */ final class FeatureWriteContext { - private final RandomAccessOnDiskGraphIndexWriter.Builder writer; + private final AbstractGraphIndexWriter.Builder writer; private final ImmutableGraphIndex graph; private final RandomAccessVectorValues vectors; // File-backed sources reuse scratch vectors; ordinal suppliers need a view per worker. private final Supplier writerVectors; private final PQVectors buildPQ; + private final ASHVectors buildASH; + private final VectorFloat inputMean; private final ConstructionMetrics metrics; - private final Map> ordinalSuppliers = new EnumMap<>(FeatureId.class); private final Map, Feature.State>> vectorSuppliers = new EnumMap<>(FeatureId.class); private final Map>> afterBuildSuppliers = new EnumMap<>(FeatureId.class); @@ -50,11 +53,19 @@ final class FeatureWriteContext { * @param buildPQ PQ-encoded build vectors, or null for full-precision and non-PQ builds * @param metrics construction metrics sink, or null when not reporting */ - FeatureWriteContext(RandomAccessOnDiskGraphIndexWriter.Builder writer, + FeatureWriteContext(AbstractGraphIndexWriter.Builder writer, ImmutableGraphIndex graph, RandomAccessVectorValues vectors, PQVectors buildPQ, ConstructionMetrics metrics) { + this(writer, graph, vectors, buildPQ, null, null, metrics); + } + + FeatureWriteContext(AbstractGraphIndexWriter.Builder writer, ImmutableGraphIndex graph, + RandomAccessVectorValues vectors, PQVectors buildPQ, ASHVectors buildASH, + VectorFloat inputMean, ConstructionMetrics metrics) { + this.buildASH = buildASH; + this.inputMean = inputMean; this.writer = writer; this.graph = graph; this.vectors = vectors; @@ -71,6 +82,8 @@ final class FeatureWriteContext { /** PQ-encoded build vectors, or null for full-precision and non-PQ builds. */ PQVectors buildPQ() { return buildPQ; } + ASHVectors buildASH() { return buildASH; } + VectorFloat inputMean() { return inputMean; } void add(Feature feature) { writer.with(feature); @@ -82,7 +95,6 @@ void add(Feature feature) { */ void supplyFromVector(FeatureId id, Function, Feature.State> encode) { vectorSuppliers.put(id, encode); - ordinalSuppliers.put(id, ordinal -> encode.apply(writerVectors.get().getVector(ordinal))); } /** Registers a feature whose state needs the completed graph, such as fused neighbor codes. */ @@ -99,7 +111,20 @@ RandomAccessOnDiskGraphIndexWriter buildWriter() throws IOException { return writer.build(); } - Map> ordinalSuppliers() { return ordinalSuppliers; } + Map> completedSuppliers(ImmutableGraphIndex.View view) { + return completedSuppliers(view, ordinal -> writerVectors.get().getVector(ordinal)); + } + + Map> completedSuppliers(ImmutableGraphIndex.View view, + IntFunction> readVector) { + var suppliers = new EnumMap>(FeatureId.class); + if (readVector != null) { + vectorSuppliers.forEach((id, supplier) -> + suppliers.put(id, ordinal -> supplier.apply(readVector.apply(ordinal)))); + } + afterBuildSuppliers.forEach((id, supplier) -> suppliers.put(id, supplier.apply(view))); + return suppliers; + } Map, Feature.State>> vectorSuppliers() { return vectorSuppliers; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FusedASHProvider.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FusedASHProvider.java new file mode 100644 index 000000000..5adf596cc --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/FusedASHProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.quantization.ASHVectors; + +/** + * ASH codes of each node's neighbors stored with the node, so searches score approximately from the + * index without separate compressed vectors. The codes come from the build-time ASH. + */ +final class FusedASHProvider implements FeatureProvider { + @Override + public FeatureId id() { + return FeatureId.FUSED_ASH; + } + + @Override + public void configure(FeatureWriteContext ctx) { + var ash = ctx.buildASH(); + if (ash == null) { + System.out.println("Skipping Fused ASH feature due to null ASHVectors"); + return; + } + ctx.add(new FusedASH(ctx.graph().maxDegree(), ash.getCompressor())); + // Neighbor codes need final adjacency, so there is no per-vector supplier for inline writes. + ctx.supplyAfterBuild(FeatureId.FUSED_ASH, view -> ordinal -> new FusedASH.State(view, ash, ordinal)); + } + + @Override + public boolean supportsInMemoryBuild() { + return false; + } + + @Override + public boolean providesApproximateScoring() { + return true; + } + + @Override + public void checkCompressedBuild(io.github.jbellis.jvector.quantization.CompressedVectors encoded) { + if (!(encoded instanceof ASHVectors)) { + throw new IllegalArgumentException("FusedASH requires ASH build compression"); + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java index 491893847..055f192e0 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GraphBuilderFactory.java @@ -17,20 +17,30 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.example.util.FilteredForkJoinPool; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.diagnostics.ASHBuildScoreExperiment; import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.VectorAccess; +import io.github.jbellis.jvector.graph.disk.AbstractGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OnDiskParallelGraphIndexWriter; import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; import io.github.jbellis.jvector.graph.disk.OrdinalMapper; import io.github.jbellis.jvector.graph.disk.RandomAccessOnDiskGraphIndexWriter; import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; import io.github.jbellis.jvector.quantization.BQVectors; +import io.github.jbellis.jvector.quantization.ASHVectors; import io.github.jbellis.jvector.quantization.CompressedVectors; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.io.FileNotFoundException; +import java.io.IOException; import java.nio.file.Path; +import java.util.function.Supplier; +import java.util.stream.IntStream; /** * The only place the benchmark harness constructs core graph builders and writers, so a change to @@ -52,27 +62,141 @@ static GraphIndexBuilder forFullPrecision(RandomAccessVectorValues vectors, build.alpha, build.addHierarchy, build.refineFinalGraph, - PhysicalCoreExecutor.pool(), - FilteredForkJoinPool.createFilteredPool()); + constructionPool(build), + hasCustomWorkers(build) ? FilteredForkJoinPool.createFilteredPool(build.workers) + : FilteredForkJoinPool.createFilteredPool()); } - /** A builder scoring with PQ- or BQ-encoded vectors. */ + private static final java.util.concurrent.ConcurrentHashMap CONSTRUCTION_POOLS = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** True when construction.workers asks for a thread count other than the platform's physical cores. */ + static boolean hasCustomWorkers(BuildConfig build) { + return build.workers != null && build.workers != PhysicalCoreExecutor.getPhysicalCoreCount(); + } + + /** Pool for construction work: the platform pool, or one sized to construction.workers. */ + static java.util.concurrent.ForkJoinPool constructionPool(BuildConfig build) { + return hasCustomWorkers(build) + ? CONSTRUCTION_POOLS.computeIfAbsent(build.workers, java.util.concurrent.ForkJoinPool::new) + : PhysicalCoreExecutor.pool(); + } + + /** Thread count for construction work, including the parallel writer. */ + static int constructionThreads(BuildConfig build) { + return build.workers != null ? build.workers : PhysicalCoreExecutor.getPhysicalCoreCount(); + } + + /** Runs training or encoding in the construction pool, so its parallel work uses those threads. */ + static T inConstructionPool(BuildConfig build, Supplier work) { + return hasCustomWorkers(build) ? constructionPool(build).submit(work::get).join() : work.get(); + } + + /** A builder scoring with PQ-, BQ- or ASH-encoded vectors. */ static GraphIndexBuilder forCompressed(CompressedVectors encoded, VectorSimilarityFunction similarity, int dimension, BuildConfig build) { + return forCompressed(compressedScores(encoded, similarity, null), dimension, build); + } + + static BuildScoreProvider compressedScores(CompressedVectors encoded, VectorSimilarityFunction similarity, + RandomAccessVectorValues raw) { + return compressedScores(encoded, similarity, raw, ASHBuildScoreExperiment.defaultMode()); + } + + private static BuildScoreProvider compressedScores(CompressedVectors encoded, VectorSimilarityFunction similarity, + RandomAccessVectorValues raw, String ashMode) { BuildScoreProvider bsp; if (encoded instanceof PQVectors) { bsp = BuildScoreProvider.pqBuildScoreProvider(similarity, (PQVectors) encoded); } else if (encoded instanceof BQVectors) { bsp = BuildScoreProvider.bqBuildScoreProvider((BQVectors) encoded); + } else if (encoded instanceof ASHVectors) { + bsp = ASHBuildScoreExperiment.create(similarity, (ASHVectors) encoded, raw, ashMode); } else { - throw new IllegalArgumentException("Disk construction supports PQ or BQ build compression"); + throw new IllegalArgumentException("Construction supports PQ, BQ or ASH build compression"); + } + return bsp; + } + + static GraphIndexBuilder forCompressed(BuildScoreProvider bsp, int dimension, BuildConfig build) { + if (hasCustomWorkers(build)) { + return new GraphIndexBuilder(bsp, dimension, build.M, build.efConstruction, build.neighborOverflow, + build.alpha, build.addHierarchy, build.refineFinalGraph, + constructionPool(build), FilteredForkJoinPool.createFilteredPool(build.workers)); } return new GraphIndexBuilder(bsp, dimension, build.M, build.efConstruction, build.neighborOverflow, build.alpha, build.addHierarchy, build.refineFinalGraph); } + static String ashConstructionMode(BuildConfig build) { + return build.ashConstructionMode == null ? ASHBuildScoreExperiment.defaultMode() : build.ashConstructionMode; + } + + /** Builds and cleans up the topology before any disk features are written. */ + static ImmutableGraphIndex buildGraph(DataSet ds, BuildConfig build, CompressedVectors encoded) throws IOException { + var vectors = ds.getBaseRavv(); + var similarity = ds.getSimilarityFunction(); + if (encoded == null) { + try (var builder = forFullPrecision(vectors, similarity, build); + var progress = PhaseProgress.counting("Graph construction", vectors.size(), + () -> builder.getGraph().size(0))) { + if (!ds.useDatasetPrefetching()) return builder.build(vectors); + VectorAccess.forEach(vectors, constructionPool(build), builder::addGraphNode); + builder.cleanup(); + return builder.getGraph(); + } + } + String ashMode = ashConstructionMode(build); + var scores = compressedScores(encoded, similarity, vectors, ashMode); + boolean symmetricInsertion = encoded instanceof ASHVectors && "symmetric-all".equals(ashMode); + try (var builder = forCompressed(scores, vectors.dimension(), build)) { + try (var progress = PhaseProgress.counting("Graph insertion", vectors.size(), + () -> builder.getGraph().size(0))) { + if (ds.useDatasetPrefetching()) { + VectorAccess.forEach(vectors, constructionPool(build), (node, vector) -> { + if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); + else builder.addGraphNode(node, vector); + }); + } else { + var views = vectors.threadLocalSupplier(); + constructionPool(build).submit(() -> + IntStream.range(0, vectors.size()).parallel().forEach(node -> { + if (symmetricInsertion) builder.addGraphNode(node, scores.searchProviderFor(node)); + else builder.addGraphNode(node, views.get().getVector(node)); + })).join(); + } + } + try (var progress = PhaseProgress.timed("Graph cleanup")) { + builder.cleanup(); + } + return builder.getGraph(); + } + } + + static AbstractGraphIndexWriter.Builder writerBuilder( + ImmutableGraphIndex graph, Path outPath, int size, String mode) throws FileNotFoundException { + return writerBuilder(graph, outPath, size, mode, PhysicalCoreExecutor.getPhysicalCoreCount()); + } + + /** As above, with {@code threads} parallel writer workers. */ + static AbstractGraphIndexWriter.Builder writerBuilder( + ImmutableGraphIndex graph, Path outPath, int size, String mode, int threads) throws FileNotFoundException { + if (mode == null) return writerBuilder(graph, outPath, size); + AbstractGraphIndexWriter.Builder builder; + if ("sequential".equals(mode)) { + builder = new OnDiskGraphIndexWriter.Builder(graph, outPath); + } else if ("parallel".equals(mode)) { + builder = new OnDiskParallelGraphIndexWriter.Builder(graph, outPath) + .withParallelWorkerThreads(threads); + } else { + throw new IllegalArgumentException("Unknown writer mode: " + mode); + } + builder.withMapper(new OrdinalMapper.IdentityMapper(size - 1)); + return builder; + } + /** A writer builder with identity ordinals and one parallel worker per physical core. */ static RandomAccessOnDiskGraphIndexWriter.Builder writerBuilder(ImmutableGraphIndex graph, Path outPath, int size) throws FileNotFoundException { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java index 444d64947..2e265c6de 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/Grid.java @@ -40,8 +40,18 @@ import io.github.jbellis.jvector.graph.similarity.ScoreFunction; import io.github.jbellis.jvector.graph.similarity.SearchScoreProvider; import io.github.jbellis.jvector.quantization.CompressedVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; +import io.github.jbellis.jvector.graph.disk.feature.NVQ; +import io.github.jbellis.jvector.quantization.NVQVectors; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.quantization.VectorCompressor; import io.github.jbellis.jvector.util.ExplicitThreadLocal; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.vector.types.VectorFloat; import java.io.IOException; @@ -58,7 +68,7 @@ */ public class Grid { - private static final String pqCacheDir = "pq_cache"; + private static final String compressorCacheDir = "compressor_cache"; private static final String indexCacheDir = "index_cache"; @@ -86,7 +96,7 @@ public static Double getIndexBuildTimeSeconds(String datasetName) { * selected by {@code artifacts}. */ public static void run(DataSet ds, GridSpec spec, RunArtifacts artifacts) throws IOException { - runGrid(ds, spec, () -> setupBenchmarks(artifacts.benchmarksToCompute()), reportingListeners(artifacts)); + runGrid(ds, spec, () -> setupBenchmarks(artifacts.benchmarksToCompute()), reportingListeners(artifacts), artifacts); } /** @@ -96,7 +106,7 @@ public static void run(DataSet ds, GridSpec spec, RunArtifacts artifacts) throws public static List runAndCollectResults(DataSet ds, GridSpec spec) throws IOException { var collector = new BenchResultCollector(); runGrid(ds, spec, Grid::regressionBenchmarks, - List.of(new ConsoleReportListener(RunArtifacts.disabled()), collector)); + List.of(new ConsoleReportListener(RunArtifacts.disabled()), collector), RunArtifacts.disabled()); return collector.results(); } @@ -114,10 +124,12 @@ private static List reportingListeners(RunArtifacts artifacts) { private static void runGrid(DataSet ds, GridSpec spec, Supplier> benchmarks, - List listeners) throws IOException + List listeners, RunArtifacts artifacts) throws IOException { // Resolve every point first, so an incomplete sweep fails before any build. final List buildPoints = spec.buildPoints(); + var searchCases = spec.search.cases(); + validateCases(ds, buildPoints, searchCases, spec); boolean success = false; // Always use a fresh temp directory for per-run artifacts @@ -131,7 +143,16 @@ private static void runGrid(DataSet ds, try { for (var build : buildPoints) { - runOneGraph(cache, spec.featureSets, build, spec.search, ds, workDir, benchmarks, listeners); + for (var search : searchCases) { + var features = spec.featuresFor(build, search); + if (build.indexType == null) { + runOneGraph(cache, features, build, search, ds, workDir, benchmarks, listeners, artifacts); + } else { + for (var featureSet : features) { + runOneGraph(cache, List.of(featureSet), build, search, ds, workDir, benchmarks, listeners, artifacts); + } + } + } } success = true; // Helps with Exception attribution } finally { @@ -155,6 +176,42 @@ private static void runGrid(DataSet ds, } } + private static void validateCases(DataSet ds, List builds, List searches, GridSpec spec) { + boolean usesAshProjection = false; + for (var build : builds) { + if (build.indexType == null) continue; + var construction = build.buildCompressor.apply(ds); + var ashMode = construction instanceof CompressorParameters.ASHParameters + ? GraphBuilderFactory.ashConstructionMode(build) : null; + construction.validateFor(ds, false, ashMode); + usesAshProjection |= ashMode != null && !"symmetric-all".equals(ashMode); + for (var search : searches) { + var traversal = search.searchCompressors.get(0).apply(ds); + boolean fused = spec.featuresFor(build, search).stream().anyMatch(Grid::hasFusedScoring); + traversal.validateFor(ds, fused, null); + usesAshProjection |= traversal instanceof CompressorParameters.ASHParameters; + if ("queued".equals(search.compression.queryExecution()) + && (long) Math.max(8, search.compression.maxBatchSize()) * queryWorkerCountForMachine() > Integer.MAX_VALUE) { + throw new IllegalArgumentException("ASH maxBatchSize exceeds the queue limit at the selected worker count"); + } + } + } + String projection = System.getProperty("jvector.ash.queryProjection", "auto"); + if (usesAshProjection && ("fp16".equals(projection) || "int8".equals(projection))) { + // Use the same provider and source dimension as the configured projection. + VectorizationProvider.getInstance().getVectorUtilSupport() + .prepareAshProjection(new float[1][ds.getDimension()]).forQueryScoring(); + } + } + + private static boolean hasFusedScoring(Set features) { + return FeatureProviders.anyMatch(features, FeatureProvider::providesApproximateScoring); + } + + private static int queryWorkerCountForMachine() { + return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); + } + /** * Iterates through the path and deletes everything inside, then the path itself. */ @@ -208,7 +265,7 @@ static void runOneGraph(OnDiskGraphIndexCache cache, .usePruningGrid(usePruningGrid) .build(); runOneGraph(cache, featureSets, build, search, ds, workDirectory, - () -> setupBenchmarks(artifacts.benchmarksToCompute()), reportingListeners(artifacts)); + () -> setupBenchmarks(artifacts.benchmarksToCompute()), reportingListeners(artifacts), artifacts); } private static void runOneGraph(OnDiskGraphIndexCache cache, @@ -218,36 +275,62 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, DataSet ds, Path workDirectory, Supplier> benchmarks, - List listeners) throws IOException + List listeners, RunArtifacts artifacts) throws IOException { // Prepare to collect index construction metrics for reporting.... - var constructionMetrics = new ConstructionMetrics(); + var constructionMetrics = new ConstructionMetrics(build.indexType != null); // Capture initial memory and disk state try (var diagnostics = new BenchmarkDiagnostics(getDiagnosticLevel())) { diagnostics.startMonitoring("testDirectory", workDirectory); diagnostics.startMonitoring("indexCache", Paths.get(indexCacheDir)); diagnostics.capturePrePhaseSnapshot("Graph Build"); + ConsoleSections.stage("Construction"); + System.out.println("Index files: " + workDirectory.toAbsolutePath() + + (cache.isEnabled() ? " (saved indexes: " + cache.cacheDir().toAbsolutePath() + ")" : "")); System.out.printf("%s: Dataset similarity function is %s%n", ds.getName(), ds.getSimilarityFunction()); // Resolve build compressor (and label quant type) so we can record compute time - VectorCompressor buildCompressorObj = null; - String buildQuantType = null; + var buildParams = build.buildCompressor == null ? CompressorParameters.NONE : build.buildCompressor.apply(ds); + Supplier> buildCodec = () -> buildParams == CompressorParameters.NONE + ? null : getCompressor(buildParams, ds, constructionMetrics, Phase.INDEX, quantTypeOf(buildParams)); - if (build.buildCompressor != null) { - var buildParams = build.buildCompressor.apply(ds); - buildQuantType = quantTypeOf(buildParams); // "PQ", "BQ", or null - buildCompressorObj = getCompressor(build.buildCompressor, ds, constructionMetrics, Phase.INDEX, buildQuantType); + VectorCompressor buildCompressorObj = null; + IndexBuildStrategy strategy; + if ("onDisk".equals(build.indexType)) { + var searchParams = search.searchCompressors.get(0).apply(ds); + boolean fused = featureSets.stream().anyMatch(Grid::hasFusedScoring); + Supplier> fusedCodec = fused + ? () -> getCompressor(searchParams, ds, constructionMetrics, Phase.INDEX, quantTypeOf(searchParams)) + : null; + // The graph depends on query projection only when construction scores with it; + // symmetric-all scoring never projects, so its graphs are shared across precisions. + String ashMode = buildParams instanceof CompressorParameters.ASHParameters + ? GraphBuilderFactory.ashConstructionMode(build) : null; + String cacheIdentity = "schema3:" + compressorId(buildParams, ds) + + (ashMode == null ? "" + : ":" + ashMode + ("symmetric-all".equals(ashMode) ? "" + : ":" + System.getProperty("jvector.ash.queryProjection", "auto"))) + + (fused ? ":" + compressorId(searchParams, ds) : ""); + strategy = new CompressedOnDiskBuild(cache, workDirectory, buildCodec, constructionMetrics, + fusedCodec, cacheIdentity); + } else { + buildCompressorObj = GraphBuilderFactory.inConstructionPool(build, buildCodec::get); + if ("inMemory".equals(build.indexType)) { + strategy = new InMemoryBuild(workDirectory, + encodeForConstruction(buildCompressorObj, ds.getBaseRavv(), constructionMetrics, + GraphBuilderFactory.constructionPool(build))); + } else { + strategy = buildCompressorObj == null ? new InMemoryBuild(workDirectory) + : new CompressedOnDiskBuild(cache, workDirectory, buildCompressorObj, constructionMetrics); + } } - - // Used in logging - String buildCompressorString = - (buildCompressorObj == null) ? "None" : String.valueOf(buildCompressorObj); - - IndexBuildStrategy strategy = (buildCompressorObj == null) - ? new InMemoryBuild(workDirectory) - : new CompressedOnDiskBuild(cache, workDirectory, buildCompressorObj, constructionMetrics); var built = strategy.build(featureSets, build, ds); + if (strategy instanceof CompressedOnDiskBuild) { + buildCompressorObj = ((CompressedOnDiskBuild) strategy).buildCompressor(); + } + final String buildCompressorString = buildCompressorObj == null + ? compressorId(buildParams, ds) : buildCompressorObj.toString(); // Null when every index came from the index cache. Read it from here, not the per-dataset map, // which still holds the previous build config's time in that case. Double indexBuildTimeS = built.buildTimeS; @@ -262,8 +345,11 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, var buildSnapshot = diagnostics.getLatestSystemSnapshot(); diagnostics.printDiskStatistics("Graph Index Build"); - if (indexBuildTimeS != null) { - System.out.printf("Index build time: %f seconds%n%n", indexBuildTimeS); + if (indexBuildTimeS != null && constructionMetrics.graphBuildTimeS != null) { + System.out.printf(java.util.Locale.ROOT, "Index build time: %.1f s (graph %.1f s, flush %.1f s)%n%n", + indexBuildTimeS, constructionMetrics.graphBuildTimeS, constructionMetrics.flushTimeS); + } else if (indexBuildTimeS != null) { + System.out.printf(java.util.Locale.ROOT, "Index build time: %.1f s%n%n", indexBuildTimeS); } else { System.out.printf("Index build time: not measured, all indexes loaded from the index cache%n%n"); } @@ -280,13 +366,15 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, constructionMetrics.resetSearch(); CompressedVectors cv; - if (FeatureProviders.anyMatch(featureSetForIndex, FeatureProvider::providesApproximateScoring)) { + if (hasFusedScoring(featureSetForIndex)) { cv = null; - System.out.format("%s: configured to use FUSED PQ, skipping vector compression%n", ds.getName()); + System.out.format("%s: using fused traversal codes from the disk index%n", ds.getName()); } else { var searchParams = cpSupplier.apply(ds); String searchQuantType = quantTypeOf(searchParams); // "PQ", "BQ", or null - var compressor = getCompressor(cpSupplier, ds, constructionMetrics, Phase.SEARCH, searchQuantType); + var compressor = build.indexType != null && searchParams == CompressorParameters.NONE + ? null + : getCompressor(searchParams, ds, constructionMetrics, Phase.SEARCH, searchQuantType); if (compressor == null) { cv = null; @@ -305,15 +393,33 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, } } - var ctx = new SearchContext(ds, build, search, features, featureSetForIndex, - buildCompressorString, cpSupplier, - (cv == null) ? "None" : String.valueOf(cv.getCompressor()), - indexBuildTimeS, indexFileSizes.get(features), buildSnapshot); - - try (var cs = new ConfiguredSystem(ds, index, cv, featureSetForIndex)) { - testConfiguration(cs, ctx, constructionMetrics, workDirectory, benchmarks, listeners); - } catch (Exception e) { - throw new RuntimeException(e); + NVQVectors memoryReranker = null; + if ("inMemory".equals(build.indexType) && "NVQ".equals(build.rerankingType)) { + var base = ds.getBaseRavv(); + var nvq = constructionMetrics.index("NVQ").timeCompute(() -> NVQProvider.train(base, null)); + memoryReranker = (NVQVectors) constructionMetrics.index("NVQ").timeEncode(() -> nvq.encodeAll(base)); + } + // Each search worker count queries this same index with its own query workers. + for (Integer workers : search.workerCounts()) { + ConsoleSections.stage(workers == null ? "Search" + : "Search: " + workers + (workers == 1 ? " worker" : " workers")); + try (var cs = new ConfiguredSystem(ds, index, cv, featureSetForIndex, build.rerankingType, + memoryReranker)) { + if (search.compression != null) { + cs.configureQueryExecution(search.compression.queryExecution(), + search.compression.maxBatchSize(), workers); + } + var traversal = cv == null ? null : cv.getCompressor(); + if (traversal == null && build.indexType != null) traversal = cs.fusedCompressor(); + var ctx = new SearchContext(ds, build, search, features, featureSetForIndex, + buildCompressorString, cpSupplier, + traversal == null ? "None" : traversal.toString(), + indexBuildTimeS, indexFileSizes.get(features), buildSnapshot, + queryParameters(cs), cs.ashQueryProjectionModel()); + testConfiguration(cs, ctx, constructionMetrics, workDirectory, benchmarks, listeners); + } catch (Exception e) { + throw new RuntimeException(e); + } } }); } @@ -346,6 +452,24 @@ private static void runOneGraph(OnDiskGraphIndexCache cache, } } + private static Map queryParameters(ConfiguredSystem cs) { + Map queryParams = new LinkedHashMap<>(); + queryParams.put("executionMode", cs.usesQueuedExecution() ? "queued" : "direct"); + if (cs.usesQueuedExecution()) { + queryParams.put("workers", cs.queryWorkerCount()); + queryParams.put("queueCapacity", Math.multiplyExact(Math.max(8, cs.queryBatchSize()), cs.queryWorkerCount())); + queryParams.put("maxBatchSize", cs.queryBatchSize()); + var preparer = cs.queryPreparer(); + queryParams.put("batchPreparation", preparer != null && preparer.supportsBatchPreparation()); + } else { + int threads = cs.directCallerThreads(); + queryParams.put("callerThreads", threads == 0 ? "common pool" : threads); + } + + return queryParams; + } + + // Entry-point policy only; the library's writer selection remains unchanged. static void configureWriterForBenchmark() { String value = System.getProperty("jvector.parallel_writer", "true").trim(); @@ -561,24 +685,23 @@ private static List regressionBenchmarks() { *

We do not record load times. If a cached compressor is loaded, no compute metric is emitted * (value remains {@code null} and will be blank in CSV/console).

* - * @param cpSupplier supplies dataset-specific compressor parameters (YAML-derived) + * @param cp dataset-specific compressor parameters (YAML-derived) * @param ds dataset being benchmarked * @param metrics per-run construction metrics sink (may be null) * @param phase which phase to attribute compute time to (index construction vs search-time setup) * @param quantType YAML quantization type label (e.g., PQ/BQ); if null, no quant metric is recorded */ - private static VectorCompressor getCompressor(Function cpSupplier, + private static VectorCompressor getCompressor(CompressorParameters cp, DataSet ds, ConstructionMetrics metrics, Phase phase, String quantType) { - var cp = cpSupplier.apply(ds); // Core compute and save logic Supplier> computeAndSaveAction = () -> { var start = System.nanoTime(); var compressor = cp.computeCompressor(ds); - System.out.format("%s: %s codebooks computed in %.2fs,%n", + System.out.format(java.util.Locale.ROOT, "%s: trained %s in %.2f s%n", ds.getName(), compressor, (System.nanoTime() - start) / 1_000_000_000.0); if (cp.supportsCaching()) { @@ -604,7 +727,7 @@ private static VectorCompressor getCompressor(Function { - Path path = Paths.get(pqCacheDir).resolve(fname); + Path path = Paths.get(compressorCacheDir).resolve(fname); if (Files.exists(path)) { return loadFromCache(ds, cp, fname, path); } @@ -612,6 +735,25 @@ private static VectorCompressor getCompressor(Function compressor, RandomAccessVectorValues vectors, + ConstructionMetrics metrics) { + return encodeForConstruction(compressor, vectors, metrics, PhysicalCoreExecutor.pool()); + } + + /** Encodes with {@code pool}, which sets how many threads the encoding uses. */ + static CompressedVectors encodeForConstruction(VectorCompressor compressor, RandomAccessVectorValues vectors, + ConstructionMetrics metrics, java.util.concurrent.ForkJoinPool pool) { + if (compressor == null) return null; + String quantType = compressor instanceof AsymmetricHashing ? "ASH" + : compressor instanceof ProductQuantization ? "PQ" : "BQ"; + return metrics.index(quantType).timeEncode(() -> compressor.encodeAll(vectors, pool)); + } + // Load/save helpers for getCompressor private static VectorCompressor loadFromCache(DataSet ds, CompressorParameters cp, String fname, Path path) { try (var readerSupplier = ReaderSupplierFactory.open(path); @@ -627,7 +769,7 @@ private static VectorCompressor loadFromCache(DataSet ds, CompressorParameter private static void saveToCache(String fname, VectorCompressor compressor) { try { - Path path = Paths.get(pqCacheDir).resolve(fname); + Path path = Paths.get(compressorCacheDir).resolve(fname); Files.createDirectories(path.getParent()); try (var writer = new BufferedRandomAccessWriter(path)) { compressor.write(writer, OnDiskGraphIndex.CURRENT_VERSION); @@ -642,15 +784,90 @@ public static class ConfiguredSystem implements AutoCloseable { ImmutableGraphIndex index; CompressedVectors cv; Set features; - // A feature such as FUSED_PQ scores approximately from the index itself. - private final boolean scoresFromIndex; private final Supplier exactVectors; + private final boolean scoresFromIndex; + private final String rerankingType; + private final NVQVectors memoryReranker; + private boolean queuedExecution; + private int queryBatchSize = 8; + private io.github.jbellis.jvector.graph.similarity.QueryPreparer queryPreparer; + private java.util.concurrent.ExecutorService queuedQueryWorkers; + private boolean closed; + + private Integer searchWorkers; + + /** Queued workers: the case's search.workers, else one per physical core the JVM can use. */ + public int queryWorkerCount() { + return searchWorkers != null ? searchWorkers : queryWorkerCountForMachine(); + } + + /** Direct caller threads: the case's search.workers, else jvector.bench.queryThreads (0 = common pool). */ + public int directCallerThreads() { + if (searchWorkers != null) return searchWorkers; + int threads = Integer.getInteger("jvector.bench.queryThreads", 0); + if (threads < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); + return threads; + } + + /** Shared across this configured index's warmups and all query measurements. */ + public synchronized java.util.concurrent.ExecutorService queryWorkers() { + if (closed) throw new IllegalStateException("Configured system is closed"); + if (queuedQueryWorkers == null) { + int count = queryWorkerCount(); + // Drain tasks are reusable; an array queue avoids a linked queue + // node allocation on every isolated query activation. + queuedQueryWorkers = new java.util.concurrent.ThreadPoolExecutor(count, count, + 0L, java.util.concurrent.TimeUnit.MILLISECONDS, + new java.util.concurrent.ArrayBlockingQueue(count), + new java.util.concurrent.ThreadPoolExecutor.AbortPolicy()); + } + return queuedQueryWorkers; + } + + /** Select before launching queries; workers and search-owned state are reused. */ + public synchronized void configureQueryExecution(String mode, int batchSize) { + configureQueryExecution(mode, batchSize, null); + } + + /** As above, with this case's search worker count, or null for the JVM-wide setting. */ + public synchronized void configureQueryExecution(String mode, int batchSize, Integer workers) { + if (queuedQueryWorkers != null && !java.util.Objects.equals(workers, searchWorkers)) + throw new IllegalStateException("Query workers are already running; configure workers before searching"); + searchWorkers = workers; + if (closed) throw new IllegalStateException("Configured system is closed"); + if (!"direct".equals(mode) && !"queued".equals(mode)) + throw new IllegalArgumentException("Query execution must be direct or queued"); + if (batchSize <= 0 || batchSize == Integer.MAX_VALUE) + throw new IllegalArgumentException("Query batch size must be positive and fit its histogram"); + queuedExecution = "queued".equals(mode); + queryBatchSize = batchSize; + if (queuedExecution && queryPreparer == null) + queryPreparer = index.queryPreparer(ds.getSimilarityFunction()).orElse(null); + } + public boolean usesQueuedExecution() { return queuedExecution; } + public int queryBatchSize() { return queryBatchSize; } + public io.github.jbellis.jvector.graph.similarity.QueryPreparer queryPreparer() { return queryPreparer; } + private final ExplicitThreadLocal searchers = ExplicitThreadLocal.withInitial(() -> { - return new GraphSearcher(index); + var searcher = new GraphSearcher(index); + // Benchmark knobs for the experimental adaptive stopping, off by default as in the library: + // jvector.bench.adaptive=true turns it on; jvector.bench.adaptiveGamma sets its margin. + String gamma = System.getProperty("jvector.bench.adaptiveGamma"); + boolean adaptive = Boolean.parseBoolean(System.getProperty("jvector.bench.adaptive", "false")); + if (gamma != null) searcher.useAdaptiveTermination(adaptive, Float.parseFloat(gamma)); + else searcher.useAdaptiveTermination(adaptive); + return searcher; }); ConfiguredSystem(DataSet ds, ImmutableGraphIndex index, CompressedVectors cv, Set features) { + this(ds, index, cv, features, null, null); + } + + ConfiguredSystem(DataSet ds, ImmutableGraphIndex index, CompressedVectors cv, Set features, + String rerankingType, NVQVectors memoryReranker) { + this.rerankingType = rerankingType; + this.memoryReranker = memoryReranker; this.ds = ds; this.index = index; this.cv = cv; @@ -660,7 +877,33 @@ public static class ConfiguredSystem implements AutoCloseable { this.exactVectors = ds.getBaseRavv().threadLocalSupplier(); } + /** Top-K searches stop adaptively, reading scores as distances under the dataset's similarity function. */ + private VectorSimilarityFunction adaptiveSimilarity() { + return ds.getSimilarityFunction(); + } + + /** Metadata from the same ASH model used for query scoring. */ + public AsymmetricHashing ashQueryProjectionModel() { + if (features.contains(FeatureId.FUSED_ASH) && index instanceof OnDiskGraphIndex) { + var feature = ((OnDiskGraphIndex) index).getFeatures().get(FeatureId.FUSED_ASH); + return feature instanceof FusedASH ? ((FusedASH) feature).getASH() : null; + } + return cv instanceof ASHVectors ? ((ASHVectors) cv).getCompressor() : null; + } + + private VectorCompressor fusedCompressor() { + if (index instanceof OnDiskGraphIndex) { + var disk = (OnDiskGraphIndex) index; + var ash = disk.getFeatures().get(FeatureId.FUSED_ASH); + if (ash instanceof FusedASH) return ((FusedASH) ash).getASH(); + var pq = disk.getFeatures().get(FeatureId.FUSED_PQ); + if (pq instanceof FusedPQ) return ((FusedPQ) pq).getPQ(); + } + return null; + } + public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, ImmutableGraphIndex.View view) { + if (rerankingType != null) return configuredScorer(queryVector, view); var scoringView = (ImmutableGraphIndex.ScoringView) view; ScoreFunction.ApproximateScoreFunction asf; if (scoresFromIndex) { @@ -674,7 +917,36 @@ public SearchScoreProvider scoreProviderFor(VectorFloat queryVector, Immutabl asf = cv.precomputedScoreFunctionFor(queryVector, ds.getSimilarityFunction()); } var rr = scoringView.rerankerFor(queryVector, ds.getSimilarityFunction()); - return new DefaultSearchScoreProvider(asf, rr); + return new DefaultSearchScoreProvider(asf, rr, adaptiveSimilarity()); + } + + private SearchScoreProvider configuredScorer(VectorFloat query, ImmutableGraphIndex.View view) { + var similarity = ds.getSimilarityFunction(); + var diskView = view instanceof OnDiskGraphIndex.View ? (OnDiskGraphIndex.View) view : null; + ScoreFunction score; + if (scoresFromIndex) { + score = diskView.approximateScoreFunctionFor(query, similarity); + } else if (cv != null) { + score = cv.precomputedScoreFunctionFor(query, similarity); + } else { + score = diskView == null + ? DefaultSearchScoreProvider.exact(query, similarity, exactVectors.get()).scoreFunction() + : diskView.rerankerFor(query, similarity); + } + + ScoreFunction.ExactScoreFunction reranker = null; + if ("FP32".equals(rerankingType)) { + var vectors = diskView == null ? exactVectors.get() : diskView; + reranker = vectors.rerankerFor(query, similarity); + } else if ("NVQ".equals(rerankingType)) { + if (diskView == null) { + reranker = memoryReranker.scoreFunctionFor(query, similarity)::similarityTo; + } else { + var nvq = (NVQ) ((OnDiskGraphIndex) index).getFeatures().get(FeatureId.NVQ_VECTORS); + reranker = nvq.rerankerFor(query, similarity, diskView); + } + } + return new DefaultSearchScoreProvider(score, reranker, adaptiveSimilarity()); } public GraphSearcher getSearcher() { @@ -686,8 +958,22 @@ public DataSet getDataSet() { } @Override - public void close() throws Exception { - searchers.close(); + public synchronized void close() throws Exception { + closed = true; + try { + if (queuedQueryWorkers != null) { + queuedQueryWorkers.shutdown(); + boolean interrupted = false; + try { + while (!queuedQueryWorkers.isTerminated()) { + try { queuedQueryWorkers.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS); } + catch (InterruptedException e) { interrupted = true; } + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + } + } finally { + searchers.close(); + } } } @@ -696,6 +982,8 @@ private static String quantTypeOf(CompressorParameters cp) { if (cp == null || cp == CompressorParameters.NONE) return null; if (cp instanceof CompressorParameters.PQParameters) return "PQ"; if (cp instanceof CompressorParameters.BQParameters) return "BQ"; + if (cp instanceof CompressorParameters.ASHParameters) return "ASH"; + if (cp instanceof CompressorParameters.NVQParameters) return "NVQ"; // Unknown/unsupported type for quant timing purposes return null; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java index 82bf3eacd..2d998b161 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/GridSpec.java @@ -17,11 +17,13 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.diagnostics.ASHBuildScoreExperiment; import io.github.jbellis.jvector.example.util.CompressorParameters; import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -41,18 +43,41 @@ public final class GridSpec { final List> featureSets; final SearchConfig search; final boolean enableIndexCache; + private final List fusedGraph; private GridSpec(Builder b) { this.buildSweep = List.copyOf(b.buildSweep); this.featureSets = b.featureSets; this.search = b.search; this.enableIndexCache = b.enableIndexCache; + this.fusedGraph = b.fusedGraph; } /** The sweep a dataset's YAML config describes. */ public static GridSpec fromConfig(MultiConfig config) { var construction = config.construction; var search = config.search; + if (config.usesSchema3()) { + boolean onDisk = "onDisk".equals(construction.type); + var writerMode = onDisk ? Objects.requireNonNullElse(construction.writerMode, "parallel") : null; + return graphSweeps(construction.addHierarchy, construction.refineFinalGraph, construction.outDegree, + construction.neighborOverflow, construction.efConstruction) + .sweep("construction.compression", construction.compression, (point, compressor) -> point + .buildCompressor(compressor.getCompressorParameters()) + .ashConstructionMode("ASH".equals(compressor.type) + ? Objects.requireNonNullElseGet(compressor.ashConstructionMode(), + ASHBuildScoreExperiment::defaultMode) + : null) + .indexType(construction.type).writerMode(writerMode).rerankingType(construction.rerankingType()) + .workers(construction.resolvedWorkers())) + .featureSets(List.of()) + .fusedGraph(construction.fusedGraph == null ? List.of(false) : construction.fusedGraph) + .search(SearchConfig.builder().compressionEntries(search.compression) + .topKGrid(search.topKOverquery).usePruningGrid(search.useSearchPruning) + .workerGrid(search.resolvedWorkers()).build()) + .enableIndexCache(onDisk && Boolean.TRUE.equals(construction.useSavedIndexIfExists)) + .build(); + } return fromGrids(construction.addHierarchy, construction.refineFinalGraph, construction.outDegree, @@ -82,13 +107,7 @@ static GridSpec fromGrids(List addHierarchyGrid, List> featureSets, SearchConfig search, boolean enableIndexCache) { - // Dimension names are the YAML field names, so error messages point at the config to fix. - return builder() - .sweep("addHierarchy", addHierarchyGrid, BuildConfig.Builder::addHierarchy) - .sweep("refineFinalGraph", refineFinalGraphGrid, BuildConfig.Builder::refineFinalGraph) - .sweep("outDegree", mGrid, BuildConfig.Builder::M) - .sweep("neighborOverflow", neighborOverflowGrid, BuildConfig.Builder::neighborOverflow) - .sweep("efConstruction", efConstructionGrid, BuildConfig.Builder::efConstruction) + return graphSweeps(addHierarchyGrid, refineFinalGraphGrid, mGrid, neighborOverflowGrid, efConstructionGrid) .sweep("construction.compression", buildCompressors, BuildConfig.Builder::buildCompressor) .featureSets(featureSets) .search(search) @@ -96,6 +115,36 @@ static GridSpec fromGrids(List addHierarchyGrid, .build(); } + private static Builder graphSweeps(List hierarchy, List refinement, List degree, + List overflow, List efConstruction) { + return builder() + .sweep("addHierarchy", hierarchy, BuildConfig.Builder::addHierarchy) + .sweep("refineFinalGraph", refinement, BuildConfig.Builder::refineFinalGraph) + .sweep("outDegree", degree, BuildConfig.Builder::M) + .sweep("neighborOverflow", overflow, BuildConfig.Builder::neighborOverflow) + .sweep("efConstruction", efConstruction, BuildConfig.Builder::efConstruction); + } + + List> featuresFor(BuildConfig build, SearchConfig searchCase) { + if (build.indexType == null) return featureSets; + if ("inMemory".equals(build.indexType)) return List.of(Set.of()); + + // The file format requires a vector feature. Source vectors also provide primary + // traversal scores for uncompressed search, independently of the reranking policy. + var base = EnumSet.noneOf(FeatureId.class); + if ("NVQ".equals(build.rerankingType)) base.add(FeatureId.NVQ_VECTORS); + if (base.isEmpty() || "None".equals(searchCase.compression.type)) base.add(FeatureId.INLINE_VECTORS); + var sets = new ArrayList>(); + for (boolean fused : fusedGraph) { + var features = EnumSet.copyOf(base); + if (fused) { + features.add("ASH".equals(searchCase.compression.type) ? FeatureId.FUSED_ASH : FeatureId.FUSED_PQ); + } + if (!sets.contains(features)) sets.add(features); + } + return sets; + } + public static Builder builder() { return new Builder(); } @@ -138,6 +187,7 @@ public static final class Builder { private List> featureSets; private SearchConfig search; private boolean enableIndexCache; + private List fusedGraph; private Builder() {} @@ -150,6 +200,7 @@ public Builder sweep(String name, List values, BiConsumer> featureSets) { this.featureSets = featureSets; return this; } public Builder search(SearchConfig search) { this.search = search; return this; } public Builder enableIndexCache(boolean enableIndexCache) { this.enableIndexCache = enableIndexCache; return this; } + Builder fusedGraph(List fusedGraph) { this.fusedGraph = List.copyOf(fusedGraph); return this; } /** * @throws IllegalStateException if the feature sets or search config were not set diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java new file mode 100644 index 000000000..bd3bd98bb --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHQueuedSearch.java @@ -0,0 +1,112 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.util.BatchingExecutor; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.ExplicitThreadLocal; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +/** The small host-side queue adapter shared by the two ASH hello-world programs. */ +final class HelloASHQueuedSearch { + private HelloASHQueuedSearch() {} + + // Inputs and prepared buffers belong to one request until its callback completes. + private static final class Request { + final VectorFloat query; + final PreparedQuery prepared; + final CompletableFuture completion = new CompletableFuture<>(); + SearchResult result; + + Request(VectorFloat query, PreparedQuery prepared) { + this.query = query.copy(); + this.prepared = prepared; + } + } + + static List search(OnDiskGraphIndex graph, VectorSimilarityFunction similarity, + List> queries, int searchCores) throws Exception { + var preparer = graph.queryPreparer(similarity).orElseThrow(); + int maximumBatch = 8, admission = 8 * searchCores; + + // The application supplies execution threads. Each worker owns its searcher. + // Close the queue first (drains callbacks), then searchers, then the executor. + var executor = Executors.newFixedThreadPool(searchCores); + try (var searchers = ExplicitThreadLocal.withInitial(() -> new GraphSearcher(graph)); + var queue = new BatchingExecutor(searchCores, admission, maximumBatch, + executor, new BatchingExecutor.Operation() { + public Object batchKey(Request request) { + return preparer; // Only queries for this same model can share preparation. + } + + public void prepareBatch(List batch) { + // These small arrays keep the example simple; each call owns its arrays. + var inputs = new VectorFloat[batch.size()]; + var states = new PreparedQuery[batch.size()]; + for (int i = 0; i < batch.size(); i++) { + inputs[i] = batch.get(i).query; + states[i] = batch.get(i).prepared; + } + if (batch.size() == 1) preparer.prepare(inputs[0], states[0]); + else preparer.prepareBatch(inputs, states, batch.size()); + } + + public void execute(Request request) { + var searcher = searchers.get(); + var view = (ImmutableGraphIndex.ScoringView) searcher.getView(); + request.result = searcher.search(view.scoreProviderFor(request.prepared, true), + 10, 20, 0.0f, 0.0f, Bits.ALL); + } + + public void complete(Request request, Throwable failure) { + if (failure == null) request.completion.complete(request.result); + else request.completion.completeExceptionally(failure); + } + })) { + var requests = new ArrayList(); + // Submit individual queries. The queue batches currently waiting work; + // it never waits for a timer or requires the caller to assemble a batch. + for (var query : queries) { + var request = new Request(query, preparer.newState()); + if (!queue.trySubmit(request)) { + // Rejected requests remain caller-owned: reject or apply backpressure. + throw new RejectedExecutionException("Query admission is full"); + } + requests.add(request); + } + // Waiting happens on the caller, outside the executor needed to run searches. + // Futures preserve submission order even if callbacks arrive out of order. + var results = new ArrayList(); + for (var request : requests) results.add(request.completion.join()); + return results; + } finally { + executor.shutdown(); // Accepted work was drained by queue.close(), never discarded. + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java new file mode 100644 index 000000000..0a5573776 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldFp16.java @@ -0,0 +1,122 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.disk.ReaderSupplierFactory; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.VectorMean; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskParallelGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OrdinalMapper; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.NVQ; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.NVQuantization; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.ExplicitThreadLocal; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.stream.IntStream; + +/** A first ASH graph: public 100k vectors, compressed disk storage, and top-10 search. + * Uses native FP16 query projection on Intel Sapphire Rapids or newer FP16 hardware. + */ +public class HelloASHWorldFp16 { + public static void main(String[] args) throws Exception { + // Select before loading any model. Training/encoding still use FP32. + System.setProperty("jvector.vectorization_provider", "native"); + System.setProperty("jvector.ash.queryProjection", "fp16"); + Path path = args.length == 0 ? Path.of("ash-fp16.graph") : Path.of(args[0]); + if (Files.exists(path)) throw new IllegalArgumentException("Choose a new index path: " + path); + + // 1. Download/cache a public dataset: normalized 384-dimensional embeddings. + try (var dataset = DataSets.loadDataSet("e5-small-v2-100k").orElseThrow().getDataSet()) { + var base = dataset.getBaseRavv(); + var similarity = dataset.getSimilarityFunction(); + System.out.printf("Loaded %,d vectors, %d dimensions%n", base.size(), base.dimension()); + + // 2. Learn ASH(b=2,d=192): half as many dimensions, 2 stored bits each. + // The API's bit budget includes a small per-vector header (40 bits). + int b = 2, d = base.dimension() / 2; + var ash = AsymmetricHashing.initialize(base, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + b * d, 1, b); + // Collect NVQ's mean during encoding instead of scanning the source again. + var mean = new VectorMean(base.size(), base.dimension()); + var codes = ash.encodeAll(mean.duringScan(base), PhysicalCoreExecutor.pool()); + var nvq = NVQuantization.create(mean.mean(), 1); + + // 3. Construct a hierarchical graph using symmetric ASH code comparisons. + var scores = BuildScoreProvider.ashBuildScoreProvider(similarity, codes); + try (var builder = new GraphIndexBuilder(scores, base.dimension(), + 32, 100, 1.2f, 1.2f, true, true)) { + PhysicalCoreExecutor.pool().submit(() -> + IntStream.range(0, base.size()).parallel().forEach(node -> + builder.addGraphNode(node, scores.searchProviderFor(node)))).join(); + builder.cleanup(); + + // 4. Save neighbor-local ASH blocks plus NVQ vectors for reranking. + // Encode NVQ while the parallel writer emits complete records. + var vectors = base.threadLocalSupplier(); + try (var views = ExplicitThreadLocal.withInitial(() -> builder.getGraph().getView()); + var writer = new OnDiskParallelGraphIndexWriter.Builder(builder.getGraph(), path) + .withParallelWorkerThreads(PhysicalCoreExecutor.getPhysicalCoreCount()) + .withMapper(new OrdinalMapper.IdentityMapper(base.size() - 1)) + .with(new FusedASH(32, ash)).with(new NVQ(nvq)).build()) { + writer.write(Map.of( + FeatureId.FUSED_ASH, node -> new FusedASH.State(views.get(), codes, node), + FeatureId.NVQ_VECTORS, node -> new NVQ.State(nvq.encode(vectors.get().getVector(node))))); + } + } + System.out.println("Saved " + path.toAbsolutePath()); + + // 5. Reopen the self-contained index; the model is stored in its header. + try (var readers = ReaderSupplierFactory.open(path); + var graph = OnDiskGraphIndex.load(readers); + var searcher = new GraphSearcher(graph)) { + var preparer = graph.queryPreparer(similarity).orElseThrow(); + var prepared = preparer.newState(); + preparer.prepare(dataset.getQueryVectors().get(0), prepared); + var view = (ImmutableGraphIndex.ScoringView) searcher.getView(); + // Fused ASH finds candidates; NVQ reranks 20 candidates down to 10. + // One searcher/prepared state per in-flight query; reuse after completion. + var result = searcher.search(view.scoreProviderFor(prepared, true), + 10, 20, 0.0f, 0.0f, Bits.ALL); + System.out.println("Synchronous top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : result.getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + result.getVisitedCount()); + + // 6. The same search through the queued API. The application assigns + // two search workers; the queue uses admission=8C and maximum batch=8. + // Submit eight independent queries without waiting between submissions. + var queued = HelloASHQueuedSearch.search(graph, similarity, + dataset.getQueryVectors().subList(0, 8), 2); + System.out.println("Queued searches completed: " + queued.size()); + System.out.println("Queued top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : queued.get(0).getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + queued.get(0).getVisitedCount()); + } + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java new file mode 100644 index 000000000..0aa0a17f1 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloASHWorldInt8.java @@ -0,0 +1,122 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.disk.ReaderSupplierFactory; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.VectorMean; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskParallelGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.OrdinalMapper; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.NVQ; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.NVQuantization; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.ExplicitThreadLocal; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.stream.IntStream; + +/** A first ASH graph: public 100k vectors, compressed disk storage, and top-10 search. + * Uses INT8 query projection on Intel Ice Lake (or another supported INT8 CPU). + */ +public class HelloASHWorldInt8 { + public static void main(String[] args) throws Exception { + // Select before loading any model. Training/encoding still use FP32. + System.setProperty("jvector.vectorization_provider", "native"); + System.setProperty("jvector.ash.queryProjection", "int8"); + Path path = args.length == 0 ? Path.of("ash-int8.graph") : Path.of(args[0]); + if (Files.exists(path)) throw new IllegalArgumentException("Choose a new index path: " + path); + + // 1. Download/cache a public dataset: normalized 384-dimensional embeddings. + try (var dataset = DataSets.loadDataSet("e5-small-v2-100k").orElseThrow().getDataSet()) { + var base = dataset.getBaseRavv(); + var similarity = dataset.getSimilarityFunction(); + System.out.printf("Loaded %,d vectors, %d dimensions%n", base.size(), base.dimension()); + + // 2. Learn ASH(b=2,d=192): half as many dimensions, 2 stored bits each. + // The API's bit budget includes a small per-vector header (40 bits). + int b = 2, d = base.dimension() / 2; + var ash = AsymmetricHashing.initialize(base, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + b * d, 1, b); + // Collect NVQ's mean during encoding instead of scanning the source again. + var mean = new VectorMean(base.size(), base.dimension()); + var codes = ash.encodeAll(mean.duringScan(base), PhysicalCoreExecutor.pool()); + var nvq = NVQuantization.create(mean.mean(), 1); + + // 3. Construct a hierarchical graph using symmetric ASH code comparisons. + var scores = BuildScoreProvider.ashBuildScoreProvider(similarity, codes); + try (var builder = new GraphIndexBuilder(scores, base.dimension(), + 32, 100, 1.2f, 1.2f, true, true)) { + PhysicalCoreExecutor.pool().submit(() -> + IntStream.range(0, base.size()).parallel().forEach(node -> + builder.addGraphNode(node, scores.searchProviderFor(node)))).join(); + builder.cleanup(); + + // 4. Save neighbor-local ASH blocks plus NVQ vectors for reranking. + // Encode NVQ while the parallel writer emits complete records. + var vectors = base.threadLocalSupplier(); + try (var views = ExplicitThreadLocal.withInitial(() -> builder.getGraph().getView()); + var writer = new OnDiskParallelGraphIndexWriter.Builder(builder.getGraph(), path) + .withParallelWorkerThreads(PhysicalCoreExecutor.getPhysicalCoreCount()) + .withMapper(new OrdinalMapper.IdentityMapper(base.size() - 1)) + .with(new FusedASH(32, ash)).with(new NVQ(nvq)).build()) { + writer.write(Map.of( + FeatureId.FUSED_ASH, node -> new FusedASH.State(views.get(), codes, node), + FeatureId.NVQ_VECTORS, node -> new NVQ.State(nvq.encode(vectors.get().getVector(node))))); + } + } + System.out.println("Saved " + path.toAbsolutePath()); + + // 5. Reopen the self-contained index; the model is stored in its header. + try (var readers = ReaderSupplierFactory.open(path); + var graph = OnDiskGraphIndex.load(readers); + var searcher = new GraphSearcher(graph)) { + var preparer = graph.queryPreparer(similarity).orElseThrow(); + var prepared = preparer.newState(); + preparer.prepare(dataset.getQueryVectors().get(0), prepared); + var view = (ImmutableGraphIndex.ScoringView) searcher.getView(); + // Fused ASH finds candidates; NVQ reranks 20 candidates down to 10. + // One searcher/prepared state per in-flight query; reuse after completion. + var result = searcher.search(view.scoreProviderFor(prepared, true), + 10, 20, 0.0f, 0.0f, Bits.ALL); + System.out.println("Synchronous top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : result.getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + result.getVisitedCount()); + + // 6. The same search through the queued API. The application assigns + // two search workers; the queue uses admission=8C and maximum batch=8. + // Submit eight independent queries without waiting between submissions. + var queued = HelloASHQueuedSearch.search(graph, similarity, + dataset.getQueryVectors().subList(0, 8), 2); + System.out.println("Queued searches completed: " + queued.size()); + System.out.println("Queued top 10 for query 0 (vector ordinal, similarity score):"); + for (var hit : queued.get(0).getNodes()) System.out.printf(" %d %.6f%n", hit.node, hit.score); + System.out.println("Visited nodes: " + queued.get(0).getVisitedCount()); + } + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java index bec06b41d..83823a028 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/HelloVectorWorld.java @@ -17,34 +17,36 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSets; -import io.github.jbellis.jvector.example.reporting.RunArtifacts; -import io.github.jbellis.jvector.example.yaml.MultiConfig; -import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.util.Bits; import java.io.IOException; -import java.util.List; +/** Builds a graph and searches it through the library API. */ public class HelloVectorWorld { public static void main(String[] args) throws IOException { - System.out.println("Heap space available is " + Runtime.getRuntime().maxMemory()); - String datasetName = "ada002-100k"; - - // Dataset-tuned config (construction/search grids) - MultiConfig config = MultiConfig.getDefaultConfig(datasetName); - - // Run-level policy config (benchmarks/console/logging + run metadata) - RunConfig runCfg = RunConfig.loadDefault(); - - var ds = DataSets.loadDataSet(datasetName).orElseThrow( - () -> new RuntimeException("dataset " + datasetName + " not found")) - .getDataSet(); - - // Run artifacts + selections (sys_info/dataset_info/experiments.csv) - RunArtifacts artifacts = RunArtifacts.open(runCfg, List.of(config)); - artifacts.registerDataset(datasetName, ds); - - // Run - Grid.run(ds, GridSpec.fromConfig(config), artifacts); + var loaded = DataSets.loadDataSet(datasetName) + .orElseThrow(() -> new IllegalArgumentException("Dataset not found: " + datasetName)); + try (var dataset = loaded.getDataSet()) { + var vectors = dataset.getBaseRavv(); + var similarity = dataset.getSimilarityFunction(); + var query = dataset.getQueryVectors().get(0); + + try (var builder = GraphIndexBuilder.builder(vectors, similarity, 32) + .withBeamWidth(100) + .withNeighborOverflow(1.2f) + .withAlpha(1.2f) + .build(); + var graph = builder.build(vectors)) { + var result = GraphSearcher.search(query, 10, vectors, similarity, graph, Bits.ALL); + System.out.printf("Nearest neighbors to the first query in %s (%d indexed vectors)%n", + datasetName, graph.size(0)); + for (var match : result.getNodes()) { + System.out.printf("vector %d score %.6f%n", match.node, match.score); + } + } + } } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java index 92782c316..c0eb3ab7b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/IPCService.java @@ -275,7 +275,7 @@ String search(String input, SessionContext ctx) { var rr = view instanceof ImmutableGraphIndex.ScoringView ? ((ImmutableGraphIndex.ScoringView) view).rerankerFor(queryVector, ctx.similarityFunction) : ctx.ravv.rerankerFor(queryVector, ctx.similarityFunction); - var ssp = new DefaultSearchScoreProvider(sf, rr); + var ssp = DefaultSearchScoreProvider.forSimilarity(sf, rr, ctx.similarityFunction); r = new GraphSearcher(ctx.index).search(ssp, searchEf, Bits.ALL); } catch (Exception e) { throw new RuntimeException(e); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java index 346818c2d..a33cce6d4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/InMemoryBuild.java @@ -19,18 +19,16 @@ import io.github.jbellis.jvector.disk.ReaderSupplierFactory; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.quantization.CompressedVectors; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; -import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import java.io.IOException; import java.nio.file.Path; -import java.util.EnumMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.IntFunction; /** * Builds the graph on heap from full-precision vectors, then writes each feature set to the work directory. @@ -38,15 +36,26 @@ */ final class InMemoryBuild implements IndexBuildStrategy { private final Path workDirectory; + private final CompressedVectors encoded; InMemoryBuild(Path workDirectory) { + this(workDirectory, null); + } + + InMemoryBuild(Path workDirectory, CompressedVectors encoded) { this.workDirectory = workDirectory; + this.encoded = encoded; } @Override public BuiltIndexes build(List> featureSets, BuildConfig build, DataSet ds) throws IOException { var floatVectors = ds.getBaseRavv(); Map, ImmutableGraphIndex> indexes = new LinkedHashMap<>(); + if (build.indexType != null) { + long start = System.nanoTime(); + var graph = GraphBuilderFactory.buildGraph(ds, build, encoded); + return new BuiltIndexes(Map.of(Set.of(), graph), Map.of(), (System.nanoTime() - start) / 1e9); + } var builder = GraphBuilderFactory.forFullPrecision(floatVectors, ds.getSimilarityFunction(), build); long start = System.nanoTime(); var onHeapGraph = builder.build(floatVectors); @@ -75,7 +84,7 @@ public BuiltIndexes build(List> featureSets, BuildConfi try (var writer = ctx.buildWriter(); var view = ctx.afterBuildSuppliers().isEmpty() ? null : onHeapGraph.getView()) { start = System.nanoTime(); - writer.write(writeSuppliers(ctx, view)); + writer.write(ctx.completedSuppliers(view)); System.out.format("Wrote %s in %.2fs%n", features, (System.nanoTime() - start) / 1_000_000_000.0); } @@ -84,16 +93,4 @@ public BuiltIndexes build(List> featureSets, BuildConfi } return new BuiltIndexes(indexes, new LinkedHashMap<>(), buildTimeS); } - - /** @param view a view of the completed graph, or null when no feature needs one */ - private static Map> writeSuppliers(FeatureWriteContext ctx, - ImmutableGraphIndex.View view) { - if (view == null) { - return ctx.ordinalSuppliers(); - } - Map> suppliers = new EnumMap<>(FeatureId.class); - suppliers.putAll(ctx.ordinalSuppliers()); - ctx.afterBuildSuppliers().forEach((id, supplier) -> suppliers.put(id, supplier.apply(view))); - return suppliers; - } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java index bbd346d03..c6492b7dd 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/NVQProvider.java @@ -16,6 +16,8 @@ package io.github.jbellis.jvector.example; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.vector.types.VectorFloat; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import io.github.jbellis.jvector.graph.disk.feature.NVQ; import io.github.jbellis.jvector.quantization.NVQuantization; @@ -30,12 +32,21 @@ public FeatureId id() { @Override public void configure(FeatureWriteContext ctx) { var vectors = ctx.vectors(); - int nSubVectors = vectors.dimension() == 2 ? 1 : 2; - var nvq = ctx.timeIndexCompute("NVQ", () -> NVQuantization.compute(vectors, nSubVectors)); + var nvq = ctx.timeIndexCompute("NVQ", () -> train(vectors, ctx.inputMean())); ctx.add(new NVQ(nvq)); ctx.supplyFromVector(FeatureId.NVQ_VECTORS, vector -> new NVQ.State(nvq.encode(vector))); } + /** Subvector count the benchmark uses for NVQ; also part of the on-disk graph cache key. */ + static int subvectors(int dimension) { + return dimension <= 2 ? 1 : 2; + } + + static NVQuantization train(RandomAccessVectorValues vectors, VectorFloat mean) { + int nSubVectors = subvectors(vectors.dimension()); + return mean == null ? NVQuantization.compute(vectors, nSubVectors) : NVQuantization.create(mean, nSubVectors); + } + @Override public boolean providesRerankVectors() { return true; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PhaseProgress.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PhaseProgress.java new file mode 100644 index 000000000..2bfaece77 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/PhaseProgress.java @@ -0,0 +1,97 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** + * Console progress for a long construction phase. A daemon thread wakes every + * {@code jvector.bench.progressSeconds} (default 10) to print elapsed time and, when the phase + * has a counter, items done and an estimate of the time left. It reads the counter only, so it + * does not slow the phase. Set the property to 0 to print only the start and finish lines. + */ +final class PhaseProgress implements AutoCloseable { + private static final long INTERVAL_SECONDS = Long.getLong("jvector.bench.progressSeconds", 10); + + private final String phase; + private final long total; + private final LongSupplier done; + private final long start = System.nanoTime(); + private final ScheduledExecutorService ticker; + private boolean reportedComplete; + + private PhaseProgress(String phase, long total, LongSupplier done) { + this.phase = phase; + this.total = total; + this.done = done; + System.out.println(phase + " started"); + if (INTERVAL_SECONDS <= 0) { + ticker = null; + return; + } + ticker = Executors.newSingleThreadScheduledExecutor(r -> { + var thread = new Thread(r, "jvector-bench-progress"); + thread.setDaemon(true); + return thread; + }); + ticker.scheduleAtFixedRate(this::report, INTERVAL_SECONDS, INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + /** A phase that reports {@code done} of {@code total} items. */ + static PhaseProgress counting(String phase, long total, LongSupplier done) { + return new PhaseProgress(phase, total, done); + } + + /** A phase without a counter; reports elapsed time only. */ + static PhaseProgress timed(String phase) { + return new PhaseProgress(phase, 0, null); + } + + private double elapsedSeconds() { + return (System.nanoTime() - start) / 1e9; + } + + private synchronized void report() { + double elapsed = elapsedSeconds(); + if (done == null || total <= 0) { + System.out.printf(Locale.ROOT, "%s: %.0f s elapsed%n", phase, elapsed); + return; + } + long count = Math.min(done.getAsLong(), total); + if (count >= total) { + if (!reportedComplete) System.out.printf(Locale.ROOT, "%s: all %,d done, finishing (%.0f s elapsed)%n", phase, total, elapsed); + else System.out.printf(Locale.ROOT, "%s: finishing (%.0f s elapsed)%n", phase, elapsed); + reportedComplete = true; + return; + } + String left = count == 0 ? "" : String.format(Locale.ROOT, ", about %.0f s left", elapsed * (total - count) / count); + System.out.printf(Locale.ROOT, "%s: %,d of %,d (%.0f%%), %.0f s elapsed%s%n", + phase, count, total, 100.0 * count / total, elapsed, left); + } + + @Override + public void close() { + if (ticker != null) ticker.shutdownNow(); + synchronized (this) { + System.out.printf(Locale.ROOT, "%s finished in %.1f s%n", phase, elapsedSeconds()); + } + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java index 00351059a..753ff4a25 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchConfig.java @@ -18,10 +18,12 @@ import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; import io.github.jbellis.jvector.example.util.CompressorParameters; +import io.github.jbellis.jvector.example.yaml.Compression; import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; /** * The search-side sweep {@link Grid} runs against every index it builds: each search compressor, @@ -35,11 +37,36 @@ public final class SearchConfig { /** topK mapped to the overquery factors to try; each factor gives rerankK = topK * factor. */ public final Map> topKGrid; public final List usePruningGrid; + private final List entries; + /** Search compression and delivery settings for a single schema 3 index case. */ + final Compression compression; + /** Search worker counts to sweep over one index; null leaves query concurrency to JVM-wide settings. */ + public final List workerGrid; private SearchConfig(Builder b) { this.searchCompressors = b.searchCompressors; this.topKGrid = b.topKGrid; this.usePruningGrid = b.usePruningGrid; + this.entries = b.entries; + this.compression = b.compression; + this.workerGrid = b.workerGrid; + } + + /** The worker counts to query each index with; a single null means the JVM-wide setting. */ + List workerCounts() { + return workerGrid == null ? java.util.Collections.singletonList(null) : workerGrid; + } + + /** One case per compression entry. Each case queries its index once per search worker count. */ + List cases() { + if (entries == null) return List.of(this); + return entries.stream().map(entry -> { + var builder = builder().topKGrid(topKGrid).usePruningGrid(usePruningGrid) + .searchCompressors(List.of(entry.getCompressorParameters())); + builder.compression = entry; + builder.workerGrid = workerGrid; + return builder.build(); + }).collect(Collectors.toList()); } public static Builder builder() { @@ -50,13 +77,27 @@ public static final class Builder { private List> searchCompressors; private Map> topKGrid; private List usePruningGrid; + private List entries; + private Compression compression; + private List workerGrid; private Builder() {} + Builder workerGrid(List workerGrid) { + this.workerGrid = workerGrid == null ? null : List.copyOf(workerGrid); + return this; + } + public Builder searchCompressors(List> searchCompressors) { this.searchCompressors = searchCompressors; return this; } public Builder topKGrid(Map> topKGrid) { this.topKGrid = topKGrid; return this; } public Builder usePruningGrid(List usePruningGrid) { this.usePruningGrid = usePruningGrid; return this; } + Builder compressionEntries(List entries) { + this.entries = List.copyOf(entries); + this.searchCompressors = entries.stream().map(Compression::getCompressorParameters).collect(Collectors.toList()); + return this; + } + /** @throws IllegalStateException if a parameter was not set */ public SearchConfig build() { require(searchCompressors, "searchCompressors"); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchContext.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchContext.java index 9371abc5a..9500e82a3 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchContext.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/SearchContext.java @@ -22,6 +22,8 @@ import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import java.util.Set; +import java.util.Map; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import java.util.function.Function; /** @@ -30,6 +32,8 @@ *

To give listeners more data, add a field here and set it where {@link Grid} creates the context.

*/ final class SearchContext { + final Map queryParameters; + final AsymmetricHashing ashQueryProjectionModel; final DataSet ds; final BuildConfig build; final SearchConfig search; @@ -60,6 +64,18 @@ final class SearchContext { Double indexBuildTimeS, Long indexFileSizeBytes, SystemMonitor.SystemSnapshot buildSnapshot) { + this(ds, build, search, requestedFeatures, indexFeatures, buildCompressorLabel, searchCompressor, + searchCompressorLabel, indexBuildTimeS, indexFileSizeBytes, buildSnapshot, Map.of(), null); + } + + SearchContext(DataSet ds, BuildConfig build, SearchConfig search, + Set requestedFeatures, Set indexFeatures, + String buildCompressorLabel, Function searchCompressor, + String searchCompressorLabel, Double indexBuildTimeS, Long indexFileSizeBytes, + SystemMonitor.SystemSnapshot buildSnapshot, Map queryParameters, + AsymmetricHashing ashQueryProjectionModel) { + this.queryParameters = java.util.Collections.unmodifiableMap(new java.util.LinkedHashMap<>(queryParameters)); + this.ashQueryProjectionModel = ashQueryProjectionModel; this.ds = ds; this.build = build; this.search = search; diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java index 897eec550..dfd42850f 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/AccuracyBenchmark.java @@ -91,11 +91,15 @@ public List runBenchmark( int totalQueries = cs.getDataSet().getQueryVectors().size(); // execute all queries in parallel and collect results - List results = IntStream.range(0, totalQueries) - .parallel() - .mapToObj(i -> QueryExecutor.executeQuery( - cs, topK, rerankK, usePruning, i)) - .collect(Collectors.toList()); + List results; + try (QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { + results = queued == null ? IntStream.range(0, totalQueries).parallel() + .mapToObj(i -> QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, i)) + .collect(Collectors.toList()) + : java.util.Arrays.asList(queued.runPass(totalQueries, + cs.getDataSet().getQueryVectors()::get, null, true).results); + } var list = new ArrayList(); if (computeRecall) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java index 1e10c102d..784b12111 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/CountBenchmark.java @@ -107,7 +107,18 @@ public List runBenchmark( LongAdder nodesExpandedBaseLayer = new LongAdder(); int totalQueries = cs.getDataSet().getQueryVectors().size(); + try (QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { for (int run = 0; run < queryRuns; run++) { + if (queued != null) { + var pass = queued.runPass(totalQueries, cs.getDataSet().getQueryVectors()::get, null, true); + for (SearchResult result : pass.results) { + nodesVisited.add(result.getVisitedCount()); + nodesExpanded.add(result.getExpandedCount()); + nodesExpandedBaseLayer.add(result.getExpandedCountBaseLayer()); + } + continue; + } IntStream.range(0, totalQueries) .parallel() .forEach(i -> { @@ -119,6 +130,8 @@ public List runBenchmark( }); } + } + double avgVisited = nodesVisited.sum() / (double) (queryRuns * totalQueries); double avgExpanded = nodesExpanded.sum() / (double) (queryRuns * totalQueries); double avgBase = nodesExpandedBaseLayer.sum() / (double) (queryRuns * totalQueries); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java index d04a9c1bc..ca7ca7076 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/LatencyBenchmark.java @@ -18,17 +18,27 @@ import java.util.List; import java.util.ArrayList; -import java.util.Collections; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.LongSupplier; import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; /** - * Measures per‐query latency (mean and standard deviation) over N runs, - * and counts correct top‐K results. + * Measures unloaded per-query latency after random-query warmup through the + * same execution policy used by the timed requests. */ public class LatencyBenchmark extends AbstractQueryBenchmark { private static final String DEFAULT_FORMAT = ".3f"; + private static final int WARMUP_PASSES = 3; + private static final long MIN_WARMUP_NANOS = TimeUnit.SECONDS.toNanos(3); private boolean computeAvgLatency; private boolean computeLatencySTD; @@ -87,6 +97,90 @@ public LatencyBenchmark displayP999Latency(String format) { return this; } + /** + * The same pass and search-function instance serve warmup and measurement. + * Separate dispatch sites can leave the measured call cold after the JVM + * inlines the warmup site. Random query buffers are allocated once and + * refilled between warmup sweeps, without touching measured query contents. + */ + static long[] measure(VectorFloat[] queries, int queryRuns, int warmupPasses, + long minimumNanos, Function, SearchResult> search, + LongSupplier clock) { + Objects.requireNonNull(queries, "queries"); + Objects.requireNonNull(search, "search"); + Objects.requireNonNull(clock, "clock"); + if (queries.length == 0 || queryRuns <= 0 || warmupPasses <= 0 || minimumNanos < 0) + throw new IllegalArgumentException("Require queries, positive run counts, and nonnegative warmup duration"); + int dimension = Objects.requireNonNull(queries[0], "query").length(); + if (dimension <= 0) throw new IllegalArgumentException("Query dimension must be positive"); + long[] times = new long[Math.multiplyExact(queries.length, queryRuns)]; + var vectors = VectorizationProvider.getInstance().getVectorTypeSupport(); + VectorFloat[] randomInputs = new VectorFloat[queries.length]; + for (int i = 0; i < randomInputs.length; i++) randomInputs[i] = vectors.createFloatVector(dimension); + + for (int phase = 0; phase <= warmupPasses; phase++) { + boolean warming = phase < warmupPasses; + VectorFloat[] inputs = warming ? randomInputs : queries; + int repeats = warming ? 1 : queryRuns; + int offset = 0; + long started = clock.getAsLong(); + do { + if (warming) { randomize(randomInputs, dimension); offset = 0; } + for (int run = 0; run < repeats; run++) + offset = runPass(inputs, search, times, offset); + } while (warming && clock.getAsLong() - started < minimumNanos); + } + return times; + } + + private static void randomize(VectorFloat[] inputs, int dimension) { + var random = ThreadLocalRandom.current(); + for (var query : inputs) { + boolean nonZero = false; + for (int d = 0; d < dimension; d++) { + float value = random.nextFloat(); + query.set(d, value); + nonZero |= value != 0; + } + if (!nonZero) query.set(0, 1); + VectorUtil.l2normalize(query); + } + } + + /** No warmup/measurement branch or allocation at the timed call site. */ + private static int runPass(VectorFloat[] queries, Function, SearchResult> search, + long[] times, int offset) { + Objects.checkFromIndexSize(offset, queries.length, times.length); + for (int i = 0; i < queries.length; i++) { + long start = System.nanoTime(); + SearchResult result = search.apply(queries[i]); + long duration = System.nanoTime() - start; + times[offset++] = duration; + SINK += result.getVisitedCount(); + } + return offset; + } + + /** Summarize only the completed measured samples; warmup overwrites no input data. */ + List metrics(long[] latencies) { + double mean = 0, m2 = 0; + int count = 0; + for (long duration : latencies) { + count++; + double delta = duration - mean; + mean += delta / count; + m2 += delta * (duration - mean); + } + double standardDeviation = count > 0 ? Math.sqrt(m2 / count) / 1e6 : 0; + Arrays.sort(latencies); + int index = Math.max(0, Math.min(latencies.length - 1, (int) Math.ceil(0.999 * latencies.length) - 1)); + var result = new ArrayList(); + if (computeAvgLatency) result.add(Metric.of("search.latency.mean_ms", "Mean Latency (ms)", formatAvgLatency, mean / 1e6)); + if (computeLatencySTD) result.add(Metric.of("search.latency.std_ms", "STD Latency (ms)", formatLatencySTD, standardDeviation)); + if (computeP999Latency) result.add(Metric.of("search.latency.p999_ms", "p999 Latency (ms)", formatP999Latency, latencies[index] / 1e6)); + return result; + } + @Override public String getBenchmarkName() { return "LatencyBenchmark"; @@ -104,54 +198,14 @@ public List runBenchmark( throw new IllegalArgumentException("At least one parameter must be set to true"); } - int totalQueries = cs.getDataSet().getQueryVectors().size(); - double mean = 0.0; - double m2 = 0.0; - int count = 0; - - // Collect every query latency - List latencies = new ArrayList<>(totalQueries * queryRuns); - - // Run the full set of queries queryRuns times - for (int run = 0; run < queryRuns; run++) { - for (int i = 0; i < totalQueries; i++) { - long start = System.nanoTime(); - SearchResult sr = QueryExecutor.executeQuery( - cs, topK, rerankK, usePruning, i); - long duration = System.nanoTime() - start; - // record latency for percentile computation - latencies.add(duration); - SINK += sr.getVisitedCount(); - - // Welford’s online algorithm for variance - count++; - double delta = duration - mean; - mean += delta / count; - m2 += delta * (duration - mean); - } - } - - mean /= 1e6; - double standardDeviation = (count > 0) ? Math.sqrt(m2 / count) / 1e6: 0.0; - - // Compute 99.9th percentile - Collections.sort(latencies); - int idx = (int)Math.ceil(0.999 * latencies.size()) - 1; - if (idx < 0) idx = 0; - if (idx >= latencies.size()) idx = latencies.size() - 1; - double p999Latency = latencies.get(idx) / 1e6; - - var list = new ArrayList(); - if (computeAvgLatency) { - list.add(Metric.of("search.latency.mean_ms", "Mean Latency (ms)", formatAvgLatency, mean)); + VectorFloat[] queries = cs.getDataSet().getQueryVectors().toArray(new VectorFloat[0]); + try (QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { + Function, SearchResult> search = query -> queued == null + ? QueryExecutor.executeQuery(cs, topK, rerankK, usePruning, query) + : queued.executeSingle(query); + long[] times = measure(queries, queryRuns, WARMUP_PASSES, MIN_WARMUP_NANOS, search, System::nanoTime); + return metrics(times); } - if (computeLatencySTD) { - list.add(Metric.of("search.latency.std_ms", "STD Latency (ms)", formatLatencySTD, standardDeviation)); - } - if (computeP999Latency) { - list.add(Metric.of("search.latency.p999_ms", "p999 Latency (ms)", formatP999Latency, p999Latency)); - } - return list; - } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java index c5039569b..a05fc3567 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryBenchmark.java @@ -19,6 +19,8 @@ import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; /** * A common interface for all search benchmarks. @@ -33,6 +35,19 @@ List runBenchmark( boolean usePruning, int queryRuns ); + + /** + * Runs within a query-measurement group. The reporter belongs only to this + * invocation and must not be retained after this method returns. Suppliers + * must own completed samples, independently of any closed execution object. + * QueryTester evaluates them after all measurements in the group succeed. + * The original overload remains responsible for immediate standalone reports. + */ + default List runBenchmark( + ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, + int queryRuns, Consumer>> deferredReports) { + return runBenchmark(cs, topK, rerankK, usePruning, queryRuns); + } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java index 45094bbd6..184ff2546 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutor.java @@ -22,6 +22,35 @@ import io.github.jbellis.jvector.vector.types.VectorFloat; public class QueryExecutor { + /** + * Outside measurement, checks repeated searches on each caller's owned + * searcher using one prepared provider. Concurrent callers may form native + * projection batches; their floating-point reduction can differ slightly + * from a separate singleton preparation. Kernel-oracle and recall checks + * validate that numerical work independently. + */ + public static void verifyConcurrentQuery(ConfiguredSystem cs, int topK, int rerankK, + boolean usePruning, int ordinal) { + var searcher = cs.getSearcher(); + searcher.usePruning(usePruning); + var query = cs.getDataSet().getQueryVectors().get(ordinal); + var provider = cs.scoreProviderFor(query, searcher.getView()); + var expected = searcher.search(provider, topK, rerankK, 0.0f, 0.0f, Bits.ALL); + var actual = searcher.search(provider, topK, rerankK, 0.0f, 0.0f, Bits.ALL); + if (!java.util.Arrays.equals(expected.getNodes(), actual.getNodes()) + || expected.getVisitedCount() != actual.getVisitedCount()) + throw new IllegalStateException("Concurrent prepared query mismatch at " + ordinal); + } + + /** Executes only traversal/reranking after a caller has prepared the query. */ + public static SearchResult executePreparedQuery(ConfiguredSystem cs, int topK, int rerankK, + boolean usePruning, io.github.jbellis.jvector.graph.similarity.PreparedQuery prepared) { + var searcher = cs.getSearcher(); + searcher.usePruning(usePruning); + var view = (io.github.jbellis.jvector.graph.ImmutableGraphIndex.ScoringView) searcher.getView(); + return searcher.search(view.scoreProviderFor(prepared, true), topK, rerankK, 0.0f, 0.0f, Bits.ALL); + } + /** * Executes the query at index i using the given parameters. * diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java index 7eb2d28a5..44ffc7149 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueryTester.java @@ -20,6 +20,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.function.Supplier; import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; import io.github.jbellis.jvector.example.benchmarks.diagnostics.BenchmarkDiagnostics; @@ -77,6 +78,7 @@ public List run( int queryRuns) { List results = new ArrayList<>(); + List>> deferredReports = new ArrayList<>(); // Capture memory and disk usage before/after running queries // Use NONE level to suppress logging output that would break the table @@ -90,10 +92,14 @@ public List run( diagnostics.capturePrePhaseSnapshot("Query"); for (var benchmark : benchmarks) { - var metrics = benchmark.runBenchmark(cs, topK, rerankK, usePruning, queryRuns); + var metrics = benchmark.runBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports::add); results.addAll(metrics); } + // Fold/sort queued response samples only after all query measurements. + for (var report : deferredReports) results.addAll(report.get()); + deferredReports.clear(); + // Capture memory and disk usage after running queries diagnostics.capturePostPhaseSnapshot("Query"); @@ -129,6 +135,9 @@ public List run( } catch (IOException e) { throw new RuntimeException(e); + } finally { + // Failure discards only this invocation's unreported snapshots. + deferredReports.clear(); } return results; } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java new file mode 100644 index 000000000..53afcda16 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/QueuedQueryExecution.java @@ -0,0 +1,319 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example.benchmarks; + +import io.github.jbellis.jvector.example.Grid.ConfiguredSystem; +import io.github.jbellis.jvector.example.benchmarks.diagnostics.BenchmarkDiagnostics.QueryTimeRecorder; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.graph.similarity.QueryPreparer; +import io.github.jbellis.jvector.util.BatchingExecutor; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.agrona.collections.LongArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.LongAdder; +import java.util.function.IntFunction; + +/** + * The queued execution policy used by the standard BenchYAML benchmarks. It + * supplies the same full passes and query sources as the calling benchmark; + * warmup, sampling, GC policy and reporting remain in those benchmarks. + */ +public final class QueuedQueryExecution implements AutoCloseable { + private static final QueryTimeRecorder NO_TIMING = nanos -> {}; + private final ConfiguredSystem system; + private final int topK, rerankK; + private final boolean usePruning; + private final QueryPreparer preparer; + private final BatchingExecutor execution; + private final ArrayBlockingQueue free; + private LongArrayList measuredResponseTimes = new LongArrayList(); + private long[] initialHistogram; + private boolean measuring; + + private static final class Rows { + final VectorFloat[] queries; + final PreparedQuery[] states; + Rows(int count) { queries = new VectorFloat[count]; states = new PreparedQuery[count]; } + } + + private static final class Request { + final PreparedQuery state; + final Semaphore completed = new Semaphore(0); + Pass pass; + int ordinal; + long submittedAt; + VectorFloat query; + SearchResult result; + Throwable failure; + Request(PreparedQuery state) { this.state = state; } + } + + public static final class Pass { + final IntFunction> inputs; + final QueryTimeRecorder recorder; + final CountDownLatch completed; + final AtomicReference failure = new AtomicReference<>(); + final LongAdder visited = new LongAdder(); + final long[] responseTimes; + public final SearchResult[] results; + // Only validation passes allocate a reference result for each query. + final SearchResult[] references; + Pass(int count, IntFunction> inputs, QueryTimeRecorder recorder, boolean capture, boolean validate) { + this.inputs = inputs; this.recorder = recorder; + completed = new CountDownLatch(count); + responseTimes = new long[count]; + results = capture ? new SearchResult[count] : null; + references = validate ? new SearchResult[count] : null; + } + public long visited() { return visited.sum(); } + } + + public QueuedQueryExecution(ConfiguredSystem system, int topK, int rerankK, boolean usePruning) { + if (topK <= 0 || rerankK < topK) + throw new IllegalArgumentException("Require 0 < topK <= rerankK"); + this.system = Objects.requireNonNull(system, "system"); + this.topK = topK; this.rerankK = rerankK; this.usePruning = usePruning; + this.preparer = system.queryPreparer(); + int cores = system.queryWorkerCount(); + int batchSize = system.queryBatchSize(); + if (batchSize <= 0) throw new IllegalArgumentException("Benchmark query batch size must be positive"); + // One full default batch per worker bounds waiting while permitting + // genuine GEMM preparation. Keep the same admission limit for B=1 + // controls; larger explicitly selected batches remain supported. + int capacity = Math.multiplyExact(Math.max(8, batchSize), cores); + free = new ArrayBlockingQueue<>(capacity); + for (int i = 0; i < capacity; i++) free.add(new Request(preparer == null ? null : preparer.newState())); + var rows = ThreadLocal.withInitial(() -> new Rows(batchSize)); + var workers = system.queryWorkers(); + execution = new BatchingExecutor<>(cores, capacity, batchSize, workers, + new BatchingExecutor.Operation() { + public Object batchKey(Request request) { + return preparer != null && preparer.supportsBatchPreparation() ? preparer : null; + } + public void prepareBatch(List requests) { + for (Request request : requests) + if (request.pass != null) request.query = request.pass.inputs.apply(request.ordinal); + if (preparer == null) { + recordReferences(requests); + return; + } + Rows buffers = rows.get(); + try { + for (int i = 0; i < requests.size(); i++) { + buffers.queries[i] = requests.get(i).query; + buffers.states[i] = requests.get(i).state; + } + preparer.prepareBatch(buffers.queries, buffers.states, requests.size()); + recordReferences(requests); + } finally { + Arrays.fill(buffers.queries, 0, requests.size(), null); + Arrays.fill(buffers.states, 0, requests.size(), null); + } + } + private void recordReferences(List requests) { + // Before publishing prepared work, search on the preparing + // thread with exactly the same numerical state. GEMV and + // GEMM can round differently; comparing against a separate + // singleton projection would conflate that with a race. + Pass pass = requests.get(0).pass; + if (pass == null || pass.references == null) return; + for (Request request : requests) { + request.pass.references[request.ordinal] = request.state == null + ? QueryExecutor.executeQuery(system, topK, rerankK, usePruning, request.query) + : QueryExecutor.executePreparedQuery(system, topK, rerankK, usePruning, request.state); + } + } + public void execute(Request request) { + request.result = request.state == null + ? QueryExecutor.executeQuery(system, topK, rerankK, usePruning, request.query) + : QueryExecutor.executePreparedQuery(system, topK, rerankK, usePruning, request.state); + } + public void complete(Request request, Throwable failure) { + long responseTime = System.nanoTime() - request.submittedAt; + request.failure = failure; + Pass pass = request.pass; + if (pass == null) { request.completed.release(); return; } + try { + if (failure == null) { + pass.visited.add(request.result.getVisitedCount()); + pass.responseTimes[request.ordinal] = responseTime; + pass.recorder.recordTime(responseTime); + if (pass.results != null) pass.results[request.ordinal] = request.result; + } else pass.failure.compareAndSet(null, failure); + } catch (Throwable error) { pass.failure.compareAndSet(null, error); } + finally { + release(request); + pass.completed.countDown(); + } + } + }); + } + + private Request acquire() { + boolean interrupted = false; + for (;;) { + try { Request request = free.take(); if (interrupted) Thread.currentThread().interrupt(); return request; } + catch (InterruptedException e) { interrupted = true; } + } + } + + private void release(Request request) { + request.query = null; request.result = null; request.failure = null; request.pass = null; + free.add(request); + } + + private void submit(Request request) { + boolean accepted = false; + try { + accepted = execution.trySubmit(request); + if (!accepted) throw new IllegalStateException("Admission accounting mismatch"); + } finally { + // A throwing or rejected submission never transferred ownership. + if (!accepted) release(request); + } + } + + public Pass runPass(int count, IntFunction> source, QueryTimeRecorder recorder, boolean capture) { + return runPass(count, source, recorder, capture, false); + } + + /** + * Checks handoff and result ownership using the same prepared arithmetic on + * the preparing thread and the executing thread. Runs outside measurement; + * native numerical tests and AccuracyBenchmark check projection/recall. + */ + public void verifyConcurrentResults(int count, IntFunction> source) { + if (measuring) throw new IllegalStateException("Validation cannot run during measurement"); + Pass pass = runPass(count, source, null, true, true); + for (int i = 0; i < count; i++) { + if (!Arrays.equals(pass.references[i].getNodes(), pass.results[i].getNodes()) + || pass.references[i].getVisitedCount() != pass.results[i].getVisitedCount()) + throw new IllegalStateException("Concurrent prepared query mismatch at " + i); + } + } + + private Pass runPass(int count, IntFunction> source, + QueryTimeRecorder recorder, boolean capture, boolean validate) { + if (count < 0) throw new IllegalArgumentException("Query count must be nonnegative"); + Objects.requireNonNull(source, "source"); + Pass pass = new Pass(count, source, recorder == null ? NO_TIMING : recorder, capture, validate); + for (int i = 0; i < count; i++) { + long submittedAt = System.nanoTime(); // Includes waiting for admission. + Request request = acquire(); + request.pass = pass; request.ordinal = i; request.submittedAt = submittedAt; + submit(request); + } + boolean interrupted = false; + for (;;) { + try { pass.completed.await(); break; } + catch (InterruptedException e) { interrupted = true; } + } + if (interrupted) Thread.currentThread().interrupt(); + rethrow(pass.failure.get()); + if (measuring) for (long time : pass.responseTimes) measuredResponseTimes.addLong(time); + return pass; + } + + /** One submitted request with normal queue/batch capacity; not a singleton setting. */ + public SearchResult executeSingle(VectorFloat query) { + Objects.requireNonNull(query, "query"); + long submittedAt = System.nanoTime(); + Request request = acquire(); + request.pass = null; request.query = query; request.submittedAt = submittedAt; + submit(request); + request.completed.acquireUninterruptibly(); + try { rethrow(request.failure); return request.result; } + finally { release(request); } + } + + private static void rethrow(Throwable error) { + if (error instanceof Error) throw (Error) error; + if (error instanceof RuntimeException) throw (RuntimeException) error; + if (error != null) throw new RuntimeException("Queued query failed", error); + } + + public void beginMeasurements() { + if (measuring || measuredResponseTimes == null) + throw new IllegalStateException("Measurements already started or transferred"); + initialHistogram = execution.batchHistogram(); + measuring = true; + } + + /** + * Transfers completed samples without copying the large response buffer. + * Call after measured passes complete and before closing this execution. + * The returned value does not retain this executor, requests, or model. + */ + public Measurements finishMeasurements() { + if (!measuring) throw new IllegalStateException("Measurements are not active"); + long[] histogram = execution.batchHistogram(); + for (int i = 1; i < histogram.length; i++) histogram[i] -= initialHistogram[i]; + var snapshot = new Measurements(measuredResponseTimes, histogram); + measuredResponseTimes = null; + measuring = false; + return snapshot; + } + + /** Completed samples, safe to aggregate after the execution has closed. */ + public static final class Measurements { + private final LongArrayList responseTimes; + private final long[] histogram; + + private Measurements(LongArrayList responseTimes, long[] histogram) { + this.responseTimes = responseTimes; + this.histogram = histogram; + } + + /** Sorting and formatting occur only when the owner requests reporting. */ + public List metrics() { + long[] times = responseTimes.toLongArray(); + Arrays.sort(times); + double sum = 0; + for (long time : times) sum += time; + long groups = 0, queries = 0; + for (int i = 1; i < histogram.length; i++) { + groups += histogram[i]; + queries += i * histogram[i]; + } + double meanBatch = groups == 0 ? 0 : (double) queries / groups; + if (Boolean.getBoolean("jvector.bench.printQueryBatches")) { + System.out.printf("QUERY_BATCHES queries=%d groups=%d mean=%.4f histogram=%s%n", + queries, groups, meanBatch, Arrays.toString(histogram)); + } + return List.of( + Metric.of("search.execution.response_mean_ms", "Loaded mean (ms)", ".3f", times.length == 0 ? 0 : sum / times.length / 1e6), + Metric.of("search.execution.response_p95_ms", "Loaded p95 (ms)", ".3f", percentile(times, .95)), + Metric.of("search.execution.response_p99_ms", "Loaded p99 (ms)", ".3f", percentile(times, .99)), + Metric.of("search.execution.mean_batch_size", "Mean batch", ".3f", meanBatch)); + } + } + + private static double percentile(long[] sorted, double p) { + return sorted.length == 0 ? 0 : sorted[Math.min(sorted.length - 1, (int) Math.ceil(sorted.length * p) - 1)] / 1e6; + } + + @Override + public void close() { + execution.close(); // ConfiguredSystem owns the workers and their reusable search state. + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java index eaca57a17..b935c19fc 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/ThroughputBenchmark.java @@ -29,6 +29,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.LongAdder; import java.util.stream.IntStream; @@ -135,10 +138,73 @@ public List runBenchmark( boolean usePruning, int queryRuns) { + return runWithReporting(cs, topK, rerankK, usePruning, queryRuns, null); + } + + @Override + public List runBenchmark( + ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, + int queryRuns, Consumer>> deferredReports) { + return runWithReporting(cs, topK, rerankK, usePruning, queryRuns, + Objects.requireNonNull(deferredReports, "deferredReports")); + } + + private List runWithReporting( + ConfiguredSystem cs, int topK, int rerankK, boolean usePruning, + int queryRuns, Consumer>> deferredReports) { + if (cs.usesQueuedExecution()) return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports); + int threads = cs.directCallerThreads(); + if (threads > 1) { + var pool = new java.util.concurrent.ForkJoinPool(threads); + try { + return pool.submit(() -> runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports)).join(); + } finally { + pool.shutdown(); + } + } + return runConfiguredBenchmark(cs, topK, rerankK, usePruning, queryRuns, deferredReports); + } + + private VectorFloat randomQuery(int dim) { + VectorFloat query = vts.createFloatVector(dim); + for (int j = 0; j < dim; j++) query.set(j, ThreadLocalRandom.current().nextFloat()); + VectorUtil.l2normalize(query); + return query; + } + + private static IntStream queryStream(ConfiguredSystem cs, int count) { + IntStream stream = IntStream.range(0, count); + return cs.directCallerThreads() == 1 ? stream : stream.parallel(); + } + + private List runConfiguredBenchmark(ConfiguredSystem cs, int topK, int rerankK, + boolean usePruning, int queryRuns, + Consumer>> deferredReports) { + if (!(computeAvgQps || computeMedianQps || computeMaxQps)) { throw new RuntimeException("At least one metric must be displayed"); } - try (BenchmarkDiagnostics diagnostics = new BenchmarkDiagnostics(diagnosticLevel)) { + try (BenchmarkDiagnostics diagnostics = new BenchmarkDiagnostics(diagnosticLevel); + QueuedQueryExecution queued = cs.usesQueuedExecution() + ? new QueuedQueryExecution(cs, topK, rerankK, usePruning) : null) { + if (Boolean.getBoolean("jvector.bench.verifyConcurrentResults")) { + int count = cs.getDataSet().getQueryVectors().size(); + if (queued != null) { + queued.verifyConcurrentResults(count, cs.getDataSet().getQueryVectors()::get); + } else { + // Direct callers may naturally combine projections too. Keep + // the prepared provider fixed while checking per-thread search + // reuse; comparing GEMV with GEMM would test rounding instead. + queryStream(cs, count).forEach(i -> + QueryExecutor.verifyConcurrentQuery(cs, topK, rerankK, usePruning, i)); + } + if (diagnosticLevel != DiagnosticLevel.NONE) { + diagnostics.console(String.format(java.util.Locale.ROOT, + "[Validation] Passed concurrent-result checks for %,d queries (topK=%d, rerankK=%d)", + count, topK, rerankK)); + } + } + int totalQueries = cs.getDataSet().getQueryVectors().size(); int dim = cs.getDataSet().getDimension(); @@ -149,17 +215,14 @@ public List runBenchmark( String warmupPhase = "Warmup-" + warmupRun; warmupQps[warmupRun] = diagnostics.monitorPhaseWithQueryTiming(warmupPhase, (recorder) -> { - IntStream.range(0, totalQueries) - .parallel() + if (queued != null) { + SINK += queued.runPass(totalQueries, k -> randomQuery(dim), recorder, false).visited(); + } else queryStream(cs, totalQueries) .forEach(k -> { long queryStart = System.nanoTime(); // Generate a random vector - VectorFloat randQ = vts.createFloatVector(dim); - for (int j = 0; j < dim; j++) { - randQ.set(j, ThreadLocalRandom.current().nextFloat()); - } - VectorUtil.l2normalize(randQ); + VectorFloat randQ = randomQuery(dim); SearchResult sr = QueryExecutor.executeQuery( cs, topK, rerankK, usePruning, randQ); SINK += sr.getVisitedCount(); @@ -188,7 +251,12 @@ public List runBenchmark( } } + double minSampleSeconds = Double.parseDouble(System.getProperty("jvector.bench.throughputMinSeconds", "1.0")); + if (!Double.isFinite(minSampleSeconds) || minSampleSeconds < 0) + throw new IllegalArgumentException("throughputMinSeconds must be finite and nonnegative"); + long minSampleNanos = (long)(minSampleSeconds * 1e9); double[] qpsSamples = new double[numTestRuns]; + if (queued != null) queued.beginMeasurements(); for (int testRun = 0; testRun < numTestRuns; testRun++) { String testPhase = "Test-" + testRun; @@ -209,25 +277,35 @@ public List runBenchmark( LongAdder visitedAdder = new LongAdder(); long startTime = System.nanoTime(); - IntStream.range(0, totalQueries) - .parallel() - .forEach(i -> { - long queryStart = System.nanoTime(); - - SearchResult sr = QueryExecutor.executeQuery( - cs, topK, rerankK, usePruning, i); - // "Use" the result to prevent optimization - visitedAdder.add(sr.getVisitedCount()); - - long queryEnd = System.nanoTime(); - recorder.recordTime(queryEnd - queryStart); - }); - + long completedQueries = 0; + do { + if (queued != null) { + visitedAdder.add(queued.runPass(totalQueries, + cs.getDataSet().getQueryVectors()::get, recorder, false).visited()); + } else queryStream(cs, totalQueries) + .forEach(i -> { + long queryStart = System.nanoTime(); + + SearchResult sr = QueryExecutor.executeQuery( + cs, topK, rerankK, usePruning, i); + // "Use" the result to prevent optimization + visitedAdder.add(sr.getVisitedCount()); + + long queryEnd = System.nanoTime(); + recorder.recordTime(queryEnd - queryStart); + }); + + completedQueries += totalQueries; + } while (System.nanoTime() - startTime < minSampleNanos); + SINK += visitedAdder.sum(); double elapsedSec = (System.nanoTime() - startTime) / 1e9; - return totalQueries / elapsedSec; + if (diagnosticLevel != DiagnosticLevel.NONE) { + diagnostics.console(String.format(java.util.Locale.ROOT, + "[%s] Completed %,d queries in %.3f s (%,.1f QPS)", + testPhase, completedQueries, elapsedSec, completedQueries / elapsedSec)); + } + return completedQueries / elapsedSec; }); - - diagnostics.console("Test Run " + testRun + ": " + qpsSamples[testRun] + " QPS\n"); } // Performance variance analysis @@ -288,6 +366,11 @@ public List runBenchmark( formatMaxQps, minQps)); } + if (queued != null) { + var samples = queued.finishMeasurements(); + if (deferredReports == null) list.addAll(samples.metrics()); + else deferredReports.accept(samples::metrics); + } return list; } catch (IOException e) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java index 0891115b4..cedaf43ac 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/benchmarks/diagnostics/BenchmarkDiagnostics.java @@ -329,28 +329,15 @@ public void printDiskStatistics(String label) { DiskUsageMonitor.MultiDirectorySnapshot lastDisk = diskSnapshots.get(diskSnapshots.size() - 1); DiskUsageMonitor.MultiDirectorySnapshot totalDisk = lastDisk.subtract(firstDisk); - System.out.printf("\nDisk Usage Summary %s:%n", label); - - // Print statistics for each monitored directory + // One line per monitored directory: size and files now, and the change during the phase. + System.out.printf("Disk use after %s:%n", label); for (String dirLabel : lastDisk.snapshots.keySet()) { DiskUsageMonitor.DiskUsageSnapshot lastSnap = lastDisk.get(dirLabel); - DiskUsageMonitor.DiskUsageSnapshot totalSnap = totalDisk.get(dirLabel); - - System.out.printf(" [%s]:%n", dirLabel); - System.out.printf(" Total Disk Used: %s%n", DiskUsageMonitor.formatBytes(lastSnap.totalBytes)); - System.out.printf(" Total Files: %d%n", lastSnap.fileCount); - if (totalSnap != null) { - System.out.printf(" Net Change: %s, %+d files%n", - DiskUsageMonitor.formatBytes(totalSnap.totalBytes), totalSnap.fileCount); - } + DiskUsageMonitor.DiskUsageSnapshot change = totalDisk.get(dirLabel); + System.out.printf(" %-16s %10s in %d files%s%n", dirLabel, DiskUsageMonitor.formatBytes(lastSnap.totalBytes), + lastSnap.fileCount, change == null ? "" : String.format(" (%s%s, %+d files)", + change.totalBytes >= 0 ? "+" : "", DiskUsageMonitor.formatBytes(change.totalBytes), change.fileCount)); } - - // Print overall totals - System.out.printf(" [Overall Total]:%n"); - System.out.printf(" Total Disk Used: %s%n", DiskUsageMonitor.formatBytes(lastDisk.getTotalBytes())); - System.out.printf(" Total Files: %d%n", lastDisk.getTotalFileCount()); - System.out.printf(" Net Change: %s, %+d files%n", - DiskUsageMonitor.formatBytes(totalDisk.getTotalBytes()), totalDisk.getTotalFileCount()); } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java new file mode 100644 index 000000000..40c6a02d1 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHBuildScoreExperiment.java @@ -0,0 +1,90 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example.diagnostics; + +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.SearchScoreProvider; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; + +/** Benchmark-only adapter; asymmetric construction retains access to original vectors. */ +public final class ASHBuildScoreExperiment { + private ASHBuildScoreExperiment() {} + public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors encoded, + RandomAccessVectorValues raw) { + return create(vsf, encoded, raw, defaultMode()); + } + + public static String defaultMode() { + return System.getProperty("jvector.bench.ashConstruction", "symmetric-all"); + } + + public static BuildScoreProvider create(VectorSimilarityFunction vsf, ASHVectors encoded, + RandomAccessVectorValues raw, String mode) { + System.out.println("ASH construction scoring mode: " + mode); + if (mode.equals("symmetric") || mode.equals("symmetric-all")) { + return BuildScoreProvider.ashBuildScoreProvider(vsf,encoded); + } + if (mode.equals("symmetric-scalar")) { + if (vsf != VectorSimilarityFunction.DOT_PRODUCT || encoded.getCompressor().landmarkCount != 1) + throw new IllegalArgumentException("ASH construction requires DOT_PRODUCT and C=1"); + var symmetric = new io.github.jbellis.jvector.quantization.ASHSymmetricScorer(encoded, + io.github.jbellis.jvector.quantization.ASHSymmetricScorer.Kernel.SCALAR); + return new BuildScoreProvider() { + public boolean isExact() { return false; } + public VectorFloat approximateCentroid() { return encoded.getCompressor().landmarks[0].copy(); } + public SearchScoreProvider searchProviderFor(VectorFloat x) { + return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); + } + public SearchScoreProvider searchProviderFor(int node) { + return new DefaultSearchScoreProvider(symmetric.scoreFunctionFor(node),null,io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); + } + public SearchScoreProvider diversityProviderFor(int node) { return searchProviderFor(node); } + }; + } + if (!mode.equals("asymmetric") && !mode.equals("asymmetric-cached")) throw new IllegalArgumentException("Unknown ASH construction mode: " + mode); + var views=raw.threadLocalSupplier(); + final SearchScoreProvider[] cached; + if (mode.equals("asymmetric-cached")) { + // ASH single-vector scorers own immutable query state and can be shared. + cached = new SearchScoreProvider[encoded.count()]; + io.github.jbellis.jvector.util.PhysicalCoreExecutor.pool().submit(() -> + java.util.stream.IntStream.range(0, cached.length).parallel().forEach(node -> + cached[node] = new DefaultSearchScoreProvider( + encoded.precomputedScoreFunctionFor(views.get().getVector(node), vsf), null, io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT))) + .join(); + long landmarks = encoded.getCompressor().landmarkCount; + long floats = (long) cached.length * ((1L + landmarks) * encoded.getCompressor().quantizedDim + 2 * landmarks); + System.out.printf("Cached asymmetric query arrays: %.3f MiB (array/object overhead additional)%n", + floats * Float.BYTES / 1048576.0); + } else { + cached = null; + } + return new BuildScoreProvider() { + public boolean isExact() { return false; } + public VectorFloat approximateCentroid() { return encoded.getCompressor().landmarks[0].copy(); } + public SearchScoreProvider searchProviderFor(VectorFloat x) { + return new DefaultSearchScoreProvider(encoded.precomputedScoreFunctionFor(x,vsf),null,io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT); + } + public SearchScoreProvider searchProviderFor(int node) { return cached == null ? searchProviderFor(views.get().getVector(node)) : cached[node]; } + public SearchScoreProvider diversityProviderFor(int node) { return searchProviderFor(node); } + }; + } +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java new file mode 100644 index 000000000..3d0db3ca3 --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/diagnostics/ASHScoreDebug.java @@ -0,0 +1,137 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example.diagnostics; + +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.ASHScorer; +import io.github.jbellis.jvector.quantization.ASHSymmetricScorer; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Random; + +/** Input-space comparison using production scorers, with all scores on the raw dot-product scale. */ +public final class ASHScoreDebug { + private static volatile float sink; + private ASHScoreDebug() {} + public static void run(RandomAccessVectorValues values, ASHVectors encoded, + int pairs, int subset, int k, Random random) { + if (pairs <= 0 || subset < 2 || k <= 0) throw new IllegalArgumentException("Invalid sample sizes"); + int n = Math.min(subset, values.size()); + if (n < 2) throw new IllegalArgumentException("At least two vectors are required"); + k = Math.min(k, n - 1); + var symmetric = new ASHSymmetricScorer(encoded); + var asymmetric = new ASHScorer(encoded.getCompressor()); + // Separate views: shared RAVV implementations may overwrite returned vectors. + var left = values.copy(); var right = values.copy(); + double[] gt = new double[pairs], sa = new double[pairs], sb = new double[pairs], ss = new double[pairs]; + int asymWins = 0, symWins = 0; + for (int t = 0; t < pairs; t++) { + int i = random.nextInt(n), j = random.nextInt(n - 1); + if (j >= i) j++; + var x = left.getVector(i); var y = right.getVector(j); + gt[t] = VectorUtil.dotProduct(x,y); + sa[t] = asymmetric.scoreFunctionFor(x,VectorSimilarityFunction.DOT_PRODUCT).similarityTo(encoded.get(j)); + sb[t] = asymmetric.scoreFunctionFor(y,VectorSimilarityFunction.DOT_PRODUCT).similarityTo(encoded.get(i)); + ss[t] = symmetric.dotProduct(i,j); + if (Math.abs(sa[t]-gt[t]) < Math.abs(ss[t]-gt[t])) asymWins++; + if (Math.abs(ss[t]-gt[t]) < Math.abs(sa[t]-gt[t])) symWins++; + } + System.out.printf("ASH score study: N=%d sampleN=%d pairs=%d bpd=%d d=%d raw dots, self excluded%n", + values.size(),n,pairs,encoded.getCompressor().bitsPerDimension,encoded.getCompressor().quantizedDim); + report("ASYM_FORWARD",gt,sa); report("ASYM_REVERSE",gt,sb); report("SYMMETRIC",gt,ss); + System.out.printf("CLOSER_TO_EXACT asym=%d sym=%d ties=%d%n",asymWins,symWins,pairs-asymWins-symWins); + int queries = 16; + double overlapAsym=0, overlapSym=0, localA=0,localS=0; + for (int q = 0; q < queries; q++) { + int id=random.nextInt(n); + var x=left.getVector(id); + var asf=asymmetric.scoreFunctionFor(x,VectorSimilarityFunction.DOT_PRODUCT); + float[][] scores = new float[3][n]; + for (int j=0;j= 0) times[pass] = elapsed; + } + Arrays.sort(times); + System.out.printf("STEADY_SCORING %s median_ns_per_pair=%.3f millions_per_second=%.3f%n", + mode, times[4]/(double)n, n*1000.0/times[4]); + } + private static Integer[] top(float[] a,int k) { + Integer[] ids=new Integer[a.length]; for(int i=0;icomparingDouble(i->-a[i]).thenComparingInt(i->i)); + return Arrays.copyOf(ids,k); + } + private static void report(String name,double[] x,double[] y) { + double sx=0,sy=0,sxx=0,syy=0,sxy=0,mae=0,mse=0; + for(int i=0;i fixedColumns() { + return fixedColumns(false); + } + + static List fixedColumns(boolean includeIndexOptions) { List columns = new ArrayList<>(List.of( "schema_version", "run_id", @@ -64,8 +68,14 @@ public static List fixedColumns() { "usePruning", "topK", "overquery", - "rerankK" + "rerankK", + "ash_query_projection_requested", + "ash_query_projection_effective" )); + if (includeIndexOptions) { + columns.addAll(List.of("index_type", "writer_mode", "reranking_type", "ash_construction_mode", + "query_execution", "query_threads", "max_batch_size")); + } return List.copyOf(columns); } @@ -126,6 +136,18 @@ public static Map fixedValues(RunContext run, return fixed; } + static void addIndexOptions(Map row, Map build, Map search) { + row.put("index_type", java.util.Objects.toString(build.get("type"), "")); + row.put("writer_mode", java.util.Objects.toString(build.get("writerMode"), "")); + row.put("reranking_type", java.util.Objects.toString(build.get("reranking"), "")); + row.put("ash_construction_mode", java.util.Objects.toString(build.get("ashConstruction"), "")); + row.put("query_execution", java.util.Objects.toString(search.get("executionMode"), "")); + String threadCountKey = "queued".equals(search.get("executionMode")) ? "workers" : "callerThreads"; + Object threadCount = search.get(threadCountKey); + row.put("query_threads", threadCount instanceof Number ? threadCount.toString() : ""); + row.put("max_batch_size", java.util.Objects.toString(search.get("maxBatchSize"), "")); + } + /** * @deprecated Use {@link #fixedValues(RunContext, String, Map, Set, String, String, boolean, int, double, int)}, * which takes the construction parameters by name. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java index 70b2b97c2..76ca97fdc 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/LoggingSchemaPlanner.java @@ -135,9 +135,10 @@ private static void expandPrefixes(Set union, } // construction reranking types (FP/NVQ) - if (cfg.construction != null && cfg.construction.reranking != null) { - if (cfg.construction.reranking.contains("NVQ")) { - wantsNVQ = true; + if (cfg.construction != null && cfg.construction.usesNVQReranking()) { + wantsNVQ = true; + if (cfg.usesSchema3() && "inMemory".equals(cfg.construction.type)) { + indexQuantTypes.add("NVQ"); } } @@ -145,7 +146,13 @@ private static void expandPrefixes(Set union, if (cfg.search != null && cfg.search.compression != null) { for (var c : cfg.search.compression) { if (c != null && c.type != null && !"None".equals(c.type)) { - searchQuantTypes.add(c.type); // "PQ" or "BQ" + var fusedGraph = cfg.construction == null ? null : cfg.construction.fusedGraph; + if (cfg.usesSchema3() && fusedGraph != null && fusedGraph.contains(true)) { + indexQuantTypes.add(c.type); + } + if (!cfg.usesSchema3() || fusedGraph == null || fusedGraph.contains(false)) { + searchQuantTypes.add(c.type); + } } } } @@ -158,7 +165,7 @@ private static void expandPrefixes(Set union, union.add(p + "." + qt + ".encoding_time_s"); } if (wantsNVQ) { - union.add(p + ".NVQ.compute_time_s"); // we intentionally do not time NVQ encode + union.add(p + ".NVQ.compute_time_s"); // Disk NVQ encoding is part of graph/write. } } else if ("search.search_quant_time_s".equals(p)) { for (String qt : searchQuantTypes) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java index da8459645..7a0de96e3 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunArtifacts.java @@ -21,7 +21,9 @@ import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.MetricSelection; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import java.io.IOException; import java.io.UncheckedIOException; @@ -43,6 +45,8 @@ */ public final class RunArtifacts { + private final String ashQueryProjectionRequested = + System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "auto"); private final RunContext run; private final DatasetInfoWriter datasetInfoWriter; private final ExperimentsCsvWriter experimentsWriter; @@ -129,16 +133,18 @@ public static RunArtifacts open(RunConfig runCfg, List datasetConfi } // Logging enabled => create run artifacts - var reporting = RunReporting.open(runCfg); + var reporting = RunReporting.open(runCfg, datasetConfigs); var run = reporting.run(); - System.out.println("Logging enabled: " + run.runDir().toAbsolutePath() + ".\n" + - "Delete this directory to remove run artifacts and free up disk space."); + System.out.println("Results: " + run.runDir().toAbsolutePath()); var datasetInfoWriter = DatasetInfoWriter.open(run); var outputKeyColumns = LoggingSchemaPlanner.unionLoggingMetricKeys(runCfg, datasetConfigs); - var experimentsWriter = new ExperimentsCsvWriter(run, ExperimentsSchemaV1.fixedColumns(), outputKeyColumns); + boolean includeIndexOptions = datasetConfigs != null + && datasetConfigs.stream().anyMatch(cfg -> cfg != null && cfg.usesSchema3()); + var experimentsWriter = new ExperimentsCsvWriter(run, ExperimentsSchemaV1.fixedColumns(includeIndexOptions), + outputKeyColumns); return new RunArtifacts( run, @@ -188,6 +194,28 @@ public static RunArtifacts legacyNoLogging(Map> legacyBench * @param buildParameters construction parameters by name; see {@link ExperimentsSchemaV1#fixedValues} * for which ones become columns */ + public void logRow(String datasetName, Map buildParameters, + Set featureSet, String buildCompressor, String searchCompressor, + boolean usePruning, int topK, double overquery, int rerankK, List outputs) { + logRow(datasetName, buildParameters, featureSet, buildCompressor, searchCompressor, + usePruning, topK, overquery, rerankK, null, outputs); + } + + @Deprecated + public void logRow(String datasetName, int M, int efConstruction, float neighborOverflow, + boolean addHierarchy, boolean refineFinalGraph, Set features, + String buildCompressor, String searchCompressor, boolean usePruning, + int topK, double overquery, int rerankK, AsymmetricHashing ashModel, List outputs) { + Map parameters = new LinkedHashMap<>(); + parameters.put("M", M); + parameters.put("efConstruction", efConstruction); + parameters.put("neighborOverflow", neighborOverflow); + parameters.put("addHierarchy", addHierarchy); + parameters.put("refineFinalGraph", refineFinalGraph); + logRow(datasetName, parameters, features, buildCompressor, searchCompressor, + usePruning, topK, overquery, rerankK, ashModel, outputs); + } + public void logRow(String datasetName, Map buildParameters, Set featureSetForIndex, @@ -197,7 +225,16 @@ public void logRow(String datasetName, int topK, double overquery, int rerankK, + AsymmetricHashing ashQueryModel, List logOutputs) { + logRow(datasetName, buildParameters, featureSetForIndex, buildCompressorString, searchCompressorString, + usePruning, topK, overquery, rerankK, ashQueryModel, Map.of(), logOutputs); + } + + public void logRow(String datasetName, Map buildParameters, Set featureSetForIndex, + String buildCompressorString, String searchCompressorString, boolean usePruning, + int topK, double overquery, int rerankK, AsymmetricHashing ashQueryModel, + Map queryParameters, List logOutputs) { if (experimentsWriter == null || run == null) { return; } @@ -215,6 +252,14 @@ public void logRow(String datasetName, rerankK ); + fixed.put("ash_query_projection_requested", ashQueryModel == null ? "" : ashQueryProjectionRequested); + String effective = ashQueryModel == null ? null + : ashQueryModel.stiefelTransform.initializedQueryProjectionPrecision(); + fixed.put("ash_query_projection_effective", effective == null ? "" : effective); + if (buildParameters.containsKey("type")) { + ExperimentsSchemaV1.addIndexOptions(fixed, buildParameters, queryParameters); + } + try { experimentsWriter.appendRow(fixed, logOutputs); } catch (IOException e) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java index 9cb21e24a..640ef07f7 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/RunReporting.java @@ -16,7 +16,9 @@ package io.github.jbellis.jvector.example.reporting; +import io.github.jbellis.jvector.example.yaml.MultiConfig; import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import java.io.IOException; import java.nio.file.Files; @@ -24,7 +26,9 @@ import java.nio.file.Paths; import java.time.Clock; import java.time.Instant; +import java.util.List; import java.util.UUID; +import java.util.concurrent.ForkJoinPool; /** * Bootstraps a benchmark run directory and writes sys_info.json. @@ -52,6 +56,10 @@ public String jvectorRef() { } public static RunReporting open(RunConfig runCfg) throws IOException { + return open(runCfg, List.of()); + } + + static RunReporting open(RunConfig runCfg, List configs) throws IOException { // Resolve base logging directory (default "logging") String logDir = (runCfg != null && runCfg.logging != null && runCfg.logging.dir != null && !runCfg.logging.dir.isBlank()) ? runCfg.logging.dir.trim() @@ -63,9 +71,9 @@ public static RunReporting open(RunConfig runCfg) throws IOException { String runId = RunIdUtil.renderRunId(runIdTemplate, clock); // Run metadata - String jvectorRef = (runCfg != null && runCfg.logging != null && runCfg.logging.jvectorRef != null) - ? runCfg.logging.jvectorRef - : ""; + String configuredRef = (runCfg != null && runCfg.logging != null && runCfg.logging.jvectorRef != null) + ? runCfg.logging.jvectorRef : ""; + String jvectorRef = System.getProperty("jvector.bench.sourceRef", configuredRef); UUID runUuid = UUID.randomUUID(); Instant createdAt = clock.instant(); @@ -74,7 +82,7 @@ public static RunReporting open(RunConfig runCfg) throws IOException { Files.createDirectories(runDir); Integer buildThreads = detectBuildThreads(); - Integer queryThreads = java.util.concurrent.ForkJoinPool.getCommonPoolParallelism(); + Integer queryThreads = queryParallelism(configs); String systemId = SysInfoWriter.writeSysInfo( runDir, @@ -91,6 +99,30 @@ public static RunReporting open(RunConfig runCfg) throws IOException { return new RunReporting(run, jvectorRef); } + private static Integer queryParallelism(List configs) { + boolean direct = configs == null || configs.isEmpty(); + boolean queued = false; + if (configs != null) { + for (var config : configs) { + if (config == null || !config.usesSchema3()) { + direct = true; + continue; + } + for (var entry : config.search.compression) { + if ("queued".equals(entry.queryExecution())) queued = true; + else direct = true; + } + } + } + if (direct && queued) return null; + if (queued) { + return Math.max(1, Math.min(Runtime.getRuntime().availableProcessors(), PhysicalCoreExecutor.getPhysicalCoreCount())); + } + int selected = Integer.getInteger("jvector.bench.queryThreads", 0); + if (selected < 0) throw new IllegalArgumentException("queryThreads must be nonnegative"); + return selected == 0 ? ForkJoinPool.getCommonPoolParallelism() : selected; + } + private static Integer detectBuildThreads() { try { Object exec = io.github.jbellis.jvector.util.PhysicalCoreExecutor.pool(); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java index 9f9e4ae9a..be4716996 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SearchReportingCatalog.java @@ -90,8 +90,17 @@ private static Map> namedMetricKeys() { "total_file_size_mb", "search.disk.total_file_size_mb", "file_count", "search.disk.file_count" ), + "execution", Map.of( + "response_mean_ms", "search.execution.response_mean_ms", + "response_p95_ms", "search.execution.response_p95_ms", + "response_p99_ms", "search.execution.response_p99_ms", + "mean_batch_size", "search.execution.mean_batch_size" + ), "construction", Map.of( "index_build_time_s","construction.index_build_time_s", + "graph_build_time_s","construction.graph_build_time_s", + "flush_time_s","construction.flush_time_s", + "reopen_time_s","construction.reopen_time_s", "index_quant_time_s","@prefix:construction.index_quant_time_s", "search_quant_time_s","@prefix:search.search_quant_time_s" ) diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java index 4a30196a2..e4cc79327 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/reporting/SysInfoWriter.java @@ -70,7 +70,7 @@ public static String writeSysInfo(Path runDir, threads.put("parallel_stream_parallelism", fjp); // Query benchmark characteristics - threads.put("query_throughput_parallelism", fjp); + threads.put("query_throughput_parallelism", queryThreads); threads.put("query_latency_threads", 1); // Raw common pool parallelism diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java index 48d47f88c..b2fcfa3c4 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/CompressorParameters.java @@ -18,12 +18,16 @@ import io.github.jbellis.jvector.disk.RandomAccessReader; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.BinaryQuantization; import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.ProductQuantization; import io.github.jbellis.jvector.quantization.VectorCompressor; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.io.IOException; +import java.util.concurrent.ForkJoinTask; public abstract class CompressorParameters { public static final CompressorParameters NONE = new NoCompressionParameters(); @@ -47,6 +51,9 @@ public VectorCompressor loadCompressor(RandomAccessReader in) throws IOExcept public abstract VectorCompressor computeCompressor(DataSet ds); + /** Validate dataset-dependent constraints before allocating codes or training a model. */ + public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) {} + public static class PQParameters extends CompressorParameters { private final int m; private final int k; @@ -60,6 +67,20 @@ public PQParameters(int m, int k, boolean isCentered, float anisotropicThreshold this.anisotropicThreshold = anisotropicThreshold; } + @Override + public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) { + if (m < 1 || m > ds.getDimension()) { + throw new IllegalArgumentException("PQ m must be between 1 and the dataset dimension"); + } + if (k < 1 || k > 256 || k > ds.getBaseRavv().size()) { + throw new IllegalArgumentException("PQ k must be between 1 and 256 and fit the training set"); + } + if (fused && k != 256) throw new IllegalArgumentException("Fused PQ requires k=256"); + if (!Float.isFinite(anisotropicThreshold) || anisotropicThreshold < -1 || anisotropicThreshold >= 1) { + throw new IllegalArgumentException("PQ anisotropicThreshold must be finite and in [-1, 1)"); + } + } + @Override public VectorCompressor computeCompressor(DataSet ds) { return ProductQuantization.compute(ds.getBaseRavv(), m, k, isCentered, anisotropicThreshold); @@ -95,6 +116,7 @@ public NVQParameters(int nSubVectors) { this.nSubVectors = nSubVectors; } + @Override public VectorCompressor computeCompressor(DataSet ds) { return NVQuantization.compute(ds.getBaseRavv(), nSubVectors); @@ -122,5 +144,97 @@ public VectorCompressor computeCompressor(DataSet ds) { return null; } } + public static class ASHParameters extends CompressorParameters { + private final int optimizer; + private final int encodedBits; + private final int landmarkCount; + private final int bitsPerDimension; + + public ASHParameters(int optimizer, int encodedBits, int landmarkCount) { + this(optimizer, encodedBits, landmarkCount, 1); + } + + public ASHParameters(int optimizer, int encodedBits, int landmarkCount, int bitsPerDimension) { + this.optimizer = optimizer; + this.encodedBits = encodedBits; + this.landmarkCount = landmarkCount; + this.bitsPerDimension = bitsPerDimension; + } + + @Override + public VectorCompressor loadCompressor(RandomAccessReader in) throws IOException { + return AsymmetricHashing.load(in); + } + + @Override + public void validateFor(DataSet ds, boolean fused, String ashConstructionMode) { + if (ds.getSimilarityFunction() != VectorSimilarityFunction.DOT_PRODUCT) { + throw new IllegalArgumentException("ASH requires dot-product similarity"); + } + int payload = encodedBits - AsymmetricHashing.HEADER_BITS; + if (encodedBits <= AsymmetricHashing.HEADER_BITS || payload % bitsPerDimension != 0 + || payload / bitsPerDimension > ds.getDimension()) { + throw new IllegalArgumentException("ASH encodedBits must yield a whole projected dimension count from 1 to the dataset dimension"); + } + if (optimizer != AsymmetricHashing.ITQ && optimizer != AsymmetricHashing.RANDOM) { + throw new IllegalArgumentException("ASH optimizer must be ITQ or RANDOM"); + } + if (landmarkCount < 1 || landmarkCount > 256 || landmarkCount > ds.getBaseRavv().size()) { + throw new IllegalArgumentException("ASH landmarkCount must be between 1 and 256 and fit the training set"); + } + if (ashConstructionMode != null) { + switch (ashConstructionMode) { + case "symmetric": + case "symmetric-all": + case "symmetric-scalar": + if (landmarkCount != 1) { + throw new IllegalArgumentException("Symmetric ASH construction scoring requires one landmark"); + } + break; + case "asymmetric": + case "asymmetric-cached": + break; + default: + throw new IllegalArgumentException("Unknown ASH construction mode: " + ashConstructionMode); + } + } + } + + @Override + public VectorCompressor computeCompressor(DataSet ds) { + try { + // Native BLAS uses the construction pool's size when training runs in it, + // otherwise the library's physical-core pool. + var executor = ForkJoinTask.inForkJoinPool() ? ForkJoinTask.getPool() : PhysicalCoreExecutor.pool(); + return AsymmetricHashing.initialize( + ds.getBaseRavv(), + optimizer, + encodedBits, + landmarkCount, + bitsPerDimension, + executor); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public String idStringFor(DataSet ds) { + return String.format( + "ASH_%s_v%d_opt%d_bits%d_bpd%d_C%d", + ds.getName(), + io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION, + optimizer, + encodedBits, + bitsPerDimension, + landmarkCount + ); + } + + @Override + public boolean supportsCaching() { + return true; + } + } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java index 79a921a46..b7db5156c 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/MMapReader.java @@ -45,6 +45,11 @@ public long getPosition() { return position; } + public byte readByte() { + return buffer.memory().getByte(position++); + } + + @Override public int readInt() { try { return buffer.memory().getInt(position); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java index ae4a57e1d..f1b5d3227 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCache.java @@ -129,6 +129,11 @@ public static final class CacheKey { public final boolean addHierarchy; public final boolean refineFinalGraph; public final String compressorId; + /** Base vectors in the dataset; two datasets or profiles sharing a name must not share a graph. */ + public final int baseCount; + public final int dimension; + /** NVQ subvectors when the feature set stores NVQ vectors, otherwise 0. */ + public final int nvqSubvectors; private CacheKey(String datasetName, Set featureSet, @@ -138,7 +143,10 @@ private CacheKey(String datasetName, float alpha, boolean addHierarchy, boolean refineFinalGraph, - String compressorId) { + String compressorId, + int baseCount, + int dimension, + int nvqSubvectors) { this.datasetName = datasetName; this.featureSet = featureSet; this.M = M; @@ -148,6 +156,9 @@ private CacheKey(String datasetName, this.addHierarchy = addHierarchy; this.refineFinalGraph = refineFinalGraph; this.compressorId = compressorId; + this.baseCount = baseCount; + this.dimension = dimension; + this.nvqSubvectors = nvqSubvectors; } } @@ -163,12 +174,28 @@ public CacheKey key(String datasetName, float alpha, boolean addHierarchy, boolean refineFinalGraph, - VectorCompressor buildCompressor) { + VectorCompressor buildCompressor, + int baseCount, + int dimension, + int nvqSubvectors) { Objects.requireNonNull(datasetName, "datasetName"); Objects.requireNonNull(featureSet, "featureSet"); Objects.requireNonNull(buildCompressor, "buildCompressor"); String compressorId = buildCompressor.toString().replaceAll("\\s+", ""); - return new CacheKey(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, addHierarchy, refineFinalGraph, compressorId); + return keyWithCompressorId(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, + addHierarchy, refineFinalGraph, compressorId, baseCount, dimension, nvqSubvectors); + } + + public CacheKey keyWithCompressorId(String datasetName, Set featureSet, int M, + int efConstruction, float neighborOverflow, float alpha, + boolean addHierarchy, boolean refineFinalGraph, String compressorId, + int baseCount, int dimension, int nvqSubvectors) { + if (baseCount <= 0 || dimension <= 0 || nvqSubvectors < 0) + throw new IllegalArgumentException("Cache key needs a positive vector count and dimension"); + if (featureSet.contains(FeatureId.NVQ_VECTORS) != (nvqSubvectors > 0)) + throw new IllegalArgumentException("NVQ subvectors must be given exactly when NVQ vectors are stored"); + return new CacheKey(datasetName, featureSet, M, efConstruction, neighborOverflow, alpha, + addHierarchy, refineFinalGraph, compressorId, baseCount, dimension, nvqSubvectors); } /** @@ -326,7 +353,6 @@ static Entry disabled() { return new Entry(true, "", Path.of(""), Path.of("")); } - // TODO include index and quantization version(s) in signature static Entry compute(Path cacheDir, CacheKey key) { String rawName = Paths.get(key.datasetName).getFileName().toString(); String datasetBase = rawName.replaceFirst("\\.[^.]+$", ""); @@ -336,9 +362,14 @@ static Entry compute(Path cacheDir, CacheKey key) { .sorted() .collect(Collectors.joining("-")); + // The on-disk format, the dataset's shape and the NVQ layout all change the file; the + // quantizer ids in compressorId carry their own model parameters and versions. String signature = String.join("_", datasetBase, - featureSetName, + "n" + key.baseCount, + "d" + key.dimension, + "fmt" + OnDiskGraphIndex.CURRENT_VERSION, + featureSetName + (key.nvqSubvectors > 0 ? "-nvq" + key.nvqSubvectors : ""), "M" + key.M, "ef" + key.efConstruction, "of" + key.neighborOverflow, diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java index a491d0c9e..58e987f71 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/util/SiftLoader.java @@ -35,9 +35,15 @@ public class SiftLoader { private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); public static List> readFvecs(String filePath) { + return readFvecs(filePath, Integer.MAX_VALUE); + } + + /** Reads at most {@code maxVectors} vectors without loading the rest of the file. */ + public static List> readFvecs(String filePath, int maxVectors) { + if (maxVectors <= 0) throw new IllegalArgumentException("maxVectors must be positive"); var vectors = new ArrayList>(); try (var dis = new DataInputStream(new BufferedInputStream(new FileInputStream(filePath)))) { - while (dis.available() > 0) { + while (vectors.size() < maxVectors && dis.available() > 0) { var dimension = Integer.reverseBytes(dis.readInt()); if (dimension <= 0) { throw new IOException("Corrupt fvecs file: negative or zero dimension " + dimension + " (possible file corruption or wrong format)"); diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java index 626efc591..429210cf9 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/CommonParameters.java @@ -29,4 +29,14 @@ public class CommonParameters { public List> getCompressorParameters() { return compression.stream().map(Compression::getCompressorParameters).collect(Collectors.toList()); } + + void validateCompression(String section, boolean search) { + if (compression == null || compression.isEmpty()) { + throw new IllegalArgumentException(section + ".compression must contain at least one entry"); + } + for (var entry : compression) { + if (entry == null) throw new IllegalArgumentException(section + ".compression contains an empty entry"); + entry.validateSchema3(section, search); + } + } } diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java index a8277508a..6e171c12e 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Compression.java @@ -18,15 +18,102 @@ import io.github.jbellis.jvector.example.util.CompressorParameters; import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import java.util.Map; +import java.util.List; +import java.util.Set; import java.util.function.Function; public class Compression { public String type; + public String scoring; public Map parameters; + void validateSchema3(String section, boolean search) { + String path = section + ".compression"; + if (type == null || !List.of("None", "ASH", "PQ", "BQ").contains(type)) { + throw new IllegalArgumentException(path + ".type must be None, ASH, PQ, or BQ"); + } + if (scoring != null && (search || !"ASH".equals(type) + || !List.of("symmetric", "asymmetric", "mixed").contains(scoring))) { + throw new IllegalArgumentException(path + ".scoring requires ASH construction and must be symmetric, asymmetric, or mixed"); + } + + Set allowed; + switch (type) { + case "ASH": + allowed = search + ? Set.of("optimizer", "encodedBits", "compressionRatio", "landmarkCount", "bitsPerDimension", + "queryProjection", "queryExecution", "maxBatchSize") + : Set.of("optimizer", "encodedBits", "compressionRatio", "landmarkCount", "bitsPerDimension", + "queryProjection"); + break; + case "PQ": + allowed = Set.of("m", "mFactor", "k", "centerData", "anisotropicThreshold"); + break; + default: + allowed = Set.of(); + } + var settings = parameters == null ? Map.of() : parameters; + for (var setting : settings.entrySet()) { + var key = setting.getKey(); + if (key == null || !allowed.contains(key) || setting.getValue() == null) { + throw new IllegalArgumentException(path + ".parameters." + key + " is invalid for " + type); + } + } + if ("PQ".equals(type) && settings.containsKey("m") == settings.containsKey("mFactor")) { + throw new IllegalArgumentException(path + " requires exactly one of m or mFactor for PQ"); + } + if ("PQ".equals(type) && settings.containsKey("mFactor") && Integer.parseInt(settings.get("mFactor")) < 1) { + throw new IllegalArgumentException(path + ".parameters.mFactor must be positive"); + } + if ("ASH".equals(type)) { + if (!settings.containsKey("optimizer") + || settings.containsKey("encodedBits") == settings.containsKey("compressionRatio")) { + throw new IllegalArgumentException(path + " requires optimizer and exactly one of encodedBits or compressionRatio for ASH"); + } + if (!List.of("ITQ", "RANDOM").contains(settings.get("optimizer"))) { + throw new IllegalArgumentException(path + ".parameters.optimizer must be ITQ or RANDOM"); + } + var projection = settings.get("queryProjection"); + if (projection != null) SearchParameters.AshQueryProjectionSetting.validatePrecision(projection); + var execution = queryExecution(); + if (!List.of("direct", "queued").contains(execution)) { + throw new IllegalArgumentException(path + ".parameters.queryExecution must be direct or queued"); + } + if (settings.containsKey("maxBatchSize") && !"queued".equals(execution)) { + throw new IllegalArgumentException(path + ".parameters.maxBatchSize requires queued queryExecution"); + } + maxBatchSize(); + } + } + + public String queryExecution() { + return parameters == null ? "direct" : parameters.getOrDefault("queryExecution", "direct"); + } + + public int maxBatchSize() { + String configured = parameters == null ? null : parameters.get("maxBatchSize"); + if (configured == null) return 8; + int size = Integer.parseInt(configured); + if (size <= 0 || size == Integer.MAX_VALUE) { + throw new IllegalArgumentException("maxBatchSize must be positive and fit its histogram"); + } + return size; + } + + public String ashConstructionMode() { + if (scoring == null) return null; + switch (scoring) { + case "symmetric": return "symmetric-all"; + case "mixed": return "symmetric"; + case "asymmetric": return "asymmetric"; + default: throw new IllegalArgumentException("Unknown ASH construction scoring: " + scoring); + } + } + public Function getCompressorParameters() { switch (type) { case "None": @@ -44,7 +131,7 @@ public Function getCompressorParameters() { if (strCenterData == null) { centerData = ds.getSimilarityFunction() == VectorSimilarityFunction.EUCLIDEAN; } else { - centerData = strCenterData.equals("Yes");; + centerData = strCenterData.equals("Yes"); } if (parameters.containsKey("m")) { @@ -60,9 +147,97 @@ public Function getCompressorParameters() { }; case "BQ": return ds -> new CompressorParameters.BQParameters(); + case "ASH": + return ds -> { + if (parameters == null) { + throw new IllegalArgumentException("ASH requires parameters"); + } + + int landmarkCount = Integer.parseInt(parameters.getOrDefault("landmarkCount", "1")); + int bitsPerDimension = Integer.parseInt(parameters.getOrDefault("bitsPerDimension", "1")); + if (bitsPerDimension < 1 || bitsPerDimension > 9) { + throw new IllegalArgumentException( + "ASH bitsPerDimension must be in [1,9], got " + bitsPerDimension); + } + + // optimizer can be numeric or name (RANDOM/ITQ) + String optStr = parameters.get("optimizer"); + if (optStr == null) { + throw new IllegalArgumentException("ASH requires 'optimizer' parameter"); + } + + int optimizer; + switch (optStr.trim().toUpperCase()) { + case "RANDOM": + optimizer = AsymmetricHashing.RANDOM; + break; + case "ITQ": + optimizer = AsymmetricHashing.ITQ; + break; + default: + optimizer = Integer.parseInt(optStr); + } + + boolean hasEncodedBits = parameters.containsKey("encodedBits"); + boolean hasCompressionRatio = parameters.containsKey("compressionRatio"); + if (hasEncodedBits == hasCompressionRatio) { + throw new IllegalArgumentException( + "ASH requires exactly one of 'encodedBits' or 'compressionRatio'"); + } + + int encodedBits; + if (hasEncodedBits) { + encodedBits = Integer.parseInt(parameters.get("encodedBits")); + } else { + int requestedCompressionRatio = Integer.parseInt(parameters.get("compressionRatio")); + if (requestedCompressionRatio != 8 + && requestedCompressionRatio != 16 + && requestedCompressionRatio != 32 + && requestedCompressionRatio != 64 + && requestedCompressionRatio != 128 + && requestedCompressionRatio != 256) { + throw new IllegalArgumentException( + "ASH compressionRatio must be one of 8, 16, 32, 64, 128, or 256"); + } + + int originalBits = ds.getDimension() * Float.SIZE; + int targetEncodedBits = Math.max(AsymmetricHashing.HEADER_BITS + 64, + originalBits / requestedCompressionRatio); + + int minimumPayloadBits = ((64 + bitsPerDimension - 1) / bitsPerDimension) + * bitsPerDimension; + int payloadTargetBits = Math.max(minimumPayloadBits, + targetEncodedBits - AsymmetricHashing.HEADER_BITS); + int lowerPayloadBits = Math.max(minimumPayloadBits, + (payloadTargetBits / bitsPerDimension) * bitsPerDimension); + int upperPayloadBits = Math.max(minimumPayloadBits, + ((payloadTargetBits + bitsPerDimension - 1) / bitsPerDimension) * bitsPerDimension); + + int payloadBits = Math.abs(lowerPayloadBits - payloadTargetBits) + <= Math.abs(upperPayloadBits - payloadTargetBits) + ? lowerPayloadBits + : upperPayloadBits; + + encodedBits = AsymmetricHashing.HEADER_BITS + payloadBits; + + double actualCompressionRatio = (double) originalBits / encodedBits; + System.out.printf( + "ASH requested compressionRatio=%d bitsPerDimension=%d resolved to encodedBits=%d " + + "(header=%d, payload=%d) actualCompressionRatio=%.2f%n", + requestedCompressionRatio, + bitsPerDimension, + encodedBits, + AsymmetricHashing.HEADER_BITS, + payloadBits, + actualCompressionRatio); + } + + return new CompressorParameters.ASHParameters( + optimizer, encodedBits, landmarkCount, bitsPerDimension); + }; default: throw new IllegalArgumentException("Unsupported compression type: " + type); } } -} \ No newline at end of file +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java index 5177fdf4a..942f94139 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/ConstructionParameters.java @@ -18,55 +18,141 @@ import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import java.util.ArrayList; import java.util.EnumSet; +import java.util.LinkedHashSet; import java.util.List; -import java.util.stream.Collectors; +import java.util.Map; +import java.util.function.Predicate; public class ConstructionParameters extends CommonParameters { + public String type; + public String writerMode; public List outDegree; public List efConstruction; public List neighborOverflow; public List addHierarchy; public List refineFinalGraph; - public List reranking; + /** Schema 2 accepts a list of features. Schema 3 accepts a single map with a type. */ + public Object reranking; public List fusedGraph; public Boolean useSavedIndexIfExists; + /** Schema 3: threads for training, encoding, graph insertion and writing; an integer or max. */ + public Object workers; + + /** The construction worker count, or null when the YAML leaves it to the platform default. */ + public Integer resolvedWorkers() { + return workers == null ? null : Workers.resolve(workers, "construction.workers"); + } + + void validateSchema3() { + if (type == null || !List.of("inMemory", "onDisk").contains(type)) { + throw new IllegalArgumentException("construction.type must be inMemory or onDisk"); + } + if (writerMode != null && !List.of("sequential", "parallel").contains(writerMode)) { + throw new IllegalArgumentException("construction.writerMode must be sequential or parallel"); + } + if ("inMemory".equals(type) && (writerMode != null || fusedGraph != null || useSavedIndexIfExists != null)) { + throw new IllegalArgumentException("construction.writerMode, fusedGraph, and useSavedIndexIfExists require onDisk"); + } + validateSweep("outDegree", outDegree, value -> value > 0); + validateSweep("efConstruction", efConstruction, value -> value > 0); + validateSweep("neighborOverflow", neighborOverflow, value -> Float.isFinite(value) && value >= 1); + validateSweep("addHierarchy", addHierarchy, value -> true); + validateSweep("refineFinalGraph", refineFinalGraph, value -> true); + if (fusedGraph != null) validateSweep("fusedGraph", fusedGraph, value -> true); + rerankingType(); + resolvedWorkers(); + validateCompression("construction", false); + } + + private static void validateSweep(String name, List values, Predicate valid) { + if (values == null || values.isEmpty() || values.stream().anyMatch(value -> value == null || !valid.test(value))) { + throw new IllegalArgumentException("construction." + name + " requires a nonempty list of valid values"); + } + } + + public String rerankingType() { + if (reranking == null) return "None"; + if (!(reranking instanceof Map)) { + throw new IllegalArgumentException("construction.reranking must be a map with a type in schema 3"); + } + var fields = (Map) reranking; + if (fields.size() != 1 || !(fields.get("type") instanceof String) + || !List.of("None", "FP32", "NVQ").contains(fields.get("type"))) { + throw new IllegalArgumentException("construction.reranking.type must be None, FP32, or NVQ"); + } + return (String) fields.get("type"); + } + + public boolean usesNVQReranking() { + if (reranking instanceof List) return ((List) reranking).contains("NVQ"); + return "NVQ".equals(rerankingType()); + } public List> getFeatureSets() { - List> featureSets = null; + var featureSets = new LinkedHashSet>(); + boolean hasPQConstruction = hasConstructionCompressor("PQ"); + boolean hasASHConstruction = hasConstructionCompressor("ASH"); + for (var fusedItem : fusedGraph) { - var newFeatures = reranking.stream().map(item -> { - EnumSet features; + if (!(reranking instanceof List)) { + throw new IllegalArgumentException("construction.reranking must be a list in schema 2"); + } + for (var item : (List) reranking) { + EnumSet baseFeatures; - switch (item) { + switch (String.valueOf(item)) { case "FP": - if (fusedItem) { - features = EnumSet.of(FeatureId.INLINE_VECTORS, FeatureId.FUSED_PQ); - } else { - features = EnumSet.of(FeatureId.INLINE_VECTORS); - } + baseFeatures = EnumSet.of(FeatureId.INLINE_VECTORS); break; case "NVQ": - if (fusedItem) { - features = EnumSet.of(FeatureId.NVQ_VECTORS, FeatureId.FUSED_PQ); - } else { - features = EnumSet.of(FeatureId.NVQ_VECTORS); - } + baseFeatures = EnumSet.of(FeatureId.NVQ_VECTORS); break; default: throw new IllegalArgumentException("Only 'FP' and 'NVQ' are supported"); } - return features; - }).collect(Collectors.toList()); - if (featureSets == null) { - featureSets = newFeatures; - } else { - featureSets.addAll(newFeatures); + if (!fusedItem) { + featureSets.add(baseFeatures); + continue; + } + + boolean addedFused = false; + if (hasPQConstruction) { + var features = EnumSet.copyOf(baseFeatures); + features.add(FeatureId.FUSED_PQ); + featureSets.add(features); + addedFused = true; + } + if (hasASHConstruction) { + var features = EnumSet.copyOf(baseFeatures); + features.add(FeatureId.FUSED_ASH); + featureSets.add(features); + addedFused = true; + } + + if (!addedFused) { + throw new IllegalArgumentException( + "fusedGraph=Yes requires construction compression type PQ or ASH"); + } } } - return featureSets; + return new ArrayList<>(featureSets); + } + + private boolean hasConstructionCompressor(String type) { + if (compression == null) { + return false; + } + + for (Compression c : compression) { + if (c != null && c.type != null && c.type.equalsIgnoreCase(type)) { + return true; + } + } + return false; } -} \ No newline at end of file +} diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java index fc1f7f351..3b4fb6e78 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/DatasetCollection.java @@ -28,6 +28,8 @@ public class DatasetCollection { private static final String defaultFile = "./jvector-examples/yaml-configs/datasets.yml"; + /** The section CI benchmarks select from; its datasets need the protected catalog. */ + public static final String REGRESSION_TESTS = "regression-tests"; public final Map> datasetNames; @@ -56,6 +58,17 @@ public List getAll() { return allDatasetNames; } + /** All dataset names outside the given section. */ + public List getAllExcept(String section) { + List names = new ArrayList<>(); + for (var entry : datasetNames.entrySet()) { + if (!entry.getKey().equals(section) && entry.getValue() != null) { + names.addAll(entry.getValue()); + } + } + return names; + } + public List getSection(String section) { List sectionDatasetNames = new ArrayList<>(); for (var key : datasetNames.keySet()) { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java index 851d2b2ab..80c396b0b 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/MultiConfig.java @@ -48,33 +48,45 @@ public class MultiConfig { private int yamlSchemaVersion; private int onDiskIndexVersion; - private static final java.util.concurrent.ConcurrentSkipListSet DEFAULT_USED_FOR_DATASETS = - new java.util.concurrent.ConcurrentSkipListSet<>(); - - private static final java.util.concurrent.atomic.AtomicReference DEFAULT_FILE_USED = - new java.util.concurrent.atomic.AtomicReference<>(); - + /** + * Returns the default index config for a dataset: {@code index-parameters/.yml}, named exactly + * like the dataset and declaring that dataset. Subdirectories hold examples and are never searched. + * A dataset without its own file uses {@code default.yml}. + */ public static MultiConfig getDefaultConfig(String datasetName) throws FileNotFoundException { - var name = defaultDirectory + datasetName; - if (!name.endsWith(".yml")) { - name += ".yml"; - } - File configFile = new File(name); - boolean useDefault = !configFile.exists(); - if (useDefault) { - configFile = new File(defaultDirectory + "default.yml"); - - // Record fallback usage for a compact summary print later - DEFAULT_FILE_USED.compareAndSet(null, configFile); - DEFAULT_USED_FOR_DATASETS.add(datasetName); - } - - var config = getConfig(configFile); + return getDefaultConfig(new File(defaultDirectory), datasetName); + } - if (useDefault) { - config.dataset = datasetName; + static MultiConfig getDefaultConfig(File directory, String datasetName) throws FileNotFoundException { + String fileName = datasetName.endsWith(".yml") ? datasetName : datasetName + ".yml"; + String[] names = directory.list(); + if (names == null) throw new FileNotFoundException("Index config directory not found: " + directory.getPath()); + // Compare names exactly. File.exists ignores case on some file systems, so lookups would + // otherwise find different files on macOS and Linux. + String caseOnly = null; + for (String name : names) { + if (name.equals(fileName)) { + File file = new File(directory, name); + MultiConfig config = getConfig(file); + if (config.dataset != null && !config.dataset.equals(datasetName)) { + throw new IllegalArgumentException("Index config " + file.getPath() + " declares dataset: " + + config.dataset + "." + System.lineSeparator() + + "A default config's file name must match its dataset field exactly."); + } + return config; + } + if (name.equalsIgnoreCase(fileName) && new File(directory, name).isFile()) caseOnly = name; } - + if (caseOnly != null) { + throw new IllegalArgumentException("No index config is named exactly " + fileName + "; found" + + System.lineSeparator() + " " + new File(directory, caseOnly).getPath() + + System.lineSeparator() + "File names must match the dataset name exactly, including case."); + } + File fallback = new File(directory, "default.yml"); + System.err.println("WARNING: No index config named " + fileName + "; using " + fallback.getPath() + "." + + System.lineSeparator() + "Dataset defaults are named .yml in " + directory.getPath() + "."); + MultiConfig config = getConfig(fallback); + config.dataset = datasetName; return config; } @@ -82,14 +94,16 @@ public static MultiConfig getConfig(String configName) throws FileNotFoundExcept File configFile = new File(configName); // If the file doesn't exist as an absolute path, try relative to the default directory if (!configFile.exists() && !configName.startsWith("/") && !configName.contains(":")) { - configFile = new File(defaultDirectory + configName); + File relative = new File(defaultDirectory + configName); + if (!relative.exists()) throw new FileNotFoundException("Index config not found: " + configName); + configFile = relative; } return getConfig(configFile); } static MultiConfig getConfig(File configFile) throws FileNotFoundException { if (!configFile.exists()) { - throw new FileNotFoundException(configFile.getAbsolutePath()); + throw new FileNotFoundException("Index config not found: " + configFile.getPath()); } final String text; @@ -115,11 +129,18 @@ static MultiConfig getConfig(File configFile) throws FileNotFoundException { + configFile.getAbsolutePath()); } - // Validate supported versions - if (cfg.getYamlSchemaVersion() != 1) { + // Validate supported versions. Schema 1 and 2 share one layout; schema 2 only + // marked files written after ASH was added, so earlier schema 1 files still load. + if (cfg.getYamlSchemaVersion() < 1 || cfg.getYamlSchemaVersion() > 3) { throw new IllegalArgumentException("Unsupported yamlSchemaVersion=" + cfg.getYamlSchemaVersion() + " in " + configFile.getAbsolutePath()); } + try { + if (cfg.usesSchema3()) cfg.validateSchema3(); + else cfg.validateSchema2(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(configFile.getAbsolutePath() + ": " + e.getMessage(), e); + } return cfg; } catch (IOException e) { @@ -131,7 +152,7 @@ static MultiConfig getConfig(File configFile) throws FileNotFoundException { // Legacy yamlSchemaVersion "0": lenient parsing (ignore unknown fields like search.benchmarks) if (WARNED_LEGACY.compareAndSet(false, true)) { System.err.println("WARNING: Deprecated legacy YAML schema detected (no yamlSchemaVersion). " - + "Unknown fields will be ignored. Please migrate configs to yamlSchemaVersion: 1 and run-config.yml."); + + "Unknown fields will be ignored. Please migrate configs to yamlSchemaVersion: 3 and run-config.yml."); } try { @@ -189,6 +210,104 @@ public int getYamlSchemaVersion() { return yamlSchemaVersion; } + public boolean usesSchema3() { + return yamlSchemaVersion == 3; + } + + private void validateSchema2() { + if (construction != null && (construction.type != null || construction.writerMode != null + || (construction.reranking != null && !(construction.reranking instanceof List)))) { + throw new IllegalArgumentException("construction.type, writerMode, and reranking.type require schema 3"); + } + if ((construction != null && construction.workers != null) || (search != null && search.workers != null)) { + throw new IllegalArgumentException("construction.workers and search.workers require schema 3"); + } + if (hasSchema3CompressionFields(construction) || hasSchema3CompressionFields(search)) { + throw new IllegalArgumentException("compression scoring and ASH query settings require schema 3"); + } + } + + private static boolean hasSchema3CompressionFields(CommonParameters section) { + if (section == null || section.compression == null) return false; + for (var entry : section.compression) { + if (entry == null) continue; + if (entry.scoring != null) return true; + if ("ASH".equals(entry.type) && entry.parameters != null + && (entry.parameters.containsKey("queryExecution") + || entry.parameters.containsKey("maxBatchSize") + || entry.parameters.containsKey("queryProjection"))) return true; + } + return false; + } + + private void validateSchema3() { + if (dataset == null || dataset.isBlank() || construction == null || search == null) { + throw new IllegalArgumentException("schema 3 requires dataset, construction, and search"); + } + construction.validateSchema3(); + search.validateSchema3(); + var reranking = construction.rerankingType(); + boolean fused = construction.fusedGraph != null && construction.fusedGraph.contains(Boolean.TRUE); + if (fused && "None".equals(reranking)) { + throw new IllegalArgumentException("fusedGraph requires construction.reranking.type FP32 or NVQ"); + } + for (var entry : search.compression) { + if (fused && !List.of("ASH", "PQ").contains(entry.type)) { + throw new IllegalArgumentException("fusedGraph requires ASH or PQ search compression"); + } + if ("None".equals(entry.type) && !"None".equals(reranking)) { + throw new IllegalArgumentException("Search type None scores with full-precision vectors, so " + + reranking + " reranking does not apply. Use reranking.type None"); + } + } + } + + /** + * The pipeline this schema 3 config selects, inferred from its fields, for example + * {@code onDisk: build ASH-2b symmetric -> search fused ASH-2b -> rerank NVQ}. + * Sweeps list each value. Returns null for older schemas. + */ + public String describePipeline() { + return pipeline(true); + } + + /** As {@link #describePipeline()} without quantizer settings, so configs with one pipeline compare equal. */ + public String pipelineSignature() { + return pipeline(false); + } + + private String pipeline(boolean detailed) { + if (!usesSchema3()) return null; + String fused = ""; + if (construction.fusedGraph != null) { + boolean yes = construction.fusedGraph.contains(Boolean.TRUE); + boolean no = construction.fusedGraph.contains(Boolean.FALSE); + fused = yes && no ? "fused or separate " : yes ? "fused " : ""; + } + return construction.type + + ": build " + describe(construction.compression, detailed) + + " -> search " + fused + describe(search.compression, detailed) + + " -> rerank " + construction.rerankingType(); + } + + private static String describe(List entries, boolean detailed) { + var parts = new ArrayList(); + for (var entry : entries) { + // No compression means the stage scores full-precision vectors; say so. + String part = "None".equals(entry.type) ? "FP32" : entry.type; + if (detailed && "ASH".equals(entry.type)) { + Map parameters = entry.parameters == null ? Map.of() : entry.parameters; + Object bits = parameters.get("bitsPerDimension"); + part += "-" + (bits == null ? "1" : bits) + "b"; + Object landmarks = parameters.get("landmarkCount"); + if (landmarks != null && !"1".equals(String.valueOf(landmarks))) part += " " + landmarks + " landmarks"; + } + if (detailed && entry.scoring != null) part += " " + entry.scoring; + parts.add(part); + } + return String.join(" | ", parts); + } + public void setYamlSchemaVersion(int yamlSchemaVersion) { this.yamlSchemaVersion = yamlSchemaVersion; } @@ -205,29 +324,6 @@ public void setOnDiskIndexVersion(int onDiskIndexVersion) { this.onDiskIndexVersion = onDiskIndexVersion; } - public static void printDefaultConfigUsageSummary() { - File f = DEFAULT_FILE_USED.get(); - if (f == null) return; - - var datasets = new java.util.ArrayList<>(DEFAULT_USED_FOR_DATASETS); - if (datasets.isEmpty()) return; - - // Print a wrapped bracket-list similar to "Executing the following datasets" - System.out.println("Default YAML used for datasets: " + wrapList(datasets, 6, " ")); - } - - private static String wrapList(java.util.List items, int perLine, String indent) { - StringBuilder sb = new StringBuilder(); - sb.append("["); - for (int i = 0; i < items.size(); i++) { - if (i > 0) sb.append(", "); - if (i > 0 && (i % perLine) == 0) sb.append(System.lineSeparator()).append(indent); - sb.append(items.get(i)); - } - sb.append("]"); - return sb.toString(); - } - @SuppressWarnings("unchecked") private static Map> extractLegacySearchBenchmarks(String yamlText) { try { diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java index 8711007aa..a78802f10 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/RunConfig.java @@ -22,6 +22,8 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.List; import java.util.Map; @@ -50,8 +52,9 @@ public class RunConfig { /** Logging selection and run-level logging settings. */ public RunLogging logging; + /** Select an explicit run YAML without editing the checkout's default file. */ public static RunConfig loadDefault() throws FileNotFoundException { - return load(defaultDirectory + defaultRunFile); + return load(System.getProperty("jvector.bench.runConfig", defaultDirectory + defaultRunFile)); } public static RunConfig load(String configName) throws FileNotFoundException { @@ -62,9 +65,16 @@ public static RunConfig load(String configName) throws FileNotFoundException { if (!configFile.exists()) { throw new FileNotFoundException(configFile.getAbsolutePath()); } - InputStream inputStream = new FileInputStream(configFile); - Yaml yaml = new Yaml(); - return yaml.loadAs(inputStream, RunConfig.class); + try (InputStream inputStream = new FileInputStream(configFile)) { + Yaml yaml = new Yaml(); + RunConfig config = yaml.loadAs(inputStream, RunConfig.class); + if (config == null) throw new IllegalArgumentException("Empty run configuration: " + configFile); + return config; + } catch (FileNotFoundException failure) { + throw failure; + } catch (IOException failure) { + throw new UncheckedIOException("Cannot read run configuration: " + configFile, failure); + } } /** Run-level logging settings + selection for experiments.csv. */ diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java index dabce268a..4606ce988 100644 --- a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/SearchParameters.java @@ -18,10 +18,120 @@ import java.util.List; import java.util.Map; +import java.util.ArrayList; +import java.util.Objects; public class SearchParameters extends CommonParameters { + /** ASH query arithmetic; omitted settings preserve JVM properties and auto. */ + public String queryProjection; + + public static final String ASH_QUERY_PROJECTION_PROPERTY = "jvector.ash.queryProjection"; + + /** Resolve one precision before loading models; cached views cannot change mid-JVM. */ + public static final class AshQueryProjectionSetting implements AutoCloseable { + private final String original = System.getProperty(ASH_QUERY_PROJECTION_PROPERTY); + private boolean applied; + + public void apply(List configs) { + String selected = null; + boolean explicitYaml = false; + List settings = new ArrayList<>(); + boolean usesAsh = false; + for (MultiConfig config : configs) { + boolean configUsesAsh = hasAsh(config.construction) || hasAsh(config.search); + usesAsh |= configUsesAsh; + if (config.usesSchema3()) { + collectProjectionSettings(config.construction, settings); + collectProjectionSettings(config.search, settings); + } else { + String legacy = config.search == null ? null : config.search.queryProjection; + if (legacy != null) validatePrecision(legacy); + if (configUsesAsh) settings.add(legacy); + } + } + if (usesAsh && original != null) validatePrecision(original); + for (var yaml : settings) { + if (yaml != null) validatePrecision(yaml); + String mode = original != null ? original : yaml == null ? "auto" : yaml; + validatePrecision(mode); + if (selected != null && !selected.equals(mode)) + throw new IllegalArgumentException("Different ASH queryProjection settings require separate BenchYAML JVMs"); + selected = mode; + explicitYaml |= yaml != null; + } + if (original == null && explicitYaml && selected != null) { + System.setProperty(ASH_QUERY_PROJECTION_PROPERTY, selected); + applied = true; + } + } + + private static void collectProjectionSettings(CommonParameters section, List settings) { + if (section == null || section.compression == null) return; + for (var entry : section.compression) { + if (entry != null && "ASH".equals(entry.type) && entry.parameters != null) { + String value = entry.parameters.get("queryProjection"); + if (value != null) settings.add(value); + } + } + } + + private static boolean hasAsh(CommonParameters parameters) { + return parameters != null && parameters.compression != null + && parameters.compression.stream().anyMatch(c -> c != null && "ASH".equals(c.type)); + } + + static void validatePrecision(String value) { + if (!List.of("auto", "fp32", "fp16", "int8").contains(value)) + throw new IllegalArgumentException("queryProjection must be auto, fp32, fp16 or int8: " + value); + } + + @Override public void close() { + if (applied) { + System.clearProperty(ASH_QUERY_PROJECTION_PROPERTY); + applied = false; + } + } + } + public Map> topKOverquery; public List useSearchPruning; + /** + * Schema 3: a sweep of search worker counts, each an integer or max. Direct search uses this many + * calling threads; queued ASH search uses this many workers to prepare and execute queries. + */ + public List workers; + + /** One resolved count per sweep value, or null when the YAML leaves it to the platform default. */ + public List resolvedWorkers() { + if (workers == null) return null; + if (workers.isEmpty()) throw new IllegalArgumentException("search.workers must list at least one worker count"); + var counts = new ArrayList(); + for (var value : workers) counts.add(Workers.resolve(value, "search.workers")); + return counts; + } + + void validateSchema3() { + if (queryProjection != null) { + throw new IllegalArgumentException("place queryProjection in the ASH compression parameters in schema 3"); + } + validateCompression("search", true); + resolvedWorkers(); + if (topKOverquery == null || topKOverquery.isEmpty() || useSearchPruning == null || useSearchPruning.isEmpty() + || useSearchPruning.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("search.topKOverquery and search.useSearchPruning must be set"); + } + for (var entry : topKOverquery.entrySet()) { + var k = entry.getKey(); + if (k == null || k <= 0 || entry.getValue() == null || entry.getValue().isEmpty()) { + throw new IllegalArgumentException("search.topKOverquery requires positive K and a list of multipliers"); + } + for (var multiplier : entry.getValue()) { + if (multiplier == null || !Double.isFinite(multiplier) || multiplier < 1 || multiplier * k > Integer.MAX_VALUE) { + throw new IllegalArgumentException("search.topKOverquery multipliers must be finite, at least 1, and fit an integer candidate budget"); + } + } + } + } // NOTE: benchmark compute + console/logging selection are now run-level (run-config.yml) // and are no longer recognized in dataset configs. diff --git a/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Workers.java b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Workers.java new file mode 100644 index 000000000..3dc3add0b --- /dev/null +++ b/jvector-examples/src/main/java/io/github/jbellis/jvector/example/yaml/Workers.java @@ -0,0 +1,32 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example.yaml; + +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; + +/** Worker counts in index YAML: a positive integer, or {@code max} for the platform's physical cores. */ +public final class Workers { + private Workers() {} + + /** Resolves one YAML value; {@code field} names it in the error message. */ + public static int resolve(Object value, String field) { + if ("max".equals(value)) return PhysicalCoreExecutor.getPhysicalCoreCount(); + if (value instanceof Integer && (Integer) value > 0) return (Integer) value; + throw new IllegalArgumentException(field + ": " + value + " is not a worker count." + + System.lineSeparator() + "Use a positive integer, or max for the platform's physical cores."); + } +} diff --git a/jvector-examples/src/main/resources/log4j2.xml b/jvector-examples/src/main/resources/log4j2.xml index 0f9260830..caf9f2290 100644 --- a/jvector-examples/src/main/resources/log4j2.xml +++ b/jvector-examples/src/main/resources/log4j2.xml @@ -2,11 +2,15 @@ - + + + + + diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/IndexSchemaVersionTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/IndexSchemaVersionTest.java new file mode 100644 index 000000000..dadcbee0f --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/IndexSchemaVersionTest.java @@ -0,0 +1,68 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** Index YAML schema versions BenchYAML accepts. */ +public class IndexSchemaVersionTest { + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + /** The layout of the dataset defaults in earlier releases, which declared schema 1. */ + private static final String BODY = "onDiskIndexVersion: 7\ndataset: test\n" + + "construction:\n outDegree: [32]\n efConstruction: [100]\n neighborOverflow: [1.2f]\n" + + " addHierarchy: [Yes]\n refineFinalGraph: [Yes]\n fusedGraph: [No]\n" + + " compression:\n - type: PQ\n parameters:\n mFactor: 8\n" + + " reranking:\n - NVQ\n useSavedIndexIfExists: No\n" + + "search:\n topKOverquery:\n 10: [1.0, 2.0]\n useSearchPruning: [No]\n" + + " compression:\n - type: PQ\n parameters:\n mFactor: 8\n"; + + private MultiConfig load(int schema) throws Exception { + var file = new File(directory.getRoot(), "schema" + schema + ".yml"); + Files.writeString(file.toPath(), "yamlSchemaVersion: " + schema + "\n" + BODY); + return MultiConfig.getConfig(file.getPath()); + } + + private static String plan(MultiConfig config) { + var spec = GridSpec.fromConfig(config); + return spec.buildPoints().stream().map(p -> p.reportedParameters().toString()).collect(Collectors.joining(";")) + + " | " + spec.search.cases().size() + " search cases"; + } + + @Test + public void schemaOneFilesFromEarlierReleasesLoadLikeSchemaTwo() throws Exception { + var one = load(1); + assertEquals(1, one.getYamlSchemaVersion()); + assertEquals(plan(load(2)), plan(one)); + } + + @Test + public void versionsOutsideOneToThreeAreRejected() { + for (int unsupported : new int[] {0, 4}) { + var e = assertThrows(IllegalArgumentException.class, () -> load(unsupported)); + assertTrue(e.getMessage(), e.getMessage().startsWith("Unsupported yamlSchemaVersion=" + unsupported)); + } + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java new file mode 100644 index 000000000..36482ce29 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestDeferredQueryReporting.java @@ -0,0 +1,180 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.benchmarks.Metric; +import io.github.jbellis.jvector.example.benchmarks.QueryBenchmark; +import io.github.jbellis.jvector.example.benchmarks.QueryTester; +import io.github.jbellis.jvector.example.benchmarks.QueuedQueryExecution; +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static org.junit.Assert.*; + +/** Reporting belongs to one measurement invocation and owns completed samples. */ +public class TestDeferredQueryReporting { + private Grid.ConfiguredSystem system() { + var vector = VectorizationProvider.getInstance().getVectorTypeSupport() + .createFloatVector(new float[]{1}); + var data = new SimpleDataSet("deferred-report", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector), List.of(vector), List.of(List.of(0))); + return new Grid.ConfiguredSystem(data, null, null, Set.of()) { + @Override public int queryWorkerCount() { return 1; } + }; + } + + private QueryBenchmark probe(String name, + BiFunction>>, List> operation) { + return new QueryBenchmark() { + public String getBenchmarkName() { return name; } + + public List runBenchmark(Grid.ConfiguredSystem cs, int k, int r, + boolean pruning, int runs) { + var reports = new ArrayList>>(); + var result = new ArrayList<>(operation.apply(cs, reports::add)); + for (var report : reports) result.addAll(report.get()); + return result; + } + + public List runBenchmark(Grid.ConfiguredSystem cs, int k, int r, + boolean pruning, int runs, Consumer>> reports) { + return operation.apply(cs, reports); + } + }; + } + + private static Metric report() { + return Metric.of("test.report", "report", ".1f", 7); + } + + @Test + public void reportingRunsAfterEveryMeasurementAndRetainsItsValues() throws Exception { + var order = new ArrayList(); + try (var cs = system()) { + var producer = probe("throughput", (c, reports) -> { + order.add("throughput"); + reports.accept(() -> { order.add("report"); return List.of(report()); }); + return List.of(); + }); + var latency = probe("latency", (c, reports) -> { + assertFalse(order.contains("report")); order.add("latency"); return List.of(); + }); + var accuracy = probe("accuracy", (c, reports) -> { + assertFalse(order.contains("report")); order.add("accuracy"); return List.of(); + }); + var metrics = new QueryTester(List.of(producer, latency, accuracy)).run(cs, 1, 1, false, 1); + assertEquals(List.of("throughput", "latency", "accuracy", "report"), order); + assertEquals(1, metrics.stream().filter(m -> m.getKey().equals("test.report")).count()); + assertEquals(7, metrics.stream().filter(m -> m.getKey().equals("test.report")) + .findFirst().orElseThrow().getValue(), 0); + assertTrue(new QueryTester(List.of()).run(cs, 1, 1, false, 1).stream() + .noneMatch(m -> m.getKey().equals("test.report"))); + } + } + + @Test + public void failedMeasurementCannotLeakReportingIntoTheNextInvocation() throws Exception { + var order = new ArrayList(); + try (var cs = system()) { + var producer = probe("throughput", (c, reports) -> { + reports.accept(() -> { order.add("report"); return List.of(report()); }); + return List.of(); + }); + var failure = probe("latency", (c, reports) -> { + throw new IllegalStateException("measurement failed"); + }); + assertThrows(IllegalStateException.class, + () -> new QueryTester(List.of(producer, failure)).run(cs, 1, 1, false, 1)); + var later = new QueryTester(List.of()).run(cs, 1, 1, false, 1); + assertTrue(order.isEmpty()); + assertTrue(later.stream().noneMatch(m -> m.getKey().equals("test.report"))); + } + } + + @Test + public void standaloneReportingDoesNotAttachStateToTheConfiguredSystem() throws Exception { + try (var cs = system()) { + var benchmark = probe("standalone", (c, reports) -> { + reports.accept(() -> List.of(report())); + return List.of(); + }); + assertEquals(1, benchmark.runBenchmark(cs, 1, 1, false, 1).size()); + assertTrue(new QueryTester(List.of()).run(cs, 1, 1, false, 1).stream() + .noneMatch(m -> m.getKey().equals("test.report"))); + } + } + + @Test + public void legacyBenchmarkOverloadStillRunsInQueryTester() throws Exception { + try (var cs = system()) { + var benchmark = new QueryBenchmark() { + public String getBenchmarkName() { return "legacy"; } + public List runBenchmark(Grid.ConfiguredSystem system, int k, int r, + boolean pruning, int runs) { return List.of(report()); } + }; + assertTrue(new QueryTester(List.of(benchmark)).run(cs, 1, 1, false, 1).stream() + .anyMatch(m -> m.getKey().equals("test.report"))); + } + } + + @Test(timeout=10000) + public void rejectedSubmissionReturnsItsPooledRequestAndInputGuardsPrecedeAllocation() throws Exception { + try (var cs = system()) { + assertThrows(IllegalArgumentException.class, () -> new QueuedQueryExecution(cs, 0, 1, false)); + assertThrows(IllegalArgumentException.class, () -> new QueuedQueryExecution(cs, 2, 1, false)); + var queued = new QueuedQueryExecution(cs, 1, 1, false); + assertThrows(IllegalArgumentException.class, () -> queued.runPass(-1, i -> null, null, false)); + assertThrows(NullPointerException.class, () -> queued.runPass(0, null, null, false)); + assertThrows(NullPointerException.class, () -> queued.executeSingle(null)); + queued.close(); + var query = cs.getDataSet().getQueryVectors().get(0); + for (int i = 0; i < 100; i++) + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> queued.executeSingle(query)); + } + } + + @Test + public void completedSnapshotSurvivesExecutorCloseAndCanOnlyBeTransferredOnce() throws Exception { + try (var cs = system()) { + QueuedQueryExecution.Measurements samples; + try (var queued = new QueuedQueryExecution(cs, 1, 1, false)) { + assertThrows(IllegalStateException.class, queued::finishMeasurements); + queued.beginMeasurements(); + assertThrows(IllegalStateException.class, queued::beginMeasurements); + queued.runPass(0, i -> { throw new AssertionError("Empty pass fetched a query"); }, null, false); + samples = queued.finishMeasurements(); + assertThrows(IllegalStateException.class, queued::finishMeasurements); + assertThrows(IllegalStateException.class, queued::beginMeasurements); + } + var first = samples.metrics(); + var second = samples.metrics(); + assertEquals(4, first.size()); + for (int i = 0; i < first.size(); i++) { + assertEquals(0, first.get(i).getValue(), 0); + assertEquals(first.get(i).getValue(), second.get(i).getValue(), 0); + } + } + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java new file mode 100644 index 000000000..614281ac1 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedResultValidation.java @@ -0,0 +1,187 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.benchmarks.QueuedQueryExecution; +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.NodesIterator; +import io.github.jbellis.jvector.graph.similarity.*; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.Assert.*; + +/** Separate numerical batch differences from corrupted prepared-state handoffs. */ +public class TestQueuedResultValidation { + private static VectorFloat vector(float value) { + return VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(new float[]{value}); + } + + private static final class State implements PreparedQuery { + VectorFloat query; + float projected; + int searches; + public VectorFloat queryVector() { return query; } + public long ramBytesUsed() { return 0; } + } + + private static final class Preparer implements QueryPreparer { + public PreparedQuery newState() { return new State(); } + public boolean supportsBatchPreparation() { return true; } + public long ramBytesUsed() { return 0; } + public void prepare(VectorFloat query, PreparedQuery output) { + State state = (State) output; + state.query = query; state.projected = query.get(0); state.searches = 0; + } + public void prepareBatch(VectorFloat[] inputs, PreparedQuery[] outputs, int count) { + for (int i = 0; i < count; i++) { + prepare(inputs[i], outputs[i]); + // Deliberately distinguish batch arithmetic from singleton + // arithmetic. Validation must compare the same prepared state. + ((State) outputs[i]).projected += .00001f; + } + } + } + + private static final class Graph implements ImmutableGraphIndex { + final AtomicInteger bindings = new AtomicInteger(); + final boolean corrupt; + Graph(boolean corrupt) { this.corrupt = corrupt; } + public NodesIterator getNodes(int level) { return new NodesIterator.ArrayNodesIterator(new int[]{0}); } + public int maxDegree() { return 1; } + public List maxDegrees() { return List.of(1); } + public int getDimension() { return 1; } + public boolean isHierarchical() { return false; } + public int getMaxLevel() { return 0; } + public int getDegree(int level) { return 1; } + public double getAverageDegree(int level) { return 0; } + public int size(int level) { return 1; } + public long ramBytesUsed() { return 0; } + public void close() {} + public ScoringView getView() { + return new ScoringView() { + public NodesIterator getNeighborsIterator(int level, int node) { return NodesIterator.EMPTY_NODE_ITERATOR; } + public void processNeighbors(int level, int node, ScoreFunction score, IntMarker visited, NeighborProcessor processor) {} + public int size() { return 1; } + public NodeAtLevel entryNode() { return new NodeAtLevel(0, 0); } + public Bits liveNodes() { return Bits.ALL; } + public boolean contains(int level, int node) { return level == 0 && node == 0; } + public void close() {} + public ScoreFunction.ExactScoreFunction rerankerFor(VectorFloat query, VectorSimilarityFunction similarity) { + return node -> query.get(0); + } + public ScoreFunction.ApproximateScoreFunction approximateScoreFunctionFor(VectorFloat query, VectorSimilarityFunction similarity) { + return node -> query.get(0); + } + public SearchScoreProvider scoreProviderFor(PreparedQuery prepared, boolean rerank) { + State state = (State) prepared; + bindings.incrementAndGet(); + float score = state.projected + (corrupt && state.searches++ > 0 ? .1f : 0); + return new DefaultSearchScoreProvider((ScoreFunction.ExactScoreFunction) node -> score); + } + }; + } + } + + private Grid.ConfiguredSystem system(Graph graph, int workers) { + var queries = new ArrayList>(); + for (int i = 0; i < 97; i++) queries.add(vector(.1f + i / 1000f)); + var data = new SimpleDataSet("queued-validation", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector(1)), queries, java.util.Collections.nCopies(queries.size(), List.of(0))); + return new Grid.ConfiguredSystem(data, graph, null, Set.of()) { + final Preparer preparer = new Preparer(); + @Override public int queryWorkerCount() { return workers; } + @Override public QueryPreparer queryPreparer() { return preparer; } + }; + } + + @Test(timeout=20000) + public void identicalPreparedMathValidatesAcrossWorkersAndNormalPassDoesNotSearchTwice() throws Exception { + for (int workers : new int[]{1, 3}) { + var graph = new Graph(false); + try (var cs = system(graph, workers); var queued = new QueuedQueryExecution(cs, 1, 1, false)) { + var queries = cs.getDataSet().getQueryVectors(); + queued.verifyConcurrentResults(queries.size(), queries::get); + assertEquals(2 * queries.size(), graph.bindings.get()); + graph.bindings.set(0); + var pass = queued.runPass(queries.size(), queries::get, null, true); + assertEquals(queries.size(), graph.bindings.get()); + for (int i = 0; i < queries.size(); i++) + assertEquals(queries.get(i).get(0) + .00001f, pass.results[i].getNodes()[0].score, 0); + queued.verifyConcurrentResults(0, i -> { throw new AssertionError("Empty validation fetched input"); }); + queued.beginMeasurements(); + assertThrows(IllegalStateException.class, () -> queued.verifyConcurrentResults(0, queries::get)); + } + } + } + + @Test(timeout=10000) + public void corruptedPreparedStateStillFailsValidation() throws Exception { + try (var cs = system(new Graph(true), 2); var queued = new QueuedQueryExecution(cs, 1, 1, false)) { + var queries = cs.getDataSet().getQueryVectors(); + var error = assertThrows(IllegalStateException.class, + () -> queued.verifyConcurrentResults(queries.size(), queries::get)); + assertTrue(error.getMessage().startsWith("Concurrent prepared query mismatch at ")); + } + } + private Grid.ConfiguredSystem directSystem(boolean corrupt, AtomicInteger preparations) { + List> queries = java.util.Collections.nCopies(97, vector(.3f)); + var data = new SimpleDataSet("direct-validation", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector(1)), queries, java.util.Collections.nCopies(queries.size(), List.of(0))); + return new Grid.ConfiguredSystem(data, new Graph(false), null, Set.of()) { + @Override public SearchScoreProvider scoreProviderFor(VectorFloat query, ImmutableGraphIndex.View view) { + preparations.incrementAndGet(); + // Like GEMV/GEMM reduction rounding, preparation on different + // caller threads may produce distinct, individually valid math. + final float value = query.get(0) + Thread.currentThread().getId() * .00001f; + final int[] calls = {0}; + return new DefaultSearchScoreProvider((ScoreFunction.ExactScoreFunction) node -> + value + (corrupt && calls[0]++ > 0 ? .1f : 0)); + } + }; + } + + @Test(timeout=20000) + public void concurrentDirectValidationPreparesOneProviderPerInput() throws Exception { + var preparations = new AtomicInteger(); + try (var cs = directSystem(false, preparations)) { + java.util.stream.IntStream.range(0, cs.getDataSet().getQueryVectors().size()).parallel() + .forEach(i -> io.github.jbellis.jvector.example.benchmarks.QueryExecutor + .verifyConcurrentQuery(cs, 1, 1, false, i)); + assertEquals(cs.getDataSet().getQueryVectors().size(), preparations.get()); + } + } + + @Test(timeout=10000) + public void directValidationStillDetectsChangedScoresFromThePreparedProvider() throws Exception { + var preparations = new AtomicInteger(); + try (var cs = directSystem(true, preparations)) { + var error = assertThrows(IllegalStateException.class, + () -> io.github.jbellis.jvector.example.benchmarks.QueryExecutor + .verifyConcurrentQuery(cs, 1, 1, false, 0)); + assertTrue(error.getMessage().startsWith("Concurrent prepared query mismatch at ")); + assertEquals(1, preparations.get()); + } + } + +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedWorkerLifetime.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedWorkerLifetime.java new file mode 100644 index 000000000..214b13825 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/TestQueuedWorkerLifetime.java @@ -0,0 +1,73 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.util.BatchingExecutor; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.Assert.*; + +/** Warmup and later benchmark phases must use the same workers and thread-owned state. */ +public class TestQueuedWorkerLifetime { + @Test(timeout=20000) + public void workerStateSurvivesClosingAnExecutionPhase() throws Exception { + var vector = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(new float[]{1}); + var data = new SimpleDataSet("worker-lifetime", VectorSimilarityFunction.DOT_PRODUCT, + List.of(vector), List.of(vector), List.of(List.of(0))); + var system = new Grid.ConfiguredSystem(data, null, null, Set.of()); + var pool = system.queryWorkers(); + var scratch = new ThreadLocal(); + int count = system.queryWorkerCount(); + try { + for (int phase = 0; phase < 2; phase++) { + int expected = phase; + var entered = new CountDownLatch(count); + var completed = new CountDownLatch(count); + var failure = new AtomicReference(); + try (var queue = new BatchingExecutor(count, count, 1, pool, + new BatchingExecutor.Operation() { + public Object batchKey(Integer query) { return null; } + public void prepareBatch(List queries) {} + public void execute(Integer query) throws Exception { + entered.countDown(); + if (!entered.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("Workers did not all start"); + if (expected == 0) scratch.set(1); + else assertEquals(Integer.valueOf(1), scratch.get()); + } + public void complete(Integer query, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + completed.countDown(); + } + })) { + for (int i = 0; i < count; i++) assertTrue(queue.trySubmit(i)); + assertTrue(completed.await(10, TimeUnit.SECONDS)); + assertNull(failure.get()); + } + assertSame(pool, system.queryWorkers()); + assertFalse(pool.isShutdown()); + } + } finally { system.close(); } + assertTrue(pool.isTerminated()); + assertThrows(IllegalStateException.class, system::queryWorkers); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/WorkersConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/WorkersConfigTest.java new file mode 100644 index 000000000..cef1d06d0 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/WorkersConfigTest.java @@ -0,0 +1,89 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.util.List; + +import static org.junit.Assert.*; + +/** construction.workers and search.workers in schema 3 index YAML. */ +public class WorkersConfigTest { + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + private MultiConfig load(int schema, String constructionWorkers, String searchWorkers) throws Exception { + var file = directory.newFile().toPath(); + Files.writeString(file, "yamlSchemaVersion: " + schema + "\nonDiskIndexVersion: 7\ndataset: test\n" + + "construction:\n" + (schema == 3 ? " type: onDisk\n" : "") + + " outDegree: [16]\n efConstruction: [50]\n" + + " neighborOverflow: [1.2]\n addHierarchy: [No]\n refineFinalGraph: [Yes]\n" + + " compression:\n - type: None\n" + (schema == 3 ? " reranking:\n type: None\n" : " reranking: [NVQ]\n") + + " useSavedIndexIfExists: No\n" + + (constructionWorkers == null ? "" : " workers: " + constructionWorkers + "\n") + + "search:\n topKOverquery:\n 10: [1.0]\n useSearchPruning: [No]\n" + + (searchWorkers == null ? "" : " workers: " + searchWorkers + "\n") + + " compression:\n - type: None\n"); + return MultiConfig.getConfig(file.toString()); + } + + @Test + public void workersResolveToCountsAndMaxIsThePhysicalCoreCount() throws Exception { + var config = load(3, "max", "[1, 8, max]"); + int max = PhysicalCoreExecutor.getPhysicalCoreCount(); + assertEquals(Integer.valueOf(max), config.construction.resolvedWorkers()); + assertEquals(List.of(1, 8, max), config.search.resolvedWorkers()); + + var spec = GridSpec.fromConfig(config); + assertEquals(Integer.valueOf(max), spec.buildPoints().get(0).workers); + // One search case per compression entry; it queries one index with each worker count. + var cases = spec.search.cases(); + assertEquals(1, cases.size()); + assertEquals(List.of(1, 8, max), cases.get(0).workerCounts()); + } + + @Test + public void omittedWorkersKeepThePlatformDefaults() throws Exception { + var spec = GridSpec.fromConfig(load(3, null, null)); + assertNull(spec.buildPoints().get(0).workers); + assertEquals(1, spec.search.cases().size()); + assertEquals(java.util.Collections.singletonList(null), spec.search.cases().get(0).workerCounts()); + } + + @Test + public void invalidWorkerCountsNameTheFieldAndTheRule() { + var nl = System.lineSeparator(); + var zero = assertThrows(IllegalArgumentException.class, () -> load(3, "0", null)); + assertTrue(zero.getMessage(), zero.getMessage().endsWith("construction.workers: 0 is not a worker count." + nl + + "Use a positive integer, or max for the platform's physical cores.")); + var word = assertThrows(IllegalArgumentException.class, () -> load(3, null, "[1, all]")); + assertTrue(word.getMessage(), word.getMessage().endsWith("search.workers: all is not a worker count." + nl + + "Use a positive integer, or max for the platform's physical cores.")); + var empty = assertThrows(IllegalArgumentException.class, () -> load(3, null, "[]")); + assertTrue(empty.getMessage(), empty.getMessage().endsWith("search.workers must list at least one worker count")); + } + + @Test + public void workersRequireSchema3() { + var error = assertThrows(IllegalArgumentException.class, () -> load(2, "max", null)); + assertTrue(error.getMessage(), error.getMessage().endsWith("construction.workers and search.workers require schema 3")); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java new file mode 100644 index 000000000..1b69970f7 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionConfigTest.java @@ -0,0 +1,121 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example.benchmarks; + +import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import java.util.List; +import org.junit.Before; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import java.nio.file.Files; +import static org.junit.Assert.*; + +public class AshProjectionConfigTest { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + private String original; + @Before public void isolateProperty() { + original = System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); + System.clearProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); + } + @After public void restoreProperty() { + if (original == null) System.clearProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); + else System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, original); + } + private MultiConfig yaml(String body) throws Exception { + var file = temporary.newFile().toPath(); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: 7\ndataset: precision-test\n" + + "construction:\n compression:\n - type: ASH\n parameters:\n encodedBits: 48\n optimizer: RANDOM\n bitsPerDimension: 2\n" + + (body.isEmpty() ? "search: {}\n" : body)); + return MultiConfig.getConfig(file.toString()); + } + @Test public void omissionDoesNotSetAPropertyOrChangeExistingDefaults() throws Exception { + var config = yaml(""); + assertNull(config.search.queryProjection); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(config)); + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void eachYamlSelectionIsAppliedAndRestored() throws Exception { + for (String mode : new String[]{"auto", "fp32", "fp16", "int8"}) { + var config = yaml("search:\n queryProjection: " + mode + "\n"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(config)); + assertEquals(mode, System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + } + @Test public void explicitJvmSettingOverridesYamlAndIsPreserved() throws Exception { + System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "fp32"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(yaml("search:\n queryProjection: int8\n"))); + assertEquals("fp32", System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + assertEquals("fp32", System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void failedRunsRestoreOnlyTheirTemporarySetting() throws Exception { + var config = yaml("search:\n queryProjection: int8\n"); + assertThrows(IllegalStateException.class, () -> { + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(config)); + throw new IllegalStateException("failed run"); + } + }); + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void invalidNewValuesFailWithoutChangingTheProperty() throws Exception { + var bad = yaml("search:\n queryProjection: pf32\n"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(List.of(bad))); + } + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "unknown"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(List.of(yaml("")))); + } + assertEquals("unknown", System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void conflictingAshPrecisionsFailBeforeModelsAreLoaded() throws Exception { + var first = yaml("search:\n queryProjection: int8\n"); + var second = yaml("search:\n queryProjection: fp32\n"); + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + assertThrows(IllegalArgumentException.class, () -> setting.apply(List.of(first,second))); + } + assertNull(System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + @Test public void runConfigRejectsAlgorithmParameters() throws Exception { + var file = temporary.newFile().toPath(); + Files.writeString(file,"yamlSchemaVersion: 1\nash:\n queryProjection: int8\n"); + assertThrows(org.yaml.snakeyaml.error.YAMLException.class, () -> RunConfig.load(file.toString())); + } + @Test public void pqConfigurationDoesNotOverrideAshSelection() throws Exception { + var ash = yaml("search:\n queryProjection: int8\n"); + var pq = yaml(""); + pq.construction.compression.get(0).type = "PQ"; + try (var setting = new SearchParameters.AshQueryProjectionSetting()) { + setting.apply(List.of(ash,pq)); + assertEquals("int8",System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY)); + } + } + +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java new file mode 100644 index 000000000..cd4524ab0 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/AshProjectionReportingTest.java @@ -0,0 +1,79 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example.benchmarks; + +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.example.reporting.*; +import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import java.nio.file.Files; +import java.util.*; +import static org.junit.Assert.*; + +public class AshProjectionReportingTest { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + @Test public void csvAddsOnlyTwoColumnsAndReportsPreparedViewWithoutInitializingIt() throws Exception { + String key = SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, previous = System.getProperty(key); + System.setProperty(key, "fp32"); + try { + assertEquals(List.of("schema_version","run_id","run_uuid","system_id","dataset_name", + "M","efConstruction","neighborOverflow","addHierarchy","refineFinalGraph", + "feature_set","build_compressor","search_compressor","usePruning","topK","overquery","rerankK"), + ExperimentsSchemaV1.fixedColumns().subList(0,17)); + assertEquals(List.of("ash_query_projection_requested","ash_query_projection_effective"), + ExperimentsSchemaV1.fixedColumns().subList(17,19)); + var vectors = VectorizationProvider.getInstance().getVectorTypeSupport(); + var first = vectors.createFloatVector(new float[]{1,0,0,0}); + var second = vectors.createFloatVector(new float[]{0,1,0,0}); + var ds = new SimpleDataSet("precision-metadata", VectorSimilarityFunction.DOT_PRODUCT, + List.of(first,second), List.of(first), List.of(List.of(0))); + var model = AsymmetricHashing.initialize(ds.getBaseRavv(), AsymmetricHashing.RANDOM, 48, 1, 2); + assertNull(model.stiefelTransform.initializedQueryProjectionPrecision()); + var config = new RunConfig(); + config.benchmarks = Map.of("accuracy",List.of("recall")); + config.logging = new RunConfig.RunLogging(); + config.logging.type = "csv"; + config.logging.dir = temporary.getRoot().toString(); + config.logging.benchmarks = config.benchmarks; + var artifacts = RunArtifacts.open(config,List.of()); + var metrics = List.of(Metric.of("search.accuracy.recall_at_10","recall",".5f",1)); + artifacts.logRow("precision-metadata",32,100,1.2f,true,true,Set.of(FeatureId.FUSED_ASH),"ASH","None",false,10,1,10,model,metrics); + assertNull(model.stiefelTransform.initializedQueryProjectionPrecision()); + model.stiefelTransform.projectQueryDirect(new float[]{1,0,0,0},new float[model.quantizedDim]); + assertEquals("fp32",model.stiefelTransform.initializedQueryProjectionPrecision()); + artifacts.logRow("precision-metadata",32,100,1.2f,true,true,Set.of(FeatureId.FUSED_ASH),"ASH","None",false,10,2,20,model,metrics); + artifacts.logRow("precision-metadata",32,100,1.2f,true,true,Set.of(FeatureId.FUSED_PQ),"PQ","None",false,10,1,10,null,metrics); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(4,lines.size()); + assertEquals("fp32",lines.get(1).split(",",-1)[17]); + assertEquals("",lines.get(1).split(",",-1)[18]); + assertEquals("fp32",lines.get(2).split(",",-1)[17]); + assertEquals("fp32",lines.get(2).split(",",-1)[18]); + assertEquals("",lines.get(3).split(",",-1)[17]); + assertEquals("",lines.get(3).split(",",-1)[18]); + } finally { + if(previous==null)System.clearProperty(key);else System.setProperty(key,previous); + } + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java new file mode 100644 index 000000000..195536fe3 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/QueryExecutionConfigTest.java @@ -0,0 +1,44 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example.benchmarks; + +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.example.yaml.RunConfig; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import java.nio.file.Files; +import static org.junit.Assert.*; + +public class QueryExecutionConfigTest { + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + @Test + public void runConfigRejectsQueryDeliverySettings() throws Exception { + var file = directory.newFile("run.yml").toPath(); + Files.writeString(file, "yamlSchemaVersion: 1\nqueryExecution:\n mode: queued\n"); + assertThrows(org.yaml.snakeyaml.error.YAMLException.class, () -> RunConfig.load(file.toString())); + } + + @Test + public void schema2RejectsNestedAshDeliverySettings() throws Exception { + var file = directory.newFile("index.yml").toPath(); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: 7\ndataset: test\n" + + "search:\n compression:\n - type: ASH\n parameters:\n queryExecution: queued\n"); + var error = assertThrows(IllegalArgumentException.class, () -> MultiConfig.getConfig(file.toString())); + assertTrue(error.getMessage().contains("require schema 3")); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java new file mode 100644 index 000000000..40a65d709 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/StandaloneASHBenchmarkConfigTest.java @@ -0,0 +1,202 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example.benchmarks; + +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.example.yaml.RunConfig; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.yaml.snakeyaml.Yaml; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +/** Fair code budgets and independent selection of checked-in run/index YAML. */ +public class StandaloneASHBenchmarkConfigTest { + private static final Path INDEX_CONFIGS = Path.of("jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq"); + private static final Path RUN_CONFIGS = Path.of("jvector-examples/yaml-configs/ash-pq"); + private static final Path ALL_INDEX_CONFIGS = Path.of("jvector-examples/yaml-configs/index-parameters"); + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + @Test + public void explicitRunFileDoesNotRequireEditingTheDefaultYaml() throws Exception { + var path = directory.newFile("run.yml").toPath().toAbsolutePath(); + Files.writeString(path, "yamlSchemaVersion: 1\nbenchmarks:\n accuracy: [recall]\n"); + String previous = System.getProperty("jvector.bench.runConfig"); + try { + System.setProperty("jvector.bench.runConfig", path.toString()); + assertEquals(List.of("recall"), RunConfig.loadDefault().benchmarks.get("accuracy")); + Files.writeString(path, ""); + assertThrows(IllegalArgumentException.class, RunConfig::loadDefault); + } finally { + if (previous == null) System.clearProperty("jvector.bench.runConfig"); + else System.setProperty("jvector.bench.runConfig", previous); + } + } + + @Test + public void yamlPathsAreNotRegexesAndMissingExplicitRunFileFailsBeforeDataLoading() throws Exception { + var index = directory.newFile("index with [literal.yml").toPath(); + Files.copy(INDEX_CONFIGS.resolve("mpnet-2bit-ash.yml"), index, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + var missing = directory.getRoot().toPath().resolve("missing-run.yml"); + String previous = System.getProperty("jvector.bench.runConfig"); + try { + System.setProperty("jvector.bench.runConfig", missing.toString()); + var failure = assertThrows(java.io.FileNotFoundException.class, + () -> io.github.jbellis.jvector.example.BenchYAML.main(new String[]{index.toString()})); + assertTrue(failure.getMessage().contains("missing-run.yml")); + } finally { + if (previous == null) System.clearProperty("jvector.bench.runConfig"); + else System.setProperty("jvector.bench.runConfig", previous); + } + } + + @SuppressWarnings("unchecked") + private Map yaml(Path path) throws Exception { + return new Yaml().load(Files.readString(path)); + } + + @SuppressWarnings("unchecked") + private Map compression(Map section) { + return ((List>) section.get("compression")).get(0); + } + + @Test + @SuppressWarnings("unchecked") + public void allPairsMatchBytesAndUseFusedGraphsNvqAndTheSameSearchParameters() throws Exception { + var dimensions = Map.of("mpnet", 768, "cohere", 1024, "ada002", 1536, "openai3072", 3072, + "mpnet10m", 768, "cohere10m", 1024, "cap6m", 1536); + for (var entry : dimensions.entrySet()) { + for (int bits : new int[]{2, 4}) { + Map previousConstruction = null; + String previousDataset = null; + for (String algorithm : List.of("ash", "pq")) { + Path path = INDEX_CONFIGS.resolve(entry.getKey() + "-" + bits + "bit-" + algorithm + ".yml"); + assertNotNull(MultiConfig.getConfig(path.toAbsolutePath().toString())); + var root = yaml(path); + var construction = (Map) root.get("construction"); + var search = (Map) root.get("search"); + assertEquals(List.of(true), construction.get("fusedGraph")); + assertEquals(Map.of("type", "NVQ"), construction.get("reranking")); + assertEquals("onDisk", construction.get("type")); + assertEquals("parallel", construction.get("writerMode")); + assertEquals(List.of(32), construction.get("outDegree")); + assertEquals(List.of(100), construction.get("efConstruction")); + var searchSettings = new java.util.LinkedHashMap<>( + (Map) compression(search).get("parameters")); + if (algorithm.equals("ash")) { + assertEquals("queued", searchSettings.remove("queryExecution")); + assertEquals(8, searchSettings.remove("maxBatchSize")); + assertEquals("auto", searchSettings.remove("queryProjection")); + } + assertEquals(compression(construction).get("parameters"), searchSettings); + assertEquals(Map.of(10, List.of(1.0, 2.0)), search.get("topKOverquery")); + var parameters = (Map) compression(construction).get("parameters"); + int expectedBits = 40 + entry.getValue() / 2 * bits; + if (algorithm.equals("ash")) { + assertEquals(expectedBits, parameters.get("encodedBits")); + assertEquals(1, parameters.get("landmarkCount")); + assertEquals(bits, parameters.get("bitsPerDimension")); + assertEquals("ITQ", parameters.get("optimizer")); + } else { + assertEquals(expectedBits / 8, parameters.get("m")); + assertEquals(256, parameters.get("k")); + assertEquals(previousDataset, root.get("dataset")); + for (String field : List.of("outDegree", "efConstruction", "neighborOverflow", + "addHierarchy", "refineFinalGraph", "fusedGraph", "reranking", "useSavedIndexIfExists")) + assertEquals(previousConstruction.get(field), construction.get(field)); + } + previousDataset = (String) root.get("dataset"); + previousConstruction = construction; + } + } + } + } + + @Test + public void runRecipesKeepStandardBenchmarkSelections() throws Exception { + for (String mode : List.of("construction", "direct", "queued")) { + var config = RunConfig.load(RUN_CONFIGS.resolve("run-" + mode + ".yml").toString()); + assertEquals(List.of("recall"), config.benchmarks.get("accuracy")); + if (!mode.equals("construction")) { + assertEquals(List.of("AVG"), config.benchmarks.get("throughput")); + assertEquals(List.of("AVG", "STD", "P999"), config.benchmarks.get("latency")); + } + } + } + + @Test + @SuppressWarnings("unchecked") + public void provenanceRecordsTheActualSourceAndSelectedQueryParallelism() throws Exception { + String oldThreads = System.getProperty("jvector.bench.queryThreads"); + String oldRef = System.getProperty("jvector.bench.sourceRef"); + try { + System.setProperty("jvector.bench.queryThreads", "7"); + System.setProperty("jvector.bench.sourceRef", "test-source-reference"); + var config = new RunConfig(); + config.logging = new RunConfig.RunLogging(); + config.logging.dir = directory.getRoot().getAbsolutePath(); + config.logging.jvectorRef = "old-label"; + for (String mode : List.of("direct", "queued")) { + config.benchmarks = Map.of("accuracy", List.of("recall")); + config.logging.type = "csv"; + config.logging.benchmarks = config.benchmarks; + config.logging.runId = mode; + String codec = mode.equals("queued") ? "ash" : "pq"; + var index = MultiConfig.getConfig(INDEX_CONFIGS.resolve("mpnet-2bit-" + codec + ".yml") + .toAbsolutePath().toString()); + var artifacts = io.github.jbellis.jvector.example.reporting.RunArtifacts.open(config, List.of(index)); + var info = yaml(artifacts.experimentsWriter().path().resolveSibling("sys_info.json")); + assertEquals("test-source-reference", info.get("jvector_ref")); + var threads = (Map) info.get("threads"); + int expected = mode.equals("direct") ? 7 : Math.max(1, + Math.min(Runtime.getRuntime().availableProcessors(), + io.github.jbellis.jvector.util.PhysicalCoreExecutor.getPhysicalCoreCount())); + assertEquals(expected, threads.get("query_throughput_parallelism")); + } + } finally { + if (oldThreads == null) System.clearProperty("jvector.bench.queryThreads"); + else System.setProperty("jvector.bench.queryThreads", oldThreads); + if (oldRef == null) System.clearProperty("jvector.bench.sourceRef"); + else System.setProperty("jvector.bench.sourceRef", oldRef); + } + } + + @Test + @SuppressWarnings("unchecked") + public void onlyMultiJvmRecipesKeepSavedIndexes() throws Exception { + // Saved indexes consume disk space without notice, so starting points leave reuse off. + // The ASH/PQ protocol queries each graph from JVMs after the one that built it. + List paths; + try (var files = Files.walk(ALL_INDEX_CONFIGS)) { + paths = files.filter(p -> p.toString().endsWith(".yml")).sorted().collect(java.util.stream.Collectors.toList()); + } + assertTrue(paths.size() > 36); + for (Path path : paths) { + var construction = (Map) yaml(path).get("construction"); + if (construction != null && construction.containsKey("useSavedIndexIfExists")) { + boolean reused = path.startsWith(INDEX_CONFIGS); + assertEquals(path.toString(), reused, construction.get("useSavedIndexIfExists")); + } + } + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java new file mode 100644 index 000000000..5112c378b --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/TestLatencyWarmup.java @@ -0,0 +1,113 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example.benchmarks; + +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.HashSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.Assert.*; + +/** Checks the shared pass, random input ownership, timing floor and metric isolation. */ +public class TestLatencyWarmup { + private static final SearchResult RESULT = new SearchResult(new SearchResult.NodeScore[0], 1, 0, 0, 0, 0); + + private VectorFloat[] queries(int count) { + var result = new VectorFloat[count]; + for (int i = 0; i < count; i++) + result[i] = VectorizationProvider.getInstance().getVectorTypeSupport() + .createFloatVector(new float[]{1,0,0,0,0,0,0,0}); + return result; + } + + @Test + public void warmupAndMeasurementUseTheSameOperationAndExcludeWarmupSamples() { + var measured = queries(3); + var originals = Collections.newSetFromMap(new IdentityHashMap()); + Collections.addAll(originals, measured); + var warmIdentities = Collections.newSetFromMap(new IdentityHashMap()); + var values = new HashSet(); + var clock = new AtomicLong(); + var warmCalls = new AtomicInteger(); + var measuredCalls = new AtomicInteger(); + long[] samples = LatencyBenchmark.measure(measured, 2, 2, 10, query -> { + if (originals.contains(query)) { + measuredCalls.incrementAndGet(); + assertEquals(12, warmCalls.get()); + assertEquals(1, query.get(0), 0); + assertEquals(0, query.get(1), 0); + } else { + warmCalls.incrementAndGet(); + warmIdentities.add(query); + values.add(query.get(0)); + double norm = 0; + for (int d = 0; d < 8; d++) norm += query.get(d) * query.get(d); + assertEquals(1, norm, 1e-5); + } + clock.addAndGet(2); + return RESULT; + }, clock::get); + assertEquals(12, warmCalls.get()); + assertEquals(6, measuredCalls.get()); + assertEquals(6, samples.length); + assertEquals(3, warmIdentities.size()); // Reused across every warmup sweep. + assertTrue(values.size() > 3); + for (long value : samples) assertTrue(value >= 0); + } + + @Test + public void minimumQueryCoverageAppliesWithoutATimeFloor() { + var calls = new AtomicInteger(); + long[] samples = LatencyBenchmark.measure(queries(7), 2, 3, 0, query -> { + calls.incrementAndGet(); return RESULT; + }, () -> 0); + assertEquals(35, calls.get()); + assertEquals(14, samples.length); + } + + @Test + public void statisticsUseOnlySuppliedMeasuredSamples() { + var metrics = LatencyBenchmark.createDefault().displayLatencySTD().displayP999Latency() + .metrics(new long[]{3_000_000, 1_000_000, 2_000_000}); + assertEquals(2, metrics.stream().filter(m -> m.getKey().equals("search.latency.mean_ms")).findFirst().orElseThrow().getValue(), 1e-9); + assertEquals(Math.sqrt(2.0 / 3), metrics.stream().filter(m -> m.getKey().equals("search.latency.std_ms")).findFirst().orElseThrow().getValue(), 1e-9); + assertEquals(3, metrics.stream().filter(m -> m.getKey().equals("search.latency.p999_ms")).findFirst().orElseThrow().getValue(), 1e-9); + } + + @Test + public void failedWarmupPropagatesBeforeAnyMeasuredSearch() { + var error = new IllegalStateException("search failed"); + assertSame(error, assertThrows(IllegalStateException.class, + () -> LatencyBenchmark.measure(queries(1), 1, 1, 0, query -> { throw error; }, () -> 0))); + } + + @Test + public void invalidInputsFailBeforeSearch() { + var calls = new AtomicInteger(); + for (int[] args : new int[][]{{0,1,1,0}, {1,0,1,0}, {1,1,0,0}, {1,1,1,-1}}) + assertThrows(IllegalArgumentException.class, () -> LatencyBenchmark.measure( + queries(args[0]), args[1], args[2], args[3], query -> {calls.incrementAndGet();return RESULT;}, () -> 0)); + assertEquals(0, calls.get()); + assertThrows(NullPointerException.class, () -> LatencyBenchmark.measure(queries(1),1,1,0,null,()->0)); + assertThrows(NullPointerException.class, () -> LatencyBenchmark.measure(queries(1),1,1,0,query->RESULT,null)); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java index 13f2136aa..5f337eb6d 100644 --- a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/benchmarks/datasets/DataSetPropertiesTest.java @@ -384,7 +384,7 @@ public void productionMetadataFileLoadsSuccessfully() { var reader = DataSetMetadataReader.load(); var props = reader.getProperties("ada002-100k"); assertTrue(props.isPresent(), "ada002-100k should be in the production metadata file"); - assertEquals(VectorSimilarityFunction.COSINE, props.get().similarityFunction().orElse(null)); + assertEquals(VectorSimilarityFunction.DOT_PRODUCT, props.get().similarityFunction().orElse(null)); } @Test diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCacheKeyTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCacheKeyTest.java new file mode 100644 index 000000000..f21947c30 --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/util/OnDiskGraphIndexCacheKeyTest.java @@ -0,0 +1,64 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example.util; + +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.EnumSet; +import java.util.Set; + +import static org.junit.Assert.*; + +/** Cache names must change exactly when something that shapes the index file changes. */ +public class OnDiskGraphIndexCacheKeyTest { + private static final Set FUSED_NVQ = EnumSet.of(FeatureId.FUSED_ASH, FeatureId.NVQ_VECTORS); + + @Rule + public TemporaryFolder dir = new TemporaryFolder(); + + private static String name(OnDiskGraphIndexCache cache, String dataset, int count, int dim, int nvq) { + var key = cache.keyWithCompressorId(dataset, FUSED_NVQ, 32, 100, 1.2f, 1.2f, true, true, + "schema3:ASH_x:symmetric-all", count, dim, nvq); + return cache.resolve(key).finalPath.getFileName().toString(); + } + + @Test + public void signatureCarriesShapeFormatAndNvqLayout() throws Exception { + var cache = OnDiskGraphIndexCache.initialize(dir.getRoot().toPath()); + String base = name(cache, "ds", 1_000_000, 768, 2); + assertTrue(base, base.contains("_n1000000_d768_fmt" + OnDiskGraphIndex.CURRENT_VERSION + "_")); + assertTrue(base, base.contains("_NVQ_VECTORS_nvq2_")); + assertEquals("identical inputs share one graph", base, name(cache, "ds", 1_000_000, 768, 2)); + assertNotEquals("a larger profile is a different graph", base, name(cache, "ds", 100_000_000, 768, 2)); + assertNotEquals("a different dimension is a different graph", base, name(cache, "ds", 1_000_000, 384, 2)); + assertNotEquals("a different NVQ layout is a different file", base, name(cache, "ds", 1_000_000, 768, 1)); + } + + @Test + public void nvqSubvectorsMustMatchTheFeatureSet() throws Exception { + var cache = OnDiskGraphIndexCache.initialize(dir.getRoot().toPath()); + assertThrows(IllegalArgumentException.class, () -> name(cache, "ds", 10, 4, 0)); + assertThrows(IllegalArgumentException.class, () -> cache.keyWithCompressorId("ds", + EnumSet.of(FeatureId.FUSED_ASH, FeatureId.INLINE_VECTORS), 32, 100, 1.2f, 1.2f, true, true, + "id", 10, 4, 2)); + assertThrows(IllegalArgumentException.class, () -> name(cache, "ds", 0, 4, 2)); + } +} diff --git a/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java new file mode 100644 index 000000000..a4d617c1c --- /dev/null +++ b/jvector-examples/src/test/java/io/github/jbellis/jvector/example/yaml/IndexParametersTest.java @@ -0,0 +1,226 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example.yaml; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** Dataset default lookup, the checked-in index parameters, and the example pipelines. */ +public class IndexParametersTest { + private static final Path INDEX_PARAMETERS = Path.of("jvector-examples/yaml-configs/index-parameters"); + private static final String NL = System.lineSeparator(); + @Rule public final TemporaryFolder directory = new TemporaryFolder(); + + private static String config(String dataset) { + return "yamlSchemaVersion: 3\nonDiskIndexVersion: 7\ndataset: " + dataset + "\n" + + "construction:\n type: inMemory\n outDegree: [16]\n efConstruction: [50]\n" + + " neighborOverflow: [1.2f]\n addHierarchy: [No]\n refineFinalGraph: [Yes]\n" + + " compression:\n - type: None\n reranking:\n type: None\n" + + "search:\n topKOverquery:\n 10: [1.0]\n useSearchPruning: [No]\n" + + " compression:\n - type: None\n"; + } + + private File write(String name, String dataset) throws Exception { + var file = new File(directory.getRoot(), name); + Files.writeString(file.toPath(), config(dataset)); + return file; + } + + @Test + public void fullPrecisionSearchRejectsAnyReranker() throws Exception { + for (var reranker : List.of("NVQ", "FP32")) { + var file = new File(directory.getRoot(), "rerank-" + reranker + ".yml"); + Files.writeString(file.toPath(), config("test").replace("reranking:\n type: None", "reranking:\n type: " + reranker)); + var e = assertThrows(IllegalArgumentException.class, () -> MultiConfig.getConfig(file.getPath())); + assertTrue(e.getMessage(), e.getMessage().endsWith("Search type None scores with full-precision vectors, so " + + reranker + " reranking does not apply. Use reranking.type None")); + } + } + + @Test + public void exactNameFindsTheDatasetDefault() throws Exception { + write("mpnet-1m.yml", "mpnet-1m"); + write("default.yml", "default"); + assertEquals("mpnet-1m", MultiConfig.getDefaultConfig(directory.getRoot(), "mpnet-1m").dataset); + } + + @Test + public void mismatchedDatasetFieldIsRejected() throws Exception { + var file = write("cap-6m.yml", "cap-1M"); + var e = assertThrows(IllegalArgumentException.class, + () -> MultiConfig.getDefaultConfig(directory.getRoot(), "cap-6m")); + assertEquals("Index config " + file.getPath() + " declares dataset: cap-1M." + NL + + "A default config's file name must match its dataset field exactly.", e.getMessage()); + } + + @Test + public void caseOnlyMatchIsRejectedOnEveryFileSystem() throws Exception { + var file = write("mpnet-1M.yml", "mpnet-1m"); + write("default.yml", "default"); + var e = assertThrows(IllegalArgumentException.class, + () -> MultiConfig.getDefaultConfig(directory.getRoot(), "mpnet-1m")); + assertEquals("No index config is named exactly mpnet-1m.yml; found" + NL + " " + file.getPath() + NL + + "File names must match the dataset name exactly, including case.", e.getMessage()); + } + + @Test + public void subdirectoriesAreNotSearchedAndMissingDefaultsWarn() throws Exception { + assertTrue(new File(directory.getRoot(), "in-memory-fp").mkdir()); + write("in-memory-fp/mpnet-5m.yml", "mpnet-5m"); + var fallback = write("default.yml", "default"); + var err = new ByteArrayOutputStream(); + var previous = System.err; + MultiConfig config; + try { + System.setErr(new PrintStream(err, true, StandardCharsets.UTF_8)); + config = MultiConfig.getDefaultConfig(directory.getRoot(), "mpnet-5m"); + } finally { + System.setErr(previous); + } + assertEquals("mpnet-5m", config.dataset); + assertEquals("WARNING: No index config named mpnet-5m.yml; using " + fallback.getPath() + "." + NL + + "Dataset defaults are named .yml in " + directory.getRoot().getPath() + "." + NL, + err.toString(StandardCharsets.UTF_8)); + } + + @Test + public void missingExplicitConfigNamesThePath() { + var e = assertThrows(FileNotFoundException.class, + () -> MultiConfig.getConfig("ash-options/no-such-example.yml")); + assertEquals("Index config not found: ash-options/no-such-example.yml", e.getMessage()); + } + + @Test + public void checkedInDefaultsAreFoundByTheirExactDatasetNames() throws Exception { + List names; + try (var files = Files.list(INDEX_PARAMETERS)) { + names = files.map(p -> p.getFileName().toString()) + .filter(n -> n.endsWith(".yml") && !n.equals("default.yml")).sorted().collect(Collectors.toList()); + } + assertTrue(names.size() > 20); + for (String name : names) { + String dataset = name.substring(0, name.length() - ".yml".length()); + assertEquals(name, dataset, MultiConfig.getDefaultConfig(INDEX_PARAMETERS.toFile(), dataset).dataset); + } + } + + private static Map allConfigs() throws Exception { + var configs = new TreeMap(); + try (var files = Files.walk(INDEX_PARAMETERS)) { + for (var path : files.filter(p -> p.toString().endsWith(".yml")).collect(Collectors.toList())) { + configs.put(INDEX_PARAMETERS.relativize(path), MultiConfig.getConfig(path.toString())); + } + } + return configs; + } + + @Test + public void everyExampleInAFolderUsesOnePipeline() throws Exception { + var byFolder = new TreeMap>>(); + for (var entry : allConfigs().entrySet()) { + Path relative = entry.getKey(); + if (relative.getNameCount() != 2 || relative.startsWith("studies")) continue; + byFolder.computeIfAbsent(relative.getName(0).toString(), k -> new TreeMap<>()) + .computeIfAbsent(entry.getValue().pipelineSignature(), k -> new ArrayList<>()).add(relative); + } + assertTrue(byFolder.size() >= 10); + for (var folder : byFolder.entrySet()) { + if (folder.getValue().size() == 1) continue; + var listing = new StringBuilder(); + folder.getValue().forEach((pipeline, files) -> + files.forEach(f -> listing.append(NL).append(" ").append(f).append(": ").append(pipeline))); + fail("Files in " + folder.getKey() + "/ select different pipelines:" + listing + NL + + "Every file in an example folder must use that folder's pipeline."); + } + } + + @Test + public void everyConfigurationOptionHasAnExample() throws Exception { + var used = new LinkedHashMap>(); + for (var config : allConfigs().values()) { + var c = config.construction; + add(used, "construction.type", c.type); + add(used, "construction.writerMode", c.writerMode); + add(used, "construction.reranking.type", c.rerankingType()); + if (c.fusedGraph != null) c.fusedGraph.forEach(v -> add(used, "construction.fusedGraph", v ? "Yes" : "No")); + for (var entry : c.compression) { + add(used, "construction.compression.type", entry.type); + add(used, "construction.compression.scoring", entry.scoring); + if (entry.parameters != null) add(used, "ASH optimizer", entry.parameters.get("optimizer")); + } + for (var entry : config.search.compression) { + add(used, "search.compression.type", entry.type); + if ("ASH".equals(entry.type) && entry.parameters != null) { + add(used, "ASH queryProjection", entry.parameters.get("queryProjection")); + add(used, "ASH queryExecution", entry.parameters.get("queryExecution")); + } + } + } + var required = new LinkedHashMap>(); + required.put("construction.type", List.of("inMemory", "onDisk")); + required.put("construction.writerMode", List.of("sequential", "parallel")); + required.put("construction.fusedGraph", List.of("Yes", "No")); + required.put("construction.compression.type", List.of("None", "ASH", "PQ", "BQ")); + required.put("construction.compression.scoring", List.of("symmetric", "asymmetric", "mixed")); + required.put("search.compression.type", List.of("None", "ASH", "PQ", "BQ")); + required.put("construction.reranking.type", List.of("None", "FP32", "NVQ")); + required.put("ASH optimizer", List.of("ITQ", "RANDOM")); + required.put("ASH queryProjection", List.of("auto", "fp32", "fp16", "int8")); + required.put("ASH queryExecution", List.of("direct", "queued")); + var missing = new ArrayList(); + required.forEach((field, values) -> values.stream() + .filter(v -> !used.getOrDefault(field, Set.of()).contains(v)) + .forEach(v -> missing.add("No index config uses " + field + ": " + v + "."))); + assertTrue(String.join(NL, missing), missing.isEmpty()); + } + + private static void add(Map> used, String field, Object value) { + if (value != null) used.computeIfAbsent(field, k -> new TreeSet<>()).add(String.valueOf(value)); + } + + @Test + public void pipelineIsInferredFromTheFields() throws Exception { + var compressed = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("compressed-build-fused-ash-nvq/cap-1M-2bit.yml").toString()); + assertEquals("onDisk: build ASH-2b symmetric -> search fused ASH-2b -> rerank NVQ", compressed.describePipeline()); + assertEquals("onDisk: build ASH -> search fused ASH -> rerank NVQ", compressed.pipelineSignature()); + var memory = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("in-memory-ash/mpnet-1m.yml").toString()); + assertEquals("inMemory: build FP32 -> search ASH-2b -> rerank FP32", memory.describePipeline()); + var uncompressed = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("glove-100-angular.yml").toString()); + assertEquals("onDisk: build FP32 -> search FP32 -> rerank None", uncompressed.describePipeline()); + var landmarks = MultiConfig.getConfig(INDEX_PARAMETERS.resolve("ash-options/ada002-1M-4-landmarks.yml").toString()); + assertEquals("onDisk: build ASH-2b 4 landmarks asymmetric -> search fused ASH-2b 4 landmarks -> rerank NVQ", + landmarks.describePipeline()); + } +} diff --git a/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml b/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml new file mode 100644 index 000000000..812486bf0 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/catalog-1m.yml @@ -0,0 +1,22 @@ +# Local mapping for the exact normalized benchmark inputs. Obtain the datasets +# separately, then point JVECTOR_BENCH_DATA_DIR at the directory containing these +# files/subdirectories. No private machine path or download credentials belong here. +_defaults: + cache_dir: ${JVECTOR_BENCH_DATA_DIR} + +mpnet-1m: + base: mpnet-1m/wiki_mpnet_embeddings_base_999812.fvecs + query: mpnet-1m/wiki_mpnet_embeddings_query_10000.fvecs + gt: mpnet-1m/wiki_mpnet_embeddings_gt_ip_100.ivecs +cohere-english-v3-1M: + base: cohere-1m/cohere_wiki_en_flat_base_1m_norm.fvecs + query: cohere-1m/cohere_wiki_en_flat_query_10k_norm.fvecs + gt: cohere-1m/cohere_wiki_en_flat_gt_1m_ip_k100.ivecs +ada002-1M: + base: ada_002_1M_base_982790.fvecs + query: ada_002_1M_query_10000.fvecs + gt: ada_002_1M_gt_ip_100.ivecs +openai-3072-1m: + base: openai-3072-1m/dbpedia_openai_3_large_3072_base_999000.fvecs + query: openai-3072-1m/dbpedia_openai_3_large_3072_query_1000.fvecs + gt: openai-3072-1m/dbpedia_openai_3_large_3072_gt_ip_100.ivecs diff --git a/jvector-examples/yaml-configs/ash-pq/catalog-large.yml b/jvector-examples/yaml-configs/ash-pq/catalog-large.yml new file mode 100644 index 000000000..1b79cf3da --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/catalog-large.yml @@ -0,0 +1,18 @@ +# Exact normalized larger datasets. Set JVECTOR_BENCH_DATA_DIR to their common parent. +_defaults: + cache_dir: ${JVECTOR_BENCH_DATA_DIR} + +mpnet-10m: + base: mpnet-10m/wiki_mpnet_embeddings_base_9997697.fvecs + query: mpnet-10m/wiki_mpnet_embeddings_query_10000.fvecs + gt: mpnet-10m/wiki_mpnet_embeddings_gt_ip_100.ivecs + +cohere-english-v3-10M: + base: cohere-10m/cohere_wiki_en_flat_base_10m_norm.fvecs + query: cohere-10m/cohere_wiki_en_flat_query_10k_norm.fvecs + gt: cohere-10m/cohere_wiki_en_flat_gt_10m_ip_k100.ivecs + +cap-6M: + base: cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_base_6m_norm_shuffle.fvecs + query: cap-6m/Caselaw_gte-Qwen2-1.5B_embeddings_query_10k_norm_shuffle.fvecs + gt: cap-6m/cap_6m_gt_norm_shuffle_ip_k100.ivecs diff --git a/jvector-examples/yaml-configs/ash-pq/run-construction.yml b/jvector-examples/yaml-configs/ash-pq/run-construction.yml new file mode 100644 index 000000000..661177fc3 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-construction.yml @@ -0,0 +1,18 @@ +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +benchmarks: + accuracy: [recall] +console: + benchmarks: + accuracy: [recall] + metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] +logging: + dir: logging + runId: ash-pq-construction-{ts} + type: csv + benchmarks: + accuracy: [recall] + metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] diff --git a/jvector-examples/yaml-configs/ash-pq/run-direct.yml b/jvector-examples/yaml-configs/ash-pq/run-direct.yml new file mode 100644 index 000000000..1b5ef1ba1 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-direct.yml @@ -0,0 +1,23 @@ +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +console: + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +logging: + dir: logging + runId: ash-pq-direct-{ts} + type: csv + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] diff --git a/jvector-examples/yaml-configs/ash-pq/run-queued.yml b/jvector-examples/yaml-configs/ash-pq/run-queued.yml new file mode 100644 index 000000000..03d0e4319 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-queued.yml @@ -0,0 +1,29 @@ +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +console: + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] +logging: + dir: logging + runId: ash-pq-queued-{ts} + type: csv + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + construction: [index_build_time_s, index_quant_time_s, search_quant_time_s] + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] diff --git a/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml b/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml new file mode 100644 index 000000000..e61168737 --- /dev/null +++ b/jvector-examples/yaml-configs/ash-pq/run-single-jvm.yml @@ -0,0 +1,31 @@ +# One JVM per experiment: construction, then queries for each search.workers value. +# The console prints the construction summary once and keeps the query tables to query +# columns; the CSV records construction metrics on every row. +# Used by standalone BenchYAML via -Djvector.bench.runConfig=. +yamlSchemaVersion: 1 +onDiskIndexVersion: 7 +benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] +console: + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] +logging: + dir: logging + runId: ash-pq-single-jvm-{ts} + type: csv + benchmarks: + throughput: [AVG] + latency: [AVG, STD, P999] + accuracy: [recall] + count: [visited] + metrics: + construction: [index_build_time_s, graph_build_time_s, flush_time_s, reopen_time_s, index_quant_time_s, search_quant_time_s] + execution: [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] diff --git a/jvector-examples/yaml-configs/dataset-metadata.yml b/jvector-examples/yaml-configs/dataset-metadata.yml index a0c840a8c..64a3be859 100644 --- a/jvector-examples/yaml-configs/dataset-metadata.yml +++ b/jvector-examples/yaml-configs/dataset-metadata.yml @@ -16,96 +16,124 @@ # relevant loader code. ada002-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB ada002-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cap-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cap-6M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cohere-english-v3-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cohere-english-v3-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB cohere-english-v3-10M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB colbert-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB colbert-10M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB degen-200k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB dpr-1M: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB dpr-10M: - similarity_function: COSINE - load_behavior: NO_SCRUB -dpr-gemma-1m: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB dpr-gemma-10m: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB e5-small-v2-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB e5-base-v2-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB e5-large-v2-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB gecko-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB nv-qa-v4-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB openai-v3-small-1536-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB openai-v3-large-3072-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB openai-v3-large-1536-100k: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB # ann-benchmarks glove-25-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB glove-50-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB lastfm-64-dot: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB glove-100-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB glove-200-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB nytimes-256-angular: - similarity_function: COSINE + similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB sift-128-euclidean: similarity_function: EUCLIDEAN load_behavior: NO_SCRUB +dpr-gemma-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB +mpnet-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB +tahoe-x1-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB +personahub-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB openai-3072-1m: similarity_function: DOT_PRODUCT load_behavior: NO_SCRUB openai-1536-1m: similarity_function: DOT_PRODUCT - load_behavior: NO_SCRUB \ No newline at end of file + load_behavior: NO_SCRUB +cap-10M: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB + +cohere-10M: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB + +esmc-600-1m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB + +esmc-600-6m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB + +mpnet-10m: + similarity_function: DOT_PRODUCT + load_behavior: NO_SCRUB diff --git a/jvector-examples/yaml-configs/datasets.yml b/jvector-examples/yaml-configs/datasets.yml index 60aa893f5..abcd1175b 100644 --- a/jvector-examples/yaml-configs/datasets.yml +++ b/jvector-examples/yaml-configs/datasets.yml @@ -1,24 +1,29 @@ +# Datasets BenchYAML runs when no dataset is named on the command line. +# The defaults are a few public 100k and 1M datasets that download on first use +# (see dataset-catalogs/public-catalog.yaml). Uncomment others to include them. jvector-100k: - cohere-english-v3-100k - ada002-100k - openai-v3-small-1536-100k - - gecko-100k - - openai-v3-large-3072-100k - - openai-v3-large-1536-100k - - e5-small-v2-100k - - e5-base-v2-100k - - e5-large-v2-100k +# - gecko-100k +# - openai-v3-large-3072-100k +# - openai-v3-large-1536-100k +# - e5-small-v2-100k +# - e5-base-v2-100k +# - e5-large-v2-100k jvector-1M: - ada002-1M - - colbert-1M +# - colbert-1M ann-benchmarks: - - glove-25-angular - - glove-50-angular - - lastfm-64-dot - - glove-100-angular - - glove-200-angular - - nytimes-256-angular - sift-128-euclidean +# - glove-25-angular +# - glove-50-angular +# - lastfm-64-dot +# - glove-100-angular +# - glove-200-angular +# - nytimes-256-angular +# The regression-tests section is what AutoBenchYAML (CI) selects from. Its datasets come +# from a protected catalog, so BenchYAML skips this section unless a dataset is named. regression-tests: - cap-1M - cap-6M @@ -26,15 +31,12 @@ regression-tests: - cohere-english-v3-10M - dpr-gemma-1m - dpr-gemma-10m -openai-unconfigured: - - openai-3072-1m - - openai-1536-1m +#openai-unconfigured: +# - openai-3072-1m +# - openai-1536-1m #other-datasets: -# - dpr-1M -# - dpr-10M -# - cap-1M -# - cap-6M -# - cohere-english-v3-1M -# - cohere-english-v3-10M +# - mpnet-1m +# - tahoe-x1-1m +# - personahub-1m # - deep-image-96-angular # large files not yet supported -# - gist-960-euclidean # large files not yet supported \ No newline at end of file +# - gist-960-euclidean # large files not yet supported diff --git a/jvector-examples/yaml-configs/index-parameters/README.md b/jvector-examples/yaml-configs/index-parameters/README.md new file mode 100644 index 000000000..0730b84bd --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/README.md @@ -0,0 +1,34 @@ +# Index parameters + +## Dataset defaults + +Each `.yml` in this directory is that dataset's default. Running BenchYAML with a dataset +name loads `index-parameters/.yml`. The file name must match the dataset name exactly, +including case, and the file must declare that dataset. A dataset without its own file uses +`default.yml`. Subdirectories are never searched for defaults. + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML mpnet-1m +``` + +## Examples + +Each subdirectory holds complete configurations for one pipeline. Run them by path. BenchYAML +prints the pipeline each configuration selects before it runs. + +| Folder | Pipeline | Use case | +| --- | --- | --- | +| [in-memory-fp](in-memory-fp) | inMemory: build None -> search None -> rerank None | Small or frequently queried collections that fit in memory, and lower-dimensional data where quantization saves little. | +| [in-memory-ash](in-memory-ash) | inMemory: build None -> search ASH -> rerank FP32 | Larger in-memory collections that need faster traversal while keeping exact final scores. | +| [in-memory-bq](in-memory-bq) | inMemory: build BQ -> search BQ -> rerank FP32 | Very high-dimensional embeddings, where sign codes keep construction and traversal cheap. | +| [flush-fused-ash-nvq](flush-fused-ash-nvq) | onDisk: build None -> search fused ASH -> rerank NVQ | The database write path: an in-memory index (for example a memtable) is flushed to an on-disk index. | +| [compressed-build-fused-ash-nvq](compressed-build-fused-ash-nvq) | onDisk: build ASH symmetric -> search fused ASH -> rerank NVQ | Ingest under a memory limit: construction scores compact codes instead of source vectors. | +| [flush-fused-pq-nvq](flush-fused-pq-nvq) | onDisk: build None -> search fused PQ -> rerank NVQ | The same write path as flush-fused-ash-nvq with PQ codes, for comparison and compatibility. | +| [on-disk-ash-fp32](on-disk-ash-fp32) | onDisk: build ASH -> search ASH -> rerank FP32 | Indexes too large for memory, where compact codes stay resident and full vectors are read only for rescoring. | +| [on-disk-pq-fp32](on-disk-pq-fp32) | onDisk: build PQ -> search PQ -> rerank FP32 | The same layout as on-disk-ash-fp32 with PQ codes. | +| [on-disk-fp](on-disk-fp) | onDisk: build None -> search None -> rerank None | Lower-dimensional data stored on disk, and tuning graph degree, build width, hierarchy and pruning. | +| [ash-options](ash-options) | onDisk: build ASH -> search fused ASH -> rerank NVQ | Choosing ASH settings for a deployment. Each file changes one option from the folder's shared setup. | +| [studies](studies) | Varies | Published comparisons, run with their own scripts | + +A pipeline combines the index type, construction scoring, fused codes, search scoring and +reranking. See [the BenchYAML guide](../../../docs/benchyaml-guide.md) for every field. diff --git a/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml new file mode 100644 index 000000000..d75d633b0 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ada002-1M.yml @@ -0,0 +1,48 @@ +# ASH configuration for DOT_PRODUCT datasets only. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: ada002-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/README.md b/jvector-examples/yaml-configs/index-parameters/ash-options/README.md new file mode 100644 index 000000000..faec313d1 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/README.md @@ -0,0 +1,15 @@ +# ASH options + +Fused ASH graphs with NVQ reranking, each changing one ASH option: construction scoring, landmarks, optimizer, query projection or query execution. + +**Use case:** Choosing ASH settings for a deployment. Each file changes one option from the folder's shared setup. + +**Pipeline:** onDisk: build ASH -> search fused ASH -> rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/ash-options/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/ada002-1M-4-landmarks.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/ada002-1M-4-landmarks.yml new file mode 100644 index 000000000..2960c23e5 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/ada002-1M-4-landmarks.yml @@ -0,0 +1,39 @@ +# ada002-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# use four ASH landmarks. Symmetric and mixed construction +# require one landmark, so the build uses asymmetric scoring. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: ada002-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: asymmetric + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 4 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 4 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-direct-execution.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-direct-execution.yml new file mode 100644 index 000000000..6b2b773dd --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-direct-execution.yml @@ -0,0 +1,38 @@ +# cap-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# run each search on the calling thread. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cap-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryExecution: direct diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-fp32-projection.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-fp32-projection.yml new file mode 100644 index 000000000..e55563814 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cap-1M-fp32-projection.yml @@ -0,0 +1,38 @@ +# cap-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# project queries with FP32 arithmetic. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cap-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp32 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-mixed-build.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-mixed-build.yml new file mode 100644 index 000000000..18d6ce3d1 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-mixed-build.yml @@ -0,0 +1,38 @@ +# cohere-english-v3-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# build with mixed ASH scoring: asymmetric insertion, symmetric pruning. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: mixed + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-queued-execution.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-queued-execution.yml new file mode 100644 index 000000000..2ebe71a80 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/cohere-english-v3-1M-queued-execution.yml @@ -0,0 +1,39 @@ +# cohere-english-v3-1M: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# submit searches to workers that prepare up to eight queries together. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/gecko-100k-random-optimizer.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/gecko-100k-random-optimizer.yml new file mode 100644 index 000000000..ac363e7bd --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/gecko-100k-random-optimizer.yml @@ -0,0 +1,37 @@ +# gecko-100k: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# train ASH with a random rotation instead of ITQ. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: gecko-100k + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: RANDOM + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: RANDOM + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-asymmetric-build.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-asymmetric-build.yml new file mode 100644 index 000000000..1a667ecbd --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-asymmetric-build.yml @@ -0,0 +1,39 @@ +# mpnet-1m: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# build with asymmetric ASH scoring: full-precision insertion +# queries against stored codes for insertion and pruning. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: asymmetric + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-int8-projection.yml b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-int8-projection.yml new file mode 100644 index 000000000..7caf7660c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/ash-options/mpnet-1m-int8-projection.yml @@ -0,0 +1,39 @@ +# mpnet-1m: fused 2-bit ASH graph with NVQ reranking. This example shows how to +# project queries with INT8 arithmetic. Requires a +# provider and CPU that support it; the run fails validation otherwise. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: int8 diff --git a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml index c9c6c09d8..abd1e7f2f 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-10M.yml @@ -1,10 +1,11 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: cap-10M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -12,26 +13,36 @@ construction: refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No search: topKOverquery: 10: [1.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml index 6aa8454b5..2d58190c3 100644 --- a/jvector-examples/yaml-configs/index-parameters/cap-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cap-1M.yml @@ -1,10 +1,12 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +# ASH configuration for DOT_PRODUCT datasets only. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: cap-1M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -12,26 +14,36 @@ construction: refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - - NVQ - useSavedIndexIfExists: No + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: 10: [1.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cap-6M.yml b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml new file mode 100644 index 000000000..33b246ed3 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/cap-6M.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cap-6M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml new file mode 100644 index 000000000..76a8a78f2 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/cohere-10M.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-10M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml index 17b3dd54a..6a9d0cf49 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-10M.yml @@ -1,10 +1,11 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: cohere-english-v3-10M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -12,26 +13,36 @@ construction: refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - - NVQ + type: NVQ useSavedIndexIfExists: No search: topKOverquery: 10: [1.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml index 56fb2849f..f63c37213 100644 --- a/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/cohere-english-v3-1M.yml @@ -1,10 +1,12 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +# ASH configuration for DOT_PRODUCT datasets only. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: cohere-english-v3-1M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -12,26 +14,36 @@ construction: refineFinalGraph: [Yes] fusedGraph: [Yes] compression: - - type: PQ + - type: ASH parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 reranking: - - NVQ - useSavedIndexIfExists: No + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing search: topKOverquery: 10: [1.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml index 66b47a056..d14d806bb 100644 --- a/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml +++ b/jvector-examples/yaml-configs/index-parameters/colbert-1M.yml @@ -1,10 +1,11 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: colbert-1M useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -14,15 +15,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None - -# Run-level controls, such as benchmarks, console, and logging, are in run-config.yml. \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md new file mode 100644 index 000000000..538f848bb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/README.md @@ -0,0 +1,15 @@ +# Build with ASH codes, flush a fused ASH graph + +Builds the graph with symmetric ASH scoring, then writes fused ASH traversal codes and NVQ reranking vectors in parallel. + +**Use case:** Ingest under a memory limit: construction scores compact codes instead of source vectors. + +**Pipeline:** onDisk: build ASH symmetric -> search fused ASH -> rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-2bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-2bit.yml new file mode 100644 index 000000000..28563bdd0 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-2bit.yml @@ -0,0 +1,41 @@ +# CAP-1M at 32X nominal compression. Build with symmetric 2-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-4bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-4bit.yml new file mode 100644 index 000000000..9f83744ce --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/cap-1M-4bit.yml @@ -0,0 +1,41 @@ +# CAP-1M at 16X nominal compression. Build with symmetric 4-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-2bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-2bit.yml new file mode 100644 index 000000000..fc0def898 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-2bit.yml @@ -0,0 +1,41 @@ +# MPNet 10M at 32X nominal compression. Build with symmetric 2-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-4bit.yml b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-4bit.yml new file mode 100644 index 000000000..4403d5c48 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/compressed-build-fused-ash-nvq/mpnet-10m-4bit.yml @@ -0,0 +1,41 @@ +# MPNet 10M at 16X nominal compression. Build with symmetric 4-bit ASH scoring +# and write fused ASH traversal codes and NVQ reranking vectors in parallel. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + scoring: symmetric + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/default.yml b/jvector-examples/yaml-configs/index-parameters/default.yml index e0a4ed3a5..122b5b531 100644 --- a/jvector-examples/yaml-configs/index-parameters/default.yml +++ b/jvector-examples/yaml-configs/index-parameters/default.yml @@ -1,41 +1,79 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +# ASH configuration for DOT_PRODUCT datasets only. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: default # Prefetch bounded batches instead of preloading the entire dataset into memory. useDatasetPrefetching: false construction: + type: onDisk + # type: inMemory # Omit writerMode, fusedGraph, and useSavedIndexIfExists. + # writerMode: parallel # Default for onDisk. + # writerMode: sequential outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] addHierarchy: [Yes] refineFinalGraph: [Yes] - fusedGraph: [No] + fusedGraph: [Yes] + # fusedGraph: [No] # Use separate search codes, or source vectors with search type None. compression: - - type: PQ + - type: ASH + # scoring: symmetric # Also accepts asymmetric or mixed. parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) + compressionRatio: 32 # Other ratios: 8, 16, 64, 128, 256. + # encodedBits: 808 # Alternative to compressionRatio, includes the 40-bit header. + optimizer: ITQ # Or RANDOM. + landmarkCount: 1 # Symmetric and mixed construction require 1. + bitsPerDimension: 2 # Fused ASH supports 1, 2, and 4. + # queryProjection: auto # Used for asymmetric scoring. See ASH search below. +# Select the compression entries for this case. +# For source scoring, use None in both sections, fusedGraph: [No], and reranking.type: None. +# BQ traversal requires fusedGraph: [No]. +# - type: None +# - type: BQ +# For fused PQ search, select PQ for search and use 256 centroids. +# - type: PQ +# parameters: +# mFactor: 8 +# # m: 96 # Alternative to mFactor. +# k: 256 +# # centerData: "No" # Defaults to centering for Euclidean similarity. +# anisotropicThreshold: -1.0 reranking: - - NVQ - useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + type: NVQ + # type: FP32 # Rescore with source vectors. + # type: None # Skip rescoring where supported. Set fusedGraph: [No]. + useSavedIndexIfExists: No # Set Yes to reuse a compatible disk index. search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - - type: PQ + - type: ASH parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - -# Run-level controls, such as benchmarks, console, and logging, are in run-config.yml. \ No newline at end of file + compressionRatio: 32 # Other ratios: 8, 16, 64, 128, 256. + # encodedBits: 808 # Alternative to compressionRatio, includes the 40-bit header. + optimizer: ITQ # Or RANDOM. + landmarkCount: 1 # Symmetric and mixed construction require 1. + bitsPerDimension: 2 # Fused ASH supports 1, 2, and 4. + # queryProjection: auto # fp32, fp16, and int8 are also available. + # queryExecution: direct + # queryExecution: queued + # maxBatchSize: 8 # For queued execution. +# Select the compression entries for this case. +# For source scoring, use None in both sections, fusedGraph: [No], and reranking.type: None. +# BQ traversal requires fusedGraph: [No]. +# - type: None +# - type: BQ +# For fused PQ search, select PQ for search and use 256 centroids. +# - type: PQ +# parameters: +# mFactor: 8 +# # m: 96 # Alternative to mFactor. +# k: 256 +# # centerData: "No" # Defaults to centering for Euclidean similarity. +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml deleted file mode 100644 index 90eac34e8..000000000 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10M.yml +++ /dev/null @@ -1,37 +0,0 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -dataset: dpr-gemma-10M -useDatasetPrefetching: false - -construction: - outDegree: [32] - efConstruction: [100] - neighborOverflow: [1.2f] - addHierarchy: [Yes] - refineFinalGraph: [Yes] - fusedGraph: [Yes] - compression: - - type: PQ - parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - reranking: - - NVQ - useSavedIndexIfExists: No - -search: - topKOverquery: - 10: [1.0] - useSearchPruning: [Yes] - compression: - - type: PQ - parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml new file mode 100644 index 000000000..12e314013 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-10m.yml @@ -0,0 +1,48 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: dpr-gemma-10m +useDatasetPrefetching: false + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml deleted file mode 100644 index 354c06436..000000000 --- a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1M.yml +++ /dev/null @@ -1,37 +0,0 @@ -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 - -dataset: dpr-gemma-1M -useDatasetPrefetching: false - -construction: - outDegree: [32] - efConstruction: [100] - neighborOverflow: [1.2f] - addHierarchy: [Yes] - refineFinalGraph: [Yes] - fusedGraph: [Yes] - compression: - - type: PQ - parameters: - # m: 192 # we can either specify the integer m or the integer mFactor. In this case, m will be set to the data dimensionality divided by mFactor - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) - reranking: - - NVQ - useSavedIndexIfExists: No - -search: - topKOverquery: - 10: [1.0] - useSearchPruning: [Yes] - compression: - - type: PQ - parameters: - # m: 192 - mFactor: 8 - # k: 256 # optional parameter. By default, k=256 - centerData: No - anisotropicThreshold: -1.0 # optional parameter. By default, anisotropicThreshold=-1 (i.e., no anisotropy) \ No newline at end of file diff --git a/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml new file mode 100644 index 000000000..d3be708e2 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/dpr-gemma-1m.yml @@ -0,0 +1,48 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: dpr-gemma-1m +useDatasetPrefetching: false + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml new file mode 100644 index 000000000..fe8f3de59 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-1m.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: esmc-600-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml b/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml new file mode 100644 index 000000000..2d1298464 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/esmc-600-6m.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: esmc-600-6m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md new file mode 100644 index 000000000..6ca1aaf7d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/README.md @@ -0,0 +1,15 @@ +# Build in memory, flush a fused ASH graph + +Builds the graph with the source vectors, then writes fused ASH traversal codes and NVQ reranking vectors to disk. + +**Use case:** The database write path: an in-memory index (for example a memtable) is flushed to an on-disk index. + +**Pipeline:** onDisk: build None -> search fused ASH -> rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-2bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-2bit.yml new file mode 100644 index 000000000..ef21451e4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-2bit.yml @@ -0,0 +1,35 @@ +# CAP-1M at 32X nominal compression. Build the graph with source FP32 vectors, +# then write fused 2-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-4bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-4bit.yml new file mode 100644 index 000000000..aaf278b2d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/cap-1M-4bit.yml @@ -0,0 +1,35 @@ +# CAP-1M at 16X nominal compression. Build the graph with source FP32 vectors, +# then write fused 4-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-1M + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-2bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-2bit.yml new file mode 100644 index 000000000..7b04e9883 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-2bit.yml @@ -0,0 +1,35 @@ +# MPNet 10M at 32X nominal compression. Build the graph with source FP32 vectors, +# then write fused 2-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-4bit.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-4bit.yml new file mode 100644 index 000000000..c857d1f9e --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-ash-nvq/mpnet-10m-4bit.yml @@ -0,0 +1,35 @@ +# MPNet 10M at 16X nominal compression. Build the graph with source FP32 vectors, +# then write fused 4-bit ASH traversal codes and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: sequential + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + queryProjection: fp16 + queryExecution: queued + maxBatchSize: 8 diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md new file mode 100644 index 000000000..190722264 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/README.md @@ -0,0 +1,15 @@ +# Build in memory, flush a fused PQ graph + +Builds the graph with the source vectors, then writes fused PQ traversal codes and NVQ reranking vectors to disk. + +**Use case:** The same write path as flush-fused-ash-nvq with PQ codes, for comparison and compatibility. + +**Pipeline:** onDisk: build None -> search fused PQ -> rerank NVQ + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/cohere-english-v3-1M.yml new file mode 100644 index 000000000..6ac0190d2 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/cohere-english-v3-1M.yml @@ -0,0 +1,31 @@ +# cohere-english-v3-1M (1024 dimensions): build the graph with full-precision vectors, then write fused PQ +# traversal codes (D/8 subspaces, 256 centroids) and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/mpnet-1m.yml b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/mpnet-1m.yml new file mode 100644 index 000000000..3d1b01223 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/flush-fused-pq-nvq/mpnet-1m.yml @@ -0,0 +1,31 @@ +# mpnet-1m (768 dimensions): build the graph with full-precision vectors, then write fused PQ +# traversal codes (D/8 subspaces, 256 centroids) and NVQ reranking vectors to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: None + reranking: + type: NVQ + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml new file mode 100644 index 000000000..1f729599c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/gecko-100k.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: gecko-100k + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml index 1b03b1edd..a930974da 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-100-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: glove-100-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -15,13 +16,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml index 1630baaba..8df0496ac 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-200-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: glove-200-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -15,13 +16,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml index bc2293a9d..fa1a468be 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-25-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: glove-25-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -15,13 +16,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml index 309ef9769..6dd0775e6 100644 --- a/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/glove-50-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: glove-50-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -15,13 +16,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md new file mode 100644 index 000000000..5d4e8a5b8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/README.md @@ -0,0 +1,15 @@ +# In-memory graph with ASH search + +Builds an in-memory graph with the source vectors. Searches traverse with ASH codes and rescore the candidates with the source vectors. + +**Use case:** Larger in-memory collections that need faster traversal while keeping exact final scores. + +**Pipeline:** inMemory: build None -> search ASH -> rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/in-memory-ash/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/ada002-1M.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/ada002-1M.yml new file mode 100644 index 000000000..e1ca75496 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/ada002-1M.yml @@ -0,0 +1,30 @@ +# ada002-1M (1536 dimensions): in-memory graph built with full-precision vectors. +# Searches traverse with 2-bit ASH codes (nominal 32x) and rescore with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: ada002-1M + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: FP32 + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/cohere-english-v3-1M.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/cohere-english-v3-1M.yml new file mode 100644 index 000000000..141651cb8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/cohere-english-v3-1M.yml @@ -0,0 +1,30 @@ +# cohere-english-v3-1M (1024 dimensions): in-memory graph built with full-precision vectors. +# Searches traverse with 2-bit ASH codes (nominal 32x) and rescore with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: cohere-english-v3-1M + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: FP32 + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-ash/mpnet-1m.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/mpnet-1m.yml new file mode 100644 index 000000000..73e76e919 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-ash/mpnet-1m.yml @@ -0,0 +1,30 @@ +# mpnet-1m (768 dimensions): in-memory graph built with full-precision vectors. +# Searches traverse with 2-bit ASH codes (nominal 32x) and rescore with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: FP32 + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md new file mode 100644 index 000000000..fd5d631b4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/README.md @@ -0,0 +1,15 @@ +# In-memory graph built and searched with BQ + +Builds and searches an in-memory graph with 1-bit BQ codes, then rescores with the source vectors. + +**Use case:** Very high-dimensional embeddings, where sign codes keep construction and traversal cheap. + +**Pipeline:** inMemory: build BQ -> search BQ -> rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/in-memory-bq/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/openai-3072-1m.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/openai-3072-1m.yml new file mode 100644 index 000000000..2a6bd7b20 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/openai-3072-1m.yml @@ -0,0 +1,25 @@ +# openai-3072-1m (3072 dimensions): in-memory graph built and searched with 1-bit BQ codes, +# then rescored with full-precision vectors. BQ suits very high-dimensional embeddings. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: openai-3072-1m + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: BQ + reranking: + type: FP32 + +search: + topKOverquery: + 10: [2.0, 4.0, 8.0] + useSearchPruning: [No] + compression: + - type: BQ diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-bq/tahoe-x1-1m.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/tahoe-x1-1m.yml new file mode 100644 index 000000000..5f2da82a8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-bq/tahoe-x1-1m.yml @@ -0,0 +1,25 @@ +# tahoe-x1-1m (2560 dimensions): in-memory graph built and searched with 1-bit BQ codes, +# then rescored with full-precision vectors. BQ suits very high-dimensional embeddings. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: tahoe-x1-1m + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: BQ + reranking: + type: FP32 + +search: + topKOverquery: + 10: [2.0, 4.0, 8.0] + useSearchPruning: [No] + compression: + - type: BQ diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md new file mode 100644 index 000000000..abfc03544 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/README.md @@ -0,0 +1,15 @@ +# In-memory graph, full-precision vectors + +Builds and searches a graph held entirely in memory with the source vectors. Nothing is written to disk and nothing is quantized. + +**Use case:** Small or frequently queried collections that fit in memory, and lower-dimensional data where quantization saves little. + +**Pipeline:** inMemory: build None -> search None -> rerank None + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/in-memory-fp/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/gecko-100k.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/gecko-100k.yml new file mode 100644 index 000000000..6647cf594 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/gecko-100k.yml @@ -0,0 +1,26 @@ +# gecko-100k (768 dimensions, 100k vectors): in-memory graph built and searched with full-precision vectors. +# Nothing is written to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: gecko-100k + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: None + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/glove-100-angular.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/glove-100-angular.yml new file mode 100644 index 000000000..a38ef9722 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/glove-100-angular.yml @@ -0,0 +1,26 @@ +# glove-100-angular (100 dimensions, angular similarity): in-memory graph built and searched with full-precision vectors. +# Nothing is written to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: glove-100-angular + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: None + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/in-memory-fp/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/sift-128-euclidean.yml new file mode 100644 index 000000000..1a576046a --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/in-memory-fp/sift-128-euclidean.yml @@ -0,0 +1,26 @@ +# sift-128-euclidean (128 dimensions, Euclidean similarity): in-memory graph built and searched with full-precision vectors. +# Nothing is written to disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: sift-128-euclidean + +construction: + type: inMemory + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + compression: + - type: None + reranking: + type: None + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml index c2e2fb98a..e9507211a 100644 --- a/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml +++ b/jvector-examples/yaml-configs/index-parameters/lastfm-64-dot.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: lastfm-64-dot useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -15,13 +16,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml new file mode 100644 index 000000000..887062150 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-10m.yml @@ -0,0 +1,38 @@ +# MPNet-10M: ASH(b=2,d=384), nominal 32x compression. +# Fused graph scoring and NVQ reranking. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # Saved indexes consume disk space, so reuse is opt-in. Set Yes to query a + # compatible index saved by an earlier run in the same working directory. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml b/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml new file mode 100644 index 000000000..935b0a72f --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/mpnet-1m.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: mpnet-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml index 4e2db21a7..46fe062a6 100644 --- a/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml +++ b/jvector-examples/yaml-configs/index-parameters/nytimes-256-angular.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: nytimes-256-angular useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -15,13 +16,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md new file mode 100644 index 000000000..346d359d5 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/README.md @@ -0,0 +1,15 @@ +# On-disk graph with separate ASH codes + +Builds with ASH scoring and writes the graph with the source vectors. Searches traverse with ASH codes held in memory and rescore from disk. + +**Use case:** Indexes too large for memory, where compact codes stay resident and full vectors are read only for rescoring. + +**Pipeline:** onDisk: build ASH -> search ASH -> rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. ASH requires dot-product datasets. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1m.yml new file mode 100644 index 000000000..005b58354 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/esmc-600-1m.yml @@ -0,0 +1,37 @@ +# esmc-600-1m (1152 dimensions): build with 2-bit ASH scoring and write the graph with full-precision +# vectors. Searches traverse with ASH codes held in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: esmc-600-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/personahub-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/personahub-1m.yml new file mode 100644 index 000000000..c07d014fb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/personahub-1m.yml @@ -0,0 +1,37 @@ +# personahub-1m (1024 dimensions): build with 2-bit ASH scoring and write the graph with full-precision +# vectors. Searches traverse with ASH codes held in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: personahub-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/tahoe-x1-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/tahoe-x1-1m.yml new file mode 100644 index 000000000..cc6b80eac --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-ash-fp32/tahoe-x1-1m.yml @@ -0,0 +1,37 @@ +# tahoe-x1-1m (2560 dimensions): build with 2-bit ASH scoring and write the graph with full-precision +# vectors. Searches traverse with ASH codes held in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: tahoe-x1-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md new file mode 100644 index 000000000..2a4972b9c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/README.md @@ -0,0 +1,15 @@ +# On-disk graph, full-precision vectors + +Builds and searches an on-disk graph with the source vectors, without quantization. Includes a graph-settings sweep. + +**Use case:** Lower-dimensional data stored on disk, and tuning graph degree, build width, hierarchy and pruning. + +**Pipeline:** onDisk: build None -> search None -> rerank None + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/on-disk-fp/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/glove-50-angular-graph-sweep.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/glove-50-angular-graph-sweep.yml new file mode 100644 index 000000000..3f0896ffa --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/glove-50-angular-graph-sweep.yml @@ -0,0 +1,28 @@ +# glove-50-angular (50 dimensions, angular similarity): sweep graph settings on an +# uncompressed on-disk graph. Each list value is a separate case; the run covers every +# combination of degree, build width and hierarchy, each searched with and without pruning. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: glove-50-angular + +construction: + type: onDisk + outDegree: [16, 32] + efConstruction: [50, 100] + neighborOverflow: [1.2f] + addHierarchy: [No, Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: None + reranking: + type: None + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No, Yes] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/lastfm-64-dot.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/lastfm-64-dot.yml new file mode 100644 index 000000000..e8d5d91a6 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/lastfm-64-dot.yml @@ -0,0 +1,27 @@ +# lastfm-64-dot (65 dimensions, dot product): on-disk graph built and searched with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: lastfm-64-dot + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: None + reranking: + type: None + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-fp/nytimes-256-angular.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/nytimes-256-angular.yml new file mode 100644 index 000000000..06f7d90c6 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-fp/nytimes-256-angular.yml @@ -0,0 +1,27 @@ +# nytimes-256-angular (256 dimensions, angular similarity): on-disk graph built and searched with full-precision vectors. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: nytimes-256-angular + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: None + reranking: + type: None + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + 100: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md new file mode 100644 index 000000000..e334f9991 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/README.md @@ -0,0 +1,15 @@ +# On-disk graph with separate PQ codes + +Builds with PQ scoring and writes the graph with the source vectors. Searches traverse with PQ codes held in memory and rescore from disk. + +**Use case:** The same layout as on-disk-ash-fp32 with PQ codes. + +**Pipeline:** onDisk: build PQ -> search PQ -> rerank FP32 + +Files are named for their dataset. Each sets `dataset:`; change it to run the same pipeline on another compatible dataset. + +Run one file, or every file in the folder, by passing paths to BenchYAML from the repository root: + +```bash +java -cp "$BENCH_JAR" io.github.jbellis.jvector.example.BenchYAML jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/*.yml +``` diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/dpr-gemma-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/dpr-gemma-1m.yml new file mode 100644 index 000000000..e324d58cb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/dpr-gemma-1m.yml @@ -0,0 +1,35 @@ +# dpr-gemma-1m (768 dimensions): build with PQ scoring (D/8 subspaces) and write the graph with +# full-precision vectors. Searches traverse with PQ codes in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: dpr-gemma-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/personahub-1m.yml b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/personahub-1m.yml new file mode 100644 index 000000000..f4cff95a4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/on-disk-pq-fp32/personahub-1m.yml @@ -0,0 +1,35 @@ +# personahub-1m (1024 dimensions): build with PQ scoring (D/8 subspaces) and write the graph with +# full-precision vectors. Searches traverse with PQ codes in memory and rescore from disk. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: personahub-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 + reranking: + type: FP32 + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0, 4.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + mFactor: 8 + k: 256 + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml new file mode 100644 index 000000000..ad4d824bb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/openai-v3-small-1536-100k.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: openai-v3-small-1536-100k + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml b/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml new file mode 100644 index 000000000..d5d8cba10 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/personahub-1m.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: personahub-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml index 94362ec70..f52bb4a1d 100644 --- a/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml +++ b/jvector-examples/yaml-configs/index-parameters/sift-128-euclidean.yml @@ -1,11 +1,12 @@ # This is here for loader testing purposes only, use other datasets for realistic test data -yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 dataset: sift-128-euclidean useDatasetPrefetching: false construction: + type: onDisk outDegree: [32] efConstruction: [100] neighborOverflow: [1.2f] @@ -15,13 +16,13 @@ construction: compression: - type: None reranking: - - NVQ + type: None useSavedIndexIfExists: No search: topKOverquery: 10: [1.0, 2.0, 5.0, 10.0] 100: [1.0, 2.0] - useSearchPruning: [Yes] + useSearchPruning: [No] compression: - type: None diff --git a/jvector-examples/yaml-configs/index-parameters/studies/README.md b/jvector-examples/yaml-configs/index-parameters/studies/README.md new file mode 100644 index 000000000..a42847097 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/README.md @@ -0,0 +1,12 @@ +# Studies + +Recipes for published comparisons. See +[Reproducing ASH versus PQ](../../../../docs/ash-pq-benchmark.md) for how to run them, +and [ASH performance](../../../../docs/ash-performance.md) for the results. + +- [ash-vs-pq](ash-vs-pq): ASH and PQ at matched code sizes on 1M and 6M/10M datasets, + with separate JVMs for construction and each query core budget. This is the + protocol behind the published results; run it with the study's scripts. +- [ash-vs-pq-single-jvm](ash-vs-pq-single-jvm): the same cases, each in one JVM that + builds once and queries with 1 and 8 search workers. Run them with the + [benchmark launcher](../../../bench-profiles/README.md); future studies use this form. diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-ash.yml new file mode 100644 index 000000000..8eb3e1baf --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-ash.yml @@ -0,0 +1,46 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-pq.yml new file mode 100644 index 000000000..4d37a27bb --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-2bit-pq.yml @@ -0,0 +1,43 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-ash.yml new file mode 100644 index 000000000..ca2e4dee4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-ash.yml @@ -0,0 +1,46 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-pq.yml new file mode 100644 index 000000000..dedfad158 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/ada002-4bit-pq.yml @@ -0,0 +1,43 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-ash.yml new file mode 100644 index 000000000..c8ce82c1c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-ash.yml @@ -0,0 +1,46 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-pq.yml new file mode 100644 index 000000000..5ae84d9a6 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-2bit-pq.yml @@ -0,0 +1,43 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-ash.yml new file mode 100644 index 000000000..378a0af94 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-ash.yml @@ -0,0 +1,46 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-pq.yml new file mode 100644 index 000000000..108ab8145 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cap6m-4bit-pq.yml @@ -0,0 +1,43 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-ash.yml new file mode 100644 index 000000000..ab4f1fc0b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-pq.yml new file mode 100644 index 000000000..97ef91b11 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-2bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-ash.yml new file mode 100644 index 000000000..2757381b7 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-pq.yml new file mode 100644 index 000000000..904e2be4f --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere-4bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-ash.yml new file mode 100644 index 000000000..c53752441 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-pq.yml new file mode 100644 index 000000000..c353b5200 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-2bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-ash.yml new file mode 100644 index 000000000..ed98bd31d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-ash.yml @@ -0,0 +1,46 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-pq.yml new file mode 100644 index 000000000..65f571e74 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/cohere10m-4bit-pq.yml @@ -0,0 +1,43 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-ash.yml new file mode 100644 index 000000000..3d8b8e6c8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-pq.yml new file mode 100644 index 000000000..21e4da780 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-2bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-ash.yml new file mode 100644 index 000000000..e8551b11a --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-pq.yml new file mode 100644 index 000000000..171bf684d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet-4bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml new file mode 100644 index 000000000..a8ccd92e1 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml new file mode 100644 index 000000000..5ac79d3c3 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-2bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-ash.yml new file mode 100644 index 000000000..59bd21f45 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-ash.yml @@ -0,0 +1,46 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml new file mode 100644 index 000000000..ff3339f7b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/mpnet10m-4bit-pq.yml @@ -0,0 +1,43 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-ash.yml new file mode 100644 index 000000000..243ebb07f --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-ash.yml @@ -0,0 +1,46 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-pq.yml new file mode 100644 index 000000000..14b1ad4e7 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-2bit-pq.yml @@ -0,0 +1,43 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-ash.yml new file mode 100644 index 000000000..0dbb97288 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-ash.yml @@ -0,0 +1,46 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-pq.yml new file mode 100644 index 000000000..37d9a73e1 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq-single-jvm/openai3072-4bit-pq.yml @@ -0,0 +1,43 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# One JVM per experiment: build with every physical core, then query the same index +# with 1 and 8 search workers. The settings otherwise match studies/ash-vs-pq. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + workers: max + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # One JVM builds and queries, so nothing needs to be saved for later JVMs. + useSavedIndexIfExists: No + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + workers: [1, 8] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-ash.yml new file mode 100644 index 000000000..56901979d --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-ash.yml @@ -0,0 +1,44 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-pq.yml new file mode 100644 index 000000000..c547f0a9b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-2bit-pq.yml @@ -0,0 +1,41 @@ +# ada002-1M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-ash.yml new file mode 100644 index 000000000..5611d961c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-ash.yml @@ -0,0 +1,44 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-pq.yml new file mode 100644 index 000000000..736278f8b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/ada002-4bit-pq.yml @@ -0,0 +1,41 @@ +# ada002-1M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: ada002-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-ash.yml new file mode 100644 index 000000000..ac68161f3 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-ash.yml @@ -0,0 +1,44 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-pq.yml new file mode 100644 index 000000000..5afd99a7e --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-2bit-pq.yml @@ -0,0 +1,41 @@ +# cap-6M: 1536 input dimensions, 197 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-ash.yml new file mode 100644 index 000000000..7505d3afa --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-ash.yml @@ -0,0 +1,44 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-pq.yml new file mode 100644 index 000000000..71e4b92d4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cap6m-4bit-pq.yml @@ -0,0 +1,41 @@ +# cap-6M: 1536 input dimensions, 389 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cap-6M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-ash.yml new file mode 100644 index 000000000..457ff26e7 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-ash.yml @@ -0,0 +1,44 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-pq.yml new file mode 100644 index 000000000..dbc00bb40 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-2bit-pq.yml @@ -0,0 +1,41 @@ +# cohere-english-v3-1M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-ash.yml new file mode 100644 index 000000000..189fcf892 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-ash.yml @@ -0,0 +1,44 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-pq.yml new file mode 100644 index 000000000..446263fe0 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere-4bit-pq.yml @@ -0,0 +1,41 @@ +# cohere-english-v3-1M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-1M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-ash.yml new file mode 100644 index 000000000..df4294d7c --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-ash.yml @@ -0,0 +1,44 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1064 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-pq.yml new file mode 100644 index 000000000..938650fd4 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-2bit-pq.yml @@ -0,0 +1,41 @@ +# cohere-english-v3-10M: 1024 input dimensions, 133 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 133 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 133 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-ash.yml new file mode 100644 index 000000000..8e1f6ff36 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-ash.yml @@ -0,0 +1,44 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 2088 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-pq.yml new file mode 100644 index 000000000..df5b1ec2b --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/cohere10m-4bit-pq.yml @@ -0,0 +1,41 @@ +# cohere-english-v3-10M: 1024 input dimensions, 261 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 261 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: cohere-english-v3-10M + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 261 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-ash.yml new file mode 100644 index 000000000..320138023 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-ash.yml @@ -0,0 +1,44 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-pq.yml new file mode 100644 index 000000000..265b7664a --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-2bit-pq.yml @@ -0,0 +1,41 @@ +# mpnet-1m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-ash.yml new file mode 100644 index 000000000..a5b45e4e8 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-ash.yml @@ -0,0 +1,44 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-pq.yml new file mode 100644 index 000000000..c53a271c0 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet-4bit-pq.yml @@ -0,0 +1,41 @@ +# mpnet-1m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-ash.yml new file mode 100644 index 000000000..e4db21135 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-ash.yml @@ -0,0 +1,44 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 808 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-pq.yml new file mode 100644 index 000000000..3101d62c6 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-2bit-pq.yml @@ -0,0 +1,41 @@ +# mpnet-10m: 768 input dimensions, 101 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 101 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 101 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-ash.yml new file mode 100644 index 000000000..ec21db3ca --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-ash.yml @@ -0,0 +1,44 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 1576 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-pq.yml new file mode 100644 index 000000000..a32d405c3 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/mpnet10m-4bit-pq.yml @@ -0,0 +1,41 @@ +# mpnet-10m: 768 input dimensions, 197 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 197 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: mpnet-10m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 197 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-ash.yml new file mode 100644 index 000000000..0623f4b18 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-ash.yml @@ -0,0 +1,44 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 3112 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-pq.yml new file mode 100644 index 000000000..9efeb6189 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-2bit-pq.yml @@ -0,0 +1,41 @@ +# openai-3072-1m: 3072 input dimensions, 389 code bytes per vector. +# The 2-bit label describes the ASH budget; PQ uses 389 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 389 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-ash.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-ash.yml new file mode 100644 index 000000000..32e711b99 --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-ash.yml @@ -0,0 +1,44 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + queryProjection: auto + queryExecution: queued + maxBatchSize: 8 + encodedBits: 6184 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 4 diff --git a/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-pq.yml b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-pq.yml new file mode 100644 index 000000000..a00fa2e1e --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/studies/ash-vs-pq/openai3072-4bit-pq.yml @@ -0,0 +1,41 @@ +# openai-3072-1m: 3072 input dimensions, 773 code bytes per vector. +# The 4-bit label describes the ASH budget; PQ uses 773 8-bit subspaces. +# ASH uses C=1 and D/2 projected dimensions, including its five-byte header. +# See docs/ash-pq-benchmark.md for construction, direct and queued run commands. +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 +dataset: openai-3072-1m + +construction: + type: onDisk + writerMode: parallel + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 + reranking: + type: NVQ + # A fresh case directory measures training/encoding/construction. Later JVMs + # in that directory reuse exactly the same model and index for querying. + useSavedIndexIfExists: Yes + +search: + topKOverquery: + 10: [1.0, 2.0] + useSearchPruning: [No] + compression: + - type: PQ + parameters: + m: 773 + k: 256 + centerData: "No" + anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml new file mode 100644 index 000000000..f02f79f9e --- /dev/null +++ b/jvector-examples/yaml-configs/index-parameters/tahoe-x1-1m.yml @@ -0,0 +1,47 @@ +yamlSchemaVersion: 3 +onDiskIndexVersion: 7 + +dataset: tahoe-x1-1m + +construction: + type: onDisk + outDegree: [32] + efConstruction: [100] + neighborOverflow: [1.2f] + addHierarchy: [Yes] + refineFinalGraph: [Yes] + fusedGraph: [Yes] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 + reranking: + type: NVQ + useSavedIndexIfExists: No # If yes, the system will attempt to locate a cached copy of the index instead of constructing + +search: + topKOverquery: + 10: [1.0, 2.0, 5.0, 10.0] + useSearchPruning: [No] + compression: + - type: ASH + parameters: + compressionRatio: 32 + optimizer: ITQ + landmarkCount: 1 + bitsPerDimension: 2 +# To use PQ, comment out ASH and uncomment PQ in both sections. +# - type: PQ +# parameters: +# mFactor: 8 +# k: 256 +# anisotropicThreshold: -1.0 diff --git a/jvector-examples/yaml-configs/run-config.yml b/jvector-examples/yaml-configs/run-config.yml index ba5ee1077..6c5eb9abc 100644 --- a/jvector-examples/yaml-configs/run-config.yml +++ b/jvector-examples/yaml-configs/run-config.yml @@ -1,25 +1,28 @@ # Run-level controls for BenchYAML (applies to the whole invocation) yamlSchemaVersion: 1 -onDiskIndexVersion: 6 +onDiskIndexVersion: 7 # 7 adds fused ASH graphs; 6 is the previous format # Benchmark stats to compute benchmarks: - throughput: [ AVG ] # additional options: [AVG, MEDIAN, MAX] - latency: [ AVG ] # [AVG, STD, P999] - count: [ visited ] # [visited, expanded, expanded base layer] - accuracy: [ recall ] # [recall, MAP] + throughput: [ AVG ] # [AVG, MEDIAN, MAX] + latency: [ AVG ] # [AVG, STD, P999] + count: [ visited ] # [visited, expanded, expanded base layer] + accuracy: [ recall ] # [recall, MAP] # Console display (omit/leave blank to print all computed outputs) console: benchmarks: - throughput: [ AVG ] # [AVG, MEDIAN, MAX] - latency: [ AVG ] # [AVG, STD, P999] + throughput: [ AVG ] # [AVG, MEDIAN, MAX] + latency: [ AVG ] # [AVG, STD, P999] count: [ visited ] # [visited, expanded, expanded base layer] accuracy: [ recall ] # [recall, MAP] metrics: - system: [ max_heap_mb, max_offheap_mb ] # [ max_heap_mb, max_offheap_mb ] -# disk: total_file_size_mb # [ total_file_size_mb, file_count ] -# construction: index_build_time_s # [ index_build_time_s, index_quant_time_s, search_quant_time_s ] + system: [ max_heap_mb, max_offheap_mb ] # [max_heap_mb, max_offheap_mb] + construction: [ index_build_time_s ] # [index_build_time_s, graph_build_time_s, flush_time_s, + # reopen_time_s, index_quant_time_s, search_quant_time_s] +# disk: [ total_file_size_mb ] # [total_file_size_mb, file_count] +# execution: [ response_mean_ms ] # queued or direct query execution: + # [response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size] # Experiments CSV logging (selection + run metadata) #logging: @@ -35,4 +38,5 @@ console: # metrics: # system: [ max_heap_mb ] # [ max_heap_mb, max_offheap_mb ] # disk: [ total_file_size_mb, file_count ] # [ total_file_size_mb, file_count ] -# construction: [ index_build_time_s ] # [ index_build_time_s, index_quant_time_s, search_quant_time_s ] +# construction: [ index_build_time_s ] # [ index_build_time_s, graph_build_time_s, flush_time_s, reopen_time_s, index_quant_time_s, search_quant_time_s ] +# execution: [ response_mean_ms ] # [ response_mean_ms, response_p95_ms, response_p99_ms, mean_batch_size ] diff --git a/jvector-native/pom.xml b/jvector-native/pom.xml index 40d7838ef..bb704f7f1 100644 --- a/jvector-native/pom.xml +++ b/jvector-native/pom.xml @@ -16,6 +16,11 @@ true false + + false + + org.apache.rat + apache-rat-plugin + + + + src/main/native/subprojects/highway/** + src/main/onednn/subprojects/onednn/** + src/main/*/subprojects/packagecache/** + src/main/*/subprojects/.wraplock + + + org.apache.maven.plugins maven-compiler-plugin @@ -120,6 +142,7 @@ release true + true @@ -191,6 +214,7 @@ build_native_lib.sh ${native.buildtype} ${native.crossarch} + ${native.onednn} false ${project.basedir}/src/main/native/src/ diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java b/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java index 0a5fde49e..d50925001 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java @@ -93,6 +93,11 @@ public void readFully(long[] vector) { position += vector.length * 8L; } + @Override + public byte readByte() { + return memory.get(ValueLayout.JAVA_BYTE, position++); + } + @Override public int readInt() { var k = memory.get(intLayout, position); diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java new file mode 100644 index 000000000..e49cc53c4 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP16Projection.java @@ -0,0 +1,258 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +/** + * Immutable, model-owned FP16 query matrix with strict FP32 accumulation/output. + * The optional library uses sequential oneDNN; parallelism belongs to search + * callers. A projection shares the Highway adapter's calling-thread staging, + * while native conversion storage and scratch are reused by the calling thread. + * Neither thread-local cache retains the model or its matrix. + * + *

Only query-scoring views use this representation. Encoding retains Highway + * FP32. Singleton and batch calls execute on the calling thread. + */ +final class NativeASHFP16Projection implements VectorUtilSupport.AshProjection { + private static final int OK = 0, UNSUPPORTED = 1, INVALID_ARGUMENT = 2, + RANGE = 3, NO_MEMORY = 4; + + private static final MemoryLayout RESULT_LAYOUT = MemoryLayout.structLayout( + JAVA_INT, MemoryLayout.sequenceLayout(256, JAVA_BYTE)); + + /** Loaded only if a query actually asks for an FP16 view. */ + private static final class Bindings { + static final Bindings INSTANCE = load(); + final MethodHandle create, destroy, project, bytes, batch; + + private Bindings(SymbolLookup lookup) throws Throwable { + MethodHandle abi = bind(lookup, "jvector_ash_fp16_abi", FunctionDescriptor.of(JAVA_INT)); + if ((int) abi.invokeExact() != 1) + throw new UnsatisfiedLinkError("Unsupported ASH FP16 library ABI"); + create = bind(lookup, "jvector_ash_fp16_create", + FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS)); + destroy = bind(lookup, "jvector_ash_fp16_destroy", FunctionDescriptor.ofVoid(ADDRESS)); + project = bind(lookup, "jvector_ash_fp16_project", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, ADDRESS)); + bytes = bind(lookup, "jvector_ash_fp16_bytes", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); + // Older optional libraries retain correct singleton FP16 behavior. + batch = lookup.find("jvector_ash_fp16_project_batch") + .map(symbol -> Linker.nativeLinker().downcallHandle(symbol, + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS, ADDRESS))) + .orElse(null); + } + + private static MethodHandle bind(SymbolLookup lookup, String name, FunctionDescriptor descriptor) { + MemorySegment symbol = lookup.find(name) + .orElseThrow(() -> new UnsatisfiedLinkError("Missing ASH FP16 symbol: " + name)); + return Linker.nativeLinker().downcallHandle(symbol, descriptor); + } + + private static Bindings load() { + if (!LibraryLoader.loadAshFp16()) return null; + try { + return new Bindings(SymbolLookup.loaderLookup()); + } catch (UnsatisfiedLinkError | IllegalCallerException | SecurityException + | UnsupportedOperationException unavailable) { + return null; + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 binding failure", failure); + } + } + } + + private final MemorySegment context; + private final int rows, columns; + private volatile long retainedBytes; + private volatile boolean batchAvailable; + // Set only on the existing automatic FP32 range-fallback branch. + private volatile boolean fp32FallbackUsed; + private static final java.lang.invoke.VarHandle RETAINED_BYTES; + static { + try { + RETAINED_BYTES = java.lang.invoke.MethodHandles.lookup().findVarHandle( + NativeASHFP16Projection.class, "retainedBytes", long.class); + } catch (ReflectiveOperationException failure) { + throw new ExceptionInInitializerError(failure); + } + } + // Present only in automatic mode, for queries outside the finite FP16 range. + private final VectorUtilSupport.AshProjection fallback; + + private NativeASHFP16Projection(MemorySegment context, int rows, int columns, + long weightBytes, VectorUtilSupport.AshProjection fallback) { + this.context = context; + this.rows = rows; + this.columns = columns; + this.retainedBytes = weightBytes; + this.batchAvailable = Bindings.INSTANCE.batch != null; + this.fallback = fallback; + } + + /** Model initialization only; returns fallback for genuinely unsupported automatic selection. */ + static VectorUtilSupport.AshProjection prepare(MemorySegment matrix, int rows, int columns, + VectorUtilSupport.AshProjection fallback, + boolean required) { + Bindings bindings = Bindings.INSTANCE; + if (bindings == null) { + if (!required) return fallback; + throw new UnsupportedOperationException("ASH FP16 query projection library is unavailable; " + + "build with -Dnative.onednn=true or select fp32/auto"); + } + try (Arena setup = Arena.ofConfined()) { + MemorySegment status = setup.allocate(RESULT_LAYOUT); + MemorySegment pointer = (MemorySegment) bindings.create.invokeExact(matrix, rows, columns, status); + int code = status.get(JAVA_INT, 0); + if (pointer.address() == 0) { + if (!required && (code == UNSUPPORTED || code == RANGE)) return fallback; + throw failure(code, status); + } + if (code != OK) { + bindings.destroy.invokeExact(pointer); + throw failure(code, status); + } + long bytes; + MemorySegment owned; + try { + bytes = (long) bindings.bytes.invokeExact(pointer); + owned = pointer.reinterpret(1, Arena.ofAuto(), NativeASHFP16Projection::destroy); + } catch (Throwable failure) { + bindings.destroy.invokeExact(pointer); + throw failure; + } + return new NativeASHFP16Projection(owned, rows, columns, bytes, required ? null : fallback); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 initialization failure", failure); + } + } + + private static void destroy(MemorySegment pointer) { + try { + Bindings.INSTANCE.destroy.invokeExact(pointer); + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 cleanup failure", failure); + } + } + + private static RuntimeException failure(int code, MemorySegment result) { + String message = "ASH FP16 projection: " + result.getString(JAVA_INT.byteSize()); + if (code == NO_MEMORY) throw new OutOfMemoryError(message); + if (code == UNSUPPORTED) return new UnsupportedOperationException(message); + if (code == INVALID_ARGUMENT || code == RANGE) return new IllegalArgumentException(message); + return new IllegalStateException(message + " (status " + code + ")"); + } + + /** Native owned storage; per-thread staging and opaque oneDNN scratch are excluded. */ + @Override + public long ramBytesUsed() { return retainedBytes; } + + @Override + public String operandPrecision() { return fp32FallbackUsed ? "fp16+fp32" : "fp16"; } + + @Override + public boolean supportsBatchProjection() { return batchAvailable; } + + /** Shares prepared FP16 weights; bounded native tiles handle arbitrary batch lengths. */ + @Override + public void projectBatch(float[][] inputs, float[][] outputs, int count) { + VectorUtilSupport.AshProjection.validateBatch(inputs, outputs, count); + if (count == 0) return; + if (inputs[0].length != columns || outputs[0].length != rows) + throw new IllegalArgumentException("ASH FP16 batch shape mismatch"); + if (count == 1 || !batchAvailable) { + for (int i = 0; i < count; i++) project(inputs[i], outputs[i]); + return; + } + long inputRowBytes = (long) columns * Float.BYTES; + long outputRowBytes = (long) rows * Float.BYTES; + NativeASHProjection.Buffers buffers = NativeASHProjection.buffers( + Math.multiplyExact(count, inputRowBytes), Math.multiplyExact(count, outputRowBytes)); + for (int i = 0; i < count; i++) + MemorySegment.copy(MemorySegment.ofArray(inputs[i]), 0, buffers.input, + i * inputRowBytes, inputRowBytes); + if (buffers.status.byteSize() == 0) buffers.status = Arena.ofAuto().allocate(RESULT_LAYOUT); + try { + int status = (int) Bindings.INSTANCE.batch.invokeExact( + context, buffers.input, count, buffers.output, buffers.status); + // Publish lazy packing even on a partially successful/failed native + // call. Concurrent callers cannot overwrite a newer, larger total. + long current = (long) Bindings.INSTANCE.bytes.invokeExact(context); + for (long previous; (previous = retainedBytes) < current;) + if (RETAINED_BYTES.compareAndSet(this, previous, current)) break; + if (status == UNSUPPORTED) batchAvailable = false; + if (status == UNSUPPORTED || (status == RANGE && fallback != null)) { + // Preserve each query's FP16/automatic-range behavior, including + // compatibility with libraries lacking an optimized batch shape. + for (int i = 0; i < count; i++) project(inputs[i], outputs[i]); + return; + } + if (status != OK) throw failure(status, buffers.status); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 batch execution failure", failure); + } + for (int i = 0; i < count; i++) + MemorySegment.copy(buffers.output, i * outputRowBytes, + MemorySegment.ofArray(outputs[i]), 0, outputRowBytes); + } + + @Override + public void project(float[] query, float[] out) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(out, "out"); + if (query.length != columns || out.length != rows || query == out) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + long inputBytes = (long) columns * Float.BYTES; + long outputBytes = (long) rows * Float.BYTES; + NativeASHProjection.Buffers buffers = NativeASHProjection.buffers(inputBytes, outputBytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, buffers.input, 0, inputBytes); + if (buffers.status.byteSize() == 0) buffers.status = Arena.ofAuto().allocate(RESULT_LAYOUT); + try { + int status = (int) Bindings.INSTANCE.project.invokeExact( + context, buffers.input, buffers.output, buffers.status); + if (status == RANGE && fallback != null) { + fallback.project(query, out); + fp32FallbackUsed = true; + return; + } + if (status != OK) throw failure(status, buffers.status); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP16 execution failure", failure); + } + MemorySegment.copy(buffers.output, 0, MemorySegment.ofArray(out), 0, outputBytes); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java new file mode 100644 index 000000000..d57639a91 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjection.java @@ -0,0 +1,143 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; + +/** + * Preferred oneDNN FP32 GEMM for bulk base-vector projection, with Highway fallback. + * Owns no second matrix: the immutable row-major Highway matrix is shared. + * Single-vector projection and query precision selection stay with the delegate. + * Library scratch and JIT preparation are included in the caller's first use. + */ +final class NativeASHFP32BatchProjection implements VectorUtilSupport.AshProjection { + private static final class Bindings { + static final MethodHandle BATCH = load(); + private static MethodHandle load() { + // The optional package already embeds sequential oneDNN for FP16 + // queries. Older packages simply lack the new FP32 entry point. + if (!LibraryLoader.loadAshFp16()) return null; + try { + SymbolLookup lookup = SymbolLookup.loaderLookup(); + MethodHandle abi = Linker.nativeLinker().downcallHandle( + lookup.find("jvector_ash_fp16_abi") + .orElseThrow(() -> new UnsatisfiedLinkError("Missing ASH add-on ABI")), + FunctionDescriptor.of(JAVA_INT)); + if ((int) abi.invokeExact() != 1) + throw new UnsatisfiedLinkError("Unsupported ASH add-on ABI"); + return lookup.find("jvector_ash_fp32_project_batch") + .map(symbol -> Linker.nativeLinker().downcallHandle(symbol, + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, + JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS))) + .orElse(null); + } catch (UnsatisfiedLinkError | IllegalCallerException | SecurityException + | UnsupportedOperationException unavailable) { + return null; + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH FP32 binding failure", failure); + } + } + } + + // The model charges this adapter; its retained delegate is separate. + private static final long FALLBACK_HEAP_BYTES = + RamUsageEstimator.shallowSizeOfInstance(NativeASHProjection.class); + + private final NativeASHProjection fallback; + static final int HIGHWAY_MIN_ROWS = 4, HIGHWAY_MAX_ROWS = 32; + + static VectorUtilSupport.AshProjection prepare(NativeASHProjection fallback) { + return Bindings.BATCH == null ? fallback : new NativeASHFP32BatchProjection(fallback); + } + + private NativeASHFP32BatchProjection(NativeASHProjection fallback) { + this.fallback = fallback; + } + + @Override + public long ramBytesUsed() { return FALLBACK_HEAP_BYTES + fallback.ramBytesUsed(); } + + @Override + public void project(float[] input, float[] output) { fallback.project(input, output); } + + @Override + public VectorUtilSupport.AshProjection forQueryScoring() { + var query = fallback.forQueryScoring(); + // No new representation means the same backing. Preserve the owning view's + // identity so model accounting does not charge the FP32 matrix twice. + return query == fallback ? this : query; + } + + /** projectBatch always succeeds: oneDNN SGEMM, or the Highway batch kernel when oneDNN declines. */ + @Override + public boolean supportsBatchProjection() { return true; } + + @Override + public void projectBatch(float[][] inputs, float[][] outputs, int count) { + VectorUtilSupport.AshProjection.validateBatch(inputs, outputs, count); + if (count == 0) return; + // Highway's register-tiled kernel beats SGEMM for query-sized batches + // (about 2x at 8 rows on Ice Lake and Granite Rapids); oneDNN wins for + // pairs and for encoding-sized batches. Both accumulate in FP32. + if (count >= HIGHWAY_MIN_ROWS && count <= HIGHWAY_MAX_ROWS && fallback.supportsBatchProjection()) { + fallback.projectBatch(inputs, outputs, count); + return; + } + final int rows = fallback.rows(), columns = fallback.columns(); + if (inputs[0].length != columns || outputs[0].length != rows) + throw new IllegalArgumentException("ASH projection batch shape mismatch"); + long inputRowBytes = (long) columns * Float.BYTES; + long outputRowBytes = (long) rows * Float.BYTES; + NativeASHProjection.Buffers buffers = NativeASHProjection.buffers( + Math.multiplyExact(count, inputRowBytes), Math.multiplyExact(count, outputRowBytes)); + for (int i = 0; i < count; i++) + MemorySegment.copy(MemorySegment.ofArray(inputs[i]), 0, buffers.input, + i * inputRowBytes, inputRowBytes); + final int status; + try { + status = (int) Bindings.BATCH.invokeExact(fallback.matrix(), buffers.input, + rows, columns, count, buffers.output); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected exception from oneDNN FP32 projection", error); + } + // oneDNN status values are translated by the C wrapper. Unsupported + // kernels retain Highway; allocation/runtime failures are never hidden. + if (status == 3) { + fallback.projectBatch(inputs, outputs, count); + return; + } + if (status == 1) throw new OutOfMemoryError("oneDNN FP32 projection allocation failed"); + if (status == 2) throw new IllegalArgumentException("Invalid oneDNN FP32 projection arguments"); + if (status != 0) throw new IllegalStateException("oneDNN FP32 projection failed: " + status); + for (int i = 0; i < count; i++) + MemorySegment.copy(buffers.output, i * outputRowBytes, + MemorySegment.ofArray(outputs[i]), 0, outputRowBytes); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java new file mode 100644 index 000000000..1636d26de --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHInt8Projection.java @@ -0,0 +1,199 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_BYTE; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; + +/** + * Immutable, model-owned oneDNN INT8 query view. Training/encoding retain FP32. + * Reuses the existing projection contract, caller-owned batching and calling-thread + * staging. Native scratch is thread-local and retains no model or packed weights. + * Explicit INT8 selection never silently substitutes another precision. + */ +final class NativeASHInt8Projection implements VectorUtilSupport.AshProjection { + private static final int OK = 0, UNSUPPORTED = 1, INVALID_ARGUMENT = 2, + RANGE = 3, NO_MEMORY = 4; + // The optional add-on shares the existing ABI-1 caller-owned result structure. + private static final MemoryLayout RESULT_LAYOUT = MemoryLayout.structLayout( + JAVA_INT, MemoryLayout.sequenceLayout(256, JAVA_BYTE)); + + private static final class Bindings { + static final Bindings INSTANCE = load(); + final MethodHandle create, destroy, batch, bytes; + + private Bindings(SymbolLookup lookup) throws Throwable { + MethodHandle abi = bind(lookup, "jvector_ash_fp16_abi", FunctionDescriptor.of(JAVA_INT)); + if ((int) abi.invokeExact() != 1) + throw new UnsatisfiedLinkError("Unsupported ASH projection add-on ABI"); + create = bind(lookup, "jvector_ash_int8_create", + FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS)); + destroy = bind(lookup, "jvector_ash_int8_destroy", FunctionDescriptor.ofVoid(ADDRESS)); + batch = bind(lookup, "jvector_ash_int8_project_batch", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS, ADDRESS)); + bytes = bind(lookup, "jvector_ash_int8_bytes", FunctionDescriptor.of(JAVA_LONG, ADDRESS)); + } + + private static MethodHandle bind(SymbolLookup lookup, String name, FunctionDescriptor descriptor) { + MemorySegment symbol = lookup.find(name) + .orElseThrow(() -> new UnsatisfiedLinkError("Missing ASH INT8 symbol: " + name)); + return Linker.nativeLinker().downcallHandle(symbol, descriptor); + } + + private static Bindings load() { + if (!LibraryLoader.loadAshFp16()) return null; + try { + return new Bindings(SymbolLookup.loaderLookup()); + } catch (UnsatisfiedLinkError | IllegalCallerException | SecurityException + | UnsupportedOperationException unavailable) { + return null; + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 binding failure", failure); + } + } + } + + private final MemorySegment context; + private final int rows, columns; + + private NativeASHInt8Projection(MemorySegment context, int rows, int columns) { + this.context = context; + this.rows = rows; + this.columns = columns; + } + + static VectorUtilSupport.AshProjection prepare(MemorySegment matrix, int rows, int columns) { + Bindings bindings = Bindings.INSTANCE; + if (bindings == null) + throw new UnsupportedOperationException("ASH INT8 query projection add-on is unavailable; " + + "build with -Dnative.onednn=true or select fp32/auto"); + try (Arena setup = Arena.ofConfined()) { + MemorySegment status = setup.allocate(RESULT_LAYOUT); + MemorySegment pointer = (MemorySegment) bindings.create.invokeExact(matrix, rows, columns, status); + int code = status.get(JAVA_INT, 0); + if (pointer.address() == 0) throw failure(code, status); + if (code != OK) { + bindings.destroy.invokeExact(pointer); + throw failure(code, status); + } + MemorySegment owned; + try { + owned = pointer.reinterpret(1, Arena.ofAuto(), NativeASHInt8Projection::destroy); + } catch (Throwable failure) { + bindings.destroy.invokeExact(pointer); + throw failure; + } + return new NativeASHInt8Projection(owned, rows, columns); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 initialization failure", failure); + } + } + + private static void destroy(MemorySegment pointer) { + try { + Bindings.INSTANCE.destroy.invokeExact(pointer); + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 cleanup failure", failure); + } + } + + private static RuntimeException failure(int code, MemorySegment status) { + String message = "ASH INT8 projection: " + status.getString(JAVA_INT.byteSize()); + if (code == NO_MEMORY) throw new OutOfMemoryError(message); + if (code == UNSUPPORTED) return new UnsupportedOperationException(message); + if (code == INVALID_ARGUMENT || code == RANGE) return new IllegalArgumentException(message); + return new IllegalStateException(message + " (status " + code + ")"); + } + + /** Retained native model storage, including lazily packed batch weights. */ + @Override + public long ramBytesUsed() { + try { + return (long) Bindings.INSTANCE.bytes.invokeExact(context); + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 accounting failure", failure); + } + } + + @Override + public String operandPrecision() { return "int8"; } + + @Override + public boolean supportsBatchProjection() { return true; } + + @Override + public void project(float[] query, float[] output) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(output, "output"); + if (query.length != columns || output.length != rows || query == output) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + long inputBytes = (long) columns * Float.BYTES; + long outputBytes = (long) rows * Float.BYTES; + var buffers = NativeASHProjection.buffers(inputBytes, outputBytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, buffers.input, 0, inputBytes); + execute(buffers, 1); + MemorySegment.copy(buffers.output, 0, MemorySegment.ofArray(output), 0, outputBytes); + } + + private void execute(NativeASHProjection.Buffers buffers, int count) { + if (buffers.status.byteSize() == 0) buffers.status = Arena.ofAuto().allocate(RESULT_LAYOUT); + try { + int status = (int) Bindings.INSTANCE.batch.invokeExact( + context, buffers.input, count, buffers.output, buffers.status); + if (status != OK) throw failure(status, buffers.status); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected ASH INT8 execution failure", failure); + } + } + + @Override + public void projectBatch(float[][] queries, float[][] outputs, int count) { + VectorUtilSupport.AshProjection.validateBatch(queries, outputs, count); + if (count == 0) return; + if (queries[0].length != columns || outputs[0].length != rows) + throw new IllegalArgumentException("ASH INT8 projection batch shape mismatch"); + long inputRowBytes = (long) columns * Float.BYTES; + long outputRowBytes = (long) rows * Float.BYTES; + var buffers = NativeASHProjection.buffers( + Math.multiplyExact(count, inputRowBytes), Math.multiplyExact(count, outputRowBytes)); + for (int i = 0; i < count; i++) + MemorySegment.copy(MemorySegment.ofArray(queries[i]), 0, buffers.input, + i * inputRowBytes, inputRowBytes); + execute(buffers, count); + for (int i = 0; i < count; i++) + MemorySegment.copy(buffers.output, i * outputRowBytes, + MemorySegment.ofArray(outputs[i]), 0, outputRowBytes); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java new file mode 100644 index 000000000..6e511516f --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHKernels.java @@ -0,0 +1,326 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static java.lang.foreign.ValueLayout.JAVA_INT; + +/** + * Checked, synchronous FFM access to Highway neighborhood and canonical ASH kernels. + * + *

Keep this hand-written binding outside {@code cnative}, which is generated by jextract. + * Ordinary graph neighborhoods use a bounded critical downcall and access heap arrays directly. + * Larger inputs use one ordinary downcall with temporary native storage, allowing the JVM to + * reach safepoints during longer native work. Neither path retains pointers or calls Java.

+ * + *

The ASH symbols are optional for compatibility with libraries predating this kernel. + * Capability detection leaves the provider's Java LUT path available when it is absent.

+ */ +final class NativeASHKernels { + private static final boolean TRACE_DOWNCALLS = Boolean.getBoolean("jextract.trace.downcalls"); + private static final String SYMBOL = "ash_score_neighborhood_c1_f32"; + private static final int HEADER_BYTES_PER_LANE = 5; + private static final int LUT_ENTRIES_PER_GROUP = 16; + + // Bound critical-call work independently of CPU model, clock speed, and graph degree. + // Charge masked/padded vector lanes as well as active lanes. This is a conservative + // work budget, not a measured timing guarantee or an optimized copying crossover. + static final int MAX_CRITICAL_LANE_GROUPS = 1 << 16; + + private static final MemorySegment ADDRESS_OF_KERNEL = SymbolLookup.loaderLookup().find(SYMBOL).orElse(null); + private static final FunctionDescriptor DESCRIPTOR = + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_FLOAT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final MethodHandle CRITICAL_SCORE = ADDRESS_OF_KERNEL == null + ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR, Linker.Option.critical(true)); + private static final MethodHandle ORDINARY_SCORE = + ADDRESS_OF_KERNEL == null ? null : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); + + private static final MemorySegment ADDRESS_OF_CANONICAL = + SymbolLookup.loaderLookup().find("ash_canonical_lut_dot_f32").orElse(null); + private static final FunctionDescriptor CANONICAL_DESCRIPTOR = + FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT); + private static final MethodHandle CRITICAL_CANONICAL = ADDRESS_OF_CANONICAL == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_CANONICAL, CANONICAL_DESCRIPTOR, + Linker.Option.critical(true)); + private static final MethodHandle ORDINARY_CANONICAL = ADDRESS_OF_CANONICAL == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_CANONICAL, CANONICAL_DESCRIPTOR); + + private static final MemorySegment ADDRESS_OF_FOUR_BIT = + SymbolLookup.loaderLookup().find("ash_projection_dot_4bit_f32").orElse(null); + private static final MethodHandle CRITICAL_FOUR_BIT = ADDRESS_OF_FOUR_BIT == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_FOUR_BIT, CANONICAL_DESCRIPTOR, + Linker.Option.critical(true)); + private static final MethodHandle ORDINARY_FOUR_BIT = ADDRESS_OF_FOUR_BIT == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_FOUR_BIT, CANONICAL_DESCRIPTOR); + + static boolean isFourBitDotAvailable() { return ADDRESS_OF_FOUR_BIT != null; } + + static float dotFourBit(float[] query, byte[] code, int dimensions) { + return dotFourBit(query, code, 0, dimensions); + } + + /** Projection dot with the 4-bit code starting at {@code codeOffset}. */ + static float dotFourBit(float[] query, byte[] code, int codeOffset, int dimensions) { + if (!isFourBitDotAvailable()) { + throw new UnsupportedOperationException("Native 4-bit ASH projection scoring is unavailable"); + } + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(code, "code"); + Objects.checkFromIndexSize(0, dimensions, query.length); + long bytes = (dimensions + 1L) / 2; + if (codeOffset < 0 || codeOffset + bytes > code.length) + throw new IndexOutOfBoundsException("Canonical ASH code is too small"); + if (dimensions == 0) return 0f; + if (dimensions <= MAX_CRITICAL_LANE_GROUPS) { + return dotFourBitCritical(query, code, codeOffset, dimensions); + } + try (Arena arena = Arena.ofConfined()) { + long queryBytes = (long) dimensions * Float.BYTES; + MemorySegment nativeQuery = arena.allocate(queryBytes, Float.BYTES); + MemorySegment nativeCode = arena.allocate(bytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, nativeQuery, 0, queryBytes); + MemorySegment.copy(MemorySegment.ofArray(code), codeOffset, nativeCode, 0, bytes); + return invokeDot(ORDINARY_FOUR_BIT, nativeQuery, nativeCode, dimensions); + } + } + + private NativeASHKernels() {} + + /** + * Detect the optional ASH entry after the provider has loaded its supporting library. + * Older libraries can still provide their existing operations and the Java LUT fallback. + */ + static boolean isAvailable() { + return ADDRESS_OF_KERNEL != null; + } + + static boolean isCanonicalLutAvailable() { return ADDRESS_OF_CANONICAL != null; } + + static float dotCanonical(byte[] code, int groups, float[] lut) { + return dotCanonical(code, 0, groups, lut); + } + + /** + * One short heap-access call per direct node, for the code starting at {@code codeOffset}; + * no decoding or temporary output array. + */ + static float dotCanonical(byte[] code, int codeOffset, int groups, float[] lut) { + if (!isCanonicalLutAvailable()) { + throw new UnsupportedOperationException("Native canonical ASH LUT scoring is unavailable"); + } + ASHLutScoring.checkCanonicalBounds(code, codeOffset, groups, lut); + if (groups <= MAX_CRITICAL_LANE_GROUPS) { + return dotCanonicalCritical(code, codeOffset, groups, lut); + } + // Bound time in a critical call by the same group/lane work budget as + // neighborhood scoring. Unusually large legal codes may reach safepoints. + try (Arena arena = Arena.ofConfined()) { + long bytes = (groups + 1L) / 2; + long lutBytes = (long) groups * LUT_ENTRIES_PER_GROUP * Float.BYTES; + MemorySegment nativeCode = arena.allocate(bytes); + MemorySegment nativeLut = arena.allocate(lutBytes, Float.BYTES); + MemorySegment.copy(MemorySegment.ofArray(code), codeOffset, nativeCode, 0, bytes); + MemorySegment.copy(MemorySegment.ofArray(lut), 0, nativeLut, 0, lutBytes); + return invokeDot(ORDINARY_CANONICAL, nativeCode, nativeLut, groups); + } + } + + // Hot paths: each heap segment is created next to its invokeExact so, once the + // static final handle inlines, escape analysis can scalar-replace it. + private static float dotFourBitCritical(float[] query, byte[] code, int codeOffset, int dimensions) { + return invokeDot(CRITICAL_FOUR_BIT, MemorySegment.ofArray(query), + MemorySegment.ofArray(code).asSlice(codeOffset), dimensions); + } + + private static float dotCanonicalCritical(byte[] code, int codeOffset, int groups, float[] lut) { + return invokeDot(CRITICAL_CANONICAL, MemorySegment.ofArray(code).asSlice(codeOffset), + MemorySegment.ofArray(lut), groups); + } + + private static float invokeDot(MethodHandle handle, MemorySegment first, + MemorySegment second, int count) { + try { + return (float) handle.invokeExact(first, second, count); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Throwable failure) { + throw new AssertionError("Unexpected canonical ASH dot invocation failure", failure); + } + } + + /** Validate all Java-side size and aliasing contracts before passing pointers to C++. */ + static void scoreNeighborhood( + byte[] codes, int groups, int blockSize, int capacity, float[] lut, float landmarkDot, float[] out) { + if (!isAvailable()) { + throw new UnsupportedOperationException("Native library does not provide " + SYMBOL); + } + Objects.requireNonNull(codes, "codes"); + Objects.requireNonNull(lut, "lut"); + Objects.requireNonNull(out, "out"); + if (groups <= 0 || capacity <= 0 || (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + throw new IllegalArgumentException("Expected positive groups/capacity and blockSize in {8,16,32}"); + } + if (lut == out) { + throw new IllegalArgumentException("ASH output must not alias the read-only query LUT"); + } + long blockCount = (capacity - 1L) / blockSize + 1; + long packedBytes = blockCount * (((groups + 1L) / 2 + HEADER_BYTES_PER_LANE) * blockSize); + long lutFloats = groups * (long) LUT_ENTRIES_PER_GROUP; + if (packedBytes > codes.length || lutFloats > lut.length || capacity > out.length) { + throw new IndexOutOfBoundsException("ASH arrays are smaller than the configured neighborhood"); + } + + int status; + long vectorSlots = blockCount * Math.max(blockSize, 16); // Native kernels cap FP32 width at 16. + if ((long) groups * vectorSlots <= MAX_CRITICAL_LANE_GROUPS) { + // These heap segments are valid only for this synchronous, short leaf call. + status = invoke(CRITICAL_SCORE, MemorySegment.ofArray(codes), MemorySegment.ofArray(lut), landmarkDot, + groups, blockSize, capacity, MemorySegment.ofArray(out)); + } else { + status = scoreLargeNeighborhood( + codes, packedBytes, lut, lutFloats, landmarkDot, groups, blockSize, capacity, out); + } + checkStatus(status); + } + + private static int scoreLargeNeighborhood(byte[] codes, long packedBytes, float[] lut, long lutFloats, + float landmarkDot, int groups, int blockSize, int capacity, float[] out) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeCodes = arena.allocate(packedBytes); + MemorySegment nativeLut = arena.allocate(lutFloats * Float.BYTES, Float.BYTES); + MemorySegment nativeOut = arena.allocate((long) capacity * Float.BYTES, Float.BYTES); + MemorySegment.copy(MemorySegment.ofArray(codes), 0, nativeCodes, 0, packedBytes); + MemorySegment.copy(MemorySegment.ofArray(lut), 0, nativeLut, 0, lutFloats * Float.BYTES); + int status = + invoke(ORDINARY_SCORE, nativeCodes, nativeLut, landmarkDot, groups, blockSize, capacity, nativeOut); + if (status == 0) { + MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) capacity * Float.BYTES); + } + return status; + } + } + + private static int invoke(MethodHandle handle, MemorySegment codes, MemorySegment lut, float landmarkDot, + int groups, int blockSize, int capacity, MemorySegment out) { + if (TRACE_DOWNCALLS) { + System.out.printf("%s(%d, %d, %d)%n", SYMBOL, groups, blockSize, capacity); + } + try { + return (int) handle.invokeExact(codes, lut, landmarkDot, groups, blockSize, capacity, out); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from " + SYMBOL, error); + } + } + + private static final String INT8_SYMBOL = "ash_score_neighborhood_i8_c1"; + private static final MemorySegment ADDRESS_OF_INT8 = SymbolLookup.loaderLookup().find(INT8_SYMBOL).orElse(null); + private static final FunctionDescriptor INT8_DESCRIPTOR = FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, + JAVA_FLOAT, JAVA_FLOAT, JAVA_FLOAT, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final MethodHandle CRITICAL_INT8 = ADDRESS_OF_INT8 == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_INT8, INT8_DESCRIPTOR, Linker.Option.critical(true)); + private static final MethodHandle ORDINARY_INT8 = ADDRESS_OF_INT8 == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_INT8, INT8_DESCRIPTOR); + + /** Whether the library scores offset-binary neighborhoods with an INT8 query. */ + static boolean isInt8NeighborhoodAvailable() { return ADDRESS_OF_INT8 != null; } + + /** + * Scores an offset-binary neighborhood ({@code [unit][lane][bits bytes]} body per block, then the + * headers) with an INT8 query of {@code units * 8} entries. + */ + static void scoreNeighborhoodInt8(byte[] codes, int bits, int units, int blockSize, int capacity, + byte[] q, float invScale, float center, float landmarkDot, float[] out) { + if (!isInt8NeighborhoodAvailable()) { + throw new UnsupportedOperationException("Native library does not provide " + INT8_SYMBOL); + } + Objects.requireNonNull(codes, "codes"); + Objects.requireNonNull(q, "q"); + Objects.requireNonNull(out, "out"); + if (units <= 0 || capacity <= 0 || bits < 3 || bits > 9 || bits == 4 + || (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + throw new IllegalArgumentException("Expected offset-binary bits, positive units/capacity and blockSize in {8,16,32}"); + } + long blockCount = (capacity - 1L) / blockSize + 1; + long packedBytes = blockCount * (((long) units * bits + HEADER_BYTES_PER_LANE) * blockSize); + long queryBytes = (long) units * 8; + if (packedBytes > codes.length || queryBytes > q.length || capacity > out.length) { + throw new IndexOutOfBoundsException("ASH arrays are smaller than the configured neighborhood"); + } + int status; + if ((long) units * blockCount * blockSize <= MAX_CRITICAL_LANE_GROUPS) { + // These heap segments are valid only for this synchronous, short leaf call. + status = invokeInt8(CRITICAL_INT8, MemorySegment.ofArray(codes), MemorySegment.ofArray(q), + invScale, center, landmarkDot, bits, units, blockSize, capacity, MemorySegment.ofArray(out)); + } else { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeCodes = arena.allocate(packedBytes); + MemorySegment nativeQuery = arena.allocate(queryBytes); + MemorySegment nativeOut = arena.allocate((long) capacity * Float.BYTES, Float.BYTES); + MemorySegment.copy(MemorySegment.ofArray(codes), 0, nativeCodes, 0, packedBytes); + MemorySegment.copy(MemorySegment.ofArray(q), 0, nativeQuery, 0, queryBytes); + status = invokeInt8(ORDINARY_INT8, nativeCodes, nativeQuery, invScale, center, landmarkDot, + bits, units, blockSize, capacity, nativeOut); + if (status == 0) { + MemorySegment.copy(nativeOut, 0, MemorySegment.ofArray(out), 0, (long) capacity * Float.BYTES); + } + } + } + checkStatus(status); + } + + private static int invokeInt8(MethodHandle handle, MemorySegment codes, MemorySegment q, + float invScale, float center, float landmarkDot, int bits, int units, int blockSize, int capacity, + MemorySegment out) { + if (TRACE_DOWNCALLS) { + System.out.printf("%s(%d, %d, %d, %d)%n", INT8_SYMBOL, bits, units, blockSize, capacity); + } + try { + return (int) handle.invokeExact(codes, q, invScale, center, landmarkDot, bits, units, blockSize, + capacity, out); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from " + INT8_SYMBOL, error); + } + } + + private static void checkStatus(int status) { + // These values are the C ABI contract documented in jvector_simd_kernel_list.h. + switch (status) { + case 0: + return; + case 1: + throw new IllegalStateException("Invalid landmark in a C=1 FusedASH neighborhood"); + case 2: + throw new IllegalArgumentException("Invalid native ASH neighborhood shape"); + default: + throw new IllegalStateException("Unknown native ASH status: " + status); + } + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java new file mode 100644 index 000000000..18bcde3aa --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHProjection.java @@ -0,0 +1,232 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; + +/** + * Immutable native FP32 matrix for single-vector and bulk ASH projection. + * The automatically managed arena lives with the model; it is not thread confined. + * Native query/output staging is reused per calling thread, so projections may run concurrently. + * Large projections use ordinary FFM and copy only the small query and result, allowing + * safepoints during the dense multiply. Matrix storage is never copied per query. + */ +final class NativeASHProjection implements VectorUtilSupport.AshProjection { + private static final String SYMBOL = "ash_project_f32"; + private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); + // Conservative work bound for short, synchronous critical calls; not a timing claim. + private static final int MAX_CRITICAL_CELLS = 1 << 16; + private static final MemorySegment ADDRESS_OF_KERNEL = SymbolLookup.loaderLookup().find(SYMBOL).orElse(null); + private static final FunctionDescriptor DESCRIPTOR = + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS); + private static final MethodHandle CRITICAL = ADDRESS_OF_KERNEL == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR, Linker.Option.critical(true)); + private static final MethodHandle ORDINARY = ADDRESS_OF_KERNEL == null ? null + : Linker.nativeLinker().downcallHandle(ADDRESS_OF_KERNEL, DESCRIPTOR); + // Packing width is an ABI layout constant, not a limit on the batch size. + private static final int PACKED_ROWS = 32; + private static final MethodHandle BATCH = SymbolLookup.loaderLookup().find("ash_project_batch_f32") + .map(symbol -> Linker.nativeLinker().downcallHandle(symbol, + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS))) + .orElse(null); + + // Calling-thread storage is shared across models and FP32/FP16/INT8 views. It + // holds no model reference, grows only when necessary and dies with the thread. + private static final ThreadLocal BUFFERS = ThreadLocal.withInitial(Buffers::new); + + static final class Buffers { + MemorySegment input = MemorySegment.NULL, output = MemorySegment.NULL; + // Allocated only by the FP16/INT8 views; carries errors without native thread affinity. + MemorySegment status = MemorySegment.NULL; + + void ensure(long inputBytes, long outputBytes) { + if (input.byteSize() >= inputBytes && output.byteSize() >= outputBytes) return; + long inCapacity = Math.addExact(Math.max(inputBytes, input.byteSize()), 63) & ~63L; + long outCapacity = Math.addExact(Math.max(outputBytes, output.byteSize()), 63) & ~63L; + MemorySegment storage = Arena.ofAuto().allocate(Math.addExact(inCapacity, outCapacity), 64); + input = storage.asSlice(0, inCapacity); + output = storage.asSlice(inCapacity, outCapacity); + } + } + + static Buffers buffers(long inputBytes, long outputBytes) { + Buffers buffers = BUFFERS.get(); + buffers.ensure(inputBytes, outputBytes); + return buffers; + } + + private final MemorySegment matrix; + // Allocated only for bulk encoding, shared by all encoding workers. Queries + // continue to use the original matrix and do not pay for this extra backing. + private volatile MemorySegment batchMatrix; + private final int rows; + private final int columns; + + static boolean isAvailable() { return ADDRESS_OF_KERNEL != null; } + + NativeASHProjection(float[][] source) { + Objects.requireNonNull(source, "matrix"); + if (!isAvailable()) throw new UnsupportedOperationException("Native library does not provide " + SYMBOL); + rows = source.length; + if (rows == 0 || Objects.requireNonNull(source[0], "matrix row").length == 0) + throw new IllegalArgumentException("ASH projection dimensions must be positive"); + columns = source[0].length; + for (float[] row : source) { + if (Objects.requireNonNull(row, "matrix row").length != columns) throw new IllegalArgumentException("Ragged ASH projection"); + } + long rowBytes = (long) columns * Float.BYTES; + matrix = Arena.ofAuto().allocate(Math.multiplyExact((long) rows, rowBytes), 64); + for (int row = 0; row < rows; row++) + MemorySegment.copy(MemorySegment.ofArray(source[row]), 0, matrix, row * rowBytes, rowBytes); + } + + @Override + public long ramBytesUsed() { + MemorySegment packed = batchMatrix; + return matrix.byteSize() + (packed == null ? 0 : packed.byteSize()); + } + + int rows() { return rows; } + int columns() { return columns; } + MemorySegment matrix() { return matrix; } + + private MemorySegment batchMatrix() { + MemorySegment ready = batchMatrix; + if (ready == null) { + synchronized (this) { + ready = batchMatrix; + if (ready == null) { + ready = Arena.ofAuto().allocate(matrix.byteSize(), 64); + float[] tile = new float[Math.multiplyExact(columns, Math.min(rows, PACKED_ROWS))]; + for (long firstRow = 0; firstRow < rows; firstRow += PACKED_ROWS) { + int width = (int) Math.min(PACKED_ROWS, rows - firstRow); + for (int col = 0; col < columns; col++) { + for (int lane = 0; lane < width; lane++) { + long row = firstRow + lane; + tile[col * width + lane] = matrix.getAtIndex(JAVA_FLOAT, row * columns + col); + } + } + MemorySegment.copy(MemorySegment.ofArray(tile), 0, ready, + firstRow * columns * Float.BYTES, (long) width * columns * Float.BYTES); + } + batchMatrix = ready; + } + } + } + return ready; + } + + @Override + public void projectBatch(float[][] inputs, float[][] outputs, int count) { + VectorUtilSupport.AshProjection.validateBatch(inputs, outputs, count); + if (count == 0) return; + if (inputs[0].length != columns || outputs[0].length != rows) + throw new IllegalArgumentException("ASH projection batch shape mismatch"); + if (BATCH == null) { + // Compatibility with an older native library; Panama providers use + // the same default batch contract with their own FP32 projection. + VectorUtilSupport.AshProjection.super.projectBatch(inputs, outputs, count); + return; + } + long inputRowBytes = (long) columns * Float.BYTES; + long outputRowBytes = (long) rows * Float.BYTES; + Buffers buffers = buffers(Math.multiplyExact(count, inputRowBytes), + Math.multiplyExact(count, outputRowBytes)); + for (int i = 0; i < count; i++) + MemorySegment.copy(MemorySegment.ofArray(inputs[i]), 0, buffers.input, + i * inputRowBytes, inputRowBytes); + // Bulk work uses ordinary FFM so the JVM can reach safepoints. The + // matrix is packed once; input/output staging grows only when needed. + try { + BATCH.invokeExact(batchMatrix(), buffers.input, rows, columns, count, buffers.output); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from ash_project_batch_f32", error); + } + for (int i = 0; i < count; i++) + MemorySegment.copy(buffers.output, i * outputRowBytes, + MemorySegment.ofArray(outputs[i]), 0, outputRowBytes); + } + + @Override + public void project(float[] query, float[] out) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(out, "out"); + if (query.length != columns || out.length != rows || query == out) + throw new IllegalArgumentException("ASH projection shape or aliasing mismatch"); + if ((long) rows * columns <= MAX_CRITICAL_CELLS) { + invoke(CRITICAL, MemorySegment.ofArray(query), MemorySegment.ofArray(out)); + } else { + long queryBytes = (long) columns * Float.BYTES; + long outputBytes = (long) rows * Float.BYTES; + Buffers buffers = buffers(queryBytes, outputBytes); + MemorySegment.copy(MemorySegment.ofArray(query), 0, buffers.input, 0, queryBytes); + invoke(ORDINARY, buffers.input, buffers.output); + MemorySegment.copy(buffers.output, 0, MemorySegment.ofArray(out), 0, outputBytes); + } + } + + @Override + public boolean supportsBatchProjection() { return BATCH != null; } + + /** Query-only selection; the original FP32 matrix and encoding path are retained. */ + @Override + public VectorUtilSupport.AshProjection forQueryScoring() { + String mode = System.getProperty("jvector.ash.queryProjection", "auto"); + switch (mode) { + case "fp32": return this; + case "int8": return NativeASHInt8Projection.prepare(matrix, rows, columns); + case "auto": + // INT8 where an integer dot-product backend exists, else FP16, else this FP32 projection. + try { + return NativeASHInt8Projection.prepare(matrix, rows, columns); + } catch (UnsupportedOperationException unavailable) { + return fp16WithFallback(); + } + case "fp16": return NativeASHFP16Projection.prepare(matrix, rows, columns, this, true); + default: throw new IllegalArgumentException( + "jvector.ash.queryProjection must be auto, fp32, fp16 or int8: " + mode); + } + } + + /** Optimized FP16 where available and in range, else this FP32 projection: automatic mode's second choice. */ + VectorUtilSupport.AshProjection fp16WithFallback() { + return NativeASHFP16Projection.prepare(matrix, rows, columns, this, false); + } + + private void invoke(MethodHandle handle, MemorySegment query, MemorySegment out) { + if (TRACE) System.out.printf("%s(%d, %d)%n", SYMBOL, rows, columns); + try { + handle.invokeExact(matrix, query, rows, columns, out); + } catch (RuntimeException | Error error) { + throw error; + } catch (Throwable error) { + throw new AssertionError("Unexpected checked exception from " + SYMBOL, error); + } + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java new file mode 100644 index 000000000..8e2446123 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeASHSymmetricKernels.java @@ -0,0 +1,318 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandle; +import java.util.Objects; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static java.lang.foreign.ValueLayout.JAVA_INT; + +/** + * Checked optional Highway bindings for exact symmetric ASH integer arithmetic. + * Graph pair comparisons use one bounded critical call. Complete interleaved + * ranges use one LUT call; larger legal inputs use ordinary FFM with temporary + * native buffers. Neither kernel allocates, retains pointers, blocks or calls Java. + * Missing symbols preserve the provider's existing Java implementations. + *

+ * JIT notes. The pair kernel runs billions of times per graph build, so: + *

    + *
  • Downcall handles are {@code static final} fields. HotSpot trusts only static + * finals (not instance finals) as constants, so {@code invokeExact} links straight to + * the downcall stub and inlines it. A non-constant handle goes through the generic + * invoker ({@code Invokers.checkCustomized}) and the heap segments escape into it.
  • + *
  • Each heap {@link MemorySegment} is created in the same small method that passes + * it to {@code invokeExact}, so once the stub inlines, escape analysis can + * scalar-replace the segment and its session instead of allocating them per call.
  • + *
  • Tracing, exception construction and the large-input {@link Arena} path live in + * separate cold methods, keeping hot-method bytecode small enough to inline.
  • + *
+ */ +final class NativeASHSymmetricKernels { + private static final int MAX_CRITICAL_WORK = 1 << 16; + private static final boolean TRACE = Boolean.getBoolean("jextract.trace.downcalls"); + private static final String PAIR_SYMBOL = "ash_symmetric_dot"; + private static final String LUT_SYMBOL = "ash_symmetric_lut_i16"; + private static final String ROWS_SYMBOL = "ash_symmetric_dot_rows"; + private static final FunctionDescriptor PAIR_DESCRIPTOR = + FunctionDescriptor.of(JAVA_FLOAT, ADDRESS, ADDRESS, JAVA_INT, JAVA_INT); + private static final FunctionDescriptor LUT_DESCRIPTOR = + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final FunctionDescriptor ROWS_DESCRIPTOR = + FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS); + private static final MemorySegment PAIR_ADDRESS = find(PAIR_SYMBOL); + private static final MemorySegment LUT_ADDRESS = find(LUT_SYMBOL); + // Must stay static final: see the class comment. + private static final MethodHandle PAIR_CRITICAL = downcall(PAIR_ADDRESS, PAIR_DESCRIPTOR, true); + private static final MethodHandle PAIR_ORDINARY = downcall(PAIR_ADDRESS, PAIR_DESCRIPTOR, false); + private static final MethodHandle LUT_CRITICAL = downcall(LUT_ADDRESS, LUT_DESCRIPTOR, true); + private static final MethodHandle LUT_ORDINARY = downcall(LUT_ADDRESS, LUT_DESCRIPTOR, false); + private static final MethodHandle ROWS_CRITICAL = downcall(find(ROWS_SYMBOL), ROWS_DESCRIPTOR, true); + + private NativeASHSymmetricKernels() {} + + static boolean hasPairKernel() { return PAIR_CRITICAL != null; } + static boolean hasLutKernel() { return LUT_CRITICAL != null; } + static boolean hasRowsKernel() { return ROWS_CRITICAL != null; } + + static float dot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, int dimensions, int bits) { + if (dimensions < 0 || (bits != 1 && bits != 2 && bits != 4)) throw badPairShape(); + if (PAIR_CRITICAL == null) throw pairUnavailable(); + if (bits == 1) { + long words = (dimensions + 63L) / 64; + if (words > aBits.length || words > bBits.length) throw outOfBounds("ASH bit arrays"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, bits); + return dimensions <= MAX_CRITICAL_WORK + ? pairCritical(aBits, bBits, dimensions, bits) + : pairOrdinary(MemorySegment.ofArray(aBits), MemorySegment.ofArray(bBits), + words * Long.BYTES, dimensions, bits); + } + long bytes = ((long) dimensions * bits + 7) / 8; + if (bytes > aCodes.length || bytes > bCodes.length) throw outOfBounds("ASH code arrays"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, bits); + return dimensions <= MAX_CRITICAL_WORK + ? pairCritical(aCodes, bCodes, dimensions, bits) + : pairOrdinary(MemorySegment.ofArray(aCodes), MemorySegment.ofArray(bCodes), bytes, dimensions, bits); + } + + /** 2/4-bit codes at offsets in larger arrays (flat code pages). Same arithmetic as {@link #dot}. */ + static float dotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, int dimensions, int bits) { + if (dimensions < 0 || (bits != 2 && bits != 4)) throw badOffsetShape(); + if (PAIR_CRITICAL == null) throw pairUnavailable(); + long bytes = ((long) dimensions * bits + 7) / 8; + if (aOffset < 0 || bOffset < 0 || aOffset + bytes > aCodes.length || bOffset + bytes > bCodes.length) + throw outOfBounds("ASH code array ranges"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, bits); + return dimensions <= MAX_CRITICAL_WORK + ? pairCriticalAt(aCodes, aOffset, bCodes, bOffset, dimensions, bits) + : pairOrdinary(MemorySegment.ofArray(aCodes).asSlice(aOffset, bytes), + MemorySegment.ofArray(bCodes).asSlice(bOffset, bytes), bytes, dimensions, bits); + } + + /** 1-bit sign words at offsets in larger arrays (code pages). Same arithmetic as {@link #dot}. */ + static float binaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + if (dimensions < 0) throw badPairShape(); + if (PAIR_CRITICAL == null) throw pairUnavailable(); + long words = (dimensions + 63L) / 64; + if (aOffset < 0 || bOffset < 0 || aOffset + words > aBits.length || bOffset + words > bBits.length) + throw outOfBounds("ASH bit array ranges"); + if (TRACE) trace(PAIR_SYMBOL, dimensions, 1); + return dimensions <= MAX_CRITICAL_WORK + ? pairCriticalAt(aBits, aOffset, bBits, bOffset, dimensions) + : pairOrdinary(MemorySegment.ofArray(aBits).asSlice((long) aOffset * Long.BYTES, words * Long.BYTES), + MemorySegment.ofArray(bBits).asSlice((long) bOffset * Long.BYTES, words * Long.BYTES), + words * Long.BYTES, dimensions, 1); + } + + /** + * 2/4-bit query code against {@code count} codes {@code stride} bytes apart: {@code out[i]} equals + * {@link #dotAt} of the query and row {@code i}. Each critical call scores at most + * {@code MAX_CRITICAL_WORK} dimensions in total. + */ + static void dotRows(byte[] query, int queryOffset, byte[] rows, int stride, int count, + int dimensions, int bits, float[] out) { + if (dimensions < 0 || count < 0 || (bits != 2 && bits != 4)) throw badOffsetShape(); + if (ROWS_CRITICAL == null) throw rowsUnavailable(); + long bytes = ((long) dimensions * bits + 7) / 8; + if (stride < bytes || queryOffset < 0 || queryOffset + bytes > query.length || count > out.length + || (count > 0 && (long) (count - 1) * stride + bytes > rows.length)) + throw outOfBounds("ASH code row ranges"); + if (count == 0) return; + if (TRACE) trace(ROWS_SYMBOL, dimensions, count); + if (dimensions > MAX_CRITICAL_WORK) { + for (int i = 0; i < count; i++) out[i] = dotAt(query, queryOffset, rows, i * stride, dimensions, bits); + return; + } + int perCall = Math.max(1, MAX_CRITICAL_WORK / Math.max(1, dimensions)); + for (int first = 0; first < count; first += perCall) { + rowsCritical(query, queryOffset, rows, first * stride, stride, Math.min(perCall, count - first), + dimensions, bits, out, first); + } + } + + // Hot path: as in pairCritical, the sliced segments stay local to invokeExact. + private static void rowsCritical(byte[] query, int queryOffset, byte[] rows, int rowsOffset, int stride, + int count, int dimensions, int bits, float[] out, int outOffset) { + try { + ROWS_CRITICAL.invokeExact(MemorySegment.ofArray(query).asSlice(queryOffset), + MemorySegment.ofArray(rows).asSlice(rowsOffset), stride, count, dimensions, bits, + MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES)); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(ROWS_SYMBOL, t); + } + } + + private static float pairCriticalAt(long[] a, int aOffset, long[] b, int bOffset, int dimensions) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a).asSlice((long) aOffset * Long.BYTES), + MemorySegment.ofArray(b).asSlice((long) bOffset * Long.BYTES), dimensions, 1); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + + // Hot path for dotAt: as in pairCritical, the sliced segments stay local to invokeExact. + private static float pairCriticalAt(byte[] a, int aOffset, byte[] b, int bOffset, int dimensions, int bits) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a).asSlice(aOffset), + MemorySegment.ofArray(b).asSlice(bOffset), dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + + // Hot path: segments are created and consumed here so they don't escape after inlining. + private static float pairCritical(byte[] a, byte[] b, int dimensions, int bits) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a), MemorySegment.ofArray(b), dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + + private static float pairCritical(long[] a, long[] b, int dimensions, int bits) { + try { + return (float) PAIR_CRITICAL.invokeExact(MemorySegment.ofArray(a), MemorySegment.ofArray(b), dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + + // Cold path: inputs too large for a bounded critical call are copied to native memory. + private static float pairOrdinary(MemorySegment a, MemorySegment b, long bytes, int dimensions, int bits) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment na = arena.allocate(bytes, Long.BYTES), nb = arena.allocate(bytes, Long.BYTES); + MemorySegment.copy(a, 0, na, 0, bytes); MemorySegment.copy(b, 0, nb, 0, bytes); + return (float) PAIR_ORDINARY.invokeExact(na, nb, dimensions, bits); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(PAIR_SYMBOL, t); + } + } + + static void scoreLut(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + Objects.requireNonNull(codes, "codes"); Objects.requireNonNull(lut, "lut"); Objects.requireNonNull(out, "out"); + if (LUT_CRITICAL == null) throw lutUnavailable(); + if (groups < 0 || stride <= 0) throw new IllegalArgumentException("ASH groups/stride"); + Objects.checkFromIndexSize(lane, count, stride); + Objects.checkFromIndexSize(outOffset, count, out.length); + long codeBytes = ((groups + 1L) / 2) * stride, lutBytes = (long) groups * 32 * Short.BYTES; + if (codeBytes > codes.length || lutBytes / Short.BYTES > lut.length) throw outOfBounds("ASH LUT/code arrays"); + if (count == 0) return; + if (TRACE) trace(LUT_SYMBOL, groups, count); + // Charge padded SIMD lanes as well as active lanes (native width is capped at 16). + long vectorSlots = ((count + 15L) / 16) * 16; + if ((long) groups * vectorSlots <= MAX_CRITICAL_WORK) { + lutCritical(codes, lut, out, outOffset, groups, stride, lane, count); + } else { + lutOrdinary(codes, lut, out, outOffset, groups, stride, lane, count, codeBytes, lutBytes); + } + } + + // Hot path: as with pairCritical, segments stay local to the invokeExact call. + private static void lutCritical(byte[] codes, short[] lut, float[] out, int outOffset, + int groups, int stride, int lane, int count) { + try { + LUT_CRITICAL.invokeExact(MemorySegment.ofArray(codes), MemorySegment.ofArray(lut), groups, stride, lane, count, + MemorySegment.ofArray(out).asSlice((long) outOffset * Float.BYTES)); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(LUT_SYMBOL, t); + } + } + + private static void lutOrdinary(byte[] codes, short[] lut, float[] out, int outOffset, + int groups, int stride, int lane, int count, long codeBytes, long lutBytes) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nc = arena.allocate(Math.max(1, codeBytes)); + MemorySegment nl = arena.allocate(Math.max(Short.BYTES, lutBytes), Short.BYTES); + MemorySegment no = arena.allocate((long) count * Float.BYTES, Float.BYTES); + MemorySegment.copy(MemorySegment.ofArray(codes), 0, nc, 0, codeBytes); + MemorySegment.copy(MemorySegment.ofArray(lut), 0, nl, 0, lutBytes); + LUT_ORDINARY.invokeExact(nc, nl, groups, stride, lane, count, no); + MemorySegment.copy(no, 0, MemorySegment.ofArray(out), (long) outOffset * Float.BYTES, (long) count * Float.BYTES); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw unexpected(LUT_SYMBOL, t); + } + } + + // ---- cold helpers (kept out of line so hot methods stay small) ---- + + private static MemorySegment find(String symbol) { + return SymbolLookup.loaderLookup().find(symbol).orElse(null); + } + + private static MethodHandle downcall(MemorySegment address, FunctionDescriptor descriptor, boolean critical) { + if (address == null) return null; + return critical + ? Linker.nativeLinker().downcallHandle(address, descriptor, Linker.Option.critical(true)) + : Linker.nativeLinker().downcallHandle(address, descriptor); + } + + private static void trace(String symbol, int x, int y) { + System.out.printf("%s(%d, %d)%n", symbol, x, y); + } + + private static IllegalArgumentException badPairShape() { + return new IllegalArgumentException("Symmetric ASH requires nonnegative dimensions and bits in {1,2,4}"); + } + + private static IllegalArgumentException badOffsetShape() { + return new IllegalArgumentException("Offset symmetric ASH requires nonnegative dimensions and bits in {2,4}"); + } + + private static UnsupportedOperationException pairUnavailable() { + return new UnsupportedOperationException("Native symmetric pair kernel unavailable"); + } + + private static UnsupportedOperationException rowsUnavailable() { + return new UnsupportedOperationException("Native symmetric rows kernel unavailable"); + } + + private static UnsupportedOperationException lutUnavailable() { + return new UnsupportedOperationException("Native symmetric LUT kernel unavailable"); + } + + private static IndexOutOfBoundsException outOfBounds(String what) { + return new IndexOutOfBoundsException(what); + } + + private static AssertionError unexpected(String symbol, Throwable t) { + return new AssertionError("Unexpected exception from " + symbol, t); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java new file mode 100644 index 000000000..7186695d3 --- /dev/null +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeBlasThreads.java @@ -0,0 +1,127 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Sets the BLAS thread count in the native library that netlib loads for JVector's BLAS and LAPACK + * calls: {@code dev.ludovic.netlib.blas.nativeLibPath}, else {@code dev.ludovic.netlib.blas.nativeLib}, + * else {@code libblas.so.3}. Opening it again returns the handle netlib uses. + */ +final class NativeBlasThreads { + private static final Logger LOG = Logger.getLogger(NativeBlasThreads.class.getName()); + private static final Control CONTROL = find(); + + private NativeBlasThreads() { + } + + /** Applies the count and returns the value to restore, or -1 when no supported library is loaded. */ + static int set(int threads) { + return CONTROL == null ? -1 : CONTROL.set(threads); + } + + /** The mechanism in use, for diagnostics. */ + static String description() { + return CONTROL == null ? "none" : CONTROL.name; + } + + /** + * Thread-local setters apply and restore per thread. A process-wide setter is shared by every + * thread, so its scopes are reference counted: the first open scope saves the process count and + * applies its own, later scopes join without changing it, and the saved count returns when the + * last scope closes. A scope's restore value is then {@link #RELEASE}, which closes that scope. + */ + static final class Control { + /** Returned to process-wide scopes; passing it back releases the scope. Never a real count. */ + static final int RELEASE = Integer.MAX_VALUE; + + final String name; + final MethodHandle setter; // int f(int) returning the previous value, or void f(int) + final MethodHandle getter; // int f(void), only for process-wide setters + final boolean global; + private int openScopes; // process-wide only, guarded by this + private int savedCount; // process count before the first open scope + private int appliedCount; // count applied by the first open scope + + Control(String name, MethodHandle setter, MethodHandle getter, boolean global) { + this.name = name; + this.setter = setter; + this.getter = getter; + this.global = global; + } + + int set(int threads) { + try { + if (!global) return (int) setter.invokeExact(threads); + synchronized (this) { + if (threads == RELEASE) { + if (openScopes == 0) return appliedCount; + if (--openScopes == 0 && savedCount != appliedCount) setter.invokeExact(savedCount); + return appliedCount; + } + if (openScopes++ == 0) { + savedCount = (int) getter.invokeExact(); + appliedCount = threads; + if (savedCount != threads) setter.invokeExact(threads); + } + return RELEASE; + } + } catch (Throwable t) { + throw new IllegalStateException("Setting " + name + " to " + threads + " failed", t); + } + } + } + + private static Control find() { + String path = System.getProperty("dev.ludovic.netlib.blas.nativeLibPath"); + String library = path != null ? path : System.getProperty("dev.ludovic.netlib.blas.nativeLib", "libblas.so.3"); + SymbolLookup lookup; + try { + lookup = SymbolLookup.libraryLookup(library, Arena.global()); + } catch (IllegalArgumentException | UnsupportedOperationException e) { + LOG.fine("No native BLAS library " + library + "; BLAS threads follow its own configuration."); + return null; + } + var linker = Linker.nativeLinker(); + var intToInt = FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT); + var intToVoid = FunctionDescriptor.ofVoid(ValueLayout.JAVA_INT); + var voidToInt = FunctionDescriptor.of(ValueLayout.JAVA_INT); + + // MKL's C interface is the mixed-case name; its lower- and upper-case exports are the + // Fortran interface, which takes a pointer. + Optional control = lookup.find("MKL_Set_Num_Threads_Local") + .map(s -> new Control("MKL thread-local threads", linker.downcallHandle(s, intToInt), null, false)) + .or(() -> lookup.find("openblas_set_num_threads_local") + .map(s -> new Control("OpenBLAS thread-local threads", linker.downcallHandle(s, intToInt), null, false))) + .or(() -> lookup.find("openblas_set_num_threads").flatMap(set -> lookup.find("openblas_get_num_threads") + .map(get -> new Control("OpenBLAS process-wide threads", + linker.downcallHandle(set, intToVoid), linker.downcallHandle(get, voidToInt), true)))); + if (control.isEmpty()) { + LOG.fine("Native BLAS library " + library + " has no thread-count setting; BLAS threads follow its own configuration."); + return null; + } + LOG.fine("BLAS threads are set through " + control.get().name + " in " + library); + return control.get(); + } +} diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java index 4f6b57650..2e1356b7e 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/NativeVectorUtilSupport.java @@ -33,6 +33,8 @@ */ class NativeVectorUtilSupport extends PanamaVectorUtilSupport { + private static final boolean REQUIRE_NATIVE_ASH = "native".equalsIgnoreCase( + System.getProperty("jvector.vectorization_provider", "").trim()); public NativeVectorUtilSupport() {} /** Select once at provider creation; leave the wide scan and its call site unchanged. */ @@ -52,6 +54,16 @@ public String getActiveIsa() { .getString(0); } + @Override + public int setBlasThreadsLocal(int threads) { + return NativeBlasThreads.set(threads); + } + + /** How BLAS thread counts are applied: an MKL or OpenBLAS setting, or {@code none}. */ + public String getBlasThreadControl() { + return NativeBlasThreads.description(); + } + /** * Returns the value of {@code JVECTOR_MAX_ISA} that was in effect when the * native library was loaded, or {@code null} if the variable was absent or @@ -91,6 +103,126 @@ public VectorFloat nvqSub(VectorFloat a, int aOffset, VectorFloat b, in return NativeNVQArithmetic.sub(a, aOffset, b, bOffset, length); } + @Override + public AshProjection prepareAshProjection(float[][] matrix) { + return NativeASHProjection.isAvailable() + ? NativeASHFP32BatchProjection.prepare(new NativeASHProjection(matrix)) : super.prepareAshProjection(matrix); + } + + @Override + public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, + int dimensions, int bits) { + return NativeASHSymmetricKernels.hasPairKernel() + ? NativeASHSymmetricKernels.dot(aBits, aCodes, bBits, bCodes, dimensions, bits) + : super.ashSymmetricDot(aBits, aCodes, bBits, bCodes, dimensions, bits); + } + + @Override + public float ashSymmetricBinaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + return NativeASHSymmetricKernels.hasPairKernel() + ? NativeASHSymmetricKernels.binaryDotAt(aBits, aOffset, bBits, bOffset, dimensions) + : super.ashSymmetricBinaryDotAt(aBits, aOffset, bBits, bOffset, dimensions); + } + + @Override + public float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, + int dimensions, int bits) { + return NativeASHSymmetricKernels.hasPairKernel() + ? NativeASHSymmetricKernels.dotAt(aCodes, aOffset, bCodes, bOffset, dimensions, bits) + : super.ashSymmetricDotAt(aCodes, aOffset, bCodes, bOffset, dimensions, bits); + } + + @Override + public boolean supportsAshSymmetricDotRows() { + return NativeASHSymmetricKernels.hasRowsKernel(); + } + + @Override + public void ashSymmetricDotRows(byte[] query, int queryOffset, byte[] rows, int stride, int count, + int dimensions, int bits, float[] out) { + if (NativeASHSymmetricKernels.hasRowsKernel()) + NativeASHSymmetricKernels.dotRows(query, queryOffset, rows, stride, count, dimensions, bits, out); + else super.ashSymmetricDotRows(query, queryOffset, rows, stride, count, dimensions, bits, out); + } + + @Override + public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + if (NativeASHSymmetricKernels.hasLutKernel()) + NativeASHSymmetricKernels.scoreLut(codes, groups, stride, lane, count, lut, out, outOffset); + else super.ashSymmetricLutScore(codes, groups, stride, lane, count, lut, out, outOffset); + } + + @Override + public boolean supportsAshNeighborhoodScoring() { + return NativeASHKernels.isAvailable(); + } + + @Override + public boolean supportsNativeAshCanonicalLutScoring() { return NativeASHKernels.isCanonicalLutAvailable(); } + + @Override + public void requireNativeAshSingleScoring(int bitsPerDimension) { + if (bitsPerDimension == 2 && NativeASHKernels.isCanonicalLutAvailable()) return; + if (bitsPerDimension == 4 && NativeASHKernels.isFourBitDotAvailable()) return; + throw new IllegalStateException("Required native ASH single scorer is unavailable for b=" + + bitsPerDimension + (bitsPerDimension == 2 + ? " (ash_canonical_lut_dot_f32)" : " (ash_projection_dot_4bit_f32)")); + } + + @Override + public float ashProjectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { + if (bitsPerDimension == 4 && NativeASHKernels.isFourBitDotAvailable()) { + return NativeASHKernels.dotFourBit(query, code, codeOffset, dimensions); + } + if (REQUIRE_NATIVE_ASH) { + requireNativeAshSingleScoring(bitsPerDimension); + if (bitsPerDimension == 2) + throw new IllegalStateException("Native b=2 scoring requires its prepared LUT; use ASHScorer"); + } + return super.ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); + } + + @Override + public boolean supportsAshCanonicalLutScoring() { + return NativeASHKernels.isCanonicalLutAvailable(); + } + + @Override + public float ashCanonicalLutDot(byte[] code, int codeOffset, int groups, float[] lut) { + return NativeASHKernels.dotCanonical(code, codeOffset, groups, lut); + } + + @Override + public String ashKernelDescription() { + String neighborhood = supportsAshNeighborhoodScoring() + ? "Highway native, ISA=" + getActiveIsa() + : "unavailable; using Java LUT"; + return super.ashKernelDescription() + "; C=1 whole-neighborhood=" + neighborhood + + "; dense projection=" + (NativeASHProjection.isAvailable() ? "Highway" : "Java") + + "; symmetric pair=" + (NativeASHSymmetricKernels.hasPairKernel() ? "Highway" : "Java") + + "; symmetric LUT=" + (NativeASHSymmetricKernels.hasLutKernel() ? "Highway" : "Java") + + "; INT8 offset-binary neighborhood=" + (supportsAshInt8NeighborhoodScoring() ? "Highway" : "Java"); + } + + @Override + public void ashScoreNeighborhood(byte[] codes, int groups, int blockSize, int capacity, + float[] lut, float landmarkDot, float[] out) { + NativeASHKernels.scoreNeighborhood(codes, groups, blockSize, capacity, lut, landmarkDot, out); + } + + @Override + public boolean supportsAshInt8NeighborhoodScoring() { + return NativeASHKernels.isInt8NeighborhoodAvailable(); + } + + @Override + public void ashScoreNeighborhoodInt8(byte[] codes, int bits, int units, int blockSize, int capacity, + byte[] q, float invScale, float center, float landmarkDot, float[] out) { + NativeASHKernels.scoreNeighborhoodInt8(codes, bits, units, blockSize, capacity, q, invScale, center, + landmarkDot, out); + } + @Override protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset) { return FloatVector.fromMemorySegment(SPEC, ((MemorySegmentVectorFloat) vector).get(), vector.offset(offset), ByteOrder.LITTLE_ENDIAN); @@ -103,7 +235,9 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat @Override protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { - vector.intoMemorySegment(((MemorySegmentVectorFloat) v).get(), v.offset(offset), ByteOrder.LITTLE_ENDIAN); + // MemorySegmentVectorFloat is heap-backed: use its array store intrinsic directly. + // This also avoids the intoMemorySegment compiler failure observed on JDK 22. + vector.intoArray(((MemorySegmentVectorFloat) v).array(), offset); } @Override diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java index 0fceb23b3..6cae930be 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/vector/cnative/LibraryLoader.java @@ -39,12 +39,16 @@ private LibraryLoader() {} * current CPU architecture, or {@code null} when the architecture is not supported. */ static String resourceNameForArch() { + return resourceNameForArch("jvector"); + } + + static String resourceNameForArch(String library) { String arch = System.getProperty("os.arch", ""); if (arch.equals("aarch64") || arch.equals("arm64")) { - return "/libjvector-aarch64.so"; + return "/lib" + library + "-aarch64.so"; } if (arch.equals("amd64") || arch.equals("x86_64")) { - return "/libjvector-x86_64.so"; + return "/lib" + library + "-x86_64.so"; } return null; } @@ -55,9 +59,14 @@ static String resourceNameForArch() { /// Returns `false` when the library is unavailable, native access is denied to this module /// (`IllegalCallerException`, e.g. with `--illegal-native-access=deny`), or a security policy /// blocks loading (`SecurityException`). The bundled copy isn't retried after access denial. - public static boolean loadJvector() { + public static boolean loadJvector() { return loadLibrary("jvector"); } + + /** Loads the optional oneDNN add-on for FP32 base batches and FP16/INT8 queries; Highway stays independent. */ + public static boolean loadAshFp16() { return loadLibrary("jvector_ash_fp16"); } + + private static boolean loadLibrary(String library) { try { - System.loadLibrary("jvector"); + System.loadLibrary(library); return true; } catch (UnsatisfiedLinkError e) { // ignore @@ -67,7 +76,7 @@ public static boolean loadJvector() { try { // reinventing the wheel instead of picking up deps, so we'll just use the classloader to load the library // as a resource and then copy it to a tmp directory and load it from there - String resourceName = resourceNameForArch(); + String resourceName = resourceNameForArch(library); if (resourceName == null) { return false; // unsupported architecture } diff --git a/jvector-native/src/main/native/README.md b/jvector-native/src/main/native/README.md index 8983a68d0..c1a69275d 100644 --- a/jvector-native/src/main/native/README.md +++ b/jvector-native/src/main/native/README.md @@ -41,9 +41,13 @@ aarch64-cross.ini - Meson cross-compile file: build aarch64 from an x86_64-cross.ini - Meson cross-compile file: build x86_64 from an aarch64 host jextract_generate_bindings.sh - Generate Java bindings from native headers using jextract build_native_lib.sh - Build the native library (native arch + optional cross-compile) -third_party/highway/ - Google Highway header-only library (git submodule) +subprojects/highway.wrap - Google Highway revision, fetched by Meson and used header-only ``` +The optional oneDNN add-on (`libjvector_ash_fp16`) is a separate Meson project in +[`../onednn`](../onednn), with its own `meson.build` and `subprojects/onednn.wrap`. +libjvector never depends on it. + --- ## How to build @@ -58,7 +62,12 @@ third_party/highway/ - Google Highway header-only library (git submodul | clang++ (AArch64) | Clang any recent | NEON tier always built; SVE/SVE2 require **Clang ≥ 22** — older Clang produces a NEON-only build with a warning | | [Meson](https://mesonbuild.com/) | 0.55 | `pip install meson` | | [Ninja](https://ninja-build.org/) | any | `sudo apt install ninja-build` | -| Git submodules | — | `git submodule update --init` (needed once) | +| CMake (oneDNN add-on only) | 3.13+ | `sudo apt install cmake`; needed in addition to Meson and Ninja | +| JDK (native ASH) | 22+ | Final FFM API; build and run the native provider with supported native-access settings | + +Meson fetches Google Highway at the revision pinned in `subprojects/highway.wrap` +on the first `meson setup`; no Git submodule is needed. Offline builds can +populate `subprojects/highway` with that revision beforehand. > **AArch64 compiler note:** On Clang < 22, the SVE and SVE2 tiers are > automatically skipped at configure time (`meson setup` prints a warning). @@ -66,12 +75,25 @@ third_party/highway/ - Google Highway header-only library (git submodul > runtime even on SVE2-capable hardware (e.g. Graviton 4). Upgrade to > Clang ≥ 22 or use GCC to get full SVE/SVE2 acceleration. +For ASH, the oneDNN add-on is the preferred projection implementation for FP32 +encoding batches and FP16/INT8 queries where supported. It is built from the pinned +source and bundled; users do not need a separately installed `libdnnl`. Building +oneDNN takes a few minutes, so it is off by default and enabled with +`-Dnative.onednn=true` (Maven) or `true` as the script's third argument; CI and +the release profile enable it. A first online build needs access to the source URL +in `../onednn/subprojects/onednn.wrap`; offline builds can populate that exact +revision beforehand. On Ubuntu, install the native build prerequisites with: + +```bash +sudo apt-get install g++ meson ninja-build cmake +``` + ### Build steps Run the build script from this directory: ```bash -bash src/build_native_lib.sh [buildtype] [crossarch] +bash src/build_native_lib.sh [buildtype] [crossarch] [onednn] ``` Parameters (all optional): @@ -80,15 +102,24 @@ Parameters (all optional): |-----------|---------|-------------| | `buildtype` | `release` | `release`, `debug`, or `debugoptimized` | | `crossarch` | `false` | Set to `true` to also cross-compile for the other arch | +| `onednn` | `false` | Set to `true` to also build and bundle the oneDNN add-on (FP32 batch and FP16/INT8 query projection) | + +To include oneDNN: + +```bash +bash src/build_native_lib.sh release false true +``` The script: 1. Detects the host arch via `uname -m` (x86_64 or aarch64). -2. Verifies prerequisites (g++, meson, ninja, Highway submodule). +2. Verifies prerequisites (g++, meson, ninja; cmake when oneDNN is on). 3. Always builds the **native arch** library into `target/meson-build-/`. 4. If `crossarch=true`, also cross-compiles the **other arch** library into `target/meson-build-/` using the bundled cross-compile file. 5. Copies each built `.so` to `jvector-native/target/meson-build/libjvector-.so` (relative to the repository root), which Maven includes as a resource. +6. If `onednn=true`, builds the separate `../onednn` project into + `target/meson-build-onednn-/` and copies `libjvector_ash_fp16-.so` beside it. The script does not strip or split debug symbols. To install all required dependencies automatically and then build, pass `--auto-install-deps` as the first argument: @@ -101,6 +132,12 @@ This is the easiest way to get started on a fresh Ubuntu machine. For other distributions the script will print an error indicating which install commands need to be added. +The build is quiet by default. A first build prints one line when each library starts and one +when it finishes; a later build prints a finish line only for a library it actually rebuilt. +Meson and Ninja output goes to `target/meson-build-.setup.log` and `.compile.log` and is +printed only if a step fails. Set `JVECTOR_NATIVE_BUILD_VERBOSE=1` to print everything, +including each command the script runs. + ### Cross-compilation Both `.so` files can be produced on a single machine without access to the @@ -144,8 +181,8 @@ The cross-compile file used for each direction lives in this directory: | File | Direction | |------|-----------| -| [`aarch64-cross.ini`](../aarch64-cross.ini) | x86_64 host → aarch64 target | -| [`x86_64-cross.ini`](../x86_64-cross.ini) | aarch64 host → x86_64 target | +| [`aarch64-cross.ini`](aarch64-cross.ini) | x86_64 host → aarch64 target | +| [`x86_64-cross.ini`](x86_64-cross.ini) | aarch64 host → x86_64 target | Meson reads the cross file (via `--cross-file`) and sets `host_machine.cpu_family` to the target arch, which causes `meson.build` to @@ -153,7 +190,15 @@ select the correct ISA variant list (x86 tiers vs AArch64 tiers) automatically. ### Building with Maven -From the project root, you can build the native module using Maven: +From the project root, the recommended ASH build includes oneDNN: + +```bash +mvn -pl jvector-native -am package -Dnative.onednn=true +``` + +It requires the CMake prerequisite above. The commands below build only the +Highway library; append `-Dnative.onednn=true` to include oneDNN. The `release` +profile turns it on. **Native arch only — release (default):** ```bash @@ -434,7 +479,7 @@ An unrecognised value is silently ignored and full CPU detection is used. ## Adding a new SIMD kernel The kernel registry is driven by the X-macro file -[`jvector_simd_kernel_list.h`](jvector_simd_kernel_list.h). A single entry +[`jvector_simd_kernel_list.h`](src/jvector_simd_kernel_list.h). A single entry there auto-generates: - the public C declaration in `jvector_simd.h` (exported to Java via FFM) @@ -679,7 +724,7 @@ if (max_isa != MaxIsa::SSE42 && max_isa != MaxIsa::AVX2 } ``` -If the required CPUID bits are not yet in [`jvector_cpu_features.h`](jvector_cpu_features.h), +If the required CPUID bits are not yet in [`jvector_cpu_features.h`](src/jvector_cpu_features.h), add them to the `CpuFeature` enum and populate them in `populate_cpu_features()`. @@ -709,7 +754,7 @@ HWY_FLATTEN float my_kernel(const float *a, size_t length) Add a `JV_REQUIRE_HWY_*` guard so the build fails loudly if the compiler selects the wrong Highway target for this translation unit. The existing checks for -`JV_REQUIRE_HWY_AVX3` and `JV_REQUIRE_HWY_AVX2` in [`assert_hwy_targets.h`](assert_hwy_targets.h) +`JV_REQUIRE_HWY_AVX3` and `JV_REQUIRE_HWY_AVX2` in [`assert_hwy_targets.h`](src/assert_hwy_targets.h) show the pattern — add an `#elif` block for the new tier: ```cpp @@ -824,3 +869,165 @@ When changing the native interface: including `jvector_supports_abi`, where the inspection tools are available. Export inspection complements the runtime tests; it does not establish ABI support by itself. + + + +## ASH query projection + +ASH base-vector encoding retains FP32: batch projection prefers oneDNN and +single-vector projection uses Highway. Training uses the BLAS/LAPACK provider. +Query scoring can prepare a separate immutable FP16 or explicit INT8 view of +the same trained projection matrix. Model and graph formats do not change. + +Use [Choosing ASH query-projection precision](../../../../docs/ash-projection-precision.md) +for processor-family recommendations, settings and when selection takes effect. +This section describes native build, arithmetic, ownership and diagnostics. + +### FP16 behavior + +FP16 uses FP16 operands with strict FP32 accumulation/output. The existing +`auto` policy prefers optimized FP16 and retains its FP32 fallback. An individual +query outside the finite FP16 range uses FP32 in automatic mode. Required `fp16` +accepts only finite matrix/query values with magnitude at most 65504; underflow +and rounding follow IEEE binary16 arithmetic. Input shape/aliasing errors, +allocation failures and execution errors are reported rather than hidden. + +### INT8 behavior + +Required `int8` prepares symmetric signed weights per output row and quantizes +each query independently. oneDNN computes unsigned-input/signed-weight products +with signed 32-bit accumulation. Correcting the input offset and dequantizing +produces FP32 output. Scales, quantization and rescaling use FP32 arithmetic. A nonzero operand whose +scale rounds to zero is rejected before integer conversion. Nonfinite operands +are rejected; output rescaling retains ordinary FP32 overflow behavior. + +On x86, only the INT8 quantization and batch-projection functions use an AVX2 +compiler target. The generic model-creation guard runs first and requires a +qualified VNNI/AMX backend, whose ISA includes AVX2. This keeps rounding in native +instructions without changing FP16/FP32 or library-wide compiler settings. + +The qualified VNNI/AMX kernels preserve full-range integer dot products. Legacy +kernels can saturate at a 16-bit intermediate; INT8 rejects them rather than +silently changing the quantization range. Both the effective oneDNN ISA and the +selected primitive are checked. CPU vendor names do not control dispatch. +The 65535-column bound prevents signed 32-bit accumulation overflow. + +Explicit INT8 reports unsupported backends without substituting another +precision. The loader validates the existing add-on ABI before binding its +symbols. Invalid operands, numeric range failures, allocation failures and +unexpected execution failures retain distinct error categories across FFM. + +### Recommended oneDNN build and package + +Native Maven and direct-script builds include oneDNN when asked to +(`-Dnative.onednn=true`, or `true` as the script's third argument) on the supported +Linux platforms; the `release` profile and CI turn it on. Use JDK 22 or newer. It is +**automatically preferred** for FP32 encoding batches and supported FP16 queries; +INT8 is available through explicit selection. Highway stays independent and supplies +FP32 fallback when the add-on or the requested operation is unavailable: + + +```bash +mvn -pl jvector-native -am package -Dnative.onednn=true +``` + +For a direct native build: + +```bash +bash src/build_native_lib.sh release false true +``` + +The recommended oneDNN build requires CMake 3.13+ in addition to the native prerequisites. Meson +fetches the exact oneDNN revision pinned in `../onednn/subprojects/onednn.wrap`; an offline +build can populate `../onednn/subprojects/onednn` with that revision beforehand. oneDNN is +built with the sequential CPU runtime, no GPU support, and only inference +matrix-multiplication/reorder primitives. Its dispatcher selects supported CPU +instructions at runtime; the build does not use `-march=native`. + +oneDNN is statically linked into `libjvector_ash_fp16-.so`, so the jar +carries one self-contained add-on and needs no system oneDNN. Maven packages +this library and the upstream license/third-party notices beside the Highway +resource. The existing architecture-aware loader first tries the system library +path, then extracts the matching classpath resource. No external `libdnnl` or +benchmark-specific path is required. The resource keeps its existing FP16 name +for binary compatibility but supplies FP32 batch and FP16/INT8 query projection. +Without `-Dnative.onednn=true` (the default) the build leaves oneDNN projection out +of the package and removes stale add-on resources from previous builds; Highway FP32 +is then used. +The optional component follows the native module's Linux platform support; +cross-compilation still requires the corresponding target toolchain. + +FP32 batch projection retains the original FP32 matrix and uses sequential +oneDNN SGEMM without converting database operands to FP16. Single-vector encoding +continues to use Highway FP32. The FP32 loader validates the same ABI as FP16; +missing/older add-ons and unsupported operations keep Highway. FP16 automatic +mode additionally falls back for operands outside FP16's finite range. Explicit +`fp16` and `int8` modes require support; invalid inputs, allocation failures and execution +errors propagate. See [implementation and measured encoding scope](../../../../docs/native-ash-preparation.md#preferred-onednn-fp32-encoding-projection-november-1-2026). + +### Ownership, parallelism and tests + +Each model owns its immutable prepared low-precision weights and execution +plans. Java automatic arenas release the native context when it becomes +unreachable. Each calling thread reuses its conversion, staging and scratch +buffers; neither thread-local cache retains the model or weights. Calls use +ordinary FFM downcalls. oneDNN starts no search workers or nested thread pool. +Concurrent callers use the same immutable model with separate workspaces. + +The native-module Java tests retain FP32 coverage and check FP16 against an +independent rounded-operand oracle, irregular and large dimensions, numeric +boundaries, input guards, model switching, concurrent platform/virtual threads, +query-only precision and loading without the optional library. INT8 tests additionally +cover full-range integer accumulation, FP32 scale underflow/overflow guards, batch tails, +concurrent model switching, lazy packed-weight accounting, ABI compatibility and +required-mode rejection of legacy integer kernels. On a machine +known to support FP16, require both oneDNN paths rather than capability skips with: + +```bash +mvn -pl jvector-native -am test -Dnative.onednn=true -Djvector.test.requireFp16=true -Djvector.test.requireFp32OneDnn=true +``` + +On VNNI/AMX hardware, require INT8 execution instead of capability skips: + +```bash +mvn -pl jvector-native -am test -Djvector.test.requireInt8=true +``` + +No INT8 diagnostics are printed unconditionally. For a separate diagnostic run, +use oneDNN's standard verbose mode to inspect the selected primitive: + +```text +ONEDNN_VERBOSE=1 java ... -Djvector.ash.queryProjection=int8 ... +``` + +Keep verbose diagnostics out of reported timing runs. + +With Google Test installed, Meson also builds `test_ash_fp16`; run it with +`meson test -C --suite ash_fp16 --print-errorlogs`. + +For projection throughput, use the built-in `DistancesASH` comparison with +`-Djvector.bench.int8-projection=true`. The existing `jvector.bench.recall` +setting controls optional exhaustive scans; leave it false for projection-only +measurements. Graph recall and latency are measured separately with BenchYAML. + +For performance validation, use the standard BenchYAML query path on identical +saved graphs for `fp32`, `auto`/`fp16` or explicit `int8`. Include normal query preparation in +its timers and compare recall@10 as well as QPS. Control CPU affinity and NUMA +memory placement consistently across runs; otherwise projection locality can +obscure the precision comparison. Do not include compilation or another active +benchmark in the query measurement interval. + + +### Concurrent query integration + +ASH can combine independent queries when the selected projection advertises +batch support. FP16 and INT8 use true oneDNN matrix multiplication while +retaining a singleton path. Applications continue +to issue synchronous searches; no query executor or nested native thread pool +is created. Base-vector encoding and training remain FP32. + +Database developers should follow [Integrating ASH queries into a database](../../../../docs/ash-query-integration.md) +for caller concurrency, bounded admission, resource ownership and latency +validation. The [maintainer design](../../../../docs/ash-query-batching-design.md) +describes coordination separately. Native batch entry points accept counts above +eight; internal micro-tiling does not restrict their API. diff --git a/jvector-native/src/main/native/meson.build b/jvector-native/src/main/native/meson.build index ec85d86e4..2ff11b244 100644 --- a/jvector-native/src/main/native/meson.build +++ b/jvector-native/src/main/native/meson.build @@ -18,10 +18,11 @@ project('jvector_simd_kernels', 'cpp', default_options: ['cpp_std=c++17'] ) -# Highway headers are used as an include directory only. -# HWY_COMPILE_ONLY_STATIC / HWY_COMPILE_ONLY_SCALAR bypass the +# Highway is fetched at the revision pinned in subprojects/highway.wrap and used +# header-only: HWY_COMPILE_ONLY_STATIC / HWY_COMPILE_ONLY_SCALAR bypass the # dynamic-dispatch runtime, so only headers are needed at compile time. -hwy_inc = include_directories('third_party/highway') +highway_proj = subproject('highway') +hwy_dep = highway_proj.get_variable('highway_dep') # Each ISA variant: name, JV_ISA namespace, and extra compiler flags. # These all compile jvector_simd_kernels.cpp (the generic kernel file). @@ -114,7 +115,7 @@ foreach isa : isa_variants lib = static_library( 'simdKernels_' + isa['name'], sources : 'src/jvector_simd_kernels.cpp', - include_directories: hwy_inc, + dependencies : hwy_dep, cpp_args : isa['args'] + ['-DJV_ISA=' + isa['namespace'], '-fvisibility=hidden'] ) isa_libs += lib @@ -127,7 +128,7 @@ if cpu == 'x86_64' avx3_dl_lib = static_library( 'simdKernels_avx3_dl', sources : 'src/jvector_avx3_dl_kernels.cpp', - include_directories: hwy_inc, + dependencies : hwy_dep, cpp_args : [ '-march=icelake-server', '-DHWY_COMPILE_ONLY_STATIC', @@ -143,7 +144,7 @@ if cpu == 'x86_64' avx3_spr_lib = static_library( 'simdKernels_avx3_spr', sources : 'src/jvector_avx3_spr_kernels.cpp', - include_directories: hwy_inc, + dependencies : hwy_dep, cpp_args : [ '-march=sapphirerapids', '-DHWY_COMPILE_ONLY_STATIC', @@ -163,8 +164,9 @@ endif vectorutil_lib = shared_library( 'jvector', sources : ['src/jvector_simd.cpp', - 'third_party/highway/hwy/abort.cc'], - include_directories: [include_directories('src'), hwy_inc], + highway_proj.get_variable('highway_abort_src')], + include_directories: include_directories('src'), + dependencies : hwy_dep, cpp_args : ['-DJVECTOR_BUILD', '-fvisibility=hidden'] + dispatch_defines, link_whole : isa_libs, version : meson.project_version(), @@ -232,4 +234,4 @@ else command: ['echo', 'ERROR: bench_simd_kernels is not available because the benchmark library was not found. ' + 'Install libbenchmark-dev (apt) or google-benchmark (brew) and re-run: meson setup --wipe build']) -endif \ No newline at end of file +endif diff --git a/jvector-native/src/main/native/src/ash_neighborhood-inl.h b/jvector-native/src/main/native/src/ash_neighborhood-inl.h new file mode 100644 index 000000000..598f6a4f9 --- /dev/null +++ b/jvector-native/src/main/native/src/ash_neighborhood-inl.h @@ -0,0 +1,423 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JVECTOR_ASH_NEIGHBORHOOD_INL_H +#define JVECTOR_ASH_NEIGHBORHOOD_INL_H + +// Included inside JV_ISA after Highway headers. The helpers are private to each +// ISA translation unit. The public entry is registered by the kernel X-macro. +namespace { +constexpr int kAshSuccess = 0; +constexpr int kAshInvalidLandmark = 1; +constexpr int kAshInvalidShape = 2; +constexpr size_t kAshHeaderBytesPerLane = 5; + +template +HWY_INLINE hn::VFromD AshLookup(D d, const float *lut, hn::VFromD> indices) { + const hn::Rebind di; +#if HWY_TARGET <= HWY_AVX3 + // A register-wide 16-float permute is an AVX-512 instruction. + if constexpr (hn::MaxLanes(d) == 16) { + return hn::TableLookupLanes(hn::LoadU(d, lut), hn::IndicesFromVec(d, hn::BitCast(di, indices))); + } else +#endif + { + // AVX2, NEON, SVE and scalar do not have a register-wide 16-float permute. + return hn::GatherIndex(d, lut, hn::BitCast(di, indices)); + } +} + +template +HWY_INLINE hn::VFromD AshPromoteHalf(D d, hn::VFromD> bits) { + const hn::Rebind df16; + auto value = hn::PromoteTo(d, hn::BitCast(df16, bits)); +#if HWY_TARGET <= HWY_SSE4 + if constexpr (hn::MaxLanes(d) < 8) { + // Highway's SSE software conversion assumes finite half values. Repair + // exponent 31 explicitly; AVX2/AVX-512 use hardware F16 conversion and + // NEON converts infinities and NaNs correctly. + const hn::Rebind du; + const auto wide = hn::PromoteTo(du, bits); + const auto exponent = hn::And(wide, hn::Set(du, 0x7c00)); + const auto special = hn::Eq(exponent, hn::Set(du, 0x7c00)); + const auto sign = hn::ShiftLeft<16>(hn::And(wide, hn::Set(du, 0x8000))); + const auto mantissa = hn::ShiftLeft<13>(hn::And(wide, hn::Set(du, 0x03ff))); + const auto ieee = hn::BitCast(d, hn::Or(hn::Or(sign, mantissa), hn::Set(du, 0x7f800000))); + value = hn::IfThenElse(hn::RebindMask(d, special), ieee, value); + } +#endif + return value; +} + +template +HWY_INLINE int AshFinish(D d, const unsigned char *header, int blockSize, int lane, int count, float landmarkDot, + hn::VFromD dot, float *out) { + const hn::Rebind db; + const hn::Rebind dh; + const auto landmarks = hn::LoadN(db, header + 4 * blockSize + lane, count); + if (!hn::AllTrue(db, hn::Eq(landmarks, hn::Zero(db)))) { + return kAshInvalidLandmark; + } + // Header arrays are [scale: B x f16][offset: B x f16][landmark: B x u8]. + // Disk f16 values are big-endian; Highway's x86 loads interpret native byte order. + auto scaleBits = hn::LoadN(dh, reinterpret_cast(header) + lane, count); + auto offsetBits = hn::LoadN(dh, reinterpret_cast(header + 2 * blockSize) + lane, count); + scaleBits = hn::ReverseLaneBytes(scaleBits); + offsetBits = hn::ReverseLaneBytes(offsetBits); + const auto scale = AshPromoteHalf(d, scaleBits); + const auto offset = AshPromoteHalf(d, offsetBits); + const auto raw = hn::Add(hn::MulAdd(scale, dot, hn::Set(d, landmarkDot)), offset); + const auto half = hn::Set(d, 0.5f); + // (1 + dot) / 2, unclamped, as ASHScorer.toSimilarity. + const auto similarity = hn::MulAdd(raw, half, half); + hn::StoreN(similarity, d, out, count); + return kAshSuccess; +} + +template +HWY_INLINE int AshBlock32(D d, const unsigned char *codes, const float *lut, int groups, float landmarkDot, + float *out) { + const hn::Rebind db; + const hn::Rebind du; + const auto mask = hn::Set(du, 15); + // Both halves of B32 share the loaded lookup tables. Four independent + // accumulators hide addition latency and avoid repeated LUT loads. + auto lo0 = hn::Zero(d), hi0 = hn::Zero(d); + auto lo1 = hn::Zero(d), hi1 = hn::Zero(d); + const auto *row = codes; + const auto *table = lut; + const auto *end = lut + static_cast(groups / 2) * 32; + for (; table < end; table += 32, row += 32) { + const auto c0 = hn::PromoteTo(du, hn::LoadU(db, row)); + const auto c1 = hn::PromoteTo(du, hn::LoadU(db, row + 16)); + const auto lowTable = hn::LoadU(d, table); + const auto highTable = hn::LoadU(d, table + 16); + const hn::Rebind di; + lo0 = hn::Add(lo0, hn::TableLookupLanes(lowTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::And(c0, mask))))); + hi0 = hn::Add(hi0, + hn::TableLookupLanes(highTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::ShiftRight<4>(c0))))); + lo1 = hn::Add(lo1, hn::TableLookupLanes(lowTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::And(c1, mask))))); + hi1 = hn::Add(hi1, + hn::TableLookupLanes(highTable, hn::IndicesFromVec(d, hn::BitCast(di, hn::ShiftRight<4>(c1))))); + } + if (groups & 1) { + const auto c0 = hn::PromoteTo(du, hn::LoadU(db, row)); + const auto c1 = hn::PromoteTo(du, hn::LoadU(db, row + 16)); + lo0 = hn::Add(lo0, AshLookup(d, table, hn::And(c0, mask))); + lo1 = hn::Add(lo1, AshLookup(d, table, hn::And(c1, mask))); + } + const auto *header = codes + ((static_cast(groups) + 1) / 2) * 32; + int status = AshFinish(d, header, 32, 0, 16, landmarkDot, hn::Add(lo0, hi0), out); + return status ? status : AshFinish(d, header, 32, 16, 16, landmarkDot, hn::Add(lo1, hi1), out + 16); +} + +} // namespace + +#ifndef JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY +// Canonical vectors pack consecutive groups into low/high nibbles of each byte. +// Gather from the same query LUT as the neighborhood kernel, without gathering +// projected query coordinates or repeatedly decoding signed-magnitude components. +float ash_canonical_lut_dot_f32(const unsigned char *HWY_RESTRICT code, + const float *HWY_RESTRICT lut, int groups) { + if (code == nullptr || lut == nullptr || groups <= 0) return NAN; + const hn::CappedTag d; + const hn::Rebind db; + const hn::Rebind di; + const auto offsets = hn::Mul(hn::Iota(di, 0), hn::Set(di, 32)); + const auto mask = hn::Set(di, 15); + const size_t lanes = hn::Lanes(d); + const size_t pairs = static_cast(groups) / 2; + auto low = hn::Zero(d), high = hn::Zero(d); + size_t i = 0; + for (; i + lanes <= pairs; i += lanes) { + const auto packed = hn::PromoteTo(di, hn::LoadU(db, code + i)); + const auto lowIndex = hn::Add(offsets, hn::And(packed, mask)); + const auto highIndex = hn::Add(offsets, hn::Add(hn::Set(di, 16), hn::ShiftRight<4>(packed))); + low = hn::Add(low, hn::GatherIndex(d, lut + i * 32, lowIndex)); + high = hn::Add(high, hn::GatherIndex(d, lut + i * 32, highIndex)); + } + float sum = hn::ReduceSum(d, hn::Add(low, high)); + for (; i < pairs; i++) { + sum += lut[i * 32 + (code[i] & 15)]; + sum += lut[i * 32 + 16 + (code[i] >> 4)]; + } + if (groups & 1) sum += lut[pairs * 32 + (code[pairs] & 15)]; + return sum; +} + +// Synchronous, allocation-free leaf. Buffer sizes and non-aliasing are checked by +// NativeASHKernels before FFM. The ordinary FFM path uses the same entry for larger +// legal inputs. FP32 reductions may differ in order from the Java scalar oracle. +int ash_score_neighborhood_c1_f32(const unsigned char *HWY_RESTRICT codes, const float *HWY_RESTRICT lut, + float landmarkDot, int groups, int blockSize, int capacity, float *HWY_RESTRICT out) { + if (codes == nullptr || lut == nullptr || out == nullptr || groups <= 0 || capacity <= 0 || + (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + return kAshInvalidShape; + } + const hn::CappedTag d; + const hn::Rebind db; + const hn::Rebind du; + const auto mask = hn::Set(du, 15); + const int lanes = static_cast(hn::Lanes(d)); + const size_t bodyBytes = ((static_cast(groups) + 1) / 2) * blockSize; + const size_t blockBytes = bodyBytes + kAshHeaderBytesPerLane * blockSize; + // Widen traversal arithmetic so the final base increment cannot overflow int. + for (size_t base = 0; base < static_cast(capacity); base += blockSize, codes += blockBytes) { + const int count = std::min(blockSize, capacity - static_cast(base)); +#if HWY_TARGET <= HWY_AVX3 + if constexpr (hn::MaxLanes(d) == 16) { + if (blockSize == 32 && count == 32) { + int status = AshBlock32(d, codes, lut, groups, landmarkDot, out + base); + if (status) + return status; + continue; + } + } +#endif + for (int lane = 0; lane < count; lane += lanes) { + const int active = std::min(lanes, count - lane); + auto lo = hn::Zero(d), hi = hn::Zero(d); + int g = 0; + for (; g + 1 < groups; g += 2) { + const auto c = + hn::PromoteTo(du, hn::LoadN(db, codes + static_cast(g / 2) * blockSize + lane, active)); + lo = hn::Add(lo, AshLookup(d, lut + static_cast(g) * 16, hn::And(c, mask))); + hi = hn::Add(hi, AshLookup(d, lut + static_cast(g + 1) * 16, hn::ShiftRight<4>(c))); + } + if (g < groups) { + const auto c = + hn::PromoteTo(du, hn::LoadN(db, codes + static_cast(g / 2) * blockSize + lane, active)); + lo = hn::Add(lo, AshLookup(d, lut + static_cast(g) * 16, hn::And(c, mask))); + } + int status = AshFinish(d, codes + bodyBytes, blockSize, lane, active, landmarkDot, hn::Add(lo, hi), + out + base + lane); + if (status) + return status; + } + } + return kAshSuccess; +} + +#endif // JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY + +namespace { +// Offset-binary neighborhoods (3 and 5 to 9 bits). The body is [unit][lane][bits bytes]: one unit is +// eight dimensions, so each lane's eight fields fill exactly `bits` bytes. A vector group holds one +// 64-bit word per lane. Bytes are gathered into each lane's word, every field is rotated into its own +// byte, and products with an INT8 query digit accumulate four dimensions per 32-bit lane. +constexpr int kAshInt8MaxBits = 9; + +// Eight consecutive query digits as one 64-bit word, for broadcasting to every lane's word. +HWY_INLINE uint64_t AshQueryWord(const int8_t *digits) { + uint64_t word; + hwy::CopyBytes<8>(digits, &word); + return word; +} + +// Per-lane exact sums: each lane's two 32-bit halves hold dimensions 0-3 and 4-7 of every unit. +template +HWY_INLINE hn::VFromD> AshLaneSums(D64 d64, D32 /*d32*/, hn::VFromD acc) { + const hn::RebindToSigned di64; + const hn::Rebind di32; + const auto wide = hn::BitCast(di64, acc); + const auto sum = hn::Add(hn::ShiftRight<32>(hn::ShiftLeft<32>(wide)), hn::ShiftRight<32>(wide)); + return hn::ConvertTo(hn::Rebind(), hn::DemoteTo(di32, sum)); +} + +// Unpacks one lane group's fields for one unit and accumulates their products with the query word. +// Nine-bit fields add their top bits to `top`, which the caller scales by 256. +template +HWY_INLINE void AshAccumulateFields(D64 d64, D8 d8, D32 d32, int bits, size_t lanes, const unsigned char *src, + VI8 qv, Idx idxA, Idx idxB, hn::VFromD rotLo, + hn::VFromD rotHi, hn::VFromD lowMask, hn::MFromD lastField, + hn::VFromD &acc, hn::VFromD &top) { + if (bits <= 8) { + // A group needs lanes * bits <= Lanes(d8) bytes; later bytes are ignored, and the header after the + // body keeps the full-width load inside the block. + auto fields = hn::LoadU(d8, src); + if (bits < 8) { + fields = hn::TableLookupLanes(fields, idxA); + fields = hn::And(hn::BitCast(d8, hn::MultiRotateRight(hn::BitCast(d64, fields), rotLo)), lowMask); + } + acc = hn::SumOfMulQuadAccumulate(d32, fields, qv, acc); + return; + } + // lanes * 9 bytes; the second table may extend past the block, so load it bounded. + const size_t span = lanes * static_cast(bits); + const auto t0 = hn::LoadN(d8, src, std::min(span, hn::Lanes(d8))); + const auto t1 = hn::LoadN(d8, src + hn::Lanes(d8), span > hn::Lanes(d8) ? span - hn::Lanes(d8) : 0); + const auto viewA = hn::BitCast(d64, hn::TwoTablesLookupLanes(d8, t0, t1, idxA)); + const auto viewB = hn::BitCast(d64, hn::TwoTablesLookupLanes(d8, t0, t1, idxB)); + const auto low = hn::IfThenElse(lastField, hn::BitCast(d8, hn::MultiRotateRight(viewB, rotLo)), + hn::BitCast(d8, hn::MultiRotateRight(viewA, rotLo))); + const auto high = hn::And(hn::IfThenElse(lastField, hn::BitCast(d8, hn::MultiRotateRight(viewB, rotHi)), + hn::BitCast(d8, hn::MultiRotateRight(viewA, rotHi))), + hn::Set(d8, uint8_t{1})); + acc = hn::SumOfMulQuadAccumulate(d32, low, qv, acc); + top = hn::SumOfMulQuadAccumulate(d32, high, qv, top); +} +} // namespace + +// Whole-neighborhood C=1 ASH for offset-binary codes with an INT8 query: per lane, +// dot = ( - center) * invScale, then the header terms and similarity. q holds units * 8 entries +// (zero past the projected dimension). Returns kAshSuccess, kAshInvalidLandmark or kAshInvalidShape. +int ash_score_neighborhood_i8_c1(const unsigned char *HWY_RESTRICT codes, const int8_t *HWY_RESTRICT q, + float invScale, float center, float landmarkDot, int bits, int units, + int blockSize, int capacity, float *HWY_RESTRICT out) { + if (codes == nullptr || q == nullptr || out == nullptr || units <= 0 || capacity <= 0 || bits < 3 || + bits > kAshInt8MaxBits || bits == 4 || (blockSize != 8 && blockSize != 16 && blockSize != 32)) { + return kAshInvalidShape; + } + // At most eight 64-bit lanes per group, so groups divide every block size. + const hn::CappedTag d64; + const hn::Repartition d8; + const hn::Repartition di8; + const hn::Repartition d32; + const hn::Rebind df; + const size_t lanes = hn::Lanes(d64); + const size_t b = static_cast(bits); + const size_t rowBytes = b * static_cast(blockSize); + const size_t bodyBytes = static_cast(units) * rowBytes; + const size_t blockBytes = bodyBytes + kAshHeaderBytesPerLane * blockSize; + + // Gather and rotation controls. Lane l's bytes start at l * bits; field k starts at bit k * bits. + // Nine-bit fields span nine bytes per lane: two eight-byte views (bytes 0-7 and 1-8) supply the low + // byte and top bit of fields 0-6 and of field 7 respectively. + HWY_ALIGN uint8_t gatherA[64], gatherB[64], rotateLo[64], rotateHi[64]; + for (size_t l = 0; l < lanes; l++) { + for (size_t k = 0; k < 8; k++) { + const size_t i = 8 * l + k; + if (bits <= 8) { + gatherA[i] = static_cast(l * b + std::min(k, b - 1)); + gatherB[i] = gatherA[i]; + rotateLo[i] = static_cast(k * b); + rotateHi[i] = 0; + } else { + gatherA[i] = static_cast(l * b + k); + gatherB[i] = static_cast(l * b + 1 + k); + rotateLo[i] = static_cast(k < 7 ? k * b : 55); + rotateHi[i] = static_cast(k < 7 ? k * b + 8 : 63); + } + } + } + const auto lowMask = hn::Set(d8, static_cast(bits >= 8 ? 0xFF : (1u << bits) - 1)); + const auto rotLo = hn::Load(d8, rotateLo), rotHi = hn::Load(d8, rotateHi); + // Byte 7 of every lane's word takes field 7 from the shifted view. + const auto lastField = hn::Eq(hn::And(hn::Iota(d8, 0), hn::Set(d8, uint8_t{7})), hn::Set(d8, uint8_t{7})); + const auto idxA = hn::IndicesFromVec(d8, hn::Load(d8, gatherA)); + const auto idxB = hn::IndicesFromVec(d8, hn::Load(d8, gatherB)); + + // Up to four lane groups share each pass over a block: every unit row is read once, contiguously, and + // its query word is broadcast once. With eight 64-bit lanes, one pass covers a 32-lane block. + const size_t groupBytes = lanes * b; + for (size_t base = 0; base < static_cast(capacity); base += blockSize, codes += blockBytes) { + const int count = std::min(blockSize, capacity - static_cast(base)); + const size_t groups = (static_cast(count) + lanes - 1) / lanes; + for (size_t first = 0; first < groups; first += 4) { + const size_t n = std::min(4, groups - first); + auto a0 = hn::Zero(d32), a1 = hn::Zero(d32), a2 = hn::Zero(d32), a3 = hn::Zero(d32); + auto t0 = hn::Zero(d32), t1 = hn::Zero(d32), t2 = hn::Zero(d32), t3 = hn::Zero(d32); + const unsigned char *row = codes + first * groupBytes; + for (int u = 0; u < units; u++, row += rowBytes) { + const auto qv = hn::BitCast(di8, hn::Set(d64, AshQueryWord(q + 8 * u))); + AshAccumulateFields(d64, d8, d32, bits, lanes, row, qv, idxA, idxB, rotLo, rotHi, lowMask, lastField, + a0, t0); + if (n > 1) + AshAccumulateFields(d64, d8, d32, bits, lanes, row + groupBytes, qv, idxA, idxB, rotLo, rotHi, + lowMask, lastField, a1, t1); + if (n > 2) + AshAccumulateFields(d64, d8, d32, bits, lanes, row + 2 * groupBytes, qv, idxA, idxB, rotLo, + rotHi, lowMask, lastField, a2, t2); + if (n > 3) + AshAccumulateFields(d64, d8, d32, bits, lanes, row + 3 * groupBytes, qv, idxA, idxB, rotLo, + rotHi, lowMask, lastField, a3, t3); + } + // Vectors are passed by value (no arrays), which scalable-vector targets require. + const auto finish = [&](size_t i, hn::VFromD sum, hn::VFromD top) -> int { + const auto acc = bits > 8 ? hn::Add(sum, hn::ShiftLeft<8>(top)) : sum; + const auto dot = hn::Mul(hn::Sub(AshLaneSums(d64, d32, acc), hn::Set(df, center)), hn::Set(df, invScale)); + const int lane = static_cast((first + i) * lanes); + const int active = std::min(static_cast(lanes), count - lane); + return AshFinish(df, codes + bodyBytes, blockSize, lane, active, landmarkDot, dot, out + base + lane); + }; + int status = finish(0, a0, t0); + if (!status && n > 1) status = finish(1, a1, t1); + if (!status && n > 2) status = finish(2, a2, t2); + if (!status && n > 3) status = finish(3, a3, t3); + if (status) return status; + } + } + return kAshSuccess; +} + +#ifndef JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY +// A 4-bit nibble represents one coordinate, so a full query LUT is 16 times +// larger than the projection. Decode consecutive coordinates in registers and +// keep the projection access contiguous instead of gathering a large table. +float ash_projection_dot_4bit_f32(const float *HWY_RESTRICT query, + const unsigned char *HWY_RESTRICT code, int dimensions) { + if (query == nullptr || code == nullptr || dimensions < 0) return NAN; + const hn::CappedTag d; + const hn::Rebind db; + const hn::Rebind du; + const size_t lanes = hn::Lanes(d); + const size_t count = static_cast(dimensions); +#if HWY_TARGET <= HWY_AVX3 + // Exact signed-magnitude levels: keep them in one register instead of + // converting magnitudes and constructing sign masks for every vector. + alignas(64) static constexpr float levels[16] = { + -.5f, -1.5f, -2.5f, -3.5f, -4.5f, -5.5f, -6.5f, -7.5f, + .5f, 1.5f, 2.5f, 3.5f, 4.5f, 5.5f, 6.5f, 7.5f}; + const auto table = hn::LoadU(d, levels); +#else + const auto magnitudeMask = hn::Set(du, 7); + const auto signMask = hn::Set(du, 8); + const auto half = hn::Set(d, 0.5f); +#endif + auto low = hn::Zero(d), high = hn::Zero(d); + size_t i = 0; + for (; i + 2 * lanes <= count; i += 2 * lanes) { + const auto packed = hn::PromoteTo(du, hn::LoadU(db, code + i / 2)); + const auto hiCode = hn::ShiftRight<4>(packed); +#if HWY_TARGET <= HWY_AVX3 + // VPERMPS uses only the low four index bits. Use the intrinsic to avoid + // a redundant mask and Highway IndicesFromVec debug checks on upper bits. + const auto loValue = hn::VFromD{_mm512_permutexvar_ps(packed.raw, table.raw)}; + const auto hiValue = hn::VFromD{_mm512_permutexvar_ps(hiCode.raw, table.raw)}; +#else + auto loValue = hn::Add(hn::ConvertTo(d, hn::And(packed, magnitudeMask)), half); + auto hiValue = hn::Add(hn::ConvertTo(d, hn::And(hiCode, magnitudeMask)), half); + loValue = hn::IfThenElse(hn::RebindMask(d, hn::Eq(hn::And(packed, signMask), hn::Zero(du))), + hn::Neg(loValue), loValue); + hiValue = hn::IfThenElse(hn::RebindMask(d, hn::Eq(hn::And(hiCode, signMask), hn::Zero(du))), + hn::Neg(hiValue), hiValue); +#endif + hn::VFromD qLow, qHigh; + hn::LoadInterleaved2(d, query + i, qLow, qHigh); + low = hn::MulAdd(qLow, loValue, low); + high = hn::MulAdd(qHigh, hiValue, high); + } + float sum = hn::ReduceSum(d, hn::Add(low, high)); + for (; i < count; i++) { + const unsigned field = (code[i / 2] >> ((i & 1) * 4)) & 15; + const float value = static_cast(field & 7) + 0.5f; + sum += query[i] * ((field & 8) ? value : -value); + } + return sum; +} +#endif // JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY + +#endif // JVECTOR_ASH_NEIGHBORHOOD_INL_H diff --git a/jvector-native/src/main/native/src/ash_projection-inl.h b/jvector-native/src/main/native/src/ash_projection-inl.h new file mode 100644 index 000000000..7b4be30b7 --- /dev/null +++ b/jvector-native/src/main/native/src/ash_projection-inl.h @@ -0,0 +1,155 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JVECTOR_ASH_PROJECTION_INL_H +#define JVECTOR_ASH_PROJECTION_INL_H + +// Included within JV_ISA. The immutable matrix is contiguous row-major and is +// prepared once per model. A whole query crosses FFM once; no allocation, +// callback, synchronization or pointer retention occurs in this leaf kernel. +void ash_project_f32(const float *HWY_RESTRICT matrix, const float *HWY_RESTRICT query, + int rows, int cols, float *HWY_RESTRICT out) { + const hn::ScalableTag d; + const size_t lanes = hn::Lanes(d); + const size_t columns = static_cast(cols); + int row = 0; + // Four rows share each query load. Two chains per row hide FMA latency + // without changing the stored matrix or batching independent graph queries. + for (; rows - row >= 4; row += 4) { + const float *a = matrix + static_cast(row) * columns; + auto s0 = hn::Zero(d), s1 = hn::Zero(d), s2 = hn::Zero(d), s3 = hn::Zero(d); + auto t0 = hn::Zero(d), t1 = hn::Zero(d), t2 = hn::Zero(d), t3 = hn::Zero(d); + size_t col = 0; + for (; col + 2 * lanes <= columns; col += 2 * lanes) { + const auto q0 = hn::LoadU(d, query + col); + const auto q1 = hn::LoadU(d, query + col + lanes); + s0 = hn::MulAdd(hn::LoadU(d, a + col), q0, s0); + s1 = hn::MulAdd(hn::LoadU(d, a + columns + col), q0, s1); + s2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col), q0, s2); + s3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col), q0, s3); + t0 = hn::MulAdd(hn::LoadU(d, a + col + lanes), q1, t0); + t1 = hn::MulAdd(hn::LoadU(d, a + columns + col + lanes), q1, t1); + t2 = hn::MulAdd(hn::LoadU(d, a + 2 * columns + col + lanes), q1, t2); + t3 = hn::MulAdd(hn::LoadU(d, a + 3 * columns + col + lanes), q1, t3); + } + s0 = hn::Add(s0, t0); s1 = hn::Add(s1, t1); + s2 = hn::Add(s2, t2); s3 = hn::Add(s3, t3); + for (; col < columns; col += lanes) { + const size_t n = std::min(lanes, columns - col); + const auto q = hn::LoadN(d, query + col, n); + s0 = hn::MulAdd(hn::LoadN(d, a + col, n), q, s0); + s1 = hn::MulAdd(hn::LoadN(d, a + columns + col, n), q, s1); + s2 = hn::MulAdd(hn::LoadN(d, a + 2 * columns + col, n), q, s2); + s3 = hn::MulAdd(hn::LoadN(d, a + 3 * columns + col, n), q, s3); + } + out[row] = hn::ReduceSum(d, s0); out[row + 1] = hn::ReduceSum(d, s1); + out[row + 2] = hn::ReduceSum(d, s2); out[row + 3] = hn::ReduceSum(d, s3); + } + for (; row < rows; ++row) { + const float *a = matrix + static_cast(row) * columns; + auto sum = hn::Zero(d); + for (size_t col = 0; col < columns; col += lanes) { + const size_t n = std::min(lanes, columns - col); + sum = hn::MulAdd(hn::LoadN(d, a + col, n), hn::LoadN(d, query + col, n), sum); + } + out[row] = hn::ReduceSum(d, sum); + } +} + +// A packed matrix consists of 32-output tiles. Within each tile the original +// dimensions are contiguous rows of up to 32 weights (no tail padding). The +// caller prepares this once; each tile is reused across the whole input batch. +// All operands, FMA accumulators and results are FP32. +template +HWY_INLINE void ash_project_batch_tile(const float* HWY_RESTRICT weights, + const float* HWY_RESTRICT inputs, + int rows, int cols, size_t width, size_t firstLane, + size_t n0, size_t n1, + float* HWY_RESTRICT out) { + const hn::CappedTag d; + const size_t lanes = hn::Lanes(d); + const size_t columns = static_cast(cols); + const size_t stride = static_cast(rows); + auto s0 = hn::Zero(d), s1 = hn::Zero(d), s2 = hn::Zero(d), s3 = hn::Zero(d); + auto t0 = hn::Zero(d), t1 = hn::Zero(d), t2 = hn::Zero(d), t3 = hn::Zero(d); + for (size_t col = 0; col < columns; ++col) { + const float* w = weights + col * width + firstLane; + const auto a0 = hn::LoadN(d, w, n0); + const auto q0 = hn::Set(d, inputs[col]); + s0 = hn::MulAdd(a0, q0, s0); + if constexpr (TwoVectors) { + const auto a1 = hn::LoadN(d, w + lanes, n1); + t0 = hn::MulAdd(a1, q0, t0); + if constexpr (FourQueries) { + t1 = hn::MulAdd(a1, hn::Set(d, inputs[columns + col]), t1); + t2 = hn::MulAdd(a1, hn::Set(d, inputs[2 * columns + col]), t2); + t3 = hn::MulAdd(a1, hn::Set(d, inputs[3 * columns + col]), t3); + } + } + if constexpr (FourQueries) { + s1 = hn::MulAdd(a0, hn::Set(d, inputs[columns + col]), s1); + s2 = hn::MulAdd(a0, hn::Set(d, inputs[2 * columns + col]), s2); + s3 = hn::MulAdd(a0, hn::Set(d, inputs[3 * columns + col]), s3); + } + } + hn::StoreN(s0, d, out, n0); + if constexpr (TwoVectors) hn::StoreN(t0, d, out + lanes, n1); + if constexpr (FourQueries) { + hn::StoreN(s1, d, out + stride, n0); + hn::StoreN(s2, d, out + 2 * stride, n0); + hn::StoreN(s3, d, out + 3 * stride, n0); + if constexpr (TwoVectors) { + hn::StoreN(t1, d, out + stride + lanes, n1); + hn::StoreN(t2, d, out + 2 * stride + lanes, n1); + hn::StoreN(t3, d, out + 3 * stride + lanes, n1); + } + } +} + +void ash_project_batch_f32(const float* HWY_RESTRICT matrix, + const float* HWY_RESTRICT inputs, + int rows, int cols, int count, + float* HWY_RESTRICT out) { + const hn::CappedTag d; + const size_t lanes = hn::Lanes(d); + for (size_t row = 0; row < static_cast(rows); row += 32) { + const size_t tileRows = std::min(size_t{32}, static_cast(rows) - row); + const float* weights = matrix + row * static_cast(cols); + for (size_t offset = 0; offset < tileRows; offset += 2 * lanes) { + const size_t n0 = std::min(lanes, tileRows - offset); + const size_t n1 = tileRows - offset > lanes + ? std::min(lanes, tileRows - offset - lanes) : 0; + int b = 0; + for (; count - b >= 4; b += 4) { + const float* x = inputs + static_cast(b) * cols; + float* y = out + static_cast(b) * rows + row + offset; + if (n1 != 0) + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + else + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + } + for (; b < count; ++b) { + const float* x = inputs + static_cast(b) * cols; + float* y = out + static_cast(b) * rows + row + offset; + if (n1 != 0) + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + else + ash_project_batch_tile(weights, x, rows, cols, tileRows, offset, n0, n1, y); + } + } + } +} +#endif diff --git a/jvector-native/src/main/native/src/ash_symmetric-inl.h b/jvector-native/src/main/native/src/ash_symmetric-inl.h new file mode 100644 index 000000000..ff416f081 --- /dev/null +++ b/jvector-native/src/main/native/src/ash_symmetric-inl.h @@ -0,0 +1,215 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JVECTOR_ASH_SYMMETRIC_INL_H +#define JVECTOR_ASH_SYMMETRIC_INL_H + +// Included within JV_ISA. All sizes/pointers are checked by the Java binding. +// Products of doubled signed-magnitude components are exact integers; divide +// by four only once, after accumulation. Padding bits never contribute. +namespace { +HWY_INLINE int AshDoubledComponent(const uint8_t *code, int j, int bits) { + const int perByte = 8 / bits; + const int field = code[j / perByte] >> ((j % perByte) * bits); + const int sign = 1 << (bits - 1); + const int magnitude = 2 * (field & (sign - 1)) + 1; + return (field & sign) ? magnitude : -magnitude; +} + +HWY_INLINE float AshBinaryPair(const uint64_t *a, const uint64_t *b, int dimensions) { + const hn::ScalableTag d; + const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 64; + auto counts = hn::Zero(d); + // Integer lanes can hold all mismatches even at the maximum Java dimension. + // Full vectors use plain loads; only the last partial vector is masked. + size_t word = 0; + for (; word + lanes <= words; word += lanes) { + counts = hn::Add(counts, hn::PopulationCount(hn::Xor(hn::LoadU(d, a + word), hn::LoadU(d, b + word)))); + } + if (word < words) { + const size_t n = words - word; + counts = hn::Add(counts, hn::PopulationCount(hn::Xor(hn::LoadN(d, a + word, n), hn::LoadN(d, b + word, n)))); + } + uint64_t mismatches = hn::ReduceSum(d, counts); + const int tail = dimensions & 63; + if (tail) mismatches += hwy::PopCount((a[words] ^ b[words]) & ((uint64_t{1} << tail) - 1)); + return static_cast(dimensions) - 2.0f * static_cast(mismatches); +} + +// sign*(1+2*ma)*(1+2*mb) for each 2-bit field pair in av and bv, less one per field, evaluated +// with weighted popcounts and summed per 64-bit lane. +template +HWY_INLINE hn::VFromD> AshTwoBitDelta(D d, hn::VFromD av, hn::VFromD bv) { + const auto slots = hn::Set(d, uint64_t{0x5555555555555555}); + const auto neg = hn::And(hn::ShiftRight<1>(hn::Xor(av, bv)), slots); + const auto ma = hn::And(av, slots), mb = hn::And(bv, slots), both = hn::And(ma, mb); + auto delta = hn::ShiftLeft<1>(hn::Sub( + hn::Add(hn::PopulationCount(ma), hn::PopulationCount(mb)), hn::PopulationCount(neg))); + delta = hn::Sub(delta, hn::ShiftLeft<2>(hn::Add( + hn::PopulationCount(hn::And(ma, neg)), hn::PopulationCount(hn::And(mb, neg))))); + delta = hn::Add(delta, hn::ShiftLeft<2>(hn::PopulationCount(both))); + delta = hn::Sub(delta, hn::ShiftLeft<3>(hn::PopulationCount(hn::And(both, neg)))); + return hn::BitCast(hn::RebindToSigned(), delta); +} + +HWY_INLINE float AshTwoBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { + const hn::ScalableTag d; + const size_t lanes = hn::Lanes(d), words = static_cast(dimensions) / 32; + // Canonical codes are little-endian packed fields. LoadU/LoadN permit + // byte alignment on all supported little-endian production targets. + const auto *wa = reinterpret_cast(a), *wb = reinterpret_cast(b); + // Keep partial sums in registers; one horizontal reduction per pair. Full vectors use + // plain loads; only the last partial vector is masked. + auto deltas = hn::Zero(hn::RebindToSigned()); + size_t word = 0; + for (; word + lanes <= words; word += lanes) { + deltas = hn::Add(deltas, AshTwoBitDelta(d, hn::LoadU(d, wa + word), hn::LoadU(d, wb + word))); + } + if (word < words) { + const size_t n = words - word; + deltas = hn::Add(deltas, AshTwoBitDelta(d, hn::LoadN(d, wa + word, n), hn::LoadN(d, wb + word, n))); + } + const hn::RebindToSigned di; + int64_t total = static_cast(words) * 32 + hn::ReduceSum(di, deltas); + // The last partial word: copy only its code bytes and clear the padding fields, which then + // contribute nothing. One word of popcounts instead of one decode per leftover dimension. + const int tail = dimensions & 31; + if (tail) { + // A masked load of the code bytes only; it never reads past the code. + const hn::FixedTag d8; + const hn::FixedTag d64; + const size_t tailBytes = (static_cast(tail) + 3) / 4; + uint64_t ta = hn::GetLane(hn::BitCast(d64, hn::LoadN(d8, a + words * 8, tailBytes))); + uint64_t tb = hn::GetLane(hn::BitCast(d64, hn::LoadN(d8, b + words * 8, tailBytes))); + const uint64_t fields = (uint64_t{1} << (2 * tail)) - 1, slot = uint64_t{0x5555555555555555}; + ta &= fields; + tb &= fields; + const uint64_t neg = ((ta ^ tb) >> 1) & slot, ma = ta & slot, mb = tb & slot, both = ma & mb; + int64_t delta = 2 * (static_cast(hwy::PopCount(ma)) + hwy::PopCount(mb) - hwy::PopCount(neg)); + delta -= 4 * (static_cast(hwy::PopCount(ma & neg)) + hwy::PopCount(mb & neg)); + delta += 4 * static_cast(hwy::PopCount(both)); + delta -= 8 * static_cast(hwy::PopCount(both & neg)); + total += tail + delta; + } + return static_cast(total) * 0.25f; +} + +// The last partial vector of 4-bit codes, n bytes. Kept out of line so the full-vector loop +// compiles as before. LoadN zero-fills the lanes past the code; a zero byte decodes to two +// (+1)*(+1) products, which are subtracted. +HWY_NOINLINE int64_t AshFourBitPartial(const uint8_t *a, const uint8_t *b, size_t n) { + const hn::ScalableTag di; + const hn::Repartition db; + const hn::RebindToSigned ds; + const auto av = hn::LoadN(db, a, n), bv = hn::LoadN(db, b, n); + auto sum = hn::Zero(di); + for (int shift = 0; shift < 8; shift += 4) { + const auto ax = hn::ShiftRightSame(av, shift), bx = hn::ShiftRightSame(bv, shift); + const auto am = hn::Add(hn::ShiftLeft<1>(hn::And(ax, hn::Set(db, 7))), hn::Set(db, 1)); + const auto bm = hn::BitCast(ds, hn::Add(hn::ShiftLeft<1>(hn::And(bx, hn::Set(db, 7))), hn::Set(db, 1))); + const auto sameSign = hn::Eq(hn::And(hn::Xor(ax, bx), hn::Set(db, 8)), hn::Zero(db)); + const auto signedB = hn::IfThenElse(hn::RebindMask(ds, sameSign), bm, hn::Neg(bm)); + sum = hn::SumOfMulQuadAccumulate(di, am, signedB, sum); + } + return hn::ReduceSum(di, sum) - 2 * static_cast(hn::Lanes(db) - n); +} + +HWY_INLINE float AshFourBitPair(const uint8_t *a, const uint8_t *b, int dimensions) { + int64_t total = 0; + const hn::ScalableTag di; + const hn::Repartition db; + const hn::RebindToSigned ds; + const size_t bytes = static_cast(dimensions) / 2, lanes = hn::Lanes(db); + // One accumulator for the whole pair. Each pass adds at most 8 * 15 * 15 = 1800 per int32 + // lane, so a lane overflows only past about a million vectors, far beyond any Java code. + auto sum = hn::Zero(di); + size_t offset = 0; + for (; offset + lanes <= bytes; offset += lanes) { + const auto av = hn::LoadU(db, a + offset), bv = hn::LoadU(db, b + offset); + for (int shift = 0; shift < 8; shift += 4) { + const auto ax = hn::ShiftRightSame(av, shift), bx = hn::ShiftRightSame(bv, shift); + const auto am = hn::Add(hn::ShiftLeft<1>(hn::And(ax, hn::Set(db, 7))), hn::Set(db, 1)); + const auto bm = hn::BitCast(ds, hn::Add(hn::ShiftLeft<1>(hn::And(bx, hn::Set(db, 7))), hn::Set(db, 1))); + const auto sameSign = hn::Eq(hn::And(hn::Xor(ax, bx), hn::Set(db, 8)), hn::Zero(db)); + const auto signedB = hn::IfThenElse(hn::RebindMask(ds, sameSign), bm, hn::Neg(bm)); + // VNNI when supported; Highway supplies widening multiply otherwise. + sum = hn::SumOfMulQuadAccumulate(di, am, signedB, sum); + } + } + total += hn::ReduceSum(di, sum); + if (offset < bytes) { + total += AshFourBitPartial(a + offset, b + offset, bytes - offset); + offset = bytes; + } + for (int j = static_cast(2 * offset); j < dimensions; ++j) total += AshDoubledComponent(a, j, 4) * AshDoubledComponent(b, j, 4); + return static_cast(total) * 0.25f; +} +} // namespace + +float ash_symmetric_dot(const void *a, const void *b, int dimensions, int bits) { + if (bits == 1) return AshBinaryPair(static_cast(a), static_cast(b), dimensions); + if (bits == 2) return AshTwoBitPair(static_cast(a), static_cast(b), dimensions); + return AshFourBitPair(static_cast(a), static_cast(b), dimensions); +} + +// Scores one code against count contiguous codes stride bytes apart, each exactly as ash_symmetric_dot. +void ash_symmetric_dot_rows(const void *query, const void *rows, int stride, int count, int dimensions, int bits, + float *HWY_RESTRICT out) { + const auto *row = static_cast(rows); + for (int i = 0; i < count; ++i) out[i] = ash_symmetric_dot(query, row + static_cast(i) * stride, dimensions, bits); +} + +#ifndef JVECTOR_ASH_PAIR_ONLY +// Exact signed-short LUTs, unlike the IVF scan's approximate uint8 LUTs. Widen +// before accumulation. At most 8192 groups enter an int32 subtotal, then spill +// into int64 lane totals so arbitrary short entries and long codes cannot wrap. +void ash_symmetric_lut_i16(const uint8_t *HWY_RESTRICT codes, const int16_t *HWY_RESTRICT lut, + int groups, int stride, int lane, int count, float *HWY_RESTRICT out) { + const hn::CappedTag d; + const hn::Rebind db; + const hn::Rebind ds; + const int lanes = static_cast(hn::Lanes(d)); + for (size_t base = 0; base < static_cast(count); base += lanes) { + const int active = static_cast(std::min(static_cast(lanes), static_cast(count) - base)); + int64_t totals[16] = {}; + for (int first = 0; first < groups;) { + const int end = first + std::min(8192, groups - first); + auto sum = hn::Zero(d); + for (int g = first; g < end; ++g) { + auto packed = hn::PromoteTo(d, hn::LoadN(db, codes + static_cast(g / 2) * stride + lane + base, active)); + const auto indices = hn::And(hn::ShiftRightSame(packed, 4 * (g & 1)), hn::Set(d, 15)); +#if HWY_TARGET <= HWY_AVX3 + if constexpr (hn::MaxLanes(d) == 16) { + const auto table = hn::PromoteTo(d, hn::LoadU(ds, lut + static_cast(g) * 32)); + sum = hn::Add(sum, hn::TableLookupLanes(table, hn::IndicesFromVec(d, indices))); + } else +#endif + { + int32_t table[16]; + for (int i = 0; i < 16; ++i) table[i] = lut[static_cast(g) * 32 + i]; + sum = hn::Add(sum, hn::GatherIndex(d, table, indices)); + } + } + int32_t partial[16]; + hn::StoreU(sum, d, partial); + for (int i = 0; i < active; ++i) totals[i] += partial[i]; + first = end; + } + for (int i = 0; i < active; ++i) out[base + i] = static_cast(totals[i]) * 0.25f; + } +} +#endif +#endif diff --git a/jvector-native/src/main/native/src/build_native_lib.sh b/jvector-native/src/main/native/src/build_native_lib.sh index 060b1b7a0..97f45e628 100755 --- a/jvector-native/src/main/native/src/build_native_lib.sh +++ b/jvector-native/src/main/native/src/build_native_lib.sh @@ -2,8 +2,57 @@ # fail on error set -e -# print commands as they are executed -set -x +# Quiet by default: Meson and Ninja output goes to a log in the build directory and is shown +# only if a step fails. JVECTOR_NATIVE_BUILD_VERBOSE=1 prints everything, including each command. +VERBOSE="${JVECTOR_NATIVE_BUILD_VERBOSE:-}" +if [ -n "${VERBOSE}" ]; then set -x; fi + +# Prints a status line only in verbose mode. +info() { + if [ -n "${VERBOSE}" ]; then printf "$@"; fi +} + +# A library's progress lines: the start line only for a first (unconfigured) build, which is the slow +# case, and the finish line only when Ninja rebuilt something, so an up-to-date build prints nothing. +# Usage: announce_start ; report_built +announce_start() { + if [[ ! -f "$1/meson-private/coredata.dat" ]]; then + echo "jvector-native: building $2 (the first build can take $3)" + fi +} +report_built() { + if [ -z "${VERBOSE}" ] && grep -q "ninja: no work to do" "$1" 2>/dev/null; then return; fi + echo "jvector-native: built $2 in $((SECONDS - $3)) s" +} + +# Configures only when it is new or its setup arguments changed. Reconfiguring an +# unchanged build regenerates the oneDNN CMake subproject and forces a relink on every build; +# meson compile still regenerates by itself when a meson.build file changes. +# Usage: setup_if_needed +setup_if_needed() { + local build_dir="$1" source_dir="$2"; shift 2 + local stamp="${build_dir}.setup-args" wanted="${source_dir} $*" + if [[ -f "${build_dir}/meson-private/coredata.dat" ]]; then + if [[ -f "${stamp}" && "$(cat "${stamp}")" == "${wanted}" ]]; then return; fi + set -- --reconfigure "$@" + fi + run_step "${build_dir}.setup.log" meson setup "${build_dir}" "${source_dir}" "$@" + printf '%s' "${wanted}" > "${stamp}" +} + +# Runs a build step. Quiet mode keeps its output in and prints the log's end on failure. +# Usage: run_step +run_step() { + local log="$1"; shift + if [ -n "${VERBOSE}" ]; then "$@"; return; fi + mkdir -p "$(dirname "${log}")" + if ! "$@" > "${log}" 2>&1; then + echo "ERROR: native build step failed: $*" >&2 + tail -n 60 "${log}" >&2 + echo "Full log: ${log} (rerun with JVECTOR_NATIVE_BUILD_VERBOSE=1 to print everything)" >&2 + exit 1 + fi +} # Copyright DataStax, Inc. # @@ -27,16 +76,16 @@ set -x # --------------------------------------------------------------------------- REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" NATIVE_DIR="${REPO_ROOT}/jvector-native/src/main/native" +# Separate Meson project for the optional oneDNN add-on (libjvector_ash_fp16). +ONEDNN_DIR="${REPO_ROOT}/jvector-native/src/main/onednn" MODULE_ROOT="${REPO_ROOT}/jvector-native" - -HIGHWAY_DIR="${NATIVE_DIR}/third_party/highway" # Drop the .so files into target/meson-build/ so that mvn clean removes them # along with every other build artefact, and Maven's resource plugin picks them # up from there instead of from src/main/resources/. RESOURCES_DIR="${MODULE_ROOT}/target/meson-build" if [ "$1" == "--auto-install-deps" ] ; then AUTO_INSTALL_DEPS=true ; shift ; fi -printf "AUTO_INSTALL_DEPS=%s\n" "${AUTO_INSTALL_DEPS}" +info "AUTO_INSTALL_DEPS=%s\n" "${AUTO_INSTALL_DEPS}" # Accept buildtype parameter (default: release) BUILDTYPE="${1:-release}" @@ -45,28 +94,30 @@ if [ "$BUILDTYPE" != "release" ] && [ "$BUILDTYPE" != "debug" ] && [ "$BUILDTYPE echo " Valid values: release, debug, debugoptimized" BUILDTYPE="release" fi -printf "BUILDTYPE=%s\n" "${BUILDTYPE}" +info "BUILDTYPE=%s\n" "${BUILDTYPE}" # Accept crossarch flag (default: false). # When true, builds both the native arch library AND cross-compiles for the other arch. # On x86_64: builds libjvector-x86_64.so + cross-compiles libjvector-aarch64.so # On aarch64: builds libjvector-aarch64.so + cross-compiles libjvector-x86_64.so CROSSARCH="${2:-false}" -printf "CROSSARCH=%s\n" "${CROSSARCH}" +info "CROSSARCH=%s\n" "${CROSSARCH}" + +# Optional oneDNN add-on for FP32 encoding batches and FP16/INT8 queries, built +# from its own Meson project; libjvector stays independent. Off by default because +# building oneDNN takes minutes; CI and release builds pass true. +ONEDNN="${3:-false}" +case "${ONEDNN}" in + true|false) ;; + *) echo "ERROR: oneDNN flag must be true or false."; exit 1 ;; +esac mkdir -p "${RESOURCES_DIR}" # target/meson-build/ is under target/, which IS cleaned by mvn clean. # src/main/resources/ is intentionally NOT written to any more. -# Check that the Google Highway submodule has been initialised -if [ ! -f "${HIGHWAY_DIR}/hwy/highway.h" ]; then - echo "ERROR: Google Highway submodule not found at ${HIGHWAY_DIR}." - echo " Run the following command from the repository root to fix this:" - echo "" - echo " git submodule update --init" - echo "" - exit 1 -fi +# Meson fetches Google Highway at the revision pinned in +# ${NATIVE_DIR}/subprojects/highway.wrap on first setup. # Desired minimum GCC version MIN_GCC_VERSION=11 @@ -92,12 +143,15 @@ require_cmd() { require_cmd meson "sudo apt-get install -y meson" require_cmd ninja "sudo apt-get install -y ninja-build" +if [[ "${ONEDNN}" == "true" ]]; then + require_cmd cmake "sudo apt-get install -y cmake" +fi # --------------------------------------------------------------------------- # Detect the host CPU architecture. # --------------------------------------------------------------------------- HOST_ARCH="$(uname -m)" # e.g. x86_64 or aarch64 -printf "HOST_ARCH=%s\n" "${HOST_ARCH}" +info "HOST_ARCH=%s\n" "${HOST_ARCH}" # --------------------------------------------------------------------------- # build_native @@ -112,6 +166,9 @@ build_native() { rm -rf "${OUT_SO}" + # Configured builds are reused, so repeated Maven phases keep incremental native outputs. + local cross_options=() + if [ "${ARCH}" == "${HOST_ARCH}" ]; then # ---- Native build ------------------------------------------------------ require_cmd g++ "sudo apt-get install -y g++" @@ -122,9 +179,6 @@ build_native() { exit 1 fi - meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ - --wipe \ - --buildtype="${BUILDTYPE}" else # ---- Cross-compile build ----------------------------------------------- # Only x86_64 <-> aarch64 is supported. @@ -141,14 +195,13 @@ build_native() { fi require_cmd "${CROSS_TOOLCHAIN}" "${CROSS_INSTALL}" - - meson setup "${BUILD_DIR}" "${NATIVE_DIR}" \ - --wipe \ - --cross-file "${CROSS_FILE}" \ - --buildtype="${BUILDTYPE}" + cross_options=(--cross-file "${CROSS_FILE}") fi - meson compile -C "${BUILD_DIR}" + announce_start "${BUILD_DIR}" "the native library for ${ARCH}" "a few minutes" + local started=${SECONDS} + setup_if_needed "${BUILD_DIR}" "${NATIVE_DIR}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" + run_step "${BUILD_DIR}.compile.log" meson compile -C "${BUILD_DIR}" # The versioned .so (e.g. libjvector.so.0.1.0) is the real file; symlinks point to it. SOFILE=$(find "${BUILD_DIR}" -maxdepth 1 -name 'libjvector.so.*' -type f | head -1) @@ -157,7 +210,24 @@ build_native() { exit 1 fi cp "${SOFILE}" "${OUT_SO}" - printf "Built: %s\n" "${OUT_SO}" + report_built "${BUILD_DIR}.compile.log" "$(basename "${OUT_SO}")" "${started}" + + local FP16_OUT="${RESOURCES_DIR}/libjvector_ash_fp16-${ARCH}.so" + if [ "${ONEDNN}" == "true" ]; then + local ONEDNN_BUILD_DIR="${MODULE_ROOT}/target/meson-build-onednn-${ARCH}" + announce_start "${ONEDNN_BUILD_DIR}" "the oneDNN add-on for ${ARCH}" "several minutes" + started=${SECONDS} + setup_if_needed "${ONEDNN_BUILD_DIR}" "${ONEDNN_DIR}" "${cross_options[@]}" --buildtype="${BUILDTYPE}" + run_step "${ONEDNN_BUILD_DIR}.compile.log" meson compile -C "${ONEDNN_BUILD_DIR}" + cp "${ONEDNN_BUILD_DIR}/libjvector_ash_fp16.so" "${FP16_OUT}" + report_built "${ONEDNN_BUILD_DIR}.compile.log" "$(basename "${FP16_OUT}")" "${started}" + # License texts travel with the optional static dependency in either jar. + cp "${ONEDNN_DIR}/subprojects/onednn/LICENSE" "${RESOURCES_DIR}/oneDNN-LICENSE.txt" + cp "${ONEDNN_DIR}/subprojects/onednn/THIRD-PARTY-PROGRAMS" "${RESOURCES_DIR}/oneDNN-THIRD-PARTY.txt" + else + # A later build without the add-on must not package a stale enabled build. + rm -f "${FP16_OUT}" + fi } # --------------------------------------------------------------------------- @@ -172,6 +242,15 @@ else exit 1 fi +if [ "${ONEDNN}" == "false" ]; then + # Maven copies resources without removing old files from target/classes. + # Remove only this optional component from both resource locations. + for resource_dir in "${RESOURCES_DIR}" "${MODULE_ROOT}/target/classes"; do + rm -f "${resource_dir}"/libjvector_ash_fp16-*.so \ + "${resource_dir}/oneDNN-LICENSE.txt" "${resource_dir}/oneDNN-THIRD-PARTY.txt" + done +fi + # Always build the native arch. build_native "${HOST_ARCH}" diff --git a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp index ae8ab73bb..9f7b597b9 100644 --- a/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp +++ b/jvector-native/src/main/native/src/jvector_avx3_dl_kernels.cpp @@ -32,6 +32,16 @@ namespace hn = hwy::HWY_NAMESPACE; +#include +#include + namespace AVX3_DL { +#define JVECTOR_ASH_PAIR_ONLY +#include "ash_symmetric-inl.h" +// The INT8 neighborhood kernel needs VBMI byte permutes and multishifts and VNNI dot products. +#define JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY +#include "ash_neighborhood-inl.h" +#undef JVECTOR_ASH_INT8_NEIGHBORHOOD_ONLY +#undef JVECTOR_ASH_PAIR_ONLY } // namespace AVX3_DL diff --git a/jvector-native/src/main/native/src/jvector_simd.cpp b/jvector-native/src/main/native/src/jvector_simd.cpp index ec69d5cfa..a2ce74f33 100644 --- a/jvector-native/src/main/native/src/jvector_simd.cpp +++ b/jvector-native/src/main/native/src/jvector_simd.cpp @@ -107,11 +107,14 @@ static const KernelVTable AVX3_vtable = { }; #undef KERNEL_ENTRY -// AVX3_DL (Ice Lake) inherits all slots from AVX3 unchanged for now. +// AVX3_DL inherits AVX3 and uses native popcount/byte-dot for ASH pairs. // To override a slot: t.kernel_name = AVX3_DL::kernel_name; // The implementation must exist in jvector_avx3_dl_kernels.cpp. static const KernelVTable AVX3_DL_vtable = []() { KernelVTable t = AVX3_vtable; + t.ash_symmetric_dot = AVX3_DL::ash_symmetric_dot; + t.ash_symmetric_dot_rows = AVX3_DL::ash_symmetric_dot_rows; + t.ash_score_neighborhood_i8_c1 = AVX3_DL::ash_score_neighborhood_i8_c1; return t; }(); diff --git a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h index 03ec7f809..8e995acd1 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernel_list.h +++ b/jvector-native/src/main/native/src/jvector_simd_kernel_list.h @@ -31,6 +31,25 @@ #ifndef JVECTOR_SIMD_KERNEL_LIST_H #define JVECTOR_SIMD_KERNEL_LIST_H +// ASH C=1 ABI contract: +// codes: ceil(capacity/B) complete blocks, each ceil(groups/2)*B + 5*B bytes. +// lut: groups*16 read-only floats, out: capacity writable floats, no aliasing. +// groups/capacity are positive, B is 8/16/32. Inputs use FusedASHLayout bytes. +// Return 0 on success, 1 for a nonzero landmark, 2 for an invalid shape/pointer. +// Output may be partially written on error. No allocation, pointer retention, +// callbacks, or blocking are permitted: ordinary graph calls use critical FFM. + +// ASH preparation/symmetric ABI contracts (all pointers are borrowed synchronously): +// project: positive rows/cols, row-major matrix[rows*cols], query[cols], out[rows]. +// batch: nonnegative count; input[count*cols] and out[count*rows] are row-major. +// Matrix is tiled in output groups of 32, each [cols][tileRows]; the +// last tile is not padded. FP32, no batch-size cap or internal threads. +// pair: nonnegative dimensions, bits in {1,2,4}; binary uint64 words for bits=1, +// canonical little-endian packed bytes otherwise. Padding bits are ignored. +// LUT: nonnegative groups/count, positive stride, 0 <= lane <= lane+count <= stride; +// codes[ceil(groups/2)*stride], lut[groups*32] signed shorts, out[count]. +// Writable outputs must not alias read-only inputs. Callers validate buffer sizes. + // clang-format off #define JVECTOR_SIMD_KERNEL_LIST \ /* Vector similarity */ \ @@ -60,6 +79,20 @@ KERNEL_ENTRY(void, calculate_partial_sums_dot_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ KERNEL_ENTRY(void, calculate_partial_sums_euclidean_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, const float *query, int queryOffset, float *partialSums), (codebook, codebookIndex, size, clusterCount, query, queryOffset, partialSums)) \ KERNEL_ENTRY(void, calculate_partial_sums_self_magnitude_f32, (const float *codebook, int codebookIndex, size_t size, int clusterCount, float *partialSums), (codebook, codebookIndex, size, clusterCount, partialSums)) \ + /* ASH preparation and exact packed construction scoring; synchronous leaf calls. */ \ + KERNEL_ENTRY(void, ash_project_f32, (const float *matrix, const float *query, int rows, int cols, float *out), (matrix, query, rows, cols, out)) \ + KERNEL_ENTRY(void, ash_project_batch_f32, (const float *matrix, const float *inputs, int rows, int cols, int count, float *out), (matrix, inputs, rows, cols, count, out)) \ + KERNEL_ENTRY(float, ash_symmetric_dot, (const void *a, const void *b, int dimensions, int bits), (a, b, dimensions, bits)) \ + KERNEL_ENTRY(void, ash_symmetric_dot_rows, (const void *query, const void *rows, int stride, int count, int dimensions, int bits, float *out), (query, rows, stride, count, dimensions, bits, out)) \ + KERNEL_ENTRY(void, ash_symmetric_lut_i16, (const unsigned char *codes, const int16_t *lut, int groups, int stride, int lane, int count, float *out), (codes, lut, groups, stride, lane, count, out)) \ + /* Canonical ASH dot using the prepared query LUT. */ \ + KERNEL_ENTRY(float, ash_canonical_lut_dot_f32, (const unsigned char *code, const float *lut, int groups), (code, lut, groups)) \ + /* Contiguous canonical 4-bit dot; avoids the 16x larger query LUT. */ \ + KERNEL_ENTRY(float, ash_projection_dot_4bit_f32, (const float *query, const unsigned char *code, int dimensions), (query, code, dimensions)) \ + /* Whole-neighborhood C=1 ASH, one downcall including headers and similarity. */ \ + KERNEL_ENTRY(int, ash_score_neighborhood_c1_f32, (const unsigned char *codes, const float *lut, float landmarkDot, int groups, int blockSize, int capacity, float *out), (codes, lut, landmarkDot, groups, blockSize, capacity, out)) \ + /* Whole-neighborhood C=1 ASH for offset-binary codes (3, 5-9 bits) with an INT8 query. */ \ + KERNEL_ENTRY(int, ash_score_neighborhood_i8_c1, (const unsigned char *codes, const int8_t *q, float invScale, float center, float landmarkDot, int bits, int units, int blockSize, int capacity, float *out), (codes, q, invScale, center, landmarkDot, bits, units, blockSize, capacity, out)) \ /* NVQ kernels */ \ KERNEL_ENTRY(void, nvq_quantize_8bit, (const float *vector, size_t length, float alpha, float x0, float minValue, float maxValue, unsigned char *destination), (vector, length, alpha, x0, minValue, maxValue, destination)) \ KERNEL_ENTRY(float, nvq_loss, (const float *vector, size_t length, float alpha, float x0, float minValue, float maxValue, int nBits), (vector, length, alpha, x0, minValue, maxValue, nBits)) \ diff --git a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp index 69c7fc062..d2a38e2a3 100644 --- a/jvector-native/src/main/native/src/jvector_simd_kernels.cpp +++ b/jvector-native/src/main/native/src/jvector_simd_kernels.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include "jvector_simd.h" #include "hwy/highway.h" #include "assert_hwy_targets.h" @@ -381,6 +382,10 @@ HWY_INLINE float L2SquareDistance(const float *a, namespace JV_ISA { +#include "ash_neighborhood-inl.h" +#include "ash_projection-inl.h" +#include "ash_symmetric-inl.h" + HWY_FLATTEN float cosine_f32( const float *a, size_t aoffset, const float *b, size_t boffset, size_t length) { diff --git a/jvector-native/src/main/native/subprojects/.gitignore b/jvector-native/src/main/native/subprojects/.gitignore new file mode 100644 index 000000000..cd52905d3 --- /dev/null +++ b/jvector-native/src/main/native/subprojects/.gitignore @@ -0,0 +1,3 @@ +/highway/ +/packagecache/ +/.wraplock diff --git a/jvector-native/src/main/native/subprojects/highway.wrap b/jvector-native/src/main/native/subprojects/highway.wrap new file mode 100644 index 000000000..54b3a4c8c --- /dev/null +++ b/jvector-native/src/main/native/subprojects/highway.wrap @@ -0,0 +1,24 @@ +# +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Highway is used header-only (each ISA tier is compiled with +# HWY_COMPILE_ONLY_STATIC), so only its include directory and abort.cc are needed. +[wrap-git] +directory = highway +url = https://github.com/google/highway.git +revision = 16f5f5c768064d0bb52952888a14236bec75c104 +depth = 1 +patch_directory = highway diff --git a/jvector-native/src/main/native/subprojects/packagefiles/highway/meson.build b/jvector-native/src/main/native/subprojects/packagefiles/highway/meson.build new file mode 100644 index 000000000..1c04d6a0b --- /dev/null +++ b/jvector-native/src/main/native/subprojects/packagefiles/highway/meson.build @@ -0,0 +1,27 @@ +# +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Overlay build file for the Highway wrap. Highway's own CMake build compiles its +# dynamic-dispatch runtime, which jvector does not use: each ISA tier is compiled +# with HWY_COMPILE_ONLY_STATIC, so only the headers and hwy::Abort are needed. +project('highway', 'cpp') + +highway_dep = declare_dependency( + include_directories: include_directories('.'), +) +# abort.cc was split out of targets.cc for projects that do their own dispatch +# (google/highway#1935). +highway_abort_src = files('hwy/abort.cc') diff --git a/jvector-native/src/main/native/third_party/highway b/jvector-native/src/main/native/third_party/highway deleted file mode 160000 index 16f5f5c76..000000000 --- a/jvector-native/src/main/native/third_party/highway +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 16f5f5c768064d0bb52952888a14236bec75c104 diff --git a/jvector-native/src/main/onednn/meson.build b/jvector-native/src/main/onednn/meson.build new file mode 100644 index 000000000..9a165a662 --- /dev/null +++ b/jvector-native/src/main/onednn/meson.build @@ -0,0 +1,78 @@ +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Optional ASH add-on: FP32 batch encoding and FP16/INT8 query projection with +# sequential oneDNN. It is a separate project from the Highway library +# (../native) and builds libjvector_ash_fp16, which Java loads on its own; +# libjvector never depends on it. +# +# oneDNN is linked statically so the packaged add-on is one self-contained +# library with no runtime dependency on a system oneDNN; oneDNN's own runtime +# dispatcher still selects the instructions the host CPU supports. Building +# oneDNN takes a few minutes, which is why Maven builds this project only with +# -Dnative.onednn=true (CI and the release profile turn it on). +project('jvector_ash_onednn', 'cpp', + version: '0.1.0', + meson_version: '>= 0.55.0', + default_options: ['cpp_std=c++17'] +) + +cmake = import('cmake') +onednn_options = cmake.subproject_options() +onednn_options.add_cmake_defines({ + 'CMAKE_POSITION_INDEPENDENT_CODE': true, + 'ONEDNN_LIBRARY_TYPE': 'STATIC', + 'ONEDNN_CPU_RUNTIME': 'SEQ', + 'ONEDNN_GPU_RUNTIME': 'NONE', + 'ONEDNN_BUILD_GRAPH': false, + 'ONEDNN_BUILD_TESTS': false, + 'ONEDNN_BUILD_EXAMPLES': false, + 'ONEDNN_ENABLE_PRIMITIVE': 'MATMUL;REORDER', + 'ONEDNN_ENABLE_WORKLOAD': 'INFERENCE', + 'ONEDNN_ARCH_OPT_FLAGS': '', +}) +onednn_project = cmake.subproject('onednn', options: onednn_options) +# oneDNN's aggregate archive has no source files of its own. Meson's CMake +# converter marks that aggregate non-PIC, although all constituent object +# libraries are PIC. Re-archive those objects with the correct metadata. +onednn_archive_inputs = [onednn_project.target('dnnl').extract_all_objects(recursive: true)] +# The CMake build links KleidiAI as a separate static target on AArch64. +if 'kleidiai_ops' in onednn_project.target_list() + onednn_archive_inputs += [onednn_project.target('kleidiai_ops').extract_all_objects(recursive: true)] +endif +onednn_objects = static_library('ash_onednn', + objects: onednn_archive_inputs, + pic: true, +) +onednn_dependency = onednn_project.dependency('dnnl').partial_dependency( + compile_args: true, includes: true, link_args: true) +ash_fp16_lib = shared_library('jvector_ash_fp16', + sources: ['src/jvector_ash_fp16.cpp', 'src/jvector_ash_int8.cpp'], + dependencies: onednn_dependency, + link_with: onednn_objects, + cpp_args: ['-fvisibility=hidden'], + link_args: ['-Wl,--exclude-libs,ALL'], + install: true, +) + +gtest_dep = dependency('gtest_main', required: false) +if gtest_dep.found() + ash_fp16_tests = executable('test_ash_fp16', + sources: 'tests/test_ash_fp16.cpp', + include_directories: include_directories('src'), + dependencies: gtest_dep, + link_with: ash_fp16_lib, + ) + test('ash_fp16', ash_fp16_tests, protocol: 'gtest', suite: 'ash_fp16') +endif diff --git a/jvector-native/src/main/onednn/src/jvector_ash_fp16.cpp b/jvector-native/src/main/onednn/src/jvector_ash_fp16.cpp new file mode 100644 index 000000000..1d234ecaa --- /dev/null +++ b/jvector-native/src/main/onednn/src/jvector_ash_fp16.cpp @@ -0,0 +1,365 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "jvector_ash_fp16.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Calling workers own parallelism. This library must never start a nested pool. +static_assert(DNNL_CPU_RUNTIME == DNNL_RUNTIME_SEQ, + "ASH projection requires the sequential oneDNN CPU runtime"); + +static_assert(offsetof(jvector_ash_fp16_result, message) == sizeof(int32_t)); +static_assert(sizeof(jvector_ash_fp16_result) == sizeof(int32_t) + 256); + +namespace { +using namespace dnnl; +std::atomic next_id{1}; +int set_error(jvector_ash_fp16_result* result, int status, const char* message) noexcept { + result->status = status; + std::snprintf(result->message, sizeof(result->message), "%s", message); + return status; +} + +bool representable(const float* values, size_t count) noexcept { + for (size_t i = 0; i < count; ++i) { + if (!std::isfinite(values[i]) || std::fabs(values[i]) > 65504.0f) return false; + } + return true; +} + +struct Unsupported : std::runtime_error { using std::runtime_error::runtime_error; }; + +// A bounded primitive cache is an implementation tile, not an API batch limit. +constexpr int kBatchTile = 8; +struct BatchKernel { + matmul::primitive_desc descriptor; + matmul operation; + memory weights; + BatchKernel(const engine& cpu, int rows, int columns, int count) { + primitive_attr attributes; + attributes.set_scratchpad_mode(scratchpad_mode::user); + attributes.set_fpmath_mode(fpmath_mode::strict); + attributes.set_accumulation_mode(accumulation_mode::strict); + // GEMV and GEMM need different packing. Let oneDNN choose the batch + // layout; Context shares one packed matrix between compatible kernels. + descriptor = matmul::primitive_desc(cpu, + memory::desc({count, columns}, memory::data_type::f16, memory::format_tag::ab), + memory::desc({columns, rows}, memory::data_type::f16, memory::format_tag::any), + memory::desc({count, rows}, memory::data_type::f32, memory::format_tag::ab), attributes); + if (std::strncmp(descriptor.impl_info_str(), "ref", 3) == 0) + throw Unsupported("No optimized oneDNN FP16 batch kernel supports this layout"); + operation = matmul(descriptor); + } +}; + +struct Context { + const uint64_t id = next_id.fetch_add(1, std::memory_order_relaxed); + const int rows, columns; + engine cpu{engine::kind::cpu, 0}; + matmul::primitive_desc descriptor; + matmul operation; + memory weights; + uint64_t weight_bytes = 0; + std::atomic retained_bytes{0}; + std::array, kBatchTile + 1> batch_ready{}; + std::array, kBatchTile + 1> batches{}; + std::mutex batch_mutex; + + const BatchKernel& batch(int count) { + auto* ready = batch_ready[count].load(std::memory_order_acquire); + if (!ready) { + std::lock_guard guard(batch_mutex); + ready = batch_ready[count].load(std::memory_order_relaxed); + if (!ready) { + auto kernel = std::make_unique(cpu, rows, columns, count); + uint64_t added = sizeof(BatchKernel); + if (kernel->descriptor.weights_desc() == weights.get_desc()) kernel->weights = weights; + else { + for (const auto& other : batches) { + if (other && other->weights.get_desc() == kernel->descriptor.weights_desc()) { + kernel->weights = other->weights; + break; + } + } + if (!kernel->weights) { + kernel->weights = memory(kernel->descriptor.weights_desc(), cpu); + stream setup(cpu); + reorder(weights, kernel->weights).execute(setup, weights, kernel->weights); + setup.wait(); + added += kernel->descriptor.weights_desc().get_size(); + } + } + batches[count] = std::move(kernel); + retained_bytes.fetch_add(added, std::memory_order_release); + ready = batches[count].get(); + batch_ready[count].store(ready, std::memory_order_release); + } + } + return *ready; + } + + Context(const float* matrix, int r, int c) : rows(r), columns(c) { + for (auto& entry : batch_ready) entry.store(nullptr, std::memory_order_relaxed); + // A is row-major [rows][columns]. Its transpose is the [columns][rows] + // right operand, represented by strides instead of a second FP32 copy. + const memory::dims shape{columns, rows}, strides{1, columns}; + auto source = memory(memory::desc(shape, memory::data_type::f32, strides), + cpu, const_cast(matrix)); + auto half = memory(memory::desc(shape, memory::data_type::f16, strides), cpu); + stream setup(cpu); + reorder(source, half).execute(setup, source, half); + setup.wait(); + + primitive_attr attributes; + attributes.set_scratchpad_mode(scratchpad_mode::user); + attributes.set_fpmath_mode(fpmath_mode::strict); + attributes.set_accumulation_mode(accumulation_mode::strict); + descriptor = matmul::primitive_desc(cpu, + memory::desc({1, columns}, memory::data_type::f16, memory::format_tag::ab), + memory::desc(shape, memory::data_type::f16, memory::format_tag::any), + memory::desc({1, rows}, memory::data_type::f32, memory::format_tag::ab), + attributes); + if (std::strncmp(descriptor.impl_info_str(), "ref", 3) == 0) + throw Unsupported("No optimized oneDNN FP16 kernel supports this model"); + operation = matmul(descriptor); + if (descriptor.weights_desc() == half.get_desc()) weights = std::move(half); + else { + weights = memory(descriptor.weights_desc(), cpu); + reorder(half, weights).execute(setup, half, weights); + setup.wait(); + } + weight_bytes = descriptor.weights_desc().get_size(); + retained_bytes.store(weight_bytes + sizeof(Context), std::memory_order_relaxed); + // Only the prepared representation survives construction. In particular, + // an additional unneeded copy of the FP16 matrix is not retained. + } +}; + +// Calling-thread state owns scratch and library handles, never a Context or its +// weights. Switching models replaces this state; it cannot keep a model alive. +struct Workspace { + uint64_t context_id = 0; + std::unique_ptr execution; + memory source_f32, source_f16, output, scratch; + reorder conversion; + + void initialize(const Context& context, const float* input, float* out) { + // Invalidate old state first: a failed allocation while switching models + // must not leave the previous id paired with partially replaced buffers. + context_id = 0; + execution = std::make_unique(context.cpu); + source_f32 = memory(memory::desc({1, context.columns}, memory::data_type::f32, + memory::format_tag::ab), context.cpu, const_cast(input)); + source_f16 = memory(context.descriptor.src_desc(), context.cpu); + output = memory(context.descriptor.dst_desc(), context.cpu, out); + scratch = memory(context.descriptor.scratchpad_desc(), context.cpu); + conversion = reorder(source_f32, source_f16); + context_id = context.id; // Publish only after every allocation succeeds. + } +}; +thread_local Workspace workspace; + +struct BatchWorkspace { + uint64_t context_id = 0; + std::unique_ptr execution; + memory source_f32, source_f16, output, scratch; + reorder conversion; + + void initialize(const Context& context, const BatchKernel& kernel, int count, + const float* input, float* out) { + context_id = 0; + execution = std::make_unique(context.cpu); + source_f32 = memory(memory::desc({count, context.columns}, memory::data_type::f32, + memory::format_tag::ab), context.cpu, const_cast(input)); + source_f16 = memory(kernel.descriptor.src_desc(), context.cpu); + output = memory(kernel.descriptor.dst_desc(), context.cpu, out); + scratch = memory(kernel.descriptor.scratchpad_desc(), context.cpu); + conversion = reorder(source_f32, source_f16); + context_id = context.id; + } +}; +// Separate slots prevent changing natural batch sizes from allocating on every +// call. They hold caller scratch and handles, never a Context or its weights. +thread_local std::array batch_workspaces; +} + +extern "C" { +int jvector_ash_fp16_abi(void) { return 1; } + +void* jvector_ash_fp16_create(const float* matrix, int rows, int columns, jvector_ash_fp16_result* result) { + if (!result) return nullptr; + result->message[0] = '\0'; + result->status = JV_ASH_FP16_INVALID_ARGUMENT; + if (!matrix || rows <= 0 || columns <= 0 + || uint64_t(rows) * uint64_t(columns) > std::numeric_limits::max() / sizeof(float)) { + set_error(result, result->status, "Invalid matrix pointer or dimensions"); return nullptr; + } + if (!representable(matrix, size_t(rows) * size_t(columns))) { + set_error(result, JV_ASH_FP16_RANGE, "Projection matrix is outside the finite FP16 range"); + return nullptr; + } + try { + auto prepared = std::make_unique(matrix, rows, columns); + result->status = JV_ASH_FP16_OK; + return prepared.release(); + } catch (const Unsupported& e) { + set_error(result, JV_ASH_FP16_UNSUPPORTED, e.what()); + } catch (const dnnl::error& e) { + set_error(result, e.status == dnnl_unimplemented ? JV_ASH_FP16_UNSUPPORTED + : e.status == dnnl_out_of_memory ? JV_ASH_FP16_NO_MEMORY + : JV_ASH_FP16_ERROR, e.what()); + } catch (const std::bad_alloc& e) { + set_error(result, JV_ASH_FP16_NO_MEMORY, e.what()); + } catch (const std::exception& e) { + set_error(result, JV_ASH_FP16_ERROR, e.what()); + } catch (...) { set_error(result, JV_ASH_FP16_ERROR, "Unknown projection initialization failure"); } + return nullptr; +} + +void jvector_ash_fp16_destroy(void* pointer) { delete static_cast(pointer); } + +uint64_t jvector_ash_fp16_bytes(const void* pointer) { + return pointer ? static_cast(pointer)->retained_bytes.load(std::memory_order_acquire) : 0; +} + +int jvector_ash_fp16_project(void* pointer, const float* input, float* output, + jvector_ash_fp16_result* result) { + if (!result) return JV_ASH_FP16_INVALID_ARGUMENT; + result->message[0] = '\0'; + result->status = JV_ASH_FP16_OK; + if (!pointer || !input || !output || input == output) + return set_error(result, JV_ASH_FP16_INVALID_ARGUMENT, "Invalid or aliased projection buffers"); + auto& context = *static_cast(pointer); + if (!representable(input, context.columns)) + return set_error(result, JV_ASH_FP16_RANGE, "Query is outside the finite FP16 range"); + try { + auto& buffers = workspace; + if (buffers.context_id != context.id) buffers.initialize(context, input, output); + buffers.source_f32.set_data_handle(const_cast(input)); + buffers.output.set_data_handle(output); + // Stack argument arrays avoid the allocating C++ map overloads. + const dnnl_exec_arg_t conversion[] = { + {DNNL_ARG_FROM, buffers.source_f32.get()}, {DNNL_ARG_TO, buffers.source_f16.get()}}; + auto status = dnnl_primitive_execute(buffers.conversion.get(), + buffers.execution->get(), 2, conversion); + if (status != dnnl_success) return set_error(result, status == dnnl_out_of_memory + ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, "FP16 input conversion failed"); + const dnnl_exec_arg_t arguments[] = {{DNNL_ARG_SRC, buffers.source_f16.get()}, + {DNNL_ARG_WEIGHTS, context.weights.get()}, {DNNL_ARG_DST, buffers.output.get()}, + {DNNL_ARG_SCRATCHPAD, buffers.scratch.get()}}; + status = dnnl_primitive_execute(context.operation.get(), buffers.execution->get(), 4, arguments); + if (status != dnnl_success) return set_error(result, status == dnnl_out_of_memory + ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, "FP16 projection execution failed"); + buffers.execution->wait(); + return JV_ASH_FP16_OK; + } catch (const dnnl::error& e) { return set_error(result, e.status == dnnl_out_of_memory + ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, e.what()); } + catch (const std::bad_alloc& e) { return set_error(result, JV_ASH_FP16_NO_MEMORY, e.what()); } + catch (const std::exception& e) { return set_error(result, JV_ASH_FP16_ERROR, e.what()); } + catch (...) { return set_error(result, JV_ASH_FP16_ERROR, "Unknown projection execution failure"); } +} + +int jvector_ash_fp16_project_batch(void* pointer, const float* input, int count, + float* output, jvector_ash_fp16_result* result) { + if (!result) return JV_ASH_FP16_INVALID_ARGUMENT; + result->message[0] = '\0'; + result->status = JV_ASH_FP16_OK; + if (!pointer || !input || !output || input == output || count <= 0) + return set_error(result, JV_ASH_FP16_INVALID_ARGUMENT, "Invalid FP16 batch buffers or count"); + auto& context = *static_cast(pointer); + if (uint64_t(count) > std::numeric_limits::max() / sizeof(float) + / uint64_t(std::max(context.rows, context.columns))) + return set_error(result, JV_ASH_FP16_INVALID_ARGUMENT, "FP16 batch size overflow"); + // Validate the entire input before writing any result, including later tiles. + if (!representable(input, size_t(count) * size_t(context.columns))) + return set_error(result, JV_ASH_FP16_RANGE, "Query is outside the finite FP16 range"); + try { + for (int first = 0; first < count;) { + const int size = std::min(kBatchTile, count - first); + const float* in = input + size_t(first) * context.columns; + float* out = output + size_t(first) * context.rows; + if (size == 1) { + int status = jvector_ash_fp16_project(pointer, in, out, result); + if (status != JV_ASH_FP16_OK) return status; + } else { + const auto& kernel = context.batch(size); + auto& buffers = batch_workspaces[size]; + if (buffers.context_id != context.id) + buffers.initialize(context, kernel, size, in, out); + buffers.source_f32.set_data_handle(const_cast(in)); + buffers.output.set_data_handle(out); + const dnnl_exec_arg_t conversion[] = { + {DNNL_ARG_FROM, buffers.source_f32.get()}, {DNNL_ARG_TO, buffers.source_f16.get()}}; + auto status = dnnl_primitive_execute(buffers.conversion.get(), buffers.execution->get(), 2, conversion); + if (status != dnnl_success) return set_error(result, + status == dnnl_out_of_memory ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, + "FP16 batch conversion failed"); + const dnnl_exec_arg_t arguments[] = {{DNNL_ARG_SRC, buffers.source_f16.get()}, + {DNNL_ARG_WEIGHTS, kernel.weights.get()}, {DNNL_ARG_DST, buffers.output.get()}, + {DNNL_ARG_SCRATCHPAD, buffers.scratch.get()}}; + status = dnnl_primitive_execute(kernel.operation.get(), buffers.execution->get(), 4, arguments); + if (status != dnnl_success) return set_error(result, + status == dnnl_out_of_memory ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, + "FP16 batch execution failed"); + buffers.execution->wait(); + } + first += size; + } + return JV_ASH_FP16_OK; + } catch (const Unsupported& e) { return set_error(result, JV_ASH_FP16_UNSUPPORTED, e.what()); } + catch (const dnnl::error& e) { return set_error(result, e.status == dnnl_unimplemented + ? JV_ASH_FP16_UNSUPPORTED : e.status == dnnl_out_of_memory + ? JV_ASH_FP16_NO_MEMORY : JV_ASH_FP16_ERROR, e.what()); } + catch (const std::bad_alloc& e) { return set_error(result, JV_ASH_FP16_NO_MEMORY, e.what()); } + catch (const std::exception& e) { return set_error(result, JV_ASH_FP16_ERROR, e.what()); } + catch (...) { return set_error(result, JV_ASH_FP16_ERROR, "Unknown FP16 batch execution failure"); } +} + +} + +// Row-major FP32 GEMM. This API performs no reduced-precision conversion and +// uses the package's sequential runtime, leaving parallelism with encodeAll. +extern "C" int jvector_ash_fp32_project_batch(const float* matrix, + const float* input, int rows, int columns, int count, float* output) { + if (!matrix || !input || !output || rows <= 0 || columns <= 0 || count <= 0) return 2; + try { + const auto status = dnnl_sgemm('N', 'T', count, rows, columns, + 1.0f, input, columns, matrix, columns, 0.0f, output, rows); + // Keep the add-on's small ABI independent of oneDNN enum extensions. + switch (status) { + case dnnl_success: return 0; + case dnnl_out_of_memory: return 1; + case dnnl_invalid_arguments: return 2; + case dnnl_unimplemented: return 3; + default: return 5; + } + } catch (const std::bad_alloc&) { + return 1; + } catch (...) { + return 5; + } +} diff --git a/jvector-native/src/main/onednn/src/jvector_ash_fp16.h b/jvector-native/src/main/onednn/src/jvector_ash_fp16.h new file mode 100644 index 000000000..1000994f7 --- /dev/null +++ b/jvector-native/src/main/onednn/src/jvector_ash_fp16.h @@ -0,0 +1,92 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once +#include + +#if defined(_WIN32) +#define JV_ASH_FP16_EXPORT __declspec(dllexport) +#else +#define JV_ASH_FP16_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +enum jvector_ash_fp16_status { + JV_ASH_FP16_OK = 0, + JV_ASH_FP16_UNSUPPORTED = 1, + JV_ASH_FP16_INVALID_ARGUMENT = 2, + JV_ASH_FP16_RANGE = 3, + JV_ASH_FP16_NO_MEMORY = 4, + JV_ASH_FP16_ERROR = 5 +}; + +/* Caller-owned status storage avoids a second, thread-affine last-error call. + * In particular, diagnostics remain correct if a Java virtual thread migrates. */ +typedef struct jvector_ash_fp16_result { + int status; + char message[256]; +} jvector_ash_fp16_result; + +JV_ASH_FP16_EXPORT int jvector_ash_fp16_abi(void); +/* Copies a row-major [rows][columns] FP32 matrix and prepares immutable FP16 + * weights. The caller retains its input; result is required and always written. + * Successful contexts may be used concurrently, but must not be destroyed while + * a call is in flight. No C++ exception crosses this ABI. */ +JV_ASH_FP16_EXPORT void* jvector_ash_fp16_create(const float* matrix, int rows, + int columns, jvector_ash_fp16_result* result); +JV_ASH_FP16_EXPORT void jvector_ash_fp16_destroy(void* context); +/* Retained native context, kernel wrappers and distinct packed weights. + * Per-calling-thread workspace and opaque oneDNN/JIT bookkeeping are excluded. */ +JV_ASH_FP16_EXPORT uint64_t jvector_ash_fp16_bytes(const void* context); +/* input has columns floats, output has rows floats; neither may alias the other. + * Finite operands must be within the FP16 finite range. Output is FP32. */ +JV_ASH_FP16_EXPORT int jvector_ash_fp16_project(void* context, const float* input, + float* output, jvector_ash_fp16_result* result); +/* Row-major input [count][columns], output [count][rows]. Caches packed FP16 + * weights by layout; count is positive and has no tile-size cap. + * Conversion and accumulation match singleton projection. No exception crosses + * the ABI. Java validates shape and nonaliasing before entering native code. */ +JV_ASH_FP16_EXPORT int jvector_ash_fp16_project_batch(void* context, const float* input, + int count, float* output, jvector_ash_fp16_result* result); + +/* FP32 bulk projection, using the same sequential oneDNN package. + * Matrix is row-major [rows][columns], input [count][columns], output [count][rows]. + * Java validates nonaliasing storage and lengths. Returns 0 success, 1 allocation + * failure, 2 invalid arguments, 3 unsupported, or 5 runtime failure. */ +JV_ASH_FP16_EXPORT int jvector_ash_fp32_project_batch(const float* matrix, + const float* input, int rows, int columns, int count, float* output); + +/* Explicit query-only INT8 view. Reuses ABI 1 and its status/result structure. + * create requires a qualified x86 VNNI/AMX primitive and finite FP32 operands. + * columns <= 65535 bounds the full-range integer accumulator. Training and + * encoding never use this view. No C++ exception crosses the ABI. + * bytes includes retained plain/packed weights, scales and kernel wrappers; + * caller scratch and opaque oneDNN/JIT allocations are excluded, as for FP16. */ +JV_ASH_FP16_EXPORT void* jvector_ash_int8_create(const float* matrix, int rows, + int columns, jvector_ash_fp16_result* result); +JV_ASH_FP16_EXPORT void jvector_ash_int8_destroy(void* context); +JV_ASH_FP16_EXPORT uint64_t jvector_ash_int8_bytes(const void* context); +/* Contiguous FP32 input [count][columns], output [count][rows]. Positive count + * has no tile-size cap. Java validates shapes/nonaliasing and publishes output + * only after success. On failure the caller must discard native output. */ +JV_ASH_FP16_EXPORT int jvector_ash_int8_project_batch(void* context, + const float* input, int count, float* output, jvector_ash_fp16_result* result); + +#ifdef __cplusplus +} +#endif diff --git a/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp new file mode 100644 index 000000000..235543bd3 --- /dev/null +++ b/jvector-native/src/main/onednn/src/jvector_ash_int8.cpp @@ -0,0 +1,491 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Query-only INT8 representation; FP32/FP16 implementations remain independent. +#include "jvector_ash_fp16.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(__x86_64__) || defined(_M_X64) +#include +#endif + +static_assert(DNNL_CPU_RUNTIME == DNNL_RUNTIME_SEQ, "Calling workers own parallelism"); +static_assert(offsetof(jvector_ash_fp16_result, message) == sizeof(int32_t)); +static_assert(sizeof(jvector_ash_fp16_result) == sizeof(int32_t) + 256); + +namespace { +using namespace dnnl; +constexpr int kBatchTile = 8; // Matches the existing FP16 native execution tile, not an API limit. +constexpr int kMaxColumns = 65535; // 255 * 127 * D remains within signed int32. +std::atomic next_id{1}; +struct Unsupported : std::runtime_error { using std::runtime_error::runtime_error; }; + +int set_error(jvector_ash_fp16_result* result, int status, const char* message) noexcept { + result->status = status; + std::snprintf(result->message, sizeof(result->message), "%s", message); + return status; +} + +// Translate every native failure before it reaches FFM, using the existing ABI-1 statuses. +int current_error(jvector_ash_fp16_result* result) noexcept { + try { throw; } + catch (const Unsupported& e) { return set_error(result, JV_ASH_FP16_UNSUPPORTED, e.what()); } + catch (const dnnl::error& e) { + return set_error(result, e.status == dnnl_unimplemented ? JV_ASH_FP16_UNSUPPORTED + : e.status == dnnl_out_of_memory ? JV_ASH_FP16_NO_MEMORY + : e.status == dnnl_invalid_arguments ? JV_ASH_FP16_INVALID_ARGUMENT + : JV_ASH_FP16_ERROR, e.what()); + } + catch (const std::bad_alloc& e) { return set_error(result, JV_ASH_FP16_NO_MEMORY, e.what()); } + catch (const std::invalid_argument& e) { return set_error(result, JV_ASH_FP16_INVALID_ARGUMENT, e.what()); } + catch (const std::out_of_range& e) { return set_error(result, JV_ASH_FP16_RANGE, e.what()); } + catch (const std::exception& e) { return set_error(result, JV_ASH_FP16_ERROR, e.what()); } + catch (...) { return set_error(result, JV_ASH_FP16_ERROR, "Unknown INT8 projection failure"); } +} + +bool integer_dot_available() { +#if defined(__x86_64__) || defined(_M_X64) + // Honor oneDNN's effective ISA, including its documented runtime ISA cap. + switch (get_effective_cpu_isa()) { + case cpu_isa::avx2_vnni: + case cpu_isa::avx2_vnni_2: + case cpu_isa::avx512_core_vnni: + case cpu_isa::avx512_core_bf16: + case cpu_isa::avx512_core_fp16: + case cpu_isa::avx512_core_amx: + case cpu_isa::avx512_core_amx_fp16: return true; + default: return false; + } +#else + return false; // Arm backends require separate qualification of their exact-dot primitive. +#endif +} + +// Every qualified x86 VNNI/AMX backend includes AVX2. Keep the entry-point +// hardware check generic; specialize only these INT8 computation functions. +// This preserves direct FP32 rounding instructions in the portable library. +#if defined(__x86_64__) || defined(_M_X64) +#define JV_ASH_INT8_TARGET __attribute__((target("avx2"))) +#else +#define JV_ASH_INT8_TARGET +#endif + +// Share the prototype's FP32 quantization between matrix/query preparation. +// Reject a nonzero operand whose scale rounds to zero, before division and +// integer conversion. Do not change the ordinary arithmetic or rounding. +template +JV_ASH_INT8_TARGET float quantize(const float* input, int count, Code* output, int offset, int32_t* sum = nullptr) { + float maxabs = 0; + for (int i = 0; i < count; ++i) { + if (!std::isfinite(input[i])) throw std::invalid_argument("Nonfinite INT8 operand"); + maxabs = std::max(maxabs, std::fabs(input[i])); + } + float scale = maxabs == 0 ? 1.0f : maxabs / 127.0f; + if (scale == 0) throw std::invalid_argument("INT8 operand scale underflows FP32; rescale input"); + int32_t total = 0; + for (int i = 0; i < count; ++i) { + int q = std::clamp(int(std::nearbyint(input[i] / scale)), -127, 127); + output[i] = Code(q + offset); + total += q; + } + if (sum) *sum = total; + return scale; +} + +struct Kernel { + matmul::primitive_desc descriptor; + matmul operation; + memory weights; + Kernel(const engine& cpu, int rows, int columns, int count) { + primitive_attr attributes; + attributes.set_scratchpad_mode(scratchpad_mode::user); + descriptor = matmul::primitive_desc(cpu, + memory::desc({count, columns}, memory::data_type::u8, memory::format_tag::ab), + memory::desc({columns, rows}, memory::data_type::s8, memory::format_tag::any), + memory::desc({count, rows}, memory::data_type::s32, memory::format_tag::ab), attributes); + // The pinned oneDNN exposes the selected implementation through this + // public descriptor query. Accept its VNNI AND AMX integer kernels; + // legacy u8*s8 kernels can saturate at a 16-bit intermediate and must not + // silently compute a different dot product. Do not gate on CPU vendor. + const char* implementation = descriptor.impl_info_str(); + if (!std::strstr(implementation, "vnni") && !std::strstr(implementation, "amx")) + throw Unsupported("No qualified full-range VNNI/AMX INT8 projection kernel"); + operation = matmul(descriptor); + } +}; + +// On every AVX-512 VNNI CPU an exact-integer tile kernel serves every query tile of 1-8 rows: +// u8*s8 sums in int32 are exact, so outputs equal oneDNN's. Across nine 1M datasets it beat +// oneDNN (AMX included) for single rows and batches. Its register width follows the CPU: +// - Without AMX (Ice Lake class), dense 512-bit integer dot products (oneDNN's 4- and 8-row +// avx512_core_vnni matmul, or a zmm kernel) slowed the graph search that follows them by +// 9-15%; the same tiles on 256-bit registers did not, and are the fastest. +// - With AMX (Sapphire Rapids and later), 512-bit tiles are 1-4% faster than oneDNN's AMX path. +// AVX2-VNNI CPUs without AVX-512 keep oneDNN, as does JVECTOR_ASH_INT8_ONEDNN (a diagnostic +// override) when set. +bool direct_vnni_available() { +#if defined(__x86_64__) || defined(_M_X64) + if (std::getenv("JVECTOR_ASH_INT8_ONEDNN")) return false; // Diagnostic: force oneDNN. + switch (get_effective_cpu_isa()) { + case cpu_isa::avx512_core_vnni: + case cpu_isa::avx512_core_bf16: + case cpu_isa::avx512_core_amx: + case cpu_isa::avx512_core_amx_fp16: return __builtin_cpu_supports("avx512vnni"); + default: return false; + } +#else + return false; +#endif +} + +// 512-bit tiles where AMX marks a core whose 512-bit integer units do not slow later search. +bool direct_uses_zmm() { +#if defined(__x86_64__) || defined(_M_X64) + switch (get_effective_cpu_isa()) { + case cpu_isa::avx512_core_amx: + case cpu_isa::avx512_core_amx_fp16: return true; + default: return false; + } +#else + return false; +#endif +} + +// The query as two INT8 digits with one scale: input / scale ~ coarse + fine / 254. The second digit +// holds the coarse digit's rounding remainder, which accounts for nearly all INT8 projection error +// (rounding the matrix rows costs far less). Both digits are stored with the +128 offset. +JV_ASH_INT8_TARGET float quantize_two_digits(const float* input, int count, uint8_t* coarse, uint8_t* fine) { + float scale = quantize(input, count, coarse, 128); + for (int i = 0; i < count; ++i) { + float remainder = input[i] / scale - float(int(coarse[i]) - 128); + fine[i] = uint8_t(std::clamp(int(std::nearbyint(remainder * 254.0f)), -127, 127) + 128); + } + return scale; +} + +// One projected value from the coarse and fine digit dot products: (coarse + fine / 254) * scales. The digits are +// combined exactly in integers first, coarse * 254 + fine, so the floating-point steps are one conversion and +// multiplications only. No product is added to anything, so no compiler can fuse a step into an FMA, and every +// build rounds exactly as the Java test oracle does. 64 bits hold the sum: |coarse| < 65536 * 127 * 127. +inline float combine_digits(int32_t coarse, int32_t fine, float query_scale, float row_scale) { + constexpr float kInv254 = 1.0f / 254.0f; + return float(int64_t(coarse) * 254 + fine) * kInv254 * query_scale * row_scale; +} + +#if defined(__x86_64__) || defined(_M_X64) +// NB blocks of 16 output rows (two 8-lane halves each) for every query row of +// the tile: 2*M*NB independent int32 accumulators hide the dot-product latency. +// Missing final blocks recompute block 0 and are not stored. +template +__attribute__((target("avx512f,avx512vl,avx512vnni"))) +void vnni_tile(const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, int32_t* out, int out_stride) { + for (int block = 0; block < blocks; block += NB) { + const int8_t* w[NB]; + for (int j = 0; j < NB; ++j) + w[j] = packed + size_t(block + j < blocks ? block + j : block) * groups * 64; + __m256i lo[M][NB], hi[M][NB]; + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB; ++j) lo[i][j] = hi[i][j] = _mm256_setzero_si256(); + for (int g = 0; g < groups; ++g) { + __m256i b0[NB], b1[NB]; + for (int j = 0; j < NB; ++j) { + b0[j] = _mm256_loadu_si256(reinterpret_cast(w[j] + size_t(g) * 64)); + b1[j] = _mm256_loadu_si256(reinterpret_cast(w[j] + size_t(g) * 64 + 32)); + } + for (int i = 0; i < M; ++i) { + int32_t four; + std::memcpy(&four, query + size_t(i) * stride + size_t(g) * 4, sizeof(four)); + __m256i a = _mm256_set1_epi32(four); + for (int j = 0; j < NB; ++j) { + lo[i][j] = _mm256_dpbusd_epi32(lo[i][j], a, b0[j]); + hi[i][j] = _mm256_dpbusd_epi32(hi[i][j], a, b1[j]); + } + } + } + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB && block + j < blocks; ++j) { + auto* row = out + size_t(i) * out_stride + size_t(block + j) * 16; + _mm256_storeu_si256(reinterpret_cast<__m256i*>(row), lo[i][j]); + _mm256_storeu_si256(reinterpret_cast<__m256i*>(row + 8), hi[i][j]); + } + } +} + +// The same tile on 512-bit registers: one load covers a block's 16 rows x 4 columns. +template +__attribute__((target("avx512f,avx512vl,avx512vnni"))) +void vnni_tile_zmm(const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, int32_t* out, int out_stride) { + for (int block = 0; block < blocks; block += NB) { + const int8_t* w[NB]; + for (int j = 0; j < NB; ++j) + w[j] = packed + size_t(block + j < blocks ? block + j : block) * groups * 64; + __m512i acc[M][NB]; + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB; ++j) acc[i][j] = _mm512_setzero_si512(); + for (int g = 0; g < groups; ++g) { + __m512i b[NB]; + for (int j = 0; j < NB; ++j) b[j] = _mm512_loadu_si512(w[j] + size_t(g) * 64); + for (int i = 0; i < M; ++i) { + int32_t four; + std::memcpy(&four, query + size_t(i) * stride + size_t(g) * 4, sizeof(four)); + __m512i a = _mm512_set1_epi32(four); + for (int j = 0; j < NB; ++j) acc[i][j] = _mm512_dpbusd_epi32(acc[i][j], a, b[j]); + } + } + for (int i = 0; i < M; ++i) + for (int j = 0; j < NB && block + j < blocks; ++j) + _mm512_storeu_si512(out + size_t(i) * out_stride + size_t(block + j) * 16, acc[i][j]); + } +} + +void vnni_gemm_zmm(int count, const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, + int32_t* out, int out_stride) { + switch (count) { + case 1: vnni_tile_zmm<1, 4>(query, stride, packed, groups, blocks, out, out_stride); break; + case 2: vnni_tile_zmm<2, 4>(query, stride, packed, groups, blocks, out, out_stride); break; + case 3: vnni_tile_zmm<3, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 4: vnni_tile_zmm<4, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 5: vnni_tile_zmm<5, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 6: vnni_tile_zmm<6, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 7: vnni_tile_zmm<7, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + default: vnni_tile_zmm<8, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + } +} + +void vnni_gemm(int count, const uint8_t* query, int stride, const int8_t* packed, int groups, int blocks, + int32_t* out, int out_stride) { + switch (count) { + case 1: vnni_tile<1, 4>(query, stride, packed, groups, blocks, out, out_stride); break; + case 2: vnni_tile<2, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 3: vnni_tile<3, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 4: vnni_tile<4, 2>(query, stride, packed, groups, blocks, out, out_stride); break; + case 5: vnni_tile<5, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + case 6: vnni_tile<6, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + case 7: vnni_tile<7, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + default: vnni_tile<8, 1>(query, stride, packed, groups, blocks, out, out_stride); break; + } +} +#endif + +struct Context { + const uint64_t id = next_id.fetch_add(1, std::memory_order_relaxed); + const int rows, columns; + engine cpu{engine::kind::cpu, 0}; + std::vector scales; + std::vector sums; + // Retained once so additional oneDNN layouts never require another FP32 + // matrix copy or requantization. Compatible batch kernels share packed weights. + memory plain; + std::array, kBatchTile + 1> kernels{}; + std::array, kBatchTile + 1> ready{}; + std::mutex mutex; + std::atomic retained_bytes{0}; + // Set when the direct VNNI kernel replaces oneDNN for every tile (see direct_vnni_available). + const bool direct = direct_vnni_available(); + const bool zmm = direct && direct_uses_zmm(); + const int padded_columns = (columns + 3) & ~3, padded_rows = (rows + 15) & ~15; + std::unique_ptr packed; + + Context(const float* matrix, int r, int c) : rows(r), columns(c), scales(r), sums(r) { + for (auto& entry : ready) entry.store(nullptr, std::memory_order_relaxed); + auto descriptor = memory::desc({columns, rows}, memory::data_type::s8, memory::dims{1, columns}); + plain = memory(descriptor, cpu); + auto* target = static_cast(plain.get_data_handle()); + for (int row = 0; row < rows; ++row) + scales[row] = quantize(matrix + size_t(row) * columns, columns, + target + size_t(row) * columns, 0, &sums[row]); + retained_bytes.store(descriptor.get_size() + sizeof(Context) + + size_t(rows) * (sizeof(float) + sizeof(int32_t)), std::memory_order_relaxed); + if (direct) { + // [row block of 16][column group of 4][16 rows][4 columns], zero padded. + size_t bytes = size_t(padded_rows) * padded_columns; + packed.reset(new int8_t[bytes]()); + for (int row = 0; row < rows; ++row) + for (int column = 0; column < columns; ++column) + packed[(size_t(row / 16) * (padded_columns / 4) + column / 4) * 64 + + (row % 16) * 4 + column % 4] = target[size_t(row) * columns + column]; + retained_bytes.fetch_add(bytes, std::memory_order_relaxed); + } + // Single rows always use oneDNN: its 1-row kernel is load-bound, does not + // slow the following search, and is faster than vnni_tile<1>. + kernel(1); // Check required hardware/primitive during model initialization. + } + + const Kernel& kernel(int count) { + auto* prepared = ready[count].load(std::memory_order_acquire); + if (!prepared) { + std::lock_guard guard(mutex); + prepared = ready[count].load(std::memory_order_relaxed); + if (!prepared) { + auto next = std::make_unique(cpu, rows, columns, count); + uint64_t added = sizeof(Kernel); + for (const auto& other : kernels) + if (other && other->weights.get_desc() == next->descriptor.weights_desc()) { + next->weights = other->weights; + break; + } + if (!next->weights) { + next->weights = memory(next->descriptor.weights_desc(), cpu); + stream setup(cpu); + reorder(plain, next->weights).execute(setup, plain, next->weights); + setup.wait(); + added += next->descriptor.weights_desc().get_size(); + } + kernels[count] = std::move(next); + retained_bytes.fetch_add(added, std::memory_order_release); + prepared = kernels[count].get(); + ready[count].store(prepared, std::memory_order_release); + } + } + return *prepared; + } +}; + +struct Workspace { + uint64_t context_id = 0; + std::unique_ptr execution; + memory input, output, scratch; + std::array scales{}; + void initialize(const Context& context, const Kernel& kernel) { + context_id = 0; // Never publish partially replaced buffers after allocation failure. + execution = std::make_unique(context.cpu); + input = memory(kernel.descriptor.src_desc(), context.cpu); + output = memory(kernel.descriptor.dst_desc(), context.cpu); + scratch = memory(kernel.descriptor.scratchpad_desc(), context.cpu); + context_id = context.id; + } +}; +// Bounded per-thread slots hold no Context or packed weights. +thread_local std::array workspaces; +} + +extern "C" { +void* jvector_ash_int8_create(const float* matrix, int rows, int columns, jvector_ash_fp16_result* result) { + if (!result) return nullptr; + result->status = JV_ASH_FP16_OK; + result->message[0] = 0; + try { + if (!matrix || rows <= 0 || columns <= 0 + || uint64_t(rows) * uint64_t(columns) > std::numeric_limits::max() / sizeof(float)) + throw std::invalid_argument("Invalid INT8 matrix pointer or dimensions"); + if (columns > kMaxColumns) throw std::out_of_range("INT8 accumulator dimension exceeds 65535"); + if (!integer_dot_available()) throw Unsupported("INT8 projection requires an enabled x86 VNNI/AMX backend"); + return new Context(matrix, rows, columns); + } catch (...) { current_error(result); return nullptr; } +} + +void jvector_ash_int8_destroy(void* pointer) { delete static_cast(pointer); } + +uint64_t jvector_ash_int8_bytes(const void* pointer) { + return pointer ? static_cast(pointer)->retained_bytes.load(std::memory_order_acquire) : 0; +} + +JV_ASH_INT8_TARGET int jvector_ash_int8_project_batch(void* pointer, const float* input, int count, + float* output, jvector_ash_fp16_result* result) { + if (!result) return JV_ASH_FP16_INVALID_ARGUMENT; + result->status = JV_ASH_FP16_OK; + result->message[0] = 0; + try { + if (!pointer || !input || !output || count <= 0 || input == output) + throw std::invalid_argument("Invalid INT8 projection buffers or count"); + auto& context = *static_cast(pointer); + if (uint64_t(count) > std::numeric_limits::max() / sizeof(float) + / uint64_t(std::max(context.rows, context.columns))) + throw std::invalid_argument("INT8 batch size overflow"); + for (int first = 0; first < count;) { + int size = std::min(kBatchTile, count - first); +#if defined(__x86_64__) || defined(_M_X64) + if (context.direct) { + thread_local std::vector coarse, fine; + thread_local std::vector coarseDots, fineDots; + thread_local std::array scales; + size_t qbytes = size_t(kBatchTile) * context.padded_columns; + size_t dbytes = size_t(kBatchTile) * context.padded_rows; + // Padding columns meet zero weights, so stale bytes there add nothing. + if (coarse.size() < qbytes) { coarse.assign(qbytes, 0); fine.assign(qbytes, 0); } + if (coarseDots.size() < dbytes) { coarseDots.resize(dbytes); fineDots.resize(dbytes); } + for (int i = 0; i < size; ++i) + scales[i] = quantize_two_digits(input + size_t(first + i) * context.columns, context.columns, + coarse.data() + size_t(i) * context.padded_columns, + fine.data() + size_t(i) * context.padded_columns); + const auto gemm = context.zmm ? vnni_gemm_zmm : vnni_gemm; + gemm(size, coarse.data(), context.padded_columns, context.packed.get(), context.padded_columns / 4, + context.padded_rows / 16, coarseDots.data(), context.padded_rows); + gemm(size, fine.data(), context.padded_columns, context.packed.get(), context.padded_columns / 4, + context.padded_rows / 16, fineDots.data(), context.padded_rows); + for (int i = 0; i < size; ++i) + for (int row = 0; row < context.rows; ++row) { + size_t at = size_t(i) * context.padded_rows + row; + int32_t c = coarseDots[at] - 128 * context.sums[row]; + int32_t f = fineDots[at] - 128 * context.sums[row]; + output[size_t(first + i) * context.rows + row] = combine_digits(c, f, scales[i], context.scales[row]); + } + first += size; + continue; + } +#endif + const auto& kernel = context.kernel(size); + auto& buffers = workspaces[size]; + if (buffers.context_id != context.id) buffers.initialize(context, kernel); + // Two query digits, as in the direct kernel: the same matmul runs on the coarse digits, then + // on the fine digits, reusing the workspace buffers. + auto* quantized = static_cast(buffers.input.get_data_handle()); + thread_local std::vector fine; + thread_local std::vector coarseSums; + size_t qbytes = size_t(size) * context.columns, sbytes = size_t(size) * context.rows; + if (fine.size() < qbytes) fine.resize(qbytes); + if (coarseSums.size() < sbytes) coarseSums.resize(sbytes); + for (int i = 0; i < size; ++i) + buffers.scales[i] = quantize_two_digits(input + size_t(first + i) * context.columns, + context.columns, quantized + size_t(i) * context.columns, fine.data() + size_t(i) * context.columns); + const dnnl_exec_arg_t args[] = {{DNNL_ARG_SRC, buffers.input.get()}, + {DNNL_ARG_WEIGHTS, kernel.weights.get()}, {DNNL_ARG_DST, buffers.output.get()}, + {DNNL_ARG_SCRATCHPAD, buffers.scratch.get()}}; + const auto* sums = static_cast(buffers.output.get_data_handle()); + for (int pass = 0; pass < 2; ++pass) { + if (pass == 1) std::memcpy(quantized, fine.data(), qbytes); + error::wrap_c_api(dnnl_primitive_execute(kernel.operation.get(), buffers.execution->get(), 4, args), + "INT8 projection execution failed"); + buffers.execution->wait(); + if (pass == 0) std::memcpy(coarseSums.data(), sums, sbytes * sizeof(int32_t)); + } + for (int i = 0; i < size; ++i) + for (int row = 0; row < context.rows; ++row) { + size_t at = size_t(i) * context.rows + row; + int32_t c = coarseSums[at] - 128 * context.sums[row]; + int32_t f = sums[at] - 128 * context.sums[row]; + output[size_t(first + i) * context.rows + row] = + combine_digits(c, f, buffers.scales[i], context.scales[row]); + } + first += size; // Cannot overflow even when count approaches INT_MAX. + } + return JV_ASH_FP16_OK; + } catch (...) { return current_error(result); } +} +} diff --git a/jvector-native/src/main/onednn/subprojects/.gitignore b/jvector-native/src/main/onednn/subprojects/.gitignore new file mode 100644 index 000000000..da926c526 --- /dev/null +++ b/jvector-native/src/main/onednn/subprojects/.gitignore @@ -0,0 +1,3 @@ +/onednn/ +/packagecache/ +/.wraplock diff --git a/jvector-native/src/main/onednn/subprojects/onednn.wrap b/jvector-native/src/main/onednn/subprojects/onednn.wrap new file mode 100644 index 000000000..bc6fee9f6 --- /dev/null +++ b/jvector-native/src/main/onednn/subprojects/onednn.wrap @@ -0,0 +1,22 @@ +# +# Copyright DataStax, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +[wrap-git] +directory = onednn +url = https://github.com/uxlfoundation/oneDNN.git +revision = 18378c8475643c4172fe059fefba855b14c07345 +depth = 1 +method = cmake diff --git a/jvector-native/src/main/onednn/tests/test_ash_fp16.cpp b/jvector-native/src/main/onednn/tests/test_ash_fp16.cpp new file mode 100644 index 000000000..fbb46f224 --- /dev/null +++ b/jvector-native/src/main/onednn/tests/test_ash_fp16.cpp @@ -0,0 +1,118 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#include "jvector_ash_fp16.h" +#include +#include +#include +#include +#include +#include +#include + +TEST(AshFp16, InvalidArgumentsDoNotCrossTheAbi) { + const float matrix[] = {1}; + jvector_ash_fp16_result result{}; + EXPECT_EQ(jvector_ash_fp16_abi(), 1); + EXPECT_EQ(jvector_ash_fp16_create(nullptr, 1, 1, &result), nullptr); + EXPECT_EQ(result.status, JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_create(matrix, 0, 1, &result), nullptr); + EXPECT_EQ(result.status, JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_create(matrix, 1, -1, &result), nullptr); + EXPECT_EQ(jvector_ash_fp16_create(matrix, 1, 1, nullptr), nullptr); + EXPECT_EQ(jvector_ash_fp16_project(nullptr, nullptr, nullptr, &result), JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_project_batch(nullptr, nullptr, 0, nullptr, &result), + JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_project_batch(nullptr, nullptr, 1, nullptr, nullptr), + JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_bytes(nullptr), 0); + jvector_ash_fp16_destroy(nullptr); +} + +TEST(AshFp16, RejectsMatrixRangeBeforeCreatingContext) { + for (float value : {std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), 65505.0f}) { + jvector_ash_fp16_result result{}; + EXPECT_EQ(jvector_ash_fp16_create(&value, 1, 1, &result), nullptr); + EXPECT_EQ(result.status, JV_ASH_FP16_RANGE); + } +} + +TEST(AshFp16, TrueBatchesCacheWeightsAndHandleTilesAndTails) { + const float matrix[] = {.25f, -.5f, .75f, -.125f, .5f, .25f}; + jvector_ash_fp16_result result{}; + using Owner = std::unique_ptr; + Owner context(jvector_ash_fp16_create(matrix, 2, 3, &result), &jvector_ash_fp16_destroy); + if (result.status == JV_ASH_FP16_UNSUPPORTED) GTEST_SKIP() << result.message; + ASSERT_EQ(result.status, JV_ASH_FP16_OK) << result.message; + const auto weights = jvector_ash_fp16_bytes(context.get()); + for (int count : {1, 2, 3, 4, 5, 7, 8, 9, 17}) { + std::vector input(count * 3, 1), output(count * 2, -99); + ASSERT_EQ(jvector_ash_fp16_project_batch(context.get(), input.data(), count, + output.data(), &result), JV_ASH_FP16_OK) << result.message; + for (int i = 0; i < count; ++i) { + EXPECT_FLOAT_EQ(output[i * 2], .5f); + EXPECT_FLOAT_EQ(output[i * 2 + 1], .625f); + } + const auto retained = jvector_ash_fp16_bytes(context.get()); + EXPECT_GE(retained, weights); + ASSERT_EQ(jvector_ash_fp16_project_batch(context.get(), input.data(), count, + output.data(), &result), JV_ASH_FP16_OK); + EXPECT_EQ(retained, jvector_ash_fp16_bytes(context.get())); + input.back() = std::numeric_limits::quiet_NaN(); + std::fill(output.begin(), output.end(), -99); + EXPECT_EQ(jvector_ash_fp16_project_batch(context.get(), input.data(), count, + output.data(), &result), JV_ASH_FP16_RANGE); + for (float value : output) EXPECT_FLOAT_EQ(value, -99); + } +} + +TEST(AshFp16, OddDimensionsConcurrentUseAndModelSwitching) { + const float matrix[] = {.25f, -.5f, .75f, -.125f, .5f, .25f}; + jvector_ash_fp16_result result{}; + using Owner = std::unique_ptr; + Owner a(jvector_ash_fp16_create(matrix, 2, 3, &result), &jvector_ash_fp16_destroy); + if (result.status == JV_ASH_FP16_UNSUPPORTED) GTEST_SKIP() << result.message; + ASSERT_EQ(result.status, JV_ASH_FP16_OK) << result.message; + ASSERT_NE(a, nullptr); + EXPECT_GT(jvector_ash_fp16_bytes(a.get()), 0); + const float second[] = {2, -1, .5f}; + Owner b(jvector_ash_fp16_create(second, 1, 3, &result), &jvector_ash_fp16_destroy); + ASSERT_EQ(result.status, JV_ASH_FP16_OK); + std::vector> workers; + for (int thread = 0; thread < 4; ++thread) workers.push_back(std::async(std::launch::async, [&] { + const float input[] = {1, 1, 1}; + float out[2]{}; + jvector_ash_fp16_result call_result{}; + for (int repeat = 0; repeat < 25; ++repeat) { + EXPECT_EQ(jvector_ash_fp16_project(a.get(), input, out, &call_result), JV_ASH_FP16_OK); + EXPECT_FLOAT_EQ(out[0], .5f); + EXPECT_FLOAT_EQ(out[1], .625f); + EXPECT_EQ(jvector_ash_fp16_project(b.get(), input, out, &call_result), JV_ASH_FP16_OK); + EXPECT_FLOAT_EQ(out[0], 1.5f); + } + })); + for (auto& worker : workers) worker.get(); + float out[2]; + const float input[] = {1, 1, 1}; + EXPECT_EQ(jvector_ash_fp16_project(a.get(), nullptr, out, &result), JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_project(a.get(), input, nullptr, &result), JV_ASH_FP16_INVALID_ARGUMENT); + EXPECT_EQ(jvector_ash_fp16_project(a.get(), out, out, &result), JV_ASH_FP16_INVALID_ARGUMENT); + for (float value : {std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), 65505.0f}) { + float bad[] = {value, 1, 1}; + EXPECT_EQ(jvector_ash_fp16_project(a.get(), bad, out, &result), JV_ASH_FP16_RANGE); + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCanonicalLutTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCanonicalLutTest.java new file mode 100644 index 000000000..180d042a4 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCanonicalLutTest.java @@ -0,0 +1,177 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Exact binary-fraction oracles cover lane tails, both FFM modes and concurrent callers. */ +class NativeASHCanonicalLutTest { + @BeforeAll static void loadLibrary() { + assumeTrue(LibraryLoader.loadJvector(), "Native library unavailable"); + assertTrue(NativeASHKernels.isCanonicalLutAvailable(), "New native kernel must be packaged"); + assertTrue(NativeASHKernels.isFourBitDotAvailable(), "Native 4-bit dot must be packaged"); + } + + @Test void canonicalCodesMatchIndependentProjectionOracle() { + Random random = new Random(103); + for (int bits : new int[] {2, 4}) { + for (int dims : new int[] {1, 2, 3, 7, 15, 16, 17, 31, 32, 33, 127, 384, 512, 768, 1536, 3073}) { + float[] q = new float[dims]; + for (int j = 0; j < dims; j++) q[j] = (random.nextInt(33) - 16) / 32f; + byte[] code = new byte[(dims * bits + 7) / 8]; + random.nextBytes(code); + int groups = FusedASHLayout.codeGroups(dims, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(q, dims, bits, lut); + double expected = 0; + for (int j = 0; j < dims; j++) { + int field = (code[j * bits / 8] >>> ((j * bits) % 8)) & ((1 << bits) - 1); + int sign = 1 << (bits - 1); + double component = ((field & (sign - 1)) + .5) * ((field & sign) == 0 ? -1 : 1); + expected += q[j] * component; + } + assertEquals((float) expected, NativeASHKernels.dotCanonical(code, groups, lut), 0f, + "bits=" + bits + " dims=" + dims); + } + } + } + + @Test void generalFp32InputsMatchDoublePrecisionDecoder() { + Random random = new Random(83417); + for (int bits : new int[] {2, 4}) { + for (int dims : new int[] {33, 127, 384, 512, 768, 1536, 3073}) { + float[] q = new float[dims]; + for (int j = 0; j < dims; j++) q[j] = random.nextFloat() - .5f; + byte[] code = new byte[(dims * bits + 7) / 8]; + random.nextBytes(code); + int groups = FusedASHLayout.codeGroups(dims, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(q, dims, bits, lut); + double expected = 0, magnitude = 0; + for (int j = 0; j < dims; j++) { + int field = (code[j * bits / 8] >>> ((j * bits) % 8)) & ((1 << bits) - 1); + int sign = 1 << (bits - 1); + double component = ((field & (sign - 1)) + .5) * ((field & sign) == 0 ? -1 : 1); + expected += q[j] * component; + magnitude += Math.abs(q[j] * component); + } + // Allow FP32 LUT construction and reduction order; scale by the + // absolute sum so cancellation does not make the tolerance misleading. + assertEquals(expected, NativeASHKernels.dotCanonical(code, groups, lut), 1e-6 + magnitude * 2e-6); + } + } + } + + @Test void arbitraryLutsCoverOddGroupsAndOrdinaryDowncalls() { + Random random = new Random(123); + for (int groups : new int[] {1, 2, 3, 15, 16, 17, 31, 32, 33, 63, 64, 65, + NativeASHKernels.MAX_CRITICAL_LANE_GROUPS, NativeASHKernels.MAX_CRITICAL_LANE_GROUPS + 1}) { + byte[] code = new byte[(groups + 1) / 2]; + float[] lut = new float[groups * 16]; + random.nextBytes(code); + for (int i = 0; i < lut.length; i++) lut[i] = (random.nextInt(33) - 16) / 32f; + float expected = ASHLutScoring.dotCanonical(code, groups, lut); + assertEquals(expected, NativeASHKernels.dotCanonical(code, groups, lut), 0f); + if ((groups & 1) != 0) { + code[code.length - 1] ^= (byte) 0xf0; + assertEquals(expected, NativeASHKernels.dotCanonical(code, groups, lut), 0f); + } + } + } + + @Test void contiguousFourBitDotMatchesIndependentDecoder() { + Random random = new Random(89217); + for (int dims : new int[] {0, 1, 2, 3, 15, 16, 17, 31, 32, 33, 383, 384, 1536, 3073, + NativeASHKernels.MAX_CRITICAL_LANE_GROUPS, NativeASHKernels.MAX_CRITICAL_LANE_GROUPS + 1}) { + float[] query = new float[dims]; + byte[] code = new byte[(dims + 1) / 2]; + random.nextBytes(code); + double expected = 0, magnitude = 0; + for (int j = 0; j < dims; j++) { + query[j] = random.nextFloat() - .5f; + int field = (code[j / 2] >>> ((j & 1) * 4)) & 15; + double component = ((field & 7) + .5) * ((field & 8) == 0 ? -1 : 1); + expected += query[j] * component; + magnitude += Math.abs(query[j] * component); + } + assertEquals(expected, NativeASHKernels.dotFourBit(query, code, dims), 1e-6 + magnitude * 2e-6); + } + } + + @Test void contiguousFourBitDotChecksBounds() { + assertEquals(0f, NativeASHKernels.dotFourBit(new float[0], new byte[0], 0)); + assertThrows(NullPointerException.class, () -> NativeASHKernels.dotFourBit(null, new byte[1], 1)); + assertThrows(NullPointerException.class, () -> NativeASHKernels.dotFourBit(new float[1], null, 1)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHKernels.dotFourBit(new float[1], new byte[1], -1)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHKernels.dotFourBit(new float[1], new byte[1], 2)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHKernels.dotFourBit(new float[3], new byte[1], 3)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHKernels.dotFourBit(new float[1], new byte[1], Integer.MAX_VALUE)); + } + + @Test void rejectsInvalidInputsBeforeNativeAccess() { + assertThrows(NullPointerException.class, () -> NativeASHKernels.dotCanonical(null, 1, new float[16])); + assertThrows(NullPointerException.class, () -> NativeASHKernels.dotCanonical(new byte[1], 1, null)); + for (int groups : new int[] {0, -1}) + assertThrows(IllegalArgumentException.class, () -> NativeASHKernels.dotCanonical(new byte[1], groups, new float[16])); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHKernels.dotCanonical(new byte[0], 1, new float[16])); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHKernels.dotCanonical(new byte[1], 1, new float[15])); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHKernels.dotCanonical(new byte[1], Integer.MAX_VALUE, new float[16])); + } + + @Test void propagatesSelectedNonFiniteEntries() { + float[] lut = new float[32]; + lut[0] = Float.NaN; + assertTrue(Float.isNaN(NativeASHKernels.dotCanonical(new byte[1], 2, lut))); + lut[0] = Float.POSITIVE_INFINITY; + assertEquals(Float.POSITIVE_INFINITY, NativeASHKernels.dotCanonical(new byte[1], 2, lut)); + assertTrue(Float.isNaN(NativeASHKernels.dotFourBit(new float[]{Float.NaN}, new byte[]{8}, 1))); + assertEquals(Float.POSITIVE_INFINITY, + NativeASHKernels.dotFourBit(new float[]{Float.POSITIVE_INFINITY}, new byte[]{8}, 1)); + } + + @Test void concurrentCallsRetainNoQueryState() { + CompletableFuture[] jobs = IntStream.range(0, 8).mapToObj(seed -> CompletableFuture.runAsync(() -> { + Random random = new Random(seed); + byte[] code = new byte[257]; + float[] lut = new float[513 * 16]; + random.nextBytes(code); + for (int i = 0; i < lut.length; i++) lut[i] = (random.nextInt(33) - 16) / 32f; + float expected = ASHLutScoring.dotCanonical(code, 513, lut); + float[] query = new float[513]; + float expectedFourBit = 0; + for (int j = 0; j < query.length; j++) { + query[j] = (random.nextInt(33) - 16) / 32f; + int field = (code[j / 2] >>> ((j & 1) * 4)) & 15; + float value = (field & 7) + .5f; + expectedFourBit += query[j] * ((field & 8) == 0 ? -value : value); + } + for (int repeat = 0; repeat < 100; repeat++) { + assertEquals(expected, NativeASHKernels.dotCanonical(code, 513, lut), 0f); + assertEquals(expectedFourBit, NativeASHKernels.dotFourBit(query, code, query.length), 0f); + } + })).toArray(CompletableFuture[]::new); + CompletableFuture.allOf(jobs).join(); + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java new file mode 100644 index 000000000..fdf0bc490 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHCompatibilityTest.java @@ -0,0 +1,126 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Optional ASH symbol detection must not make unrelated provider construction fail. */ +class NativeASHCompatibilityTest { + @Test + void packagedLibraryProvidesNeighborhoodScoring() { + String arch = System.getProperty("os.arch", ""); + assumeTrue(arch.equals("amd64") || arch.equals("x86_64")); + assumeTrue(LibraryLoader.loadJvector(), "Native jvector library not available"); + assertTrue(new NativeVectorUtilSupport().supportsAshNeighborhoodScoring()); + assertTrue(new NativeVectorUtilSupport().supportsAshCanonicalLutScoring()); + assertTrue(NativeASHKernels.isFourBitDotAvailable()); + var backend = new NativeVectorUtilSupport(); + assertDoesNotThrow(() -> backend.requireNativeAshSingleScoring(2)); + assertDoesNotThrow(() -> backend.requireNativeAshSingleScoring(4)); + assertTrue(NativeASHProjection.isAvailable()); + assertTrue(NativeASHSymmetricKernels.hasPairKernel()); + assertTrue(NativeASHSymmetricKernels.hasLutKernel()); + } + + @Test + void missingAshSymbolRetainsJavaLutScoring() throws Exception { + // A fresh JVM deliberately loads no native library. This tests an actual absent + // symbol without changing the parent JVM's already loaded library or using mocks. + Path output = Files.createTempFile("ash-missing-symbol-", ".log"); + Process child = null; + try { + child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", "-cp", + System.getProperty("java.class.path"), MissingSymbolProbe.class.getName()) + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start(); + assertTrue(child.waitFor(30, TimeUnit.SECONDS), "Missing-symbol probe timed out"); + String diagnostics = Files.readString(output); + assertEquals(0, child.exitValue(), diagnostics); + assertTrue(diagnostics.contains("MISSING_ASH_FALLBACK_OK"), diagnostics); + } finally { + if (child != null && child.isAlive()) { + child.destroyForcibly().waitFor(); + } + Files.deleteIfExists(output); + } + } + + /** Child-process entry: do not initialize LibraryLoader or the global provider. */ + public static final class MissingSymbolProbe { + public static void main(String[] args) { + var backend = new NativeVectorUtilSupport(); + if (backend.supportsAshNeighborhoodScoring() || backend.supportsAshCanonicalLutScoring()) { + throw new AssertionError("ASH symbol unexpectedly available without a loaded native library"); + } + if (NativeASHKernels.isFourBitDotAvailable() || NativeASHProjection.isAvailable() || NativeASHSymmetricKernels.hasPairKernel() + || NativeASHSymmetricKernels.hasLutKernel()) { + throw new AssertionError("New ASH symbols unexpectedly available"); + } + for (int bits : new int[] {2, 4}) { + try { + backend.requireNativeAshSingleScoring(bits); + throw new AssertionError("Missing native b=" + bits + " single scorer was accepted"); + } catch (IllegalStateException expected) { + if (!expected.getMessage().contains("b=" + bits)) throw expected; + } + } + float[] projected = new float[2]; + backend.prepareAshProjection(new float[][]{{1,2,3},{4,5,6}}) + .project(new float[]{2,3,4}, projected); + if (projected[0] != 20f || projected[1] != 47f) + throw new AssertionError("Java projection fallback failed"); + if (backend.ashSymmetricDot(null,new byte[]{0},null,new byte[]{0},2,4) != .5f) + throw new AssertionError("Java symmetric pair fallback failed"); + if (backend.ashProjectionDot(new float[]{2, 4}, new byte[]{(byte) 0x88}, 2, 4) != 3f) + throw new AssertionError("Java 4-bit projection-code fallback failed"); + float[] symmetric = new float[1]; + backend.ashSymmetricLutScore(new byte[1],1,1,0,1,new short[32],symmetric,0); + if (symmetric[0] != 0f) throw new AssertionError("Java symmetric LUT fallback failed"); + float[] lut = new float[16]; + java.util.Arrays.fill(lut, .25f); + float[] out = new float[8]; + backend.ashLutScore(new byte[8], 0, 1, 8, 0, 8, lut, out, 0); + for (float value : out) { + if (value != .25f) { + throw new AssertionError("Existing Java LUT scorer failed"); + } + } + try { + backend.ashScoreNeighborhood(new byte[48], 1, 8, 8, lut, 0f, out); + throw new AssertionError("Unavailable native kernel accepted a call"); + } catch (UnsupportedOperationException expected) { + // Capability is optional, and direct calls report its absence clearly. + } + try { + backend.ashCanonicalLutDot(new byte[1], 1, lut); + throw new AssertionError("Unavailable canonical native kernel accepted a call"); + } catch (UnsupportedOperationException expected) { + // Fused scorers select their existing projection-code fallback instead. + } + System.out.println("MISSING_ASH_FALLBACK_OK"); + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java new file mode 100644 index 000000000..2d86a5476 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP16ProjectionTest.java @@ -0,0 +1,423 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** FP16 arithmetic, query-only selection, concurrency and optional-backend compatibility. */ +@ResourceLock("java.lang.System.properties") +class NativeASHFP16ProjectionTest { + private static final String PROPERTY = "jvector.ash.queryProjection"; + + @BeforeAll + static void loadNative() { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireFp16")) + assertTrue(available, "Highway library unavailable"); + else assumeTrue(available, "Highway library unavailable"); + } + + private static VectorUtilSupport.AshProjection view(NativeASHProjection original, String mode) { + String previous = System.getProperty(PROPERTY); + try { + if (mode == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, mode); + return original.forQueryScoring(); + } finally { + if (previous == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, previous); + } + } + + private static VectorUtilSupport.AshProjection required(NativeASHProjection original) { + try { return view(original, "fp16"); } + catch (UnsupportedOperationException unsupported) { + if (Boolean.getBoolean("jvector.test.requireFp16")) throw unsupported; + assumeTrue(false, unsupported.getMessage()); + throw unsupported; + } + } + + private static float half(float value) { + return Float.float16ToFloat(Float.floatToFloat16(value)); + } + + private static void checkOracle(float[][] matrix, float[] query, float[] actual, boolean fp16) { + for (int row = 0; row < matrix.length; row++) { + double expected = 0, magnitude = 0; + for (int col = 0; col < query.length; col++) { + double product = (double) (fp16 ? half(matrix[row][col]) : matrix[row][col]) + * (fp16 ? half(query[col]) : query[col]); + expected += product; + magnitude += Math.abs(product); + } + assertEquals(expected, actual[row], 2e-6 * magnitude + 1e-7, "row " + row); + } + } + + @Test + void batchAbiExecutesOptimizedPrimitivesForSmallAndLargeMatrices() throws Throwable { + required(new NativeASHProjection(new float[][]{{1}})); + var signature = java.lang.foreign.FunctionDescriptor.of(java.lang.foreign.ValueLayout.JAVA_INT, + java.lang.foreign.ValueLayout.ADDRESS, java.lang.foreign.ValueLayout.ADDRESS, + java.lang.foreign.ValueLayout.JAVA_INT, java.lang.foreign.ValueLayout.ADDRESS, + java.lang.foreign.ValueLayout.ADDRESS); + var symbol = java.lang.foreign.SymbolLookup.loaderLookup().find("jvector_ash_fp16_project_batch"); + if (Boolean.getBoolean("jvector.test.requireFp16")) assertTrue(symbol.isPresent()); + else assumeTrue(symbol.isPresent(), "Optional FP16 batch ABI unavailable"); + var call = java.lang.foreign.Linker.nativeLinker().downcallHandle(symbol.orElseThrow(), signature); + for (int[] shape : new int[][]{{3,7}, {384,768}, {1536,3072}}) { + float[][] matrix = new float[shape[0]][shape[1]]; + for (float[] row : matrix) Arrays.fill(row, .25f); + var projection = required(new NativeASHProjection(matrix)); + var field = NativeASHFP16Projection.class.getDeclaredField("context"); + field.setAccessible(true); + var context = (java.lang.foreign.MemorySegment) field.get(projection); + try (var arena = java.lang.foreign.Arena.ofConfined()) { + var input = arena.allocate(17L * shape[1] * Float.BYTES, Float.BYTES); + var output = arena.allocate(17L * shape[0] * Float.BYTES, Float.BYTES); + var status = arena.allocate(260, Integer.BYTES); + for (long i = 0; i < 17L * shape[1]; i++) + input.setAtIndex(java.lang.foreign.ValueLayout.JAVA_FLOAT, i, 1f); + for (int count : new int[]{2,3,4,5,6,7,8,9,17}) { + int result = (int) call.invokeExact(context, input, count, output, status); + if (result == 1 && !Boolean.getBoolean("jvector.test.requireFp16")) + assumeTrue(false, "Optimized FP16 batch unsupported on this CPU"); + assertEquals(0, result, "Required batch must execute without singleton fallback"); + for (long i = 0; i < (long) count * shape[0]; i++) + assertEquals(shape[1] * .25f, output.getAtIndex(java.lang.foreign.ValueLayout.JAVA_FLOAT, i)); + } + } + } + } + + @Test + void batchesMatchRoundedOracleIncludingTailsAndPreserveWeightStorage() { + Random random = new Random(162784); + for (int[] shape : new int[][]{{3,7}, {65,129}, {384,768}, {1536,3072}}) { + float[][] matrix = new float[shape[0]][shape[1]]; + for (float[] row : matrix) for (int j = 0; j < row.length; j++) row[j] = random.nextFloat() - .5f; + var projection = required(new NativeASHProjection(matrix)); + long weightBytes = projection.ramBytesUsed(); + for (int count : new int[]{0,1,2,3,4,5,7,8,9,17}) { + float[][] inputs = new float[count + 1][shape[1]], outputs = new float[count + 1][shape[0]]; + for (float[] input : inputs) for (int j = 0; j < input.length; j++) input[j] = random.nextFloat() - .5f; + Arrays.fill(outputs[count], 123f); + projection.projectBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) checkOracle(matrix, inputs[i], outputs[i], true); + for (float value : outputs[count]) assertEquals(123f, value); + assertTrue(projection.ramBytesUsed() >= weightBytes); + long retained = projection.ramBytesUsed(); + projection.projectBatch(inputs, outputs, count); + assertEquals(retained, projection.ramBytesUsed(), "Repeated batch sizes reuse their packed storage"); + } + } + } + + @Test + void batchGuardsAndAutomaticRangeFallback() { + float[][] matrix = {{.1234567f, .3456789f}, {-.3333333f, .5f}}; + var projection = required(new NativeASHProjection(matrix)); + float[][] inputs = {{1,2}, {3,4}}, outputs = {{42,42}, {42,42}}; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, -1)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, inputs, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, new float[][]{outputs[0], outputs[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, new float[][]{outputs[0], new float[1]}, 2)); + inputs[1][1] = Float.NaN; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, 2)); + for (float[] out : outputs) assertArrayEquals(new float[]{42,42}, out); + inputs[1][1] = 70000f; + // Automatic FP16 (auto's choice where INT8 is unavailable) falls back to FP32 out of range. + var automatic = new NativeASHProjection(matrix).fp16WithFallback(); + automatic.projectBatch(inputs, outputs, 2); + checkOracle(matrix, inputs[0], outputs[0], true); + checkOracle(matrix, inputs[1], outputs[1], false); + } + + @Test + void unavailableBatchCapabilityUsesTheOriginalSingletonPath() throws Exception { + float[][] matrix = {{.1234567f, .3456789f}, {-.3333333f, .5f}}; + var projection = required(new NativeASHProjection(matrix)); + var availability = NativeASHFP16Projection.class.getDeclaredField("batchAvailable"); + availability.setAccessible(true); + availability.setBoolean(projection, false); // Also the state of an older, singleton-only ABI. + assertFalse(projection.supportsBatchProjection()); + float[][] inputs = {{1,2}, {3,4}, {5,6}}, outputs = new float[3][2]; + projection.projectBatch(inputs, outputs, inputs.length); + for (int i = 0; i < inputs.length; i++) checkOracle(matrix, inputs[i], outputs[i], true); + assertFalse(projection.supportsBatchProjection()); + } + + @Test + void concurrentBatchInitializationAndModelSwitchingKeepCallerOutputsIndependent() throws Exception { + float[][] a = {{.1234567f, -.3333333f, .9f}, {-.5555555f, .0123456f, .25f}}; + float[][] b = {{.456789f, .234567f, -.75f}}; + var first = required(new NativeASHProjection(a)); + var second = required(new NativeASHProjection(b)); + var start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(8)) { + var futures = new ArrayList>(); + for (int worker = 0; worker < 8; worker++) { + final int count = new int[]{2,3,8,17}[worker % 4]; + futures.add(pool.submit(() -> { + float[][] inputs = new float[count][3], outA = new float[count][2], outB = new float[count][1]; + for (int i = 0; i < count; i++) inputs[i] = new float[]{.234567f + i * .01f, -.456789f, .75f}; + start.await(); + for (int repeat = 0; repeat < 20; repeat++) { + first.projectBatch(inputs, outA, count); + second.projectBatch(inputs, outB, count); + for (int i = 0; i < count; i++) { + checkOracle(a, inputs[i], outA[i], true); + checkOracle(b, inputs[i], outB[i], true); + } + } + return null; + })); + } + start.countDown(); + for (var future : futures) future.get(30, TimeUnit.SECONDS); + } finally { start.countDown(); } + } + + @Test + void matchesIndependentRoundedOperandOracleAcrossDimensions() { + Random random = new Random(93124); + for (int[] shape : new int[][]{{1,1},{3,7},{17,31},{65,129},{257,513},{384,768},{1536,3072}}) { + float[][] matrix = new float[shape[0]][shape[1]]; + for (float[] row : matrix) for (int i = 0; i < row.length; i++) row[i] = random.nextFloat() - .5f; + var fp32 = new NativeASHProjection(matrix); + var fp16 = required(fp32); + assertInstanceOf(NativeASHFP16Projection.class, fp16); + assertTrue(fp16.ramBytesUsed() >= (long) shape[0] * shape[1] * 2); + // Repeated calls exercise workspace reuse with different queries. + for (int call = 0; call < 17; call++) { + float[] query = new float[shape[1]], out = new float[shape[0]]; + for (int i = 0; i < query.length; i++) query[i] = random.nextFloat() - .5f; + float[] saved = query.clone(); + fp16.project(query, out); + checkOracle(matrix, query, out, true); + assertArrayEquals(saved, query); + } + assertSame(fp32, view(fp32, "fp32")); + } + } + + @Test + void halfPrecisionBoundaryRoundingMatchesTheIndependentConversion() { + var projection = required(new NativeASHProjection(new float[][]{{1}})); + float tie = 1f + Math.scalb(1f, -11); + for (float value : new float[]{0f, -0f, Float.MIN_VALUE, Math.scalb(1f, -24), + Math.scalb(1f, -14), tie, Math.nextUp(tie), -tie, 65504f, -65504f}) { + float[] out = new float[1]; + projection.project(new float[]{value}, out); + assertEquals(half(value), out[0], 0f); + } + } + + @Test + void sharedModelsSurviveConcurrentCallsModelSwitchingAndGc() throws Exception { + float[][] a = {{.1234567f, -.3333333f, .9f}, {-.5555555f, .0123456f, .25f}}; + float[][] b = {{.456789f, .234567f, -.75f}}; + var first = required(new NativeASHProjection(a)); + var second = required(new NativeASHProjection(b)); + try (var pool = Executors.newFixedThreadPool(8)) { + var tasks = new ArrayList>(); + for (int thread = 0; thread < 8; thread++) tasks.add(() -> { + float[] query = {.234567f, -.456789f, .75f}; + float[] outA = new float[2], outB = new float[1]; + for (int call = 0; call < 40; call++) { + first.project(query, outA); + second.project(query, outB); + checkOracle(a, query, outA, true); + checkOracle(b, query, outB, true); + if (call == 20) System.gc(); + } + return null; + }); + for (var result : pool.invokeAll(tasks)) result.get(); + } + } + + @Test + void virtualThreadCallsKeepTheirResultsAndDiagnostics() throws Exception { + float[][] matrix = {{.1234567f, -.3456789f}}; + var projection = required(new NativeASHProjection(matrix)); + try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { + var tasks = new ArrayList>(); + for (int thread = 0; thread < 16; thread++) tasks.add(() -> { + float[] out = new float[1]; + for (int call = 0; call < 16; call++) { + var error = assertThrows(IllegalArgumentException.class, + () -> projection.project(new float[]{Float.NaN, 1}, out)); + assertTrue(error.getMessage().contains("finite FP16 range")); + float[] query = {.234567f, -.456789f}; + projection.project(query, out); + checkOracle(matrix, query, out, true); + Thread.yield(); + } + return null; + }); + for (var result : pool.invokeAll(tasks)) result.get(); + } + } + + @Test + void guardsShapesAliasesAndRangeWithoutDamagingSubsequentCalls() { + float[][] matrix = {{.1234567f, .3456789f}, {-.3333333f, .5f}}; + var fp16 = required(new NativeASHProjection(matrix)); + float[] query = {1, 2}, out = new float[2]; + assertThrows(NullPointerException.class, () -> fp16.project(null, out)); + assertThrows(NullPointerException.class, () -> fp16.project(query, null)); + assertThrows(IllegalArgumentException.class, () -> fp16.project(query, query)); + assertThrows(IllegalArgumentException.class, () -> fp16.project(new float[1], out)); + assertThrows(IllegalArgumentException.class, () -> fp16.project(query, new float[1])); + for (float invalid : new float[]{Float.NaN, Float.POSITIVE_INFINITY, 65505f}) { + Arrays.fill(out, 42f); + assertThrows(IllegalArgumentException.class, () -> fp16.project(new float[]{invalid, 1}, out)); + assertArrayEquals(new float[]{42, 42}, out); + } + fp16.project(query, out); + checkOracle(matrix, query, out, true); + } + + @Test + void automaticFp16PrefersFp16ButFp32AndRangeFallbackRemainAvailable() { + var original = new NativeASHProjection(new float[][]{{.25f, -.5f}}); + assertSame(original, view(original, "fp32")); + var explicit = required(original); + assertInstanceOf(NativeASHFP16Projection.class, explicit); + // auto selects INT8 first where available; its FP16 choice keeps the FP32 range fallback. + var automatic = original.fp16WithFallback(); + assertInstanceOf(NativeASHFP16Projection.class, automatic); + assertEquals("fp16", automatic.operandPrecision()); + float[] query = {70000, 2}, out = new float[1], expected = new float[1]; + original.project(query, expected); + automatic.project(query, out); + assertArrayEquals(expected, out); + assertEquals("fp16+fp32", automatic.operandPrecision()); + var largeMatrix = new NativeASHProjection(new float[][]{{70000}}); + assertSame(largeMatrix, largeMatrix.fp16WithFallback()); + assertThrows(IllegalArgumentException.class, () -> view(largeMatrix, "fp16")); + assertThrows(IllegalArgumentException.class, () -> view(original, "typo")); + } + + @Test + void encodingStaysFp32BeforeAndAfterTheQueryViewIsPrepared() { + String previous = System.getProperty(PROPERTY); + try { + System.setProperty(PROPERTY, "fp16"); + double[][] w = {{.1234567, .2345678}, {-.3456789, .4567891}, {.5678912, -.6789123}}; + var transform = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(w)); + float[] query = {.1234567f, -.3456789f, .9876543f}; + float[] encodingBefore = new float[2], encodingAfter = new float[2], queryOut = new float[2]; + transform.project(query, encodingBefore); + checkOracle(transform.AFloat, query, encodingBefore, false); + try { transform.projectQuery(query, queryOut); } + catch (UnsupportedOperationException unsupported) { + if (Boolean.getBoolean("jvector.test.requireFp16")) throw unsupported; + assumeTrue(false, unsupported.getMessage()); + } + checkOracle(transform.AFloat, query, queryOut, true); + assertTrue(Math.abs(encodingBefore[0] - queryOut[0]) > 1e-6f); + transform.project(query, encodingAfter); + assertArrayEquals(encodingBefore, encodingAfter); + } finally { + if (previous == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, previous); + } + } + + @Test + void missingOptionalLibraryFallsBackAndExplicitFp16Fails(@TempDir Path temporary) throws Exception { + Path source = Path.of(NativeASHProjection.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + assertTrue(Files.isDirectory(source)); + Path stripped = temporary.resolve("native-without-fp16.jar"); + try (var jar = new JarOutputStream(Files.newOutputStream(stripped)); var paths = Files.walk(source)) { + for (Path p : paths.filter(Files::isRegularFile).toList()) { + String name = source.relativize(p).toString().replace(File.separatorChar, '/'); + if (name.startsWith("libjvector_ash_fp16")) continue; + jar.putNextEntry(new JarEntry(name)); + Files.copy(p, jar); + jar.closeEntry(); + } + } + String raw = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + String cp = stripped + File.pathSeparator + String.join(File.pathSeparator, + Arrays.stream(raw.split(java.util.regex.Pattern.quote(File.pathSeparator))) + .filter(p -> !Path.of(p).toAbsolutePath().normalize().equals(source.toAbsolutePath().normalize())) + .toList()); + Path log = temporary.resolve("unavailable.log"); + Process child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin/java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djava.library.path=" + temporary, "-cp", cp, UnavailableProbe.class.getName()) + .redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue(child.waitFor(30, TimeUnit.SECONDS)); + assertEquals(0, child.exitValue(), Files.readString(log)); + assertTrue(Files.readString(log).contains("FP16_UNAVAILABLE_OK")); + } finally { + if (child.isAlive()) { + child.destroyForcibly(); + child.waitFor(5, TimeUnit.SECONDS); + } + } + } + + public static final class UnavailableProbe { + public static void main(String[] args) { + if (!LibraryLoader.loadJvector()) throw new AssertionError("Missing Highway fixture"); + var original = new NativeASHProjection(new float[][]{{1, 2}}); + if (view(original, "auto") != original) throw new AssertionError("Automatic fallback failed"); + try { + view(original, "fp16"); + throw new AssertionError("Explicit FP16 silently fell back"); + } catch (UnsupportedOperationException expected) { /* expected */ } + for (int attempt = 0; attempt < 2; attempt++) { + try { + view(original, "int8"); + throw new AssertionError("Explicit INT8 silently fell back"); + } catch (UnsupportedOperationException expected) { /* no initializer poisoning */ } + } + var transform = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(new double[][]{{1}, {2}})); + System.setProperty(PROPERTY, "fp16"); + float[] out = new float[1]; + transform.project(new float[]{1, 1}, out); // Encoding does not require FP16. + if (out[0] != 3) throw new AssertionError("Encoding changed"); + System.out.println("FP16_UNAVAILABLE_OK"); + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java new file mode 100644 index 000000000..ca4a9f10d --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHFP32BatchProjectionTest.java @@ -0,0 +1,232 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.SymbolLookup; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Actual optional oneDNN FP32 execution, separate from Highway's arithmetic tests. */ +class NativeASHFP32BatchProjectionTest { + @BeforeAll + static void requireBackend() { + boolean nativeAvailable = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireNative")) assertTrue(nativeAvailable); + else assumeTrue(nativeAvailable); + var projection = prepared(new float[][]{{1}}); + if (Boolean.getBoolean("jvector.test.requireFp32OneDnn")) + assertInstanceOf(NativeASHFP32BatchProjection.class, projection); + else assumeTrue(projection instanceof NativeASHFP32BatchProjection, "Optional oneDNN FP32 unavailable"); + } + + private static VectorUtilSupport.AshProjection prepared(float[][] matrix) { + return NativeASHFP32BatchProjection.prepare(new NativeASHProjection(matrix)); + } + + private static float[][] random(Random random, int rows, int cols) { + float[][] result = new float[rows][cols]; + for (float[] row : result) for (int c = 0; c < cols; c++) row[c] = random.nextFloat() - .5f; + return result; + } + + private static void oracle(float[][] matrix, float[][] inputs, float[][] outputs, int count) { + for (int b = 0; b < count; b++) for (int r = 0; r < matrix.length; r++) { + double sum = 0, magnitude = 0; + for (int c = 0; c < matrix[r].length; c++) { + double value = (double) matrix[r][c] * inputs[b][c]; + sum += value; magnitude += Math.abs(value); + } + assertEquals(sum, outputs[b][r], 5e-6 * magnitude + 1e-7); + } + } + + @Test + void optionalSymbolExecutesOneDnnWithoutHighwayFallback() throws Throwable { + var descriptor = FunctionDescriptor.of( + JAVA_INT, + ADDRESS, ADDRESS, + JAVA_INT, JAVA_INT, + JAVA_INT, ADDRESS); + var symbol = SymbolLookup.loaderLookup() + .find("jvector_ash_fp32_project_batch").orElseThrow(); + var call = Linker.nativeLinker().downcallHandle(symbol, descriptor); + try (var arena = Arena.ofConfined()) { + var matrix = arena.allocate(4, 4); var input = arena.allocate(4, 4); + var output = arena.allocate(4, 4); + matrix.set(JAVA_FLOAT, 0, 2f); + input.set(JAVA_FLOAT, 0, 3f); + int status = (int) call.invokeExact(matrix, input, 1, 1, 1, output); + assertEquals(0, status, "Actual oneDNN SGEMM must succeed, not compatibility fallback"); + assertEquals(6f, output.get(JAVA_FLOAT, 0)); + assertEquals(2, (int) call.invokeExact(matrix, input, 0, 1, 1, output)); + } + } + + @Test + void queryFallbackRetainsOwnerIdentityAndDoesNotDoubleChargeWeights() { + String previous = System.getProperty("jvector.ash.queryProjection"); + try { + System.setProperty("jvector.ash.queryProjection", "fp32"); + var projection = prepared(new float[][]{{1, 2}, {3, 4}}); + long bytes = projection.ramBytesUsed(); + assertSame(projection, projection.forQueryScoring()); + assertEquals(bytes, projection.ramBytesUsed()); + System.setProperty("jvector.ash.queryProjection", "auto"); + var fullRange = prepared(new float[][]{{70000f}}); + var automatic = fullRange.forQueryScoring(); + float[] out = new float[1]; + automatic.project(new float[]{2f}, out); + if (automatic instanceof NativeASHInt8Projection) { + // auto prefers INT8 where available; a one-column operand is exact in INT8. + assertEquals(140000f, out[0], 140000f * 1e-6f); + } else { + // Without INT8, finite FP32 weights outside FP16 range force the automatic FP32 fallback, + // which keeps the owning view's identity and charges the matrix once. + assertSame(fullRange, automatic); + assertEquals(140000f, out[0]); + assertEquals(Float.BYTES + RamUsageEstimator.shallowSizeOfInstance(NativeASHProjection.class), + fullRange.ramBytesUsed()); + } + } finally { + if (previous == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", previous); + } + } + + @Test + void fp32QueryViewAdvertisesBatchingAndMatchesSingleQueries() { + String previous = System.getProperty("jvector.ash.queryProjection"); + try { + System.setProperty("jvector.ash.queryProjection", "fp32"); + Random random = new Random(5521); + float[][] matrix = random(random, 64, 128); + var query = prepared(matrix).forQueryScoring(); + // Queued ASH search prepares queries in batches only when the view says it can. + assertTrue(query.supportsBatchProjection()); + float[][] inputs = random(random, 8, 128); + float[][] batched = new float[8][64]; + query.projectBatch(inputs, batched, 8); + for (int i = 0; i < 8; i++) { + float[] single = new float[64]; + query.project(inputs[i], single); + for (int r = 0; r < 64; r++) assertEquals(single[r], batched[i][r], 1e-4f); + } + } finally { + if (previous == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", previous); + } + } + + @Test + void fp32ArithmeticSupportsArbitraryBatchSizesAndSimdTails() { + Random random = new Random(32145); + for (int[] shape : new int[][]{{1,1},{3,7},{33,65},{384,768},{1536,3072}}) { + float[][] matrix = random(random, shape[0], shape[1]); + var projection = prepared(matrix); + for (int count : new int[]{0,1,3,4,8,17,32,33,65}) { + float[][] inputs = random(random, count + 1, shape[1]); + float[][] saved = Arrays.stream(inputs).map(float[]::clone).toArray(float[][]::new); + float[][] outputs = new float[count + 1][shape[0]]; + Arrays.fill(outputs[count], 123f); + projection.projectBatch(inputs, outputs, count); + oracle(matrix, inputs, outputs, count); + for (int i = 0; i <= count; i++) assertArrayEquals(saved[i], inputs[i]); + for (float value : outputs[count]) assertEquals(123f, value); + } + // Batches of 4-32 rows ran on Highway, which packs one copy of the matrix. + assertEquals(2L * shape[0] * shape[1] * Float.BYTES + + RamUsageEstimator.shallowSizeOfInstance(NativeASHProjection.class), projection.ramBytesUsed()); + } + } + + @Test + void preservesFullPrecisionWeightsAndCopiesModelOnlyAtPreparation() { + float weight = 1f + Math.scalb(1f, -12), value = 1f + Math.scalb(1f, -13); + float[][] matrix = {{weight}}; + var projection = prepared(matrix); + matrix[0][0] = 0; + float[][] outputs = new float[33][1], inputs = new float[33][1]; + for (float[] input : inputs) input[0] = value; + projection.projectBatch(inputs, outputs, 33); + for (float[] output : outputs) assertEquals(weight * value, output[0], 0f); + float[] singleton = new float[1]; + projection.project(new float[]{value}, singleton); + assertEquals(weight * value, singleton[0], 0f); + var fullRange = prepared(new float[][]{{70000f}}); + float[][] rangeOutput = new float[1][1]; + fullRange.projectBatch(new float[][]{{2f}}, rangeOutput, 1); + assertEquals(140000f, rangeOutput[0][0], 0f); + } + + @Test + void validatesEveryInputBeforeWritingOutputs() { + var projection = prepared(new float[][]{{1,2},{3,4}}); + float[][] x = {{1,2},{3,4}}, y = {{99,99},{99,99}}; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, -1)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, 3)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, x, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],x[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],y[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{x[0],{1}}, y, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{{1}}, new float[][]{{1,2}}, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(null, y, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(new float[][]{x[0],null}, y, 2)); + for (float[] row : y) for (float value : row) assertEquals(99f, value); + projection.projectBatch(new float[0][], new float[0][], 0); + float[][] largeX = new float[65][2], largeY = new float[65][2]; + largeY[64] = largeX[17]; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(largeX, largeY, 65)); + } + + @Test + void concurrentCallersCanSwitchMatricesAndSurviveCollection() throws Exception { + Random random = new Random(8419); + float[][] a = random(random, 33, 65), b = random(random, 129, 257); + var first = prepared(a); var second = prepared(b); + CyclicBarrier barrier = new CyclicBarrier(8); + try (var pool = Executors.newFixedThreadPool(8)) { + var tasks = new ArrayList>(); + for (int t = 0; t < 8; t++) tasks.add(() -> { + float[][] x = random(new Random(14), 33, 65), z = random(new Random(15), 5, 257); + float[][] y = new float[33][33], w = new float[5][129]; + barrier.await(); + for (int i = 0; i < 8; i++) { + first.projectBatch(x, y, 33); second.projectBatch(z, w, 5); + oracle(a, x, y, 33); oracle(b, z, w, 5); + if (i == 4) System.gc(); + } + return null; + }); + for (var result : pool.invokeAll(tasks)) result.get(); + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java new file mode 100644 index 000000000..ff61f2a3b --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHInt8ProjectionTest.java @@ -0,0 +1,437 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; + +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** INT8 numerical, lifecycle, dispatch and compatibility regression tests. */ +@ResourceLock("java.lang.System.properties") +class NativeASHInt8ProjectionTest { + private static final String PROPERTY = "jvector.ash.queryProjection"; + + @BeforeAll + static void loadNative() { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireInt8")) assertTrue(available, "Native library required"); + else assumeTrue(available, "Native library unavailable"); + } + + private static final String[] PATH_FLAGS = {"fma", "avx2", "avx_vnni", "avx512f", "avx512vl", "avx512_vnni", + "avx512_bf16", "avx512_fp16", "amx_tile", "amx_int8"}; + + /** The CPU and settings that select the INT8 path, for failure messages; built only when an assertion fails. */ + private static String cpuContext() { + String vendor = "?", model = "?", flags = ""; + try { + for (String line : Files.readAllLines(Path.of("/proc/cpuinfo"))) { + int colon = line.indexOf(':'); + if (colon < 0) continue; + String key = line.substring(0, colon).trim(), value = line.substring(colon + 1).trim(); + if (key.equals("vendor_id") && vendor.equals("?")) vendor = value; + else if (key.equals("model name") && model.equals("?")) model = value; + else if (key.equals("flags") && flags.isEmpty()) { + var present = new java.util.HashSet<>(Arrays.asList(value.split("\\s+"))); + var kept = new ArrayList(); + for (String flag : PATH_FLAGS) if (present.contains(flag)) kept.add(flag); + flags = String.join(" ", kept); + } + } + } catch (java.io.IOException | RuntimeException e) { + model = "unreadable /proc/cpuinfo (" + e + ")"; + } + return " [cpu: " + vendor + ", " + model + "; flags: " + flags + "; JVECTOR_ASH_INT8_ONEDNN=" + + System.getenv("JVECTOR_ASH_INT8_ONEDNN") + "; os.arch=" + System.getProperty("os.arch") + "]"; + } + + private static VectorUtilSupport.AshProjection prepare(float[][] weights) { + var base = new NativeASHProjection(weights); + try { + return NativeASHInt8Projection.prepare(base.matrix(), base.rows(), base.columns()); + } catch (UnsupportedOperationException unsupported) { + if (Boolean.getBoolean("jvector.test.requireInt8")) throw unsupported; + assumeTrue(false, unsupported.getMessage()); + throw unsupported; + } + } + + private static float scale(float[] values) { + float max = 0; + for (float value : values) max = Math.max(max, Math.abs(value)); + return max == 0 ? 1 : max / 127f; + } + + private static int code(float value, float scale) { + return Math.max(-127, Math.min(127, (int) Math.rint(value / scale))); + } + + // The query is two INT8 digits: value / scale ~ coarse + fine / 254, with fine the coarse remainder. + private static int fine(float value, float scale) { + return Math.max(-127, Math.min(127, (int) Math.rint((value / scale - code(value, scale)) * 254f))); + } + + private static float[] oracle(float[][] matrix, float[] query) { + float queryScale = scale(query); + float[] out = new float[matrix.length]; + for (int row = 0; row < matrix.length; row++) { + float weightScale = scale(matrix[row]); + long coarse = 0, fine = 0; + for (int column = 0; column < query.length; column++) { + int weight = code(matrix[row][column], weightScale); + coarse += (long) code(query[column], queryScale) * weight; + fine += (long) fine(query[column], queryScale) * weight; + } + // As the native kernels: combine the digits exactly, then one conversion and multiplications only. + out[row] = (float) (coarse * 254 + fine) * (1.0f / 254.0f) * queryScale * weightScale; + } + return out; + } + + private static float[][] matrix(int rows, int columns, long seed) { + var random = new Random(seed); + float[][] matrix = new float[rows][columns]; + for (float[] row : matrix) + for (int i = 0; i < row.length; i++) row[i] = random.nextFloat() - .5f; + return matrix; + } + + @Test + void integerOraclePaddingTailsAndSingletonAgree() { + float[][] matrix = matrix(33, 65, 19); + matrix[0] = new float[65]; + var projection = prepare(matrix); + float[][] inputs = matrix(17, 65, 20), outputs = new float[18][33]; + inputs[0] = new float[65]; + Arrays.fill(outputs[17], 42); + for (int count : new int[]{0,1,2,3,4,5,6,7,8,9,17}) { + projection.projectBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) { + assertArrayEquals(oracle(matrix, inputs[i]), outputs[i], 1e-7f); + float[] singleton = new float[33]; + projection.project(inputs[i], singleton); + assertArrayEquals(singleton, outputs[i]); + } + for (float value : outputs[17]) assertEquals(42f, value); + } + } + + @Test + void halfStepWeightsUseNearestEvenAndQueryHalfStepsAreExact() { + float[] ties = {.5f, 1.5f, 2.5f, 3.5f, -.5f, -1.5f, -2.5f, -3.5f}; + float[] expected = {0, 2, 2, 4, 0, -2, -2, -4}; + float[][] weights = new float[32][64]; + float[] query = new float[64]; + query[63] = 127f; // Exact unit scale for both quantization paths. + for (int row = 0; row < ties.length; row++) { + weights[row][row] = 127f; + query[row] = ties[row]; + weights[row + 8][row] = ties[row]; + weights[row + 8][62] = 127f; + } + var projection = prepare(weights); + float[] output = new float[32]; + projection.project(query, output); + // The query's fine digit carries each half step (127 / 254), so queries are exact; weights still + // round half steps to the nearest even code. + for (int row = 0; row < ties.length; row++) { + assertEquals(127f * ties[row], output[row], 1e-4f); + assertEquals(ties[row] * expected[row], output[row + 8], 1e-4f); + } + assertArrayEquals(oracle(weights, query), output); + } + + @Test + void fullRangeProductsDoNotSaturateThroughAccumulatorDimensionBound() { + for (int columns : new int[]{768, 65535}) { + float[][] matrix = new float[32][columns]; + float[] query = new float[columns]; + Arrays.fill(query, 1f); + for (int row = 0; row < matrix.length; row++) Arrays.fill(matrix[row], row % 2 == 0 ? 1f : -1f); + var projection = prepare(matrix); + float[] output = new float[32]; + projection.project(query, output); + assertArrayEquals(oracle(matrix, query), output); + assertEquals(columns, output[0], .02f); + assertEquals(-columns, output[1], .02f); + } + assertThrows(IllegalArgumentException.class, () -> prepare(new float[32][65536])); + } + + @Test + void everyTileSizeMatchesSingletonsThroughAccumulatorDimensionBound() { + // Tiles of 2-8 rows may use a different integer kernel than single rows; + // exact int32 sums must make every tile size agree bit for bit. + for (int columns : new int[]{3, 1023, 65535}) { + float[][] matrix = matrix(47, columns, columns); + for (int column = 0; column < columns; column++) matrix[1][column] = column % 2 == 0 ? 1f : -1f; + var projection = prepare(matrix); + float[][] inputs = matrix(8, columns, columns + 1); + Arrays.fill(inputs[7], 1f); // Saturated codes against the alternating row. + for (int count = 1; count <= 8; count++) { + float[][] outputs = new float[count][47]; + projection.projectBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) { + float[] singleton = new float[47]; + projection.project(inputs[i], singleton); + final int c = count, r = i; + assertArrayEquals(singleton, outputs[i], () -> "columns=" + columns + " count=" + c + " row=" + r + cpuContext()); + if (columns < 65535) assertArrayEquals(oracle(matrix, inputs[i]), outputs[i], 1e-7f, + () -> "oracle, columns=" + columns + " count=" + c + " row=" + r + cpuContext()); + } + } + } + } + + @Test + void fp32ScalesMatchPrototypeAndUnderflowFailsBeforeConversion() { + for (float weight : new float[]{0, Float.MIN_NORMAL, 1e-38f, 1f}) { + float[][] weights = new float[32][64]; + for (float[] row : weights) Arrays.fill(row, weight); + var projection = prepare(weights); + for (float value : new float[]{0, Float.MIN_NORMAL, .25f}) { + float[] query = new float[64], output = new float[32]; + Arrays.fill(query, value); + projection.project(query, output); + assertArrayEquals(oracle(weights, query), output); + } + } + float[][] tinyWeights = new float[32][64]; + for (float[] row : tinyWeights) Arrays.fill(row, Float.MIN_VALUE); + assertThrows(IllegalArgumentException.class, () -> prepare(tinyWeights)); + var projection = prepare(matrix(32,64,100)); + float[] query = new float[64], output = new float[32]; + Arrays.fill(query, Float.MIN_VALUE); + Arrays.fill(output, 42f); + assertThrows(IllegalArgumentException.class, () -> projection.project(query, output)); + for (float value : output) assertEquals(42f, value); + } + + @Test + void invalidInputDoesNotPublishPartialBatchResults() { + var projection = prepare(matrix(32, 64, 1)); + float[][] inputs = matrix(9, 64, 2), outputs = new float[9][32]; + for (float[] row : outputs) Arrays.fill(row, 42); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, -1)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, 10)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, inputs, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, + new float[][]{outputs[0], outputs[0]}, 2)); + for (float invalid : new float[]{Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY}) { + inputs[8][0] = invalid; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(inputs, outputs, 9)); + for (float[] row : outputs) for (float value : row) assertEquals(42f, value); + } + assertThrows(NullPointerException.class, () -> projection.project(null, new float[32])); + assertThrows(NullPointerException.class, () -> projection.project(new float[64], null)); + assertThrows(IllegalArgumentException.class, () -> projection.project(new float[1], new float[32])); + assertThrows(IllegalArgumentException.class, () -> projection.project(new float[64], new float[1])); + float[][] invalidMatrix = matrix(32, 64, 3); + invalidMatrix[0][0] = Float.NaN; + assertThrows(IllegalArgumentException.class, () -> prepare(invalidMatrix)); + } + + @Test + void fp32ResultOverflowMatchesPrototype() { + float[][] weights = new float[32][64]; + for (int row = 0; row < weights.length; row++) + Arrays.fill(weights[row], row % 2 == 0 ? 1f : -1f); + var projection = prepare(weights); + float[] input = new float[64], output = new float[32]; + Arrays.fill(input, Float.MAX_VALUE); + projection.project(input, output); + assertArrayEquals(oracle(weights, input), output); + assertEquals(Float.POSITIVE_INFINITY, output[0]); + assertEquals(Float.NEGATIVE_INFINITY, output[1]); + } + + @Test + void concurrentFirstUseAndAlternatingModelsAndShapes() throws Exception { + float[][][] weights = {matrix(33,65,3), matrix(64,128,4)}; + VectorUtilSupport.AshProjection[] projections = {prepare(weights[0]), prepare(weights[1])}; + try (var pool = Executors.newFixedThreadPool(16)) { + var work = new ArrayList>(); + for (int thread = 0; thread < 16; thread++) { + final int seed = thread; + work.add(() -> { + for (int trial = 0; trial < 16; trial++) { + int model = (trial + seed) % 2, count = 1 + (trial + seed) % 17; + float[][] inputs = matrix(count, weights[model][0].length, seed * 100L + trial); + float[][] outputs = new float[count][weights[model].length]; + projections[model].projectBatch(inputs, outputs, count); + for (int i = 0; i < count; i++) { + final int m = model, c = count, r = i; + assertArrayEquals(oracle(weights[model], inputs[i]), outputs[i], 1e-7f, + () -> "oracle, model=" + m + " count=" + c + " row=" + r + cpuContext()); + } + } + return null; + }); + } + for (var task : pool.invokeAll(work)) task.get(); + } + } + + @Test + void packedMemoryAccountingStabilizesAfterAllBatchShapes() { + var projection = prepare(matrix(64,128,5)); + long previous = projection.ramBytesUsed(); + for (int count = 1; count <= 8; count++) { + projection.projectBatch(matrix(count,128,count), new float[count][64], count); + assertTrue(projection.ramBytesUsed() >= previous); + previous = projection.ramBytesUsed(); + } + long prepared = projection.ramBytesUsed(); + for (int count = 8; count >= 1; count--) + projection.projectBatch(matrix(count,128,count), new float[count][64], count); + assertEquals(prepared, projection.ramBytesUsed()); + } + + @Test + void precisionSelectionPreservesOriginalFp32AndAutomaticPolicy() { + assertTrue(LibraryLoader.loadJvector()); + prepare(matrix(32,64,6)); // Require/qualify this backend before testing its selection. + var original = new NativeASHProjection(matrix(32,64,6)); + String saved = System.getProperty(PROPERTY); + try { + float[] query = matrix(1,64,7)[0], before = new float[32], after = new float[32]; + original.project(query, before); + System.setProperty(PROPERTY, "int8"); + var int8 = original.forQueryScoring(); + assertInstanceOf(NativeASHInt8Projection.class, int8); + assertEquals("int8", int8.operandPrecision()); + original.project(query, after); + assertArrayEquals(before, after); + System.setProperty(PROPERTY, "fp32"); + assertSame(original, original.forQueryScoring()); + // auto prefers INT8 wherever this backend is available. + System.setProperty(PROPERTY, "auto"); + assertInstanceOf(NativeASHInt8Projection.class, original.forQueryScoring()); + } finally { + if (saved == null) System.clearProperty(PROPERTY); else System.setProperty(PROPERTY, saved); + } + } + + @Test + void modelCachesQueryViewAndKeepsEncodingSeparate() { + prepare(matrix(32,64,8)); + double[][] transform = new double[64][32]; + float[][] weights = matrix(32,64,9); + for (int row = 0; row < 32; row++) + for (int column = 0; column < 64; column++) transform[column][row] = weights[row][column]; + String saved = System.getProperty(PROPERTY); + try { + System.setProperty(PROPERTY,"int8"); + var model = new io.github.jbellis.jvector.quantization.AsymmetricHashing.StiefelTransform( + new org.apache.commons.math3.linear.Array2DRowRealMatrix(transform)); + float[] query = matrix(1,64,10)[0], encoding = new float[32], projected = new float[32]; + model.project(query,encoding); + model.projectQueryDirect(query,projected); + assertArrayEquals(oracle(model.AFloat,query),projected,1e-7f); + System.setProperty(PROPERTY,"fp32"); + float[] cached = new float[32], encodedAgain = new float[32]; + model.projectQueryDirect(query,cached); + model.project(query,encodedAgain); + assertArrayEquals(projected,cached); + assertArrayEquals(encoding,encodedAgain); + } finally { + if (saved == null) System.clearProperty(PROPERTY); else System.setProperty(PROPERTY,saved); + } + } + + @Test + void abiVersionAndMissingSymbolsAreRejectedBeforeCalls() throws Exception { + Class type = Class.forName(NativeASHInt8Projection.class.getName() + "$Bindings"); + var constructor = type.getDeclaredConstructor(SymbolLookup.class); + constructor.setAccessible(true); + try (var arena = Arena.ofConfined()) { + for (int version : new int[]{0,1}) { + MemorySegment abi = Linker.nativeLinker().upcallStub(MethodHandles.constant(int.class, version), + FunctionDescriptor.of(JAVA_INT), arena); + SymbolLookup lookup = name -> name.equals("jvector_ash_fp16_abi") ? Optional.of(abi) : Optional.empty(); + var failure = assertThrows(InvocationTargetException.class, () -> constructor.newInstance(lookup)); + assertInstanceOf(UnsatisfiedLinkError.class, failure.getCause()); + } + } + } + + @Test + void nativeStatusesMapToTheirDocumentedJavaFailures() throws Exception { + var failure = NativeASHInt8Projection.class.getDeclaredMethod("failure", int.class, MemorySegment.class); + failure.setAccessible(true); + try (var arena = Arena.ofConfined()) { + var status = arena.allocate(260,4); + status.setString(4,"fixture"); + assertInstanceOf(UnsupportedOperationException.class, failure.invoke(null,1,status)); + assertInstanceOf(IllegalArgumentException.class, failure.invoke(null,2,status)); + assertInstanceOf(IllegalArgumentException.class, failure.invoke(null,3,status)); + assertInstanceOf(IllegalStateException.class, failure.invoke(null,5,status)); + var error = assertThrows(InvocationTargetException.class, () -> failure.invoke(null,4,status)); + assertInstanceOf(OutOfMemoryError.class, error.getCause()); + } + } + + @Test + void legacyIsaCannotSilentlyRunSaturatingFallback(@TempDir Path temporary) throws Exception { + String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + Path log = temporary.resolve("legacy-isa.log"); + var builder = new ProcessBuilder(Path.of(System.getProperty("java.home"),"bin/java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djvector.test.requireInt8=true", "-cp",classpath, LegacyIsaProbe.class.getName()); + builder.environment().put("ONEDNN_MAX_CPU_ISA","AVX2"); + builder.environment().put("DNNL_MAX_CPU_ISA","AVX2"); + Process child = builder.redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue(child.waitFor(30,TimeUnit.SECONDS)); + assertEquals(0,child.exitValue(),Files.readString(log)); + } finally { + if (child.isAlive()) { child.destroyForcibly(); child.waitFor(5,TimeUnit.SECONDS); } + } + } + + public static final class LegacyIsaProbe { + public static void main(String[] args) { + assertTrue(LibraryLoader.loadJvector()); + try { + prepare(new float[32][64]); + throw new AssertionError("INT8 silently accepted legacy ISA"); + } catch (UnsupportedOperationException expected) { /* required mode fails clearly */ } + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java new file mode 100644 index 000000000..3b0332d49 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHPreparationTest.java @@ -0,0 +1,154 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Independent arithmetic oracles cover vector tails, both FFM modes and shared-model use. */ +class NativeASHPreparationTest { + @BeforeAll + static void loadLibrary() { + assumeTrue(LibraryLoader.loadJvector(), "Native library unavailable"); + assertTrue(NativeASHProjection.isAvailable()); + assertTrue(NativeASHSymmetricKernels.hasPairKernel()); + assertTrue(NativeASHSymmetricKernels.hasLutKernel()); + } + + @Test + void projectionMatchesDoubleOracleAndIsThreadSafe() throws Exception { + Random random = new Random(93124); + for (int[] shape : new int[][]{{1,1},{3,7},{4,15},{5,17},{17,31},{33,129},{257,513},{1536,3072}}) { + int rows = shape[0], columns = shape[1]; + float[][] matrix = new float[rows][columns]; + float[] query = new float[columns]; + for (float[] row : matrix) for (int j = 0; j < columns; j++) row[j] = random.nextFloat() - .5f; + for (int j = 0; j < columns; j++) query[j] = random.nextFloat() - .5f; + var projection = new NativeASHProjection(matrix); + assertEquals((long) rows * columns * Float.BYTES, projection.ramBytesUsed()); + float[] expected = new float[rows]; + double[] magnitude = new double[rows]; + for (int i = 0; i < rows; i++) { + double sum = 0; + for (int j = 0; j < columns; j++) { + double product = (double) matrix[i][j] * query[j]; + sum += product; magnitude[i] += Math.abs(product); + } + expected[i] = (float) sum; + } + float[] original = query.clone(); + try (var workers = Executors.newFixedThreadPool(4)) { + var tasks = new ArrayList>(); + for (int repeat = 0; repeat < 8; repeat++) tasks.add(() -> { + float[] out = new float[rows]; + projection.project(query, out); + for (int i = 0; i < rows; i++) assertEquals(expected[i], out[i], 2e-6 * magnitude[i] + 1e-7); + return null; + }); + for (var result : workers.invokeAll(tasks)) result.get(); + } + assertArrayEquals(original, query); + } + } + + @Test + void packedPairsMatchIndependentDecoderIncludingDirtyPadding() { + Random random = new Random(55191); + for (int bits : new int[]{1,2,4}) { + for (int dimensions : new int[]{0,1,2,3,7,15,16,17,31,32,33,63,64,65,127,128,129,257,384,512,768,1536,65537}) { + long[] aBits = new long[(dimensions + 63) / 64], bBits = aBits.clone(); + byte[] a = new byte[(dimensions * bits + 7) / 8], b = a.clone(); + for (int i = 0; i < aBits.length; i++) { aBits[i] = random.nextLong(); bBits[i] = random.nextLong(); } + random.nextBytes(a); random.nextBytes(b); + long total = 0; + for (int j = 0; j < dimensions; j++) { + if (bits == 1) total += (((aBits[j/64] ^ bBits[j/64]) >>> (j%64)) & 1) == 0 ? 1 : -1; + else total += (long) doubled(a,j,bits) * doubled(b,j,bits); + } + float expected = bits == 1 ? (float) total : total * .25f; + assertEquals(expected, NativeASHSymmetricKernels.dot(aBits,a,bBits,b,dimensions,bits), 0f); + assertEquals(expected, NativeASHSymmetricKernels.dot(bBits,b,aBits,a,dimensions,bits), 0f); + } + } + } + + private static int doubled(byte[] codes, int j, int bits) { + int field = (codes[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); + int sign = 1 << (bits - 1), magnitude = 2 * (field & (sign - 1)) + 1; + return (field & sign) == 0 ? -magnitude : magnitude; + } + + @Test + void integerLutHandlesOffsetsTailsAndOverflow() { + Random random = new Random(1021); + int stride = 37, lane = 3; + for (int groups : new int[]{0,1,2,31,63,64,65,257,4097,65539}) { + byte[] codes = new byte[((groups+1)/2)*stride]; random.nextBytes(codes); + short[] lut = new short[groups*32]; + for (int g = 0; g < groups; g++) for (int i = 0; i < 16; i++) { + short value = groups == 65539 ? Short.MAX_VALUE : (short) random.nextInt(); + lut[g*32+i] = lut[g*32+i+16] = value; + } + for (int count : new int[]{0,1,7,16,17,32,34}) { + float[] out = new float[count+2]; Arrays.fill(out, Float.NaN); + NativeASHSymmetricKernels.scoreLut(codes,groups,stride,lane,count,lut,out,1); + assertTrue(Float.isNaN(out[0])); assertTrue(Float.isNaN(out[count+1])); + for (int i = 0; i < count; i++) { + long sum = 0; + for (int g = 0; g < groups; g++) sum += lut[g*32+((codes[(g/2)*stride+lane+i] >>> (4*(g%2))) & 15)]; + assertEquals(sum*.25f,out[i+1],0f); + } + } + } + } + + @Test + void defaultQueryViewPreservesProvidersWithoutReducedPrecision() { + VectorUtilSupport.AshProjection original = (query, out) -> out[0] = query[0]; + assertSame(original, original.forQueryScoring()); + float[] out = new float[1]; + original.forQueryScoring().project(new float[]{.1234567f}, out); + assertEquals(.1234567f, out[0]); + } + + @Test + void rejectsUnsafeInputsBeforeNativeCall() { + assertThrows(NullPointerException.class, () -> new NativeASHProjection(null)); + assertThrows(NullPointerException.class, () -> new NativeASHProjection(new float[][]{null})); + assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[0][])); + assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[][]{new float[0]})); + var projection = new NativeASHProjection(new float[][]{{1,2},{3,4}}); + float[] alias = {1,2}; + assertThrows(IllegalArgumentException.class, () -> projection.project(alias,alias)); + assertThrows(IllegalArgumentException.class, () -> projection.project(new float[1],new float[2])); + assertThrows(IllegalArgumentException.class, () -> new NativeASHProjection(new float[][]{{1},{2,3}})); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dot(null,new byte[1],null,new byte[1],9,2)); + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,-1,2)); + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dot(null,null,null,null,1,3)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,7,2,new short[32],new float[2],0)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.scoreLut(new byte[8],1,8,0,8,new short[31],new float[8],0)); + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java new file mode 100644 index 000000000..ba92aaee2 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHProjectionTest.java @@ -0,0 +1,175 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.foreign.SymbolLookup; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** FP32 batch arithmetic, caller-buffer contracts and shared-matrix lifetime. */ +class NativeASHProjectionTest { + @BeforeAll + static void loadNative() { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireNative")) assertTrue(available); + else assumeTrue(available, "Native library unavailable"); + assertTrue(SymbolLookup.loaderLookup().find("ash_project_batch_f32").isPresent(), + "Tests must exercise the new native batch kernel, not compatibility fallback"); + } + + private static float[][] randomRows(Random random, int rows, int columns) { + float[][] result = new float[rows][columns]; + for (float[] row : result) + for (int i = 0; i < columns; i++) row[i] = random.nextFloat() - .5f; + return result; + } + + private static void checkOracle(float[][] matrix, float[][] inputs, float[][] out, int count) { + for (int b = 0; b < count; b++) { + for (int row = 0; row < matrix.length; row++) { + double expected = 0, magnitude = 0; + for (int col = 0; col < matrix[row].length; col++) { + double product = (double) matrix[row][col] * inputs[b][col]; + expected += product; + magnitude += Math.abs(product); + } + assertEquals(expected, out[b][row], 5e-6 * magnitude + 1e-7, + "vector " + b + ", row " + row); + } + } + } + + @Test + void providerRetainsHighwayWhenOptionalFp32IsUnavailable() { + var projection = new NativeVectorUtilSupport().prepareAshProjection(new float[][]{{1,2},{3,4}}); + if (Boolean.getBoolean("jvector.test.requireFp32OneDnn")) + assertInstanceOf(NativeASHFP32BatchProjection.class, projection); + if (Boolean.getBoolean("jvector.test.requireFp32Absent")) + assertInstanceOf(NativeASHProjection.class, projection); + float[][] outputs = new float[1][2]; + projection.projectBatch(new float[][]{{2,3}}, outputs, 1); + assertArrayEquals(new float[]{8,18}, outputs[0]); + } + + @Test + void nativeAndPanamaMatchDoubleOracleAcrossBatchAndSimdTails() { + Random random = new Random(829104); + for (int[] shape : new int[][]{{1,1}, {3,7}, {17,31}, {33,65}, {384,768}, {1536,3072}}) { + float[][] matrix = randomRows(random, shape[0], shape[1]); + var nativeProjection = new NativeASHProjection(matrix); + var panama = new PanamaVectorUtilSupport().prepareAshProjection(matrix); + long matrixBytes = (long) shape[0] * shape[1] * Float.BYTES; + assertEquals(matrixBytes, nativeProjection.ramBytesUsed()); + for (int count : new int[]{0,1,3,4,5,8,17,32,33,65}) { + float[][] inputs = randomRows(random, count + 1, shape[1]); + float[][] out = new float[count + 1][shape[0]]; + float[][] reference = new float[count + 1][shape[0]]; + float[][] saved = Arrays.stream(inputs).map(float[]::clone).toArray(float[][]::new); + Arrays.fill(out[count], 123f); + nativeProjection.projectBatch(inputs, out, count); + panama.projectBatch(inputs, reference, count); + checkOracle(matrix, inputs, out, count); + for (int b = 0; b < count; b++) { + float[][] one = new float[1][shape[0]]; + nativeProjection.projectBatch(new float[][]{inputs[b]}, one, 1); + assertArrayEquals(out[b], one[0], "Batch size must not change native arithmetic"); + } + checkOracle(matrix, inputs, reference, count); + for (int b = 0; b <= count; b++) assertArrayEquals(saved[b], inputs[b]); + for (float value : out[count]) assertEquals(123f, value); + } + assertEquals(2 * matrixBytes, nativeProjection.ramBytesUsed()); + } + } + + @Test + void retainsFp32OperandsIncludingValuesThatRoundInFp16() { + float weight = 1f + Math.scalb(1f, -12); + float value = 1f + Math.scalb(1f, -13); + var projection = new NativeASHProjection(new float[][]{{weight}}); + float[][] out = new float[5][1]; + projection.projectBatch(new float[][]{{value},{value},{value},{value},{value}}, out, 5); + for (float[] row : out) assertEquals(weight * value, row[0], 0f); + float[] single = new float[1]; + projection.project(new float[]{value}, single); + assertEquals(weight * value, single[0], 0f); + assertNotEquals(1f, out[0][0]); + } + + @Test + void validatesShapesCountsAndAliasingBeforeWriting() { + for (var projection : new VectorUtilSupport.AshProjection[]{ + new NativeASHProjection(new float[][]{{1,2},{3,4}}), + new PanamaVectorUtilSupport().prepareAshProjection(new float[][]{{1,2},{3,4}})}) { + float[][] x = {{1,2},{3,4}}, y = {{99,99},{99,99}}; + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, -1)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, y, 3)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, x, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],y[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(x, new float[][]{y[0],x[0]}, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{x[0],{1}}, y, 2)); + assertThrows(IllegalArgumentException.class, () -> projection.projectBatch(new float[][]{{1}}, new float[][]{{1,2}}, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(null, y, 1)); + assertThrows(NullPointerException.class, () -> projection.projectBatch(new float[][]{null}, y, 1)); + for (float[] row : y) for (float value : row) assertEquals(99f, value); + projection.projectBatch(new float[0][], new float[0][], 0); + float[][] manyInputs = new float[65][2], manyOutputs = new float[65][2]; + manyOutputs[64] = manyInputs[17]; + assertThrows(IllegalArgumentException.class, + () -> projection.projectBatch(manyInputs, manyOutputs, 65)); + manyOutputs[64] = manyOutputs[3]; + assertThrows(IllegalArgumentException.class, + () -> projection.projectBatch(manyInputs, manyOutputs, 65)); + } + } + + @Test + void lazyPackingAndStagingRemainSafeAcrossWorkersAndModels() throws Exception { + Random random = new Random(40215); + float[][] a = randomRows(random, 33, 65), b = randomRows(random, 129, 257); + var first = new NativeASHProjection(a); + var second = new NativeASHProjection(b); + CyclicBarrier start = new CyclicBarrier(8); + try (var pool = Executors.newFixedThreadPool(8)) { + var tasks = new ArrayList>(); + for (int t = 0; t < 8; t++) tasks.add(() -> { + float[][] inputA = randomRows(new Random(42), 35, 65); + float[][] inputB = randomRows(new Random(43), 5, 257); + float[][] outA = new float[35][33], outB = new float[5][129]; + start.await(); + for (int call = 0; call < 8; call++) { + first.projectBatch(inputA, outA, 35); + second.projectBatch(inputB, outB, 5); + checkOracle(a, inputA, outA, 35); + checkOracle(b, inputB, outB, 5); + if (call == 4) System.gc(); + } + return null; + }); + for (var task : pool.invokeAll(tasks)) task.get(); + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java new file mode 100644 index 000000000..9a8de0a4b --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSingleScoringTest.java @@ -0,0 +1,98 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.graph.*; +import io.github.jbellis.jvector.graph.similarity.*; +import io.github.jbellis.jvector.quantization.*; +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Native default dispatch must preserve independent, query-owned prepared state. */ +class NativeASHSingleScoringTest { + private static final class Values implements RandomAccessVectorValues { + final VectorFloat[] vectors; + Values(int count, int dim) { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(51237); + vectors = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + float[] value = new float[dim]; + for (int j = 0; j < dim; j++) value[j] = random.nextFloat() - .5f; + vectors[i] = types.createFloatVector(value); + VectorUtil.l2normalize(vectors[i]); + } + } + public int size() { return vectors.length; } + public int dimension() { return vectors[0].length(); } + public VectorFloat getVector(int i) { return vectors[i]; } + public boolean isValueShared() { return false; } + public RandomAccessVectorValues copy() { return this; } + } + + @Test void defaultSingleScorersUseNativeWithoutSilentFallback() throws Exception { + boolean available = LibraryLoader.loadJvector(); + if (Boolean.getBoolean("jvector.test.requireNative")) assertTrue(available, "Native library required"); + else assumeTrue(available, "Native library unavailable"); + + var output = java.nio.file.Files.createTempFile("ash-default-native-", ".log"); + Process child = null; + try { + child = new ProcessBuilder(java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djvector.vectorization_provider=native", "-cp", System.getProperty("java.class.path"), + DefaultSingleProbe.class.getName()).redirectErrorStream(true).redirectOutput(output.toFile()).start(); + assertTrue(child.waitFor(30, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(0, child.exitValue(), java.nio.file.Files.readString(output)); + } finally { + if (child != null && child.isAlive()) child.destroyForcibly().waitFor(); + java.nio.file.Files.deleteIfExists(output); + } + } + + public static final class DefaultSingleProbe { + public static void main(String[] args) throws Exception { verifyDefaultNativeSingle(); } + } + + private static void verifyDefaultNativeSingle() throws Exception { + assertEquals("native", System.getProperty("jvector.vectorization_provider")); + for (int bits : new int[] {2, 4}) { + var values = new Values(40, 64); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var expected = new float[40]; + encoded.blockScorerFor(values.getVector(0), VectorSimilarityFunction.DOT_PRODUCT) + .scoreRange(0, 40, expected); + var single = encoded.scoreFunctionFor(values.getVector(0), VectorSimilarityFunction.DOT_PRODUCT); + var prepared = encoded.precomputedScoreFunctionFor(values.getVector(0), VectorSimilarityFunction.DOT_PRODUCT); + encoded.scoreFunctionFor(values.getVector(7), VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < 40; i++) { + assertEquals(expected[i], single.similarityTo(i), 2e-5f); + assertEquals(expected[i], prepared.similarityTo(i), 2e-5f); + } + } + // A regression to the old direct b=2 dispatch must fail rather than silently use Java. + assertThrows(IllegalStateException.class, () -> new NativeVectorUtilSupport() + .ashProjectionDot(new float[2], new byte[1], 2, 2)); + } + +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java new file mode 100644 index 000000000..17d047150 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeASHSymmetricDotAtTest.java @@ -0,0 +1,121 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Offset kernels on codes embedded in larger arrays must equal the standalone kernels, bit for bit. */ +class NativeASHSymmetricDotAtTest { + @BeforeAll + static void loadLibrary() { + assumeTrue(LibraryLoader.loadJvector(), "Native library unavailable"); + assertTrue(NativeASHSymmetricKernels.hasPairKernel()); + } + + @Test + void offsetCodesMatchStandaloneCodes() { + Random random = new Random(20261108); + for (int bits : new int[]{2, 4}) { + for (int dimensions : new int[]{0, 1, 3, 7, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, + 257, 384, 512, 768, 1024, 1536, 3072, 65537}) { + int bytes = (dimensions * bits + 7) / 8; + for (int trial = 0; trial < 4; trial++) { + byte[] a = new byte[bytes], b = new byte[bytes]; + random.nextBytes(a); random.nextBytes(b); + // Embed at arbitrary offsets surrounded by noise, as in a code page. + int aOff = random.nextInt(130), bOff = random.nextInt(130); + byte[] aPage = new byte[aOff + bytes + random.nextInt(70)]; + byte[] bPage = new byte[bOff + bytes + random.nextInt(70)]; + random.nextBytes(aPage); random.nextBytes(bPage); + System.arraycopy(a, 0, aPage, aOff, bytes); + System.arraycopy(b, 0, bPage, bOff, bytes); + float expected = NativeASHSymmetricKernels.dot(null, a, null, b, dimensions, bits); + assertEquals(expected, NativeASHSymmetricKernels.dotAt(aPage, aOff, bPage, bOff, dimensions, bits), 0f, + "bits=" + bits + " d=" + dimensions); + assertEquals(expected, NativeASHSymmetricKernels.dotAt(bPage, bOff, aPage, aOff, dimensions, bits), 0f); + } + } + } + } + + /** Every dimension count, including partial words and vectors, must equal a per-dimension decode. */ + @Test + void codesMatchPerDimensionReference() { + Random random = new Random(20261111); + for (int bits : new int[]{2, 4}) { + for (int dimensions = 1; dimensions <= 400; dimensions++) { + int bytes = (dimensions * bits + 7) / 8; + byte[] a = new byte[bytes], b = new byte[bytes]; + // Random padding bits as well: the kernels must ignore them. + random.nextBytes(a); random.nextBytes(b); + long doubled = 0; + for (int j = 0; j < dimensions; j++) doubled += (long) component(a, j, bits) * component(b, j, bits); + assertEquals(doubled * 0.25f, NativeASHSymmetricKernels.dot(null, a, null, b, dimensions, bits), 0f, + "bits=" + bits + " d=" + dimensions); + } + } + } + + /** Doubled signed-magnitude component j: the sign is the field's high bit, the magnitude its low bits. */ + private static int component(byte[] code, int j, int bits) { + int perByte = 8 / bits; + int field = (code[j / perByte] & 0xFF) >>> ((j % perByte) * bits); + int sign = 1 << (bits - 1); + int magnitude = 2 * (field & (sign - 1)) + 1; + return (field & sign) != 0 ? magnitude : -magnitude; + } + + @Test + void offsetSignWordsMatchStandaloneSignWords() { + Random random = new Random(20261109); + for (int dimensions : new int[]{0, 1, 63, 64, 65, 384, 1000, 65537}) { + int words = (dimensions + 63) / 64; + for (int trial = 0; trial < 4; trial++) { + long[] a = random.longs(words).toArray(), b = random.longs(words).toArray(); + int aOff = random.nextInt(20), bOff = random.nextInt(20); + long[] aPage = random.longs(aOff + words + random.nextInt(9)).toArray(); + long[] bPage = random.longs(bOff + words + random.nextInt(9)).toArray(); + System.arraycopy(a, 0, aPage, aOff, words); + System.arraycopy(b, 0, bPage, bOff, words); + float expected = NativeASHSymmetricKernels.dot(a, null, b, null, dimensions, 1); + assertEquals(expected, NativeASHSymmetricKernels.binaryDotAt(aPage, aOff, bPage, bOff, dimensions), 0f, + "d=" + dimensions); + } + } + long[] page = new long[4]; + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.binaryDotAt(page, 0, page, 0, -1)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.binaryDotAt(page, 3, page, 0, 128)); + assertDoesNotThrow(() -> NativeASHSymmetricKernels.binaryDotAt(page, 2, page, 0, 128)); + } + + @Test + void argumentAndBoundsChecks() { + byte[] page = new byte[200]; + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dotAt(page, 0, page, 0, 8, 1)); + assertThrows(IllegalArgumentException.class, () -> NativeASHSymmetricKernels.dotAt(page, 0, page, 0, -1, 2)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dotAt(page, 150, page, 0, 384, 2)); + assertThrows(IndexOutOfBoundsException.class, () -> NativeASHSymmetricKernels.dotAt(page, 0, page, -1, 8, 2)); + // Exactly fitting at the end of the page is legal. + assertDoesNotThrow(() -> NativeASHSymmetricKernels.dotAt(page, 104, page, 0, 384, 2)); + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java new file mode 100644 index 000000000..b03448375 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeBlasThreadsTest.java @@ -0,0 +1,185 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** The native provider sets BLAS threads, and ASH training bounds them by its executor. */ +class NativeBlasThreadsTest { + // A fake process-wide library: one count shared by every thread, like openblas_set_num_threads. + private static int processCount; + private static void fakeSet(int threads) { processCount = threads; } + private static int fakeGet() { return processCount; } + + private static NativeBlasThreads.Control processWide() throws Exception { + var lookup = java.lang.invoke.MethodHandles.lookup(); + var set = lookup.findStatic(NativeBlasThreadsTest.class, "fakeSet", + java.lang.invoke.MethodType.methodType(void.class, int.class)); + var get = lookup.findStatic(NativeBlasThreadsTest.class, "fakeGet", + java.lang.invoke.MethodType.methodType(int.class)); + return new NativeBlasThreads.Control("fake process-wide", set, get, true); + } + + @Test + void overlappingProcessWideScopesHoldTheCountUntilTheLastCloses() throws Exception { + var control = processWide(); + processCount = 48; + int a = control.set(1); // scope A opens on one thread + int b = control.set(1); // scope B opens on another before A closes + assertEquals(1, processCount); + control.set(a); // A closes while B is still open + assertEquals(1, processCount, "B must keep its single thread"); + control.set(b); // B closes last + assertEquals(48, processCount, "The process count must be restored"); + control.set(b); // a repeated restore changes nothing + assertEquals(48, processCount); + } + + @Test + void aConflictingProcessWideScopeDoesNotChangeTheHeldCount() throws Exception { + var control = processWide(); + processCount = 48; + int a = control.set(1); + int b = control.set(4); // cannot change a count another thread is using + assertEquals(1, processCount); + control.set(b); + control.set(a); + assertEquals(48, processCount); + } + + private static boolean supported() { + return !"none".equals(NativeBlasThreads.description()); + } + + @Test + void setterReturnsTheValueToRestore() { + assumeTrue(supported(), "No native BLAS thread control on this host"); + int previous = NativeBlasThreads.set(3); + try { + assertEquals(3, NativeBlasThreads.set(previous), NativeBlasThreads.description()); + } finally { + NativeBlasThreads.set(previous); + } + } + + @Test + void trainingUsesOneBlasThreadWithoutAnExecutorAndTheExecutorSizeWithOne() throws Exception { + assumeTrue(supported() && Files.isDirectory(Path.of("/proc/self/task")), + "Needs native BLAS thread control and Linux thread accounting"); + int cores = Runtime.getRuntime().availableProcessors(); + assumeTrue(cores >= 16, "Needs enough CPUs to tell one BLAS thread from many"); + int single = nativeThreadsStarted("single"); + int pooled = nativeThreadsStarted("pooled"); + assertTrue(single <= 1, "Training without an executor started " + single + " native threads"); + assertTrue(pooled >= single + 8, "Training with a " + cores + "-thread executor started only " + + pooled + " native threads; the BLAS limit may not be applied"); + } + + /** Native-library threads a fresh JVM gains while training ASH with ITQ. */ + private static int nativeThreadsStarted(String mode) throws Exception { + var output = Files.createTempFile("blas-threads-", ".log"); + Process child = null; + try { + var builder = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--add-modules=jdk.incubator.vector", "--enable-native-access=ALL-UNNAMED", + "-Djvector.vectorization_provider=native", "-cp", System.getProperty("java.class.path"), + TrainingProbe.class.getName(), mode) + .redirectErrorStream(true).redirectOutput(output.toFile()); + for (var name : new String[] {"MKL_NUM_THREADS", "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "GOTO_NUM_THREADS"}) { + builder.environment().remove(name); + } + child = builder.start(); + assertTrue(child.waitFor(120, TimeUnit.SECONDS), "Training probe timed out"); + String log = Files.readString(output); + assertEquals(0, child.exitValue(), log); + var match = Pattern.compile("native-threads-started=(\\d+)").matcher(log); + assertTrue(match.find(), log); + return Integer.parseInt(match.group(1)); + } finally { + if (child != null && child.isAlive()) child.destroyForcibly().waitFor(); + Files.deleteIfExists(output); + } + } + + public static final class TrainingProbe { + public static void main(String[] args) throws Exception { + var values = new Values(12_000, 512); + int before = nativeThreads(); + if ("pooled".equals(args[0])) { + var pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors()); + AsymmetricHashing.initialize(values, AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 512, 1, 2, pool); + pool.shutdown(); + } else { + AsymmetricHashing.initialize(values, AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 512, 1, 2); + } + System.out.println("native-threads-started=" + Math.max(0, nativeThreads() - before)); + } + + /** + * Threads the JVM did not name. The JVM names its own and Java threads, so these are the + * threads native libraries such as MKL's OpenMP runtime start; they keep the process name. + */ + private static int nativeThreads() throws Exception { + int count = 0; + try (var tasks = Files.list(Path.of("/proc/self/task"))) { + for (var task : (Iterable) tasks::iterator) { + try { + if ("java".equals(Files.readString(task.resolve("comm")).trim())) count++; + } catch (java.nio.file.NoSuchFileException exited) { + // The thread ended while listing. + } + } + } + return count; + } + } + + private static final class Values implements RandomAccessVectorValues { + private final VectorFloat[] vectors; + + Values(int count, int dim) { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(7); + vectors = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + float[] value = new float[dim]; + for (int j = 0; j < dim; j++) value[j] = (float) random.nextGaussian(); + vectors[i] = types.createFloatVector(value); + VectorUtil.l2normalize(vectors[i]); + } + } + + public int size() { return vectors.length; } + public int dimension() { return vectors[0].length(); } + public VectorFloat getVector(int i) { return vectors[i]; } + public boolean isValueShared() { return false; } + public RandomAccessVectorValues copy() { return this; } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeVectorStoreTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeVectorStoreTest.java new file mode 100644 index 000000000..85f49fb19 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/NativeVectorStoreTest.java @@ -0,0 +1,63 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.vector.cnative.LibraryLoader; +import java.util.Arrays; +import java.util.List; +import jdk.incubator.vector.FloatVector; +import jdk.incubator.vector.VectorSpecies; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Exercise the heap-backed store intrinsic on JDK 22 without compiler exclusions. */ +class NativeVectorStoreTest { + private static NativeVectorUtilSupport backend; + + @BeforeAll + static void loadNativeLibrary() { + String arch = System.getProperty("os.arch", ""); + assumeTrue(arch.equals("amd64") || arch.equals("x86_64")); + assumeTrue(LibraryLoader.loadJvector(), "Native jvector library not available"); + backend = new NativeVectorUtilSupport(); + } + + @Test + void storesPreserveOffsetsAndCanariesForEveryFloatSpecies() { + List> species = List.of( + FloatVector.SPECIES_64, FloatVector.SPECIES_128, FloatVector.SPECIES_256, FloatVector.SPECIES_512); + for (VectorSpecies shape : species) { + float[] values = new float[shape.length()]; + for (int i = 0; i < values.length; i++) + values[i] = i + .25f; + var vector = FloatVector.fromArray(shape, values, 0); + float[] destination = new float[shape.length() + 5]; + Arrays.fill(destination, -17f); + var output = new MemorySegmentVectorFloat(destination); + // Repeated stores exercise compiled Vector API code, where JDK 22 previously crashed. + for (int i = 0; i < 20_000; i++) + backend.intoVectorFloat(vector, output, 3); + for (int i = 0; i < destination.length; i++) { + float expected = i >= 3 && i < 3 + values.length ? values[i - 3] : -17f; + assertEquals(expected, destination[i], 0f); + } + } + } +} diff --git a/jvector-native/src/test/java/io/github/jbellis/jvector/vector/cnative/LibraryLoaderTest.java b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/cnative/LibraryLoaderTest.java new file mode 100644 index 000000000..7f2059227 --- /dev/null +++ b/jvector-native/src/test/java/io/github/jbellis/jvector/vector/cnative/LibraryLoaderTest.java @@ -0,0 +1,46 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.vector.cnative; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import static org.junit.jupiter.api.Assertions.*; + +@ResourceLock("java.lang.System.properties") +class LibraryLoaderTest { + @Test + void optionalResourcesFollowTheExistingArchitectureConvention() { + String previous = System.getProperty("os.arch"); + try { + for (String arch : new String[]{"amd64", "x86_64"}) { + System.setProperty("os.arch", arch); + assertEquals("/libjvector-x86_64.so", LibraryLoader.resourceNameForArch()); + assertEquals("/libjvector_ash_fp16-x86_64.so", LibraryLoader.resourceNameForArch("jvector_ash_fp16")); + } + for (String arch : new String[]{"aarch64", "arm64"}) { + System.setProperty("os.arch", arch); + assertEquals("/libjvector-aarch64.so", LibraryLoader.resourceNameForArch()); + assertEquals("/libjvector_ash_fp16-aarch64.so", LibraryLoader.resourceNameForArch("jvector_ash_fp16")); + } + System.setProperty("os.arch", "unsupported"); + assertNull(LibraryLoader.resourceNameForArch()); + assertNull(LibraryLoader.resourceNameForArch("jvector_ash_fp16")); + } finally { + if (previous == null) System.clearProperty("os.arch"); + else System.setProperty("os.arch", previous); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java index 06b1f0880..adfdc1a2a 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/disk/TestFvecReadProgress.java @@ -23,8 +23,14 @@ import java.nio.channels.*; import static org.junit.Assert.*; +/** + * A reader that spins would call the test channel without end, so the channel fails after a fixed number of calls + * instead of the tests relying on a wall-clock timeout, which a slow or busy machine can exceed. + */ public class TestFvecReadProgress { - @Test(timeout = 1000) public void repeatedZeroReadsFailInsteadOfSpinning() { + private static final int MAX_CALLS = 1000; + + @Test public void repeatedZeroReadsFailInsteadOfSpinning() { var channel = new TestChannel(-1); var failure = assertThrows(IOException.class, () -> FvecFileVectorValues.readFully(channel, ByteBuffer.allocate(1), 123)); @@ -32,7 +38,7 @@ public class TestFvecReadProgress { assertEquals(16, channel.calls); } - @Test(timeout = 1000) public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { + @Test public void progressResetsTheZeroReadLimitAndPreservesOffsets() throws Exception { var channel = new TestChannel(15); var buffer = ByteBuffer.allocate(4); buffer.position(2); @@ -43,20 +49,24 @@ public class TestFvecReadProgress { assertEquals((byte) 124, buffer.get(3)); } - @Test(timeout = 1000) public void eofStillFailsImmediately() { + @Test public void eofStillFailsImmediately() { var channel = new TestChannel(0) { - @Override public int read(ByteBuffer buffer, long offset) { return -1; } + @Override public int read(ByteBuffer buffer, long offset) { countCall(); return -1; } }; assertThrows(EOFException.class, () -> FvecFileVectorValues.readFully(channel, ByteBuffer.allocate(1), 0)); + assertEquals(1, channel.calls); } private static class TestChannel extends FileChannel { final int zerosPerByte; int calls, zeros; TestChannel(int zerosPerByte) { this.zerosPerByte = zerosPerByte; } + void countCall() { + if (++calls > MAX_CALLS) throw new AssertionError("readFully kept reading after " + MAX_CALLS + " calls"); + } @Override public int read(ByteBuffer buffer, long offset) { - calls++; + countCall(); if (zerosPerByte < 0 || zeros++ < zerosPerByte) return 0; zeros = 0; buffer.put((byte) offset); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java new file mode 100644 index 000000000..c025b2487 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodBenchmark.java @@ -0,0 +1,203 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.*; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.io.IOException; +import java.nio.file.Files; +import java.util.*; +import static org.junit.Assert.*; + +/** Fixture replay, full-scan scheduling, and failure-boundary checks for DistancesASH. */ +public class TestASHNeighborhoodBenchmark { + @Test + public void serializedNeighborhoodsMatchOracleAcrossWidthsAndTails() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + List> vectors = new ArrayList<>(); + Random random = new Random(5293); + for (int i = 0; i < 35; i++) { + float[] data = new float[64]; + for (int d = 0; d < data.length; d++) + data[d] = random.nextFloat() - .5f; + var vector = types.createFloatVector(data); + VectorUtil.l2normalize(vector); + vectors.add(vector); + } + var values = new ListRandomAccessVectorValues(vectors, 64); + for (int bits : new int[] {1, 2, 3, 4}) { + var ash = AsymmetricHashing.initialize( + values, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + ASHVectors encoded = (ASHVectors) ash.encodeAll(values); + for (int capacity : new int[] {1, 8, 16, 23, 32, 64}) { + int blockSize = FusedASHLayout.chooseBlockSize(capacity); + byte[][] packed = new byte[(vectors.size() - 1) / capacity + 1] + [FusedASHLayout.featureSize(capacity, 33, bits, blockSize)]; + for (int i = 0; i < vectors.size(); i++) { + int neighbor = i % capacity; + FusedASHLayout.packForFused(packed[i / capacity], + FusedASHLayout.blockOffset(neighbor / blockSize, 33, bits, blockSize), neighbor % blockSize, + encoded.get(i), ash, blockSize); + } + var fixture = new ASHNeighborhoodFixture( + ash, vectors.subList(0, 2), packed, capacity, blockSize, vectors.size()); + ASHNeighborhoodValidator.validate(fixture); + if (bits == 2 && capacity == 8) { + var query = fixture.queries.get(0); + var reference = new ASHNeighborhoodScorer(ash, query, capacity, blockSize); + float[] output = new float[capacity]; + double checksum = 0; + for (byte[] neighborhood : packed) { + reference.score(neighborhood, output); + for (float score : output) + checksum += score; + } + int[] order = ASHNeighborhoodBenchmark.accessOrder(packed.length, "shuffled"); + int[] duplicate = order.clone(); + duplicate[0] = duplicate[1]; + assertThrows( + IllegalArgumentException.class, () -> new ASHNeighborhoodPipeline(fixture, duplicate, 1)); + assertThrows( + IllegalArgumentException.class, () -> new ASHNeighborhoodPipeline(fixture, new int[0], 1)); + for (int threads : new int[] {1, 2, 4}) { + try (var parallel = new ASHNeighborhoodPipeline(fixture, order, threads)) { + for (int sweep = 0; sweep < 3; sweep++) { + // Both scans can be in flight; each must retain its own prepared query. + var firstScan = parallel.submit(query); + var nextQuery = fixture.queries.get(1); + var nextScorer = new ASHNeighborhoodScorer(ash, nextQuery, capacity, blockSize); + double nextChecksum = 0; + for (byte[] neighborhood : packed) { + nextScorer.score(neighborhood, output); + for (float score : output) + nextChecksum += score; + } + var nextScan = parallel.submit(nextQuery); + assertEquals(checksum, firstScan.await(), 1e-9); + assertEquals(nextChecksum, nextScan.await(), 1e-9); + } + } + } + var closed = new ASHNeighborhoodPipeline(fixture, order, 1); + closed.close(); + assertThrows(IllegalStateException.class, () -> closed.submit(query)); + try (var pipeline = new ASHNeighborhoodPipeline(fixture, order, 1)) { + var submitted = pipeline.submit(query); + Thread.currentThread().interrupt(); + try { + assertThrows(IllegalStateException.class, submitted::await); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); // Do not leak interruption into the JUnit runner. + } + } + // A failed worker must report its exception and still shut down cleanly. + int landmark = FusedASHLayout.landmarkOffset(0, 33, bits, blockSize); + byte saved = packed[0][landmark]; + packed[0][landmark] = (byte) 255; + try (var parallel = new ASHNeighborhoodPipeline(fixture, order, 2)) { + assertThrows(IllegalStateException.class, () -> parallel.submit(query).await()); + } finally { + packed[0][landmark] = saved; + } + } + var path = Files.createTempFile("ash-neighborhood-", ".ashn"); + Files.delete(path); // The writer deliberately refuses to overwrite fixtures. + try { + ASHNeighborhoodFixture.write(path, fixture); + var loaded = ASHNeighborhoodFixture.read(path); + ASHNeighborhoodValidator.validate(loaded); + assertEquals(fixture.capacity, loaded.capacity); + for (int i = 0; i < packed.length; i++) + assertArrayEquals(packed[i], loaded.neighborhoods[i]); + var scorer = new ASHNeighborhoodScorer(loaded.ash, loaded.queries.get(0), capacity, blockSize); + if (VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshNeighborhoodScoring() + && !"scalar".equals(System.getProperty("jvector.ash.blockKernel"))) { + assertTrue( + scorer.toString(), scorer.toString().contains("native Highway whole-neighborhood FFM")); + } + float[] out = new float[capacity + 1]; + out[capacity] = Float.NaN; + scorer.score(packed[0], out); + assertTrue(Float.isNaN(out[capacity])); + assertThrows(IndexOutOfBoundsException.class, () -> scorer.score(new byte[0], out)); + assertThrows(IndexOutOfBoundsException.class, () -> scorer.score(packed[0], new float[0])); + String hash = ASHNeighborhoodFixture.sha256(path); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.write(path, fixture)); + assertEquals(hash, ASHNeighborhoodFixture.sha256(path)); + assertEquals(64, hash.length()); + byte[] original = Files.readAllBytes(path); + Files.write(path, Arrays.copyOf(original, original.length - 1)); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + Files.write(path, Arrays.copyOf(original, original.length + 1)); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + } finally { + Files.deleteIfExists(path); + } + } + } + } + + @Test + public void accessOrdersAreRepeatablePermutations() { + int[] sequential = ASHNeighborhoodBenchmark.accessOrder(37, "sequential"); + int[] shuffled = ASHNeighborhoodBenchmark.accessOrder(37, "shuffled"); + assertArrayEquals(shuffled, ASHNeighborhoodBenchmark.accessOrder(37, "shuffled")); + assertFalse(Arrays.equals(sequential, shuffled)); + Arrays.sort(shuffled); + assertArrayEquals(sequential, shuffled); + assertThrows(IllegalArgumentException.class, () -> ASHNeighborhoodBenchmark.accessOrder(37, "invalid")); + } + + @Test + public void rejectsMalformedIntegerSettings() { + String name = "jvector.bench.scoringPasses"; + String previous = System.getProperty(name); + try { + System.setProperty(name, "not-an-integer"); + assertThrows(IllegalArgumentException.class, () -> ASHNeighborhoodBenchmark.positive(name, 5)); + } finally { + if (previous == null) + System.clearProperty(name); + else + System.setProperty(name, previous); + } + } + + @Test + public void rejectsTruncatedFixturesAndFvecs() throws Exception { + var path = Files.createTempFile("ash-truncated-", ".bin"); + try { + Files.write(path, new byte[] {1, 2, 3}); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.readFvecs(path.toString(), 1)); + assertThrows(IllegalArgumentException.class, () -> ASHNeighborhoodFixture.readFvecs(path.toString(), 0)); + var buffer = java.nio.ByteBuffer.allocate(9 * Integer.BYTES + 44); + buffer.putInt(0x4153484e).putInt(1).putInt(32).putInt(32).putInt(1).putInt(44); + buffer.putInt(0).putInt(0).putInt(Integer.MAX_VALUE).putInt(0); + buffer.putInt(Integer.MAX_VALUE).putInt(4).putInt(0).putInt(1); + Files.write(path, buffer.array()); + assertThrows(IOException.class, () -> ASHNeighborhoodFixture.read(path)); + } finally { + Files.deleteIfExists(path); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodRecall.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodRecall.java new file mode 100644 index 000000000..e933b18da --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestASHNeighborhoodRecall.java @@ -0,0 +1,122 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.quantization.ASHNeighborhoodScorer; +import io.github.jbellis.jvector.quantization.ASHVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Random; +import org.junit.Test; +import static org.junit.Assert.*; + +/** Recall ranking is checked independently of the heap and excludes packed padding. */ +public class TestASHNeighborhoodRecall { + @Test + public void ranksTheCompleteBaseSetAndExcludesPadding() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + List> base = new ArrayList<>(); + var random = new Random(9294); + for (int i = 0; i < 11; i++) { + float[] vector = new float[64]; + for (int d = 0; d < vector.length; d++) { + vector[d] = random.nextFloat() - .5f; + } + base.add(types.createFloatVector(vector)); + } + var ash = AsymmetricHashing.initialize(new ListRandomAccessVectorValues(base, 64), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * 2, 1, 2); + ASHVectors encoded = (ASHVectors) ash.encodeAll(new ListRandomAccessVectorValues(base, 64)); + byte[][] packed = new byte[2][FusedASHLayout.featureSize(8, 33, 2, 8)]; + for (int i = 0; i < base.size(); i++) { + FusedASHLayout.packForFused(packed[i / 8], 0, i % 8, encoded.get(i), ash, 8); + } + // Maliciously attractive padding must never displace a real result. + for (int lane = 3; lane < 8; lane++) { + int offset = FusedASHLayout.offsetOffset(0, 33, 2, 8) + lane * 2; + packed[1][offset] = 0x7b; + packed[1][offset + 1] = (byte) 0xff; // Largest finite positive binary16. + } + var fixture = new ASHNeighborhoodFixture(ash, List.of(base.get(0)), packed, 8, 8, 11); + float[] all = new float[16]; + float[] scratch = new float[8]; + var scorer = new ASHNeighborhoodScorer(ash, base.get(0), 8, 8); + for (int i = 0; i < packed.length; i++) { + scorer.score(packed[i], scratch); + System.arraycopy(scratch, 0, all, i * 8, 8); + } + Integer[] sorted = new Integer[11]; + Arrays.setAll(sorted, i -> i); + Arrays.sort(sorted, Comparator.comparingDouble(i -> all[i]).reversed().thenComparingInt(i -> i)); + int[] expected = Arrays.stream(sorted).limit(10).mapToInt(Integer::intValue).toArray(); + var result = ASHNeighborhoodRecall.scan(fixture, 0, expected); + assertArrayEquals(expected, result.ids); + assertEquals(10, result.hits); + for (int id : result.ids) { + assertTrue(id < fixture.baseCount); + } + } + + @Test + public void tiesPreferSmallerOrdinalsRegardlessOfOfferOrder() { + var top = new ASHNeighborhoodRecall.TopK(); + for (int id = 24; id >= 0; id--) { + top.offer(.5f, id); + } + var ranked = top.ranked(); + assertEquals(10, ranked.length); + for (int i = 0; i < ranked.length; i++) { + assertEquals(i, ranked[i].ordinal); + } + } + + @Test + public void validatesLittleEndianGroundTruthAndRejectsBadRows() throws Exception { + var path = Files.createTempFile("ash-recall-", ".ivecs"); + try { + ByteBuffer bytes = ByteBuffer.allocate(11 * Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN); + bytes.putInt(10); + for (int id = 0; id < 10; id++) { + bytes.putInt(id); + } + Files.write(path, bytes.array()); + assertArrayEquals( + new int[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)[0]); + bytes.putInt(10 * Integer.BYTES, 11); + Files.write(path, bytes.array()); + assertThrows(IOException.class, () -> ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)); + bytes.putInt(10 * Integer.BYTES, 0); + Files.write(path, bytes.array()); + assertThrows(IOException.class, () -> ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)); + Files.write(path, Arrays.copyOf(bytes.array(), 20)); + assertThrows(IOException.class, () -> ASHNeighborhoodRecall.readGroundTruth(path, 1, 11)); + } finally { + Files.deleteIfExists(path); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java index 89f6c32d8..1b909e64c 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressedBuildPreflight.java @@ -51,7 +51,7 @@ public void fusedPqWithoutTwoHundredFiftySixClustersFailsBeforeWriting() throws var ds = dataset("preflight-" + UUID.randomUUID(), 16, 128); var work = Files.createTempDirectory("compressed-build-preflight"); var cacheDir = work.resolve("saved"); - var pqFile = Path.of("pq_cache").resolve(new CompressorParameters.PQParameters(4, 16, false, -1f).idStringFor(ds)); + var pqFile = Path.of("compressor_cache").resolve(new CompressorParameters.PQParameters(4, 16, false, -1f).idStringFor(ds)); try { var cache = OnDiskGraphIndexCache.initialize(cacheDir); var e = assertThrows(IllegalArgumentException.class, () -> Grid.runOneGraph(cache, diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressorCaching.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressorCaching.java index 4b8111916..904d5cc76 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressorCaching.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestCompressorCaching.java @@ -24,6 +24,7 @@ import io.github.jbellis.jvector.example.util.CompressorParameters; import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.quantization.NVQuantization; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.ProductQuantization; import io.github.jbellis.jvector.quantization.VectorCompressor; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; @@ -80,4 +81,20 @@ public void nvqLoadsAsNVQ() throws Exception { assertTrue(loaded.getClass().getName(), loaded instanceof NVQuantization); assertEquals(nvq, loaded); } + @Test + public void ashLoadsAsASHAndCanEncodeVectors() throws Exception { + var ds = dataSet(); + var cp = new CompressorParameters.ASHParameters(AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 4 * 2, 1, 2); + var ash = (AsymmetricHashing) cp.computeCompressor(ds); + var loaded = roundTrip(cp, ash); + assertTrue(loaded.getClass().getName(), loaded instanceof AsymmetricHashing); + var restored = (AsymmetricHashing) loaded; + assertEquals(ash.originalDimension, restored.originalDimension); + assertEquals(ash.quantizedDim, restored.quantizedDim); + assertEquals(ash.encodedBits, restored.encodedBits); + assertEquals(ash.bitsPerDimension, restored.bitsPerDimension); + assertEquals(ds.getBaseRavv().size(), restored.encodeAll(ds.getBaseRavv()).count()); + } + } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java index 5379cb0ef..9034a23fd 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestDatasetPrefetching.java @@ -17,6 +17,7 @@ package io.github.jbellis.jvector.example; import io.github.jbellis.jvector.example.benchmarks.datasets.*; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; import io.github.jbellis.jvector.example.yaml.*; import io.github.jbellis.jvector.example.util.CompressorParameters; import io.github.jbellis.jvector.example.util.OnDiskGraphIndexCache; @@ -49,18 +50,18 @@ private MultiConfig config(String build, String search, boolean fused, String re @Test public void yamlDefaultsToResidentAndOptInIsParsed() throws Exception { var file = Files.createTempFile("prefetch-flag", ".yml"); try { - Files.writeString(file, "yamlSchemaVersion: 1\nonDiskIndexVersion: 6\n"); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: " + OnDiskGraphIndex.CURRENT_VERSION + "\n"); assertFalse(MultiConfig.getConfig(file.toString()).useDatasetPrefetching); - Files.writeString(file, "yamlSchemaVersion: 1\nonDiskIndexVersion: 6\nuseDatasetPrefetching: false\n"); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: " + OnDiskGraphIndex.CURRENT_VERSION + "\nuseDatasetPrefetching: false\n"); assertFalse(MultiConfig.getConfig(file.toString()).useDatasetPrefetching); - Files.writeString(file, "yamlSchemaVersion: 1\nonDiskIndexVersion: 6\nuseDatasetPrefetching: true\n"); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: " + OnDiskGraphIndex.CURRENT_VERSION + "\nuseDatasetPrefetching: true\n"); assertTrue(MultiConfig.getConfig(file.toString()).useDatasetPrefetching); } finally { Files.delete(file); } } @Test public void datasetPrefetchingAndCompactionOptionsAreIndependent() throws Exception { var file = Files.createTempFile("prefetch-compaction-flags", ".yml"); try { - String common = "yamlSchemaVersion: 1\nonDiskIndexVersion: 6\nuseDatasetPrefetching: true\n"; + String common = "yamlSchemaVersion: 2\nonDiskIndexVersion: " + OnDiskGraphIndex.CURRENT_VERSION + "\nuseDatasetPrefetching: true\n"; Files.writeString(file, common); var config = MultiConfig.getConfig(file.toString()); assertTrue(config.useDatasetPrefetching); @@ -78,7 +79,7 @@ private MultiConfig config(String build, String search, boolean fused, String re @Test public void versionedYamlRejectsUnknownSettings() throws Exception { var file = Files.createTempFile("prefetch-unknown-setting", ".yml"); try { - Files.writeString(file, "yamlSchemaVersion: 1\nonDiskIndexVersion: 6\nuseDatasetPrefetching: false\nunknownSetting: true\n"); + Files.writeString(file, "yamlSchemaVersion: 2\nonDiskIndexVersion: " + OnDiskGraphIndex.CURRENT_VERSION + "\nuseDatasetPrefetching: false\nunknownSetting: true\n"); assertThrows(org.yaml.snakeyaml.error.YAMLException.class, () -> MultiConfig.getConfig(file.toString())); } finally { Files.delete(file); } @@ -135,7 +136,7 @@ private MultiConfig config(String build, String search, boolean fused, String re public List> getQueryVectors(){return List.of(vectors.get(0));} public List> getGroundTruth(){return List.of(List.of(0));} }; - var cacheFile = Path.of("pq_cache").resolve(new CompressorParameters.PQParameters(4,16,false,-1f).idStringFor(ds)); + var cacheFile = Path.of("compressor_cache").resolve(new CompressorParameters.PQParameters(4,16,false,-1f).idStringFor(ds)); var work = Files.createTempDirectory("bulk-grid-wiring"); try { Grid.runOneGraph(OnDiskGraphIndexCache.initialize(work.resolve("saved")), List.of(EnumSet.of(FeatureId.INLINE_VECTORS),EnumSet.of(FeatureId.NVQ_VECTORS)), diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHConstructionMode.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHConstructionMode.java new file mode 100644 index 000000000..0847835fb --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHConstructionMode.java @@ -0,0 +1,113 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.example.benchmarks.datasets.DataSet; +import io.github.jbellis.jvector.example.reporting.RunArtifacts; +import io.github.jbellis.jvector.example.util.CompressorParameters; +import io.github.jbellis.jvector.example.util.OnDiskGraphIndexCache; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Assume; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; + +/** Exercises Grid's insertion policy, not just the printed construction-mode label. */ +public class TestGridASHConstructionMode { + private static final String PROPERTY = "jvector.bench.ashConstruction"; + + @Test(timeout = 120_000) + public void defaultBuildMatchesExplicitSymmetricAllAndLegacyOverrideStillWorks() throws Exception { + // Sequential insertion makes the serialized graph a deterministic comparison. + Assume.assumeTrue("run with -Djvector.physical_core_count=1", + PhysicalCoreExecutor.getPhysicalCoreCount() == 1); + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(42); + var list = new ArrayList>(); + for (int n = 0; n < 256; n++) { + var vector = vts.createFloatVector(32); + for (int d = 0; d < 32; d++) vector.set(d, random.nextFloat() - 0.4f); + VectorUtil.l2normalize(vector); + list.add(vector); + } + var vectors = new ListRandomAccessVectorValues(list, 32); + String name = "ash-construction-mode-" + UUID.randomUUID(); + DataSet ds = new DataSet() { + public int getDimension() { return 32; } + public String getName() { return name; } + public VectorSimilarityFunction getSimilarityFunction() { return VectorSimilarityFunction.DOT_PRODUCT; } + public RandomAccessVectorValues getBaseRavv() { return vectors; } + public List> getBaseVectors() { return list; } + public List> getQueryVectors() { return List.of(list.get(0)); } + public List> getGroundTruth() { return List.of(List.of(0)); } + }; + var params = new CompressorParameters.ASHParameters(AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 16 * 2, 1, 2); + String previous = System.getProperty(PROPERTY); + Path work = Files.createTempDirectory("ash-construction-mode"); + try { + byte[] defaultGraph = build(ds, params, work.resolve("default"), null); + byte[] explicitGraph = build(ds, params, work.resolve("explicit"), "symmetric-all"); + assertArrayEquals("unset property must use encoded queries for insertion", explicitGraph, defaultGraph); + byte[] legacyGraph = build(ds, params, work.resolve("legacy"), "symmetric"); + assertFalse("the legacy override must still select raw-query insertion", + Arrays.equals(defaultGraph, legacyGraph)); + } finally { + if (previous == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, previous); + TestUtil.deleteQuietly(work); + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(ds))); + } + } + + private static byte[] build(DataSet ds, CompressorParameters params, Path work, String mode) throws Exception { + if (mode == null) System.clearProperty(PROPERTY); + else System.setProperty(PROPERTY, mode); + Files.createDirectories(work); + Path saved = work.resolve("index_cache"); + Grid.runOneGraph(OnDiskGraphIndexCache.initialize(saved), + List.of(EnumSet.of(FeatureId.NVQ_VECTORS, FeatureId.FUSED_ASH)), + 8, 32, 1.2f, false, false, ignored -> params, List.of(), + Map.of(10, List.of(1.0)), List.of(false), RunArtifacts.disabled(), ds, work); + try (var files = Files.list(saved)) { + var indexes = files.filter(p -> p.getFileName().toString().startsWith("graph_")) + .collect(Collectors.toList()); + if (indexes.size() != 1) throw new AssertionError("Expected one freshly built index: " + indexes); + return Files.readAllBytes(indexes.get(0)); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java new file mode 100644 index 000000000..a5ee607b2 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridASHIntegration.java @@ -0,0 +1,388 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.example; + +import io.github.jbellis.jvector.example.benchmarks.datasets.SimpleDataSet; +import io.github.jbellis.jvector.example.reporting.RunArtifacts; +import io.github.jbellis.jvector.example.util.CompressorParameters; +import io.github.jbellis.jvector.example.util.OnDiskGraphIndexCache; +import io.github.jbellis.jvector.example.yaml.MetricSelection; +import io.github.jbellis.jvector.example.yaml.RunConfig; +import io.github.jbellis.jvector.example.yaml.MultiConfig; +import io.github.jbellis.jvector.example.yaml.SearchParameters; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import static org.junit.Assert.*; + +/** The refactored strategy, feature provider, queued queries and CSV listener must work together. */ +public class TestGridASHIntegration { + @Rule public final TemporaryFolder temporary = new TemporaryFolder(); + + @Test(timeout = 120_000) + public void gridSpecBuildsFusedASHAndReportsPrecisionMetadata() throws Exception { + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(19); + var vectors = new ArrayList>(); + for (int i = 0; i < 128; i++) { + var v = vts.createFloatVector(32); + for (int d = 0; d < 32; d++) v.set(d, random.nextFloat() - 0.5f); + vectors.add(v); + } + var data = new SimpleDataSet("grid-ash-merge-" + UUID.randomUUID(), VectorSimilarityFunction.DOT_PRODUCT, + vectors, List.of(vectors.get(0)), List.of(List.of(0))); + var parameters = new CompressorParameters.ASHParameters(AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 16 * 2, 1, 2); + var spec = GridSpec.fromGrids(List.of(false), List.of(false), List.of(8), List.of(1.2f), List.of(32), + List.of(ignored -> parameters), List.of(EnumSet.of(FeatureId.INLINE_VECTORS, FeatureId.FUSED_ASH)), + SearchConfig.builder().searchCompressors(List.of(ignored -> CompressorParameters.NONE)) + .topKGrid(Map.of(1, List.of(1.0, 2.0))).usePruningGrid(List.of(false)).build(), false); + var config = new RunConfig(); + config.benchmarks = Map.of("accuracy", List.of("recall")); + config.logging = new RunConfig.RunLogging(); + config.logging.type = "csv"; + config.logging.dir = temporary.getRoot().toString(); + config.logging.benchmarks = config.benchmarks; + String previous = System.getProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); + var originalOut = System.out; + var output = new ByteArrayOutputStream(); + try { + System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, "fp32"); + var indexConfig = new MultiConfig(); + indexConfig.search = new SearchParameters(); + indexConfig.search.topKOverquery = Map.of(1, List.of(1.0, 2.0)); + var artifacts = RunArtifacts.open(config, List.of(indexConfig)); + System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8)); + Grid.run(data, spec, artifacts); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(3, lines.size()); + var headers = Arrays.asList(lines.get(0).split(",")); + for (var line : lines.subList(1, lines.size())) { + var cells = line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1); + assertEquals(headers.size(), cells.length); + assertEquals("fp32", cells[headers.indexOf("ash_query_projection_requested")]); + assertEquals("fp32", cells[headers.indexOf("ash_query_projection_effective")]); + assertTrue(cells[headers.indexOf("feature_set")].contains("FUSED_ASH")); + } + var console = output.toString(StandardCharsets.UTF_8); + assertTrue(console, console.contains("executionMode direct")); + } finally { + System.setOut(originalOut); + if (previous == null) System.clearProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY); + else System.setProperty(SearchParameters.ASH_QUERY_PROJECTION_PROPERTY, previous); + Files.deleteIfExists(Path.of("compressor_cache").resolve(parameters.idStringFor(data))); + } + } + @Test(timeout = 120_000) + public void schema3FlushesSearchCodesOverSourceScoredGraph() throws Exception { + var data = schema3Data(); + String callerThreads = expectedDirectThreadCount(); + for (String codec : List.of("ASH", "PQ")) { + var search = "ASH".equals(codec) + ? String.join("\n", "- type: ASH", " parameters:", " optimizer: RANDOM", + " encodedBits: 72", " bitsPerDimension: 2", " queryProjection: fp32", + " queryExecution: queued", " maxBatchSize: 4") + : "- type: PQ\n parameters: {m: 4, k: 256}"; + var config = schema3Config("onDisk", "ASH".equals(codec) ? "sequential" : "parallel", + true, "NVQ", search); + var spec = GridSpec.fromConfig(config); + try (var projection = new SearchParameters.AshQueryProjectionSetting()) { + projection.apply(List.of(config)); + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + try { + Grid.run(data, spec, artifacts); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(2, lines.size()); + assertTrue(lines.get(0), lines.get(0).contains("index_type,writer_mode,reranking_type")); + assertTrue(lines.get(1), lines.get(1).contains("FUSED_" + codec)); + assertTrue(lines.get(1), lines.get(1).matches("ASH".equals(codec) + ? ".*,onDisk,sequential,NVQ,,queued,[0-9]+,4,.*" + : ".*,onDisk,parallel,NVQ,,direct," + callerThreads + ",,.*")); + assertTrue(lines.get(1), lines.get(1).contains("NVQ_VECTORS")); + assertFalse(lines.get(1), lines.get(1).contains("INLINE_VECTORS")); + } finally { + var params = config.search.getCompressorParameters().get(0).apply(data); + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(data))); + } + } + } + } + + @Test(timeout = 120_000) + public void schema3SourceOnlySearchUsesNoReranker() throws Exception { + var data = schema3Data(); + for (String type : List.of("inMemory", "onDisk")) { + var config = schema3Config(type, "sequential", false, "None", "- type: None"); + var spec = GridSpec.fromConfig(config); + var build = spec.buildPoints().get(0); + var features = spec.featuresFor(build, spec.search.cases().get(0)); + var directory = temporary.newFolder().toPath(); + IndexBuildStrategy strategy = "inMemory".equals(type) + ? new InMemoryBuild(directory) + : new CompressedOnDiskBuild( + OnDiskGraphIndexCache.disabled(), + directory, null, new ConstructionMetrics(true), null, "test"); + var built = strategy.build(features, build, data); + var featureSet = features.get(0); + assertEquals("onDisk".equals(type) ? Set.of(FeatureId.INLINE_VECTORS) : Set.of(), featureSet); + try (var index = built.indexes.get(featureSet); + var cs = new Grid.ConfiguredSystem(data, index, null, featureSet, "None", null); + var view = index.getView()) { + if (index instanceof OnDiskGraphIndex) { + assertEquals(featureSet, ((OnDiskGraphIndex) index).getFeatureSet()); + } + var scorer = cs.scoreProviderFor(data.getQueryVectors().get(0), view); + assertTrue(scorer.scoreFunction().isExact()); + assertNull(scorer.reranker()); + var searcher = cs.getSearcher(); + var query = data.getQueryVectors().get(0); + var result = searcher.search(cs.scoreProviderFor(query, searcher.getView()), + 1, 2, 0.0f, 0.0f, Bits.ALL); + assertEquals(1, result.getNodes().length); + assertEquals(0, result.getRerankedCount()); + var best = result.getNodes()[0]; + assertEquals(data.getSimilarityFunction().compare(query, data.getBaseVectors().get(best.node)), + best.score, 0.000001f); + } + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + Grid.run(data, spec, artifacts); + assertEquals(2, Files.readAllLines(artifacts.experimentsWriter().path()).size()); + } + } + + @Test(timeout = 120_000) + public void schema3SupportsOptionalRerankingForNonFusedCompressedSearch() throws Exception { + var data = schema3Data(); + String callerThreads = expectedDirectThreadCount(); + for (String reranker : List.of("None", "NVQ")) { + var config = schema3Config("inMemory", null, false, reranker, "- type: BQ"); + var run = csvRunConfig(); + run.logging.metrics = new MetricSelection(); + run.logging.metrics.put("construction", List.of("index_quant_time_s")); + var artifacts = RunArtifacts.open(run, List.of(config)); + Grid.run(data, GridSpec.fromConfig(config), artifacts); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(2, lines.size()); + assertTrue(lines.get(1), lines.get(1).matches(".*,inMemory,," + reranker + ",,direct," + callerThreads + ",,.*")); + assertEquals("NVQ".equals(reranker), + lines.get(0).contains("construction.index_quant_time_s.NVQ.encoding_time_s")); + } + } + + @Test(timeout = 120_000) + public void schema3BuildsAndFlushesWithTheSameAshSettings() throws Exception { + var data = schema3Data(); + String ash = "- type: ASH\n parameters: {optimizer: RANDOM, encodedBits: 72, bitsPerDimension: 2}"; + var config = schema3Config("onDisk", "parallel", true, "FP32", + ash.replace(" parameters:", " scoring: symmetric\n parameters:"), ash); + var params = config.search.getCompressorParameters().get(0).apply(data); + try { + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + Grid.run(data, GridSpec.fromConfig(config), artifacts); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + assertEquals(2, lines.size()); + assertTrue(lines.get(1), lines.get(1).contains("FUSED_ASH")); + assertTrue(lines.get(1), lines.get(1).matches(".*,onDisk,parallel,FP32,symmetric-all,direct," + + expectedDirectThreadCount() + ",,.*")); + } finally { + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(data))); + } + } + + @Test + public void schema3CacheHitSkipsBuildAndFlush() throws Exception { + var data = schema3Data(); + var config = schema3Config("onDisk", "parallel", false, "None", "- type: None"); + var spec = GridSpec.fromConfig(config); + var build = spec.buildPoints().get(0); + var features = spec.featuresFor(build, spec.search.cases().get(0)); + var cache = OnDiskGraphIndexCache.initialize(temporary.newFolder().toPath()); + var work = temporary.newFolder().toPath(); + var fresh = new CompressedOnDiskBuild(cache, work, () -> null, new ConstructionMetrics(true), + null, "schema3:None").build(features, build, data); + var featureSet = features.get(0); + try (var index = fresh.indexes.get(featureSet)) { + assertNotNull(fresh.buildTimeS); + assertEquals(data.getBaseRavv().size(), index.size(0)); + } + + var reused = new CompressedOnDiskBuild(cache, work, () -> { + throw new AssertionError("cache hit tried to prepare a build compressor"); + }, new ConstructionMetrics(true), null, "schema3:None").build(features, build, data); + try (var index = reused.indexes.get(featureSet)) { + assertNull(reused.buildTimeS); + assertEquals(fresh.fileSizes, reused.fileSizes); + assertEquals(data.getBaseRavv().size(), index.size(0)); + } + } + + @Test(timeout = 120_000) + public void searchWorkersSweepQueriesOneIndexWithEachWorkerCount() throws Exception { + var data = schema3Data(); + var search = String.join("\n", "- type: ASH", " parameters:", " optimizer: RANDOM", + " encodedBits: 72", " bitsPerDimension: 2", " queryProjection: fp32", + " queryExecution: queued", " maxBatchSize: 4"); + var config = schema3Config("onDisk", "parallel", true, "NVQ", search); + config.construction.workers = 2; + config.search.workers = List.of(1, 2); + var params = config.search.getCompressorParameters().get(0).apply(data); + try (var projection = new SearchParameters.AshQueryProjectionSetting()) { + projection.apply(List.of(config)); + var artifacts = RunArtifacts.open(csvRunConfig(), List.of(config)); + var console = new java.io.ByteArrayOutputStream(); + var stdout = System.out; + try { + System.setOut(new java.io.PrintStream(console, true, java.nio.charset.StandardCharsets.UTF_8)); + Grid.run(data, GridSpec.fromConfig(config), artifacts); + } finally { + System.setOut(stdout); + } + var output = console.toString(java.nio.charset.StandardCharsets.UTF_8); + assertEquals(output, 1, output.split("Building graph index", -1).length - 1); + var lines = Files.readAllLines(artifacts.experimentsWriter().path()); + // One build, then one row per search worker count, each recording its own workers. + assertEquals(3, lines.size()); + assertTrue(lines.get(1), lines.get(1).matches(".*,onDisk,parallel,NVQ,,queued,1,4,.*")); + assertTrue(lines.get(2), lines.get(2).matches(".*,onDisk,parallel,NVQ,,queued,2,4,.*")); + } finally { + Files.deleteIfExists(Path.of("compressor_cache").resolve(params.idStringFor(data))); + } + } + + @Test + public void schema3RecordsGraphBuildFlushAndReopenTimes() throws Exception { + var data = schema3Data(); + var config = schema3Config("onDisk", "parallel", false, "None", "- type: None"); + var spec = GridSpec.fromConfig(config); + var build = spec.buildPoints().get(0); + var features = spec.featuresFor(build, spec.search.cases().get(0)); + var cache = OnDiskGraphIndexCache.initialize(temporary.newFolder().toPath()); + var work = temporary.newFolder().toPath(); + + var fresh = new ConstructionMetrics(true); + var built = new CompressedOnDiskBuild(cache, work, () -> null, fresh, null, "schema3:None") + .build(features, build, data); + built.indexes.values().forEach(index -> { try { index.close(); } catch (Exception e) { throw new RuntimeException(e); } }); + assertTrue(fresh.graphBuildTimeS > 0); + assertTrue(fresh.flushTimeS > 0); + assertTrue(fresh.reopenTimeS > 0); + assertEquals(built.buildTimeS, fresh.graphBuildTimeS + fresh.flushTimeS, 1e-9); + var recorded = new ArrayList(); + fresh.appendTo(recorded); + var keys = recorded.stream().map(io.github.jbellis.jvector.example.benchmarks.Metric::getKey) + .collect(java.util.stream.Collectors.toSet()); + assertTrue(keys.toString(), keys.containsAll(List.of("construction.graph_build_time_s", + "construction.flush_time_s", "construction.reopen_time_s"))); + + // A cache hit loads the cached index without building, flushing or reopening it. + var reused = new ConstructionMetrics(true); + var cached = new CompressedOnDiskBuild(cache, work, () -> null, reused, null, "schema3:None") + .build(features, build, data); + cached.indexes.values().forEach(index -> { try { index.close(); } catch (Exception e) { throw new RuntimeException(e); } }); + assertNull(cached.buildTimeS); + assertNull(reused.graphBuildTimeS); + assertNull(reused.flushTimeS); + assertNull(reused.reopenTimeS); + } + + @Test + public void schema3RejectsFusedSearchWithoutReranking() { + var error = assertThrows(IllegalArgumentException.class, + () -> schema3Config("onDisk", "parallel", true, "None", + "- type: PQ\n parameters: {m: 4, k: 256}")); + assertTrue(error.getMessage(), error.getMessage().contains("fusedGraph requires construction.reranking.type")); + } + + private SimpleDataSet schema3Data() { + var support = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(91); + var vectors = new ArrayList>(); + for (int i = 0; i < 288; i++) { + var vector = support.createFloatVector(32); + for (int d = 0; d < 32; d++) vector.set(d, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(vector); + vectors.add(vector); + } + return new SimpleDataSet("grid-schema3-" + UUID.randomUUID(), VectorSimilarityFunction.DOT_PRODUCT, + vectors, List.of(vectors.get(0)), List.of(List.of(0))); + } + + private static String expectedDirectThreadCount() { + int threads = Integer.getInteger("jvector.bench.queryThreads", 0); + return threads == 0 ? "" : Integer.toString(threads); + } + + private RunConfig csvRunConfig() throws Exception { + var config = new RunConfig(); + config.benchmarks = Map.of("accuracy", List.of("recall")); + config.logging = new RunConfig.RunLogging(); + config.logging.type = "csv"; + config.logging.dir = temporary.newFolder().toString(); + config.logging.benchmarks = config.benchmarks; + return config; + } + + private MultiConfig schema3Config(String type, String writer, boolean fused, String reranker, String search) + throws Exception { + return schema3Config(type, writer, fused, reranker, "- type: None", search); + } + + private MultiConfig schema3Config(String type, String writer, boolean fused, String reranker, + String construction, String search) throws Exception { + var path = temporary.newFile().toPath(); + String diskSettings = "onDisk".equals(type) + ? " writerMode: " + writer + "\n fusedGraph: [" + fused + "]\n" + : ""; + String yaml = String.join("\n", + "yamlSchemaVersion: 3", + "onDiskIndexVersion: " + OnDiskGraphIndex.CURRENT_VERSION, + "dataset: test", + "construction:", + " type: " + type, + diskSettings + " outDegree: [8]", + " efConstruction: [32]", + " neighborOverflow: [1.2f]", + " addHierarchy: [No]", + " refineFinalGraph: [No]", + " compression:", + " " + construction.replace("\n", "\n "), + " reranking:", + " type: " + reranker, + "search:", + " topKOverquery:", + " 1: [2.0]", + " useSearchPruning: [No]", + " compression:", + " " + search.replace("\n", "\n ")); + Files.writeString(path, yaml); + return MultiConfig.getConfig(path.toString()); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java index 7a5bbd3b7..1f8ba1f4c 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridDeferredFeatureParallelism.java @@ -148,7 +148,7 @@ private static void runGrid(List> list, RecordingVectors vectors, config.setParallelBuild(previous); TestUtil.deleteQuietly(work); // Grid caches codebooks by dataset name in the working directory; remove this run's. - Files.deleteIfExists(Path.of("pq_cache").resolve(pqParameters.idStringFor(ds))); + Files.deleteIfExists(Path.of("compressor_cache").resolve(pqParameters.idStringFor(ds))); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java index 2e8adbd22..7d0c797aa 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestGridSpec.java @@ -25,11 +25,16 @@ import io.github.jbellis.jvector.graph.disk.feature.FeatureId; import org.junit.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; +import java.util.HashSet; +import java.util.Set; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import static org.junit.Assert.*; @@ -193,4 +198,38 @@ public void fromConfigRejectsAnUnsetCacheFlag() { var e = assertThrows(NullPointerException.class, () -> GridSpec.fromConfig(config)); assertTrue(e.getMessage(), e.getMessage().contains("useSavedIndexIfExists")); } + @Test + public void checkedInIndexParametersParseAsSchema3() throws Exception { + Path directory = Path.of("jvector-examples", "yaml-configs", "index-parameters"); + if (!Files.isDirectory(directory)) directory = Path.of("..").resolve(directory); + int checked = 0; + var sourceOnlyFiles = Set.of("colbert-1M.yml", "glove-25-angular.yml", "glove-50-angular.yml", + "glove-100-angular.yml", "glove-200-angular.yml", "lastfm-64-dot.yml", + "nytimes-256-angular.yml", "sift-128-euclidean.yml"); + var sourceOnly = new HashSet(); + try (var paths = Files.list(directory)) { + for (var path : paths.filter(p -> p.toString().endsWith(".yml")).collect(Collectors.toList())) { + var config = MultiConfig.getConfig(path.toAbsolutePath().toString()); + assertEquals(path.toString(), 3, config.getYamlSchemaVersion()); + assertTrue(path.toString(), config.construction.neighborOverflow.stream().allMatch(Float.class::isInstance)); + var spec = GridSpec.fromConfig(config); + var name = path.getFileName().toString(); + if (sourceOnlyFiles.contains(name)) { + for (var point : spec.buildPoints()) { + assertEquals("onDisk", point.indexType); + assertEquals(CompressorParameters.NONE, point.buildCompressor.apply(null)); + assertEquals("None", point.rerankingType); + for (var search : spec.search.cases()) { + assertEquals(CompressorParameters.NONE, search.searchCompressors.get(0).apply(null)); + assertEquals(List.of(Set.of(FeatureId.INLINE_VECTORS)), spec.featuresFor(point, search)); + } + } + sourceOnly.add(name); + } + checked++; + } + } + assertTrue(checked >= 26); + assertEquals(sourceOnlyFiles, sourceOnly); + } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestReportedParameters.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestReportedParameters.java index b74c9978d..375a383a0 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestReportedParameters.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/example/TestReportedParameters.java @@ -64,7 +64,8 @@ public void experimentsColumnsAreUnchanged() { assertEquals(List.of("schema_version", "run_id", "run_uuid", "system_id", "dataset_name", "M", "efConstruction", "neighborOverflow", "addHierarchy", "refineFinalGraph", "feature_set", "build_compressor", "search_compressor", - "usePruning", "topK", "overquery", "rerankK"), + "usePruning", "topK", "overquery", "rerankK", + "ash_query_projection_requested", "ash_query_projection_effective"), ExperimentsSchemaV1.fixedColumns()); } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java new file mode 100644 index 000000000..3bcf52013 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/AdaptiveTerminationTest.java @@ -0,0 +1,189 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.util.BoundedLongHeap; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static io.github.jbellis.jvector.graph.AdaptiveTermination.Decision.CONTINUE; +import static io.github.jbellis.jvector.graph.AdaptiveTermination.Decision.FALL_BACK_TO_STANDARD; +import static io.github.jbellis.jvector.graph.AdaptiveTermination.Decision.TERMINATE; +import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.COSINE; +import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.DOT_PRODUCT; +import static io.github.jbellis.jvector.vector.VectorSimilarityFunction.EUCLIDEAN; +import static org.junit.jupiter.api.Assertions.*; + +class AdaptiveTerminationTest { + /** The gamma whose squared-distance factor (1 + gamma)^2 is {@code factor}. */ + private static float gammaFor(double factor) { + return (float) (Math.sqrt(factor) - 1); + } + + private static NodeQueue results(float... scores) { + NodeQueue q = new NodeQueue(new BoundedLongHeap(scores.length), NodeQueue.Order.MIN_HEAP); + for (int i = 0; i < scores.length; i++) q.push(i, scores[i]); + return q; + } + + private static AdaptiveTermination rule(int k, double factor, VectorSimilarityFunction similarity, float bound) { + var t = new AdaptiveTermination(); + assertTrue(t.reset(k, gammaFor(factor), similarity, bound)); + return t; + } + + @Test void requiresKResults() { + var t = rule(2, 1.1, COSINE, Float.NaN); + assertEquals(CONTINUE, t.shouldTerminate(0.1f, results(0.9f))); + } + + @Test void cosineReadsOneMinusScore() { + var t = rule(1, 1.1, COSINE, Float.NaN); + NodeQueue q = results(0.9f); + assertEquals(CONTINUE, t.shouldTerminate(0.89f, q)); + assertEquals(TERMINATE, t.shouldTerminate(0.88f, q)); + } + + @Test void euclideanReadsSquaredDistance() { + // Score 0.5 is d^2 = 1; with factor 1.1 the cutoff is d^2 = 1.1, score 1 / 2.1 = 0.476. + var t = rule(1, 1.1, EUCLIDEAN, Float.NaN); + NodeQueue q = results(0.5f); + assertEquals(CONTINUE, t.shouldTerminate(0.48f, q)); + assertEquals(TERMINATE, t.shouldTerminate(0.47f, q)); + } + + @Test void dotProductReadsDistanceFromTheBound() { + // Bound 9: top = 5. The k-th dot product 7 scores 4, distance 1; factor 1.1 puts the cutoff at score 3.9. + var t = rule(1, 1.1, DOT_PRODUCT, 9f); + NodeQueue q = results(4f); + assertEquals(CONTINUE, t.shouldTerminate(3.95f, q)); + assertEquals(TERMINATE, t.shouldTerminate(3.85f, q)); + } + + @Test void dotProductDecisionsDoNotDependOnScale() { + var random = new Random(7); + for (int i = 0; i < 10_000; i++) { + float bound = 1 + 9 * random.nextFloat(); + float kthDot = bound * (2 * random.nextFloat() - 1); + float candidateDot = kthDot - bound * random.nextFloat(); + double margin = Math.abs((bound - candidateDot) - 1.02 * (bound - kthDot)) / bound; + if (margin < 1e-4) continue; // too close to the cutoff for float scores at every scale + AdaptiveTermination.Decision reference = null; + for (float scale : new float[] {0.01f, 1f, 100f}) { + var t = rule(1, 1.02, DOT_PRODUCT, bound * scale); + var decision = t.shouldTerminate((1 + candidateDot * scale) / 2, results((1 + kthDot * scale) / 2)); + if (reference == null) reference = decision; + assertEquals(reference, decision, "scale " + scale + " bound " + bound + " kth " + kthDot + " candidate " + candidateDot); + } + } + } + + @Test void matchesThePreviousRuleOnUnitLengthScores() { + // The rule this replaced read 1 - score as the distance and compared it against (1 + 0.01) times the k-th's. + var random = new Random(11); + for (var similarity : new VectorSimilarityFunction[] {COSINE, DOT_PRODUCT}) { + var t = new AdaptiveTermination(); + assertTrue(t.reset(1, AdaptiveTermination.DEFAULT_GAMMA, similarity, 1f)); + for (int i = 0; i < 100_000; i++) { + float kth = random.nextFloat(), candidate = kth * random.nextFloat(); + double previous = (1 - candidate) - (1.01 * (1 - kth) + 1e-6); + if (Math.abs(previous) < 1e-5) continue; + assertEquals(previous > 0 ? TERMINATE : CONTINUE, t.shouldTerminate(candidate, results(kth)), + similarity + " kth " + kth + " candidate " + candidate); + } + } + } + + @Test void scoresAboveTheBoundRaiseIt() { + var t = rule(1, 1.1, DOT_PRODUCT, 1f); + NodeQueue q = results(0.9f); + assertEquals(TERMINATE, t.shouldTerminate(0.88f, q)); + // An approximate score of 1.2 moves the zero point up, which loosens the margin: 0.32 <= 1.1 * 0.3. + t.observe(1.2f); + t.observe(1.1f); + assertEquals(CONTINUE, t.shouldTerminate(0.88f, q)); + assertEquals(TERMINATE, t.shouldTerminate(0.85f, q)); + } + + @Test void theCapStopsCandidatesTheMarginWouldContinue() { + // A wide margin continues 0.5 against a k-th best of 0.9, until the cap rises above it. + var t = rule(1, 4.0, COSINE, Float.NaN); + NodeQueue q = results(0.9f); + assertEquals(CONTINUE, t.shouldTerminate(0.7f, q)); + t.raiseCap(0.8f); + assertEquals(TERMINATE, t.shouldTerminate(0.7f, q)); + assertEquals(CONTINUE, t.shouldTerminate(0.85f, q)); + assertTrue(t.lowestContinuableScore(0.9f) >= 0.8f); + // The cap only rises. + t.raiseCap(0.5f); + assertEquals(TERMINATE, t.shouldTerminate(0.7f, q)); + } + + @Test void theBarNeverFallsDuringASearch() { + var t = rule(1, 1.1, DOT_PRODUCT, 1f); + float before = t.lowestContinuableScore(0.9f); + // An approximate score above the bound raises the zero point, which would lower a recomputed bar. + t.observe(1.5f); + assertTrue(t.lowestContinuableScore(0.9f) >= before); + assertEquals(TERMINATE, t.shouldTerminate(Math.nextDown(before), results(0.9f))); + } + + @Test void dotProductWithoutABoundUsesTheStandardRule() { + var t = new AdaptiveTermination(); + assertFalse(t.reset(1, 0.01f, DOT_PRODUCT, Float.NaN)); + assertEquals(FALL_BACK_TO_STANDARD, t.shouldTerminate(0.1f, results(0.9f))); + assertEquals(Float.NEGATIVE_INFINITY, t.lowestContinuableScore(0.9f)); + assertFalse(t.reset(1, 0.01f, DOT_PRODUCT, Float.POSITIVE_INFINITY)); + assertFalse(t.reset(1, 0.01f, DOT_PRODUCT, -1f)); + } + + @Test void nonFiniteScoresFallBackUntilReset() { + var t = rule(1, 1.1, COSINE, Float.NaN); + assertEquals(FALL_BACK_TO_STANDARD, t.shouldTerminate(Float.NaN, results(0.9f))); + assertEquals(FALL_BACK_TO_STANDARD, t.shouldTerminate(0.1f, results(0.9f))); + assertTrue(t.reset(1, gammaFor(1.1), COSINE, Float.NaN)); + assertEquals(TERMINATE, t.shouldTerminate(0.88f, results(0.9f))); + assertEquals(CONTINUE, t.shouldTerminate(Float.POSITIVE_INFINITY, results(0.9f))); + assertEquals(TERMINATE, t.shouldTerminate(Float.NEGATIVE_INFINITY, results(0.9f))); + } + + @Test void theExpansionBarNeverExcludesAContinuedCandidate() { + var random = new Random(5); + for (var similarity : VectorSimilarityFunction.values()) { + for (int i = 0; i < 20_000; i++) { + float bound = 10 * random.nextFloat(); + var t = new AdaptiveTermination(); + assertTrue(t.reset(1, 0.05f * random.nextFloat(), similarity, bound)); + float kth, candidate; + if (similarity == EUCLIDEAN) { + kth = random.nextFloat(); + candidate = kth * random.nextFloat(); + } else { + float top = (1 + bound) / 2; + kth = top - top * random.nextFloat(); + candidate = kth - top * random.nextFloat(); + if (random.nextInt(4) == 0) t.observe(top + random.nextFloat()); + } + float bar = t.lowestContinuableScore(kth); + if (t.shouldTerminate(candidate, results(kth)) == CONTINUE) { + assertTrue(candidate >= bar, similarity + " kth " + kth + " candidate " + candidate + " bar " + bar); + } + } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestAdaptiveStoppingBounds.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestAdaptiveStoppingBounds.java new file mode 100644 index 000000000..7b489ac0a --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestAdaptiveStoppingBounds.java @@ -0,0 +1,86 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertTrue; + +/** + * Adaptive stopping only ever extends a search: it expands at least the nodes the standard rule would, and, capped, + * at most the nodes a standard search with twice the beam would, whatever the margin, the data and the similarity. + */ +public class TestAdaptiveStoppingBounds { + private static final int N = 2000, DIM = 32, QUERIES = 30; + + @Test + public void adaptiveStoppingStaysBetweenTheStandardRuleAndTwiceItsBeam() throws Exception { + var random = new Random(31); + for (boolean unitLength : new boolean[] {true, false}) { + List> base = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < N; i++) { + var v = TestUtil.randomVector(random, DIM); + VectorUtil.l2normalize(v); + if (!unitLength) VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var vectors = new ListRandomAccessVectorValues(base, DIM); + for (var similarity : VectorSimilarityFunction.values()) { + if (similarity == VectorSimilarityFunction.COSINE && !unitLength) continue; + ImmutableGraphIndex graph; + try (var builder = new GraphIndexBuilder(BuildScoreProvider.randomAccessScoreProvider(vectors, similarity), + DIM, 16, 64, 1.2f, 1.2f, false)) { + graph = builder.build(vectors); + } + try (var standard = new GraphSearcher(graph); var adaptive = new GraphSearcher(graph)) { + standard.useAdaptiveTermination(false); + for (int q = 0; q < QUERIES; q++) { + var query = TestUtil.randomVector(random, DIM); + float bound = (float) Math.sqrt(VectorUtil.dotProduct(query, query)) * maxNorm; + var sf = DefaultSearchScoreProvider.exact(query, similarity, vectors).scoreFunction(); + var provider = new DefaultSearchScoreProvider(sf, null, similarity, bound); + for (int k : new int[] {5, 10}) { + int least = standard.search(provider, k, k, 0f, 0f, Bits.ALL).getExpandedCountBaseLayer(); + int most = standard.search(provider, k, 2 * k, 0f, 0f, Bits.ALL).getExpandedCountBaseLayer(); + // The default margin, and one wide enough to search the whole graph without the cap. + for (float gamma : new float[] {0.005f, 1f}) { + adaptive.useAdaptiveTermination(true, gamma); + int expanded = adaptive.search(provider, k, k, 0f, 0f, Bits.ALL).getExpandedCountBaseLayer(); + String where = similarity + (unitLength ? " unit" : " mixed") + " query " + q + " k " + k + + " gamma " + gamma + ": expanded " + expanded; + assertTrue(where + " < standard " + least, expanded >= least); + assertTrue(where + " > standard at twice the beam " + most, expanded <= most); + } + } + } + } + } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java new file mode 100644 index 000000000..d71bffac5 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestBoundedCandidates.java @@ -0,0 +1,105 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.util.FixedBitSet; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +/** + * Deferring candidates that a layer search can no longer expand must not change any search: results, scores, + * visited and expanded counts match a searcher that keeps every candidate, for standard and adaptive stopping, + * filters, hierarchical graphs and repeated resume() calls. + */ +public class TestBoundedCandidates { + @Test + public void boundedSearchesMatchUnboundedOnes() throws IOException { + var random = new Random(42); + int dimension = 24, count = 3000; + var list = new ArrayList>(); + for (int i = 0; i < count; i++) list.add(TestUtil.randomVector(random, dimension)); + var vectors = new ListRandomAccessVectorValues(list, dimension); + for (var similarity : VectorSimilarityFunction.values()) { + for (boolean hierarchy : new boolean[] {false, true}) { + var builder = new GraphIndexBuilder(BuildScoreProvider.randomAccessScoreProvider(vectors, similarity), + dimension, 16, 60, 1.2f, 1.2f, hierarchy); + var graph = builder.build(vectors); + try (var bounded = new GraphSearcher(graph); var unbounded = new GraphSearcher(graph)) { + // Adaptive stopping is experimental and off by default; enabled here so its bar is covered too. + bounded.useAdaptiveTermination(true); + unbounded.useAdaptiveTermination(true); + unbounded.boundCandidates(false); + for (int q = 0; q < 40; q++) { + var query = TestUtil.randomVector(random, dimension); + Bits accept = q % 3 == 0 ? Bits.ALL : randomFilter(random, count, q % 3 == 1 ? 0.7 : 0.1); + int topK = new int[] {1, 5, 10}[q % 3]; + int rerankK = topK * new int[] {1, 2, 5}[(q / 3) % 3]; + boolean adaptive = q % 2 == 0; + String where = similarity + " hierarchy " + hierarchy + " query " + q; + var b = bounded.search(provider(query, similarity, vectors, adaptive), topK, rerankK, 0f, 0f, accept); + var u = unbounded.search(provider(query, similarity, vectors, adaptive), topK, rerankK, 0f, 0f, accept); + assertSame(where, u, b); + for (int r = 0; r < 3; r++) { + assertSame(where + " resume " + r, unbounded.resume(topK, rerankK), bounded.resume(topK, rerankK)); + } + } + } + } + } + } + + private static DefaultSearchScoreProvider provider(VectorFloat query, VectorSimilarityFunction similarity, + RandomAccessVectorValues vectors, boolean adaptive) { + var exact = DefaultSearchScoreProvider.exact(query, similarity, vectors); + return new DefaultSearchScoreProvider(exact.scoreFunction(), null, adaptive ? similarity : null); + } + + private static Bits randomFilter(Random random, int count, double density) { + var bits = new FixedBitSet(count); + for (int i = 0; i < count; i++) if (random.nextDouble() < density) bits.set(i); + return bits; + } + + private static void assertSame(String where, SearchResult expected, SearchResult actual) { + assertEquals(where + " visited", expected.getVisitedCount(), actual.getVisitedCount()); + assertEquals(where + " expanded", expected.getExpandedCount(), actual.getExpandedCount()); + assertEquals(where + " expanded base layer", expected.getExpandedCountBaseLayer(), actual.getExpandedCountBaseLayer()); + assertArrayEquals(where + " nodes", nodes(expected), nodes(actual)); + for (int i = 0; i < expected.getNodes().length; i++) { + assertEquals(where + " score " + i, expected.getNodes()[i].score, actual.getNodes()[i].score, 0f); + } + } + + private static int[] nodes(SearchResult result) { + int[] nodes = new int[result.getNodes().length]; + for (int i = 0; i < nodes.length; i++) nodes[i] = result.getNodes()[i].node; + return nodes; + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java index 0c7afabb9..91e6a4db5 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestNeighbors.java @@ -23,7 +23,9 @@ import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import org.junit.Test; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -116,13 +118,23 @@ public void testConcurrentDuplicateOffersWithDifferentScores() throws Exception new TestVectorGraph.CircularFloatVectorValues(32), VectorSimilarityFunction.DOT_PRODUCT); var map = new ConcurrentNeighborMap(new VamanaDiversityProvider(bsp, 1.2f), 64, 80); map.addNode(0); - var pool = new ForkJoinPool(8); + // awaitTermination can return before the worker threads have exited, and the + // randomized-testing leak check then reports them; join every worker we created. + List workers = new ArrayList<>(); + var pool = new ForkJoinPool(8, p -> { + var worker = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(p); + synchronized (workers) { workers.add(worker); } + return worker; + }, null, false); try { pool.submit(() -> IntStream.range(0, 2000).parallel() .forEach(i -> map.insertEdge(0, 1 + i % 20, i, 1.2f))).join(); } finally { pool.shutdown(); assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + synchronized (workers) { + for (Thread worker : workers) worker.join(TimeUnit.SECONDS.toMillis(10)); + } } assertEquals(20, map.get(0).size()); var ids = new HashSet(); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestVectorMean.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestVectorMean.java new file mode 100644 index 000000000..f51e664b5 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/TestVectorMean.java @@ -0,0 +1,222 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.graph; + +import io.github.jbellis.jvector.quantization.*; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +public class TestVectorMean { + private static VectorFloat vector(float... values) { + return VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(values); + } + + private static class BorrowedValues implements RandomAccessVectorValues { + final List> vectors; + final AtomicInteger reads; + final VectorFloat scratch; + BorrowedValues(List> vectors, AtomicInteger reads) { + this.vectors = vectors; + this.reads = reads; + scratch = vector(new float[vectors.get(0).length()]); + } + public int size() { return vectors.size(); } + public int dimension() { return scratch.length(); } + public boolean isValueShared() { return true; } + public RandomAccessVectorValues copy() { return new BorrowedValues(vectors, reads); } + public VectorFloat getVector(int ordinal) { + reads.incrementAndGet(); + var v = vectors.get(ordinal); + if (v == null) return null; + scratch.copyFrom(v, 0, 0, dimension()); + return scratch; + } + } + + @Test public void fullMeanMatchesExistingNVQWithoutAnotherRead() { + var input = List.of(vector(1, 2), vector(3, 6), vector(5, 4)); + var reads = new AtomicInteger(); + var source = new BorrowedValues(input, reads); + var collector = new VectorMean(3, 2, 3, 7); + var pool = new ForkJoinPool(3); + try { VectorAccess.forEach(collector.duringScan(source), pool, (i, v) -> {}); } + finally { pool.shutdown(); } + assertEquals(3, reads.get()); + assertEquals(3, collector.count()); + var mean = collector.mean(); + var reused = NVQuantization.create(mean, 1); + assertEquals(3, reads.get()); + var original = NVQuantization.compute(source, 1); + for (int d = 0; d < 2; d++) assertEquals(original.globalMean.get(d), reused.globalMean.get(d), 1e-6); + mean.set(0, -100); + assertEquals(3, collector.mean().get(0), 0); + } + + @Test public void deterministicSampleIsAccurateAndIgnoresRepeatedAccess() { + int n = 100000, dimensions = 4; + var one = new VectorMean(n, dimensions); + var two = new VectorMean(n, dimensions); + var v = vector(new float[dimensions]); + var random = new Random(19); + double[] exact = new double[dimensions]; + for (int i = 0; i < n; i++) { + for (int d = 0; d < dimensions; d++) { + float value = (float) (random.nextGaussian() + d); + v.set(d, value); + exact[d] += value; + } + one.accept(i, v); + one.accept(i, v); // repetitions never weight a sample twice + two.accept(i, v); + } + assertEquals(VectorMean.DEFAULT_SAMPLE_SIZE, one.count()); + for (int d = 0; d < dimensions; d++) { + assertEquals(one.mean().get(d), two.mean().get(d), 0); + assertEquals(exact[d] / n, one.mean().get(d), .025); + } + } + + @Test public void sampledMeanRetainsNVQReconstructionQuality() { + int n = 20000, dimensions = 16; + var random = new Random(28); + var inputs = new ArrayList>(); + var collector = new VectorMean(n, dimensions); + for (int i = 0; i < n; i++) { + var v = vector(new float[dimensions]); + for (int d = 0; d < dimensions; d++) v.set(d, (float) (random.nextGaussian() + d)); + inputs.add(v); + collector.accept(i, v); + } + var full = NVQuantization.compute(new ListRandomAccessVectorValues(inputs, dimensions), 2); + var sampled = NVQuantization.create(collector.mean(), 2); + double fullError = 0, sampledError = 0; + for (int i = 0; i < 100; i++) { + fullError += full.reconstructionError(inputs.get(i)); + sampledError += sampled.reconstructionError(inputs.get(i)); + } + assertTrue("Sampled mean changed NVQ reconstruction error materially: " + sampledError + " vs " + fullError, + sampledError <= fullError * 1.05); + } + + @Test public void pointReadsDoNotCountAndIncompleteScanFails() { + var source = new BorrowedValues(List.of(vector(1), vector(2), vector(3)), new AtomicInteger()); + var mean = new VectorMean(3, 1, 3, 0); + var scan = mean.duringScan(source); + for (int i = 0; i < 3; i++) scan.getVector(i); + assertEquals(0, mean.count()); + assertThrows(IllegalStateException.class, mean::mean); + try (var cursor = VectorAccess.openRange(scan.copy(), 0, 2)) { while (cursor.next()) {} } + assertFalse(mean.isComplete()); + try (var cursor = VectorAccess.openSelection(scan, new int[]{2, 0, 2}, 0, 3)) { while (cursor.next()) {} } + assertEquals(3, mean.count()); + assertEquals(2, mean.mean().get(0), 0); + } + + @Test public void databasePushInputNeedsNoLoaderOrWrapper() { + var mean = new VectorMean(4, 2, 4, 0); + mean.accept(3, vector(7, 8)); + mean.accept(1, vector(3, 4)); + mean.accept(0, vector(1, 2)); + mean.accept(2, vector(5, 6)); + assertEquals(4, mean.mean().get(0), 0); + assertEquals(5, mean.mean().get(1), 0); + } + + @Test public void invalidValuesAndMissingInputsAreHandledExplicitly() { + assertThrows(IllegalArgumentException.class, () -> new VectorMean(0, 2)); + assertThrows(IllegalArgumentException.class, () -> new VectorMean(1, 0)); + var mean = new VectorMean(2, 2, 2, 0); + assertThrows(IllegalArgumentException.class, () -> mean.accept(0, vector(1))); + assertThrows(IllegalArgumentException.class, () -> mean.accept(0, vector(1, Float.NaN))); + assertThrows(IllegalArgumentException.class, () -> mean.accept(0, vector(1, Float.POSITIVE_INFINITY))); + mean.accept(0, vector(1, 2)); + mean.accept(1, null); + assertTrue(mean.isComplete()); + assertEquals(1, mean.count()); + assertEquals(1, mean.mean().get(0), 0); + var empty = new VectorMean(1, 1); + empty.accept(0, null); + assertThrows(IllegalStateException.class, empty::mean); + } + + @Test public void existingEncodersCollectOriginalCoordinatesWithOneReadEach() throws Exception { + var input = new ArrayList>(); + var random = new Random(81); + for (int i = 0; i < 128; i++) { + var v = vector(new float[16]); + for (int d = 0; d < 16; d++) v.set(d, random.nextFloat() + 2); + input.add(v); + } + var resident = new ListRandomAccessVectorValues(input, 16); + var pq = ProductQuantization.compute(resident, 2, 4, true); + var ash = AsymmetricHashing.initialize(resident, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 8 * 2, 1, 2); + var pool = new ForkJoinPool(4); + try { + for (var compressor : new VectorCompressor[]{pq, ash, new BinaryQuantization(16)}) { + var reads = new AtomicInteger(); + var source = new BorrowedValues(input, reads); + var collector = new VectorMean(128, 16, 128, 0); + compressor.encodeAll(collector.duringScan(source), pool); + assertEquals(compressor.getClass().getName(), 128, reads.get()); + assertEquals(128, collector.count()); + var nvq = NVQuantization.create(collector.mean(), 2); + assertEquals(128, reads.get()); + var full = NVQuantization.compute(source, 2); + for (int d = 0; d < 16; d++) assertEquals(full.globalMean.get(d), nvq.globalMean.get(d), 2e-6); + } + } finally { pool.shutdown(); } + } + + @Test public void rangeSourceKeepsBatchAccessAndClosesCursors() { + var reads = new AtomicInteger(); + var closes = new AtomicInteger(); + var values = new BorrowedValues(List.of(vector(2), vector(4), vector(6)), reads); + BatchedVectorValues batched = new BatchedVectorValues() { + public int size() { return values.size(); } + public int dimension() { return values.dimension(); } + public boolean isValueShared() { return true; } + public RandomAccessVectorValues copy() { return this; } + public VectorFloat getVector(int ordinal) { throw new AssertionError("Point read used"); } + public VectorCursor openSelection(int[] ordinals, int offset, int count) { throw new AssertionError(); } + public VectorCursor openRange(int start, int end) { + var cursor = VectorAccess.openRange(values, start, end); + return new VectorCursor() { + public boolean next() { return cursor.next(); } + public int ordinal() { return cursor.ordinal(); } + public VectorFloat vector() { return cursor.vector(); } + public boolean isValueShared() { return true; } + public void close() { closes.incrementAndGet(); cursor.close(); } + }; + } + }; + var mean = new VectorMean(3, 1); + try (var cursor = mean.duringScan(batched).openRange(0, 3)) { while (cursor.next()) {} } + assertEquals(3, reads.get()); + assertEquals(1, closes.get()); + assertEquals(4, mean.mean().get(0), 0); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java new file mode 100644 index 000000000..e163e9e20 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestFeatureHeaderVersion.java @@ -0,0 +1,133 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.graph.disk; + +import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.ByteBufferIndexWriter; +import io.github.jbellis.jvector.disk.IndexWriter; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.feature.Feature; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; +import io.github.jbellis.jvector.graph.disk.feature.SeparatedVectors; +import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; +import io.github.jbellis.jvector.graph.disk.feature.NVQ; +import io.github.jbellis.jvector.graph.disk.feature.SeparatedNVQ; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.NVQuantization; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +/** + * A feature header embedded in a graph serializes its compressor at the graph's version, not at + * {@link OnDiskGraphIndex#CURRENT_VERSION}, so a graph written at an older version is laid out as + * that version wrote it. + */ +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class TestFeatureHeaderVersion extends RandomizedTest { + private static final int DIMENSION = 32, MAX_DEGREE = 16; + + private interface Body { + void write(IndexWriter out, int version) throws IOException; + } + + @Test + public void featureHeadersAreWrittenAtTheGraphVersion() throws IOException { + var ravv = new ListRandomAccessVectorValues(TestUtil.createRandomVectors(512, DIMENSION), DIMENSION); + var pq = ProductQuantization.compute(ravv, 8, 256, false); + var nvq = NVQuantization.compute(ravv, 2); + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + DIMENSION, 1); + var fusedAsh = new FusedASH(MAX_DEGREE, ash); + int blockSize = fusedAsh.blockSize(); + + // Each feature's header is its compressor at the graph's version, plus any fields of its own. + var cases = List.of( + new Case(FeatureId.FUSED_PQ, new FusedPQ(MAX_DEGREE, pq), pq::write), + new Case(FeatureId.NVQ_VECTORS, new NVQ(nvq), nvq::write), + new Case(FeatureId.SEPARATED_NVQ, new SeparatedNVQ(nvq, 1234L), (out, v) -> { + nvq.write(out, v); + out.writeLong(1234L); + }), + new Case(FeatureId.FUSED_ASH, fusedAsh, (out, v) -> { + ash.write(out, v); + out.writeInt(blockSize); + }), + // Full-precision vectors store their largest length from version 7: unknown until any are written. + new Case(FeatureId.INLINE_VECTORS, new InlineVectors(DIMENSION), (out, v) -> { + if (v >= 7) out.writeFloat(Float.NaN); + }), + new Case(FeatureId.SEPARATED_VECTORS, new SeparatedVectors(DIMENSION, 4321L), (out, v) -> { + out.writeLong(4321L); + if (v >= 7) out.writeFloat(Float.NaN); + })); + + for (int version = 2; version <= OnDiskGraphIndex.CURRENT_VERSION; version++) { + var format = GraphIndexFormatFactory.forVersion(version); + for (var c : cases) { + if (!format.getSupportedFeatures().contains(c.id)) continue; + var features = new EnumMap(FeatureId.class); + features.put(c.id, c.feature); + byte[] written = bytes(out -> format.writeHeaderFeatures(out, features)); + int v = version; + byte[] expected = bytes(out -> c.body.write(out, v)); + // The format's own framing (feature ids, counts) precedes the header. + byte[] header = Arrays.copyOfRange(written, written.length - expected.length, written.length); + assertArrayEquals(c.id + " at version " + version, expected, header); + assertEquals(c.id + " header size at version " + version, expected.length, c.feature.headerSize(version)); + } + } + } + + private static final class Case { + final FeatureId id; + final Feature feature; + final Body body; + + Case(FeatureId id, Feature feature, Body body) { + this.id = id; + this.feature = feature; + this.body = body; + } + } + + private interface Writes { + void to(IndexWriter out) throws IOException; + } + + private static byte[] bytes(Writes writes) throws IOException { + try (var out = ByteBufferIndexWriter.create(1024 * 1024, false)) { + writes.to(out); + ByteBuffer data = out.getWrittenData(); + byte[] result = new byte[data.remaining()]; + data.get(result); + return result; + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java index 27c0a82e8..db64f17a6 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestGraphIndexFormatFactory.java @@ -89,9 +89,9 @@ public void testFormatCharacteristicsPerVersion() { assertFalse(v2.usesFooter()); assertEquals(EnumSet.of(FeatureId.INLINE_VECTORS), v2.getSupportedFeatures()); - // versions 3-5 support every feature except FUSED_PQ; multi-layer arrives at v4, + // Versions 3-5 predate fused features; multi-layer arrives at v4, // footer-based metadata arrives at v5. - var nonFused = EnumSet.complementOf(EnumSet.of(FeatureId.FUSED_PQ)); + var nonFused = EnumSet.complementOf(EnumSet.of(FeatureId.FUSED_PQ, FeatureId.FUSED_ASH)); for (int version : new int[]{3, 4, 5}) { var format = GraphIndexFormatFactory.forVersion(version); assertEquals("version " + version + " supported features", nonFused, format.getSupportedFeatures()); @@ -104,7 +104,15 @@ public void testFormatCharacteristicsPerVersion() { assertTrue(v6.supportsMultiLayer()); assertTrue(v6.usesFooter()); assertTrue(v6.supportsFeature(FeatureId.FUSED_PQ)); - assertEquals(EnumSet.allOf(FeatureId.class), v6.getSupportedFeatures()); + assertFalse(v6.supportsFeature(FeatureId.FUSED_ASH)); + assertEquals(EnumSet.complementOf(EnumSet.of(FeatureId.FUSED_ASH)), v6.getSupportedFeatures()); + + var v7 = GraphIndexFormatFactory.forVersion(7); + assertTrue(v7.supportsMultiLayer()); + assertTrue(v7.usesFooter()); + assertTrue(v7.supportsFeature(FeatureId.FUSED_PQ)); + assertTrue(v7.supportsFeature(FeatureId.FUSED_ASH)); + assertEquals(EnumSet.allOf(FeatureId.class), v7.getSupportedFeatures()); } @Test diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java index c76796bb4..92bf7f897 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java @@ -29,11 +29,13 @@ import io.github.jbellis.jvector.graph.TestVectorGraph; import io.github.jbellis.jvector.graph.disk.feature.Feature; import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; import io.github.jbellis.jvector.graph.disk.feature.FusedPQ; import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; import io.github.jbellis.jvector.graph.disk.feature.NVQ; import io.github.jbellis.jvector.graph.disk.feature.SeparatedNVQ; import io.github.jbellis.jvector.graph.disk.feature.SeparatedVectors; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; import io.github.jbellis.jvector.quantization.NVQuantization; import io.github.jbellis.jvector.quantization.PQVectors; import io.github.jbellis.jvector.quantization.ProductQuantization; @@ -340,7 +342,7 @@ public void testV0Read() throws IOException { var onDiskGraph = OnDiskGraphIndex.load(readerSupplier); var onDiskView = onDiskGraph.getView()) { - assertEquals(88, onDiskGraph.ramBytesUsed()); // Current size of graph that hasn't been read from + assertEquals(96, onDiskGraph.ramBytesUsed()); // Includes the FUSED_ASH feature metadata slot; graph hasn't been read from assertEquals(32, onDiskGraph.getDegree(0)); assertEquals(2, onDiskGraph.version); assertEquals(100_000, onDiskGraph.size(0)); @@ -379,18 +381,76 @@ public void testV0Write() throws IOException { @Test public void testVersionRoundTrip() throws Exception { - // Versions 3-5 predate V6's feature-reordering/footer-format changes but still need to - // round-trip correctly. Version 2 is covered separately by testV0Read/testV0Write, and - // version 6 (current) is exercised throughout the rest of this test class. - for (int version : new int[]{3, 4, 5}) { + // Every version a caller can select still round-trips, not only the current one: hosts + // write older formats until all of their readers support the newer one. Version 2 is + // covered separately by testV0Read/testV0Write. + for (int version = 3; version <= OnDiskGraphIndex.CURRENT_VERSION; version++) { versionRoundTrip(version, false); } - // Only versions 4 and 5 (of the ones under test here) support multi-layer graphs. - for (int version : new int[]{4, 5}) { + // Multi-layer graphs start at version 4. + for (int version = 4; version <= OnDiskGraphIndex.CURRENT_VERSION; version++) { versionRoundTrip(version, true); } } + /** Version 6 with fused PQ, NVQ and a hierarchy, as written by hosts that have not adopted version 7. */ + @Test + public void testFusedPQWithNVQAtVersion6() throws Exception { + var graph = new TestUtil.RandomlyConnectedGraphIndex( + List.of(new CommonHeader.LayerInfo(256, 8), new CommonHeader.LayerInfo(16, 4)), getRandom()); + int dimension = 64; + var vectors = TestUtil.createRandomVectors(graph.size(0), dimension); + var ravv = new ListRandomAccessVectorValues(vectors, dimension); + var pq = ProductQuantization.compute(ravv, 16, 256, false); + var pqv = (PQVectors) pq.encodeAll(ravv); + var nvq = NVQuantization.compute(ravv, 2); + var outputPath = testDirectory.resolve("version_6_fused_pq_nvq"); + + try (var writer = new OnDiskGraphIndexWriter.Builder(graph, outputPath) + .withVersion(6) + .with(new NVQ(nvq)) + .with(new FusedPQ(graph.getDegree(0), pq)) + .build()) + { + var view = graph.getView(); + writer.write(Map.of( + FeatureId.NVQ_VECTORS, ordinal -> new NVQ.State(nvq.encode(ravv.getVector(ordinal))), + FeatureId.FUSED_PQ, ordinal -> new FusedPQ.State(view, pqv, ordinal))); + } + + try (var readerSupplier = new SimpleMappedReader.Supplier(outputPath.toAbsolutePath()); + var onDiskGraph = OnDiskGraphIndex.load(readerSupplier)) + { + assertEquals(6, onDiskGraph.version); + assertEquals(EnumSet.of(FeatureId.NVQ_VECTORS, FeatureId.FUSED_PQ), onDiskGraph.features.keySet()); + assertEquals(graph.getMaxLevel(), onDiskGraph.getMaxLevel()); + TestUtil.assertGraphEquals(graph, onDiskGraph); + assertEquals(pq, ((FusedPQ) onDiskGraph.features.get(FeatureId.FUSED_PQ)).getPQ()); + assertEquals(nvq, ((NVQ) onDiskGraph.features.get(FeatureId.NVQ_VECTORS)).getNVQuantization()); + } + } + + @Test + public void testFusedASHRequiresVersion7() throws Exception { + var graph = new TestUtil.RandomlyConnectedGraphIndex(32, 4, getRandom()); + var ravv = new TestVectorGraph.CircularFloatVectorValues(graph.size(0)); + var e = assertThrows(IllegalArgumentException.class, () -> new OnDiskGraphIndexWriter.Builder(graph, + testDirectory.resolve("version_6_fused_ash")) + .withVersion(6) + .with(new InlineVectors(ravv.dimension())) + .with(fusedAshFeature(graph)) + .build()); + assertTrue(e.getMessage(), e.getMessage().startsWith("Feature FUSED_ASH is not supported by version 6")); + } + + private static FusedASH fusedAshFeature(ImmutableGraphIndex graph) throws java.io.IOException { + var vectors = TestUtil.createRandomVectors(graph.size(0), 64); + vectors.forEach(io.github.jbellis.jvector.vector.VectorUtil::l2normalize); + var ash = AsymmetricHashing.initialize(new ListRandomAccessVectorValues(vectors, 64), + AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 64, 1, 2); + return new FusedASH(graph.getDegree(0), ash); + } + private void versionRoundTrip(int version, boolean hierarchical) throws Exception { ImmutableGraphIndex graph = hierarchical ? new TestUtil.RandomlyConnectedGraphIndex( diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java index 40be0aecf..be07207a2 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndexCompactor.java @@ -277,6 +277,14 @@ public void testExactVectorValuesAfterCompaction() throws Exception { OnDiskGraphIndex compacted = OnDiskGraphIndex.load(rsOut); assertEquals(2 * n, compacted.size(0)); + // The compacted header stores the largest length of the vectors it holds, for adaptive dot-product stopping. + float maxLength = 0; + for (var v : vecs0) maxLength = Math.max(maxLength, (float) Math.sqrt(io.github.jbellis.jvector.vector.VectorUtil.dotProduct(v, v))); + for (var v : vecs1) maxLength = Math.max(maxLength, (float) Math.sqrt(io.github.jbellis.jvector.vector.VectorUtil.dotProduct(v, v))); + var inline = (io.github.jbellis.jvector.graph.disk.feature.InlineVectors) + compacted.getFeatures().get(io.github.jbellis.jvector.graph.disk.feature.FeatureId.INLINE_VECTORS); + assertEquals(maxLength, inline.maxVectorNorm(), maxLength * 1e-6f); + var view = compacted.getView(); VectorFloat buf = vectorTypeSupport.createFloatVector(dim); diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskVectorNorms.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskVectorNorms.java new file mode 100644 index 000000000..c03c60de0 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskVectorNorms.java @@ -0,0 +1,96 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph.disk; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.feature.Feature; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; +import io.github.jbellis.jvector.graph.disk.feature.SeparatedVectors; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * On-disk graphs store the largest length of their full-precision vectors from format version 7, so dot-product + * searches over them can stop adaptively; older formats leave it unknown, and those searches stop by the standard rule. + */ +public class TestOnDiskVectorNorms { + private static final int N = 300, DIM = 16; + + @Test + public void fullPrecisionHeadersStoreTheLargestLength() throws Exception { + var random = new Random(37); + List> vectors = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < N; i++) { + var v = TestUtil.randomVector(random, DIM); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + vectors.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var ravv = new ListRandomAccessVectorValues(vectors, DIM); + var graph = new TestUtil.RandomlyConnectedGraphIndex(N, 8, random); + var query = TestUtil.randomVector(random, DIM); + float queryNorm = (float) Math.sqrt(VectorUtil.dotProduct(query, query)); + Path directory = Files.createTempDirectory("ondisk-norms"); + + for (int version : new int[] {OnDiskGraphIndex.CURRENT_VERSION, 6}) { + var path = directory.resolve("inline-" + version); + try (var writer = new OnDiskGraphIndexWriter.Builder(graph, path).withVersion(version) + .with(new InlineVectors(DIM)).build()) { + writer.write(Feature.singleStateFactory(FeatureId.INLINE_VECTORS, + node -> new InlineVectors.State(ravv.getVector(node)))); + } + try (var readers = new SimpleMappedReader.Supplier(path); + var index = OnDiskGraphIndex.load(readers); + var view = index.getView()) { + float stored = ((InlineVectors) index.getFeatures().get(FeatureId.INLINE_VECTORS)).maxVectorNorm(); + float bound = view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT).dotProductBound(); + if (version >= 7) { + assertEquals(maxNorm, stored, maxNorm * 1e-6f); + assertEquals(queryNorm * maxNorm, bound, queryNorm * maxNorm * 1e-5f); + } else { + assertTrue("version " + version + ": " + stored, Float.isNaN(stored)); + assertTrue(Float.isNaN(bound)); + } + } + } + + var path = directory.resolve("separated"); + try (var writer = new OnDiskGraphIndexWriter.Builder(graph, path).with(new SeparatedVectors(DIM, 0L)).build()) { + writer.write(Feature.singleStateFactory(FeatureId.SEPARATED_VECTORS, + node -> new InlineVectors.State(ravv.getVector(node)))); + } + try (var readers = new SimpleMappedReader.Supplier(path); + var index = OnDiskGraphIndex.load(readers)) { + float stored = ((SeparatedVectors) index.getFeatures().get(FeatureId.SEPARATED_VECTORS)).maxVectorNorm(); + assertEquals(maxNorm, stored, maxNorm * 1e-6f); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/similarity/TestAdaptiveStoppingDefault.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/similarity/TestAdaptiveStoppingDefault.java new file mode 100644 index 000000000..d9654b188 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/similarity/TestAdaptiveStoppingDefault.java @@ -0,0 +1,120 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.graph.similarity; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.disk.SimpleWriter; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.quantization.AsymmetricHashing; +import io.github.jbellis.jvector.quantization.ProductQuantization; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Top-K searches stop adaptively for every similarity function a provider names, and dot-product searches know an + * upper bound on the dot products they score, whatever the vectors' lengths. + */ +public class TestAdaptiveStoppingDefault { + @Test + public void providersNameTheSimilarityTheirScoresMeasure() { + var random = new Random(3); + var vectors = new ListRandomAccessVectorValues(List.of(TestUtil.randomVector(random, 8), + TestUtil.randomVector(random, 8)), 8); + var query = TestUtil.randomVector(random, 8); + for (var similarity : VectorSimilarityFunction.values()) { + assertEquals(similarity, DefaultSearchScoreProvider.exact(query, similarity, vectors).adaptiveSimilarity()); + var sf = DefaultSearchScoreProvider.exact(query, similarity, vectors).scoreFunction(); + assertEquals(similarity, DefaultSearchScoreProvider.forSimilarity(sf, null, similarity).adaptiveSimilarity()); + // A provider that cannot tell what its scores measure keeps the standard rule. + assertNull(new DefaultSearchScoreProvider(sf).adaptiveSimilarity()); + assertNull(new DefaultSearchScoreProvider(sf, null).adaptiveSimilarity()); + // Graph construction keeps the standard rule for full-precision scores. + assertNull(BuildScoreProvider.randomAccessScoreProvider(vectors, similarity) + .searchProviderFor(query).adaptiveSimilarity()); + } + } + + @Test + public void dotProductBoundsHoldForVectorsOfAnyLength() throws Exception { + var random = new Random(17); + int n = 500, dim = 32; + List> base = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < n; i++) { + var v = TestUtil.randomVector(random, dim); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var ravv = new ListRandomAccessVectorValues(base, dim); + var query = TestUtil.randomVector(random, dim); + VectorUtil.scale(query, 3f); + float queryNorm = (float) Math.sqrt(VectorUtil.dotProduct(query, query)); + var dot = VectorSimilarityFunction.DOT_PRODUCT; + + // A list of vectors does not track lengths, so dot-product searches over it keep the standard rule. + assertTrue(Float.isNaN(DefaultSearchScoreProvider.exact(query, dot, ravv).dotProductBound())); + + // A caller that knows the bound can supply it. + var exact = DefaultSearchScoreProvider.exact(query, dot, ravv).scoreFunction(); + assertEquals(queryNorm * maxNorm, + new DefaultSearchScoreProvider(exact, null, dot, queryNorm * maxNorm).dotProductBound(), 0f); + + // PQ bounds every dot product it scores, from its reconstructions. + for (boolean center : new boolean[] {false, true}) { + var pq = ProductQuantization.compute(ravv, 8, 32, center); + var pqv = pq.encodeAll(ravv, ForkJoinPool.commonPool()); + var sf = pqv.precomputedScoreFunctionFor(query, dot); + float bound = sf.dotProductBound(); + assertTrue(Float.isFinite(bound)); + for (int i = 0; i < n; i++) { + float estimate = 2 * sf.similarityTo(i) - 1; + assertTrue("PQ center " + center + " node " + i + ": " + estimate + " > " + bound, + estimate <= bound * (1 + 1e-5f) + 1e-5f); + } + } + + // ASH records the largest length it encodes and keeps it through a save and load. + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 32 * 2, 1, 2); + var ashv = ash.encodeAll(ravv, ForkJoinPool.commonPool()); + assertEquals(maxNorm, ash.maxEncodedNorm(), maxNorm * 1e-4f); + assertEquals(queryNorm * ash.maxEncodedNorm(), ashv.precomputedScoreFunctionFor(query, dot).dotProductBound(), + queryNorm * maxNorm * 1e-5f); + File file = File.createTempFile("ash-norm", ".model"); + file.deleteOnExit(); + try (var writer = new SimpleWriter(file.toPath())) { + ash.write(writer, OnDiskGraphIndex.CURRENT_VERSION); + } + assertEquals(ash.compressorSize(), file.length()); + try (var readerSupplier = new SimpleMappedReader.Supplier(file.toPath())) { + assertEquals(ash.maxEncodedNorm(), AsymmetricHashing.load(readerSupplier.get()).maxEncodedNorm(), 0f); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java new file mode 100644 index 000000000..8311c4837 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHAdaptiveStopping.java @@ -0,0 +1,112 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.graph.similarity.SearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Adaptive stopping is experimental and off by default. Enabled on the searcher, ASH searches stop adaptively whatever + * provider wraps their scores; otherwise they stop by the standard rule. + */ +public class TestASHAdaptiveStopping { + @Test + public void ashScoresAlwaysStopAdaptivelyUnlessTheSearcherSaysOtherwise() throws Exception { + var random = new Random(17); + int count = 3000, dimension = 64; + var input = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + input[i] = TestUtil.randomVector(random, dimension); + VectorUtil.l2normalize(input[i]); + } + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 32 * 2, 1, 2); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var bsp = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, encoded); + try (var builder = new GraphIndexBuilder(bsp, dimension, 16, 50, 1.2f, 1.2f, true, true)) { + for (int i = 0; i < count; i++) builder.addGraphNode(i, input[i]); + builder.cleanup(); + ImmutableGraphIndex graph = builder.getGraph(); + for (int q = 0; q < 20; q++) { + var query = TestUtil.randomVector(random, dimension); + VectorUtil.l2normalize(query); + var asf = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + assertTrue(asf.requiresAdaptiveTermination()); + var requested = search(graph, new DefaultSearchScoreProvider(asf, null, VectorSimilarityFunction.DOT_PRODUCT), true); + // Plain wrappers that do not ask for adaptive stopping still get it. + assertSame(requested, search(graph, new DefaultSearchScoreProvider(asf), true)); + assertSame(requested, search(graph, new DefaultSearchScoreProvider(asf, null), true)); + assertSame(requested, search(graph, customProvider(asf), true)); + // The searcher's switch is the override: the search is then the standard one. + ScoreFunction.ApproximateScoreFunction unmarked = asf::similarityTo; + var standard = search(graph, new DefaultSearchScoreProvider(unmarked), true); + assertSame(standard, search(graph, new DefaultSearchScoreProvider(asf, null, VectorSimilarityFunction.DOT_PRODUCT), false)); + // A searcher that does not enable it stops by the standard rule. + try (var searcher = new GraphSearcher(graph)) { + assertSame(standard, searcher.search(new DefaultSearchScoreProvider(asf, null, VectorSimilarityFunction.DOT_PRODUCT), + 10, 10, 0.0f, 0.0f, Bits.ALL)); + } + } + } + } + + private static SearchResult search(ImmutableGraphIndex graph, SearchScoreProvider provider, boolean adaptive) + throws Exception { + try (var searcher = new GraphSearcher(graph)) { + searcher.useAdaptiveTermination(adaptive); + return searcher.search(provider, 10, 10, 0.0f, 0.0f, Bits.ALL); + } + } + + /** A provider written by an application, which knows nothing about adaptive stopping. */ + private static SearchScoreProvider customProvider(ScoreFunction scoreFunction) { + return new SearchScoreProvider() { + @Override public ScoreFunction scoreFunction() { return scoreFunction; } + @Override public ScoreFunction.ExactScoreFunction reranker() { return null; } + @Override public ScoreFunction.ExactScoreFunction exactScoreFunction() { return null; } + }; + } + + private static void assertSame(SearchResult expected, SearchResult actual) { + assertArrayEquals(Arrays.stream(expected.getNodes()).mapToInt(n -> n.node).toArray(), + Arrays.stream(actual.getNodes()).mapToInt(n -> n.node).toArray()); + assertEquals(expected.getVisitedCount(), actual.getVisitedCount()); + assertEquals(expected.getExpandedCount(), actual.getExpandedCount()); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java new file mode 100644 index 000000000..368074154 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHBatchEncoding.java @@ -0,0 +1,300 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.lang.reflect.Proxy; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** Base-vector encoding contracts shared by the native and Panama providers. */ +public class TestASHBatchEncoding { + private static final int DIMENSION = 16; + + private static VectorFloat[] vectors(int count) { + Random random = new Random(771023); + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + VectorFloat[] result = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + result[i] = types.createFloatVector(DIMENSION); + for (int d = 0; d < DIMENSION; d++) result[i].set(d, random.nextFloat() - .5f); + } + return result; + } + + private static AsymmetricHashing model(int bits, int landmarks) throws Exception { + return AsymmetricHashing.initialize(MockVectorValues.fromValues(vectors(128)), + AsymmetricHashing.RANDOM, AsymmetricHashing.HEADER_BITS + 9 * bits, landmarks, bits); + } + + private static void assertCode(AsymmetricHashing.QuantizedVector expected, + AsymmetricHashing.QuantizedVector actual) { + assertEquals(expected.landmark, actual.landmark); + assertEquals(expected.scale, actual.scale, 0f); + assertEquals(expected.offset, actual.offset, 0f); + assertArrayEquals(expected.binaryVector, actual.binaryVector); + assertArrayEquals(expected.extraBits, actual.extraBits); + } + + private static void assertHighPrecisionCodeCompatible(AsymmetricHashing ash, + AsymmetricHashing.QuantizedVector expected, + AsymmetricHashing.QuantizedVector actual) { + assertEquals(expected.landmark, actual.landmark); + // Different FP32 reduction orders can cross a 9-bit code boundary. Check each reconstructed + // component within one step, rather than requiring bit equality of that discontinuous quantizer. + // The stored offset folds in -scale * , so compare the code-independent centered offset. + int bits = ash.bitsPerDimension; + for (int d = 0; d < ash.quantizedDim; d++) { + float e = AsymmetricHashing.decodeOffsetBinary(AsymmetricHashing.offsetBinaryField(expected.extraBits, 0, d, bits), bits); + float a = AsymmetricHashing.decodeOffsetBinary(AsymmetricHashing.offsetBinaryField(actual.extraBits, 0, d, bits), bits); + assertEquals(expected.scale * e, actual.scale * a, expected.scale + actual.scale + 1e-7); + } + float e = ash.centeredOffset(expected), a = ash.centeredOffset(actual); + assertEquals(e, a, 2e-3f * Math.max(1f, Math.abs(e))); + } + + private static final class Values implements RandomAccessVectorValues { + private final VectorFloat[] vectors; + private final boolean shared; + private final VectorFloat scratch; + private final AtomicInteger reads; + Values(VectorFloat[] vectors, boolean shared) { + this(vectors, shared, null); + } + Values(VectorFloat[] vectors, boolean shared, AtomicInteger reads) { + this.reads = reads; + this.vectors = vectors; + this.shared = shared; + scratch = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(DIMENSION); + } + @Override public int size() { return vectors.length; } + @Override public int dimension() { return DIMENSION; } + @Override public boolean isValueShared() { return shared; } + @Override public RandomAccessVectorValues copy() { return new Values(vectors, shared, reads); } + @Override public VectorFloat getVector(int ordinal) { + if (reads != null) reads.incrementAndGet(); + VectorFloat vector = vectors[ordinal]; + if (vector == null || !shared) return vector; + for (int d = 0; d < DIMENSION; d++) scratch.set(d, vector.get(d)); + return scratch; + } + } + + @Test + public void bulkMatchesSingleForTailsMissingValuesAndBorrowedStorage() throws Exception { + for (int bits : new int[]{1,2,3,4,9}) { + var ash = model(bits, bits == 3 ? 3 : 1); + for (int count : new int[]{0,1,31,32,33,65,97}) { + VectorFloat[] input = vectors(count); + if (count > 32) { + input[0] = null; + input[31] = null; + input[count - 1] = ash.landmarks[0].copy(); // zero residual + } + ASHVectors firstBulkResult = null; + for (boolean shared : new boolean[]{false,true}) { + for (int threads : new int[]{1,3}) { + ForkJoinPool pool = new ForkJoinPool(threads); + try { + var actual = ash.encodeAll(new Values(input, shared), pool); + assertEquals(count, actual.count()); + if (firstBulkResult == null) firstBulkResult = actual; + for (int i = 0; i < count; i++) { + var expected = input[i] == null + ? AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, bits) + : ash.encode(input[i]); + try { + // A BLAS implementation may choose a different FP32 + // reduction for a worker's batch shape. Preserve exact + // 1/2/3/4-bit checks; use the existing reconstruction + // error bound for discontinuous 9-bit codes/half scales. + if (bits == 9 && input[i] != null) + assertHighPrecisionCodeCompatible(ash, firstBulkResult.get(i), actual.get(i)); + else assertCode(firstBulkResult.get(i), actual.get(i)); + if (bits == 9 && input[i] != null) + assertHighPrecisionCodeCompatible(ash, expected, actual.get(i)); + else assertCode(expected, actual.get(i)); + } + catch (AssertionError failure) { + throw new AssertionError("bits=" + bits + ", count=" + count + + ", shared=" + shared + ", threads=" + threads + ", ordinal=" + i, + failure); + } + } + } finally { pool.shutdown(); } + } + } + } + } + } + + @Test + public void correctionsAreComputedInTheEncodingPassAndSharedWithoutChangingCodesOrScores() throws Exception { + for (int bits : new int[]{1,2,3,4,9}) for (int landmarks : new int[]{1,3}) { + var ash = model(bits, landmarks); + var input = vectors(97); + input[31] = null; + input[96] = ash.landmarks[0].copy(); + AtomicInteger reads = new AtomicInteger(); + var source = new Values(input, true, reads); + ForkJoinPool pool = new ForkJoinPool(3); + try { + var bulk = ash.encodeAll(source, pool); + assertEquals("Read each raw vector exactly once", input.length, reads.get()); + assertNotNull(bulk.symmetricOffsets); + AsymmetricHashing.QuantizedVector[] codes = new AsymmetricHashing.QuantizedVector[input.length]; + for (int i = 0; i < codes.length; i++) { + codes[i] = input[i] == null ? AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, bits) : ash.encode(input[i]); + if (bits == 9 && input[i] != null) { + // Singleton and batch FP32 reductions can cross a 9-bit code boundary; score the batch codes. + assertHighPrecisionCodeCompatible(ash, codes[i], bulk.get(i)); + codes[i] = bulk.get(i); + } else { + assertCode(codes[i], bulk.get(i)); + } + float expected = ash.centeredOffset(bulk.get(i)); + assertEquals(Float.floatToRawIntBits(expected), Float.floatToRawIntBits(bulk.symmetricOffsets[i])); + } + var uncached = new ASHVectors(ash, codes); + var prepared = new ASHSymmetricScorer(bulk); + var reference = new ASHSymmetricScorer(uncached); + var offsets = ASHSymmetricScorer.class.getDeclaredField("centeredOffsets"); + offsets.setAccessible(true); + assertSame(bulk.symmetricOffsets, offsets.get(prepared)); + assertSame(bulk.symmetricOffsets, offsets.get(new ASHSymmetricScorer(bulk))); + assertEquals(io.github.jbellis.jvector.util.RamUsageEstimator.shallowSizeOf(bulk.symmetricOffsets), + bulk.ramBytesUsed() - uncached.ramBytesUsed()); + var reordered = bulk.reorderByLandmarkFast(); + var reorderedScorer = new ASHSymmetricScorer(reordered.vectors); + for (int i = 0; i < codes.length; i++) for (int j = 0; j < codes.length; j++) { + assertEquals(reference.dotProduct(i, j), prepared.dotProduct(i, j), 0f); + assertEquals(prepared.dotProduct(reordered.newToOld[i], reordered.newToOld[j]), + reorderedScorer.dotProduct(i, j), 0f); + } + assertEquals("Scorer creation/reordering must not reread raw input", input.length, reads.get()); + } finally { pool.shutdown(); } + } + } + + @Test + public void encodeToClearsReusedCodesAndEncodingIgnoresQueryPrecision() throws Exception { + String old = System.getProperty("jvector.ash.queryProjection"); + try { + // This would fail even on a capable machine if encoding requested a + // query view. Query-only selection must never affect base encoding. + System.setProperty("jvector.ash.queryProjection", "invalid-query-mode"); + var ash = model(4, 1); + VectorFloat[] input = vectors(65); + var dest = ash.encode(input[0]); + for (var vector : input) { + var expected = ash.encode(vector); + ash.encodeTo(vector, dest); + assertCode(expected, dest); + } + ForkJoinPool pool = new ForkJoinPool(2); + try { + var bulk = ash.encodeAll(new Values(input, true), pool); + for (int i = 0; i < input.length; i++) assertCode(ash.encode(input[i]), bulk.get(i)); + } finally { pool.shutdown(); } + } finally { + if (old == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", old); + } + } + + @Test + public void allMissingSourceRetainsZeroRepresentations() throws Exception { + var ash = model(2, 1); + ForkJoinPool pool = new ForkJoinPool(3); + try { + var encoded = ash.encodeAll(new Values(new VectorFloat[97], true), pool); + var empty = AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, 2); + for (int i = 0; i < 97; i++) assertCode(empty, encoded.get(i)); + } finally { pool.shutdown(); } + } + + @Test + public void invalidArgumentsAndSourceFailureArePropagatedAfterWorkersFinish() throws Exception { + var ash = model(2, 1); + assertThrows(NullPointerException.class, () -> ash.encode(null)); + var shortVector = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(1); + assertThrows(IllegalArgumentException.class, () -> ash.encode(shortVector)); + var input = vectors(128); + AtomicInteger otherRangeReads = new AtomicInteger(); + RandomAccessVectorValues source = new RandomAccessVectorValues() { + @Override public int size() { return 128; } + @Override public int dimension() { return DIMENSION; } + @Override public boolean isValueShared() { return false; } + @Override public RandomAccessVectorValues copy() { return this; } + @Override public VectorFloat getVector(int ordinal) { + if (ordinal == 0) throw new IllegalStateException("source failed"); + if (ordinal >= 64) otherRangeReads.incrementAndGet(); + return input[ordinal]; + } + }; + ForkJoinPool pool = new ForkJoinPool(2); + try { + assertThrows(NullPointerException.class, () -> ash.encodeAll(null, pool)); + assertThrows(NullPointerException.class, () -> ash.encodeAll(source, null)); + assertThrows(IllegalStateException.class, () -> ash.encodeAll(source, pool)); + assertEquals("No source worker may outlive encodeAll failure", 64, otherRangeReads.get()); + } finally { pool.shutdown(); } + } + + @Test + public void backingArrayNormalizationMatchesOpaqueVectorsBitForBit() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var normalize = AsymmetricHashing.class.getDeclaredMethod("normalizeResidual", + VectorFloat.class, + VectorFloat.class, float.class, float[].class); + normalize.setAccessible(true); + Random random = new Random(81231); + for (int dimensions : new int[]{0,1,15,16,17,31,32,33,768,3072}) { + var vector = types.createFloatVector(dimensions); + var mean = types.createFloatVector(dimensions); + for (int j = 0; j < dimensions; j++) { + vector.set(j, j % 7 == 0 ? -0.0f : random.nextFloat() - .5f); + mean.set(j, j % 7 == 0 ? 0.0f : random.nextFloat() - .5f); + } + var opaqueVector = (VectorFloat) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{VectorFloat.class}, + (proxy, method, args) -> method.invoke(vector, args)); + var opaqueMean = (VectorFloat) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{VectorFloat.class}, + (proxy, method, args) -> method.invoke(mean, args)); + for (float residualNorm : new float[]{0f,1f,3f,Float.MAX_VALUE}) { + float[] expected = new float[dimensions]; + normalize.invoke(null, opaqueVector, opaqueMean, residualNorm, expected); + for (var x : new VectorFloat[]{vector, opaqueVector}) + for (var mu : new VectorFloat[]{mean, opaqueMean}) { + float[] actual = new float[dimensions]; + normalize.invoke(null, x, mu, residualNorm, actual); + for (int j = 0; j < dimensions; j++) + assertEquals(Float.floatToRawIntBits(expected[j]), Float.floatToRawIntBits(actual[j])); + } + } + } + } + +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java new file mode 100644 index 000000000..3d5887dda --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCodes.java @@ -0,0 +1,291 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.disk.ByteBufferIndexWriter; +import io.github.jbellis.jvector.disk.ByteBufferReader; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Assume; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.*; + +/** + * The compact code store must hold exactly the codes it is given, serialize them in the + * standalone vector format, and score them identically to standalone vectors, including + * across page boundaries. + */ +public class TestASHCodes { + private static final int[] BITS = {1, 2, 3, 4, 9}; + // Small pages so that a few hundred vectors span many of them. + private static final int SMALL_PAGE_BYTES = 256; + + private static AsymmetricHashing.QuantizedVector randomVector(int dims, int bits, Random random) { + var v = AsymmetricHashing.QuantizedVector.createEmpty(dims, bits); + for (int w = 0; w < v.binaryVector.length; w++) v.binaryVector[w] = random.nextLong(); + random.nextBytes(v.extraBits); + // Values exactly representable in binary16, so a serialization round trip is lossless. + v.scale = 0.5f + random.nextInt(64) / 64f; + v.offset = -1f + random.nextInt(128) / 64f; + v.landmark = (byte) random.nextInt(3); + return v; + } + + private static ASHCodes store(AsymmetricHashing.QuantizedVector[] vectors, int dims, int bits) { + var codes = ASHCodes.allocate(vectors.length, dims, bits, SMALL_PAGE_BYTES); + for (int i = 0; i < vectors.length; i++) codes.set(i, vectors[i]); + return codes; + } + + @Test + public void storesExactlyWhatItIsGivenAcrossPages() { + var random = new Random(4); + for (int bits : BITS) for (int dims : new int[]{1, 33, 64, 200}) { + var vectors = new AsymmetricHashing.QuantizedVector[301]; + for (int i = 0; i < vectors.length; i++) vectors[i] = randomVector(dims, bits, random); + var codes = store(vectors, dims, bits); + assertTrue("bits=" + bits + " dims=" + dims + " should span pages", + (codes.signPages != null ? codes.signPages.length : codes.bodyPages.length) > 1); + var reused = AsymmetricHashing.QuantizedVector.createEmpty(dims, bits); + for (int i = 0; i < vectors.length; i++) { + assertEquals(vectors[i], codes.get(i)); + codes.copyTo(i, reused); + assertEquals(vectors[i], reused); + } + // Same content in a store with different page sizes is equal and hashes equally. + var defaultPages = ASHCodes.copyOf(vectors, dims, bits); + assertTrue(codes.contentEquals(defaultPages)); + assertEquals(codes.contentHash(), defaultPages.contentHash()); + + int[] newToOld = new int[vectors.length]; + for (int i = 0; i < newToOld.length; i++) newToOld[i] = (i * 7) % newToOld.length; + var permuted = codes.permute(newToOld); + for (int i = 0; i < vectors.length; i++) assertEquals(vectors[newToOld[i]], permuted.get(i)); + } + } + + @Test + public void rejectsVectorsWithTheWrongShape() { + var codes = ASHCodes.allocate(2, 64, 4); + var shortCode = AsymmetricHashing.QuantizedVector.createEmpty(64, 4); + shortCode.extraBits = new byte[shortCode.extraBits.length - 1]; + assertThrows(IllegalArgumentException.class, () -> codes.set(0, shortCode)); + assertThrows(IndexOutOfBoundsException.class, + () -> codes.set(2, AsymmetricHashing.QuantizedVector.createEmpty(64, 4))); + assertThrows(IllegalArgumentException.class, () -> ASHCodes.allocate(1, 64, 4, 0)); + } + + @Test + public void serializesInTheStandaloneVectorFormat() throws Exception { + var random = new Random(5); + for (int bits : BITS) for (int dims : new int[]{1, 33, 200}) { + var vectors = new AsymmetricHashing.QuantizedVector[97]; + for (int i = 0; i < vectors.length; i++) vectors[i] = randomVector(dims, bits, random); + int size = vectors.length * AsymmetricHashing.QuantizedVector.serializedSizeBytes(dims, bits); + ByteBuffer expected, actual; + try (var writer = ByteBufferIndexWriter.create(size, false)) { + for (var v : vectors) v.write(writer, dims, bits); + expected = writer.getWrittenData(); + } + try (var writer = ByteBufferIndexWriter.create(size, false)) { + store(vectors, dims, bits).write(writer); + actual = writer.getWrittenData(); + } + assertEquals("bits=" + bits + " dims=" + dims, expected, actual); + var loaded = ASHCodes.load(new ByteBufferReader(actual.duplicate()), vectors.length, dims, bits); + for (int i = 0; i < vectors.length; i++) assertEquals(vectors[i], loaded.get(i)); + } + } + + private static VectorFloat[] inputs(int n, int dim, long seed) { + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(seed); + VectorFloat[] out = new VectorFloat[n]; + for (int i = 0; i < n; i++) { + float[] v = new float[dim]; + for (int j = 0; j < dim; j++) v[j] = (float) random.nextGaussian(); + out[i] = vts.createFloatVector(v); + } + return out; + } + + /** Encoded vectors re-stored in small pages, plus standalone copies of the same codes. */ + private static ASHVectors smallPages(ASHVectors encoded) { + var a = encoded.getCompressor(); + var codes = ASHCodes.allocate(encoded.count(), a.quantizedDim, a.bitsPerDimension, SMALL_PAGE_BYTES); + for (int i = 0; i < encoded.count(); i++) codes.set(i, encoded.get(i)); + return new ASHVectors(a, codes, encoded.symmetricOffsets); + } + + @Test + public void asymmetricScoresMatchStandaloneVectors() throws Exception { + for (int bits : new int[]{1, 2, 3, 4}) for (int dim : new int[]{40, 130}) { + var input = inputs(260, dim, 17L * bits + dim); + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + (dim / 2) * bits, 2, bits); + var vectors = smallPages(ash.encodeAll(values, ForkJoinPool.commonPool())); + var reference = new ASHScorer(ash); + for (int q = 0; q < 5; q++) { + var query = input[q * 31]; + var standalone = reference.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + var inPlace = vectors.scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < vectors.count(); i++) { + float expected = ASHScorer.toSimilarity(standalone.similarityTo(vectors.get(i))); + float actual = inPlace.similarityTo(i); + if (bits == 1) { + // The 1-bit SIMD kernel accumulates in a different order. + assertEquals("bits=1 dim=" + dim + " i=" + i, expected, actual, 1e-5f * Math.max(1f, Math.abs(expected))); + } else { + // A code's tail may be summed in vector or scalar order depending on + // where it sits in its array. + assertEquals("bits=" + bits + " dim=" + dim + " i=" + i, expected, actual, + 1e-5f * Math.max(1f, Math.abs(expected))); + } + } + } + } + } + + @Test + public void symmetricScoresDoNotDependOnWhereTheQueryIsStored() throws Exception { + for (int bits : new int[]{1, 2, 4}) for (int dim : new int[]{40, 96, 200}) { + var input = inputs(300, dim, 31L * bits + dim); + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + (dim / 2) * bits, 1, bits); + var vectors = smallPages(ash.encodeAll(values, ForkJoinPool.commonPool())); + var auto = new ASHSymmetricScorer(vectors, ASHSymmetricScorer.Kernel.AUTO); + var scalar = new ASHSymmetricScorer(vectors, ASHSymmetricScorer.Kernel.SCALAR); + var random = new Random(bits * 1000L + dim); + for (int t = 0; t < 3000; t++) { + int i = random.nextInt(input.length), j = random.nextInt(input.length); + String where = "bits=" + bits + " dim=" + dim + " i=" + i + " j=" + j; + int expected = Float.floatToIntBits(scalar.scoreFunctionFor(i).similarityTo(j)); + // Integer-exact pair kernels: SIMD and scalar agree bit for bit. + assertEquals(where, expected, Float.floatToIntBits(auto.scoreFunctionFor(i).similarityTo(j))); + // A standalone copy of a stored query scores the same as the stored query. + assertEquals(where, expected, Float.floatToIntBits(auto.scoreFunctionFor(vectors.get(i)).similarityTo(j))); + } + var diversity = vectors.diversityFunctionFor(3, VectorSimilarityFunction.DOT_PRODUCT); + for (int j = 0; j < input.length; j++) { + assertEquals(Float.floatToIntBits(auto.scoreFunctionFor(3).similarityTo(j)), + Float.floatToIntBits(diversity.similarityTo(j))); + } + } + } + + @Test + public void reorderingMovesCodesWithTheirOrdinals() throws Exception { + var input = inputs(200, 64, 9); + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 32 * 4, 3, 4); + var vectors = smallPages(ash.encodeAll(values, ForkJoinPool.commonPool())); + var order = vectors.reorderByLandmarkFast(); + for (int n = 0; n < vectors.count(); n++) { + assertEquals(vectors.get(order.newToOld[n]), order.vectors.get(n)); + } + assertEquals(vectors.ramBytesUsed(), order.vectors.ramBytesUsed()); + } + + @Test + public void offsetKernelsMatchZeroOffsetKernels() { + var vu = VectorizationProvider.getInstance().getVectorUtilSupport(); + var random = new Random(20261109); + for (int bits : new int[]{2, 4}) { + // Odd and partial-word lengths exercise the vector tails. + for (int dims : new int[]{1, 3, 7, 31, 32, 33, 63, 65, 127, 129, 384, 768, 1536}) { + int bytes = (dims * bits + 7) / 8; + for (int trial = 0; trial < 4; trial++) { + byte[] a = new byte[bytes], b = new byte[bytes]; + random.nextBytes(a); + random.nextBytes(b); + // Embed the codes at arbitrary offsets among random bytes, as in a code page. + int aOff = random.nextInt(130), bOff = random.nextInt(130); + byte[] aPage = embed(a, aOff, random); + byte[] bPage = embed(b, bOff, random); + String where = "bits=" + bits + " dims=" + dims; + // Exact length: no kernel may read query elements past the dimension count. + float[] query = new float[dims]; + for (int k = 0; k < dims; k++) query[k] = (float) random.nextGaussian(); + // Summation order may depend on the offset, so allow rounding differences. + float direct = vu.ashProjectionDot(query, a, dims, bits); + assertEquals(where, direct, vu.ashProjectionDot(query, aPage, aOff, dims, bits), + 1e-5f * Math.max(1f, Math.abs(direct))); + float tuned = vu.ashProjectionDotTuned(query, a, dims, bits); + assertEquals(where, tuned, vu.ashProjectionDotTuned(query, aPage, aOff, dims, bits), + 1e-5f * Math.max(1f, Math.abs(tuned))); + int groups = FusedASHLayout.codeGroups(dims, bits); + float[] lut = new float[groups * 16]; + for (int k = 0; k < lut.length; k++) lut[k] = (float) random.nextGaussian(); + assertEquals(where, Float.floatToIntBits(vu.ashCanonicalLutDot(a, groups, lut)), + Float.floatToIntBits(vu.ashCanonicalLutDot(aPage, aOff, groups, lut))); + if (vu.supportsAshSymmetricDotAt()) { + assertEquals(where, Float.floatToIntBits(vu.ashSymmetricDot(null, a, null, b, dims, bits)), + Float.floatToIntBits(vu.ashSymmetricDotAt(aPage, aOff, bPage, bOff, dims, bits))); + } + } + } + } + if (vu.supportsAshSymmetricScoring()) { + for (int dims : new int[]{1, 63, 64, 65, 384, 1000}) { + int words = (dims + 63) / 64; + long[] a = random.longs(words).toArray(), b = random.longs(words).toArray(); + long[] aPage = new long[words + 9], bPage = new long[words + 5]; + System.arraycopy(a, 0, aPage, 7, words); + System.arraycopy(b, 0, bPage, 2, words); + assertEquals("dims=" + dims, Float.floatToIntBits(vu.ashSymmetricDot(a, null, b, null, dims, 1)), + Float.floatToIntBits(vu.ashSymmetricBinaryDotAt(aPage, 7, bPage, 2, dims))); + } + long[] bitsPage = new long[4]; + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricBinaryDotAt(bitsPage, 3, bitsPage, 0, 128)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricBinaryDotAt(bitsPage, -1, bitsPage, 0, 64)); + } + float[] query = new float[384]; + byte[] page = new byte[200]; + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashProjectionDot(query, page, 150, 384, 2)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashProjectionDot(query, page, -1, 8, 2)); + // A code that ends exactly at the end of the array is in range. + vu.ashProjectionDot(query, page, 104, 384, 2); + if (vu.supportsAshSymmetricDotAt()) { + assertThrows(IllegalArgumentException.class, () -> vu.ashSymmetricDotAt(page, 0, page, 0, 8, 1)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricDotAt(page, 150, page, 0, 384, 2)); + vu.ashSymmetricDotAt(page, 104, page, 0, 384, 2); + // Two 96-byte rows at stride 100: the second ends inside the array, a third would not. + float[] out = new float[3]; + vu.ashSymmetricDotRows(page, 0, page, 100, 2, 384, 2, out); + assertEquals(Float.floatToIntBits(vu.ashSymmetricDotAt(page, 0, page, 100, 384, 2)), Float.floatToIntBits(out[1])); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricDotRows(page, 0, page, 105, 2, 384, 2, out)); + assertThrows(IndexOutOfBoundsException.class, () -> vu.ashSymmetricDotRows(page, 0, page, 100, 3, 384, 2, out)); + assertThrows(IllegalArgumentException.class, () -> vu.ashSymmetricDotRows(page, 0, page, 100, 1, 384, 1, out)); + } + } + + private static byte[] embed(byte[] code, int offset, Random random) { + byte[] page = new byte[offset + code.length + random.nextInt(70)]; + random.nextBytes(page); + System.arraycopy(code, 0, page, offset, code.length); + return page; + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCovariancePCA.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCovariancePCA.java new file mode 100644 index 000000000..729ae5f8d --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHCovariancePCA.java @@ -0,0 +1,145 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.SingularValueDecomposition; +import org.junit.Test; +import java.lang.reflect.InvocationTargetException; +import java.util.Random; +import static org.junit.Assert.*; + +/** Covariance PCA against independent SVD subspaces and direct-SVD fallback cases. */ +public class TestASHCovariancePCA { + @Test public void principalAxesAndInputArePreserved() { + double[] x = {3, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 1, 0, 0}; + double[] copy = x.clone(), basis = ASHCovariancePCA.projection(x, 5, 3, 2); + assertNotNull(basis); + assertOrthonormal(basis, 3, 2); + assertEquals(1.0, Math.abs(basis[0]), 1e-12); + assertEquals(1.0, Math.abs(basis[4]), 1e-12); + assertArrayEquals(copy, x, 0); + } + + @Test public void retainedSubspaceMatchesIndependentDirectSvd() { + Random random = new Random(103); + for (int[] shape : new int[][] {{13, 5, 2}, {31, 7, 4}, {21, 3, 1}}) { + int rows = shape[0], dims = shape[1], latent = shape[2]; + double[] x = new double[rows * dims]; + double[][] matrix = new double[rows][dims]; + for (int r = 0; r < rows; r++) for (int c = 0; c < dims; c++) + x[r + c * rows] = matrix[r][c] = random.nextGaussian(); + double[] copy = x.clone(), basis = ASHCovariancePCA.projection(x, rows, dims, latent); + assertNotNull(basis); + var v = new SingularValueDecomposition(new Array2DRowRealMatrix(matrix, false)).getV(); + assertOrthonormal(basis, dims, latent); + // Compare projectors, independent of eigenvector signs or rotations. + for (int r = 0; r < dims; r++) for (int c = 0; c < dims; c++) { + double actual = 0, expected = 0; + for (int j = 0; j < latent; j++) { + actual += basis[r + j * dims] * basis[c + j * dims]; + expected += v.getEntry(r, j) * v.getEntry(c, j); + } + assertEquals(expected, actual, 1e-10); + } + assertArrayEquals(copy, x, 0); + } + } + + @Test public void uncertainRankAndBoundaryUseDirectSvd() throws Exception { + double[][] inputs = {new double[15], new double[] {3,0,0, 0,0,0, 0,0,0}, + new double[] {3,0,0, 0,1,0, 0,0,1}}; + for (double[] x : inputs) { + int rows = x.length / 3; + assertNull(ASHCovariancePCA.projection(x, rows, 3, 2)); + assertOrthonormal(basis(x, rows, 3, 2), 3, 2); + } + } + + @Test public void wideInputsRetainNullSpaceCompletion() throws Exception { + double[] x = {3, 0, 4, 0, 0, 0, 0, 0}; + double[] copy = x.clone(); + assertNull(ASHCovariancePCA.projection(x, 2, 4, 3)); + assertOrthonormal(basis(x, 2, 4, 3), 4, 3); + assertArrayEquals(copy, x, 0); + } + + @Test public void illConditionedRetainedTailUsesDirectSvd() throws Exception { + double[] x = {1,0,0, 0,1e-10,0, 0,0,1e-15}; + assertNull(ASHCovariancePCA.projection(x, 3, 3, 2)); + assertOrthonormal(basis(x, 3, 3, 2), 3, 2); + } + + @Test public void repeatedEigenvaluesWithinRetainedSubspaceAreAllowed() { + double[] x = {2,0,0, 0,2,0, 0,0,1}; + for (int latent : new int[] {2, 3}) { + double[] basis = ASHCovariancePCA.projection(x, 3, 3, latent); + assertNotNull(basis); + assertOrthonormal(basis, 3, latent); + for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) { + double product = 0; + for (int j = 0; j < latent; j++) product += basis[r + j * 3] * basis[c + j * 3]; + assertEquals(r == c && r < latent ? 1 : 0, product, 1e-12); + } + } + } + + @Test public void discardedNullDirectionsDoNotForceFallback() { + double[] x = {3,0,0, 0,2,0, 0,0,0}; + double[] basis = ASHCovariancePCA.projection(x, 3, 3, 2); + assertNotNull(basis); + assertOrthonormal(basis, 3, 2); + assertEquals(1, Math.abs(basis[0]), 1e-12); + assertEquals(1, Math.abs(basis[4]), 1e-12); + } + + @Test public void oneDimensionalInputHasACompleteBasis() { + double[] x = {0, 2, -3}, copy = x.clone(); + double[] basis = ASHCovariancePCA.projection(x, 3, 1, 1); + assertNotNull(basis); + assertOrthonormal(basis, 1, 1); + assertArrayEquals(copy, x, 0); + } + + @Test public void invalidShapesAndNonFiniteInputAreRejected() { + assertThrows(NullPointerException.class, () -> ASHCovariancePCA.projection(null, 1, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[1], 0, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[1], 1, 1, 0)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[1], 1, 1, 2)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[2], 1, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[0], 50000, 50000, 1)); + for (double invalid : new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.MAX_VALUE}) + assertThrows(IllegalArgumentException.class, () -> ASHCovariancePCA.projection(new double[] {invalid}, 1, 1, 1)); + } + + private static double[] basis(double[] x, int rows, int dims, int latent) throws Exception { + var method = AsymmetricHashing.class.getDeclaredMethod("computePcaBasis", double[].class, int.class, int.class, int.class); + method.setAccessible(true); + try { return (double[]) method.invoke(null, x, rows, dims, latent); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + private static void assertOrthonormal(double[] basis, int dims, int latent) { + for (int i = 0; i < latent; i++) for (int j = 0; j < latent; j++) { + double dot = 0; + for (int k = 0; k < dims; k++) dot += basis[k + i * dims] * basis[k + j * dims]; + assertEquals(i == j ? 1 : 0, dot, 1e-11); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHEncodedNorms.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHEncodedNorms.java new file mode 100644 index 000000000..762d7fbe9 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHEncodedNorms.java @@ -0,0 +1,83 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.disk.SimpleWriter; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * ASH knows the largest length of the vectors it holds codes for, however they were encoded: by the same model + * instance, or by another instance of the model whose codes are wrapped afterwards. + */ +public class TestASHEncodedNorms { + @Test + public void codesRecoverTheLengthsTheyEncode() throws Exception { + var random = new Random(23); + int n = 400, dim = 48; + List> base = new ArrayList<>(); + float maxNorm = 0; + for (int i = 0; i < n; i++) { + var v = TestUtil.randomVector(random, dim); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + } + var ravv = new ListRandomAccessVectorValues(base, dim); + for (int bits : new int[] {1, 2, 3, 4, 9}) { + for (int landmarks : new int[] {1, 4}) { + String where = bits + " bits, " + landmarks + " landmarks"; + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 40 * bits, landmarks, bits); + // A second instance of the same model, saved before it encoded anything. + File file = File.createTempFile("ash-model", ".bin"); + file.deleteOnExit(); + try (var writer = new SimpleWriter(file.toPath())) { + ash.write(writer, OnDiskGraphIndex.CURRENT_VERSION); + } + AsymmetricHashing other; + try (var readers = new SimpleMappedReader.Supplier(file.toPath())) { + other = AsymmetricHashing.load(readers.get()); + } + var codes = new AsymmetricHashing.QuantizedVector[n]; + for (int i = 0; i < n; i++) { + codes[i] = ash.encode(base.get(i)); + float exact = VectorUtil.dotProduct(base.get(i), base.get(i)); + float fromCode = ash.encodedSquaredNorm(codes[i]); + assertEquals(where + " node " + i, exact, fromCode, exact * 2e-3f + 1e-4f); + } + assertEquals(where, maxNorm, ash.maxEncodedNorm(), maxNorm * 1e-5f); + // Wrapping the codes with the other instance counts them, never below their true lengths. + new ASHVectors(other, codes); + assertTrue(where + ": " + other.maxEncodedNorm(), other.maxEncodedNorm() >= maxNorm); + assertEquals(where, maxNorm, other.maxEncodedNorm(), maxNorm * 5e-3f); + } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHFloatOrthogonalizer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHFloatOrthogonalizer.java new file mode 100644 index 000000000..a7d11f322 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHFloatOrthogonalizer.java @@ -0,0 +1,104 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.SingularValueDecomposition; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.*; + +/** FP32 polar factors against an independent FP64 reference and degenerate shapes. */ +public class TestASHFloatOrthogonalizer { + @Test + public void reusedWorkspaceMatchesIndependentPolarFactor() { + Random random = new Random(77); + for (int[] shape : new int[][] {{1,1}, {5,3}, {3,5}, {7,7}}) { + int rows = shape[0], cols = shape[1]; + ASHFloatOrthogonalizer workspace = new ASHFloatOrthogonalizer(rows, cols); + double[] output = new double[rows * cols]; + for (int repeat = 0; repeat < 12; repeat++) { + double[] input = new double[output.length]; + double[][] rounded = new double[rows][cols]; + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) { + input[r + c * rows] = random.nextGaussian(); + rounded[r][c] = (float) input[r + c * rows]; + } + double[] copy = input.clone(); + var svd = new SingularValueDecomposition(new Array2DRowRealMatrix(rounded, false)); + var expected = svd.getU().multiply(svd.getVT()); + Arrays.fill(output, Double.NaN); + workspace.orthogonalize(input, output); + assertArrayEquals(copy, input, 0); + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) { + assertEquals(expected.getEntry(r, c), output[r + c * rows], 2e-5); + } + assertPartialIsometry(output, rows, cols); + } + } + } + + @Test + public void zeroAndRankDeficientInputsKeepAnOrthonormalCompletion() { + for (int[] shape : new int[][] {{1,1}, {5,3}, {3,5}, {7,7}}) { + int rows = shape[0], cols = shape[1]; + ASHFloatOrthogonalizer workspace = new ASHFloatOrthogonalizer(rows, cols); + double[] input = new double[rows * cols], output = new double[input.length]; + workspace.orthogonalize(input, output); + assertPartialIsometry(output, rows, cols); + for (int r = 0; r < rows; r++) input[r] = r + 1; + workspace.orthogonalize(input, output); + assertPartialIsometry(output, rows, cols); + } + } + + @Test + public void invalidInputsAreRejectedAndTheWorkspaceCanBeReusedAfterFailure() { + assertThrows(IllegalArgumentException.class, () -> new ASHFloatOrthogonalizer(0, 1)); + assertThrows(IllegalArgumentException.class, () -> new ASHFloatOrthogonalizer(1, -1)); + assertThrows(IllegalArgumentException.class, () -> new ASHFloatOrthogonalizer(50000, 50000)); + ASHFloatOrthogonalizer workspace = new ASHFloatOrthogonalizer(2, 2); + double[] input = {1,0,0,1}, output = new double[4]; + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(null, output)); + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(input, null)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(new double[3], output)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, new double[3])); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, input)); + for (double invalid : new double[] {Double.NaN, Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, Double.MAX_VALUE}) { + assertThrows(IllegalArgumentException.class, + () -> workspace.orthogonalize(new double[] {1,invalid,0,1}, output)); + workspace.orthogonalize(input, output); + assertArrayEquals(input, output, 1e-6); + } + } + + private static void assertPartialIsometry(double[] matrix, int rows, int cols) { + int rank = Math.min(rows, cols); + for (int i = 0; i < rank; i++) for (int j = 0; j < rank; j++) { + double dot = 0; + for (int k = 0; k < Math.max(rows, cols); k++) { + dot += rows >= cols ? matrix[k + i * rows] * matrix[k + j * rows] + : matrix[i + k * rows] * matrix[j + k * rows]; + } + assertEquals(i == j ? 1 : 0, dot, 2e-5); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java new file mode 100644 index 000000000..99d2dfddf --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHGraphSearch.java @@ -0,0 +1,150 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.*; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.nio.file.Files; +import java.util.EnumMap; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.function.IntFunction; +import static org.junit.Assert.*; + +/** Search-level regression: raw negative dot products must not strand hierarchy descent. */ +public class TestASHGraphSearch { + @Test + public void negativeEntryReachesBaseLayerWithStandaloneAndFusedScoring() throws Exception { + String single = System.getProperty("jvector.ash.singleKernel"); + String block = System.getProperty("jvector.ash.blockKernel"); + try { + var provider = VectorizationProvider.getInstance(); + var vts = provider.getVectorTypeSupport(); + VectorFloat[] input = new VectorFloat[17]; + Random random = new Random(92201); + for (int n = 0; n < input.length; n++) { + input[n] = vts.createFloatVector(64); + for (int d = 0; d < 64; d++) input[n].set(d, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(input[n]); + } + var query = input[0].copy(); + for (int d = 0; d < query.length(); d++) query.set(d, -query.get(d)); + var values = MockVectorValues.fromValues(input); + var graph = new TestUtil.FullyConnectedGraphIndex(0, List.of(input.length, 1)) { + @Override public List maxDegrees() { return List.of(16, 0); } + @Override public int getDimension() { return 64; } + }; + var backend = provider.getVectorUtilSupport(); + for (int bits : new int[]{1, 2, 3, 4, 9}) { + // Forced SIMD must be available for this code format: offset-binary codes (3 and 5-9 bits) + // also need canonical LUT scoring, which only some backends provide. + boolean simd = backend.supportsAshLutScoring() + && (!AsymmetricHashing.usesOffsetBinaryCode(bits) || backend.supportsAshCanonicalLutScoring()); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var path = Files.createTempFile("ash-negative-entry-", ".index"); + try { + var suppliers = new EnumMap>(FeatureId.class); + try (var view = graph.getView(); var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(graph.maxDegree(), ash, 32)) + .with(new InlineVectors(64)).build()) { + suppliers.put(FeatureId.FUSED_ASH, n -> new FusedASH.State(view, encoded, n)); + suppliers.put(FeatureId.INLINE_VECTORS, n -> new InlineVectors.State(input[n])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); + var disk = OnDiskGraphIndex.load(reader, 0)) { + for (String mode : simd ? new String[]{"scalar", "auto", "simd"} : new String[]{"scalar", "auto"}) { + System.setProperty("jvector.ash.singleKernel", mode); + System.setProperty("jvector.ash.blockKernel", mode); + var raw = new ASHScorer(ash).scoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + assertTrue("Regression requires a negative entry score", raw.similarityTo(encoded.get(0)) < 0f); + var standalone = encoded.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + try (var searcher = new GraphSearcher(graph)) { + searcher.usePruning(false); + var scores = new DefaultSearchScoreProvider(standalone, + n -> VectorSimilarityFunction.DOT_PRODUCT.compare(query, input[n]), VectorSimilarityFunction.DOT_PRODUCT); + assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); + } + for (int n = 0; n < input.length; n++) { + assertEquals(Math.max(0f, (1f + raw.similarityTo(encoded.get(n))) / 2f), + standalone.similarityTo(n), 0.00005f); + } + try (var searcher = new GraphSearcher(disk)) { + searcher.usePruning(false); + var view = (OnDiskGraphIndex.View) searcher.getView(); + var fused = view.approximateScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT); + assertEquals(standalone.similarityTo(0), fused.similarityTo(0), 0.00005f); + var scores = new DefaultSearchScoreProvider(fused, + view.rerankerFor(query, VectorSimilarityFunction.DOT_PRODUCT), VectorSimilarityFunction.DOT_PRODUCT); + assertEquals(10, searcher.search(scores, 10, 10, 0f, 0f, Bits.ALL).getNodes().length); + fused.enableSimilarityToNeighbors(0); + var neighbors = view.getNeighborsIterator(0, 0); + for (int lane = 0; neighbors.hasNext(); lane++) { + assertEquals(standalone.similarityTo(neighbors.nextInt()), + fused.similarityToNeighbor(0, lane), 0.002f); + } + } + } + } + } finally { Files.deleteIfExists(path); } + } + } finally { + restore("jvector.ash.singleKernel", single); + restore("jvector.ash.blockKernel", block); + } + } + + @Test + public void dotProductSimilarityUsesTheExactScaleUnclamped() { + assertEquals(-0.125f, ASHScorer.toSimilarity(-1.25f), 0f); + assertEquals(0f, ASHScorer.toSimilarity(-1f), 0f); + assertEquals(0.375f, ASHScorer.toSimilarity(-0.25f), 0f); + assertEquals(0.5f, ASHScorer.toSimilarity(0f), 0f); + assertEquals(1f, ASHScorer.toSimilarity(1f), 0f); + assertEquals(3f, ASHScorer.toSimilarity(5f), 0f); + for (float dot : new float[] {-7.5f, -1.25f, 0f, 0.75f, 4f}) { + float a = 0.3f * dot, b = 0.7f * dot; + assertEquals(VectorSimilarityFunction.DOT_PRODUCT.compare(vector(a), vector(1f)) + - VectorSimilarityFunction.DOT_PRODUCT.compare(vector(b), vector(1f)), + ASHScorer.toSimilarity(a) - ASHScorer.toSimilarity(b), 1e-6f); + } + } + + private static VectorFloat vector(float value) { + var v = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(1); + v.set(0, value); + return v; + } + + private static void restore(String name, String value) { + if (value == null) System.clearProperty(name); else System.setProperty(name, value); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHInt8Neighborhood.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHInt8Neighborhood.java new file mode 100644 index 000000000..7c610a3b2 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHInt8Neighborhood.java @@ -0,0 +1,132 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** Offset-binary field-block neighborhoods scored with an INT8 query, in Java and natively. */ +public class TestASHInt8Neighborhood { + private static final int[] WIDTHS = {3, 5, 6, 7, 8, 9}; + + private static AsymmetricHashing.QuantizedVector randomCode(Random random, int dimension, int bits) throws Exception { + var constructor = AsymmetricHashing.QuantizedVector.class.getDeclaredConstructor( + float.class, float.class, byte.class, long[].class, byte[].class); + constructor.setAccessible(true); + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(dimension, bits)]; + for (int j = 0; j < dimension; j++) { + AsymmetricHashing.setOffsetBinaryField(code, j, bits, random.nextInt(1 << bits)); + } + return constructor.newInstance(0.5f + random.nextFloat(), random.nextFloat() - 0.5f, (byte) 0, + new long[0], code); + } + + @Test + public void javaInt8MatchesExactDotsAndNativeMatchesJava() throws Exception { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + Random random = new Random(20261111); + for (int bits : WIDTHS) { + for (int dimension : new int[]{1, 7, 8, 9, 63, 64, 65, 192, 383}) { + for (int blockSize : new int[]{8, 16, 32}) { + int capacity = 2 * blockSize + 3; + int blocks = FusedASHLayout.blocksPerNode(capacity, blockSize); + byte[] packed = new byte[FusedASHLayout.featureSize(capacity, dimension, bits, blockSize)]; + var vectors = new AsymmetricHashing.QuantizedVector[blocks * blockSize]; + for (int i = 0; i < vectors.length; i++) { + vectors[i] = randomCode(random, dimension, bits); + int offset = FusedASHLayout.blockOffset(i / blockSize, dimension, bits, blockSize); + FusedASHLayout.packQuantizedVector(packed, offset, i % blockSize, vectors[i], dimension, bits, blockSize); + } + float[] q = new float[dimension]; + double maxAbs = 0; + for (int j = 0; j < dimension; j++) { + q[j] = (float) random.nextGaussian(); + maxAbs = Math.max(maxAbs, Math.abs(q[j])); + } + var query = new ASHInt8Query(dimension, bits); + query.prepare(q); + float landmarkDot = random.nextFloat() - 0.5f; + float[] java = new float[capacity + 2]; + Arrays.fill(java, -7f); + double center = ((1 << bits) - 1) / 2.0; + for (int i = 0; i < capacity; i++) { + int offset = FusedASHLayout.blockOffset(i / blockSize, dimension, bits, blockSize); + int lane = i % blockSize; + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(dimension, bits)]; + FusedASHLayout.readLaneCode(packed, offset, lane, dimension, bits, blockSize, code); + assertArrayEquals("lane code bits=" + bits + " d=" + dimension, vectors[i].extraBits, code); + double exact = 0, valueSum = 0; + for (int j = 0; j < dimension; j++) { + double value = AsymmetricHashing.offsetBinaryField(code, 0, j, bits) - center; + exact += q[j] * value; + valueSum += Math.abs(value); + } + float dot = query.laneDot(packed, offset, lane, blockSize); + // Each rounded component is within half a step, maxAbs / 254, of the query. + assertEquals("bits=" + bits + " d=" + dimension, exact, dot, + maxAbs / 254 * valueSum * 1.001 + 1e-5 * Math.max(1, Math.abs(exact))); + // The packed header holds FP16 scale and offset. + java[i] = ASHScorer.toSimilarity( + FusedASHLayout.readScale(packed, offset, lane, dimension, bits, blockSize) * dot + + landmarkDot + FusedASHLayout.readOffset(packed, offset, lane, dimension, bits, blockSize)); + } + if (!backend.supportsAshInt8NeighborhoodScoring()) continue; + float[] nativeOut = new float[capacity + 2]; + Arrays.fill(nativeOut, -7f); + backend.ashScoreNeighborhoodInt8(packed, bits, query.units, blockSize, capacity, + query.digits, query.invScale, query.center, landmarkDot, nativeOut); + for (int i = 0; i < capacity; i++) { + // Exact integer sums; the FP32 finish may round differently (no FMA below AVX2). + assertEquals("native bits=" + bits + " d=" + dimension + " bs=" + blockSize + " lane=" + i, + java[i], nativeOut[i], 1e-5f * Math.max(1f, Math.abs(java[i]))); + } + assertEquals("Lanes past the capacity stay untouched", -7f, nativeOut[capacity], 0f); + } + } + } + } + + @Test + public void nativeRejectsNonzeroLandmarksAndBadShapes() throws Exception { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (!backend.supportsAshInt8NeighborhoodScoring()) return; + Random random = new Random(5); + int dimension = 24, bits = 5, blockSize = 8; + byte[] packed = new byte[FusedASHLayout.featureSize(blockSize, dimension, bits, blockSize)]; + for (int lane = 0; lane < blockSize; lane++) { + var v = randomCode(random, dimension, bits); + if (lane == 3) v.landmark = 1; + FusedASHLayout.packQuantizedVector(packed, 0, lane, v, dimension, bits, blockSize); + } + var query = new ASHInt8Query(dimension, bits); + query.prepare(new float[]{1, -2, 3, 0.5f, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 2, 2, 2, 2, 0, 0, 0, 1}); + float[] out = new float[blockSize]; + assertThrows(IllegalStateException.class, () -> backend.ashScoreNeighborhoodInt8(packed, bits, query.units, + blockSize, blockSize, query.digits, query.invScale, query.center, 0f, out)); + assertThrows(IllegalArgumentException.class, () -> backend.ashScoreNeighborhoodInt8(packed, 4, query.units, + blockSize, blockSize, query.digits, query.invScale, query.center, 0f, out)); + assertThrows(IndexOutOfBoundsException.class, () -> backend.ashScoreNeighborhoodInt8(packed, bits, + query.units + 1, blockSize, blockSize, query.digits, query.invScale, query.center, 0f, out)); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java new file mode 100644 index 000000000..5d25f1567 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHLutScoring.java @@ -0,0 +1,150 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.vector.ASHLutScoring; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import org.junit.Test; +import java.util.Arrays; +import java.util.Random; +import static org.junit.Assert.*; + +/** Independent decoded-component oracle; covers padding, offsets and SIMD lane boundaries. */ +public class TestASHLutScoring { + @Test + public void allSerializedHeadersAreFortyBits() throws Exception { + assertEquals(40, AsymmetricHashing.HEADER_BITS); + assertEquals(5, AsymmetricHashing.HEADER_BYTES); + for (int bits = 1; bits <= 9; bits++) { + for (int dimensions : new int[]{1, 33, 64, 769}) { + var vector = AsymmetricHashing.QuantizedVector.createEmpty(dimensions, bits); + vector.scale = 1.0003f; + vector.offset = -0.33333f; + vector.landmark = (byte) 255; + int bodyBytes = vector.binaryVector.length * Long.BYTES + vector.extraBits.length; + try (var writer = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(bodyBytes + 5, false)) { + vector.write(writer, dimensions, bits); + assertEquals(bodyBytes + 5, writer.position()); + assertEquals(bodyBytes + 5, AsymmetricHashing.QuantizedVector.serializedSizeBytes(dimensions, bits)); + var decoded = AsymmetricHashing.QuantizedVector.load( + new io.github.jbellis.jvector.disk.ByteBufferReader(writer.getWrittenData()), dimensions, bits); + assertEquals(255, decoded.landmark & 255); + assertEquals(vector.scale, decoded.scale, 0.001f); + assertEquals(vector.offset, decoded.offset, 0.001f); + } + if (FusedASHLayout.supportsBitsPerDimension(bits)) { + for (int blockSize : new int[]{8, 16, 32}) { + assertEquals(5 * blockSize, FusedASHLayout.blockHeaderBytes(blockSize)); + byte[] block = new byte[FusedASHLayout.blockBytes(dimensions, bits, blockSize)]; + FusedASHLayout.packQuantizedVector(block, 0, blockSize - 1, vector, dimensions, bits, blockSize); + assertEquals(255, FusedASHLayout.readLandmark(block, 0, blockSize - 1, dimensions, bits, blockSize)); + } + } + } + } + } + + @Test + public void packedBlocksMatchDecodedComponents() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (Boolean.getBoolean("jvector.test.requireAshSimd")) { + assertTrue("Test must execute a SIMD backend: " + backend.getClass(), backend.supportsAshLutScoring()); + assertTrue(backend.supportsAshProjectionScoring()); + } + Random random = new Random(87342); + for (int bits : new int[]{1, 2, 3, 4, 5, 8, 9}) { + boolean offsetBinary = bits != 1 && bits != 2 && bits != 4; + // Offset-binary entries carry weights up to 2^(b-1); scale the FP32 tolerance with them. + float widthScale = offsetBinary ? (1 << bits) : 1; + for (int d : new int[]{1, 2, 3, 4, 7, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 129, 384, 768, 769}) { + int groups = FusedASHLayout.codeGroups(d, bits); + float[] q = new float[d]; + for (int i = 0; i < d; i++) q[i] = random.nextFloat() * 2 - 1; + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(q, d, bits, lut); + for (int size : new int[]{8, 16, 32}) { + int offset = 7; + byte[] packed = new byte[offset + FusedASHLayout.nibbleBlockBodyBytes(d, bits, size)]; + random.nextBytes(packed); // Padding is deliberately nonzero. + float[] expected = new float[size]; + for (int lane = 0; lane < size; lane++) { + byte[] canonical = new byte[FusedASHLayout.canonicalCodeBytes(d, bits)]; + for (int g = 0; g < groups; g++) { + int nibble = (lane + g) & 15; // Exercise every code, including both signs. + FusedASHLayout.setPackedNibble(packed, offset, lane, g, size, nibble); + FusedASHLayout.setFlatNibble(canonical, g, nibble); + } + for (int i = 0; i < d; i++) { + if (offsetBinary) { + // Offset-binary: field i is bits [i*b, (i+1)*b), value field - (2^b - 1)/2. + int field = 0; + for (int k = 0; k < bits; k++) { + int bit = i * bits + k; + field |= ((canonical[bit >>> 3] >>> (bit & 7)) & 1) << k; + } + expected[lane] += q[i] * (field - ((1 << bits) - 1) / 2f); + continue; + } + int field = (canonical[i * bits / 8] & 255) >>> (i * bits % 8); + int sign = 1 << (bits - 1); + float magnitude = bits == 1 ? 1 : (field & (sign - 1)) + 0.5f; + expected[lane] += q[i] * ((field & sign) == 0 ? -magnitude : magnitude); + } + if (offsetBinary) { + assertEquals("canonical LUT bits=" + bits + " d=" + d, expected[lane], + backend.ashCanonicalLutDot(canonical, groups, lut), tolerance(expected[lane]) * widthScale); + } else if (bits != 1) { + assertEquals("single bits=" + bits + " d=" + d, expected[lane], + backend.usesAshProjectionTuning() + ? backend.ashProjectionDotTuned(q, canonical, d, bits) + : backend.ashProjectionDot(q, canonical, d, bits), tolerance(expected[lane])); + } + } + for (int start = 0; start <= size; start++) { + for (int count = 0; count <= size - start; count++) { + float[] actual = new float[size + 4]; + float[] scalar = new float[size + 4]; + Arrays.fill(actual, Float.NaN); + backend.ashLutScore(packed, offset, groups, size, start, count, lut, actual, 2); + ASHLutScoring.score(packed, offset, groups, size, start, count, lut, scalar, 2); + assertTrue(Float.isNaN(actual[1])); + assertTrue(Float.isNaN(actual[count + 2])); + for (int i = 0; i < count; i++) { + String label = "bits=" + bits + " d=" + d + " size=" + size + " lane=" + (start + i); + assertEquals(label, expected[start + i], actual[2 + i], tolerance(expected[start + i]) * widthScale); + assertEquals(label, expected[start + i], scalar[2 + i], tolerance(expected[start + i]) * widthScale); + } + } + } + } + } + } + } + + @Test + public void rejectsInvalidBounds() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashLutScore(new byte[8], 0, 2, 8, 7, 2, new float[32], new float[2], 0)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashLutScore(new byte[7], 0, 2, 8, 0, 1, new float[32], new float[1], 0)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashLutScore(new byte[8], 0, 2, 8, 0, 1, new float[31], new float[1], 0)); + } + + private static float tolerance(float value) { return 0.0003f + Math.abs(value) * 0.00002f; } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java new file mode 100644 index 000000000..803f49688 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHMemoryAccounting.java @@ -0,0 +1,467 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.Feature; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; +import io.github.jbellis.jvector.util.RamUsageEstimator; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.BlockRealMatrix; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.Random; +import java.util.concurrent.Executors; +import java.util.function.IntFunction; + +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +/** Retained ownership and lazy-cache deltas; all sizes follow the running JVM's layout. */ +public class TestASHMemoryAccounting { + private static volatile long accountingResult; + + private static AsymmetricHashing model(int bits, int landmarks) throws Exception { + var type = VectorizationProvider.getInstance().getVectorTypeSupport(); + VectorFloat[] values = new VectorFloat[97]; + Random random = new Random(87614); + for (int n = 0; n < values.length; n++) { + values[n] = type.createFloatVector(64); + for (int j = 0; j < 64; j++) values[n].set(j, random.nextFloat() - .5f); + VectorUtil.l2normalize(values[n]); + } + return AsymmetricHashing.initialize(MockVectorValues.fromValues(values), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, landmarks, bits); + } + + private static AsymmetricHashing.QuantizedVector[] codes(AsymmetricHashing ash, int count) { + var result = new AsymmetricHashing.QuantizedVector[count]; + for (int i = 0; i < count; i++) { + result[i] = AsymmetricHashing.QuantizedVector.createEmpty(ash.quantizedDim, ash.bitsPerDimension); + result[i].scale = 1f; result[i].offset = 0f; + result[i].landmark = (byte)(i % ash.landmarkCount); + } + return result; + } + + private static Object field(Object object, String name) throws Exception { + Field f = object.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(object); + } + + private static long pagesShallow(Object[] pages) { + return pages == null ? 0 : RamUsageEstimator.shallowSizeOf(pages); + } + + private static long blockBytes(byte[][] blocks) { + long bytes = RamUsageEstimator.shallowSizeOf(blocks); + for (byte[] block : blocks) bytes += RamUsageEstimator.sizeOf(block); + return bytes; + } + + @Test + public void canonicalVectorsChargeHeapObjectsAndArrayHeaders() { + for (int bits : new int[]{1,2,3,4,9}) for (int dims : new int[]{1,33,384,1536}) { + var code = AsymmetricHashing.QuantizedVector.createEmpty(dims, bits); + long expected = RamUsageEstimator.shallowSizeOf(code) + + RamUsageEstimator.shallowSizeOf(code.binaryVector) + + RamUsageEstimator.shallowSizeOf(code.extraBits); + assertEquals(expected, code.ramBytesUsed()); + assertTrue(code.ramBytesUsed() > AsymmetricHashing.QuantizedVector.serializedSizeBytes(dims, bits)); + // Capacity, including padding beyond the logical code, remains retained. + code.binaryVector = Arrays.copyOf(code.binaryVector, code.binaryVector.length + 2); + code.extraBits = Arrays.copyOf(code.extraBits, code.extraBits.length + 3); + assertEquals(RamUsageEstimator.shallowSizeOf(code) + + RamUsageEstimator.sizeOf(code.binaryVector) + + RamUsageEstimator.sizeOf(code.extraBits), code.ramBytesUsed()); + } + } + + @Test + public void collectionsChargeVectorsHeadersAndModelExactlyOnce() throws Exception { + for (int bits : new int[]{1,2,3,4,9}) { + var ash = model(bits, 2); + var empty = new ASHVectors(ash, codes(ash, 0)); + for (int count : new int[]{1,17,1024}) { + var data = codes(ash, count); + var vectors = new ASHVectors(ash, data); + // Code pages plus decoded headers, with no per-vector object or array. + var store = (ASHCodes) field(vectors, "codes"); + var none = (ASHCodes) field(empty, "codes"); + long expectedDelta = pagesShallow(store.signPages) + pagesShallow(store.bodyPages) + - pagesShallow(none.signPages) - pagesShallow(none.bodyPages) + + 2*(RamUsageEstimator.sizeOf(new float[count])-RamUsageEstimator.sizeOf(new float[0])) + + RamUsageEstimator.sizeOf(new byte[count])-RamUsageEstimator.sizeOf(new byte[0]); + if (store.signPages != null) for (long[] page : store.signPages) expectedDelta += RamUsageEstimator.sizeOf(page); + if (store.bodyPages != null) for (byte[] page : store.bodyPages) expectedDelta += RamUsageEstimator.sizeOf(page); + assertEquals(expectedDelta, vectors.ramBytesUsed()-empty.ramBytesUsed()); + long payload = (long) count * (store.signWords * Long.BYTES + store.bodyBytes + ASHCodes.HEADER_RAM_BYTES); + assertTrue("bits=" + bits + " count=" + count + " must not exceed its payload by more than page and" + + " array headers", vectors.ramBytesUsed() - empty.ramBytesUsed() <= payload + 256); + long bytes = vectors.ramBytesUsed(); + for (int i=0;i<10;i++) assertEquals(bytes, vectors.ramBytesUsed()); + assertEquals(bytes, vectors.reorderByLandmarkFast().vectors.ramBytesUsed()); + } + } + } + + @Test + public void sharedInputVectorsAreCopiedAndChargedPerOrdinal() throws Exception { + var ash = model(2, 1); + var data = codes(ash, 2); + var separate = new ASHVectors(ash, data); + data[1] = data[0]; + assertEquals(separate.ramBytesUsed(), new ASHVectors(ash, data).ramBytesUsed()); + } + + @Test + public void oversizedInputArraysAreNotRetained() throws Exception { + var ash = model(4, 1); + var code = codes(ash, 1)[0]; + code.extraBits = new byte[8192]; // Valid oversized capacity; only its code prefix is stored. + var data = new AsymmetricHashing.QuantizedVector[300_000]; + Arrays.fill(data, code); + var vectors = new ASHVectors(ash, data); + var store = (ASHCodes) field(vectors, "codes"); + assertTrue((long)data.length*code.ramBytesUsed() > Integer.MAX_VALUE); + long payload = (long) data.length * (store.bodyBytes + ASHCodes.HEADER_RAM_BYTES); + assertTrue(vectors.ramBytesUsed() - ash.ramBytesUsed() < payload + (1 << 16)); + assertEquals(code.extraBits.length, 8192); + assertEquals(store.bodyBytes, vectors.get(7).extraBits.length); + } + + @Test + public void productionCodeLayoutsRetainLittleMoreThanTheirSerializedSize() { + for (int bits : new int[]{2,4}) for (int dimension : new int[]{384,512,768,1536}) { + int vectors = 100_000; + var codes = ASHCodes.allocate(vectors, dimension, bits); + // Per vector: the code body, plus 4 bytes because the two binary16 headers are kept as floats. + long perVector = AsymmetricHashing.QuantizedVector.serializedSizeBytes(dimension, bits) + - AsymmetricHashing.HEADER_BYTES + ASHCodes.HEADER_RAM_BYTES; + long overhead = codes.ramBytesUsed() - vectors * perVector; + assertTrue("bits=" + bits + " dims=" + dimension + " overhead=" + overhead, + overhead >= 0 && overhead < 64L * 1024); + } + } + + @Test + public void allLandmarkBackingArraysAreIncluded() throws Exception { + var one = model(2, 1); var four = model(2, 4); + long expected = RamUsageEstimator.shallowSizeOf(four.landmarks)-RamUsageEstimator.shallowSizeOf(one.landmarks); + for (int c=1;c<4;c++) expected += four.landmarks[c].ramBytesUsed(); + expected += 2*(RamUsageEstimator.sizeOf(new float[4])-RamUsageEstimator.sizeOf(new float[1])); + expected += RamUsageEstimator.shallowSizeOf(four.landmarkProj)-RamUsageEstimator.shallowSizeOf(one.landmarkProj); + expected += 3*RamUsageEstimator.sizeOf(new float[33]); + assertEquals(expected, four.ramBytesUsed()-one.ramBytesUsed()); + } + + @Test + public void queryRepresentationsAreChargedOnlyAfterCreationAndOnlyOnce() throws Exception { + String previous = System.getProperty("jvector.ash.queryProjection"); + try { + for (String mode : new String[]{"fp32", "auto"}) { + System.setProperty("jvector.ash.queryProjection", mode); + var ash = model(2, 1); + long before = ash.ramBytesUsed(); + assertNull(field(ash.stiefelTransform, "queryProjection")); + assertNull(field(ash.stiefelTransform, "queryBatches")); + float[] input = new float[64], output = new float[33]; + ash.stiefelTransform.projectQuery(input, output); + var primary = (io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection) + field(ash.stiefelTransform, "projection"); + var query = (io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection) + field(ash.stiefelTransform, "queryProjection"); + if (mode.equals("fp32")) assertSame(primary, query); + if (mode.equals("auto") && Boolean.getBoolean("jvector.test.requireFp16")) { + assertNotSame(primary, query); + assertTrue(query.ramBytesUsed() >= 64L*33*Short.BYTES); + } + long added = (long) field(ash.stiefelTransform, "queryProjectionHeapBytes") + + (query == primary ? 0 : query.ramBytesUsed()); + var batches = field(ash.stiefelTransform, "queryBatches"); + if (query.supportsBatchProjection()) assertNotNull(batches); + else assertNull(batches); + assertEquals(before+added, ash.ramBytesUsed()); + ash.stiefelTransform.projectQuery(input, output); + assertEquals(before+added, ash.ramBytesUsed()); + } + } finally { + if (previous == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", previous); + } + } + + @Test + public void blockCachesChargeAllocatedCapacityIncludingPaddedTails() throws Exception { + for (int bits : new int[]{2,4}) { + var ash = model(bits, 1); var vectors = new ASHVectors(ash, codes(ash, 17)); + for (int block : new int[]{8,16,32}) { + long before = vectors.ramBytesUsed(); + byte[][] data = vectors.symmetricProjectionBlocks(block); + long added = vectors.ramBytesUsed()-before; + assertTrue(added >= blockBytes(data)); + assertTrue("Small conservative map allowance only", added <= blockBytes(data)+512); + assertSame(data, vectors.symmetricProjectionBlocks(block)); + assertEquals(before+added, vectors.ramBytesUsed()); + } + } + } + + @Test + public void concurrentCacheCreationIsChargedOnceAndFailuresDoNotPublish() throws Exception { + var ash = model(4, 1); var data = codes(ash, 37); + var serial = new ASHVectors(ash, data); var parallel = new ASHVectors(ash, data); + for (int block : new int[]{8,16,32}) serial.symmetricProjectionBlocks(block); + var pool = Executors.newFixedThreadPool(8); + try { + var jobs = new ArrayList>(); + for (int i=0;i<32;i++) jobs.add(() -> { + for (int block : new int[]{8,16,32}) parallel.symmetricProjectionBlocks(block); + return null; + }); + for (var result : pool.invokeAll(jobs)) result.get(); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(30, java.util.concurrent.TimeUnit.SECONDS)); + } + assertEquals(serial.ramBytesUsed(), parallel.ramBytesUsed()); + // A vector without a complete code is rejected when the collection is built. + var bad = codes(ash, 17); bad[0].extraBits = new byte[0]; + assertThrows(IllegalArgumentException.class, () -> new ASHVectors(ash, bad)); + } + + @Test + public void symmetricCachesAreChargedWithoutCountingTheirSharedModelAgain() throws Exception { + for (int bits : new int[]{1,2,4}) { + var ash = model(bits, 2); var vectors = new ASHVectors(ash, codes(ash, 17)); + long before = vectors.ramBytesUsed(); + vectors.diversityFunctionFor(0, VectorSimilarityFunction.DOT_PRODUCT); + Object symmetric = field(vectors, "diversityScorer"); + float[][] dots = (float[][]) field(symmetric, "landmarkDots"); + long expected = RamUsageEstimator.shallowSizeOf(symmetric)+RamUsageEstimator.shallowSizeOf(dots); + for (float[] row : dots) expected += RamUsageEstimator.sizeOf(row); + expected += RamUsageEstimator.shallowSizeOf((float[])field(symmetric, "centeredOffsets")); + assertEquals(expected, vectors.ramBytesUsed()-before); + long after = vectors.ramBytesUsed(); + vectors.diversityFunctionFor(1, VectorSimilarityFunction.DOT_PRODUCT); + assertEquals(after, vectors.ramBytesUsed()); + } + } + + @Test + public void oneBitSingleScoringAddsNoCopyAndBlockLayoutIsCharged() throws Exception { + assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshMaskedLoad()); + var ash = model(1, 1); var vectors = new ASHVectors(ash, codes(ash, 17)); + long ownBefore = vectors.ramBytesUsed() - ash.ramBytesUsed(); + // Single-vector SIMD scoring reads the stored sign pages in place. Query preparation may + // initialize provider weights in the model, so compare the collection's own bytes. + vectors.scoreFunctionFor(ash.landmarks[0], VectorSimilarityFunction.DOT_PRODUCT).similarityTo(3); + assertEquals(ownBefore, vectors.ramBytesUsed() - ash.ramBytesUsed()); + var query = ash.landmarks[0]; + for (int block : new int[]{8,16,8}) { + vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, block); + long[] packed = (long[])field(field(vectors, "packedBits"), "words"); + // Query preparation may initialize provider weights; subtract model growth. + long own = vectors.ramBytesUsed()-ash.ramBytesUsed(); + assertTrue(packed != null); + assertTrue(own >= RamUsageEstimator.sizeOf(packed)); + long stable = vectors.ramBytesUsed(); + vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, block); + assertEquals(stable, vectors.ramBytesUsed()); + } + } + + @Test + public void fusedAshSourcesChargeTheirOwnedVectorStorage() throws Exception { + var ash = model(2, 1); var encoded = new ASHVectors(ash, codes(ash, 97)); + var graph = new TestUtil.RandomlyConnectedGraphIndex(97, 8, new Random(9182)); + var path = Files.createTempFile("ash-retained-source-", ".index"); + try { + var suppliers = new EnumMap>(FeatureId.class); + try (var graphView = graph.getView(); var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(8, ash, 8)).with(new InlineVectors(64)).build()) { + suppliers.put(FeatureId.FUSED_ASH, node -> new FusedASH.State(graphView, encoded, node)); + suppliers.put(FeatureId.INLINE_VECTORS, node -> new InlineVectors.State(ash.landmarks[0])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); var loaded = OnDiskGraphIndex.load(reader, 0); + var view = loaded.getView()) { + var source = (FusedASH.FusedASHInlineSource)view.getInlineSourceFeatures().values().iterator().next(); + assertEquals(RamUsageEstimator.shallowSizeOf(source)+source.getVector().ramBytesUsed(), source.ramBytesUsed()); + } + } finally { Files.deleteIfExists(path); } + } + + + + @Test + public void queryBatchConfigurationIsValidatedAndDoesNotChangeEncodingOnlyMemory() throws Exception { + String property = "jvector.ash.queryBatchSize"; + String previous = System.getProperty(property); + try { + for (String invalid : new String[]{"", "invalid", "0", "-1", "2147483647", "2147483648"}) { + System.setProperty(property, invalid); + assertThrows(IllegalArgumentException.class, () -> + new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(4, 2))); + } + long encodingBytes = -1; + for (int capacity : new int[]{1,8,17}) { + System.setProperty(property, Integer.toString(capacity)); + var transform = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(4, 2)); + long before = (long) field(transform, "heapBytes"); + if (encodingBytes < 0) encodingBytes = before; + assertEquals("No query dispatcher is allocated by construction", encodingBytes, before); + transform.project(new float[4], new float[2]); + transform.projectBatch(new float[2][4], new float[2][2], 2); + assertEquals(before, (long) field(transform, "heapBytes")); + assertNull(field(transform, "queryProjection")); + assertNull(field(transform, "queryBatches")); + assertNull(transform.queryProjectionBatchHistogram()); + transform.projectQuery(new float[4], new float[2]); + if (capacity == 1) { + assertNull(field(transform, "queryBatches")); + assertNull(transform.queryProjectionBatchHistogram()); + } + } + System.clearProperty(property); + var defaults = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(4, 2)); + assertEquals(8, field(defaults, "queryBatchSize")); + } finally { + if (previous == null) System.clearProperty(property); else System.setProperty(property, previous); + } + } + + @Test + public void callerLedProjectionPreservesResultsAndDoesNotChangeEncodingPrecision() throws Exception { + String property = "jvector.ash.queryBatchSize"; + String old = System.getProperty(property); + try { + for (int batchSize : new int[]{2,8,17}) { + System.setProperty(property, Integer.toString(batchSize)); + var ash = model(4, 1); + float[][] inputs = new float[8][64], expected = new float[8][33]; + Random random = new Random(78342); + for (int i = 0; i < inputs.length; i++) { + for (int j = 0; j < inputs[i].length; j++) inputs[i][j] = random.nextFloat() - .5f; + ash.stiefelTransform.projectQuery(inputs[i], expected[i]); + } + var pool = Executors.newFixedThreadPool(8); + try { + var jobs = new ArrayList>(); + for (int i = 0; i < 8; i++) { + final int index = i; + jobs.add(() -> { + float[] actual = new float[33]; + for (int repeat = 0; repeat < 50; repeat++) { + ash.stiefelTransform.projectQuery(inputs[index], actual); + assertArrayEquals(expected[index], actual, 2e-6f); + } + return null; + }); + } + for (var result : pool.invokeAll(jobs)) result.get(); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(30, java.util.concurrent.TimeUnit.SECONDS)); + } + long projected = 0; + long[] histogram = ash.stiefelTransform.queryProjectionBatchHistogram(); + if (histogram != null) { + for (int i = 1; i < histogram.length; i++) projected += i * histogram[i]; + assertEquals(408, projected); + } + float[] encodedProjection = new float[33]; + ash.stiefelTransform.project(inputs[0], encodedProjection); + for (int row = 0; row < 33; row++) { + double value = 0; + for (int col = 0; col < 64; col++) + value += (double) ash.stiefelTransform.AFloat[row][col] * inputs[0][col]; + assertEquals(value, encodedProjection[row], 2e-6); + } + } + } finally { + if (old == null) System.clearProperty(property); else System.setProperty(property, old); + } + } + + @Test + public void repeatedModelAndVectorAccountingDoesNotAllocate() throws Exception { + java.lang.management.ThreadMXBean threadBean = java.lang.management.ManagementFactory.getThreadMXBean(); + assumeTrue(threadBean instanceof com.sun.management.ThreadMXBean); + com.sun.management.ThreadMXBean bean = (com.sun.management.ThreadMXBean) threadBean; + assumeTrue(bean.isThreadAllocatedMemorySupported()); + if (!bean.isThreadAllocatedMemoryEnabled()) bean.setThreadAllocatedMemoryEnabled(true); + + var ash = model(4, 1); + var vectors = new ASHVectors(ash, codes(ash, 17)); + ash.stiefelTransform.projectQuery(new float[64], new float[33]); + vectors.symmetricProjectionBlocks(8); + vectors.diversityFunctionFor(0, VectorSimilarityFunction.DOT_PRODUCT); + + // Exercise exactly the measured call site before reading the allocation counter. + for (int i = 0; i < 100; i++) accountingResult = readAccounting(ash, vectors, 2048); + // A JIT deoptimization inside a window can make the JVM's own VarHandle linkage allocate, so measure up to + // three windows and require one of them to be allocation-free. A real allocation shows up in every window. + long thread = Thread.currentThread().getId(); + long allocated = Long.MAX_VALUE; + for (int window = 0; window < 3 && allocated != 0; window++) { + long before = bean.getThreadAllocatedBytes(thread); + accountingResult = readAccounting(ash, vectors, 2048); + allocated = Math.min(allocated, bean.getThreadAllocatedBytes(thread) - before); + } + assertEquals("Polling retained ASH memory must not allocate", 0L, allocated); + } + + private static long readAccounting(AsymmetricHashing ash, ASHVectors vectors, int count) { + long bytes = 0; + for (int i = 0; i < count; i++) bytes += ash.ramBytesUsed() + vectors.ramBytesUsed(); + return bytes; + } + + @Test + public void denseAndBlockedTransformBackingIsMeasuredWithoutMatrixCopies() throws Exception { + for (int rows : new int[]{1,53,105}) { + int cols = Math.min(rows, 53); + var array = new AsymmetricHashing.StiefelTransform(new Array2DRowRealMatrix(rows, cols)); + var block = new AsymmetricHashing.StiefelTransform(new BlockRealMatrix(rows, cols)); + long arrayMatrix = RamUsageEstimator.shallowSizeOf(array.W) + + RamUsageEstimator.shallowSizeOf(((Array2DRowRealMatrix)array.W).getDataRef()); + for (double[] row : ((Array2DRowRealMatrix)array.W).getDataRef()) arrayMatrix += RamUsageEstimator.sizeOf(row); + Field blocksField = BlockRealMatrix.class.getDeclaredField("blocks"); blocksField.setAccessible(true); + double[][] blocks = (double[][])blocksField.get(block.W); + long blockMatrix = RamUsageEstimator.shallowSizeOf(block.W)+RamUsageEstimator.shallowSizeOf(blocks); + for (double[] data : blocks) blockMatrix += RamUsageEstimator.sizeOf(data); + assertEquals(blockMatrix-arrayMatrix, (long)field(block,"heapBytes")-(long)field(array,"heapBytes")); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHNeighborhoodScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHNeighborhoodScorer.java new file mode 100644 index 000000000..d339797cf --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHNeighborhoodScorer.java @@ -0,0 +1,59 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import java.util.Random; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Public scorer construction rejects invalid shapes before projection or native access. */ +public class TestASHNeighborhoodScorer { + @Test + public void rejectsWrongQueryDimensionsAndInvalidCapacity() throws Exception { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(14932); + VectorFloat[] input = new VectorFloat[ 3 ]; + for (int i = 0; i < input.length; i++) { + float[] values = new float[64]; + for (int d = 0; d < values.length; d++) + values[d] = random.nextFloat() - .5f; + input[i] = types.createFloatVector(values); + } + var ash = AsymmetricHashing.initialize(MockVectorValues.fromValues(input), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * 4, 1, 4); + assertThrows(IllegalArgumentException.class, + () -> new ASHNeighborhoodScorer(ash, types.createFloatVector(63), 32, 32)); + assertThrows(IllegalArgumentException.class, + () -> new ASHNeighborhoodScorer(ash, types.createFloatVector(65), 32, 32)); + assertThrows(IllegalArgumentException.class, () -> new ASHNeighborhoodScorer(ash, input[0], 0, 32)); + assertThrows(IllegalArgumentException.class, () -> new ASHNeighborhoodScorer(ash, input[0], 32, 31)); + assertThrows(ArithmeticException.class, () -> new ASHNeighborhoodScorer(ash, input[0], Integer.MAX_VALUE, 32)); + } + + @Test + public void layoutSizesFailOnOverflowInsteadOfWrapping() { + assertEquals(1_073_741_824, FusedASHLayout.codeGroups(Integer.MAX_VALUE, 2)); + assertEquals(67_108_864, FusedASHLayout.blocksPerNode(Integer.MAX_VALUE, 32)); + assertThrows(ArithmeticException.class, () -> FusedASHLayout.blockBodyBytes(Integer.MAX_VALUE, 4, 32)); + assertThrows(ArithmeticException.class, () -> FusedASHLayout.featureSize(Integer.MAX_VALUE, 512, 4, 32)); + assertThrows(IllegalArgumentException.class, () -> FusedASHLayout.blockOffset(-1, 33, 4, 32)); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java new file mode 100644 index 000000000..6a6011f31 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPackedBitsRace.java @@ -0,0 +1,111 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assume.assumeTrue; + +/** Concurrent first queries on a fresh 1-bit collection must score exactly like a single query. */ +public class TestASHPackedBitsRace { + private static final String BLOCK = "jvector.ash.blockKernel"; + private static final float TOLERANCE = 1e-5f; + + @Test + public void concurrentFirstBlockScorersSeeFullyPackedBits() throws Exception { + assumeTrue(VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshMaskedLoad()); + String old = System.getProperty(BLOCK); + System.setProperty(BLOCK, "simd"); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + var type = VectorizationProvider.getInstance().getVectorTypeSupport(); + int n = 20_000; + VectorFloat[] values = new VectorFloat[n]; + Random random = new Random(5512); + for (int i = 0; i < n; i++) { + values[i] = type.createFloatVector(64); + for (int j = 0; j < 64; j++) values[i].set(j, random.nextFloat() - .5f); + VectorUtil.l2normalize(values[i]); + } + var ash = AsymmetricHashing.initialize(MockVectorValues.fromValues(values), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 64, 1, 1); + var encoded = ash.encodeAll(MockVectorValues.fromValues(values), ForkJoinPool.commonPool()); + var codes = new AsymmetricHashing.QuantizedVector[n]; + for (int i = 0; i < n; i++) codes[i] = encoded.get(i); + VectorFloat query = values[7]; + + int[] sizes = {16, 32}; + float[][] expected = new float[sizes.length][]; + for (int s = 0; s < sizes.length; s++) { + expected[s] = score(new ASHVectors(ash, codes), query, sizes[s]); + } + + for (int round = 0; round < 40; round++) { + // Even rounds race two queries with the same block size, odd rounds with different sizes. + var vectors = new ASHVectors(ash, codes); + int a = 0, b = round % 2; + var barrier = new CyclicBarrier(2); + List> tasks = new ArrayList<>(); + for (int s : new int[]{a, b}) { + tasks.add(() -> { + barrier.await(); + return score(vectors, query, sizes[s]); + }); + } + List> results = pool.invokeAll(tasks); + // Vector API lane reductions do not fix their summation order, so an early (interpreted) and a later + // (compiled) scoring of the same data can differ by an ULP. A partial or wrong-size array is off by + // about 0.1, so this tolerance still catches the race by four orders of magnitude. + assertArrayEquals("round " + round, expected[a], results.get(0).get(), TOLERANCE); + assertArrayEquals("round " + round, expected[b], results.get(1).get(), TOLERANCE); + } + } finally { + pool.shutdownNow(); + if (old == null) System.clearProperty(BLOCK); else System.setProperty(BLOCK, old); + } + } + + private static float[] score(ASHVectors vectors, VectorFloat query, int blockSize) { + var scorer = vectors.blockScorerFor(query, VectorSimilarityFunction.DOT_PRODUCT, blockSize); + int n = vectors.count(); + float[] out = new float[n]; + float[] block = new float[blockSize]; + // Score from the last block backwards so a reader reaches unfilled blocks first. + for (int start = (n - 1) / blockSize * blockSize; start >= 0; start -= blockSize) { + int count = Math.min(blockSize, n - start); + scorer.scoreRange(start, count, block); + System.arraycopy(block, 0, out, start, count); + } + return out; + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPcaTraining.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPcaTraining.java new file mode 100644 index 000000000..44bd68f89 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHPcaTraining.java @@ -0,0 +1,77 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import org.junit.Test; + +import java.lang.reflect.InvocationTargetException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** Checks the PCA basis contract independently of subsequent ITQ rotations. */ +public class TestASHPcaTraining { + @Test + public void tallMatrixPreservesPrincipalAxes() throws Exception { + // Column-major diagonal matrix with two additional zero rows. + double[] x = {3, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 1, 0, 0}; + double[] vt = pca(x, 5, 3); + assertOrthonormal(vt, 3); + for (int axis = 0; axis < 3; axis++) { + assertEquals(1.0, Math.abs(vt[axis + axis * 3]), 1e-12); + } + assertEquals(3.0, x[0], 0.0); // LAPACK must only overwrite the scratch copy. + } + + @Test + public void wideRankDeficientMatrixIncludesNullSpace() throws Exception { + double[] vt = pca(new double[] {3, 0, 4, 0, 0, 0, 0, 0}, 2, 4); + assertEquals(16, vt.length); + assertOrthonormal(vt, 4); + assertEquals(0.6, Math.abs(vt[0]), 1e-12); + assertEquals(0.8, Math.abs(vt[4]), 1e-12); + assertEquals(0.0, vt[8], 1e-12); + assertEquals(0.0, vt[12], 1e-12); + } + + @Test + public void nonFiniteInputIsRejectedBeforeLapack() { + assertThrows(IllegalArgumentException.class, + () -> pca(new double[] {1, Double.NaN, 0, 1}, 2, 2)); + } + + private static double[] pca(double[] x, int rows, int cols) throws ReflectiveOperationException { + var method = AsymmetricHashing.class.getDeclaredMethod("computeNativeVt", double[].class, int.class, int.class); + method.setAccessible(true); + try { + return (double[]) method.invoke(null, x, rows, cols); + } catch (InvocationTargetException error) { + if (error.getCause() instanceof RuntimeException) throw (RuntimeException) error.getCause(); + throw error; + } + } + + private static void assertOrthonormal(double[] vt, int dimension) { + for (int i = 0; i < dimension; i++) { + for (int j = 0; j < dimension; j++) { + double dot = 0.0; + for (int c = 0; c < dimension; c++) dot += vt[i + c * dimension] * vt[j + c * dimension]; + assertEquals(i == j ? 1.0 : 0.0, dot, 1e-12); + } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java new file mode 100644 index 000000000..f61390f30 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHQueryPreparation.java @@ -0,0 +1,217 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.*; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.agrona.collections.Int2ObjectHashMap; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.util.EnumMap; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; +import java.util.function.IntFunction; + +import static org.junit.Assert.*; + +/** Query ownership, fallback formulas, and real on-disk scratch reuse. */ +public class TestASHQueryPreparation { + private static final VectorSimilarityFunction DOT = VectorSimilarityFunction.DOT_PRODUCT; + + private static VectorFloat[] inputs() { + var type = VectorizationProvider.getInstance().getVectorTypeSupport(); + var vectors = new VectorFloat[97]; + Random random = new Random(69381); + for (int n = 0; n < vectors.length; n++) { + vectors[n] = type.createFloatVector(64); + for (int d = 0; d < 64; d++) vectors[n].set(d, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(vectors[n]); + } + return vectors; + } + + private static AsymmetricHashing model(VectorFloat[] vectors, int bits) throws Exception { + return AsymmetricHashing.initialize(MockVectorValues.fromValues(vectors), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 2, bits); + } + + @Test + public void fastScanSkipsGenericTermsAndStandaloneScorersStayIndependent() throws Exception { + var vectors = inputs(); + for (int bits : new int[]{1, 2, 3, 4, 9}) { + var ash = model(vectors, bits); + var scorer = new ASHScorer(ash); + var first = scorer.precomputeQuery(vectors[0]); + boolean fastScan = bits == 2 || bits == 4; + // Every width folds the landmark term into its offsets, so only Aq and its sum are prepared. + assertEquals(ash.quantizedDim, first.qProj.length); + assertEquals(ash.landmarkCount, first.dotQMuByLandmark.length); + float[] projection = first.qProj.clone(); + float[] landmarkDots = first.dotQMuByLandmark.clone(); + // Publicly retained prepared state must never borrow another query's arrays. + var retained = bits == 1 || fastScan ? new ASHNeighborhoodScorer(ash, vectors[0], 8, 8) : null; + var second = scorer.precomputeQuery(vectors[1]); + assertNotSame(first.qProj, second.qProj); + assertArrayEquals(projection, first.qProj, 0f); + assertArrayEquals(landmarkDots, first.dotQMuByLandmark, 0f); + if (retained != null) { + float[] lut = retained.queryLut().clone(); + new ASHNeighborhoodScorer(ash, vectors[1], 8, 8); + assertArrayEquals(lut, retained.queryLut(), 0f); + } + } + } + + private static Object field(Object object, String name) throws Exception { + Field f = object.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(object); + } + + @Test + public void fusedScratchReusesArraysAndRejectsStaleDecoders() throws Exception { + var vectors = inputs(); + for (int bits : new int[]{1, 2, 3, 4, 9}) { + var ash = model(vectors, bits); + var encoded = ash.encodeAll(MockVectorValues.fromValues(vectors), ForkJoinPool.commonPool()); + int degree = 8, block = 8; + byte[] bytes = new byte[FusedASHLayout.featureSize(degree, ash.quantizedDim, bits, block)]; + for (int n = 0; n < degree; n++) FusedASHLayout.packForFused(bytes, 0, n, encoded.get(n), ash, block); + var source = new FusedASHDecoder.PackedNeighborhoods() { + public void readInto(int origin, byte[] dest) { System.arraycopy(bytes, 0, dest, 0, bytes.length); } + public int maxDegree() { return degree; } + public int featureSize() { return bytes.length; } + }; + var cache = new Int2ObjectHashMap(); + cache.put(0, () -> 0L); + var scratch = new FusedASHDecoder.QueryScratch(ash, degree, block); + Object[] storage = {field(scratch, "input"), field(scratch, "prepared"), field(scratch, "lut"), + field(scratch, "neighborhoodBytes"), field(scratch, "scores")}; + FusedASHDecoder previous = null; + for (int q = 0; q < 12; q++) { + var decoder = FusedASHDecoder.newDecoder(source, ash, cache, ignored -> encoded.get(0), + vectors[q], scratch, block, DOT); + var reference = new ASHScorer(ash).scalarScoreFunctionFor(vectors[q]); + assertEquals(ASHScorer.toSimilarity(reference.similarityTo(encoded.get(0))), decoder.similarityTo(0), 0.00005f); + decoder.enableSimilarityToNeighbors(1); + for (int lane = 0; lane < degree; lane++) assertEquals( + ASHScorer.toSimilarity(reference.similarityTo(encoded.get(lane))), decoder.similarityToNeighbor(1, lane), 0.002f); + // Re-entering a neighborhood for the same query is valid (search resume). + decoder.enableSimilarityToNeighbors(2); + decoder.enableSimilarityToNeighbors(1); + if (previous != null) { + var stale = previous; + assertThrows(IllegalStateException.class, () -> stale.similarityTo(0)); + assertThrows(IllegalStateException.class, () -> stale.enableSimilarityToNeighbors(1)); + assertThrows(IllegalStateException.class, () -> stale.similarityToNeighbor(1, 0)); + } + previous = decoder; + String[] names = {"input", "prepared", "lut", "neighborhoodBytes", "scores"}; + for (int n = 0; n < names.length; n++) assertSame(storage[n], field(scratch, names[n])); + } + var last = previous; + var wrong = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(63); + assertThrows(IllegalArgumentException.class, () -> FusedASHDecoder.newDecoder(source, ash, cache, + ignored -> encoded.get(0), wrong, scratch, block, DOT)); + assertThrows(NullPointerException.class, () -> FusedASHDecoder.newDecoder(source, ash, cache, + ignored -> encoded.get(0), null, scratch, block, DOT)); + // Input validation happens before invalidation or scratch mutation. + last.similarityTo(0); + var other = model(vectors, bits); + assertThrows(IllegalArgumentException.class, () -> FusedASHDecoder.newDecoder(source, other, cache, + ignored -> encoded.get(0), vectors[0], scratch, block, DOT)); + last.similarityTo(0); + String previousMode = System.getProperty("jvector.ash.singleKernel"); + try { + System.setProperty("jvector.ash.singleKernel", "invalid-mode"); + assertThrows(IllegalArgumentException.class, () -> FusedASHDecoder.newDecoder(source, ash, cache, + ignored -> encoded.get(0), vectors[0], scratch, block, DOT)); + // Failure after overwriting preparation invalidates the previous decoder. + assertThrows(IllegalStateException.class, () -> last.similarityTo(0)); + } finally { + if (previousMode == null) System.clearProperty("jvector.ash.singleKernel"); + else System.setProperty("jvector.ash.singleKernel", previousMode); + } + var recovered = FusedASHDecoder.newDecoder(source, ash, cache, ignored -> encoded.get(0), + vectors[0], scratch, block, DOT); + recovered.enableSimilarityToNeighbors(1); + recovered.similarityToNeighbor(1, 0); + } + } + + @Test + public void onDiskViewsOwnIndependentScratchOnSameThread() throws Exception { + var vectors = inputs(); + for (int bits : new int[]{1, 2, 3, 4, 9}) { + var ash = model(vectors, bits); + var encoded = ash.encodeAll(MockVectorValues.fromValues(vectors), ForkJoinPool.commonPool()); + var graph = new TestUtil.RandomlyConnectedGraphIndex(vectors.length, 8, new Random(9182)); + var path = Files.createTempFile("ash-query-scratch-", ".index"); + try { + var suppliers = new EnumMap>(FeatureId.class); + try (var graphView = graph.getView(); var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(8, ash, 8)).with(new InlineVectors(64)).build()) { + suppliers.put(FeatureId.FUSED_ASH, node -> new FusedASH.State(graphView, encoded, node)); + suppliers.put(FeatureId.INLINE_VECTORS, node -> new InlineVectors.State(vectors[node])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); var loaded = OnDiskGraphIndex.load(reader, 0); + var firstView = loaded.getView(); var secondView = loaded.getView()) { + var first = firstView.approximateScoreFunctionFor(vectors[0], DOT); + first.enableSimilarityToNeighbors(0); + float expected = first.similarityToNeighbor(0, 0); + var second = secondView.approximateScoreFunctionFor(vectors[1], DOT); + second.enableSimilarityToNeighbors(0); + assertEquals(expected, first.similarityToNeighbor(0, 0), 0f); + first.enableSimilarityToNeighbors(1); + first.enableSimilarityToNeighbors(0); + assertEquals(expected, first.similarityToNeighbor(0, 0), 0f); + Object firstScratch = field(firstView, "featureScratch"); + firstView.approximateScoreFunctionFor(vectors[2], DOT); + assertSame(firstScratch, field(firstView, "featureScratch")); + assertThrows(IllegalStateException.class, () -> first.similarityToNeighbor(0, 0)); + second.similarityToNeighbor(0, 0); + try (var searcher = new GraphSearcher(loaded)) { + var searchView = (OnDiskGraphIndex.View) searcher.getView(); + var approximate = searchView.approximateScoreFunctionFor(vectors[3], DOT); + var provider = new DefaultSearchScoreProvider(approximate, + searchView.rerankerFor(vectors[3], DOT), DOT); + approximate.enableSimilarityToNeighbors(0); + float original = approximate.similarityToNeighbor(0, 0); + assertEquals(5, searcher.search(provider, 5, 10, 0f, 0f, Bits.ALL).getNodes().length); + assertTrue(searcher.resume(5, 10).getNodes().length > 0); + approximate.enableSimilarityToNeighbors(0); + assertEquals(original, approximate.similarityToNeighbor(0, 0), 0f); + } + } + } finally { Files.deleteIfExists(path); } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java new file mode 100644 index 000000000..937727205 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHScoringDispatch.java @@ -0,0 +1,241 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.ByteBufferIndexWriter; +import io.github.jbellis.jvector.disk.ByteBufferReader; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.Feature; +import io.github.jbellis.jvector.graph.disk.feature.FeatureId; +import io.github.jbellis.jvector.graph.disk.feature.FusedASH; +import io.github.jbellis.jvector.graph.disk.feature.InlineVectors; +import io.github.jbellis.jvector.graph.disk.feature.FusedFeature; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.agrona.collections.Int2ObjectHashMap; +import org.junit.Test; +import java.util.Random; +import java.util.EnumMap; +import java.util.function.IntFunction; +import java.nio.file.Files; +import java.util.concurrent.ForkJoinPool; +import static org.junit.Assert.*; + +/** Exercises the production factories and decoder, not only their layout helpers. */ +public class TestASHScoringDispatch { + private static final String BLOCK = "jvector.ash.blockKernel"; + private static final String SINGLE = "jvector.ash.singleKernel"; + + @Test + public void projectionSimdHandlesPartialBlocks() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + if (!backend.supportsAshProjectionScoring()) return; + Random random = new Random(84621); + for (int bits : new int[]{2, 4}) { + for (int dimensions = 1; dimensions <= 748; dimensions++) { + float[] query = new float[dimensions]; + byte[] code = new byte[(dimensions * bits + 7) / 8]; + for (int i = 0; i < dimensions; i++) query[i] = random.nextFloat() - 0.5f; + random.nextBytes(code); + float expected = AsymmetricHashing.dotProjectionCode(query, code, dimensions, bits); + float actual; + if (bits == 2 && backend.supportsNativeAshCanonicalLutScoring()) { + int groups = FusedASHLayout.codeGroups(dimensions, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(query, dimensions, bits, lut); + actual = backend.ashCanonicalLutDot(code, groups, lut); + } else { + actual = backend.ashProjectionDot(query, code, dimensions, bits); + } + assertEquals("bits=" + bits + " dimensions=" + dimensions, expected, actual, 0.0002f); + } + } + } + + @Test + public void standaloneAndFusedUseSelectedKernels() throws Exception { + String oldBlock = System.getProperty(BLOCK), oldSingle = System.getProperty(SINGLE); + try { + var provider = VectorizationProvider.getInstance(); + boolean simd = provider.getVectorUtilSupport().supportsAshLutScoring(); + VectorFloat[] input = new VectorFloat[97]; + Random random = new Random(6234); + for (int n = 0; n < input.length; n++) { + input[n] = provider.getVectorTypeSupport().createFloatVector(64); + for (int i = 0; i < 64; i++) input[n].set(i, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(input[n]); + } + var values = MockVectorValues.fromValues(input); + for (int bits : new int[]{1, 2, 4}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 2, bits); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + // Reload canonical metadata/body bytes before exercising the new factories. + try (var writer = ByteBufferIndexWriter.create(1024 * 1024, false)) { + encoded.write(writer, OnDiskGraphIndex.CURRENT_VERSION); + encoded = ASHVectors.load(new ByteBufferReader(writer.getWrittenData())); + } + checkDiskGraph(encoded, input); + for (int q : new int[]{3, 68}) { + System.setProperty(SINGLE, "scalar"); + var reference = new ASHScorer(ash).scoreFunctionFor(input[q], VectorSimilarityFunction.DOT_PRODUCT); + float[] expected = new float[input.length]; + for (int i = 0; i < expected.length; i++) { + expected[i] = Math.max(0f, (1f + reference.similarityTo(encoded.get(i))) / 2f); + } + for (String mode : simd ? new String[]{"scalar", "auto", "simd"} : new String[]{"scalar", "auto"}) { + System.setProperty(BLOCK, mode); + System.setProperty(SINGLE, mode); + var single = encoded.scoreFunctionFor(input[q], VectorSimilarityFunction.DOT_PRODUCT); + for (int i = 0; i < expected.length; i++) assertEquals(expected[i], single.similarityTo(i), 0.00005f); + for (int size : new int[]{8, 16, 32}) { + var block = encoded.blockScorerFor(input[q], VectorSimilarityFunction.DOT_PRODUCT, size); + if (bits != 1) assertTrue(block.toString(), block.toString().contains( + mode.equals("scalar") || !simd ? "scalar ASH LUT" : "SIMD ASH LUT")); + for (int start : new int[]{0, 1, 7, 15, 31, 65, 96, 97}) { + int count = Math.min(32, input.length - start); + float[] actual = new float[count]; + block.scoreRange(start, count, actual); + for (int i = 0; i < count; i++) assertEquals(expected[start + i], actual[i], 0.00005f); + } + checkFused(ash, encoded, input[q], expected, size); + } + // The two-argument overload deliberately remains an independent scalar oracle. + float[] referenceBlock = new float[32]; + encoded.blockScorerFor(input[q], VectorSimilarityFunction.DOT_PRODUCT).scoreRange(0, 32, referenceBlock); + for (int i = 0; i < 32; i++) assertEquals(expected[i], referenceBlock[i], 0.00005f); + } + } + } + } finally { + restore(BLOCK, oldBlock); + restore(SINGLE, oldSingle); + } + } + + private static void checkDiskGraph(ASHVectors encoded, VectorFloat[] input) throws Exception { + var graph = new TestUtil.RandomlyConnectedGraphIndex(input.length, 37, new Random(9182)); + var path = Files.createTempFile("ash-simd-roundtrip-", ".index"); + try { + var builder = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(37, encoded.getCompressor(), 32)) + .with(new InlineVectors(input[0].length())); + var suppliers = new EnumMap>(FeatureId.class); + try (var graphView = graph.getView(); var writer = builder.build()) { + suppliers.put(FeatureId.FUSED_ASH, ordinal -> new FusedASH.State(graphView, encoded, ordinal)); + suppliers.put(FeatureId.INLINE_VECTORS, ordinal -> new InlineVectors.State(input[ordinal])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); + var loaded = OnDiskGraphIndex.load(reader, 0); + var view = loaded.getView()) { + var reference = encoded.scoreFunctionFor(input[3], VectorSimilarityFunction.DOT_PRODUCT); + var fused = view.approximateScoreFunctionFor(input[3], VectorSimilarityFunction.DOT_PRODUCT); + assertTrue(fused.toString(), fused instanceof FusedASHDecoder); + for (int origin : new int[]{0, 7, 63, 96}) { + fused.enableSimilarityToNeighbors(origin); + var neighbors = view.getNeighborsIterator(0, origin); + for (int lane = 0; neighbors.hasNext(); lane++) { + assertEquals(reference.similarityTo(neighbors.nextInt()), + fused.similarityToNeighbor(origin, lane), 0.002f); + } + } + } + } finally { Files.deleteIfExists(path); } + } + + private static void checkFused(AsymmetricHashing ash, ASHVectors vectors, + VectorFloat query, float[] expected, int size) { + int degree = 37; + byte[] bytes = new byte[FusedASHLayout.featureSize(degree, ash.quantizedDim, ash.bitsPerDimension, size)]; + for (int i = 0; i < degree; i++) { + int offset = FusedASHLayout.blockOffset(i / size, ash.quantizedDim, ash.bitsPerDimension, size); + var v = vectors.get(i); + FusedASHLayout.packQuantizedVector(bytes, offset, i % size, v, ash.quantizedDim, ash.bitsPerDimension, size); + } + int[] reads = {0}; + var source = new FusedASHDecoder.PackedNeighborhoods() { + public void readInto(int origin, byte[] dest) { + reads[0]++; + if (origin == 2) throw new IllegalStateException("Injected read failure"); + System.arraycopy(bytes, 0, dest, 0, bytes.length); + } + public int maxDegree() { return degree; } + public int featureSize() { return bytes.length; } + }; + var cache = new Int2ObjectHashMap(); + cache.put(0, () -> 0L); + var decoder = FusedASHDecoder.newDecoder(source, ash, cache, ignored -> vectors.get(0), query, + new byte[bytes.length], new float[degree], size, VectorSimilarityFunction.DOT_PRODUCT); + decoder.enableSimilarityToNeighbors(1); + for (int i = 0; i < degree; i++) assertEquals("fused bits=" + ash.bitsPerDimension + " lane=" + i, + expected[i], decoder.similarityToNeighbor(1, i), 0.002f); + assertEquals(expected[0], decoder.similarityTo(0), 0.00005f); + decoder.enableSimilarityToNeighbors(1); + assertEquals(1, reads[0]); + assertThrows(IllegalStateException.class, () -> decoder.enableSimilarityToNeighbors(2)); + assertThrows(IllegalArgumentException.class, () -> decoder.similarityToNeighbor(1, 0)); + decoder.enableSimilarityToNeighbors(1); + assertEquals(3, reads[0]); + } + + @Test + public void fusedGraphWith64LandmarksMatchesStandalone() throws Exception { + var provider = VectorizationProvider.getInstance(); + VectorFloat[] input = new VectorFloat[97]; + Random random = new Random(64256); + for (int n = 0; n < input.length; n++) { + input[n] = provider.getVectorTypeSupport().createFloatVector(128); + for (int d = 0; d < 128; d++) input[n].set(d, random.nextFloat() - 0.5f); + VectorUtil.l2normalize(input[n]); + } + var values = MockVectorValues.fromValues(input); + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 90, 64, 2); + var encoded = ash.encodeAll(values, ForkJoinPool.commonPool()); + var scorer = encoded.scoreFunctionFor(input[3], VectorSimilarityFunction.DOT_PRODUCT); + float[] expected = new float[input.length]; + for (int i = 0; i < input.length; i++) expected[i] = scorer.similarityTo(i); + for (int size : new int[]{8, 16, 32}) checkFused(ash, encoded, input[3], expected, size); + checkDiskGraph(encoded, input); + } + + @Test + public void forcedSimdCannotSilentlyFallBack() { + String old = System.getProperty(BLOCK); + try { + System.setProperty(BLOCK, "simd"); + if (VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshLutScoring()) { + assertTrue(new ASHLutKernel().toString().contains("SIMD")); + } else { + assertThrows(IllegalStateException.class, ASHLutKernel::new); + } + System.setProperty(BLOCK, "misspelled"); + assertThrows(IllegalArgumentException.class, ASHLutKernel::new); + } finally { restore(BLOCK, old); } + } + + private static void restore(String property, String value) { + if (value == null) System.clearProperty(property); else System.setProperty(property, value); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneScoring.java new file mode 100644 index 000000000..9dace5ba9 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneScoring.java @@ -0,0 +1,326 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Exercises standalone ASH scoring without a graph, an on-disk index, + * FusedASH, or YAML configuration. + * + * The projected dimension is held constant while bitsPerDimension changes. + * This isolates the multibit ASH encoder and ASHScorer paths. + */ +public class TestASHStandaloneScoring { + private static final int ORIGINAL_DIMENSION = 128; + private static final int PROJECTED_DIMENSION = 64; + private static final int VECTOR_COUNT = 1_024; + private static final int QUERY_COUNT = 64; + private static final int LANDMARK_COUNT = 1; + + @Test + public void testOneBitStandaloneScoring() throws Exception { + runStandaloneScoringTest(1); + } + + @Test + public void testTwoBitStandaloneScoring() throws Exception { + runStandaloneScoringTest(2); + } + + @Test + public void testFourBitStandaloneScoring() throws Exception { + runStandaloneScoringTest(4); + } + + @Test + public void testAllSupportedBitWidthsTogether() throws Exception { + var vectors = createNormalizedVectors( + VECTOR_COUNT, + ORIGINAL_DIMENSION, + 0x5eedL); + + double oneBitCorrelation = scoreCorrelation(vectors, 1); + double twoBitCorrelation = scoreCorrelation(vectors, 2); + double fourBitCorrelation = scoreCorrelation(vectors, 4); + + System.out.printf( + "Standalone ASH correlation: 1-bit=%.6f, 2-bit=%.6f, 4-bit=%.6f%n", + oneBitCorrelation, + twoBitCorrelation, + fourBitCorrelation); + + assertTrue( + "1-bit correlation is too low: " + oneBitCorrelation, + oneBitCorrelation > 0.20); + + assertTrue( + "2-bit correlation is too low: " + twoBitCorrelation, + twoBitCorrelation > 0.20); + + assertTrue( + "4-bit correlation is too low: " + fourBitCorrelation, + fourBitCorrelation > 0.20); + /* + * More stored bits should not catastrophically degrade scoring. + * The tolerance allows small variations from training and quantization. + */ + assertTrue( + "2-bit scoring degraded unexpectedly" + + ": oneBit=" + oneBitCorrelation + + ", twoBit=" + twoBitCorrelation, + twoBitCorrelation >= oneBitCorrelation - 0.10); + + assertTrue( + "4-bit scoring degraded unexpectedly" + + ": oneBit=" + oneBitCorrelation + + ", fourBit=" + fourBitCorrelation, + fourBitCorrelation >= oneBitCorrelation - 0.10); + } + + private static void runStandaloneScoringTest(int bitsPerDimension) + throws Exception { + var vectors = createNormalizedVectors( + VECTOR_COUNT, + ORIGINAL_DIMENSION, + 0x5eedL); + + double correlation = scoreCorrelation(vectors, bitsPerDimension); + + System.out.printf( + "Standalone ASH bitsPerDimension=%d, correlation=%.6f%n", + bitsPerDimension, + correlation); + + assertTrue( + "Expected positive exact/approximate correlation" + + " for bitsPerDimension=" + bitsPerDimension + + ", but got " + correlation, + correlation > 0.2); + } + + private static double scoreCorrelation( + VectorFloat[] vectors, + int bitsPerDimension) throws Exception { + var ravv = MockVectorValues.fromValues(vectors); + + /* + * encodedBits includes the ASH header. Keeping PROJECTED_DIMENSION + * constant reproduces the important distinction between: + * + * 1 bit: HEADER_BITS + 32 + * 2 bit: HEADER_BITS + 64 + * 4 bit: HEADER_BITS + 128 + */ + int encodedBits = + AsymmetricHashing.HEADER_BITS + + PROJECTED_DIMENSION * bitsPerDimension; + + AsymmetricHashing ash = AsymmetricHashing.initialize( + ravv, + AsymmetricHashing.ITQ, + encodedBits, + LANDMARK_COUNT, + bitsPerDimension); + + ASHVectors encoded = + ash.encodeAll(ravv, ForkJoinPool.commonPool()); + + int sampleCount = QUERY_COUNT * VECTOR_COUNT; + double[] exactScores = new double[sampleCount]; + double[] approximateScores = new double[sampleCount]; + + int sample = 0; + + for (int queryOrdinal = 0; + queryOrdinal < QUERY_COUNT; + queryOrdinal++) { + VectorFloat query = vectors[queryOrdinal]; + + int querySampleStart = sample; + + /* + * Calculate ground truth before constructing the ASH scorer. + * This also protects the test against query preprocessing that + * uses or modifies mutable vector storage. + */ + for (int vectorOrdinal = 0; + vectorOrdinal < VECTOR_COUNT; + vectorOrdinal++) { + exactScores[sample++] = VectorUtil.dotProduct( + query, + vectors[vectorOrdinal]); + } + + ScoreFunction.ApproximateScoreFunction scorer = + encoded.scoreFunctionFor( + query, + VectorSimilarityFunction.DOT_PRODUCT); + + for (int vectorOrdinal = 0; + vectorOrdinal < VECTOR_COUNT; + vectorOrdinal++) { + double approximate = + scorer.similarityTo(vectorOrdinal); + + assertFinite( + approximate, + bitsPerDimension, + queryOrdinal, + vectorOrdinal, + encoded.get(vectorOrdinal)); + + approximateScores[querySampleStart + vectorOrdinal] = + approximate; + } + } + + assertEquals(sampleCount, sample); + + double correlation = + pearsonCorrelation(exactScores, approximateScores); + + assertTrue( + "Correlation is not finite for bitsPerDimension=" + + bitsPerDimension + + ": " + correlation, + Double.isFinite(correlation)); + + return correlation; + } + + private static void assertFinite( + double score, + int bitsPerDimension, + int queryOrdinal, + int vectorOrdinal, + AsymmetricHashing.QuantizedVector encoded) { + if (!Double.isFinite(score)) { + fail( + "Non-finite standalone ASH score" + + ": bitsPerDimension=" + bitsPerDimension + + ", query=" + queryOrdinal + + ", vector=" + vectorOrdinal + + ", score=" + score + + ", scale=" + encoded.scale + + ", offset=" + encoded.offset + + ", landmark=" + + (encoded.landmark & 0xFF)); + } + + if (!Float.isFinite(encoded.scale)) { + fail( + "Non-finite ASH scale" + + ": bitsPerDimension=" + bitsPerDimension + + ", vector=" + vectorOrdinal + + ", scale=" + encoded.scale); + } + + if (!Float.isFinite(encoded.offset)) { + fail( + "Non-finite ASH offset" + + ": bitsPerDimension=" + bitsPerDimension + + ", vector=" + vectorOrdinal + + ", offset=" + encoded.offset); + } + + int landmark = encoded.landmark & 0xFF; + assertTrue( + "Invalid landmark" + + ": bitsPerDimension=" + bitsPerDimension + + ", vector=" + vectorOrdinal + + ", landmark=" + landmark, + landmark >= 0 && landmark < LANDMARK_COUNT); + } + + private static VectorFloat[] createNormalizedVectors( + int count, + int dimension, + long seed) { + Random random = new Random(seed); + VectorFloat[] vectors = new VectorFloat[count]; + + for (int i = 0; i < count; i++) { + VectorFloat vector = + TestUtil.randomVector(random, dimension); + + // Replace TestUtil's values with zero-mean Gaussian components. + for (int d = 0; d < dimension; d++) { + vector.set(d, (float) random.nextGaussian()); + } + + VectorUtil.l2normalize(vector); + vectors[i] = vector; + } + + return vectors; + } + + private static double pearsonCorrelation( + double[] left, + double[] right) { + assertEquals(left.length, right.length); + + double leftMean = 0.0; + double rightMean = 0.0; + + for (int i = 0; i < left.length; i++) { + leftMean += left[i]; + rightMean += right[i]; + } + + leftMean /= left.length; + rightMean /= right.length; + + double covariance = 0.0; + double leftVariance = 0.0; + double rightVariance = 0.0; + + for (int i = 0; i < left.length; i++) { + double leftDelta = left[i] - leftMean; + double rightDelta = right[i] - rightMean; + + covariance += leftDelta * rightDelta; + leftVariance += leftDelta * leftDelta; + rightVariance += rightDelta * rightDelta; + } + + assertTrue( + "Exact scores have zero variance", + leftVariance > 0.0); + + assertTrue( + "Approximate scores have zero variance", + rightVariance > 0.0); + + return covariance + / Math.sqrt(leftVariance * rightVariance); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java new file mode 100644 index 000000000..4e22b59ea --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHStandaloneVsFusedScoring.java @@ -0,0 +1,355 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Compares standalone ASH scores with scores produced from the fused byte + * layout for the same queries and quantized vectors. + * + * This does not involve graph construction, graph search, or disk I/O. + */ +public class TestASHStandaloneVsFusedScoring { + private static final int ORIGINAL_DIMENSION = 128; + private static final int PROJECTED_DIMENSION = 64; + private static final int VECTOR_COUNT = 1_024; + private static final int QUERY_COUNT = 32; + private static final int LANDMARK_COUNT = 1; + private static final int MAX_DEGREE = 64; + + /* + * The fused layout stores scale and offset as float16. Allow for the + * resulting rounding error when comparing against standalone scoring. + */ + private static final float ABSOLUTE_TOLERANCE = 2.0e-3f; + private static final float RELATIVE_TOLERANCE = 2.0e-3f; + + @Test + public void testOneBitStandaloneMatchesFused() throws Exception { + compareStandaloneAndFused(1); + } + + @Test + public void testTwoBitStandaloneMatchesFused() throws Exception { + compareStandaloneAndFused(2); + } + + @Test + public void testFourBitStandaloneMatchesFused() throws Exception { + compareStandaloneAndFused(4); + } + + private static void compareStandaloneAndFused(int bitsPerDimension) + throws Exception { + VectorFloat[] vectors = createNormalizedVectors( + VECTOR_COUNT, + ORIGINAL_DIMENSION, + 0x5eedL); + + var ravv = MockVectorValues.fromValues(vectors); + + int encodedBits = + AsymmetricHashing.HEADER_BITS + + PROJECTED_DIMENSION * bitsPerDimension; + + AsymmetricHashing ash = AsymmetricHashing.initialize( + ravv, + AsymmetricHashing.ITQ, + encodedBits, + LANDMARK_COUNT, + bitsPerDimension); + + ASHVectors encoded = + ash.encodeAll(ravv, ForkJoinPool.commonPool()); + + int blockSize = + FusedASHLayout.chooseBlockSize(MAX_DEGREE); + + byte[] fusedBlock = new byte[ + FusedASHLayout.blockBytes( + ash.quantizedDim, + bitsPerDimension, + blockSize)]; + + /* + * Fill one complete fused block. Each encoded vector occupies the + * lane with the same ordinal. + */ + for (int lane = 0; lane < blockSize; lane++) { + packForFused( + fusedBlock, + 0, + lane, + encoded.get(lane), + ash, + blockSize); + } + + int groups = FusedASHLayout.codeGroups( + ash.quantizedDim, + bitsPerDimension); + + float[] queryLut = new float[groups * 16]; + float[] dotQMuByLandmark = + new float[ash.landmarkCount]; + + double maximumDelta = 0.0; + int maximumDeltaQuery = -1; + int maximumDeltaLane = -1; + float maximumDeltaStandalone = Float.NaN; + float maximumDeltaFused = Float.NaN; + + for (int queryOrdinal = 0; + queryOrdinal < QUERY_COUNT; + queryOrdinal++) { + VectorFloat query = vectors[queryOrdinal]; + + ScoreFunction.ApproximateScoreFunction standaloneScorer = + encoded.scoreFunctionFor( + query, + VectorSimilarityFunction.DOT_PRODUCT); + + precomputeFusedQuery( + query, + ash, + queryLut, + dotQMuByLandmark); + + for (int lane = 0; lane < blockSize; lane++) { + float standaloneScore = + standaloneScorer.similarityTo(lane); + + float fusedScore = + ASHScorer.toSimilarity(FusedASHLayout.scoreLane( + fusedBlock, + 0, + lane, + ash.quantizedDim, + bitsPerDimension, + blockSize, + queryLut, + dotQMuByLandmark)); + + assertTrue( + failurePrefix( + bitsPerDimension, + queryOrdinal, + lane, + standaloneScore, + fusedScore) + + ": standalone score is not finite", + Float.isFinite(standaloneScore)); + + assertTrue( + failurePrefix( + bitsPerDimension, + queryOrdinal, + lane, + standaloneScore, + fusedScore) + + ": fused score is not finite", + Float.isFinite(fusedScore)); + + double delta = + Math.abs(standaloneScore - fusedScore); + + if (delta > maximumDelta) { + maximumDelta = delta; + maximumDeltaQuery = queryOrdinal; + maximumDeltaLane = lane; + maximumDeltaStandalone = standaloneScore; + maximumDeltaFused = fusedScore; + } + + float tolerance = + ABSOLUTE_TOLERANCE + + RELATIVE_TOLERANCE + * Math.abs(standaloneScore); + + assertEquals( + failurePrefix( + bitsPerDimension, + queryOrdinal, + lane, + standaloneScore, + fusedScore) + + ", tolerance=" + tolerance, + standaloneScore, + fusedScore, + tolerance); + } + } + + System.out.printf( + "ASH standalone/fused parity" + + ": bitsPerDimension=%d" + + ", blockSize=%d" + + ", maxDelta=%.9f" + + ", query=%d" + + ", lane=%d" + + ", standalone=%.9f" + + ", fused=%.9f%n", + bitsPerDimension, + blockSize, + maximumDelta, + maximumDeltaQuery, + maximumDeltaLane, + maximumDeltaStandalone, + maximumDeltaFused); + } + + /** Mirrors FusedASH.packForFused(): canonical 1/2/4-bit offsets already contain the landmark term. */ + private static void packForFused( + byte[] destination, + int blockOffset, + int lane, + AsymmetricHashing.QuantizedVector vector, + AsymmetricHashing ash, + int blockSize) { + FusedASHLayout.packQuantizedVector(destination, blockOffset, lane, vector, + ash.quantizedDim, ash.bitsPerDimension, blockSize); + } + + private static float dotOneBitProjectionCode( + float[] projected, + long[] signWords, + int quantizedDimension) { + float sum = 0.0f; + + for (int dimension = 0; + dimension < quantizedDimension; + dimension++) { + boolean positive = + ((signWords[dimension >>> 6] + >>> (dimension & 63)) & 1L) != 0L; + + sum += positive + ? projected[dimension] + : -projected[dimension]; + } + + return sum; + } + + /** + * Mirrors FusedASHDecoder.precomputeQuery(). + */ + private static void precomputeFusedQuery( + VectorFloat query, + AsymmetricHashing ash, + float[] queryLut, + float[] dotQMuByLandmark) { + int originalDimension = query.length(); + int quantizedDimension = ash.quantizedDim; + + float[] queryArray = + new float[originalDimension]; + + for (int dimension = 0; + dimension < originalDimension; + dimension++) { + queryArray[dimension] = query.get(dimension); + } + + float[][] projectionMatrix = + ash.stiefelTransform.AFloat; + + var vectorUtil = VectorizationProvider + .getInstance() + .getVectorUtilSupport(); + + float[] projectedQuery = + new float[quantizedDimension]; + + for (int projectedDimension = 0; + projectedDimension < quantizedDimension; + projectedDimension++) { + projectedQuery[projectedDimension] = + vectorUtil.ashDotRow( + projectionMatrix[projectedDimension], + queryArray); + } + + FusedASHLayout.buildQueryLut( + projectedQuery, + quantizedDimension, + ash.bitsPerDimension, + queryLut); + + for (int landmark = 0; + landmark < ash.landmarkCount; + landmark++) { + dotQMuByLandmark[landmark] = + VectorUtil.dotProduct( + query, + ash.landmarks[landmark]); + } + } + + private static VectorFloat[] createNormalizedVectors( + int count, + int dimension, + long seed) { + Random random = new Random(seed); + VectorFloat[] vectors = + new VectorFloat[count]; + + for (int i = 0; i < count; i++) { + VectorFloat vector = + TestUtil.randomVector(random, dimension); + + for (int d = 0; d < dimension; d++) { + vector.set( + d, + (float) random.nextGaussian()); + } + + VectorUtil.l2normalize(vector); + vectors[i] = vector; + } + + return vectors; + } + + private static String failurePrefix( + int bitsPerDimension, + int queryOrdinal, + int lane, + float standaloneScore, + float fusedScore) { + return "Standalone/fused score mismatch" + + ": bitsPerDimension=" + bitsPerDimension + + ", query=" + queryOrdinal + + ", lane=" + lane + + ", standalone=" + standaloneScore + + ", fused=" + fusedScore; + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java new file mode 100644 index 000000000..847f7b029 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHSymmetricScorer.java @@ -0,0 +1,346 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.util.Random; +import static org.junit.Assert.*; + +public class TestASHSymmetricScorer { + // Deliberately independent unpacking oracle, including padding with arbitrary bits. + private static float component(AsymmetricHashing.QuantizedVector v, int j, int bits) { + if (bits == 1) return ((v.binaryVector[j / 64] >>> (j % 64)) & 1) == 0 ? -1f : 1f; + if (bits == 2 || bits == 4) { + int field = (v.extraBits[j * bits / 8] >>> (j * bits % 8)) & ((1 << bits) - 1); + float magnitude = (field & ((1 << (bits - 1)) - 1)) + 0.5f; + return (field & (1 << (bits - 1))) == 0 ? -magnitude : magnitude; + } + // Offset-binary fields: bits [j*b, (j+1)*b) little-endian, value field - (2^b - 1) / 2. + int field = 0; + for (int k = 0; k < bits; k++) { + int pos = j * bits + k; + field |= ((v.extraBits[pos / 8] >>> (pos % 8)) & 1) << k; + } + return field - ((1 << bits) - 1) * 0.5f; + } + + @Test public void packedDotMatchesIndependentDecoderAndSimdIncludingTails() { + Random random = new Random(981234); + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + for (int bits = 1; bits <= 9; bits++) { + for (int d = 1; d <= 749; d++) { + var a = AsymmetricHashing.QuantizedVector.createEmpty(d, bits); + var b = AsymmetricHashing.QuantizedVector.createEmpty(d, bits); + for (int w = 0; w < a.binaryVector.length; w++) { + a.binaryVector[w] = random.nextLong(); b.binaryVector[w] = random.nextLong(); + } + random.nextBytes(a.extraBits); random.nextBytes(b.extraBits); + double expected = 0; + float[] unpacked = new float[d]; + for (int j = 0; j < d; j++) { + unpacked[j] = component(a, j, bits); + expected += unpacked[j] * component(b, j, bits); + } + float actual = ASHSymmetricScorer.codeDot(a, b, d, bits); + assertEquals("bits=" + bits + " d=" + d, (float) expected, actual, 0f); + assertEquals(actual, ASHSymmetricScorer.codeDot(b, a, d, bits), 0f); + if ((bits == 1 || bits == 2 || bits == 4) && backend.supportsAshSymmetricScoring()) { + assertEquals("symmetric SIMD bits=" + bits + " d=" + d, actual, + backend.ashSymmetricDot(a.binaryVector, a.extraBits, b.binaryVector, b.extraBits, d, bits), 0f); + } + if (bits == 2 && backend.supportsNativeAshCanonicalLutScoring()) { + // Native b=2 deliberately requires prepared state; keep its raw-entry guard. + if ("native".equalsIgnoreCase(System.getProperty("jvector.vectorization_provider", "").trim())) { + assertThrows(IllegalStateException.class, + () -> backend.ashProjectionDot(unpacked, b.extraBits, unpacked.length, 2)); + } + int groups = FusedASHLayout.codeGroups(d, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(unpacked, d, bits, lut); + assertEquals(actual, backend.ashCanonicalLutDot(b.extraBits, groups, lut), 0f); + } else if ((bits == 2 || bits == 4) && backend.supportsAshProjectionScoring()) { + assertEquals(actual, backend.ashProjectionDot(unpacked, b.extraBits, d, bits), 0f); + } + } + } + } + + @Test public void integerBlockLutPreservesTailsAndAvoidsShortOverflow() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + Random random = new Random(77531); + for (int groups : new int[]{1,2,31,63,64,65,127,257,4097}) { + int stride = 37; + byte[] codes = new byte[((groups+1)/2)*stride]; + random.nextBytes(codes); + short[] lut = new short[groups*32]; + for (int g = 0; g < groups; g++) for (int code = 0; code < 16; code++) { + short value = groups == 4097 ? (short)225 : (short)(random.nextInt(451)-225); + for (int copy = 0; copy < 2; copy++) lut[g*32+copy*16+code] = value; + } + for (int count : new int[]{1,7,16,17,32,34}) { + float[] out = new float[count+2]; + java.util.Arrays.fill(out, Float.NaN); + backend.ashSymmetricLutScore(codes, groups, stride, 3, count, lut, out, 1); + assertTrue(Float.isNaN(out[0])); assertTrue(Float.isNaN(out[count+1])); + for (int i = 0; i < count; i++) { + long expected = 0; + for (int g = 0; g < groups; g++) expected += lut[g*32+((codes[(g/2)*stride+3+i] >>> (4*(g%2))) & 15)]; + assertEquals("groups="+groups+" count="+count, expected*.25f,out[i+1],0f); + } + } + } + } + + private static VectorFloat[] inputs(int n, int d) { + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + Random r = new Random(773); + VectorFloat[] input = new VectorFloat[n]; + for (int i = 0; i < n; i++) { + input[i] = vts.createFloatVector(d); + for (int j = 0; j < d; j++) input[i].set(j, r.nextFloat() - 0.15f); + VectorUtil.l2normalize(input[i]); + } + return input; + } + + @Test public void appendixBMatchesDecodedReconstructionAndHeaderRoundTrip() throws Exception { + String previous = System.getProperty("jvector.ash.queryProjection"); + try { + // This identity reconstructs with the original FP32 matrix. Its + // asymmetric side must use that same arithmetic, not rounded operands. + System.setProperty("jvector.ash.queryProjection", "fp32"); + checkAppendixBReconstruction(); + } finally { + if (previous == null) System.clearProperty("jvector.ash.queryProjection"); + else System.setProperty("jvector.ash.queryProjection", previous); + } + } + + private void checkAppendixBReconstruction() throws Exception { + var input = inputs(25, 40); + var values = MockVectorValues.fromValues(input); + for (int bits = 1; bits <= 9; bits++) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 31 * bits, 1, bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + var scorer = new ASHSymmetricScorer(encoded); + var bsp = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, encoded); + for (int i = 0; i < input.length; i++) { + for (int j = 0; j < input.length; j++) { + var a = encoded.get(i); var b = encoded.get(j); + double dot = 0, ma = 0, mb = 0; + var decodedX = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(40); + // Explicit W^T reconstruction exercises the orthonormal simplification. + for (int k = 0; k < 40; k++) { + double x = 0, y = 0; + for (int l = 0; l < 31; l++) { + x += ash.stiefelTransform.AFloat[l][k] * component(a, l, bits); + y += ash.stiefelTransform.AFloat[l][k] * component(b, l, bits); + } + decodedX.set(k, ash.landmarks[0].get(k) + a.scale * (float) x); + dot += x * y; + ma += x * ash.landmarks[0].get(k); + mb += y * ash.landmarks[0].get(k); + } + // Stored offsets include -scale * at every width. + double oa = a.offset + a.scale * ma, ob = b.offset + b.scale * mb; + double expected = a.scale * b.scale * dot + oa + ob + + VectorUtil.dotProduct(ash.landmarks[0], ash.landmarks[0]); + assertEquals(expected, scorer.dotProduct(i,j), 2e-5); + // ASH preserves the original landmark dot via the header, rather than + // substituting the reconstructed source's landmark dot. + double sourceCorrection = a.offset; + float decodedAsymmetric = new ASHScorer(ash) + .scoreFunctionFor(decodedX, VectorSimilarityFunction.DOT_PRODUCT).similarityTo(b); + assertEquals(decodedAsymmetric + sourceCorrection, scorer.dotProduct(i,j), 2e-5); + assertEquals(scorer.dotProduct(i,j), scorer.dotProduct(j,i), 0f); + assertEquals(scorer.scoreFunctionFor(i).similarityTo(j), + scorer.scoreFunctionFor(encoded.get(i)).similarityTo(j), 0f); + assertEquals(scorer.scoreFunctionFor(i).similarityTo(j), + encoded.diversityFunctionFor(i,VectorSimilarityFunction.DOT_PRODUCT).similarityTo(j), 0f); + assertEquals(ASHScorer.toSimilarity(scorer.dotProduct(i,j)), + bsp.searchProviderFor(i).scoreFunction().similarityTo(j), 0f); + assertEquals(bsp.searchProviderFor(i).scoreFunction().similarityTo(j), + bsp.diversityScoreFunctionFor(i).similarityTo(j), 0f); + } + for (var kernel : ASHSymmetricScorer.Kernel.values()) { + if (kernel == ASHSymmetricScorer.Kernel.SIMD && + (!(bits == 1 || bits == 2 || bits == 4) || + !VectorizationProvider.getInstance().getVectorUtilSupport().supportsAshSymmetricScoring())) continue; + var block = scorer.blockScorerFor(encoded.get(i), 8, kernel); + float[] scores = new float[19]; + block.scoreRange(3, scores.length, scores); + for (int j = 0; j < scores.length; j++) assertEquals("C1 block bits="+bits+" kernel="+kernel, + ASHScorer.toSimilarity(scorer.dotProduct(i,j+3)), scores[j], 5e-5f); + } + assertEquals("Self score should reconstruct the input norm, up to FP16 headers", + VectorUtil.dotProduct(input[i],input[i]), scorer.dotProduct(i,i), 0.002); + } + try (var out = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(1024*1024,false)) { + encoded.write(out, io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); + var reloaded = ASHVectors.load(new io.github.jbellis.jvector.disk.ByteBufferReader(out.getWrittenData())); + assertEquals("reloaded bits=" + bits, scorer.dotProduct(2,7), new ASHSymmetricScorer(reloaded).dotProduct(2,7), 0f); + } + } + } + + @Test public void zeroResidualAtLandmarkStaysFinite() throws Exception { + var input = inputs(20,40); + for (int i = 1; i < input.length; i++) input[i] = input[0].copy(); + var values = MockVectorValues.fromValues(input); + for (int bits : new int[]{1,2,4,9}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 31*bits,1,bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + float score = new ASHSymmetricScorer(encoded).dotProduct(0,1); + assertEquals(VectorUtil.dotProduct(input[0],input[1]), score, 0.0001f); + } + } + + @Test public void graphProviderStillRejectsMultipleLandmarks() throws Exception { + var values = MockVectorValues.fromValues(inputs(25,40)); + var ash = AsymmetricHashing.initialize(values,AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS+31*2,2,2); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + new ASHSymmetricScorer(encoded); + assertThrows(IllegalArgumentException.class, () -> BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT, encoded)); + } + + @Test public void batchScoresMatchSingleScoresBitForBit() throws Exception { + // 1024 projected dimensions put 512 (2-bit) or 256 (4-bit) codes in each 256 KiB page, so a batch gathers + // codes from several pages; 700 codes exceed one bounded native call. + var input = inputs(1200, 1024); + var values = MockVectorValues.fromValues(input); + var random = new Random(41); + for (int bits : new int[]{2, 4}) { + var ash = AsymmetricHashing.initialize(values, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 1024 * bits, 1, bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + var scorer = new ASHSymmetricScorer(encoded); + for (int origin : new int[]{0, 777, 1199}) { + var sf = scorer.scoreFunctionFor(origin); + for (int count : new int[]{0, 1, 7, 40, 700}) { + int[] nodes = random.ints(count, 0, input.length).toArray(); + float[] scores = new float[count]; + sf.similarityTo(nodes, count, scores); + for (int i = 0; i < count; i++) { + assertEquals("bits=" + bits + " origin=" + origin + " node=" + nodes[i], + Float.floatToIntBits(sf.similarityTo(nodes[i])), Float.floatToIntBits(scores[i])); + } + } + } + } + } + + @Test public void constructionWorksWithPackedCodesAndNegativeSimilarities() throws Exception { + var input = inputs(80,40); + for (int d = 0; d < 40; d++) input[1].set(d, -input[0].get(d)); + var values = MockVectorValues.fromValues(input); + for (int bits : new int[]{1,2,3,4,9}) { + var ash = AsymmetricHashing.initialize(values,AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS+31*bits,1,bits); + var encoded = ash.encodeAll(values, java.util.concurrent.ForkJoinPool.commonPool()); + var bsp = BuildScoreProvider.ashBuildScoreProvider(VectorSimilarityFunction.DOT_PRODUCT,encoded); + assertTrue(new ASHSymmetricScorer(encoded).dotProduct(0,1) < 0f); + assertEquals(VectorSimilarityFunction.DOT_PRODUCT, bsp.searchProviderFor(0).adaptiveSimilarity()); + assertTrue(Float.isFinite(bsp.searchProviderFor(0).dotProductBound())); + try (var builder = new GraphIndexBuilder(bsp,40,16,40,1.2f,1.2f,true,true)) { + for (int i=0;i (encoded.get(node).landmark & 255) >= 128)); + try (var out = io.github.jbellis.jvector.disk.ByteBufferIndexWriter.create(1024*1024,false)) { + encoded.write(out, io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION); + var loaded = ASHVectors.load(new io.github.jbellis.jvector.disk.ByteBufferReader(out.getWrittenData())); + assertEquals(256, loaded.getCompressor().landmarkCount); + for (int i = 0; i < encoded.count(); i++) { + assertEquals(encoded.get(i).landmark & 255, loaded.get(i).landmark & 255); + } + assertEquals(scalar.dotProduct(7,11), new ASHSymmetricScorer(loaded,ASHSymmetricScorer.Kernel.SCALAR).dotProduct(7,11), 2e-6f); + assertEquals(encoded.scoreFunctionFor(input[3],VectorSimilarityFunction.DOT_PRODUCT).similarityTo(7), + loaded.scoreFunctionFor(input[3],VectorSimilarityFunction.DOT_PRODUCT).similarityTo(7), 2e-6f); + } + } + for (int blockSize : new int[]{8,16,32}) { + var query = encoded.get(7); + var block = vector.blockScorerFor(query, blockSize, useSimd + ? ASHSymmetricScorer.Kernel.SIMD : ASHSymmetricScorer.Kernel.SCALAR); + float[] scores = new float[41]; + block.scoreRange(3,41,scores); + for (int k = 0; k < 41; k++) assertEquals(scalar.scoreFunctionFor(query).similarityTo(k+3), scores[k], 0.005f); + } + } + } + } + +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingSetup.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingSetup.java new file mode 100644 index 000000000..37a143cdc --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingSetup.java @@ -0,0 +1,351 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.RandomAccessVectorValues; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** Sampled C=1 setup, numeric order, RNG state and borrowed-source lifetime. */ +public class TestASHTrainingSetup { + private static final class Values implements RandomAccessVectorValues { + final VectorFloat[] input; + final int dimensions; + final boolean shared; + final VectorFloat scratch; + final AtomicInteger reads; + int failAt = -1; + + Values(VectorFloat[] input, int dimensions, boolean shared) { + this(input, dimensions, shared, new AtomicInteger()); + } + + private Values(VectorFloat[] input, int dimensions, boolean shared, AtomicInteger reads) { + this.input = input; + this.dimensions = dimensions; + this.shared = shared; + this.reads = reads; + scratch = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimensions); + } + + @Override public int size() { return input.length; } + @Override public int dimension() { return dimensions; } + @Override public boolean isValueShared() { return shared; } + @Override public RandomAccessVectorValues copy() { + Values copy = new Values(input, dimensions, shared, reads); + copy.failAt = failAt; + return copy; + } + @Override public VectorFloat getVector(int ordinal) { + reads.incrementAndGet(); + if (ordinal == failAt) throw new IllegalStateException("source failure"); + if (!shared || input[ordinal] == null) return input[ordinal]; + scratch.copyFrom(input[ordinal], 0, 0, dimensions); + return scratch; + } + } + + private static Object prepare(Values source, int dimensions, Random random, boolean learn) throws Exception { + var method = AsymmetricHashing.class.getDeclaredMethod("prepareSingleLandmarkTraining", + RandomAccessVectorValues.class, int.class, Random.class, boolean.class); + method.setAccessible(true); + try { return method.invoke(null, source, dimensions, random, learn); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + + private static Object field(Object owner, String name) throws Exception { + var field = owner.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(owner); + } + + private static VectorFloat[] vectors(int count, int dimensions) { + var types = VectorizationProvider.getInstance().getVectorTypeSupport(); + Random random = new Random(19093); + VectorFloat[] values = new VectorFloat[count]; + for (int i = 0; i < count; i++) { + values[i] = types.createFloatVector(dimensions); + for (int d = 0; d < dimensions; d++) { + float value = random.nextFloat() - .5f; + if (i % 11 == 0) value = d % 2 == 0 ? -0f : 0f; + if (i % 11 == 1) value *= 1e-12f; + values[i].set(d, value); + } + } + return values; + } + + private static final class Reference { + final VectorFloat mean; + final double[] xCol; + final int rows; + + Reference(VectorFloat[] source, int dimensions, Random rng, boolean learn) { + int sampleSize = Math.min(source.length, dimensions * 20); + Random selection = learn ? rng : new Random(103); + int[] ordinals = new int[sampleSize]; + for (int i = 0; i < sampleSize; i++) ordinals[i] = i; + for (int i = sampleSize; i < source.length; i++) { + int j = selection.nextInt(i + 1); + if (j < sampleSize) ordinals[j] = i; + } + Arrays.sort(ordinals); + VectorFloat[] points = new VectorFloat[sampleSize]; + for (int i = 0; i < sampleSize; i++) { + points[i] = source[ordinals[i]].copy(); + float norm = (float) Math.sqrt(VectorUtil.dotProduct(points[i], points[i])); + if (norm > 1e-10f) VectorUtil.scale(points[i], 1f / norm); + } + double[] accumulator = new double[dimensions]; + for (VectorFloat point : points) + for (int d = 0; d < dimensions; d++) accumulator[d] += point.get(d); + mean = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(dimensions); + double inverseCount = 1.0 / points.length; + for (int d = 0; d < dimensions; d++) mean.set(d, (float) (accumulator[d] * inverseCount)); + rows = learn ? sampleSize : 0; + xCol = learn ? new double[rows * dimensions] : null; + if (!learn) return; + double[] residual = new double[dimensions]; + for (int row = 0; row < rows; row++) { + double norm2 = 0; + for (int d = 0; d < dimensions; d++) { + double value = (double) points[row].get(d) - (double) mean.get(d); + residual[d] = value; + norm2 += value * value; + } + double inverse = 1.0 / Math.max(Math.sqrt(norm2), 1e-20); + for (int d = 0; d < dimensions; d++) xCol[row + d * rows] = residual[d] * inverse; + } + } + } + + @Test + public void sampledMeanAndResidualsMatchOracleIncludingRngAndBorrowedStorage() throws Exception { + for (int[] shape : new int[][]{{1,1},{1,20},{1,21},{1,65},{7,31},{7,32},{7,33}, + {7,64},{7,65},{7,139},{7,140},{7,141},{16,321},{33,67},{65,1311}}) { + int dimensions = shape[0], count = shape[1]; + VectorFloat[] values = vectors(count, dimensions); + int[][] saved = new int[count][dimensions]; + for (int i = 0; i < count; i++) for (int d = 0; d < dimensions; d++) + saved[i][d] = Float.floatToRawIntBits(values[i].get(d)); + for (boolean shared : new boolean[]{false,true}) for (boolean learn : new boolean[]{false,true}) { + Random referenceRandom = new Random(103), actualRandom = new Random(103); + Reference expected = new Reference(values, dimensions, referenceRandom, learn); + Values source = new Values(values, dimensions, shared); + Object actual = prepare(source, dimensions, actualRandom, learn); + VectorFloat mean = (VectorFloat) field(actual, "landmark"); + assertEquals(Math.min(count, dimensions * 20), source.reads.get()); + for (int d = 0; d < dimensions; d++) + assertEquals(Float.floatToRawIntBits(expected.mean.get(d)), Float.floatToRawIntBits(mean.get(d))); + Object training = field(actual, "trainingData"); + if (learn) { + assertEquals(expected.rows, field(training, "rows")); + assertEquals(dimensions, field(training, "columns")); + double[] observed = (double[]) field(training, "xCol"); + assertEquals(expected.xCol.length, observed.length); + for (int i = 0; i < observed.length; i++) + assertEquals(Double.doubleToRawLongBits(expected.xCol[i]), Double.doubleToRawLongBits(observed[i])); + } else assertNull(training); + for (int i = 0; i < 8; i++) assertEquals(referenceRandom.nextLong(), actualRandom.nextLong()); + for (int i = 0; i < count; i++) for (int d = 0; d < dimensions; d++) + assertEquals(saved[i][d], Float.floatToRawIntBits(values[i].get(d))); + } + } + } + + @Test + public void initializationReadsTheSameSampleForEveryTrainingStage() throws Exception { + int dimensions = 7, count = 141; + for (int optimizer : new int[]{AsymmetricHashing.RANDOM, AsymmetricHashing.ITQ}) { + for (int bits : new int[]{1,2,4,9}) { + Values source = new Values(vectors(count, dimensions), dimensions, true); + var model = AsymmetricHashing.initialize(source, optimizer, + AsymmetricHashing.HEADER_BITS + 3 * bits, 1, bits); + Reference expected = new Reference(source.input, dimensions, new Random(103), + optimizer == AsymmetricHashing.ITQ); + assertEquals(1, model.landmarks.length); + assertEquals(3, model.quantizedDim); + assertEquals(dimensions * 20, source.reads.get()); + for (int d = 0; d < dimensions; d++) { + assertEquals(Float.floatToRawIntBits(expected.mean.get(d)), + Float.floatToRawIntBits(model.landmarks[0].get(d))); + } + } + } + } + + @Test + public void setupRejectsInvalidSourcesAndPropagatesReadFailures() throws Exception { + assertThrows(IllegalArgumentException.class, + () -> prepare(new Values(new VectorFloat[0], 3, false), 3, new Random(103), true)); + Values source = new Values(vectors(65, 7), 7, false); + source.failAt = 17; + assertThrows(IllegalStateException.class, () -> prepare(source, 7, new Random(103), true)); + assertEquals(18, source.reads.get()); + Values ragged = new Values(vectors(3, 7), 7, false); + ragged.input[1] = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(6); + assertThrows(IllegalArgumentException.class, () -> prepare(ragged, 7, new Random(103), true)); + Values nullRow = new Values(vectors(3, 7), 7, false); + nullRow.input[1] = null; + assertThrows(NullPointerException.class, () -> prepare(nullRow, 7, new Random(103), true)); + Values empty = new Values(new VectorFloat[0], 7, false); + assertThrows(IllegalArgumentException.class, () -> AsymmetricHashing.initialize(empty, + AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertEquals(0, empty.reads.get()); + } + + /** Metadata-only sources must fail validation before any vector access. */ + private static RandomAccessVectorValues metadataOnlySource(int count, int dimensions) { + return new RandomAccessVectorValues() { + @Override public int size() { return count; } + @Override public int dimension() { return dimensions; } + @Override public boolean isValueShared() { return false; } + @Override public RandomAccessVectorValues copy() { return this; } + @Override public VectorFloat getVector(int ordinal) { + throw new AssertionError("Invalid source/configuration must not fetch vectors"); + } + }; + } + + @Test + public void invalidMetadataAndConfigurationAreRejectedBeforeReading() { + int budget = AsymmetricHashing.HEADER_BITS + 6; + assertThrows(NullPointerException.class, + () -> AsymmetricHashing.initialize(null, AsymmetricHashing.ITQ, budget, 1, 2)); + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(0, 7), AsymmetricHashing.ITQ, budget, 1, 2)); + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(-1, 7), AsymmetricHashing.ITQ, budget, 1, 2)); + for (int dimensions : new int[]{0,-1}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, dimensions), + AsymmetricHashing.ITQ, budget, 1, 2)); + } + for (int optimizer : new int[]{0,-1,3}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), optimizer, budget, 1, 2)); + } + for (int bits : new int[]{0,10}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), + AsymmetricHashing.ITQ, budget, 1, bits)); + } + for (int landmarks : new int[]{0,257}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), + AsymmetricHashing.ITQ, budget, landmarks, 2)); + } + for (int invalidBudget : new int[]{AsymmetricHashing.HEADER_BITS, + AsymmetricHashing.HEADER_BITS + 3, AsymmetricHashing.HEADER_BITS + 16}) { + assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 7), + AsymmetricHashing.ITQ, invalidBudget, 1, 2)); + } + } + + @Test + public void sampleSizingDoesNotOverflowAndRejectsUnrepresentableMatrices() throws Exception { + var method = AsymmetricHashing.class.getDeclaredMethod("trainingSampleSize", int.class, int.class); + method.setAccessible(true); + assertEquals(Integer.valueOf(Integer.MAX_VALUE), + method.invoke(null, Integer.MAX_VALUE, Integer.MAX_VALUE)); + assertEquals(Integer.valueOf(100), method.invoke(null, 100, Integer.MAX_VALUE)); + assertEquals(Integer.valueOf(140), method.invoke(null, Integer.MAX_VALUE, 7)); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(50_000, 50_000), + AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertTrue(failure.getMessage().contains("array size limit")); + IllegalArgumentException wideFailure = assertThrows(IllegalArgumentException.class, + () -> AsymmetricHashing.initialize(metadataOnlySource(1, 50_000), + AsymmetricHashing.ITQ, AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertTrue(wideFailure.getMessage().contains("PCA basis")); + } + + @Test + public void nonFiniteAndOverflowingSampleNormsAreRejected() { + for (float invalid : new float[]{Float.NaN, Float.POSITIVE_INFINITY, + Float.NEGATIVE_INFINITY, Float.MAX_VALUE}) { + for (boolean shared : new boolean[]{false,true}) { + for (boolean learn : new boolean[]{false,true}) { + Values source = new Values(vectors(3, 7), 7, shared); + source.input[1].set(3, invalid); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> prepare(source, 7, new Random(103), learn)); + assertTrue(failure.getMessage().contains("ordinal 1")); + assertEquals(2, source.reads.get()); + assertEquals(Float.floatToRawIntBits(invalid), + Float.floatToRawIntBits(source.input[1].get(3))); + } + } + } + } + + @Test + public void sharedSourceReadFailuresPropagateDuringInitialization() { + Values source = new Values(vectors(65, 7), 7, true); + source.failAt = 17; + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> AsymmetricHashing.initialize(source, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + 6, 1, 2)); + assertEquals("source failure", failure.getMessage()); + assertEquals(18, source.reads.get()); + } + + @Test + public void zeroAndConstantSamplesProduceFiniteModels() throws Exception { + for (boolean constant : new boolean[]{false,true}) { + VectorFloat[] input = vectors(65, 3); + for (VectorFloat value : input) { + for (int d = 0; d < 3; d++) value.set(d, constant ? (d + 1) * .25f : 0f); + } + Object prepared = prepare(new Values(input, 3, true), 3, new Random(103), true); + Object training = field(prepared, "trainingData"); + for (double value : (double[]) field(training, "xCol")) assertEquals(0.0, value, 0.0); + Values source = new Values(input, 3, true); + var model = AsymmetricHashing.initialize(source, AsymmetricHashing.ITQ, + AsymmetricHashing.HEADER_BITS + 4, 1, 2); + for (double[] row : model.stiefelTransform.AData) { + for (double value : row) assertTrue(Double.isFinite(value)); + } + assertEquals(60, source.reads.get()); + } + } + + @Test + public void multipleLandmarksStillTrainThroughTheExistingPath() throws Exception { + for (int optimizer : new int[]{AsymmetricHashing.ITQ, AsymmetricHashing.RANDOM}) { + Values source = new Values(vectors(120, 7), 7, true); + var model = AsymmetricHashing.initialize(source, optimizer, + AsymmetricHashing.HEADER_BITS + 6, 3, 2); + assertEquals(3, model.landmarks.length); + assertEquals(3, model.quantizedDim); + assertEquals(120, source.reads.get()); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java new file mode 100644 index 000000000..0d252e318 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestASHTrainingWorkspace.java @@ -0,0 +1,327 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.SingularValueDecomposition; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.stream.IntStream; + +import static org.junit.Assert.*; + +/** Numeric layout, in-place quantization, workspace reuse and native buffer contracts. */ +public class TestASHTrainingWorkspace { + private static final double K_EPS = 1e-5; + + @Test + public void transposedProductsMatchIndependentScalarProducts() throws Exception { + Random random = new Random(41); + for (int[] shape : new int[][] {{1, 1}, {1, 5}, {3, 7}, {19, 5}, {33, 8}}) { + int rows = shape[0], dims = shape[1]; + double[] x = randomArray(rows * dims, random); + double[] xCopy = x.clone(); + double[] rotation = randomArray(dims * dims, random); + double[] rotationCopy = rotation.clone(); + double[] projected = new double[x.length]; + double[] update = new double[rotation.length]; + for (int repeat = 0; repeat < 3; repeat++) { + Arrays.fill(projected, Double.NaN); + product("projectTrainingRows", x, rows, dims, rotation, projected); + for (int row = 0; row < rows; row++) { + for (int col = 0; col < dims; col++) { + double expected = 0; + for (int k = 0; k < dims; k++) expected += x[row + k * rows] * rotation[k + col * dims]; + assertEquals(expected, projected[row * dims + col], 1e-12); + } + } + Arrays.fill(update, Double.NaN); + product("updateTrainingMatrix", x, rows, dims, projected, update); + for (int row = 0; row < dims; row++) { + for (int col = 0; col < dims; col++) { + double expected = 0; + for (int k = 0; k < rows; k++) expected += x[k + row * rows] * projected[k * dims + col]; + assertEquals(expected, update[row + col * dims], 1e-11); + } + } + assertArrayEquals(xCopy, x, 0); + assertArrayEquals(rotationCopy, rotation, 0); + } + } + } + + @Test + public void contiguousQuantizationMatchesPreviousColumnMajorAlgorithmBitForBit() throws Exception { + Random random = new Random(103); + for (int bits = 1; bits <= 9; bits++) { + for (int[] shape : new int[][] {{1, 1}, {3, 5}, {17, 9}, {35, 32}, {97, 17}}) { + int rows = shape[0], dims = shape[1]; + Object encoder = encoder(rows, dims, bits); + for (int repeat = 0; repeat < 3; repeat++) { + double[] contiguous = randomArray(rows * dims, random); + // Zero/tiny rows and signed zero exercise normalization and sign boundaries. + for (int j = 0; j < dims; j++) contiguous[j] = j % 2 == 0 ? 0.0 : -0.0; + if (rows > 1) for (int j = 0; j < dims; j++) contiguous[dims + j] *= 1e-30; + double[] columnMajor = new double[contiguous.length]; + for (int r = 0; r < rows; r++) for (int j = 0; j < dims; j++) columnMajor[r + j * rows] = contiguous[r * dims + j]; + double[] expected = new double[contiguous.length]; + referenceColumnMajor(columnMajor, rows, dims, bits, expected); + encode(encoder, contiguous); + for (int r = 0; r < rows; r++) for (int j = 0; j < dims; j++) { + assertEquals("bits=" + bits + " row=" + r + " dim=" + j, + Double.doubleToLongBits(expected[r + j * rows]), Double.doubleToLongBits(contiguous[r * dims + j])); + } + } + } + } + } + + @Test + public void independentTrainersCanQuantizeConcurrently() { + CompletableFuture[] jobs = IntStream.range(0, 4).mapToObj(index -> + CompletableFuture.runAsync(() -> { + try { + int rows = 97 + index, dims = 17 + index, bits = 2 + index; + Object encoder = encoder(rows, dims, bits); + Random random = new Random(108 + index); + for (int repeat = 0; repeat < 3; repeat++) { + double[] actual = randomArray(rows * dims, random); + double[] columns = new double[actual.length]; + for (int r = 0; r < rows; r++) for (int d = 0; d < dims; d++) + columns[r + d * rows] = actual[r * dims + d]; + double[] expected = new double[actual.length]; + referenceColumnMajor(columns, rows, dims, bits, expected); + encode(encoder, actual); + for (int r = 0; r < rows; r++) for (int d = 0; d < dims; d++) + assertEquals(Double.doubleToLongBits(expected[r + d * rows]), + Double.doubleToLongBits(actual[r * dims + d])); + } + } catch (Exception failure) { + throw new AssertionError(failure); + } + })).toArray(CompletableFuture[]::new); + CompletableFuture.allOf(jobs).join(); + } + + @Test + public void reusedPolarWorkspaceMatchesIndependentSvd() { + Random random = new Random(77); + for (int[] shape : new int[][] {{1, 1}, {5, 3}, {3, 5}, {4, 4}}) { + int rows = shape[0], cols = shape[1]; + ASHOrthogonalizer workspace = new ASHOrthogonalizer(rows, cols); + double[] destination = new double[rows * cols]; + for (int repeat = 0; repeat < 4; repeat++) { + double[] input = randomArray(rows * cols, random); + double[] copy = input.clone(); + double[][] matrix = new double[rows][cols]; + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) matrix[r][c] = input[r + c * rows]; + var svd = new SingularValueDecomposition(new Array2DRowRealMatrix(matrix, false)); + var expected = svd.getU().multiply(svd.getVT()); + Arrays.fill(destination, Double.NaN); + workspace.orthogonalize(input, destination); + assertArrayEquals(copy, input, 0); + for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) assertEquals(expected.getEntry(r, c), destination[r + c * rows], 1e-11); + assertPartialIsometry(destination, rows, cols); + } + // The polar completion is not unique for rank-deficient inputs. + double[] deficient = new double[rows * cols]; + for (int r = 0; r < rows; r++) deficient[r] = r + 1; + workspace.orthogonalize(deficient, destination); + assertPartialIsometry(destination, rows, cols); + workspace.orthogonalize(new double[rows * cols], destination); + assertPartialIsometry(destination, rows, cols); + } + } + + @Test + public void polarWorkspaceRejectsInvalidBuffersAndRecoversAfterFailure() { + assertThrows(IllegalArgumentException.class, () -> new ASHOrthogonalizer(0, 1)); + assertThrows(IllegalArgumentException.class, () -> new ASHOrthogonalizer(1, -1)); + assertThrows(IllegalArgumentException.class, () -> new ASHOrthogonalizer(50000, 50000)); + ASHOrthogonalizer workspace = new ASHOrthogonalizer(2, 2); + double[] input = {1, 0, 0, 1}, output = new double[4]; + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(null, output)); + assertThrows(NullPointerException.class, () -> workspace.orthogonalize(input, null)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(new double[3], output)); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, new double[3])); + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(input, input)); + for (double invalid : new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + double[] bad = {1, invalid, 0, 1}; + assertThrows(IllegalArgumentException.class, () -> workspace.orthogonalize(bad, output)); + workspace.orthogonalize(input, output); + assertArrayEquals(input, output, 1e-12); + } + } + + @Test + public void matrixAndEncoderContractsRejectInvalidShapesAndAliasing() throws Exception { + double[] x = new double[6], square = new double[4]; + assertThrows(IllegalArgumentException.class, () -> product("projectTrainingRows", x, 0, 2, square, x)); + assertThrows(IllegalArgumentException.class, () -> product("projectTrainingRows", x, 3, 2, square, new double[5])); + assertThrows(IllegalArgumentException.class, () -> product("projectTrainingRows", x, 3, 2, square, x)); + assertThrows(IllegalArgumentException.class, () -> product("updateTrainingMatrix", x, 3, 2, x, x)); + assertThrows(IllegalArgumentException.class, () -> product("updateTrainingMatrix", square, 2, 2, square, square)); + assertThrows(NullPointerException.class, () -> product("projectTrainingRows", null, 3, 2, square, x)); + assertThrows(IllegalArgumentException.class, () -> encoder(0, 2, 2)); + assertThrows(IllegalArgumentException.class, () -> encoder(1, 2, 0)); + assertThrows(IllegalArgumentException.class, () -> encoder(1, 2, 10)); + assertThrows(IllegalArgumentException.class, () -> encoder(50000, 50000, 2)); + Object encoder = encoder(3, 2, 2); + assertThrows(NullPointerException.class, () -> encode(encoder, null)); + assertThrows(IllegalArgumentException.class, () -> encode(encoder, new double[5])); + } + + @Test + public void pcaWorkspaceQueryAndDecompositionLeaveAllInputUntouched() throws Exception { + Random random = new Random(82); + for (int[] shape : new int[][] {{7, 3}, {2, 5}, {1, 1}}) { + double[] input = randomArray(shape[0] * shape[1], random); + double[] copy = input.clone(); + call(method("computeNativeVt", double[].class, int.class, int.class), null, input, shape[0], shape[1]); + assertArrayEquals(copy, input, 0); + } + } + + private static void assertPartialIsometry(double[] matrix, int rows, int cols) { + int rank = Math.min(rows, cols); + for (int i = 0; i < rank; i++) for (int j = 0; j < rank; j++) { + double dot = 0; + for (int k = 0; k < Math.max(rows, cols); k++) { + dot += rows >= cols ? matrix[k + i * rows] * matrix[k + j * rows] + : matrix[i + k * rows] * matrix[j + k * rows]; + } + assertEquals(i == j ? 1 : 0, dot, 1e-11); + } + } + + private static double[] randomArray(int count, Random random) { + double[] values = new double[count]; + for (int i = 0; i < count; i++) values[i] = random.nextGaussian(); + return values; + } + + private static Method method(String name, Class... types) throws NoSuchMethodException { + Method method = AsymmetricHashing.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method; + } + + private static Object call(Method method, Object target, Object... args) throws Exception { + try { return method.invoke(target, args); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + + private static void product(String name, double[] x, int rows, int dims, double[] other, double[] output) throws Exception { + call(method(name, double[].class, int.class, int.class, double[].class, double[].class), null, x, rows, dims, other, output); + } + + private static Object encoder(int rows, int dims, int bits) throws Exception { + Class type = Class.forName(AsymmetricHashing.class.getName() + "$TrainingDirectionEncoder"); + Constructor constructor = type.getDeclaredConstructor(int.class, int.class, int.class); + constructor.setAccessible(true); + try { return constructor.newInstance(rows, dims, bits); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception) throw (Exception) failure.getCause(); + throw (Error) failure.getCause(); + } + } + + private static void encode(Object encoder, double[] input) throws Exception { + Method method = encoder.getClass().getDeclaredMethod("encodeInPlace", double[].class); + method.setAccessible(true); + call(method, encoder, (Object) input); + } + + private static double scale(double[] values, int dims, int bits) { + try { return (double) call(method("computeOptimalScalingFactor", double[].class, int.class, int.class), null, values, dims, bits); } + catch (Exception failure) { throw new AssertionError(failure); } + } + + // Retain the former column-major loop as the numerical regression oracle. + private static void referenceColumnMajor( + double[] xCol, + int rows, + int dims, + int bitsPerDimension, + double[] outCol + ) { + if (outCol.length < rows * dims) { + throw new IllegalArgumentException("Output matrix too small"); + } + + if (bitsPerDimension <= 1) { + final double invSqrtD = 1.0 / Math.sqrt(dims); + final int size = rows * dims; + + for (int i = 0; i < size; i++) { + outCol[i] = xCol[i] >= 0.0 ? invSqrtD : -invSqrtD; + } + return; + } + + final int excessBits = bitsPerDimension - 1; + final int maxCode = (1 << excessBits) - 1; + + // Each task owns a contiguous range of training rows and its scratch arrays. + // computeOptimalScalingFactor already keeps its event buffers thread-local. + final int workers = Math.min(rows, Math.min(16, Runtime.getRuntime().availableProcessors())); + IntStream.range(0, workers).parallel().forEach(worker -> { + double[] absNorm = new double[dims]; + double[] mag = new double[dims]; + int start = worker * rows / workers; + int end = (worker + 1) * rows / workers; + + for (int r = start; r < end; r++) { + double norm2 = 0.0; + for (int j = 0; j < dims; j++) { + double v = xCol[r + j * rows]; + norm2 += v * v; + } + + double invNorm = 1.0 / Math.sqrt(Math.max(norm2, 1e-20)); + for (int j = 0; j < dims; j++) { + absNorm[j] = Math.abs(xCol[r + j * rows]) * invNorm; + } + + double t = scale(absNorm, dims, bitsPerDimension); + double encodedNorm2 = 0.0; + for (int j = 0; j < dims; j++) { + int q = Math.min((int) (t * absNorm[j] + K_EPS), maxCode); + double m = q + 0.5; + mag[j] = m; + encodedNorm2 += m * m; + } + + double encodedInvNorm = 1.0 / Math.sqrt(Math.max(encodedNorm2, 1e-20)); + for (int j = 0; j < dims; j++) { + double signed = xCol[r + j * rows] < 0.0 ? -mag[j] : mag[j]; + outCol[r + j * rows] = signed * encodedInvNorm; + } + } + }); + } + +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java index 456ae2d18..a700629bb 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestCompressedVectors.java @@ -28,6 +28,8 @@ import org.junit.Test; import java.io.File; +import java.nio.ByteBuffer; +import java.nio.file.Files; import java.util.List; import static io.github.jbellis.jvector.TestUtil.createRandomVectors; @@ -36,6 +38,8 @@ import static java.lang.Math.abs; import static java.lang.Math.log; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; @ThreadLeakScope(ThreadLeakScope.Scope.NONE) public class TestCompressedVectors extends RandomizedTest { @@ -259,4 +263,49 @@ public void testRawEqualsPrecomputed() { public void testCenteringDisturbance() { } + + @Test + public void testAshRequiresFormatVersionSevenOrLater() throws Exception { + var vectors = createRandomVectors(256, 64); + vectors.forEach(VectorUtil::l2normalize); + var ravv = new ListRandomAccessVectorValues(vectors, 64); + var ash = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 64, 1, 2); + int current = io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex.CURRENT_VERSION; + + File file = File.createTempFile("ashtest", ".model"); + try (var writer = new SimpleWriter(file.toPath())) { + ash.write(writer, current); + } + try (var readerSupplier = new SimpleMappedReader.Supplier(file.toPath())) { + var loaded = AsymmetricHashing.load(readerSupplier.get()); + assertEquals(ash.originalDimension, loaded.originalDimension); + assertEquals(ash.quantizedDim, loaded.quantizedDim); + } + + File older = File.createTempFile("ashtest", ".model"); + try (var writer = new SimpleWriter(older.toPath())) { + var e = assertThrows(IllegalArgumentException.class, + () -> ash.write(writer, AsymmetricHashing.MIN_FORMAT_VERSION - 1)); + assertTrue(e.getMessage().endsWith("got " + (AsymmetricHashing.MIN_FORMAT_VERSION - 1)), e.getMessage()); + } + + // The header is the magic number, then the format version. + byte[] model = Files.readAllBytes(file.toPath()); + for (int version : new int[] {AsymmetricHashing.MIN_FORMAT_VERSION - 1, current + 1}) { + assertLoadFails(model, 4, version, "got " + version); + } + assertLoadFails(model, 0, 0x12345678, "Invalid ASH magic: 0x12345678"); + } + + private static void assertLoadFails(byte[] model, int offset, int value, String message) throws Exception { + byte[] patched = model.clone(); + ByteBuffer.wrap(patched, offset, Integer.BYTES).putInt(value); + File file = File.createTempFile("ashtest", ".model"); + Files.write(file.toPath(), patched); + try (var readerSupplier = new SimpleMappedReader.Supplier(file.toPath())) { + var e = assertThrows(IllegalArgumentException.class, () -> AsymmetricHashing.load(readerSupplier.get())); + assertTrue(e.getMessage().endsWith(message), e.getMessage()); + } + } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java new file mode 100644 index 000000000..33fe45ca8 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHBlockPacking.java @@ -0,0 +1,113 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import org.junit.Test; +import java.util.Arrays; +import java.util.Random; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class TestFusedASHBlockPacking { + /** Independent oracle: the original nibble-by-nibble fused layout. */ + private static void reference(byte[] destination, int offset, int lane, + AsymmetricHashing.QuantizedVector vector, + int dimension, int bits, int blockSize) { + if (bits != 1 && bits != 2 && bits != 4) { + // Field blocks: unit u holds each lane's code bytes [u*b, (u+1)*b), zero past the code. + int units = (dimension + 7) / 8, bytes = FusedASHLayout.canonicalCodeBytes(dimension, bits); + for (int u = 0; u < units; u++) { + for (int k = 0; k < bits; k++) { + int i = u * bits + k; + destination[offset + (u * blockSize + lane) * bits + k] = i < bytes ? vector.extraBits[i] : 0; + } + } + FusedASHLayout.writeLaneHeader(destination, offset, lane, dimension, bits, blockSize, + vector.scale, vector.offset, vector.landmark); + return; + } + int groups = FusedASHLayout.codeGroups(dimension, bits); + for (int group = 0; group < groups; group++) { + int nibble = bits == 1 + ? FusedASHLayout.signNibbleFromWords(vector.binaryVector, group, dimension) + : FusedASHLayout.flatNibble(vector.extraBits, group); + FusedASHLayout.setPackedNibble(destination, offset, lane, group, blockSize, nibble); + } + FusedASHLayout.writeLaneHeader(destination, offset, lane, dimension, bits, blockSize, + vector.scale, vector.offset, vector.landmark); + } + + @Test + public void testPackingMatchesOriginalLayout() throws Exception { + var constructor = AsymmetricHashing.QuantizedVector.class.getDeclaredConstructor( + float.class, float.class, byte.class, long[].class, byte[].class); + constructor.setAccessible(true); + Random random = new Random(19); + int[] dimensions = new int[266]; + for (int i = 0; i < 257; i++) { + dimensions[i] = i + 1; + } + int[] large = {383, 384, 385, 767, 768, 769, 1536, 3072, 3073}; + System.arraycopy(large, 0, dimensions, 257, large.length); + int cases = 0; + for (int bits : new int[]{1, 2, 3, 4, 9}) { + for (int dimension : dimensions) { + for (int blockSize : new int[]{8, 16, 32}) { + var vectors = new AsymmetricHashing.QuantizedVector[blockSize + 3]; + for (int i = 0; i < vectors.length; i++) { + byte[] code = new byte[FusedASHLayout.canonicalCodeBytes(dimension, bits)]; + random.nextBytes(code); + long[] signs = new long[(dimension + 63) / 64]; + for (int j = 0; j < signs.length; j++) { + signs[j] = random.nextLong(); + } + vectors[i] = constructor.newInstance(random.nextFloat() * 3, + random.nextFloat() - 0.5f, (byte) random.nextInt(256), signs, code); + } + int length = FusedASHLayout.blockBytes(dimension, bits, blockSize); + int offset = 7; // Exercise unaligned long loads/stores and guard bytes. + for (int count = 0; count <= blockSize; count++) { + byte[] expected = new byte[length + 14]; + Arrays.fill(expected, (byte) 87); + Arrays.fill(expected, offset, offset + length, (byte) 0); + byte[] actual = expected.clone(); + for (int lane = 0; lane < count; lane++) { + reference(expected, offset, lane, vectors[3 + lane], + dimension, bits, blockSize); + } + FusedASHBlockPacker.pack(actual, offset, vectors, 3, count, + dimension, bits, blockSize); + assertArrayEquals("bits=" + bits + " dimension=" + dimension + + " blockSize=" + blockSize + " count=" + count, expected, actual); + cases++; + } + + // Single-vector packing must preserve other lanes and high padding nibbles. + byte[] expected = new byte[length + 14]; + random.nextBytes(expected); + byte[] actual = expected.clone(); + reference(expected, offset, blockSize - 1, vectors[0], dimension, bits, blockSize); + FusedASHLayout.packQuantizedVector(actual, offset, blockSize - 1, + vectors[0], dimension, bits, blockSize); + assertArrayEquals(expected, actual); + cases++; + } + } + } + assertEquals(82_460, cases); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java new file mode 100644 index 000000000..a9012323c --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestFusedASHQueryLut.java @@ -0,0 +1,106 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.quantization; + +import org.junit.Test; +import java.util.Random; +import static org.junit.Assert.assertEquals; + +/** The specialized query LUT builder must equal the per-entry definition bit for bit. */ +public class TestFusedASHQueryLut { + private static void reference(float[] qProj, int quantizedDim, int bits, float[] lut) { + int groupDims = FusedASHLayout.projectionDimsPerNibble(bits); + int groups = FusedASHLayout.codeGroups(quantizedDim, bits); + for (int group = 0; group < groups; group++) { + for (int nibble = 0; nibble < 16; nibble++) { + float sum = 0f; + for (int slot = 0; slot < groupDims; slot++) { + int dim = group * groupDims + slot; + if (dim >= quantizedDim) break; + int field = (nibble >>> (slot * bits)) & ((1 << bits) - 1); + sum += qProj[dim] * FusedASHLayout.decodeProjectionComponent(field, bits); + } + lut[(group << 4) + nibble] = sum; + } + } + } + + @Test + public void matchesDefinitionForEveryWidthAndTail() { + Random random = new Random(7); + float[] specials = {0f, -0f, Float.MIN_VALUE, -Float.MIN_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, + Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NaN, 1e-30f, -3.25f}; + for (int bits : new int[] {1, 2, 4}) { + for (int dim : new int[] {1, 2, 3, 4, 5, 7, 8, 63, 64, 191, 192, 512, 513}) { + for (int trial = 0; trial < 20; trial++) { + float[] q = new float[dim + 3]; // Values past quantizedDim must be ignored. + for (int i = 0; i < q.length; i++) { + q[i] = trial % 3 == 0 ? specials[random.nextInt(specials.length)] + : (float) random.nextGaussian() * (trial % 2 == 0 ? 1e-3f : 10f); + } + int size = FusedASHLayout.codeGroups(dim, bits) * 16; + float[] expected = new float[size + 5], actual = new float[size + 5]; + reference(q, dim, bits, expected); + java.util.Arrays.fill(actual, 42f); + java.util.Arrays.fill(expected, size, expected.length, 42f); + FusedASHLayout.buildQueryLut(q, dim, bits, actual); + for (int i = 0; i < expected.length; i++) { + assertEquals("bits=" + bits + " dim=" + dim + " entry=" + i, + Float.floatToRawIntBits(expected[i]) == Float.floatToRawIntBits(actual[i]) + || (Float.isNaN(expected[i]) && Float.isNaN(actual[i])), true); + } + } + } + } + } + + /** Offset-binary widths: summing each 4-bit group's entry reproduces the decoded dot product. */ + @Test + public void offsetBinaryLutSumsMatchDecodedDots() { + Random random = new Random(11); + for (int bits : new int[] {3, 5, 6, 7, 8, 9}) { + for (int dim : new int[] {1, 2, 3, 5, 7, 8, 9, 63, 64, 65, 191, 192, 383}) { + float[] q = new float[dim + 3]; + for (int i = 0; i < q.length; i++) q[i] = (float) random.nextGaussian(); + int groups = FusedASHLayout.codeGroups(dim, bits); + float[] lut = new float[groups * 16]; + FusedASHLayout.buildQueryLut(q, dim, bits, lut); + for (int trial = 0; trial < 20; trial++) { + // Random fields, plus random padding bits past the last field that must be ignored. + byte[] code = new byte[(groups + 1) / 2]; + random.nextBytes(code); + int used = dim * bits; + for (int bit = used; bit < code.length * 8; bit++) code[bit >>> 3] &= (byte) ~(1 << (bit & 7)); + double expected = 0, scale = 0; + for (int j = 0; j < dim; j++) { + int field = AsymmetricHashing.offsetBinaryField(code, 0, j, bits); + double value = field - ((1 << bits) - 1) / 2.0; + expected += q[j] * value; + scale += Math.abs(q[j]) * ((1 << bits) - 1); + } + float actual = 0f; + for (int g = 0; g < groups; g++) { + int nibble = ((code[g >>> 1] & 0xFF) >>> ((g & 1) * 4)) & 15; + actual += lut[(g << 4) + nibble]; + } + assertEquals("bits=" + bits + " dim=" + dim, expected, actual, 1e-5 * Math.max(1, scale)); + assertEquals(expected, AsymmetricHashing.dotOffsetBinaryCode(q, code, 0, dim, bits), 1e-5 * Math.max(1, scale)); + } + } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPQEncodedNorms.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPQEncodedNorms.java new file mode 100644 index 000000000..5af24d674 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPQEncodedNorms.java @@ -0,0 +1,102 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.disk.SimpleWriter; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.types.ByteSequence; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ForkJoinPool; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * A PQ model knows the largest reconstruction length of the vectors it holds codes for, keeps it through a save and + * load at format version 7 or later, and reports it unknown when loaded from an older format. + */ +public class TestPQEncodedNorms { + @Test + public void largestLengthIsSavedWithTheModelAndUnknownWithoutIt() throws Exception { + var random = new Random(29); + int n = 300, dim = 16; + List> base = new ArrayList<>(); + for (int i = 0; i < n; i++) { + var v = TestUtil.randomVector(random, dim); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * random.nextDouble())); + base.add(v); + } + var ravv = new ListRandomAccessVectorValues(base, dim); + var pq = ProductQuantization.compute(ravv, 4, 32, false); + var pqv = pq.encodeAll(ravv, ForkJoinPool.commonPool()); + float expected = 0; + for (int i = 0; i < n; i++) expected = Math.max(expected, (float) Math.sqrt(pq.reconstructedSquaredNorm(pqv.get(i), 0))); + assertEquals(expected, pq.maxEncodedNorm(), 0f); + assertEquals(expected, pqv.maxReconstructedNorm(), 0f); + + // Saved and loaded at the current version, the model keeps it, and the header size accounts for it. + var current = roundTrip(pq, OnDiskGraphIndex.CURRENT_VERSION); + assertEquals(expected, current.maxEncodedNorm(), 0f); + + // An older format did not store it: unknown (infinite), and it stays unknown after encoding more, since the + // lengths of the codes encoded before cannot be known. Dot-product searches then keep the standard rule. + var older = roundTrip(pq, ProductQuantization.MAX_NORM_VERSION - 1); + assertEquals(Float.POSITIVE_INFINITY, older.maxEncodedNorm(), 0f); + older.encode(base.get(0)); + assertEquals(Float.POSITIVE_INFINITY, older.maxEncodedNorm(), 0f); + var query = TestUtil.randomVector(random, dim); + var olderVectors = older.encodeAll(ravv, ForkJoinPool.commonPool()); + assertTrue(!Float.isFinite(olderVectors.precomputedScoreFunctionFor(query, VectorSimilarityFunction.DOT_PRODUCT) + .dotProductBound())); + // Unknown survives a save and load at the current version too. + assertEquals(Float.POSITIVE_INFINITY, roundTrip(older, OnDiskGraphIndex.CURRENT_VERSION).maxEncodedNorm(), 0f); + + // Codes wrapped with another instance of the model are counted in it. + var fresh = roundTrip(pq, OnDiskGraphIndex.CURRENT_VERSION); + var empty = new ProductQuantization(fresh.codebooks, fresh.getClusterCount(), fresh.subvectorSizesAndOffsets, + fresh.globalCentroid, fresh.anisotropicThreshold); + // Nothing recorded yet: no bound, so a model saved now does not claim one. + assertTrue(Float.isNaN(empty.maxEncodedNorm())); + assertTrue(Float.isNaN(roundTrip(empty, OnDiskGraphIndex.CURRENT_VERSION).maxEncodedNorm())); + var codes = new ByteSequence[n]; + for (int i = 0; i < n; i++) codes[i] = pqv.get(i).copy(); + empty.createCompressedVectors(codes); + assertEquals(expected, empty.maxEncodedNorm(), 0f); + } + + private static ProductQuantization roundTrip(ProductQuantization pq, int version) throws Exception { + File file = File.createTempFile("pq-norm", ".bin"); + file.deleteOnExit(); + try (var writer = new SimpleWriter(file.toPath())) { + pq.write(writer, version); + } + assertEquals("version " + version, pq.compressorSize(version), file.length()); + try (var readers = new SimpleMappedReader.Supplier(file.toPath())) { + return ProductQuantization.load(readers.get()); + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java new file mode 100644 index 000000000..a0f541bd4 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestPreparedASHQuery.java @@ -0,0 +1,244 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.TestUtil; +import io.github.jbellis.jvector.disk.SimpleMappedReader; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.MockVectorValues; +import io.github.jbellis.jvector.graph.SearchResult; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndex; +import io.github.jbellis.jvector.graph.disk.OnDiskGraphIndexWriter; +import io.github.jbellis.jvector.graph.disk.feature.*; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.PreparedQuery; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; +import java.nio.file.Files; +import java.util.EnumMap; +import java.util.List; +import java.util.Random; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.function.IntFunction; +import static org.junit.Assert.*; + +/** Numerical preparation ownership, batch tails, handoff, and resumed graph searches. */ +public class TestPreparedASHQuery { + private static final VectorSimilarityFunction DOT = VectorSimilarityFunction.DOT_PRODUCT; + + private static VectorFloat[] inputs(int count) { + var type = VectorizationProvider.getInstance().getVectorTypeSupport(); + var values = new VectorFloat[count]; + Random random = new Random(341975); + for (int n = 0; n < count; n++) { + values[n] = type.createFloatVector(64); + for (int d = 0; d < 64; d++) values[n].set(d, random.nextFloat() - .5f); + VectorUtil.l2normalize(values[n]); + } + return values; + } + + private static AsymmetricHashing model(VectorFloat[] input, int bits) throws Exception { + return AsymmetricHashing.initialize(MockVectorValues.fromValues(input), AsymmetricHashing.RANDOM, + AsymmetricHashing.HEADER_BITS + 33 * bits, 1, bits); + } + + @Test + public void batchMatchesSinglePreparationForEveryFusedBitCountAndOwnsInputs() throws Exception { + var values = inputs(97); + for (int bits : new int[]{1, 2, 3, 4, 9}) { + var ash = model(values, bits); + var preparer = new ASHQueryPreparer(ash); + var states = new PreparedQuery[17]; + for (int i = 0; i < states.length; i++) states[i] = preparer.newState(); + var reference = preparer.newState(); + for (int count : new int[]{0, 1, 2, 8, 17}) { + preparer.prepareBatch(values, states, count); + for (int i = 0; i < count; i++) { + preparer.prepare(values[i], reference); + var expected = ASHQueryPreparer.state(ash, reference, true); + var actual = ASHQueryPreparer.state(ash, states[i], true); + assertArrayEquals(expected.prepared.qProj, actual.prepared.qProj, 2e-6f); + assertArrayEquals(expected.prepared.dotQMuByLandmark, actual.prepared.dotQMuByLandmark, 0f); + // Offset-binary entries weight Aq by up to 2^(b-1), amplifying batch/single projection + // differences of up to 2e-6 by the same factor. + float lutTolerance = bits == 1 || bits == 2 || bits == 4 ? 5e-6f : 2e-6f * (1 << bits); + for (int e = 0; e < expected.lut.length; e++) { + assertEquals(expected.lut[e], actual.lut[e], lutTolerance * Math.max(1f, Math.abs(expected.lut[e]))); + } + } + } + var mutable = values[0].copy(); + preparer.prepare(mutable, reference); + float first = reference.queryVector().get(0); + mutable.set(0, first + 10); + assertEquals(first, reference.queryVector().get(0), 0f); + long bytes = reference.ramBytesUsed(); + for (int i = 0; i < 50; i++) preparer.prepare(values[i], reference); + assertEquals(bytes, reference.ramBytesUsed()); + assertTrue(bytes > 4L * (64 + 33)); + } + } + + @Test + public void invalidBatchDoesNotOverwriteExistingStates() throws Exception { + var values = inputs(97); + var ash = model(values, 2); + var preparer = new ASHQueryPreparer(ash); + var first = preparer.newState(); + var second = preparer.newState(); + assertThrows(IllegalStateException.class, first::queryVector); + preparer.prepare(values[0], first); + float original = first.queryVector().get(0); + assertThrows(IllegalArgumentException.class, + () -> preparer.prepareBatch(values, new PreparedQuery[]{first, first}, 2)); + assertEquals(original, first.queryVector().get(0), 0f); + var other = new ASHQueryPreparer(model(values, 2)).newState(); + assertThrows(IllegalArgumentException.class, () -> preparer.prepare(values[0], other)); + assertThrows(IndexOutOfBoundsException.class, + () -> preparer.prepareBatch(values, new PreparedQuery[]{first}, 2)); + assertThrows(IndexOutOfBoundsException.class, + () -> preparer.prepareBatch(values, new PreparedQuery[]{first}, -1)); + var bad = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(63); + assertThrows(IllegalArgumentException.class, + () -> preparer.prepareBatch(new VectorFloat[]{values[1], bad}, new PreparedQuery[]{first, second}, 2)); + assertEquals(original, first.queryVector().get(0), 0f); + } + + + @Test + public void oneRowUsesDirectProjectionEvenWhenBackendHasNoSingleRowShortcut() throws Exception { + var values = inputs(17); + var ash = model(values, 2); + var preparer = new ASHQueryPreparer(ash); + preparer.supportsBatchPreparation(); // Initialize the immutable query view. + var field = AsymmetricHashing.StiefelTransform.class.getDeclaredField("queryProjection"); + field.setAccessible(true); + var original = (io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection) field.get(ash.stiefelTransform); + int[] calls = new int[2]; + var wrapper = new io.github.jbellis.jvector.vector.VectorUtilSupport.AshProjection() { + public void project(float[] input, float[] output) { + calls[0]++; + original.project(input, output); + } + public boolean supportsBatchProjection() { return true; } + public void projectBatch(float[][] input, float[][] output, int count) { + assertTrue("One-row requests must not enter backend batching", count > 1); + calls[1]++; + original.projectBatch(input, output, count); + } + public long ramBytesUsed() { return original.ramBytesUsed(); } + }; + var first = preparer.newState(); + var second = preparer.newState(); + field.set(ash.stiefelTransform, wrapper); + try { + preparer.prepareBatch(values, new PreparedQuery[]{first}, 0); + assertArrayEquals(new int[]{0, 0}, calls); + preparer.prepareBatch(values, new PreparedQuery[]{first}, 1); + assertArrayEquals(new int[]{1, 0}, calls); + var snapshot = ASHQueryPreparer.state(ash, first, true).prepared.qProj.clone(); + long generation = ASHQueryPreparer.state(ash, first, true).generation; + var bad = VectorizationProvider.getInstance().getVectorTypeSupport().createFloatVector(63); + assertThrows(IllegalArgumentException.class, + () -> preparer.prepareBatch(new VectorFloat[]{bad}, new PreparedQuery[]{first}, 1)); + assertEquals(generation, ASHQueryPreparer.state(ash, first, true).generation); + assertArrayEquals(snapshot, ASHQueryPreparer.state(ash, first, true).prepared.qProj, 0f); + assertArrayEquals(new int[]{1, 0}, calls); + preparer.prepareBatch(values, new PreparedQuery[]{first, second}, 2); + assertArrayEquals(new int[]{1, 1}, calls); + } finally { + field.set(ash.stiefelTransform, original); + } + } + + @Test(timeout=30000) + public void distinctStatesCanBePreparedAndHandedOffAcrossThreads() throws Exception { + var values = inputs(97); + var ash = model(values, 4); + var preparer = new ASHQueryPreparer(ash); + var pool = Executors.newFixedThreadPool(4); + try { + var tasks = new java.util.ArrayList>(); + for (int i = 0; i < 16; i++) { + final int ordinal = i; + tasks.add(pool.submit(() -> { + var state = preparer.newState(); + preparer.prepare(values[ordinal], state); + return state; + })); + } + for (int i = 0; i < tasks.size(); i++) { + var state = tasks.get(i).get(10, TimeUnit.SECONDS); + for (int d = 0; d < 64; d++) assertEquals(values[i].get(d), state.queryVector().get(d), 0f); + } + } finally { pool.shutdownNow(); } + } + + private static void assertSameResults(SearchResult a, SearchResult b) { + assertArrayEquals(a.getNodes(), b.getNodes()); + } + + @Test + public void realDiskSearchFilteringAndResumeUsePreparedStateWithoutReprojection() throws Exception { + var values = inputs(17); + var ravv = MockVectorValues.fromValues(values); + var graph = new TestUtil.FullyConnectedGraphIndex(0, List.of(17, 1)) { + @Override public List maxDegrees() { return List.of(16, 0); } + @Override public int getDimension() { return 64; } + }; + for (int bits : new int[]{1, 2, 3, 4, 9}) { + var ash = model(values, bits); + var encoded = ash.encodeAll(ravv, ForkJoinPool.commonPool()); + var path = Files.createTempFile("ash-prepared-query-", ".index"); + try { + var suppliers = new EnumMap>(FeatureId.class); + try (var view = graph.getView(); var writer = new OnDiskGraphIndexWriter.Builder(graph, path) + .with(new FusedASH(16, ash, 32)).with(new InlineVectors(64)).build()) { + suppliers.put(FeatureId.FUSED_ASH, n -> new FusedASH.State(view, encoded, n)); + suppliers.put(FeatureId.INLINE_VECTORS, n -> new InlineVectors.State(values[n])); + writer.write(suppliers); + } + try (var reader = new SimpleMappedReader.Supplier(path); var disk = OnDiskGraphIndex.load(reader, 0); + var direct = new GraphSearcher(disk); var queued = new GraphSearcher(disk)) { + var preparer = disk.queryPreparer(DOT).orElseThrow(); + var state = preparer.newState(); + var directView = (OnDiskGraphIndex.View) direct.getView(); + var queuedView = (OnDiskGraphIndex.View) queued.getView(); + for (int q = 0; q < 4; q++) { + preparer.prepareBatch(new VectorFloat[]{values[q]}, new PreparedQuery[]{state}, 1); + var provider = queuedView.scoreProviderFor(state, true); + var ordinary = new DefaultSearchScoreProvider( + directView.approximateScoreFunctionFor(values[q], DOT), directView.rerankerFor(values[q], DOT), DOT); + Bits accepted = n -> (n & 1) == 0; + assertSameResults(direct.search(ordinary, 3, 17, 0f, 0f, accepted), + queued.search(provider, 3, 17, 0f, 0f, accepted)); + assertSameResults(direct.resume(3, 17), queued.resume(3, 17)); + preparer.prepare(values[q + 1], state); + assertThrows(IllegalStateException.class, () -> provider.scoreFunction().similarityTo(0)); + } + assertThrows(UnsupportedOperationException.class, () -> disk.queryPreparer(VectorSimilarityFunction.COSINE)); + } + } finally { Files.deleteIfExists(path); } + } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java new file mode 100644 index 000000000..1b2d48b1f --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/quantization/TestUnnormalizedVectors.java @@ -0,0 +1,242 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.quantization; + +import io.github.jbellis.jvector.graph.GraphIndexBuilder; +import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; +import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; +import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider; +import io.github.jbellis.jvector.graph.similarity.DefaultSearchScoreProvider; +import io.github.jbellis.jvector.graph.similarity.ScoreFunction; +import io.github.jbellis.jvector.util.Bits; +import io.github.jbellis.jvector.vector.VectorSimilarityFunction; +import io.github.jbellis.jvector.vector.VectorUtil; +import io.github.jbellis.jvector.vector.VectorizationProvider; +import io.github.jbellis.jvector.vector.types.VectorFloat; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.ForkJoinPool; +import java.util.function.IntFunction; + +import static org.junit.Assert.assertTrue; + +/** + * Dot-product search over vectors whose lengths vary 100x must rank by the full dot product, norms included, for exact, + * PQ and ASH scoring, both by brute force and through a graph built with that scoring. The data is chosen so that a + * scorer that ignored norms (ranking by direction alone) would miss most of the true neighbors. Each scorer is judged + * against itself on the same directions at unit length, so its accuracy at a given budget does not decide the result: + * a scorer that keeps norm information is at least as accurate on the mixed lengths and clearly above norm-blind ranking. + * Graph searches that stop adaptively must engage on both datasets and find what searches stopping by the standard + * rule find. + */ +public class TestUnnormalizedVectors { + private static final int N = 1000, DIM = 64, QUERIES = 100, K = 10; + // Recall@10 over 100 queries moves by about 0.01 between backends and graph builds; allow several times that. + private static final double NOISE = 0.05; + // How far above norm-blind ranking a norm-aware scorer must be. + private static final double MARGIN = 0.1; + // How much recall adaptive stopping may give up against the standard rule. + private static final double ADAPTIVE_LOSS = 0.02; + + @Test + public void graphSearchRanksByTheFullDotProduct() throws Exception { + var vts = VectorizationProvider.getInstance().getVectorTypeSupport(); + var random = new Random(20261112); + List> base = new ArrayList<>(N), unitBase = new ArrayList<>(N); + for (int i = 0; i < N; i++) { + var v = randomVector(vts, random); + base.add(v); + unitBase.add(unit(v)); + } + List> queries = new ArrayList<>(QUERIES), unitQueries = new ArrayList<>(QUERIES); + for (int i = 0; i < QUERIES; i++) { + var q = randomVector(vts, random); + queries.add(q); + unitQueries.add(unit(q)); + } + + // The norm-blind ranking (cosine) against the dot-product truth: what a scorer ignoring norms would score. + List> dotTruth = truth(queries, base), cosineTruth = new ArrayList<>(); + for (var q : queries) cosineTruth.add(topK(i -> VectorUtil.cosine(q, base.get(i)))); + double normBlind = recall(cosineTruth, dotTruth); + System.out.format("norm-blind (cosine) recall@%d against the dot-product truth: %.3f%n", K, normBlind); + + // The same directions at unit length are the control: a scorer as accurate on mixed lengths as there loses + // nothing to the norms; one less accurate on mixed lengths does. + List failures = new ArrayList<>(); + var mixed = runAll("mixed lengths", base, queries, dotTruth); + var unitResults = runAll("unit length ", unitBase, unitQueries, truth(unitQueries, unitBase)); + for (var name : mixed.keySet()) { + double[] m = mixed.get(name), u = unitResults.get(name); + System.out.format("%-22s mixed scan %.3f graph %.3f adaptive %.3f | unit scan %.3f graph %.3f adaptive %.3f | mixed - unit %+.3f%n", + name, m[0], m[1], m[2], u[0], u[1], u[2], m[0] - u[0]); + for (int i = 0; i < 2; i++) { + String path = i == 0 ? "scan" : "graph"; + if (!(m[i] >= u[i] - NOISE)) failures.add(name + " " + path + " loses to norms: " + m[i] + " < unit " + u[i]); + if (!(m[i] >= normBlind + MARGIN)) failures.add(name + " " + path + " near norm-blind: " + m[i]); + } + for (var r : new double[][] {m, u}) { + String which = r == m ? " mixed" : " unit"; + if (!(r[2] >= r[1] - ADAPTIVE_LOSS)) failures.add(name + which + " adaptive " + r[2] + " < standard " + r[1]); + if (r[3] == 0) failures.add(name + which + " adaptive stopping never changed a search"); + } + } + assertTrue("the data must separate norm-aware from norm-blind ranking: " + normBlind, normBlind < 0.5); + assertTrue("must rank by the full dot product: " + failures, failures.isEmpty()); + } + + /** Recall of every scorer configuration on one dataset; budgets are matched at 16 and 32 bytes per vector. */ + private static java.util.Map runAll(String label, List> base, + List> queries, List> truth) + throws java.io.IOException { + var ravv = new ListRandomAccessVectorValues(base, DIM); + var vsf = VectorSimilarityFunction.DOT_PRODUCT; + // A list of vectors does not track lengths, so the exact scores report the bound dot-product adaptive stopping + // needs: the query's length times the largest stored length. + float maxNorm = 0; + for (var v : base) maxNorm = Math.max(maxNorm, (float) Math.sqrt(VectorUtil.dotProduct(v, v))); + final float largest = maxNorm; + var results = new java.util.LinkedHashMap(); + results.put("exact (256 B)", check(label, "exact (256 B)", BuildScoreProvider.randomAccessScoreProvider(ravv, vsf), + ravv, queries, truth, q -> { + var exact = DefaultSearchScoreProvider.exact(q, vsf, ravv).scoreFunction(); + float bound = (float) Math.sqrt(VectorUtil.dotProduct(q, q)) * largest; + return new ScoreFunction.ExactScoreFunction() { + @Override + public float similarityTo(int node) { + return exact.similarityTo(node); + } + + @Override + public float dotProductBound() { + return bound; + } + }; + })); + // PQ: 16 B = 16 subspaces of 4 dimensions; 32 B = 32 subspaces of 2; 256 centroids each. + for (int m : new int[] {16, 32}) { + var pqv = ProductQuantization.compute(ravv, m, 256, false).encodeAll(ravv, ForkJoinPool.commonPool()); + String name = "PQ " + m + "x8 (" + m + " B)"; + results.put(name, check(label, name, BuildScoreProvider.pqBuildScoreProvider(vsf, pqv), ravv, queries, truth, + q -> pqv.precomputedScoreFunctionFor(q, vsf))); + } + // ASH, one landmark, 5-byte header: 16 B = 44 dims x 2 bits or 22 x 4; 32 B = 54 x 4 (or 64 x 2 = 21 B). + int[][] ash = {{2, 44}, {4, 22}, {4, 54}, {2, 64}}; + for (int[] config : ash) { + int bits = config[0], dims = config[1]; + int totalBits = AsymmetricHashing.HEADER_BITS + dims * bits; + var ashv = AsymmetricHashing.initialize(ravv, AsymmetricHashing.RANDOM, totalBits, 1, bits) + .encodeAll(ravv, ForkJoinPool.commonPool()); + String name = "ASH " + dims + "x" + bits + " (" + (totalBits / 8) + " B)"; + results.put(name, check(label, name, BuildScoreProvider.ashBuildScoreProvider(vsf, ashv), ravv, queries, + truth, q -> ashv.precomputedScoreFunctionFor(q, vsf))); + } + return results; + } + + private static List> truth(List> queries, List> base) { + List> truth = new ArrayList<>(); + for (var q : queries) truth.add(topK(i -> VectorUtil.dotProduct(q, base.get(i)))); + return truth; + } + + private static VectorFloat unit(VectorFloat v) { + var u = v.copy(); + VectorUtil.l2normalize(u); + return u; + } + + /** Random direction, length log-uniform in [0.1, 10]. */ + private static VectorFloat randomVector(io.github.jbellis.jvector.vector.types.VectorTypeSupport vts, Random r) { + var v = vts.createFloatVector(DIM); + for (int d = 0; d < DIM; d++) v.set(d, (float) r.nextGaussian()); + VectorUtil.l2normalize(v); + VectorUtil.scale(v, (float) Math.pow(10, -1 + 2 * r.nextDouble())); + return v; + } + + /** + * Recall@K against {@code truth} by brute force, by graph search stopping by the standard rule, and by graph + * search stopping adaptively, then the fraction of queries whose adaptive search visited a different number of + * nodes: {scan, graph, adaptive, changed}. The graph is built with the same scoring. + */ + private static double[] check(String label, String name, BuildScoreProvider bsp, ListRandomAccessVectorValues ravv, + List> queries, List> truth, + java.util.function.Function, ScoreFunction> scorerFor) { + List> scan = new ArrayList<>(), graphResults = new ArrayList<>(), adaptiveResults = new ArrayList<>(); + int changed = 0; + ImmutableGraphIndex graph; + try (var builder = new GraphIndexBuilder(bsp, DIM, 16, 100, 1.2f, 1.2f, false)) { + graph = builder.build(ravv); + } catch (Exception e) { + throw new RuntimeException(e); + } + try (var searcher = new GraphSearcher(graph); var adaptive = new GraphSearcher(graph)) { + searcher.useAdaptiveTermination(false); + adaptive.useAdaptiveTermination(true); + for (var q : queries) { + var sf = scorerFor.apply(q); + scan.add(topK(sf::similarityTo)); + var result = searcher.search(new DefaultSearchScoreProvider(sf, null, VectorSimilarityFunction.DOT_PRODUCT), + K, 100, 0f, 0f, Bits.ALL); + graphResults.add(nodes(result)); + var adaptiveResult = adaptive.search(new DefaultSearchScoreProvider(sf, null, VectorSimilarityFunction.DOT_PRODUCT), + K, 100, 0f, 0f, Bits.ALL); + adaptiveResults.add(nodes(adaptiveResult)); + if (adaptiveResult.getVisitedCount() != result.getVisitedCount()) changed++; + } + } catch (Exception e) { + throw new RuntimeException(e); + } + double scanRecall = recall(scan, truth), graphRecall = recall(graphResults, truth); + double adaptiveRecall = recall(adaptiveResults, truth); + System.out.format("%s %-22s brute-force recall@%d %.3f, graph recall@%d %.3f, adaptive %.3f (%d of %d searches changed)%n", + label, name, K, scanRecall, K, graphRecall, adaptiveRecall, changed, queries.size()); + return new double[] {scanRecall, graphRecall, adaptiveRecall, changed / (double) queries.size()}; + } + + private static Set nodes(io.github.jbellis.jvector.graph.SearchResult result) { + var found = new HashSet(); + for (var ns : result.getNodes()) found.add(ns.node); + return found; + } + + private static Set topK(IntFunction score) { + Integer[] ids = new Integer[N]; + float[] scores = new float[N]; + for (int i = 0; i < N; i++) { + ids[i] = i; + scores[i] = score.apply(i); + } + Arrays.sort(ids, (a, b) -> Float.compare(scores[b], scores[a])); + return new HashSet<>(Arrays.asList(ids).subList(0, K)); + } + + private static double recall(List> found, List> truth) { + int hits = 0; + for (int i = 0; i < truth.size(); i++) { + for (int id : found.get(i)) if (truth.get(i).contains(id)) hits++; + } + return hits / (double) (truth.size() * K); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java new file mode 100644 index 000000000..db4f4d00b --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBatchingExecutor.java @@ -0,0 +1,460 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.util; + +import org.junit.Test; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.Assert.*; + +/** Tests real queued submission, bounded admission, failures, handoff and close. */ +public class TestBatchingExecutor { + private abstract static class IntOperation implements BatchingExecutor.Operation { + public Object batchKey(Integer value) { return this; } + public void prepareBatch(List values) throws Exception {} + public void execute(Integer value) throws Exception {} + } + + @Test(timeout=15000) + public void oneProducerAndOneWorkerPrepareEightWithoutEightCallerThreads() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var done = new CountDownLatch(9); + var maximumBatch = new AtomicInteger(); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(1, 9, 8, executor, new IntOperation() { + public void prepareBatch(List values) { + maximumBatch.accumulateAndGet(values.size(), Math::max); + assertTrue(values.size() <= 8); + } + public void execute(Integer value) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + } + public void complete(Integer value, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + assertTrue(service.trySubmit(0)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int i = 1; i < 9; i++) assertTrue(service.trySubmit(i)); + assertEquals(9, service.outstanding()); + assertFalse(service.trySubmit(10)); + release.countDown(); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertEquals(8, maximumBatch.get()); + assertEquals(1, service.batchHistogram()[1]); + assertEquals(1, service.batchHistogram()[8]); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void drainingABatchDoesNotRetainIdleSharedExecutorThreads() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var firstEntered = new CountDownLatch(1); + var firstRelease = new CountDownLatch(1); + var batchEntered = new CountDownLatch(1); + var batchRelease = new CountDownLatch(1); + var done = new CountDownLatch(9); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(1, 9, 8, executor, new IntOperation() { + public void execute(Integer value) throws Exception { + if (value == 0) { firstEntered.countDown(); firstRelease.await(); } + if (value == 1) { batchEntered.countDown(); batchRelease.await(); } + } + public void complete(Integer value, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + try { + assertTrue(service.trySubmit(0)); + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); + for (int i = 1; i < 9; i++) assertTrue(service.trySubmit(i)); + firstRelease.countDown(); + assertTrue(batchEntered.await(5, TimeUnit.SECONDS)); + batchRelease.countDown(); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertEquals(Integer.valueOf(42), executor.submit(() -> 42).get(5, TimeUnit.SECONDS)); + } finally { firstRelease.countDown(); batchRelease.countDown(); } + } finally { firstRelease.countDown(); batchRelease.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void sharedExecutorNeedsNoPermanentlyReservedThreads() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var completed = new CountDownLatch(1); + try (var service = new BatchingExecutor(4, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable error) { assertNull(error); completed.countDown(); } + })) { + assertTrue(service.trySubmit(1)); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + assertEquals(Integer.valueOf(7), executor.submit(() -> 7).get(5, TimeUnit.SECONDS)); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void completionFailureIsContainedAndReportedWithoutPoisoningLaterRequests() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var error = new IllegalStateException("callback failure"); + var second = new CountDownLatch(1); + var completed = new AtomicInteger(); + var service = new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable failure) { + assertNull(failure); + completed.incrementAndGet(); + if (request == 0) throw error; + second.countDown(); + } + }); + try { + assertTrue(service.trySubmit(0)); + assertTrue(service.trySubmit(1)); + assertTrue(second.await(5, TimeUnit.SECONDS)); + assertEquals(2, completed.get()); + assertEquals(1, service.completionFailureCount()); + assertSame(error, service.firstCompletionFailure()); + var failure = assertThrows(IllegalStateException.class, service::close); + assertSame(error, failure.getCause()); + assertEquals(0, service.outstanding()); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void keyFailureCompletionDoesNotHoldInternalLock() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var submitter = Executors.newSingleThreadExecutor(); + var done = new CountDownLatch(2); + var first = new IllegalArgumentException("key failure"); + var errors = new AtomicReference(); + var reference = new AtomicReference>(); + var service = new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public Object batchKey(Integer request) { if (request == 0) throw first; return null; } + public void complete(Integer request, Throwable failure) { + try { + if (request == 0) { + assertSame(first, failure); + assertTrue(submitter.submit(() -> reference.get().trySubmit(1)).get(5, TimeUnit.SECONDS)); + } else assertNull(failure); + } catch (Throwable error) { errors.compareAndSet(null, error); } + finally { done.countDown(); } + } + }); + reference.set(service); + try (service) { + assertTrue(service.trySubmit(0)); + assertTrue(done.await(7, TimeUnit.SECONDS)); + assertNull(errors.get()); + } finally { submitter.shutdownNow(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void rejectedDispatchCompletesAcceptedWorkAndLaterSubmissionCanSucceed() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var launches = new AtomicInteger(); + Executor sometimesRejects = work -> { + if (launches.getAndIncrement() == 0) throw new RejectedExecutionException("busy host"); + executor.execute(work); + }; + var failures = new AtomicInteger(); + var completed = new CountDownLatch(2); + try (var service = new BatchingExecutor(1, 8, 8, sometimesRejects, new IntOperation() { + public void complete(Integer request, Throwable failure) { + if (failure != null) { assertTrue(failure instanceof RejectedExecutionException); failures.incrementAndGet(); } + completed.countDown(); + } + })) { + assertTrue(service.trySubmit(0)); + assertTrue(service.trySubmit(1)); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + assertEquals(1, failures.get()); + assertEquals(0, service.completionFailureCount()); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void directExecutorDoesNotMakeAsynchronousSubmissionExecuteInline() { + var executions = new AtomicInteger(); + var error = new AtomicReference(); + try (var service = new BatchingExecutor(1, 8, 8, Runnable::run, new IntOperation() { + public void execute(Integer request) { executions.incrementAndGet(); } + public void complete(Integer request, Throwable failure) { error.set(failure); } + })) { + assertTrue(service.trySubmit(0)); + assertEquals(0, executions.get()); + assertTrue(error.get() instanceof RejectedExecutionException); + assertEquals(0, service.outstanding()); + } + } + + @Test + public void knownDiscardAndCallerRunsPoliciesAreRejectedBeforeAdmission() { + for (var policy : List.of(new ThreadPoolExecutor.DiscardPolicy(), + new ThreadPoolExecutor.DiscardOldestPolicy(), new ThreadPoolExecutor.CallerRunsPolicy())) { + var executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue(1), policy); + try { + assertThrows(IllegalArgumentException.class, + () -> new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable failure) {} + })); + } finally { executor.shutdownNow(); } + } + } + + @Test(timeout=15000) + public void deferredTaskMayLaterRunOnItsOriginalSubmittingThread() { + var scheduled = new ArrayBlockingQueue(1); + var executions = new AtomicInteger(); + var completed = new AtomicInteger(); + try (var service = new BatchingExecutor(1, 8, 8, scheduled::add, new IntOperation() { + public void execute(Integer request) { executions.incrementAndGet(); } + public void complete(Integer request, Throwable failure) { assertNull(failure); completed.incrementAndGet(); } + })) { + assertTrue(service.trySubmit(1)); + assertEquals(0, executions.get()); + scheduled.remove().run(); + assertEquals(1, executions.get()); + assertEquals(1, completed.get()); + } + } + + @Test(timeout=15000) + public void incompatibleQueuedRequestsUseTheFullWorkerBudget() throws Exception { + var executor = Executors.newFixedThreadPool(4); + var scheduled = new ArrayBlockingQueue(4); + var entered = new CountDownLatch(4); + var done = new CountDownLatch(4); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(4, 8, 8, scheduled::add, new IntOperation() { + public Object batchKey(Integer request) { return null; } + public void execute(Integer request) throws Exception { + entered.countDown(); + if (!entered.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("Budget underutilized"); + } + public void complete(Integer request, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + for (int i = 0; i < 4; i++) assertTrue(service.trySubmit(i)); + assertEquals(4, scheduled.size()); + for (int i = 0; i < 4; i++) executor.execute(scheduled.remove()); + assertTrue(done.await(7, TimeUnit.SECONDS)); + assertNull(failure.get()); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void closeWaitsForCallbacksAndPreservesInterruptStatus() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + var closer = Executors.newSingleThreadExecutor(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var service = new BatchingExecutor(1, 8, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable failure) { + entered.countDown(); + boolean interrupted = false; + for (;;) { + try { release.await(); break; } + catch (InterruptedException error) { interrupted = true; } + } + if (interrupted) Thread.currentThread().interrupt(); + } + }); + try { + assertTrue(service.trySubmit(0)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(0, service.outstanding()); + var closed = closer.submit(() -> { + Thread.currentThread().interrupt(); + service.close(); + return Thread.currentThread().isInterrupted(); + }); + assertThrows(TimeoutException.class, () -> closed.get(20, TimeUnit.MILLISECONDS)); + release.countDown(); + assertTrue(closed.get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); service.close(); executor.shutdownNow(); closer.shutdownNow(); } + } + + @Test(timeout=15000) + public void concurrentSubmitAndCloseCompleteEveryAcceptedRequestExactlyOnce() throws Exception { + var executor = Executors.newFixedThreadPool(4); + var producers = Executors.newFixedThreadPool(4); + var accepted = new AtomicInteger(); + var completions = new AtomicIntegerArray(2000); + var first = new CountDownLatch(1); + var start = new CountDownLatch(1); + var failure = new AtomicReference(); + var service = new BatchingExecutor(4, 32, 8, executor, new IntOperation() { + public void complete(Integer request, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + if (completions.incrementAndGet(request) != 1) + failure.compareAndSet(null, new AssertionError("Duplicate completion")); + } + }); + try { + var futures = new java.util.ArrayList>(); + for (int producer = 0; producer < 4; producer++) { + int offset = producer * 500; + futures.add(producers.submit(() -> { + start.await(); + for (int i = 0; i < 500; i++) { + try { + while (!service.trySubmit(offset + i)) Thread.yield(); + accepted.incrementAndGet(); first.countDown(); + } catch (RejectedExecutionException closed) { return null; } + } + return null; + })); + } + start.countDown(); + assertTrue(first.await(5, TimeUnit.SECONDS)); + service.close(); + for (var future : futures) future.get(5, TimeUnit.SECONDS); + assertNull(failure.get()); + int completed = 0; + for (int i = 0; i < 2000; i++) completed += completions.get(i); + assertEquals(accepted.get(), completed); + } finally { start.countDown(); service.close(); producers.shutdownNow(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void boundedRingsWrapUnderConcurrentMixedModelTraffic() throws Exception { + int count = 20000; + var executor = Executors.newFixedThreadPool(4); + var producers = Executors.newFixedThreadPool(4); + Object[] keys = {new Object(), new Object()}; + var done = new CountDownLatch(count); + var completed = new AtomicIntegerArray(count); + var failure = new AtomicReference(); + try (var service = new BatchingExecutor(4, 32, 8, executor, new IntOperation() { + public Object batchKey(Integer request) { return keys[(request / 3) & 1]; } + public void prepareBatch(List values) { + Object key = batchKey(values.get(0)); + for (Integer value : values) assertSame(key, batchKey(value)); + } + public void complete(Integer request, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + if (completed.incrementAndGet(request) != 1) + failure.compareAndSet(null, new AssertionError("Duplicate completion")); + done.countDown(); + } + })) { + var futures = new java.util.ArrayList>(); + for (int producer = 0; producer < 4; producer++) { + int offset = producer * (count / 4); + futures.add(producers.submit(() -> { + for (int i = 0; i < count / 4; i++) + while (!service.trySubmit(offset + i)) Thread.yield(); + })); + } + for (var future : futures) future.get(10, TimeUnit.SECONDS); + assertTrue(done.await(3, TimeUnit.SECONDS)); + assertNull(failure.get()); + long queries = 0; + var histogram = service.batchHistogram(); + for (int i = 1; i < histogram.length; i++) queries += i * histogram[i]; + assertEquals(count, queries); + for (int i = 0; i < count; i++) assertEquals(1, completed.get(i)); + } finally { executor.shutdownNow(); producers.shutdownNow(); } + } + + @Test(timeout=15000) + public void isolatedRequestsStartImmediatelyAtCapacityEightAndRecordsAreReused() throws Exception { + var executor = Executors.newFixedThreadPool(2); + var completed = new ArrayBlockingQueue(1); + var completedCount = new AtomicInteger(); + try (var service = new BatchingExecutor(2, 8, 8, executor, new IntOperation() { + public void complete(Integer value, Throwable error) { + assertNull(error); completedCount.incrementAndGet(); completed.add(value); + } + })) { + for (int i = 0; i < 100; i++) { + assertTrue(service.trySubmit(i)); + assertEquals(Integer.valueOf(i), completed.poll(5, TimeUnit.SECONDS)); + } + assertEquals(100, completedCount.get()); + assertEquals(100, service.batchHistogram()[1]); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void prepareAndExecutionFailuresCompleteRequestsAndCloseDrainsAcceptedWork() throws Exception { + var executor = Executors.newFixedThreadPool(3); + var counts = new AtomicIntegerArray(64); + var errors = new AtomicInteger(); + var service = new BatchingExecutor(3, 64, 8, executor, new IntOperation() { + public Object batchKey(Integer value) { return null; } + public void prepareBatch(List values) { + if (values.get(0) == 7) throw new IllegalArgumentException("prepare failure"); + } + public void execute(Integer value) { + if (value == 31) throw new IllegalStateException("search failure"); + } + public void complete(Integer value, Throwable error) { + counts.incrementAndGet(value); + if (error != null) errors.incrementAndGet(); + } + }); + try { + for (int i = 0; i < 64; i++) assertTrue(service.trySubmit(i)); + service.close(); + assertEquals(0, service.outstanding()); + for (int i = 0; i < 64; i++) assertEquals(1, counts.get(i)); + assertEquals(2, errors.get()); + assertThrows(RejectedExecutionException.class, () -> service.trySubmit(0)); + service.close(); + } finally { executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void differentModelsNeverSharePreparationAndPreparedWorkCanChangeWorkers() throws Exception { + var executor = Executors.newFixedThreadPool(4); + Object[] keys = {new Object(), new Object()}; + var done = new CountDownLatch(128); + var failure = new AtomicReference(); + var preparedThread = new ConcurrentHashMap(); + var changedWorker = new AtomicBoolean(); + try (var service = new BatchingExecutor(4, 128, 8, executor, new IntOperation() { + public Object batchKey(Integer value) { return keys[(value / 16) & 1]; } + public void prepareBatch(List values) { + Object key = batchKey(values.get(0)); + for (Integer value : values) { + assertSame(key, batchKey(value)); + preparedThread.put(value, Thread.currentThread()); + } + } + public void execute(Integer value) { + if (preparedThread.get(value) != Thread.currentThread()) changedWorker.set(true); + } + public void complete(Integer value, Throwable error) { + if (error != null) failure.compareAndSet(null, error); + done.countDown(); + } + })) { + for (int i = 0; i < 128; i++) assertTrue(service.trySubmit(i)); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertEquals(128, preparedThread.size()); + // Handoff is allowed rather than mandatory; scheduling determines the owner. + } finally { executor.shutdownNow(); } + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBlasThreads.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBlasThreads.java new file mode 100644 index 000000000..1f19c331b --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestBlasThreads.java @@ -0,0 +1,60 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.util; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** BLAS thread scopes nest per thread and restore the enclosing count. */ +public class TestBlasThreads { + @Test + public void scopesNestAndRestore() { + assertEquals(0, BlasThreads.current()); + try (var outer = BlasThreads.limit(8)) { + assertEquals(8, BlasThreads.current()); + try (var single = BlasThreads.singleUnlessLimited()) { + assertEquals("An enclosing limit applies", 8, BlasThreads.current()); + } + try (var inner = BlasThreads.limit(2)) { + assertEquals(2, BlasThreads.current()); + } + assertEquals(8, BlasThreads.current()); + } + assertEquals(0, BlasThreads.current()); + try (var single = BlasThreads.singleUnlessLimited()) { + assertEquals(1, BlasThreads.current()); + } + assertEquals(0, BlasThreads.current()); + } + + @Test + public void scopesAreThreadLocal() throws Exception { + try (var scope = BlasThreads.limit(4)) { + int[] seen = new int[1]; + var other = new Thread(() -> seen[0] = BlasThreads.current()); + other.start(); + other.join(); + assertEquals(0, seen[0]); + } + } + + @Test + public void countMustBePositive() { + assertThrows(IllegalArgumentException.class, () -> BlasThreads.limit(0)); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java new file mode 100644 index 000000000..c53e271c3 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestCallerBatchProcessor.java @@ -0,0 +1,535 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.util; + +import org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.Assert.*; + +public class TestCallerBatchProcessor { + private static void awaitWaiting(CallerBatchProcessor processor, int expected) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (processor.waitingCount() != expected && System.nanoTime() < deadline) Thread.yield(); + assertEquals(expected, processor.waitingCount()); + } + + private abstract static class IntegerOperation implements CallerBatchProcessor.Operation { + public void applyBatch(List input, List output) throws Exception { + for (int i = 0; i < input.size(); i++) apply(input.get(i), output.get(i)); + } + } + + @Test(timeout=10000) + public void loneCallsRunImmediatelyOnTheCallerAtBothBatchLimits() { + for (int limit : new int[]{1,8}) { + Thread owner = Thread.currentThread(); + var processor = new CallerBatchProcessor<>(2, limit, new IntegerOperation() { + public void apply(Integer value, int[] output) { + assertSame(owner, Thread.currentThread()); + output[0] = value; + } + }); + int[] out = new int[1]; + for (int i = 0; i < 100; i++) { processor.process(i, out); assertEquals(i, out[0]); } + assertEquals(100, processor.batchHistogram()[1]); + assertEquals(0, processor.waitingCount()); + } + } + + @Test(timeout=15000) + public void concurrentLeadersCombineOnlyExistingFollowersAndPublishOwnedOutputs() throws Exception { + var entered = new CountDownLatch(2); + var release = new CountDownLatch(1); + var active = new AtomicInteger(); + var maximum = new AtomicInteger(); + var processor = new CallerBatchProcessor<>(2, 3, new CallerBatchProcessor.Operation() { + private void begin() { maximum.accumulateAndGet(active.incrementAndGet(), Math::max); } + public void apply(Integer value, int[] out) throws Exception { + begin(); + try { if (value < 2) { entered.countDown(); release.await(); } out[0] = value * 7; } + finally { active.decrementAndGet(); } + } + public void applyBatch(List input, List output) { + begin(); + try { + assertTrue(input.size() <= 3); + for (int i = 0; i < input.size(); i++) output.get(i)[0] = input.get(i) * 7; + try { input.clear(); fail("Batch input view must be read-only"); } + catch (UnsupportedOperationException expected) { } + } finally { active.decrementAndGet(); } + } + }); + var executor = Executors.newFixedThreadPool(9); + try { + var futures = new ArrayList>(); + for (int i = 0; i < 2; i++) { + final int value = i; + futures.add(executor.submit(() -> { int[] out = new int[1]; processor.process(value, out); assertEquals(value * 7, out[0]); })); + } + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(2, maximum.get()); + for (int i = 2; i < 9; i++) { + final int value = i; + futures.add(executor.submit(() -> { + Thread original = Thread.currentThread(); + int[] out = new int[1]; processor.process(value, out); + assertSame(original, Thread.currentThread()); + assertEquals(value * 7, out[0]); + })); + } + awaitWaiting(processor, 7); + release.countDown(); + for (var future : futures) future.get(5, TimeUnit.SECONDS); + long[] histogram = processor.batchHistogram(); + assertEquals(2, maximum.get()); + assertTrue(histogram[2] + histogram[3] > 0); + assertEquals(9, histogram[1] + 2 * histogram[2] + 3 * histogram[3]); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=20000) + public void repeatedSlotReuseDoesNotLoseOrMisrouteRequests() throws Exception { + var processor = new CallerBatchProcessor<>(2, 8, new IntegerOperation() { + public void apply(Integer value, int[] out) { Thread.yield(); out[0] = value; } + }); + var executor = Executors.newFixedThreadPool(16); + var start = new CountDownLatch(1); + try { + var futures = new ArrayList>(); + for (int thread = 0; thread < 16; thread++) { + final int prefix = thread * 1000; + futures.add(executor.submit(() -> { + start.await(); + int[] output = new int[1]; + for (int i = 0; i < 500; i++) { + processor.process(prefix + i, output); + assertEquals(prefix + i, output[0]); + } + return null; + })); + } + start.countDown(); + for (var future : futures) future.get(10, TimeUnit.SECONDS); + long[] histogram = processor.batchHistogram(); + long requests = 0; + for (int i = 1; i < histogram.length; i++) requests += i * histogram[i]; + assertEquals(8000, requests); + assertEquals(0, processor.waitingCount()); + } finally { start.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void batchFailureIsPublishedAndDoesNotStrandTheNextLeader() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var processor = new CallerBatchProcessor<>(1, 2, new IntegerOperation() { + public void apply(Integer value, int[] out) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + if (value == 2) throw new IllegalArgumentException("batch failure"); + out[0] = value; + } + }); + var executor = Executors.newFixedThreadPool(4); + try { + var first = executor.submit(() -> processor.process(0, new int[1])); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var one = executor.submit(() -> processor.process(1, new int[1])); + awaitWaiting(processor, 1); + var two = executor.submit(() -> processor.process(2, new int[1])); + awaitWaiting(processor, 2); + var three = executor.submit(() -> { int[] out = new int[1]; processor.process(3, out); return out[0]; }); + awaitWaiting(processor, 3); + release.countDown(); first.get(5, TimeUnit.SECONDS); + for (var failed : List.of(one, two)) { + try { failed.get(5, TimeUnit.SECONDS); fail("Missing batch failure"); } + catch (ExecutionException expected) { assertTrue(expected.getCause() instanceof IllegalArgumentException); } + } + assertEquals(Integer.valueOf(3), three.get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test(timeout=15000) + public void interruptedFollowerKeepsItsBuffersAliveUntilCompletion() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var completed = new CountDownLatch(1); + var interrupted = new AtomicBoolean(); + var processor = new CallerBatchProcessor<>(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] out) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + out[0] = value; + } + }); + var executor = Executors.newSingleThreadExecutor(); + Thread follower = new Thread(() -> { + int[] output = new int[1]; processor.process(17, output); + assertEquals(17, output[0]); + interrupted.set(Thread.currentThread().isInterrupted()); completed.countDown(); + }); + try { + var leader = executor.submit(() -> processor.process(0, new int[1])); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + follower.start(); awaitWaiting(processor, 1); follower.interrupt(); + assertFalse(completed.await(50, TimeUnit.MILLISECONDS)); + release.countDown(); leader.get(5, TimeUnit.SECONDS); + assertTrue(completed.await(5, TimeUnit.SECONDS)); assertTrue(interrupted.get()); + } finally { release.countDown(); follower.join(5000); executor.shutdownNow(); } + } + + @Test(timeout=10000) + public void reentrantOperationFailsWithoutLeakingLeaderCapacity() { + var reference = new AtomicReference>(); + var processor = new CallerBatchProcessor<>(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] out) { + if (value == 0) reference.get().process(1, out); + out[0] = value; + } + }); + reference.set(processor); + try { processor.process(0, new int[1]); fail("Reentrant operation must fail"); } + catch (IllegalStateException expected) { } + int[] out = new int[1]; processor.process(2, out); assertEquals(2, out[0]); + } + @Test + public void repeatedSingletonDispatchDoesNotAllocateRequestObjects() { + var threadBean = java.lang.management.ManagementFactory.getThreadMXBean(); + org.junit.Assume.assumeTrue(threadBean instanceof com.sun.management.ThreadMXBean); + var bean = (com.sun.management.ThreadMXBean) threadBean; + org.junit.Assume.assumeTrue(bean.isThreadAllocatedMemorySupported()); + if (!bean.isThreadAllocatedMemoryEnabled()) bean.setThreadAllocatedMemoryEnabled(true); + var processor = CallerBatchProcessor.opportunistic(8, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { output[0] = value; } + }); + int[] output = new int[1]; + for (int i = 0; i < 50000; i++) processor.process(1, output); + long thread = Thread.currentThread().getId(); + long before = bean.getThreadAllocatedBytes(thread); + for (int i = 0; i < 10000; i++) processor.process(1, output); + long allocated = bean.getThreadAllocatedBytes(thread) - before; + assertEquals(0, allocated); + assertEquals(1, output[0]); + } + + @Test(timeout=15000) + public void concurrentHistogramSnapshotsDoNotChangeDispatchOrFinalCounts() throws Exception { + // Also exercises leader counts above a machine-word bitmap's capacity. + var processor = new CallerBatchProcessor<>(65, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { output[0] = value; } + }); + var pool = Executors.newFixedThreadPool(8); + var stop = new AtomicBoolean(); + var observer = new Thread(() -> { + while (!stop.get()) { + processor.batchHistogram(); + Thread.yield(); + } + }); + observer.start(); + try { + var work = new ArrayList>(); + for (int worker = 0; worker < 8; worker++) work.add(() -> { + int[] out = new int[1]; + for (int i = 0; i < 1000; i++) { + processor.process(1, out); + assertEquals(1, out[0]); + } + return null; + }); + for (var future : pool.invokeAll(work)) future.get(); + assertEquals(8000, processor.batchHistogram()[1]); + assertEquals(0, processor.waitingCount()); + } finally { + stop.set(true); + observer.join(5000); + pool.shutdownNow(); + } + } + + + @Test(timeout=15000) + public void pendingInputsAreServedInFifoOrderRegardlessOfCombinerThread() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var order = new ArrayList(); + var processor = new CallerBatchProcessor<>(1, 3, new IntegerOperation() { + public void apply(Integer value, int[] output) throws Exception { + if (value == 0) { entered.countDown(); release.await(); } + order.add(value); + output[0] = value; + } + }); + var pool = Executors.newFixedThreadPool(8); + try { + var work = new ArrayList>(); + work.add(pool.submit(() -> processor.process(0, new int[1]))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int i = 1; i < 8; i++) { + final int value = i; + work.add(pool.submit(() -> { + int[] output = new int[1]; + processor.process(value, output); + assertEquals(value, output[0]); + })); + awaitWaiting(processor, i); + } + release.countDown(); + for (var future : work) future.get(5, TimeUnit.SECONDS); + assertEquals(List.of(0, 1, 2, 3, 4, 5, 6, 7), order); + } finally { release.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=20000) + public void contendedDispatchReusesRequestsAndBatchViewsWithoutJavaAllocation() throws Exception { + var threadBean = java.lang.management.ManagementFactory.getThreadMXBean(); + org.junit.Assume.assumeTrue(threadBean instanceof com.sun.management.ThreadMXBean); + var bean = (com.sun.management.ThreadMXBean) threadBean; + org.junit.Assume.assumeTrue(bean.isThreadAllocatedMemorySupported()); + if (!bean.isThreadAllocatedMemoryEnabled()) bean.setThreadAllocatedMemoryEnabled(true); + var processor = new CallerBatchProcessor<>(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { + for (int i = 0; i < 100; i++) Thread.onSpinWait(); + output[0] = value; + } + }); + var pool = Executors.newFixedThreadPool(8); + try { + var work = new ArrayList>(); + for (int worker = 0; worker < 8; worker++) work.add(() -> { + int[] output = new int[1]; + for (int i = 0; i < 20000; i++) processor.process(1, output); + // A JIT deoptimization inside a window can make the JVM's own VarHandle linkage allocate, so measure + // up to three windows and require one to be allocation-free. A real allocation shows up in every one. + long tid = Thread.currentThread().getId(); + long bytes = Long.MAX_VALUE; + for (int window = 0; window < 3 && bytes != 0; window++) { + long before = bean.getThreadAllocatedBytes(tid); + for (int i = 0; i < 2000; i++) processor.process(1, output); + bytes = Math.min(bytes, bean.getThreadAllocatedBytes(tid) - before); + } + assertEquals(1, output[0]); + return bytes; + }); + for (var result : pool.invokeAll(work)) assertEquals(0L, result.get().longValue()); + long[] histogram = processor.batchHistogram(); + long combined = 0; + for (int size = 2; size < histogram.length; size++) combined += histogram[size]; + assertTrue("Must exercise contended batch dispatch", combined > 0); + } finally { pool.shutdownNow(); } + } + + @Test(timeout=20000) + public void completionRacingWithParkingNeverLosesTheWakeup() throws Exception { + var processor = CallerBatchProcessor.opportunistic(8, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) { output[0] = value; } + }); + var pool = Executors.newFixedThreadPool(16); + var ready = new CountDownLatch(16); + var start = new CountDownLatch(1); + try { + var work = new ArrayList>(); + for (int worker = 0; worker < 16; worker++) work.add(() -> { + int[] output = new int[1]; + ready.countDown(); + start.await(); + for (int i = 0; i < 10000; i++) { + processor.process(1, output); + assertEquals(1, output[0]); + } + return null; + }); + var futures = new ArrayList>(); + for (var task : work) futures.add(pool.submit(task)); + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + for (var future : futures) future.get(10, TimeUnit.SECONDS); + long total = 0; + long[] histogram = processor.batchHistogram(); + for (int size = 1; size < histogram.length; size++) total += size * histogram[size]; + assertEquals(160000, total); + } finally { start.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=20000) + public void opportunisticBypassUsesTheCallerBudgetAndDrainsOverflowBeforeReturningToDirect() throws Exception { + var directEntered = new CountDownLatch(4); + var directRelease = new CountDownLatch(1); + var overflowEntered = new CountDownLatch(1); + var overflowRelease = new CountDownLatch(1); + var active = new AtomicInteger(); + var maximum = new AtomicInteger(); + var batches = new AtomicInteger(); + var lastCaller = new AtomicReference(); + var processor = CallerBatchProcessor.opportunistic(4, 8, + new CallerBatchProcessor.Operation() { + public void apply(Integer value, int[] output) throws Exception { + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + try { + if (value < 4) { directEntered.countDown(); directRelease.await(); } + if (value == 4) { overflowEntered.countDown(); overflowRelease.await(); } + if (value == 13) lastCaller.set(Thread.currentThread()); + output[0] = value; + } finally { active.decrementAndGet(); } + } + public void applyBatch(List input, List output) { + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + try { + batches.incrementAndGet(); + for (int i = 0; i < input.size(); i++) output.get(i)[0] = input.get(i); + } finally { active.decrementAndGet(); } + } + }); + var pool = Executors.newFixedThreadPool(13); + try { + var work = new ArrayList>(); + for (int value = 0; value < 4; value++) { + final int v = value; + work.add(pool.submit(() -> processor.process(v, new int[1]))); + } + assertTrue(directEntered.await(5, TimeUnit.SECONDS)); + assertEquals(4, maximum.get()); // No premature serialization at the combiner limit of one. + work.add(pool.submit(() -> processor.process(4, new int[1]))); + assertTrue(overflowEntered.await(5, TimeUnit.SECONDS)); + assertEquals(5, maximum.get()); // Four direct calls plus one combining call. + for (int value = 5; value < 12; value++) { + final int v = value; + work.add(pool.submit(() -> { + int[] output = new int[1]; processor.process(v, output); assertEquals(v, output[0]); + })); + } + awaitWaiting(processor, 7); + directRelease.countDown(); + for (int k = 0; k < 4; k++) work.get(k).get(5, TimeUnit.SECONDS); + work.add(pool.submit(() -> processor.process(12, new int[1]))); + awaitWaiting(processor, 8); // Released direct slots cannot barge ahead of queued work. + overflowRelease.countDown(); + for (var result : work) result.get(5, TimeUnit.SECONDS); + assertTrue(batches.get() > 0); + assertTrue(maximum.get() <= 5); + int[] output = new int[1]; + processor.process(13, output); + assertEquals(13, output[0]); + assertSame(Thread.currentThread(), lastCaller.get()); + long total = 0; + long[] histogram = processor.batchHistogram(); + for (int b = 1; b < histogram.length; b++) total += b * histogram[b]; + assertEquals(14, total); + } finally { directRelease.countDown(); overflowRelease.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=15000) + public void freeDirectCapacityDoesNotWaitForAnExecutingOverflowOperation() throws Exception { + var directEntered = new CountDownLatch(1); + var directRelease = new CountDownLatch(1); + var overflowEntered = new CountDownLatch(1); + var overflowRelease = new CountDownLatch(1); + var processor = CallerBatchProcessor.opportunistic(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) throws Exception { + if (value == 0) { directEntered.countDown(); directRelease.await(); } + if (value == 1) { overflowEntered.countDown(); overflowRelease.await(); } + output[0] = value; + } + }); + var pool = Executors.newFixedThreadPool(3); + try { + var direct = pool.submit(() -> processor.process(0, new int[1])); + assertTrue(directEntered.await(5, TimeUnit.SECONDS)); + var overflow = pool.submit(() -> processor.process(1, new int[1])); + assertTrue(overflowEntered.await(5, TimeUnit.SECONDS)); + directRelease.countDown(); direct.get(5, TimeUnit.SECONDS); + assertEquals(0, processor.waitingCount()); + // The earlier overflow call is already executing, not waiting. It + // must not make a new call wait with an available direct slot. + var next = pool.submit(() -> { + int[] output = new int[1]; processor.process(2, output); return output[0]; + }); + assertEquals(Integer.valueOf(2), next.get(1, TimeUnit.SECONDS)); + overflowRelease.countDown(); overflow.get(5, TimeUnit.SECONDS); + assertEquals(3, processor.batchHistogram()[1]); + } finally { directRelease.countDown(); overflowRelease.countDown(); pool.shutdownNow(); } + } + + @Test(timeout=15000) + public void realBatchesRetainSharingUntilTheirBorrowedInputsComplete() throws Exception { + var directEntered = new CountDownLatch(1); + var directRelease = new CountDownLatch(1); + var overflowEntered = new CountDownLatch(1); + var overflowRelease = new CountDownLatch(1); + var batchEntered = new CountDownLatch(1); + var batchRelease = new CountDownLatch(1); + var processor = CallerBatchProcessor.opportunistic(1, 8, new IntegerOperation() { + public void apply(Integer value, int[] output) throws Exception { + if (value == 0) { directEntered.countDown(); directRelease.await(); } + if (value == 1) { overflowEntered.countDown(); overflowRelease.await(); } + output[0] = value; + } + public void applyBatch(List input, List output) throws Exception { + assertEquals(2, input.size()); + batchEntered.countDown(); batchRelease.await(); + super.applyBatch(input, output); + } + }); + var pool = Executors.newFixedThreadPool(5); + try { + var first = pool.submit(() -> processor.process(0, new int[1])); + assertTrue(directEntered.await(5, TimeUnit.SECONDS)); + var overflow = pool.submit(() -> processor.process(1, new int[1])); + assertTrue(overflowEntered.await(5, TimeUnit.SECONDS)); + var two = pool.submit(() -> processor.process(2, new int[1])); + awaitWaiting(processor, 1); + var three = pool.submit(() -> processor.process(3, new int[1])); + awaitWaiting(processor, 2); + overflowRelease.countDown(); overflow.get(5, TimeUnit.SECONDS); + assertTrue(batchEntered.await(5, TimeUnit.SECONDS)); + directRelease.countDown(); first.get(5, TimeUnit.SECONDS); + var next = pool.submit(() -> { + int[] output = new int[1]; processor.process(4, output); return output[0]; + }); + awaitWaiting(processor, 1); + batchRelease.countDown(); + two.get(5, TimeUnit.SECONDS); three.get(5, TimeUnit.SECONDS); + assertEquals(Integer.valueOf(4), next.get(5, TimeUnit.SECONDS)); + assertEquals(3, processor.batchHistogram()[1]); + assertEquals(1, processor.batchHistogram()[2]); + } finally { + directRelease.countDown(); overflowRelease.countDown(); batchRelease.countDown(); pool.shutdownNow(); + } + } + + @Test(timeout=10000) + public void opportunisticBypassReleasesCapacityAfterFailureAndRejectsInvalidBudgets() { + var operation = new IntegerOperation() { + public void apply(Integer value, int[] output) { + if (value == 0) throw new IllegalArgumentException("failure"); + output[0] = value; + } + }; + for (int parallelism : new int[]{1, 4, 8}) { + var processor = CallerBatchProcessor.opportunistic(parallelism, 8, operation); + for (int i = 0; i < parallelism + 2; i++) { + try { processor.process(0, new int[1]); fail("Missing failure"); } + catch (IllegalArgumentException expected) { } + } + int[] output = new int[1]; processor.process(7, output); assertEquals(7, output[0]); + } + try { CallerBatchProcessor.opportunistic(0, 8, operation); fail("Invalid budget"); } + catch (IllegalArgumentException expected) { } + } + +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java index ac4695bbc..9abe4662a 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestIntMap.java @@ -37,6 +37,7 @@ public class TestIntMap extends RandomizedTest { @Test public void testInsert() { testInsertInternal(new DenseIntMap<>(100)); + testInsertInternal(new PagedIntMap<>(100)); testInsertInternal(new SparseIntMap<>()); } @@ -55,6 +56,7 @@ private void testInsertInternal(IntMap map) { @Test public void testUpdate() { testUpdateInternal(new DenseIntMap<>(100)); + testUpdateInternal(new PagedIntMap<>(100)); testUpdateInternal(new SparseIntMap<>()); } @@ -74,6 +76,7 @@ private void testUpdateInternal(IntMap map) { @Test public void testRemove() { testRemoveInternal(new DenseIntMap<>(100)); + testRemoveInternal(new PagedIntMap<>(100)); testRemoveInternal(new SparseIntMap<>()); } @@ -95,6 +98,7 @@ private void testRemoveInternal(IntMap map) { public void testConcurrency() throws InterruptedException { for (int i = 0; i < 100; i++) { testConcurrencyOnce(new DenseIntMap<>(100)); + testConcurrencyOnce(new PagedIntMap<>(100)); testConcurrencyOnce(new SparseIntMap<>()); } } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestPagedIntMap.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestPagedIntMap.java new file mode 100644 index 000000000..ee6d32988 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/util/TestPagedIntMap.java @@ -0,0 +1,184 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.jbellis.jvector.util; + +import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** PagedIntMap-specific behavior: page boundaries, growth, and no lost updates while growing. */ +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class TestPagedIntMap extends RandomizedTest { + + @Test + public void testPageBoundariesAndSparseGrowth() { + var map = new PagedIntMap(1); + int page = PagedIntMap.PAGE_SIZE; + int[] keys = {0, page - 1, page, page + 1, 3 * page - 1, 10 * page, 1_000_003}; + for (int k : keys) { + Assert.assertNull(map.get(k)); + Assert.assertTrue(map.compareAndPut(k, null, "v" + k)); + } + Assert.assertEquals(keys.length, map.size()); + for (int k : keys) Assert.assertEquals("v" + k, map.get(k)); + // Gaps read as null, including inside and beyond installed pages. + Assert.assertNull(map.get(page + 2)); + Assert.assertNull(map.get(5 * page)); + Assert.assertNull(map.get(Integer.MAX_VALUE)); + Assert.assertNull(map.remove(Integer.MAX_VALUE)); + } + + @Test + public void testLargestKey() { + var map = new PagedIntMap(0); + Assert.assertTrue(map.compareAndPut(Integer.MAX_VALUE, null, "max")); + Assert.assertEquals("max", map.get(Integer.MAX_VALUE)); + Assert.assertEquals(1, map.size()); + } + + @Test + public void testArgumentChecks() { + var map = new PagedIntMap(16); + Assert.assertThrows(IllegalArgumentException.class, () -> map.compareAndPut(1, null, null)); + Assert.assertThrows(IndexOutOfBoundsException.class, () -> map.compareAndPut(-1, null, "x")); + Assert.assertThrows(IndexOutOfBoundsException.class, () -> map.get(-1)); + Assert.assertThrows(IllegalArgumentException.class, () -> new PagedIntMap(-1)); + } + + @Test + public void testCompareAndPutSemantics() { + var map = new PagedIntMap(4); + Assert.assertTrue(map.compareAndPut(7, null, "a")); + Assert.assertFalse("insert over existing must fail", map.compareAndPut(7, null, "b")); + Assert.assertFalse("stale expected value must fail", map.compareAndPut(7, "zzz", "b")); + Assert.assertTrue(map.compareAndPut(7, "a", "b")); + Assert.assertEquals(1, map.size()); + Assert.assertEquals("b", map.remove(7)); + Assert.assertNull(map.remove(7)); + Assert.assertEquals(0, map.size()); + } + + @Test + public void testForEachInKeyOrder() { + var map = new PagedIntMap(1); + int[] keys = {5, 4096, 4097, 9000, 70_000}; + for (int i = keys.length - 1; i >= 0; i--) map.compareAndPut(keys[i], null, keys[i]); + List seen = new ArrayList<>(); + map.forEach((k, v) -> { Assert.assertEquals(Integer.valueOf(k), v); seen.add(k); }); + Assert.assertEquals(List.of(5, 4096, 4097, 9000, 70_000), seen); + } + + /** + * The race DenseIntMap's lock prevents: a CAS landing while the storage grows. Threads keep + * incrementing counters on low keys with CAS loops while other threads force growth by + * inserting ever-higher keys. Any lost update shows up as a counter below its expected total. + */ + @Test + public void testNoLostUpdatesWhileGrowing() throws Exception { + for (int round = 0; round < 5; round++) { + var map = new PagedIntMap(1); + int counters = 64, incrementers = 8, incrementsEach = 20_000, growers = 4, growKeys = 300_000; + for (int k = 0; k < counters; k++) map.compareAndPut(k, null, 0); + var start = new CountDownLatch(1); + var failure = new AtomicReference(); + List threads = new ArrayList<>(); + for (int t = 0; t < incrementers; t++) { + final int seed = t; + threads.add(new Thread(() -> { + try { + start.await(); + for (int i = 0; i < incrementsEach; i++) { + int key = (seed * 31 + i) % counters; + while (true) { + Integer old = map.get(key); + if (map.compareAndPut(key, old, old + 1)) break; + } + } + } catch (Throwable e) { failure.compareAndSet(null, e); } + })); + } + for (int t = 0; t < growers; t++) { + final int offset = t; + threads.add(new Thread(() -> { + try { + start.await(); + for (int i = 0; i < growKeys; i++) { + int key = counters + i * growers + offset; + Assert.assertTrue(map.compareAndPut(key, null, -key)); + } + } catch (Throwable e) { failure.compareAndSet(null, e); } + })); + } + threads.forEach(Thread::start); + start.countDown(); + for (Thread t : threads) t.join(); + if (failure.get() != null) throw new AssertionError(failure.get()); + + long total = 0; + for (int k = 0; k < counters; k++) total += map.get(k); + Assert.assertEquals((long) incrementers * incrementsEach, total); + Assert.assertEquals(counters + growers * growKeys, map.size()); + for (int i = 0; i < growers * growKeys; i++) { + int key = counters + i; + Assert.assertEquals(Integer.valueOf(-key), map.get(key)); + } + } + } + + /** Readers running during growth never see a value disappear once observed. */ + @Test + public void testReadersDuringGrowth() throws Exception { + var map = new PagedIntMap(1); + int keys = 400_000; + var done = new AtomicBoolean(); + var failure = new AtomicReference(); + var writer = new Thread(() -> { + for (int k = 0; k < keys; k++) map.compareAndPut(k, null, k); + done.set(true); + }); + List readers = new ArrayList<>(); + for (int r = 0; r < 4; r++) { + readers.add(new Thread(() -> { + try { + int highestSeen = -1; + while (!done.get()) { + // Values are inserted in key order, so everything at or below a seen key must be present. + int probe = Math.max(0, highestSeen); + Integer v = map.get(probe); + if (v != null) { + Assert.assertEquals(Integer.valueOf(probe), v); + highestSeen = probe + 1; + } + if (highestSeen > 0) Assert.assertNotNull(map.get(highestSeen - 1)); + } + } catch (Throwable e) { failure.compareAndSet(null, e); } + })); + } + readers.forEach(Thread::start); + writer.start(); + writer.join(); + for (Thread t : readers) t.join(); + if (failure.get() != null) throw new AssertionError(failure.get()); + Assert.assertEquals(keys, map.size()); + } +} diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/TestASHNativeScoring.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/TestASHNativeScoring.java new file mode 100644 index 000000000..f533ba1d6 --- /dev/null +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/vector/TestASHNativeScoring.java @@ -0,0 +1,110 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import io.github.jbellis.jvector.quantization.ASHScorer; +import io.github.jbellis.jvector.quantization.FusedASHLayout; +import java.util.Arrays; +import java.util.Random; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** Native array contracts, layout tails, IEEE headers, and critical/ordinary FFM agreement. */ +public class TestASHNativeScoring { + @Test + public void nativeNeighborhoodMatchesDecodedLutForWidthsTailsAndHeaders() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + org.junit.Assume.assumeTrue(backend.supportsAshNeighborhoodScoring()); + var random = new Random(527399); + for (int groups : new int[] {1, 2, 7, 8, 9, 16, 17, 33, 65, 128, 256, 512, 1536}) { + float[] lut = new float[groups * 16]; + for (int i = 0; i < lut.length; i++) lut[i] = random.nextFloat() - .5f; + for (int blockSize : new int[] {8, 16, 32}) { + for (int capacity : new int[] {1, 7, 23, 32, 37, 64, 128, 129}) { + // Four bits/dimension makes quantizedDim equal to the group count. + byte[] codes = new byte[FusedASHLayout.featureSize(capacity, groups, 4, blockSize)]; + random.nextBytes(codes); + for (int lane = 0; lane < capacity; lane++) { + int offset = FusedASHLayout.blockOffset(lane / blockSize, groups, 4, blockSize); + FusedASHLayout.writeLaneHeader(codes, offset, lane % blockSize, groups, 4, blockSize, + random.nextFloat(), random.nextFloat() - .5f, (byte) 0); + } + float landmarkDot = random.nextFloat() - .5f; + float[] actual = new float[capacity + 1]; + actual[capacity] = Float.NaN; + backend.ashScoreNeighborhood(codes, groups, blockSize, capacity, lut, landmarkDot, actual); + for (int lane = 0; lane < capacity; lane++) { + int offset = FusedASHLayout.blockOffset(lane / blockSize, groups, 4, blockSize); + float expected = ASHScorer.toSimilarity(FusedASHLayout.scoreLane( + codes, offset, lane % blockSize, groups, 4, blockSize, lut, new float[] {landmarkDot})); + assertEquals("groups=" + groups + " B=" + blockSize + " capacity=" + capacity + " lane=" + lane, + expected, actual[lane], 3e-5f + 3e-5f * Math.abs(expected)); + } + assertTrue(Float.isNaN(actual[capacity])); + int header = FusedASHLayout.blockOffset(0, groups, 4, blockSize); + // Isolate half conversion from cancellation and reduction-order error. + float[] headerLut = new float[lut.length]; + Arrays.fill(headerLut, .25f); + for (float scale : new float[] {Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, 0f, + -0f, 0x1.0p-24f, 0x1.0p-14f, 65504f}) { + FusedASHLayout.writeLaneHeader(codes, header, 0, groups, 4, blockSize, scale, 0f, (byte) 0); + backend.ashScoreNeighborhood( + codes, groups, blockSize, capacity, headerLut, landmarkDot, actual); + float expected = ASHScorer.toSimilarity(FusedASHLayout.scoreLane( + codes, header, 0, groups, 4, blockSize, headerLut, new float[] {landmarkDot})); + if (Float.isNaN(expected)) + assertTrue(Float.isNaN(actual[0])); + else + assertEquals("scale=" + scale + " groups=" + groups, expected, actual[0], + Float.isInfinite(expected) ? 0f : 3e-5f + 3e-5f * Math.abs(expected)); + } + FusedASHLayout.writeLaneHeader(codes, header, 0, groups, 4, blockSize, 1f, 0f, (byte) 1); + assertThrows(IllegalStateException.class, + () + -> backend.ashScoreNeighborhood( + codes, groups, blockSize, capacity, lut, landmarkDot, actual)); + } + } + } + } + + @Test + public void nativeEntryRejectsInvalidShapesBeforeFFM() { + var backend = VectorizationProvider.getInstance().getVectorUtilSupport(); + org.junit.Assume.assumeTrue(backend.supportsAshNeighborhoodScoring()); + var codes = new byte[192]; // one code row + five header bytes per lane, B32 + var lut = new float[16]; + var out = new float[32]; + assertThrows( + IllegalArgumentException.class, () -> backend.ashScoreNeighborhood(codes, 0, 32, 32, lut, 0f, out)); + assertThrows( + IllegalArgumentException.class, () -> backend.ashScoreNeighborhood(codes, 1, 31, 32, lut, 0f, out)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(new byte[0], 1, 32, 32, lut, 0f, out)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(codes, 1, 32, 32, new float[15], 0f, out)); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(codes, 1, 32, 32, lut, 0f, new float[31])); + assertThrows(IndexOutOfBoundsException.class, + () -> backend.ashScoreNeighborhood(codes, Integer.MAX_VALUE, 32, 32, lut, 0f, out)); + float[] shared = new float[32]; + assertThrows(IllegalArgumentException.class, + () -> backend.ashScoreNeighborhood(codes, 1, 32, 32, shared, 0f, shared)); + assertThrows(NullPointerException.class, () -> backend.ashScoreNeighborhood(null, 1, 32, 32, lut, 0f, out)); + } +} diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java new file mode 100644 index 000000000..45a9322de --- /dev/null +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaASHKernels.java @@ -0,0 +1,259 @@ +/* + * Copyright DataStax, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.jbellis.jvector.vector; + +import jdk.incubator.vector.*; +import java.util.Objects; + +/** + * Tunable ASH SIMD kernels. Settings are JVM-lifetime constants so HotSpot can remove + * unused registers and unrolled updates. Accumulators are named locals, never an array. + * + *

Unroll is the number of SIMD code chunks grouped in an outer-loop iteration. + * Accumulators rotate independently of that grouping, so any supported combination + * uses all requested registers. A chunk is a nibble pair for LUT scoring or one + * SIMD-width of code bytes for projection scoring. HotSpot controls final machine + * unrolling; keeping a single vector-helper call site avoids heap allocation caused + * by exhausting the compiler's inlining budget.

+ * + *

Optional VM settings (defaults shown): + * {@code jvector.ash.lut.accumulators=2}, {@code jvector.ash.lut.unroll=1}, + * {@code jvector.ash.projection.accumulators=1}, {@code jvector.ash.projection.unroll=1}. + * Accumulators accept 1/2/4/8; unroll accepts 1/2/4. Invalid values fail explicitly. + * These settings are independent of the one-bit {@code jvector.ash.512.accumulators}. + * Use a fresh JVM for each tuning trial.

+ */ +final class PanamaASHKernels { + private static final VectorSpecies F = FloatVector.SPECIES_PREFERRED; + private static final VectorSpecies I = VectorSpecies.of(int.class, F.vectorShape()); + private static final VectorSpecies B = F.length() <= 8 ? ByteVector.SPECIES_64 : ByteVector.SPECIES_128; + private static final int[] TWO_BIT_INDICES = queryIndices(4); + private static final int[] FOUR_BIT_INDICES = queryIndices(2); + private static final int[][] TWO_BIT_TAIL_INDICES = tailQueryIndices(4); + private static final int[][] FOUR_BIT_TAIL_INDICES = tailQueryIndices(2); + + // Conservative defaults from JDK 23/AVX-512 sweeps and end-to-end validation. + // The original compact projection loop is retained for the default 1/1 setting. + private static final int LUT_ACC = setting("lut.accumulators", 2, 8); + private static final int LUT_UNROLL = setting("lut.unroll", 1, 4); + private static final int PROJECTION_ACC = setting("projection.accumulators", 1, 8); + private static final int PROJECTION_UNROLL = setting("projection.unroll", 1, 4); + + private PanamaASHKernels() {} + + private static int setting(String suffix, int fallback, int maximum) { + String name = "jvector.ash." + suffix; + String value = System.getProperty(name, Integer.toString(fallback)).trim(); + final int parsed; + try { parsed = Integer.parseInt(value); } + catch (NumberFormatException e) { throw new IllegalArgumentException(name + " must be a power of two in [1," + maximum + "]: " + value, e); } + if (parsed < 1 || parsed > maximum || (parsed & (parsed - 1)) != 0) { + throw new IllegalArgumentException(name + " must be a power of two in [1," + maximum + "]: " + value); + } + return parsed; + } + + static String description() { + return "LUT accumulators=" + LUT_ACC + ", unroll=" + LUT_UNROLL + + "; projection accumulators=" + PROJECTION_ACC + ", unroll=" + PROJECTION_UNROLL; + } + + static boolean supported() { return F.length() == 4 || F.length() == 8 || F.length() == 16; } + + static boolean projectionTuningEnabled() { return PROJECTION_ACC != 1 || PROJECTION_UNROLL != 1; } + + private static int[] queryIndices(int stride) { + int[] indices = new int[F.length()]; + for (int i = 0; i < indices.length; i++) indices[i] = i * stride; + return indices; + } + + /** + * Gather indices for a partial final block of {@code active} lanes. Inactive lanes point at + * index 0: a masked gather may still bounds-check them, and a short query has no element + * at their full-block positions. + */ + private static int[][] tailQueryIndices(int stride) { + int[][] tails = new int[F.length()][]; + for (int active = 1; active < tails.length; active++) { + int[] indices = new int[F.length()]; + for (int lane = 0; lane < active; lane++) indices[lane] = lane * stride; + tails[active] = indices; + } + return tails; + } + + static void lutScore(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + ASHLutScoring.checkBounds(codes, offset, groups, stride, lane, count, lut, out, outOffset); + int pairs = groups / 2; + for (int i = 0; i < count; i += F.length()) { + int active = Math.min(F.length(), count - i); + var byteMask = B.indexInRange(0, active); + FloatVector a0 = FloatVector.zero(F); + FloatVector a1 = FloatVector.zero(F); + FloatVector a2 = FloatVector.zero(F); + FloatVector a3 = FloatVector.zero(F); + FloatVector a4 = FloatVector.zero(F); + FloatVector a5 = FloatVector.zero(F); + FloatVector a6 = FloatVector.zero(F); + FloatVector a7 = FloatVector.zero(F); + int pair = 0; + while (pair < pairs) { + int end = Math.min(pairs, pair + LUT_UNROLL); + // One lookup call site avoids HotSpot's vector inlining budget limit. + // A constant-trip inner loop supplies the requested unroll grouping. + do { + FloatVector value = lutPair(codes, offset, pair, stride, lane + i, lut, byteMask); + FloatVector next = a0.add(value); + if (LUT_ACC == 1) { + a0 = next; + } + else if (LUT_ACC == 2) { + a0 = a1; + a1 = next; + } + else if (LUT_ACC == 4) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = next; + } + else if (LUT_ACC == 8) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = a4; + a4 = a5; + a5 = a6; + a6 = a7; + a7 = next; + } + } while (++pair < end); + } + if ((groups & 1) != 0) { + var packed = loadCodes(codes, offset + pairs * stride + lane + i, byteMask); + a0 = a0.add(lookup(lut, (groups - 1) * 16, packed.and(15))); + } + if (LUT_ACC > 1) a0 = a0.add(a1); + if (LUT_ACC > 2) a0 = a0.add(a2); + if (LUT_ACC > 3) a0 = a0.add(a3); + if (LUT_ACC > 4) a0 = a0.add(a4); + if (LUT_ACC > 5) a0 = a0.add(a5); + if (LUT_ACC > 6) a0 = a0.add(a6); + if (LUT_ACC > 7) a0 = a0.add(a7); + a0.intoArray(out, outOffset + i, F.indexInRange(0, active)); + } + } + + private static IntVector loadCodes(byte[] codes, int offset, VectorMask mask) { + return (IntVector) ByteVector.fromArray(B, codes, offset, mask).convertShape(VectorOperators.B2I, I, 0); + } + + private static FloatVector lutPair(byte[] codes, int offset, int pair, int stride, + int lane, float[] lut, VectorMask mask) { + var packed = loadCodes(codes, offset + pair * stride + lane, mask); + return lookup(lut, pair * 32, packed.and(15)) + .add(lookup(lut, pair * 32 + 16, packed.lanewise(VectorOperators.LSHR, 4).and(15))); + } + + private static FloatVector lookup(float[] lut, int offset, IntVector indices) { + int width = F.length(); + // Keep the shuffle and data in the same lane type. Casting an Int shuffle + // to a Float shuffle can materialize heap objects on JDK 23. Reinterpreting + // the LUT bits instead preserves register-only permutation. + var shuffle = indices.and(width - 1).toShuffle(); + IntVector result = FloatVector.fromArray(F, lut, offset).reinterpretAsInts().rearrange(shuffle); + for (int table = width; table < 16; table += width) { + var values = FloatVector.fromArray(F, lut, offset + table).reinterpretAsInts().rearrange(shuffle); + result = result.blend(values, indices.compare(VectorOperators.GE, table)); + } + return result.reinterpretAsFloats(); + } + + /** Projection dot with the code starting at {@code codeOffset}. */ + static float projectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bits) { + if (bits != 2 && bits != 4) throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); + Objects.checkFromIndexSize(0, dimensions, query.length); + int perByte = 8 / bits; + int bytes = dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1); + Objects.checkFromIndexSize(codeOffset, bytes, code.length); + int sign = 1 << (bits - 1); + int[] indices = bits == 2 ? TWO_BIT_INDICES : FOUR_BIT_INDICES; + FloatVector a0 = FloatVector.zero(F); + FloatVector a1 = FloatVector.zero(F); + FloatVector a2 = FloatVector.zero(F); + FloatVector a3 = FloatVector.zero(F); + FloatVector a4 = FloatVector.zero(F); + FloatVector a5 = FloatVector.zero(F); + FloatVector a6 = FloatVector.zero(F); + FloatVector a7 = FloatVector.zero(F); + int base = 0; + while (base < bytes) { + int remainingChunks = (bytes - base - 1) / F.length() + 1; + int chunks = Math.min(PROJECTION_UNROLL, remainingChunks); + for (int u = 0; u < chunks; u++, base += F.length()) { + var packed = loadCodes(code, codeOffset + base, B.indexInRange(0, Math.min(F.length(), bytes - base))); + // Reuse each widened code byte across its 2/4 projected dimensions. + // Keep the decode, query gather and accumulation in one compilation unit. + for (int slot = 0; slot < perByte; slot++) { + int remaining = dimensions - base * perByte - slot; + if (remaining <= 0) break; + int active = Math.min(F.length(), (remaining - 1) / perByte + 1); + var fields = packed.lanewise(VectorOperators.LSHR, slot * bits); + var magnitude = ((FloatVector) fields.and(sign - 1).convertShape(VectorOperators.I2F, F, 0)).add(0.5f); + var values = magnitude.blend(magnitude.neg(), fields.and(sign).compare(VectorOperators.EQ, 0).cast(F)); + int[] gather = active == F.length() ? indices + : (bits == 2 ? TWO_BIT_TAIL_INDICES : FOUR_BIT_TAIL_INDICES)[active]; + var q = FloatVector.fromArray(F, query, base * perByte + slot, gather, 0, F.indexInRange(0, active)); + FloatVector next = a0.add(q.mul(values)); + if (PROJECTION_ACC == 1) { + a0 = next; + } + else if (PROJECTION_ACC == 2) { + a0 = a1; + a1 = next; + } + else if (PROJECTION_ACC == 4) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = next; + } + else if (PROJECTION_ACC == 8) { + a0 = a1; + a1 = a2; + a2 = a3; + a3 = a4; + a4 = a5; + a5 = a6; + a6 = a7; + a7 = next; + } + } + } + } + if (PROJECTION_ACC > 1) a0 = a0.add(a1); + if (PROJECTION_ACC > 2) a0 = a0.add(a2); + if (PROJECTION_ACC > 3) a0 = a0.add(a3); + if (PROJECTION_ACC > 4) a0 = a0.add(a4); + if (PROJECTION_ACC > 5) a0 = a0.add(a5); + if (PROJECTION_ACC > 6) a0 = a0.add(a6); + if (PROJECTION_ACC > 7) a0 = a0.add(a7); + return a0.reduceLanes(VectorOperators.ADD); + } +} diff --git a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java index fc6dbc4b2..10bf725aa 100644 --- a/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java +++ b/jvector-twenty/src/main/java/io/github/jbellis/jvector/vector/PanamaVectorUtilSupport.java @@ -27,11 +27,328 @@ import jdk.incubator.vector.VectorMask; import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; +import java.util.Objects; import jdk.incubator.vector.VectorShape; import java.util.List; class PanamaVectorUtilSupport implements VectorUtilSupport { + // JDK 20's C2 miscompiles the masked AVX-512 loads and stores in the ASH kernels, + // which crashes or corrupts the heap. JDK 20 is past end of life, so ASH uses the + // scalar kernels there. The constant folds away on later JDKs. + private static final boolean ASH_SIMD = Runtime.version().feature() != 20; + private static final VectorSpecies ASH_FLOATS = FloatVector.SPECIES_PREFERRED; + private static final VectorSpecies ASH_INTS = VectorSpecies.of( + int.class, ASH_FLOATS.vectorShape()); + // Byte loads need only one byte per float lane; conversion part zero widens those lanes. + private static final VectorSpecies ASH_BYTES = ASH_FLOATS.length() <= 8 + ? ByteVector.SPECIES_64 : ByteVector.SPECIES_128; + private static final VectorSpecies ASH_SYM_LONGS = LongVector.SPECIES_PREFERRED; + private static final VectorSpecies ASH_SYM_WORD_BYTES = VectorSpecies.of(byte.class, ASH_SYM_LONGS.vectorShape()); + private static final VectorSpecies ASH_SYM_BYTES = ByteVector.SPECIES_PREFERRED; + private static final VectorSpecies ASH_SYM_SHORTS = VectorSpecies.of(short.class, ASH_SYM_BYTES.vectorShape()); + private static final VectorSpecies ASH_SYM_INTS = VectorSpecies.of(int.class, ASH_SYM_BYTES.vectorShape()); + private static final VectorSpecies ASH_SYM_FLOATS = VectorSpecies.of(float.class, ASH_SYM_BYTES.vectorShape()); + private static final VectorSpecies ASH_SYM_BLOCK_BYTES = VectorSpecies.of(byte.class, + jdk.incubator.vector.VectorShape.forBitSize(ASH_SYM_SHORTS.vectorBitSize() / 2)); + private static final int[] ASH_TWO_BIT_QUERY_INDICES = ashQueryIndices(4); + private static final int[] ASH_FOUR_BIT_QUERY_INDICES = ashQueryIndices(2); + private static final int[][] ASH_TWO_BIT_TAIL_INDICES = ashTailQueryIndices(4); + private static final int[][] ASH_FOUR_BIT_TAIL_INDICES = ashTailQueryIndices(2); + + private static int[] ashQueryIndices(int dimensionsPerByte) { + int[] indices = new int[ASH_FLOATS.length()]; + for (int i = 0; i < indices.length; i++) indices[i] = i * dimensionsPerByte; + return indices; + } + + // Indices for a partial block: inactive lanes repeat the first query element, so the + // gather stays in range without a mask. + private static int[][] ashTailQueryIndices(int dimensionsPerByte) { + int[][] tails = new int[ASH_FLOATS.length()][]; + for (int active = 1; active < tails.length; active++) { + int[] indices = new int[ASH_FLOATS.length()]; + for (int lane = 0; lane < active; lane++) indices[lane] = lane * dimensionsPerByte; + tails[active] = indices; + } + return tails; + } + + @Override + public void ashSymmetricLutScore(byte[] codes, int groups, int stride, int lane, int count, + short[] lut, float[] out, int outOffset) { + var shorts = ASH_SYM_SHORTS; + if (shorts.length() < 16 || shorts.length() > 32) { + VectorUtilSupport.super.ashSymmetricLutScore(codes, groups, stride, lane, count, lut, out, outOffset); + return; + } + var bytes = ASH_SYM_BLOCK_BYTES; + var ints = ASH_SYM_INTS; + var floats = ASH_SYM_FLOATS; + for (int i = 0; i < count; i += shorts.length()) { + int active = Math.min(shorts.length(), count - i); + var mask = bytes.indexInRange(0, active); + var sum0 = FloatVector.zero(floats); + var sum1 = FloatVector.zero(floats); + for (int first = 0; first < groups; first += 64) { + var subtotal = jdk.incubator.vector.ShortVector.zero(shorts); + int end = Math.min(groups, first + 64); + for (int g = first; g < end; g += 2) { + var packed = ByteVector.fromArray(bytes, codes, (g / 2) * stride + lane + i, mask); + var fields = (jdk.incubator.vector.ShortVector) packed.convertShape(VectorOperators.B2S, shorts, 0); + var low = jdk.incubator.vector.ShortVector.fromArray(shorts, lut, g * 32) + .rearrange(fields.and((short)15).toShuffle()); + if (g + 1 < end) { + var high = jdk.incubator.vector.ShortVector.fromArray(shorts, lut, (g + 1) * 32) + .rearrange(fields.lanewise(VectorOperators.LSHR, 4).and((short)15).toShuffle()); + low = low.add(high); + } + subtotal = subtotal.add(low); + } + var lo = (IntVector) subtotal.convertShape(VectorOperators.S2I, ints, 0); + var hi = (IntVector) subtotal.convertShape(VectorOperators.S2I, ints, 1); + sum0 = sum0.add((FloatVector)lo.convertShape(VectorOperators.I2F, floats, 0)); + sum1 = sum1.add((FloatVector)hi.convertShape(VectorOperators.I2F, floats, 0)); + } + sum0.mul(.25f).intoArray(out, outOffset + i, floats.indexInRange(0, active)); + if (active > floats.length()) sum1.mul(.25f).intoArray(out, outOffset + i + floats.length(), floats.indexInRange(floats.length(), active)); + } + } + + @Override + public boolean supportsAshSymmetricScoring() { return ASH_SIMD; } + + @Override + public float ashSymmetricDot(long[] aBits, byte[] aCodes, long[] bBits, byte[] bCodes, + int dimensions, int bits) { + // Keep the Vector API compilation units small and species compile-time constant. + // A shared, large dispatch body can leave boxed vectors in graph-pruning callers. + return switch (bits) { + case 1 -> ashSymmetricBinary(aBits, 0, bBits, 0, dimensions); + case 2 -> ashSymmetricTwoBit(aCodes, 0, bCodes, 0, dimensions); + case 4 -> ashSymmetricFourBit(aCodes, 0, bCodes, 0, dimensions); + default -> throw new IllegalArgumentException("Symmetric SIMD supports 1, 2, and 4 bits"); + }; + } + + @Override + public boolean supportsAshSymmetricDotAt() { return ASH_SIMD; } + + @Override + public float ashSymmetricDotAt(byte[] aCodes, int aOffset, byte[] bCodes, int bOffset, + int dimensions, int bits) { + if (dimensions < 0 || (bits != 2 && bits != 4)) + throw new IllegalArgumentException("Offset symmetric ASH requires nonnegative dimensions and bits in {2,4}"); + long bytes = ((long) dimensions * bits + 7) / 8; + if (aOffset < 0 || bOffset < 0 || aOffset + bytes > aCodes.length || bOffset + bytes > bCodes.length) + throw new IndexOutOfBoundsException("ASH code array ranges"); + return bits == 2 ? ashSymmetricTwoBit(aCodes, aOffset, bCodes, bOffset, dimensions) + : ashSymmetricFourBit(aCodes, aOffset, bCodes, bOffset, dimensions); + } + + @Override + public float ashSymmetricBinaryDotAt(long[] aBits, int aOffset, long[] bBits, int bOffset, int dimensions) { + if (dimensions < 0) throw new IllegalArgumentException("dimensions must be nonnegative"); + int words = (int) ((dimensions + 63L) >>> 6); + Objects.checkFromIndexSize(aOffset, words, aBits.length); + Objects.checkFromIndexSize(bOffset, words, bBits.length); + return ashSymmetricBinary(aBits, aOffset, bBits, bOffset, dimensions); + } + + private static float ashSymmetricBinary(long[] aBits, int aOff, long[] bBits, int bOff, int dimensions) { + var longs = ASH_SYM_LONGS; + long mismatches = 0; + int words = dimensions >>> 6; + for (int w = 0; w < words; w += longs.length()) { + var mask = longs.indexInRange(w, words); + var a = LongVector.fromArray(longs, aBits, aOff + w, mask); + var b = LongVector.fromArray(longs, bBits, bOff + w, mask); + mismatches += a.lanewise(VectorOperators.XOR, b) + .lanewise(VectorOperators.BIT_COUNT).reduceLanes(VectorOperators.ADD); + } + int tail = dimensions & 63; + if (tail != 0) mismatches += Long.bitCount((aBits[aOff + words] ^ bBits[bOff + words]) & ((1L << tail) - 1)); + return dimensions - 2f * mismatches; + } + + private static float ashSymmetricTwoBit(byte[] aCodes, int aOff, byte[] bCodes, int bOff, int dimensions) { + var longs = ASH_SYM_LONGS; + // The doubled signed code is sign * (1 + 2*magnitudeBit). + // Weighted popcounts evaluate its product without unpacking components. + final long slots = 0x5555555555555555L; + int words = dimensions / 32; + long sum = (long) words * 32; + for (int word = 0; word < words; word += longs.length()) { + var byteSpecies = ASH_SYM_WORD_BYTES; + var byteMask = byteSpecies.indexInRange(word * 8, words * 8); + var a = ByteVector.fromArray(byteSpecies, aCodes, aOff + word * 8, byteMask).reinterpretAsLongs(); + var b = ByteVector.fromArray(byteSpecies, bCodes, bOff + word * 8, byteMask).reinterpretAsLongs(); + var negative = a.lanewise(VectorOperators.XOR, b).lanewise(VectorOperators.LSHR, 1).and(slots); + var ma = a.and(slots); + var mb = b.and(slots); + var both = ma.and(mb); + var delta = negative.lanewise(VectorOperators.BIT_COUNT).mul(-2) + .add(ma.lanewise(VectorOperators.BIT_COUNT).mul(2)) + .add(mb.lanewise(VectorOperators.BIT_COUNT).mul(2)) + .add(ma.and(negative).lanewise(VectorOperators.BIT_COUNT).mul(-4)) + .add(mb.and(negative).lanewise(VectorOperators.BIT_COUNT).mul(-4)) + .add(both.lanewise(VectorOperators.BIT_COUNT).mul(4)) + .add(both.and(negative).lanewise(VectorOperators.BIT_COUNT).mul(-8)); + sum += delta.reduceLanes(VectorOperators.ADD); + } + int tail = dimensions % 32; + if (tail != 0) { + long a = 0, b = 0; + for (int j = 0; j < (tail + 3) / 4; j++) { + a |= (long) (aCodes[aOff + words * 8 + j] & 255) << (8 * j); + b |= (long) (bCodes[bOff + words * 8 + j] & 255) << (8 * j); + } + long valid = slots & ((1L << (2 * tail)) - 1); + long negative = ((a ^ b) >>> 1) & valid; + long ma = a & valid, mb = b & valid, both = ma & mb; + sum += tail - 2 * Long.bitCount(negative) + + 2 * (Long.bitCount(ma) + Long.bitCount(mb)) + - 4 * (Long.bitCount(ma & negative) + Long.bitCount(mb & negative)) + + 4 * Long.bitCount(both) - 8 * Long.bitCount(both & negative); + } + return sum * 0.25f; + } + + private static float ashSymmetricFourBit(byte[] aCodes, int aOff, byte[] bCodes, int bOff, int dimensions) { + var bytes = ASH_SYM_BYTES; + var shorts = ASH_SYM_SHORTS; + int count = dimensions / 2; + long sum = 0; + for (int offset = 0; offset < count; offset += bytes.length()) { + int active = Math.min(bytes.length(), count - offset); + var mask = bytes.indexInRange(0, active); + var a = ByteVector.fromArray(bytes, aCodes, aOff + offset, mask); + var b = ByteVector.fromArray(bytes, bCodes, bOff + offset, mask); + var total = jdk.incubator.vector.ShortVector.zero(shorts); + for (int shift = 0; shift < 8; shift += 4) { + var ax = a.lanewise(VectorOperators.LSHR, shift); + var bx = b.lanewise(VectorOperators.LSHR, shift); + var am = ax.and((byte) 7).mul((byte) 2).add((byte) 1); + var bm = bx.and((byte) 7).mul((byte) 2).add((byte) 1); + am = am.blend(am.neg(), ax.and((byte) 8).compare(VectorOperators.EQ, (byte) 0)); + bm = bm.blend(bm.neg(), bx.and((byte) 8).compare(VectorOperators.EQ, (byte) 0)); + for (int part = 0; part < 2; part++) { + var av = (jdk.incubator.vector.ShortVector) am.convertShape(VectorOperators.B2S, shorts, part); + var bv = (jdk.incubator.vector.ShortVector) bm.convertShape(VectorOperators.B2S, shorts, part); + total = total.add(av.mul(bv)); + } + } + // Widen before reduction: no short-sum overflow, including wider future species. + for (int part = 0; part < 2; part++) { + sum += ((IntVector) total.convertShape(VectorOperators.S2I, ASH_SYM_INTS, part)) + .reduceLanes(VectorOperators.ADD); + } + sum -= 2L * (bytes.length() - active); // zero padding decodes to -1 in each nibble + } + if ((dimensions & 1) != 0) { + int a = aCodes[aOff + count] & 15, b = bCodes[bOff + count] & 15; + int av = (2 * (a & 7) + 1) * ((a & 8) == 0 ? -1 : 1); + int bv = (2 * (b & 7) + 1) * ((b & 8) == 0 ? -1 : 1); + sum += av * bv; + } + return sum * 0.25f; + } + + @Override + public boolean supportsAshProjectionScoring() { return ASH_SIMD && PanamaASHKernels.supported(); } + + @Override + public boolean supportsAshLutScoring() { return ASH_SIMD && PanamaASHKernels.supported(); } + + @Override + public String ashKernelDescription() { return PanamaASHKernels.description(); } + + @Override + public boolean usesAshProjectionTuning() { return PanamaASHKernels.projectionTuningEnabled(); } + + @Override + public float ashProjectionDotTuned(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { + if (!supportsAshProjectionScoring()) { + return VectorUtilSupport.super.ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); + } + return PanamaASHKernels.projectionDot(query, code, codeOffset, dimensions, bitsPerDimension); + } + + @Override + public float ashProjectionDot(float[] query, byte[] code, int codeOffset, int dimensions, int bitsPerDimension) { + if (!supportsAshProjectionScoring()) { + return VectorUtilSupport.super.ashProjectionDot(query, code, codeOffset, dimensions, bitsPerDimension); + } + if (bitsPerDimension != 2 && bitsPerDimension != 4) { + throw new IllegalArgumentException("Projection scoring requires 2 or 4 bits per dimension"); + } + Objects.checkFromIndexSize(0, dimensions, query.length); + int perByte = 8 / bitsPerDimension; + int bytes = dimensions / perByte + (dimensions % perByte == 0 ? 0 : 1); + Objects.checkFromIndexSize(codeOffset, bytes, code.length); + int sign = 1 << (bitsPerDimension - 1); + int[] indices = bitsPerDimension == 2 ? ASH_TWO_BIT_QUERY_INDICES : ASH_FOUR_BIT_QUERY_INDICES; + int lanes = ASH_FLOATS.length(); + // No masked loads or gathers: JDK 20's C2 miscompiles masked AVX-512 memory + // operations here. Whole blocks need no mask. A partial final block loads its + // bytes unmasked when the array extends past them (extra lanes are discarded), + // gathers with indices that stay in range, and masks only the register addition. + // Otherwise the tail is scalar, so results may differ in the last bit by position. + int fullBytes = (dimensions / (lanes * perByte)) * lanes; + int loadBytes = ASH_BYTES.length(); + FloatVector sum = FloatVector.zero(ASH_FLOATS); + int base = 0; + for (; base < fullBytes && base + loadBytes <= bytes; base += lanes) { + var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, codeOffset + base) + .convertShape(VectorOperators.B2I, ASH_INTS, 0); + for (int slot = 0; slot < perByte; slot++) { + sum = sum.add(ashProjectionTerm(query, packed, base * perByte + slot, indices, slot, bitsPerDimension)); + } + } + if (base < bytes && bytes - base <= lanes && codeOffset + base + loadBytes <= code.length) { + var packed = (IntVector) ByteVector.fromArray(ASH_BYTES, code, codeOffset + base) + .convertShape(VectorOperators.B2I, ASH_INTS, 0); + int[][] tails = bitsPerDimension == 2 ? ASH_TWO_BIT_TAIL_INDICES : ASH_FOUR_BIT_TAIL_INDICES; + for (int slot = 0; slot < perByte; slot++) { + int remaining = dimensions - base * perByte - slot; + if (remaining <= 0) break; + int active = Math.min(lanes, (remaining - 1) / perByte + 1); + int[] gather = active == lanes ? indices : tails[active]; + sum = sum.add(ashProjectionTerm(query, packed, base * perByte + slot, gather, slot, bitsPerDimension), + ASH_FLOATS.indexInRange(0, active)); + } + base = bytes; + } + float total = sum.reduceLanes(VectorOperators.ADD); + for (int i = base * perByte; i < dimensions; i++) { + int field = (code[codeOffset + i / perByte] & 255) >>> ((i % perByte) * bitsPerDimension); + float magnitude = (field & (sign - 1)) + 0.5f; + total += query[i] * ((field & sign) == 0 ? -magnitude : magnitude); + } + return total; + } + + // One slot of a block: decode signed-magnitude fields and multiply by the gathered query. + private static FloatVector ashProjectionTerm(float[] query, IntVector packed, int queryOffset, + int[] gather, int slot, int bitsPerDimension) { + int sign = 1 << (bitsPerDimension - 1); + var fields = packed.lanewise(VectorOperators.LSHR, slot * bitsPerDimension); + var magnitude = ((FloatVector) fields.and(sign - 1) + .convertShape(VectorOperators.I2F, ASH_FLOATS, 0)).add(0.5f); + var values = magnitude.blend(magnitude.neg(), fields.and(sign) + .compare(VectorOperators.EQ, 0).cast(ASH_FLOATS)); + return FloatVector.fromArray(ASH_FLOATS, query, queryOffset, gather, 0).mul(values); + } + + @Override + public void ashLutScore(byte[] codes, int offset, int groups, int stride, + int lane, int count, float[] lut, float[] out, int outOffset) { + if (!supportsAshLutScoring()) { + VectorUtilSupport.super.ashLutScore(codes, offset, groups, stride, lane, count, lut, out, outOffset); + return; + } + PanamaASHKernels.lutScore(codes, offset, groups, stride, lane, count, lut, out, outOffset); + } static final int PREFERRED_BIT_SIZE = FloatVector.SPECIES_PREFERRED.vectorBitSize(); static { @@ -49,7 +366,7 @@ protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat } protected FloatVector fromVectorFloat(VectorSpecies SPEC, VectorFloat vector, int offset, int[] indices, int indicesOffset) { - return FloatVector.fromArray(SPEC, ((ArrayVectorFloat)vector).get(), offset, indices, indicesOffset); + return FloatVector.fromArray(SPEC, ((ArrayVectorFloat) vector).get(), offset, indices, indicesOffset); } protected void intoVectorFloat(FloatVector vector, VectorFloat v, int offset) { @@ -154,8 +471,7 @@ public float dotProduct(VectorFloat v1, VectorFloat v2) { } @Override - public float dotProduct(VectorFloat v1, int v1offset, VectorFloat v2, int v2offset, final int length) - { + public float dotProduct(VectorFloat v1, int v1offset, VectorFloat v2, int v2offset, final int length) { //Common case first if (length >= FloatVector.SPECIES_PREFERRED.length()) return dotProductPreferred(v1, v1offset, v2, v2offset, length); @@ -257,8 +573,7 @@ float dotProductPreferred(VectorFloat va, int vaoffset, VectorFloat vb, in // Unrolled vector loop; for dot product from L1 cache, an unroll factor of 2 generally suffices. // If we are going to be getting data that's further down the hierarchy but not fetched off disk/network, // we might want to unroll further, e.g. to 8 (4 sets of a,b,sum with 3-ahead reads seems to work best). - if (length >= vectorLength * 2) - { + if (length >= vectorLength * 2) { length -= vectorLength * 2; a0 = fromVectorFloat(FloatVector.SPECIES_PREFERRED, va, vaoffset + vectorLength * 0); b0 = fromVectorFloat(FloatVector.SPECIES_PREFERRED, vb, vboffset + vectorLength * 0); @@ -266,8 +581,7 @@ float dotProductPreferred(VectorFloat va, int vaoffset, VectorFloat vb, in b1 = fromVectorFloat(FloatVector.SPECIES_PREFERRED, vb, vboffset + vectorLength * 1); vaoffset += vectorLength * 2; vboffset += vectorLength * 2; - while (length >= vectorLength * 2) - { + while (length >= vectorLength * 2) { // All instructions in the main loop have no dependencies between them and can be executed in parallel. length -= vectorLength * 2; sum0 = a0.fma(b0, sum0); @@ -606,7 +920,7 @@ public void addInPlace(VectorFloat v1, VectorFloat v2) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, v1.get(i) + v2.get(i)); + v1.set(i, v1.get(i) + v2.get(i)); } } @@ -627,7 +941,7 @@ public void addInPlace(VectorFloat v1, float value) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, v1.get(i) + value); + v1.set(i, v1.get(i) + value); } } @@ -648,7 +962,7 @@ public void subInPlace(VectorFloat v1, VectorFloat v2) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, v1.get(i) - v2.get(i)); + v1.set(i, v1.get(i) - v2.get(i)); } } @@ -664,7 +978,7 @@ public void subInPlace(VectorFloat vector, float value) { // Process the tail for (int i = vectorizedLength; i < vector.length(); i++) { - vector.set(i, vector.get(i) - value); + vector.set(i, vector.get(i) - value); } } @@ -735,19 +1049,18 @@ public void minInPlace(VectorFloat v1, VectorFloat v2) { // Process the tail for (int i = vectorizedLength; i < v1.length(); i++) { - v1.set(i, Math.min(v1.get(i), v2.get(i))); + v1.set(i, Math.min(v1.get(i), v2.get(i))); } } @Override public float assembleAndSum(VectorFloat data, int dataBase, ByteSequence baseOffsets) { - return assembleAndSum(data, dataBase, baseOffsets, 0, baseOffsets.length()); + return assembleAndSum(data, dataBase, baseOffsets, 0, baseOffsets.length()); } @Override public float assembleAndSum(VectorFloat data, int dataBase, ByteSequence baseOffsets, int baseOffsetsOffset, int baseOffsetsLength) { - return switch (PREFERRED_BIT_SIZE) - { + return switch (PREFERRED_BIT_SIZE) { case 512 -> assembleAndSum512(data, dataBase, baseOffsets, baseOffsetsOffset, baseOffsetsLength); case 256 -> assembleAndSum256(data, dataBase, baseOffsets, baseOffsetsOffset, baseOffsetsLength); case 128 -> assembleAndSum128(data, dataBase, baseOffsets, baseOffsetsOffset, baseOffsetsLength); @@ -768,7 +1081,7 @@ float assembleAndSum512(VectorFloat data, int dataBase, ByteSequence baseO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_512) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * dataBase; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_512, data, offset, convOffsets, 0)); @@ -797,7 +1110,7 @@ float assembleAndSum256(VectorFloat data, int dataBase, ByteSequence baseO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_256) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * dataBase; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_256, data, offset, convOffsets, 0)); @@ -832,11 +1145,13 @@ public float assembleAndSumPQ( int clusterCount // = k ) { //compute the size of the subvector - return switch (PREFERRED_BIT_SIZE) - { - case 512 -> assembleAndSumPQ_512(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); - case 256 -> assembleAndSumPQ_256(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); - case 128 -> assembleAndSumPQ_128(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); + return switch (PREFERRED_BIT_SIZE) { + case 512 -> + assembleAndSumPQ_512(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); + case 256 -> + assembleAndSumPQ_256(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); + case 128 -> + assembleAndSumPQ_128(codebookPartialSums, subspaceCount, vector1Ordinals, vector1OrdinalOffset, vector2Ordinals, vector2OrdinalOffset, clusterCount); default -> throw new IllegalStateException("Unsupported vector width: " + PREFERRED_BIT_SIZE); }; } @@ -850,19 +1165,19 @@ float assembleAndSumPQ_128( int baseOffsetsOffset2, int clusterCount // = k ) { - final int k = clusterCount; - final int blockSize = k * (k + 1) / 2; + final int k = clusterCount; + final int blockSize = k * (k + 1) / 2; float res = 0f; for (int i = 0; i < subspaceCount; i++) { int c1 = Byte.toUnsignedInt(baseOffsets1.get(i + baseOffsetsOffset1)); int c2 = Byte.toUnsignedInt(baseOffsets2.get(i + baseOffsetsOffset2)); - int r = Math.min(c1, c2); - int c = Math.max(c1, c2); + int r = Math.min(c1, c2); + int c = Math.max(c1, c2); - int offsetRow = r * k - (r * (r - 1) / 2); + int offsetRow = r * k - (r * (r - 1) / 2); int idxInBlock = offsetRow + (c - r); - int base = i * blockSize; + int base = i * blockSize; res += data.get(base + idxInBlock); } @@ -880,10 +1195,10 @@ float assembleAndSumPQ_256( int clusterCount // = k ) { final VectorSpecies FSPECIES = FloatVector.SPECIES_256; - final int LANES = FSPECIES.length(); - final int k = clusterCount; - final int blockSize = k * (k + 1) / 2; - final int M = subspaceCount; + final int LANES = FSPECIES.length(); + final int k = clusterCount; + final int blockSize = k * (k + 1) / 2; + final int M = subspaceCount; int[] convOffsets = scratchInt256.get(); FloatVector sum = FloatVector.zero(FloatVector.SPECIES_256); @@ -912,9 +1227,9 @@ float assembleAndSumPQ_256( var c = c1v.max(c2v); // c) offsetRow = r*k - (r*(r-1))/2 - var rk = r.mul(kvec); - var triangular = r.mul(r.sub(onevec)).mul(twovec); - var offsetRow = rk.sub(triangular); + var rk = r.mul(kvec); + var triangular = r.mul(r.sub(onevec)).mul(twovec); + var offsetRow = rk.sub(triangular); // d) idxInBlock = offsetRow + (c - r) + (i * blockSize) offsetRow.add(c.sub(r)).add(scale) @@ -937,12 +1252,12 @@ float assembleAndSumPQ_256( for (int i = (M / LANES) * LANES; i < M; i++) { int c1 = Byte.toUnsignedInt(baseOffsets1.get(i + baseOffsetsOffset1)); int c2 = Byte.toUnsignedInt(baseOffsets2.get(i + baseOffsetsOffset2)); - int r = Math.min(c1, c2); - int c = Math.max(c1, c2); + int r = Math.min(c1, c2); + int c = Math.max(c1, c2); - int offsetRow = r * k - (r * (r - 1) / 2); + int offsetRow = r * k - (r * (r - 1) / 2); int idxInBlock = offsetRow + (c - r); - int base = i * blockSize; + int base = i * blockSize; res += data.get(base + idxInBlock); } @@ -960,10 +1275,10 @@ float assembleAndSumPQ_512( int clusterCount // = k ) { final VectorSpecies FSPECIES = FloatVector.SPECIES_512; - final int LANES = FSPECIES.length(); - final int k = clusterCount; - final int blockSize = k * (k + 1) / 2; - final int M = subspaceCount; + final int LANES = FSPECIES.length(); + final int k = clusterCount; + final int blockSize = k * (k + 1) / 2; + final int M = subspaceCount; int[] convOffsets = scratchInt512.get(); FloatVector sum = FloatVector.zero(FloatVector.SPECIES_512); @@ -990,9 +1305,9 @@ float assembleAndSumPQ_512( var c = c1v.max(c2v); // c) offsetRow = r*k - (r*(r-1))/2 - var rk = r.mul(kvec); - var triangular = r.mul(r.sub(onevec)).mul(twovec); - var offsetRow = rk.sub(triangular); + var rk = r.mul(kvec); + var triangular = r.mul(r.sub(onevec)).mul(twovec); + var offsetRow = rk.sub(triangular); // d) idxInBlock = offsetRow + (c - r) + (i * blockSize) offsetRow.add(c.sub(r)).add(scale) @@ -1015,12 +1330,12 @@ float assembleAndSumPQ_512( for (int i = (M / LANES) * LANES; i < M; i++) { int c1 = Byte.toUnsignedInt(baseOffsets1.get(i + baseOffsetsOffset1)); int c2 = Byte.toUnsignedInt(baseOffsets2.get(i + baseOffsetsOffset2)); - int r = Math.min(c1, c2); - int c = Math.max(c1, c2); + int r = Math.min(c1, c2); + int c = Math.max(c1, c2); - int offsetRow = r * k - (r * (r - 1) / 2); + int offsetRow = r * k - (r * (r - 1) / 2); int idxInBlock = offsetRow + (c - r); - int base = i * blockSize; + int base = i * blockSize; res += data.get(base + idxInBlock); } @@ -1096,7 +1411,7 @@ private static int combineBytes(int i, int shuffle, ByteSequence quantizedPar return ((Byte.toUnsignedInt(highByte) << 8) | Byte.toUnsignedInt(lowByte)); } - private static float combineBytes(int i, int shuffle, VectorFloat partials) { + private static float combineBytes(int i, int shuffle, VectorFloat partials) { return partials.get(i * 256 + shuffle); } @@ -1112,9 +1427,12 @@ private static int computeSingleShuffle(int codebookPosition, int neighborPositi @Override public float pqDecodedCosineSimilarity(ByteSequence encoded, int encodedOffset, int encodedLength, int clusterCount, VectorFloat partialSums, VectorFloat aMagnitude, float bMagnitude) { return switch (PREFERRED_BIT_SIZE) { - case 512 -> pqDecodedCosineSimilarity512(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); - case 256 -> pqDecodedCosineSimilarity256(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); - case 128 -> pqDecodedCosineSimilarity128(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); + case 512 -> + pqDecodedCosineSimilarity512(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); + case 256 -> + pqDecodedCosineSimilarity256(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); + case 128 -> + pqDecodedCosineSimilarity128(encoded, encodedOffset, encodedLength, clusterCount, partialSums, aMagnitude, bMagnitude); default -> throw new IllegalStateException("Unsupported vector width: " + PREFERRED_BIT_SIZE); }; } @@ -1136,7 +1454,7 @@ float pqDecodedCosineSimilarity512(ByteSequence baseOffsets, int baseOffsetsO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_512) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * clusterCount; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_512, partialSums, offset, convOffsets, 0)); @@ -1172,7 +1490,7 @@ float pqDecodedCosineSimilarity256(ByteSequence baseOffsets, int baseOffsetsO .lanewise(VectorOperators.AND, BYTE_TO_INT_MASK_256) .reinterpretAsInts() .add(scale) - .intoArray(convOffsets,0); + .intoArray(convOffsets, 0); var offset = i * clusterCount; sum = sum.add(fromVectorFloat(FloatVector.SPECIES_256, partialSums, offset, convOffsets, 0)); @@ -1548,7 +1866,7 @@ public float[] nvqCosine8bit(VectorFloat vector, void transpose(VectorFloat arr, int first, int last, int nRows) { final int mn1 = (last - first - 1); - final int n = (last - first) / nRows; + final int n = (last - first) / nRows; boolean[] visited = new boolean[last - first]; float temp; int cycle = first; @@ -1556,7 +1874,7 @@ void transpose(VectorFloat arr, int first, int last, int nRows) { if (visited[cycle - first]) continue; int a = cycle - first; - do { + do { a = a == mn1 ? mn1 : (n * a) % mn1; temp = arr.get(first + a); arr.set(first + a, arr.get(cycle)); @@ -1597,6 +1915,694 @@ public void calculatePartialSums(VectorFloat codebook, int codebookIndex, int @Override public float pqDecodedCosineSimilarity(ByteSequence encoded, int clusterCount, VectorFloat partialSums, VectorFloat aMagnitude, float bMagnitude) { - return pqDecodedCosineSimilarity(encoded, 0, encoded.length(), clusterCount, partialSums, aMagnitude, bMagnitude); + return pqDecodedCosineSimilarity(encoded, 0, encoded.length(), clusterCount, partialSums, aMagnitude, bMagnitude); + } + + @Override + public float ashDotRow(float[] Arow, float[] x) { + assert Arow.length == x.length : "Arow.length != x.length"; + + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int len = x.length; + final int upper = SPEC.loopBound(len); + + FloatVector sum = FloatVector.zero(SPEC); + + int i = 0; + for (; i < upper; i += SPEC.length()) { + FloatVector va = FloatVector.fromArray(SPEC, Arow, i); + FloatVector vx = FloatVector.fromArray(SPEC, x, i); + sum = va.fma(vx, sum); + } + + float acc = sum.reduceLanes(VectorOperators.ADD); + + // scalar tail + for (; i < len; i++) { + acc += Arow[i] * x[i]; + } + + return acc; + } + + @Override + public boolean supportsAshMaskedLoad() { + return ASH_SIMD; + } + + private static final int ASH_512_ACCUMULATORS = + Integer.getInteger("jvector.ash.512.accumulators", 8); + + /** Species-width-independent masked add; the fallback for machines without 16 float lanes. */ + private float ashMaskedAddFlat(float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + // 4 Accumulators to hide instruction latency (Pipeline depth ~4) + FloatVector acc0 = FloatVector.zero(SPEC); + FloatVector acc1 = FloatVector.zero(SPEC); + FloatVector acc2 = FloatVector.zero(SPEC); + FloatVector acc3 = FloatVector.zero(SPEC); + + // Pre-calculate mask extraction constants based on LANES + long laneMask; + if (LANES == 16) laneMask = 0xFFFFL; // AVX-512 + else if (LANES == 8) laneMask = 0xFFL; // AVX2 + else if (LANES == 4) laneMask = 0xFL; // NEON / SSE + else laneMask = (1L << LANES) - 1; + + // Safe limit for Fast Path + int safeWords = d / 64; + + for (int w = 0; w < safeWords; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; // Branch prediction handles this well + + int baseDim = w * 64; + + // Iterate 64 bits in steps of LANES (e.g., 0, 8, 16, 24... for AVX2) + // We unroll manually inside the loop using modulo to pick accumulators + // The JIT is smart enough to unroll this loop completely since LANES is constant. + int accumulatorIdx = 0; + + for (int off = 0; off < 64; off += LANES) { + // Shift and mask + long m = (wordBits >>> off) & laneMask; + + if (m != 0) { + VectorMask bitMask = VectorMask.fromLong(SPEC, m); + FloatVector loaded = FloatVector.fromArray(SPEC, tildeQ, qOffset + baseDim + off); + + // Round-robin distribution to accumulators to break dependency chains + switch (accumulatorIdx & 3) { // (idx % 4) + case 0 -> acc0 = acc0.add(loaded, bitMask); + case 1 -> acc1 = acc1.add(loaded, bitMask); + case 2 -> acc2 = acc2.add(loaded, bitMask); + case 3 -> acc3 = acc3.add(loaded, bitMask); + } + } + accumulatorIdx++; + } + } + + for (int w = safeWords; w < words; w++) { + long wordBits = allPackedVectors[packedBase + w]; + if (wordBits == 0) continue; + + int baseDim = w * 64; + + for (int off = 0; off < 64; off += LANES) { + int localIdx = baseDim + off; + if (localIdx >= d) break; + + long m = (wordBits >>> off) & laneMask; + if (m == 0) continue; + + VectorMask bitMask = VectorMask.fromLong(SPEC, m); + int absIdx = qOffset + localIdx; + + if (localIdx + LANES <= d) { + // Safe unmasked load + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx), bitMask); + } else { + // Boundary masked load + VectorMask rangeMask = SPEC.indexInRange(absIdx, qOffset + d); + acc0 = acc0.add(FloatVector.fromArray(SPEC, tildeQ, absIdx, rangeMask), + bitMask.and(rangeMask)); + } + } + } + + // Reduce all accumulators + return acc0.add(acc1).add(acc2).add(acc3).reduceLanes(VectorOperators.ADD); + } + + public float ashMaskedAdd_512( + float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words) { + return ashMaskedAdd_512( + tildeQ, + qOffset, + allPackedVectors, + packedBase, + d, + words, + ASH_512_ACCUMULATORS); + } + + /** + * Package-private overload for correctness tests and microbenchmarks. + */ + float ashMaskedAdd_512( + float[] tildeQ, + int qOffset, + long[] allPackedVectors, + int packedBase, + int d, + int words, + int accumulatorCount) { + if (d < 0) { + throw new IllegalArgumentException("d must be nonnegative: " + d); + } + + int requiredWords = d / 64 + (d % 64 == 0 ? 0 : 1); + if (words < requiredWords) { + throw new IllegalArgumentException( + "Not enough packed words: required=" + + requiredWords + + ", provided=" + + words); + } + + Objects.checkFromIndexSize(qOffset, d, tildeQ.length); + Objects.checkFromIndexSize(packedBase, requiredWords, allPackedVectors.length); + // The unrolled kernels below address sixteen float lanes per vector. + // Other machines must use the species-width-independent implementation. + if (FloatVector.SPECIES_PREFERRED.length() != 16) { + if (accumulatorCount != 4 && accumulatorCount != 8) { + throw new IllegalArgumentException("accumulatorCount must be 4 or 8"); + } + return ashMaskedAddFlat(tildeQ, qOffset, allPackedVectors, packedBase, d, requiredWords); + } + + if (accumulatorCount == 4) { + return ashMaskedAdd4( + tildeQ, + qOffset, + allPackedVectors, + packedBase, + d); + } + + if (accumulatorCount == 8) { + return ashMaskedAdd8( + tildeQ, + qOffset, + allPackedVectors, + packedBase, + d); + } + + throw new IllegalArgumentException( + "ASH 512 accumulator count must be 4 or 8: " + + accumulatorCount); + } + + private float ashMaskedAdd4( + float[] tildeQ, + int qOffset, + long[] packedVectors, + int packedBase, + int d) { + final VectorSpecies spec = FloatVector.SPECIES_PREFERRED; + + FloatVector acc0 = FloatVector.zero(spec); + FloatVector acc1 = FloatVector.zero(spec); + FloatVector acc2 = FloatVector.zero(spec); + FloatVector acc3 = FloatVector.zero(spec); + + int fullWords = d >>> 6; + + for (int w = 0; w < fullWords; w++) { + int qBase = qOffset + (w << 6); + long packed = packedVectors[packedBase + w]; + + acc0 = addMasked16(acc0, tildeQ, qBase, packed, 0, spec); + acc1 = addMasked16(acc1, tildeQ, qBase + 16, packed, 16, spec); + acc2 = addMasked16(acc2, tildeQ, qBase + 32, packed, 32, spec); + acc3 = addMasked16(acc3, tildeQ, qBase + 48, packed, 48, spec); + } + + int remainder = d & 63; + if (remainder != 0) { + int qBase = qOffset + (fullWords << 6); + long packed = packedVectors[packedBase + fullWords]; + + if (remainder > 0) { + acc0 = addMaskedTail( + acc0, tildeQ, qBase, packed, 0, + Math.min(remainder, 16), spec); + } + if (remainder > 16) { + acc1 = addMaskedTail( + acc1, tildeQ, qBase + 16, packed, 16, + Math.min(remainder - 16, 16), spec); + } + if (remainder > 32) { + acc2 = addMaskedTail( + acc2, tildeQ, qBase + 32, packed, 32, + Math.min(remainder - 32, 16), spec); + } + if (remainder > 48) { + acc3 = addMaskedTail( + acc3, tildeQ, qBase + 48, packed, 48, + remainder - 48, spec); + } + } + + return reduce4(acc0, acc1, acc2, acc3); + } + + private float ashMaskedAdd8( + float[] tildeQ, + int qOffset, + long[] packedVectors, + int packedBase, + int d) { + final VectorSpecies spec = FloatVector.SPECIES_PREFERRED; + + FloatVector acc0 = FloatVector.zero(spec); + FloatVector acc1 = FloatVector.zero(spec); + FloatVector acc2 = FloatVector.zero(spec); + FloatVector acc3 = FloatVector.zero(spec); + FloatVector acc4 = FloatVector.zero(spec); + FloatVector acc5 = FloatVector.zero(spec); + FloatVector acc6 = FloatVector.zero(spec); + FloatVector acc7 = FloatVector.zero(spec); + + int fullWords = d >>> 6; + int w = 0; + + // Two packed words produce eight independent SIMD additions. + for (; w + 1 < fullWords; w += 2) { + int qBase = qOffset + (w << 6); + + long packed0 = packedVectors[packedBase + w]; + acc0 = addMasked16(acc0, tildeQ, qBase, packed0, 0, spec); + acc1 = addMasked16(acc1, tildeQ, qBase + 16, packed0, 16, spec); + acc2 = addMasked16(acc2, tildeQ, qBase + 32, packed0, 32, spec); + acc3 = addMasked16(acc3, tildeQ, qBase + 48, packed0, 48, spec); + + long packed1 = packedVectors[packedBase + w + 1]; + int qBase1 = qBase + 64; + + acc4 = addMasked16(acc4, tildeQ, qBase1, packed1, 0, spec); + acc5 = addMasked16(acc5, tildeQ, qBase1 + 16, packed1, 16, spec); + acc6 = addMasked16(acc6, tildeQ, qBase1 + 32, packed1, 32, spec); + acc7 = addMasked16(acc7, tildeQ, qBase1 + 48, packed1, 48, spec); + } + + // One remaining complete word. + if (w < fullWords) { + int qBase = qOffset + (w << 6); + long packed = packedVectors[packedBase + w]; + + acc0 = addMasked16(acc0, tildeQ, qBase, packed, 0, spec); + acc1 = addMasked16(acc1, tildeQ, qBase + 16, packed, 16, spec); + acc2 = addMasked16(acc2, tildeQ, qBase + 32, packed, 32, spec); + acc3 = addMasked16(acc3, tildeQ, qBase + 48, packed, 48, spec); + } + + int remainder = d & 63; + if (remainder != 0) { + int qBase = qOffset + (fullWords << 6); + long packed = packedVectors[packedBase + fullWords]; + + if (remainder > 0) { + acc4 = addMaskedTail( + acc4, tildeQ, qBase, packed, 0, + Math.min(remainder, 16), spec); + } + if (remainder > 16) { + acc5 = addMaskedTail( + acc5, tildeQ, qBase + 16, packed, 16, + Math.min(remainder - 16, 16), spec); + } + if (remainder > 32) { + acc6 = addMaskedTail( + acc6, tildeQ, qBase + 32, packed, 32, + Math.min(remainder - 32, 16), spec); + } + if (remainder > 48) { + acc7 = addMaskedTail( + acc7, tildeQ, qBase + 48, packed, 48, + remainder - 48, spec); + } + } + + return reduce8( + acc0, acc1, acc2, acc3, + acc4, acc5, acc6, acc7); + } + + private static FloatVector addMasked16( + FloatVector accumulator, + float[] values, + int valueOffset, + long packed, + int bitShift, + VectorSpecies spec) { + long bits = (packed >>> bitShift) & 0xFFFFL; + VectorMask selected = VectorMask.fromLong(spec, bits); + + return accumulator.add( + FloatVector.fromArray(spec, values, valueOffset), + selected); + } + + private static FloatVector addMaskedTail( + FloatVector accumulator, + float[] values, + int valueOffset, + long packed, + int bitShift, + int laneCount, + VectorSpecies spec) { + VectorMask validLanes = + spec.indexInRange(0, laneCount); + + long bits = (packed >>> bitShift) & 0xFFFFL; + VectorMask selected = + VectorMask.fromLong(spec, bits).and(validLanes); + + FloatVector vector = + FloatVector.fromArray( + spec, + values, + valueOffset, + validLanes); + + return accumulator.add(vector, selected); + } + + private static float reduce4( + FloatVector acc0, + FloatVector acc1, + FloatVector acc2, + FloatVector acc3) { + FloatVector sum01 = acc0.add(acc1); + FloatVector sum23 = acc2.add(acc3); + + return sum01.add(sum23) + .reduceLanes(VectorOperators.ADD); + } + + private static float reduce8( + FloatVector acc0, + FloatVector acc1, + FloatVector acc2, + FloatVector acc3, + FloatVector acc4, + FloatVector acc5, + FloatVector acc6, + FloatVector acc7) { + FloatVector sum01 = acc0.add(acc1); + FloatVector sum23 = acc2.add(acc3); + FloatVector sum45 = acc4.add(acc5); + FloatVector sum67 = acc6.add(acc7); + + return sum01.add(sum23) + .add(sum45.add(sum67)) + .reduceLanes(VectorOperators.ADD); + } + + @Override + public void ashMaskedAddBlockAllWords(float[] tildeQ, + int d, + long[] packedBits, + int blockWordBase, + int words, + int blockSize, + int laneStart, + int blockLen, + float[] outMaskedAdd) { + + // Like in the ASH paper, SIMD lanes are dimensions (SPECIES_PREFERRED), + // masked load from tildeQ and horizontal sum per neighbor. + // + // Optimization: amortize horizontal reductions. + // Instead of reducing each masked chunk immediately, we accumulate masked + // vectors into a vector register accumulator and reduce once per lane. + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int LANES = SPEC.length(); + + for (int lane = 0; lane < blockLen; lane++) { + // Vector accumulator: stays in registers (no array accumulation). + FloatVector accVec = FloatVector.zero(SPEC); + + // Scalar accumulator for unusual widths fallback only. + float scalarAcc = 0f; + + int laneIndex = laneStart + lane; + + for (int w = 0; w < words; w++) { + long wordBits = packedBits[blockWordBase + (w * blockSize) + laneIndex]; + int baseDim = w * 64; + + // process this 64-bit word in LANES-bit chunks + for (int off = 0; off < 64 && (baseDim + off) < d; off += LANES) { + int qBase = baseDim + off; + + // Range mask for tail dims beyond d + VectorMask inRange = SPEC.indexInRange(qBase, d); + + long maskBits; + if (LANES == 16) { + maskBits = (wordBits >>> off) & 0xFFFFL; + } else if (LANES == 8) { + maskBits = (wordBits >>> off) & 0xFFL; + } else if (LANES == 4) { + maskBits = (wordBits >>> off) & 0xFL; + } else { + // Unusual width; fall back to scalar for this chunk + long m = wordBits >>> off; + float s = 0f; + for (int b = 0; b < LANES; b++) { + if (((m >>> b) & 1L) != 0L) { + int idx = qBase + b; + if (idx < d) s += tildeQ[idx]; + } + } + scalarAcc += s; + continue; + } + + if (maskBits == 0L) continue; + + VectorMask bitMask = + VectorMask.fromLong(SPEC, maskBits).and(inRange); + + // masked load (paper) + FloatVector masked = FloatVector.fromArray(SPEC, tildeQ, qBase, bitMask); + + // amortized accumulation (register) + accVec = accVec.add(masked); + } + } + + // One horizontal sum per lane (instead of per chunk) + float maskedAdd = accVec.reduceLanes(VectorOperators.ADD) + scalarAcc; + + outMaskedAdd[lane] = maskedAdd; + } + } + + private static void ashMaskedAddBlockWordLutNibble(float[] tildeQ, + int baseDim, + int d, + long[] packedBits, + int packedBase, + int laneStart, + int blockLen, + float[] acc) { + for (int off = 0; off < 64 && (baseDim + off) < d; off += 4) { + int q0 = baseDim + off; + + float v0 = (q0 + 0 < d) ? tildeQ[q0 + 0] : 0f; + float v1 = (q0 + 1 < d) ? tildeQ[q0 + 1] : 0f; + float v2 = (q0 + 2 < d) ? tildeQ[q0 + 2] : 0f; + float v3 = (q0 + 3 < d) ? tildeQ[q0 + 3] : 0f; + + float lut1 = v0; + float lut2 = v1; + float lut3 = v0 + v1; + float lut4 = v2; + float lut5 = v0 + v2; + float lut6 = v1 + v2; + float lut7 = v0 + v1 + v2; + float lut8 = v3; + float lut9 = v0 + v3; + float lut10 = v1 + v3; + float lut11 = v0 + v1 + v3; + float lut12 = v2 + v3; + float lut13 = v0 + v2 + v3; + float lut14 = v1 + v2 + v3; + float lut15 = v0 + v1 + v2 + v3; + + for (int lane = 0; lane < blockLen; lane++) { + long word = packedBits[packedBase + laneStart + lane]; + int mask = (int) ((word >>> off) & 0xF); + switch (mask) { + case 0: break; + case 1: acc[lane] += lut1; break; + case 2: acc[lane] += lut2; break; + case 3: acc[lane] += lut3; break; + case 4: acc[lane] += lut4; break; + case 5: acc[lane] += lut5; break; + case 6: acc[lane] += lut6; break; + case 7: acc[lane] += lut7; break; + case 8: acc[lane] += lut8; break; + case 9: acc[lane] += lut9; break; + case 10: acc[lane] += lut10; break; + case 11: acc[lane] += lut11; break; + case 12: acc[lane] += lut12; break; + case 13: acc[lane] += lut13; break; + case 14: acc[lane] += lut14; break; + case 15: acc[lane] += lut15; break; + } + } + } + } + + private static void ashMaskedAddBlockWordLutByteTwoNibbles(float[] tildeQ, + int baseDim, + int d, + long[] packedBits, + int packedBase, + int laneStart, + int blockLen, + float[] acc) { + for (int off = 0; off < 64 && (baseDim + off) < d; off += 8) { + int q0 = baseDim + off; + + float v0 = (q0 + 0 < d) ? tildeQ[q0 + 0] : 0f; + float v1 = (q0 + 1 < d) ? tildeQ[q0 + 1] : 0f; + float v2 = (q0 + 2 < d) ? tildeQ[q0 + 2] : 0f; + float v3 = (q0 + 3 < d) ? tildeQ[q0 + 3] : 0f; + float v4 = (q0 + 4 < d) ? tildeQ[q0 + 4] : 0f; + float v5 = (q0 + 5 < d) ? tildeQ[q0 + 5] : 0f; + float v6 = (q0 + 6 < d) ? tildeQ[q0 + 6] : 0f; + float v7 = (q0 + 7 < d) ? tildeQ[q0 + 7] : 0f; + + // Low nibble (v0..v3) + float lo1 = v0; + float lo2 = v1; + float lo3 = v0 + v1; + float lo4 = v2; + float lo5 = v0 + v2; + float lo6 = v1 + v2; + float lo7 = v0 + v1 + v2; + float lo8 = v3; + float lo9 = v0 + v3; + float lo10 = v1 + v3; + float lo11 = v0 + v1 + v3; + float lo12 = v2 + v3; + float lo13 = v0 + v2 + v3; + float lo14 = v1 + v2 + v3; + float lo15 = v0 + v1 + v2 + v3; + + // High nibble (v4..v7) + float hi1 = v4; + float hi2 = v5; + float hi3 = v4 + v5; + float hi4 = v6; + float hi5 = v4 + v6; + float hi6 = v5 + v6; + float hi7 = v4 + v5 + v6; + float hi8 = v7; + float hi9 = v4 + v7; + float hi10 = v5 + v7; + float hi11 = v4 + v5 + v7; + float hi12 = v6 + v7; + float hi13 = v4 + v6 + v7; + float hi14 = v5 + v6 + v7; + float hi15 = v4 + v5 + v6 + v7; + + for (int lane = 0; lane < blockLen; lane++) { + long word = packedBits[packedBase + laneStart + lane]; + int mask = (int) ((word >>> off) & 0xFF); + + int lo = mask & 0xF; + int hi = (mask >>> 4) & 0xF; + + float addLo = 0f; + switch (lo) { + case 0: break; + case 1: addLo = lo1; break; + case 2: addLo = lo2; break; + case 3: addLo = lo3; break; + case 4: addLo = lo4; break; + case 5: addLo = lo5; break; + case 6: addLo = lo6; break; + case 7: addLo = lo7; break; + case 8: addLo = lo8; break; + case 9: addLo = lo9; break; + case 10: addLo = lo10; break; + case 11: addLo = lo11; break; + case 12: addLo = lo12; break; + case 13: addLo = lo13; break; + case 14: addLo = lo14; break; + case 15: addLo = lo15; break; + } + + float addHi = 0f; + switch (hi) { + case 0: break; + case 1: addHi = hi1; break; + case 2: addHi = hi2; break; + case 3: addHi = hi3; break; + case 4: addHi = hi4; break; + case 5: addHi = hi5; break; + case 6: addHi = hi6; break; + case 7: addHi = hi7; break; + case 8: addHi = hi8; break; + case 9: addHi = hi9; break; + case 10: addHi = hi10; break; + case 11: addHi = hi11; break; + case 12: addHi = hi12; break; + case 13: addHi = hi13; break; + case 14: addHi = hi14; break; + case 15: addHi = hi15; break; + } + + acc[lane] += (addLo + addHi); + } + } + } + + private static void ashMaskedAddBlockWordMask(float[] tildeQ, + int baseDim, + int d, + long[] packedBits, + int packedBase, + int laneStart, + int blockLen, + float[] acc) { + final VectorSpecies SPEC = FloatVector.SPECIES_PREFERRED; + final int lanes = SPEC.length(); + + for (int off = 0; off < 64 && (baseDim + off) < d; off += lanes) { + int qBase = baseDim + off; + + VectorMask inRange = SPEC.indexInRange(qBase, d); + FloatVector qVec = FloatVector.fromArray(SPEC, tildeQ, qBase, inRange); + + for (int lane = 0; lane < blockLen; lane++) { + long word = packedBits[packedBase + laneStart + lane]; + + long maskBits; + if (lanes == 16) { + maskBits = (word >>> off) & 0xFFFFL; + } else if (lanes == 8) { + maskBits = (word >>> off) & 0xFFL; + } else { // NEON 4 lanes + maskBits = (word >>> off) & 0xFL; + } + + if (maskBits == 0L) continue; + + VectorMask bitMask = VectorMask.fromLong(SPEC, maskBits).and(inRange); + acc[lane] += qVec.reduceLanes(VectorOperators.ADD, bitMask); + } + } } -} \ No newline at end of file +} diff --git a/plot_output.py b/plot_output.py deleted file mode 100644 index 7a3215236..000000000 --- a/plot_output.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright DataStax, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -import sys -import re -import matplotlib.pyplot as plt - -import re -from dataclasses import dataclass, astuple - - -@dataclass -class Point: - pq: int - recall: float - throughput: float - M: int - ef: int - overquery: int - -def parse_data(description, data): - """ - Parses a given set of data lines to extract relevant information. - - Parameters: - - description (str): Metadata description of the dataset. - - data (list of str): List of data lines to parse. - - Returns: - - dict: A dictionary containing parsed information. - """ - base_vector_count = int(re.search(r'(\d+) base', description).group(1)) - query_vector_count = int(re.search(r'(\d+) query', description).group(1)) - dimensions = int(re.search(r'dimensions (\d+)', description).group(1)) - dataset_name = re.search(r'(\S+):', description).group(1) - - parsed_data = [] - current_pq = None - M = None - for line in data: - if "ProductQuantization" in line: - current_pq = 'PQ@' + re.search(r'\((\d+)\)', line).group(1) - elif "BinaryQuantization" in line: - current_pq = 'BQ' - elif "Uncompressed" in line: - current_pq = 'UC' - elif "Build M=" in line: - M = int(re.search(r'M=(\d+)', line).group(1)) - ef = int(re.search(r'ef=(\d+)', line).group(1)) - elif " Query " in line: - if "(memory)" in line: - # in-memory (on-heap) graph + vectors are benched as a sanity check; - # we shouldn't include them in the plot of disk-based performance - continue - recall = float(re.search(r'recall (\d+\.\d+)', line).group(1)) - query_time = float(re.search(r'in (\d+\.\d+)s', line).group(1)) - overquery = int(re.search(r'top 100/(\d+) ', line).group(1)) - - throughput = query_vector_count * 10 / query_time - - assert current_pq is not None - assert M is not None - parsed_data.append(Point(current_pq, recall, throughput, M, ef, overquery)) - - return { - 'name': dataset_name, - 'base_vector_count': base_vector_count, - 'dimensions': dimensions, - 'data': parsed_data - } - - -def is_pareto_optimal(candidate, others): - """Determine if a candidate point is Pareto-optimal.""" - for point in others: - # Check if another point has higher or equal recall and throughput - if point.recall >= candidate.recall and point.throughput > candidate.throughput: - return False - if point.recall > candidate.recall and point.throughput >= candidate.throughput: - return False - return True - -def filter_pareto_optimal(data): - """Filter out only the Pareto-optimal points.""" - return [point for point in data if is_pareto_optimal(point, data)] - -def plot_dataset(dataset, output_dir="."): - # Extract dataset info - name = dataset['name'] - base_vector_count = dataset['base_vector_count'] - dimensions = dataset['dimensions'] - data = dataset['data'] - - # Create plot - plt.figure(figsize=(15, 20)) - for pq, recall, throughput, M, ef, overquery in (astuple(p) for p in data): - plt.scatter(recall, throughput, label=f'Q={pq}, M={M}, ef={ef}, oq={overquery}') - plt.annotate(f'Q={pq}, M={M}, ef={ef}, oq={overquery}', (recall, throughput)) - - # Set title and labels - plt.title(f"Dataset: {name}\\nBase Vector Count: {base_vector_count}\\nDimensions: {dimensions}") - plt.xlabel('Recall') - plt.ylabel('Throughput') - plt.legend(loc='upper left', bbox_to_anchor=(1, 1)) - plt.grid(True, which='both', linestyle='--', linewidth=0.5) - plt.tight_layout() - - # Save the plot to a file - filename = f"{output_dir}/{name}_plot.png" - plt.savefig(filename) - print("saved " + filename) - - # Clear the figure for the next plot - plt.clf() - -# Load and parse data -with open(sys.argv[1], 'r') as file: - content = file.read().strip().split('\n\n') -datasets = [] -for dataset in content: - lines = dataset.split('\n') - description = lines[0] - data = lines[1:] - datasets.append((description, data)) -parsed_datasets = [parse_data(desc, data) for desc, data in datasets] - -# Filter and plot -for dataset in parsed_datasets: - dataset['data'] = filter_pareto_optimal(dataset['data']) -for dataset in parsed_datasets: - plot_dataset(dataset) diff --git a/pom.xml b/pom.xml index b659f2037..83a0c01e1 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,7 @@ UTF-8 true 4.1.0-SNAPSHOT + 3.0.3 jvector-base @@ -206,6 +207,17 @@ snakeyaml 2.4 + + + dev.ludovic.netlib + blas + ${ludovic.netlib.version} + + + dev.ludovic.netlib + lapack + ${ludovic.netlib.version} + diff --git a/rat-excludes.txt b/rat-excludes.txt index dbc696f0a..73e9cd4a0 100644 --- a/rat-excludes.txt +++ b/rat-excludes.txt @@ -40,3 +40,5 @@ jvector-native/src/main/native/third_party/** src/main/native/third_party/** jvector-native/src/target/meson-build/** jvector-native/target/meson-build/** +.run/*.run.xml +bench-profiles/**