Skip to content

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

Open
maxwbuckley wants to merge 2 commits into
NVIDIA:mainfrom
maxwbuckley:sm120/nn-descent-fp32-l2-expression
Open

maxwbuckley wants to merge 2 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: 20cf9bd9-f5b4-4ce0-878a-0404fdde767a
📥 Commits

Reviewing files that changed from the base of the PR and between efc7d30 and 9b6405d.

📒 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 L2 distance calculations by keeping an operation in single precision, which may reduce unintended precision differences in distance results.

Walkthrough

The expanded L2 distance calculation changes its multiplier literal from 2.0 to 2.0f, keeping the multiplication in single precision.

Changes

L2 distance calculation

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

Priority: ➖ Normal

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 9b640

The change keeps the expanded L2 calculation in single precision, with possible rounding differences. No additional changed files or concrete regressions are identified, so no material merge risk remains.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: calculating the expanded L2 distance in fp32 instead of fp64.
Description check ✅ Passed The description explains the fp64 promotion issue, the code change, and its reported performance and accuracy results.
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.

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.

2 participants