Skip to content

Compute the expanded L2 distance in fp32 instead of fp64 - #2531

Merged
rapids-bot[bot] merged 4 commits into
NVIDIA:mainfrom
maxwbuckley:sm120/nn-descent-fp32-l2-expression
Oct 12, 2026
Merged

rapids-bot[bot] merged 4 commits into
NVIDIA:mainfrom
maxwbuckley:sm120/nn-descent-fp32-l2-expression

Conversation

@maxwbuckley

Copy link
Copy Markdown
Contributor

Problem

In calculate_metric (cpp/src/neighbors/detail/nn_descent.cuh), the L2Expanded / L2SqrtExpanded branch computes

s_distances[i] = l2_norms[..] + l2_norms[..] - 2.0 * s_distances[i];

2.0 is a double literal, so the whole expression is promoted to fp64: both l2_norms loads and s_distances[i] are widened, the arithmetic runs on the fp64 pipe, and the result is narrowed back on assignment. This executes once per Gram matrix element, twice per CTA, for every row of the dataset on every NN-Descent iteration.

Consumer GPUs run fp64 at a small fraction of their fp32 rate (1/64 on GB202), so this ends up dominating local_join_kernel_wmma on those parts. Counting fp64 opcodes (DADD/DMUL/DFMA/DSETP/F2D/D2F) in the SASS for this kernel:

fp64 instructions
before 18
after 0

Measurements

RTX 5090 (sm_120a), CUDA 13.2, SIFT1M (1M × 128), graph_degree 64, intermediate_graph_degree 128, 20 iterations, clocks locked. local_join time from nsys, build wall clock is the best of three repeats.

before after speedup
local_join_kernel_wmma (20 iters) 1578.0 ms 1412.0 ms 1.118x
nn_descent::build wall clock 3627.9 ms 3452.9 ms 1.051x

Baseline reproducibility across three separate builds of unmodified main: 1577.99 / 1578.21 / 1577.91 ms (0.02% spread).

The end-to-end figure is smaller than the kernel figure because local_join is ~84% of GPU kernel time but only ~44% of build wall clock; the rest is host-side work.

Accuracy

The fp64 intermediate was not buying accuracy. Both operands already carry fp16-level error — s_distances[i] comes out of an fp16 wmma, and l2_norms is computed in fp32 from fp16 data — so evaluating the final subtraction in fp64 cannot recover precision that was lost upstream.

  • recall@10 on SIFT1M: 0.9992–1.0000, versus 0.9996–1.0000 for unmodified main across repeats. The spread is run-to-run nondeterminism in graph construction, not an effect of this change: an unrelated control patch that cannot alter any computed value shows the same variation.
  • A standalone harness driving this kernel directly produces a bit-identical graph at d=96, d=128 and d=960.
  • The negative-distance clamp immediately below ([BUG] NN Descent resulting in negative and NaN distances #991) is untouched and still behaves the same way.

Scope

This is not architecture specific — any part with a low fp64:fp32 ratio pays it, so sm_80/sm_90 should benefit too, by an amount that scales with their fp64 ratio. I only have Blackwell consumer hardware to measure on, so I have not quantified it elsewhere.

🤖 Generated with Claude Code

In calculate_metric the L2Expanded / L2SqrtExpanded branch reads

  s_distances[i] = l2_norms[..] + l2_norms[..] - 2.0 * s_distances[i];

`2.0` is a double literal, so the whole expression is promoted to fp64:
both l2_norms loads and s_distances[i] are widened, the arithmetic runs
on the fp64 pipe, and the result is narrowed back on assignment. This
happens once per Gram matrix element, twice per CTA, for every row of
the dataset on every NN-Descent iteration.

Consumer GPUs run fp64 at a small fraction of their fp32 rate (1/64 on
GB202), so this dominates local_join_kernel_wmma on those parts.
Counting fp64 opcodes (DADD/DMUL/DFMA/DSETP/F2D/D2F) in the SASS for
this kernel gives 18 before the change and 0 after.

Measured on an RTX 5090 (sm_120a), SIFT1M, 1M x 128, graph_degree 64,
intermediate_graph_degree 128, 20 iterations, clocks locked:

  local_join_kernel_wmma   1578.0 ms -> 1412.0 ms   (1.118x)
  nn_descent::build         3627.9 ms -> 3452.9 ms   (1.051x)

The fp64 intermediate was not buying accuracy. Both operands already
carry fp16-level error: s_distances[i] comes out of an fp16 wmma, and
l2_norms is computed in fp32 from fp16 data, so evaluating the final
subtraction in fp64 cannot recover precision lost upstream. recall@10
is unchanged at 0.9992-1.0000, which is the run-to-run spread of the
unmodified build, and a standalone harness produces a bit-identical
graph at d=96, d=128 and d=960. The negative-distance clamp below
(issue NVIDIA#991) is untouched.

This is not architecture specific; any part with a low fp64:fp32 ratio
pays it.
@maxwbuckley
maxwbuckley requested a review from a team as a code owner August 31, 2026 11:45
@copy-pr-bot

copy-pr-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener thank you :)

@lowener lowener added improvement Improves an existing functionality non-breaking Introduces a non-breaking change C++ labels Aug 31, 2026
@lowener

lowener commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

/ok to test 36d5e5d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener merged main in (9b6405d) — the branch was 101 commits behind, which is what Recently Updated was flagging. The merge was clean and the net diff is unchanged (the one line in nn_descent.cuh).

Locally (RTX 5090, sm_120, CUDA 13.3 env from conda/environments/all_cuda-133_arch-x86_64.yaml) I did a full C++/C build and ran the complete ctest suite on both this branch and unmodified main (efc7d30): the same 58/60 suites pass on each, including NEIGHBORS_ANN_NN_DESCENT_TEST, NEIGHBORS_ALL_NEIGHBORS_TEST and the CAGRA suites. The two that fail here (NEIGHBORS_ANN_IVF_PQ_TEST and cuvs_c_verify_install_headers) fail identically on main on this machine.

Could you /ok to test the new head when you get a chance? Thank you!

@coderabbitai

coderabbitai Bot commented Oct 6, 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: 0dbb9b09-eb7e-47b8-b92c-5d46b988c205

📥 Commits

Reviewing files that changed from the base of the PR and between ae75db9 and 2b4015e.


📒 Files selected for processing (1)
  • cpp/src/neighbors/detail/nn_descent.cuh

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



📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Improved numerical consistency in expanded L2 distance calculations by keeping the relevant computation in single precision, which may reduce unintended precision differences in distance results. Negative computed distances continue to be clamped to zero, and the optional square-root behavior remains unchanged. No other changes to distance calculation behavior are included in this update.

Walkthrough

The expanded L2 distance calculation changes its multiplier literal from 2.0 to 2.0f. The clamping and optional square-root path remain unchanged.

Changes

L2 distance calculation

Layer / File(s) Summary
L2 multiplier literal
cpp/src/neighbors/detail/nn_descent.cuh
The expanded L2 distance expression now uses 2.0f instead of 2.0.

Priority: ➖ Normal

Estimated code review effort: 1 (Trivial) | ~2 minutes

Change: Refactor

Suggested reviewers: lowener


Merge Risk: ⚪ Minimal · up to 2b401

No actionable merge-blocking risk is evident in the described single-precision multiplier change. The PR is ready to merge after normal checks.

Pre-merge checks | Passed 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: computing the expanded L2 distance in fp32 instead of fp64.
Description check Passed The description is directly related to the code change and provides relevant implementation details, performance measurements, accuracy results, testing information, and scope.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
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.

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

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

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

@maxwbuckley

Copy link
Copy Markdown
Contributor Author

@lowener I merged main in again (ae75db9) because Recently Updated was flagging the branch as 21 commits behind. The merge was clean and the net diff is still the one line in nn_descent.cuh; #2754 touched the same file, but in a different area.

I rebuilt and ran the complete ctest suite on the new head (RTX 5090, sm_120, CUDA 13.3 env). 58 of 60 suites pass, including NEIGHBORS_ANN_NN_DESCENT_TEST, NEIGHBORS_ALL_NEIGHBORS_TEST and all the CAGRA suites. The same two fail as on unmodified main on this machine: cuvs_c_verify_install_headers (a header-path issue in my local setup) and NEIGHBORS_ANN_IVF_PQ_TEST, which aborts at the same case, build_host_input_search/20, on main.

This is approved but CI has never run on a current head. Could you /ok to test ae75db9? Thank you!

@aamijar

aamijar commented Oct 12, 2026

Copy link
Copy Markdown
Member

/ok to test 2b4015e

@aamijar

aamijar commented Oct 12, 2026

Copy link
Copy Markdown
Member

/merge

@rapids-bot
rapids-bot Bot merged commit 18f71e1 into NVIDIA:main Oct 12, 2026
118 of 119 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C++ improvement Improves an existing functionality non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants