From f52f253a1866b35bf3fd840bf3338b3b7589943a Mon Sep 17 00:00:00 2001 From: Kaival Parikh Date: Fri, 20 Jun 2025 10:32:10 +0000 Subject: [PATCH] Backport Faiss-based vector format --- .../workflows/run-special-checks-sandbox.yml | 58 ++ gradle/generation/extract-jdk-apis.gradle | 5 +- gradle/generation/regenerate.gradle | 1 + gradle/java/core-mrjar.gradle | 6 +- gradle/testing/defaults-tests.gradle | 3 +- gradle/testing/randomization.gradle | 1 + .../randomization/policies/tests.policy | 3 + lucene/CHANGES.txt | 2 + lucene/sandbox/src/generated/jdk/jdk21.apijar | Bin 0 -> 57069 bytes lucene/sandbox/src/java/module-info.java | 3 + .../faiss/FaissKnnVectorsFormatProvider.java | 88 +++ .../sandbox/codecs/faiss/package-info.java | 22 + .../codecs/faiss/FaissKnnVectorsFormat.java | 120 ++++ .../codecs/faiss/FaissKnnVectorsReader.java | 220 +++++++ .../codecs/faiss/FaissKnnVectorsWriter.java | 248 +++++++ .../sandbox/codecs/faiss/LibFaissC.java | 614 ++++++++++++++++++ .../sandbox/codecs/faiss/package-info.java | 51 ++ .../org.apache.lucene.codecs.KnnVectorsFormat | 16 + .../faiss/TestFaissKnnVectorsFormat.java | 104 +++ 19 files changed, 1562 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/run-special-checks-sandbox.yml create mode 100644 lucene/sandbox/src/generated/jdk/jdk21.apijar create mode 100644 lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormatProvider.java create mode 100644 lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/package-info.java create mode 100644 lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormat.java create mode 100644 lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsReader.java create mode 100644 lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsWriter.java create mode 100644 lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/LibFaissC.java create mode 100644 lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/package-info.java create mode 100644 lucene/sandbox/src/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat create mode 100644 lucene/sandbox/src/test/org/apache/lucene/sandbox/codecs/faiss/TestFaissKnnVectorsFormat.java diff --git a/.github/workflows/run-special-checks-sandbox.yml b/.github/workflows/run-special-checks-sandbox.yml new file mode 100644 index 000000000000..b43bbb8cd34a --- /dev/null +++ b/.github/workflows/run-special-checks-sandbox.yml @@ -0,0 +1,58 @@ +name: "Run special checks: module lucene/sandbox" + +on: + workflow_dispatch: + + pull_request: + branches: + - '*' + + push: + branches: + - 'main' + - 'branch_10x' + +jobs: + faiss-tests: + name: tests for the Faiss codec (v${{ matrix.faiss-version }} with JDK ${{ matrix.java }} on ${{ matrix.os }}) + timeout-minutes: 15 + + strategy: + matrix: + os: [ ubuntu-latest ] + java: [ '21' ] + faiss-version: [ '1.11.0' ] + + runs-on: ${{ matrix.os }} + + steps: + - name: Install Mamba + uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f #v3.2.0 + with: + miniforge-version: 'latest' + auto-activate-base: 'false' + activate-environment: 'faiss-env' + # TODO: Use only conda-forge if possible, see https://github.com/conda-forge/faiss-split-feedstock/pull/88 + channels: 'pytorch,conda-forge' + conda-remove-defaults: 'true' + + - name: Install Faiss + run: mamba install faiss-cpu=${{ matrix.faiss-version }} + + - name: Checkout Lucene + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Prepare Lucene workspace + uses: ./.github/actions/prepare-for-build + + - name: Run tests for Faiss codec + run: > + LD_LIBRARY_PATH=$CONDA_PREFIX/lib + ./gradlew -p lucene/sandbox + -Dtests.faiss.run=true + test + --tests "org.apache.lucene.sandbox.codecs.faiss.*" + + defaults: + run: + shell: bash -leo pipefail {0} diff --git a/gradle/generation/extract-jdk-apis.gradle b/gradle/generation/extract-jdk-apis.gradle index 3adde87da838..dd7e9babd93a 100644 --- a/gradle/generation/extract-jdk-apis.gradle +++ b/gradle/generation/extract-jdk-apis.gradle @@ -17,7 +17,10 @@ def resources = scriptResources(buildscript) -configure(project(":lucene:core")) { +configure([ + project(":lucene:core"), + project(":lucene:sandbox"), +]) { ext { apijars = layout.projectDirectory.dir("src/generated/jdk") mrjarJavaVersions = [ 21 ] diff --git a/gradle/generation/regenerate.gradle b/gradle/generation/regenerate.gradle index d23cfd7d54f0..8edaabc77d80 100644 --- a/gradle/generation/regenerate.gradle +++ b/gradle/generation/regenerate.gradle @@ -91,6 +91,7 @@ configure([ project(":lucene:queryparser"), project(":lucene:expressions"), project(":lucene:test-framework"), + project(":lucene:sandbox"), ]) { task regenerate() { description "Rerun any code or static data generation tasks." diff --git a/gradle/java/core-mrjar.gradle b/gradle/java/core-mrjar.gradle index 2bd5de51971d..5e9032ffdd3f 100644 --- a/gradle/java/core-mrjar.gradle +++ b/gradle/java/core-mrjar.gradle @@ -17,7 +17,10 @@ // Produce an MR-JAR with Java 19+ foreign and vector implementations -configure(project(":lucene:core")) { +configure([ + project(":lucene:core"), + project(":lucene:sandbox"), +]) { plugins.withType(JavaPlugin) { mrjarJavaVersions.each { jdkVersion -> sourceSets.create("main${jdkVersion}") { @@ -27,6 +30,7 @@ configure(project(":lucene:core")) { } configurations["main${jdkVersion}Implementation"].extendsFrom(configurations['implementation']) dependencies.add("main${jdkVersion}Implementation", sourceSets.main.output) + dependencies.add("testImplementation", sourceSets["main${jdkVersion}"].output) tasks.named("compileMain${jdkVersion}Java").configure { def apijar = apijars.file("jdk${jdkVersion}.apijar") diff --git a/gradle/testing/defaults-tests.gradle b/gradle/testing/defaults-tests.gradle index 22d3f9530dab..f3c191cbb233 100644 --- a/gradle/testing/defaults-tests.gradle +++ b/gradle/testing/defaults-tests.gradle @@ -145,7 +145,8 @@ allprojects { ':lucene:core', ':lucene:codecs', ":lucene:distribution.tests", - ":lucene:test-framework" + ":lucene:test-framework", + ":lucene:sandbox", ] ? 'ALL-UNNAMED' : 'org.apache.lucene.core') def loggingConfigFile = layout.projectDirectory.file("${resources}/logging.properties") diff --git a/gradle/testing/randomization.gradle b/gradle/testing/randomization.gradle index 185cd0872a9c..357589d981fc 100644 --- a/gradle/testing/randomization.gradle +++ b/gradle/testing/randomization.gradle @@ -116,6 +116,7 @@ allprojects { description: "Forces use of integer vectors even when slow."], [propName: 'tests.defaultvectorization', value: false, description: "Uses defaults for running tests with correct JVM settings to test Panama vectorization (tests.jvmargs, tests.vectorsize, tests.forceintegervectors)."], + [propName: "tests.faiss.run", value: false, description: "Explicitly run tests for the Faiss codec."], ] } } diff --git a/gradle/testing/randomization/policies/tests.policy b/gradle/testing/randomization/policies/tests.policy index f8e09ba03661..d7b4c5792548 100644 --- a/gradle/testing/randomization/policies/tests.policy +++ b/gradle/testing/randomization/policies/tests.policy @@ -80,6 +80,9 @@ grant { permission java.io.FilePermission "${hunspell.corpora}${/}-", "read"; permission java.io.FilePermission "${hunspell.dictionaries}", "read"; permission java.io.FilePermission "${hunspell.dictionaries}${/}-", "read"; + + // Faiss tests + permission java.lang.RuntimePermission "loadLibrary.faiss_c"; }; // Permissions for jacoco code coverage diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index dc0b7dade9c3..5d7277a483a7 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -27,6 +27,8 @@ New Features * GITHUB#14784: Make pack methods public for BigIntegerPoint and HalfFloatPoint. (Prudhvi Godithi) +* GITHUB#14178: Add a Faiss-based vector format in the sandbox module. (Kaival Parikh) + Improvements --------------------- * GITHUB#14458: Add an IndexDeletion policy that retains the last N commits. (Owais Kazi) diff --git a/lucene/sandbox/src/generated/jdk/jdk21.apijar b/lucene/sandbox/src/generated/jdk/jdk21.apijar new file mode 100644 index 0000000000000000000000000000000000000000..8120f23b8b46ada56b9043792110a3bfb0ded3de GIT binary patch literal 57069 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

#m)(CZ|P(FOy=y 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 YCkX}!&jbQu2K>uL26&4Oe*O0U03|T;GXMYp literal 0 HcmV?d00001 diff --git a/lucene/sandbox/src/java/module-info.java b/lucene/sandbox/src/java/module-info.java index d79a150fea3e..34f4ebba539e 100644 --- a/lucene/sandbox/src/java/module-info.java +++ b/lucene/sandbox/src/java/module-info.java @@ -22,6 +22,7 @@ requires org.apache.lucene.facet; exports org.apache.lucene.payloads; + exports org.apache.lucene.sandbox.codecs.faiss; exports org.apache.lucene.sandbox.codecs.idversion; exports org.apache.lucene.sandbox.codecs.quantization; exports org.apache.lucene.sandbox.document; @@ -39,4 +40,6 @@ provides org.apache.lucene.codecs.PostingsFormat with org.apache.lucene.sandbox.codecs.idversion.IDVersionPostingsFormat; + provides org.apache.lucene.codecs.KnnVectorsFormat with + org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormatProvider; } diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormatProvider.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormatProvider.java new file mode 100644 index 000000000000..cb722eb08325 --- /dev/null +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormatProvider.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.lucene.sandbox.codecs.faiss; + +import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.Arrays; +import java.util.stream.Collectors; +import org.apache.lucene.codecs.KnnVectorsFormat; +import org.apache.lucene.codecs.KnnVectorsReader; +import org.apache.lucene.codecs.KnnVectorsWriter; +import org.apache.lucene.index.SegmentReadState; +import org.apache.lucene.index.SegmentWriteState; + +/** + * Provides a Faiss-based vector format, see corresponding classes in {@code java21/} for docs! + * + * @lucene.experimental + */ +public class FaissKnnVectorsFormatProvider extends KnnVectorsFormat { + private final KnnVectorsFormat delegate; + + public FaissKnnVectorsFormatProvider() { + this(lookup()); + } + + public FaissKnnVectorsFormatProvider(String description, String indexParams) { + this(lookup(description, indexParams)); + } + + private FaissKnnVectorsFormatProvider(KnnVectorsFormat delegate) { + super(delegate.getName()); + this.delegate = delegate; + } + + private static KnnVectorsFormat lookup(Object... args) { + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + Class cls = + lookup.findClass("org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat"); + + MethodType type = + MethodType.methodType( + void.class, + Arrays.stream(args).map(Object::getClass).collect(Collectors.toUnmodifiableList())); + MethodHandle constr = lookup.findConstructor(cls, type); + + return (KnnVectorsFormat) constr.invokeWithArguments(args); + } catch (ClassNotFoundException e) { + throw new LinkageError("FaissKnnVectorsFormat is missing from JAR file", e); + } catch (IllegalAccessException | NoSuchMethodException e) { + throw new LinkageError("FaissKnnVectorsFormat is missing correctly typed constructor", e); + } catch (Throwable t) { + throw new RuntimeException(t); + } + } + + @Override + public KnnVectorsWriter fieldsWriter(SegmentWriteState state) throws IOException { + return delegate.fieldsWriter(state); + } + + @Override + public KnnVectorsReader fieldsReader(SegmentReadState state) throws IOException { + return delegate.fieldsReader(state); + } + + @Override + public int getMaxDimensions(String fieldName) { + return delegate.getMaxDimensions(fieldName); + } +} diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/package-info.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/package-info.java new file mode 100644 index 000000000000..09cc3d2441f7 --- /dev/null +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/faiss/package-info.java @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +/** + * Provides a Faiss-based vector format, see corresponding classes in {@code java21/} for docs! + * + * @lucene.experimental + */ +package org.apache.lucene.sandbox.codecs.faiss; diff --git a/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormat.java b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormat.java new file mode 100644 index 000000000000..83beae607dc5 --- /dev/null +++ b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsFormat.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.lucene.sandbox.codecs.faiss; + +import static org.apache.lucene.util.hnsw.HnswGraphBuilder.DEFAULT_BEAM_WIDTH; +import static org.apache.lucene.util.hnsw.HnswGraphBuilder.DEFAULT_MAX_CONN; + +import java.io.IOException; +import java.util.Locale; +import org.apache.lucene.codecs.KnnVectorsFormat; +import org.apache.lucene.codecs.KnnVectorsReader; +import org.apache.lucene.codecs.KnnVectorsWriter; +import org.apache.lucene.codecs.hnsw.FlatVectorScorerUtil; +import org.apache.lucene.codecs.hnsw.FlatVectorsFormat; +import org.apache.lucene.codecs.lucene99.Lucene99FlatVectorsFormat; +import org.apache.lucene.index.SegmentReadState; +import org.apache.lucene.index.SegmentWriteState; + +/** + * A Faiss-based format to create and search vector indexes, using {@link LibFaissC} to interact + * with the native library. + * + *

The Faiss index is configured using its flexible index factory, which + * allows creating arbitrary indexes by "describing" them. These indexes can be tuned by setting + * relevant parameters. + * + *

A separate Faiss index is created per-segment, and uses the following files: + * + *

    + *
  • .faissm (metadata file): stores field number, offset and length of actual + * Faiss index in data file. + *
  • .faissd (data file): stores concatenated Faiss indexes for all fields. + *
  • All files required by {@link Lucene99FlatVectorsFormat} for storing raw vectors. + *
+ * + *

Note: Set the {@code $OMP_NUM_THREADS} environment variable to control internal + * threading. + * + *

TODO: There is no guarantee of backwards compatibility! + * + * @lucene.experimental + */ +public final class FaissKnnVectorsFormat extends KnnVectorsFormat { + public static final String NAME = FaissKnnVectorsFormat.class.getSimpleName(); + static final int VERSION_START = 0; + static final int VERSION_CURRENT = VERSION_START; + static final String META_CODEC_NAME = NAME + "Meta"; + static final String DATA_CODEC_NAME = NAME + "Data"; + static final String META_EXTENSION = "faissm"; + static final String DATA_EXTENSION = "faissd"; + + private final String description; + private final String indexParams; + private final FlatVectorsFormat rawVectorsFormat; + + /** + * Constructs an HNSW-based format using default {@code maxConn}={@value + * org.apache.lucene.util.hnsw.HnswGraphBuilder#DEFAULT_MAX_CONN} and {@code beamWidth}={@value + * org.apache.lucene.util.hnsw.HnswGraphBuilder#DEFAULT_BEAM_WIDTH}. + */ + public FaissKnnVectorsFormat() { + this( + String.format(Locale.ROOT, "IDMap,HNSW%d", DEFAULT_MAX_CONN), + String.format(Locale.ROOT, "efConstruction=%d", DEFAULT_BEAM_WIDTH)); + } + + /** + * Constructs a format using the specified index factory string and index parameters (see class + * docs for more information). + * + * @param description the index factory string to initialize Faiss indexes. + * @param indexParams the index params to set on Faiss indexes. + */ + public FaissKnnVectorsFormat(String description, String indexParams) { + super(NAME); + this.description = description; + this.indexParams = indexParams; + this.rawVectorsFormat = + new Lucene99FlatVectorsFormat(FlatVectorScorerUtil.getLucene99FlatVectorsScorer()); + } + + @Override + public KnnVectorsWriter fieldsWriter(SegmentWriteState state) throws IOException { + return new FaissKnnVectorsWriter( + description, indexParams, state, rawVectorsFormat.fieldsWriter(state)); + } + + @Override + public KnnVectorsReader fieldsReader(SegmentReadState state) throws IOException { + return new FaissKnnVectorsReader(state, rawVectorsFormat.fieldsReader(state)); + } + + @Override + public int getMaxDimensions(String fieldName) { + return DEFAULT_MAX_DIMENSIONS; + } + + @Override + public String toString() { + return String.format( + Locale.ROOT, "%s(description=%s indexParams=%s)", NAME, description, indexParams); + } +} diff --git a/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsReader.java b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsReader.java new file mode 100644 index 000000000000..0f7ce99c2084 --- /dev/null +++ b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsReader.java @@ -0,0 +1,220 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.lucene.sandbox.codecs.faiss; + +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.DATA_CODEC_NAME; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.DATA_EXTENSION; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.META_CODEC_NAME; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.META_EXTENSION; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.VERSION_CURRENT; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.VERSION_START; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.FAISS_IO_FLAG_MMAP; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.FAISS_IO_FLAG_READ_ONLY; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.indexRead; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.indexSearch; + +import java.io.IOException; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.apache.lucene.codecs.CodecUtil; +import org.apache.lucene.codecs.KnnVectorsReader; +import org.apache.lucene.codecs.hnsw.FlatVectorsReader; +import org.apache.lucene.index.ByteVectorValues; +import org.apache.lucene.index.CorruptIndexException; +import org.apache.lucene.index.FieldInfo; +import org.apache.lucene.index.FloatVectorValues; +import org.apache.lucene.index.IndexFileNames; +import org.apache.lucene.index.SegmentReadState; +import org.apache.lucene.index.VectorSimilarityFunction; +import org.apache.lucene.search.KnnCollector; +import org.apache.lucene.store.ChecksumIndexInput; +import org.apache.lucene.store.DataAccessHint; +import org.apache.lucene.store.FileTypeHint; +import org.apache.lucene.store.IndexInput; +import org.apache.lucene.util.Bits; +import org.apache.lucene.util.IOUtils; + +/** + * Read per-segment Faiss indexes and associated metadata. + * + * @lucene.experimental + */ +final class FaissKnnVectorsReader extends KnnVectorsReader { + private final FlatVectorsReader rawVectorsReader; + private final IndexInput data; + private final Map indexMap; + private final Arena arena; + private boolean closed; + + public FaissKnnVectorsReader(SegmentReadState state, FlatVectorsReader rawVectorsReader) + throws IOException { + this.rawVectorsReader = rawVectorsReader; + this.indexMap = new HashMap<>(); + this.arena = Arena.ofShared(); + this.closed = false; + + List fieldMetaList = new ArrayList<>(); + String metaFileName = + IndexFileNames.segmentFileName(state.segmentInfo.name, state.segmentSuffix, META_EXTENSION); + try (ChecksumIndexInput meta = state.directory.openChecksumInput(metaFileName)) { + Throwable priorE = null; + int versionMeta = -1; + try { + versionMeta = + CodecUtil.checkIndexHeader( + meta, + META_CODEC_NAME, + VERSION_START, + VERSION_CURRENT, + state.segmentInfo.getId(), + state.segmentSuffix); + + FieldMeta fieldMeta; + while ((fieldMeta = parseNextField(meta, state)) != null) { + fieldMetaList.add(fieldMeta); + } + } catch (Throwable t) { + priorE = t; + } finally { + CodecUtil.checkFooter(meta, priorE); + } + + String dataFileName = + IndexFileNames.segmentFileName( + state.segmentInfo.name, state.segmentSuffix, DATA_EXTENSION); + this.data = + state.directory.openInput( + dataFileName, state.context.withHints(FileTypeHint.DATA, DataAccessHint.RANDOM)); + + int versionData = + CodecUtil.checkIndexHeader( + this.data, + DATA_CODEC_NAME, + VERSION_START, + VERSION_CURRENT, + state.segmentInfo.getId(), + state.segmentSuffix); + if (versionMeta != versionData) { + throw new CorruptIndexException( + String.format( + Locale.ROOT, + "Format versions mismatch (meta=%d, data=%d)", + versionMeta, + versionData), + data); + } + CodecUtil.retrieveChecksum(data); + + for (FieldMeta fieldMeta : fieldMetaList) { + if (indexMap.put(fieldMeta.fieldInfo.name, loadField(data, arena, fieldMeta)) != null) { + throw new CorruptIndexException("Duplicate field: " + fieldMeta.fieldInfo.name, meta); + } + } + } catch (Throwable t) { + IOUtils.closeWhileHandlingException(this); + throw t; + } + } + + private static FieldMeta parseNextField(IndexInput meta, SegmentReadState state) + throws IOException { + int fieldNumber = meta.readInt(); + if (fieldNumber == -1) { + return null; + } + + FieldInfo fieldInfo = state.fieldInfos.fieldInfo(fieldNumber); + if (fieldInfo == null) { + throw new CorruptIndexException("Invalid field number: " + fieldNumber, meta); + } + + long dataOffset = meta.readLong(); + long dataLength = meta.readLong(); + + return new FieldMeta(fieldInfo, dataOffset, dataLength); + } + + private static IndexEntry loadField(IndexInput data, Arena arena, FieldMeta fieldMeta) + throws IOException { + int ioFlags = FAISS_IO_FLAG_MMAP | FAISS_IO_FLAG_READ_ONLY; + + // Read index into memory + MemorySegment indexPointer = + indexRead(data.slice(fieldMeta.fieldInfo.name, fieldMeta.offset, fieldMeta.length), ioFlags) + // Ensure timely cleanup + .reinterpret(arena, LibFaissC::freeIndex); + + return new IndexEntry(indexPointer, fieldMeta.fieldInfo.getVectorSimilarityFunction()); + } + + @Override + public void checkIntegrity() throws IOException { + rawVectorsReader.checkIntegrity(); + // TODO: Evaluate if we need an explicit check for validity of Faiss indexes + CodecUtil.checksumEntireFile(data); + } + + @Override + public FloatVectorValues getFloatVectorValues(String field) throws IOException { + return rawVectorsReader.getFloatVectorValues(field); + } + + @Override + public ByteVectorValues getByteVectorValues(String field) { + // TODO: Support using SQ8 quantization, see: + // - https://github.com/opensearch-project/k-NN/pull/2425 + throw new UnsupportedOperationException("Byte vectors not supported"); + } + + @Override + public void search(String field, float[] vector, KnnCollector knnCollector, Bits acceptDocs) { + IndexEntry entry = indexMap.get(field); + if (entry != null) { + indexSearch(entry.indexPointer, entry.function, vector, knnCollector, acceptDocs); + } + } + + @Override + public void search(String field, byte[] vector, KnnCollector knnCollector, Bits acceptDocs) { + // TODO: Support using SQ8 quantization, see: + // - https://github.com/opensearch-project/k-NN/pull/2425 + throw new UnsupportedOperationException("Byte vectors not supported"); + } + + @Override + public Map getOffHeapByteSize(FieldInfo fieldInfo) { + // TODO: How to estimate Faiss usage? + return rawVectorsReader.getOffHeapByteSize(fieldInfo); + } + + @Override + public void close() throws IOException { + if (closed == false) { + closed = true; + IOUtils.close(rawVectorsReader, arena::close, data, indexMap::clear); + } + } + + private record FieldMeta(FieldInfo fieldInfo, long offset, long length) {} + + private record IndexEntry(MemorySegment indexPointer, VectorSimilarityFunction function) {} +} diff --git a/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsWriter.java b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsWriter.java new file mode 100644 index 000000000000..43d1991f3974 --- /dev/null +++ b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/FaissKnnVectorsWriter.java @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.lucene.sandbox.codecs.faiss; + +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.DATA_CODEC_NAME; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.DATA_EXTENSION; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.META_CODEC_NAME; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.META_EXTENSION; +import static org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat.VERSION_CURRENT; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.FAISS_IO_FLAG_MMAP; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.FAISS_IO_FLAG_READ_ONLY; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.createIndex; +import static org.apache.lucene.sandbox.codecs.faiss.LibFaissC.indexWrite; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.lucene.codecs.CodecUtil; +import org.apache.lucene.codecs.KnnFieldVectorsWriter; +import org.apache.lucene.codecs.KnnVectorsWriter; +import org.apache.lucene.codecs.hnsw.FlatFieldVectorsWriter; +import org.apache.lucene.codecs.hnsw.FlatVectorsWriter; +import org.apache.lucene.index.FieldInfo; +import org.apache.lucene.index.FloatVectorValues; +import org.apache.lucene.index.IndexFileNames; +import org.apache.lucene.index.MergeState; +import org.apache.lucene.index.SegmentWriteState; +import org.apache.lucene.index.Sorter; +import org.apache.lucene.index.VectorSimilarityFunction; +import org.apache.lucene.search.DocIdSet; +import org.apache.lucene.store.IndexOutput; +import org.apache.lucene.util.IOUtils; +import org.apache.lucene.util.hnsw.IntToIntFunction; + +/** + * Write per-segment Faiss indexes and associated metadata. + * + * @lucene.experimental + */ +final class FaissKnnVectorsWriter extends KnnVectorsWriter { + private final String description, indexParams; + private final FlatVectorsWriter rawVectorsWriter; + private final IndexOutput meta, data; + private final Map> rawFields; + private boolean finished; + + public FaissKnnVectorsWriter( + String description, + String indexParams, + SegmentWriteState state, + FlatVectorsWriter rawVectorsWriter) + throws IOException { + + this.description = description; + this.indexParams = indexParams; + this.rawVectorsWriter = rawVectorsWriter; + this.rawFields = new HashMap<>(); + this.finished = false; + + try { + String metaFileName = + IndexFileNames.segmentFileName( + state.segmentInfo.name, state.segmentSuffix, META_EXTENSION); + this.meta = state.directory.createOutput(metaFileName, state.context); + CodecUtil.writeIndexHeader( + this.meta, + META_CODEC_NAME, + VERSION_CURRENT, + state.segmentInfo.getId(), + state.segmentSuffix); + + String dataFileName = + IndexFileNames.segmentFileName( + state.segmentInfo.name, state.segmentSuffix, DATA_EXTENSION); + this.data = state.directory.createOutput(dataFileName, state.context); + CodecUtil.writeIndexHeader( + this.data, + DATA_CODEC_NAME, + VERSION_CURRENT, + state.segmentInfo.getId(), + state.segmentSuffix); + } catch (Throwable t) { + IOUtils.closeWhileHandlingException(this); + throw t; + } + } + + @Override + public void mergeOneField(FieldInfo fieldInfo, MergeState mergeState) throws IOException { + rawVectorsWriter.mergeOneField(fieldInfo, mergeState); + switch (fieldInfo.getVectorEncoding()) { + case BYTE -> + // TODO: Support using SQ8 quantization, see: + // - https://github.com/opensearch-project/k-NN/pull/2425 + throw new UnsupportedOperationException("Byte vectors not supported"); + case FLOAT32 -> { + FloatVectorValues merged = + KnnVectorsWriter.MergedVectorValues.mergeFloatVectorValues(fieldInfo, mergeState); + writeFloatField(fieldInfo, merged, doc -> doc); + } + } + } + + @Override + public KnnFieldVectorsWriter addField(FieldInfo fieldInfo) throws IOException { + FlatFieldVectorsWriter rawFieldVectorsWriter = rawVectorsWriter.addField(fieldInfo); + rawFields.put(fieldInfo, rawFieldVectorsWriter); + return rawFieldVectorsWriter; + } + + @Override + public void flush(int maxDoc, Sorter.DocMap sortMap) throws IOException { + rawVectorsWriter.flush(maxDoc, sortMap); + for (Map.Entry> entry : rawFields.entrySet()) { + FieldInfo fieldInfo = entry.getKey(); + switch (fieldInfo.getVectorEncoding()) { + case BYTE -> + // TODO: Support using SQ8 quantization, see: + // - https://github.com/opensearch-project/k-NN/pull/2425 + throw new UnsupportedOperationException("Byte vectors not supported"); + + case FLOAT32 -> { + @SuppressWarnings("unchecked") + FlatFieldVectorsWriter rawWriter = + (FlatFieldVectorsWriter) entry.getValue(); + + List vectors = rawWriter.getVectors(); + int dimension = fieldInfo.getVectorDimension(); + DocIdSet docIdSet = rawWriter.getDocsWithFieldSet(); + + writeFloatField( + fieldInfo, + new BufferedFloatVectorValues(vectors, dimension, docIdSet), + (sortMap != null) ? sortMap::oldToNew : doc -> doc); + } + } + } + } + + private void writeFloatField( + FieldInfo fieldInfo, FloatVectorValues floatVectorValues, IntToIntFunction oldToNewDocId) + throws IOException { + int number = fieldInfo.number; + meta.writeInt(number); + + // Write index to temp file and deallocate from memory + try (Arena temp = Arena.ofConfined()) { + VectorSimilarityFunction function = fieldInfo.getVectorSimilarityFunction(); + MemorySegment indexPointer = + createIndex(description, indexParams, function, floatVectorValues, oldToNewDocId) + // Ensure timely cleanup + .reinterpret(temp, LibFaissC::freeIndex); + + int ioFlags = FAISS_IO_FLAG_MMAP | FAISS_IO_FLAG_READ_ONLY; + + // Write index + long dataOffset = data.getFilePointer(); + indexWrite(indexPointer, data, ioFlags); + long dataLength = data.getFilePointer() - dataOffset; + + meta.writeLong(dataOffset); + meta.writeLong(dataLength); + } + } + + @Override + public void finish() throws IOException { + if (finished) { + throw new IllegalStateException("Already finished"); + } + finished = true; + + rawVectorsWriter.finish(); + meta.writeInt(-1); + CodecUtil.writeFooter(meta); + CodecUtil.writeFooter(data); + } + + @Override + public void close() throws IOException { + IOUtils.close(rawVectorsWriter, meta, data); + } + + @Override + public long ramBytesUsed() { + // TODO: How to estimate Faiss usage? + return rawVectorsWriter.ramBytesUsed(); + } + + private static class BufferedFloatVectorValues extends FloatVectorValues { + private final List floats; + private final int dimension; + private final DocIdSet docIdSet; + + public BufferedFloatVectorValues(List floats, int dimension, DocIdSet docIdSet) { + this.floats = floats; + this.dimension = dimension; + this.docIdSet = docIdSet; + } + + @Override + public float[] vectorValue(int ord) { + return floats.get(ord); + } + + @Override + public int dimension() { + return dimension; + } + + @Override + public int size() { + return floats.size(); + } + + @Override + public FloatVectorValues copy() { + return new BufferedFloatVectorValues(floats, dimension, docIdSet); + } + + @Override + public DocIndexIterator iterator() { + try { + return fromDISI(docIdSet.iterator()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } +} diff --git a/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/LibFaissC.java b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/LibFaissC.java new file mode 100644 index 000000000000..02c1ecd0791f --- /dev/null +++ b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/LibFaissC.java @@ -0,0 +1,614 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.lucene.sandbox.codecs.faiss; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static java.lang.foreign.ValueLayout.JAVA_INT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; +import static org.apache.lucene.search.DocIdSetIterator.NO_MORE_DOCS; + +import java.io.IOException; +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.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.LongBuffer; +import java.util.Arrays; +import java.util.Locale; +import org.apache.lucene.index.FloatVectorValues; +import org.apache.lucene.index.KnnVectorValues; +import org.apache.lucene.index.VectorSimilarityFunction; +import org.apache.lucene.search.KnnCollector; +import org.apache.lucene.store.IndexInput; +import org.apache.lucene.store.IndexOutput; +import org.apache.lucene.util.Bits; +import org.apache.lucene.util.FixedBitSet; +import org.apache.lucene.util.hnsw.IntToIntFunction; + +/** + * Utility class to wrap necessary functions of the native C API of Faiss + * using Project Panama. + * + * @lucene.experimental + */ +final class LibFaissC { + // TODO: Use vectorized version where available + public static final String LIBRARY_NAME = "faiss_c"; + public static final String LIBRARY_VERSION = "1.11.0"; + + // See flags defined in c_api/index_io_c.h + static final int FAISS_IO_FLAG_MMAP = 1; + static final int FAISS_IO_FLAG_READ_ONLY = 2; + + private static final int BUFFER_SIZE = 256 * 1024 * 1024; // 256 MB + + // Begin support utilities for JDK21 -- wrappers for functions that were renamed! + private static final MethodHandle GET_STRING_DELEGATE; + + private static String getStringDelegate(MemorySegment segment, long offset) { + return call(GET_STRING_DELEGATE.bindTo(segment), offset); + } + + private static final MethodHandle ALLOCATE_STRING_DELEGATE; + + private static MemorySegment allocateFromDelegate(Arena arena, String string) { + return call(ALLOCATE_STRING_DELEGATE.bindTo(arena), string); + } + + private static final MethodHandle ALLOCATE_DELEGATE; + + private static MemorySegment allocateDelegate(Arena arena, MemoryLayout layout, long count) { + return call(ALLOCATE_DELEGATE.bindTo(arena), layout, count); + } + + static { + int runtimeVersion = Runtime.version().feature(); + assert runtimeVersion >= 21; + + String getStringFunctionName; + String allocateStringFunctionName; + String allocateFunctionName; + if (runtimeVersion == 21) { + getStringFunctionName = "getUtf8String"; + allocateStringFunctionName = "allocateUtf8String"; + allocateFunctionName = "allocateArray"; + } else { + getStringFunctionName = "getString"; + allocateStringFunctionName = "allocateFrom"; + allocateFunctionName = "allocate"; + } + + try { + GET_STRING_DELEGATE = + MethodHandles.lookup() + .findVirtual( + MemorySegment.class, + getStringFunctionName, + MethodType.methodType(String.class, long.class)); + + ALLOCATE_STRING_DELEGATE = + MethodHandles.lookup() + .findVirtual( + Arena.class, + allocateStringFunctionName, + MethodType.methodType(MemorySegment.class, String.class)); + + ALLOCATE_DELEGATE = + MethodHandles.lookup() + .findVirtual( + Arena.class, + allocateFunctionName, + MethodType.methodType(MemorySegment.class, MemoryLayout.class, long.class)); + } catch (IllegalAccessException | NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + // End support utilities for JDK21 + + static { + System.loadLibrary(LIBRARY_NAME); + checkLibraryVersion(); + } + + private LibFaissC() {} + + private static MemorySegment getUpcallStub( + Arena arena, MethodHandle target, FunctionDescriptor descriptor) { + return Linker.nativeLinker().upcallStub(target, descriptor, arena); + } + + private static MethodHandle getDowncallHandle( + String functionName, FunctionDescriptor descriptor) { + return Linker.nativeLinker() + .downcallHandle(SymbolLookup.loaderLookup().find(functionName).orElseThrow(), descriptor); + } + + private static void checkLibraryVersion() { + MethodHandle getVersion = + getDowncallHandle("faiss_get_version", FunctionDescriptor.of(ADDRESS)); + + MemorySegment nativeString = call(getVersion); + String actualVersion = getStringDelegate(nativeString.reinterpret(Long.MAX_VALUE), 0); + + if (LIBRARY_VERSION.equals(actualVersion) == false) { + throw new UnsupportedOperationException( + String.format( + Locale.ROOT, + "Expected Faiss library version %s, found %s", + LIBRARY_VERSION, + actualVersion)); + } + } + + private static final MethodHandle FREE_INDEX = + getDowncallHandle("faiss_Index_free", FunctionDescriptor.ofVoid(ADDRESS)); + + public static void freeIndex(MemorySegment indexPointer) { + call(FREE_INDEX, indexPointer); + } + + private static final MethodHandle FREE_CUSTOM_IO_WRITER = + getDowncallHandle("faiss_CustomIOWriter_free", FunctionDescriptor.ofVoid(ADDRESS)); + + public static void freeCustomIOWriter(MemorySegment customIOWriterPointer) { + call(FREE_CUSTOM_IO_WRITER, customIOWriterPointer); + } + + private static final MethodHandle FREE_CUSTOM_IO_READER = + getDowncallHandle("faiss_CustomIOReader_free", FunctionDescriptor.ofVoid(ADDRESS)); + + public static void freeCustomIOReader(MemorySegment customIOReaderPointer) { + call(FREE_CUSTOM_IO_READER, customIOReaderPointer); + } + + private static final MethodHandle FREE_PARAMETER_SPACE = + getDowncallHandle("faiss_ParameterSpace_free", FunctionDescriptor.ofVoid(ADDRESS)); + + private static void freeParameterSpace(MemorySegment parameterSpacePointer) { + call(FREE_PARAMETER_SPACE, parameterSpacePointer); + } + + private static final MethodHandle FREE_ID_SELECTOR_BITMAP = + getDowncallHandle("faiss_IDSelectorBitmap_free", FunctionDescriptor.ofVoid(ADDRESS)); + + private static void freeIDSelectorBitmap(MemorySegment idSelectorBitmapPointer) { + call(FREE_ID_SELECTOR_BITMAP, idSelectorBitmapPointer); + } + + private static final MethodHandle FREE_SEARCH_PARAMETERS = + getDowncallHandle("faiss_SearchParameters_free", FunctionDescriptor.ofVoid(ADDRESS)); + + private static void freeSearchParameters(MemorySegment searchParametersPointer) { + call(FREE_SEARCH_PARAMETERS, searchParametersPointer); + } + + private static final MethodHandle INDEX_FACTORY = + getDowncallHandle( + "faiss_index_factory", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS, JAVA_INT)); + + private static final MethodHandle PARAMETER_SPACE_NEW = + getDowncallHandle("faiss_ParameterSpace_new", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + + private static final MethodHandle SET_INDEX_PARAMETERS = + getDowncallHandle( + "faiss_ParameterSpace_set_index_parameters", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS)); + + private static final MethodHandle ID_SELECTOR_BITMAP_NEW = + getDowncallHandle( + "faiss_IDSelectorBitmap_new", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS)); + + private static final MethodHandle SEARCH_PARAMETERS_NEW = + getDowncallHandle( + "faiss_SearchParameters_new", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + + private static final MethodHandle INDEX_IS_TRAINED = + getDowncallHandle("faiss_Index_is_trained", FunctionDescriptor.of(JAVA_INT, ADDRESS)); + + private static final MethodHandle INDEX_TRAIN = + getDowncallHandle( + "faiss_Index_train", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS)); + + private static final MethodHandle INDEX_ADD_WITH_IDS = + getDowncallHandle( + "faiss_Index_add_with_ids", + FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS)); + + public static MemorySegment createIndex( + String description, + String indexParams, + VectorSimilarityFunction function, + FloatVectorValues floatVectorValues, + IntToIntFunction oldToNewDocId) + throws IOException { + + try (Arena temp = Arena.ofConfined()) { + int size = floatVectorValues.size(); + int dimension = floatVectorValues.dimension(); + + // Mapped from faiss/MetricType.h + int metric = + switch (function) { + case DOT_PRODUCT -> 0; + case EUCLIDEAN -> 1; + case COSINE, MAXIMUM_INNER_PRODUCT -> + throw new UnsupportedOperationException("Metric type not supported"); + }; + + // Create an index + MemorySegment pointer = temp.allocate(ADDRESS); + callAndHandleError( + INDEX_FACTORY, pointer, dimension, allocateFromDelegate(temp, description), metric); + MemorySegment indexPointer = pointer.get(ADDRESS, 0); + + // Set index params + callAndHandleError(PARAMETER_SPACE_NEW, pointer); + MemorySegment parameterSpacePointer = + pointer + .get(ADDRESS, 0) + // Ensure timely cleanup + .reinterpret(temp, LibFaissC::freeParameterSpace); + + callAndHandleError( + SET_INDEX_PARAMETERS, + parameterSpacePointer, + indexPointer, + allocateFromDelegate(temp, indexParams)); + + // TODO: Improve memory usage (with a tradeoff in performance) by batched indexing, see: + // - https://github.com/opensearch-project/k-NN/issues/1506 + // - https://github.com/opensearch-project/k-NN/issues/1938 + + // Allocate docs in native memory + MemorySegment docs = allocateDelegate(temp, JAVA_FLOAT, (long) size * dimension); + FloatBuffer docsBuffer = docs.asByteBuffer().order(ByteOrder.nativeOrder()).asFloatBuffer(); + + // Allocate ids in native memory + MemorySegment ids = allocateDelegate(temp, JAVA_LONG, size); + LongBuffer idsBuffer = ids.asByteBuffer().order(ByteOrder.nativeOrder()).asLongBuffer(); + + KnnVectorValues.DocIndexIterator iterator = floatVectorValues.iterator(); + for (int i = iterator.nextDoc(); i != NO_MORE_DOCS; i = iterator.nextDoc()) { + idsBuffer.put(oldToNewDocId.apply(i)); + docsBuffer.put(floatVectorValues.vectorValue(iterator.index())); + } + + // Train index + int isTrained = call(INDEX_IS_TRAINED, indexPointer); + if (isTrained == 0) { + callAndHandleError(INDEX_TRAIN, indexPointer, size, docs); + } + + // Add docs to index + callAndHandleError(INDEX_ADD_WITH_IDS, indexPointer, size, docs, ids); + + return indexPointer; + } + } + + @SuppressWarnings("unused") // called using a MethodHandle + private static long writeBytes( + IndexOutput output, MemorySegment inputPointer, long itemSize, long numItems) + throws IOException { + long size = itemSize * numItems; + inputPointer = inputPointer.reinterpret(size); + + if (size <= BUFFER_SIZE) { // simple case, avoid buffering + byte[] bytes = new byte[(int) size]; + inputPointer.asSlice(0, size).asByteBuffer().order(ByteOrder.nativeOrder()).get(bytes); + output.writeBytes(bytes, bytes.length); + } else { // copy buffered number of bytes repeatedly + byte[] bytes = new byte[BUFFER_SIZE]; + for (long offset = 0; offset < size; offset += BUFFER_SIZE) { + int length = (int) Math.min(size - offset, BUFFER_SIZE); + inputPointer + .asSlice(offset, length) + .asByteBuffer() + .order(ByteOrder.nativeOrder()) + .get(bytes, 0, length); + output.writeBytes(bytes, length); + } + } + return numItems; + } + + @SuppressWarnings("unused") // called using a MethodHandle + private static long readBytes( + IndexInput input, MemorySegment outputPointer, long itemSize, long numItems) + throws IOException { + long size = itemSize * numItems; + outputPointer = outputPointer.reinterpret(size); + + if (size <= BUFFER_SIZE) { // simple case, avoid buffering + byte[] bytes = new byte[(int) size]; + input.readBytes(bytes, 0, bytes.length); + outputPointer + .asSlice(0, bytes.length) + .asByteBuffer() + .order(ByteOrder.nativeOrder()) + .put(bytes); + } else { // copy buffered number of bytes repeatedly + byte[] bytes = new byte[BUFFER_SIZE]; + for (long offset = 0; offset < size; offset += BUFFER_SIZE) { + int length = (int) Math.min(size - offset, BUFFER_SIZE); + input.readBytes(bytes, 0, length); + outputPointer + .asSlice(offset, length) + .asByteBuffer() + .order(ByteOrder.nativeOrder()) + .put(bytes, 0, length); + } + } + return numItems; + } + + private static final MethodHandle WRITE_BYTES_HANDLE; + private static final MethodHandle READ_BYTES_HANDLE; + + static { + try { + WRITE_BYTES_HANDLE = + MethodHandles.lookup() + .findStatic( + LibFaissC.class, + "writeBytes", + MethodType.methodType( + long.class, IndexOutput.class, MemorySegment.class, long.class, long.class)); + + READ_BYTES_HANDLE = + MethodHandles.lookup() + .findStatic( + LibFaissC.class, + "readBytes", + MethodType.methodType( + long.class, IndexInput.class, MemorySegment.class, long.class, long.class)); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private static final MethodHandle CUSTOM_IO_WRITER_NEW = + getDowncallHandle( + "faiss_CustomIOWriter_new", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + + private static final MethodHandle WRITE_INDEX_CUSTOM = + getDowncallHandle( + "faiss_write_index_custom", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)); + + public static void indexWrite(MemorySegment indexPointer, IndexOutput output, int ioFlags) { + try (Arena temp = Arena.ofConfined()) { + MethodHandle writerHandle = WRITE_BYTES_HANDLE.bindTo(output); + MemorySegment writerStub = + getUpcallStub( + temp, writerHandle, FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG, JAVA_LONG)); + + MemorySegment pointer = temp.allocate(ADDRESS); + callAndHandleError(CUSTOM_IO_WRITER_NEW, pointer, writerStub); + MemorySegment customIOWriterPointer = + pointer + .get(ADDRESS, 0) + // Ensure timely cleanup + .reinterpret(temp, LibFaissC::freeCustomIOWriter); + + callAndHandleError(WRITE_INDEX_CUSTOM, indexPointer, customIOWriterPointer, ioFlags); + } + } + + private static final MethodHandle CUSTOM_IO_READER_NEW = + getDowncallHandle( + "faiss_CustomIOReader_new", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + + private static final MethodHandle READ_INDEX_CUSTOM = + getDowncallHandle( + "faiss_read_index_custom", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); + + public static MemorySegment indexRead(IndexInput input, int ioFlags) { + try (Arena temp = Arena.ofConfined()) { + MethodHandle readerHandle = READ_BYTES_HANDLE.bindTo(input); + MemorySegment readerStub = + getUpcallStub( + temp, readerHandle, FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG, JAVA_LONG)); + + MemorySegment pointer = temp.allocate(ADDRESS); + callAndHandleError(CUSTOM_IO_READER_NEW, pointer, readerStub); + MemorySegment customIOReaderPointer = + pointer + .get(ADDRESS, 0) + // Ensure timely cleanup + .reinterpret(temp, LibFaissC::freeCustomIOReader); + + callAndHandleError(READ_INDEX_CUSTOM, customIOReaderPointer, ioFlags, pointer); + return pointer.get(ADDRESS, 0); + } + } + + private static final MethodHandle INDEX_SEARCH = + getDowncallHandle( + "faiss_Index_search", + FunctionDescriptor.of( + JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS)); + + private static final MethodHandle INDEX_SEARCH_WITH_PARAMS = + getDowncallHandle( + "faiss_Index_search_with_params", + FunctionDescriptor.of( + JAVA_INT, ADDRESS, JAVA_LONG, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS, ADDRESS)); + + public static void indexSearch( + MemorySegment indexPointer, + VectorSimilarityFunction function, + float[] query, + KnnCollector knnCollector, + Bits acceptDocs) { + + try (Arena temp = Arena.ofConfined()) { + FixedBitSet fixedBitSet = + switch (acceptDocs) { + case null -> null; + case FixedBitSet bitSet -> bitSet; + // TODO: Add optimized case for SparseFixedBitSet + case Bits bits -> FixedBitSet.copyOf(bits); + }; + + // Allocate queries in native memory + MemorySegment queries = allocateDelegate(temp, JAVA_FLOAT, query.length); + queries.asByteBuffer().order(ByteOrder.nativeOrder()).asFloatBuffer().put(query); + + // Faiss knn search + int k = knnCollector.k(); + MemorySegment distancesPointer = allocateDelegate(temp, JAVA_FLOAT, k); + MemorySegment idsPointer = allocateDelegate(temp, JAVA_LONG, k); + + MemorySegment localIndex = indexPointer.reinterpret(temp, null); + if (fixedBitSet == null) { + // Search without runtime filters + callAndHandleError(INDEX_SEARCH, localIndex, 1, queries, k, distancesPointer, idsPointer); + } else { + MemorySegment pointer = temp.allocate(ADDRESS); + + long[] bits = fixedBitSet.getBits(); + MemorySegment nativeBits = allocateDelegate(temp, JAVA_LONG, bits.length); + + // Use LITTLE_ENDIAN to convert long[] -> uint8_t* + nativeBits.asByteBuffer().order(ByteOrder.LITTLE_ENDIAN).asLongBuffer().put(bits); + + callAndHandleError(ID_SELECTOR_BITMAP_NEW, pointer, fixedBitSet.length(), nativeBits); + MemorySegment idSelectorBitmapPointer = + pointer + .get(ADDRESS, 0) + // Ensure timely cleanup + .reinterpret(temp, LibFaissC::freeIDSelectorBitmap); + + callAndHandleError(SEARCH_PARAMETERS_NEW, pointer, idSelectorBitmapPointer); + MemorySegment searchParametersPointer = + pointer + .get(ADDRESS, 0) + // Ensure timely cleanup + .reinterpret(temp, LibFaissC::freeSearchParameters); + + // Search with runtime filters + callAndHandleError( + INDEX_SEARCH_WITH_PARAMS, + localIndex, + 1, + queries, + k, + searchParametersPointer, + distancesPointer, + idsPointer); + } + + // Retrieve scores + float[] distances = new float[k]; + distancesPointer.asByteBuffer().order(ByteOrder.nativeOrder()).asFloatBuffer().get(distances); + + // Retrieve ids + long[] ids = new long[k]; + idsPointer.asByteBuffer().order(ByteOrder.nativeOrder()).asLongBuffer().get(ids); + + // Record hits + for (int i = 0; i < k; i++) { + // Not enough results + if (ids[i] == -1) { + break; + } + + // Scale Faiss distances to Lucene scores, see VectorSimilarityFunction.java + float score = + switch (function) { + case DOT_PRODUCT -> + // distance in Faiss === dotProduct in Lucene + Math.max((1 + distances[i]) / 2, 0); + + case EUCLIDEAN -> + // distance in Faiss === squareDistance in Lucene + 1 / (1 + distances[i]); + + case COSINE, MAXIMUM_INNER_PRODUCT -> + throw new UnsupportedOperationException("Metric type not supported"); + }; + + knnCollector.collect((int) ids[i], score); + } + } + } + + @SuppressWarnings("unchecked") + private static T call(MethodHandle handle, Object... args) { + try { + return (T) handle.invokeWithArguments(args); + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + + private static void callAndHandleError(MethodHandle handle, Object... args) { + int returnCode = call(handle, args); + if (returnCode < 0) { + // TODO: Surface actual exception in a thread-safe manner? + throw new FaissException(returnCode); + } + } + + /** + * Exception used to rethrow handled Faiss errors in native code. + * + * @lucene.experimental + */ + public static class FaissException extends RuntimeException { + // See error codes defined in c_api/error_c.h + enum ErrorCode { + /// No error + OK(0), + /// Any exception other than Faiss or standard C++ library exceptions + UNKNOWN_EXCEPT(-1), + /// Faiss library exception + FAISS_EXCEPT(-2), + /// Standard C++ library exception + STD_EXCEPT(-4); + + private final int code; + + ErrorCode(int code) { + this.code = code; + } + + static ErrorCode fromCode(int code) { + return Arrays.stream(ErrorCode.values()) + .filter(errorCode -> errorCode.code == code) + .findFirst() + .orElseThrow(); + } + } + + public FaissException(int code) { + super(String.format(Locale.ROOT, "Faiss library ran into %s", ErrorCode.fromCode(code))); + } + } +} diff --git a/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/package-info.java b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/package-info.java new file mode 100644 index 000000000000..e63fa3070f96 --- /dev/null +++ b/lucene/sandbox/src/java21/org/apache/lucene/sandbox/codecs/faiss/package-info.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +/** + * Faiss is "a library for efficient + * similarity search and clustering of dense vectors", with support for various vector + * transforms, indexing algorithms, quantization techniques, etc. This package provides a pluggable + * Faiss-based format to perform vector searches in Lucene, via {@link + * org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormat}. + * + *

To use this format: Install pytorch/faiss-cpu v{@value + * org.apache.lucene.sandbox.codecs.faiss.LibFaissC#LIBRARY_VERSION} from Conda and place shared libraries (including + * dependencies) on the {@code $LD_LIBRARY_PATH} environment variable or {@code -Djava.library.path} + * JVM argument. + * + *

Important: Ensure that the license of the Conda distribution and channels is applicable to + * you. pytorch and conda-forge are community-maintained channels with + * permissive licenses! + * + *

Sample setup: + * + *

    + *
  • Install micromamba (an open-source Conda + * package manager) or similar + *
  • Install dependencies using {@code micromamba create -n faiss-env -c pytorch -c conda-forge + * -y faiss-cpu=}{@value org.apache.lucene.sandbox.codecs.faiss.LibFaissC#LIBRARY_VERSION} + *
  • Activate environment using {@code micromamba activate faiss-env} + *
  • Add shared libraries to runtime using {@code export LD_LIBRARY_PATH=$CONDA_PREFIX/lib} + *
  • And you're good to go! (add the {@code -Dtests.faiss.run=true} JVM argument to ensure Faiss + * tests are run) + *
+ * + * @lucene.experimental + */ +package org.apache.lucene.sandbox.codecs.faiss; diff --git a/lucene/sandbox/src/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat b/lucene/sandbox/src/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat new file mode 100644 index 000000000000..418d70fb51fd --- /dev/null +++ b/lucene/sandbox/src/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +org.apache.lucene.sandbox.codecs.faiss.FaissKnnVectorsFormatProvider diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/codecs/faiss/TestFaissKnnVectorsFormat.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/codecs/faiss/TestFaissKnnVectorsFormat.java new file mode 100644 index 000000000000..92226118ead0 --- /dev/null +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/codecs/faiss/TestFaissKnnVectorsFormat.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.lucene.sandbox.codecs.faiss; + +import static org.apache.lucene.index.VectorEncoding.FLOAT32; +import static org.apache.lucene.index.VectorSimilarityFunction.DOT_PRODUCT; +import static org.apache.lucene.index.VectorSimilarityFunction.EUCLIDEAN; + +import org.apache.lucene.codecs.Codec; +import org.apache.lucene.index.VectorEncoding; +import org.apache.lucene.index.VectorSimilarityFunction; +import org.apache.lucene.tests.index.BaseKnnVectorsFormatTestCase; +import org.apache.lucene.tests.util.TestUtil; +import org.junit.BeforeClass; +import org.junit.Ignore; + +/** + * Tests for {@link FaissKnnVectorsFormatProvider}. Will run only if required shared libraries + * (including dependencies) are present at runtime, or the {@value #FAISS_RUN_TESTS} JVM arg is set + * to {@code true} + */ +public class TestFaissKnnVectorsFormat extends BaseKnnVectorsFormatTestCase { + private static final String FAISS_RUN_TESTS = "tests.faiss.run"; + + private static final VectorEncoding[] SUPPORTED_ENCODINGS = {FLOAT32}; + private static final VectorSimilarityFunction[] SUPPORTED_FUNCTIONS = {DOT_PRODUCT, EUCLIDEAN}; + + @BeforeClass + public static void maybeSuppress() throws ClassNotFoundException { + // Explicitly run tests + if (Boolean.getBoolean(FAISS_RUN_TESTS)) { + return; + } + + // Otherwise check if dependencies are present + boolean faissLibraryPresent; + try { + Class.forName("org.apache.lucene.sandbox.codecs.faiss.LibFaissC"); + faissLibraryPresent = true; + } catch ( + @SuppressWarnings("unused") + UnsatisfiedLinkError error) { + faissLibraryPresent = false; + } + assumeTrue("Native libraries present", faissLibraryPresent); + } + + @Override + protected VectorEncoding randomVectorEncoding() { + return SUPPORTED_ENCODINGS[random().nextInt(SUPPORTED_ENCODINGS.length)]; + } + + @Override + protected VectorSimilarityFunction randomSimilarity() { + return SUPPORTED_FUNCTIONS[random().nextInt(SUPPORTED_FUNCTIONS.length)]; + } + + @Override + protected Codec getCodec() { + return TestUtil.alwaysKnnVectorsFormat(new FaissKnnVectorsFormatProvider()); + } + + @Override + @Ignore // does not honour visitedLimit + public void testSearchWithVisitedLimit() {} + + @Override + @Ignore // does not support byte vectors + public void testByteVectorScorerIteration() {} + + @Override + @Ignore // does not support byte vectors + public void testMismatchedFields() {} + + @Override + @Ignore // does not support byte vectors + public void testSortedIndexBytes() {} + + @Override + @Ignore // does not support byte vectors + public void testRandomBytes() {} + + @Override + @Ignore // does not support byte vectors + public void testEmptyByteVectorData() {} + + @Override + @Ignore // does not support byte vectors + public void testMergingWithDifferentByteKnnFields() {} +}