Skip to main content

tenflowers_core/graph/optimization/
manager.rs

1//! Graph optimization manager and statistics
2//!
3//! This module provides the main graph optimizer that orchestrates all optimization
4//! passes and collects statistics about the optimization process.
5
6use super::fusion::OperationFusionPass;
7use super::memory::MemoryOptimizationPass;
8use super::passes::OptimizationPass;
9use super::passes::{
10    AlgebraicSimplificationPass, CSEPass, ConstantFoldingPass, DeadCodeEliminationPass,
11    OperationSchedulingPass, StrengthReductionPass,
12};
13use super::placement::DevicePlacementOptimizationPass;
14use crate::graph::{Graph, NodeId};
15use crate::Result;
16use std::collections::{HashMap, HashSet};
17
18/// Graph optimization manager
19pub struct GraphOptimizer {
20    passes: Vec<Box<dyn OptimizationPass>>,
21    max_iterations: usize,
22}
23
24impl GraphOptimizer {
25    /// Create a new graph optimizer with default passes
26    pub fn new() -> Self {
27        let mut optimizer = Self::empty();
28        optimizer.add_default_passes();
29        optimizer
30    }
31
32    /// Create an empty optimizer with no passes
33    pub fn empty() -> Self {
34        Self {
35            passes: Vec::new(),
36            max_iterations: 10,
37        }
38    }
39
40    /// Add default optimization passes
41    pub fn add_default_passes(&mut self) {
42        // Add passes in priority order (highest priority first)
43        self.add_pass(Box::new(ConstantFoldingPass::new())); // Priority 200
44        self.add_pass(Box::new(AlgebraicSimplificationPass::new())); // Priority 180
45        self.add_pass(Box::new(CSEPass::new())); // Priority 150
46        self.add_pass(Box::new(StrengthReductionPass::new())); // Priority 140
47        self.add_pass(Box::new(OperationSchedulingPass::new())); // Priority 120
48        self.add_pass(Box::new(OperationFusionPass::new()));
49        self.add_pass(Box::new(MemoryOptimizationPass::new()));
50        self.add_pass(Box::new(DevicePlacementOptimizationPass::new()));
51        self.add_pass(Box::new(DeadCodeEliminationPass::new())); // Priority 50 (run last)
52    }
53
54    /// Build an optimizer using only the passes that are safe to run ahead
55    /// of the eager `Session` executor.
56    ///
57    /// This deliberately excludes:
58    /// - [`OperationFusionPass`]: it rewrites nodes into fused operation
59    ///   names (`"Dense"`, `"AddReLU"`, `"ConvBatchNormReLU"`, ...) that the
60    ///   eager executor's operation dispatcher does not implement. Running
61    ///   it here would silently turn a working graph (e.g. any `MatMul`
62    ///   immediately followed by `Add`) into a hard
63    ///   "operation not supported" error at execution time.
64    /// - [`MemoryOptimizationPass`] and [`DevicePlacementOptimizationPass`]:
65    ///   both only annotate node attributes (lifetime hints, in-place
66    ///   markers, device placement metadata) that the eager executor never
67    ///   reads, so running them would add cost without any effect.
68    ///
69    /// The remaining passes (constant folding, algebraic simplification,
70    /// common subexpression elimination, strength reduction, scheduling,
71    /// and dead code elimination) only ever produce operation names the
72    /// executor already understands, or mutate a node's inputs/op in place
73    /// while preserving its id, so they are safe to run unconditionally.
74    pub fn for_eager_execution() -> Self {
75        let mut optimizer = Self::empty();
76        optimizer.add_pass(Box::new(ConstantFoldingPass::new()));
77        optimizer.add_pass(Box::new(AlgebraicSimplificationPass::new()));
78        optimizer.add_pass(Box::new(CSEPass::new()));
79        optimizer.add_pass(Box::new(StrengthReductionPass::new()));
80        optimizer.add_pass(Box::new(OperationSchedulingPass::new()));
81        optimizer.add_pass(Box::new(DeadCodeEliminationPass::new()));
82        optimizer
83    }
84
85    /// Add an optimization pass
86    pub fn add_pass(&mut self, pass: Box<dyn OptimizationPass>) {
87        self.passes.push(pass);
88        // Sort by priority (highest first)
89        self.passes.sort_by_key(|b| std::cmp::Reverse(b.priority()));
90    }
91
92    /// Set maximum number of optimization iterations
93    pub fn set_max_iterations(&mut self, max_iterations: usize) {
94        self.max_iterations = max_iterations;
95    }
96
97    /// Get the number of optimization passes
98    pub fn pass_count(&self) -> usize {
99        self.passes.len()
100    }
101
102    /// Optimize a computation graph
103    pub fn optimize(&self, graph: &mut Graph) -> Result<OptimizationStats> {
104        let mut stats = OptimizationStats::new();
105        let mut iteration = 0;
106
107        while iteration < self.max_iterations {
108            let mut changed_in_iteration = false;
109
110            for pass in &self.passes {
111                if pass.is_applicable(graph) {
112                    let start_time = std::time::Instant::now();
113                    let changed = pass.apply(graph)?;
114                    let duration = start_time.elapsed();
115
116                    stats.record_pass(pass.name(), duration, changed);
117
118                    if changed {
119                        changed_in_iteration = true;
120                    }
121                }
122            }
123
124            if !changed_in_iteration {
125                break; // Converged
126            }
127
128            iteration += 1;
129            stats.iterations += 1;
130        }
131
132        Ok(stats)
133    }
134
135    /// Optimize a computation graph while protecting `outputs` from being
136    /// eliminated or merged away.
137    ///
138    /// `outputs` should contain every node id that must keep its identity
139    /// after optimization — typically the fetch targets a caller (such as
140    /// the eager `Session` executor) intends to read back by node id or
141    /// name. Every pass is informed of this set via
142    /// [`OptimizationPass::set_outputs`] before running; passes that never
143    /// destroy a node's identity ignore it, while dead code elimination,
144    /// common subexpression elimination, and identity-removing algebraic /
145    /// strength-reduction rewrites treat these ids as protected (skipping
146    /// the rewrite for that node this iteration rather than applying it).
147    pub fn optimize_with_outputs(
148        &self,
149        graph: &mut Graph,
150        outputs: &HashSet<NodeId>,
151    ) -> Result<OptimizationStats> {
152        for pass in &self.passes {
153            pass.set_outputs(outputs);
154        }
155        self.optimize(graph)
156    }
157}
158
159impl Default for GraphOptimizer {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165/// Statistics about graph optimization
166#[derive(Debug, Clone)]
167pub struct OptimizationStats {
168    pub iterations: usize,
169    pub total_time: std::time::Duration,
170    pub pass_stats: HashMap<String, PassStats>,
171}
172
173#[derive(Debug, Clone)]
174pub struct PassStats {
175    pub runs: usize,
176    pub changes: usize,
177    pub total_time: std::time::Duration,
178}
179
180impl OptimizationStats {
181    fn new() -> Self {
182        Self {
183            iterations: 0,
184            total_time: std::time::Duration::new(0, 0),
185            pass_stats: HashMap::new(),
186        }
187    }
188
189    fn record_pass(&mut self, pass_name: &str, duration: std::time::Duration, changed: bool) {
190        self.total_time += duration;
191
192        let stats = self
193            .pass_stats
194            .entry(pass_name.to_string())
195            .or_insert(PassStats {
196                runs: 0,
197                changes: 0,
198                total_time: std::time::Duration::new(0, 0),
199            });
200
201        stats.runs += 1;
202        stats.total_time += duration;
203        if changed {
204            stats.changes += 1;
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_graph_optimizer_creation() {
215        let optimizer = GraphOptimizer::new();
216        assert_eq!(optimizer.passes.len(), 9); // All default passes
217
218        let empty_optimizer = GraphOptimizer::empty();
219        assert_eq!(empty_optimizer.passes.len(), 0);
220    }
221
222    #[test]
223    fn test_optimization_stats() {
224        let mut stats = OptimizationStats::new();
225        assert_eq!(stats.iterations, 0);
226        assert_eq!(stats.pass_stats.len(), 0);
227
228        stats.record_pass("test_pass", std::time::Duration::from_millis(10), true);
229        assert_eq!(stats.pass_stats.len(), 1);
230        assert!(stats.pass_stats.contains_key("test_pass"));
231    }
232
233    #[test]
234    fn test_pass_addition() {
235        let mut optimizer = GraphOptimizer::empty();
236
237        // Add a pass and verify it's added
238        optimizer.add_pass(Box::new(ConstantFoldingPass::new()));
239        assert_eq!(optimizer.passes.len(), 1);
240
241        // Add another pass and verify sorting by priority
242        optimizer.add_pass(Box::new(DeadCodeEliminationPass::new()));
243        assert_eq!(optimizer.passes.len(), 2);
244
245        // Verify higher priority pass comes first
246        assert!(optimizer.passes[0].priority() >= optimizer.passes[1].priority());
247    }
248
249    #[test]
250    fn test_max_iterations() {
251        let mut optimizer = GraphOptimizer::new();
252        optimizer.set_max_iterations(5);
253        assert_eq!(optimizer.max_iterations, 5);
254    }
255
256    #[test]
257    fn test_for_eager_execution_excludes_unsupported_passes() {
258        // The eager executor's operation dispatcher does not implement the
259        // fused op names OperationFusionPass produces, and it never reads
260        // the metadata MemoryOptimizationPass / DevicePlacementOptimizationPass
261        // attach, so `for_eager_execution` must omit all three while keeping
262        // the other six passes `add_default_passes` registers.
263        let eager = GraphOptimizer::for_eager_execution();
264        assert_eq!(eager.pass_count(), 6);
265
266        let default_optimizer = GraphOptimizer::new();
267        assert_eq!(default_optimizer.pass_count(), 9);
268
269        for pass in &eager.passes {
270            assert_ne!(pass.name(), "OperationFusion");
271            assert_ne!(pass.name(), "MemoryOptimization");
272            assert_ne!(pass.name(), "DevicePlacementOptimization");
273        }
274    }
275
276    #[test]
277    fn test_optimize_with_outputs_protects_marked_node() {
278        // `x + 0` would normally simplify away to `x`, losing the node id.
279        // Marking it as an output must prevent that.
280        let mut graph = Graph::new();
281        let x = graph
282            .add_node(
283                "x".to_string(),
284                crate::graph::NodeType::Placeholder {
285                    dtype: crate::dtype::DType::Float32,
286                    shape: crate::shape::Shape::new(vec![]),
287                },
288                crate::device::Device::Cpu,
289                HashMap::new(),
290            )
291            .expect("test: add_node should succeed");
292        let mut zero_attrs = HashMap::new();
293        zero_attrs.insert(
294            "value".to_string(),
295            crate::graph::AttributeValue::Tensor(crate::tensor::Tensor::from_scalar(0.0f32)),
296        );
297        let zero = graph
298            .add_node(
299                "zero".to_string(),
300                crate::graph::NodeType::Constant,
301                crate::device::Device::Cpu,
302                zero_attrs,
303            )
304            .expect("test: add_node should succeed");
305        let add = graph
306            .add_node(
307                "x_plus_zero".to_string(),
308                crate::graph::NodeType::Operation("Add".to_string()),
309                crate::device::Device::Cpu,
310                HashMap::new(),
311            )
312            .expect("test: add_node should succeed");
313        graph
314            .add_edge(
315                x,
316                add,
317                0,
318                0,
319                crate::dtype::DType::Float32,
320                crate::shape::Shape::new(vec![]),
321                false,
322            )
323            .expect("test: add_edge should succeed");
324        graph
325            .add_edge(
326                zero,
327                add,
328                0,
329                1,
330                crate::dtype::DType::Float32,
331                crate::shape::Shape::new(vec![]),
332                false,
333            )
334            .expect("test: add_edge should succeed");
335
336        let optimizer = GraphOptimizer::for_eager_execution();
337        let mut outputs = HashSet::new();
338        outputs.insert(add);
339        optimizer
340            .optimize_with_outputs(&mut graph, &outputs)
341            .expect("test: optimize_with_outputs should succeed");
342
343        assert!(
344            graph.get_node(add).is_some(),
345            "the marked output node must survive optimization"
346        );
347    }
348
349    #[test]
350    fn test_pass_stats_recording() {
351        let mut stats = OptimizationStats::new();
352
353        // Record some passes
354        stats.record_pass("pass1", std::time::Duration::from_millis(10), true);
355        stats.record_pass("pass1", std::time::Duration::from_millis(5), false);
356        stats.record_pass("pass2", std::time::Duration::from_millis(20), true);
357
358        // Verify stats
359        assert_eq!(stats.pass_stats.len(), 2);
360
361        let pass1_stats = &stats.pass_stats["pass1"];
362        assert_eq!(pass1_stats.runs, 2);
363        assert_eq!(pass1_stats.changes, 1);
364        assert_eq!(pass1_stats.total_time, std::time::Duration::from_millis(15));
365
366        let pass2_stats = &stats.pass_stats["pass2"];
367        assert_eq!(pass2_stats.runs, 1);
368        assert_eq!(pass2_stats.changes, 1);
369        assert_eq!(pass2_stats.total_time, std::time::Duration::from_millis(20));
370
371        // Verify total time
372        assert_eq!(stats.total_time, std::time::Duration::from_millis(35));
373    }
374}