Repository navigation
Fix IVF-RaBitQ search for queries on a list centroid #2802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -195,7 +195,7 @@ ConfigureTest( | |
|
|
||
| ConfigureTest( | ||
| NAME NEIGHBORS_ANN_IVF_RABITQ_TEST | ||
| PATH neighbors/ann_ivf_rabitq/test_float_int64_t.cu | ||
| PATH neighbors/ann_ivf_rabitq/test_float_int64_t.cu neighbors/ann_ivf_rabitq/test_exact_match.cu | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new test is a separate translation unit in the existing |
||
| GPUS 1 | ||
| PERCENT 100 | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| #include <cuvs/neighbors/ivf_rabitq.hpp> | ||
|
|
||
| #include <raft/core/device_mdarray.hpp> | ||
| #include <raft/core/host_mdarray.hpp> | ||
| #include <raft/core/resource/cuda_stream.hpp> | ||
| #include <raft/core/resources.hpp> | ||
| #include <raft/util/cudart_utils.hpp> | ||
|
|
||
| #include <gtest/gtest.h> | ||
|
|
||
| #include <algorithm> | ||
| #include <cmath> | ||
| #include <cstdint> | ||
| #include <limits> | ||
| #include <ostream> | ||
| #include <random> | ||
| #include <vector> | ||
|
|
||
| namespace cuvs::neighbors::ivf_rabitq { | ||
|
|
||
| struct ivf_rabitq_exact_match_inputs { | ||
| uint32_t dim; | ||
| uint32_t bits_per_dim; | ||
| search_mode mode; | ||
| }; | ||
|
|
||
| inline auto operator<<(std::ostream& os, const ivf_rabitq_exact_match_inputs& p) -> std::ostream& | ||
| { | ||
| const char* mode = p.mode == search_mode::LUT16 ? "LUT16" | ||
| : p.mode == search_mode::LUT32 ? "LUT32" | ||
| : p.mode == search_mode::QUANT4 ? "QUANT4" | ||
| : "QUANT8"; | ||
| return os << "ivf_rabitq_exact_match_inputs {.dim = " << p.dim | ||
| << ", .bits_per_dim = " << p.bits_per_dim << ", .mode = " << mode << "}"; | ||
| } | ||
|
|
||
| /** | ||
| * Queries that coincide with a list centroid. | ||
| * | ||
| * The dataset has as many distinct rows as there are lists, and each row is repeated many times, so | ||
| * each list holds the copies of one row and its centroid is that row. Each query is one of the | ||
| * distinct rows, so the probe distance ||q - c||^2 to its own list is zero up to rounding. Search | ||
| * must return copies of the query at distance ~0, for every search mode. | ||
| */ | ||
| class ivf_rabitq_exact_match_test : public ::testing::TestWithParam<ivf_rabitq_exact_match_inputs> { | ||
| protected: | ||
| void run() | ||
| { | ||
| constexpr int64_t kDistinct = 32; | ||
| constexpr int64_t kCopies = 128; | ||
| constexpr int64_t kRows = kDistinct * kCopies; | ||
| constexpr int64_t kK = 10; | ||
| constexpr uint32_t kLists = kDistinct; | ||
|
|
||
| auto ps = GetParam(); | ||
| auto dim = static_cast<int64_t>(ps.dim); | ||
| auto stream = raft::resource::get_cuda_stream(handle_); | ||
|
|
||
| std::mt19937 gen(42); | ||
| std::uniform_real_distribution<float> uniform(1.0f, 2.0f); | ||
| auto h_queries = raft::make_host_matrix<float, int64_t>(kDistinct, dim); | ||
| for (int64_t i = 0; i < kDistinct; i++) { | ||
| for (int64_t j = 0; j < dim; j++) { | ||
| h_queries(i, j) = uniform(gen); | ||
| } | ||
| } | ||
| auto h_dataset = raft::make_host_matrix<float, int64_t>(kRows, dim); | ||
| for (int64_t i = 0; i < kRows; i++) { | ||
| for (int64_t j = 0; j < dim; j++) { | ||
| h_dataset(i, j) = h_queries(i % kDistinct, j); | ||
| } | ||
| } | ||
|
|
||
| // The squared distance from each distinct row to the nearest other distinct row. | ||
| std::vector<float> gap(kDistinct, std::numeric_limits<float>::max()); | ||
| for (int64_t a = 0; a < kDistinct; a++) { | ||
| for (int64_t b = 0; b < kDistinct; b++) { | ||
| if (a == b) { continue; } | ||
| float d = 0; | ||
| for (int64_t j = 0; j < dim; j++) { | ||
| float diff = h_queries(a, j) - h_queries(b, j); | ||
| d += diff * diff; | ||
| } | ||
| gap[a] = std::min(gap[a], d); | ||
| } | ||
| } | ||
|
|
||
| auto d_dataset = raft::make_device_matrix<float, int64_t>(handle_, kRows, dim); | ||
| auto d_queries = raft::make_device_matrix<float, int64_t>(handle_, kDistinct, dim); | ||
| raft::copy(d_dataset.data_handle(), h_dataset.data_handle(), h_dataset.size(), stream); | ||
| raft::copy(d_queries.data_handle(), h_queries.data_handle(), h_queries.size(), stream); | ||
|
|
||
| index_params ipams; | ||
| ipams.n_lists = kLists; | ||
| ipams.bits_per_dim = ps.bits_per_dim; | ||
| auto idx = build(handle_, ipams, raft::make_const_mdspan(d_dataset.view())); | ||
|
|
||
| search_params sparams; | ||
| sparams.n_probes = kLists; | ||
| sparams.mode = ps.mode; | ||
| auto neighbors = raft::make_device_matrix<int64_t, int64_t>(handle_, kDistinct, kK); | ||
| auto distances = raft::make_device_matrix<float, int64_t>(handle_, kDistinct, kK); | ||
| search(handle_, | ||
| sparams, | ||
| idx, | ||
| raft::make_const_mdspan(d_queries.view()), | ||
| neighbors.view(), | ||
| distances.view()); | ||
|
|
||
| std::vector<int64_t> h_neighbors(kDistinct * kK); | ||
| std::vector<float> h_distances(kDistinct * kK); | ||
| raft::copy(h_neighbors.data(), neighbors.data_handle(), h_neighbors.size(), stream); | ||
| raft::copy(h_distances.data(), distances.data_handle(), h_distances.size(), stream); | ||
| raft::resource::sync_stream(handle_); | ||
|
|
||
| // Every row has kCopies > kK copies, so all kK neighbors must be exact copies of the query. | ||
| int64_t n_bad = 0; | ||
| for (int64_t q = 0; q < kDistinct; q++) { | ||
| bool bad = false; | ||
| for (int64_t j = 0; j < kK; j++) { | ||
| auto neighbor = h_neighbors[q * kK + j]; | ||
| auto dist = h_distances[q * kK + j]; | ||
| if (neighbor < 0 || neighbor >= kRows || neighbor % kDistinct != q || | ||
| !(std::abs(dist) <= 0.25f * gap[q])) { | ||
|
Comment on lines
+121
to
+129
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Each list holds the 128 copies of one distinct row, so each query (one of the distinct rows) sits on its own list's centroid, and its probe distance to that list is zero up to rounding. Every list is probed, so the old failure isn't about probe selection: the query's own list is probed but contributes no candidates. IVF-RaBitQ returns distances estimated from the quantized codes, so the check doesn't require exactly 0. It requires a distance within a quarter of the gap to the nearest other distinct row. A failing query returned exactly that gap; for example, query 0 at |
||
| bad = true; | ||
| } | ||
| } | ||
| if (bad) { | ||
| n_bad++; | ||
| auto msg = ::testing::Message() << ps << ": query " << q << " (gap " << gap[q] << "):"; | ||
| for (int64_t j = 0; j < kK; j++) { | ||
| msg << " " << h_neighbors[q * kK + j] << "(row " << h_neighbors[q * kK + j] % kDistinct | ||
| << ", " << h_distances[q * kK + j] << ")"; | ||
| } | ||
| ADD_FAILURE() << msg; | ||
| } | ||
| } | ||
| EXPECT_EQ(n_bad, 0) << ps << ": " << n_bad << " of " << kDistinct | ||
| << " queries did not return copies of themselves"; | ||
| } | ||
|
|
||
| raft::resources handle_; | ||
| }; | ||
|
|
||
| inline auto exact_match_inputs() -> std::vector<ivf_rabitq_exact_match_inputs> | ||
| { | ||
| std::vector<ivf_rabitq_exact_match_inputs> xs; | ||
| for (uint32_t dim : {64, 100, 128}) { | ||
| for (uint32_t bits_per_dim : {1, 3}) { | ||
| for (auto mode : | ||
| {search_mode::LUT16, search_mode::LUT32, search_mode::QUANT4, search_mode::QUANT8}) { | ||
| xs.push_back({dim, bits_per_dim, mode}); | ||
| } | ||
| } | ||
| } | ||
| return xs; | ||
| } | ||
|
|
||
| TEST_P(ivf_rabitq_exact_match_test, build_search) { this->run(); } // NOLINT | ||
| INSTANTIATE_TEST_SUITE_P(IvfRabitq, | ||
| ivf_rabitq_exact_match_test, | ||
| ::testing::ValuesIn(exact_match_inputs())); // NOLINT | ||
|
|
||
| } // namespace cuvs::neighbors::ivf_rabitq | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The GEMM above accumulates
-2 q.conto||q||^2 + ||c||^2(L990-L1020). Whenq == c, cancellation can leave a tiny negative value. The search kernels read that value asq_g_addand takesqrtf(q_g_add)for the error bound: bitwise block sort L56-L58, LUT16 L52-L57, LUT emit-top-k L55. The NaN then propagates intolow_dist, andlow_dist < thresholdis false for every vector in the list (bitwise L92-L96).I put the clamp here because every search mode goes through this function:
BatchClusterSearch,BatchClusterSearchLUT16andBatchClusterSearchQuantizeQueryall call it (L1072, L1099, L1127). The same clamped values feedselect_kfor probe selection andest_distin the kernels, so probe selection and the distance estimate use the same, valid distance.Question for reviewers: this adds one elementwise pass over
batch_size * n_listsfloats per search batch. The alternative issqrtf(fmaxf(q_g_add, 0.f))in each kernel, assearcher_gpu_quantize_query.cuL117 already does fornorm_quan. That has no extra pass, but every new kernel would have to remember the clamp, and the negative value would still reachselect_kandest_dist. Which do you prefer?