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