Repository navigation
Make the L2 expanded self-distance clamp relative to the norms #2794
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
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 |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: Copyright (c) 2023, NVIDIA CORPORATION. | ||
| * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
|
|
@@ -28,6 +28,23 @@ __device__ constexpr AccT get_clamp_precision() | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether an expanded L2 distance is round-off error of a self-distance and should be clamped to | ||
| * zero. Self-neighboring points have (aNorm == bNorm) == accVal, but the dot product (accVal) can | ||
| * have round-off errors, which will cause (aNorm == bNorm) ~ accVal instead. | ||
| * | ||
| * The round-off error scales with the norms, so the tolerance is relative to them for squared | ||
| * norms below one. Otherwise, distinct points with equal norms and a small magnitude (for example, | ||
| * a spectral embedding) would have their distances incorrectly clamped to zero. | ||
| */ | ||
| template <typename DataT, typename AccT> | ||
| DI bool is_self_distance_round_off(AccT dist, AccT aNorm, AccT bNorm) | ||
| { | ||
| const AccT norm_product = aNorm * bNorm; | ||
| const AccT norm_scale = norm_product < AccT(1) ? norm_product : AccT(1); | ||
| return (aNorm == bNorm) && (dist * dist < get_clamp_precision<DataT, AccT>() * norm_scale); | ||
| } | ||
|
Comment on lines
+40
to
+46
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. Before this change, the clamp fired when With equal norms Question for reviewers: is a tolerance relative to |
||
|
|
||
| // Epilogue operator for CUTLASS based kernel | ||
| template <typename DataT, typename AccT> | ||
| struct l2_exp_cutlass_op { | ||
|
|
@@ -38,13 +55,7 @@ struct l2_exp_cutlass_op { | |
| inline __device__ AccT operator()(AccT aNorm, AccT bNorm, AccT accVal) const noexcept | ||
| { | ||
| AccT outVal = aNorm + bNorm - AccT(2.0) * accVal; | ||
|
|
||
| /** | ||
| * Self-neighboring points should have (aNorm == bNorm) == accVal and the dot product (accVal) | ||
| * can sometimes have round-off errors, which will cause (aNorm == bNorm) ~ accVal instead. | ||
| */ | ||
| outVal = | ||
| outVal * AccT(!((outVal * outVal < get_clamp_precision<DataT, AccT>()) * (aNorm == bNorm))); | ||
| outVal = outVal * AccT(!is_self_distance_round_off<DataT, AccT>(outVal, aNorm, bNorm)); | ||
|
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. This is the epilogue of the CUTLASS fused 1-NN kernel (fused_l2_nn.cuh). The legacy backend choice picks that kernel on cc 8.x (
Brute-force kNN also applies this op directly, as a separate epilogue after the GEMM (knn_brute_force.cuh). |
||
| return sqrt ? raft::sqrt(outVal * static_cast<AccT>(outVal > AccT(0))) : outVal; | ||
| } | ||
|
|
||
|
|
@@ -112,15 +123,9 @@ struct l2_exp_distance_op { | |
| for (int j = 0; j < Policy::AccColsPerTh; ++j) { | ||
| AccT accVal = acc[i][j]; | ||
| AccT val = regxn[i] + regyn[j] - (AccT)2.0 * accVal; | ||
|
|
||
| /** | ||
| * Self-neighboring points should have (aNorm == bNorm) == accVal and the dot product | ||
| * (accVal) can sometimes have round-off errors, which will cause (aNorm == bNorm) ~ accVal | ||
| * instead. | ||
| */ | ||
| acc[i][j] = val * static_cast<AccT>((val > AccT(0))) * | ||
| static_cast<AccT>( | ||
| !((val * val < get_clamp_precision<DataT, AccT>()) * (regxn[i] == regyn[j]))); | ||
| acc[i][j] = | ||
| val * static_cast<AccT>((val > AccT(0))) * | ||
| static_cast<AccT>(!is_self_distance_round_off<DataT, AccT>(val, regxn[i], regyn[j])); | ||
|
Comment on lines
+126
to
+128
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 same change in the SIMT epilogue. That epilogue is used by Question for reviewers: the helper adds one multiply, one compare/select and one multiply per output element, in both epilogues. The PR has no benchmark numbers for this. Do you expect that to be measurable next to the GEMM main loop? If this is a concern, can someone run the pairwise-distance and fused 1-NN benchmarks (for example on A100, where the CUTLASS path is used) before merge? |
||
| } | ||
| } | ||
| if (sqrt) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,17 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: Copyright (c) 2018-2024, NVIDIA CORPORATION. | ||
| * SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| #include "../test_utils.cuh" | ||
| #include "distance_base.cuh" | ||
|
|
||
| #include <raft/core/device_mdarray.hpp> | ||
| #include <raft/util/cudart_utils.hpp> | ||
|
|
||
| #include <cmath> | ||
| #include <vector> | ||
|
|
||
| namespace cuvs { | ||
| namespace distance { | ||
|
|
||
|
|
@@ -162,5 +168,46 @@ INSTANTIATE_TEST_CASE_P(DistanceTests, DistanceEucExpTestD, ::testing::ValuesIn( | |
|
|
||
| class BigMatrixEucExp : public BigMatrixDistanceTest<cuvs::distance::DistanceType::L2Expanded> {}; | ||
| TEST_F(BigMatrixEucExp, Result) {} | ||
|
|
||
| // Distinct points with equal norms must keep their (small) distances. The clamp of self-distance | ||
| // round-off to zero used to apply an absolute tolerance, which collapsed such points when the data | ||
| // has a small magnitude (e.g. a spectral embedding). | ||
| template <typename T> | ||
| void test_small_scale_equal_norms(T scale) | ||
|
Comment on lines
+172
to
+176
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. This is the minimal reproducer from #2783. |
||
| { | ||
| raft::resources handle; | ||
| auto stream = raft::resource::get_cuda_stream(handle); | ||
| constexpr int64_t n = 8; | ||
| std::vector<T> h_x(n * n, T{0}); | ||
| for (int64_t i = 0; i < n; i++) { | ||
| h_x[i * n + i] = scale; | ||
| } | ||
| auto x = raft::make_device_matrix<T, int64_t>(handle, n, n); | ||
| auto d = raft::make_device_matrix<T, int64_t>(handle, n, n); | ||
| raft::update_device(x.data_handle(), h_x.data(), h_x.size(), stream); | ||
|
|
||
| for (auto metric : {DistanceType::L2Expanded, DistanceType::L2SqrtExpanded}) { | ||
| cuvs::distance::pairwise_distance(handle, | ||
| raft::make_const_mdspan(x.view()), | ||
| raft::make_const_mdspan(x.view()), | ||
| d.view(), | ||
| metric); | ||
| std::vector<T> h_d(n * n); | ||
| raft::update_host(h_d.data(), d.data_handle(), h_d.size(), stream); | ||
| raft::resource::sync_stream(handle, stream); | ||
|
|
||
| const T expected = | ||
| metric == DistanceType::L2Expanded ? T(2) * scale * scale : std::sqrt(T(2)) * scale; | ||
| for (int64_t i = 0; i < n; i++) { | ||
| for (int64_t j = 0; j < n; j++) { | ||
| EXPECT_NEAR(h_d[i * n + j], i == j ? T(0) : expected, T(1e-3) * expected) | ||
| << "metric=" << static_cast<int>(metric) << " i=" << i << " j=" << j; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| TEST(DistanceEucExpSmallScale, EqualNormsF) { test_small_scale_equal_norms<float>(1e-2f); } | ||
| TEST(DistanceEucExpSmallScale, EqualNormsD) { test_small_scale_equal_norms<double>(1e-5); } | ||
| } // end namespace distance | ||
| } // namespace cuvs | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,8 +16,10 @@ | |
| #include <raft/linalg/norm.cuh> | ||
| #include <raft/linalg/unary_op.cuh> | ||
| #include <raft/matrix/init.cuh> | ||
| #include <raft/util/cudart_utils.hpp> | ||
|
|
||
| #include <limits> | ||
| #include <vector> | ||
|
|
||
| namespace cuvs::neighbors { | ||
|
|
||
|
|
@@ -446,6 +448,77 @@ TEST(Top1nnPlan, RejectsMismatchedLaunch) | |
| plan))); | ||
| } | ||
|
|
||
| // Distinct points with equal norms and a small magnitude (as in a spectral embedding) must not be | ||
| // treated as self-neighbors when finding the nearest neighbor. | ||
| TEST(Top1nnEqualNorms, SmallScale) | ||
|
Comment on lines
+451
to
+453
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. This is the shape of the spectral failure at the |
||
| { | ||
| using Backend = cuvs::distance::detail::Top1nnBackend; | ||
| using Kvp = raft::KeyValuePair<int, float>; | ||
| raft::resources handle; | ||
| auto stream = raft::resource::get_cuda_stream(handle); | ||
| constexpr int m = 1000; | ||
| constexpr int n = 5; | ||
| constexpr int k = 5; | ||
| constexpr float s = 1e-2f; | ||
| std::vector<float> h_x(m * k, 0.0f); | ||
| std::vector<float> h_y(n * k, 0.0f); | ||
| for (int i = 0; i < m; i++) { | ||
| h_x[i * k + i % n] = s; | ||
| } | ||
| for (int j = 0; j < n; j++) { | ||
| h_y[j * k + j] = s; | ||
| } | ||
| auto x = raft::make_device_matrix<float, int>(handle, m, k); | ||
| auto y = raft::make_device_matrix<float, int>(handle, n, k); | ||
| auto x_norm = raft::make_device_vector<float, int>(handle, m); | ||
| auto y_norm = raft::make_device_vector<float, int>(handle, n); | ||
| auto output = raft::make_device_vector<Kvp, int>(handle, m); | ||
| raft::update_device(x.data_handle(), h_x.data(), h_x.size(), stream.get()); | ||
| raft::update_device(y.data_handle(), h_y.data(), h_y.size(), stream.get()); | ||
| raft::linalg::rowNorm<raft::linalg::L2Norm, true>( | ||
| x_norm.data_handle(), x.data_handle(), k, m, stream.get()); | ||
| raft::linalg::rowNorm<raft::linalg::L2Norm, true>( | ||
| y_norm.data_handle(), y.data_handle(), k, n, stream.get()); | ||
|
|
||
| cuvs::distance::detail::Top1nnTuning tuning{}; | ||
| for (auto backend : {Backend::Cutlass, Backend::Unfused}) { | ||
| if (!cuvs::distance::detail::is_top_1_nn_backend_available( | ||
| backend, x.data_handle(), y.data_handle(), m, n, k, DistanceType::L2Expanded)) { | ||
| continue; | ||
| } | ||
| const auto plan = cuvs::distance::probe_top_1_nn( | ||
| handle, x.data_handle(), y.data_handle(), m, n, k, tuning, DistanceType::L2Expanded, backend); | ||
| ASSERT_TRUE(plan.available); | ||
| auto workspace = raft::make_device_vector<char, int>(handle, plan.workspace_bytes); | ||
| cuvs::distance::top_1_nn<float, int>(handle, | ||
| output.data_handle(), | ||
| x.data_handle(), | ||
| y.data_handle(), | ||
| x_norm.data_handle(), | ||
| y_norm.data_handle(), | ||
| m, | ||
| n, | ||
| k, | ||
| tuning, | ||
| workspace.data_handle(), | ||
| workspace.size(), | ||
| false, | ||
| true, | ||
| true, | ||
| DistanceType::L2Expanded, | ||
| 0.0f, | ||
| plan); | ||
| std::vector<Kvp> h_out(m); | ||
| raft::update_host(h_out.data(), output.data_handle(), m, stream.get()); | ||
| raft::resource::sync_stream(handle, stream); | ||
| int n_wrong = 0; | ||
| for (int i = 0; i < m; i++) { | ||
| n_wrong += h_out[i].key != i % n; | ||
| } | ||
| EXPECT_EQ(n_wrong, 0) << "backend=" << static_cast<int>(backend); | ||
| } | ||
| } | ||
|
|
||
| TEST(Top1nnPlan, ExplicitUnavailableBackendIsStrict) | ||
| { | ||
| 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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve the self-distance clamp when norm products underflow.
For float inputs near
1e-12, a squared norm can be near1e-24. Squaring that norm innorm_productunderflows to zero. If a self-distance has a small positive round-off error,dist * distcan also underflow to zero. The comparison becomes0 < 0, so neither epilogue clamps that self-distance. Compare the magnitude ofdistagainst a tolerance scaled directly by the norm, without squaring either value. (github.com)🤖 Prompt for AI Agents