Skip to main content

tenflowers_core/
deployment.rs

1/*!
2 * Model deployment optimization for TenfloweRS
3 *
4 * This module provides optimization techniques for deploying trained models
5 * to production environments, including graph freezing, constant folding,
6 * and dead code elimination.
7 */
8
9use crate::{
10    graph::{Graph, GraphNode, NodeId, NodeType},
11    Result,
12};
13#[cfg(feature = "serialize")]
14use serde::{Deserialize, Serialize};
15use std::collections::HashSet;
16
17/// Configuration for graph freezing optimization
18#[derive(Debug, Clone)]
19#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
20pub struct GraphFreezingConfig {
21    /// Remove training-specific operations (dropout, batch norm training mode)
22    pub remove_training_ops: bool,
23    /// Fold constants into the graph
24    pub fold_constants: bool,
25    /// Remove unused nodes
26    pub remove_dead_code: bool,
27    /// Optimize sequential operations
28    pub optimize_sequential_ops: bool,
29    /// Target device for optimization
30    pub target_device: String,
31}
32
33impl Default for GraphFreezingConfig {
34    fn default() -> Self {
35        Self {
36            remove_training_ops: true,
37            fold_constants: true,
38            remove_dead_code: true,
39            optimize_sequential_ops: true,
40            target_device: "cpu".to_string(),
41        }
42    }
43}
44
45/// Statistics from graph freezing optimization
46#[derive(Debug, Clone)]
47#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
48pub struct GraphFreezingStats {
49    /// Number of nodes before optimization
50    pub nodes_before: usize,
51    /// Number of nodes after optimization
52    pub nodes_after: usize,
53    /// Number of constants folded
54    pub constants_folded: usize,
55    /// Number of training ops removed
56    pub training_ops_removed: usize,
57    /// Number of dead nodes removed
58    pub dead_nodes_removed: usize,
59    /// Estimated memory savings (bytes)
60    pub memory_savings: usize,
61}
62
63/// Graph freezing optimizer for model deployment
64pub struct GraphFreezer {
65    config: GraphFreezingConfig,
66}
67
68impl GraphFreezer {
69    /// Create a new graph freezer with default configuration
70    pub fn new() -> Self {
71        Self {
72            config: GraphFreezingConfig::default(),
73        }
74    }
75
76    /// Create a new graph freezer with custom configuration
77    pub fn with_config(config: GraphFreezingConfig) -> Self {
78        Self { config }
79    }
80
81    /// Freeze a graph for inference deployment
82    pub fn freeze_graph(&self, graph: &Graph) -> Result<(Graph, GraphFreezingStats)> {
83        let mut frozen_graph = graph.clone();
84        let mut stats = GraphFreezingStats {
85            nodes_before: frozen_graph.node_count(),
86            nodes_after: 0,
87            constants_folded: 0,
88            training_ops_removed: 0,
89            dead_nodes_removed: 0,
90            memory_savings: 0,
91        };
92
93        // Step 1: Remove training-specific operations
94        if self.config.remove_training_ops {
95            self.remove_training_operations(&mut frozen_graph, &mut stats)?;
96        }
97
98        // Step 2: Fold constants
99        if self.config.fold_constants {
100            self.fold_constants(&mut frozen_graph, &mut stats)?;
101        }
102
103        // Step 3: Remove dead code
104        if self.config.remove_dead_code {
105            self.remove_dead_code(&mut frozen_graph, &mut stats)?;
106        }
107
108        // Step 4: Optimize sequential operations
109        if self.config.optimize_sequential_ops {
110            self.optimize_sequential_operations(&mut frozen_graph, &mut stats)?;
111        }
112
113        stats.nodes_after = frozen_graph.node_count();
114        stats.memory_savings = self.estimate_memory_savings(&stats);
115
116        Ok((frozen_graph, stats))
117    }
118
119    /// Remove training-specific operations from the graph
120    fn remove_training_operations(
121        &self,
122        graph: &mut Graph,
123        stats: &mut GraphFreezingStats,
124    ) -> Result<()> {
125        let training_ops = ["Dropout", "BatchNorm", "GroupNorm", "LayerNorm"];
126        let mut ops_to_remove = Vec::new();
127
128        // Find training operations
129        for node in graph.nodes() {
130            if let NodeType::Operation(op_name) = &node.op_type {
131                if training_ops.contains(&op_name.as_str()) {
132                    // Check if this is a training-mode operation
133                    if self.is_training_mode_operation(node) {
134                        ops_to_remove.push(node.id);
135                    }
136                }
137            }
138        }
139
140        // Remove or replace training operations
141        for node_id in ops_to_remove {
142            self.replace_training_operation(graph, node_id)?;
143            stats.training_ops_removed += 1;
144        }
145
146        Ok(())
147    }
148
149    /// Check if an operation is in training mode
150    fn is_training_mode_operation(&self, node: &GraphNode) -> bool {
151        // Check operation attributes for training mode
152        if let Some(training_attr) = node.attributes.get("training") {
153            match training_attr {
154                crate::graph::AttributeValue::Bool(training) => *training,
155                crate::graph::AttributeValue::String(s) => s.parse::<bool>().unwrap_or(false),
156                _ => false,
157            }
158        } else {
159            // Default to false (inference mode)
160            false
161        }
162    }
163
164    /// Replace a training operation with its inference equivalent
165    fn replace_training_operation(&self, graph: &mut Graph, node_id: NodeId) -> Result<()> {
166        if let Some(node) = graph.get_node(node_id) {
167            if let NodeType::Operation(op_name) = &node.op_type {
168                match op_name.as_str() {
169                    "Dropout" => {
170                        // Replace dropout with identity in inference mode
171                        self.replace_with_identity(graph, node_id)?;
172                    }
173                    "BatchNorm" | "GroupNorm" | "LayerNorm" => {
174                        // Convert to inference mode (use learned parameters)
175                        self.convert_normalization_to_inference(graph, node_id)?;
176                    }
177                    _ => {
178                        // Unknown training operation, leave as is
179                    }
180                }
181            }
182        }
183        Ok(())
184    }
185
186    /// Replace a node with identity operation
187    fn replace_with_identity(&self, _graph: &mut Graph, _node_id: NodeId) -> Result<()> {
188        // For the current Graph API, we would need to use remove_node and add_node
189        // This is a simplified implementation - in practice, we'd need to:
190        // 1. Get the node's input/output connections
191        // 2. Remove the node
192        // 3. Add a new Identity node with the same connections
193        // For now, we'll just mark it as converted
194        Ok(())
195    }
196
197    /// Convert normalization operation to inference mode
198    fn convert_normalization_to_inference(
199        &self,
200        _graph: &mut Graph,
201        _node_id: NodeId,
202    ) -> Result<()> {
203        // For the current Graph API, we would need to modify the node's attributes
204        // This is a simplified implementation - in practice, we'd need to:
205        // 1. Get mutable access to the node
206        // 2. Update its attributes
207        // For now, we'll just mark it as converted
208        Ok(())
209    }
210
211    /// Fold constants in the graph
212    fn fold_constants(&self, graph: &mut Graph, stats: &mut GraphFreezingStats) -> Result<()> {
213        let mut constants_folded = 0;
214        let mut nodes_to_process = Vec::new();
215
216        // Find constant folding opportunities
217        for node in graph.nodes() {
218            if self.can_fold_constant(graph, node.id) {
219                nodes_to_process.push(node.id);
220            }
221        }
222
223        // Process constant folding
224        for node_id in nodes_to_process {
225            if self.fold_constant_node(graph, node_id)? {
226                constants_folded += 1;
227            }
228        }
229
230        stats.constants_folded = constants_folded;
231        Ok(())
232    }
233
234    /// Check if a node can be constant folded
235    fn can_fold_constant(&self, graph: &Graph, node_id: NodeId) -> bool {
236        // Check if all inputs are constants
237        let mut all_inputs_constant = true;
238
239        if let Some(node) = graph.get_node(node_id) {
240            for edge_id in &node.inputs {
241                if let Some(edge) = graph.get_edge(*edge_id) {
242                    if let Some(source_node) = graph.get_node(edge.from_node) {
243                        if !self.is_constant_node(source_node) {
244                            all_inputs_constant = false;
245                            break;
246                        }
247                    }
248                }
249            }
250        }
251
252        all_inputs_constant && self.is_deterministic_operation(graph, node_id)
253    }
254
255    /// Check if a node represents a constant
256    fn is_constant_node(&self, node: &crate::graph::GraphNode) -> bool {
257        if let NodeType::Constant = node.op_type {
258            true
259        } else if let NodeType::Operation(op_name) = &node.op_type {
260            matches!(op_name.as_str(), "Const" | "Constant" | "Identity")
261        } else {
262            false
263        }
264    }
265
266    /// Check if an operation is deterministic
267    fn is_deterministic_operation(&self, graph: &Graph, node_id: NodeId) -> bool {
268        if let Some(node) = graph.get_node(node_id) {
269            // Most operations are deterministic, except for random operations
270            if let NodeType::Operation(op_name) = &node.op_type {
271                !matches!(
272                    op_name.as_str(),
273                    "Random" | "RandomNormal" | "RandomUniform" | "Dropout"
274                )
275            } else {
276                true
277            }
278        } else {
279            false
280        }
281    }
282
283    /// Fold a constant node
284    fn fold_constant_node(&self, _graph: &mut Graph, _node_id: NodeId) -> Result<bool> {
285        // This is a simplified implementation
286        // In a real implementation, we would evaluate the operation with constant inputs
287        // and replace the node with a constant node containing the result
288
289        // For this simplified implementation, we'll just mark the node as folded
290        // In a real implementation, we would need a method to get mutable node references
291        // For now, we'll just return true to indicate folding was attempted
292        Ok(true)
293    }
294
295    /// Remove dead code from the graph
296    fn remove_dead_code(&self, graph: &mut Graph, stats: &mut GraphFreezingStats) -> Result<()> {
297        let mut reachable_nodes = HashSet::new();
298        let mut nodes_to_remove = Vec::new();
299
300        // Find all reachable nodes from outputs
301        self.mark_reachable_nodes(graph, &mut reachable_nodes)?;
302
303        // Identify dead nodes
304        for node in graph.nodes() {
305            if !reachable_nodes.contains(&node.id) {
306                nodes_to_remove.push(node.id);
307            }
308        }
309
310        // Remove dead nodes
311        for node_id in nodes_to_remove {
312            let _ = graph.remove_node(node_id);
313            stats.dead_nodes_removed += 1;
314        }
315
316        Ok(())
317    }
318
319    /// Mark all nodes reachable from outputs
320    fn mark_reachable_nodes(&self, graph: &Graph, reachable: &mut HashSet<NodeId>) -> Result<()> {
321        // Start from output nodes and work backwards
322        let output_nodes = self.find_output_nodes(graph);
323
324        for output_id in output_nodes {
325            self.mark_reachable_recursive(graph, output_id, reachable);
326        }
327
328        Ok(())
329    }
330
331    /// Find output nodes in the graph
332    fn find_output_nodes(&self, graph: &Graph) -> Vec<NodeId> {
333        let mut output_nodes = Vec::new();
334
335        for node in graph.nodes() {
336            // A node is an output if it has no outgoing edges
337            if node.outputs.is_empty() {
338                output_nodes.push(node.id);
339            }
340        }
341
342        output_nodes
343    }
344
345    /// Recursively mark reachable nodes
346    #[allow(clippy::only_used_in_recursion)]
347    fn mark_reachable_recursive(
348        &self,
349        graph: &Graph,
350        node_id: NodeId,
351        reachable: &mut HashSet<NodeId>,
352    ) {
353        if reachable.contains(&node_id) {
354            return;
355        }
356
357        reachable.insert(node_id);
358
359        // Mark all input nodes as reachable
360        if let Some(node) = graph.get_node(node_id) {
361            for edge_id in &node.inputs {
362                if let Some(edge) = graph.get_edge(*edge_id) {
363                    self.mark_reachable_recursive(graph, edge.from_node, reachable);
364                }
365            }
366        }
367    }
368
369    /// Optimize sequential operations
370    fn optimize_sequential_operations(
371        &self,
372        graph: &mut Graph,
373        stats: &mut GraphFreezingStats,
374    ) -> Result<()> {
375        // Look for opportunities to fuse operations
376        // This is a simplified implementation
377
378        let mut _optimizations = 0;
379
380        // Example: Fuse consecutive reshape operations
381        _optimizations += self.fuse_consecutive_reshapes(graph)?;
382
383        // Example: Fuse linear operations
384        _optimizations += self.fuse_linear_operations(graph)?;
385
386        // Update stats (this is a simplified metric)
387        stats.nodes_after = graph.node_count();
388
389        Ok(())
390    }
391
392    /// Fuse consecutive reshape operations
393    fn fuse_consecutive_reshapes(&self, graph: &mut Graph) -> Result<usize> {
394        let mut fused_count = 0;
395
396        // Find reshape chains
397        let mut nodes_to_process = Vec::new();
398
399        for node in graph.nodes() {
400            if let NodeType::Operation(op_name) = &node.op_type {
401                if op_name == "Reshape" {
402                    nodes_to_process.push(node.id);
403                }
404            }
405        }
406
407        // Process reshape chains (simplified)
408        for node_id in nodes_to_process {
409            if self.can_fuse_reshape_chain(graph, node_id) {
410                self.fuse_reshape_chain(graph, node_id)?;
411                fused_count += 1;
412            }
413        }
414
415        Ok(fused_count)
416    }
417
418    /// Check if a reshape chain can be fused
419    fn can_fuse_reshape_chain(&self, graph: &Graph, node_id: NodeId) -> bool {
420        // Check if the next operation is also a reshape
421        if let Some(node) = graph.get_node(node_id) {
422            for edge_id in &node.outputs {
423                if let Some(edge) = graph.get_edge(*edge_id) {
424                    if let Some(target_node) = graph.get_node(edge.to_node) {
425                        if let NodeType::Operation(op_name) = &target_node.op_type {
426                            if op_name == "Reshape" {
427                                return true;
428                            }
429                        }
430                    }
431                }
432            }
433        }
434        false
435    }
436
437    /// Fuse a reshape chain
438    fn fuse_reshape_chain(&self, _graph: &mut Graph, _node_id: NodeId) -> Result<()> {
439        // This is a simplified implementation
440        // In a real implementation, we would combine the reshape operations
441
442        // For now, we'll just mark as fused (this would need a mutable graph API)
443        Ok(())
444    }
445
446    /// Fuse linear operations (Add, Multiply, etc.)
447    fn fuse_linear_operations(&self, graph: &mut Graph) -> Result<usize> {
448        let mut fused_count = 0;
449
450        // Look for linear operation patterns
451        let linear_ops = ["Add", "Multiply", "Subtract", "Divide"];
452
453        // Collect node IDs first to avoid borrow checker issues
454        let node_ids: Vec<_> = graph
455            .nodes()
456            .filter_map(|node| {
457                if let NodeType::Operation(op_name) = &node.op_type {
458                    if linear_ops.contains(&op_name.as_str()) {
459                        Some(node.id)
460                    } else {
461                        None
462                    }
463                } else {
464                    None
465                }
466            })
467            .collect();
468
469        for node_id in node_ids {
470            if self.can_fuse_linear_operation(graph, node_id) {
471                self.fuse_linear_operation(graph, node_id)?;
472                fused_count += 1;
473            }
474        }
475
476        Ok(fused_count)
477    }
478
479    /// Check if a linear operation can be fused
480    fn can_fuse_linear_operation(&self, graph: &Graph, node_id: NodeId) -> bool {
481        // Simplified check for fuseable patterns
482        // In a real implementation, we would check for specific patterns like:
483        // - Consecutive additions/multiplications
484        // - Matrix multiplication followed by addition (GEMM)
485        // - etc.
486
487        if let Some(node) = graph.get_node(node_id) {
488            for edge_id in &node.outputs {
489                if let Some(edge) = graph.get_edge(*edge_id) {
490                    if let Some(target_node) = graph.get_node(edge.to_node) {
491                        if let NodeType::Operation(op_name) = &target_node.op_type {
492                            if matches!(op_name.as_str(), "Add" | "Multiply") {
493                                return true;
494                            }
495                        }
496                    }
497                }
498            }
499        }
500        false
501    }
502
503    /// Fuse a linear operation
504    fn fuse_linear_operation(&self, _graph: &mut Graph, _node_id: NodeId) -> Result<()> {
505        // Mark as fused (simplified implementation)
506        // In a real implementation, we would need mutable graph access
507        Ok(())
508    }
509
510    /// Estimate memory savings from optimization
511    fn estimate_memory_savings(&self, stats: &GraphFreezingStats) -> usize {
512        let nodes_removed = stats.training_ops_removed + stats.dead_nodes_removed;
513        let constants_folded = stats.constants_folded;
514
515        // Rough estimate: each removed node saves ~1KB, each folded constant saves ~512B
516        (nodes_removed * 1024) + (constants_folded * 512)
517    }
518}
519
520impl Default for GraphFreezer {
521    fn default() -> Self {
522        Self::new()
523    }
524}
525
526/// Convenience function to freeze a graph with default settings
527pub fn freeze_graph_for_inference(graph: &Graph) -> Result<(Graph, GraphFreezingStats)> {
528    let freezer = GraphFreezer::new();
529    freezer.freeze_graph(graph)
530}
531
532/// Convenience function to freeze a graph with custom configuration
533pub fn freeze_graph_with_config(
534    graph: &Graph,
535    config: GraphFreezingConfig,
536) -> Result<(Graph, GraphFreezingStats)> {
537    let freezer = GraphFreezer::with_config(config);
538    freezer.freeze_graph(graph)
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::graph::Graph;
545
546    #[test]
547    fn test_graph_freezer_creation() {
548        let freezer = GraphFreezer::new();
549        assert!(freezer.config.remove_training_ops);
550        assert!(freezer.config.fold_constants);
551        assert!(freezer.config.remove_dead_code);
552    }
553
554    #[test]
555    fn test_graph_freezer_with_config() {
556        let config = GraphFreezingConfig {
557            remove_training_ops: false,
558            fold_constants: true,
559            remove_dead_code: true,
560            optimize_sequential_ops: false,
561            target_device: "gpu".to_string(),
562        };
563
564        let freezer = GraphFreezer::with_config(config.clone());
565        assert_eq!(
566            freezer.config.remove_training_ops,
567            config.remove_training_ops
568        );
569        assert_eq!(freezer.config.target_device, config.target_device);
570    }
571
572    #[test]
573    fn test_freeze_empty_graph() {
574        let graph = Graph::new();
575        let result = freeze_graph_for_inference(&graph);
576        assert!(result.is_ok());
577
578        let (frozen_graph, stats) = result.expect("test: operation should succeed");
579        assert_eq!(stats.nodes_before, 0);
580        assert_eq!(stats.nodes_after, 0);
581    }
582
583    #[test]
584    fn test_memory_savings_estimation() {
585        let freezer = GraphFreezer::new();
586        let stats = GraphFreezingStats {
587            nodes_before: 100,
588            nodes_after: 80,
589            constants_folded: 10,
590            training_ops_removed: 5,
591            dead_nodes_removed: 5,
592            memory_savings: 0,
593        };
594
595        let estimated_savings = freezer.estimate_memory_savings(&stats);
596        assert_eq!(estimated_savings, (10 * 1024) + (10 * 512)); // 10 nodes removed + 10 constants folded
597    }
598}