From 9ab46b176c64b5eb549b8213bcd335011451be65 Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Mon, 3 Aug 2026 12:30:18 -1000 Subject: [PATCH 1/7] Delegate CAGRA build heuristics to cuVS Replace the hand-rolled IVF-PQ parameter derivation and the 5M-vector algorithm switch in CagraIndexParamsFactory with cuVS's own heuristics: the GPU-native path now uses AUTO_SELECT, and the accelerated-HNSW path uses CagraIndexParams.fromHnswParams(), derived from maxConn/beamWidth. The build-algorithm crossover consequently moves from 5M to 1M vectors, which is where cuVS switches from NN-descent to IVF-PQ. Expose the cuVS HNSW heuristic type on AcceleratedHNSWParams, defaulting to SAME_GRAPH_FOOTPRINT. Derive the HNSW M written to segment metadata from the graph actually built rather than from the configured graph degree, as ceil(degree / 2). cuVS may truncate the degree for small datasets, and under HEURISTIC it ignores the configured value entirely; an odd degree previously produced an M one arc too small for the reader to accept. Fixes #149 --- .../cuvs/lucene/AcceleratedHNSWParams.java | 51 +++- .../cuvs/lucene/AcceleratedHNSWUtils.java | 19 +- .../cuvs/lucene/CagraIndexParamsFactory.java | 245 +++++------------- .../cuvs/lucene/CuVS2510GPUVectorsWriter.java | 3 +- .../nvidia/cuvs/lucene/GPUSearchParams.java | 11 +- .../Lucene99AcceleratedHNSWVectorsWriter.java | 9 +- ...ratedHNSWBinaryQuantizedVectorsWriter.java | 9 +- ...ratedHNSWScalarQuantizedVectorsWriter.java | 9 +- .../TestAcceleratedHNSWOddGraphDegree.java | 111 ++++++++ .../lucene/TestCagraIndexParamsFactory.java | 177 +++++++++++++ .../lucene/TestSegmentMaxConnConsistency.java | 193 ++++++++++++++ 11 files changed, 608 insertions(+), 229 deletions(-) create mode 100644 src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java create mode 100644 src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java create mode 100644 src/test/java/com/nvidia/cuvs/lucene/TestSegmentMaxConnConsistency.java diff --git a/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java b/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java index aac31415..a5f164b7 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java +++ b/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java @@ -7,6 +7,7 @@ import com.nvidia.cuvs.CagraIndexParams.CagraGraphBuildAlgo; import com.nvidia.cuvs.CagraIndexParams.CuvsDistanceType; +import com.nvidia.cuvs.CagraIndexParams.HnswHeuristicType; import com.nvidia.cuvs.CuVSIvfPqParams; import java.util.Objects; import java.util.concurrent.ExecutorService; @@ -17,9 +18,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. */ @@ -64,6 +64,8 @@ public static enum Strategy { public static final Strategy DEFAULT_STRATEGY = Strategy.HEURISTIC; public static final CuvsDistanceType DEFAULT_CUVS_DISTANCE_TYPE = CuvsDistanceType.L2Expanded; public static final int DEFAULT_NN_DESCENT_NUM_ITERATIONS = 20; + public static final HnswHeuristicType DEFAULT_HNSW_HEURISTIC_TYPE = + HnswHeuristicType.SAME_GRAPH_FOOTPRINT; public static final Supplier DEFAULT_IVF_PQ_PARAMS = () -> { @@ -88,6 +90,7 @@ public static enum Strategy { private final Strategy strategy; private final CuvsDistanceType cuvsDistanceType; private final int nnDescentNumIterations; + private final HnswHeuristicType hnswHeuristicType; /** * Constructs an instance of {@link AcceleratedHNSWParams} with specific parameter values. @@ -95,7 +98,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,9 +105,10 @@ 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. + * @param hnswHeuristicType the heuristic cuVS applies when deriving the CAGRA build parameters from maxConn and beamWidth under the HEURISTIC strategy. */ private AcceleratedHNSWParams( int writerThreads, @@ -120,7 +123,8 @@ private AcceleratedHNSWParams( ExecutorService mergeExec, Strategy strategy, CuvsDistanceType cuvsDistanceType, - int nnDescentNumIterations) { + int nnDescentNumIterations, + HnswHeuristicType hnswHeuristicType) { super(); this.writerThreads = writerThreads; this.intermediateGraphDegree = intermediateGraphDegree; @@ -135,6 +139,7 @@ private AcceleratedHNSWParams( this.strategy = strategy; this.cuvsDistanceType = cuvsDistanceType; this.nnDescentNumIterations = nnDescentNumIterations; + this.hnswHeuristicType = hnswHeuristicType; } /** @@ -257,6 +262,16 @@ public int getNNDescentNumIterations() { return nnDescentNumIterations; } + /** + * Get the heuristic cuVS applies when deriving the CAGRA build parameters from maxConn and + * beamWidth. Only consulted under the {@link Strategy#HEURISTIC} strategy. + * + * @return the {@link HnswHeuristicType} to hand to cuVS + */ + public HnswHeuristicType getHnswHeuristicType() { + return hnswHeuristicType; + } + @Override public String toString() { return "AcceleratedHNSWParams [writerThreads=" @@ -285,6 +300,8 @@ public String toString() { + cuvsDistanceType + ", nnDescentNumIterations=" + nnDescentNumIterations + + ", hnswHeuristicType=" + + hnswHeuristicType + "]"; } @@ -306,6 +323,7 @@ public static class Builder { private Strategy strategy = DEFAULT_STRATEGY; private CuvsDistanceType cuvsDistanceType = DEFAULT_CUVS_DISTANCE_TYPE; private int nnDescentNumIterations = DEFAULT_NN_DESCENT_NUM_ITERATIONS; + private HnswHeuristicType hnswHeuristicType = DEFAULT_HNSW_HEURISTIC_TYPE; /** * Set the number of cuVS writer threads while building the index @@ -474,6 +492,21 @@ public Builder withNNDescentNumIterations(int nnDescentNumIterations) { return this; } + /** + * Set the heuristic cuVS applies when deriving the CAGRA build parameters from maxConn and + * beamWidth. Only consulted under the {@link Strategy#HEURISTIC} strategy. + * + * Default value - SAME_GRAPH_FOOTPRINT, which targets a CAGRA graph of the same on-disk size as + * the equivalent HNSW graph (graph degree = 2 * maxConn). + * + * @param hnswHeuristicType the {@link HnswHeuristicType} to hand to cuVS + * @return instance of {@link Builder} + */ + public Builder withHnswHeuristicType(HnswHeuristicType hnswHeuristicType) { + this.hnswHeuristicType = hnswHeuristicType; + return this; + } + /** * Validates the input parameters. * @@ -546,6 +579,9 @@ private void validate() throws IllegalArgumentException { if (Objects.isNull(cuvsDistanceType)) { throw new IllegalArgumentException("cuvsDistanceType cannot be null."); } + if (Objects.isNull(hnswHeuristicType)) { + throw new IllegalArgumentException("hnswHeuristicType cannot be null."); + } if (nnDescentNumIterations < MIN_NN_DESCENT_NUM_ITERATIONS || nnDescentNumIterations > MAX_NN_DESCENT_NUM_ITERATIONS) { throw new IllegalArgumentException( @@ -583,7 +619,8 @@ public AcceleratedHNSWParams build() { mergeExec, strategy, cuvsDistanceType, - nnDescentNumIterations); + nnDescentNumIterations, + hnswHeuristicType); } } } diff --git a/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWUtils.java b/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWUtils.java index 2a8ab02f..9c49c07f 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<>(); @@ -309,8 +308,7 @@ public static void writeMeta( long vectorIndexLength, int count, HnswGraph graph, - int[][] graphLevelNodeOffsets, - int graphDegree) + int[][] graphLevelNodeOffsets) throws IOException { meta.writeInt(field.number); @@ -320,7 +318,10 @@ public static void writeMeta( meta.writeVLong(vectorIndexLength); meta.writeVInt(field.getVectorDimension()); meta.writeInt(count); - meta.writeVInt(graphDegree / 2); // M = cagraGraphDegree/2 + // M = ceil(cagraGraphDegree / 2), derived from the graph being written rather than from a + // caller-supplied degree: graph.maxConn() is the widest layer-0 adjacency row, which is the + // degree cuVS actually built (it may truncate the requested one for small datasets). + meta.writeVInt(graph == null ? 0 : Math.ceilDiv(graph.maxConn(), 2)); // write graph nodes on each level if (graph == null) { @@ -394,7 +395,7 @@ public static void printInfoStream(InfoStream infoStream, String component, Stri * @throws IOException I/O Exceptions */ public static void writeEmpty(FieldInfo fieldInfo, IndexOutput op) throws IOException { - writeMeta(null, op, fieldInfo, 0, 0, 0, null, null, 0); + writeMeta(null, op, fieldInfo, 0, 0, 0, null, 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..d59e5cc6 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,36 @@ 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 derivation of the graph degrees, build algorithm and its parameters to cuVS, + // expressed in terms of the HNSW-equivalent maxConn/beamWidth. + CagraIndexParams derived = + CagraIndexParams.fromHnswParams( + rows, + dimension, + acceleratedHNSWParams.getMaxConn(), + acceleratedHNSWParams.getBeamWidth(), + acceleratedHNSWParams.getHnswHeuristicType(), + acceleratedHNSWParams.getCuvsDistanceType()); + // TODO: fromHnswParams has no writerThreads argument, so its result carries the cuVS default + // (not a heuristic value). We can 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 97fedf92..1ad0c4e1 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java +++ b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java @@ -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); try (CagraIndex index = CagraIndex.newBuilder(getCuVSResourcesInstance()) .withDataset(dataset) 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..13edc649 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; @@ -178,7 +178,6 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) thro adjacencyListMatrix, vectors, acceleratedHNSWParams.getHnswLayers(), - acceleratedHNSWParams.getGraphdegree(), params, QuantizationType.NONE); long vectorIndexOffset = hnswVectorIndex.getFilePointer(); @@ -192,8 +191,7 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) thro vectorIndexLength, size, hnswGraph, - graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphLevelNodeOffsets); cagraIndex.close(); } catch (Throwable t) { Utils.handleThrowable(t); @@ -268,8 +266,7 @@ private void writeSingleVectorGraph(FieldInfo fieldInfo, List vectors) vectorIndexLength, size, hnswGraph, - graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphLevelNodeOffsets); } 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..87907d2c 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; @@ -184,7 +184,6 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throw adjacencyListMatrix, vectors, acceleratedHNSWParams.getHnswLayers(), - acceleratedHNSWParams.getGraphdegree(), params, QuantizationType.BINARY); @@ -202,8 +201,7 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throw vectorIndexLength, size, hnswGraph, - graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphLevelNodeOffsets); cagraIndex.close(); @@ -292,8 +290,7 @@ private void writeSingleVectorGraph(FieldInfo fieldInfo, List vectors) vectorIndexLength, size, hnswGraph, - graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphLevelNodeOffsets); } catch (Throwable t) { Utils.handleThrowable(t); diff --git a/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWScalarQuantizedVectorsWriter.java b/src/main/java/com/nvidia/cuvs/lucene/LuceneAcceleratedHNSWScalarQuantizedVectorsWriter.java index 21b4be3f..7141af56 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; @@ -209,7 +209,6 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throws IOE adjacencyListMatrix, unsignedVectors, acceleratedHNSWParams.getHnswLayers(), - acceleratedHNSWParams.getGraphdegree(), params, QuantizationType.SCALAR); @@ -229,8 +228,7 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) throws IOE vectorIndexLength, size, hnswGraph, - graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphLevelNodeOffsets); cagraIndex.close(); } catch (Throwable t) { @@ -316,8 +314,7 @@ private void writeSingleVectorGraph(FieldInfo fieldInfo, List vectors) vectorIndexLength, size, hnswGraph, - graphLevelNodeOffsets, - acceleratedHNSWParams.getGraphdegree()); + graphLevelNodeOffsets); } catch (Throwable t) { Utils.handleThrowable(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..52503fa8 --- /dev/null +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java @@ -0,0 +1,177 @@ +/* + * 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 com.nvidia.cuvs.CagraIndexParams.HnswHeuristicType; +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()); + } + + /** + * The heuristic type must reach cuVS rather than being pinned to the default: under + * SIMILAR_SEARCH_PERFORMANCE cuVS derives {@code graph_degree = 2 + maxConn * 2 / 3} instead of + * SAME_GRAPH_FOOTPRINT's {@code 2 * maxConn}. Requires the native cuVS library. + */ + @Test + public void testHnswHeuristicTypeIsHonored() { + assumeTrue("cuVS not supported", isSupported()); + + AcceleratedHNSWParams params = + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.HEURISTIC) + .withHnswHeuristicType(HnswHeuristicType.SIMILAR_SEARCH_PERFORMANCE) + .withMaxConn(48) + .withBeamWidth(100) + .build(); + + assertEquals(HnswHeuristicType.SIMILAR_SEARCH_PERFORMANCE, params.getHnswHeuristicType()); + + CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params, 10_000, 128); + + // Distinguishes the two heuristics: SAME_GRAPH_FOOTPRINT would yield 2 * 48 = 96. + assertEquals(2 + 48 * 2 / 3, cagraParams.getGraphDegree()); + } + + /** + * The heuristic type defaults to SAME_GRAPH_FOOTPRINT, preserving the behavior callers get without + * touching the new setter. Pure Java, no GPU needed. + */ + @Test + public void testHnswHeuristicTypeDefault() { + assertEquals( + AcceleratedHNSWParams.DEFAULT_HNSW_HEURISTIC_TYPE, + new AcceleratedHNSWParams.Builder().build().getHnswHeuristicType()); + } + + /** + * A null heuristic type is rejected at build time rather than surfacing as a native failure. Pure + * Java, no GPU needed. + */ + @Test + public void testNullHnswHeuristicTypeRejected() { + expectThrows( + IllegalArgumentException.class, + () -> new AcceleratedHNSWParams.Builder().withHnswHeuristicType(null).build()); + } + + /** + * Parameters that only the other strategy consumes are still accepted (they are not an error), so + * that switching strategies does not require rewriting the builder chain -- they are simply not + * applied. Pure Java, no GPU needed. + */ + @Test + public void testStrategySpecificParamsRemainAccepted() { + AcceleratedHNSWParams hnswParams = + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.HEURISTIC) + .withGraphDegree(96) + .withIntermediateGraphDegree(192) + .build(); + // Retained verbatim on the instance; CagraIndexParamsFactory is what declines to apply them. + assertEquals(96, hnswParams.getGraphdegree()); + assertEquals(192, hnswParams.getIntermediateGraphDegree()); + + GPUSearchParams gpuParams = + new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.HEURISTIC) + .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.IVF_PQ) + .build(); + assertEquals(CagraGraphBuildAlgo.IVF_PQ, gpuParams.getCagraGraphBuildAlgo()); + // ... but AUTO_SELECT is what actually reaches cuVS. + assertEquals( + CagraGraphBuildAlgo.AUTO_SELECT, + CagraIndexParamsFactory.create(gpuParams).getCagraGraphBuildAlgo()); + } +} diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestSegmentMaxConnConsistency.java b/src/test/java/com/nvidia/cuvs/lucene/TestSegmentMaxConnConsistency.java new file mode 100644 index 00000000..445e1da9 --- /dev/null +++ b/src/test/java/com/nvidia/cuvs/lucene/TestSegmentMaxConnConsistency.java @@ -0,0 +1,193 @@ +/* + * 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 static org.apache.lucene.search.DocIdSetIterator.NO_MORE_DOCS; + +import org.apache.lucene.codecs.KnnVectorsReader; +import org.apache.lucene.codecs.hnsw.HnswGraphProvider; +import org.apache.lucene.codecs.perfield.PerFieldKnnVectorsFormat; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.KnnFloatVectorField; +import org.apache.lucene.index.CodecReader; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.LeafReader; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.index.NoMergePolicy; +import org.apache.lucene.index.VectorSimilarityFunction; +import org.apache.lucene.store.Directory; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.LuceneTestCase.SuppressSysoutChecks; +import org.apache.lucene.util.hnsw.HnswGraph; +import org.junit.Test; + +/** + * Verifies that the HNSW {@code M} recorded in a segment's metadata describes the graph that + * segment actually contains, for every segment an accelerated-HNSW writer can produce. + * + *

{@code M} is written as {@code ceil(cagraGraphDegree / 2)} and bounds what the reader accepts: + * Lucene sizes its arc buffer as {@code M * 2} and asserts that every stored adjacency row fits. An + * {@code M} taken from configuration rather than from the built graph can understate the graph, + * because cuVS is free to build a degree other than the one requested -- and under the HEURISTIC + * strategy it derives the degree from maxConn and ignores the configured graph degree outright. + * + *

The invariant checked here is per-segment self-consistency, not cross-segment equality. + * Segments of the same field legitimately record different values of {@code M}: cuVS truncates the + * CAGRA graph degree to {@code dataset_size - 1} for small datasets, so with maxConn 16 a 20-vector + * segment records {@code M = 10} while a 3000-vector segment records {@code M = 16}. + * + *

A non-default maxConn is used throughout. At stock defaults the configured graph degree (64) + * and the degree cuVS derives from maxConn (2 * 32) coincide, so an {@code M} read from the wrong + * source would still produce the expected value and go unnoticed. + */ +@SuppressSysoutChecks(bugUrl = "") +public class TestSegmentMaxConnConsistency extends LuceneTestCase { + + private static final String FIELD = "f"; + private static final int MAX_CONN = 16; + + /** + * Every segment -- including a degenerate single-vector one -- must record an M consistent with + * its own widest adjacency row. + */ + @Test + public void testRecordedMMatchesEachSegmentsGraph() throws Exception { + assumeTrue("cuVS not supported", isSupported()); + + // Sizes span the interesting cases: the single-vector special path, a segment small enough for + // cuVS to truncate the degree, and one large enough to keep the derived degree. + int[] segmentSizes = {1, 20, 3000}; + + try (Directory dir = newDirectory()) { + IndexWriterConfig cfg = + new IndexWriterConfig() + .setCodec( + new Lucene101AcceleratedHNSWCodec( + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.HEURISTIC) + .withMaxConn(MAX_CONN) + .build())); + // Keep the segments separate so each one's metadata can be inspected. + cfg.setMergePolicy(NoMergePolicy.INSTANCE); + + try (IndexWriter w = new IndexWriter(dir, cfg)) { + for (int size : segmentSizes) { + addDocs(w, size); + w.commit(); + } + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + assertEquals("expected one segment per size", segmentSizes.length, reader.leaves().size()); + + for (LeafReaderContext ctx : reader.leaves()) { + LeafReader leaf = ctx.reader(); + int size = leaf.getFloatVectorValues(FIELD).size(); + HnswGraph graph = graphOf(leaf); + int recordedM = graph.maxConn(); + int widestRow = widestAdjacencyRow(graph); + + assertEquals( + "segment of " + + size + + " vectors recorded M=" + + recordedM + + " but its widest adjacency row holds " + + widestRow + + " arcs", + Math.ceilDiv(widestRow, 2), + recordedM); + + // The reader sizes its arc buffer as M*2 and asserts every arc count fits, so an M that + // understates the graph corrupts reads regardless of where it came from. + assertTrue( + "segment of " + + size + + " vectors has " + + widestRow + + " arcs but only M*2=" + + (recordedM * 2), + widestRow <= recordedM * 2); + } + } + } + } + + /** + * The single-vector path must not fall back to the configured graph degree, which the HEURISTIC + * strategy does not use. + */ + @Test + public void testSingleVectorSegmentDoesNotUseConfiguredGraphDegree() throws Exception { + assumeTrue("cuVS not supported", isSupported()); + + AcceleratedHNSWParams params = + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.HEURISTIC) + .withMaxConn(MAX_CONN) + .withGraphDegree(256) // ignored under HEURISTIC; must not leak into the metadata + .build(); + + try (Directory dir = newDirectory()) { + IndexWriterConfig cfg = + new IndexWriterConfig().setCodec(new Lucene101AcceleratedHNSWCodec(params)); + cfg.setMergePolicy(NoMergePolicy.INSTANCE); + try (IndexWriter w = new IndexWriter(dir, cfg)) { + addDocs(w, 1); + w.commit(); + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + LeafReader leaf = getOnlyLeafReader(reader); + HnswGraph graph = graphOf(leaf); + assertEquals( + "single-vector segment must not record M derived from the ignored graphDegree", + Math.ceilDiv(widestAdjacencyRow(graph), 2), + graph.maxConn()); + assertNotEquals("M leaked from the configured graphDegree", 256 / 2, graph.maxConn()); + } + } + } + + private static void addDocs(IndexWriter w, int count) throws Exception { + for (int i = 0; i < count; i++) { + Document doc = new Document(); + doc.add( + new KnnFloatVectorField( + FIELD, new float[] {i, i + 1f, i + 2f, i + 3f}, VectorSimilarityFunction.EUCLIDEAN)); + w.addDocument(doc); + } + } + + /** The largest number of arcs stored for any node on any level. */ + private static int widestAdjacencyRow(HnswGraph graph) throws Exception { + int widest = 0; + for (int level = 0; level < graph.numLevels(); level++) { + HnswGraph.NodesIterator nodes = graph.getNodesOnLevel(level); + while (nodes.hasNext()) { + int node = nodes.nextInt(); + graph.seek(level, node); + int arcs = 0; + while (graph.nextNeighbor() != NO_MORE_DOCS) { + arcs++; + } + widest = Math.max(widest, arcs); + } + } + return widest; + } + + private static HnswGraph graphOf(LeafReader leaf) throws Exception { + KnnVectorsReader knnReader = ((CodecReader) leaf).getVectorReader(); + if (knnReader instanceof PerFieldKnnVectorsFormat.FieldsReader fieldsReader) { + knnReader = fieldsReader.getFieldReader(FIELD); + } + return ((HnswGraphProvider) knnReader).getGraph(FIELD); + } +} From 186a629559585a578000cc3ee60bd189353cf824 Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Wed, 5 Aug 2026 14:15:19 -1000 Subject: [PATCH 2/7] Swtich from AUTO_SELECT to fromDataset --- .../cuvs/lucene/CagraIndexParamsFactory.java | 25 +++++-- .../cuvs/lucene/CuVS2510GPUVectorsWriter.java | 3 +- .../nvidia/cuvs/lucene/GPUSearchParams.java | 52 +++++++++++++- .../lucene/TestCagraIndexParamsFactory.java | 69 ++++++++++++++++--- 4 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java index d59e5cc6..2677b4f9 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java +++ b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java @@ -6,7 +6,6 @@ package com.nvidia.cuvs.lucene; import com.nvidia.cuvs.CagraIndexParams; -import com.nvidia.cuvs.CagraIndexParams.CagraGraphBuildAlgo; /** * A centralized place for producing {@link CagraIndexParams} from the cuvs-lucene input parameter @@ -24,20 +23,34 @@ private CagraIndexParamsFactory() {} * chosen strategy in the {@link GPUSearchParams}. * * @param gpuSearchParams the input parameters for 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 * @return an instance of {@link CagraIndexParams} */ - public static CagraIndexParams create(GPUSearchParams gpuSearchParams) { + public static CagraIndexParams create( + GPUSearchParams gpuSearchParams, long rows, long dimension) { 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. + // Delegate the build-algorithm choice and its parameters to cuVS' dataset heuristic, which + // switches on the row count and tunes the algorithm with the caller's build quality. The + // graph degrees fromDataset would derive are discarded in favour of the caller's, which this + // class honours under both strategies. + CagraIndexParams derived = + CagraIndexParams.fromDataset( + rows, + dimension, + gpuSearchParams.getGraphdegree(), + gpuSearchParams.getCuvsDistanceType(), + gpuSearchParams.getBuildQuality()); builder - .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.AUTO_SELECT) - .withMetric(gpuSearchParams.getCuvsDistanceType()); + .withCagraGraphBuildAlgo(derived.getCagraGraphBuildAlgo()) + .withCuVSIvfPqParams(derived.getCuVSIvfPqParams()) + .withNNDescentNumIterations(derived.getNNDescentNumIterations()) + .withMetric(derived.getCuvsDistanceType()); } else { // 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). diff --git a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java index 1ad0c4e1..97fedf92 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java +++ b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsWriter.java @@ -241,7 +241,8 @@ private void writeFieldInternal(FieldInfo fieldInfo, List vectors) thro * @throws Throwable */ private void writeCagraIndex(OutputStream os, CuVSMatrix dataset) throws Throwable { - CagraIndexParams params = CagraIndexParamsFactory.create(gpuSearchParams); + CagraIndexParams params = + CagraIndexParamsFactory.create(gpuSearchParams, dataset.size(), dataset.columns()); try (CagraIndex index = CagraIndex.newBuilder(getCuVSResourcesInstance()) .withDataset(dataset) diff --git a/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java b/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java index f035784d..04432f4d 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java +++ b/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java @@ -42,6 +42,8 @@ public static enum Strategy { public static final int MAX_GRAPH_DEG = 512; public static final int MIN_NN_DESCENT_NUM_ITERATIONS = 1; public static final int MAX_NN_DESCENT_NUM_ITERATIONS = 100; + public static final int MIN_BUILD_QUALITY = 0; + public static final int MAX_BUILD_QUALITY = 20; public static final int DEFAULT_INT_GRAPH_DEGREE = 128; public static final int DEFAULT_GRAPH_DEGREE = 64; @@ -53,6 +55,9 @@ public static enum Strategy { public static final CuvsDistanceType DEFAULT_CUVS_DISTANCE_TYPE = CuvsDistanceType.L2Expanded; public static final int DEFAULT_NN_DESCENT_NUM_ITERATIONS = 20; + /** cuVS' own default for the build-quality heuristic input. */ + public static final int DEFAULT_BUILD_QUALITY = 7; + public static final Supplier DEFAULT_IVF_PQ_PARAMS = () -> { return new CuVSIvfPqParams.Builder().build(); @@ -67,6 +72,7 @@ public static enum Strategy { private final Strategy strategy; private final CuvsDistanceType cuvsDistanceType; private final int nnDescentNumIterations; + private final int buildQuality; /** * Constructs an instance of {@link GPUSearchParams} with specific parameter values. @@ -80,6 +86,7 @@ public static enum Strategy { * @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. + * @param buildQuality the build quality cuVS applies when deriving the build algorithm's parameters under the HEURISTIC strategy. Higher values trade build cost for graph quality. */ private GPUSearchParams( int writerThreads, @@ -90,7 +97,8 @@ private GPUSearchParams( CuVSIvfPqParams cuVSIvfPqParams, Strategy strategy, CuvsDistanceType cuvsDistanceType, - int nnDescentNumIterations) { + int nnDescentNumIterations, + int buildQuality) { super(); this.writerThreads = writerThreads; this.intermediateGraphDegree = intermediateGraphDegree; @@ -101,6 +109,7 @@ private GPUSearchParams( this.strategy = strategy; this.cuvsDistanceType = cuvsDistanceType; this.nnDescentNumIterations = nnDescentNumIterations; + this.buildQuality = buildQuality; } /** @@ -188,6 +197,16 @@ public int getnNDescentNumIterations() { return nnDescentNumIterations; } + /** + * Get the build quality handed to cuVS' build heuristic. Only consulted under the {@link + * Strategy#HEURISTIC} strategy. + * + * @return the build quality + */ + public int getBuildQuality() { + return buildQuality; + } + @Override public String toString() { return "GPUSearchParams [writerThreads=" @@ -208,6 +227,8 @@ public String toString() { + cuvsDistanceType + ", nnDescentNumIterations=" + nnDescentNumIterations + + ", buildQuality=" + + buildQuality + "]"; } @@ -225,6 +246,7 @@ public static class Builder { private Strategy strategy = DEFAULT_STRATEGY; private CuvsDistanceType cuvsDistanceType = DEFAULT_CUVS_DISTANCE_TYPE; private int nnDescentNumIterations = DEFAULT_NN_DESCENT_NUM_ITERATIONS; + private int buildQuality = DEFAULT_BUILD_QUALITY; /** * Set the number of cuVS writer threads while building the index @@ -342,6 +364,23 @@ public Builder withNNDescentNumIterations(int nnDescentNumIterations) { return this; } + /** + * Set the build quality cuVS applies when deriving the build algorithm's parameters. Higher + * values trade build cost for graph quality. + * + * Only consulted under the {@link Strategy#HEURISTIC} strategy. + * + * Valid range - Minimum: {@value MIN_BUILD_QUALITY}, Maximum: {@value MAX_BUILD_QUALITY} + * Default value - {@value DEFAULT_BUILD_QUALITY} + * + * @param buildQuality the build quality to set + * @return instance of {@link Builder} + */ + public Builder withBuildQuality(int buildQuality) { + this.buildQuality = buildQuality; + return this; + } + /** * Validates the input parameters. * @@ -394,6 +433,14 @@ private void validate() throws IllegalArgumentException { + MAX_NN_DESCENT_NUM_ITERATIONS + "]"); } + if (buildQuality < MIN_BUILD_QUALITY || buildQuality > MAX_BUILD_QUALITY) { + throw new IllegalArgumentException( + "buildQuality not in valid range. Valid range: [" + + MIN_BUILD_QUALITY + + ", " + + MAX_BUILD_QUALITY + + "]"); + } } /** @@ -415,7 +462,8 @@ public GPUSearchParams build() { cuVSIvfPqParams, strategy, cuvsDistanceType, - nnDescentNumIterations); + nnDescentNumIterations, + buildQuality); } } } diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java index 52503fa8..ca28e22a 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java @@ -26,10 +26,13 @@ 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. + * keeping the caller-supplied CAGRA-native graph degrees, writer threads and metric. Requires the + * native cuVS library. */ @Test - public void testGpuHeuristicUsesAutoSelect() { + public void testGpuHeuristicDelegatesToCuVS() { + assumeTrue("cuVS not supported", isSupported()); + GPUSearchParams params = new GPUSearchParams.Builder() .withStrategy(GPUSearchParams.Strategy.HEURISTIC) @@ -39,15 +42,64 @@ public void testGpuHeuristicUsesAutoSelect() { .withWriterThreads(4) .build(); - CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params); + // Below cuVS' 1M-row crossover, so the dataset heuristic selects NN-descent. + CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params, 10_000, 128); - assertEquals(CagraGraphBuildAlgo.AUTO_SELECT, cagraParams.getCagraGraphBuildAlgo()); + assertEquals(CagraGraphBuildAlgo.NN_DESCENT, cagraParams.getCagraGraphBuildAlgo()); + // The caller's degrees survive; fromDataset would otherwise force intermediate = 1.5 * degree. assertEquals(48, cagraParams.getGraphDegree()); assertEquals(96, cagraParams.getIntermediateGraphDegree()); assertEquals(4, cagraParams.getNumWriterThreads()); assertEquals(CuvsDistanceType.InnerProduct, cagraParams.getCuvsDistanceType()); } + /** + * Build quality reaches cuVS: NN-descent runs {@code 5 + buildQuality} iterations, so two + * different qualities must produce two different iteration counts. Requires the native cuVS + * library. + */ + @Test + public void testGpuBuildQualityIsHonored() { + assumeTrue("cuVS not supported", isSupported()); + + long low = + CagraIndexParamsFactory.create(gpuParamsWithQuality(1), 10_000, 128) + .getNNDescentNumIterations(); + long high = + CagraIndexParamsFactory.create(gpuParamsWithQuality(15), 10_000, 128) + .getNNDescentNumIterations(); + long dflt = + CagraIndexParamsFactory.create( + gpuParamsWithQuality(GPUSearchParams.DEFAULT_BUILD_QUALITY), 10_000, 128) + .getNNDescentNumIterations(); + + // cuVS derives max_iterations as 5 + buildQuality; assert the relationship it documents rather + // than hardcoding values it owns. + assertEquals(low + 14, high); + assertTrue("higher build quality must not reduce work", dflt > low && dflt < high); + } + + private static GPUSearchParams gpuParamsWithQuality(int buildQuality) { + return new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.HEURISTIC) + .withBuildQuality(buildQuality) + .build(); + } + + /** Build quality is validated at build() time rather than surfacing as a native failure. */ + @Test + public void testBuildQualityBounds() { + expectThrows( + IllegalArgumentException.class, + () -> new GPUSearchParams.Builder().withBuildQuality(-1).build()); + expectThrows( + IllegalArgumentException.class, + () -> + new GPUSearchParams.Builder() + .withBuildQuality(GPUSearchParams.MAX_BUILD_QUALITY + 1) + .build()); + } + /** * The GPU-native CUSTOM path passes the explicitly configured build parameters straight through. * Pure Java, no GPU needed. @@ -63,7 +115,7 @@ public void testGpuCustomStrategyPassesThroughValues() { .withWriterThreads(12) .build(); - CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params); + CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params, 10_000, 128); assertEquals(CagraGraphBuildAlgo.NN_DESCENT, cagraParams.getCagraGraphBuildAlgo()); assertEquals(32, cagraParams.getGraphDegree()); @@ -169,9 +221,10 @@ public void testStrategySpecificParamsRemainAccepted() { .withCagraGraphBuildAlgo(CagraGraphBuildAlgo.IVF_PQ) .build(); assertEquals(CagraGraphBuildAlgo.IVF_PQ, gpuParams.getCagraGraphBuildAlgo()); - // ... but AUTO_SELECT is what actually reaches cuVS. + // ... but for a small dataset cuVS' heuristic selects NN-descent regardless. + assumeTrue("cuVS not supported", isSupported()); assertEquals( - CagraGraphBuildAlgo.AUTO_SELECT, - CagraIndexParamsFactory.create(gpuParams).getCagraGraphBuildAlgo()); + CagraGraphBuildAlgo.NN_DESCENT, + CagraIndexParamsFactory.create(gpuParams, 10_000, 128).getCagraGraphBuildAlgo()); } } From 520a4bea76bb2d8d7c5ca359e0426e1ec50d69bb Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Wed, 5 Aug 2026 15:15:14 -1000 Subject: [PATCH 3/7] Fix metric setting that got lost in refactoring --- .../cuvs/lucene/CagraIndexParamsFactory.java | 9 +++-- .../lucene/TestCagraIndexParamsFactory.java | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java index 2677b4f9..b82574da 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java +++ b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java @@ -33,7 +33,8 @@ public static CagraIndexParams create( new CagraIndexParams.Builder() .withGraphDegree(gpuSearchParams.getGraphdegree()) .withIntermediateGraphDegree(gpuSearchParams.getIntermediateGraphDegree()) - .withNumWriterThreads(gpuSearchParams.getWriterThreads()); + .withNumWriterThreads(gpuSearchParams.getWriterThreads()) + .withMetric(gpuSearchParams.getCuvsDistanceType()); if (gpuSearchParams.getStrategy().equals(GPUSearchParams.Strategy.HEURISTIC)) { // Delegate the build-algorithm choice and its parameters to cuVS' dataset heuristic, which // switches on the row count and tunes the algorithm with the caller's build quality. The @@ -49,8 +50,7 @@ public static CagraIndexParams create( builder .withCagraGraphBuildAlgo(derived.getCagraGraphBuildAlgo()) .withCuVSIvfPqParams(derived.getCuVSIvfPqParams()) - .withNNDescentNumIterations(derived.getNNDescentNumIterations()) - .withMetric(derived.getCuvsDistanceType()); + .withNNDescentNumIterations(derived.getNNDescentNumIterations()); } else { // 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). @@ -93,7 +93,7 @@ public static CagraIndexParams create( .withCagraGraphBuildAlgo(derived.getCagraGraphBuildAlgo()) .withCuVSIvfPqParams(derived.getCuVSIvfPqParams()) .withNNDescentNumIterations(derived.getNNDescentNumIterations()) - .withMetric(derived.getCuvsDistanceType()) + .withMetric(acceleratedHNSWParams.getCuvsDistanceType()) .withNumWriterThreads(acceleratedHNSWParams.getWriterThreads()) .build(); } @@ -104,6 +104,7 @@ public static CagraIndexParams create( .withCagraGraphBuildAlgo(acceleratedHNSWParams.getCagraGraphBuildAlgo()) .withCuVSIvfPqParams(acceleratedHNSWParams.getCuVSIvfPqParams()) .withNNDescentNumIterations(acceleratedHNSWParams.getNNDescentNumIterations()) + .withMetric(acceleratedHNSWParams.getCuvsDistanceType()) .build(); } } diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java index ca28e22a..6bf34d25 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java @@ -86,6 +86,45 @@ private static GPUSearchParams gpuParamsWithQuality(int buildQuality) { .build(); } + /** + * The configured metric must survive every strategy on both paths. It describes the data, not the + * build strategy: a graph built under the wrong metric degrades recall silently, with no error at + * build or search time. + * + *

The accelerated-HNSW HEURISTIC case is the subtle one -- {@code fromHnswParams} forwards the + * metric to the build heuristic but never assigns it to the params it returns, so reading the + * metric back off its result yields cuVS' L2Expanded default. Requires the native cuVS library + * for the paths that call into it. + */ + @Test + public void testMetricSurvivesEveryStrategy() { + assumeTrue("cuVS not supported", isSupported()); + + for (GPUSearchParams.Strategy strategy : GPUSearchParams.Strategy.values()) { + GPUSearchParams gpuParams = + new GPUSearchParams.Builder() + .withStrategy(strategy) + .withCuvsDistanceType(CuvsDistanceType.InnerProduct) + .build(); + assertEquals( + "GPU-native path lost the metric under " + strategy, + CuvsDistanceType.InnerProduct, + CagraIndexParamsFactory.create(gpuParams, 10_000, 128).getCuvsDistanceType()); + } + + for (AcceleratedHNSWParams.Strategy strategy : AcceleratedHNSWParams.Strategy.values()) { + AcceleratedHNSWParams hnswParams = + new AcceleratedHNSWParams.Builder() + .withStrategy(strategy) + .withCuvsDistanceType(CuvsDistanceType.InnerProduct) + .build(); + assertEquals( + "accelerated-HNSW path lost the metric under " + strategy, + CuvsDistanceType.InnerProduct, + CagraIndexParamsFactory.create(hnswParams, 10_000, 128).getCuvsDistanceType()); + } + } + /** Build quality is validated at build() time rather than surfacing as a native failure. */ @Test public void testBuildQualityBounds() { From bf38b05599e9209d9777148c111a61bd937fdb5d Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Wed, 5 Aug 2026 15:20:40 -1000 Subject: [PATCH 4/7] Fix tests that fail without GPU --- .../cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java | 5 +++++ .../cuvs/lucene/TestCagraToHnswSerializationAndSearch.java | 5 +++++ ...CagraToHnswSerializationAndSearchWithFallbackWriter.java | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java b/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java index ce56ca9a..89012b19 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWOddGraphDegree.java @@ -103,6 +103,11 @@ public void testOddGraphDegreeIndexesAndSearches() throws Exception { @After public void afterTest() throws Exception { + // JUnit runs @After even when @Before ends in a skipped assumption, at which point the path was + // never assigned. Dereferencing it would turn the skip into a failure on machines without cuVS. + if (indexDirPath == null) { + return; + } var dir = indexDirPath.toFile(); if (dir.exists() && dir.isDirectory()) { FileUtils.deleteDirectory(dir); diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.java index 96be6c01..0634e63c 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.java @@ -194,6 +194,11 @@ public void testSingleVectorIndex() throws Exception { @After public void afterTest() throws Exception { + // JUnit runs @After even when @Before ends in a skipped assumption, at which point the path was + // never assigned. Dereferencing it would turn the skip into a failure on machines without cuVS. + if (indexDirPath == null) { + return; + } File indexDirPathFile = indexDirPath.toFile(); if (indexDirPathFile.exists() && indexDirPathFile.isDirectory()) { FileUtils.deleteDirectory(indexDirPathFile); diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.java index 04ad20a0..b759f7dc 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.java @@ -165,6 +165,12 @@ public void testCagraToHnswSerializationAndSearchWithFallbackWriter() throws Exc public static void afterClass() throws Exception { // Reset resources for other tests to work setCuVSResourcesInstance(cuVSResourcesOrNull()); + // JUnit runs @AfterClass even when @BeforeClass ends in a skipped assumption, at which point + // the path was never assigned. Dereferencing it would turn the skip into a failure on machines + // without cuVS. + if (indexDirPath == null) { + return; + } File indexDirPathFile = indexDirPath.toFile(); if (indexDirPathFile.exists() && indexDirPathFile.isDirectory()) { FileUtils.deleteDirectory(indexDirPathFile); From 27e2a7579d87eb7a6737c9f2e9879b828f1515fd Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Wed, 5 Aug 2026 15:35:42 -1000 Subject: [PATCH 5/7] Fix copyright --- .../cuvs/lucene/TestCagraToHnswSerializationAndSearch.java | 2 +- ...TestCagraToHnswSerializationAndSearchWithFallbackWriter.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.java index 0634e63c..934e7c92 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearch.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; diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.java index b759f7dc..996c1d75 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraToHnswSerializationAndSearchWithFallbackWriter.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; From 70e82479830d77f90125043294901a26812a5f1f Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Thu, 6 Aug 2026 06:15:31 -1000 Subject: [PATCH 6/7] Remove upper bound for build quality --- .../nvidia/cuvs/lucene/GPUSearchParams.java | 12 ++++-------- .../lucene/TestCagraIndexParamsFactory.java | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java b/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java index 04432f4d..5e317ec3 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java +++ b/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java @@ -43,7 +43,6 @@ public static enum Strategy { public static final int MIN_NN_DESCENT_NUM_ITERATIONS = 1; public static final int MAX_NN_DESCENT_NUM_ITERATIONS = 100; public static final int MIN_BUILD_QUALITY = 0; - public static final int MAX_BUILD_QUALITY = 20; public static final int DEFAULT_INT_GRAPH_DEGREE = 128; public static final int DEFAULT_GRAPH_DEGREE = 64; @@ -370,7 +369,8 @@ public Builder withNNDescentNumIterations(int nnDescentNumIterations) { * * Only consulted under the {@link Strategy#HEURISTIC} strategy. * - * Valid range - Minimum: {@value MIN_BUILD_QUALITY}, Maximum: {@value MAX_BUILD_QUALITY} + * Valid range - Minimum: {@value MIN_BUILD_QUALITY}, unbounded above. cuVS documents any value + * as valid, with values below 20 being the most practical. * Default value - {@value DEFAULT_BUILD_QUALITY} * * @param buildQuality the build quality to set @@ -433,13 +433,9 @@ private void validate() throws IllegalArgumentException { + MAX_NN_DESCENT_NUM_ITERATIONS + "]"); } - if (buildQuality < MIN_BUILD_QUALITY || buildQuality > MAX_BUILD_QUALITY) { + if (buildQuality < MIN_BUILD_QUALITY) { throw new IllegalArgumentException( - "buildQuality not in valid range. Valid range: [" - + MIN_BUILD_QUALITY - + ", " - + MAX_BUILD_QUALITY - + "]"); + "buildQuality must not be less than " + MIN_BUILD_QUALITY + "."); } } diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java index 6bf34d25..e5f4b694 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java @@ -125,18 +125,20 @@ public void testMetricSurvivesEveryStrategy() { } } - /** Build quality is validated at build() time rather than surfacing as a native failure. */ + /** + * Only the lower bound is enforced. A negative build quality is rejected at build() time because + * it would wrap when handed to cuVS' {@code size_t} parameter, but cuVS documents any non-negative + * value as valid -- so large values must be accepted rather than second-guessed here. Pure Java, + * no GPU needed. + */ @Test - public void testBuildQualityBounds() { + public void testBuildQualityRejectsNegativeOnly() { expectThrows( IllegalArgumentException.class, () -> new GPUSearchParams.Builder().withBuildQuality(-1).build()); - expectThrows( - IllegalArgumentException.class, - () -> - new GPUSearchParams.Builder() - .withBuildQuality(GPUSearchParams.MAX_BUILD_QUALITY + 1) - .build()); + + // Well above the value cuVS calls "most practical"; unusual, but not ours to reject. + assertEquals(50, new GPUSearchParams.Builder().withBuildQuality(50).build().getBuildQuality()); } /** From 8305a87cbff00bb3faaa123a81becbd32a4df88f Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Thu, 6 Aug 2026 07:03:23 -1000 Subject: [PATCH 7/7] Don't override IntermediateGraphDegree --- .../cuvs/lucene/CagraIndexParamsFactory.java | 42 +++++++++---------- .../lucene/TestCagraIndexParamsFactory.java | 7 +++- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java index b82574da..8bb46faf 100644 --- a/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java +++ b/src/main/java/com/nvidia/cuvs/lucene/CagraIndexParamsFactory.java @@ -29,17 +29,9 @@ private CagraIndexParamsFactory() {} */ public static CagraIndexParams create( GPUSearchParams gpuSearchParams, long rows, long dimension) { - CagraIndexParams.Builder builder = - new CagraIndexParams.Builder() - .withGraphDegree(gpuSearchParams.getGraphdegree()) - .withIntermediateGraphDegree(gpuSearchParams.getIntermediateGraphDegree()) - .withNumWriterThreads(gpuSearchParams.getWriterThreads()) - .withMetric(gpuSearchParams.getCuvsDistanceType()); if (gpuSearchParams.getStrategy().equals(GPUSearchParams.Strategy.HEURISTIC)) { // Delegate the build-algorithm choice and its parameters to cuVS' dataset heuristic, which - // switches on the row count and tunes the algorithm with the caller's build quality. The - // graph degrees fromDataset would derive are discarded in favour of the caller's, which this - // class honours under both strategies. + // switches on the row count and tunes the algorithm with the caller's build quality. CagraIndexParams derived = CagraIndexParams.fromDataset( rows, @@ -47,19 +39,27 @@ public static CagraIndexParams create( gpuSearchParams.getGraphdegree(), gpuSearchParams.getCuvsDistanceType(), gpuSearchParams.getBuildQuality()); - builder + return new CagraIndexParams.Builder() + .withGraphDegree(derived.getGraphDegree()) + .withIntermediateGraphDegree(derived.getIntermediateGraphDegree()) .withCagraGraphBuildAlgo(derived.getCagraGraphBuildAlgo()) .withCuVSIvfPqParams(derived.getCuVSIvfPqParams()) - .withNNDescentNumIterations(derived.getNNDescentNumIterations()); - } else { - // 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()); + .withNNDescentNumIterations(derived.getNNDescentNumIterations()) + .withMetric(gpuSearchParams.getCuvsDistanceType()) + .withNumWriterThreads(gpuSearchParams.getWriterThreads()) + .build(); } - return builder.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). + return new CagraIndexParams.Builder() + .withGraphDegree(gpuSearchParams.getGraphdegree()) + .withIntermediateGraphDegree(gpuSearchParams.getIntermediateGraphDegree()) + .withCagraGraphBuildAlgo(gpuSearchParams.getCagraGraphBuildAlgo()) + .withCuVSIvfPqParams(gpuSearchParams.getCuVSIvfPqParams()) + .withNNDescentNumIterations(gpuSearchParams.getnNDescentNumIterations()) + .withMetric(gpuSearchParams.getCuvsDistanceType()) + .withNumWriterThreads(gpuSearchParams.getWriterThreads()) + .build(); } /** @@ -98,13 +98,13 @@ public static CagraIndexParams create( .build(); } return new CagraIndexParams.Builder() - .withNumWriterThreads(acceleratedHNSWParams.getWriterThreads()) - .withIntermediateGraphDegree(acceleratedHNSWParams.getIntermediateGraphDegree()) .withGraphDegree(acceleratedHNSWParams.getGraphdegree()) + .withIntermediateGraphDegree(acceleratedHNSWParams.getIntermediateGraphDegree()) .withCagraGraphBuildAlgo(acceleratedHNSWParams.getCagraGraphBuildAlgo()) .withCuVSIvfPqParams(acceleratedHNSWParams.getCuVSIvfPqParams()) .withNNDescentNumIterations(acceleratedHNSWParams.getNNDescentNumIterations()) .withMetric(acceleratedHNSWParams.getCuvsDistanceType()) + .withNumWriterThreads(acceleratedHNSWParams.getWriterThreads()) .build(); } } diff --git a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java index e5f4b694..f559ea38 100644 --- a/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java +++ b/src/test/java/com/nvidia/cuvs/lucene/TestCagraIndexParamsFactory.java @@ -46,9 +46,12 @@ public void testGpuHeuristicDelegatesToCuVS() { CagraIndexParams cagraParams = CagraIndexParamsFactory.create(params, 10_000, 128); assertEquals(CagraGraphBuildAlgo.NN_DESCENT, cagraParams.getCagraGraphBuildAlgo()); - // The caller's degrees survive; fromDataset would otherwise force intermediate = 1.5 * degree. + // The graph degree is an input to the heuristic, so it survives... assertEquals(48, cagraParams.getGraphDegree()); - assertEquals(96, cagraParams.getIntermediateGraphDegree()); + // ...but the intermediate degree is derived as graph_degree * 3 / 2, overriding the configured + // 96. cuVS derives the build parameters from that value, so the two must stay in step. + assertEquals(48 * 3 / 2, cagraParams.getIntermediateGraphDegree()); + // writerThreads has no fromDataset argument and is re-attached by the factory. assertEquals(4, cagraParams.getNumWriterThreads()); assertEquals(CuvsDistanceType.InnerProduct, cagraParams.getCuvsDistanceType()); }