Repository navigation
Guard IVF-Flat cosine coarse search against zero-norm centers #2828
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 |
|---|---|---|
|
|
@@ -10,10 +10,13 @@ | |
| #include <raft/core/resource/cuda_stream.hpp> | ||
| #include <raft/linalg/init.cuh> | ||
| #include <raft/matrix/init.cuh> | ||
| #include <raft/random/rng.cuh> | ||
|
|
||
| #include <algorithm> | ||
| #include <numeric> | ||
| #include <optional> | ||
| #include <sstream> | ||
| #include <vector> | ||
|
|
||
| namespace cuvs::neighbors::ivf_flat { | ||
|
|
||
|
|
@@ -48,6 +51,83 @@ TEST(IVFFlatSerialization, StreamRoundTrip) | |
| EXPECT_EQ(loaded.n_lists(), index.n_lists()); | ||
| } | ||
|
|
||
| // k-means gives an empty cluster a zero center. With the cosine metric, the coarse search must | ||
| // still rank such lists instead of producing NaN distances: when fewer than n_probes lists have a | ||
| // finite distance, the NaN columns are never selected and the remaining probe slots are padded | ||
| // with list 0, which then is scanned several times and returns duplicated neighbors. | ||
| TEST(AnnIVFFlatTest, CosineZeroNormCenters) | ||
| { | ||
| raft::resources handle; | ||
| auto stream = raft::resource::get_cuda_stream(handle); | ||
|
|
||
| constexpr int64_t n_rows = 10000; | ||
| constexpr int64_t n_queries = 500; | ||
| constexpr int64_t dim = 8; | ||
| constexpr uint32_t n_lists = 1024; | ||
| constexpr uint32_t n_zero_lists = 1000; | ||
| constexpr uint32_t n_probes = 40; | ||
| constexpr uint32_t k = 16; | ||
|
|
||
| auto data = raft::make_device_matrix<float, int64_t>(handle, n_rows, dim); | ||
| auto queries = raft::make_device_matrix<float, int64_t>(handle, n_queries, dim); | ||
| raft::random::RngState rng(1234ULL); | ||
| raft::random::uniform(handle, rng, data.data_handle(), data.size(), 0.1f, 2.0f); | ||
| raft::random::uniform(handle, rng, queries.data_handle(), queries.size(), 0.1f, 2.0f); | ||
|
|
||
| index_params params; | ||
| params.n_lists = n_lists; | ||
| params.metric = cuvs::distance::DistanceType::CosineExpanded; | ||
| params.add_data_on_build = false; | ||
| auto idx = build(handle, params, raft::make_const_mdspan(data.view())); | ||
|
|
||
| // Zero the centers of lists 1..n_zero_lists, as if k-means had left these clusters empty. Fewer | ||
| // lists than n_probes keep a non-zero center, and list 0 is one of them. | ||
| std::vector<float> centers_h(idx.centers().size()); | ||
| raft::update_host(centers_h.data(), idx.centers().data_handle(), centers_h.size(), stream); | ||
| raft::resource::sync_stream(handle); | ||
| std::fill(centers_h.begin() + dim, centers_h.begin() + (n_zero_lists + 1) * dim, 0.0f); | ||
| raft::update_device(idx.centers().data_handle(), centers_h.data(), centers_h.size(), stream); | ||
| extend(handle, raft::make_const_mdspan(data.view()), std::nullopt, &idx); | ||
|
Comment on lines
+83
to
+90
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 test builds the bad case directly instead of relying on k-means to leave lists empty. It builds with Question for reviewers: editing |
||
|
|
||
| // All data rows are in the lists with a non-zero center, and all of those lists are probed. | ||
| search_params sparams; | ||
| sparams.n_probes = n_probes; | ||
| auto neighbors = raft::make_device_matrix<int64_t, int64_t>(handle, n_queries, k); | ||
| auto distances = raft::make_device_matrix<float, int64_t>(handle, n_queries, k); | ||
| search(handle, | ||
| sparams, | ||
| idx, | ||
| raft::make_const_mdspan(queries.view()), | ||
| neighbors.view(), | ||
| distances.view()); | ||
|
|
||
| auto naive_neighbors = raft::make_device_matrix<int64_t, int64_t>(handle, n_queries, k); | ||
| auto naive_distances = raft::make_device_matrix<float, int64_t>(handle, n_queries, k); | ||
| cuvs::neighbors::naive_knn<float, float, int64_t>(handle, | ||
| naive_distances.data_handle(), | ||
| naive_neighbors.data_handle(), | ||
| queries.data_handle(), | ||
| data.data_handle(), | ||
| n_queries, | ||
| n_rows, | ||
| dim, | ||
| k, | ||
| params.metric); | ||
|
|
||
| std::vector<int64_t> neighbors_h(n_queries * k), naive_neighbors_h(n_queries * k); | ||
| std::vector<float> distances_h(n_queries * k), naive_distances_h(n_queries * k); | ||
| raft::update_host(neighbors_h.data(), neighbors.data_handle(), neighbors_h.size(), stream); | ||
| raft::update_host(distances_h.data(), distances.data_handle(), distances_h.size(), stream); | ||
| raft::update_host( | ||
| naive_neighbors_h.data(), naive_neighbors.data_handle(), naive_neighbors_h.size(), stream); | ||
| raft::update_host( | ||
| naive_distances_h.data(), naive_distances.data_handle(), naive_distances_h.size(), stream); | ||
| raft::resource::sync_stream(handle); | ||
|
|
||
| EXPECT_TRUE(eval_neighbours( | ||
| naive_neighbors_h, neighbors_h, naive_distances_h, distances_h, n_queries, k, 0.001, 0.99)); | ||
| } | ||
|
|
||
| TEST(AnnIVFFlatTest, RepeatedExtendCopyPreservesSharedListWithinCapacity) | ||
| { | ||
| raft::resources handle; | ||
|
|
||
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.
For cosine, the GEMM runs with
alpha = -1(L130-L162), sodistis-q.c, and this map turns it into-cos(q, c), whichselect_kminimizes. A list whose cluster ended up empty has a zero center, so its norm is 0 (center norms are computed inextend,ivf_flat_build.cuhL339-L355), anddist / denombecomes0 / 0 = NaN.select_knever picks a NaN column. When fewer thann_probescolumns are finite, the remaining probe slots get index 0, so list 0 is scanned several times.Returning 0 here means similarity 0, i.e. cosine distance 1. A zero-norm center then ranks after every center with positive similarity, so it is only probed when there are not enough better lists. This matches IVF-SQ's coarse search (
ivf_sq_search.cuhL460-L474) and the convention in the cuTile 1-NN kernel from #2552 (fused_1nn_kernel.pyL132-L137).The guard also changes the zero-query case: every list now gets 0, so the probes are a
select_ktie-break. Before, every list was NaN, so all probe slots were list 0.