Skip to main content

ipfrs_tensorlogic/
op_fusion.rs

1//! Tensor operation fusion — detects and fuses sequences of tensor operations
2//! into optimized compound operations, reducing memory bandwidth and computation
3//! overhead.
4//!
5//! # Overview
6//!
7//! Many deep-learning and inference pipelines apply long chains of element-wise
8//! operations (scale, bias, activation, clamp, …) to the same tensor.  Executing
9//! each operation individually forces multiple round-trips through memory.
10//! `TensorOpFusion` analyses a sequence of [`TensorOp`] values and collapses
11//! fuseable sub-sequences into a single [`FusedOp`], which a backend can
12//! implement in one memory pass.
13//!
14//! ## Fusion rules (greedy, left-to-right)
15//!
16//! | Pattern | Result |
17//! |---|---|
18//! | Scale → Relu → Bias | `ScaleReluBias` |
19//! | Scale → Bias | `ScaleBias` |
20//! | Clamp → Normalize | `ClampNormalize` |
21//! | anything else | `Passthrough` |
22//! | empty input | `[Identity]` |
23//!
24//! Longer patterns are tried first so that the three-op rule takes priority over
25//! the two-op rule.
26
27/// A primitive tensor operation that can appear in an un-optimised pipeline.
28#[derive(Clone, Debug, PartialEq)]
29pub enum TensorOp {
30    /// Multiply every element by `factor`.
31    Scale { factor: f64 },
32    /// Add `offset` to every element.
33    Bias { offset: f64 },
34    /// Apply ReLU: max(0, x).
35    Relu,
36    /// Clamp each element to `[min, max]`.
37    Clamp { min: f64, max: f64 },
38    /// Divide every element by the L2-norm of the tensor.
39    Normalize,
40    /// Matrix-multiply marker.  Carries the output shape so callers can
41    /// reason about dimensions; cannot be fused with neighbouring ops.
42    MatMul { rows: usize, cols: usize },
43}
44
45/// A fused (compound) operation produced by [`TensorOpFusion::fuse`].
46#[derive(Clone, Debug, PartialEq)]
47pub enum FusedOp {
48    /// Scale followed immediately by Bias: `y = x * scale + bias`.
49    ScaleBias { scale: f64, bias: f64 },
50    /// Scale, then ReLU, then Bias: `y = max(0, x * scale) + bias`.
51    ScaleReluBias { scale: f64, bias: f64 },
52    /// Clamp followed by L2-normalisation.
53    ClampNormalize { min: f64, max: f64 },
54    /// No-op — used when the input sequence was empty.
55    Identity,
56    /// A single operation that could not be fused with its neighbours.
57    Passthrough(TensorOp),
58}
59
60/// The result of a fusion pass: the optimised op sequence plus accounting info.
61#[derive(Clone, Debug)]
62pub struct FusionPlan {
63    /// Optimised operation sequence.
64    pub ops: Vec<FusedOp>,
65    /// Number of ops in the original (unoptimised) sequence.
66    pub original_op_count: usize,
67    /// Number of ops in the optimised sequence (== `ops.len()`).
68    pub fused_op_count: usize,
69}
70
71impl FusionPlan {
72    /// Fraction of operations eliminated: `(original − fused) / original`.
73    ///
74    /// Returns `0.0` when `original_op_count` is zero.
75    pub fn reduction_ratio(&self) -> f64 {
76        if self.original_op_count == 0 {
77            return 0.0;
78        }
79        let reduced = self.original_op_count.saturating_sub(self.fused_op_count);
80        reduced as f64 / self.original_op_count as f64
81    }
82}
83
84/// Cumulative statistics across all [`TensorOpFusion::fuse`] calls.
85#[derive(Clone, Debug, Default)]
86pub struct FusionStats {
87    /// How many times `fuse()` has been called.
88    pub total_fusion_runs: u64,
89    /// Sum of `original_op_count` across all runs.
90    pub total_ops_fused: u64,
91    /// Sum of `(original − fused)` across all runs.
92    pub total_ops_reduced: u64,
93}
94
95/// Stateful engine that fuses primitive tensor-op sequences into compound ops.
96///
97/// # Example
98///
99/// ```
100/// use ipfrs_tensorlogic::op_fusion::{TensorOp as FusionTensorOp, TensorOpFusion};
101///
102/// let mut engine = TensorOpFusion::new();
103/// let ops = vec![
104///     FusionTensorOp::Scale { factor: 2.0 },
105///     FusionTensorOp::Bias  { offset: 1.0 },
106/// ];
107/// let plan = engine.fuse(ops);
108/// assert_eq!(plan.fused_op_count, 1);
109/// assert!((plan.reduction_ratio() - 0.5).abs() < 1e-10);
110/// ```
111#[derive(Debug, Default)]
112pub struct TensorOpFusion {
113    stats: FusionStats,
114}
115
116impl TensorOpFusion {
117    /// Create a new, zero-stats fusion engine.
118    pub fn new() -> Self {
119        Self {
120            stats: FusionStats::default(),
121        }
122    }
123
124    /// Fuse `ops` left-to-right using greedy matching and return a [`FusionPlan`].
125    pub fn fuse(&mut self, ops: Vec<TensorOp>) -> FusionPlan {
126        let original_op_count = ops.len();
127
128        let fused_ops = if ops.is_empty() {
129            vec![FusedOp::Identity]
130        } else {
131            Self::fuse_sequence(ops)
132        };
133
134        let fused_op_count = fused_ops.len();
135        let reduced = original_op_count.saturating_sub(fused_op_count);
136
137        self.stats.total_fusion_runs += 1;
138        self.stats.total_ops_fused += original_op_count as u64;
139        self.stats.total_ops_reduced += reduced as u64;
140
141        FusionPlan {
142            ops: fused_ops,
143            original_op_count,
144            fused_op_count,
145        }
146    }
147
148    /// Return a reference to the accumulated statistics.
149    pub fn stats(&self) -> &FusionStats {
150        &self.stats
151    }
152
153    /// Return `true` when `a` and `b` form the start of a fuseable pattern.
154    ///
155    /// This covers all two-op pairs that appear in any fusion rule:
156    ///
157    /// * Scale → Bias  (`ScaleBias`)
158    /// * Scale → Relu  (start of `ScaleReluBias`)
159    /// * Relu  → Bias  (tail of `ScaleReluBias`)
160    /// * Clamp → Normalize (`ClampNormalize`)
161    pub fn can_fuse(a: &TensorOp, b: &TensorOp) -> bool {
162        matches!(
163            (a, b),
164            (TensorOp::Scale { .. }, TensorOp::Bias { .. })
165                | (TensorOp::Scale { .. }, TensorOp::Relu)
166                | (TensorOp::Relu, TensorOp::Bias { .. })
167                | (TensorOp::Clamp { .. }, TensorOp::Normalize)
168        )
169    }
170
171    // -----------------------------------------------------------------------
172    // Private helpers
173    // -----------------------------------------------------------------------
174
175    /// Core greedy fusion loop.  Assumes `ops` is non-empty.
176    fn fuse_sequence(ops: Vec<TensorOp>) -> Vec<FusedOp> {
177        let mut result: Vec<FusedOp> = Vec::with_capacity(ops.len());
178        let mut cursor = 0usize;
179
180        while cursor < ops.len() {
181            // Try the longest (3-op) patterns first.
182            if cursor + 2 < ops.len() {
183                if let Some(fused) =
184                    Self::try_fuse_three(&ops[cursor], &ops[cursor + 1], &ops[cursor + 2])
185                {
186                    result.push(fused);
187                    cursor += 3;
188                    continue;
189                }
190            }
191
192            // Try 2-op patterns.
193            if cursor + 1 < ops.len() {
194                if let Some(fused) = Self::try_fuse_two(&ops[cursor], &ops[cursor + 1]) {
195                    result.push(fused);
196                    cursor += 2;
197                    continue;
198                }
199            }
200
201            // Fall back to passthrough.
202            result.push(FusedOp::Passthrough(ops[cursor].clone()));
203            cursor += 1;
204        }
205
206        result
207    }
208
209    /// Attempt to fuse three consecutive ops.
210    fn try_fuse_three(a: &TensorOp, b: &TensorOp, c: &TensorOp) -> Option<FusedOp> {
211        match (a, b, c) {
212            (
213                TensorOp::Scale { factor: scale },
214                TensorOp::Relu,
215                TensorOp::Bias { offset: bias },
216            ) => Some(FusedOp::ScaleReluBias {
217                scale: *scale,
218                bias: *bias,
219            }),
220            _ => None,
221        }
222    }
223
224    /// Attempt to fuse two consecutive ops.
225    fn try_fuse_two(a: &TensorOp, b: &TensorOp) -> Option<FusedOp> {
226        match (a, b) {
227            (TensorOp::Scale { factor: scale }, TensorOp::Bias { offset: bias }) => {
228                Some(FusedOp::ScaleBias {
229                    scale: *scale,
230                    bias: *bias,
231                })
232            }
233            (TensorOp::Clamp { min, max }, TensorOp::Normalize) => Some(FusedOp::ClampNormalize {
234                min: *min,
235                max: *max,
236            }),
237            _ => None,
238        }
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Tests
244// ---------------------------------------------------------------------------
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    // -- Construction -------------------------------------------------------
251
252    #[test]
253    fn new_starts_with_zero_stats() {
254        let engine = TensorOpFusion::new();
255        let s = engine.stats();
256        assert_eq!(s.total_fusion_runs, 0);
257        assert_eq!(s.total_ops_fused, 0);
258        assert_eq!(s.total_ops_reduced, 0);
259    }
260
261    // -- Empty input --------------------------------------------------------
262
263    #[test]
264    fn fuse_empty_returns_identity() {
265        let mut engine = TensorOpFusion::new();
266        let plan = engine.fuse(vec![]);
267        assert_eq!(plan.ops, vec![FusedOp::Identity]);
268    }
269
270    #[test]
271    fn fuse_empty_original_count_zero() {
272        let mut engine = TensorOpFusion::new();
273        let plan = engine.fuse(vec![]);
274        assert_eq!(plan.original_op_count, 0);
275    }
276
277    #[test]
278    fn reduction_ratio_zero_for_empty() {
279        let mut engine = TensorOpFusion::new();
280        let plan = engine.fuse(vec![]);
281        assert!((plan.reduction_ratio() - 0.0).abs() < f64::EPSILON);
282    }
283
284    // -- Scale + Bias -------------------------------------------------------
285
286    #[test]
287    fn fuse_scale_bias_produces_scale_bias() {
288        let mut engine = TensorOpFusion::new();
289        let plan = engine.fuse(vec![
290            TensorOp::Scale { factor: 3.0 },
291            TensorOp::Bias { offset: 1.5 },
292        ]);
293        assert_eq!(plan.ops.len(), 1);
294        assert_eq!(
295            plan.ops[0],
296            FusedOp::ScaleBias {
297                scale: 3.0,
298                bias: 1.5
299            }
300        );
301    }
302
303    #[test]
304    fn scale_bias_correct_values() {
305        let mut engine = TensorOpFusion::new();
306        let plan = engine.fuse(vec![
307            TensorOp::Scale { factor: 0.5 },
308            TensorOp::Bias { offset: -2.0 },
309        ]);
310        match &plan.ops[0] {
311            FusedOp::ScaleBias { scale, bias } => {
312                assert!((scale - 0.5).abs() < f64::EPSILON);
313                assert!((bias - (-2.0)).abs() < f64::EPSILON);
314            }
315            other => panic!("expected ScaleBias, got {:?}", other),
316        }
317    }
318
319    // -- Scale + Relu + Bias ------------------------------------------------
320
321    #[test]
322    fn fuse_scale_relu_bias_produces_scale_relu_bias() {
323        let mut engine = TensorOpFusion::new();
324        let plan = engine.fuse(vec![
325            TensorOp::Scale { factor: 2.0 },
326            TensorOp::Relu,
327            TensorOp::Bias { offset: 0.1 },
328        ]);
329        assert_eq!(plan.ops.len(), 1);
330        assert_eq!(
331            plan.ops[0],
332            FusedOp::ScaleReluBias {
333                scale: 2.0,
334                bias: 0.1
335            }
336        );
337    }
338
339    #[test]
340    fn scale_relu_bias_correct_values() {
341        let mut engine = TensorOpFusion::new();
342        let plan = engine.fuse(vec![
343            TensorOp::Scale { factor: -1.0 },
344            TensorOp::Relu,
345            TensorOp::Bias { offset: 4.0 },
346        ]);
347        match &plan.ops[0] {
348            FusedOp::ScaleReluBias { scale, bias } => {
349                assert!((scale - (-1.0)).abs() < f64::EPSILON);
350                assert!((bias - 4.0).abs() < f64::EPSILON);
351            }
352            other => panic!("expected ScaleReluBias, got {:?}", other),
353        }
354    }
355
356    // -- Clamp + Normalize --------------------------------------------------
357
358    #[test]
359    fn fuse_clamp_normalize_produces_clamp_normalize() {
360        let mut engine = TensorOpFusion::new();
361        let plan = engine.fuse(vec![
362            TensorOp::Clamp {
363                min: -1.0,
364                max: 1.0,
365            },
366            TensorOp::Normalize,
367        ]);
368        assert_eq!(plan.ops.len(), 1);
369        assert_eq!(
370            plan.ops[0],
371            FusedOp::ClampNormalize {
372                min: -1.0,
373                max: 1.0
374            }
375        );
376    }
377
378    // -- Single ops ---------------------------------------------------------
379
380    #[test]
381    fn fuse_single_scale_is_passthrough() {
382        let mut engine = TensorOpFusion::new();
383        let plan = engine.fuse(vec![TensorOp::Scale { factor: 5.0 }]);
384        assert_eq!(
385            plan.ops,
386            vec![FusedOp::Passthrough(TensorOp::Scale { factor: 5.0 })]
387        );
388    }
389
390    #[test]
391    fn fuse_single_relu_is_passthrough() {
392        let mut engine = TensorOpFusion::new();
393        let plan = engine.fuse(vec![TensorOp::Relu]);
394        assert_eq!(plan.ops, vec![FusedOp::Passthrough(TensorOp::Relu)]);
395    }
396
397    #[test]
398    fn fuse_single_matmul_is_passthrough() {
399        let mut engine = TensorOpFusion::new();
400        let plan = engine.fuse(vec![TensorOp::MatMul { rows: 4, cols: 8 }]);
401        assert_eq!(
402            plan.ops,
403            vec![FusedOp::Passthrough(TensorOp::MatMul { rows: 4, cols: 8 })]
404        );
405    }
406
407    // -- MatMul breaks chains -----------------------------------------------
408
409    #[test]
410    fn matmul_breaks_fusion_chain() {
411        let mut engine = TensorOpFusion::new();
412        // Scale and Bias are separated by MatMul → no ScaleBias should emerge.
413        let plan = engine.fuse(vec![
414            TensorOp::Scale { factor: 2.0 },
415            TensorOp::MatMul { rows: 2, cols: 2 },
416            TensorOp::Bias { offset: 1.0 },
417        ]);
418        assert_eq!(plan.ops.len(), 3);
419        assert_eq!(
420            plan.ops[0],
421            FusedOp::Passthrough(TensorOp::Scale { factor: 2.0 })
422        );
423        assert_eq!(
424            plan.ops[1],
425            FusedOp::Passthrough(TensorOp::MatMul { rows: 2, cols: 2 })
426        );
427        assert_eq!(
428            plan.ops[2],
429            FusedOp::Passthrough(TensorOp::Bias { offset: 1.0 })
430        );
431    }
432
433    // -- Non-fuseable pairs -------------------------------------------------
434
435    #[test]
436    fn bias_then_scale_produces_two_passthroughs() {
437        let mut engine = TensorOpFusion::new();
438        let plan = engine.fuse(vec![
439            TensorOp::Bias { offset: 1.0 },
440            TensorOp::Scale { factor: 2.0 },
441        ]);
442        assert_eq!(plan.ops.len(), 2);
443        assert_eq!(
444            plan.ops[0],
445            FusedOp::Passthrough(TensorOp::Bias { offset: 1.0 })
446        );
447        assert_eq!(
448            plan.ops[1],
449            FusedOp::Passthrough(TensorOp::Scale { factor: 2.0 })
450        );
451    }
452
453    // -- reduction_ratio ----------------------------------------------------
454
455    #[test]
456    fn reduction_ratio_scale_bias() {
457        let mut engine = TensorOpFusion::new();
458        let plan = engine.fuse(vec![
459            TensorOp::Scale { factor: 1.0 },
460            TensorOp::Bias { offset: 0.0 },
461        ]);
462        // 2 original → 1 fused → 50 % reduction
463        let expected = 0.5;
464        assert!((plan.reduction_ratio() - expected).abs() < f64::EPSILON);
465    }
466
467    #[test]
468    fn reduction_ratio_scale_relu_bias() {
469        let mut engine = TensorOpFusion::new();
470        let plan = engine.fuse(vec![
471            TensorOp::Scale { factor: 1.0 },
472            TensorOp::Relu,
473            TensorOp::Bias { offset: 0.0 },
474        ]);
475        // 3 original → 1 fused → 66.6...% reduction
476        let expected = 2.0 / 3.0;
477        assert!((plan.reduction_ratio() - expected).abs() < 1e-10);
478    }
479
480    // -- fused_op_count == ops.len() ----------------------------------------
481
482    #[test]
483    fn fused_op_count_equals_ops_len() {
484        let mut engine = TensorOpFusion::new();
485        let plan = engine.fuse(vec![
486            TensorOp::Scale { factor: 1.0 },
487            TensorOp::Bias { offset: 0.0 },
488            TensorOp::Relu,
489        ]);
490        assert_eq!(plan.fused_op_count, plan.ops.len());
491    }
492
493    // -- Stats --------------------------------------------------------------
494
495    #[test]
496    fn stats_fusion_runs_increments() {
497        let mut engine = TensorOpFusion::new();
498        engine.fuse(vec![TensorOp::Relu]);
499        engine.fuse(vec![TensorOp::Relu]);
500        assert_eq!(engine.stats().total_fusion_runs, 2);
501    }
502
503    #[test]
504    fn stats_total_ops_fused_accumulates() {
505        let mut engine = TensorOpFusion::new();
506        engine.fuse(vec![TensorOp::Relu, TensorOp::Relu]);
507        engine.fuse(vec![TensorOp::Scale { factor: 1.0 }]);
508        // 2 + 1 = 3
509        assert_eq!(engine.stats().total_ops_fused, 3);
510    }
511
512    #[test]
513    fn stats_total_ops_reduced_correct() {
514        let mut engine = TensorOpFusion::new();
515        // Run 1: 2 → 1 (reduced by 1)
516        engine.fuse(vec![
517            TensorOp::Scale { factor: 1.0 },
518            TensorOp::Bias { offset: 0.0 },
519        ]);
520        // Run 2: 3 → 1 (reduced by 2)
521        engine.fuse(vec![
522            TensorOp::Scale { factor: 2.0 },
523            TensorOp::Relu,
524            TensorOp::Bias { offset: 0.5 },
525        ]);
526        assert_eq!(engine.stats().total_ops_reduced, 3);
527    }
528
529    // -- can_fuse -----------------------------------------------------------
530
531    #[test]
532    fn can_fuse_scale_bias_true() {
533        assert!(TensorOpFusion::can_fuse(
534            &TensorOp::Scale { factor: 1.0 },
535            &TensorOp::Bias { offset: 0.0 }
536        ));
537    }
538
539    #[test]
540    fn can_fuse_scale_relu_true() {
541        assert!(TensorOpFusion::can_fuse(
542            &TensorOp::Scale { factor: 1.0 },
543            &TensorOp::Relu
544        ));
545    }
546
547    #[test]
548    fn can_fuse_relu_bias_true() {
549        assert!(TensorOpFusion::can_fuse(
550            &TensorOp::Relu,
551            &TensorOp::Bias { offset: 0.0 }
552        ));
553    }
554
555    #[test]
556    fn can_fuse_matmul_anything_false() {
557        assert!(!TensorOpFusion::can_fuse(
558            &TensorOp::MatMul { rows: 2, cols: 2 },
559            &TensorOp::Scale { factor: 1.0 }
560        ));
561        assert!(!TensorOpFusion::can_fuse(
562            &TensorOp::MatMul { rows: 2, cols: 2 },
563            &TensorOp::Bias { offset: 0.0 }
564        ));
565        assert!(!TensorOpFusion::can_fuse(
566            &TensorOp::MatMul { rows: 2, cols: 2 },
567            &TensorOp::Relu
568        ));
569        assert!(!TensorOpFusion::can_fuse(
570            &TensorOp::MatMul { rows: 2, cols: 2 },
571            &TensorOp::Normalize
572        ));
573    }
574
575    // -- Mixed sequences ----------------------------------------------------
576
577    #[test]
578    fn mixed_sequence_fuses_correctly() {
579        let mut engine = TensorOpFusion::new();
580        // [Scale, Bias, Clamp, Normalize, Relu]
581        // → ScaleBias, ClampNormalize, Passthrough(Relu)
582        let plan = engine.fuse(vec![
583            TensorOp::Scale { factor: 2.0 },
584            TensorOp::Bias { offset: 1.0 },
585            TensorOp::Clamp { min: 0.0, max: 5.0 },
586            TensorOp::Normalize,
587            TensorOp::Relu,
588        ]);
589        assert_eq!(plan.fused_op_count, 3);
590        assert_eq!(
591            plan.ops[0],
592            FusedOp::ScaleBias {
593                scale: 2.0,
594                bias: 1.0
595            }
596        );
597        assert_eq!(plan.ops[1], FusedOp::ClampNormalize { min: 0.0, max: 5.0 });
598        assert_eq!(plan.ops[2], FusedOp::Passthrough(TensorOp::Relu));
599    }
600}