From b701414e060a94f60c8c59df429b6b4289308c25 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:39:31 +0100 Subject: [PATCH 1/2] Use flat adjacency and dense degree buckets for core decomposition Build per-call dense working state using fixed result-map key positions. Deduplicate neighbors with row markers in a contiguous adjacency buffer, then peel degree buckets without cloning a bucket-offset buffer. Preserve the public Rust and Python APIs, original node IDs, result order, and existing reciprocal-neighbor semantics. Include independent Rust and Python regressions, benchmark controls, and a release note. --- ...e-number-dense-state-60fb188fd560af1d.yaml | 8 + rustworkx-core/Cargo.toml | 4 + rustworkx-core/benches/core_number.rs | 185 +++++++++++ .../src/connectivity/core_number.rs | 305 +++++++++++++++--- tests/digraph/test_core_number.py | 65 ++++ tests/graph/test_core_number.py | 38 +++ tools/bench_core_number.py | 139 ++++++++ 7 files changed, 698 insertions(+), 46 deletions(-) create mode 100644 releasenotes/notes/core-number-dense-state-60fb188fd560af1d.yaml create mode 100644 rustworkx-core/benches/core_number.rs create mode 100644 tools/bench_core_number.py diff --git a/releasenotes/notes/core-number-dense-state-60fb188fd560af1d.yaml b/releasenotes/notes/core-number-dense-state-60fb188fd560af1d.yaml new file mode 100644 index 0000000000..a8cc2f6c07 --- /dev/null +++ b/releasenotes/notes/core-number-dense-state-60fb188fd560af1d.yaml @@ -0,0 +1,8 @@ +other: + - | + Reduced hashing and allocation during :func:`~rustworkx.core_number` and + ``rustworkx_core::connectivity::core_number`` by using dense working storage + for core decomposition. Adjacency uses a contiguous buffer and per-call + markers to remove duplicate neighbors without sorting. Public signatures, + result ordering, and node IDs are unchanged, including when nodes have + been removed from a graph. diff --git a/rustworkx-core/Cargo.toml b/rustworkx-core/Cargo.toml index cf3c027313..a2303979c8 100644 --- a/rustworkx-core/Cargo.toml +++ b/rustworkx-core/Cargo.toml @@ -41,3 +41,7 @@ features = ["rayon"] [dev-dependencies] quickcheck = "1.0" quickcheck_macros = "1.0" + +[[bench]] +name = "core_number" +harness = false diff --git a/rustworkx-core/benches/core_number.rs b/rustworkx-core/benches/core_number.rs new file mode 100644 index 0000000000..c1922c7866 --- /dev/null +++ b/rustworkx-core/benches/core_number.rs @@ -0,0 +1,185 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +// Reproducible full-call benchmark with no extra dependencies. +// Run the same source against the base and proposed crate versions: +// RAYON_NUM_THREADS=1 cargo bench -p rustworkx-core --bench core_number -- 10000 20 +// Arguments are node count and sample count. Output is CSV; one warmup per case. +// Graph construction and exact answer validation are outside the timed calls. +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; +use std::hint::black_box; +use std::time::Instant; + +use petgraph::graph::NodeIndex; +use petgraph::stable_graph::StableDiGraph; +use rustworkx_core::connectivity::core_number; + +// Match the Python fixture's integer generator and edge insertion order. +fn irregular_edges(n: usize) -> Vec<(usize, usize)> { + let connected = n - 1; + let dense = (connected * 3 / 4).max(2); + let mut state = 0xA076_1D64_78BD_642F_u64; + let mut edges = Vec::new(); + let mut seen = HashSet::new(); + let mut add = |source, target| { + if seen.insert((source, target)) { + edges.push((source, target)); + } + }; + for source in 0..dense { + let attempts = if source % 31 == 0 { + 64 + } else { + 1 + (source * 17 + 11) % 16 + }; + for attempt in 0..attempts.min(dense - 1) { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + let target = (source + 1 + (state % (dense - 1) as u64) as usize) % dense; + add(source, target); + if attempt % 3 == 0 { + add(target, source); + } + } + } + for source in dense..connected { + for offset in 0..1 + source % 3 { + add(source, (source * 17 + offset * 13) % dense); + } + } + edges +} + +fn reference_core_numbers(n: usize, edges: &[(usize, usize)]) -> Vec { + // Independent min-degree elimination with a heap, no degree buckets or dense markers. + let mut adjacency = vec![HashSet::new(); n]; + for &(source, target) in edges { + adjacency[source].insert(target); + adjacency[target].insert(source); + } + let mut degree: Vec<_> = adjacency.iter().map(HashSet::len).collect(); + let mut pending: BinaryHeap<_> = degree + .iter() + .enumerate() + .map(|(node, &value)| Reverse((value, node))) + .collect(); + let mut removed = vec![false; n]; + let mut expected = vec![0; n]; + let mut level = 0; + while let Some(Reverse((value, node))) = pending.pop() { + if removed[node] || value != degree[node] { + continue; + } + level = level.max(value); + expected[node] = level; + removed[node] = true; + for &neighbor in &adjacency[node] { + if !removed[neighbor] { + degree[neighbor] -= 1; + pending.push(Reverse((degree[neighbor], neighbor))); + } + } + } + expected +} + +fn fixture(shape: &str, n: usize, stride: usize) -> (StableDiGraph<(), ()>, Vec) { + let mut graph = StableDiGraph::default(); + for _ in 0..n * stride { + graph.add_node(()); + } + for i in 0..n * stride { + if i % stride != 0 { + graph.remove_node(NodeIndex::new(i)); + } + } + let mut expected = vec![0; n]; + let mut edge = |a, b| { + graph.add_edge(NodeIndex::new(a * stride), NodeIndex::new(b * stride), ()); + }; + // The final live node is an isolate in every case. + let connected = n - 1; + match shape { + "ring" => { + let steps = 4.min((connected - 1) / 2); + for (a, core) in expected[..connected].iter_mut().enumerate() { + *core = 2 * steps; + for step in 1..=steps { + let b = (a + step) % connected; + edge(a, b); + edge(b, a); + } + } + } + "hub" => { + expected[..connected].fill(1); + for b in 1..connected { + edge(0, b); + } + } + "irregular" => { + let edges = irregular_edges(n); + expected = reference_core_numbers(n, &edges); + for (source, target) in edges { + edge(source, target); + } + } + "cliques" | "clique" => { + let width = if shape == "clique" { connected } else { 16 }; + for start in (0..connected).step_by(width) { + let end = (start + width).min(connected); + expected[start..end].fill(end - start - 1); + for a in start..end { + for b in a + 1..end { + edge(a, b); + } + } + } + } + _ => unreachable!(), + } + (graph, expected) +} + +fn main() { + let mut args = std::env::args().skip(1).filter(|arg| arg != "--bench"); + let n = args.next().map_or(10_000, |value| value.parse().unwrap()); + let samples = args.next().map_or(20, |value| value.parse().unwrap()); + assert!(n >= 8 && samples > 0 && args.next().is_none()); + println!("shape,nodes,edges,stride,sample,nanoseconds"); + for shape in ["ring", "hub", "cliques", "clique", "irregular"] { + // Bound the quadratic fixture independently of the sparse graph size. + let n = if shape == "clique" { n.min(256) } else { n }; + for stride in [1, 2] { + let (graph, expected) = fixture(shape, n, stride); + for sample in 0..=samples { + let start = Instant::now(); + let result = core_number(black_box(&graph)); + let elapsed = start.elapsed().as_nanos(); + black_box(&result); + assert_eq!(result.len(), n); + for (i, ((node, actual), expected)) in result.iter().zip(&expected).enumerate() { + assert_eq!(node.index(), i * stride); + assert_eq!(actual, expected); + } + if sample != 0 { + println!( + "{shape},{n},{},{stride},{sample},{elapsed}", + graph.edge_count() + ); + } + } + } + } +} diff --git a/rustworkx-core/src/connectivity/core_number.rs b/rustworkx-core/src/connectivity/core_number.rs index da336f54fa..70f598043f 100644 --- a/rustworkx-core/src/connectivity/core_number.rs +++ b/rustworkx-core/src/connectivity/core_number.rs @@ -12,12 +12,10 @@ use std::hash::Hash; -use hashbrown::{HashMap, HashSet}; use petgraph::Direction::{Incoming, Outgoing}; use petgraph::visit::{GraphBase, IntoNeighborsDirected, IntoNodeIdentifiers, NodeCount}; -use rayon::prelude::*; -use crate::dictmap::*; +use crate::dictmap::{DictMap, InitWithHasher}; /// Return the core number for each node in the graph. /// @@ -55,56 +53,78 @@ where return DictMap::new(); } - let mut cores: DictMap = DictMap::with_capacity(node_num); - let mut node_vec: Vec = graph.node_identifiers().collect(); - let mut degree_map: HashMap = HashMap::with_capacity(node_num); - let mut nbrs: HashMap> = HashMap::with_capacity(node_num); - let mut node_pos: HashMap = HashMap::with_capacity(node_num); - - for k in node_vec.iter() { - let k_nbrs: HashSet = graph - .neighbors_directed(*k, Incoming) - .chain(graph.neighbors_directed(*k, Outgoing)) - .collect(); - let k_deg = k_nbrs.len(); - - nbrs.insert(*k, k_nbrs); - cores.insert(*k, k_deg); - degree_map.insert(*k, k_deg); - } - node_vec.par_sort_by_key(|k| degree_map.get(k)); - - let mut bin_boundaries: Vec = - Vec::with_capacity(degree_map[&node_vec[node_num - 1]] + 1); - bin_boundaries.push(0); - let mut curr_degree = 0; - for (i, v) in node_vec.iter().enumerate() { - node_pos.insert(*v, i); - let v_degree = degree_map[v]; - if v_degree > curr_degree { - for _ in 0..v_degree - curr_degree { - bin_boundaries.push(i); + // DictMap provides compact working indices without requiring + // NodeIndexable or allocating up to the largest node ID. Keep its insertion + // order unchanged so the returned map still follows node_identifiers(). + let mut cores: DictMap = + graph.node_identifiers().map(|node| (node, 0)).collect(); + // Store adjacency in one buffer. Each row's dense index is a fresh marker, + // so incoming/outgoing duplicates are removed without sorting or hashing + // another set. The sentinel cannot equal a live row index, including zero. + let mut neighbors = Vec::new(); + let mut offsets = Vec::with_capacity(node_num + 1); + let mut seen = vec![usize::MAX; node_num]; + offsets.push(0); + for (row, &node) in cores.keys().enumerate() { + for neighbor in graph + .neighbors_directed(node, Incoming) + .chain(graph.neighbors_directed(node, Outgoing)) + { + let index = cores.get_index_of(&neighbor).unwrap(); + // Match the existing neighbor set, including reciprocal edges. + if seen[index] != row { + seen[index] = row; + neighbors.push(index); } - curr_degree = v_degree; } + offsets.push(neighbors.len()); + } + drop(seen); + let mut degree: Vec<_> = offsets.windows(2).map(|row| row[1] - row[0]).collect(); + let mut bins = vec![0; degree.iter().copied().max().unwrap() + 1]; + for &value in °ree { + bins[value] += 1; + } + let mut start = 0; + for count in &mut bins { + let next = start + *count; + *count = start; + start = next; } - for v_ind in 0..node_vec.len() { - let v = node_vec[v_ind]; - let v_nbrs = nbrs[&v].clone(); - for u in v_nbrs { - if cores[&u] > cores[&v] { - nbrs.get_mut(&u).unwrap().remove(&v); - let pos = node_pos[&u]; - let bin_start = bin_boundaries[cores[&u]]; - *node_pos.get_mut(&u).unwrap() = bin_start; - *node_pos.get_mut(&node_vec[bin_start]).unwrap() = pos; - node_vec.swap(bin_start, pos); - bin_boundaries[cores[&u]] += 1; - *cores.get_mut(&u).unwrap() -= 1; + // Counting sort puts every vertex in its degree bucket. Placement advances + // bins to bucket ends; shifting them restores starts without a second buffer. + let mut positions = vec![0; node_num]; + let mut vertices = vec![0; node_num]; + for (node, &value) in degree.iter().enumerate() { + positions[node] = bins[value]; + vertices[bins[value]] = node; + bins[value] += 1; + } + for value in (1..bins.len()).rev() { + bins[value] = bins[value - 1]; + } + bins[0] = 0; + + for order in 0..node_num { + let node = vertices[order]; + for &neighbor in &neighbors[offsets[node]..offsets[node + 1]] { + if degree[neighbor] > degree[node] { + let neighbor_degree = degree[neighbor]; + let position = positions[neighbor]; + let bucket_start = bins[neighbor_degree]; + let bucket_node = vertices[bucket_start]; + vertices.swap(position, bucket_start); + positions[neighbor] = bucket_start; + positions[bucket_node] = position; + bins[neighbor_degree] += 1; + degree[neighbor] -= 1; } } } + for (value, core) in cores.values_mut().zip(degree) { + *value = core; + } cores } @@ -113,6 +133,199 @@ mod tests { use crate::connectivity::core_number; use petgraph::prelude::*; + // Exercise IDs that cannot be used as dense vector indices. + #[test] + fn test_graph_map_node_ids_and_order() { + let mut graph = DiGraphMap::<&str, ()>::new(); + let nodes = ["isolated", "tail", "z", "a", "middle"]; + for node in nodes { + graph.add_node(node); + } + for (a, b) in [ + ("z", "a"), + ("a", "z"), + ("a", "middle"), + ("middle", "z"), + ("tail", "middle"), + ] { + graph.add_edge(a, b, ()); + } + let result = core_number(&graph); + assert_eq!(result.keys().copied().collect::>(), nodes); + assert_eq!( + result.values().copied().collect::>(), + [0, 1, 2, 2, 2] + ); + } + + #[test] + fn test_deleted_and_reused_node_indices() { + fn check() { + for stride in [2, 1000] { + let mut graph = StableGraph::<(), (), Ty>::default(); + for _ in 0..7 * stride { + graph.add_node(()); + } + for i in 0..7 * stride { + if i % stride != 0 { + graph.remove_node(NodeIndex::new(i)); + } + } + graph.remove_node(NodeIndex::new(stride)); + assert_eq!(graph.add_node(()), NodeIndex::new(stride)); + for (a, b) in [(0, 1), (1, 2), (2, 0), (2, 3), (4, 5)] { + graph.add_edge(NodeIndex::new(a * stride), NodeIndex::new(b * stride), ()); + } + let result = core_number(&graph); + assert_eq!( + result.keys().map(|node| node.index()).collect::>(), + (0..7).map(|i| i * stride).collect::>() + ); + assert_eq!( + result.values().copied().collect::>(), + [2, 2, 2, 1, 1, 1, 0] + ); + } + } + check::(); + check::(); + } + + #[test] + fn test_reciprocal_edges_count_one_neighbor() { + let graph = DiGraph::<(), ()>::from_edges([(0, 1), (1, 0)]); + let result = core_number(&graph); + assert_eq!(result.values().copied().collect::>(), [1, 1]); + } + + // Independent oracle: a node's core number is the largest minimum degree + // among all vertex subsets containing it. No degree buckets or peeling. + fn subset_core_numbers(adjacency: &[Vec]) -> Vec { + let n = adjacency.len(); + let mut result = vec![0; n]; + for subset in 1..1_usize << n { + let degree = (0..n) + .filter(|&a| subset & (1 << a) != 0) + .map(|a| { + (0..n) + .filter(|&b| subset & (1 << b) != 0 && (adjacency[a][b] || adjacency[b][a])) + .count() + }) + .min() + .unwrap(); + for (node, core) in result.iter_mut().enumerate() { + if subset & (1 << node) != 0 { + *core = (*core).max(degree); + } + } + } + result + } + + #[test] + fn test_all_four_node_directed_graphs() { + let edges: Vec<_> = (0..4) + .flat_map(|a| (0..4).filter(move |&b| a != b).map(move |b| (a, b))) + .collect(); + for mask in 0..1_usize << edges.len() { + let mut graph = DiGraph::<(), ()>::new(); + let mut adjacency = vec![vec![false; 4]; 4]; + for _ in 0..4 { + graph.add_node(()); + } + for (bit, &(a, b)) in edges.iter().enumerate() { + if mask & (1 << bit) != 0 { + graph.add_edge(NodeIndex::new(a), NodeIndex::new(b), ()); + adjacency[a][b] = true; + } + } + let expected = subset_core_numbers(&adjacency); + let actual = core_number(&graph); + assert_eq!( + actual.values().copied().collect::>(), + expected, + "edge mask {mask}" + ); + } + } + + // Reuse the same graph through mixed core levels, sparse rows and + // empty adjacency. Working state must belong to one call, not a previous graph. + #[test] + fn test_irregular_graphs_and_repeated_calls() { + fn check() { + let mixed = [ + (0, 1), + (0, 2), + (0, 3), + (1, 2), + (1, 3), + (2, 3), + (3, 4), + (4, 5), + (5, 3), + (5, 6), + ]; + let star = [(0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6)]; + for stride in [1, 3] { + let mut graph = StableGraph::<(), (), Ty>::default(); + for _ in 0..8 * stride { + graph.add_node(()); + } + for node in 0..8 * stride { + if node % stride != 0 { + graph.remove_node(NodeIndex::new(node)); + } + } + for edges in [&[][..], &mixed[..], &star[..], &mixed[..], &[][..]] { + graph.clear_edges(); + let mut adjacency = vec![vec![false; 8]; 8]; + for &(source, target) in edges { + graph.add_edge( + NodeIndex::new(source * stride), + NodeIndex::new(target * stride), + (), + ); + adjacency[source][target] = true; + if Ty::is_directed() && (source + target) % 2 == 0 { + graph.add_edge( + NodeIndex::new(target * stride), + NodeIndex::new(source * stride), + (), + ); + } + } + let expected = subset_core_numbers(&adjacency); + for _ in 0..2 { + let actual = core_number(&graph); + assert_eq!( + actual.keys().map(|node| node.index()).collect::>(), + (0..8).map(|node| node * stride).collect::>() + ); + assert_eq!(actual.values().copied().collect::>(), expected); + } + } + } + } + check::(); + check::(); + } + + #[test] + fn test_neighbor_set_compatibility() { + // Preserve the existing set-based behavior for unsupported loops and + // parallel edges. In particular, a self-neighbor in row zero occurs once. + let mut graph = DiGraph::<(), ()>::new(); + for _ in 0..4 { + graph.add_node(()); + } + graph.extend_with_edges([(0, 0), (0, 0), (0, 1), (0, 1), (1, 0), (2, 2)]); + for _ in 0..3 { + let actual = core_number(&graph); + assert_eq!(actual.values().copied().collect::>(), [1, 1, 1, 0]); + } + } + #[test] fn test_directed_empty() { let graph = DiGraph::::new(); diff --git a/tests/digraph/test_core_number.py b/tests/digraph/test_core_number.py index 1644b39cca..3f4bb21058 100644 --- a/tests/digraph/test_core_number.py +++ b/tests/digraph/test_core_number.py @@ -94,3 +94,68 @@ def test_directed_paper_example(self): res = rustworkx.core_number(digraph) self.assertIsInstance(res, dict) self.assertEqual(res, self.example_core) + + # Compact working indices must not replace public node IDs. + def test_removed_and_reused_indices(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(10)) + graph.remove_nodes_from([1, 3, 5, 7, 9]) + self.assertEqual(graph.add_node("reused"), 9) + self.assertEqual(graph.add_node("isolated"), 7) + graph.add_edges_from_no_data([(0, 2), (2, 4), (4, 0), (4, 6), (8, 9)]) + expected = {0: 2, 2: 2, 4: 2, 6: 1, 7: 0, 8: 1, 9: 1} + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + with self.subTest(function=function.__name__): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected)) + + def test_reciprocal_edges_count_one_neighbor(self): + graph = rustworkx.PyDiGraph() + graph.extend_from_edge_list([(0, 1), (1, 0)]) + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + with self.subTest(function=function.__name__): + self.assertEqual(function(graph), {0: 1, 1: 1}) + + # Repeated calls include reciprocal arcs and noncompact public IDs. + def test_repeated_calls_after_edge_changes(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(16)) + graph.remove_nodes_from(range(1, 16, 2)) + mixed = [ + (0, 2), + (2, 0), + (0, 4), + (0, 6), + (2, 4), + (2, 6), + (4, 6), + (6, 8), + (8, 10), + (10, 6), + (10, 12), + ] + star = [(0, node) for node in range(2, 14, 2)] + for edges, values in [ + ([], [0] * 8), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + (star, [1] * 7 + [0]), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + ([], [0] * 8), + ]: + graph.clear_edges() + graph.add_edges_from_no_data(edges) + expected = dict(zip(range(0, 16, 2), values)) + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + for _ in range(2): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected)) + + def test_neighbor_set_compatibility(self): + # Retain historical behavior without broadening the documented input contract. + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(4)) + graph.add_edges_from_no_data([(0, 0), (0, 0), (0, 1), (0, 1), (1, 0), (2, 2)]) + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + self.assertEqual(function(graph), {0: 1, 1: 1, 2: 1, 3: 0}) diff --git a/tests/graph/test_core_number.py b/tests/graph/test_core_number.py index 8415fb478e..86005799a4 100644 --- a/tests/graph/test_core_number.py +++ b/tests/graph/test_core_number.py @@ -94,3 +94,41 @@ def test_undirected_paper_example(self): res = rustworkx.core_number(graph) self.assertIsInstance(res, dict) self.assertEqual(res, self.example_core) + + # Compact working indices must not replace public node IDs. + def test_removed_and_reused_indices(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(10)) + graph.remove_nodes_from([1, 3, 5, 7, 9]) + self.assertEqual(graph.add_node("reused"), 9) + self.assertEqual(graph.add_node("isolated"), 7) + graph.add_edges_from_no_data([(0, 2), (2, 4), (4, 0), (4, 6), (8, 9)]) + expected = {0: 2, 2: 2, 4: 2, 6: 1, 7: 0, 8: 1, 9: 1} + for function in (rustworkx.core_number, rustworkx.graph_core_number): + with self.subTest(function=function.__name__): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected)) + + # Per-call work arrays must not retain earlier adjacency or core values. + def test_repeated_calls_after_edge_changes(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(16)) + graph.remove_nodes_from(range(1, 16, 2)) + mixed = [(0, 2), (0, 4), (0, 6), (2, 4), (2, 6), (4, 6), (6, 8), (8, 10), (10, 6), (10, 12)] + star = [(0, node) for node in range(2, 14, 2)] + for edges, values in [ + ([], [0] * 8), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + (star, [1] * 7 + [0]), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + ([], [0] * 8), + ]: + graph.clear_edges() + graph.add_edges_from_no_data(edges) + expected = dict(zip(range(0, 16, 2), values)) + for function in (rustworkx.core_number, rustworkx.graph_core_number): + for _ in range(2): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected)) diff --git a/tools/bench_core_number.py b/tools/bench_core_number.py new file mode 100644 index 0000000000..a6e3b5a427 --- /dev/null +++ b/tools/bench_core_number.py @@ -0,0 +1,139 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Benchmark the installed Python extension, including dict conversion. + +Run outside the checkout root against each built wheel: +RAYON_NUM_THREADS=1 python /path/to/tools/bench_core_number.py --nodes 10000 --samples 20 +Cases match rustworkx-core/benches/core_number.rs. Graph setup and exact answer +validation are untimed. Each case has one warmup and emits individual CSV samples. +""" + +import argparse +import heapq +import time + +import rustworkx + + +# The integer generator and insertion order also appear in the Rust benchmark. +def irregular_edges(n): + connected = n - 1 + dense = max(2, connected * 3 // 4) + state = 0xA0761D6478BD642F + edges, seen = [], set() + + def add(source, target): + if (source, target) not in seen: + seen.add((source, target)) + edges.append((source, target)) + + for source in range(dense): + attempts = 64 if source % 31 == 0 else 1 + (source * 17 + 11) % 16 + for attempt in range(min(attempts, dense - 1)): + state = (state * 6364136223846793005 + 1442695040888963407) & ((1 << 64) - 1) + target = (source + 1 + state % (dense - 1)) % dense + add(source, target) + if attempt % 3 == 0: + add(target, source) + for source in range(dense, connected): + for offset in range(1 + source % 3): + add(source, (source * 17 + offset * 13) % dense) + return edges + + +def reference_core_numbers(n, edges): + # Independent min-degree elimination with a heap, no degree buckets or dense markers. + adjacency = [set() for _ in range(n)] + for source, target in edges: + adjacency[source].add(target) + adjacency[target].add(source) + degree = [len(neighbors) for neighbors in adjacency] + pending = [(value, node) for node, value in enumerate(degree)] + heapq.heapify(pending) + removed, expected, level = [False] * n, [0] * n, 0 + while pending: + value, node = heapq.heappop(pending) + if removed[node] or value != degree[node]: + continue + level = max(level, value) + expected[node], removed[node] = level, True + for neighbor in adjacency[node]: + if not removed[neighbor]: + degree[neighbor] -= 1 + heapq.heappush(pending, (degree[neighbor], neighbor)) + return expected + + +def fixture(shape, n, stride): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(n * stride)) + graph.remove_nodes_from(i for i in range(n * stride) if i % stride) + expected = dict.fromkeys(range(0, n * stride, stride), 0) + connected = n - 1 + edges = [] + if shape == "ring": + steps = min(4, (connected - 1) // 2) + for a in range(connected): + expected[a * stride] = 2 * steps + for step in range(1, steps + 1): + b = (a + step) % connected + edges.extend([(a * stride, b * stride), (b * stride, a * stride)]) + elif shape == "hub": + expected.update((a * stride, 1) for a in range(connected)) + edges = [(0, b * stride) for b in range(1, connected)] + elif shape == "irregular": + dense_edges = irregular_edges(n) + expected = { + node * stride: core for node, core in enumerate(reference_core_numbers(n, dense_edges)) + } + edges = [(source * stride, target * stride) for source, target in dense_edges] + else: + width = connected if shape == "clique" else 16 + for start in range(0, connected, width): + end = min(start + width, connected) + expected.update((a * stride, end - start - 1) for a in range(start, end)) + edges.extend( + (a * stride, b * stride) for a in range(start, end) for b in range(a + 1, end) + ) + graph.add_edges_from_no_data(edges) + return graph, expected + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--nodes", type=int, default=10000) + parser.add_argument("--samples", type=int, default=20) + args = parser.parse_args() + if args.nodes < 8 or args.samples < 1: + parser.error("nodes must be at least 8 and samples must be positive") + print("shape,nodes,edges,stride,sample,nanoseconds") + for shape in ("ring", "hub", "cliques", "clique", "irregular"): + # Bound the quadratic fixture independently of the sparse graph size. + n = min(args.nodes, 256) if shape == "clique" else args.nodes + for stride in (1, 2): + graph, expected = fixture(shape, n, stride) + for sample in range(args.samples + 1): + start = time.perf_counter_ns() + result = rustworkx.core_number(graph) + elapsed = time.perf_counter_ns() - start + assert result == expected and list(result) == list(expected) + if sample: + print( + f"{shape},{n},{graph.num_edges()},{stride},{sample},{elapsed}", + flush=True, + ) + del result + + +if __name__ == "__main__": + main() From 38b190990c6075468fd5defb7dfad5e2e9a37d1c Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:48:29 +0100 Subject: [PATCH 2/2] Remove benchmark tooling from the upstream contribution --- rustworkx-core/Cargo.toml | 4 - rustworkx-core/benches/core_number.rs | 185 -------------------------- tools/bench_core_number.py | 139 ------------------- 3 files changed, 328 deletions(-) delete mode 100644 rustworkx-core/benches/core_number.rs delete mode 100644 tools/bench_core_number.py diff --git a/rustworkx-core/Cargo.toml b/rustworkx-core/Cargo.toml index a2303979c8..cf3c027313 100644 --- a/rustworkx-core/Cargo.toml +++ b/rustworkx-core/Cargo.toml @@ -41,7 +41,3 @@ features = ["rayon"] [dev-dependencies] quickcheck = "1.0" quickcheck_macros = "1.0" - -[[bench]] -name = "core_number" -harness = false diff --git a/rustworkx-core/benches/core_number.rs b/rustworkx-core/benches/core_number.rs deleted file mode 100644 index c1922c7866..0000000000 --- a/rustworkx-core/benches/core_number.rs +++ /dev/null @@ -1,185 +0,0 @@ -// Licensed under the Apache License, Version 2.0 (the "License"); you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -// Reproducible full-call benchmark with no extra dependencies. -// Run the same source against the base and proposed crate versions: -// RAYON_NUM_THREADS=1 cargo bench -p rustworkx-core --bench core_number -- 10000 20 -// Arguments are node count and sample count. Output is CSV; one warmup per case. -// Graph construction and exact answer validation are outside the timed calls. -use std::cmp::Reverse; -use std::collections::{BinaryHeap, HashSet}; -use std::hint::black_box; -use std::time::Instant; - -use petgraph::graph::NodeIndex; -use petgraph::stable_graph::StableDiGraph; -use rustworkx_core::connectivity::core_number; - -// Match the Python fixture's integer generator and edge insertion order. -fn irregular_edges(n: usize) -> Vec<(usize, usize)> { - let connected = n - 1; - let dense = (connected * 3 / 4).max(2); - let mut state = 0xA076_1D64_78BD_642F_u64; - let mut edges = Vec::new(); - let mut seen = HashSet::new(); - let mut add = |source, target| { - if seen.insert((source, target)) { - edges.push((source, target)); - } - }; - for source in 0..dense { - let attempts = if source % 31 == 0 { - 64 - } else { - 1 + (source * 17 + 11) % 16 - }; - for attempt in 0..attempts.min(dense - 1) { - state = state - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - let target = (source + 1 + (state % (dense - 1) as u64) as usize) % dense; - add(source, target); - if attempt % 3 == 0 { - add(target, source); - } - } - } - for source in dense..connected { - for offset in 0..1 + source % 3 { - add(source, (source * 17 + offset * 13) % dense); - } - } - edges -} - -fn reference_core_numbers(n: usize, edges: &[(usize, usize)]) -> Vec { - // Independent min-degree elimination with a heap, no degree buckets or dense markers. - let mut adjacency = vec![HashSet::new(); n]; - for &(source, target) in edges { - adjacency[source].insert(target); - adjacency[target].insert(source); - } - let mut degree: Vec<_> = adjacency.iter().map(HashSet::len).collect(); - let mut pending: BinaryHeap<_> = degree - .iter() - .enumerate() - .map(|(node, &value)| Reverse((value, node))) - .collect(); - let mut removed = vec![false; n]; - let mut expected = vec![0; n]; - let mut level = 0; - while let Some(Reverse((value, node))) = pending.pop() { - if removed[node] || value != degree[node] { - continue; - } - level = level.max(value); - expected[node] = level; - removed[node] = true; - for &neighbor in &adjacency[node] { - if !removed[neighbor] { - degree[neighbor] -= 1; - pending.push(Reverse((degree[neighbor], neighbor))); - } - } - } - expected -} - -fn fixture(shape: &str, n: usize, stride: usize) -> (StableDiGraph<(), ()>, Vec) { - let mut graph = StableDiGraph::default(); - for _ in 0..n * stride { - graph.add_node(()); - } - for i in 0..n * stride { - if i % stride != 0 { - graph.remove_node(NodeIndex::new(i)); - } - } - let mut expected = vec![0; n]; - let mut edge = |a, b| { - graph.add_edge(NodeIndex::new(a * stride), NodeIndex::new(b * stride), ()); - }; - // The final live node is an isolate in every case. - let connected = n - 1; - match shape { - "ring" => { - let steps = 4.min((connected - 1) / 2); - for (a, core) in expected[..connected].iter_mut().enumerate() { - *core = 2 * steps; - for step in 1..=steps { - let b = (a + step) % connected; - edge(a, b); - edge(b, a); - } - } - } - "hub" => { - expected[..connected].fill(1); - for b in 1..connected { - edge(0, b); - } - } - "irregular" => { - let edges = irregular_edges(n); - expected = reference_core_numbers(n, &edges); - for (source, target) in edges { - edge(source, target); - } - } - "cliques" | "clique" => { - let width = if shape == "clique" { connected } else { 16 }; - for start in (0..connected).step_by(width) { - let end = (start + width).min(connected); - expected[start..end].fill(end - start - 1); - for a in start..end { - for b in a + 1..end { - edge(a, b); - } - } - } - } - _ => unreachable!(), - } - (graph, expected) -} - -fn main() { - let mut args = std::env::args().skip(1).filter(|arg| arg != "--bench"); - let n = args.next().map_or(10_000, |value| value.parse().unwrap()); - let samples = args.next().map_or(20, |value| value.parse().unwrap()); - assert!(n >= 8 && samples > 0 && args.next().is_none()); - println!("shape,nodes,edges,stride,sample,nanoseconds"); - for shape in ["ring", "hub", "cliques", "clique", "irregular"] { - // Bound the quadratic fixture independently of the sparse graph size. - let n = if shape == "clique" { n.min(256) } else { n }; - for stride in [1, 2] { - let (graph, expected) = fixture(shape, n, stride); - for sample in 0..=samples { - let start = Instant::now(); - let result = core_number(black_box(&graph)); - let elapsed = start.elapsed().as_nanos(); - black_box(&result); - assert_eq!(result.len(), n); - for (i, ((node, actual), expected)) in result.iter().zip(&expected).enumerate() { - assert_eq!(node.index(), i * stride); - assert_eq!(actual, expected); - } - if sample != 0 { - println!( - "{shape},{n},{},{stride},{sample},{elapsed}", - graph.edge_count() - ); - } - } - } - } -} diff --git a/tools/bench_core_number.py b/tools/bench_core_number.py deleted file mode 100644 index a6e3b5a427..0000000000 --- a/tools/bench_core_number.py +++ /dev/null @@ -1,139 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -"""Benchmark the installed Python extension, including dict conversion. - -Run outside the checkout root against each built wheel: -RAYON_NUM_THREADS=1 python /path/to/tools/bench_core_number.py --nodes 10000 --samples 20 -Cases match rustworkx-core/benches/core_number.rs. Graph setup and exact answer -validation are untimed. Each case has one warmup and emits individual CSV samples. -""" - -import argparse -import heapq -import time - -import rustworkx - - -# The integer generator and insertion order also appear in the Rust benchmark. -def irregular_edges(n): - connected = n - 1 - dense = max(2, connected * 3 // 4) - state = 0xA0761D6478BD642F - edges, seen = [], set() - - def add(source, target): - if (source, target) not in seen: - seen.add((source, target)) - edges.append((source, target)) - - for source in range(dense): - attempts = 64 if source % 31 == 0 else 1 + (source * 17 + 11) % 16 - for attempt in range(min(attempts, dense - 1)): - state = (state * 6364136223846793005 + 1442695040888963407) & ((1 << 64) - 1) - target = (source + 1 + state % (dense - 1)) % dense - add(source, target) - if attempt % 3 == 0: - add(target, source) - for source in range(dense, connected): - for offset in range(1 + source % 3): - add(source, (source * 17 + offset * 13) % dense) - return edges - - -def reference_core_numbers(n, edges): - # Independent min-degree elimination with a heap, no degree buckets or dense markers. - adjacency = [set() for _ in range(n)] - for source, target in edges: - adjacency[source].add(target) - adjacency[target].add(source) - degree = [len(neighbors) for neighbors in adjacency] - pending = [(value, node) for node, value in enumerate(degree)] - heapq.heapify(pending) - removed, expected, level = [False] * n, [0] * n, 0 - while pending: - value, node = heapq.heappop(pending) - if removed[node] or value != degree[node]: - continue - level = max(level, value) - expected[node], removed[node] = level, True - for neighbor in adjacency[node]: - if not removed[neighbor]: - degree[neighbor] -= 1 - heapq.heappush(pending, (degree[neighbor], neighbor)) - return expected - - -def fixture(shape, n, stride): - graph = rustworkx.PyDiGraph() - graph.add_nodes_from(range(n * stride)) - graph.remove_nodes_from(i for i in range(n * stride) if i % stride) - expected = dict.fromkeys(range(0, n * stride, stride), 0) - connected = n - 1 - edges = [] - if shape == "ring": - steps = min(4, (connected - 1) // 2) - for a in range(connected): - expected[a * stride] = 2 * steps - for step in range(1, steps + 1): - b = (a + step) % connected - edges.extend([(a * stride, b * stride), (b * stride, a * stride)]) - elif shape == "hub": - expected.update((a * stride, 1) for a in range(connected)) - edges = [(0, b * stride) for b in range(1, connected)] - elif shape == "irregular": - dense_edges = irregular_edges(n) - expected = { - node * stride: core for node, core in enumerate(reference_core_numbers(n, dense_edges)) - } - edges = [(source * stride, target * stride) for source, target in dense_edges] - else: - width = connected if shape == "clique" else 16 - for start in range(0, connected, width): - end = min(start + width, connected) - expected.update((a * stride, end - start - 1) for a in range(start, end)) - edges.extend( - (a * stride, b * stride) for a in range(start, end) for b in range(a + 1, end) - ) - graph.add_edges_from_no_data(edges) - return graph, expected - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--nodes", type=int, default=10000) - parser.add_argument("--samples", type=int, default=20) - args = parser.parse_args() - if args.nodes < 8 or args.samples < 1: - parser.error("nodes must be at least 8 and samples must be positive") - print("shape,nodes,edges,stride,sample,nanoseconds") - for shape in ("ring", "hub", "cliques", "clique", "irregular"): - # Bound the quadratic fixture independently of the sparse graph size. - n = min(args.nodes, 256) if shape == "clique" else args.nodes - for stride in (1, 2): - graph, expected = fixture(shape, n, stride) - for sample in range(args.samples + 1): - start = time.perf_counter_ns() - result = rustworkx.core_number(graph) - elapsed = time.perf_counter_ns() - start - assert result == expected and list(result) == list(expected) - if sample: - print( - f"{shape},{n},{graph.num_edges()},{stride},{sample},{elapsed}", - flush=True, - ) - del result - - -if __name__ == "__main__": - main()