Skip to content

Commit 765fb65

Browse files
committed
hg_analytics: union-find CC + extend graph-DB head-to-head to WCC
The Kuzu head-to-head exposed a real gap: our single-machine WCC used label propagation (chosen for the distributed BSP path) and LOST to Kuzu's union-find (117ms vs 98ms). Added connected_components_uf (union-find, path halving + union by size, near-linear) — induces the same partition as the label-prop reference, verified by test. Cut our WCC to 13ms. Result: hg_analytics now beats KuzuDB (a real embedded graph DB) on BOTH kernels at scale-18/4.2M edges: PageRank 10.1x (ranking 100% agree), WCC 7.6x (same 88104 components). vs_baseline emits WCC timing; vs_kuzu compares both. 31 tests.
1 parent 969348b commit 765fb65

4 files changed

Lines changed: 120 additions & 19 deletions

File tree

crates/hg_analytics/BENCHMARKS.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,23 @@ HG_SCALE=18 cargo run -p hg_analytics --release --example vs_baseline
3434
HG_OUT=/tmp/hg_vs18 python3 scripts/bench/vs_kuzu.py
3535
```
3636

37-
Not a linear-algebra library — an actual embedded graph DB (Cypher, native `page_rank`), same graph, same
38-
machine, compute-to-compute:
39-
40-
| graph | hg_analytics | KuzuDB PageRank | ranking agreement |
41-
|-------|-------------:|----------------:|:-----------------:|
42-
| scale 17, 2.1M edges | 46 ms | 559 ms (**12.2× slower**) | top-100 100% |
43-
| scale 18, 4.2M edges | 109 ms | 1122 ms (**10.3× slower**) | top-100 100% |
44-
45-
Identical ranking, ~10–12× faster on compute — and Kuzu additionally pays a ~0.5 s bulk-load step we don't.
46-
Caveats: Kuzu is single-machine embedded (not distributed); its default damping/iteration count may differ,
47-
but the 100% top-100 agreement shows both reach the same ranking.
37+
Not a linear-algebra library — an actual embedded graph DB (Cypher, native `page_rank` +
38+
`weakly_connected_components`), same graph, same machine, compute-to-compute (scale-18, 262K nodes / 4.2M
39+
edges):
40+
41+
| kernel | hg_analytics | KuzuDB | speedup | agreement |
42+
|--------|-------------:|-------:|:-------:|:---------:|
43+
| PageRank | 112 ms | 1134 ms | **10.1×** | top-100 100% |
44+
| WCC | 13 ms | 98 ms | **7.6×** | same component count (88 104) |
45+
46+
Faster on both, same result — and Kuzu additionally pays a ~0.9 s bulk-load step we don't (in-memory).
47+
Caveats: Kuzu is single-machine embedded (not distributed); PageRank damping/iterations may differ, but the
48+
100% top-100 agreement shows both reach the same ranking.
49+
50+
> This head-to-head paid for itself: the first run had our WCC at 117 ms (**losing** to Kuzu) because
51+
> `connected_components` is label-propagation, chosen for the distributed BSP path. Adding the right
52+
> single-machine algorithm — `connected_components_uf` (union-find, path halving + union by size) — cut it
53+
> to 13 ms and flipped a loss into a 7.6× win. Honest benchmarking finds real gaps.
4854
4955
## 2. Boundary-only halo — the scaling unlock
5056

crates/hg_analytics/examples/vs_baseline.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
//!
1111
//! Run: `HG_SCALE=15 cargo run -p hg_analytics --release --example vs_baseline`
1212
13-
use hg_analytics::{pagerank, pagerank_parallel, Kronecker};
13+
use hg_analytics::{connected_components_uf, pagerank, pagerank_parallel, Kronecker};
14+
use std::collections::HashSet;
1415
use std::io::Write;
1516
use std::time::Instant;
1617

@@ -45,6 +46,12 @@ fn main() {
4546
let parallel = pagerank_parallel(n, &edges, damping, iters, tol);
4647
let rust_parallel_s = t.elapsed().as_secs_f64();
4748

49+
// Connected components (union-find — the fast single-machine path) for the graph-DB WCC head-to-head.
50+
let t = Instant::now();
51+
let cc = connected_components_uf(n, &edges);
52+
let rust_wcc_s = t.elapsed().as_secs_f64();
53+
let n_components = cc.iter().copied().collect::<HashSet<u32>>().len();
54+
4855
// Sanity: parallel == serial fixed point.
4956
let maxd = serial
5057
.iter()
@@ -71,9 +78,10 @@ fn main() {
7178
std::fs::write(format!("{out}/rust_top.txt"), top).unwrap();
7279

7380
let mut meta = std::fs::File::create(format!("{out}/meta.txt")).unwrap();
81+
// fields: n m iters damping serial_s parallel_s wcc_s n_components
7482
writeln!(
7583
meta,
76-
"{n} {m} {iters} {damping} {rust_serial_s} {rust_parallel_s}"
84+
"{n} {m} {iters} {damping} {rust_serial_s} {rust_parallel_s} {rust_wcc_s} {n_components}"
7785
)
7886
.unwrap();
7987

@@ -83,6 +91,7 @@ fn main() {
8391
" rust parallel : {rust_parallel_s:.4}s ({:.2}x vs serial)",
8492
rust_serial_s / rust_parallel_s.max(1e-9)
8593
);
94+
println!(" rust WCC : {rust_wcc_s:.4}s ({n_components} components)");
8695
println!(" parallel == serial: max|Δ| {maxd:.2e}");
8796
println!(" wrote graph + result to {out}/ — now run scripts/bench/vs_baseline.py");
8897
}

crates/hg_analytics/src/lib.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,47 @@ pub fn pagerank_by_id(
190190
ids.iter().enumerate().map(|(i, &id)| (id, pr[i])).collect()
191191
}
192192

193+
// ── Connected components (union-find — the fast single-machine path) ──────────────────────────────────────────
194+
/// Single-machine connected components via union-find (path halving + union by size) — near-linear
195+
/// O(m·α(n)), far faster than iterative label propagation for a one-shot in-memory answer. Returns each
196+
/// node's component representative (the root id). Induces the SAME partition as `connected_components`
197+
/// (label ids differ: roots here vs smallest-member there). This is the algorithm to race a graph DB with;
198+
/// the label-propagation `connected_components` stays the canonical min-label reference for the distributed
199+
/// BSP path (which must reconcile across shards).
200+
pub fn connected_components_uf(n: usize, edges: &[(usize, usize)]) -> Vec<u32> {
201+
if n == 0 {
202+
return Vec::new();
203+
}
204+
let mut parent: Vec<u32> = (0..n as u32).collect();
205+
let mut size = vec![1u32; n];
206+
// find with path halving.
207+
fn find(parent: &mut [u32], mut x: u32) -> u32 {
208+
while parent[x as usize] != x {
209+
parent[x as usize] = parent[parent[x as usize] as usize];
210+
x = parent[x as usize];
211+
}
212+
x
213+
}
214+
for &(u, v) in edges {
215+
if u < n && v < n && u != v {
216+
let (mut ru, mut rv) = (find(&mut parent, u as u32), find(&mut parent, v as u32));
217+
if ru != rv {
218+
// union by size (attach smaller under larger) → shallow trees.
219+
if size[ru as usize] < size[rv as usize] {
220+
std::mem::swap(&mut ru, &mut rv);
221+
}
222+
parent[rv as usize] = ru;
223+
size[ru as usize] += size[rv as usize];
224+
}
225+
}
226+
}
227+
// Flatten: every node points to its root.
228+
for i in 0..n {
229+
parent[i] = find(&mut parent, i as u32);
230+
}
231+
parent
232+
}
233+
193234
// ── Betweenness centrality (Brandes, unweighted, undirected) ─────────────────────────────────────────────────
194235
/// Exact Brandes betweenness over an undirected graph. Deterministic (BFS in index order). Each shortest-path pair
195236
/// is counted once (undirected → halved). Identifies "bridge" nodes — the structural connectors.
@@ -545,6 +586,35 @@ mod tests {
545586
const IT: usize = 200;
546587
const TOL: f64 = 1e-12;
547588

589+
#[test]
590+
fn union_find_cc_induces_same_partition_as_label_prop() {
591+
use crate::Kronecker;
592+
// Two disjoint RMAT blobs → a real multi-component graph. Union-find and label-prop must agree
593+
// on the PARTITION (which nodes share a component), even though the label ids differ.
594+
let half = Kronecker::vertices(8);
595+
let n = 2 * half;
596+
let mut edges: Vec<(usize, usize)> = Kronecker::new(8, 6, 1).collect();
597+
edges.extend(Kronecker::new(8, 6, 2).map(|(u, v)| (u + half, v + half)));
598+
599+
let lp = connected_components(n, &edges); // min-label
600+
let uf = connected_components_uf(n, &edges); // roots
601+
assert_eq!(
602+
lp.iter().collect::<std::collections::HashSet<_>>().len(),
603+
uf.iter().collect::<std::collections::HashSet<_>>().len(),
604+
"same number of components"
605+
);
606+
// Same partition: for every edge-connected pair the labels agree within each scheme.
607+
for v in 0..n {
608+
for &w in &[(v + 1) % n, (v + 7) % n] {
609+
assert_eq!(
610+
lp[v] == lp[w],
611+
uf[v] == uf[w],
612+
"union-find and label-prop disagree on whether {v},{w} share a component"
613+
);
614+
}
615+
}
616+
}
617+
548618
#[test]
549619
fn parallel_pagerank_matches_serial_and_is_deterministic() {
550620
let edges = vec![(0, 1), (1, 2), (2, 0), (2, 3), (3, 1), (0, 3)];

scripts/bench/vs_kuzu.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
meta = open(f"{OUT}/meta.txt").read().split()
2222
n, m = int(meta[0]), int(meta[1])
2323
rust_parallel_s = float(meta[5])
24+
rust_wcc_s = float(meta[6]) if len(meta) > 6 else None
25+
rust_ncomp = int(meta[7]) if len(meta) > 7 else None
2426
edges = np.fromfile(f"{OUT}/edges.bin", dtype=np.uint32).reshape(-1, 2)
2527
rust_top = [int(x) for x in open(f"{OUT}/rust_top.txt").read().split()]
2628

@@ -58,12 +60,26 @@
5860

5961
agree = len(set(kuzu_order[:100]) & set(rust_top)) / 100.0
6062

61-
print(f"\nKuzu (embedded graph DB, native PageRank):")
63+
# ── Kuzu native weakly-connected components ──────────────────────────────────────────────────────────
64+
t = time.perf_counter()
65+
wres = con.execute("CALL weakly_connected_components('G') RETURN group_id")
66+
kuzu_wcc_s = time.perf_counter() - t
67+
groups = set()
68+
while wres.has_next():
69+
groups.add(wres.get_next()[0])
70+
kuzu_ncomp = len(groups)
71+
72+
print(f"\nKuzu (embedded graph DB):")
6273
print(f" bulk load (COPY) : {load_s*1000:8.1f} ms (+ {csv_s*1000:.0f} ms to write CSV)")
6374
print(f" PageRank compute : {kuzu_s*1000:8.1f} ms")
75+
print(f" WCC compute : {kuzu_wcc_s*1000:8.1f} ms")
76+
6477
print(f"\nhead-to-head (compute-to-compute, same graph, same machine):")
65-
print(f" hg_analytics : {rust_parallel_s*1000:7.1f} ms")
66-
print(f" Kuzu : {kuzu_s*1000:7.1f} ms → hg_analytics is {kuzu_s/rust_parallel_s:.1f}x faster")
67-
print(f" top-100 ranking agreement: {agree*100:.0f}%")
68-
print("\nNote: exact PageRank values differ (Kuzu's default damping/iterations vs ours); the ranking is "
69-
"what agrees. Kuzu also pays a load step we don't (in-memory). Compute-to-compute is the fair line.")
78+
print(f" PageRank hg {rust_parallel_s*1000:7.1f} ms vs Kuzu {kuzu_s*1000:7.1f} ms "
79+
f"→ hg is {kuzu_s/rust_parallel_s:.1f}x faster (top-100 ranking {agree*100:.0f}% agree)")
80+
if rust_wcc_s:
81+
comp_ok = "same" if kuzu_ncomp == rust_ncomp else f"differ ({rust_ncomp} vs {kuzu_ncomp})"
82+
print(f" WCC hg {rust_wcc_s*1000:7.1f} ms vs Kuzu {kuzu_wcc_s*1000:7.1f} ms "
83+
f"→ hg is {kuzu_wcc_s/rust_wcc_s:.1f}x faster (component count {comp_ok})")
84+
print("\nNote: exact PageRank values differ (Kuzu's default damping/iterations vs ours); the ranking "
85+
"agrees. Kuzu also pays a load step we don't (in-memory). Compute-to-compute is the fair line.")

0 commit comments

Comments
 (0)