Skip to main content

ipfrs_tensorlogic/
graph_partitioner.rs

1//! Tensor computation graph partitioner for distributed execution.
2//!
3//! Partitions a tensor computation graph into balanced subgraphs,
4//! minimizing cross-partition communication (cut edges).
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Data structures
10// ---------------------------------------------------------------------------
11
12/// Weight associated with a single computation node in the tensor graph.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct NodeWeight {
15    /// Unique identifier for this node.
16    pub node_id: u64,
17    /// Estimated floating-point operations (flops) for this node.
18    pub compute_cost: u64,
19    /// Memory footprint of this node's output tensor in bytes.
20    pub memory_bytes: u64,
21}
22
23/// A directed edge between two nodes in the tensor computation graph.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct GraphEdge {
26    /// Source node identifier.
27    pub from: u64,
28    /// Destination node identifier.
29    pub to: u64,
30    /// Number of bytes transferred across this edge when it is cut.
31    pub data_bytes: u64,
32}
33
34/// A single partition produced by the partitioner.
35#[derive(Debug, Clone)]
36pub struct Partition {
37    /// Zero-based partition index.
38    pub partition_id: usize,
39    /// Identifiers of all nodes assigned to this partition.
40    pub node_ids: Vec<u64>,
41    /// Sum of `compute_cost` for all nodes in this partition.
42    pub total_compute: u64,
43    /// Sum of `memory_bytes` for all nodes in this partition.
44    pub total_memory: u64,
45}
46
47impl Partition {
48    /// Returns the number of nodes in this partition.
49    pub fn size(&self) -> usize {
50        self.node_ids.len()
51    }
52}
53
54/// Statistics describing a completed partitioning.
55#[derive(Debug, Clone)]
56pub struct PartitionStats {
57    /// Number of partitions produced.
58    pub num_partitions: usize,
59    /// Number of edges whose endpoints are in different partitions.
60    pub cut_edges: usize,
61    /// Total bytes transferred across all cut edges.
62    pub cut_bytes: u64,
63    /// Imbalance metric:
64    /// `(max_compute − min_compute) / avg_compute`, or `0.0` for one partition.
65    pub compute_imbalance: f64,
66}
67
68impl PartitionStats {
69    /// Returns `true` when `compute_imbalance < threshold`.
70    pub fn is_balanced(&self, threshold: f64) -> bool {
71        self.compute_imbalance < threshold
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Partitioner
77// ---------------------------------------------------------------------------
78
79/// Partitions a tensor computation graph into balanced subgraphs for
80/// distributed execution, minimising cross-partition edge traffic.
81#[derive(Debug, Default)]
82pub struct TensorGraphPartitioner {
83    /// All nodes, keyed by their `node_id`.
84    pub nodes: HashMap<u64, NodeWeight>,
85    /// All directed edges in the graph.
86    pub edges: Vec<GraphEdge>,
87}
88
89impl TensorGraphPartitioner {
90    /// Creates an empty `TensorGraphPartitioner`.
91    pub fn new() -> Self {
92        Self {
93            nodes: HashMap::new(),
94            edges: Vec::new(),
95        }
96    }
97
98    /// Inserts (or replaces) a node in the graph.
99    pub fn add_node(&mut self, node: NodeWeight) {
100        self.nodes.insert(node.node_id, node);
101    }
102
103    /// Appends an edge to the graph.
104    pub fn add_edge(&mut self, edge: GraphEdge) {
105        self.edges.push(edge);
106    }
107
108    /// Removes a node and returns `true` if it was present.
109    ///
110    /// Note: edges referencing the removed node are **not** automatically
111    /// removed; callers that need edge consistency should manage this manually.
112    pub fn remove_node(&mut self, node_id: u64) -> bool {
113        self.nodes.remove(&node_id).is_some()
114    }
115
116    /// Partitions the graph into at most `k` parts.
117    ///
118    /// # Strategy
119    ///
120    /// 1. If `k == 0` or the graph has no nodes, return an empty vector.
121    /// 2. If `k >= node count`, assign one node per partition (only non-empty
122    ///    partitions are returned).
123    /// 3. Otherwise, sort all nodes by `compute_cost` descending and assign
124    ///    each node to the partition with the lowest current total compute cost
125    ///    (greedy min-heap approximation via linear scan over `k` partitions).
126    pub fn partition(&self, k: usize) -> Vec<Partition> {
127        if k == 0 || self.nodes.is_empty() {
128            return Vec::new();
129        }
130
131        // Collect nodes sorted by compute_cost descending (ties broken by node_id
132        // for determinism).
133        let mut sorted_nodes: Vec<&NodeWeight> = self.nodes.values().collect();
134        sorted_nodes.sort_unstable_by(|a, b| {
135            b.compute_cost
136                .cmp(&a.compute_cost)
137                .then_with(|| a.node_id.cmp(&b.node_id))
138        });
139
140        let num_partitions = k.min(sorted_nodes.len());
141
142        // Initialise partitions.
143        let mut partitions: Vec<Partition> = (0..num_partitions)
144            .map(|pid| Partition {
145                partition_id: pid,
146                node_ids: Vec::new(),
147                total_compute: 0,
148                total_memory: 0,
149            })
150            .collect();
151
152        if num_partitions == sorted_nodes.len() {
153            // One node per partition – simple assignment.
154            for (node, partition) in sorted_nodes.iter().zip(partitions.iter_mut()) {
155                partition.node_ids.push(node.node_id);
156                partition.total_compute = node.compute_cost;
157                partition.total_memory = node.memory_bytes;
158            }
159        } else {
160            // Greedy: assign each node to the partition with the smallest
161            // current total_compute (linear scan, O(n·k)).
162            for node in &sorted_nodes {
163                let target = partitions
164                    .iter()
165                    .enumerate()
166                    .min_by_key(|(_, p)| p.total_compute)
167                    .map(|(i, _)| i)
168                    .unwrap_or(0); // safe: num_partitions >= 1
169
170                partitions[target].node_ids.push(node.node_id);
171                partitions[target].total_compute = partitions[target]
172                    .total_compute
173                    .saturating_add(node.compute_cost);
174                partitions[target].total_memory = partitions[target]
175                    .total_memory
176                    .saturating_add(node.memory_bytes);
177            }
178        }
179
180        // Return only non-empty partitions.
181        partitions.retain(|p| !p.node_ids.is_empty());
182        partitions
183    }
184
185    /// Computes statistics for the given set of partitions.
186    pub fn stats(&self, partitions: &[Partition]) -> PartitionStats {
187        // Build node → partition_id lookup.
188        let mut node_to_partition: HashMap<u64, usize> = HashMap::new();
189        for partition in partitions {
190            for &nid in &partition.node_ids {
191                node_to_partition.insert(nid, partition.partition_id);
192            }
193        }
194
195        // Count cut edges.
196        let mut cut_edges: usize = 0;
197        let mut cut_bytes: u64 = 0;
198        for edge in &self.edges {
199            let from_part = node_to_partition.get(&edge.from);
200            let to_part = node_to_partition.get(&edge.to);
201            match (from_part, to_part) {
202                (Some(fp), Some(tp)) if fp != tp => {
203                    cut_edges += 1;
204                    cut_bytes = cut_bytes.saturating_add(edge.data_bytes);
205                }
206                _ => {}
207            }
208        }
209
210        // Compute imbalance.
211        let num_partitions = partitions.len();
212        let compute_imbalance = if num_partitions <= 1 {
213            0.0_f64
214        } else {
215            let computes: Vec<u64> = partitions.iter().map(|p| p.total_compute).collect();
216            let max_c = computes.iter().copied().max().unwrap_or(0);
217            let min_c = computes.iter().copied().min().unwrap_or(0);
218            let avg_c: f64 = computes.iter().copied().sum::<u64>() as f64 / num_partitions as f64;
219            if avg_c == 0.0 {
220                0.0
221            } else {
222                (max_c - min_c) as f64 / avg_c
223            }
224        };
225
226        PartitionStats {
227            num_partitions,
228            cut_edges,
229            cut_bytes,
230            compute_imbalance,
231        }
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Tests
237// ---------------------------------------------------------------------------
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    // Helper: build a NodeWeight quickly.
244    fn node(id: u64, compute: u64, mem: u64) -> NodeWeight {
245        NodeWeight {
246            node_id: id,
247            compute_cost: compute,
248            memory_bytes: mem,
249        }
250    }
251
252    // Helper: build a GraphEdge quickly.
253    fn edge(from: u64, to: u64, bytes: u64) -> GraphEdge {
254        GraphEdge {
255            from,
256            to,
257            data_bytes: bytes,
258        }
259    }
260
261    // 1. Empty graph returns empty vec.
262    #[test]
263    fn test_empty_graph_returns_empty() {
264        let p = TensorGraphPartitioner::new();
265        assert!(p.partition(4).is_empty());
266    }
267
268    // 2. k == 0 returns empty vec even with nodes.
269    #[test]
270    fn test_k_zero_returns_empty() {
271        let mut p = TensorGraphPartitioner::new();
272        p.add_node(node(1, 100, 64));
273        assert!(p.partition(0).is_empty());
274    }
275
276    // 3. k == 1 → single partition gets all nodes.
277    #[test]
278    fn test_single_partition_contains_all_nodes() {
279        let mut p = TensorGraphPartitioner::new();
280        for i in 1..=5_u64 {
281            p.add_node(node(i, i * 10, i * 8));
282        }
283        let parts = p.partition(1);
284        assert_eq!(parts.len(), 1);
285        assert_eq!(parts[0].size(), 5);
286        // total_compute should be 10+20+30+40+50 = 150
287        assert_eq!(parts[0].total_compute, 150);
288        // total_memory should be 8+16+24+32+40 = 120
289        assert_eq!(parts[0].total_memory, 120);
290    }
291
292    // 4. k >= node count → one node per partition (only non-empty returned).
293    #[test]
294    fn test_k_greater_than_nodes_one_per_partition() {
295        let mut p = TensorGraphPartitioner::new();
296        p.add_node(node(10, 50, 8));
297        p.add_node(node(20, 50, 8));
298        p.add_node(node(30, 50, 8));
299        let parts = p.partition(10);
300        assert_eq!(parts.len(), 3);
301        for part in &parts {
302            assert_eq!(part.size(), 1);
303        }
304    }
305
306    // 5. k == node count → one node per partition exactly.
307    #[test]
308    fn test_k_equals_node_count() {
309        let mut p = TensorGraphPartitioner::new();
310        p.add_node(node(1, 100, 10));
311        p.add_node(node(2, 200, 20));
312        let parts = p.partition(2);
313        assert_eq!(parts.len(), 2);
314        assert!(parts.iter().all(|pt| pt.size() == 1));
315    }
316
317    // 6. Greedy balancing: 2 partitions, nodes with varied compute.
318    //    The greedy algorithm should distribute so that large nodes are spread.
319    #[test]
320    fn test_greedy_balancing_reduces_imbalance() {
321        let mut p = TensorGraphPartitioner::new();
322        // Four nodes: 100, 100, 50, 50 → ideal 2 parts of 150 each.
323        p.add_node(node(1, 100, 0));
324        p.add_node(node(2, 100, 0));
325        p.add_node(node(3, 50, 0));
326        p.add_node(node(4, 50, 0));
327        let parts = p.partition(2);
328        assert_eq!(parts.len(), 2);
329        assert_eq!(parts[0].total_compute, 150);
330        assert_eq!(parts[1].total_compute, 150);
331    }
332
333    // 7. Imbalance is 0 for a single partition.
334    #[test]
335    fn test_stats_single_partition_zero_imbalance() {
336        let mut p = TensorGraphPartitioner::new();
337        p.add_node(node(1, 500, 64));
338        let parts = p.partition(1);
339        let s = p.stats(&parts);
340        assert_eq!(s.num_partitions, 1);
341        assert!((s.compute_imbalance - 0.0).abs() < f64::EPSILON);
342    }
343
344    // 8. compute_imbalance formula verification.
345    //    Two partitions: computes 300 and 100 → avg=200, imbalance=(300-100)/200=1.0
346    #[test]
347    fn test_compute_imbalance_formula() {
348        let mut p = TensorGraphPartitioner::new();
349        // Force a 2-partition result with known totals by using k >= node_count (1:1).
350        p.add_node(node(1, 300, 0));
351        p.add_node(node(2, 100, 0));
352        let parts = p.partition(2);
353        let s = p.stats(&parts);
354        // (300-100)/200 = 1.0
355        assert!(
356            (s.compute_imbalance - 1.0).abs() < 1e-9,
357            "imbalance={}",
358            s.compute_imbalance
359        );
360    }
361
362    // 9. is_balanced threshold check.
363    #[test]
364    fn test_is_balanced_threshold() {
365        let mut p = TensorGraphPartitioner::new();
366        p.add_node(node(1, 300, 0));
367        p.add_node(node(2, 100, 0));
368        let parts = p.partition(2);
369        let s = p.stats(&parts);
370        // imbalance == 1.0 → balanced at threshold 1.1, unbalanced at 0.9
371        assert!(s.is_balanced(1.1));
372        assert!(!s.is_balanced(0.9));
373    }
374
375    // 10. cut_edges count: edges within same partition are not cut.
376    #[test]
377    fn test_cut_edges_same_partition_not_cut() {
378        let mut p = TensorGraphPartitioner::new();
379        p.add_node(node(1, 100, 0));
380        p.add_node(node(2, 50, 0));
381        // k=1 → all in one partition → no cuts.
382        p.add_edge(edge(1, 2, 1024));
383        let parts = p.partition(1);
384        let s = p.stats(&parts);
385        assert_eq!(s.cut_edges, 0);
386        assert_eq!(s.cut_bytes, 0);
387    }
388
389    // 11. cut_edges count across partitions.
390    #[test]
391    fn test_cut_edges_cross_partition() {
392        let mut p = TensorGraphPartitioner::new();
393        p.add_node(node(1, 100, 0));
394        p.add_node(node(2, 100, 0));
395        p.add_edge(edge(1, 2, 512));
396        // k=2 → two separate partitions → edge (1→2) is cut.
397        let parts = p.partition(2);
398        let s = p.stats(&parts);
399        assert_eq!(s.cut_edges, 1);
400        assert_eq!(s.cut_bytes, 512);
401    }
402
403    // 12. cut_bytes sums all cut edge data_bytes.
404    #[test]
405    fn test_cut_bytes_sum() {
406        let mut p = TensorGraphPartitioner::new();
407        p.add_node(node(1, 100, 0));
408        p.add_node(node(2, 100, 0));
409        p.add_edge(edge(1, 2, 200));
410        p.add_edge(edge(1, 2, 300));
411        let parts = p.partition(2);
412        let s = p.stats(&parts);
413        // Both edges connect different partitions.
414        assert_eq!(s.cut_bytes, 500);
415    }
416
417    // 13. Edges referencing unknown nodes are ignored (not counted as cut).
418    #[test]
419    fn test_edges_with_unknown_nodes_ignored() {
420        let mut p = TensorGraphPartitioner::new();
421        p.add_node(node(1, 100, 0));
422        // node 99 does not exist.
423        p.add_edge(edge(1, 99, 999));
424        let parts = p.partition(1);
425        let s = p.stats(&parts);
426        assert_eq!(s.cut_edges, 0);
427        assert_eq!(s.cut_bytes, 0);
428    }
429
430    // 14. remove_node returns true if present, false otherwise.
431    #[test]
432    fn test_remove_node_present() {
433        let mut p = TensorGraphPartitioner::new();
434        p.add_node(node(42, 10, 8));
435        assert!(p.remove_node(42));
436        assert!(!p.nodes.contains_key(&42));
437    }
438
439    // 15. remove_node returns false when node is absent.
440    #[test]
441    fn test_remove_node_absent() {
442        let mut p = TensorGraphPartitioner::new();
443        assert!(!p.remove_node(999));
444    }
445
446    // 16. add_edge appends correctly.
447    #[test]
448    fn test_add_edge_appended() {
449        let mut p = TensorGraphPartitioner::new();
450        p.add_edge(edge(1, 2, 64));
451        p.add_edge(edge(2, 3, 128));
452        assert_eq!(p.edges.len(), 2);
453        assert_eq!(p.edges[0].data_bytes, 64);
454        assert_eq!(p.edges[1].data_bytes, 128);
455    }
456
457    // 17. Partition size method reflects node_ids length.
458    #[test]
459    fn test_partition_size_method() {
460        let part = Partition {
461            partition_id: 0,
462            node_ids: vec![1, 2, 3],
463            total_compute: 300,
464            total_memory: 48,
465        };
466        assert_eq!(part.size(), 3);
467    }
468
469    // 18. stats on multiple partitions with no edges gives zero cuts.
470    #[test]
471    fn test_stats_no_edges_zero_cuts() {
472        let mut p = TensorGraphPartitioner::new();
473        for i in 1..=4_u64 {
474            p.add_node(node(i, i * 100, i * 8));
475        }
476        let parts = p.partition(2);
477        let s = p.stats(&parts);
478        assert_eq!(s.cut_edges, 0);
479        assert_eq!(s.cut_bytes, 0);
480    }
481
482    // 19. Greedy assigns largest nodes first, checking monotone property.
483    //     With nodes [1000,1,1,1] and k=2, the ideal split is [1000] vs [1,1,1]=3.
484    //     The greedy produces partition-0: [1000], partition-1: [1,1,1].
485    #[test]
486    fn test_greedy_largest_first_assignment() {
487        let mut p = TensorGraphPartitioner::new();
488        p.add_node(node(1, 1000, 0));
489        p.add_node(node(2, 1, 0));
490        p.add_node(node(3, 1, 0));
491        p.add_node(node(4, 1, 0));
492        let parts = p.partition(2);
493        assert_eq!(parts.len(), 2);
494        let computes: Vec<u64> = parts.iter().map(|p| p.total_compute).collect();
495        // One partition should have 1000 and the other 3.
496        assert!(computes.contains(&1000));
497        assert!(computes.contains(&3));
498    }
499
500    // 20. num_partitions in stats matches the slice length.
501    #[test]
502    fn test_stats_num_partitions_matches_slice() {
503        let mut p = TensorGraphPartitioner::new();
504        for i in 1..=6_u64 {
505            p.add_node(node(i, 10, 8));
506        }
507        let parts = p.partition(3);
508        let s = p.stats(&parts);
509        assert_eq!(s.num_partitions, parts.len());
510    }
511}