Skip to main content

fso_graph/
wcc.rs

1//! Weakly Connected Components (WCC) algorithm.
2//!
3//! The algorithm finds all weakly connected components of a graph
4//! and assigns each node to its corresponding component. Weakly
5//! connected means that all nodes that belong to the same component
6//! are connected via an undirected path.
7//!
8//! The implementation is based on the Afforest paper [1] which
9//! introduced the idea of using a sampled subgraph to identify
10//! an intermediate largest community. Nodes within that community
11//! do not need to be considered when linking the remaining edges.
12//! The idea is motivated by power law graphs, which usually have
13//! a very large component.
14//!
15//! The module contains three functions to compute wcc:
16//!
17//! - `wcc_baseline` computes components by linking all connected
18//!   nodes using a disjoint set struct [2]
19//! - `wcc_afforest` implements the algorithm presented in [1]
20//! - `wcc_afforest_dss` implements the algorithm presented in [1]
21//!   but uses a disjoint set struct [2] to represent components
22//!
23//! [1] Michael Sutton, Tal Ben-Nun, Amnon Barak:
24//! "Optimizing Parallel Graph Connectivity Computation via Subgraph Sampling",
25//! Symposium on Parallel and Distributed Processing, IPDPS 2018
26//! [2] Richard J. Anderson , Heather Woll:
27//! "Wait-free Parallel Algorithms for the Union-Find Problem",
28//! In Proc. 23rd ACM Symposium on Theory of Computing, 1994
29
30use ahash::AHashMap;
31use log::info;
32use std::{hash::Hash, time::Instant};
33
34use crate::prelude::*;
35use rayon::prelude::*;
36
37pub use crate::afforest::Afforest;
38pub use crate::dss::DisjointSetStruct;
39
40#[derive(Copy, Clone, Debug)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42#[cfg_attr(feature = "clap", derive(clap::Args))]
43pub struct WccConfig {
44    /// Number of nodes to be processed in batch by a single thread.
45    #[cfg_attr(feature = "clap", clap(long, default_value_t = WccConfig::DEFAULT_CHUNK_SIZE))]
46    pub chunk_size: usize,
47
48    /// Number of relationships of each node to sample during subgraph linking.
49    #[cfg_attr(feature = "clap", clap(long, default_value_t = WccConfig::DEFAULT_NEIGHBOR_ROUNDS))]
50    pub neighbor_rounds: usize,
51
52    /// Number of samples to draw from the DSS to find the largest component.
53    #[cfg_attr(feature = "clap", clap(long, default_value_t = WccConfig::DEFAULT_SAMPLING_SIZE))]
54    pub sampling_size: usize,
55}
56
57impl Default for WccConfig {
58    fn default() -> Self {
59        Self {
60            chunk_size: WccConfig::DEFAULT_CHUNK_SIZE,
61            neighbor_rounds: WccConfig::DEFAULT_NEIGHBOR_ROUNDS,
62            sampling_size: WccConfig::DEFAULT_SAMPLING_SIZE,
63        }
64    }
65}
66
67impl WccConfig {
68    pub const DEFAULT_CHUNK_SIZE: usize = 16384;
69    pub const DEFAULT_NEIGHBOR_ROUNDS: usize = 2;
70    pub const DEFAULT_SAMPLING_SIZE: usize = 1024;
71
72    pub fn new(chunk_size: usize, neighbor_rounds: usize, sampling_size: usize) -> Self {
73        Self {
74            chunk_size,
75            neighbor_rounds,
76            sampling_size,
77        }
78    }
79}
80
81#[allow(clippy::len_without_is_empty)]
82pub trait UnionFind<NI> {
83    /// Joins the set of `id1` with the set of `id2`.
84    fn union(&self, u: NI, v: NI);
85    /// Find the set of `id`.
86    fn find(&self, u: NI) -> NI;
87    /// Returns the number of elements in the union find,
88    /// also referred to as its 'length'.
89    fn len(&self) -> usize;
90    /// Compress the data if possible.
91    /// After that operation each index stores the final set id.
92    fn compress(&self);
93}
94
95pub trait Components<NI> {
96    fn component(&self, node: NI) -> NI;
97
98    fn to_vec(self) -> Vec<NI>;
99}
100
101/// Computes Wcc by iterating all relationships in parallel and
102/// linking source and target nodes using a disjoint set struct.
103pub fn wcc_baseline<NI, G>(graph: &G, config: WccConfig) -> impl Components<NI>
104where
105    NI: Idx,
106    G: Graph<NI> + DirectedNeighbors<NI> + Sync,
107{
108    let node_count = graph.node_count().index();
109    let dss = DisjointSetStruct::new(node_count);
110
111    (0..node_count)
112        .into_par_iter()
113        .chunks(config.chunk_size)
114        .for_each(|chunk| {
115            for u in chunk {
116                let u = NI::new(u);
117                graph.out_neighbors(u).for_each(|v| dss.union(u, *v));
118            }
119        });
120
121    dss
122}
123
124/// Computes Wcc using the Afforest algorithm backed by a disjoint
125/// set struct. The disjoint set struct performans path compression
126/// while searching the set id for a given node.
127pub fn wcc_afforest<NI, G>(graph: &G, config: WccConfig) -> impl Components<NI>
128where
129    NI: Idx + Hash,
130    G: Graph<NI> + DirectedDegrees<NI> + DirectedNeighbors<NI> + Sync,
131{
132    let start = Instant::now();
133    let comp = Afforest::new(graph.node_count().index());
134    info!("Afforest creation took {:?}", start.elapsed());
135
136    wcc(graph, &comp, config);
137
138    comp
139}
140
141/// Computes Wcc using the Afforest algorithm as described in the original
142/// paper (see module description). The backing union find structure can
143/// achieve better cache locality compared to the disjoint set struct variant.
144pub fn wcc_afforest_dss<NI, G>(graph: &G, config: WccConfig) -> impl Components<NI>
145where
146    NI: Idx + Hash,
147    G: Graph<NI> + DirectedDegrees<NI> + DirectedNeighbors<NI> + Sync,
148{
149    let start = Instant::now();
150    let dss = DisjointSetStruct::new(graph.node_count().index());
151    info!("DSS creation took {:?}", start.elapsed());
152
153    wcc(graph, &dss, config);
154
155    dss
156}
157
158fn wcc<NI, G, UF>(graph: &G, comp: &UF, config: WccConfig)
159where
160    NI: Idx + Hash,
161    G: Graph<NI> + DirectedDegrees<NI> + DirectedNeighbors<NI> + Sync,
162    UF: UnionFind<NI> + Send + Sync,
163{
164    let start = Instant::now();
165    sample_subgraph(graph, comp, config);
166    info!("Link subgraph took {:?}", start.elapsed());
167
168    let start = Instant::now();
169    comp.compress();
170    info!("Sample compress took {:?}", start.elapsed());
171
172    let start = Instant::now();
173    let largest_component = find_largest_component(comp, config);
174    info!("Get component took {:?}", start.elapsed());
175
176    let start = Instant::now();
177    link_remaining(graph, comp, largest_component, config);
178    info!("Link remaining took {:?}", start.elapsed());
179
180    let start = Instant::now();
181    comp.compress();
182    info!("Final compress took {:?}", start.elapsed());
183}
184
185// Sample a subgraph by looking at the first `NEIGHBOR_ROUNDS` many targets of each node.
186fn sample_subgraph<NI, G, UF>(graph: &G, uf: &UF, config: WccConfig)
187where
188    NI: Idx,
189    G: Graph<NI> + DirectedNeighbors<NI> + Sync,
190    UF: UnionFind<NI> + Send + Sync,
191{
192    (0..graph.node_count().index())
193        .into_par_iter()
194        .chunks(config.chunk_size)
195        .for_each(|chunk| {
196            for u in chunk {
197                let u = NI::new(u);
198
199                for v in graph.out_neighbors(u).take(config.neighbor_rounds) {
200                    uf.union(u, *v);
201                }
202            }
203        });
204}
205
206// Sample a subgraph by looking at the first `NEIGHBOR_ROUNDS` many targets of each node.
207// In contrast to `sample_subgraph`, the method calls `compress` for each neighbor round.
208#[allow(dead_code)]
209fn sample_subgraph_afforest<NI, G>(graph: &G, af: &Afforest<NI>, config: WccConfig)
210where
211    NI: Idx,
212    G: Graph<NI> + DirectedDegrees<NI> + DirectedNeighbors<NI> + Sync,
213{
214    let neighbor_rounds = config.neighbor_rounds;
215    for r in 0..neighbor_rounds {
216        info!("Neighbor round {} of {neighbor_rounds}", r + 1);
217
218        let start = Instant::now();
219        (0..graph.node_count().index())
220            .into_par_iter()
221            .chunks(config.chunk_size)
222            .for_each(|chunk| {
223                for u in chunk {
224                    let u = NI::new(u);
225                    if r < graph.out_degree(u).index() {
226                        for v in graph.out_neighbors(u).skip(r).take(1) {
227                            af.union(u, *v);
228                        }
229                    }
230                }
231            });
232
233        info!(
234            "Neighbor round {r} of {neighbor_rounds} took {:?}",
235            start.elapsed()
236        );
237
238        let start = Instant::now();
239        af.compress();
240        info!("Compress took {:?}", start.elapsed());
241    }
242}
243
244// Find the largest component after running wcc on the sampled graph.
245fn find_largest_component<NI, UF>(uf: &UF, config: WccConfig) -> NI
246where
247    NI: Idx + Hash,
248    UF: UnionFind<NI> + Send + Sync,
249{
250    use nanorand::{Rng, WyRand};
251    let mut rng = WyRand::new();
252    let mut sample_counts = AHashMap::<NI, usize>::new();
253
254    for _ in 0..config.sampling_size {
255        let component = uf.find(NI::new(rng.generate_range(0..uf.len())));
256        let count = sample_counts.entry(component).or_insert(0);
257        *count += 1;
258    }
259
260    let (most_frequent, size) = sample_counts
261        .iter()
262        .max_by(|(_, v1), (_, v2)| v1.cmp(v2))
263        .unwrap();
264
265    info!(
266        "Largest intermediate component {most_frequent:?} containing approx. {}% of the graph.",
267        (*size as f32 / config.sampling_size as f32 * 100.0) as usize
268    );
269
270    *most_frequent
271}
272
273// Process the remaining edges while skipping nodes that are in the largest component.
274fn link_remaining<NI, G, UF>(graph: &G, uf: &UF, skip_component: NI, config: WccConfig)
275where
276    NI: Idx,
277    G: Graph<NI> + DirectedDegrees<NI> + DirectedNeighbors<NI> + Sync,
278    UF: UnionFind<NI> + Send + Sync,
279{
280    (0..graph.node_count().index())
281        .into_par_iter()
282        .chunks(config.chunk_size)
283        .for_each(|chunk| {
284            for u in chunk {
285                let u = NI::new(u);
286                if uf.find(u) == skip_component {
287                    continue;
288                }
289
290                if graph.out_degree(u).index() > config.neighbor_rounds {
291                    for v in graph.out_neighbors(u).skip(config.neighbor_rounds) {
292                        uf.union(u, *v);
293                    }
294                }
295
296                for v in graph.in_neighbors(u) {
297                    uf.union(u, *v);
298                }
299            }
300        });
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn two_components_afforest_dss() {
309        let graph: DirectedCsrGraph<usize> =
310            GraphBuilder::new().edges(vec![(0, 1), (2, 3)]).build();
311
312        let res = wcc_afforest_dss(&graph, WccConfig::default());
313
314        assert_eq!(res.component(0), res.component(1));
315        assert_eq!(res.component(2), res.component(3));
316        assert_ne!(res.component(1), res.component(2));
317    }
318
319    #[test]
320    fn two_components_afforest() {
321        let graph: DirectedCsrGraph<usize> =
322            GraphBuilder::new().edges(vec![(0, 1), (2, 3)]).build();
323
324        let res = wcc_afforest(&graph, WccConfig::default());
325
326        assert_eq!(res.component(0), res.component(1));
327        assert_eq!(res.component(2), res.component(3));
328        assert_ne!(res.component(1), res.component(2));
329    }
330}