Skip to main content

torsh_fx/
subgraph_rewriter.rs

1//! Subgraph pattern matching and rewriting
2
3use crate::{FxGraph, Node, TorshResult};
4use petgraph::graph::NodeIndex;
5use petgraph::visit::EdgeRef;
6use std::collections::HashSet;
7use torsh_core::error::TorshError;
8
9/// Pattern matcher for subgraphs
10pub struct PatternMatcher {
11    /// Pattern to match
12    pattern: SubgraphPattern,
13}
14
15/// Represents a subgraph pattern to match
16#[derive(Debug, Clone)]
17pub struct SubgraphPattern {
18    /// Pattern name
19    pub name: String,
20    /// Sequence of operations in the pattern
21    pub operations: Vec<String>,
22    /// Replacement operation
23    pub replacement: String,
24}
25
26impl SubgraphPattern {
27    /// Create a new pattern
28    pub fn new(name: String, operations: Vec<String>, replacement: String) -> Self {
29        Self {
30            name,
31            operations,
32            replacement,
33        }
34    }
35
36    /// Create a linear activation fusion pattern
37    pub fn linear_relu_fusion() -> Self {
38        Self::new(
39            "linear_relu_fusion".to_string(),
40            vec!["linear".to_string(), "relu".to_string()],
41            "linear_relu".to_string(),
42        )
43    }
44
45    /// Create a conv activation fusion pattern
46    pub fn conv_relu_fusion() -> Self {
47        Self::new(
48            "conv_relu_fusion".to_string(),
49            vec!["conv2d".to_string(), "relu".to_string()],
50            "conv2d_relu".to_string(),
51        )
52    }
53
54    /// Create a batch norm fusion pattern
55    pub fn conv_bn_fusion() -> Self {
56        Self::new(
57            "conv_bn_fusion".to_string(),
58            vec!["conv2d".to_string(), "batch_norm".to_string()],
59            "conv2d_bn".to_string(),
60        )
61    }
62
63    /// Create a three-operation fusion pattern
64    pub fn conv_bn_relu_fusion() -> Self {
65        Self::new(
66            "conv_bn_relu_fusion".to_string(),
67            vec![
68                "conv2d".to_string(),
69                "batch_norm".to_string(),
70                "relu".to_string(),
71            ],
72            "conv2d_bn_relu".to_string(),
73        )
74    }
75}
76
77/// Match result for a pattern
78#[derive(Debug)]
79pub struct PatternMatch {
80    /// Matched node indices in order
81    pub nodes: Vec<NodeIndex>,
82    /// Pattern that was matched
83    pub pattern: SubgraphPattern,
84}
85
86impl PatternMatcher {
87    /// Create a new pattern matcher
88    pub fn new(pattern: SubgraphPattern) -> Self {
89        Self { pattern }
90    }
91
92    /// Find all matches of the pattern in the graph
93    pub fn find_matches(&self, graph: &FxGraph) -> Vec<PatternMatch> {
94        let mut matches = Vec::new();
95
96        // Iterate through all nodes to find potential starting points
97        for (start_idx, start_node) in graph.nodes() {
98            if let Some(pattern_match) = self.match_pattern_at(graph, start_idx, start_node) {
99                matches.push(pattern_match);
100            }
101        }
102
103        matches
104    }
105
106    /// Try to match pattern starting at given node
107    fn match_pattern_at(
108        &self,
109        graph: &FxGraph,
110        start_idx: NodeIndex,
111        start_node: &Node,
112    ) -> Option<PatternMatch> {
113        // Check if the first operation matches
114        if let Node::Call(op_name, _) = start_node {
115            if self.pattern.operations.is_empty() || &self.pattern.operations[0] != op_name {
116                return None;
117            }
118        } else {
119            return None;
120        }
121
122        // Try to match the complete pattern
123        if let Some(matched_nodes) = self.match_sequence(graph, start_idx, &self.pattern.operations)
124        {
125            return Some(PatternMatch {
126                nodes: matched_nodes,
127                pattern: self.pattern.clone(),
128            });
129        }
130
131        None
132    }
133
134    /// Match a sequence of operations starting from a node
135    fn match_sequence(
136        &self,
137        graph: &FxGraph,
138        start_idx: NodeIndex,
139        operations: &[String],
140    ) -> Option<Vec<NodeIndex>> {
141        if operations.is_empty() {
142            return Some(vec![]);
143        }
144
145        let mut current_nodes = vec![start_idx];
146        let mut matched_nodes = vec![start_idx];
147
148        // Match subsequent operations
149        for expected_op in &operations[1..] {
150            let mut next_nodes = Vec::new();
151
152            for &current_idx in &current_nodes {
153                // Find successors of current node
154                let successors: Vec<_> = graph
155                    .graph
156                    .neighbors_directed(current_idx, petgraph::Direction::Outgoing)
157                    .collect();
158
159                for successor_idx in successors {
160                    if let Some(Node::Call(op_name, _)) = graph.get_node(successor_idx) {
161                        if op_name == expected_op {
162                            next_nodes.push(successor_idx);
163                            matched_nodes.push(successor_idx);
164                        }
165                    }
166                }
167            }
168
169            if next_nodes.is_empty() {
170                return None; // Pattern doesn't match
171            }
172
173            current_nodes = next_nodes;
174        }
175
176        Some(matched_nodes)
177    }
178}
179
180/// Subgraph rewriter for applying pattern transformations
181pub struct SubgraphRewriter {
182    patterns: Vec<SubgraphPattern>,
183}
184
185impl SubgraphRewriter {
186    /// Create a new rewriter
187    pub fn new() -> Self {
188        Self {
189            patterns: Vec::new(),
190        }
191    }
192
193    /// Add a pattern to the rewriter
194    pub fn add_pattern(&mut self, pattern: SubgraphPattern) {
195        self.patterns.push(pattern);
196    }
197
198    /// Create a rewriter with common fusion patterns
199    pub fn with_common_fusions() -> Self {
200        let mut rewriter = Self::new();
201        rewriter.add_pattern(SubgraphPattern::linear_relu_fusion());
202        rewriter.add_pattern(SubgraphPattern::conv_relu_fusion());
203        rewriter.add_pattern(SubgraphPattern::conv_bn_fusion());
204        rewriter.add_pattern(SubgraphPattern::conv_bn_relu_fusion());
205        rewriter
206    }
207
208    /// Apply all patterns to the graph
209    pub fn apply(&self, graph: &mut FxGraph) -> TorshResult<usize> {
210        let mut total_replacements = 0;
211
212        for pattern in &self.patterns {
213            let replacements = self.apply_pattern(graph, pattern)?;
214            total_replacements += replacements;
215        }
216
217        Ok(total_replacements)
218    }
219
220    /// Apply a specific pattern to the graph
221    ///
222    /// Matches are recomputed after every rewrite: applying one rewrite rebuilds the
223    /// graph, which would leave any previously collected `NodeIndex` dangling.
224    fn apply_pattern(&self, graph: &mut FxGraph, pattern: &SubgraphPattern) -> TorshResult<usize> {
225        let matcher = PatternMatcher::new(pattern.clone());
226        let mut replacements = 0;
227        // The replacement operation never matches the pattern's first operation, so
228        // this terminates; the budget only guards against pathological patterns.
229        let mut budget = graph.node_count() + 1;
230
231        while budget > 0 {
232            budget -= 1;
233
234            let next_match = matcher
235                .find_matches(graph)
236                .into_iter()
237                .find(|candidate| Self::is_legal_match(graph, candidate));
238
239            match next_match {
240                Some(pattern_match) => {
241                    self.replace_match(graph, &pattern_match)?;
242                    replacements += 1;
243                }
244                None => break,
245            }
246        }
247
248        Ok(replacements)
249    }
250
251    /// Check that a match can be fused without changing the meaning of the graph
252    ///
253    /// A match is legal when it is a simple chain of distinct nodes whose length
254    /// equals the pattern length, every consecutive pair is connected, and no node
255    /// except the last one is consumed from outside the match (fusing a value that
256    /// other operations still read would silently feed them the fused result).
257    fn is_legal_match(graph: &FxGraph, pattern_match: &PatternMatch) -> bool {
258        let nodes = &pattern_match.nodes;
259        if nodes.len() != pattern_match.pattern.operations.len() || nodes.is_empty() {
260            return false;
261        }
262
263        let unique: HashSet<NodeIndex> = nodes.iter().copied().collect();
264        if unique.len() != nodes.len() {
265            return false;
266        }
267
268        for (position, &node_idx) in nodes.iter().enumerate() {
269            match graph.get_node(node_idx) {
270                Some(Node::Call(op_name, _))
271                    if *op_name == pattern_match.pattern.operations[position] => {}
272                _ => return false,
273            }
274
275            if position + 1 < nodes.len() {
276                let next_idx = nodes[position + 1];
277                if graph.graph.find_edge(node_idx, next_idx).is_none() {
278                    return false;
279                }
280                // Everything the fused nodes produce is consumed inside the match.
281                let consumers: HashSet<NodeIndex> = graph
282                    .graph
283                    .neighbors_directed(node_idx, petgraph::Direction::Outgoing)
284                    .collect();
285                if consumers.len() != 1 || !consumers.contains(&next_idx) {
286                    return false;
287                }
288            }
289        }
290
291        true
292    }
293
294    /// Replace a matched pattern with the replacement operation
295    fn replace_match(&self, graph: &mut FxGraph, pattern_match: &PatternMatch) -> TorshResult<()> {
296        if pattern_match.nodes.is_empty() {
297            return Ok(());
298        }
299
300        let first_node_idx = pattern_match.nodes[0];
301        let matched: HashSet<NodeIndex> = pattern_match.nodes.iter().copied().collect();
302
303        // Get the arguments from the first node
304        let mut args = if let Some(Node::Call(_, args)) = graph.get_node(first_node_idx) {
305            args.clone()
306        } else {
307            vec![]
308        };
309
310        // Rewire the folded nodes' external neighbours onto the fused node before
311        // anything is deleted: incoming edges from outside the pattern (weights,
312        // running statistics, ...) are real inputs of the fused operation and must
313        // not be dropped along with the node that used to consume them.
314        for &node_idx in &pattern_match.nodes[1..] {
315            let incoming: Vec<(NodeIndex, crate::Edge)> = graph
316                .graph
317                .edges_directed(node_idx, petgraph::Direction::Incoming)
318                .filter(|edge| !matched.contains(&edge.source()))
319                .map(|edge| (edge.source(), edge.weight().clone()))
320                .collect();
321            for (source_idx, weight) in incoming {
322                if !args.contains(&weight.name) {
323                    args.push(weight.name.clone());
324                }
325                if graph.graph.find_edge(source_idx, first_node_idx).is_none() {
326                    graph.graph.add_edge(source_idx, first_node_idx, weight);
327                }
328            }
329
330            let outgoing: Vec<(NodeIndex, crate::Edge)> = graph
331                .graph
332                .edges_directed(node_idx, petgraph::Direction::Outgoing)
333                .filter(|edge| !matched.contains(&edge.target()))
334                .map(|edge| (edge.target(), edge.weight().clone()))
335                .collect();
336            for (target_idx, weight) in outgoing {
337                if graph.graph.find_edge(first_node_idx, target_idx).is_none() {
338                    graph.graph.add_edge(first_node_idx, target_idx, weight);
339                }
340            }
341
342            // The fused node now produces what this node produced.
343            graph.redirect_boundary_node(node_idx, first_node_idx);
344        }
345
346        // Replace the first node with the fused operation
347        graph.graph[first_node_idx] = Node::Call(pattern_match.pattern.replacement.clone(), args);
348
349        // Remove the folded nodes in one batch through FxGraph, which keeps the
350        // input/output lists valid.
351        let to_remove: HashSet<NodeIndex> = pattern_match.nodes[1..].iter().copied().collect();
352        if !to_remove.is_empty() {
353            graph.remove_nodes(&to_remove);
354        }
355
356        Ok(())
357    }
358}
359
360impl Default for SubgraphRewriter {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366/// Convenience function for replacing patterns
367pub fn replace_pattern(graph: &mut FxGraph, pattern: &str, _replacement: &str) -> TorshResult<()> {
368    let pattern_obj = match pattern {
369        "linear->relu" => SubgraphPattern::linear_relu_fusion(),
370        "conv2d->relu" => SubgraphPattern::conv_relu_fusion(),
371        "conv2d->batch_norm" => SubgraphPattern::conv_bn_fusion(),
372        "conv2d->batch_norm->relu" => SubgraphPattern::conv_bn_relu_fusion(),
373        _ => {
374            return Err(TorshError::InvalidArgument(format!(
375                "Unknown pattern: {}",
376                pattern
377            )));
378        }
379    };
380
381    let mut rewriter = SubgraphRewriter::new();
382    rewriter.add_pattern(pattern_obj);
383    rewriter.apply(graph)?;
384
385    Ok(())
386}
387
388/// Apply common fusion optimizations
389pub fn apply_fusion_optimizations(graph: &mut FxGraph) -> TorshResult<usize> {
390    let rewriter = SubgraphRewriter::with_common_fusions();
391    rewriter.apply(graph)
392}
393
394/// Replace specific operation sequences
395pub fn replace_operation_sequence(
396    graph: &mut FxGraph,
397    sequence: &[&str],
398    replacement: &str,
399) -> TorshResult<()> {
400    let operations: Vec<String> = sequence.iter().map(|s| s.to_string()).collect();
401    let pattern = SubgraphPattern::new(
402        "custom_pattern".to_string(),
403        operations,
404        replacement.to_string(),
405    );
406
407    let mut rewriter = SubgraphRewriter::new();
408    rewriter.add_pattern(pattern);
409    rewriter.apply(graph)?;
410
411    Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::tracer::ModuleTracer;
418
419    #[test]
420    fn test_pattern_creation() {
421        let pattern = SubgraphPattern::linear_relu_fusion();
422        assert_eq!(pattern.name, "linear_relu_fusion");
423        assert_eq!(pattern.operations, vec!["linear", "relu"]);
424        assert_eq!(pattern.replacement, "linear_relu");
425    }
426
427    #[test]
428    fn test_pattern_matching() {
429        let mut tracer = ModuleTracer::new();
430        tracer.add_input("x");
431        tracer.add_call("linear", vec!["x".to_string()]);
432        tracer.add_call("relu", vec!["node_0".to_string()]);
433        tracer.add_output("node_1");
434        let graph = tracer.finalize();
435
436        let pattern = SubgraphPattern::linear_relu_fusion();
437        let matcher = PatternMatcher::new(pattern);
438        let matches = matcher.find_matches(&graph);
439
440        assert!(!matches.is_empty());
441    }
442
443    #[test]
444    fn test_subgraph_rewriting() {
445        let mut tracer = ModuleTracer::new();
446        tracer.add_input("x");
447        tracer.add_call("linear", vec!["x".to_string()]);
448        tracer.add_call("relu", vec!["node_0".to_string()]);
449        tracer.add_output("node_1");
450        let mut graph = tracer.finalize();
451
452        let original_node_count = graph.node_count();
453
454        let mut rewriter = SubgraphRewriter::new();
455        rewriter.add_pattern(SubgraphPattern::linear_relu_fusion());
456        let replacements = rewriter.apply(&mut graph).unwrap();
457
458        assert!(replacements > 0);
459        // Node count should decrease due to fusion
460        assert!(graph.node_count() < original_node_count);
461    }
462
463    #[test]
464    fn test_convenience_functions() {
465        let mut tracer = ModuleTracer::new();
466        tracer.add_input("x");
467        tracer.add_call("linear", vec!["x".to_string()]);
468        tracer.add_call("relu", vec!["node_0".to_string()]);
469        tracer.add_output("node_1");
470        let mut graph = tracer.finalize();
471
472        // Test string-based pattern replacement
473        assert!(replace_pattern(&mut graph, "linear->relu", "linear_relu").is_ok());
474
475        // Test operation sequence replacement
476        let mut tracer2 = ModuleTracer::new();
477        tracer2.add_input("x");
478        tracer2.add_call("conv2d", vec!["x".to_string()]);
479        tracer2.add_call("batch_norm", vec!["node_0".to_string()]);
480        tracer2.add_call("relu", vec!["node_1".to_string()]);
481        tracer2.add_output("node_2");
482        let mut graph2 = tracer2.finalize();
483
484        assert!(replace_operation_sequence(
485            &mut graph2,
486            &["conv2d", "batch_norm", "relu"],
487            "conv2d_bn_relu"
488        )
489        .is_ok());
490    }
491
492    #[test]
493    fn test_fusion_optimizations() {
494        let mut tracer = ModuleTracer::new();
495        tracer.add_input("x");
496        tracer.add_call("conv2d", vec!["x".to_string()]);
497        tracer.add_call("relu", vec!["node_0".to_string()]);
498        tracer.add_output("node_1");
499        let mut graph = tracer.finalize();
500
501        let _replacements = apply_fusion_optimizations(&mut graph).unwrap();
502        // Should run without error - replacements is a valid count
503    }
504}