Skip to content

Commit 49c8cec

Browse files
authored
[REVIEW] Improve 1-NN performance with split GEMM/reduction kernels on Blackwell (#1768)
cuVS currently implements 1-nearest neighbor using a fused-kernel approach, where the pairwise-distance GEMM and the subsequent reduction are combined into a single kernel. While this can be efficient, the fused implementation has limitations that prevent it from consistently achieving the best performance. Additionally, the separate-kernels implementation can be used for `half` and `int8` datatypes unlike the fused implementation which is restricted to `float` only. This PR adds a separate-kernel path in which GEMM and reduction run as two distinct kernels. On Blackwell, the separate-kernel approach performs better for certain M, N, and K configurations (see results below). In addition, this PR includes: - A simple heuristic to choose between the fused and separate paths - Unit tests covering both fused and separate execution paths - A benchmark that compares fused vs. separate performance and also reports GEMM-only time for reference **End-to-end benchmarks** I ran the CUVS_IVF_PQ_ANN_BENCH on a dataset with 10 million vectors and with following build parameters ``` "build_param": { "pq_dim": 128, "pq_bits": 8, "nlist": 10000, "niter": 10, "ratio": 100 }, ``` Observed following performance improvements: Fused: <img width="467" height="154" alt="image" src="https://github.com/user-attachments/assets/45b7b6b6-85f9-4a01-a23b-77e1ea0efa71" /> Seperate: <img width="402" height="135" alt="image" src="https://github.com/user-attachments/assets/498ec005-5333-4d25-aa35-f1553ffffe0f" /> **1-NN computing benchmark:** Following table shows the performance of fused and separate computation of 1-NN for various sizes of M, N and K. The GEMM column shows the performance of pure GEMM for comparison. Higher is better here. | M | N | K | Fused TFLOPS | Separate TFLOPS | GEMM TFLOPS | |:-----:|:----:|:---:|:-----:|:--------:|:-----:| | 16384 | 4096 | 128 | 28.28 | 37.14 | 44.53 | | 16384 | 4096 | 64 | 22.04 | 25.86 | 33.66 | | 8192 | 2048 | 128 | 26.43 | 35.33 | 40.96 | | 8192 | 2048 | 64 | 18.57 | 24.77 | 30.54 | Authors: - Vinay Deshpande (https://github.com/vinaydes) - Anupam (https://github.com/aamijar) - Corey J. Nolet (https://github.com/cjnolet) - Tamas Bela Feher (https://github.com/tfeher) Approvers: - Dante Gama Dessavre (https://github.com/dantegd) - Tamas Bela Feher (https://github.com/tfeher) URL: #1768
1 parent afb2b3f commit 49c8cec

7 files changed

Lines changed: 890 additions & 31 deletions

File tree

cpp/src/cluster/detail/kmeans_balanced.cuh

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include <raft/core/operators.hpp>
1717
#include <raft/core/resource/cuda_stream.hpp>
1818
#include <raft/core/resource/device_memory_resource.hpp>
19+
#include <raft/core/resource/device_properties.hpp>
1920
#include <raft/core/resource/thrust_policy.hpp>
2021
#include <raft/linalg/add.cuh>
2122
#include <raft/linalg/gemm.cuh>
@@ -171,22 +172,28 @@ inline std::enable_if_t<std::is_floating_point_v<MathT>> predict_core(
171172
* @return A suggested minibatch size and the expected memory cost per-row (in bytes)
172173
*/
173174
template <typename MathT, typename IdxT>
174-
constexpr auto calc_minibatch_size(IdxT n_clusters,
175-
IdxT n_rows,
176-
IdxT dim,
177-
cuvs::distance::DistanceType metric,
178-
bool needs_conversion) -> std::tuple<IdxT, size_t>
175+
auto calc_minibatch_size(const raft::resources& handle,
176+
IdxT n_clusters,
177+
IdxT n_rows,
178+
IdxT dim,
179+
cuvs::distance::DistanceType metric,
180+
bool needs_conversion) -> std::tuple<IdxT, size_t>
179181
{
180182
n_clusters = std::max<IdxT>(1, n_clusters);
181183

182184
// Estimate memory needs per row (i.e element of the batch).
183185
size_t mem_per_row = 0;
184186
switch (metric) {
185-
// fusedL2NN needs a mutex and a key-value pair for each row.
186187
case distance::DistanceType::L2Expanded:
187188
case distance::DistanceType::L2SqrtExpanded: {
188-
mem_per_row += sizeof(int);
189-
mem_per_row += sizeof(raft::KeyValuePair<IdxT, MathT>);
189+
if (use_fused<MathT, IdxT, IdxT>(handle, n_rows, n_clusters, dim)) {
190+
// fusedL2NN needs a mutex and a key-value pair for each row.
191+
mem_per_row += sizeof(int);
192+
mem_per_row += sizeof(raft::KeyValuePair<IdxT, MathT>);
193+
} else {
194+
// unfused path needs a full GEMM output (distance matrix row).
195+
mem_per_row += sizeof(MathT) * n_clusters;
196+
}
190197
} break;
191198
// Other metrics require storing a distance matrix.
192199
default: {
@@ -377,8 +384,8 @@ void predict(const raft::resources& handle,
377384
raft::common::nvtx::range<cuvs::common::nvtx::domain::cuvs> fun_scope(
378385
"predict(%zu, %u)", static_cast<size_t>(n_rows), n_clusters);
379386
auto mem_res = mr.value_or(raft::resource::get_workspace_resource_ref(handle));
380-
auto [max_minibatch_size, _mem_per_row] =
381-
calc_minibatch_size<MathT>(n_clusters, n_rows, dim, params.metric, std::is_same_v<T, MathT>);
387+
auto [max_minibatch_size, _mem_per_row] = calc_minibatch_size<MathT>(
388+
handle, n_clusters, n_rows, dim, params.metric, std::is_same_v<T, MathT>);
382389
rmm::device_uvector<MathT> cur_dataset(
383390
std::is_same_v<T, MathT> ? 0 : max_minibatch_size * dim, stream, mem_res);
384391
bool need_compute_norm =
@@ -989,8 +996,8 @@ void build_hierarchical(const raft::resources& handle,
989996
// TODO: Remove the explicit managed memory- we shouldn't be creating this on the user's behalf.
990997
rmm::mr::managed_memory_resource managed_memory;
991998
rmm::device_async_resource_ref device_memory = raft::resource::get_workspace_resource_ref(handle);
992-
auto [max_minibatch_size, mem_per_row] =
993-
calc_minibatch_size<MathT>(n_clusters, n_rows, dim, params.metric, std::is_same_v<T, MathT>);
999+
auto [max_minibatch_size, mem_per_row] = calc_minibatch_size<MathT>(
1000+
handle, n_clusters, n_rows, dim, params.metric, std::is_same_v<T, MathT>);
9941001

9951002
// Precompute the L2 norm of the dataset if relevant and not yet computed.
9961003
rmm::device_uvector<MathT> dataset_norm_buf(0, stream, device_memory);

cpp/src/cluster/detail/kmeans_common.cuh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include <raft/core/memory_type.hpp>
2020
#include <raft/core/operators.hpp>
2121
#include <raft/core/resource/cuda_stream.hpp>
22+
#include <raft/core/resource/device_properties.hpp>
2223
#include <raft/core/resource/thrust_policy.hpp>
2324
#include <raft/core/resources.hpp>
2425
#include <raft/linalg/map.cuh>
@@ -56,6 +57,31 @@
5657

5758
namespace cuvs::cluster::kmeans::detail {
5859

60+
/**
61+
* @brief Returns true if the fused distance NN implementation should be used.
62+
*
63+
* On Ampere (SM <= 8.x) always use fused.
64+
* On Hopper (SM 9.x) use fused when m or n >= 4096.
65+
* On Blackwell (SM >= 10.x) use unfused.
66+
*/
67+
template <typename MathT, typename IdxT, typename LabelT>
68+
bool use_fused(const raft::resources& handle, IdxT m, IdxT n, IdxT k)
69+
{
70+
cudaDeviceProp prop;
71+
prop = raft::resource::get_device_properties(handle);
72+
if (prop.major <= 8) {
73+
// Use fused for Ampere or before
74+
return true;
75+
} else if (prop.major == 9 && (m >= 4096 || n >= 4096)) {
76+
// On Hopper if m, n are bigger than 4096, use fused
77+
return true;
78+
} else if (prop.major >= 10) {
79+
// On Blackwell onwards, use unfused
80+
return false;
81+
}
82+
return false;
83+
}
84+
5985
template <typename DataT, typename IndexT>
6086
struct SamplingOp {
6187
DataT* rnd;

cpp/src/cluster/detail/minClusterDistanceCompute.cu

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
#include "../../distance/fused_distance_nn.cuh"
7+
#include "../../distance/unfused_distance_nn.cuh"
78
#include "kmeans_common.cuh"
89

910
#include <raft/matrix/init.cuh>
@@ -50,24 +51,50 @@ void minClusterAndDistanceCompute(
5051
raft::KeyValuePair<IndexT, DataT> initial_value(0, std::numeric_limits<DataT>::max());
5152
raft::matrix::fill(handle, minClusterAndDistance, initial_value);
5253

53-
workspace.resize((sizeof(int)) * n_samples, stream);
54-
55-
cuvs::distance::fusedDistanceNNMinReduce<DataT, raft::KeyValuePair<IndexT, DataT>, IndexT>(
56-
minClusterAndDistance.data_handle(),
57-
X.data_handle(),
58-
centroids.data_handle(),
59-
L2NormX.data_handle(),
60-
centroidsNorm.data_handle(),
61-
n_samples,
62-
n_clusters,
63-
n_features,
64-
(void*)workspace.data(),
65-
metric != cuvs::distance::DistanceType::L2Expanded,
66-
false,
67-
true,
68-
metric,
69-
0.0f,
70-
stream);
54+
bool should_use_fused =
55+
use_fused<DataT, IndexT, IndexT>(handle, n_samples, n_clusters, n_features);
56+
57+
if (should_use_fused) {
58+
workspace.resize((sizeof(int)) * n_samples, stream);
59+
60+
cuvs::distance::fusedDistanceNNMinReduce<DataT, raft::KeyValuePair<IndexT, DataT>, IndexT>(
61+
minClusterAndDistance.data_handle(),
62+
X.data_handle(),
63+
centroids.data_handle(),
64+
L2NormX.data_handle(),
65+
centroidsNorm.data_handle(),
66+
n_samples,
67+
n_clusters,
68+
n_features,
69+
(void*)workspace.data(),
70+
metric != cuvs::distance::DistanceType::L2Expanded,
71+
false,
72+
true,
73+
metric,
74+
0.0f,
75+
stream);
76+
} else {
77+
workspace.resize(sizeof(DataT) * n_samples * n_clusters, stream);
78+
79+
cuvs::distance::
80+
unfusedDistanceNNMinReduce<DataT, DataT, raft::KeyValuePair<IndexT, DataT>, IndexT>(
81+
handle,
82+
minClusterAndDistance.data_handle(),
83+
X.data_handle(),
84+
centroids.data_handle(),
85+
L2NormX.data_handle(),
86+
centroidsNorm.data_handle(),
87+
n_samples,
88+
n_clusters,
89+
n_features,
90+
(void*)workspace.data(),
91+
metric != cuvs::distance::DistanceType::L2Expanded,
92+
false,
93+
true,
94+
metric,
95+
0.0f,
96+
stream);
97+
}
7198
} else {
7299
auto dataBatchSize = getDataBatchSize(batch_samples, n_samples);
73100
auto centroidsBatchSize = getCentroidsBatchSize(batch_centroids, n_clusters);

0 commit comments

Comments
 (0)