Skip to main content

tract_core/ops/nn/
reduce.rs

1use crate::internal::Axis;
2use crate::internal::*;
3use crate::ops::binary::TypedBinOp;
4use crate::ops::cast::cast;
5use crate::ops::change_axes::wire_with_rank_broadcast;
6use crate::ops::element_wise::ElementWiseOp;
7use crate::ops::math::{Mul, Square, div, square};
8use std::convert::TryFrom;
9use std::iter::Sum;
10use std::mem::transmute;
11use tract_data::internal::ClampCast;
12use tract_data::itertools::Itertools;
13use tract_linalg::routines::Func;
14use tract_ndarray::prelude::*;
15use tract_num_traits::{AsPrimitive, Bounded};
16
17macro_rules! r {
18    ($($path:ident)::* ($dt:expr) ($($args:expr),*)) => {
19        match $dt {
20            DatumType::U8   => $($path)::*::<u8,_,_,_>($($args),*),
21            DatumType::I8   => $($path)::*::<i8,_,_,_>($($args),*),
22            DatumType::U16  => $($path)::*::<u16,_,_,_>($($args),*),
23            DatumType::I16  => $($path)::*::<i16,_,_,_>($($args),*),
24            DatumType::I32  => $($path)::*::<i32,_,_,_>($($args),*),
25            DatumType::I64  => $($path)::*::<i64,_,_,_>($($args),*),
26            DatumType::F16  => $($path)::*::<f16,_,_,_>($($args),*),
27            DatumType::F32  => $($path)::*::<f32,_,_,_>($($args),*),
28            DatumType::F64  => $($path)::*::<f64,_,_,_>($($args),*),
29            DatumType::QI8(_)  => $($path)::*::<i8,_,_,_>($($args),*),
30            DatumType::QU8(_)  => $($path)::*::<u8,_,_,_>($($args),*),
31            _ => bail!("{:?} is not a number", $dt)
32        }
33    };
34    ($($path:ident)::* ($dt:expr) ($($args:expr),*); $($q_path:ident)::* ($($q_args:expr),*)) => {
35        match $dt {
36            DatumType::U8   => $($path)::*::<u8,_,_,_>($($args),*),
37            DatumType::I8   => $($path)::*::<i8,_,_,_>($($args),*),
38            DatumType::U16  => $($path)::*::<u16,_,_,_>($($args),*),
39            DatumType::I16  => $($path)::*::<i16,_,_,_>($($args),*),
40            DatumType::I32  => $($path)::*::<i32,_,_,_>($($args),*),
41            DatumType::I64  => $($path)::*::<i64,_,_,_>($($args),*),
42            DatumType::F16  => $($path)::*::<f16,_,_,_>($($args),*),
43            DatumType::F32  => $($path)::*::<f32,_,_,_>($($args),*),
44            DatumType::F64  => $($path)::*::<f64,_,_,_>($($args),*),
45            DatumType::QI8(_)  => $($q_path)::*::<i8,_,_,_>($($q_args),*),
46            DatumType::QU8(_)  => $($q_path)::*::<u8,_,_,_>($($q_args),*),
47            _ => bail!("{:?} is not a number", $dt)
48        }
49    }
50}
51
52#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
53pub enum Reducer {
54    ArgMax(bool), // take last
55    ArgMin(bool),
56    Max,
57    Min,
58    Prod,
59    Sum,
60    MeanOfSquares,
61    All,
62    Any,
63}
64
65impl Reducer {
66    pub fn reduce(&self, axes: &[usize], input: &Tensor) -> TractResult<Tensor> {
67        use Reducer::*;
68        let dt = input.datum_type();
69        let output_shape: Vec<usize> = input
70            .shape()
71            .iter()
72            .enumerate()
73            .map(|(ax, &d)| if axes.contains(&ax) { 1 } else { d })
74            .collect();
75        let (zp, scale) = input.datum_type().zp_scale();
76        unsafe {
77            let mut t = match self {
78                ArgMax(last) => {
79                    r!(Self::reduce_t(dt)(self, axes, &output_shape, input, argmax_t, *last))
80                }
81                ArgMin(last) => {
82                    r!(Self::reduce_t(dt)(self, axes, &output_shape, input, argmin_t, *last))
83                }
84                Min => r!(Self::reduce_t(dt)(self, axes, &output_shape, input, min_t, ())),
85                Max => r!(Self::reduce_t(dt)(self, axes, &output_shape, input, max_t, ())),
86                Prod => {
87                    r!(Self::reduce_t(dt)(self, axes, &output_shape, input, prod_t, ()); Self::reduce_t(self, axes, &output_shape, input, q_prod_t, (zp, scale)))
88                }
89                Sum => {
90                    if dt.is_float() {
91                        dispatch_floatlike!(Self::sum(dt)(self, axes, input))
92                    } else {
93                        r!(Self::reduce_t(dt)(
94                            self,
95                            axes,
96                            &output_shape,
97                            input,
98                            q_sum_t,
99                            (zp, scale)
100                        ))
101                    }
102                }
103                MeanOfSquares => self.mean_of_squares(axes, input)?,
104                All => Self::reduce_t(self, axes, &output_shape, input, all_bool, ()),
105                Any => Self::reduce_t(self, axes, &output_shape, input, any_bool, ()),
106            };
107            if input.datum_type().is_quantized()
108                && input.datum_type().unquantized() == t.datum_type().unquantized()
109            {
110                t.set_datum_type(input.datum_type());
111            }
112            Ok(t)
113        }
114    }
115
116    unsafe fn reduce_t<T, TO, F, A>(
117        &self,
118        axes: &[usize],
119        output_shape: &[usize],
120        input_tensor: &Tensor,
121        f: F,
122        args: A,
123    ) -> Tensor
124    where
125        F: for<'a> Fn(ArrayViewD<'a, T>, A) -> TO,
126        T: Copy + Datum,
127        TO: Copy + Datum,
128        A: Copy,
129    {
130        use ndarray::*;
131        let input = unsafe { input_tensor.to_array_view_unchecked::<T>() };
132        let result = Array::from_shape_fn(output_shape, |coords| {
133            let slice_spec: Vec<SliceInfoElem> = coords
134                .slice()
135                .iter()
136                .enumerate()
137                .map(|(ax, &d)| if axes.contains(&ax) { (..).into() } else { d.into() })
138                .collect();
139            let slice_info = SliceInfo::<_, IxDyn, IxDyn>::try_from(slice_spec).unwrap();
140            let slice = input.slice(&slice_info);
141            f(slice, args)
142        });
143        result.into_tensor()
144    }
145
146    // sum is a special citizen: enough activity that it gets "special"
147    // treatment. we could use the same "algo" for min, max and prod, to the
148    // price of more code in the library. argmax and argmin are more
149    // tricky (not associative)
150    unsafe fn sum<T>(&self, axes: &[usize], input: &Tensor) -> Tensor
151    where
152        T: Copy + Datum + num_traits::Zero + Sum,
153        f16: AsPrimitive<T>,
154        f32: AsPrimitive<T>,
155    {
156        if axes.len() == 0 {
157            return input.to_owned();
158        }
159
160        // use tract-optimized path only when single reuction axis and is at end
161        if axes.len() > 1 || axes[0] != input.rank() - 1 {
162            // f32 sum over the leading axes of a C-contiguous input is a column
163            // sum of the [rows, inner] matrix. Fixed-size blocks, not
164            // thread-count-sized ones: the partials land in the same order at
165            // any thread count, so the result is bit-identical threaded or not.
166            if T::datum_type() == f32::datum_type()
167                && axes.iter().enumerate().all(|(ix, &a)| a == ix)
168                && let Some(full) = unsafe { input.to_array_view_unchecked::<f32>() }.as_slice()
169            {
170                let k = axes.len();
171                let rows: usize = input.shape()[..k].iter().product();
172                let inner: usize = input.shape()[k..].iter().product();
173                if rows > 0 && inner > 0 {
174                    let block = (32768 / inner).max(1);
175                    let n_blocks = rows.div_ceil(block);
176                    let mut partials = vec![0f32; n_blocks * inner];
177                    tract_linalg::multithread::par_chunks_mut(
178                        &mut partials,
179                        inner,
180                        rows * inner,
181                        |first_block, chunk| {
182                            for (i, p) in chunk.chunks_mut(inner).enumerate() {
183                                let lo = (first_block + i) * block;
184                                let hi = (lo + block).min(rows);
185                                for r in lo..hi {
186                                    for (p, v) in p.iter_mut().zip(&full[r * inner..][..inner]) {
187                                        *p += *v;
188                                    }
189                                }
190                            }
191                            Ok(())
192                        },
193                    )
194                    .unwrap();
195                    let mut out = vec![0f32; inner];
196                    for p in partials.chunks_exact(inner) {
197                        for (o, v) in out.iter_mut().zip(p) {
198                            *o += *v;
199                        }
200                    }
201                    let mut out_shape: Vec<usize> = vec![1; k];
202                    out_shape.extend_from_slice(&input.shape()[k..]);
203                    return ArrayD::from_shape_vec(IxDyn(&out_shape), out).unwrap().into_tensor();
204                }
205            }
206            let mut operative_axes = vec![];
207            let mut operative_shape: Vec<usize> = vec![];
208            for (ix, dim) in input.shape().iter().enumerate() {
209                // axis is reduced, but is not the first of a series of reduced axes
210                if ix > 0 && axes.contains(&ix) && axes.contains(&(ix - 1)) {
211                    *operative_shape.last_mut().unwrap() *= *dim;
212                } else if axes.contains(&ix) {
213                    operative_axes.push(operative_shape.len());
214                    operative_shape.push(*dim);
215                } else {
216                    operative_shape.push(*dim);
217                }
218            }
219            let mut output = unsafe {
220                input
221                    .to_array_view_unchecked::<T>()
222                    .into_shape_with_order(operative_shape)
223                    .unwrap()
224                    .sum_axis(Axis(*operative_axes.iter().max().unwrap()))
225            };
226
227            for axis in operative_axes.iter().rev().skip(1) {
228                output = output.sum_axis(Axis(*axis));
229            }
230
231            let mut output = output.into_tensor();
232
233            for &axis in axes {
234                output.insert_axis(axis).unwrap();
235            }
236
237            output
238        } else {
239            let mut output: Option<ArrayD<T>> = None;
240            for axis in axes.iter().copied() {
241                let input_view = output
242                    .as_ref()
243                    .map(|o| o.view())
244                    .unwrap_or_else(|| unsafe { input.to_array_view_unchecked::<T>() });
245
246                // Create array that will contain intermidiate result
247                let reduced_dim = input_view.shape()[axis];
248                let input_stride = input_view.strides()[axis] as usize;
249                let output_shape = input_view
250                    .shape()
251                    .iter()
252                    .enumerate()
253                    .map(|(idx, dim)| if idx != axis { *dim } else { 1 })
254                    .collect_vec();
255
256                output = Some(if let Some(full) = input_view.as_slice() {
257                    // Whole input is C-contiguous and `axis` is the last axis,
258                    // so it lays out as [n_rows, reduced_dim] row-major: sum
259                    // each row in one pass. Rows split across threads while
260                    // each row's reduction stays serial and bit-identical.
261                    let n_rows = full.len() / reduced_dim;
262                    let mut out = vec![T::zero(); n_rows];
263                    let total = full.len();
264                    // Reduce kernels are Send + Sync; build once and share by ref.
265                    let sum_f16 = Func::ReduceSum.reduce_f16().unwrap();
266                    let sum_f32 = Func::ReduceSum.reduce_f32().unwrap();
267                    tract_linalg::multithread::par_chunks_mut(
268                        &mut out,
269                        1,
270                        total,
271                        |first_row, o| {
272                            let rows = full[first_row * reduced_dim..][..o.len() * reduced_dim]
273                                .chunks_exact(reduced_dim);
274                            if reduced_dim >= 4 && T::datum_type() == f16::datum_type() {
275                                for (x, c) in o.iter_mut().zip(rows) {
276                                    let c: &[f16] = unsafe { std::mem::transmute(c) };
277                                    *x = sum_f16.run_with_params(c, ())?.as_();
278                                }
279                            } else if reduced_dim >= 4 && T::datum_type() == f32::datum_type() {
280                                for (x, c) in o.iter_mut().zip(rows) {
281                                    let c: &[f32] = unsafe { std::mem::transmute(c) };
282                                    *x = sum_f32.run_with_params(c, ())?.as_();
283                                }
284                            } else {
285                                // reduced_dim < 4 (kernel dispatch not worth it) or a
286                                // non-f16/f32 type: a plain sum matches the kernel's
287                                // remainder path bit-for-bit.
288                                for (x, c) in o.iter_mut().zip(rows) {
289                                    *x = c.iter().cloned().sum::<T>();
290                                }
291                            }
292                            Ok(())
293                        },
294                    )
295                    .unwrap();
296                    ArrayD::from_shape_vec(output_shape.clone(), out).unwrap()
297                } else {
298                    ArrayD::from_shape_fn(output_shape.clone(), |coords| {
299                        let first: *const T = &input_view[coords];
300                        let mut sum = T::zero();
301                        for i in 0..reduced_dim {
302                            sum = sum + unsafe { *(first.add(i * input_stride)) };
303                        }
304                        sum
305                    })
306                });
307            }
308            output.unwrap().into_tensor()
309        }
310    }
311
312    fn mean_of_squares(&self, axis: &[usize], input: &Tensor) -> TractResult<Tensor> {
313        let dt = input.datum_type();
314        let mut input = input.cast_to::<f32>()?.into_owned();
315        input.try_as_plain_ram_mut()?.as_slice_mut::<f32>()?.iter_mut().for_each(|x| *x = *x * *x);
316        let mut output = unsafe { self.sum::<f32>(axis, &input) };
317        let norm = output.len() as f32 / input.len() as f32;
318        output.try_as_plain_ram_mut()?.as_slice_mut::<f32>()?.iter_mut().for_each(|x| *x *= norm);
319        Ok(output.cast_to_dt(dt)?.into_owned())
320    }
321}
322
323fn argmax_t<T>(v: ArrayViewD<T>, last: bool) -> i64
324where
325    T: Copy + Datum + num_traits::Bounded + ::std::cmp::PartialOrd,
326{
327    v.iter()
328        .copied()
329        .enumerate()
330        .fold(
331            (0usize, T::min_value()),
332            |acc, v| {
333                if v.1 > acc.1 || (last && acc.1 == v.1) { v } else { acc }
334            },
335        )
336        .0 as i64
337}
338
339fn argmin_t<T>(v: ArrayViewD<T>, last: bool) -> i64
340where
341    T: Copy + Datum + num_traits::Bounded + ::std::cmp::PartialOrd,
342{
343    v.iter()
344        .copied()
345        .enumerate()
346        .fold(
347            (0usize, T::max_value()),
348            |acc, v| {
349                if v.1 < acc.1 || (last && acc.1 == v.1) { v } else { acc }
350            },
351        )
352        .0 as i64
353}
354
355fn max_t<T>(v: ArrayViewD<T>, _: ()) -> T
356where
357    T: Copy + Datum + num_traits::Bounded + ::std::cmp::PartialOrd,
358{
359    if T::datum_type() == f32::datum_type()
360        && let Some(slice) = v.as_slice()
361        && !slice.is_empty()
362    {
363        let slice = unsafe { transmute::<&[T], &[f32]>(slice) };
364        let max = Func::ReduceMax.reduce_f32().unwrap().run(slice).unwrap();
365        // SAFETY: T is f32 in this branch (checked above).
366        return unsafe { std::mem::transmute_copy::<f32, T>(&max) };
367    }
368    v.fold(T::min_value(), |acc, &v| if acc > v { acc } else { v })
369}
370
371fn min_t<T>(v: ArrayViewD<T>, _: ()) -> T
372where
373    T: Copy + Datum + num_traits::Bounded + ::std::cmp::PartialOrd,
374{
375    if T::datum_type() == f32::datum_type()
376        && let Some(slice) = v.as_slice()
377        && !slice.is_empty()
378    {
379        let slice = unsafe { transmute::<&[T], &[f32]>(slice) };
380        let min = Func::ReduceMin.reduce_f32().unwrap().run(slice).unwrap();
381        // SAFETY: T is f32 in this branch (checked above).
382        return unsafe { std::mem::transmute_copy::<f32, T>(&min) };
383    }
384    v.fold(T::max_value(), |acc, &v| if acc < v { acc } else { v })
385}
386
387fn prod_t<T>(v: ArrayViewD<T>, _: ()) -> T
388where
389    T: Copy + Datum + num_traits::One,
390{
391    v.fold(T::one(), |acc, &v| acc * v)
392}
393
394fn q_prod_t<T>(v: ArrayViewD<T>, zp_scale: (i32, f32)) -> T
395where
396    T: Copy + num_traits::AsPrimitive<f32> + Bounded + Datum,
397    f32: num_traits::AsPrimitive<T>,
398{
399    let (zp, scale) = zp_scale;
400    (v.fold(1f32, |acc, &v| acc * (v.as_() - zp as f32)) * scale.powi(v.len() as i32 - 1)
401        + zp as f32)
402        .clamp_cast()
403}
404
405fn q_sum_t<T>(v: ArrayViewD<T>, zp_scale: (i32, f32)) -> T
406where
407    T: Copy + Bounded + num_traits::AsPrimitive<i32> + Datum,
408    i32: num_traits::AsPrimitive<T>,
409{
410    let (zp, _) = zp_scale;
411    (v.fold(0i32, |acc, &v| acc + v.as_()) - zp * (v.len() as i32 - 1)).clamp_cast()
412}
413
414fn all_bool(v: ArrayViewD<bool>, _: ()) -> bool {
415    v.iter().all(|v| *v)
416}
417
418fn any_bool(v: ArrayViewD<bool>, _: ()) -> bool {
419    v.iter().any(|v| *v)
420}
421
422#[derive(Clone, Debug, new, Hash, PartialEq, Eq)]
423pub struct Reduce {
424    pub axes: TVec<usize>,
425    pub reducer: Reducer,
426}
427
428impl Op for Reduce {
429    fn name(&self) -> StaticName {
430        format!("Reduce<{:?}>", self.reducer).into()
431    }
432    fn info(&self) -> TractResult<Vec<String>> {
433        Ok(vec![format!("axes: {:?}", self.axes)])
434    }
435    op_as_typed_op!();
436}
437
438impl EvalOp for Reduce {
439    op_out_of_plan!();
440
441    fn eval(&self, _ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
442        Ok(tvec!(self.reducer.reduce(&self.axes, &inputs[0])?.into()))
443    }
444}
445
446impl TypedOp for Reduce {
447    fn input_roi(
448        &self,
449        model: &TypedModel,
450        node: &TypedNode,
451    ) -> TractResult<Option<TVec<Option<TDim>>>> {
452        crate::optim::propagate_roi::bubble_roi(model, node)
453    }
454
455    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
456        ensure!(self.axes.iter().tuple_windows().all(|(a, b)| a < b));
457        if inputs[0].datum_type == TDim::datum_type() {
458            bail!("Reduce input must be cast from TDim to i64 beforehand")
459        }
460        let mut shape: TVec<_> = inputs[0].shape.to_tvec();
461        for &ax in &self.axes {
462            shape[ax] = 1.to_dim();
463        }
464        let dt = if let Reducer::ArgMax(_) | Reducer::ArgMin(_) = self.reducer {
465            DatumType::I64
466        } else {
467            inputs[0].datum_type
468        };
469        Ok(tvec!(dt.fact(shape)))
470    }
471
472    fn declutter(
473        &self,
474        model: &TypedModel,
475        node: &TypedNode,
476    ) -> TractResult<Option<TypedModelPatch>> {
477        if let Some(patch) = self.declutter_mean_of_square(model, node)? {
478            return Ok(Some(patch));
479        }
480        if let Some(patch) = self.declutter_scalar_mul_then_sum(model, node)? {
481            return Ok(Some(patch));
482        }
483        if let Some(patch) = self.declutter_reduce_reduce(model, node)? {
484            return Ok(Some(patch));
485        }
486        if let Some(patch) = super::rms_norm::detect_rms_norm(self, model, node)? {
487            return Ok(Some(patch));
488        }
489        Ok(None)
490    }
491
492    fn cost(&self, inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
493        let dt = inputs[0].datum_type;
494        let count: TDim = inputs[0].shape.iter().product();
495        match self.reducer {
496            Reducer::Sum
497            | Reducer::Prod
498            | Reducer::Min
499            | Reducer::Max
500            | Reducer::All
501            | Reducer::Any => Ok(tvec!((Cost::FMA(dt), count))),
502            Reducer::MeanOfSquares => Ok(tvec!((Cost::FMA(dt), count * 2))),
503            Reducer::ArgMax(_) | Reducer::ArgMin(_) => Ok(tvec!((Cost::FMA(dt), count))),
504        }
505    }
506
507    fn axes_mapping(
508        &self,
509        inputs: &[&TypedFact],
510        outputs: &[&TypedFact],
511    ) -> TractResult<AxesMapping> {
512        let mut letters = 'a'..;
513        let axes = (0..inputs[0].rank())
514            .flat_map(|ix| {
515                if self.axes.contains(&ix) {
516                    tvec!(
517                        Axis::new(letters.next().unwrap(), inputs.len(), outputs.len())
518                            .input(0, ix),
519                        Axis::new(letters.next().unwrap(), inputs.len(), outputs.len())
520                            .output(0, ix),
521                    )
522                } else {
523                    tvec!(
524                        Axis::new(letters.next().unwrap(), inputs.len(), outputs.len())
525                            .input(0, ix)
526                            .output(0, ix)
527                    )
528                }
529                .into_iter()
530            })
531            .collect_vec();
532        AxesMapping::new(1, 1, axes)
533    }
534
535    fn change_axes(
536        &self,
537        model: &TypedModel,
538        node: &TypedNode,
539        _io: InOut,
540        change: &AxisOp,
541    ) -> TractResult<Option<AxisChangeConsequence>> {
542        let mut axes = tvec!();
543        for reduced in &self.axes {
544            rule_if_some!(axis = change.transform_axis(*reduced));
545            axes.push(axis);
546        }
547        axes.sort();
548        let op = Some(Box::new(Self { axes, ..self.clone() }) as _);
549        Ok(Some(AxisChangeConsequence::new(model, node, op, change)))
550    }
551
552    fn slice(
553        &self,
554        patch: &mut TypedModelPatch,
555        _model: &TypedModel,
556        node: &TypedNode,
557        _prefix: &str,
558        inputs: &[OutletId],
559        output_axis: usize,
560        _start: &TDim,
561        _end: &TDim,
562    ) -> TractResult<Option<TVec<OutletId>>> {
563        rule_if!(!self.axes.contains(&output_axis));
564        patch.wire_node(&node.name, &node.op, inputs).map(Some)
565    }
566
567    as_op!();
568}
569
570impl Reduce {
571    fn declutter_reduce_reduce(
572        &self,
573        model: &TypedModel,
574        node: &TypedNode,
575    ) -> TractResult<Option<TypedModelPatch>> {
576        use Reducer::*;
577        rule_if_some!(prec = model.linear_prec(node.id)?);
578        rule_if_some!(prec_reduce = prec.op_as::<Self>());
579        rule_if!(prec_reduce.reducer == self.reducer);
580        rule_if!([Sum, Prod, Min, Max].contains(&self.reducer));
581        let mut patch = TypedModelPatch::default();
582        let wire = patch.tap_model(model, prec.inputs[0])?;
583        let wire = patch.wire_node(
584            &node.name,
585            Self {
586                reducer: self.reducer,
587                axes: prec_reduce
588                    .axes
589                    .iter()
590                    .chain(self.axes.iter())
591                    .copied()
592                    .sorted()
593                    .dedup()
594                    .collect(),
595            },
596            &[wire],
597        )?;
598        patch.shunt_outside(model, node.id.into(), wire[0])?;
599        Ok(Some(patch))
600    }
601
602    fn declutter_scalar_mul_then_sum(
603        &self,
604        model: &TypedModel,
605        node: &TypedNode,
606    ) -> TractResult<Option<TypedModelPatch>> {
607        if self.reducer == Reducer::Sum {
608            rule_if_some!(prec = model.linear_prec(node.id)?);
609            rule_if_some!(prec_bin = prec.op_as::<TypedBinOp>());
610            rule_if!(prec_bin.0.is::<Mul>());
611            let mul_input_fact = model.node_input_facts(prec.id)?;
612            rule_if_some!(
613                scalar_slot = mul_input_fact
614                    .iter()
615                    .position(|f| f.konst.as_ref().is_some_and(|k| k.volume() == 1))
616            );
617            let mut patch = TypedModelPatch::default();
618            let scalar = patch.tap_model(model, prec.inputs[scalar_slot])?;
619            let wire = patch.tap_model(model, prec.inputs[1 - scalar_slot])?;
620            let wire = patch.wire_node(&node.name, self.clone(), &[wire])?[0];
621            let wire = patch.wire_node(&prec.name, prec_bin.clone(), &[wire, scalar])?[0];
622            patch.shunt_outside(model, node.id.into(), wire)?;
623            return Ok(Some(patch));
624        }
625        Ok(None)
626    }
627
628    fn declutter_mean_of_square(
629        &self,
630        model: &TypedModel,
631        node: &TypedNode,
632    ) -> TractResult<Option<TypedModelPatch>> {
633        if self.reducer == Reducer::Sum {
634            rule_if_some!(prec = model.linear_prec(node.id)?);
635            rule_if_some!(prec_ew = prec.op_as::<ElementWiseOp>());
636            rule_if!(prec_ew.0.is::<Square>());
637            rule_if!(node.outputs.len() == 1);
638            rule_if!(node.outputs[0].successors.len() == 1);
639            let our_inlet = node.outputs[0].successors[0];
640            let succ = model.node(our_inlet.node);
641            rule_if_some!(succ_bin = succ.op_as::<TypedBinOp>());
642            rule_if!(succ_bin.0.is::<Mul>());
643            let other = succ.inputs[1 - our_inlet.slot];
644            rule_if_some!(other_konst = model.outlet_fact(other)?.uniform.as_ref());
645            let norm: TDim = self.axes.iter().map(|&ax| &prec.outputs[0].fact.shape[ax]).product();
646            rule_if_some!(norm = norm.as_i64());
647            rule_if!(norm > 0);
648            let norm = tensor0((norm as f32).recip());
649            if other_konst.close_enough(&norm, Approximation::Close).is_ok() {
650                let mut patch = TypedModelPatch::default();
651                let wire = patch.tap_model(model, prec.inputs[0])?;
652                let wire = patch.wire_node(
653                    &node.name,
654                    Reduce::new(self.axes.clone(), Reducer::MeanOfSquares),
655                    &[wire],
656                )?[0];
657                patch.shunt_outside(model, succ.id.into(), wire)?;
658                return Ok(Some(patch));
659            }
660        }
661        Ok(None)
662    }
663}
664
665pub fn expand_mean_of_squares(
666    _ctx: &(),
667    model: &TypedModel,
668    node: &TypedNode,
669    name: &str,
670    op: &Reduce,
671) -> TractResult<Option<TypedModelPatch>> {
672    rule_if!(op.reducer == Reducer::MeanOfSquares);
673    let mut patch = TypedModelPatch::default();
674    let mut wire = tvec!(patch.tap_model(model, node.inputs[0])?);
675    let input_fact = model.outlet_fact(node.inputs[0])?;
676    let dt = input_fact.datum_type;
677    if dt != f32::datum_type() {
678        wire = patch.wire_node(format!("{name}.to_f32"), cast(f32::datum_type()), &wire)?;
679    }
680    wire = patch.wire_node(format!("{name}.sqr"), square(), &wire)?;
681    wire = patch.wire_node(
682        format!("{name}.sum"),
683        Reduce::new(op.axes.clone(), Reducer::Sum),
684        &wire,
685    )?;
686    let card = input_fact
687        .shape
688        .iter()
689        .enumerate()
690        .filter(|(ix, _dim)| op.axes.contains(ix))
691        .map(|(_ix, dim)| dim)
692        .product::<TDim>();
693    let card = patch.add_const(format!("{name}.card"), tensor0(card))?;
694    let card = patch.wire_node(format!("{name}.card_to_f32"), cast(f32::datum_type()), &[card])?;
695
696    wire =
697        wire_with_rank_broadcast(format!("{name}.norm"), &mut patch, div(), &[wire[0], card[0]])?;
698    if dt != f32::datum_type() {
699        wire = patch.wire_node(format!("{name}.from_f32"), cast(dt), &wire)?;
700    }
701    patch.shunt_outside(model, node.id.into(), wire[0])?;
702    Ok(Some(patch))
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    // Guards the f32 max reduction (max_t): the SIMD `max_f32` kernel result must
710    // be returned (contiguous path), with the scalar fold used only for strided
711    // slices. Checked against explicit per-row / per-col references.
712    #[test]
713    fn reduce_max_f32_contiguous_and_strided() {
714        let (r, c) = (5usize, 37usize); // c not a multiple of the SIMD width (tail)
715        let data: Vec<f32> = (0..r * c).map(|i| ((i * 31 % 97) as f32) - 48.0).collect();
716        let t = Tensor::from_shape(&[r, c], &data).unwrap();
717
718        // axis 1: per-row max — contiguous slices -> SIMD path.
719        let got = Reducer::Max.reduce(&[1], &t).unwrap();
720        assert_eq!(got.shape(), &[r, 1]);
721        for (i, &g) in unsafe { got.as_slice_unchecked::<f32>() }.iter().enumerate() {
722            let want = data[i * c..(i + 1) * c].iter().copied().fold(f32::MIN, f32::max);
723            assert_eq!(g, want, "row {i}");
724        }
725
726        // axis 0: per-col max — strided slices -> scalar fold.
727        let got = Reducer::Max.reduce(&[0], &t).unwrap();
728        assert_eq!(got.shape(), &[1, c]);
729        for (j, &g) in unsafe { got.as_slice_unchecked::<f32>() }.iter().enumerate() {
730            let want = (0..r).map(|i| data[i * c + j]).fold(f32::MIN, f32::max);
731            assert_eq!(g, want, "col {j}");
732        }
733
734        // k == 1 (single-element reduction) exercises the SIMD-path length guard.
735        let t1 = Tensor::from_shape(&[3, 1], &[1.0f32, -2.0, 3.0]).unwrap();
736        let got = Reducer::Max.reduce(&[1], &t1).unwrap();
737        assert_eq!(unsafe { got.as_slice_unchecked::<f32>() }, &[1.0, -2.0, 3.0]);
738    }
739
740    // The leading-axes column-sum path sums in a different order than the
741    // ndarray fallback, so the comparison is Close; the threaded run must be
742    // bit-identical to the serial fast-path result.
743    // wasi has no thread spawning at all, so the 4-thread half cannot run there.
744    #[cfg(not(target_os = "wasi"))]
745    #[test]
746    fn sum_over_leading_axes_matches_ndarray() {
747        for (shape, axes) in [
748            (vec![160usize, 160, 48], vec![0usize, 1]),
749            (vec![7, 5, 3], vec![0]),
750            (vec![2, 3, 4, 5], vec![0, 1, 2]),
751        ] {
752            let len: usize = shape.iter().product();
753            let data: Vec<f32> = (0..len).map(|i| ((i * 37 % 113) as f32) - 56.0).collect();
754            let t = Tensor::from_shape(&shape, &data).unwrap();
755            let got = Reducer::Sum.reduce(&axes, &t).unwrap();
756            let mut want = t.to_plain_array_view::<f32>().unwrap().to_owned();
757            for _ in 0..axes.len() {
758                want = want.sum_axis(tract_ndarray::Axis(0));
759            }
760            let mut want = want.into_tensor();
761            for &ax in &axes {
762                want.insert_axis(ax).unwrap();
763            }
764            assert_eq!(got.shape(), want.shape());
765            got.close_enough(&want, Approximation::Close).unwrap();
766            // wasi has no thread spawning; other wasm targets use the shared
767            // rayon global pool instead of a private ThreadPool.
768            #[cfg(target_family = "wasm")]
769            let pool = tract_linalg::multithread::Executor::RayonGlobal;
770            #[cfg(not(target_family = "wasm"))]
771            let pool = tract_linalg::multithread::Executor::multithread(4);
772            let got4 = tract_linalg::multithread::multithread_tract_scope(pool, || {
773                Reducer::Sum.reduce(&axes, &t)
774            })
775            .unwrap();
776            assert_eq!(got4, got);
777        }
778    }
779
780    // Same coverage for the f32 min reduction (min_t -> SIMD min_f32 / scalar fold).
781    #[test]
782    fn reduce_min_f32_contiguous_and_strided() {
783        let (r, c) = (5usize, 37usize); // c not a multiple of the SIMD width (tail)
784        let data: Vec<f32> = (0..r * c).map(|i| ((i * 31 % 97) as f32) - 48.0).collect();
785        let t = Tensor::from_shape(&[r, c], &data).unwrap();
786
787        // axis 1: per-row min — contiguous slices -> SIMD path.
788        let got = Reducer::Min.reduce(&[1], &t).unwrap();
789        assert_eq!(got.shape(), &[r, 1]);
790        for (i, &g) in unsafe { got.as_slice_unchecked::<f32>() }.iter().enumerate() {
791            let want = data[i * c..(i + 1) * c].iter().copied().fold(f32::MAX, f32::min);
792            assert_eq!(g, want, "row {i}");
793        }
794
795        // axis 0: per-col min — strided slices -> scalar fold.
796        let got = Reducer::Min.reduce(&[0], &t).unwrap();
797        assert_eq!(got.shape(), &[1, c]);
798        for (j, &g) in unsafe { got.as_slice_unchecked::<f32>() }.iter().enumerate() {
799            let want = (0..r).map(|i| data[i * c + j]).fold(f32::MAX, f32::min);
800            assert_eq!(g, want, "col {j}");
801        }
802    }
803}