Skip to content

Reduce binary size random select primitive#5574

Open
ChuckHastings wants to merge 4 commits into
rapidsai:mainfrom
ChuckHastings:reduce_binary_size_random_select_primitive
Open

Reduce binary size random select primitive#5574
ChuckHastings wants to merge 4 commits into
rapidsai:mainfrom
ChuckHastings:reduce_binary_size_random_select_primitive

Conversation

@ChuckHastings

Copy link
Copy Markdown
Collaborator

The random select primitive is our most complex primitive, and many portions of it do not vary based on the graph, the bias operator or the edge operator. This PR splits some of these common pieces out into separate helper functions that can be compiled once for each variation that occurs rather than being compiled redundantly with the combinatorial explosion of template parameters of the primitive.

On CUDA 12 (the CUDA version driving our work), here is the savings:

Stage libcugraph_common.so libcugraph.so libcugraph_mg.so Combined
CUDA 12 Before 89.2 MB 702.6 MB 822.3 MB 1614.1 MB
CUDA 12 After 174.9 MB (+85.6, +95.9%) 584.6 MB (-117.9 -16.8%) 698.9 MB (-123.4, -15.0%) 1458.3 MB (-155.7, -9.65%)

@copy-pr-bot

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

@ChuckHastings ChuckHastings added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Jul 7, 2026
@ChuckHastings ChuckHastings self-assigned this Jul 7, 2026
@ChuckHastings ChuckHastings marked this pull request as ready for review July 7, 2026 20:03
@ChuckHastings ChuckHastings requested review from a team as code owners July 7, 2026 20:03

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

Approving CMake.

@seunghwak

Copy link
Copy Markdown
Contributor

I am still thinking about the best approach, but I am thinking more in the line of

splitting per_v_random_select_transform_outgoing_e to per_v_random_select_e and transform_gather_e (this already exists).

I guess the edge sampling part varies a lot for different sampling methods while the follow up transform and gather part is largely same (IIRC, this is the main motivation for creating transform_gather_e. Then, we can perform transform & gather in a single wrapper function and avoid instantiating this part multiple times while keeping the primitives header only and flexible for any template type combinations.

Let me think more about this.

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

I am still reviewing this and thinking more about the best approach but few quick early comments.

void compute_heterogeneous_biased_sampling_index_without_replacement(
raft::handle_t const& handle,
std::optional<raft::device_span<size_t const>>
input_frontier_indices, // input_per_tyep_degree_offsets & input_biases are already packed if

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.

tyep=>type

}
CUGRAPH_EXPECTS(num_invalid_biases == 0,
"invalid_input_argument: bias_e_op return values should be non-negative and "
"should not exceed std::numeirc_limits<bias_t>::max().");

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.

numeirc=>numeric

assert(graph_view.number_of_local_edge_partitions() == minor_comm_size);

if (do_expensive_check) {
// FIXME: better re-factor this check function?

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.

Yeah... and this is an easy target, not sure how big will be the gain, but we have

count_invalid_vertex_pairs
https://github.com/rapidsai/cugraph/blob/main/cpp/include/cugraph/utilities/error_check_utils.cuh#L59

We may create
count_invalid_vertices as well.

And explicitly instantiate these two functions, and use them everywhere we check for invalid vertices or invalid vertex pairs.

raft::host_span<size_t const>(local_key_list_sizes.data(), local_key_list_sizes.size()),
raft::host_span<size_t const>(local_key_list_offsets.data(),
local_key_list_offsets.size() - 1),
handle.get_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.

And another thing to experiment is that how much we can save by explicitly instantiating device_comm functions (device_allgatherv, device_bcast, and so on).

Maybe not worth the effort as most heavy lifting happens inside the NCCL functions, so the saving might be negligible but we might be able to quickly experiment (e.g. just delete few device comm calls and see whether we see any meaningful change in the resulting binary 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.

If this turns out to be non-negligible, we can do something similar to what we are doing for thrust algorithms.

}
auto edge_partition_e_value_input = edge_partition_e_input_device_view_t(edge_value_input, i);

if (sample_key_indices) {

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.

Need to double check but sample_key_indices.has_value() is true only when minor_comm_size > 1.

If we accept some overhead when GraphViewType::is_multi_gpu is true and minor_comm_size == 1, we may create std::conditional_t<GraphViewType::is_multi_gpu, rmm::device_uvector<size_t>, std::byte /* dummy */> sample_key_indices and here we can use if constexpr (GraphViewType::is_multi_gpu) instead of if. So, only one of the two transform calls will be instantiated. Try to delete the if pass or the else pass and see how much this reduces the binary size. If the difference is non-negligible, this might be another place to cut binary size.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I didn't look extensively through the code, but I did add some constexpr blocks in a few places where we were just checking minor_comm_size so that in SG code we wouldn't even compile the block. I'm sure there are other examples like this, I can take a look.

@seunghwak

Copy link
Copy Markdown
Contributor

A data point to consider in this work.

per_v_random_select_transform_outgoing_e primitives are instantiated in these files.

Via random_walks_impl.cuh

  • cpp/src/sampling/random_walks_sg_v32_e32.cu
  • cpp/src/sampling/random_walks_mg_v32_e32.cu
  • cpp/src/sampling/random_walks_sg_v64_e64.cu
  • cpp/src/sampling/random_walks_mg_v64_e64.cu
    These instantiate uniform/biased/node2vec random walks; those call outgoing primitive mainly in K=1 homogeneous forms (biased and unbiased variants).

Via sample_edges.cuh

  • cpp/src/sampling/detail/sample_edges_sg_v32_e32.cu
  • cpp/src/sampling/detail/sample_edges_mg_v32_e32.cu
  • cpp/src/sampling/detail/sample_edges_sg_v64_e64.cu
  • cpp/src/sampling/detail/sample_edges_mg_v64_e64.cu
  • cpp/src/sampling/detail/temporal_sample_edges_sg_v32_e32.cu
  • cpp/src/sampling/detail/temporal_sample_edges_mg_v32_e32.cu
  • cpp/src/sampling/detail/temporal_sample_edges_sg_v64_e64.cu
  • cpp/src/sampling/detail/temporal_sample_edges_mg_v64_e64.cu
    These are where you get the broadest variation coverage (biased/unbiased, homogeneous/heterogeneous, K scalar vs Ks span, with/without edge types, temporal bias ops).

Merged these files to four, SG/v32/e32, SG/v64/e64, MG/v32/e32, MG/v64/e64.

After fixing a minor build error (/home/seunghwak/RAPIDS/cugraph/cpp/src/sampling/detail/sample_edges.cuh(65): error: class template "cugraph::detail::sample_edges_op_t" has already been defined (previous definition at line 50 of /home/seunghwak/RAPIDS/cugraph/cpp/src/sampling/random_walks_impl.cuh), just renumbered sample_edges_op_t in random_walks_impl.cuh to rw_sample_edges_op_t as a quick and dirty fix), I got roughly 5% cut in the binary size.

-rwxr-xr-x 1 seunghwak domain-users 21814664 Jul 8 11:16 libcugraph_common.so
-rwxr-xr-x 1 seunghwak domain-users 8396160 Jul 8 11:20 libcugraph_c.so
-rwxr-xr-x 1 seunghwak domain-users 208718048 Jul 8 11:20 libcugraph_mg.so
-rwxr-xr-x 1 seunghwak domain-users 7210536 Jul 8 11:18 libcugraph_mtmg.so
-rwxr-xr-x 1 seunghwak domain-users 175323672 Jul 8 11:17 libcugraph.so
to
-rwxr-xr-x 1 seunghwak domain-users 21814664 Jul 8 11:16 libcugraph_common.so
-rwxr-xr-x 1 seunghwak domain-users 8396160 Jul 8 12:08 libcugraph_c.so
-rwxr-xr-x 1 seunghwak domain-users 198574200 Jul 8 12:08 libcugraph_mg.so (4.86% cut)
-rwxr-xr-x 1 seunghwak domain-users 7210536 Jul 8 11:18 libcugraph_mtmg.so
-rwxr-xr-x 1 seunghwak domain-users 166253072 Jul 8 12:08 libcugraph.so (5.17% cut)

We may re-structure the primitive sub-functions little more to further facilitate a function re-use within a TU (e.g. create functions with only edge_t template type). I think we might be able to get similar 10% cut in this way without sacrificing primitives' capability to support arbitrary type combinations.

@seunghwak

Copy link
Copy Markdown
Contributor

I thought more about splitting per_v_random_select_transform_outgoing_e to per_v_random_select_outgoing_e and the follow-up transform primitive but it seems like the gain is not sufficient enough to justify some API complications.

The main motivation for this was

  1. Recover build parallelism (max 2x) by building multiple per_v_random_select_transform_outgoing_e calls in a single TU.
  2. Possibly remove some redundancy in per_v_random_select_transform_outgoing_e and transform_gather_e.

But it seems like the build overhead of the follow-up transform part is not very significant and removing redundancy requires additional plumbing code largely offsetting the code size reduction while also introducing some performance overhead.

More details here.

detail::per_v_random_select_transform_e is the main function here.
https://github.com/rapidsai/cugraph/blob/main/cpp/include/cugraph/prims/per_v_random_select_transform_outgoing_e.cuh#L213

This works in the following steps.

  1. Random sample edges
  2. Shuffle the edges to the edge owning GPU
  3. Transform
  4. Shuffle back the transformed output to the key owning GPU.

1 & 2 can go to per_v_random_select_outgoing_e
3 can go to transform_gather_e
and 4 could be a separate function possibly outside the primitive.

In step 3,

if () { thrust::transform(...); } else { thrust::transform(...); } is the main part.
https://github.com/rapidsai/cugraph/blob/main/cpp/include/cugraph/prims/per_v_random_select_transform_outgoing_e.cuh#L458

They will be compiled only once if template parameters are same (mainly EdgeOp and T). This won't have too much impact on build time (I guess different sampling methods have more impact on the edge selection part rather than EdgeOp or T in many cases).

In step 4,

we are mainly using cugraph thrust wrappers (except for one thrust::transform and one thrust::tabulate) or shuffle_values (I guess most heavy lifting happens in NCCL) so this will be cheap to build. And we can place this under if constexpr (GraphViewType::is_multi_gpu) so we don't need to build this for SG.

Steps 3 & 4 look cheap to build, so splitting may not be very helpful in build time by increasing parallelism (this is most effective when steps 1&2 and 3&4 took comparable amount of time, but it seems like steps 1&2 will be much more expensive than 3&4).

Complication part.

We are not really gathering edges here but gathering key frontier (major vertex can be tagged) and their sampled outgoing edges. The key distinction here is that the edge major can be tagged. This is not a simple gathering of edges. This can cause several complications. Another source of complication is that transform_gather_e uses (src, dst, multi_edge_index) to index an edge but here, we use local edge index. The latter can be more efficient but it is more of an implementation detail and properly exposing this in the public API can be a bit tricky.

So, I am leaning towards not splitting the primitive for now and focusing more on building in a single TU, use if constexpr if it helps cutting binary size/build time, refactor some sub-routines if it can prevent multiple explicit instantiations within a single TU.

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.

3 participants