Skip to content

Replace O(N*degree^2) CPU dedup with GPU warp-ballot kernel - #2437

Draft
jamxia155 wants to merge 1 commit into
NVIDIA:mainfrom
jamxia155:dedup-nn-descent-graph-on-gpu
Draft

Replace O(N*degree^2) CPU dedup with GPU warp-ballot kernel#2437
jamxia155 wants to merge 1 commit into
NVIDIA:mainfrom
jamxia155:dedup-nn-descent-graph-on-gpu

Conversation

@jamxia155

Copy link
Copy Markdown
Contributor

The graph shrink step in GNND::build() copied the NN-descent output while removing duplicate and self-referencing neighbor IDs using a nested scan: for each of the N nodes, each of the node_degree candidates was checked against all already-placed entries, giving O(N*degree^2) CPU work that scales poorly with graph degree and dataset size.

Replace with a GPU dedup_graph_kernel that runs one warp per node. The warp scans neighbors in original order, using __ballot_sync for O(warp-width) duplicate detection, and fills any remaining slots with xorshift64 random nodes. The H2D/D2H transfers are O(N*degree) -- the same order as a single pass over the graph -- while the replaced CPU work is O(N*degree^2), so the transfers are dominated by the savings at any practical degree.

Tested on 32-core AMD + H100 with n_rows=2.5M and graph_degree=56, this change reduces runtime by about 6 seconds.

The graph shrink step in GNND::build() copied the NN-descent output
while removing duplicate and self-referencing neighbor IDs using a
nested scan: for each of the N nodes, each of the `node_degree`
candidates was checked against all already-placed entries, giving
O(N*degree^2) CPU work that scales poorly with graph degree and dataset
size.

Replace with a GPU kernel (dedup_graph_kernel) that runs one warp per
node. The warp scans InternalID_t neighbors in original order, using
__ballot_sync for O(warp-width) duplicate detection, and fills any
remaining slots with xorshift64 random nodes. Original neighbor order
is preserved, avoiding the recall regression seen with sort-based
approaches. The H2D/D2H transfers are O(N*degree) -- the same order
as a single pass over the graph -- while the replaced CPU work is
O(N*degree^2), so the transfers are dominated by the savings at any
practical degree.
@copy-pr-bot

copy-pr-bot Bot commented Aug 12, 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.

@jamxia155 jamxia155 self-assigned this Aug 12, 2026
@jamxia155 jamxia155 added improvement Improves an existing functionality non-breaking Introduces a non-breaking change labels Aug 12, 2026
rmm::device_uvector<int> d_in(h_graph_elems, stream);
RAFT_CUDA_TRY(cudaMemcpyAsync(
d_in.data(), graph_.h_graph, h_graph_elems * sizeof(int), cudaMemcpyHostToDevice, stream));

@tarang-jain tarang-jain Aug 17, 2026

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 PR brings the entire set of host candidates into device memory. The number of candidates per host can be very large. Earlier we were only capping the degree of on device graph buffers to DEGREE_ON_DEVICE (=32) it seems.

Secondly, I don't see why we were even doing it in this way on host in the first place i.e. comparing every single pair. We already call sort_lists(), so the list of candidates is sorted by distances, right? Since we are sorting pairs, the same candidate should technically appear adjacent. Then we should just do a single scan through each candidate list. Or are you saying that there could be differences (for example the epilog is asymmetric maybe?) which means the same candidate can appear at non-adjacent places in the sorted list?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for taking a look, Tarang. I'll look into a streaming approach to keep the memory footprint bounded.

As for the de-dup implementation, the original implementation sorted the candidates by distance so a de-dup based on ID cannot be achieved with a linear scan. However, it is possible to first sort by ID, dedup, then sort again by distance. I will run some tests and see if that closes the gap with the GPU port.

@tarang-jain tarang-jain Aug 17, 2026

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.

the original implementation sorted the candidates by distance

It sorts the pairs. So for candidates with identical distance, the same candidate would be placed adjacent, right? I think std::sort does this for tuples by moving on to the next element if the first one is identical.

@tarang-jain tarang-jain Aug 17, 2026

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.

But we need to think about cases when the same candidate can have two different distances for example small numerical differences in distances (while adding reverse edges). Is that possible? I would be quite surprised though if there was asymmetry like that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The possibility for the same candidate with two different distances arises from a somewhat different scenario (all line numbers refer to cpp/src/neighbors/detail/nn_descent.cuh (permalink):

  • 1239-1241: init_random_graph() seeds each row's initial candidate list segment-by-segment, independently, with no cross-segment uniqueness check. The same id value from line 1240 can happen to end up in multiple segments.
  • 1246: the distances are initialized as std::numeric_limits::max().
  • 1312: update_graph() computes seg_idx = new_neighb_id.id() % num_segments; 1316: insert_to_ordered_list() is called using this one seg_idx only. If the same id exists in another segment, its initial value never gets overwritten.

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

Labels

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