Skip to main content

tract_core/ops/matmul/
optimized.rs

1use crate::internal::*;
2use crate::ops::cast::{Cast, cast};
3use crate::ops::change_axes::wire_with_rank_broadcast;
4use crate::ops::element_wise::ElementWiseOp;
5use crate::ops::nn::LeakyRelu;
6use ndarray::*;
7use tract_itertools::Itertools;
8
9use tract_linalg::mmm::{
10    AsInputValue, EagerPackedInput, FusedSpec, MMMInputValue, MatMatMul, OutputStore,
11    OutputStoreSpec, PackedMatrixStorage, PanelExtractInput, PanelExtractor,
12};
13use tract_linalg::pack::PackedFormat;
14use tract_linalg::{BinOp, Scaler};
15use tract_smallvec::ToSmallVec;
16
17use super::ModePicker;
18
19/// If `new` is `old` with only size-1 axes dropped (non-unit axes untouched, in
20/// order, nothing added or merged), return the removed axis indices; otherwise
21/// `None`. Such a reshape is a pure metadata squeeze the matmul store can absorb.
22fn pure_squeeze_removed(old: &[usize], new: &[usize]) -> Option<TVec<usize>> {
23    let mut removed: TVec<usize> = tvec!();
24    let mut j = 0;
25    for (i, &d) in old.iter().enumerate() {
26        if j < new.len() && d == new[j] {
27            j += 1;
28        } else if d == 1 {
29            removed.push(i);
30        } else {
31            return None;
32        }
33    }
34    (j == new.len() && !removed.is_empty()).then_some(removed)
35}
36
37/// A matmul operand: either an index into the runtime inputs, or a constant
38/// packed value baked in at `fuse()` time (its source outlet was `konst`), so
39/// it is never re-resolved per call. Only non-batched operands are baked.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum MatMulOperand {
42    Input(usize),
43    Const(Box<dyn MMMInputValue>),
44}
45
46impl MatMulOperand {
47    /// Base packed value for the trivial (non-batched) path.
48    #[inline]
49    unsafe fn trivial_value<'t>(&'t self, inputs: &'t [TValue]) -> &'t dyn MMMInputValue {
50        match self {
51            MatMulOperand::Input(i) => unsafe {
52                inputs
53                    .get_unchecked(*i)
54                    .try_storage_as::<PackedMatrixStorage>()
55                    .unwrap_unchecked()
56                    .value()
57            },
58            MatMulOperand::Const(v) => &**v,
59        }
60    }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum ProtoFusedSpec {
65    AddMatMul {
66        geo: AddMatMulGeometry,
67        a: MatMulOperand,
68        b: MatMulOperand,
69        packings: Vec<(usize, Option<PanelExtractor>)>,
70    },
71    BinScalar(usize, BinOp),
72    LeakyRelu(usize),
73    BinPerRow(usize, BinOp, MapOutputAxisToInput),
74    BinPerCol(usize, BinOp, MapOutputAxisToInput),
75    AddRowColProducts(usize, usize),
76    AddUnicast(OutputStoreSpec, usize, MapOutputAxisToInput),
77    Scaler(Scaler),
78    Store(Vec<OutputStoreSpec>),
79}
80
81impl ProtoFusedSpec {
82    pub fn format(&self, mmm: &dyn MatMatMul, mode: usize) -> String {
83        use ProtoFusedSpec::*;
84        match self {
85            AddMatMul { geo, packings: packing, .. } => {
86                let (a, b) = &mmm.packings()[packing[mode].0];
87                format!("matmul(k={}, {a:?}•{b:?})", geo.k)
88            }
89            BinScalar(_, op) => format!("scalar{op:?}"),
90            LeakyRelu(alpha) => format!("leaky_relu({alpha:?})"),
91            BinPerRow(_, op, _) => format!("row{op:?}"),
92            BinPerCol(_, op, _) => format!("col{op:?}"),
93            AddRowColProducts(_, _) => "add_row_col_product".to_string(),
94            AddUnicast(_, _, _) => "add_to_matrix".to_string(),
95            Scaler(s) => format!("scale({})", 1f32 * *s),
96            Store(_oss) => "store".to_string(),
97        }
98    }
99
100    pub fn resolve<'t>(
101        &'t self,
102        inputs: &'t [TValue],
103        output_coords: &[usize],
104        output: &Tensor,
105        mmm: &dyn MatMatMul,
106        mode: usize,
107    ) -> FusedSpec<'t> {
108        #[allow(clippy::let_and_return)]
109        let fs = match self {
110            ProtoFusedSpec::AddMatMul { geo, a, b, packings } => {
111                let resolve =
112                    |operand: &'t MatMulOperand, mapping: &MapOutputAxisToInput| match operand {
113                        MatMulOperand::Input(i) => {
114                            let storage =
115                                inputs[*i].try_storage_as::<PackedMatrixStorage>().unwrap();
116                            let idx = mapping.flat_index(output_coords, storage.batch_strides());
117                            storage.value_at_flat(idx)
118                        }
119                        MatMulOperand::Const(v) => &**v,
120                    };
121                let a = resolve(a, &geo.c_to_a_axis_mapping);
122                let b = resolve(b, &geo.c_to_b_axis_mapping);
123
124                let (_a_packing, b_packing) = &mmm.packings()[packings[mode].0];
125                let pa = if let Some(extractor) = &packings[mode].1 {
126                    let data = a.downcast_ref::<EagerPackedInput>().unwrap();
127                    AsInputValue::Owned(Box::new(PanelExtractInput {
128                        format: extractor.clone(),
129                        data: data.clone(),
130                    }))
131                } else {
132                    AsInputValue::Borrowed(a)
133                };
134                assert!(
135                    b_packing.dyn_eq(b.format())
136                        || (b_packing.is::<PackedFormat>() && b_packing.r() == b.format().r())
137                );
138                debug_assert!(pa.k().to_dim().compatible_with(&geo.k.to_dim()));
139                debug_assert!(b.k().to_dim().compatible_with(&geo.k.to_dim()));
140                FusedSpec::AddMatMul {
141                    a: pa,
142                    b: AsInputValue::Borrowed(b),
143                    packing: packings[mode].0,
144                }
145            }
146            ProtoFusedSpec::BinScalar(v, op) => FusedSpec::BinScalar(&inputs[*v], *op),
147            ProtoFusedSpec::LeakyRelu(v) => FusedSpec::LeakyRelu(&inputs[*v]),
148            ProtoFusedSpec::BinPerRow(v, op, map) => {
149                let mut v = inputs[*v].view();
150                unsafe { map.translate_view(output_coords, &mut v) }
151                FusedSpec::BinPerRow(v, *op)
152            }
153            ProtoFusedSpec::BinPerCol(v, op, map) => {
154                let mut v = inputs[*v].view();
155                unsafe { map.translate_view(output_coords, &mut v) }
156                FusedSpec::BinPerCol(v, *op)
157            }
158            ProtoFusedSpec::AddRowColProducts(row, col) => {
159                FusedSpec::AddRowColProducts(&inputs[*row], &inputs[*col])
160            }
161            ProtoFusedSpec::AddUnicast(store, v, map) => unsafe {
162                let mut view = inputs[*v].view();
163                map.translate_view(output_coords, &mut view);
164                FusedSpec::AddUnicast(store.wrap(&view))
165            },
166            ProtoFusedSpec::Scaler(scaler) => scaler.as_fused_spec(),
167            ProtoFusedSpec::Store(oss) => unsafe {
168                let view = output.view_offsetting_unchecked(output_coords);
169                FusedSpec::Store(oss[mode].wrap(&view))
170            },
171        };
172        fs
173    }
174
175    pub fn is_trivial(&self) -> bool {
176        match self {
177            ProtoFusedSpec::AddMatMul { geo, .. } => geo.k.as_i64().is_some(),
178            _ => true,
179        }
180    }
181
182    pub fn resolve_trivial<'t>(
183        &'t self,
184        inputs: &'t [TValue],
185        output: &mut Tensor,
186        _mmm: &dyn MatMatMul,
187        mode: usize,
188    ) -> FusedSpec<'t> {
189        #[allow(clippy::let_and_return)]
190        let fs = match self {
191            ProtoFusedSpec::AddMatMul { a, b, packings, .. } => unsafe {
192                let a = a.trivial_value(inputs);
193                let b = b.trivial_value(inputs);
194                debug_assert!(packings.len() == 1);
195                debug_assert!(packings[0].1.is_none()); // no panel extraction
196                #[cfg(debug_assertions)]
197                {
198                    let (a_packing, b_packing) = &_mmm.packings()[packings[mode].0];
199                    debug_assert!(
200                        a_packing.dyn_eq(a.format())
201                            || (a_packing.is::<PackedFormat>() && a_packing.r() == a.format().r())
202                    );
203                    debug_assert!(
204                        b_packing.dyn_eq(b.format())
205                            || (b_packing.is::<PackedFormat>() && b_packing.r() == b.format().r())
206                    );
207                }
208                FusedSpec::AddMatMul {
209                    a: AsInputValue::Borrowed(a),
210                    b: AsInputValue::Borrowed(b),
211                    packing: packings[mode].0,
212                }
213            },
214            ProtoFusedSpec::BinScalar(v, op) => FusedSpec::BinScalar(&inputs[*v], *op),
215            ProtoFusedSpec::LeakyRelu(v) => FusedSpec::LeakyRelu(&inputs[*v]),
216            ProtoFusedSpec::BinPerRow(v, op, _) => {
217                let v = inputs[*v].view();
218                FusedSpec::BinPerRow(v, *op)
219            }
220            ProtoFusedSpec::BinPerCol(v, op, _) => {
221                let v = inputs[*v].view();
222                FusedSpec::BinPerCol(v, *op)
223            }
224            ProtoFusedSpec::AddRowColProducts(row, col) => {
225                FusedSpec::AddRowColProducts(&inputs[*row], &inputs[*col])
226            }
227            ProtoFusedSpec::AddUnicast(store, v, _) => unsafe {
228                let view = inputs[*v].view();
229                FusedSpec::AddUnicast(store.wrap(&view))
230            },
231            ProtoFusedSpec::Scaler(scaler) => scaler.as_fused_spec(),
232            ProtoFusedSpec::Store(oss) => unsafe {
233                FusedSpec::Store(oss[mode].wrap(&output.view_mut()))
234            },
235        };
236        fs
237    }
238
239    /// Like [`resolve_trivial`], but a `Store` reuses a cached [`OutputStore`]
240    /// whose strides/layout are fixed for the output shape, refreshing only its
241    /// base pointer from `output`. Everything else defers to [`resolve_trivial`]
242    /// (constant operands are already baked into the op, so no per-call work).
243    fn resolve_trivial_cached<'t>(
244        &'t self,
245        inputs: &'t [TValue],
246        output: &mut Tensor,
247        mmm: &dyn MatMatMul,
248        mode: usize,
249        store: Option<OutputStore>,
250    ) -> FusedSpec<'t> {
251        match self {
252            ProtoFusedSpec::Store(oss) => unsafe {
253                FusedSpec::Store(match store {
254                    Some(cached) => cached.with_tensor(&output.view()),
255                    None => oss[mode].wrap(&output.view_mut()),
256                })
257            },
258            _ => self.resolve_trivial(inputs, output, mmm, mode),
259        }
260    }
261
262    fn check_inputs(&self, inputs: &[&TypedFact]) -> TractResult<()> {
263        use ProtoFusedSpec::*;
264        match self {
265            AddMatMul { a, b, .. } => {
266                for operand in [a, b] {
267                    if let MatMulOperand::Input(ix) = operand {
268                        ensure!(inputs[*ix].is_exotic());
269                    }
270                }
271            }
272            BinScalar(v, _)
273            | LeakyRelu(v)
274            | BinPerCol(v, _, _)
275            | BinPerRow(v, _, _)
276            | AddUnicast(_, v, _) => {
277                ensure!(inputs[*v].datum_type.is_number());
278            }
279            AddRowColProducts(row, col) => {
280                ensure!(inputs[*row].datum_type.is_number());
281                ensure!(inputs[*col].datum_type.is_number());
282            }
283            _ => (),
284        };
285        Ok(())
286    }
287
288    fn cost(&self, m: &TDim, n: &TDim, idt: DatumType) -> TVec<(Cost, TDim)> {
289        match self {
290            ProtoFusedSpec::AddMatMul { geo, .. } => {
291                tvec!((Cost::FMA(idt), m.clone() * n * &geo.k))
292            }
293            _ => tvec!(), /* FIXME maybe */
294        }
295    }
296
297    /// Collect the C axes this op reads through an output→input mapping — i.e.
298    /// the matmul batch axes. `rm_c_axis` only shifts indices past a removed
299    /// axis; it assumes none of these is the one being removed, so a fusion that
300    /// folds a C axis away must first check it is absent here.
301    fn push_mapped_c_axes(&self, out: &mut TVec<usize>) {
302        use ProtoFusedSpec::*;
303        match self {
304            AddMatMul { geo, .. } => {
305                out.extend(geo.c_to_a_axis_mapping.0.iter().map(|(c, _)| *c));
306                out.extend(geo.c_to_b_axis_mapping.0.iter().map(|(c, _)| *c));
307            }
308            BinPerRow(_, _, map) | BinPerCol(_, _, map) | AddUnicast(_, _, map) => {
309                out.extend(map.0.iter().map(|(c, _)| *c));
310            }
311            BinScalar(..) | Scaler(..) | AddRowColProducts(_, _) | LeakyRelu(_) | Store(..) => {}
312        }
313    }
314
315    fn rm_c_axis(&mut self, axis: usize) {
316        use ProtoFusedSpec::*;
317        match self {
318            AddMatMul { geo, .. } => {
319                geo.c_to_a_axis_mapping.rm_c_axis(axis);
320                geo.c_to_b_axis_mapping.rm_c_axis(axis);
321            }
322            BinScalar(..) | Scaler(..) | AddRowColProducts(_, _) | LeakyRelu(_) => {}
323            BinPerRow(_, _, map) | BinPerCol(_, _, map) => map.rm_c_axis(axis),
324            AddUnicast(_, _, map) => {
325                map.rm_c_axis(axis);
326            }
327            Store(oss, ..) => {
328                for oss in oss {
329                    match oss {
330                        OutputStoreSpec::View { m_axis, n_axis, .. } => {
331                            if let Some(m) = m_axis {
332                                *m -= (*m > axis) as usize
333                            };
334                            if let Some(n) = n_axis {
335                                *n -= (*n > axis) as usize
336                            }
337                        }
338                        OutputStoreSpec::Strides { .. } => {}
339                    }
340                }
341            }
342        }
343    }
344}
345
346#[derive(Clone, Debug, PartialEq, Eq)]
347pub struct MapOutputAxisToInput(pub TVec<(usize, usize)>);
348
349impl MapOutputAxisToInput {
350    #[inline]
351    unsafe fn translate_view(&self, output_coords: &[usize], v: &mut TensorView) {
352        for &(out_axis, in_axis) in &self.0 {
353            unsafe { v.offset_axis(in_axis, output_coords[out_axis] as isize) }
354        }
355    }
356
357    #[inline]
358    fn rm_c_axis(&mut self, axis: usize) {
359        for (c, _) in &mut self.0 {
360            *c -= (*c > axis) as usize;
361        }
362    }
363
364    /// Compute a flat index into a PackedMatrixStorage from output coordinates and batch strides.
365    #[inline]
366    pub fn flat_index(&self, output_coords: &[usize], batch_strides: &[isize]) -> usize {
367        self.0
368            .iter()
369            .map(|&(out_axis, in_axis)| output_coords[out_axis] * batch_strides[in_axis] as usize)
370            .sum()
371    }
372}
373
374#[derive(Clone, Debug, PartialEq, Eq)]
375pub struct AddMatMulGeometry {
376    pub k: TDim,
377    pub c_to_a_axis_mapping: MapOutputAxisToInput,
378    pub c_to_b_axis_mapping: MapOutputAxisToInput,
379}
380
381#[derive(Clone, Debug, PartialEq, Eq)]
382pub struct OptMatMul {
383    pub c_fact: TypedFact,
384    pub micro_ops: Vec<ProtoFusedSpec>,
385    pub mmm: Vec<Box<dyn MatMatMul>>,
386    pub mode_picker: ModePicker,
387    pub c_m_axis: Option<usize>,
388    pub c_n_axis: Option<usize>,
389    pub trivial_packing: bool,
390    pub trivial_path: bool,
391}
392
393impl Op for OptMatMul {
394    fn name(&self) -> StaticName {
395        "OptMatMul".into()
396    }
397
398    fn info(&self) -> TractResult<Vec<String>> {
399        let m = self.c_m_axis.map(|ix| &self.c_fact.shape[ix]).unwrap_or(&TDim::Val(1));
400        let n = self.c_n_axis.map(|ix| &self.c_fact.shape[ix]).unwrap_or(&TDim::Val(1));
401        let mut infos = vec![format!(
402            "c_shape:{:?}, c_m_axis:{:?} c_n_axis:{:?} m:{} n:{}",
403            self.c_fact, self.c_m_axis, self.c_n_axis, m, n,
404        )];
405        if let Some(k) = self.guess_k() {
406            infos.push(format!("Mult: m:{} k:{} n:{} with {:?}", m, k, n, self.mmm));
407        } else {
408            infos.push(format!("Mult: {:?}", self.mmm));
409        }
410        for (mode, mmm) in self.mmm.iter().enumerate() {
411            infos.push(format!(
412                "Ops: {}",
413                self.micro_ops.iter().map(|o| o.format(&**mmm, mode)).join(" >>> ")
414            ));
415        }
416        Ok(infos)
417    }
418
419    op_as_typed_op!();
420}
421
422/// Per-execution state for [`OptMatMul`]: on the trivial path it caches each
423/// micro-op's `Store` [`OutputStore`] layout (strides fixed for the output
424/// shape), so only the base pointer is refreshed per call. Constant operands
425/// are baked into the op at `fuse()`, so they need no per-call state here.
426///
427/// Scratch the op manages itself, per session: `space` is one reusable kernel
428/// buffer -- only one op evaluates at a time, and it is reallocated whenever the
429/// picked kernel cannot use the one already there -- and `stores` memoizes the
430/// trivial path's output-store descriptors per node, since those depend on that
431/// node's micro-ops. Thread-local, so nothing here has to be `Send`; a session's
432/// entry goes when the plan calls [`EvalOp::drop_session`], and its kernel buffer is
433/// kept to start the thread's next session, whose first op would otherwise allocate one.
434#[derive(Default)]
435struct MmmScratch {
436    space: Option<Box<dyn tract_linalg::mmm::ScratchSpace>>,
437    stores: HashMap<usize, StoreMemo>,
438}
439
440/// The [`OutputStore`]s of one node, and the output shape and kernel they were
441/// built for: their strides come from that shape, so a turn of another shape
442/// -- a batch axis of another extent, say -- needs its own.
443struct StoreMemo {
444    c_shape: TVec<usize>,
445    mode: usize,
446    stores: TVec<Option<OutputStore>>,
447}
448
449thread_local! {
450    static MMM_SCRATCH: std::cell::RefCell<HashMap<SessionId, MmmScratch>> =
451        std::cell::RefCell::new(HashMap::new());
452    static SPARE_SPACE: std::cell::RefCell<Option<Box<dyn tract_linalg::mmm::ScratchSpace>>> =
453        const { std::cell::RefCell::new(None) };
454}
455
456impl EvalOp for OptMatMul {
457    not_out_of_plan!();
458
459    fn eval(&self, ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
460        MMM_SCRATCH.with_borrow_mut(|per_session| {
461            let scratch = per_session.entry(ctx.session).or_insert_with(|| MmmScratch {
462                space: SPARE_SPACE.take(),
463                stores: HashMap::new(),
464            });
465            self.eval_with_scratch(ctx, inputs, scratch)
466        })
467    }
468
469    fn drop_session(&self, session: SessionId, _node_id: usize) {
470        let space = MMM_SCRATCH.with_borrow_mut(|per_session| per_session.remove(&session));
471        if let Some(space) = space.and_then(|scratch| scratch.space) {
472            SPARE_SPACE.set(Some(space));
473        }
474    }
475}
476
477impl OptMatMul {
478    fn eval_with_scratch(
479        &self,
480        ctx: &EvalContext,
481        inputs: TVec<TValue>,
482        scratch: &mut MmmScratch,
483    ) -> TractResult<TVec<TValue>> {
484        unsafe {
485            let c_shape = self.c_fact.shape.eval_to_usize(ctx.symbols)?;
486            let mut c = Tensor::uninitialized_dt(self.c_fact.datum_type, &c_shape)?;
487            let m = self.c_m_axis.map(|c_m| c.shape()[c_m]).unwrap_or(1);
488            let n = self.c_n_axis.map(|c_n| c.shape()[c_n]).unwrap_or(1);
489            let mode = self.mode_picker.pick(n)?;
490            let mmm = &*self.mmm[mode];
491            let MmmScratch { space, stores } = scratch;
492            if !space.as_ref().is_some_and(|s| mmm.can_use_scratch_space(&**s)) {
493                *space = Some(mmm.allocate_scratch_space());
494            }
495            let scratch = space.as_mut().unwrap();
496            if self.trivial_path {
497                let memo = || StoreMemo {
498                    c_shape: c.shape().into(),
499                    mode,
500                    stores: self
501                        .micro_ops
502                        .iter()
503                        .map(|o| match o {
504                            ProtoFusedSpec::Store(oss) => Some(oss[mode].wrap(&c.view())),
505                            _ => None,
506                        })
507                        .collect(),
508                };
509                let stores = stores.entry(ctx.node_id).or_insert_with(memo);
510                if stores.c_shape.as_slice() != c.shape() || stores.mode != mode {
511                    *stores = memo();
512                }
513                let uops: TVec<FusedSpec> = self
514                    .micro_ops
515                    .iter()
516                    .zip(stores.stores.iter())
517                    .map(|(o, store)| o.resolve_trivial_cached(&inputs, &mut c, mmm, mode, *store))
518                    .collect();
519                mmm.run_with_scratch_space(m, n, scratch.as_mut(), &uops)?;
520                Ok(tvec!(c.into_tvalue()))
521            } else {
522                let mut uops = vec![FusedSpec::ShiftLeft(0); self.micro_ops.len()];
523                let mut looping_shape: TVec<usize> = c_shape.to_smallvec();
524                if let Some(ax) = self.c_m_axis {
525                    looping_shape[ax] = 1;
526                }
527                if let Some(ax) = self.c_n_axis {
528                    looping_shape[ax] = 1;
529                }
530                for c_coords in indices(&*looping_shape) {
531                    for ix in 0..self.micro_ops.len() {
532                        *uops.get_unchecked_mut(ix) = self.micro_ops.get_unchecked(ix).resolve(
533                            &inputs,
534                            c_coords.slice(),
535                            &c,
536                            mmm,
537                            mode,
538                        );
539                    }
540                    mmm.run_with_scratch_space(m, n, scratch.as_mut(), &uops)
541                        .context("In mmm.run_with_scratch_space")?;
542                }
543                Ok(tvec!(c.into_tvalue()))
544            }
545        }
546    }
547}
548
549impl TypedOp for OptMatMul {
550    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
551        ensure!(self.c_m_axis.map(|ax| ax < self.c_fact.rank()).unwrap_or(true));
552        ensure!(self.c_n_axis.map(|ax| ax < self.c_fact.rank()).unwrap_or(true));
553        ensure!(self.trivial_path == self.can_use_trivial_path());
554        ensure!(self.mmm.iter().map(|mmm| mmm.internal_type()).all_equal());
555        for op in &self.micro_ops {
556            op.check_inputs(inputs)?;
557        }
558        Ok(tvec!(self.c_fact.clone()))
559    }
560
561    fn cost(&self, _inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
562        let mut sums = HashMap::new();
563        for op in &self.micro_ops {
564            for (cost, count) in op.cost(self.m(), self.n(), self.mmm[0].internal_type()) {
565                *sums.entry(cost).or_default() += count;
566            }
567        }
568        let loops = self
569            .c_fact
570            .shape
571            .iter()
572            .enumerate()
573            .map(|(ix, d)| {
574                if Some(ix) == self.c_m_axis || Some(ix) == self.c_n_axis {
575                    1.to_dim()
576                } else {
577                    d.clone()
578                }
579            })
580            .product::<TDim>();
581        for s in &mut sums.values_mut() {
582            *s *= &loops;
583        }
584        Ok(sums.into_iter().collect())
585    }
586
587    fn fuse(&self, model: &TypedModel, node: &TypedNode) -> TractResult<Option<TypedModelPatch>> {
588        use crate::ops;
589        if let Some(patch) = self.bake_const_operands(model, node)? {
590            return Ok(Some(patch));
591        }
592        rule_if!(node.outputs.len() == 1);
593        rule_if!(node.outputs[0].successors.len() == 1);
594        rule_if!(!model.output_outlets()?.contains(&node.id.into()));
595        let succ = model.node(node.outputs[0].successors[0].node);
596        let mut patch = TypedModelPatch::new(format!("fusing {succ}"));
597
598        if let Some(op) = succ.op_as::<ops::binary::TypedBinOp>() {
599            rule_if_some!(mut binop = op.0.as_linalg_binop());
600            let flipped = succ.inputs[0].node == node.id;
601            if flipped {
602                binop = binop.flip();
603            }
604            let other_outlet = succ.inputs[flipped as usize];
605            return self.fuse_binary(model, node, patch, other_outlet, binop);
606        }
607        if let Some(op) = succ.op_as::<ops::binary::OptBinByScalar>() {
608            rule_if_some!(mut binop = op.binop.as_linalg_binop());
609            let flipped = succ.inputs[0].node == node.id;
610            if flipped {
611                binop = binop.flip();
612            }
613            let other_outlet = succ.inputs[flipped as usize];
614            return self.fuse_binary(model, node, patch, other_outlet, binop);
615        }
616
617        if let Some(op) = succ.op_as::<ops::element_wise::ElementWiseOp>().map(|ew| ew.0.as_ref()) {
618            if let Some(op) = op.downcast_ref::<ops::math::QScale>() {
619                return self.fuse_op(
620                    model,
621                    node,
622                    patch,
623                    vec![ProtoFusedSpec::Scaler(op.scaler)],
624                    &[],
625                );
626            }
627            if let Some(op) = op.downcast_ref::<LeakyRelu>() {
628                rule_if!(
629                    self.mmm
630                        .iter()
631                        .all(|mmm| mmm.can_fuse(&FusedSpec::LeakyRelu(&tensor0(op.alpha))))
632                );
633                let alpha = patch.add_const(
634                    node.name.to_string() + ".alpha",
635                    tensor0(op.alpha).cast_to_dt(self.mmm[0].internal_type())?.into_owned(),
636                )?;
637                return self.fuse_op(
638                    model,
639                    node,
640                    patch,
641                    vec![ProtoFusedSpec::LeakyRelu(node.inputs.len())],
642                    &[alpha],
643                );
644            }
645        }
646        if let Some(cast_to) = succ.op_as::<ops::cast::Cast>().map(|cast| cast.to)
647            && (((cast_to.unquantized() == i8::datum_type()
648                || cast_to.unquantized() == u8::datum_type())
649                && self.c_fact.datum_type == i32::datum_type())
650                || self.mmm.iter().all(|m| m.stores().contains(&cast_to)))
651            && let Some(ProtoFusedSpec::Store(stores)) = self.micro_ops.last()
652        {
653            rule_if!(stores.iter().all(|s| !matches!(s, OutputStoreSpec::Strides { .. })));
654            let c_fact = cast_to.fact(self.c_fact.shape.clone());
655            let mut patch =
656                TypedModelPatch::fuse_with_next(model, node, Self { c_fact, ..self.clone() })?;
657            patch.dont_apply_twice = Some(format!("Fuse {succ} into {node}"));
658            return Ok(Some(patch));
659        }
660        if let Some(AxisOp::Rm(axis)) = succ.op_as::<ops::AxisOp>() {
661            rule_if!(Some(*axis) != self.c_m_axis);
662            rule_if!(Some(*axis) != self.c_n_axis);
663            let mut new_op = self.clone();
664            new_op.c_fact.shape.remove_axis(*axis)?;
665            if let Some(c_m_axis) = &mut new_op.c_m_axis {
666                *c_m_axis -= (*c_m_axis > *axis) as usize;
667            }
668            if let Some(c_n_axis) = &mut new_op.c_n_axis {
669                *c_n_axis -= (*c_n_axis > *axis) as usize;
670            }
671            for uop in &mut new_op.micro_ops {
672                uop.rm_c_axis(*axis);
673            }
674            let mut patch = TypedModelPatch::fuse_with_next(model, node, new_op)?;
675            patch.dont_apply_twice = Some(format!("Fuse {succ} into {node}"));
676            return Ok(Some(patch));
677        }
678        if let Some(into) = succ.op_as::<IntoShape>()
679            && let Some(new_op) = self.absorb_squeeze(into)
680        {
681            let mut patch = TypedModelPatch::fuse_with_next(model, node, new_op)?;
682            patch.dont_apply_twice = Some(format!("Fuse {succ} into {node}"));
683            return Ok(Some(patch));
684        }
685        // Reach over a shape-agnostic elementwise (Tanh/Sigmoid/Cast/…) to absorb
686        // a squeeze reshape into the store: matmul → ew → squeeze becomes
687        // matmul(squeezed) → ew, unchanged since the op is per-element. With the
688        // direct arm above, squeeze reshapes fuse whether before or after the ew.
689        if (succ.op_is::<ElementWiseOp>() || succ.op_is::<Cast>())
690            && succ.outputs.len() == 1
691            && let &[next] = &*succ.outputs[0].successors
692        {
693            let into_node = model.node(next.node);
694            if let Some(into) = into_node.op_as::<IntoShape>()
695                && let Some(new_op) = self.absorb_squeeze(into)
696            {
697                let mut patch = TypedModelPatch::default();
698                let inputs = node
699                    .inputs
700                    .iter()
701                    .map(|i| patch.tap_model(model, *i))
702                    .collect::<TractResult<TVec<_>>>()?;
703                let mm = patch.wire_node(&node.name, new_op, &inputs)?[0];
704                let ew = patch.wire_node(&succ.name, succ.op.clone(), &[mm])?[0];
705                patch.shunt_outside(model, into_node.id.into(), ew)?;
706                patch.dont_apply_twice = Some(format!("Reach {into_node} into {node}"));
707                return Ok(Some(patch));
708            }
709        }
710        if (succ.op_is::<AxisOp>() || succ.op_is::<IntoShape>())
711            && let &[next] = &*succ.outputs[0].successors
712        {
713            let next_node = model.node(next.node);
714            if let Some(cast) = next_node.op_as::<Cast>() {
715                let mut patch = TypedModelPatch::default();
716                let mut wire = patch.tap_model(model, node.id.into())?;
717                wire = patch.wire_node(&next_node.name, cast.clone(), &[wire])?[0];
718                wire = patch.wire_node(&succ.name, succ.op.clone(), &[wire])?[0];
719                patch.shunt_outside(model, next_node.id.into(), wire)?;
720                return Ok(Some(patch));
721            } else if let Some(op) = next_node.op_as::<ops::binary::TypedBinOp>() {
722                rule_if!(op.0.as_linalg_binop().is_some());
723                let flipped = succ.inputs[0].node == node.id;
724                let other_outlet = next_node.inputs[flipped as usize];
725                if let Some(uni) = &model.outlet_fact(other_outlet)?.uniform {
726                    let mut patch = TypedModelPatch::default();
727                    let cst = patch.add_const(&model.node(other_outlet.node).name, uni.clone())?;
728                    let output = patch.tap_model(model, node.id.into())?;
729                    let wire = wire_with_rank_broadcast(
730                        &next_node.name,
731                        &mut patch,
732                        op.clone(),
733                        &if flipped { [output, cst] } else { [cst, output] },
734                    )?;
735                    let wire = patch.wire_node(&succ.name, succ.op.clone(), &wire)?[0];
736                    patch.shunt_outside(model, next_node.id.into(), wire)?;
737                    return Ok(Some(patch));
738                }
739            }
740        }
741        if let Some(op) = succ.op_as::<ops::binary::OptBinUnicast>() {
742            let in_1_fact = model.outlet_fact(succ.inputs[0])?;
743            let in_2_fact = model.outlet_fact(succ.inputs[1])?;
744            if op.binop.is::<ops::math::Add>()
745                && self.mmm.len() == 1
746                && in_1_fact.without_value() == in_2_fact.without_value()
747            {
748                let other_slot = 1 - node.outputs[0].successors[0].slot;
749                let other_input = succ.inputs[other_slot];
750                let other_input = patch.tap_model(model, other_input)?;
751                let other_fact = patch.outlet_fact(other_input)?;
752
753                if other_fact.shape == self.c_fact.shape {
754                    let other_storage = unsafe { self.mmm[0].c_view(self.c_m_axis, self.c_n_axis) };
755                    let mapping =
756                        MapOutputAxisToInput((0..other_fact.rank()).map(|x| (x, x)).collect());
757                    return self.fuse_op(
758                        model,
759                        node,
760                        patch,
761                        vec![ProtoFusedSpec::AddUnicast(other_storage, node.inputs.len(), mapping)],
762                        &[other_input],
763                    );
764                }
765            } else {
766                rule_if_some!(mut binop = op.binop.as_linalg_binop());
767                let flipped = succ.inputs[0].node == node.id;
768                if flipped {
769                    binop = binop.flip();
770                }
771                let other_outlet = succ.inputs[flipped as usize];
772                return self.fuse_binary(model, node, patch, other_outlet, binop);
773            }
774        };
775        Ok(None)
776    }
777
778    as_op!();
779}
780
781impl OptMatMul {
782    pub fn new(
783        mmm: Vec<Box<dyn MatMatMul>>,
784        mode_picker: ModePicker,
785        c_fact: TypedFact,
786        c_m_axis: Option<usize>,
787        c_n_axis: Option<usize>,
788        micro_ops: Vec<ProtoFusedSpec>,
789        trivial_packing: bool,
790    ) -> TractResult<Self> {
791        if let Some(m) = c_m_axis {
792            ensure!(m < c_fact.rank());
793        }
794        if let Some(n) = c_n_axis {
795            ensure!(n < c_fact.rank());
796        }
797        let mut it = OptMatMul {
798            mmm,
799            mode_picker,
800            c_fact,
801            c_m_axis,
802            c_n_axis,
803            micro_ops,
804            trivial_path: false,
805            trivial_packing,
806        };
807        it.update_trivial_path();
808        Ok(it)
809    }
810
811    // for auditing only (may return None if no AddMatMul is found)
812    pub fn guess_k(&self) -> Option<TDim> {
813        self.micro_ops
814            .iter()
815            .find_map(
816                |o| {
817                    if let ProtoFusedSpec::AddMatMul { geo, .. } = o { Some(geo) } else { None }
818                },
819            )
820            .map(|geo| geo.k.clone())
821    }
822
823    #[inline]
824    pub fn m(&self) -> &TDim {
825        self.c_m_axis.map(|ax| &self.c_fact.shape[ax]).unwrap_or(&TDim::Val(1))
826    }
827
828    #[inline]
829    pub fn n(&self) -> &TDim {
830        self.c_n_axis.map(|ax| &self.c_fact.shape[ax]).unwrap_or(&TDim::Val(1))
831    }
832
833    fn update_trivial_path(&mut self) {
834        self.trivial_path = self.can_use_trivial_path();
835    }
836
837    /// If `into` is a pure unit-axis squeeze of this op's (concrete) output that
838    /// leaves the m/n axes intact, return a clone whose store produces the
839    /// squeezed shape directly. `None` when the reshape can't be absorbed.
840    fn absorb_squeeze(&self, into: &IntoShape) -> Option<Self> {
841        if into.strides != Tensor::natural_strides(&into.dims) {
842            return None;
843        }
844        let old = self.c_fact.shape.as_concrete()?;
845        let removed = pure_squeeze_removed(old, &into.dims)?;
846        if removed.iter().any(|ax| Some(*ax) == self.c_m_axis || Some(*ax) == self.c_n_axis) {
847            return None;
848        }
849        // A non-unit matmul batch axis (e.g. grouped conv) makes the packed
850        // inputs per-batch; folding any axis then desyncs that batch indexing.
851        // Only fuse when every batch axis is trivial (size 1).
852        let mut batch_axes: TVec<usize> = tvec!();
853        self.micro_ops.iter().for_each(|uop| uop.push_mapped_c_axes(&mut batch_axes));
854        if batch_axes.iter().any(|ax| old.get(*ax).copied().unwrap_or(1) > 1) {
855            return None;
856        }
857        let mut new_op = self.clone();
858        for axis in removed.iter().rev() {
859            new_op.c_fact.shape.remove_axis(*axis).ok()?;
860            if let Some(c_m_axis) = &mut new_op.c_m_axis {
861                *c_m_axis -= (*c_m_axis > *axis) as usize;
862            }
863            if let Some(c_n_axis) = &mut new_op.c_n_axis {
864                *c_n_axis -= (*c_n_axis > *axis) as usize;
865            }
866            for uop in &mut new_op.micro_ops {
867                uop.rm_c_axis(*axis);
868            }
869        }
870        Some(new_op)
871    }
872
873    fn can_use_trivial_path(&self) -> bool {
874        self.c_fact.shape.is_concrete()
875            && self.c_fact.shape.iter().enumerate().all(|(ax, dim)| {
876                Some(ax) == self.c_m_axis || Some(ax) == self.c_n_axis || dim.is_one()
877            })
878            && self.trivial_packing
879            && self.micro_ops.iter().all(|o| o.is_trivial())
880    }
881
882    /// Bake matmul operands that are fed by a constant, non-batched input into
883    /// the op as [`MatMulOperand::Const`], dropping the corresponding graph
884    /// input. Runs once the packing has const-folded so the operand's outlet
885    /// carries a packed `konst`. Returns a patch that rewires the node with the
886    /// remaining inputs, or `None` if nothing is bakeable.
887    fn bake_const_operands(
888        &self,
889        model: &TypedModel,
890        node: &TypedNode,
891    ) -> TractResult<Option<TypedModelPatch>> {
892        let bakeable = |operand: &MatMulOperand, mapping: &MapOutputAxisToInput| -> bool {
893            if let MatMulOperand::Input(i) = operand {
894                mapping.0.is_empty()
895                    && model.outlet_fact(node.inputs[*i]).is_ok_and(|f| {
896                        f.konst
897                            .as_ref()
898                            .and_then(|k| k.try_storage_as::<PackedMatrixStorage>().ok())
899                            .is_some()
900                    })
901            } else {
902                false
903            }
904        };
905        let mut baked: TVec<usize> = tvec!();
906        for op in &self.micro_ops {
907            if let ProtoFusedSpec::AddMatMul { geo, a, b, .. } = op {
908                if bakeable(a, &geo.c_to_a_axis_mapping) {
909                    let MatMulOperand::Input(i) = a else { unreachable!() };
910                    baked.push(*i);
911                }
912                if bakeable(b, &geo.c_to_b_axis_mapping) {
913                    let MatMulOperand::Input(i) = b else { unreachable!() };
914                    baked.push(*i);
915                }
916            }
917        }
918        if baked.is_empty() {
919            return Ok(None);
920        }
921        baked.sort();
922        baked.dedup();
923        let remap: Vec<Option<usize>> = {
924            let mut ni = 0;
925            (0..node.inputs.len())
926                .map(|i| {
927                    (!baked.contains(&i)).then(|| {
928                        let cur = ni;
929                        ni += 1;
930                        cur
931                    })
932                })
933                .collect()
934        };
935        let const_value = |i: usize| -> TractResult<Box<dyn MMMInputValue>> {
936            let konst = model.outlet_fact(node.inputs[i])?.konst.clone().unwrap();
937            Ok(dyn_clone::clone_box(konst.try_storage_as::<PackedMatrixStorage>()?.value()))
938        };
939        let map_operand = |operand: &MatMulOperand| -> TractResult<MatMulOperand> {
940            Ok(match operand {
941                MatMulOperand::Input(i) if baked.contains(i) => {
942                    MatMulOperand::Const(const_value(*i)?)
943                }
944                MatMulOperand::Input(i) => MatMulOperand::Input(remap[*i].unwrap()),
945                MatMulOperand::Const(v) => MatMulOperand::Const(v.clone()),
946            })
947        };
948        let micro_ops = self
949            .micro_ops
950            .iter()
951            .map(|op| -> TractResult<ProtoFusedSpec> {
952                use ProtoFusedSpec::*;
953                Ok(match op {
954                    AddMatMul { geo, a, b, packings } => AddMatMul {
955                        geo: geo.clone(),
956                        a: map_operand(a)?,
957                        b: map_operand(b)?,
958                        packings: packings.clone(),
959                    },
960                    BinScalar(v, op) => BinScalar(remap[*v].unwrap(), *op),
961                    LeakyRelu(v) => LeakyRelu(remap[*v].unwrap()),
962                    BinPerRow(v, op, m) => BinPerRow(remap[*v].unwrap(), *op, m.clone()),
963                    BinPerCol(v, op, m) => BinPerCol(remap[*v].unwrap(), *op, m.clone()),
964                    AddRowColProducts(r, c) => {
965                        AddRowColProducts(remap[*r].unwrap(), remap[*c].unwrap())
966                    }
967                    AddUnicast(s, v, m) => AddUnicast(*s, remap[*v].unwrap(), m.clone()),
968                    Scaler(s) => Scaler(*s),
969                    Store(o) => Store(o.clone()),
970                })
971            })
972            .collect::<TractResult<Vec<_>>>()?;
973        let new_op = OptMatMul { micro_ops, ..self.clone() };
974        let kept: TVec<OutletId> =
975            (0..node.inputs.len()).filter(|i| !baked.contains(i)).map(|i| node.inputs[i]).collect();
976        let mut patch = TypedModelPatch::new(format!("bake const operands into {}", node.name));
977        let taps = patch.taps(model, &kept)?;
978        let output = patch.wire_node(&node.name, new_op, &taps)?;
979        patch.shunt_outside(model, node.id.into(), output[0])?;
980        Ok(Some(patch))
981    }
982
983    fn fuse_op(
984        &self,
985        model: &TypedModel,
986        node: &TypedNode,
987        mut patch: TypedModelPatch,
988        fused_micro_op: Vec<ProtoFusedSpec>,
989        additional_inputs: &[OutletId],
990    ) -> TractResult<Option<TypedModelPatch>> {
991        let succ = model.node(node.outputs[0].successors[0].node);
992        let mut new_op = self.clone();
993        let before_last = new_op.micro_ops.len() - 1..new_op.micro_ops.len() - 1;
994        new_op.micro_ops.splice(before_last, fused_micro_op);
995        new_op.c_fact = succ.outputs[0].fact.clone();
996        new_op.update_trivial_path();
997        let mut inputs = patch.taps(model, &node.inputs)?;
998        inputs.extend(additional_inputs.iter().cloned());
999        let output = patch.wire_node(&succ.name, new_op, &inputs)?;
1000        patch.shunt_outside(model, succ.id.into(), output[0])?;
1001        Ok(Some(patch))
1002    }
1003
1004    fn fuse_binary(
1005        &self,
1006        model: &TypedModel,
1007        node: &TypedNode,
1008        mut patch: TypedModelPatch,
1009        value: OutletId,
1010        binop: BinOp,
1011    ) -> TractResult<Option<TypedModelPatch>> {
1012        let fact = model.outlet_fact(value)?;
1013        let mut v = patch.tap_model(model, value)?;
1014        if fact.datum_type != self.mmm[0].internal_type() {
1015            v = patch.wire_node(
1016                format!("{}.cast-input-{}", node.name, node.inputs.len()),
1017                cast(self.mmm[0].internal_type()),
1018                &[v],
1019            )?[0];
1020        }
1021        let value = node.inputs.len();
1022        let additional_input = tvec!(v);
1023        if fact.shape.volume() == 1.to_dim() {
1024            return self.fuse_op(
1025                model,
1026                node,
1027                patch,
1028                vec![ProtoFusedSpec::BinScalar(value, binop)],
1029                &additional_input,
1030            );
1031        }
1032        let other_shape = fact.shape.to_owned();
1033        if self.c_m_axis.is_some_and(|ax| {
1034            other_shape[ax] == self.c_fact.shape[ax] && other_shape[ax] == other_shape.volume()
1035        }) {
1036            return self.fuse_op(
1037                model,
1038                node,
1039                patch,
1040                vec![ProtoFusedSpec::BinPerRow(
1041                    value,
1042                    binop,
1043                    MapOutputAxisToInput(tvec!((self.c_m_axis.unwrap(), self.c_m_axis.unwrap()))),
1044                )],
1045                &additional_input,
1046            );
1047        }
1048        if self.c_n_axis.is_some_and(|ax| {
1049            other_shape[ax] == self.c_fact.shape[ax] && other_shape[ax] == other_shape.volume()
1050        }) {
1051            return self.fuse_op(
1052                model,
1053                node,
1054                patch,
1055                vec![ProtoFusedSpec::BinPerCol(
1056                    value,
1057                    binop,
1058                    MapOutputAxisToInput(tvec!((self.c_n_axis.unwrap(), self.c_n_axis.unwrap()))),
1059                )],
1060                &additional_input,
1061            );
1062        }
1063        Ok(None)
1064    }
1065}