diff --git a/RAPIDS_BRANCH b/RAPIDS_BRANCH index 8ce6b738..30897fb7 100644 --- a/RAPIDS_BRANCH +++ b/RAPIDS_BRANCH @@ -1 +1 @@ -release/26.08 +pull-request/2345 diff --git a/build.sh b/build.sh index 39322568..4a857492 100755 --- a/build.sh +++ b/build.sh @@ -31,22 +31,7 @@ if hasArg --build-cuvs-java; then pushd $CUVS_WORKDIR fi - # libcuvs comes from the conda packages, so normally only the java bindings have - # to be built. For a pull request, the conda packages do not contain the PR's - # changes, so CI downloads the pre-built libcuvs artifact from the PR's own CI run. - CUVS_BUILD_TARGETS=("java") - if hasArg --use-pr-libcuvs && [[ "$BRANCH" == pull-request/* ]]; then - PR_NUM="${BRANCH#pull-request/}" - echo "Downloading libcuvs conda artifact from cuvs PR #${PR_NUM}..." - LIBCUVS_CONDA_DIR=$(rapids-get-pr-artifact NVIDIA/cuvs "$PR_NUM" cpp conda) - LIBCUVS_DIR=$(rapids-extract-conda-files "$LIBCUVS_CONDA_DIR") - # The downloaded library has to take precedence over the one provided by the - # conda packages, both here and while running the java tests. - LD_LIBRARY_PATH="$LIBCUVS_DIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - export LD_LIBRARY_PATH - echo "LD_LIBRARY_PATH is: $LD_LIBRARY_PATH" - fi - ./build.sh "${CUVS_BUILD_TARGETS[@]}" + ./build.sh java popd fi @@ -55,6 +40,8 @@ if ! hasArg --run-java-tests; then MAVEN_VERIFY_ARGS=("-DskipTests") fi +echo "LD_LIBRARY_PATH just before the test is: $LD_LIBRARY_PATH" + mvn clean verify "${MAVEN_VERIFY_ARGS[@]}" \ && mvn jacoco:report \ && mvn install:install-file -Dfile=./target/cuvs-lucene-$VERSION.jar -DgroupId=$GROUP_ID -DartifactId=cuvs-lucene -Dversion=$VERSION -Dpackaging=jar \ diff --git a/ci/build_java.sh b/ci/build_java.sh index f9e3c1af..4d7e24b0 100755 --- a/ci/build_java.sh +++ b/ci/build_java.sh @@ -16,11 +16,6 @@ fi # Always build cuvs-java when running the pipeline EXTRA_BUILD_ARGS+=("--build-cuvs-java") -# When RAPIDS_BRANCH points at a cuvs pull request, the libcuvs from the conda -# packages does not contain the PR's changes, so download the pre-built artifact -# from the PR's CI run instead. Only done in CI, local builds use the conda packages. -EXTRA_BUILD_ARGS+=("--use-pr-libcuvs") - # shellcheck disable=SC1091 . /opt/conda/etc/profile.d/conda.sh @@ -66,6 +61,27 @@ else exit 1 fi +# When RAPIDS_BRANCH points at a cuvs PR, the conda packages do not contain the +# PR's changes, so download the pre-built libcuvs artifact from the PR's own CI run. +BRANCH=$(cat "RAPIDS_BRANCH") +if [[ "$BRANCH" == pull-request/* ]]; then + rapids-logger "Remove libcuvs from conda environment" + # Uninstall the conda libcuvs so the JVM's RPATH (which points to the conda + # env's lib dir) cannot find the old libcuvs_c.so ahead of the PR artifact. + set +u + conda remove --yes --force-remove libcuvs + set -u + # Download PR artifact + PR_NUM="${BRANCH#pull-request/}" + rapids-logger "Downloading libcuvs conda artifact from cuvs PR #${PR_NUM}" + LIBCUVS_CONDA_DIR=$(rapids-get-pr-artifact NVIDIA/cuvs "$PR_NUM" cpp conda) + LIBCUVS_ARTIFACT_DIR=$(rapids-extract-conda-files "$LIBCUVS_CONDA_DIR") + # The PR artifact must take precedence over the conda-installed libcuvs both + # at runtime and for cmake's find_package (to pick up new C API headers for jextract). + export LD_LIBRARY_PATH="$LIBCUVS_ARTIFACT_DIR/lib:$LD_LIBRARY_PATH" + export cuvs_ROOT="$LIBCUVS_ARTIFACT_DIR" +fi + rapids-logger "Run Java build" bash ./build.sh "${EXTRA_BUILD_ARGS[@]}" diff --git a/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java b/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java index aac31415..8d511fef 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java +++ b/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java @@ -17,9 +17,8 @@ public class AcceleratedHNSWParams { public static enum Strategy { /* - * This strategy allows for automatic selection of the underlying CAGRA build algorithm. - * With this strategy we use NN_DESCENT for dataset less than 5M vectors, else we use IVF_PQ. - * Indexing parameters, especially for IVF_PQ, are heuristically identified automatically. + * This strategy delegates the derivation of the CAGRA build parameters (graph degrees, build + * algorithm and its parameters) to cuVS, based on HNSW-equivalent maxConn and beamWidth. * * This is the default and the recommended strategy. */ @@ -95,7 +94,6 @@ public static enum Strategy { * @param writerThreads Number of cuVS writer threads to use. * @param intermediateGraphDegree The intermediate graph degree while building the CAGRA index. * @param graphdegree The graph degree to use while building the CAGRA index. - * @param indexType The type of index to build - CAGRA, BRUTEFORCE, or both. * @param hnswLayers The number of HNSW layers to build in the HNSW index. * @param maxConn The max connection parameter used when building HNSW index with the fallback mechanism. * @param beamWidth The beam width parameter used when building HNSW index with the fallback mechanism. @@ -103,7 +101,7 @@ public static enum Strategy { * @param cuVSIvfPqParams An instance of CuVSIvfPqParams containing IVF_PQ specific parameters. * @param numMergeWorkers The number of merge workers to use with the fallback mechanism. * @param mergeExec The instance of {@link ExecutorService} to use with the fallback mechanism. - * @param strategy either HEURISTIC [Default] that automatically chooses build algorithm and its parameters based on data set size or CUSTOM that uses the parameters passed though this class. + * @param strategy either HEURISTIC [Default] that delegates the CAGRA build parameters to cuVS (derived from the HNSW-equivalent maxConn and beamWidth) or CUSTOM that uses the parameters passed through this class. * @param cuvsDistanceType the cuvsDistanceType. The default option is L2Expanded. * @param nnDescentNumIterations the number of Iterations to run if building with NN_DESCENT. */ diff --git a/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWUtils.java b/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWUtils.java index 2a8ab02f..cb13f7af 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWUtils.java +++ b/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWUtils.java @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -74,7 +74,8 @@ public static GPUBuiltHnswGraph createSingleVectorHnswGraph(int size, int dimens /** * Creates a multi-layer HNSW graph with dynamic number of layers. - * M = cagraGraphDegree/2 + * M = ceil(cagraGraphDegree / 2), where cagraGraphDegree is the CAGRA adjacency list's degree + * (its column count). Ceil is used to accommodate odd graph degrees. * Each layer contains 1/M nodes from the previous layer * Creates layers until the highest layer has ≤ M nodes */ @@ -85,13 +86,11 @@ public static GPUBuiltHnswGraph createMultiLayerHnswGraph( CuVSMatrix adjacencyListMatrix, List vectors, int hnswLayers, - int graphDegree, CagraIndexParams params, QuantizationType quantization) throws Throwable { - // Calculate M as cagraGraphDegree/2 - int M = graphDegree / 2; + int M = Math.ceilDiv((int) adjacencyListMatrix.columns(), 2); // Store all layers data List layerNodes = new ArrayList<>(); @@ -320,7 +319,7 @@ public static void writeMeta( meta.writeVLong(vectorIndexLength); meta.writeVInt(field.getVectorDimension()); meta.writeInt(count); - meta.writeVInt(graphDegree / 2); // M = cagraGraphDegree/2 + meta.writeVInt(Math.ceilDiv(graphDegree, 2)); // M = ceil(cagraGraphDegree / 2) // write graph nodes on each level if (graph == null) { diff --git a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java index 20fed04b..376cc955 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java +++ b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java @@ -7,185 +7,53 @@ import com.nvidia.cuvs.CagraIndexParams; import com.nvidia.cuvs.CagraIndexParams.CagraGraphBuildAlgo; -import com.nvidia.cuvs.CagraIndexParams.CodebookGen; -import com.nvidia.cuvs.CagraIndexParams.CudaDataType; -import com.nvidia.cuvs.CagraIndexParams.CuvsDistanceType; -import com.nvidia.cuvs.CuVSIvfPqIndexParams; -import com.nvidia.cuvs.CuVSIvfPqParams; -import com.nvidia.cuvs.CuVSIvfPqSearchParams; /** - * A centralized approach to producing instances of {@link CagraIndexParams} based on the chosen strategy + * A centralized place for producing {@link CagraIndexParams} from the cuvs-lucene input parameter + * classes based on the chosen strategy. + * + *

For the {@code HEURISTIC} strategy the build heuristics are delegated to cuVS. + * */ public class CagraIndexParamsFactory { - private static final int ALGO_SWITCH_THRESHOLD = 5_000_000; + private CagraIndexParamsFactory() {} /** - * Translation of the internal logic found here: - * https://github.com/rapidsai/cuvs/blob/main/cpp/include/cuvs/neighbors/ivf_pq.hpp#L3385-L3428 + * Creates an instance of {@link CagraIndexParams} for the GPU-native CAGRA index based on the + * chosen strategy in the {@link GPUSearchParams}. * - * Ideally we should hook into the internal API but this is currently replicated to avoid complications - * in other parts of code base. - */ - private static CuVSIvfPqParams getCuVSIvfPqParams(long rows, long dimension) { - - int pqDim; - int pqBits; - - if (dimension <= 32) { - pqDim = 16; - pqBits = 8; - } else { - pqBits = 4; - if (dimension <= 64) { - pqDim = 32; - } else if (dimension <= 128) { - pqDim = 64; - } else if (dimension <= 192) { - pqDim = 96; - } else { - pqDim = (int) roundUpSafe(dimension / 2, 128); - } - } - - int nLists = (int) Math.max(1, rows / 2000); - final int kmeansNIters = 10; - final double kMinPointsPerCluster = 32; - double minKmeansTrainsetPoints = kMinPointsPerCluster * nLists; - final double maxKmeansTrainsetFraction = 1.0; - double minKmeansTrainsetFraction = - Math.min(maxKmeansTrainsetFraction, minKmeansTrainsetPoints / rows); - double kmeansTrainsetFraction = - Math.clamp( - 1.0 / Math.sqrt(rows * 1e-5), minKmeansTrainsetFraction, maxKmeansTrainsetFraction); - final CodebookGen codebookKind = CodebookGen.PER_SUBSPACE; - int nProbes = (int) Math.round(Math.sqrt(nLists) / 20 + 4); - final int refinementRate = 1; - - CuVSIvfPqIndexParams cuVSIvfPqIndexParams = - new CuVSIvfPqIndexParams.Builder() - .withCodebookKind(codebookKind) - .withKmeansNIters(kmeansNIters) - .withKmeansTrainsetFraction(kmeansTrainsetFraction) - .withNLists(nLists) - .withPqBits(pqBits) - .withPqDim(pqDim) - .withAddDataOnBuild(true) - .withConservativeMemoryAllocation(true) - .build(); - - CuVSIvfPqSearchParams cuVSIvfPqSearchParams = - new CuVSIvfPqSearchParams.Builder() - .withLutDtype(CudaDataType.CUDA_R_16F) - .withInternalDistanceDtype(CudaDataType.CUDA_R_16F) - .withNProbes(nProbes) - .build(); - - CuVSIvfPqParams cuVSIvfPqParams = - new CuVSIvfPqParams.Builder() - .withCuVSIvfPqIndexParams(cuVSIvfPqIndexParams) - .withCuVSIvfPqSearchParams(cuVSIvfPqSearchParams) - .withRefinementRate(refinementRate) - .build(); - - return cuVSIvfPqParams; - } - - /* - * Rough translation from raft's internal utility found here: - * https://github.com/rapidsai/raft/blob/main/cpp/include/raft/util/integer_utils.hpp#L47-L56 - */ - private static long roundUpSafe(long numberToRound, long modulus) { - long remainder = numberToRound % modulus; - if (remainder == 0) { - return numberToRound; - } - long roundedUp = numberToRound - remainder + modulus; - return roundedUp; - } - - private static CagraIndexParams getNNDescentParams( - int graphDegree, - int intGraphDegree, - int writerThreads, - long nnDescentNumIterations, - CuvsDistanceType cuvsDistanceType) { - return new CagraIndexParams.Builder() - .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.NN_DESCENT) - .withGraphDegree(graphDegree) - .withIntermediateGraphDegree(intGraphDegree) - .withNNDescentNumIterations(nnDescentNumIterations) - .withNumWriterThreads(writerThreads) - .withMetric(cuvsDistanceType) - .build(); - } - - private static CagraIndexParams getIVFPQParams( - int graphDegree, - int intGraphDegree, - int writerThreads, - long rows, - long dimension, - CuvsDistanceType cuvsDistanceType) { - return new CagraIndexParams.Builder() - .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.IVF_PQ) - .withCuVSIvfPqParams(getCuVSIvfPqParams(rows, dimension)) - .withNumWriterThreads(writerThreads) - .withIntermediateGraphDegree(intGraphDegree) - .withGraphDegree(graphDegree) - .withMetric(cuvsDistanceType) - .build(); - } - - /** - * Creates an instance of {@link CagraIndexParams} based on the chosen strategy in the {@link GPUSearchParams}. - * - * @param gPUSearchParams an instance of {@link GPUSearchParams} containing input params incoming via the build and search on the GPU API. - * @param rows number of vectors in the data set - * @param dimension the dimension of the vectors in the data set + * @param gpuSearchParams the input parameters for the build and search on the GPU API * @return an instance of {@link CagraIndexParams} */ - public static CagraIndexParams create( - GPUSearchParams gPUSearchParams, long rows, long dimension) { - if (gPUSearchParams.getStrategy().equals(GPUSearchParams.Strategy.HEURISTIC)) { - if (rows < ALGO_SWITCH_THRESHOLD) { - return getNNDescentParams( - gPUSearchParams.getGraphdegree(), - gPUSearchParams.getIntermediateGraphDegree(), - gPUSearchParams.getWriterThreads(), - gPUSearchParams.getnNDescentNumIterations(), - gPUSearchParams.getCuvsDistanceType()); - } else { - return getIVFPQParams( - gPUSearchParams.getGraphdegree(), - gPUSearchParams.getIntermediateGraphDegree(), - gPUSearchParams.getWriterThreads(), - rows, - dimension, - gPUSearchParams.getCuvsDistanceType()); - } + public static CagraIndexParams create(GPUSearchParams gpuSearchParams) { + CagraIndexParams.Builder builder = + new CagraIndexParams.Builder() + .withGraphDegree(gpuSearchParams.getGraphdegree()) + .withIntermediateGraphDegree(gpuSearchParams.getIntermediateGraphDegree()) + .withNumWriterThreads(gpuSearchParams.getWriterThreads()); + if (gpuSearchParams.getStrategy().equals(GPUSearchParams.Strategy.HEURISTIC)) { + // AUTO_SELECT: cuVS picks the build algorithm and derives its parameters at build time, so + // the IVF-PQ params and nn-descent iterations are left to cuVS rather than forwarded here. + builder + .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.AUTO_SELECT) + .withMetric(gpuSearchParams.getCuvsDistanceType()); } else { - return new CagraIndexParams.Builder() - .withNumWriterThreads(gPUSearchParams.getWriterThreads()) - .withIntermediateGraphDegree(gPUSearchParams.getIntermediateGraphDegree()) - .withGraphDegree(gPUSearchParams.getGraphdegree()) - .withCagraGraphBuildAlgo(gPUSearchParams.getCagraGraphBuildAlgo()) - .withCuVSIvfPqParams(gPUSearchParams.getCuVSIvfPqParams()) - .withNNDescentNumIterations(gPUSearchParams.getnNDescentNumIterations()) - .build(); + // CUSTOM: forward the caller's algorithm and the parameters it consumes -- IVF-PQ params for + // IVF_PQ, nn-descent iterations for NN_DESCENT (each is ignored by the other algorithm). + builder + .withCagraGraphBuildAlgo(gpuSearchParams.getCagraGraphBuildAlgo()) + .withCuVSIvfPqParams(gpuSearchParams.getCuVSIvfPqParams()) + .withNNDescentNumIterations(gpuSearchParams.getnNDescentNumIterations()); } + return builder.build(); } - /* - * Ideally there should be just one create method instead of two. - * We should do that when both the input parameter classes can be unified in the future. - */ - /** - * Creates an instance of {@link CagraIndexParams} based on the chosen strategy in the {@link AcceleratedHNSWParams}. + * Creates an instance of {@link CagraIndexParams} for the accelerated-HNSW index based on the + * chosen strategy in the {@link AcceleratedHNSWParams}. * - * @param acceleratedHNSWParams an instance of {@link AcceleratedHNSWParams} containing input params incoming via the build and search on the GPU API. + * @param acceleratedHNSWParams the input parameters for the build on the GPU API * @param rows number of vectors in the data set * @param dimension the dimension of the vectors in the data set * @return an instance of {@link CagraIndexParams} @@ -193,33 +61,38 @@ public static CagraIndexParams create( public static CagraIndexParams create( AcceleratedHNSWParams acceleratedHNSWParams, long rows, long dimension) { if (acceleratedHNSWParams.getStrategy().equals(AcceleratedHNSWParams.Strategy.HEURISTIC)) { - if (rows - < ALGO_SWITCH_THRESHOLD) { // TODO: maybe consider making this threshold configurable from - // outside later. - return getNNDescentParams( - acceleratedHNSWParams.getGraphdegree(), - acceleratedHNSWParams.getIntermediateGraphDegree(), - acceleratedHNSWParams.getWriterThreads(), - acceleratedHNSWParams.getNNDescentNumIterations(), - acceleratedHNSWParams.getCuvsDistanceType()); - } else { - return getIVFPQParams( - acceleratedHNSWParams.getGraphdegree(), - acceleratedHNSWParams.getIntermediateGraphDegree(), - acceleratedHNSWParams.getWriterThreads(), - rows, - dimension, - acceleratedHNSWParams.getCuvsDistanceType()); - } - } else { + // Delegate the CAGRA build heuristic (algorithm choice and its parameters) to cuVS via the + // CAGRA-native fromDataset API. The codec's HNSW maxConn maps to a CAGRA graph degree of + // 2 * maxConn (so the resulting HNSW M = ceil(graphDegree / 2) == maxConn), and beamWidth to + // build_quality = beamWidth / 16. These reproduce what fromHnswParams(SAME_GRAPH_FOOTPRINT) + // produced before, so the built graph is unchanged. + CagraIndexParams derived = + CagraIndexParams.fromDataset( + rows, + dimension, + 2L * acceleratedHNSWParams.getMaxConn(), + acceleratedHNSWParams.getCuvsDistanceType(), + acceleratedHNSWParams.getBeamWidth() / 16); + // TODO: fromDataset has no writerThreads argument, so its result carries the cuVS default + // (not a heuristic value). We rebuild the CagraIndexParams with the caller-supplied + // writerThreads for now but should fix this in cuVS in the future. return new CagraIndexParams.Builder() + .withGraphDegree(derived.getGraphDegree()) + .withIntermediateGraphDegree(derived.getIntermediateGraphDegree()) + .withCagraGraphBuildAlgo(derived.getCagraGraphBuildAlgo()) + .withCuVSIvfPqParams(derived.getCuVSIvfPqParams()) + .withNNDescentNumIterations(derived.getNNDescentNumIterations()) + .withMetric(derived.getCuvsDistanceType()) .withNumWriterThreads(acceleratedHNSWParams.getWriterThreads()) - .withIntermediateGraphDegree(acceleratedHNSWParams.getIntermediateGraphDegree()) - .withGraphDegree(acceleratedHNSWParams.getGraphdegree()) - .withCagraGraphBuildAlgo(acceleratedHNSWParams.getCagraGraphBuildAlgo()) - .withCuVSIvfPqParams(acceleratedHNSWParams.getCuVSIvfPqParams()) - .withNNDescentNumIterations(acceleratedHNSWParams.getNNDescentNumIterations()) .build(); } + return new CagraIndexParams.Builder() + .withNumWriterThreads(acceleratedHNSWParams.getWriterThreads()) + .withIntermediateGraphDegree(acceleratedHNSWParams.getIntermediateGraphDegree()) + .withGraphDegree(acceleratedHNSWParams.getGraphdegree()) + .withCagraGraphBuildAlgo(acceleratedHNSWParams.getCagraGraphBuildAlgo()) + .withCuVSIvfPqParams(acceleratedHNSWParams.getCuVSIvfPqParams()) + .withNNDescentNumIterations(acceleratedHNSWParams.getNNDescentNumIterations()) + .build(); } } diff --git a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java index e8fb304e..da5d0a60 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java +++ b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ package com.nvidia.cuvs.lucene; @@ -241,8 +241,7 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) thro * @throws Throwable */ private void writeCagraIndex(OutputStream os, CuVSMatrix dataset) throws Throwable { - CagraIndexParams params = - CagraIndexParamsFactory.create(gpuSearchParams, dataset.size(), dataset.columns()); + CagraIndexParams params = CagraIndexParamsFactory.create(gpuSearchParams); CagraIndex index = CagraIndex.newBuilder(getCuVSResourcesInstance()) .withDataset(dataset) diff --git a/src/main/java/com/nvidia/cuvs/lucene/FilterCuVSProvider.java b/src/main/java/com/nvidia/cuvs/lucene/FilterCuVSProvider.java index 07e64fb1..f5e904ec 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/FilterCuVSProvider.java +++ b/src/main/java/com/nvidia/cuvs/lucene/FilterCuVSProvider.java @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ package com.nvidia.cuvs.lucene; @@ -165,4 +165,10 @@ public HnswIndex hnswIndexBuild(CuVSResources arg0, HnswIndexParams arg1, CuVSMa throws Throwable { return delegate.hnswIndexBuild(arg0, arg1, arg2); } + + @Override + public CagraIndexParams cagraIndexParamsFromDataset( + long arg0, long arg1, long arg2, CagraIndexParams.CuvsDistanceType arg3, long arg4) { + return delegate.cagraIndexParamsFromDataset(arg0, arg1, arg2, arg3, arg4); + } } diff --git a/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java b/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java index cc445268..f035784d 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java +++ b/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -16,15 +16,15 @@ public class GPUSearchParams { public static enum Strategy { /* - * This strategy allows for automatic selection of the underlining CAGRA build algorithm. - * With this strategy we use NN_DESCENT for data set less then 5M vectors else we use IVF_PQ. - * Indexing parameters, especially for IVF_PQ, are heuristically identified automatically. + * This strategy lets cuVS auto-select the CAGRA build algorithm (and its parameters) for the + * given dataset. * * This is the default and the recommended strategy. */ HEURISTIC, /* * This is an option when the end-user would want to use custom parameter values. + * * This strategy should only be used under expert guidance. */ CUSTOM @@ -77,8 +77,7 @@ public static enum Strategy { * @param cagraGraphBuildAlgo The CAGRA build algorithm to use. * @param indexType The type of index to build - CAGRA, BRUTEFORCE, or both. * @param cuVSIvfPqParams An instance of CuVSIvfPqParams containing IVF_PQ specific parameters. - * @param strategy either HEURISTIC [Default] that automatically chooses build algorithm and its parameters based on data set size or CUSTOM that uses the parameters passed though this class. - * @param heuristicType the heuristic type. The default option is SAME_GRAPH_FOOTPRINT. + * @param strategy either HEURISTIC [Default] that lets cuVS auto-select the build algorithm and its parameters or CUSTOM that uses the parameters passed through this class. * @param cuvsDistanceType the cuvsDistanceType. The default option is L2Expanded. * @param nnDescentNumIterations the number of Iterations to run if building with NN_DESCENT. */ diff --git a/src/main/java/com/nvidia/cuvs/lucene/Lucene99AcceleratedHNSWVectorsWriter.java b/src/main/java/com/nvidia/cuvs/lucene/Lucene99AcceleratedHNSWVectorsWriter.java index 35209ce5..b57dd6e2 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/Lucene99AcceleratedHNSWVectorsWriter.java +++ b/src/main/java/com/nvidia/cuvs/lucene/Lucene99AcceleratedHNSWVectorsWriter.java @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ package com.nvidia.cuvs.lucene; @@ -170,6 +170,9 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) thro CuVSMatrix adjacencyListMatrix = cagraIndex.getGraph(); int size = (int) dataset.size(); int dimensions = fieldInfo.getVectorDimension(); + // The HNSW M is derived from the CAGRA graph degree, which is the adjacency list's column + // count (the degree cuVS actually built), used both below and when writing the meta. + int graphDegree = (int) adjacencyListMatrix.columns(); GPUBuiltHnswGraph hnswGraph = createMultiLayerHnswGraph( fieldInfo, @@ -178,7 +181,6 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) thro adjacencyListMatrix, vectors, acceleratedHNSWParams.getHnswLayers(), - acceleratedHNSWParams.getGraphdegree(), params, QuantizationType.NONE); long vectorIndexOffset = hnswVectorIndex.getFilePointer(); @@ -193,7 +195,7 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) thro size, hnswGraph, graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphDegree); cagraIndex.close(); } catch (Throwable t) { Utils.handleThrowable(t); diff --git a/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWBinaryQuantizedVectorsWriter.java b/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWBinaryQuantizedVectorsWriter.java index d8a98cc2..2cf5330a 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWBinaryQuantizedVectorsWriter.java +++ b/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWBinaryQuantizedVectorsWriter.java @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ package com.nvidia.cuvs.lucene; @@ -174,6 +174,9 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throw CuVSMatrix adjacencyListMatrix = cagraIndex.getGraph(); int size = (int) dataset.size(); + // The CAGRA graph degree is the adjacency list's column count (the degree cuVS actually + // built); it is what the HNSW meta records as the basis for M. + int graphDegree = (int) adjacencyListMatrix.columns(); // Create multi-layer HNSW graph from CAGRA GPUBuiltHnswGraph hnswGraph = @@ -184,7 +187,6 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throw adjacencyListMatrix, vectors, acceleratedHNSWParams.getHnswLayers(), - acceleratedHNSWParams.getGraphdegree(), params, QuantizationType.BINARY); @@ -203,7 +205,7 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throw size, hnswGraph, graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphDegree); cagraIndex.close(); diff --git a/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWScalarQuantizedVectorsWriter.java b/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWScalarQuantizedVectorsWriter.java index 21b4be3f..e25ef4af 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWScalarQuantizedVectorsWriter.java +++ b/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWScalarQuantizedVectorsWriter.java @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ package com.nvidia.cuvs.lucene; @@ -201,6 +201,9 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throws IOE CuVSMatrix adjacencyListMatrix = cagraIndex.getGraph(); int size = (int) dataset.size(); + // The CAGRA graph degree is the adjacency list's column count (the degree cuVS actually + // built); it is what the HNSW meta records as the basis for M. + int graphDegree = (int) adjacencyListMatrix.columns(); GPUBuiltHnswGraph hnswGraph = createMultiLayerHnswGraph( fieldInfo, @@ -209,7 +212,6 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throws IOE adjacencyListMatrix, unsignedVectors, acceleratedHNSWParams.getHnswLayers(), - acceleratedHNSWParams.getGraphdegree(), params, QuantizationType.SCALAR); @@ -230,7 +232,7 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throws IOE size, hnswGraph, graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphDegree); cagraIndex.close(); } catch (Throwable t) { diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java b/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java new file mode 100644 index 00000000..ce56ca9a --- /dev/null +++ b/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuvs.lucene; + +import static com.nvidia.cuvs.lucene.TestUtils.generateDataset; +import static com.nvidia.cuvs.lucene.ThreadLocalCuVSResourcesProvider.isSupported; +import static org.apache.lucene.index.VectorSimilarityFunction.EUCLIDEAN; + +import com.nvidia.cuvs.CagraIndexParams.CagraGraphBuildAlgo; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Random; +import java.util.UUID; +import org.apache.commons.io.FileUtils; +import org.apache.lucene.codecs.Codec; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.KnnFloatVectorField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.KnnFloatVectorQuery; +import org.apache.lucene.search.TopDocs; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.LuceneTestCase.SuppressSysoutChecks; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Regression test for the CAGRA-to-HNSW conversion when the CAGRA graph has an odd graph + * degree. + * + *

The HNSW max-connections parameter is derived from the CAGRA graph degree as {@code M = + * ceil(degree / 2)}, and Lucene's HNSW format allows up to {@code 2 * M} neighbors at level 0. With + * a plain integer {@code degree / 2}, an odd degree {@code d} yields {@code 2 * (d / 2) = d - 1 < d} + * — one fewer than the number of neighbors each node actually has — which makes the writer emit a + * graph the reader rejects with "too many neighbors: d". cuVS produces odd graph degrees when it + * clamps the degree for small datasets; here we force one deterministically via the CUSTOM strategy. + */ +@SuppressSysoutChecks(bugUrl = "") +public class TestAcceleratedHNSWOddGraphDegree extends LuceneTestCase { + + private static final String VECTOR_FIELD = "vector_field"; + private static final int ODD_GRAPH_DEGREE = 63; + + private Random random; + private Path indexDirPath; + + @Before + public void beforeTest() { + assumeTrue("cuVS not supported", isSupported()); + random = new Random(222); + indexDirPath = Paths.get(UUID.randomUUID().toString()); + } + + @Test + public void testOddGraphDegreeIndexesAndSearches() throws Exception { + // CUSTOM strategy passes the graph degree straight through to cuVS, so the built CAGRA graph + // (and thus its adjacency list's column count) has an odd degree. + AcceleratedHNSWParams params = + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.CUSTOM) + .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.NN_DESCENT) + .withIntermediateGraphDegree(128) + .withGraphDegree(ODD_GRAPH_DEGREE) + .build(); + + Codec codec = new Lucene101AcceleratedHNSWCodec(params); + IndexWriterConfig config = new IndexWriterConfig().setCodec(codec).setUseCompoundFile(false); + + int numDocs = 1000; + int dimension = 32; + int topK = 10; + float[][] dataset = generateDataset(random, numDocs, dimension); + + try (Directory indexDirectory = FSDirectory.open(indexDirPath)) { + // Indexing (flush of the HNSW graph is where an odd degree would trip "too many neighbors"). + try (IndexWriter indexWriter = new IndexWriter(indexDirectory, config)) { + for (int i = 0; i < numDocs; i++) { + Document document = new Document(); + document.add(new KnnFloatVectorField(VECTOR_FIELD, dataset[i], EUCLIDEAN)); + indexWriter.addDocument(document); + } + indexWriter.commit(); + } + + // Searching (the Lucene HNSW reader validates neighbor counts against the stored M). + try (DirectoryReader reader = DirectoryReader.open(indexDirectory)) { + assertEquals(numDocs, reader.numDocs()); + IndexSearcher searcher = new IndexSearcher(reader); + float[] queryVector = generateDataset(random, 1, dimension)[0]; + TopDocs results = + searcher.search(new KnnFloatVectorQuery(VECTOR_FIELD, queryVector, topK), topK); + assertEquals(topK, results.scoreDocs.length); + } + } + } + + @After + public void afterTest() throws Exception { + var dir = indexDirPath.toFile(); + if (dir.exists() && dir.isDirectory()) { + FileUtils.deleteDirectory(dir); + } + } +} diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java new file mode 100644 index 00000000..c949a0e3 --- /dev/null +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.nvidia.cuvs.lucene; + +import static com.nvidia.cuvs.lucene.ThreadLocalCuVSResourcesProvider.isSupported; + +import com.nvidia.cuvs.CagraIndexParams; +import com.nvidia.cuvs.CagraIndexParams.CagraGraphBuildAlgo; +import com.nvidia.cuvs.CagraIndexParams.CuvsDistanceType; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.LuceneTestCase.SuppressSysoutChecks; +import org.junit.Test; + +/** + * Verifies that {@link CagraIndexParamsFactory} keeps cuVS as the source of truth for the build + * heuristics: the GPU-native path defers the build-algorithm choice to cuVS via {@link + * CagraGraphBuildAlgo#AUTO_SELECT}, and the accelerated-HNSW path defers to cuVS' + * {@code fromHnswParams} heuristic. + */ +@SuppressSysoutChecks(bugUrl = "") +public class TestCagraIndexParamsFactory extends LuceneTestCase { + + /** + * The GPU-native HEURISTIC path hands the build-algorithm decision to cuVS (AUTO_SELECT) while + * keeping the caller-supplied CAGRA-native graph degrees and metric. Pure Java, no GPU needed. + */ + @Test + public void testGpuHeuristicUsesAutoSelect() { + GPUSearchParams params = + new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.HEURISTIC) + .withGraphDegree(48) + .withIntermediateGraphDegree(96) + .withCuvsDistanceType(CuvsDistanceType.InnerProduct) + .withWriterThreads(4) + .build(); + + CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params); + + assertEquals(CagraGraphBuildAlgo.AUTO_SELECT, cagraParams.getCagraGraphBuildAlgo()); + assertEquals(48, cagraParams.getGraphDegree()); + assertEquals(96, cagraParams.getIntermediateGraphDegree()); + assertEquals(4, cagraParams.getNumWriterThreads()); + assertEquals(CuvsDistanceType.InnerProduct, cagraParams.getCuvsDistanceType()); + } + + /** + * The GPU-native CUSTOM path passes the explicitly configured build parameters straight through. + * Pure Java, no GPU needed. + */ + @Test + public void testGpuCustomStrategyPassesThroughValues() { + GPUSearchParams params = + new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.CUSTOM) + .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.NN_DESCENT) + .withGraphDegree(32) + .withIntermediateGraphDegree(64) + .withWriterThreads(12) + .build(); + + CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params); + + assertEquals(CagraGraphBuildAlgo.NN_DESCENT, cagraParams.getCagraGraphBuildAlgo()); + assertEquals(32, cagraParams.getGraphDegree()); + assertEquals(64, cagraParams.getIntermediateGraphDegree()); + assertNotNull(cagraParams.getCuVSIvfPqParams()); + // The caller-configured writerThreads must be honored on the CUSTOM path. + assertEquals(12, cagraParams.getNumWriterThreads()); + } + + /** + * The accelerated-HNSW HEURISTIC path delegates to cuVS' native {@code fromHnswParams}, which + * derives the graph degrees from maxConn/beamWidth, and re-attaches the caller's writerThreads + * (which fromHnswParams itself cannot carry). Requires the native cuVS library. + */ + @Test + public void testHnswHeuristicDelegatesToCuVS() { + assumeTrue("cuVS not supported", isSupported()); + + AcceleratedHNSWParams params = + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.HEURISTIC) + .withMaxConn(16) + .withBeamWidth(100) + .withWriterThreads(7) + .build(); + + CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params, 10_000, 128); + + // SAME_GRAPH_FOOTPRINT yields graph_degree = 2 * maxConn; cuVS owns the exact derivation, so we + // assert the footprint relationship it documents rather than a hardcoded value. + assertEquals(2L * params.getMaxConn(), cagraParams.getGraphDegree()); + // The caller-configured writerThreads must be honored on the HEURISTIC path. + assertEquals(7, cagraParams.getNumWriterThreads()); + } +}