Skip to content

Use ECL-SCC for single-GPU strongly connected components - #5710

Open
gslota wants to merge 1 commit into
rapidsai:mainfrom
gslota:ecl-scc
Open

gslota wants to merge 1 commit into
rapidsai:mainfrom
gslota:ecl-scc

Conversation

@gslota

@gslota gslota commented Oct 16, 2026

Copy link
Copy Markdown

Single-GPU strongly_connected_components now dispatches to a new ECL-SCC implementation through the existing C++ and C APIs. Multi-GPU graphs continue to use the FWBW implementation. This avoids the serialization and performance penalty that occurs using FWBW on graphs with a significant number of nontrivial strong components. A multi-GPU implementation of ECL-SCC is targeted for a future PR.

The implementation uses cuGraph graph views, RMM allocations, current stream APIs, and packed edge masks. It supports 32-bit and 64-bit types and uses a global Manhattan collapse (256 threads, 1024-edge tiles) for internal load balancing, a degree-product initial seed to target large components on the first propagation, initial trimming, and pointer jumping. Reverse CSR construction is internal to the call. All timing stays in external benchmark drivers.

The original ECL-SCC BSD notice is retained and installed with the library.

Validation

  • Existing unchanged C++ SCC suite: 8 cases; existing SG C API SCC test and CTest resource fixture passed.
  • Existing distributed C++/C API SCC tests passed with 1 MPI rank and 2/4 MPS ranks on one physical GPU.
  • Existing Python connected-components suites passed: 15 pylibcugraph and 16 cugraph tests, using rebuilt native libraries.
  • Compute Sanitizer: C++ memcheck/full leak check, initcheck, synccheck, racecheck; C API memcheck/full leak check and synccheck passed with zero errors. C++ sanitizer runs use the existing --rmm_mode=cuda option to avoid the test harness's retained default pool allocation.
  • Four-job Release native build, clean-prefix installation, installed-library smoke test, license packaging, pre-commit hooks, and diff whitespace checks passed.
  • Earlier Doxygen comparison found no new diagnostics relative to pristine main. Tests and the C API implementation are unchanged from upstream.

Performance

One first SCC invocation per fresh process, with no explicit CUDA module warmup. Input construction/upload and output copy are outside the timer; internal reverse CSR construction and allocations are inside. Control uses pristine-main SG FWBW with matching compiler flags and native dependencies. These are single samples, not statistical estimates. All eight cases produced matching canonical partitions relative to the existing FWBW algorithm. Correctness of a large suite of inputs was successfully tested against a reference serial algorithm.

Graph ECL (ms) FWBW (ms)
toroid-wedge.mesh 44 1549
star.mesh 83 876
web-Stanford 40 10230
wiki-Talk 67 1319
cold-flow.mesh 87 2158
GAP-road 1176 2079
rmat26-renumber0 1544 2278
rmat26-renumber1 1274 2039

Hardware/software: RTX PRO 5000 Blackwell Generation Laptop GPU, CUDA 12.9, GCC 14.4, Release sm_120a, cuGraph main 5d0bc45 (26.12), OpenMPI 4.1.6, NCCL 2.32.3.1. Test graphs were retrieved from the Stanford Large Network Dataset Collection, the SuiteSparse Matrix Collection, the ECL-SCC github, and cugraph::generate_rmat_edgelist. The R-MAT graphs used scale 26, edge factor 16, and seed 0 (67,108,864 vertices and 1,073,741,824 edges) in both renumbering modes.

@gslota
gslota requested review from a team as code owners October 16, 2026 23:12
@gslota
gslota requested a review from bdice October 16, 2026 23:12
@copy-pr-bot

copy-pr-bot Bot commented Oct 16, 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.

@seunghwak seunghwak 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.

Thank you very much for the contribution!

I haven't reviewed "components/strongly_connected_components_ecl_impl.cuh" yet (will review soon) but want to discuss how should we handle the LICENSE file. Did you include the LICENSE file because you used any copyrighted code or you included the file because we used the published ECL-SCC algorithm?

* @brief Finds (strongly-connected-)component IDs of each vertices in the input graph.
*
* Uses ECL vertex queues with global Manhattan scheduling for single-GPU graphs and
* forward-backward traversal for multi-GPU graphs. An attached edge mask is honored.

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.

We generally don't expose implementation details in the public API. Better delete this.

* The input graph may be asymmetric. Component IDs can be arbitrary integers (they can be
* non-consecutive and are not ordered by component size or any other criterion).
*
* Single-GPU graphs use ECL signature propagation; multi-GPU graphs use FWBW.

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.

Same here. No need to say we are relying on ECL in a way we didn't mention we are using the FW-BW approach in the past.

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.

Why did you include this LICENSE file? Are we copying any code from the existing ECL-SCC implementation or you included this because we used the algorithm (but no code)?

And we have thirdpaty/LICENSES directory and it might be a more appropriate place to put this file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The current code is based on but very heavily modified from the original ECL-SCC implementation, though the basic algorithm logic is still consistent. I included the license to be safe. I can move it to the LICENSES subdirectory (apologies for missing that), or I can remove it entirely, if you do not think it would be necessary.

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.

Thanks for the update, and I will double check but I agree that it is safe to include the LICENSES. And better move this to the LICENSES subdirectory.

*/
#pragma once

#include "components/strongly_connected_components_ecl_impl.cuh"

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.

We can include "components/strongly_connected_components_ecl_impl.cuh" in strongly_connected_components_sg_v32_e32.cu and strongly_connected_components_sg_v64_e64.cu. Once we have both SG & MG implementations in ECL-SCC, later, we can replace the strongly_connected_components_impl.cuh with "components/strongly_connected_components_ecl_impl.cuh"

Comment thread cpp/CMakeLists.txt
include(CPack)

install(FILES src/components/ECL-SCC-LICENSE
DESTINATION share/licenses/cugraph)

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.

Do we need this? We need to double check how should we handle the LICENSE file issue.

Comment thread .pre-commit-config.yaml
- id: verify-copyright
name: verify-copyright-ecl
args: [--fix, --spdx, "--spdx-license-identifier=Apache-2.0 AND BSD-3-Clause"]
files: ^cpp/src/components/strongly_connected_components_ecl_impl[.]cuh$

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.

Not sure we really need to manually edit this file. We rarely modify this file on any algorithm's internal implementation changes.

@seunghwak seunghwak added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Oct 19, 2026

@seunghwak seunghwak 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.

For cuGraph integration, do you want to go through multiple review rounds to make this code to fit into the cuGraph codebase coding styles/conventions, or I can take over from there, and ask a coding agent to convert this code with more specific instructions.

For example, we have code/function to create a reverse graph, transform_reduce_if_v_frontier_outgoing_e to propagate label, collect_values_for_int_vertices to implement the shortcut mechanism in the multi-GPU path, and so on.

We don't recommend application code to directly work withe CSR array or edge mask, and we want the MG path to be largely SG code + alpha rather than having two separate code paths.

And cuGraph coding style (mostly coming from its RAPIDS root) is to avoid using custom kernels unless the performance benefit is significant. A lot of this can be automated using a coding agent if the instruction is specific enough.

This code will work as a great starting point in either ways, and thank you very much for the contribution!

template <typename GraphViewType>
std::unique_ptr<
graph_t<typename GraphViewType::vertex_type, typename GraphViewType::edge_type, false, false>>
build_ecl_reverse_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.

You can refer to the old SCC code on creating a reverse graph (this code will work for both SG & MG)

https://github.com/rapidsai/cugraph/blob/main/cpp/src/components/strongly_connected_components_impl.cuh#L2073

Comment on lines +558 to +587
// Initialize our seed heuristic -> vertex with the largest in*out degree gets max label
auto masked_out_degrees = graph_view.has_edge_mask() ? graph_view.compute_out_degrees(handle)
: rmm::device_uvector<edge_t>(0, stream);
{
auto candidates = thrust::make_transform_iterator(
thrust::make_counting_iterator(vertex_t{0}),
degree_product<vertex_t, edge_t>{
edges.offsets(),
reverse_edges.offsets(),
masked_out_degrees.size() ? masked_out_degrees.data() : nullptr});
size_t scratch_bytes{};
auto initial = degree_seed<vertex_t>{0, 0, std::numeric_limits<vertex_t>::max()};
RAFT_CUDA_TRY(cub::DeviceReduce::Reduce(nullptr,
scratch_bytes,
candidates,
work.seed.data(),
n,
maximum_degree_seed<vertex_t>{},
initial,
stream.get()));
work.scratch.resize(scratch_bytes, stream);
RAFT_CUDA_TRY(cub::DeviceReduce::Reduce(work.scratch.data(),
scratch_bytes,
candidates,
work.seed.data(),
n,
maximum_degree_seed<vertex_t>{},
initial,
stream.get()));
}

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.

cuGraph codebase generally prefers using thrust/cugraph primitives unless the performance difference is dramatic.

This picks the vertex with the highest in_degree * out_degree (and I assume this shouldn't be exact).

Can't we compute (double)in_degree * (double)out_degree instead of using 128bit integer (no more guarantee that we will pick the exact one but I assume this may not matter much in practice) and use cugraph::reduce_v instead? This code will work seamlessly in SG & MG.


// Initialize our seed heuristic -> vertex with the largest in*out degree gets max label
auto masked_out_degrees = graph_view.has_edge_mask() ? graph_view.compute_out_degrees(handle)
: rmm::device_uvector<edge_t>(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.

Can we just do out_degrees = graph_view.compute_out_degrees(hanlde)? I assume this won't impact performance significantly over computing from offsets on the go? This code will work in both SG & MG and simplify the codebase. And a single code path regardless of whether we are using edge mask or not.

Comment on lines +590 to +596
trim_singletons<<<blocks_for(n), block_size, 0, stream.get()>>>(
edges.offsets(),
reverse_edges.offsets(),
masked_out_degrees.size() ? masked_out_degrees.data() : nullptr,
n,
labels.data());
RAFT_CUDA_TRY(cudaPeekAtLastError());

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.

We generally prefer thrust over custom kernels unless the performance difference is significant.

This sounds like thrust::transform_if. A predicate will check whether in_degree or out_degree is 0. If the predicate is satisfied, writes vertex ID.

Comment on lines +620 to +627
initialize_signatures<<<blocks_for(active_count), block_size, 0, stream.get()>>>(
active[active_buffer].data(),
active_count,
incoming.data(),
outgoing.data(),
n,
work.seed.data(),
first_round);

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.

Similar here. I think thrust::scatter will be sufficient.

Something like

auto color_first = thrust::make_transform_iterator(
  active_first,
  [...]__device__(vertex_t v) {
    auto olor = (first_round && v == seed->vertex) ? n : v;
    return cuda::std::make_tuple(color, color);
  }));
thrust::scatter(handle.get_thrust_policy(),
   color_first, color_first + active_count,
  active_first,
  thrust::make_zip_iterator(incoming.begin(), outgoing.begin());

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

Labels

improvement Improvement / enhancement to an existing function non-breaking Non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants