Skip to content

Fix IVF-RaBitQ search for queries on a list centroid - #2802

Open
bdice wants to merge 2 commits into
NVIDIA:mainfrom
bdice:fix-ivf-rabitq-exact-match-search-only
Open

bdice wants to merge 2 commits into
NVIDIA:mainfrom
bdice:fix-ivf-rabitq-exact-match-search-only

Conversation

@bdice

@bdice bdice commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

IVF-RaBitQ computes the probe distance ||q - c||^2 in expanded form (||q||^2 + ||c||^2 - 2 q.c). When a query sits on a list centroid, for example a query equal to a row that is duplicated across a whole list, rounding can make that value slightly negative. The block-sort search kernels take sqrtf of it for the error bound, which gives NaN. The NaN lower bound then fails low_dist < threshold for every vector in that list, so the query's own list contributes no candidates and all k results come from other lists. This affects QUANT4/QUANT8 (the default) at any bits_per_dim, and LUT16/LUT32 with bits_per_dim > 1.

This PR clamps the probe distances to non-negative values after the GEMM. It also adds a regression test where each list holds copies of one row and the queries are those rows. Without the fix, 18 of the 24 cases fail deterministically on H100 and RTX PRO 6000.

See #2800 and #2783.

@copy-pr-bot

copy-pr-bot Bot commented Oct 10, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@bdice

bdice commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test c799bd9

@bdice bdice left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notes for reviewers:

  • Problem: this isn't a CI flake, it's a deterministically incorrect result that seemingly isn't caught by tests. I found it while reviewing workstream G (#2798) of the flaky-test sweep in #2783 (workstream K; the reproducer PR is #2800). When a query sits on a list centroid, for example a query equal to a row that is duplicated across a whole list, IVF-RaBitQ search returns nothing from that list: each such query returned 10 copies of the nearest other distinct row. It is deterministic. In the baseline run (38078120302, unmodified libcuvs), 18 of the 24 new test cases failed in all 20 repeats on both H100 and RTX PRO 6000.
  • Root cause: the probe distance ||q - c||^2 is computed in expanded form, and rounding can make it slightly negative. The block-sort search kernels take sqrtf of it for the error bound, which gives NaN, so low_dist < threshold is false for every vector in that list. This affects QUANT4/QUANT8 at any bits_per_dim, and LUT16/LUT32 with bits_per_dim > 1. The no-ex LUT kernels never compute the error bound, which is why the 6 LUT16/LUT32 cases with bits_per_dim = 1 passed.
  • Fix: clamp the probe distances to >= 0 right after the GEMM. With the fix (run 38079761584), all 24 cases pass in every repeat, and the full NEIGHBORS_ANN_IVF_RABITQ_TEST passes. Mean recall over the 230 existing cases changed from 0.7479 to 0.7482 on RTX PRO 6000 and from 0.7440 to 0.7441 on H100, which is within run-to-run noise (details).
  • Not involved: the max_topk_dist > 0 check in device_functions.cuh only skips a threshold update, which is conservative. Open PRs #2291, #2373, #2377 and #2362 don't fix this.
  • Interactions: this is independent of #2798 and merges cleanly with it; the test is in its own file. #2291 changes the same spot in PrepareClusterSearchInputs. Whichever PR lands second should apply the clamp after #2291's inner-product binaryOp, and only to centroid_distances, not to g_add, which can legitimately be negative for inner product.

Comment on lines +1021 to +1028
// Rounding in the expanded form can make ||q - c||^2 slightly negative when the query is on (or
// very near) a centroid. The search kernels take sqrt(||q - c||^2) for the error bound, which is
// NaN for a negative value, and a NaN lower bound drops every vector of that list, so clamp to 0.
raft::linalg::unaryOp(searcher.get_centroid_distances(),
searcher.get_centroid_distances(),
batch_size * num_centroids,
raft::plug_const_op<float, raft::max_op>{0.f},
searcher_stream.get());

Copy link
Copy Markdown
Contributor Author

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.c onto ||q||^2 + ||c||^2 (L990-L1020). When q == c, cancellation can leave a tiny negative value. The search kernels read that value as q_g_add and take sqrtf(q_g_add) for the error bound: bitwise block sort L56-L58, LUT16 L52-L57, LUT emit-top-k L55. The NaN then propagates into low_dist, and low_dist < threshold is false for every vector in the list (bitwise L92-L96).

I put the clamp here because every search mode goes through this function: BatchClusterSearch, BatchClusterSearchLUT16 and BatchClusterSearchQuantizeQuery all call it (L1072, L1099, L1127). The same clamped values feed select_k for probe selection and est_dist in 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_lists floats per search batch. The alternative is sqrtf(fmaxf(q_g_add, 0.f)) in each kernel, as searcher_gpu_quantize_query.cu L117 already does for norm_quan. That has no extra pass, but every new kernel would have to remember the clamp, and the negative value would still reach select_k and est_dist. Which do you prefer?

Comment on lines +121 to +129
// 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])) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 dim = 64 returned row 24 at 7.8915 (#2783 comment). The !(... <= ...) form also catches a NaN distance.

Comment thread cpp/tests/CMakeLists.txt
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test is a separate translation unit in the existing NEIGHBORS_ANN_IVF_RABITQ_TEST binary. That keeps it out of the hunks #2798 adds to ann_ivf_rabitq.cuh and test_float_int64_t.cu, so the two PRs can merge in either order.

@bdice
bdice marked this pull request as ready for review October 12, 2026 14:52
@bdice
bdice requested review from a team as code owners October 12, 2026 14:52
@coderabbitai

coderabbitai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/cuvs/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 5fb21b80-cb5d-40bf-81c2-2bcc13da691e

📥 Commits

Reviewing files that changed from the base of the PR and between 24b0b17 and c799bd9.


📒 Files selected for processing (3)
  • cpp/src/neighbors/ivf_rabitq/gpu_index/ivf_gpu.cu
  • cpp/tests/CMakeLists.txt
  • cpp/tests/neighbors/ann_ivf_rabitq/test_exact_match.cu

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 6 remain after this review.



📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Improved search reliability by preventing invalid negative distance values from affecting results.
  • Tests
    • Added coverage for exact-match searches across multiple vector sizes, precision settings, and search modes. The tests verify that returned results match the query and have sufficiently low distances.

Walkthrough

The change clamps negative squared query-to-centroid distances to zero. It adds and registers parameterized IVF-RaBitQ exact-match tests across multiple dimensions, bit depths, and search modes.

Changes

IVF-RaBitQ Exact-Match Search

Layer / File(s) Summary
Clamp centroid distances
cpp/src/neighbors/ivf_rabitq/gpu_index/ivf_gpu.cu
PrepareClusterSearchInputs clamps computed squared query-to-centroid distances to zero. The file adds RAFT operator and unary-operation headers.
Add parameterized exact-match coverage
cpp/tests/neighbors/ann_ivf_rabitq/test_exact_match.cu, cpp/tests/CMakeLists.txt
Adds and registers exact-match tests for dimensions 64, 100, and 128; bit depths 1 and 3; and four search modes. The tests check result indices, matching rows, and distance bounds.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: achirkin


Merge Risk: ⚪ Minimal · up to c799b

The centroid-distance clamp and exact-match coverage appear ready to merge after normal checks.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check Passed The description clearly explains the negative probe-distance issue, the clamp fix, and the regression test. It directly matches the changeset.
Title check Passed The title clearly identifies the main change: fixing IVF-RaBitQ search for queries that match a list centroid.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

Full details: Docstring Coverage

Explanation

Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files. (1 skipped: 1 unsupported.)


  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

🧪 Generate unit tests (beta)
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working non-breaking Introduces a non-breaking change

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant