Skip to content

Commit 82f0c30

Browse files
authored
Fast CAGRA Index Merge (#2352)
This PR implements the Fastener graph merge operation. This PR supports merging for `float`, `half`, `int8`, and `uint8` dtypes, and euclidean distances. There was originally specialization to use int8 GEMM for the integer types, but I ran into portability issues on Ada and it turns out that using the same f32 path for both is simpler and not substantially slower. Currently investigating switching the unified path to TF32 to use tensor cores. The core logic resides in `cagra_merge_scaffold.cuh`. This PR adds 7.64 MiB to `libcuvs.so`, a 2.94% increase. <img width="2012" height="866" alt="image" src="https://github.com/user-attachments/assets/06249176-042b-42f2-b5ff-366ac316dd1e" /> (H100) <img width="2310" height="1408" alt="image" src="https://github.com/user-attachments/assets/1b801196-a451-4c77-beab-862c6f1222f7" /> <img width="1024" height="640" alt="image" src="https://github.com/user-attachments/assets/6f320c19-c47b-4564-8781-8872674274bc" /> <img width="1024" height="640" alt="image" src="https://github.com/user-attachments/assets/e197b2a5-4534-403b-9682-5098fcef9c70" /> <img width="1024" height="640" alt="image" src="https://github.com/user-attachments/assets/7818f9d4-174b-4646-8dfc-8529a21e2691" /> <img width="1024" height="640" alt="image" src="https://github.com/user-attachments/assets/43971772-8a1a-4c65-9bcb-606d4c5bb21b" /> Authors: - Ben Landrum (https://github.com/landrumb) Approvers: - Dante Gama Dessavre (https://github.com/dantegd) - Artem M. Chirkin (https://github.com/achirkin) URL: #2352
1 parent 4c720a2 commit 82f0c30

19 files changed

Lines changed: 4221 additions & 40 deletions

File tree

‎c/include/cuvs/neighbors/cagra.h‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,27 @@ struct cuvsCagraIndexParams {
223223

224224
typedef struct cuvsCagraIndexParams* cuvsCagraIndexParams_t;
225225

226+
/** Algorithm used to merge physical CAGRA indices. */
227+
enum cuvsCagraMergeAlgo {
228+
CUVS_CAGRA_MERGE_AUTO = 0,
229+
CUVS_CAGRA_MERGE_FASTENER = 1,
230+
CUVS_CAGRA_MERGE_REBUILD = 2
231+
};
232+
233+
/** Parameters controlling how physical CAGRA indices are merged. */
234+
struct cuvsCagraMergeParams {
235+
enum cuvsCagraMergeAlgo algo;
236+
uint32_t levels;
237+
uint32_t root_fanout;
238+
uint32_t lower_fanout;
239+
double leader_fraction;
240+
uint32_t max_leaders;
241+
uint32_t leaf_size;
242+
uint32_t leaf_degree;
243+
};
244+
245+
typedef struct cuvsCagraMergeParams* cuvsCagraMergeParams_t;
246+
226247
/**
227248
* @brief Allocate CAGRA Index params, and populate with default values
228249
*
@@ -239,6 +260,12 @@ CUVS_EXPORT cuvsError_t cuvsCagraIndexParamsCreate(cuvsCagraIndexParams_t* param
239260
*/
240261
CUVS_EXPORT cuvsError_t cuvsCagraIndexParamsDestroy(cuvsCagraIndexParams_t params);
241262

263+
/** Allocate CAGRA merge params and populate them with AUTO defaults. */
264+
CUVS_EXPORT cuvsError_t cuvsCagraMergeParamsCreate(cuvsCagraMergeParams_t* params);
265+
266+
/** De-allocate CAGRA merge params. */
267+
CUVS_EXPORT cuvsError_t cuvsCagraMergeParamsDestroy(cuvsCagraMergeParams_t params);
268+
242269
/**
243270
* @brief Allocate CAGRA Compression params, and populate with default values
244271
*
@@ -967,7 +994,7 @@ CUVS_EXPORT cuvsError_t cuvsCagraIndexFromArgs(cuvsResources_t res,
967994
*
968995
* All input indices must have been built with the same data type (`index.dtype`) and
969996
* have the same dimensionality (`index.dims`). The merged index uses the output
970-
* parameters specified in `cuvsCagraIndexParams`.
997+
* parameters specified in `cuvsCagraIndexParams`. The merge algorithm is selected automatically.
971998
*
972999
* Input indices must have:
9731000
* - `index.dtype.code` and `index.dtype.bits` matching across all indices.
@@ -1013,7 +1040,7 @@ CUVS_EXPORT cuvsError_t cuvsCagraIndexFromArgs(cuvsResources_t res,
10131040
* @endcode
10141041
*
10151042
* @param[in] res cuvsResources_t opaque C handle
1016-
* @param[in] params cuvsCagraIndexParams_t parameters controlling merge behavior
1043+
* @param[in] params cuvsCagraIndexParams_t parameters for the output index
10171044
* @param[in] indices Array of input cuvsCagraIndex_t handles to merge
10181045
* @param[in] num_indices Number of input indices
10191046
* @param[in] filter Filter that can be used to filter out vectors from the merged index
@@ -1034,6 +1061,34 @@ CUVS_EXPORT cuvsError_t cuvsCagraMerge(cuvsResources_t res,
10341061
cuvsDataset_t merged_dataset,
10351062
cuvsCagraIndex_t output_index);
10361063

1064+
/**
1065+
* @brief Merge multiple CAGRA indices with explicit merge parameters.
1066+
*
1067+
* @param[in] res cuvsResources_t opaque C handle
1068+
* @param[in] params cuvsCagraIndexParams_t parameters for the output index
1069+
* @param[in] merge_params cuvsCagraMergeParams_t parameters controlling the merge algorithm, or
1070+
* NULL to use AUTO defaults
1071+
* @param[in] indices Array of input cuvsCagraIndex_t handles to merge
1072+
* @param[in] num_indices Number of input indices
1073+
* @param[in] filter Filter that can be used to filter out vectors from the merged index
1074+
* @param[out] merged_dataset Empty owning dataset handle. Merge first attempts to allocate and
1075+
* populate device storage with the same layout as the input indices. For
1076+
* an unfiltered merge, AUTO and REBUILD can fall back to host storage if
1077+
* device allocation fails; explicit FASTENER reports the allocation
1078+
* failure instead. Keep this dataset alive while using `output_index`.
1079+
* A host-backed output index must be updated with
1080+
* `cuvsCagraUpdateDataset` before device search.
1081+
* @param[out] output_index Output handle initialized with `cuvsCagraIndexCreate`
1082+
*/
1083+
CUVS_EXPORT cuvsError_t cuvsCagraMergeWithParams(cuvsResources_t res,
1084+
cuvsCagraIndexParams_t params,
1085+
cuvsCagraMergeParams_t merge_params,
1086+
cuvsCagraIndex_t* indices,
1087+
size_t num_indices,
1088+
cuvsFilter filter,
1089+
cuvsDataset_t merged_dataset,
1090+
cuvsCagraIndex_t output_index);
1091+
10371092
/**
10381093
* @}
10391094
*/

‎c/src/neighbors/cagra.cpp‎

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ static void merge_indices_for_layout(
122122
cuvs::neighbors::cagra::index_params const& params_cpp,
123123
std::vector<cuvs::neighbors::cagra::index<T, uint32_t, DatasetViewT>*>& index_ptrs,
124124
cuvsFilter filter,
125+
cuvs::neighbors::cagra::merge_params const& merge_params,
125126
cuvsDataset_t merged_dataset,
126127
cuvsCagraIndex_t output_index)
127128
{
@@ -151,7 +152,8 @@ static void merge_indices_for_layout(
151152
auto owner = std::make_unique<owner_t>(std::move(matrix), dim);
152153
auto view = owner->as_dataset_view();
153154
auto merged_idx =
154-
cuvs::neighbors::cagra::merge(*res_ptr, params_cpp, index_ptrs, view, row_filter);
155+
cuvs::neighbors::cagra::merge(
156+
*res_ptr, params_cpp, index_ptrs, view, merge_params, row_filter);
155157
auto* holder =
156158
new cuvs_cagra_c_api_index_lifetime_holder<T, DatasetViewT>{std::move(merged_idx)};
157159
bind_index_lifetime_holder_to_C_index<T, DatasetViewT>(
@@ -164,7 +166,10 @@ static void merge_indices_for_layout(
164166
merged_dataset->layout = output_layout;
165167
merged_dataset->is_owning = true;
166168
return;
167-
} catch (std::bad_alloc const&) {
169+
} catch (std::bad_alloc const& failure) {
170+
if (merge_params.algo == cuvs::neighbors::cagra::merge_algo::FASTENER) {
171+
RAFT_FAIL("FASTENER cagra::merge could not allocate device memory: %s", failure.what());
172+
}
168173
// Filtered merge gathers rows with device-only primitives, matching the restriction on the
169174
// legacy host fallback.
170175
RAFT_EXPECTS(filter.type == NO_FILTER,
@@ -1132,6 +1137,7 @@ void _merge(cuvsResources_t res,
11321137
cuvsCagraIndex_t* indices,
11331138
size_t num_indices,
11341139
cuvsFilter filter,
1140+
const cuvs::neighbors::cagra::merge_params& merge_params,
11351141
cuvsDataset_t merged_dataset,
11361142
cuvsCagraIndex_t output_index)
11371143
{
@@ -1179,13 +1185,13 @@ void _merge(cuvsResources_t res,
11791185
convert_opaque_indices_to_concrete_types<T, cuvs::neighbors::device_padded_dataset_view<T, int64_t>>(
11801186
indices, num_indices);
11811187
merge_indices_for_layout<T, cuvs::neighbors::device_padded_dataset_view<T, int64_t>>(
1182-
res_ptr, params_cpp, index_ptrs, filter, merged_dataset, output_index);
1188+
res_ptr, params_cpp, index_ptrs, filter, merge_params, merged_dataset, output_index);
11831189
} else {
11841190
auto index_ptrs =
11851191
convert_opaque_indices_to_concrete_types<T, cuvs::neighbors::device_standard_dataset_view<T, int64_t>>(
11861192
indices, num_indices);
11871193
merge_indices_for_layout<T, cuvs::neighbors::device_standard_dataset_view<T, int64_t>>(
1188-
res_ptr, params_cpp, index_ptrs, filter, merged_dataset, output_index);
1194+
res_ptr, params_cpp, index_ptrs, filter, merge_params, merged_dataset, output_index);
11891195
}
11901196
}
11911197

@@ -1919,15 +1925,41 @@ extern "C" cuvsError_t cuvsCagraMerge(cuvsResources_t res,
19191925
cuvsFilter filter,
19201926
cuvsDataset_t merged_dataset,
19211927
cuvsCagraIndex_t output_index)
1928+
{
1929+
return cuvsCagraMergeWithParams(
1930+
res, params, nullptr, indices, num_indices, filter, merged_dataset, output_index);
1931+
}
1932+
1933+
extern "C" cuvsError_t cuvsCagraMergeWithParams(cuvsResources_t res,
1934+
cuvsCagraIndexParams_t params,
1935+
cuvsCagraMergeParams_t merge_params,
1936+
cuvsCagraIndex_t* indices,
1937+
size_t num_indices,
1938+
cuvsFilter filter,
1939+
cuvsDataset_t merged_dataset,
1940+
cuvsCagraIndex_t output_index)
19221941
{
19231942
return cuvs::core::translate_exceptions([=] {
1924-
// Basic checks on inputs
19251943
RAFT_EXPECTS(indices != nullptr && num_indices > 0, "indices array cannot be null or empty");
19261944
RAFT_EXPECTS(params != nullptr, "params cannot be null");
19271945
RAFT_EXPECTS(indices[0] != nullptr && indices[0]->addr != 0,
19281946
"All input indices must be built (non-empty)");
19291947

1930-
// Use first index dtype as reference
1948+
auto merge_params_cpp = cuvs::neighbors::cagra::merge_params{};
1949+
if (merge_params != nullptr) {
1950+
RAFT_EXPECTS(merge_params->algo >= CUVS_CAGRA_MERGE_AUTO &&
1951+
merge_params->algo <= CUVS_CAGRA_MERGE_REBUILD,
1952+
"Unsupported CAGRA merge algorithm");
1953+
merge_params_cpp = {
1954+
.algo = static_cast<cuvs::neighbors::cagra::merge_algo>(merge_params->algo),
1955+
.levels = merge_params->levels,
1956+
.root_fanout = merge_params->root_fanout,
1957+
.lower_fanout = merge_params->lower_fanout,
1958+
.leader_fraction = merge_params->leader_fraction,
1959+
.max_leaders = merge_params->max_leaders,
1960+
.leaf_size = merge_params->leaf_size,
1961+
.leaf_degree = merge_params->leaf_degree};
1962+
}
19311963
auto dtype = (*indices[0]).dtype;
19321964
for (size_t i = 1; i < num_indices; ++i) {
19331965
RAFT_EXPECTS(indices[i] != nullptr && indices[i]->addr != 0,
@@ -1943,13 +1975,17 @@ extern "C" cuvsError_t cuvsCagraMerge(cuvsResources_t res,
19431975
output_index->addr = 0;
19441976
// Dispatch based on data type
19451977
if (dtype.code == kDLFloat && dtype.bits == 32) {
1946-
_merge<float>(res, *params, indices, num_indices, filter, merged_dataset, output_index);
1978+
_merge<float>(
1979+
res, *params, indices, num_indices, filter, merge_params_cpp, merged_dataset, output_index);
19471980
} else if (dtype.code == kDLFloat && dtype.bits == 16) {
1948-
_merge<half>(res, *params, indices, num_indices, filter, merged_dataset, output_index);
1981+
_merge<half>(
1982+
res, *params, indices, num_indices, filter, merge_params_cpp, merged_dataset, output_index);
19491983
} else if (dtype.code == kDLInt && dtype.bits == 8) {
1950-
_merge<int8_t>(res, *params, indices, num_indices, filter, merged_dataset, output_index);
1984+
_merge<int8_t>(
1985+
res, *params, indices, num_indices, filter, merge_params_cpp, merged_dataset, output_index);
19511986
} else if (dtype.code == kDLUInt && dtype.bits == 8) {
1952-
_merge<uint8_t>(res, *params, indices, num_indices, filter, merged_dataset, output_index);
1987+
_merge<uint8_t>(
1988+
res, *params, indices, num_indices, filter, merge_params_cpp, merged_dataset, output_index);
19531989
} else {
19541990
RAFT_FAIL("Unsupported index data type: code=%d, bits=%d", dtype.code, dtype.bits);
19551991
}
@@ -1995,6 +2031,26 @@ extern "C" cuvsError_t cuvsCagraIndexParamsDestroy(cuvsCagraIndexParams_t params
19952031
});
19962032
}
19972033

2034+
extern "C" cuvsError_t cuvsCagraMergeParamsCreate(cuvsCagraMergeParams_t* params)
2035+
{
2036+
return cuvs::core::translate_exceptions([=] {
2037+
auto defaults = cuvs::neighbors::cagra::merge_params{};
2038+
*params = new cuvsCagraMergeParams{.algo = CUVS_CAGRA_MERGE_AUTO,
2039+
.levels = defaults.levels,
2040+
.root_fanout = defaults.root_fanout,
2041+
.lower_fanout = defaults.lower_fanout,
2042+
.leader_fraction = defaults.leader_fraction,
2043+
.max_leaders = defaults.max_leaders,
2044+
.leaf_size = defaults.leaf_size,
2045+
.leaf_degree = defaults.leaf_degree};
2046+
});
2047+
}
2048+
2049+
extern "C" cuvsError_t cuvsCagraMergeParamsDestroy(cuvsCagraMergeParams_t params)
2050+
{
2051+
return cuvs::core::translate_exceptions([=] { delete params; });
2052+
}
2053+
19982054
extern "C" cuvsError_t cuvsCagraCompressionParamsCreate(cuvsCagraCompressionParams_t* params)
19992055
{
20002056
return cuvs::core::translate_exceptions([=] {

‎c/tests/neighbors/ann_cagra_c.cu‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,12 @@ TEST(CagraC, BuildMergeSearch)
866866
cuvsCagraIndex_t index_array[2] = {index_main, index_add};
867867
cuvsDataset_t merged_dataset;
868868
ASSERT_EQ(cuvsDatasetCreate(&merged_dataset), CUVS_SUCCESS);
869-
ASSERT_EQ(cuvsCagraMerge(res, build_params, index_array, 2, filter, merged_dataset, index_merged),
869+
cuvsCagraMergeParams_t merge_params;
870+
ASSERT_EQ(cuvsCagraMergeParamsCreate(&merge_params), CUVS_SUCCESS);
871+
EXPECT_EQ(merge_params->algo, CUVS_CAGRA_MERGE_AUTO);
872+
merge_params->algo = CUVS_CAGRA_MERGE_REBUILD;
873+
ASSERT_EQ(cuvsCagraMergeWithParams(
874+
res, build_params, merge_params, index_array, 2, filter, merged_dataset, index_merged),
870875
CUVS_SUCCESS);
871876
{
872877
cuvsDatasetMemType_t mem_type{};
@@ -950,14 +955,15 @@ TEST(CagraC, BuildMergeSearch)
950955
EXPECT_NEAR(distance_host, 0.0f, 1e-6);
951956

952957
cuvsCagraSearchParamsDestroy(search_params);
958+
cuvsCagraMergeParamsDestroy(merge_params);
959+
cuvsCagraIndexParamsDestroy(build_params);
953960
cuvsCagraIndexDestroy(index_merged);
954961
cuvsCagraIndexDestroy(index_add);
955962
cuvsCagraIndexDestroy(index_main);
956963
cuvsDatasetDestroy(padded_dataset_owner);
957964
cuvsDatasetDestroy(additional_dataset_view);
958965
cuvsDatasetDestroy(main_dataset_view);
959966
cuvsDatasetDestroy(merged_dataset);
960-
cuvsCagraIndexParamsDestroy(build_params);
961967
cuvsResourcesDestroy(res);
962968
}
963969

‎cpp/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,7 @@ if(NOT BUILD_CPU_ONLY)
13891389
${cagra_extend_inst_files}
13901390
src/neighbors/cagra_optimize.cu
13911391
src/neighbors/detail/cagra/graph_shared.cu
1392+
src/neighbors/detail/cagra/cagra_merge_scaffold_shared.cu
13921393
${cagra_serialize_inst_files}
13931394
${cagra_merge_inst_files}
13941395
${iface_cagra_inst_files}

‎cpp/bench/ann/src/cuvs/cuvs_ann_bench_param_parser.h‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,18 @@ inline void parse_build_param(const nlohmann::json& conf, cuvs::neighbors::vpq_p
302302
}
303303
}
304304

305+
inline void parse_build_param(const nlohmann::json& conf,
306+
cuvs::neighbors::cagra::merge_params& param)
307+
{
308+
param.levels = conf.value("levels", param.levels);
309+
param.root_fanout = conf.value("root_fanout", param.root_fanout);
310+
param.lower_fanout = conf.value("lower_fanout", param.lower_fanout);
311+
param.leader_fraction = conf.value("leader_fraction", param.leader_fraction);
312+
param.max_leaders = conf.value("max_leaders", param.max_leaders);
313+
param.leaf_size = conf.value("leaf_size", param.leaf_size);
314+
param.leaf_degree = conf.value("leaf_degree", param.leaf_degree);
315+
}
316+
305317
nlohmann::json collect_conf_with_prefix(const nlohmann::json& conf,
306318
const std::string& prefix,
307319
bool remove_prefix = true)
@@ -416,6 +428,20 @@ void parse_build_param(const nlohmann::json& conf,
416428
throw std::runtime_error("invalid value for merge_type");
417429
}
418430
}
431+
if (conf.contains("merge_algo")) {
432+
std::string algo = conf.at("merge_algo");
433+
if (algo == "AUTO") {
434+
param.merge_params.algo = cuvs::neighbors::cagra::merge_algo::AUTO;
435+
} else if (algo == "FASTENER") {
436+
param.merge_params.algo = cuvs::neighbors::cagra::merge_algo::FASTENER;
437+
} else if (algo == "REBUILD") {
438+
param.merge_params.algo = cuvs::neighbors::cagra::merge_algo::REBUILD;
439+
} else {
440+
throw std::runtime_error("invalid value for merge_algo");
441+
}
442+
}
443+
nlohmann::json fastener_conf = collect_conf_with_prefix(conf, "fastener_");
444+
if (!fastener_conf.empty()) { parse_build_param(fastener_conf, param.merge_params); }
419445

420446
nlohmann::json comp_search_conf = collect_conf_with_prefix(conf, "compression_");
421447
if (!comp_search_conf.empty()) {

‎cpp/bench/ann/src/cuvs/cuvs_cagra_wrapper.h‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ class cuvs_cagra : public algo<T>, public algo_gpu {
168168
std::optional<cuvs::neighbors::vpq_params> compression = std::nullopt;
169169
size_t num_dataset_splits = 1;
170170
CagraMergeType merge_type = CagraMergeType::kPhysical;
171+
cuvs::neighbors::cagra::merge_params merge_params;
171172
};
172173

173174
cuvs_cagra(Metric metric, int dim, const build_param& param, int concurrent_searches = 1)
@@ -332,13 +333,14 @@ void cuvs_cagra<T, IdxT>::build(const T* dataset, size_t nrow)
332333

333334
auto sub_index = index_type(handle_, params.metric);
334335
if (index_params_.merge_type == CagraMergeType::kPhysical) {
335-
// Physical merge only needs the rows of every split; cagra::merge builds the graph.
336+
// Fastener reuses the input graphs. Build every split so AUTO, FASTENER, and REBUILD all
337+
// receive the same prepared indexes.
336338
if (dataset_is_on_host) {
337-
sub_index.update_device_dataset_same_layout(
338-
handle_, detail::make_padded_view<T>(handle_, sub_host, sub_dataset_buffer));
339+
sub_index = cuvs::neighbors::cagra::build(
340+
handle_, params, detail::make_padded_view<T>(handle_, sub_host, sub_dataset_buffer));
339341
} else {
340-
sub_index.update_device_dataset_same_layout(
341-
handle_, detail::make_padded_view<T>(handle_, sub_dev, sub_dataset_buffer));
342+
sub_index = cuvs::neighbors::cagra::build(
343+
handle_, params, detail::make_padded_view<T>(handle_, sub_dev, sub_dataset_buffer));
342344
}
343345
}
344346
if (index_params_.merge_type == CagraMergeType::kLogical) {
@@ -373,8 +375,13 @@ void cuvs_cagra<T, IdxT>::build(const T* dataset, size_t nrow)
373375
*dataset_ = raft::make_device_matrix<T, int64_t>(handle_, merged_rows, stride);
374376
auto merged_dataset_view = cuvs::neighbors::device_padded_dataset_view<T, int64_t>(
375377
raft::make_const_mdspan(dataset_->view()), static_cast<uint32_t>(dim_));
376-
index_ = std::make_shared<index_type>(cuvs::neighbors::cagra::merge(
377-
handle_, params, indices, merged_dataset_view, merge_row_filter));
378+
index_ =
379+
std::make_shared<index_type>(cuvs::neighbors::cagra::merge(handle_,
380+
params,
381+
indices,
382+
merged_dataset_view,
383+
index_params_.merge_params,
384+
merge_row_filter));
378385
// The merged index holds all the rows now; drop the splits rather than keep a second copy
379386
// of the dataset on the device for the rest of the run.
380387
sub_indices_.clear();

0 commit comments

Comments
 (0)