Skip to main content

oxigdal_ml/optimization/
mod.rs

1//! Model optimization techniques for efficient inference
2//!
3//! This module provides various model optimization techniques to reduce model size,
4//! improve inference speed, and reduce memory consumption while maintaining accuracy.
5//!
6//! # Techniques
7//!
8//! - **Quantization**: Reduce precision (FP32 -> INT8/FP16)
9//! - **Pruning**: Remove unnecessary weights and connections
10//! - **Knowledge Distillation**: Transfer knowledge from large to small models
11//! - **Model Compression**: GZIP, Huffman coding, weight sharing
12//!
13//! # Example
14//!
15//! ```no_run
16//! use oxigdal_ml::optimization::quantize_model;
17//! use oxigdal_ml::optimization::{QuantizationConfig, QuantizationType};
18//!
19//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
20//! let config = QuantizationConfig::builder()
21//!     .quantization_type(QuantizationType::Int8)
22//!     .per_channel(true)
23//!     .build();
24//!
25//! quantize_model("model.onnx", "model_quantized.onnx", &config)?;
26//! # Ok(())
27//! # }
28//! ```
29
30pub mod distillation;
31pub mod graph_opt;
32pub mod pruning;
33pub mod quantization;
34
35pub use distillation::{
36    // Neural network components (for testing/extension)
37    DenseLayer,
38    // Core types
39    DistillationConfig,
40    DistillationConfigBuilder,
41    DistillationLoss,
42    DistillationStats,
43    DistillationTrainer,
44    // Optimizer types
45    EarlyStopping,
46    ForwardCache,
47    LearningRateSchedule,
48    MLPGradients,
49    OptimizerType,
50    SimpleMLP,
51    SimpleRng,
52    Temperature,
53    TrainingState,
54    // Core functions
55    cross_entropy_loss,
56    cross_entropy_with_label,
57    kl_divergence,
58    kl_divergence_from_logits,
59    log_softmax,
60    mse_loss,
61    soft_targets,
62    softmax,
63    train_student_model,
64};
65pub use graph_opt::{
66    GraphOptConfig, OptimizationBenchmark, apply_graph_optimization,
67    apply_graph_optimization_from_bytes, benchmark_optimization,
68};
69pub use pruning::{
70    // Unstructured pruning types
71    FineTuneCallback,
72    GradientInfo,
73    ImportanceMethod,
74    LotteryTicketState,
75    MaskCreationMode,
76    NoOpFineTune,
77    // Core configuration types
78    PruningConfig,
79    PruningConfigBuilder,
80    PruningGranularity,
81    PruningMask,
82    PruningSchedule,
83    PruningStats,
84    PruningStrategy,
85    UnstructuredPruner,
86    WeightStatistics,
87    WeightTensor,
88    // Helper functions
89    compute_channel_importance,
90    compute_gradient_importance,
91    compute_magnitude_importance,
92    compute_taylor_importance,
93    iterative_pruning,
94    prune_model,
95    prune_weights_direct,
96    prune_weights_with_gradients,
97    select_weights_to_prune,
98    structured_pruning,
99    unstructured_pruning,
100};
101pub use quantization::{
102    QuantizationConfig, QuantizationMode, QuantizationParams, QuantizationResult, QuantizationType,
103    calibrate_quantization, dequantize_tensor, quantize_model, quantize_tensor,
104};
105
106use crate::error::Result;
107use std::path::Path;
108use tracing::info;
109
110/// Model optimization statistics
111#[derive(Debug, Clone)]
112pub struct OptimizationStats {
113    /// Original model size in bytes
114    pub original_size: usize,
115    /// Optimized model size in bytes
116    pub optimized_size: usize,
117    /// Compression ratio
118    pub compression_ratio: f32,
119    /// Inference speedup factor
120    pub speedup: f32,
121    /// Accuracy change (percentage points)
122    pub accuracy_delta: f32,
123}
124
125impl OptimizationStats {
126    /// Creates optimization statistics
127    #[must_use]
128    pub fn new(
129        original_size: usize,
130        optimized_size: usize,
131        speedup: f32,
132        accuracy_delta: f32,
133    ) -> Self {
134        let compression_ratio = if optimized_size > 0 {
135            original_size as f32 / optimized_size as f32
136        } else {
137            0.0
138        };
139
140        Self {
141            original_size,
142            optimized_size,
143            compression_ratio,
144            speedup,
145            accuracy_delta,
146        }
147    }
148
149    /// Returns the size reduction in bytes
150    #[must_use]
151    pub fn size_reduction(&self) -> usize {
152        self.original_size.saturating_sub(self.optimized_size)
153    }
154
155    /// Returns the size reduction as a percentage
156    #[must_use]
157    pub fn size_reduction_percent(&self) -> f32 {
158        if self.original_size > 0 {
159            (self.size_reduction() as f32 / self.original_size as f32) * 100.0
160        } else {
161            0.0
162        }
163    }
164
165    /// Checks if optimization is worthwhile (> 20% size reduction with < 2% accuracy loss)
166    #[must_use]
167    pub fn is_worthwhile(&self) -> bool {
168        self.size_reduction_percent() > 20.0 && self.accuracy_delta.abs() < 2.0
169    }
170}
171
172/// Model optimization profile
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum OptimizationProfile {
175    /// Maximum accuracy, minimal optimization
176    Accuracy,
177    /// Balanced accuracy and speed
178    Balanced,
179    /// Maximum speed, aggressive optimization
180    Speed,
181    /// Minimum size for edge devices
182    Size,
183}
184
185/// Combined optimization pipeline
186pub struct OptimizationPipeline {
187    /// Quantization configuration
188    pub quantization: Option<QuantizationConfig>,
189    /// Pruning configuration
190    pub pruning: Option<PruningConfig>,
191    /// Whether to apply weight sharing
192    pub weight_sharing: bool,
193    /// Whether to apply operator fusion
194    pub operator_fusion: bool,
195    /// Fine-grained graph optimization configuration.
196    /// When `None`, derives from `operator_fusion` (all passes if true, none if false).
197    pub graph_opt_config: Option<GraphOptConfig>,
198}
199
200impl OptimizationPipeline {
201    /// Returns the effective [`GraphOptConfig`] for this pipeline.
202    ///
203    /// If `graph_opt_config` is set, returns it. Otherwise, derives from
204    /// the `operator_fusion` flag.
205    #[must_use]
206    pub fn effective_graph_opt_config(&self) -> GraphOptConfig {
207        if let Some(ref config) = self.graph_opt_config {
208            config.clone()
209        } else if self.operator_fusion {
210            GraphOptConfig::default()
211        } else {
212            GraphOptConfig::none()
213        }
214    }
215
216    /// Returns the [`oxionnx::OptLevel`] that should be used when loading
217    /// models in this pipeline (for benchmarking and inference).
218    #[must_use]
219    pub fn opt_level(&self) -> oxionnx::OptLevel {
220        self.effective_graph_opt_config().to_opt_level()
221    }
222}
223
224impl OptimizationPipeline {
225    /// Creates an optimization pipeline from a profile
226    #[must_use]
227    pub fn from_profile(profile: OptimizationProfile) -> Self {
228        match profile {
229            OptimizationProfile::Accuracy => Self {
230                quantization: Some(
231                    QuantizationConfig::builder()
232                        .quantization_type(QuantizationType::Float16)
233                        .build(),
234                ),
235                pruning: None,
236                weight_sharing: false,
237                operator_fusion: true,
238                graph_opt_config: Some(GraphOptConfig::default()),
239            },
240            OptimizationProfile::Balanced => Self {
241                quantization: Some(
242                    QuantizationConfig::builder()
243                        .quantization_type(QuantizationType::Int8)
244                        .per_channel(true)
245                        .build(),
246                ),
247                pruning: Some(
248                    PruningConfig::builder()
249                        .sparsity_target(0.3)
250                        .strategy(PruningStrategy::Magnitude)
251                        .build(),
252                ),
253                weight_sharing: true,
254                operator_fusion: true,
255                graph_opt_config: Some(GraphOptConfig::default()),
256            },
257            OptimizationProfile::Speed => Self {
258                quantization: Some(
259                    QuantizationConfig::builder()
260                        .quantization_type(QuantizationType::Int8)
261                        .per_channel(true)
262                        .build(),
263                ),
264                pruning: Some(
265                    PruningConfig::builder()
266                        .sparsity_target(0.5)
267                        .strategy(PruningStrategy::Structured)
268                        .build(),
269                ),
270                weight_sharing: true,
271                operator_fusion: true,
272                graph_opt_config: Some(GraphOptConfig {
273                    constant_folding: true,
274                    dead_node_elimination: true,
275                    common_subexpression_elimination: true,
276                    operator_fusion: true,
277                }),
278            },
279            OptimizationProfile::Size => Self {
280                quantization: Some(
281                    QuantizationConfig::builder()
282                        .quantization_type(QuantizationType::Int8)
283                        .per_channel(true)
284                        .build(),
285                ),
286                pruning: Some(
287                    PruningConfig::builder()
288                        .sparsity_target(0.7)
289                        .strategy(PruningStrategy::Structured)
290                        .build(),
291                ),
292                weight_sharing: true,
293                operator_fusion: true,
294                graph_opt_config: Some(GraphOptConfig::default()),
295            },
296        }
297    }
298
299    /// Applies the optimization pipeline to a model
300    ///
301    /// # Errors
302    /// Returns an error if optimization fails
303    pub fn optimize<P: AsRef<std::path::Path>>(
304        &self,
305        input_path: P,
306        output_path: P,
307    ) -> Result<OptimizationStats> {
308        use tracing::info;
309
310        info!("Running optimization pipeline");
311
312        let input = input_path.as_ref();
313        let output = output_path.as_ref();
314
315        // Get original size
316        let original_size = std::fs::metadata(input)
317            .map(|m| m.len() as usize)
318            .unwrap_or(0);
319
320        // Apply optimizations in sequence
321        let mut current_path = input.to_path_buf();
322
323        // 1. Pruning (if configured)
324        if let Some(ref config) = self.pruning {
325            let pruned_path = output.with_extension("pruned.onnx");
326            prune_model(&current_path, &pruned_path, config)?;
327            current_path = pruned_path;
328        }
329
330        // 2. Quantization (if configured)
331        if let Some(ref config) = self.quantization {
332            let quantized_path = output.with_extension("quantized.onnx");
333            quantize_model(&current_path, &quantized_path, config)?;
334            current_path = quantized_path;
335        }
336
337        // 3. Final rename
338        std::fs::rename(&current_path, output)?;
339
340        // Get optimized size
341        let optimized_size = std::fs::metadata(output)
342            .map(|m| m.len() as usize)
343            .unwrap_or(0);
344
345        // Measure actual speedup by benchmarking both models
346        let opt_level = self.opt_level();
347        let speedup = Self::measure_speedup(input, output, opt_level)?;
348
349        // Accuracy measurement would require test dataset
350        // For now, use conservative estimate based on optimization level
351        let accuracy_delta = Self::estimate_accuracy_delta(self);
352
353        Ok(OptimizationStats::new(
354            original_size,
355            optimized_size,
356            speedup,
357            accuracy_delta,
358        ))
359    }
360
361    /// Measures inference speedup between original and optimized model.
362    ///
363    /// The `opt_level` parameter controls which graph optimization passes are
364    /// applied when loading the optimized model for benchmarking.
365    fn measure_speedup(
366        original_path: &Path,
367        optimized_path: &Path,
368        opt_level: oxionnx::OptLevel,
369    ) -> Result<f32> {
370        // Number of warmup and benchmark iterations
371        const WARMUP_ITERS: usize = 5;
372        const BENCH_ITERS: usize = 20;
373
374        // Check if both models exist
375        if !original_path.exists() || !optimized_path.exists() {
376            info!("Skipping speedup measurement: model files not accessible");
377            return Ok(1.5); // Conservative default estimate
378        }
379
380        // Create dummy input for benchmarking
381        // In production, would use representative dataset
382        let dummy_input = vec![0.0f32; 224 * 224 * 3]; // Typical image size
383        let input_shape = vec![1, 3, 224, 224];
384
385        // Benchmark original model (no optimization for baseline)
386        let original_time = match Self::benchmark_model(
387            original_path,
388            &dummy_input,
389            &input_shape,
390            WARMUP_ITERS,
391            BENCH_ITERS,
392            oxionnx::OptLevel::None,
393        ) {
394            Ok(t) => t,
395            Err(e) => {
396                info!("Could not benchmark original model: {}, using estimate", e);
397                return Ok(1.5);
398            }
399        };
400
401        // Benchmark optimized model with the pipeline's optimization level
402        let optimized_time = match Self::benchmark_model(
403            optimized_path,
404            &dummy_input,
405            &input_shape,
406            WARMUP_ITERS,
407            BENCH_ITERS,
408            opt_level,
409        ) {
410            Ok(t) => t,
411            Err(e) => {
412                info!("Could not benchmark optimized model: {}, using estimate", e);
413                return Ok(1.5);
414            }
415        };
416
417        if optimized_time > 0.0 {
418            let speedup = (original_time / optimized_time) as f32;
419            info!(
420                "Measured speedup: {:.2}x (original: {:.2}ms, optimized: {:.2}ms)",
421                speedup,
422                original_time * 1000.0,
423                optimized_time * 1000.0
424            );
425            Ok(speedup)
426        } else {
427            Ok(1.5) // Default if measurement fails
428        }
429    }
430
431    /// Benchmarks a single model with the specified optimization level.
432    fn benchmark_model(
433        model_path: &Path,
434        input: &[f32],
435        input_shape: &[usize],
436        warmup_iters: usize,
437        bench_iters: usize,
438        opt_level: oxionnx::OptLevel,
439    ) -> Result<f64> {
440        use ndarray::{Array, IxDyn};
441        use oxionnx::{SessionBuilder, Tensor};
442        use std::time::Instant;
443
444        // Load ONNX model with the specified optimization level
445        let session = SessionBuilder::new()
446            .with_optimization_level(opt_level)
447            .commit_from_file(model_path)
448            .map_err(|e| crate::error::ModelError::LoadFailed {
449                reason: format!("Failed to load model for benchmarking: {}", e),
450            })?;
451
452        // Get input name — TensorInfo has .name field access
453        let input_name = session
454            .input_info()
455            .first()
456            .ok_or_else(|| crate::error::ModelError::LoadFailed {
457                reason: "No input tensors found in model".to_string(),
458            })?
459            .name
460            .clone();
461
462        // Create input array from data and shape
463        let array_shape: Vec<usize> = input_shape.to_vec();
464        let total_elements: usize = array_shape.iter().product();
465
466        // Validate input size
467        if input.len() != total_elements {
468            return Err(crate::error::InferenceError::InvalidInputShape {
469                expected: array_shape.clone(),
470                actual: vec![input.len()],
471            }
472            .into());
473        }
474
475        // Create ndarray with dynamic dimensions
476        let input_array =
477            Array::from_shape_vec(IxDyn(&array_shape), input.to_vec()).map_err(|e| {
478                crate::error::InferenceError::Failed {
479                    reason: format!("Failed to create input array: {}", e),
480                }
481            })?;
482
483        // Run warmup iterations
484        for _ in 0..warmup_iters {
485            // Tensor::from_ndarray_view returns Tensor directly (no Result)
486            let input_tensor = Tensor::from_ndarray_view(input_array.view());
487            let inputs_map =
488                oxionnx::inputs![input_name.as_str() => input_tensor].map_err(|e| {
489                    crate::error::InferenceError::Failed {
490                        reason: format!("Failed to build inputs map: {}", e),
491                    }
492                })?;
493
494            let _ = session
495                .run(&inputs_map)
496                .map_err(|e| crate::error::InferenceError::Failed {
497                    reason: format!("Warmup inference failed: {}", e),
498                })?;
499        }
500
501        // Run benchmark iterations with timing
502        let start = Instant::now();
503        for _ in 0..bench_iters {
504            let input_tensor = Tensor::from_ndarray_view(input_array.view());
505            let inputs_map =
506                oxionnx::inputs![input_name.as_str() => input_tensor].map_err(|e| {
507                    crate::error::InferenceError::Failed {
508                        reason: format!("Failed to build inputs map: {}", e),
509                    }
510                })?;
511
512            let _ = session
513                .run(&inputs_map)
514                .map_err(|e| crate::error::InferenceError::Failed {
515                    reason: format!("Benchmark inference failed: {}", e),
516                })?;
517        }
518        let elapsed = start.elapsed();
519
520        // Calculate average time per inference in seconds
521        let avg_time = elapsed.as_secs_f64() / bench_iters as f64;
522
523        Ok(avg_time)
524    }
525
526    /// Estimates accuracy delta based on optimization configuration
527    fn estimate_accuracy_delta(&self) -> f32 {
528        let mut delta = 0.0f32;
529
530        // Quantization impact
531        if let Some(ref quant) = self.quantization {
532            delta += match quant.quantization_type {
533                QuantizationType::Float16 => -0.1, // Minimal loss
534                QuantizationType::Int8 => -0.5,    // Small loss
535                QuantizationType::UInt8 => -0.5,   // Similar to Int8
536                QuantizationType::Int4 => -2.0,    // Moderate loss
537            };
538        }
539
540        // Pruning impact
541        if let Some(ref prune) = self.pruning {
542            delta += -prune.sparsity_target * 2.0; // Rough heuristic
543        }
544
545        delta
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_optimization_stats() {
555        let stats = OptimizationStats::new(
556            1000000, // 1 MB original
557            250000,  // 250 KB optimized
558            2.0,     // 2x speedup
559            -0.5,    // 0.5% accuracy loss
560        );
561
562        assert_eq!(stats.size_reduction(), 750000);
563        assert!((stats.size_reduction_percent() - 75.0).abs() < 0.1);
564        assert!((stats.compression_ratio - 4.0).abs() < 0.1);
565        assert!(stats.is_worthwhile());
566    }
567
568    #[test]
569    fn test_optimization_profile_accuracy() {
570        let pipeline = OptimizationPipeline::from_profile(OptimizationProfile::Accuracy);
571        assert!(pipeline.quantization.is_some());
572        assert!(pipeline.pruning.is_none());
573        assert!(pipeline.operator_fusion);
574        assert!(pipeline.graph_opt_config.is_some());
575        assert_eq!(pipeline.opt_level(), oxionnx::OptLevel::All);
576    }
577
578    #[test]
579    fn test_optimization_profile_speed() {
580        let pipeline = OptimizationPipeline::from_profile(OptimizationProfile::Speed);
581        assert!(pipeline.quantization.is_some());
582        assert!(pipeline.pruning.is_some());
583        assert!(pipeline.weight_sharing);
584        assert!(pipeline.operator_fusion);
585        assert_eq!(pipeline.opt_level(), oxionnx::OptLevel::All);
586    }
587
588    #[test]
589    fn test_optimization_profile_size() {
590        let pipeline = OptimizationPipeline::from_profile(OptimizationProfile::Size);
591        assert!(pipeline.quantization.is_some());
592        assert!(pipeline.pruning.is_some());
593
594        if let Some(pruning) = &pipeline.pruning {
595            // Size profile should have high sparsity
596            assert!(pruning.sparsity_target >= 0.6);
597        }
598        assert_eq!(pipeline.opt_level(), oxionnx::OptLevel::All);
599    }
600
601    #[test]
602    fn test_effective_graph_opt_config_from_fusion_flag() {
603        // When graph_opt_config is None, derive from operator_fusion
604        let pipeline = OptimizationPipeline {
605            quantization: None,
606            pruning: None,
607            weight_sharing: false,
608            operator_fusion: true,
609            graph_opt_config: None,
610        };
611        let config = pipeline.effective_graph_opt_config();
612        assert!(config.any_enabled());
613        assert_eq!(pipeline.opt_level(), oxionnx::OptLevel::All);
614
615        // operator_fusion = false => no optimization
616        let pipeline_no_fusion = OptimizationPipeline {
617            quantization: None,
618            pruning: None,
619            weight_sharing: false,
620            operator_fusion: false,
621            graph_opt_config: None,
622        };
623        let config_none = pipeline_no_fusion.effective_graph_opt_config();
624        assert!(!config_none.any_enabled());
625        assert_eq!(pipeline_no_fusion.opt_level(), oxionnx::OptLevel::None);
626    }
627
628    #[test]
629    fn test_effective_graph_opt_config_explicit_override() {
630        // Explicit graph_opt_config overrides operator_fusion flag
631        let custom_config = GraphOptConfig {
632            constant_folding: true,
633            dead_node_elimination: false,
634            common_subexpression_elimination: false,
635            operator_fusion: false,
636        };
637        let pipeline = OptimizationPipeline {
638            quantization: None,
639            pruning: None,
640            weight_sharing: false,
641            operator_fusion: false, // false, but graph_opt_config has cf=true
642            graph_opt_config: Some(custom_config),
643        };
644        let config = pipeline.effective_graph_opt_config();
645        assert!(config.constant_folding);
646        assert!(!config.operator_fusion);
647        // Any enabled => OptLevel::All
648        assert_eq!(pipeline.opt_level(), oxionnx::OptLevel::All);
649    }
650}