Skip to content

Replace O(N*degree^2) dedup with per-thread bit-packed bitset - #2437

Open
jamxia155 wants to merge 2 commits into
NVIDIA:mainfrom
jamxia155:dedup-nn-descent-graph-on-gpu
Open

Replace O(N*degree^2) dedup with per-thread bit-packed bitset#2437
jamxia155 wants to merge 2 commits into
NVIDIA:mainfrom
jamxia155:dedup-nn-descent-graph-on-gpu

Conversation

@jamxia155

@jamxia155 jamxia155 commented Aug 12, 2026

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 per-thread bit-packed "seen" array (1 bit per dataset row), making the check O(1) and the whole pass O(N*degree). Bits are cleared immediately after each row finishes, so no per-row reset is needed, and the extra memory is a small, nrow-independent fraction of what the step already allocates.

Tested on 48-core AMD + H100, up to 20M rows / 1024 degrees against the original loop, a sort-based variant, a hashmap variant, and a GPU kernel; this wins every configuration tested (1.8x-8.5x over the next best), though the margin vs. GPU narrows with nrow as the per-thread array outgrows cache.

@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
Comment thread cpp/src/neighbors/detail/nn_descent.cuh Outdated
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.

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.

I benchmarked a few different implementations (original, sort-based, hashmap-based, bitset-based, GPU __ballot_sync-based): the bitset-based approach on CPU won in all configurations tested. So I'm switching this PR to a CPU implementation after all.

Of course, the perf comparison depends on the relative capabilities of the CPU and GPU (node I tested on had 48 logical cores + H100 PCI-E) but the burden of proof to switch to GPU is also higher than the current CPU optimization proposed.

The final duplicate-removal pass in GNND::build() scanned every
candidate against everything already placed in the row -- O(node_degree)
per candidate. Replace with a per-thread bit-packed "seen" array (1 bit
per dataset row), making the check O(1) and the whole pass
O(nrow * node_degree). Bits are cleared immediately after each row
finishes, so no per-row reset is needed, and the extra memory is a
small, nrow-independent fraction of what the step already allocates.

Also adds an idx >= nrow bounds check the original loop didn't have,
guarding against a pre-existing edge case where an uninitialized
sentinel value could reach the final output graph.

Benchmarked up to 20M rows / 1024 degree against the original loop, a
sort-based variant, a hashmap variant, and a GPU kernel; this wins
every configuration tested (1.8x-8.5x over the next best), though the
margin narrows with nrow as the per-thread array outgrows cache --
untested beyond 20M rows.
@jamxia155
jamxia155 force-pushed the dedup-nn-descent-graph-on-gpu branch from fd92c36 to 8ff98cd Compare August 18, 2026 19:59
@jamxia155 jamxia155 changed the title Replace O(N*degree^2) CPU dedup with GPU warp-ballot kernel Replace O(N*degree^2) dedup with per-thread bit-packed bitset Aug 18, 2026
@jamxia155
jamxia155 marked this pull request as ready for review August 18, 2026 20:13
@jamxia155
jamxia155 requested a review from a team as a code owner August 18, 2026 20:13
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