Skip to content

[WIP] Fast CAGRA Index Merge - #2352

Draft
landrumb wants to merge 18 commits into
NVIDIA:mainfrom
landrumb:landrumb/cagra-fastener-merge
Draft

[WIP] Fast CAGRA Index Merge#2352
landrumb wants to merge 18 commits into
NVIDIA:mainfrom
landrumb:landrumb/cagra-fastener-merge

Conversation

@landrumb

@landrumb landrumb commented Jul 22, 2026

Copy link
Copy Markdown

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.

image (H100) image image

@copy-pr-bot

copy-pr-bot Bot commented Jul 22, 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.

@copy-pr-bot

copy-pr-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@dantegd

dantegd commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

/ok to test 2c54392

@aamijar aamijar added non-breaking Introduces a non-breaking change feature request New feature or request labels Jul 22, 2026
@aamijar aamijar moved this to In Progress in Unstructured Data Processing Jul 22, 2026
@landrumb

Copy link
Copy Markdown
Author

/ok to test

@landrumb

Copy link
Copy Markdown
Author

/ok to test

@landrumb

Copy link
Copy Markdown
Author

/ok to test

@landrumb

Copy link
Copy Markdown
Author

/ok to test

@landrumb

Copy link
Copy Markdown
Author

/ok to test

@landrumb

Copy link
Copy Markdown
Author

/ok to test

@dantegd dantegd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I really like the direction and PR! Reusing the partition graphs, adding a lightweight cross-partition scaffold, and then running the existing CAGRA optimization is such a great way to make merge substantially cheaper than a rebuild. The split between preflight, consolidation, scaffold construction, and graph assembly also makes the implementation fairly easy to follow.

One API concern: the C API still calls the existing C++ overload without merge_params, so it needs updating, right?

RAFT_EXPECTS(range.start == covered && range.end > range.start &&
range.end <= static_cast<int64_t>(partitions.memberships.size()),
"Fastener partition ranges must compactly cover all memberships");
for (int64_t start = range.start; start < range.end; start += leaf_size) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there’s a connectivity edge case here. Since the sorts are stable, rows within a partition can remain grouped by their source index. Slicing an oversized partition into consecutive chunks can then produce leaves containing rows from only one input, and the leaf kernel skips every same-origin pair.

For example, identical inputs whose row counts are multiples of leaf_size can end up with every chunk aligned to a single origin, leaving the original graphs disconnected. Could we either keep splitting or deterministically mix origins before slicing? A regression test for that case would also be helpful.

raft::common::nvtx::range<cuvs::common::nvtx::domain::cuvs> scope("cagra::merge/append/sort");
cagra::detail::graph::sort_knn_graph_device_inplace(
handle, params.metric, raft::make_const_mdspan(dataset.view()), merged_graph.view());
merged_graph = merge_scaffold::cap_sorted_graph(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we capping this a little too early? If an input already has graph_degree local neighbors that are closer than its scaffold neighbors, this nearest prefix cap can remove every cross index edge before graph::optimize() gets a chance to consider it.

It also leaves the optimizer with the same input and output degree, so there’s no candidate headroom for robust pruning. Was wondering if maybe we keep up to max(intermediate_graph_degree, graph_degree) candidates here, bounded by the available candidate width, and let the optimizer produce the final graph_degree?

RAFT_FAIL("FASTENER cagra::merge is unsupported: %s", preflight.reason.c_str());
}

return merge_fastener(handle, params, merge_params, indices, preflight);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should AUTO fall back to rebuild if Fastener runs out of memory? Preflight can validate shapes and limits, but it can’t know whether the consolidated dataset, temporary GEMM buffers, and graphs will all fit at runtime.

The previous merge path could fall back to host memory, so changing the existing overload to AUTO means some merges that previously succeeded may now fail. I think explicit FASTENER should still fail, but AUTO retrying the rebuild path would preserve the existing behavior. What do you think?

if (params.graph_degree == 0 || static_cast<uint64_t>(params.graph_degree) >= rows) {
return reject("graph_degree must be positive and smaller than the combined row count");
}
if (static_cast<uint64_t>(params.graph_degree) > max_input_degree + scaffold_degree) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one limit is missing from preflight: the graph passed to sort_knn_graph_device_inplace() can’t exceed kMaxSortDegree (1024). For example, an input degree of 1024 plus any scaffold currently passes preflight and then fails later in the sorter. In AUTO mode, that also means we miss the rebuild fallback.

Could we include max_input_degree + scaffold_degree <= kMaxSortDegree in the eligibility check?

} // namespace

typedef AnnCagraIndexMergeTest<float, float, std::uint32_t> AnnCagraFastenerMergeRecallTest;
TEST_P(AnnCagraFastenerMergeRecallTest, DefaultFloatMergeSearchRecallAgainstBruteForce)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add recall coverage for at least one non-float dtype and one merge with more than two inputs? The current dtype tests show that the output graph is structurally valid, but only the small two-part float case verifies that the merged graph is actually useful for search. That feels important if AUTO is going to become the default.

auto dim = dataset.extent(1);

// Cut every parent in the bucket into fixed-height tiles
int tile_rows = context.assignment_tile_rows;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we derive tile_rows from the dimension and padded leader count instead of always using 2048?

Preflight only checks the leaf-GEMM shape, while assignment needs roughly (tile_rows + padded_leaders) * dim + tile_rows * padded_leaders float elements. Some configurations therefore pass preflight and later fail the workspace assertion here if I'm not mistaken. Could we reduce the tile height per bucket and reject only when the leader matrix plus even one point row cannot fit?

dataset,
parents,
buckets[bucket_index],
1 << bucket_index,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, have you measured how much work is going to padded leaders here? A parent just over a power-of-two boundary could nearly doubles the gathered leader storage, dot-product GEMM, distance materialization, and select_k width.

RAFT_CUDA_TRY(cudaGetLastError());

int leader_blocks = strided_grid_size(static_cast<int64_t>(batch_size * leader_elements));
manyway_gather_tile_leaders_kernel<<<leader_blocks, THREADS_PER_BLOCK, 0, stream>>>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like every tile of the same parent gathers the same leader matrix again. For large parents split across many 2048-row tiles, that can add substantial conversion and memory traffic, especially when the leader count is close to the tile height. Could we gather each parent’s leaders once and reuse them across its tiles, or did benchmarking show that the added bookkeeping costs more?

int64_t total = static_cast<int64_t>(batch_size) * tile_rows * padded_leaders;
for (; linear < total; linear += stride) {
int leader = static_cast<int>(linear % padded_leaders);
int row = static_cast<int>((linear / padded_leaders) % tile_rows);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a small thing, but wanted to ask: for dimensions that aren’t naturally 16-byte aligned, this allocates and copies a second full consolidated dataset while all input datasets are still alive. Would it be practical to consolidate directly into aligned storage and pass a row stride through the scaffold/sort kernels? That could materially lower peak memory for int8/uint8 and other unaligned dimensions.

static_cast<uint32_t>(offsets[part]));
RAFT_CUDA_TRY(cudaGetLastError());
}
raft::resource::sync_stream(res);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this synchronization needed? The helper has no local temporary buffers consumed by the copy kernels, and the caller immediately sorts the returned graph on the same resource stream. Stream ordering should already preserve that dependency

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature request New feature or request non-breaking Introduces a non-breaking change

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants