Skip to main content

tract_core/ops/einsum/
mod.rs

1use std::borrow::Borrow;
2use std::fmt::Debug;
3
4use crate::internal::*;
5use crate::ops::array::MultiBroadcastTo;
6use crate::tract_data::itertools::Itertools;
7
8mod eval;
9
10pub mod einsum_matmul;
11pub mod kernel_selection;
12pub mod prefix_matmul;
13
14#[cfg(test)]
15mod proptest;
16
17use num_traits::One;
18use tract_linalg::block_quant::{BlockQuantFact, PackedBlockQuantFact};
19use tract_linalg::mmm::PackedExoticFact;
20
21pub fn block_quant_aware_input_shape(fact: &TypedFact) -> TractResult<Cow<'_, [TDim]>> {
22    if fact.is_plain() {
23        return Ok(Cow::Borrowed(&*fact.shape));
24    }
25    let Some(exotic_fact) = fact.exotic_fact() else {
26        bail!("Datum fact is exotic, but no exotic fact was found.")
27    };
28    if let Some(_bqf) = exotic_fact.downcast_ref::<BlockQuantFact>() {
29        Ok(Cow::Borrowed(&*fact.shape))
30    } else if let Some(pof) = exotic_fact.downcast_ref::<PackedBlockQuantFact>() {
31        Ok(Cow::Owned(
32            fact.shape.iter().cloned().chain(pof.shape.iter().map(|i| i.to_dim())).collect_vec(),
33        ))
34    } else if let Some(pof) = exotic_fact.downcast_ref::<PackedExoticFact>() {
35        Ok(Cow::Owned(
36            fact.shape.iter().cloned().chain([pof.mn.clone(), pof.k.to_dim()]).collect_vec(),
37        ))
38    } else {
39        bail!("Unsupported exotic fact {exotic_fact:?}")
40    }
41}
42
43#[derive(Clone, Hash, PartialEq, Eq)]
44pub struct EinSum {
45    pub axes: AxesMapping,
46    pub operating_dt: DatumType,
47    // if present, assume we're a binary op.
48    // 9 inputs are: A,B,bias, A0,Ascale, B0,BScale, C0,Cscale
49    pub q_params: Option<DatumType>,
50}
51
52impl EinSum {
53    pub fn new(axes: AxesMapping, operating_dt: DatumType) -> EinSum {
54        EinSum { axes, operating_dt, q_params: None }
55    }
56
57    pub fn newq(axes: AxesMapping, operating_dt: DatumType, output_type: DatumType) -> EinSum {
58        EinSum { axes, operating_dt, q_params: Some(output_type) }
59    }
60
61    pub fn actual_input_shapes_from_facts<'m>(
62        &self,
63        inputs: &'m [impl Borrow<TypedFact>],
64    ) -> TractResult<TVec<Cow<'m, [TDim]>>> {
65        ensure!(inputs.len() == self.axes.input_count());
66        let shapes: TVec<Cow<[TDim]>> = inputs
67            .iter()
68            .map(|t| block_quant_aware_input_shape(t.borrow()))
69            .collect::<TractResult<_>>()?;
70        ensure!(
71            shapes.iter().enumerate().all(|(ix, fact)| fact.len() == self.axes.rank(InOut::In(ix)))
72        );
73        Ok(shapes)
74    }
75
76    #[allow(unused_variables)]
77    pub(crate) fn propagate_axis(
78        &self,
79        model: &TypedModel,
80        node: &TypedNode,
81        io: InOut,
82        axis: usize,
83    ) -> TractResult<Option<TypedModelPatch>> {
84        let mut new_axis = self.axes.axis((io, axis))?.clone();
85        let repr = new_axis.repr;
86        let mut patch = TypedModelPatch::new(format!("Propagate axis {}", new_axis.repr));
87        let mut taps = tvec!();
88        for (ix, input) in node.inputs.iter().enumerate() {
89            let mut tap = patch.tap_model(model, *input)?;
90            rule_if!(new_axis.inputs[ix].len() <= 1); // FIXME maybe
91            if new_axis.inputs[ix].is_empty() {
92                let insert_at = self.axes.rank(InOut::In(ix));
93                tap = patch.wire_node(
94                    format!("{}.prop_axis.{}.input_{}", node.name, new_axis.repr, ix),
95                    AxisOp::Add(insert_at),
96                    &[tap],
97                )?[0];
98                new_axis.inputs[ix].push(insert_at);
99            }
100            taps.push(tap);
101        }
102        let must_rm_axis: Option<usize> = if new_axis.outputs[0].len() == 0 {
103            let insert_at = self.axes.rank(InOut::Out(0));
104            new_axis.outputs[0].push(insert_at);
105            Some(insert_at)
106        } else {
107            None
108        };
109        let new_expr = self
110            .axes
111            .iter_all_axes()
112            .map(|it| if it.repr == new_axis.repr { new_axis.clone() } else { it.clone() })
113            .collect_vec();
114        let axes = AxesMapping::new(node.inputs.len(), 1, new_expr)?;
115        let mut wire = patch.wire_node(&node.name, Self { axes, ..self.clone() }, &taps)?;
116        if let Some(position) = must_rm_axis {
117            wire = patch.wire_node(
118                format!("{}.prop_axis.{}.output", node.name, repr),
119                AxisOp::Rm(position),
120                &wire,
121            )?;
122        }
123        patch.shunt_outside(model, node.id.into(), wire[0])?;
124        Ok(Some(patch))
125    }
126
127    pub fn acceptable_accumulators(&self) -> TVec<DatumType> {
128        if self.operating_dt.is_integer() {
129            tvec!(i32::datum_type())
130        } else if self.operating_dt == f16::datum_type() {
131            tvec!(f16::datum_type(), f32::datum_type())
132        } else {
133            tvec!(self.operating_dt)
134        }
135    }
136}
137
138impl Debug for EinSum {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        write!(f, "EinSum {} ({:?})", self.axes, self.operating_dt)
141    }
142}
143
144impl Op for EinSum {
145    fn name(&self) -> StaticName {
146        "EinSum".into()
147    }
148
149    fn info(&self) -> TractResult<Vec<String>> {
150        let mut info = vec![format!("{} ({:?})", self.axes, self.operating_dt)];
151        if let Some(qp) = self.q_params {
152            info.push(format!("Quantized output: {qp:?}"));
153        }
154        Ok(info)
155    }
156
157    op_as_typed_op!();
158}
159
160impl EvalOp for EinSum {
161    op_out_of_plan!();
162
163    fn eval(&self, _ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
164        if inputs.iter().all(|i| i.datum_type().is_number() && i.is_plain()) {
165            let mut adhoc_model = TypedModel::default();
166            let mut wires = tvec!();
167            for (ix, input) in inputs.iter().enumerate() {
168                let fact = TypedFact::shape_and_dt_of(input);
169                let wire = adhoc_model.add_source(format!("input.{ix}"), fact)?;
170                wires.push(wire);
171            }
172            let output = adhoc_model.wire_node("einsum", self.clone(), &wires)?;
173            adhoc_model.select_output_outlets(&output)?;
174            let opti = adhoc_model.into_optimized()?;
175            if opti.nodes.iter().all(|node| !node.op_is::<Self>()) {
176                return opti.into_runnable()?.run(inputs);
177            }
178        }
179
180        let output = if let Some(qp) = self.q_params {
181            eval::eval_q(&self.axes, qp, inputs)
182        } else {
183            dispatch_numbers!(eval::eval_t(self.operating_dt)(&self.axes, inputs))
184        }?;
185        Ok(tvec!(output.into_tvalue()))
186    }
187}
188
189impl TypedOp for EinSum {
190    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
191        let shapes = self.actual_input_shapes_from_facts(inputs)?;
192        for i in 0..inputs.len() {
193            ensure!(shapes[i].len() == self.axes.rank(InOut::In(i)));
194        }
195        for axis in self.axes.iter_all_axes() {
196            assert!(
197                shapes
198                    .iter()
199                    .enumerate()
200                    .flat_map(|(slot, shape)| axis.inputs[slot].iter().map(|a| &shape[*a]))
201                    .try_fold(TDim::one(), |a, b| TDim::broadcast(a, b.clone()))
202                    .is_ok()
203            );
204        }
205        if let Some(qp) = self.q_params {
206            ensure!(inputs.len() == 9);
207            Ok(tvec!(qp.fact(eval::output_shape(&self.axes, &shapes[0..2])?)))
208        } else {
209            Ok(tvec!(TypedFact::dt_shape(
210                self.operating_dt,
211                eval::output_shape(&self.axes, &shapes)?
212            )))
213        }
214    }
215
216    fn input_roi(
217        &self,
218        model: &TypedModel,
219        node: &TypedNode,
220    ) -> TractResult<Option<TVec<Option<TDim>>>> {
221        // First try bubble_roi: works for inputs that cover all ROI coord
222        // axes mentioned in the output ROI.  For inputs that DON'T cover
223        // every coord axis (= contracted/projected-out axes from this
224        // input's perspective), try the closed-form chunked-band recogniser
225        // which yields a constant band on the input's kept axis after
226        // existentially quantifying the projected axes.
227        let output_fact = model.outlet_fact(OutletId::new(node.id, 0))?;
228        let Some(roi) = &output_fact.region_of_interest else { return Ok(None) };
229        let input_facts: TVec<&TypedFact> =
230            node.inputs.iter().map(|i| model.outlet_fact(*i)).collect::<TractResult<_>>()?;
231        let output_facts = tvec![output_fact];
232        let inputs_ref: Vec<&TypedFact> = input_facts.iter().copied().collect();
233        let outputs_ref: Vec<&TypedFact> = output_facts.iter().copied().collect();
234        let mapping = self.axes_mapping(&inputs_ref, &outputs_ref)?;
235        let roi_coord_axes: Vec<(usize, Symbol)> = roi
236            .symbols()
237            .into_iter()
238            .filter_map(|s| crate::ops::logic::sym_to_coord_axis(&s).map(|k| (k, s)))
239            .collect();
240
241        let project_for_input = |input_ix: usize| -> Option<TDim> {
242            // Classify each output ROI coord axis: projected (no input axis)
243            // or preserved (maps to input).
244            let mut projected: Vec<Symbol> = vec![];
245            let mut preserved: Vec<(Symbol, usize)> = vec![];
246            for (out_pos, sym) in &roi_coord_axes {
247                let logical = mapping
248                    .iter_all_axes()
249                    .find(|a| a.outputs.first().is_some_and(|o| o.contains(out_pos)))?;
250                match logical.inputs[input_ix].first() {
251                    None => projected.push(sym.clone()),
252                    Some(&in_pos) => {
253                        if input_facts[input_ix].shape[in_pos] != output_fact.shape[*out_pos] {
254                            return None;
255                        }
256                        preserved.push((sym.clone(), in_pos));
257                    }
258                }
259            }
260            if projected.is_empty() {
261                // All axes preserved — fall through to standard remap.
262                let mut sub_map: HashMap<Symbol, TDim> = HashMap::new();
263                for (sym, in_pos) in &preserved {
264                    if crate::ops::logic::sym_to_coord_axis(sym) != Some(*in_pos) {
265                        let scope = sym.scope()?;
266                        sub_map.insert(sym.clone(), TDim::Sym(scope.coord_sym(*in_pos)));
267                    }
268                }
269                return if sub_map.is_empty() {
270                    Some(roi.clone())
271                } else {
272                    roi.substitute_all(&sub_map).ok()
273                };
274            }
275            // Try the chunked-band recogniser: one projected axis × one
276            // preserved axis at a time.
277            for p_sym in &projected {
278                for (k_sym, k_in_pos) in &preserved {
279                    if let Some(band) = crate::optim::propagate_roi::recognise_chunked_band_project(
280                        roi, p_sym, k_sym,
281                    ) {
282                        // Result mentions k_sym (output frame).  Remap to
283                        // input axis position.
284                        if crate::ops::logic::sym_to_coord_axis(k_sym) != Some(*k_in_pos) {
285                            let scope = k_sym.scope()?;
286                            let mut m: HashMap<Symbol, TDim> = HashMap::new();
287                            m.insert(k_sym.clone(), TDim::Sym(scope.coord_sym(*k_in_pos)));
288                            return band.substitute_all(&m).ok();
289                        }
290                        return Some(band);
291                    }
292                }
293            }
294            None
295        };
296        let result: TVec<Option<TDim>> = (0..node.inputs.len()).map(project_for_input).collect();
297        Ok(Some(result))
298    }
299
300    fn axes_mapping(
301        &self,
302        inputs: &[&TypedFact],
303        _outputs: &[&TypedFact],
304    ) -> TractResult<AxesMapping> {
305        let mut axes = self.axes.clone();
306        for (slot, i) in inputs.iter().enumerate() {
307            if i.is_exotic()
308                && (i.exotic_fact().is_some_and(|of| {
309                    of.is::<PackedExoticFact>() || of.is::<PackedBlockQuantFact>()
310                }))
311            {
312                axes = axes
313                    .remove_axis_occurency(InOut::In(slot), i.rank())?
314                    .remove_axis_occurency(InOut::In(slot), i.rank())?;
315            }
316        }
317        Ok(axes)
318    }
319
320    fn cost(&self, inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
321        let shapes = self.actual_input_shapes_from_facts(inputs)?;
322        let oshape = eval::output_shape(&self.axes, &shapes)?;
323        let ks = self
324            .axes
325            .iter_all_axes()
326            .filter(|axis| axis.outputs[0].len() == 0)
327            .map(|axis| {
328                axis.inputs
329                    .iter()
330                    .enumerate()
331                    .flat_map(|(ix, axes)| {
332                        axes.iter()
333                            .map(|axis| shapes[ix][*axis].clone())
334                            .collect::<TVec<_>>()
335                            .into_iter()
336                    })
337                    .find(|d| !d.is_one())
338                    .unwrap_or_else(|| 1.to_dim())
339            })
340            .product::<TDim>();
341        Ok(tvec!((Cost::FMA(self.operating_dt), oshape.iter().product::<TDim>() * ks)))
342    }
343
344    fn slice(
345        &self,
346        patch: &mut TypedModelPatch,
347        model: &TypedModel,
348        node: &TypedNode,
349        prefix: &str,
350        inputs: &[OutletId],
351        output_axis: usize,
352        _start: &TDim,
353        _end: &TDim,
354    ) -> TractResult<Option<TVec<OutletId>>> {
355        let facts = model.node_input_facts(node.id)?;
356        let axis = self.axes.axis((InOut::Out(0), output_axis))?;
357        if facts
358            .iter()
359            .enumerate()
360            .any(|(slot, fact)| axis.inputs[slot].len() > 0 && fact.is_exotic())
361        {
362            Ok(None)
363        } else {
364            patch.wire_node(prefix, self.clone(), inputs).map(Some)
365        }
366    }
367
368    #[allow(unused_variables)]
369    fn change_axes(
370        &self,
371        model: &TypedModel,
372        node: &TypedNode,
373        io: InOut,
374        change: &AxisOp,
375    ) -> TractResult<Option<AxisChangeConsequence>> {
376        let (mut inputs, mut outputs) = self.axes.to_strs();
377        let interface: &mut String = match io {
378            InOut::In(i) => &mut inputs[i],
379            InOut::Out(o) => &mut outputs[o],
380        };
381        let mut axes: Vec<char> = interface.chars().collect();
382        match change {
383            AxisOp::Rm(rm) => {
384                axes.remove(*rm);
385            }
386            AxisOp::Add(add) => axes.insert(*add, self.axes.available_label()),
387            AxisOp::Move(from, to) => {
388                let c = axes.remove(*from);
389                axes.insert(*to, c);
390            }
391            _ => {
392                return Ok(None);
393            }
394        };
395        *interface = axes.into_iter().collect();
396        let axes = AxesMapping::from_strs(&inputs, &outputs)?;
397        Ok(Some(AxisChangeConsequence {
398            substitute_op: Some(Box::new(EinSum { axes, ..self.clone() })),
399            wire_changes: tvec!((io, change.clone())),
400        }))
401    }
402
403    fn declutter_with_session(
404        &self,
405        session: &mut crate::optim::OptimizerSession,
406        model: &TypedModel,
407        node: &TypedNode,
408    ) -> TractResult<Option<TypedModelPatch>> {
409        if let Some(patch) = declutter_reshape_folding_input_axis(self, session, model, node)? {
410            return Ok(Some(patch));
411        }
412        if let Some(patch) = declutter_broadcast(self, session, model, node)? {
413            return Ok(Some(patch));
414        }
415        if let Some(patch) = unit_k_to_broadcast_mul(self, model, node)? {
416            return Ok(Some(patch));
417        }
418        Ok(None)
419    }
420
421    fn codegen(
422        &self,
423        model: &TypedModel,
424        node: &TypedNode,
425    ) -> TractResult<Option<TypedModelPatch>> {
426        rule_if!(
427            (self.q_params.is_none() && node.inputs.len() == 2)
428                || (self.q_params.is_some() && node.inputs.len() == 9)
429        );
430        // Some EinSums are introduced during codegen itself (e.g. ConvTranspose lowering
431        // emits an EinSum + DeconvSum pair). Those don't get a chance to go through declutter
432        // before being lowered, so we re-check the unit-K → broadcast-Mul rule here as a
433        // fast path. For EinSums that already existed at declutter time, this is a no-op
434        // (the declutter pass would already have rewritten them).
435        if let Some(patch) = unit_k_to_broadcast_mul(self, model, node)? {
436            return Ok(Some(patch));
437        }
438        einsum_matmul::detect_rule(&(), model, node, &node.name, self)
439    }
440
441    as_op!();
442}
443
444fn declutter_reshape_folding_input_axis(
445    op: &EinSum,
446    _session: &mut crate::optim::OptimizerSession,
447    model: &TypedModel,
448    node: &TypedNode,
449) -> TractResult<Option<TypedModelPatch>> {
450    for (slot, prec) in node.inputs.iter().map(|n| model.node(n.node)).enumerate() {
451        let Some(&AxisOp::Reshape(at, ref from, ref to)) = prec.op_as() else { continue };
452        if to.len() > 1 {
453            continue;
454        }
455        let mut axes = op.axes.clone();
456        let extra_labels = axes.available_labels().take(from.len() - 1).collect_vec();
457        // add a temporary input to axes to hold the extra axes
458        let extra_input = node.inputs.len();
459        axes = axes.with_extra_input(extra_input)?;
460        for label in &extra_labels {
461            axes = axes.with_extra_axis(*label, InOut::In(extra_input), 0)?;
462        }
463        let folded_axis = op.axes.axis((InOut::In(slot), at))?;
464        rule_if!(folded_axis.outputs[0].len() <= 1);
465        let mut patch = TypedModelPatch::default();
466        let mut taps = patch.taps(model, &node.inputs)?;
467        for (input, tap) in taps.iter_mut().enumerate() {
468            if folded_axis.inputs[input].len() == 0 {
469                continue;
470            };
471            rule_if!(folded_axis.inputs[input].len() <= 1);
472            let pos = folded_axis.inputs[input][0];
473            for label in &extra_labels {
474                axes = axes.with_extra_axis_occurency(*label, InOut::In(input), pos)?;
475            }
476            *tap = patch.wire_node(
477                format!("{}.reshape_folded_input_{}", node.name, input),
478                AxisOp::Reshape(pos, to.clone(), from.clone()),
479                &[*tap],
480            )?[0];
481        }
482        if folded_axis.outputs[0].len() == 1 {
483            let pos = folded_axis.outputs[0][0];
484            for label in &extra_labels {
485                axes = axes.with_extra_axis_occurency(*label, InOut::Out(0), pos)?;
486            }
487        }
488        axes = axes.remove_slot(InOut::In(extra_input))?;
489        let mut wire = patch.wire_node(&node.name, EinSum { axes, ..op.clone() }, &taps)?;
490        if folded_axis.outputs[0].len() == 1 {
491            let pos = folded_axis.outputs[0][0];
492            wire = patch.wire_node(
493                format!("{}.reshape_folded_output", node.name),
494                AxisOp::Reshape(pos, from.clone(), to.clone()),
495                &wire,
496            )?;
497        }
498        patch.shunt_outside(model, node.id.into(), wire[0])?;
499        return Ok(Some(patch));
500    }
501    Ok(None)
502}
503
504fn declutter_broadcast(
505    op: &EinSum,
506    _session: &mut crate::optim::OptimizerSession,
507    model: &TypedModel,
508    node: &TypedNode,
509) -> TractResult<Option<TypedModelPatch>> {
510    for (ix, outlet) in node.inputs.iter().enumerate() {
511        let prec = model.node(outlet.node);
512        if prec.op_is::<MultiBroadcastTo>() && prec.outputs[0].successors.len() == 1 {
513            let mut patch = TypedModelPatch::default();
514            let mut wires = patch.taps(model, &node.inputs)?;
515            wires[ix] = patch.tap_model(model, prec.inputs[0])?;
516            let wire = patch.wire_node(&node.name, op.clone(), &wires)?[0];
517            patch.shunt_outside(model, node.id.into(), wire)?;
518            return Ok(Some(patch));
519        }
520    }
521    Ok(None)
522}
523
524/// Rewrite an EinSum whose contraction product is statically 1 as a broadcast Mul.
525///
526/// Triggers when:
527/// - All "k-like" axes (present in both inputs, absent from output) have shape 1 in both inputs, OR
528/// - There are no k-like axes at all (Hadamard products like `mn,mn->mn`, outer products like
529///   `m,n->mn`, or any pure broadcast pattern).
530///
531/// In both cases the einsum has no real contraction work — it's a broadcast multiplication
532/// dressed up as an einsum. Lowering it as a matmul leaves the GEMM kernel running per-tile
533/// setup (clear, panel-load, store) for at most one FMA, so a direct broadcast Mul is much
534/// faster on Native (and a net semantic simplification regardless of perf).
535///
536/// Quantized einsums are left untouched: the existing `dequant` path in `EinSumMatMul::codegen`
537/// produces a non-q einsum that this rule then catches naturally on the next declutter pass.
538fn unit_k_to_broadcast_mul(
539    op: &EinSum,
540    model: &TypedModel,
541    node: &TypedNode,
542) -> TractResult<Option<TypedModelPatch>> {
543    if op.q_params.is_some() || node.inputs.len() != 2 {
544        return Ok(None);
545    }
546    let input_facts = model.node_input_facts(node.id)?;
547    let input_shapes = op.actual_input_shapes_from_facts(&input_facts)?;
548    let k_axes: TVec<&Axis> = op
549        .axes
550        .iter_all_axes()
551        .filter(|a| a.inputs[0].len() == 1 && a.inputs[1].len() == 1 && a.outputs[0].is_empty())
552        .collect();
553    // Bail if any k-axis is non-trivial — that's a real contraction, leave it to matmul lowering.
554    let any_nontrivial_k = k_axes.iter().any(|a| {
555        !input_shapes[0][a.inputs[0][0]].is_one() || !input_shapes[1][a.inputs[1][0]].is_one()
556    });
557    if any_nontrivial_k {
558        return Ok(None);
559    }
560    // Scope: only fire when this einsum's output is consumed by a DeconvSum (i.e. it was
561    // emitted by the ConvTranspose lowering pipeline in `Deconv::wire_with_deconv_sum`).
562    // That's the original target case (DFN3 / GTCRN depthwise ConvTranspose with 1×N kernel
563    // collapsing to K=1 — see PR #2183). Other K=1 einsums (e.g. degenerate Q@K^T inside
564    // SDPA when head_dim=1, random-shape proptests with K=1) are intentionally left alone:
565    // backend-specific pipelines (Metal SDPA fusion, MetalMul rank-4 broadcast-segment limit,
566    // …) pattern-match on the matmul shape and break when we substitute a Mul.
567    let has_deconv_sum_consumer = node.outputs.first().is_some_and(|o| {
568        o.successors.iter().any(|inlet| model.node(inlet.node).op.name() == "DeconvSum")
569    });
570    if !has_deconv_sum_consumer {
571        return Ok(None);
572    }
573
574    let one = TDim::one();
575    // Reject "non-trivial single-side disappearing" axes — those need a real reduction.
576    for axis in op.axes.iter_all_axes() {
577        let in_left =
578            axis.inputs[0].first().map(|pos| &input_shapes[0][*pos]).unwrap_or(&one) != &one;
579        let in_right =
580            axis.inputs[1].first().map(|pos| &input_shapes[1][*pos]).unwrap_or(&one) != &one;
581        let in_out = !axis.outputs[0].is_empty();
582        if (in_left ^ in_right) && !in_out {
583            return Ok(None);
584        }
585    }
586
587    let c_axes: Vec<char> = op.axes.axes(InOut::Out(0)).map(|a| a.repr).collect();
588    if c_axes.is_empty() {
589        return Ok(None);
590    }
591
592    let k_reprs: TVec<char> = k_axes.iter().map(|a| a.repr).collect();
593    let mut patch = TypedModelPatch::new("EinSum unit-K → broadcast Mul");
594    let mut wires: TVec<OutletId> = patch.taps(model, &node.inputs)?;
595    let name = &node.name;
596
597    for (slot, wire) in wires.iter_mut().enumerate() {
598        // Promote inputs to operating_dt so the result type matches EinSum::output_facts
599        // (e.g. i8 inputs with i32 operating_dt for an integer matmul that has been dequantized).
600        let cur_dt = patch.outlet_fact(*wire)?.datum_type;
601        if cur_dt != op.operating_dt {
602            *wire = patch.wire_node(
603                format!("{name}.cast_in{slot}"),
604                crate::ops::cast::cast(op.operating_dt),
605                &[*wire],
606            )?[0];
607        }
608
609        // Drop k axes (sorted descending so positions stay valid).
610        let mut k_positions: Vec<usize> = k_axes.iter().map(|a| a.inputs[slot][0]).collect();
611        k_positions.sort_by(|a, b| b.cmp(a));
612        for (i, pos) in k_positions.into_iter().enumerate() {
613            *wire =
614                patch.wire_node(format!("{name}.rm_k_in{slot}.{i}"), AxisOp::Rm(pos), &[*wire])?[0];
615        }
616
617        let mut current: Vec<char> = op
618            .axes
619            .axes(InOut::In(slot))
620            .map(|a| a.repr)
621            .filter(|c| !k_reprs.contains(c))
622            .collect();
623
624        // Drop any remaining axes not in output (must be size 1 by precondition above).
625        let mut to_drop: Vec<(usize, char)> = current
626            .iter()
627            .enumerate()
628            .filter(|(_, c)| !c_axes.contains(c))
629            .map(|(i, c)| (i, *c))
630            .collect();
631        to_drop.sort_by_key(|a| std::cmp::Reverse(a.0));
632        for (pos, c) in to_drop {
633            *wire = patch.wire_node(
634                format!("{name}.rm_extra_in{slot}_{c}"),
635                AxisOp::Rm(pos),
636                &[*wire],
637            )?[0];
638            current.remove(pos);
639        }
640
641        // Insert unit axes for output axes missing from this input.
642        for (target_pos, &t) in c_axes.iter().enumerate() {
643            if !current.contains(&t) {
644                *wire = patch.wire_node(
645                    format!("{name}.add_in{slot}_{t}"),
646                    AxisOp::Add(target_pos),
647                    &[*wire],
648                )?[0];
649                current.insert(target_pos, t);
650            }
651        }
652
653        // Permute to match output axis order.
654        for (target_pos, &t) in c_axes.iter().enumerate() {
655            let cur_pos = current.iter().position(|&c| c == t).unwrap();
656            if cur_pos != target_pos {
657                *wire = patch.wire_node(
658                    format!("{name}.move_in{slot}_{t}"),
659                    AxisOp::Move(cur_pos, target_pos),
660                    &[*wire],
661                )?[0];
662                let removed = current.remove(cur_pos);
663                current.insert(target_pos, removed);
664            }
665        }
666    }
667
668    let result = patch.wire_node(name, crate::ops::math::mul(), &wires)?;
669    patch.shunt_outside(model, node.id.into(), result[0])?;
670    Ok(Some(patch))
671}