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