Skip to main content

tract_core/ops/cnn/conv/
conv.rs

1use tract_data::itertools::izip;
2use tract_linalg::WeightType;
3use tract_linalg::block_quant::{BlockQuantFact, PackedBlockQuantFormat};
4use tract_linalg::mmm::retain_best;
5use tract_num_traits::Zero;
6
7use crate::internal::*;
8use crate::model::*;
9use crate::ops;
10use crate::ops::array::Pad;
11use crate::ops::array::PadMode;
12use crate::ops::binary::TypedBinOp;
13use crate::ops::cast::cast;
14use crate::ops::cnn::PaddingSpec::*;
15use crate::ops::cnn::conv::block_quant::{BlockQuantIntoShape, SplitGroupBlockQuant};
16use crate::ops::cnn::conv::lazy_im2col::LazyIm2Col;
17use crate::ops::cnn::conv::lazy_im2col::LazyIm2colParams;
18use crate::ops::cnn::wire_reshape_bias_for_bin;
19use crate::ops::einsum::EinSum;
20use crate::ops::math::{Add, Div, Mul, Sub};
21use crate::ops::math::{add, div, mul, sub};
22use crate::ops::matmul::ModePicker;
23use crate::ops::matmul::optimized::AddMatMulGeometry;
24use crate::ops::matmul::optimized::MapOutputAxisToInput;
25use crate::ops::matmul::optimized::MatMulOperand;
26use crate::ops::matmul::pack::{OptMatMulPack, OptSimpleMatMulPack};
27use crate::ops::matmul::quant::wire_ensure_q8_flavour;
28use crate::ops::nn::Reduce;
29
30use super::depth_wise::DepthWise;
31use super::im2col::Im2Col;
32use crate::ops::cnn::conv::KernelFormat;
33use crate::ops::cnn::pools::{ConcretePoolGeometry, PoolGeometry, PoolSpec};
34use crate::ops::matmul::optimized::{OptMatMul, ProtoFusedSpec};
35use crate::ops::nn::{BaseDataShape, DataFormat, DataShape};
36
37use tract_linalg::mmm::{MMMInputFormat, MatMatMul, Query};
38use tract_linalg::pack::{PackedFormat, PackedI8K4};
39
40#[derive(Debug, Clone, new, Hash, PartialEq, Eq)]
41pub struct Conv {
42    pub pool_spec: PoolSpec,
43    pub kernel_fmt: KernelFormat,
44    pub group: usize,
45    // None -> floats
46    // Some(I32) -> output is I32 (use quantized kernels, but output will be i32). last 2 Q inputs
47    // are ignored
48    // Some(QXX) -> quantized XX, but parameters are ignored (I8, U8, or I32) in favor of last 2 Q inputs
49    pub q_params: Option<DatumType>,
50}
51
52impl Conv {
53    pub fn input_channels(&self) -> usize {
54        self.pool_spec.input_channels
55    }
56
57    pub fn output_channels(&self) -> usize {
58        self.pool_spec.output_channels
59    }
60
61    pub fn wire_kernel_as_g_o_ihw(
62        &self,
63        model: &mut TypedModel,
64        name: &str,
65        mut kernel: OutletId,
66    ) -> TractResult<TVec<OutletId>> {
67        let fact = model.outlet_fact(kernel)?;
68        if fact.is_exotic() {
69            ensure!(self.kernel_fmt == KernelFormat::OIHW && fact.rank() >= 2);
70            kernel = model.wire_node(
71                format!("{name}.prep_kernel.g"),
72                SplitGroupBlockQuant { group: self.group },
73                &[kernel],
74            )?[0];
75            kernel = model.wire_node(
76                format!("{name}.prep_kernel.ihw"),
77                BlockQuantIntoShape {
78                    shape: tvec!(
79                        self.output_channels() / self.group,
80                        self.input_channels() / self.group
81                            * self.pool_spec.kernel_shape.iter().product::<usize>(),
82                    ),
83                },
84                &[kernel],
85            )?[0];
86            Ok(tvec!(kernel))
87        } else {
88            for (ix, op) in self
89                .kernel_fmt
90                .kernel_as_group_o_ihw_ops(&fact.shape, self.group)
91                .into_iter()
92                .enumerate()
93            {
94                kernel = model.wire_node(format!("{name}.prep_kernel.{ix}"), op, &[kernel])?[0];
95            }
96            Ok(tvec!(kernel))
97        }
98    }
99
100    fn wire_pack_g_o_ihw(
101        &self,
102        model: &mut TypedModel,
103        name: &str,
104        format: &dyn MMMInputFormat,
105        kernel: OutletId,
106    ) -> TractResult<OutletId> {
107        let fact = model.outlet_fact(kernel)?;
108        let wire = if fact.is_exotic() {
109            let fact = model
110                .outlet_fact(kernel)?
111                .exotic_fact
112                .as_ref()
113                .and_then(|of| of.downcast_ref::<BlockQuantFact>())
114                .context("Only manage BlockQuant")?;
115            model.wire_node(
116                format!("{name}.prep_kernel.pack"),
117                OptSimpleMatMulPack {
118                    packed_format: format
119                        .downcast_ref::<PackedBlockQuantFormat>()
120                        .context("Expect a block quant format")?
121                        .clone(),
122                    k: fact.k(),
123                    m: fact.m(),
124                },
125                &[kernel],
126            )?
127        } else {
128            // PackedFormat or a custom numeric packer (e.g. PackedI8K4).
129            model.wire_node(
130                format!("{name}.prep_kernel.pack"),
131                OptMatMulPack {
132                    packers: vec![dyn_clone::clone_box(format)],
133                    k_axis: 2,
134                    mn_axis: 1,
135                    mode_picker: ModePicker::Single,
136                },
137                &[kernel],
138            )?
139        };
140        Ok(wire[0])
141    }
142
143    // group,bias
144    fn wire_bias_as_non_linear(
145        &self,
146        model: &mut TypedModel,
147        name: &str,
148        bias: OutletId,
149        c_group_axis: usize,
150    ) -> TractResult<(ProtoFusedSpec, OutletId)> {
151        use tract_linalg::BinOp::Add;
152        let fact = model.outlet_fact(bias)?;
153        if fact.shape.volume().is_one() {
154            Ok((ProtoFusedSpec::BinScalar(2, Add), bias))
155        } else {
156            let bias = AxisOp::wire_split_axis(
157                model,
158                format!("{name}.reformat_bias"),
159                bias,
160                0,
161                self.group,
162            )?[0];
163            let pfs =
164                ProtoFusedSpec::BinPerRow(2, Add, MapOutputAxisToInput(tvec!((c_group_axis, 0))));
165            Ok((pfs, bias))
166        }
167    }
168
169    pub unsafe fn wire_as_quant_im2col(
170        &self,
171        model: &mut TypedModel,
172        name: &str,
173        wires: &[OutletId],
174    ) -> TractResult<TVec<OutletId>> {
175        ensure!(self.q_params.is_some());
176        use crate::ops::matmul::quant as qmm;
177
178        let c_dt = self.q_params.unwrap();
179        let &[mut x, mut kernel, bias, mut x0, x_scale, mut k0, mut k_scale, y0, y_scale] = wires
180        else {
181            bail!("Wrong number of inputs")
182        };
183        wire_ensure_q8_flavour(model, name, &mut kernel, "k", &mut k0, i8::datum_type())?;
184        wire_ensure_q8_flavour(model, name, &mut x, "x", &mut x0, i8::datum_type())?;
185
186        let a_fact = model.outlet_fact(kernel)?.clone();
187        let b_fact = model.outlet_fact(x)?.clone();
188
189        let (_geo, m, k, n) = self.compute_geo(&b_fact)?;
190        let (mmm, packing) = self.choose_impl(&b_fact, &a_fact, m, k, &n)?;
191        let output_shape = self.pool_spec.output_shape(&b_fact.shape)?;
192
193        let k0_fact = model.outlet_fact(k0)?;
194        if k0_fact.rank() == 1 && !k0_fact.shape.volume().is_one() {
195            k0 = model.wire_node(
196                format!("{name}.k0_group_channels"),
197                AxisOp::Reshape(
198                    0,
199                    k0_fact.shape.to_tvec(),
200                    tvec!(self.group.to_dim(), (self.output_channels() / self.group).to_dim(),),
201                ),
202                &[k0],
203            )?[0];
204            if !output_shape.fmt.c_is_last() {
205                k0 = model.wire_node(format!("{name}.k0_spatial_axis"), AxisOp::Add(2), &[k0])?[0];
206            }
207        }
208
209        if !model.outlet_fact(k_scale)?.shape.volume().is_one() {
210            // requant is performed before geo_reshape, so we need at most one geo axis to the
211            // right
212            if !output_shape.fmt.c_is_last() {
213                k_scale = model.wire_node(
214                    format!("{name}.a_scale_axis_fix"),
215                    AxisOp::Add(1),
216                    &[k_scale],
217                )?[0];
218            }
219        }
220
221        let abc_scale = qmm::combine_scales(model, name, k_scale, x_scale, y_scale)?;
222
223        let im2col = model.wire_node(
224            format!("{name}.im2col"),
225            Im2Col::new(
226                self.pool_spec.clone(),
227                self.group,
228                k,
229                &b_fact.shape,
230                mmm.clone(),
231                packing,
232            )?,
233            &[x, x0],
234        )?[0];
235
236        let g_o_ihw = self.wire_kernel_as_g_o_ihw(model, name, kernel)?;
237        let g_o_ihw_as_i32 =
238            model.wire_node(format!("{name}.kernel_as_i32"), cast(i32::datum_type()), &g_o_ihw)?;
239        let sum_ker_g_c_k = model.wire_node(
240            format!("{name}.sum_ker_g_c_k"),
241            Reduce::new(tvec!(2), ops::nn::Reducer::Sum),
242            &g_o_ihw_as_i32,
243        )?;
244        let sum_ker_a_g_c =
245            model.wire_node(format!("{name}.rm_k"), AxisOp::Rm(2), &sum_ker_g_c_k)?;
246        // align sum_A from G,C to "C" shape: N,HW,G,C (or N,G,C,HW)
247        let sum_ker_n_g_c = model.wire_node(
248            format!("{name}.sum_ker_n_g_c.axis_0"),
249            AxisOp::Add(0),
250            &sum_ker_a_g_c,
251        )?;
252        let hw_position = if self.pool_spec.data_format.c_is_last() { 1 } else { 3 };
253        let sum_ker = model.wire_node(
254            format!("{name}.sum_ker_n_g_c"),
255            AxisOp::Add(hw_position),
256            &sum_ker_n_g_c,
257        )?;
258
259        ensure!(
260            mmm.packings()[packing].1.downcast_ref::<PackedFormat>().is_some()
261                || mmm.packings()[packing].1.downcast_ref::<PackedI8K4>().is_some(),
262            "Im2Col/QSumB support PackedFormat or PackedI8K4 activation packings"
263        );
264        let mut sum_x = model.wire_node(
265            format!("{name}.sum_x"),
266            super::QSumB { dt: b_fact.datum_type, n, r: mmm.nr(), k },
267            &[im2col],
268        )?;
269        // sum_b is N,G,HW. make it N,HW,G,C or N,G,C,HW
270        sum_x = model.wire_node(format!("{name}.add_c"), AxisOp::Add(2), &sum_x)?;
271        if self.pool_spec.data_format.c_is_last() {
272            sum_x =
273                model.wire_node(format!("{name}.transpose_sum_b"), AxisOp::Move(3, 1), &sum_x)?;
274        }
275
276        let (mmm_output_shape, c_axis, h_axis) = self.mmm_output_shape(&output_shape)?;
277        let bias_name = &model.node(bias.node).name;
278        let bias =
279            model.wire_node(format!("{bias_name}.cast"), cast(mmm.internal_type()), &[bias])?[0];
280        let wire = self.wire_mm_weights_bias(
281            model,
282            name,
283            im2col,
284            g_o_ihw[0],
285            bias,
286            mmm,
287            packing,
288            i32::datum_type(),
289            mmm_output_shape.clone().into(),
290            k,
291            c_axis,
292            h_axis,
293        )?;
294
295        let wire = qmm::compensate_zero_points(
296            model,
297            name,
298            wire[0],
299            k.to_dim(),
300            k0,
301            x0,
302            sum_ker[0],
303            sum_x[0],
304        )?;
305
306        let wire = self.wire_remove_group(model, name, &[wire], &mmm_output_shape, c_axis)?;
307        let wire = self.wire_rm_n_if_needed(model, name, &wire)?;
308        let wire = qmm::requant(model, name, wire[0], c_dt, abc_scale, y0)?;
309        Self::wire_geo_reshape(model, name, &[wire], &output_shape)
310    }
311
312    pub fn wire_remove_group<D: DimLike>(
313        &self,
314        model: &mut TypedModel,
315        name: &str,
316        wire: &[OutletId],
317        mmm_output_shape: &[D],
318        c_axis: usize,
319    ) -> TractResult<TVec<OutletId>> {
320        let m = &mmm_output_shape[c_axis];
321        let op = if self.group == 1 {
322            AxisOp::Rm(c_axis - 1)
323        } else {
324            AxisOp::Reshape(
325                c_axis - 1,
326                tvec!(self.group.to_dim(), m.to_dim()),
327                tvec!(m.to_dim() * self.group),
328            )
329        };
330        model.wire_node(format!("{name}.reshape_group"), op, wire)
331    }
332
333    pub unsafe fn wire_as_im2col_pair(
334        &self,
335        model: &mut TypedModel,
336        name: &str,
337        wire: &[OutletId],
338    ) -> TractResult<TVec<OutletId>> {
339        let &[x, w, bias] = wire else { bail!("Wrong number of inputs") };
340        let x_fact = model.outlet_fact(x)?.clone();
341        let w_fact = model.outlet_fact(w)?.clone();
342        let c_dt = crate::ops::matmul::output_type(x_fact.datum_type);
343
344        let (_, m, k, n) = self.compute_geo(&x_fact)?;
345        let (mmm, packing) = self.choose_impl(&x_fact, &w_fact, m, k, &n)?;
346        let geo_output_shape = self.pool_spec.output_shape(&x_fact.shape)?;
347        let (mmm_output_shape, c_axis, h_axis) = self.mmm_output_shape(&geo_output_shape)?;
348
349        let padding =
350            model.add_const(format!("{name}.b0"), Tensor::zero_scalar_dt(x_fact.datum_type)?)?;
351
352        let mut wire: TVec<_> = wire.into();
353        wire[0] = model.wire_node(
354            format!("{name}.im2col"),
355            Im2Col::new(
356                self.pool_spec.clone(),
357                self.group,
358                k,
359                &x_fact.shape,
360                mmm.clone(),
361                packing,
362            )?,
363            &[wire[0], padding],
364        )?[0];
365
366        let g_o_ihw = self.wire_kernel_as_g_o_ihw(model, name, wire[1])?;
367
368        let wire = self
369            .wire_mm_weights_bias(
370                model,
371                name,
372                wire[0],
373                g_o_ihw[0],
374                bias,
375                mmm,
376                packing,
377                c_dt,
378                mmm_output_shape.clone().into(),
379                k.to_usize().unwrap(),
380                c_axis,
381                h_axis,
382            )
383            .context("in wire_opt_matmul")?;
384
385        let wire = self.wire_remove_group(model, name, &wire, &mmm_output_shape, c_axis)?;
386        let wire = self.wire_rm_n_if_needed(model, name, &wire)?;
387        Self::wire_geo_reshape(model, name, &wire, &geo_output_shape)
388    }
389
390    // always have N and G. G is right before C, c_axis point to C, c_axis-1 points to G
391    fn mmm_output_shape<D: DimLike>(
392        &self,
393        output_shape: &BaseDataShape<D, TVec<D>>,
394    ) -> TractResult<(TVec<D>, usize, usize)> {
395        let geo_collapsed_out: D = output_shape.hw_dims().iter().cloned().product();
396        let shape: BaseDataShape<D, TVec<D>> = output_shape.fmt.with_n().from_n_c_hw(
397            output_shape.n().cloned().unwrap_or_else(|| 1.into()),
398            output_shape.c().clone(),
399            tvec!(geo_collapsed_out),
400        )?;
401        let mut mmm_output_shape: TVec<D> = shape.shape.clone();
402        let mut c_axis = shape.c_axis();
403        let mut h_axis = shape.h_axis();
404        mmm_output_shape[shape.c_axis()] = mmm_output_shape[c_axis].clone() / self.group;
405        mmm_output_shape.insert(c_axis, self.group.into());
406        if h_axis > c_axis {
407            h_axis += 1;
408        }
409        c_axis += 1;
410        Ok((mmm_output_shape, c_axis, h_axis))
411    }
412
413    fn wire_rm_n_if_needed(
414        &self,
415        model: &mut TypedModel,
416        name: &str,
417        wire: &[OutletId],
418    ) -> TractResult<TVec<OutletId>> {
419        if self.pool_spec.data_format.has_n() {
420            Ok(wire.into())
421        } else {
422            model.wire_node(format!("{name}.rm_n"), AxisOp::Rm(0), wire)
423        }
424    }
425
426    fn wire_geo_reshape<D: DimLike>(
427        model: &mut TypedModel,
428        name: &str,
429        wire: &[OutletId],
430        output_shape: &BaseDataShape<D, TVec<D>>,
431    ) -> TractResult<TVec<OutletId>> {
432        let geo_collapsed_out: D = output_shape.hw_dims().iter().cloned().product();
433        model
434            .wire_node(
435                name,
436                AxisOp::Reshape(
437                    output_shape.h_axis(),
438                    tvec!(geo_collapsed_out.to_dim()),
439                    output_shape.hw_dims().iter().map(|d| d.to_dim()).collect(),
440                ),
441                wire,
442            )
443            .context("in wire_geo_reshape")
444    }
445
446    pub unsafe fn wire_as_lazy_im2col(
447        &self,
448        model: &mut TypedModel,
449        name: &str,
450        wire: &[OutletId],
451    ) -> TractResult<TVec<OutletId>> {
452        let &[mut x, kernel, bias] = wire else { bail!("Wrong number of inputs") };
453        let mut x_fact = model.outlet_fact(x)?.clone();
454        let w_fact = model.outlet_fact(kernel)?.clone();
455        let (geo, m, k, n) = self.compute_geo(&x_fact)?;
456        let (mmm, packing) = self.choose_impl(&x_fact, &w_fact, m, k, &n)?;
457        debug!("{name} as lazy_im2col: m={m} k={k} n={n} {mmm:?}");
458        let input_shape = x_fact.shape.as_concrete().unwrap().to_vec();
459        let mut geo = geo.to_concrete(&input_shape)?.into_owned();
460        let mut input_shape: DataShape = self.pool_spec.data_format.shape(input_shape.into())?;
461        let padding = self.pool_spec.computed_padding(input_shape.hw_dims());
462        if padding.iter().any(|axis| axis.pad_before != 0 || axis.pad_after != 0) {
463            let mut pads = vec![(0, 0); x_fact.rank()];
464            for (ix, ax) in padding.iter().enumerate() {
465                pads[input_shape.h_axis() + ix] = (ax.pad_before, ax.pad_after);
466            }
467            let op = crate::ops::array::Pad {
468                mode: crate::ops::array::PadMode::Constant(
469                    Tensor::zero_scalar_dt(x_fact.datum_type)?.into_arc_tensor(),
470                ),
471                pads,
472            };
473            x = model.wire_node(format!("{name}.pad"), op, &[x])?[0];
474            let valid_pool_spec = PoolSpec { padding: Valid, ..self.pool_spec.clone() };
475            x_fact = model.outlet_fact(x)?.clone();
476            let concrete_shape = x_fact.shape.as_concrete().unwrap();
477            input_shape = valid_pool_spec.data_format.shape(concrete_shape.into())?;
478            geo = valid_pool_spec
479                .compute_geo(&x_fact.shape)?
480                .to_concrete(concrete_shape)?
481                .into_owned();
482        }
483        let c_dt = crate::ops::matmul::output_type(x_fact.datum_type);
484        let c_stride = input_shape.c_stride();
485        let size_of_b = x_fact.datum_type.size_of() as isize;
486        let n_byte_offsets: Vec<isize> =
487            geo.patch.centers_offsets().into_iter().map(|x| x * size_of_b).collect();
488        // For grouped convs, k offsets cover one group's input slice (ci_per_group channels);
489        // each group reads from a different base offset (group_stride_bytes apart).
490        let ci_per_group = self.input_channels() / self.group;
491        let k_byte_offsets: Vec<isize> = (0..ci_per_group)
492            .flat_map(|ici| {
493                geo.patch
494                    .standard_layout_data_field
495                    .iter()
496                    .map(move |x| (x + (ici * c_stride) as isize) * size_of_b)
497            })
498            .collect();
499        let group_stride_bytes = (ci_per_group * c_stride) as isize * size_of_b;
500        let (mmm_output_shape, c_axis, h_axis) = self.mmm_output_shape(&geo.output_shape)?;
501        let packer = mmm.packings()[packing]
502            .1
503            .downcast_ref::<PackedFormat>()
504            .with_context(|| {
505                format_err!(
506                    "Quand Im2Col expects regular packed format, got {:?}",
507                    mmm.packings()[packing].1
508                )
509            })?
510            .clone();
511        let params = LazyIm2colParams { packer, n_byte_offsets, k_byte_offsets };
512        let x = model.wire_node(
513            format!("{name}.lazyIm2col"),
514            LazyIm2Col { params: Arc::new(params), group: self.group, group_stride_bytes },
515            &[x],
516        )?[0];
517
518        let kernel = self.wire_kernel_as_g_o_ihw(model, name, kernel)?[0];
519        let wire = self.wire_mm_weights_bias(
520            model,
521            name,
522            x,
523            kernel,
524            bias,
525            mmm,
526            packing,
527            c_dt,
528            mmm_output_shape.clone().into(),
529            k,
530            c_axis,
531            h_axis,
532        )?;
533
534        let wire = self.wire_remove_group(model, name, &wire, &mmm_output_shape, c_axis)?;
535        let wire = self.wire_rm_n_if_needed(model, name, &wire)?;
536        Self::wire_geo_reshape(model, name, &wire, &geo.output_shape)
537    }
538
539    #[allow(clippy::type_complexity)]
540    fn compute_geo(
541        &self,
542        input_fact: &TypedFact,
543    ) -> TractResult<(PoolGeometry, usize, usize, TDim)> {
544        let geo = self.pool_spec.compute_geo(&input_fact.shape)?;
545
546        trace!("output channels: {:?}", self.output_channels());
547        let m = self.output_channels() / self.group;
548        let k = self.input_channels() * self.pool_spec.kernel_shape.iter().product::<usize>()
549            / self.group;
550        let n: TDim =
551            self.pool_spec.output_shape(&input_fact.shape)?.hw_dims().iter().cloned().product();
552        Ok((geo, m, k, n))
553    }
554
555    fn choose_impl(
556        &self,
557        input_fact: &TypedFact,
558        weight_fact: &TypedFact,
559        m: usize,
560        k: usize,
561        n: &TDim,
562    ) -> TractResult<(Box<dyn MatMatMul>, usize)> {
563        let w_dt = weight_fact.datum_type;
564        let x_dt = input_fact.datum_type;
565
566        let acc = if x_dt.is_float() { x_dt } else { i32::datum_type() };
567        // The weights are packed once, ahead of time, so a kernel reached through a panel
568        // extractor would pay it on every panel of every call.
569        let query = Query {
570            weight: if weight_fact.is_exotic() {
571                let bqf = weight_fact
572                    .exotic_fact
573                    .as_ref()
574                    .and_then(|of| of.downcast_ref::<BlockQuantFact>())
575                    .unwrap();
576                WeightType::BlockQuant(bqf.format.clone())
577            } else {
578                w_dt.into()
579            },
580            activation: x_dt,
581            accumulators: tvec!(acc),
582            store: None,
583            allow_extractor: false,
584            m: Some(m),
585            k: Some(k),
586            n: n.as_usize(),
587        };
588        if weight_fact.is_exotic() {
589            let mut suitable = tract_linalg::MmmDispatch::native().suitable(&query);
590            retain_best(&mut suitable);
591            suitable
592                .into_iter()
593                .map(|(mmm, p, _)| (mmm, p))
594                .max_by_key(|(mmm, _)| mmm.mr() * mmm.nr())
595                .context("No matmul found")
596        } else {
597            let (mmm, packing, _) =
598                tract_linalg::MmmDispatch::native().pick(&query).context("No matmul found")?;
599            Ok((mmm, packing))
600        }
601    }
602
603    #[allow(clippy::too_many_arguments)]
604    fn wire_mm_weights_bias(
605        &self,
606        model: &mut TypedModel,
607        name: &str,
608        input: OutletId,
609        g_o_ihw: OutletId,
610        bias: OutletId,
611        mmm: Box<dyn MatMatMul>,
612        packing: usize,
613        c_datum_type: DatumType,
614        mmm_output_shape: ShapeFact,
615        k: usize,
616        c_m_axis: usize,
617        c_n_axis: usize,
618    ) -> TractResult<TVec<OutletId>> {
619        ensure!(model.outlet_fact(bias)?.datum_type == mmm.internal_type());
620        let a_pack = &mmm.packings()[packing].0;
621        let packed_ker = self
622            .wire_pack_g_o_ihw(model, name, &**a_pack, g_o_ihw)
623            .context("in kernel_as_packed_as")?;
624        let (mut c_to_a_axis_mapping, mut c_to_b_axis_mapping) = (tvec!(), tvec!());
625
626        c_to_a_axis_mapping.push((c_m_axis - 1, 0)); // Group
627        c_to_b_axis_mapping.push((0, 0)); // Batch
628        c_to_b_axis_mapping.push((c_m_axis - 1, 1)); // Group
629
630        let geo = AddMatMulGeometry {
631            k: k.to_dim(),
632            c_to_a_axis_mapping: MapOutputAxisToInput(c_to_a_axis_mapping),
633            c_to_b_axis_mapping: MapOutputAxisToInput(c_to_b_axis_mapping),
634        };
635        let mut ops: Vec<ProtoFusedSpec> = vec![ProtoFusedSpec::AddMatMul {
636            geo,
637            a: MatMulOperand::Input(1),
638            b: MatMulOperand::Input(0),
639            packings: vec![(packing, None)],
640        }];
641        let mut wires: TVec<OutletId> = tvec!(input, packed_ker);
642        let bias_fact = model.outlet_fact(bias)?;
643        if bias_fact.konst.is_none() || !bias_fact.konst.as_ref().unwrap().is_all_zero()? {
644            let (fused, bias) = self.wire_bias_as_non_linear(model, name, bias, c_m_axis - 1)?;
645            wires.push(bias);
646            ops.push(fused);
647        }
648        ops.push(ProtoFusedSpec::Store(vec![unsafe {
649            mmm.c_view(Some(c_m_axis), Some(c_n_axis))
650        }]));
651        model.wire_node(
652            format!("{name}.matmatmul"),
653            OptMatMul::new(
654                vec![mmm],
655                ModePicker::Single,
656                c_datum_type.fact(mmm_output_shape),
657                Some(c_m_axis),
658                Some(c_n_axis),
659                ops,
660                packing == 0 && self.group == 1,
661            )?,
662            &wires,
663        )
664    }
665
666    pub fn wire_as_depth_wise(
667        &self,
668        model: &mut TypedModel,
669        name: &str,
670        wire: &[OutletId],
671    ) -> TractResult<OutletId> {
672        let &[x, kernel, mut bias] = wire else { bail!("Wrong number of inputs") };
673        let x_fact = model.outlet_fact(x)?.clone();
674        let x_shape = x_fact.shape.as_concrete().unwrap();
675        let ConcretePoolGeometry { input_shape, patch, output_shape } =
676            self.pool_spec.compute_geo(&x_fact.shape)?.to_concrete(x_shape)?.into_owned();
677        let kernel = self.wire_kernel_as_g_o_ihw(model, name, kernel)?;
678        // Channel-last DepthWise reads the kernel tap-major, [G, HW, O]: one channel vector per tap.
679        let kernel = if self.pool_spec.data_format.c_is_last()
680            && !model.outlet_fact(kernel[0])?.is_exotic()
681        {
682            model.wire_node(format!("{name}.kernel_c_inner"), AxisOp::Move(0, 2), &kernel)?
683        } else {
684            kernel
685        };
686        let c_axis = self.pool_spec.data_format.shape(x_shape)?.c_axis();
687        bias = wire_reshape_bias_for_bin(
688            model,
689            name,
690            bias,
691            x_fact.rank(),
692            c_axis,
693            self.output_channels(),
694        )?[0];
695        let op = DepthWise::new(patch, input_shape, output_shape);
696        Ok(model.wire_node(name, op, &[x, kernel[0], bias])?[0])
697    }
698
699    /// Whether this convolution becomes a [`super::DirectSpatialConv`] on this host: group-1 f32
700    /// NCHW over two spatial axes, at least two kernel taps, at most 64 inputs per output point
701    /// where AMX runs im2col's matrix products and 576 elsewhere, the kernels it needs, and an
702    /// output row of at least `MIN_DIRECT_SPATIAL_W` points.
703    fn can_direct_spatial(&self, input_fact: &TypedFact) -> bool {
704        if self.q_params.is_some()
705            || input_fact.datum_type != f32::datum_type()
706            || self.pool_spec.data_format != crate::ops::nn::DataFormat::NCHW
707            || self.group != 1
708            || self.pool_spec.rank() != 2
709            || TRACT_DISABLE_DIRECT_SPATIAL.get()
710        {
711            return false;
712        }
713        let kvol = self.pool_spec.kernel_shape.iter().product::<usize>();
714        let k_limit = if amx_owns_fat_spatial() { 64 } else { 576 };
715        if kvol < 2 || self.input_channels() * kvol > k_limit {
716            return false;
717        }
718        if tract_linalg::routines::conv_w4_f32().is_none()
719            || (!self.output_channels().is_multiple_of(4)
720                && tract_linalg::routines::depthwise_w_f32().is_none())
721        {
722            return false;
723        }
724        let Some(shape) = input_fact.shape.as_concrete() else {
725            return false;
726        };
727        // Only padding wider than the kernel's reach makes the output row longer than the input
728        // one, so the input row turns most candidates down before the output shape is computed.
729        let w_in =
730            self.pool_spec.data_format.shape(shape).ok().and_then(|s| s.hw_dims().last().copied());
731        if w_in.is_some_and(|w| w < MIN_DIRECT_SPATIAL_W) {
732            return false;
733        }
734        let Ok(output) = self.pool_spec.output_shape(shape) else {
735            return false;
736        };
737        output.hw_dims().last().is_some_and(|w| *w >= MIN_DIRECT_SPATIAL_W)
738    }
739
740    fn wire_as_direct_spatial(
741        &self,
742        model: &mut TypedModel,
743        name: &str,
744        wire: &[OutletId],
745    ) -> TractResult<OutletId> {
746        let &[x, kernel, mut bias] = wire else { bail!("Wrong number of inputs") };
747        let x_fact = model.outlet_fact(x)?.clone();
748        let x_shape = x_fact.shape.as_concrete().unwrap();
749        let ConcretePoolGeometry { input_shape, patch, output_shape } =
750            self.pool_spec.compute_geo(&x_fact.shape)?.to_concrete(x_shape)?.into_owned();
751        let kernel = self.wire_kernel_as_g_o_ihw(model, name, kernel)?;
752        let c_axis = self.pool_spec.data_format.shape(x_shape)?.c_axis();
753        bias = wire_reshape_bias_for_bin(
754            model,
755            name,
756            bias,
757            x_fact.rank(),
758            c_axis,
759            self.output_channels(),
760        )?[0];
761        let op = super::DirectSpatialConv::new(patch, input_shape, output_shape);
762        Ok(model.wire_node(name, op, &[x, kernel[0], bias])?[0])
763    }
764
765    /// Eligibility for the direct register-blocked conv (see `blocked.rs`):
766    /// f32 NCHW, kernel width 1 (extent on H only), unit stride/dilation on the
767    /// contiguous W axis, grouped with a *small* number of out-channels per group
768    /// (where the im2col matmul's M-tile would be mostly wasted). Concrete shape
769    /// required. Returns the fully-parameterised op, or None to fall back.
770    fn try_blocked_conv(&self, input_fact: &TypedFact) -> Option<super::BlockedConv> {
771        // The direct blocked conv beats im2col on wasm (no AMX; the gather +
772        // wasted-M-tile matmul is slow) but LOSES on native, where shape-aware
773        // AMX dispatch already handles the tiny-M matmul well. So: on by default
774        // on wasm, opt-in on native. Env overrides either way for A/B.
775        let enabled = if cfg!(target_family = "wasm") {
776            !TRACT_DISABLE_BLOCKED_CONV.get()
777        } else {
778            TRACT_ENABLE_BLOCKED_CONV.get()
779        };
780        if !enabled {
781            return None;
782        }
783        if self.q_params.is_some() {
784            return None;
785        }
786        if input_fact.datum_type != f32::datum_type() {
787            return None;
788        }
789        if self.pool_spec.data_format != crate::ops::nn::DataFormat::NCHW {
790            return None;
791        }
792        if self.pool_spec.rank() != 2 || self.pool_spec.kernel_shape[1] != 1 {
793            return None;
794        }
795        if self.pool_spec.stride(1) != 1 || self.pool_spec.dilation(1) != 1 {
796            return None;
797        }
798        let group = self.group;
799        let oc = self.output_channels();
800        let c_in = self.input_channels();
801        if group == 0 || !oc.is_multiple_of(group) || !c_in.is_multiple_of(group) {
802            return None;
803        }
804        let ocg = oc / group;
805        // Win condition: tiny per-group output count makes the im2col matmul's
806        // m-tile wasteful. Large ocg packs the tile fine — leave it to im2col.
807        if ocg == 0 || ocg > 8 {
808            return None;
809        }
810        let concrete = input_fact.shape.as_concrete()?;
811        let shape = self.pool_spec.data_format.shape(concrete).ok()?;
812        let h_axis = shape.h_axis();
813        let h_in = concrete[h_axis];
814        let w = concrete[h_axis + 1];
815        let pads = self.pool_spec.computed_padding(shape.hw_dims());
816        Some(super::BlockedConv {
817            n: *shape.n().unwrap_or(&1),
818            c_in,
819            h_in,
820            w,
821            oc,
822            group,
823            kh: self.pool_spec.kernel_shape[0],
824            stride_h: self.pool_spec.stride(0),
825            dil_h: self.pool_spec.dilation(0),
826            pad_before_h: pads[0].pad_before,
827            h_out: pads[0].convoluted,
828        })
829    }
830
831    fn wire_as_blocked_conv(
832        &self,
833        model: &mut TypedModel,
834        name: &str,
835        wire: &[OutletId],
836        op: super::BlockedConv,
837    ) -> TractResult<OutletId> {
838        let &[x, kernel, bias] = wire else { bail!("Wrong number of inputs") };
839        // Kernel → [group, ocg, icg·kh] (group-major, i-major/h-minor); its flat
840        // layout is exactly the [oc, icg·kh] the op indexes.
841        let g_o_ihw = self.wire_kernel_as_g_o_ihw(model, name, kernel)?;
842        Ok(model.wire_node(name, op, &[x, g_o_ihw[0], bias])?[0])
843    }
844
845    fn declutter_stride_slice_to_downsample(
846        &self,
847        model: &TypedModel,
848        node: &TypedNode,
849    ) -> TractResult<Option<TypedModelPatch>> {
850        let spatial_rank = self.pool_spec.rank();
851        if let Some(axis) = (0..spatial_rank).find(|&ax| {
852            self.pool_spec.stride(ax) > 1
853                && self.pool_spec.padding.valid_dim(ax, self.pool_spec.stride(ax) == 1)
854                && (self.pool_spec.kernel_shape[ax] == 1
855                    || self.pool_spec.dilation(ax).is_multiple_of(self.pool_spec.stride(ax)))
856        }) {
857            let input_fact = model.outlet_fact(node.inputs[0])?;
858            let downsample_factor = self.pool_spec.stride(axis);
859            let mut new_op = self.clone();
860            if new_op.pool_spec.dilation(axis) > 1 {
861                new_op.pool_spec.dilations.as_mut().unwrap()[axis] =
862                    new_op.pool_spec.dilations.as_mut().unwrap()[axis].divceil(downsample_factor);
863            }
864            new_op.pool_spec.strides.as_mut().unwrap()[axis] /= downsample_factor;
865            let mut patch = TypedModelPatch::default();
866            let mut taps = patch.taps(model, &node.inputs)?;
867            let shape = self.pool_spec.data_format.shape(&input_fact.shape)?;
868            taps[0] = patch.wire_node(
869                format!("{}.downsample.{}", node.name, axis),
870                crate::ops::Downsample::new(axis + shape.h_axis(), downsample_factor as isize, 0),
871                &[taps[0]],
872            )?[0];
873            let id = patch.wire_node(&*node.name, new_op, &taps)?[0];
874            patch.shunt_outside(model, OutletId::new(node.id, 0), id)?;
875            return Ok(Some(patch));
876        }
877        Ok(None)
878    }
879
880    fn declutter_as_einsum(
881        &self,
882        model: &TypedModel,
883        node: &TypedNode,
884    ) -> TractResult<Option<TypedModelPatch>> {
885        let (input_facts, output_facts) = model.node_facts(node.id)?;
886        let full_input_shape = input_facts[0].shape.to_tvec();
887        let input_shape = self.pool_spec.data_format.shape(&full_input_shape)?;
888        if self.group == 1
889            && self.pool_spec.strides().iter().all(|s| *s == 1)
890            && self.pool_spec.dilations().iter().all(|d| *d == 1)
891            && self.pool_spec.kernel_shape.iter().product::<usize>() == 1
892            && self
893                .pool_spec
894                .computed_padding(input_shape.hw_dims())
895                .iter()
896                .all(|pad| pad.pad_after.is_zero() && pad.pad_before.is_zero())
897        {
898            let mut axes = self.axes_mapping(&input_facts, &output_facts)?;
899            let mut patch = TypedModelPatch::new("declutter_as_einsum");
900            let mut taps = patch.taps(model, &node.inputs)?;
901            let name = &node.name;
902            let co = self.output_channels();
903            taps[1] =
904                self.wire_kernel_as_g_o_ihw(&mut patch, &format!("{name}.filters"), taps[1])?[0];
905            taps[1] =
906                patch.wire_node(format!("{name}.filters_as_co_ci"), AxisOp::Rm(0), &[taps[1]])?[0];
907
908            while axes.rank(InOut::In(1)) > 0 {
909                axes = axes.remove_axis_occurency(InOut::In(1), 0)?;
910            }
911            axes = axes
912                .with_extra_axis_occurency('O', InOut::In(1), 0)?
913                .with_extra_axis_occurency('I', InOut::In(1), 1)?;
914
915            let bias_fact = input_facts[2];
916            let wire = if self.q_params.is_some() {
917                if bias_fact.rank() == 1 {
918                    axes = axes.linking('O', (InOut::In(2), 0))?;
919                }
920                let op = EinSum { axes, operating_dt: i32::datum_type(), q_params: self.q_params };
921                patch.wire_node(format!("{name}.einsum"), op, &taps)?[0]
922            } else {
923                axes = axes.remove_slot(InOut::In(2))?;
924                let op = EinSum { axes, operating_dt: input_facts[0].datum_type, q_params: None };
925                let mut wire = patch.wire_node(format!("{name}.einsum"), op, &taps[0..2])?[0];
926
927                if !bias_fact.konst.as_ref().map(|f| f.is_zero()).transpose()?.unwrap_or(false) {
928                    let bias_current_shape =
929                        if bias_fact.rank() == 0 { tvec!() } else { tvec!(co.to_dim()) };
930                    let mut bias_shape = tvec!(1.to_dim(); input_shape.rank());
931                    if bias_fact.rank() > 0 {
932                        bias_shape[input_shape.c_axis()] = co.to_dim();
933                    }
934                    let b = patch.wire_node(
935                        format!("{name}.bias.reshape"),
936                        AxisOp::Reshape(0, bias_current_shape, bias_shape),
937                        &[taps[2]],
938                    )?[0];
939                    wire = patch.wire_node(
940                        format!("{name}.bias"),
941                        crate::ops::math::add(),
942                        &[wire, b],
943                    )?[0];
944                }
945                wire
946            };
947            patch.node_mut(wire.node).name = node.name.to_string();
948            patch.shunt_outside(model, node.id.into(), wire)?;
949            return Ok(Some(patch));
950        }
951        Ok(None)
952    }
953
954    fn declutter_precursor_padding(
955        &self,
956        model: &TypedModel,
957        node: &TypedNode,
958    ) -> TractResult<Option<TypedModelPatch>> {
959        rule_if!(!matches!(
960            self.pool_spec.padding,
961            ExplicitOnnxPool(_, _, _) | SameLower | SameUpper
962        ));
963        let prec = model.node(node.inputs[0].node);
964        rule_if_some!(pad = prec.op_as::<Pad>());
965        rule_if_let!(PadMode::Constant(value) = &pad.mode);
966        let shape = self.pool_spec.data_format.shape(&model.outlet_fact(node.inputs[0])?.shape)?;
967        rule_if!(value.is_zero()?);
968        rule_if!(pad.pads[shape.c_axis()] == (0, 0));
969        if self.pool_spec.data_format.has_n() {
970            rule_if!(pad.pads[0] == (0, 0));
971        }
972        let mut before: TVec<usize> = pad.pads[shape.hw_axes()].iter().map(|pair| pair.0).collect();
973        let mut after: TVec<usize> = pad.pads[shape.hw_axes()].iter().map(|pair| pair.1).collect();
974        if let Explicit(bef, aft) = &self.pool_spec.padding {
975            izip!(&mut before, bef).for_each(|(pad, cv)| *pad += cv);
976            izip!(&mut after, aft).for_each(|(pad, cv)| *pad += cv);
977        }
978        let padding = Explicit(before, after);
979        let mut new = self.clone();
980        new.pool_spec.padding = padding;
981        let mut patch = TypedModelPatch::default();
982        let mut wire = patch.taps(model, &node.inputs)?;
983        wire[0] = patch.tap_model(model, prec.inputs[0])?;
984        let wire = patch.wire_node(&node.name, new, &wire)?;
985        patch.shunt_outside(model, node.id.into(), wire[0])?;
986        Ok(Some(patch))
987    }
988
989    fn declutter_channel_arithmetic_succ(
990        &self,
991        model: &TypedModel,
992        node: &TypedNode,
993    ) -> TractResult<Option<TypedModelPatch>> {
994        rule_if!(self.q_params.is_none());
995        rule_if!(self.group == 1);
996        rule_if_let!(&[succ_outlet] = &*node.outputs[0].successors);
997        let succ = model.node(succ_outlet.node);
998        rule_if_some!(bin = succ.op_as::<TypedBinOp>());
999        let other_input = succ.inputs[1 - succ_outlet.slot];
1000        let axes_mapping = model.node_axes_mapping(succ.id)?;
1001        let input_shape =
1002            self.pool_spec.data_format.shape(&model.outlet_fact(node.inputs[0])?.shape)?;
1003        let conv_c_axis = input_shape.c_axis();
1004        rule_if!(
1005            axes_mapping.axis((InOut::In(succ_outlet.slot), conv_c_axis))?.inputs
1006                [1 - succ_outlet.slot]
1007                .len()
1008                == 1
1009        );
1010        let mut other_expected_shape = tvec!(1.to_dim(); input_shape.rank());
1011        other_expected_shape[conv_c_axis] = self.output_channels().to_dim();
1012        rule_if!(*other_expected_shape == *model.outlet_fact(other_input)?.shape);
1013
1014        let mut patch = TypedModelPatch::default();
1015        let [input, mut kernel, mut bias] = *patch.taps(model, &node.inputs)? else {
1016            panic!("Expect three inputs");
1017        };
1018        let name = &node.name;
1019        let succ_name = &succ.name;
1020
1021        let operand = patch.tap_model(model, other_input)?;
1022
1023        let renamed_bias = format!("{name}.{succ_name}.bias");
1024        let renamed_kernel = format!("{name}.{succ_name}.kernel");
1025        bias = wire_reshape_bias_for_bin(
1026            &mut patch,
1027            format!("{renamed_bias}.reshape"),
1028            bias,
1029            1,
1030            0,
1031            self.output_channels(),
1032        )?[0];
1033
1034        let operand = wire_reshape_bias_for_bin(
1035            &mut patch,
1036            format!("{renamed_bias}.reshape_operand"),
1037            operand,
1038            1,
1039            0,
1040            self.output_channels(),
1041        )?[0];
1042
1043        let operand_fact = patch.outlet_fact(operand)?.shape.to_tvec();
1044        let kernel_fact = patch.outlet_fact(kernel)?;
1045        let mut operand_shape_for_kernel = tvec!(1.to_dim(); 2 + input_shape.hw_rank());
1046        operand_shape_for_kernel[self.kernel_fmt.o_axis(&kernel_fact.shape)] =
1047            self.output_channels().to_dim();
1048        let operand_for_kernel = patch.wire_node(
1049            format!("{renamed_kernel}.reshape_operand"),
1050            AxisOp::Reshape(0, operand_fact, operand_shape_for_kernel),
1051            &[operand],
1052        )?[0];
1053
1054        if bin.0.is::<Sub>() && succ_outlet.slot == 0 {
1055            bias = patch.wire_node(&renamed_bias, sub(), &[bias, operand])?[0];
1056        } else if bin.0.is::<Sub>() {
1057            bias = patch.wire_node(&renamed_bias, sub(), &[operand, bias])?[0];
1058        } else if bin.0.is::<Div>() && succ_outlet.slot == 0 {
1059            bias = patch.wire_node(&renamed_bias, div(), &[bias, operand])?[0];
1060            kernel = patch.wire_node(&renamed_kernel, div(), &[kernel, operand_for_kernel])?[0];
1061        } else if bin.0.is::<Div>() {
1062            bias = patch.wire_node(&renamed_bias, div(), &[operand, bias])?[0];
1063            kernel = patch.wire_node(&renamed_kernel, div(), &[operand_for_kernel, kernel])?[0];
1064        } else if bin.0.is::<Add>() {
1065            bias = patch.wire_node(&renamed_bias, add(), &[bias, operand])?[0];
1066        } else if bin.0.is::<Mul>() {
1067            bias = patch.wire_node(&renamed_bias, mul(), &[bias, operand])?[0];
1068            kernel = patch.wire_node(&renamed_kernel, mul(), &[kernel, operand_for_kernel])?[0];
1069        } else {
1070            return Ok(None);
1071        };
1072        let wire = patch.wire_node(&node.name, self.clone(), &[input, kernel, bias])?[0];
1073        patch.shunt_outside(model, succ_outlet.node.into(), wire)?;
1074        Ok(Some(patch))
1075    }
1076}
1077
1078impl Op for Conv {
1079    fn name(&self) -> StaticName {
1080        "Conv".into()
1081    }
1082
1083    fn info(&self) -> TractResult<Vec<String>> {
1084        let mut info = self.pool_spec.info();
1085        info.push(format!("Kernel {:?} (groups:{})", self.kernel_fmt, self.group));
1086        Ok(info)
1087    }
1088
1089    fn validation(&self) -> Validation {
1090        Validation::Rounding
1091    }
1092
1093    op_as_typed_op!();
1094}
1095
1096impl EvalOp for Conv {
1097    op_out_of_plan!();
1098
1099    fn eval(&self, _ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
1100        let mut model = TypedModel::default();
1101        let wire: TVec<OutletId> = inputs
1102            .iter()
1103            .enumerate()
1104            .map(|(ix, v)| model.add_source(format!("source.{ix}"), v.datum_type().fact(v.shape())))
1105            .collect::<TractResult<_>>()?;
1106        let wire = unsafe {
1107            if self.q_params.is_some() {
1108                self.wire_as_quant_im2col(&mut model, "im2col-adhoc", &wire)?
1109            } else {
1110                self.wire_as_im2col_pair(&mut model, "im2col-adhoc", &wire)?
1111            }
1112        };
1113        model.select_output_outlets(&wire)?;
1114        model.into_runnable()?.run(inputs)
1115    }
1116}
1117
1118impl TypedOp for Conv {
1119    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
1120        ensure!(self.q_params.is_some() || inputs[0].datum_type.is_float());
1121        let q_inputs = if self.q_params.is_some() { 6 } else { 0 };
1122        ensure!(inputs[1].datum_type.is_number() || self.kernel_fmt == KernelFormat::OIHW);
1123        if inputs.len() != 3 + q_inputs {
1124            bail!("Wrong number of inputs: expected {} got {}", 3 + q_inputs, inputs.len());
1125        }
1126        if self.q_params.is_some() {
1127            ensure!(inputs[2].datum_type == i32::datum_type());
1128            ensure!(inputs[3].datum_type == i32::datum_type());
1129            ensure!(inputs[4].datum_type.is_float());
1130            ensure!(inputs[5].datum_type == i32::datum_type());
1131            ensure!(inputs[6].datum_type.is_float());
1132            ensure!(inputs[7].datum_type == i32::datum_type());
1133            ensure!(inputs[8].datum_type.is_float());
1134        }
1135        ensure!(self.pool_spec.rank() + 2 == inputs[1].shape.len());
1136        if self.pool_spec.data_format.shape(&*inputs[0].shape)?.c()
1137            != &self.input_channels().to_dim()
1138        {
1139            bail!(
1140                "Inconsistent convolution: input is {:?}, but kernel expects {} input channels.\n{:?}",
1141                inputs[0],
1142                self.input_channels(),
1143                self
1144            );
1145        }
1146        if let ExplicitOnnxPool(bef, after, _) | Explicit(bef, after) = &self.pool_spec.padding {
1147            anyhow::ensure!(bef.len() == self.pool_spec.rank());
1148            anyhow::ensure!(after.len() == self.pool_spec.rank());
1149        }
1150        ensure!(
1151            inputs[2].rank() == 0
1152                || (inputs[2].rank() == 1
1153                    && inputs[2].shape.volume() == self.output_channels().to_dim()),
1154            "Bias should be scalar or a vector with one value per output channel. Output channels is {}, bias is {:?}",
1155            self.output_channels(),
1156            inputs[2]
1157        );
1158        let mut fact = self.pool_spec.output_facts(inputs)?.remove(0);
1159        if let Some(dt) = self.q_params {
1160            fact.datum_type = dt;
1161        } else {
1162            ensure!(
1163                inputs[1].is_exotic() || inputs[0].datum_type == inputs[1].datum_type,
1164                "Convolution input, weights and bias must have the same type, got {inputs:?}",
1165            )
1166        }
1167        Ok(tvec!(fact))
1168    }
1169
1170    fn axes_mapping(
1171        &self,
1172        inputs: &[&TypedFact],
1173        outputs: &[&TypedFact],
1174    ) -> TractResult<AxesMapping> {
1175        let fact = &inputs[0];
1176        let shape = self.pool_spec.data_format.shape(&fact.shape)?;
1177        let mut axes = AxesMapping::disconnected(inputs, outputs)?
1178            .renaming((InOut::In(0), shape.c_axis()), 'I')?
1179            .renaming((InOut::Out(0), shape.c_axis()), 'O')?;
1180        if let Some(n_axis) = shape.n_axis() {
1181            axes = axes
1182                .renaming((InOut::In(0), n_axis), 'N')?
1183                .linking('N', (InOut::Out(0), n_axis))?;
1184        }
1185        let h_axis = shape.h_axis();
1186        let geo = "HWXYZ".chars().chain('a'..);
1187        let kernel_spatial_shape = &self.pool_spec.kernel_shape;
1188        let padding = self.pool_spec.computed_padding(shape.hw_dims());
1189        for ((ix, &dim), repr) in kernel_spatial_shape.iter().enumerate().zip(geo) {
1190            if dim == 1
1191                && self.pool_spec.dilation(ix) == 1
1192                && self.pool_spec.stride(ix) == 1
1193                && padding[ix].pad_before.is_zero()
1194                && padding[ix].pad_after.is_zero()
1195            {
1196                axes = axes
1197                    .renaming((InOut::In(0), ix + h_axis), repr)?
1198                    .linking(repr, (InOut::Out(0), ix + h_axis))?;
1199            }
1200        }
1201        if self.q_params.is_some() {
1202            for (qp_ix, qp) in inputs.iter().enumerate().skip(3) {
1203                if qp.rank() == 1 {
1204                    axes = match qp_ix {
1205                        3 | 4 => axes.linking('I', (InOut::In(qp_ix), 0))?,
1206                        5 | 6 => axes.linking('O', (InOut::In(qp_ix), 0))?,
1207                        7 | 8 => axes.linking('O', (InOut::In(qp_ix), 0))?,
1208                        _ => unreachable!(),
1209                    };
1210                }
1211            }
1212        }
1213        Ok(axes)
1214    }
1215
1216    fn declutter(
1217        &self,
1218        model: &TypedModel,
1219        node: &TypedNode,
1220    ) -> TractResult<Option<TypedModelPatch>> {
1221        macro_rules! pass {
1222            ($func:ident) => {
1223                if let Some(mut r) = self.$func(model, node).context(stringify!($func))? {
1224                    trace!(stringify!($func));
1225                    r.push_context(stringify!($func));
1226                    return Ok(Some(r));
1227                }
1228            };
1229        }
1230        pass!(declutter_stride_slice_to_downsample);
1231        pass!(declutter_as_einsum);
1232        pass!(declutter_channel_arithmetic_succ);
1233        pass!(declutter_precursor_padding);
1234        Ok(None)
1235    }
1236
1237    fn cost(&self, inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
1238        let shape = self.pool_spec.data_format.shape(inputs[0].shape.to_tvec())?;
1239        let kernel_spatial_shape = &self.pool_spec.kernel_shape;
1240        let output_dims = self.pool_spec.padding.compute(
1241            shape.hw_dims(),
1242            kernel_spatial_shape,
1243            &self
1244                .pool_spec
1245                .dilations
1246                .clone()
1247                .unwrap_or_else(|| tvec!(1; kernel_spatial_shape.len())),
1248            &self.pool_spec.strides.clone().unwrap_or_else(|| tvec!(1; kernel_spatial_shape.len())),
1249        );
1250        let n_output_points: TDim =
1251            output_dims.iter().map(|d| d.convoluted.clone()).product::<TDim>();
1252        let n_output_channels = self.output_channels().to_dim();
1253        let kernel_surface = kernel_spatial_shape.iter().product::<usize>().to_dim();
1254        let one = 1.to_dim();
1255        Ok(tvec!((
1256            Cost::FMA(inputs[0].datum_type),
1257            shape.n().cloned().unwrap_or(one)
1258                * shape.c()
1259                * n_output_channels
1260                * n_output_points
1261                * kernel_surface
1262                / self.group
1263        )))
1264    }
1265
1266    /// Depthwise convs run faster channel-last (channel-inner vectorised path),
1267    /// so they advertise a c-to-last axis move on their input. `ChangeAxes`
1268    /// applies it only if the whole graph can carry the layout — the format
1269    /// swap is absorbed by `Conv::change_axes` and moves fold into neighbours;
1270    /// when propagation is blocked (locked interfaces, ops without
1271    /// `change_axes`), nothing changes. Below 16 channels the channel-inner
1272    /// path cannot fill a vector, so W-inner stays the better default. Hosts
1273    /// without a `depthwise_c_f32` kernel get no suggestion: their channel-last
1274    /// path is scalar.
1275    fn suggested_axis_changes(&self) -> TractResult<TVec<(InOut, AxisOp)>> {
1276        if tract_linalg::routines::depthwise_c_f32().is_none() {
1277            return Ok(tvec!());
1278        }
1279        let fmt = self.pool_spec.data_format;
1280        let depthwise = self.group > 1
1281            && self.group == self.input_channels()
1282            && self.group == self.output_channels();
1283        if !depthwise || self.q_params.is_some() || self.input_channels() < 16 || fmt.c_is_last() {
1284            return Ok(tvec!());
1285        }
1286        let rank = self.pool_spec.rank() + 1 + fmt.has_n() as usize;
1287        let c_axis = if fmt.has_n() { 1 } else { 0 };
1288        Ok(tvec!((InOut::In(0), AxisOp::Move(c_axis, rank - 1))))
1289    }
1290
1291    fn change_axes(
1292        &self,
1293        model: &TypedModel,
1294        node: &TypedNode,
1295        io: InOut,
1296        change: &AxisOp,
1297    ) -> TractResult<Option<AxisChangeConsequence>> {
1298        rule_if!(io != InOut::In(1));
1299        if io == InOut::In(2)
1300            && let &AxisOp::Rm(_) = change
1301        {
1302            return Ok(Some(AxisChangeConsequence {
1303                substitute_op: Some(Box::new(self.clone())),
1304                wire_changes: tvec!(),
1305            }));
1306        }
1307        let full_input_shape = model.outlet_fact(node.inputs[0])?.shape.to_tvec();
1308        let shape = self.pool_spec.data_format.shape(full_input_shape.clone())?;
1309        // an export collapsed to one stream carries no batch axis, and hosting
1310        // one is the inverse of disposing of it: the format gains its n.
1311        if shape.n_axis().is_none() && change == &AxisOp::Add(0) {
1312            let op = Conv { pool_spec: self.pool_spec.with_n_axis(), ..self.clone() };
1313            return Ok(Some(AxisChangeConsequence {
1314                substitute_op: Some(Box::new(op)),
1315                wire_changes: tvec!(
1316                    (InOut::In(0), change.clone()),
1317                    (InOut::Out(0), change.clone())
1318                ),
1319            }));
1320        }
1321        // remove n
1322        if let Some(n) = shape.n_axis() {
1323            assert_eq!(n, 0);
1324            if change == &AxisOp::Rm(n) {
1325                let op = Conv { pool_spec: self.pool_spec.dispose_n_axis(), ..self.clone() };
1326                return Ok(Some(AxisChangeConsequence {
1327                    substitute_op: Some(Box::new(op)),
1328                    wire_changes: tvec!(
1329                        (InOut::In(0), change.clone()),
1330                        (InOut::Out(0), change.clone())
1331                    ),
1332                }));
1333            }
1334            rule_if!(change.transform_axis(n).map(|axis| axis == 0).unwrap_or(false));
1335        }
1336        // format swap: chw <-> hwc
1337        let (new_format, axis_move) = match self.pool_spec.data_format {
1338            DataFormat::NCHW => {
1339                (DataFormat::NHWC, AxisOp::Move(shape.c_axis(), full_input_shape.len() - 1))
1340            }
1341            DataFormat::CHW => {
1342                (DataFormat::HWC, AxisOp::Move(shape.c_axis(), full_input_shape.len() - 1))
1343            }
1344            DataFormat::NHWC => (DataFormat::NCHW, AxisOp::Move(shape.c_axis(), 1)),
1345            DataFormat::HWC => (DataFormat::CHW, AxisOp::Move(shape.c_axis(), 0)),
1346        };
1347        if *change == axis_move {
1348            let mut new_op = self.clone();
1349            new_op.pool_spec.data_format = new_format;
1350            return Ok(Some(AxisChangeConsequence {
1351                substitute_op: Some(Box::new(new_op)),
1352                wire_changes: tvec!(
1353                    (InOut::In(0), change.clone()),
1354                    (InOut::Out(0), change.clone())
1355                ),
1356            }));
1357        }
1358        // geo axis manips
1359        rule_if!(!model.node_input_facts(node.id)?[1].is_exotic());
1360        use AxisOp::*;
1361        let h_axis = shape.h_axis();
1362        let hw_axes = shape.hw_axes();
1363        let kh_axis = self.kernel_fmt.h_axis();
1364        let (geo_adjusted, kernel_adjusted) = match change {
1365            Rm(a)
1366                if hw_axes.contains(a)
1367                    && hw_axes.len() > 1
1368                    && self.pool_spec.dilation(a - h_axis) == 1
1369                    && self.pool_spec.stride(a - h_axis) == 1
1370                    && self.pool_spec.kernel_shape[a - h_axis] == 1 =>
1371            {
1372                let geo_axis = a - h_axis;
1373                (Rm(geo_axis), Rm(kh_axis + geo_axis))
1374            }
1375            Add(a) if hw_axes.contains(a) => (Add(a - h_axis), Add(a - h_axis + kh_axis)),
1376            Move(f, t) if hw_axes.contains(f) && hw_axes.contains(t) => {
1377                (Move(f - h_axis, t - h_axis), Move(f - h_axis + kh_axis, t - h_axis + kh_axis))
1378            }
1379            _ => return Ok(None),
1380        };
1381        let pool_spec = self.pool_spec.change_geo_axes(&geo_adjusted)?;
1382        let new_op = Conv { pool_spec, ..self.clone() };
1383        Ok(Some(AxisChangeConsequence {
1384            substitute_op: Some(Box::new(new_op)),
1385            wire_changes: tvec!(
1386                (InOut::In(0), change.clone()),
1387                (InOut::In(1), kernel_adjusted),
1388                (InOut::Out(0), change.clone())
1389            ),
1390        }))
1391    }
1392
1393    fn codegen(
1394        &self,
1395        model: &TypedModel,
1396        node: &TypedNode,
1397    ) -> TractResult<Option<TypedModelPatch>> {
1398        let input_fact = model.outlet_fact(node.inputs[0])?;
1399        unsafe {
1400            if self.q_params.is_some() {
1401                let mut patch = TypedModelPatch::new("quantized-codegen");
1402                let inputs = patch.taps(model, &node.inputs)?;
1403                let wire = self
1404                    .wire_as_quant_im2col(&mut patch, &node.name, &inputs)
1405                    .context("in wire_as_quant_im2col")?;
1406                patch.shunt_outside(model, node.id.into(), wire[0])?;
1407                patch.obliterate(node.id)?;
1408                Ok(Some(patch))
1409            } else if let Some(op) = self.try_blocked_conv(input_fact) {
1410                // Direct register-blocked conv for the small-ocg NCHW kw=1 class;
1411                // beats lazy im2col by avoiding the gather + wasted M-tile matmul.
1412                let mut patch = TypedModelPatch::new("blocked-conv");
1413                let inputs = patch.taps(model, &node.inputs)?;
1414                let wire = self
1415                    .wire_as_blocked_conv(&mut patch, &node.name, &inputs, op)
1416                    .context("wire_as_blocked_conv")?;
1417                patch.shunt_outside(model, OutletId::new(node.id, 0), wire)?;
1418                patch.obliterate(node.id)?;
1419                Ok(Some(patch))
1420            } else if self.can_direct_spatial(input_fact) {
1421                let mut patch = TypedModelPatch::new("direct-spatial");
1422                let inputs = patch.taps(model, &node.inputs)?;
1423                let wire = self
1424                    .wire_as_direct_spatial(&mut patch, &node.name, &inputs)
1425                    .context("wire_as_direct_spatial")?;
1426                patch.shunt_outside(model, OutletId::new(node.id, 0), wire)?;
1427                patch.obliterate(node.id)?;
1428                Ok(Some(patch))
1429            } else if input_fact
1430                .shape
1431                .as_concrete()
1432                .map(|s| should_use_lazy(&self.pool_spec, self.group, s, input_fact.datum_type))
1433                .unwrap_or(false)
1434            {
1435                let mut patch = TypedModelPatch::new("lazy-im2col");
1436                let inputs = patch.taps(model, &node.inputs)?;
1437                let wire = self
1438                    .wire_as_lazy_im2col(&mut patch, &node.name, &inputs)
1439                    .context("wire_as_lazy_im2col")?[0];
1440                patch.shunt_outside(model, OutletId::new(node.id, 0), wire)?;
1441                patch.obliterate(node.id)?;
1442                Ok(Some(patch))
1443            } else if self.group != 1
1444                && self.group == self.output_channels()
1445                && self.group == self.input_channels()
1446                && input_fact.shape.as_concrete().is_some()
1447            {
1448                let mut patch = TypedModelPatch::new("depth_wise");
1449                let inputs = patch.taps(model, &node.inputs)?;
1450                let wire = self
1451                    .wire_as_depth_wise(&mut patch, &node.name, &inputs)
1452                    .context("wire_as_depth_wise")?;
1453                patch.shunt_outside(model, OutletId::new(node.id, 0), wire)?;
1454                patch.obliterate(node.id)?;
1455                Ok(Some(patch))
1456            } else {
1457                let mut patch = TypedModelPatch::new("im2col");
1458                let inputs = patch.taps(model, &node.inputs)?;
1459                let wire = self
1460                    .wire_as_im2col_pair(&mut patch, &node.name, &inputs)
1461                    .context("in wire_as_im2col_pair")?[0];
1462                patch.shunt_outside(model, OutletId::new(node.id, 0), wire)?;
1463                patch.obliterate(node.id)?;
1464                Ok(Some(patch))
1465            }
1466        }
1467    }
1468
1469    as_op!();
1470}
1471
1472/// Default minimum kernel volume for picking LazyIm2col over eager Im2col.
1473///
1474/// LazyIm2col has per-output-position gather indirection overhead; eager Im2col has
1475/// materialisation overhead (one big alloc + strided memcpy). For tiny kernels the
1476/// indirection wins; for bigger kernels the materialisation cost dominates. This default
1477/// is conservative — empirically lazy already wins for kernel volumes ≥ 4 on Apple AMX
1478/// (and likely lower on memory-constrained targets like embedded ARM). Override via
1479/// `TRACT_LAZY_IM2COL_MIN_KERNEL` env var to experiment with lower thresholds.
1480const DEFAULT_LAZY_IM2COL_MIN_KERNEL: usize = 6;
1481
1482/// The shortest output row a direct spatial convolution takes: its taps are repacked per zone and
1483/// per four output channels, which a shorter row does not pay back against im2col.
1484const MIN_DIRECT_SPATIAL_W: usize = 128;
1485
1486/// Whether AMX runs im2col's matrix products here: it wins the wide convolutions, so the direct
1487/// spatial one only takes thin ones.
1488fn amx_owns_fat_spatial() -> bool {
1489    #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
1490    {
1491        tract_linalg::arm64::has_amx()
1492    }
1493    #[cfg(not(all(target_arch = "aarch64", target_os = "macos")))]
1494    {
1495        false
1496    }
1497}
1498
1499crate::declare_knob!(
1500    scopable TRACT_DISABLE_DIRECT_SPATIAL,
1501    bool,
1502    false,
1503    "Disable the direct spatial convolution, falling back to im2col and a matrix product."
1504);
1505
1506crate::declare_knob!(
1507    scopable TRACT_ENABLE_BLOCKED_CONV,
1508    bool,
1509    false,
1510    "Force-enable the direct blocked convolution on native targets (on by default on wasm)."
1511);
1512crate::declare_knob!(
1513    scopable TRACT_DISABLE_BLOCKED_CONV,
1514    bool,
1515    false,
1516    "Force-disable the direct blocked convolution on wasm targets (off by default on native)."
1517);
1518crate::declare_knob!(
1519    scopable TRACT_LAZY_IM2COL_MIN_KERNEL,
1520    usize,
1521    DEFAULT_LAZY_IM2COL_MIN_KERNEL,
1522    "Minimum convolution kernel volume before lazy im2col is preferred over eager."
1523);
1524crate::declare_knob!(
1525    scopable TRACT_LAZY_IM2COL_MAX_EAGER_BYTES,
1526    usize,
1527    DEFAULT_LAZY_IM2COL_MAX_EAGER_BYTES,
1528    "Eager-im2col scratch-size ceiling, in bytes, above which lazy im2col is preferred."
1529);
1530
1531fn lazy_im2col_min_kernel() -> usize {
1532    TRACT_LAZY_IM2COL_MIN_KERNEL.get()
1533}
1534
1535/// Default eager-Im2col scratch-size ceiling, in bytes, above which LazyIm2col is
1536/// preferred regardless of kernel volume.
1537///
1538/// Eager Im2col materialises a `[k, n]` packed scratch of `k·n·sizeof` bytes — it is
1539/// allocated, written, then read back by the matmul. While that scratch is small it
1540/// stays hot in cache and the round-trip is cheap, so the kernel-volume rule above
1541/// governs. Once it is large, the materialisation becomes a pure memory-bandwidth tax
1542/// (write + read of multiple MB every inference) that outweighs LazyIm2col's per-panel
1543/// gather indirection — so prefer lazy. The kernel-volume rule alone misses this case:
1544/// a *small* kernel over a *large* output (big `n`) still materialises multiple MB.
1545///
1546/// The crossover is target-dependent. On WASM the materialisation tax bites harder
1547/// (no hardware-prefetch help, bounds-checked stores), so lazy wins from ~1 MiB of
1548/// scratch upward. On native CPUs the caches and prefetchers absorb a few MB, so the
1549/// crossover sits higher (~4 MiB, measured on Apple Silicon). Hence the per-family
1550/// defaults below. Override on either target via `TRACT_LAZY_IM2COL_MAX_EAGER_BYTES`;
1551/// this value is the key knob for the canary-model regression gate.
1552#[cfg(target_family = "wasm")]
1553const DEFAULT_LAZY_IM2COL_MAX_EAGER_BYTES: usize = 1024 * 1024;
1554#[cfg(not(target_family = "wasm"))]
1555const DEFAULT_LAZY_IM2COL_MAX_EAGER_BYTES: usize = 4 * 1024 * 1024;
1556
1557fn lazy_im2col_max_eager_bytes() -> usize {
1558    TRACT_LAZY_IM2COL_MAX_EAGER_BYTES.get()
1559}
1560
1561fn should_use_lazy(
1562    pool_spec: &PoolSpec,
1563    group: usize,
1564    input_shape: &[usize],
1565    dt: DatumType,
1566) -> bool {
1567    // Depthwise convs (group == in_channels == out_channels) have a specialised
1568    // `DepthWise` op downstream that's much faster than the generic im2col + matmul
1569    // path on every backend we measured (Apple AMX, x64, aarch64). Don't intercept
1570    // them here — let the dispatch in `conv.rs` reach `wire_as_depth_wise`.
1571    let is_depthwise =
1572        group > 1 && group == pool_spec.input_channels && group == pool_spec.output_channels;
1573    if is_depthwise {
1574        return false;
1575    }
1576    let Ok(output_shape) = pool_spec.output_shape(input_shape) else { return false };
1577    // LazyIm2col's offset tables are built for a single batch.
1578    if output_shape.n().unwrap_or(&1) != &1 {
1579        return false;
1580    }
1581    let kernel_volume = pool_spec.kernel_shape.iter().product::<usize>();
1582    // Primary rule: kernel volume. LazyIm2col's per-output-position gather indirection
1583    // is cheap relative to materialising the scratch for a sizeable kernel.
1584    if kernel_volume >= lazy_im2col_min_kernel() {
1585        return true;
1586    }
1587    // Shape-aware rule: prefer lazy when the eager scratch (`k·n·sizeof`) is large,
1588    // even for a small kernel. `n` is the output spatial volume — the dimension the
1589    // kernel-volume rule ignores but which actually drives the materialisation cost.
1590    let n: usize = output_shape.hw_dims().iter().product();
1591    let k = pool_spec.input_channels * kernel_volume / group;
1592    let eager_scratch_bytes = k.saturating_mul(n).saturating_mul(dt.size_of());
1593    if eager_scratch_bytes >= lazy_im2col_max_eager_bytes() {
1594        return true;
1595    }
1596    // A 1x1 kernel has nothing to gather: its im2col is a reshape, so there is no
1597    // materialisation for lazy to save and its per-position indirection is pure loss.
1598    if kernel_volume == 1 {
1599        return false;
1600    }
1601    // Single-panel rule. Both paths gather the same `k * n` elements: eager gathers
1602    // them once into scratch the matmul then streams once per row panel, lazy
1603    // re-gathers per row panel and builds nothing. Eager only pays for itself once
1604    // there is more than one row panel to amortise the scratch over -- at a single
1605    // panel lazy does the same gathers and skips the buffer entirely. Ask the kernel
1606    // that will actually run rather than assuming its geometry.
1607    let m = (pool_spec.output_channels / group).max(1);
1608    let mr = tract_linalg::MmmDispatch::native()
1609        .preferred_kernel(dt, Some(m), Some(k), Some(n))
1610        .map(|mmm| mmm.mr())
1611        .unwrap_or(1);
1612    m <= mr
1613}
1614
1615#[allow(non_snake_case)]
1616#[cfg(test)]
1617mod test {
1618    use super::*;
1619    use crate::ops::array::Pad;
1620    use DataFormat::*;
1621
1622    #[test]
1623    fn onnx_basic_convinteger() {
1624        let op = Conv {
1625            pool_spec: PoolSpec {
1626                data_format: NCHW,
1627                kernel_shape: tvec!(2, 2),
1628                padding: Valid,
1629                dilations: None,
1630                strides: None,
1631                input_channels: 1,
1632                output_channels: 1,
1633            },
1634            kernel_fmt: KernelFormat::OIHW,
1635            group: 1,
1636            q_params: Some(i32::datum_type()),
1637        };
1638        let input = tvec!(
1639            rctensor4(&[[[[1u8, 2, 3], [4, 5, 6], [7, 8, 9]]]]),
1640            rctensor4(&[[[[1u8, 1], [1, 1]]]]),
1641            rctensor0(0u32),
1642            rctensor0(1u8),
1643            rctensor0(1.0f32),
1644            rctensor0(0u8),
1645            rctensor0(1.0f32),
1646            rctensor0(0i32),
1647            rctensor0(1.0f32),
1648        );
1649        let input = input.into_iter().map(IntoTValue::into_tvalue).collect::<TVec<_>>();
1650        let output = op.eval(&EvalContext::out_of_plan(), input).unwrap();
1651        assert_eq!(*output[0], tensor4(&[[[[8i32, 12], [20, 24]]]]));
1652    }
1653
1654    #[test]
1655    fn valid_conv_absorbs_precursor_pad() -> TractResult<()> {
1656        let mut model = TypedModel::default();
1657        let wire = tvec!(model.add_source("source", f32::fact(dims!(1, 10)))?);
1658        let wire = model.wire_node(
1659            "pad",
1660            Pad {
1661                pads: vec![(0, 0), (1, 0)],
1662                mode: ops::array::PadMode::Constant(rctensor0(0f32)),
1663            },
1664            &wire,
1665        )?;
1666        let kernel = model.add_const("kernel", rctensor3(&[[[1f32, 2f32]]]))?;
1667        let bias = model.add_const("bias", rctensor0(0f32))?;
1668        let wire = model.wire_node(
1669            "conv",
1670            Conv {
1671                pool_spec: PoolSpec {
1672                    data_format: crate::ops::nn::DataFormat::CHW,
1673                    dilations: None,
1674                    strides: None,
1675                    kernel_shape: tvec![2],
1676                    padding: Explicit(tvec![0], tvec![0]),
1677                    input_channels: 1,
1678                    output_channels: 1,
1679                },
1680                kernel_fmt: crate::ops::cnn::KernelFormat::OIHW,
1681                group: 1,
1682                q_params: None,
1683            },
1684            &[wire[0], kernel, bias],
1685        )?;
1686        model.select_output_outlets(&wire)?;
1687        model.declutter()?;
1688        assert_eq!(model.nodes().len(), 4); // source + conv + kernel + bias
1689        let cv = model.nodes()[3].op_as::<Conv>().unwrap();
1690        assert_eq!(cv.pool_spec.padding, Explicit(tvec![1], tvec![0])); // source + conv
1691        Ok(())
1692    }
1693
1694    fn filled(shape: &[usize], mut f: impl FnMut(usize) -> f32) -> Tensor {
1695        let n: usize = shape.iter().product();
1696        let data: Vec<f32> = (0..n).map(|i| f(i)).collect();
1697        Tensor::from_shape(shape, &data).unwrap()
1698    }
1699
1700    fn run_lazy(
1701        fmt: DataFormat,
1702        input: &Tensor,
1703        kernel: &Tensor,
1704        padding: crate::ops::cnn::PaddingSpec,
1705        strides: Option<TVec<usize>>,
1706        dilations: Option<TVec<usize>>,
1707        group: usize,
1708        ic: usize,
1709        oc: usize,
1710        kernel_shape: TVec<usize>,
1711    ) -> TractResult<(TypedModel, Tensor)> {
1712        let mut model = TypedModel::default();
1713        let wire = tvec!(model.add_source("input", f32::fact(input.shape().to_vec()))?);
1714        let k = model.add_const("kernel", kernel.clone().into_arc_tensor())?;
1715        let bias = model.add_const("bias", rctensor0(0f32))?;
1716        let op = Conv {
1717            pool_spec: PoolSpec {
1718                data_format: fmt,
1719                kernel_shape,
1720                padding,
1721                dilations,
1722                strides,
1723                input_channels: ic,
1724                output_channels: oc,
1725            },
1726            kernel_fmt: KernelFormat::OIHW,
1727            group,
1728            q_params: None,
1729        };
1730        let wire = model.wire_node("conv", op, &[wire[0], k, bias])?;
1731        model.select_output_outlets(&wire)?;
1732        let model = model.into_optimized()?;
1733        let out = model.clone().into_runnable()?.run(tvec!(input.clone().into_tvalue()))?;
1734        Ok((model, (*out[0]).clone()))
1735    }
1736
1737    /// 1x1 NCHW is an einsum. When the SME 32x32 kernel runs it, the activation
1738    /// packer is a strided view and the result still matches a scalar 1x1.
1739    #[test]
1740    fn pointwise_nchw_strided_pack() -> TractResult<()> {
1741        // Large enough that the M4 model picks sme_mmm_f32_32x32. 81*79 = 6399,
1742        // so the last panel has 31 live lanes.
1743        check_pointwise(81, 79, 16, 32, true)?;
1744        check_pointwise(5, 7, 3, 5, false)?;
1745        check_pointwise(4, 16, 4, 8, false)?;
1746        Ok(())
1747    }
1748
1749    fn sme_on_this_cpu() -> bool {
1750        #[cfg(target_arch = "aarch64")]
1751        {
1752            tract_linalg::arm64::has_sme()
1753        }
1754        #[cfg(not(target_arch = "aarch64"))]
1755        {
1756            false
1757        }
1758    }
1759
1760    fn check_pointwise(
1761        h: usize,
1762        w: usize,
1763        ic: usize,
1764        oc: usize,
1765        require_sme: bool,
1766    ) -> TractResult<()> {
1767        use tract_linalg::strided_panel::StridedKMajor;
1768        let input = filled(&[1, ic, h, w], |i| (i % 7) as f32 + 1.0);
1769        let kernel = filled(&[oc, ic, 1, 1], |i| (i % 5) as f32 - 2.0);
1770        let (model, out) =
1771            run_lazy(NCHW, &input, &kernel, Valid, None, None, 1, ic, oc, tvec![1, 1])?;
1772        let uses_sme = model.nodes().iter().any(|n| {
1773            n.op_as::<OptMatMul>()
1774                .is_some_and(|op| op.mmm.iter().any(|m| m.name() == "sme_mmm_f32_32x32"))
1775        });
1776        if require_sme && sme_on_this_cpu() {
1777            let names: Vec<_> = model
1778                .nodes()
1779                .iter()
1780                .filter_map(|n| n.op_as::<OptMatMul>())
1781                .flat_map(|op| op.mmm.iter().map(|m| m.name().to_string()))
1782                .collect();
1783            ensure!(uses_sme, "{h}x{w} ic{ic} oc{oc} picked {names:?}");
1784        }
1785        if uses_sme {
1786            ensure!(
1787                model.nodes().iter().any(|n| {
1788                    n.op_as::<OptMatMulPack>().is_some_and(|op| {
1789                        op.packers.iter().any(|p| p.downcast_ref::<StridedKMajor>().is_some())
1790                    })
1791                }),
1792                "{h}x{w}: SME matmul still packs the activation"
1793            );
1794            ensure!(
1795                model
1796                    .nodes()
1797                    .iter()
1798                    .any(|n| { n.op_as::<OptMatMul>().is_some_and(|op| op.trivial_path) }),
1799                "{h}x{w}: strided pack turned off the trivial matmul path"
1800            );
1801        }
1802        let want = pointwise_reference(&input, &kernel);
1803        out.close_enough(&want, Approximation::Approximate)
1804            .with_context(|| format!("pointwise {h}x{w} ic{ic} oc{oc}"))?;
1805        Ok(())
1806    }
1807
1808    fn pointwise_reference(input: &Tensor, kernel: &Tensor) -> Tensor {
1809        let (ic, h, w) = (input.shape()[1], input.shape()[2], input.shape()[3]);
1810        let oc = kernel.shape()[0];
1811        let iv = input.to_plain_array_view::<f32>().unwrap();
1812        let iv = iv.as_slice().unwrap();
1813        let kv = kernel.to_plain_array_view::<f32>().unwrap();
1814        let kv = kv.as_slice().unwrap();
1815        let mut out = vec![0f32; oc * h * w];
1816        for o in 0..oc {
1817            for y in 0..h {
1818                for x in 0..w {
1819                    let mut acc = 0f32;
1820                    for c in 0..ic {
1821                        acc += iv[c * h * w + y * w + x] * kv[o * ic + c];
1822                    }
1823                    out[o * h * w + y * w + x] = acc;
1824                }
1825            }
1826        }
1827        Tensor::from_shape(&[1, oc, h, w], &out).unwrap()
1828    }
1829}