diff --git a/java/cuvs-lucene/README.md b/java/cuvs-lucene/README.md index 8e3881f23d..3c5db2862e 100644 --- a/java/cuvs-lucene/README.md +++ b/java/cuvs-lucene/README.md @@ -79,6 +79,55 @@ than an individual Lucene codec. Async allocation is optional for correctness an GPU workloads with repeated device allocations, especially concurrent or multi-stream searches. Applications that do not opt in use the default RMM device-memory resource. +### Parameter bounds + +The public Lucene API rejects out-of-range CAGRA parameters in Java before they reach native CAGRA. +Most of these checks run at construction time; the `SINGLE_CTA` `iTopK` limit is additionally +re-checked at search time against the effective value actually sent to native CAGRA, since a filter +can raise it after construction (see below). The table below lists what is enforced in Java; it is +**not** a claim that every value in these ranges is supported by native CAGRA — see the caveats +that follow. + +| Parameter | Java-level range enforced | +| --- | --- | +| GPU writer threads | 1–512 | +| Intermediate graph degree | 2–512 | +| Graph degree | 1–512 | +| `GPUKnnFloatVectorQuery` `iTopK` | minimum 1; at most 512 with `SINGLE_CTA` | +| `GPUKnnFloatVectorQuery` `searchWidth` | minimum 1; 4,194,303 numeric-safety ceiling | + +`graphDegree` must not exceed `intermediateGraphDegree` under the `CUSTOM` strategy. Under +`HEURISTIC`, the configured `graphDegree`/`intermediateGraphDegree` pair is not what CAGRA +actually builds with, so this relationship is not enforced on the configured pair: for +`AcceleratedHNSWParams`, both degrees are derived from `maxConn`/`beamWidth` and the configured +pair is ignored entirely; for `GPUSearchParams`, the configured `graphDegree` is passed into the +dataset-size heuristic as an input (it is not ignored), while `intermediateGraphDegree` is ignored +and the rest of the build parameters are derived from the heuristic's output. + +**Only the lower bound of 1 and the `SINGLE_CTA` `iTopK` maximum of 512 are genuine native +limits.** `MAX_ITOPK` (`Integer.MAX_VALUE`) is simply the largest value representable by the +public Java API, and `MAX_SEARCH_WIDTH` (4,194,303) only keeps CAGRA's result buffer within its +unsigned 32-bit indexing limit. Neither is a promise that native CAGRA supports every value up +to that ceiling, and in practice values anywhere near `MAX_ITOPK` are not usable. The true upper +limit for a given search depends on the resolved CAGRA algorithm, `max_iterations`, graph degree, +filtering, and available GPU memory. In particular, `MULTI_CTA` (which a normal one-query `AUTO` +search resolves to) sizes an internal traversal hash table from `search_width`, `iTopK`, +`max_iterations`, and the graph degree. This API does not replicate that calculation, since +`max_iterations` is itself auto-derived from the graph degree and dataset size, values not known +at query-construction time, so out-of-range combinations are caught by native CAGRA at search +time rather than here. + +Note that native CAGRA only reports some of those combinations cleanly. Moderately oversized +values raise a clear exception, but above roughly `iTopK` 1e9 the native hash-table sizing loop +fails to terminate and the search hangs instead of returning an error +([#2523](https://github.com/NVIDIA/cuvs/issues/2523)). Treat these constants as representational +ceilings only, and size `iTopK`/`searchWidth` to what the workload actually needs. + +The query uses an effective `iTopK` equal to the greater of the configured value and the requested +Lucene `k`; for `SINGLE_CTA`, this effective value is re-validated against the 512 limit again once +the filtered per-segment search path finishes adjusting it, since a restrictive filter can raise it +past what was checked at query construction time. + In a Maven project that includes the `cuvs-lucene` dependency shown above, create `src/main/java/com/nvidia/cuvs/lucene/examples/HelloCuvsLucene.java`: ```java diff --git a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java index a5f164b70b..58be2c76ae 100644 --- a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java +++ b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/AcceleratedHNSWParams.java @@ -31,11 +31,9 @@ public static enum Strategy { CUSTOM } - /* - * TODO: Update boundaries for all parameters when a consensus is reached. - * Issue: https://github.com/rapidsai/cuvs-lucene/issues/99 - */ + /** Bounds for the public CAGRA and HNSW build parameters. */ public static final int MIN_WRITER_THREADS = 1; + public static final int MAX_WRITER_THREADS = 512; public static final int MIN_INT_GRAPH_DEG = 2; public static final int MAX_INT_GRAPH_DEG = 512; @@ -334,6 +332,7 @@ public static class Builder { * @return instance of {@link Builder} */ public Builder withWriterThreads(int writerThreads) { + validateRange("writerThreads", writerThreads, MIN_WRITER_THREADS, MAX_WRITER_THREADS); this.writerThreads = writerThreads; return this; } @@ -347,6 +346,8 @@ public Builder withWriterThreads(int writerThreads) { * @return instance of {@link Builder} */ public Builder withIntermediateGraphDegree(int intermediateGraphDegree) { + validateRange( + "intermediateGraphDegree", intermediateGraphDegree, MIN_INT_GRAPH_DEG, MAX_INT_GRAPH_DEG); this.intermediateGraphDegree = intermediateGraphDegree; return this; } @@ -360,6 +361,7 @@ public Builder withIntermediateGraphDegree(int intermediateGraphDegree) { * @return instance of {@link Builder} */ public Builder withGraphDegree(int graphDegree) { + validateRange("graphDegree", graphDegree, MIN_GRAPH_DEG, MAX_GRAPH_DEG); this.graphdegree = graphDegree; return this; } @@ -507,6 +509,13 @@ public Builder withHnswHeuristicType(HnswHeuristicType hnswHeuristicType) { return this; } + private static void validateRange(String name, int value, int min, int max) { + if (value < min || value > max) { + throw new IllegalArgumentException( + name + " not in valid range. Valid range: [" + min + ", " + max + "]"); + } + } + /** * Validates the input parameters. * @@ -538,6 +547,10 @@ private void validate() throws IllegalArgumentException { + MAX_GRAPH_DEG + "]"); } + if (strategy == Strategy.CUSTOM && graphdegree > intermediateGraphDegree) { + throw new IllegalArgumentException( + "graphDegree must not be greater than intermediateGraphDegree."); + } if (hnswLayers < MIN_HNSW_LAYERS || hnswLayers > MAX_HNSW_LAYERS) { throw new IllegalArgumentException( "hnswLayers not in valid range. Valid range: [" diff --git a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java index 23fc738a55..c3f3e6b293 100644 --- a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java +++ b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java @@ -459,9 +459,14 @@ public void search(String field, float[] target, KnnCollector knnCollector, Bits CagraSearchParams searchParams; if (knnCollector instanceof GPUPerLeafCuVSKnnCollector) { GPUPerLeafCuVSKnnCollector collector = (GPUPerLeafCuVSKnnCollector) knnCollector; + int effectiveITopK = Math.max(collector.getiTopK(), topK); + // topK may have been raised above the value validated at query construction time (see + // GPUKnnFloatVectorQuery.validateSearchParameters), e.g. by the filter-cardinality bump + // above. Re-validate against the final value actually sent to native CAGRA. + GPUKnnFloatVectorQuery.validateSingleCtaItopk(effectiveITopK, collector.getSearchAlgo()); searchParams = new CagraSearchParams.Builder() - .withItopkSize(Math.max(collector.getiTopK(), topK)) + .withItopkSize(effectiveITopK) .withSearchWidth(collector.getSearchWidth()) .withThreadBlockSize(collector.getThreadBlockSize()) .withMaxIterations(collector.getMaxIterations()) diff --git a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUKnnFloatVectorQuery.java b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUKnnFloatVectorQuery.java index f796d88799..10b1a92047 100644 --- a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUKnnFloatVectorQuery.java +++ b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUKnnFloatVectorQuery.java @@ -71,6 +71,46 @@ */ public class GPUKnnFloatVectorQuery extends KnnFloatVectorQuery { + /** Smallest supported CAGRA intermediate-result count. */ + public static final int MIN_ITOPK = 1; + + /** + * Largest intermediate-result count representable by the public Java API. + * + *

This is a representational limit only. It is emphatically not a supported maximum: values + * anywhere near it are rejected by native CAGRA in practice. Native CAGRA sizes internal + * traversal hash tables from a combination of itopk_size, search_width, max_iterations, and + * (for MULTI_CTA, which a normal one-query {@code AUTO} search resolves to) the graph degree + * and dataset size, none of which are all known at query-construction time, so this class does + * not attempt to replicate that sizing logic. + * + *

Moderately oversized combinations are rejected by native CAGRA with a clear exception (see + * {@link Utils#handleThrowable}). Very large values are not: above roughly 1e9, native CAGRA's + * hash-table sizing loop fails to terminate and the search hangs instead of returning an error. + * See #2523. Callers should treat + * itopk_size as bounded by what their algorithm and dataset actually support, not by this + * constant. + */ + public static final int MAX_ITOPK = Integer.MAX_VALUE; + + /** Largest intermediate-result count supported by CAGRA's SINGLE_CTA search algorithm. */ + public static final int MAX_SINGLE_CTA_ITOPK = 512; + + /** Smallest supported number of CAGRA search entry points. */ + public static final int MIN_SEARCH_WIDTH = 1; + + /** + * Largest search width that keeps CAGRA's result buffer within its unsigned 32-bit indexing + * limit at the maximum graph degree and aligned {@link #MAX_ITOPK}. + * + *

This bound alone does not guarantee a given (iTopK, searchWidth) pair is supported: as + * with {@link #MAX_ITOPK}, native CAGRA may still reject a combination that exceeds its + * traversal hash table's capacity (e.g. the MULTI_CTA path used by a normal one-query {@code + * AUTO} search), since that capacity also depends on max_iterations, graph degree, and dataset + * size, which are not known here. + */ + public static final int MAX_SEARCH_WIDTH = 4_194_303; + private final int iTopK; private final int searchWidth; private final int threadBlockSize; @@ -117,6 +157,7 @@ public GPUKnnFloatVectorQuery( int maxIterations, CagraSearchParams.SearchAlgo searchAlgo) { super(field, target, k, filter); + validateSearchParameters(iTopK, searchWidth, k, searchAlgo); this.iTopK = iTopK; this.searchWidth = searchWidth; this.threadBlockSize = threadBlockSize; @@ -124,6 +165,48 @@ public GPUKnnFloatVectorQuery( this.searchAlgo = searchAlgo; } + private static void validateSearchParameters( + int iTopK, int searchWidth, int k, CagraSearchParams.SearchAlgo searchAlgo) { + validateRange("iTopK", iTopK, MIN_ITOPK, MAX_ITOPK); + validateRange("searchWidth", searchWidth, MIN_SEARCH_WIDTH, MAX_SEARCH_WIDTH); + // This is a lower bound on the effective iTopK actually sent to native CAGRA: the filtered + // per-segment fallback path (see CuVS2510GPUVectorsReader) can raise topK further based on + // filter cardinality, so a later, authoritative check is required at that point too — see + // validateSingleCtaItopk below. + validateSingleCtaItopk(Math.max(iTopK, k), searchAlgo); + } + + /** + * Validates that {@code effectiveITopK} — the itopk_size value actually about to be sent to + * native CAGRA — does not exceed the SINGLE_CTA algorithm's limit. + * + *

Callers that can further increase itopk_size after construction (e.g. the filtered + * per-segment fallback path, which raises topK based on filter cardinality) must call this + * again with the final, post-adjustment value immediately before building {@link + * CagraSearchParams}. + * + * @param effectiveITopK the itopk_size value about to be sent to native CAGRA + * @param searchAlgo the CAGRA search algorithm the query will run under + */ + static void validateSingleCtaItopk(int effectiveITopK, CagraSearchParams.SearchAlgo searchAlgo) { + if (searchAlgo == CagraSearchParams.SearchAlgo.SINGLE_CTA + && effectiveITopK > MAX_SINGLE_CTA_ITOPK) { + throw new IllegalArgumentException( + "effective iTopK must not exceed " + + MAX_SINGLE_CTA_ITOPK + + " for SINGLE_CTA search, but was " + + effectiveITopK + + "."); + } + } + + private static void validateRange(String name, int value, int min, int max) { + if (value < min || value > max) { + throw new IllegalArgumentException( + name + " not in valid range. Valid range: [" + min + ", " + max + "]"); + } + } + // ------------------------------------------------------------------------- // Optimized multi-segment path // ------------------------------------------------------------------------- diff --git a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java index 5e317ec351..f138276736 100644 --- a/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java +++ b/java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/GPUSearchParams.java @@ -30,11 +30,9 @@ public static enum Strategy { CUSTOM } - /* - * TODO: Update boundaries for all parameters when a consensus is reached. - * Issue: https://github.com/rapidsai/cuvs-lucene/issues/99 - */ + /** Bounds for the public CAGRA build parameters. */ public static final int MIN_WRITER_THREADS = 1; + public static final int MAX_WRITER_THREADS = 512; public static final int MIN_INT_GRAPH_DEG = 2; public static final int MAX_INT_GRAPH_DEG = 512; @@ -256,6 +254,7 @@ public static class Builder { * @return instance of {@link Builder} */ public Builder withWriterThreads(int writerThreads) { + validateRange("writerThreads", writerThreads, MIN_WRITER_THREADS, MAX_WRITER_THREADS); this.writerThreads = writerThreads; return this; } @@ -269,6 +268,8 @@ public Builder withWriterThreads(int writerThreads) { * @return instance of {@link Builder} */ public Builder withIntermediateGraphDegree(int intermediateGraphDegree) { + validateRange( + "intermediateGraphDegree", intermediateGraphDegree, MIN_INT_GRAPH_DEG, MAX_INT_GRAPH_DEG); this.intermediateGraphDegree = intermediateGraphDegree; return this; } @@ -282,6 +283,7 @@ public Builder withIntermediateGraphDegree(int intermediateGraphDegree) { * @return instance of {@link Builder} */ public Builder withGraphDegree(int graphDegree) { + validateRange("graphDegree", graphDegree, MIN_GRAPH_DEG, MAX_GRAPH_DEG); this.graphdegree = graphDegree; return this; } @@ -381,6 +383,13 @@ public Builder withBuildQuality(int buildQuality) { return this; } + private static void validateRange(String name, int value, int min, int max) { + if (value < min || value > max) { + throw new IllegalArgumentException( + name + " not in valid range. Valid range: [" + min + ", " + max + "]"); + } + } + /** * Validates the input parameters. * @@ -412,6 +421,10 @@ private void validate() throws IllegalArgumentException { + MAX_GRAPH_DEG + "]"); } + if (strategy == Strategy.CUSTOM && graphdegree > intermediateGraphDegree) { + throw new IllegalArgumentException( + "graphDegree must not be greater than intermediateGraphDegree."); + } if (Objects.isNull(cagraGraphBuildAlgo)) { throw new IllegalArgumentException("cagraGraphBuildAlgo cannot be null."); } diff --git a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWParams.java b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWParams.java index 03b5633a87..592d34e08e 100644 --- a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWParams.java +++ b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestAcceleratedHNSWParams.java @@ -66,6 +66,74 @@ public void testAcceleratedHNSWParamsDefaultValues() { assertEquals(DEFAULT_NN_DESCENT_NUM_ITERATIONS, params.getNNDescentNumIterations()); } + @Test + public void testAcceleratedHNSWParamsBuildParameterBoundaries() { + for (int v : new int[] {MIN_WRITER_THREADS, MAX_WRITER_THREADS}) { + assertEquals( + v, new AcceleratedHNSWParams.Builder().withWriterThreads(v).build().getWriterThreads()); + } + for (int v : new int[] {MIN_INT_GRAPH_DEG, MAX_INT_GRAPH_DEG}) { + assertEquals( + v, + new AcceleratedHNSWParams.Builder() + .withGraphDegree(MIN_GRAPH_DEG) + .withIntermediateGraphDegree(v) + .build() + .getIntermediateGraphDegree()); + } + for (int v : new int[] {MIN_GRAPH_DEG, MAX_GRAPH_DEG}) { + assertEquals( + v, + new AcceleratedHNSWParams.Builder() + .withIntermediateGraphDegree(MAX_INT_GRAPH_DEG) + .withGraphDegree(v) + .build() + .getGraphdegree()); + } + } + + @Test + public void testGraphDegreeMustNotExceedIntermediateGraphDegreeUnderCustomStrategy() { + AcceleratedHNSWParams params = + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.CUSTOM) + .withGraphDegree(DEFAULT_GRAPH_DEGREE) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .build(); + assertEquals(params.getGraphdegree(), params.getIntermediateGraphDegree()); + + assertThrows( + IllegalArgumentException.class, + () -> + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.CUSTOM) + .withGraphDegree(DEFAULT_GRAPH_DEGREE + 1) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.CUSTOM) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .withGraphDegree(DEFAULT_GRAPH_DEGREE + 1) + .build()); + } + + @Test + public void testGraphDegreeRelationshipNotEnforcedUnderHeuristicStrategy() { + // Under HEURISTIC, both degrees are derived from maxConn/beamWidth and the configured values + // are ignored, so a configured graphDegree > intermediateGraphDegree must not fail to build. + AcceleratedHNSWParams params = + new AcceleratedHNSWParams.Builder() + .withStrategy(AcceleratedHNSWParams.Strategy.HEURISTIC) + .withGraphDegree(DEFAULT_GRAPH_DEGREE + 1) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .build(); + assertEquals(DEFAULT_GRAPH_DEGREE + 1, params.getGraphdegree()); + assertEquals(DEFAULT_GRAPH_DEGREE, params.getIntermediateGraphDegree()); + } + @Test public void testAcceleratedHNSWParamsInvalidBeamWidth() { for (int v : @@ -86,7 +154,7 @@ public void testAcceleratedHNSWParamsInvalidGraphDegree() { }) { assertThrows( IllegalArgumentException.class, - () -> new AcceleratedHNSWParams.Builder().withGraphDegree(v).build()); + () -> new AcceleratedHNSWParams.Builder().withGraphDegree(v)); } } @@ -111,7 +179,7 @@ public void testAcceleratedHNSWParamsInvalidIntGraphDegree() { }) { assertThrows( IllegalArgumentException.class, - () -> new AcceleratedHNSWParams.Builder().withIntermediateGraphDegree(v).build()); + () -> new AcceleratedHNSWParams.Builder().withIntermediateGraphDegree(v)); } } @@ -136,7 +204,7 @@ public void testAcceleratedHNSWParamsInvalidWriterThreads() { }) { assertThrows( IllegalArgumentException.class, - () -> new AcceleratedHNSWParams.Builder().withWriterThreads(v).build()); + () -> new AcceleratedHNSWParams.Builder().withWriterThreads(v)); } } diff --git a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestFilteredSingleCtaITopKValidation.java b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestFilteredSingleCtaITopKValidation.java new file mode 100644 index 0000000000..8be6a7f74e --- /dev/null +++ b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestFilteredSingleCtaITopKValidation.java @@ -0,0 +1,118 @@ +/* + * 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.CagraSearchParams; +import org.apache.lucene.codecs.Codec; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.KnnFloatVectorField; +import org.apache.lucene.document.StringField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.NoMergePolicy; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.TermQuery; +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.tests.util.TestUtil; +import org.junit.Test; + +/** + * Regression test: the per-segment fallback search path must re-validate the effective SINGLE_CTA + * iTopK limit against the value actually sent to native CAGRA, not just the value checked at query + * construction time. + * + *

{@link GPUKnnFloatVectorQuery} validates {@code max(iTopK, k)} against the SINGLE_CTA limit + * (512) at construction. But the per-segment fallback path ({@link + * CuVS2510GPUVectorsReader#search}) -- used when {@link GPUKnnFloatVectorQuery#rewrite} cannot + * apply its optimized multi-partition search, e.g. because a segment has no CAGRA index for the + * field -- raises {@code topK} further, up to {@code min(k + 10, filterCardinality)}, whenever a + * filter is present. A sufficiently permissive filter can push the value actually sent to native + * CAGRA above 512 even though the value checked at construction time was within range. This must + * still be rejected before a native search plan is built. + */ +@SuppressSysoutChecks(bugUrl = "") +public class TestFilteredSingleCtaITopKValidation extends LuceneTestCase { + + private static final String VECTOR_FIELD = "vector"; + private static final String INCLUDED_FIELD = "included"; + + @Test + public void testFilterDrivenTopKIncreaseIsRevalidatedAgainstSingleCtaLimit() throws Exception { + assumeTrue("cuVS not supported", isSupported()); + + Codec codec = TestUtil.alwaysKnnVectorsFormat(new CuVS2510GPUVectorsFormat()); + int datasetSize = 1000; + int dimensions = 32; + float[][] dataset = generateDataset(random(), datasetSize, dimensions); + + try (Directory dir = newDirectory()) { + IndexWriterConfig cfg = new IndexWriterConfig().setCodec(codec); + // Keep segments separate: a single-vector segment falls back to a brute-force index + // (CuVS2510GPUVectorsWriter's MIN_CAGRA_INDEX_SIZE is 2), giving it no CAGRA index for this + // field. That forces GPUKnnFloatVectorQuery#rewrite to fall back to the standard per-segment + // Lucene search path -- the only path that raises topK based on filter cardinality -- for + // every segment, instead of taking the optimized multi-partition path. + cfg.setMergePolicy(NoMergePolicy.INSTANCE); + try (IndexWriter w = new IndexWriter(dir, cfg)) { + for (int i = 0; i < datasetSize; i++) { + Document doc = new Document(); + doc.add(new StringField(INCLUDED_FIELD, "yes", Field.Store.NO)); + doc.add(new KnnFloatVectorField(VECTOR_FIELD, dataset[i], EUCLIDEAN)); + w.addDocument(doc); + } + w.commit(); + + Document singleVectorDoc = new Document(); + singleVectorDoc.add(new StringField(INCLUDED_FIELD, "no", Field.Store.NO)); + singleVectorDoc.add(new KnnFloatVectorField(VECTOR_FIELD, dataset[0], EUCLIDEAN)); + w.addDocument(singleVectorDoc); + w.commit(); + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + IndexSearcher searcher = new IndexSearcher(reader); + + // Matches every document in the 1000-vector segment (cardinality 1000) and none in the + // single-vector segment. + Query filter = new TermQuery(new Term(INCLUDED_FIELD, "yes")); + + int k = 503; + int iTopK = 512; // Passes the constructor-time check: max(iTopK, k) == 512 <= 512. + GPUKnnFloatVectorQuery query = + new GPUKnnFloatVectorQuery( + VECTOR_FIELD, + dataset[0], + k, + filter, + iTopK, + 1, + 0, + 0, + CagraSearchParams.SearchAlgo.SINGLE_CTA); + + // On the 1000-vector segment, topK becomes min(k + 10, filterCardinality) = min(513, 1000) + // = 513, so the effective iTopK sent to native CAGRA is max(512, 513) = 513, exceeding the + // SINGLE_CTA limit of 512. Assert the specific message (not just the exception type) to + // confirm this post-filter re-validation fired, rather than some unrelated argument check. + IllegalArgumentException e = + expectThrows(IllegalArgumentException.class, () -> searcher.search(query, k)); + assertTrue(e.getMessage(), e.getMessage().contains("SINGLE_CTA")); + assertTrue(e.getMessage(), e.getMessage().contains("512")); + assertTrue(e.getMessage(), e.getMessage().contains("513")); + } + } + } +} diff --git a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestGPUKnnFloatVectorQueryParameters.java b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestGPUKnnFloatVectorQueryParameters.java new file mode 100644 index 0000000000..f1ea396505 --- /dev/null +++ b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestGPUKnnFloatVectorQueryParameters.java @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.nvidia.cuvs.lucene; + +import com.nvidia.cuvs.CagraSearchParams; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.LuceneTestCase.SuppressSysoutChecks; +import org.junit.Test; + +@SuppressSysoutChecks(bugUrl = "") +public class TestGPUKnnFloatVectorQueryParameters extends LuceneTestCase { + + private static final float[] TARGET = {0.0f}; + + @Test + public void testRejectsInvalidSearchParameters() { + assertThrows( + IllegalArgumentException.class, + () -> new GPUKnnFloatVectorQuery("vector", TARGET, 1, null, 0, 1)); + assertThrows( + IllegalArgumentException.class, + () -> new GPUKnnFloatVectorQuery("vector", TARGET, 1, null, 1, 0)); + assertThrows( + IllegalArgumentException.class, + () -> + new GPUKnnFloatVectorQuery( + "vector", TARGET, 1, null, 1, GPUKnnFloatVectorQuery.MAX_SEARCH_WIDTH + 1)); + } + + /** + * Verifies only that the Java-level range checks in {@link + * GPUKnnFloatVectorQuery#MIN_ITOPK}/{@link GPUKnnFloatVectorQuery#MAX_ITOPK} and {@link + * GPUKnnFloatVectorQuery#MIN_SEARCH_WIDTH}/{@link GPUKnnFloatVectorQuery#MAX_SEARCH_WIDTH} + * accept their own boundary values at construction time. + * + *

This does NOT prove these values are supported by native CAGRA: constructing a {@link + * GPUKnnFloatVectorQuery} never builds a native search plan, and (as documented on {@link + * GPUKnnFloatVectorQuery#MAX_ITOPK}) native CAGRA sizes internal traversal hash tables from + * itopk_size, search_width, max_iterations, graph degree, and dataset size — values this test + * does not exercise. A combination that passes this test can still be rejected by native CAGRA. + */ + @Test + public void testAcceptsJavaLevelSearchParameterBoundaries() { + new GPUKnnFloatVectorQuery( + "vector", + TARGET, + 1, + null, + GPUKnnFloatVectorQuery.MIN_ITOPK, + GPUKnnFloatVectorQuery.MIN_SEARCH_WIDTH); + new GPUKnnFloatVectorQuery( + "vector", + TARGET, + 1, + null, + GPUKnnFloatVectorQuery.MAX_ITOPK, + GPUKnnFloatVectorQuery.MAX_SEARCH_WIDTH, + 0, + 0, + CagraSearchParams.SearchAlgo.AUTO); + } + + @Test + public void testSingleCtaITopKBoundary() { + new GPUKnnFloatVectorQuery( + "vector", + TARGET, + GPUKnnFloatVectorQuery.MAX_SINGLE_CTA_ITOPK, + null, + GPUKnnFloatVectorQuery.MAX_SINGLE_CTA_ITOPK, + GPUKnnFloatVectorQuery.MIN_SEARCH_WIDTH, + 0, + 0, + CagraSearchParams.SearchAlgo.SINGLE_CTA); + + assertThrows( + IllegalArgumentException.class, + () -> + new GPUKnnFloatVectorQuery( + "vector", + TARGET, + 1, + null, + GPUKnnFloatVectorQuery.MAX_SINGLE_CTA_ITOPK + 1, + GPUKnnFloatVectorQuery.MIN_SEARCH_WIDTH, + 0, + 0, + CagraSearchParams.SearchAlgo.SINGLE_CTA)); + assertThrows( + IllegalArgumentException.class, + () -> + new GPUKnnFloatVectorQuery( + "vector", + TARGET, + GPUKnnFloatVectorQuery.MAX_SINGLE_CTA_ITOPK + 1, + null, + GPUKnnFloatVectorQuery.MAX_SINGLE_CTA_ITOPK, + GPUKnnFloatVectorQuery.MIN_SEARCH_WIDTH, + 0, + 0, + CagraSearchParams.SearchAlgo.SINGLE_CTA)); + } +} diff --git a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestGPUSearchParams.java b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestGPUSearchParams.java index 32e5c81426..45e75dde40 100644 --- a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestGPUSearchParams.java +++ b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestGPUSearchParams.java @@ -52,6 +52,75 @@ public void testGPUSearchParamsDefaultValues() { assertEquals(DEFAULT_NN_DESCENT_NUM_ITERATIONS, params.getnNDescentNumIterations()); } + @Test + public void testGPUSearchParamsBuildParameterBoundaries() { + for (int v : new int[] {MIN_WRITER_THREADS, MAX_WRITER_THREADS}) { + assertEquals( + v, new GPUSearchParams.Builder().withWriterThreads(v).build().getWriterThreads()); + } + for (int v : new int[] {MIN_INT_GRAPH_DEG, MAX_INT_GRAPH_DEG}) { + assertEquals( + v, + new GPUSearchParams.Builder() + .withGraphDegree(MIN_GRAPH_DEG) + .withIntermediateGraphDegree(v) + .build() + .getIntermediateGraphDegree()); + } + for (int v : new int[] {MIN_GRAPH_DEG, MAX_GRAPH_DEG}) { + assertEquals( + v, + new GPUSearchParams.Builder() + .withIntermediateGraphDegree(MAX_INT_GRAPH_DEG) + .withGraphDegree(v) + .build() + .getGraphdegree()); + } + } + + @Test + public void testGraphDegreeMustNotExceedIntermediateGraphDegreeUnderCustomStrategy() { + GPUSearchParams params = + new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.CUSTOM) + .withGraphDegree(DEFAULT_GRAPH_DEGREE) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .build(); + assertEquals(params.getGraphdegree(), params.getIntermediateGraphDegree()); + + assertThrows( + IllegalArgumentException.class, + () -> + new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.CUSTOM) + .withGraphDegree(DEFAULT_GRAPH_DEGREE + 1) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> + new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.CUSTOM) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .withGraphDegree(DEFAULT_GRAPH_DEGREE + 1) + .build()); + } + + @Test + public void testGraphDegreeRelationshipNotEnforcedUnderHeuristicStrategy() { + // Under HEURISTIC, the intermediate degree is derived from the graph degree and the + // configured value is ignored, so a configured graphDegree > intermediateGraphDegree must not + // fail to build. + GPUSearchParams params = + new GPUSearchParams.Builder() + .withStrategy(GPUSearchParams.Strategy.HEURISTIC) + .withGraphDegree(DEFAULT_GRAPH_DEGREE + 1) + .withIntermediateGraphDegree(DEFAULT_GRAPH_DEGREE) + .build(); + assertEquals(DEFAULT_GRAPH_DEGREE + 1, params.getGraphdegree()); + assertEquals(DEFAULT_GRAPH_DEGREE, params.getIntermediateGraphDegree()); + } + @Test public void testGPUSearchParamsInvalidGraphDegree() { for (int v : @@ -59,8 +128,7 @@ public void testGPUSearchParamsInvalidGraphDegree() { random.nextInt(MIN_VALUE, MIN_GRAPH_DEG), random.nextInt(MAX_GRAPH_DEG + 1, MAX_VALUE) }) { assertThrows( - IllegalArgumentException.class, - () -> new GPUSearchParams.Builder().withGraphDegree(v).build()); + IllegalArgumentException.class, () -> new GPUSearchParams.Builder().withGraphDegree(v)); } } @@ -73,7 +141,7 @@ public void testGPUSearchParamsInvalidIntermediateGraphDegree() { }) { assertThrows( IllegalArgumentException.class, - () -> new GPUSearchParams.Builder().withIntermediateGraphDegree(v).build()); + () -> new GPUSearchParams.Builder().withIntermediateGraphDegree(v)); } } @@ -85,8 +153,7 @@ public void testGPUSearchParamsInvalidWriterThreads() { random.nextInt(MAX_WRITER_THREADS + 1, MAX_VALUE) }) { assertThrows( - IllegalArgumentException.class, - () -> new GPUSearchParams.Builder().withWriterThreads(v).build()); + IllegalArgumentException.class, () -> new GPUSearchParams.Builder().withWriterThreads(v)); } } diff --git a/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestNativeSearchPlanBoundaryRejection.java b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestNativeSearchPlanBoundaryRejection.java new file mode 100644 index 0000000000..66c5d28c63 --- /dev/null +++ b/java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestNativeSearchPlanBoundaryRejection.java @@ -0,0 +1,118 @@ +/* + * 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.CagraSearchParams; +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.store.Directory; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.LuceneTestCase.SuppressSysoutChecks; +import org.apache.lucene.tests.util.TestUtil; +import org.junit.Test; + +/** + * Confirms that a {@code searchWidth}/{@code iTopK} combination within {@link + * GPUKnnFloatVectorQuery}'s Java-level range but unsupported by native CAGRA is rejected by native + * CAGRA itself with a clear exception, rather than by this API (which does not attempt to + * replicate native CAGRA's algorithm- and dataset-dependent hash-table sizing -- see the javadoc on + * {@link GPUKnnFloatVectorQuery#MAX_ITOPK} and {@link GPUKnnFloatVectorQuery#MAX_SEARCH_WIDTH}). + * + *

{@code MULTI_CTA} -- which a normal one-query {@code AUTO} search resolves to -- sizes an + * internal traversal hash table from {@code max(searchWidth, ceil(iTopK / 32)) * max(32, + * maxIterations)}, and native CAGRA hard-limits that table to a 25-bit index (raft::exception via + * {@code RAFT_EXPECTS(hash_bitlen <= 25, ...)} in {@code search_plan.cuh}). At the default hashmap + * fill rate of 0.5, that caps the product at 2^25 * 0.5 = 16,777,216. Setting {@code searchWidth} + * to {@link GPUKnnFloatVectorQuery#MAX_SEARCH_WIDTH} (4,194,303) alone exceeds that cap by 8x + * even at the smallest possible multiplier (32), regardless of {@code iTopK}, graph degree, or + * dataset size -- so this test does not need to reproduce native CAGRA's {@code max_iterations} + * auto-derivation to reliably trigger the rejection. + * + *

This class deliberately does not cover an oversized {@link GPUKnnFloatVectorQuery#MAX_ITOPK} + * the same way. Above roughly 1e9, native CAGRA's hash-table sizing loop fails to terminate and + * the search hangs rather than returning an error, so a test asserting on it would not be safe to + * run in CI (no bounded timeout reliably recovers a thread stuck in native code). That is tracked + * as a native bug in #2523; the clean + * rejection exercised here is the behaviour for the range below that threshold. + */ +@SuppressSysoutChecks(bugUrl = "") +public class TestNativeSearchPlanBoundaryRejection extends LuceneTestCase { + + private static final String VECTOR_FIELD = "vector"; + + @Test + public void testOversizedSearchWidthRejectedByNativeCagra() throws Exception { + assumeTrue("cuVS not supported", isSupported()); + + Codec codec = TestUtil.alwaysKnnVectorsFormat(new CuVS2510GPUVectorsFormat()); + int datasetSize = 200; + int dimensions = 32; + float[][] dataset = generateDataset(random(), datasetSize, dimensions); + + try (Directory dir = newDirectory()) { + IndexWriterConfig cfg = new IndexWriterConfig().setCodec(codec); + try (IndexWriter w = new IndexWriter(dir, cfg)) { + for (float[] vector : dataset) { + Document doc = new Document(); + doc.add(new KnnFloatVectorField(VECTOR_FIELD, vector, EUCLIDEAN)); + w.addDocument(doc); + } + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + IndexSearcher searcher = new IndexSearcher(reader); + + // A reasonable, genuinely valid combination: small search width, moderate iTopK. Must + // execute a native search plan successfully. + int k = 5; + GPUKnnFloatVectorQuery validQuery = + new GPUKnnFloatVectorQuery( + VECTOR_FIELD, + dataset[0], + k, + null, + 64, + 8, + 0, + 0, + CagraSearchParams.SearchAlgo.MULTI_CTA); + assertTrue(searcher.search(validQuery, k).scoreDocs.length > 0); + + // Within this class's own Java-level range (searchWidth <= MAX_SEARCH_WIDTH), but far + // beyond what native CAGRA's traversal hash table can represent for MULTI_CTA -- which a + // normal one-query AUTO search (used here, not an explicit MULTI_CTA) resolves to. This + // must be rejected by native CAGRA when the search plan is actually built -- not silently + // accepted or left to corrupt/misbehave. + GPUKnnFloatVectorQuery oversizedQuery = + new GPUKnnFloatVectorQuery( + VECTOR_FIELD, + dataset[0], + k, + null, + 64, + GPUKnnFloatVectorQuery.MAX_SEARCH_WIDTH, + 0, + 0, + CagraSearchParams.SearchAlgo.AUTO); + RuntimeException e = + expectThrows(RuntimeException.class, () -> searcher.search(oversizedQuery, k)); + // Assert on the specific native failure (the hash-table bit-length cap) rather than any + // RuntimeException, so an unrelated native/CUDA failure cannot make this test pass. + assertTrue(e.getMessage(), e.getMessage().contains("hash_bitlen")); + assertTrue(e.getMessage(), e.getMessage().contains("25")); + } + } + } +}