Skip to main content

tract_core/ops/cnn/conv/
blocked.rs

1//! Direct, register-blocked convolution for the "channel-mixing temporal conv"
2//! shape class: NCHW, kernel width 1 (extent only on H), unit stride/dilation on
3//! the contiguous W axis, grouped, with a *small* number of output channels per
4//! group (`ocg`).
5//!
6//! For such convs the im2col + matmul lowering is inefficient: the per-group
7//! matmul is `M = ocg` (tiny, e.g. 5) × `K = icg·KH` × `N = H·W`, so the matmul
8//! kernel's m-tile is mostly wasted — exactly the same pathology as a low-M GEMV.
9//! ORT side-steps it with a direct conv.
10//!
11//! This op computes the conv directly: for each (group, output-row, block of the
12//! contiguous W axis) it holds `ocg` accumulators in registers and reduces over
13//! `(kh, icg)`, loading each input row ONCE and reusing it across all `ocg`
14//! outputs (the same input-reuse a GEMM gets). Measured on df_dec's `df_convp.1`
15//! (group=2, 64→10ch, kernel [5,1], 100×96): 0.77 ms native / 0.79 ms wasm vs
16//! 1.72 / 2.42 ms for tract's lazy im2col and 1.13 ms for ORT — a 2.2–3.1× win,
17//! bit-exact.
18//!
19//! Eligibility is checked in `Conv::codegen`; anything outside the supported
20//! shape class falls back to im2col.
21
22use crate::internal::*;
23
24/// Width of the inner SIMD-vectorised block over the contiguous W axis.
25const WB: usize = 16;
26
27#[cfg(all(target_feature = "simd128", not(target_feature = "relaxed-simd")))]
28macro_rules! blocked_madd {
29    ($acc:expr, $a:expr, $b:expr) => {
30        f32x4_add($acc, f32x4_mul($a, $b))
31    };
32}
33
34#[cfg(target_feature = "relaxed-simd")]
35macro_rules! blocked_madd {
36    ($acc:expr, $a:expr, $b:expr) => {
37        f32x4_relaxed_madd($a, $b, $acc)
38    };
39}
40
41/// Direct blocked conv. Inputs: X [N, C, H, W] (NCHW, f32), kernel
42/// [OC, ICG·KH] (group-major: row `oc` holds its group's `icg·KH` weights,
43/// i-major/h-minor), bias [OC]. Output [N, OC, H_out, W].
44#[derive(Debug, Clone, Hash, PartialEq, Eq)]
45pub struct BlockedConv {
46    pub n: usize,
47    pub c_in: usize,
48    pub h_in: usize,
49    pub w: usize,
50    pub oc: usize,
51    pub group: usize,
52    pub kh: usize,
53    pub stride_h: usize,
54    pub dil_h: usize,
55    pub pad_before_h: usize,
56    pub h_out: usize,
57}
58
59impl BlockedConv {
60    #[inline]
61    fn icg(&self) -> usize {
62        self.c_in / self.group
63    }
64    #[inline]
65    fn ocg(&self) -> usize {
66        self.oc / self.group
67    }
68}
69
70impl Op for BlockedConv {
71    fn name(&self) -> StaticName {
72        "BlockedConv".into()
73    }
74
75    fn info(&self) -> TractResult<Vec<String>> {
76        Ok(vec![format!(
77            "N={} C={}->OC={} group={} kh={} (icg={} ocg={}) HxW={}x{} -> H_out={} pad_before={} stride_h={} dil_h={}",
78            self.n,
79            self.c_in,
80            self.oc,
81            self.group,
82            self.kh,
83            self.icg(),
84            self.ocg(),
85            self.h_in,
86            self.w,
87            self.h_out,
88            self.pad_before_h,
89            self.stride_h,
90            self.dil_h,
91        )])
92    }
93
94    op_as_typed_op!();
95}
96
97impl EvalOp for BlockedConv {
98    op_out_of_plan!();
99
100    fn eval(&self, _ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
101        let x_t = inputs[0].cast_to::<f32>()?;
102        let k_t = inputs[1].cast_to::<f32>()?;
103        let b_t = inputs[2].cast_to::<f32>()?;
104        // SAFETY: just cast to f32; conv I/O tensors are standard (contiguous) layout.
105        let x = unsafe { x_t.as_slice_unchecked::<f32>() };
106        let kernel = unsafe { k_t.as_slice_unchecked::<f32>() };
107        let bias_raw = unsafe { b_t.as_slice_unchecked::<f32>() };
108        // Normalise bias to a per-output-channel vector (it may arrive as a
109        // scalar zero, empty, or already [oc]).
110        let bias_vec: Vec<f32> = match bias_raw.len() {
111            0 => vec![0.0; self.oc],
112            1 => vec![bias_raw[0]; self.oc],
113            _ => bias_raw.to_vec(),
114        };
115        let bias = bias_vec.as_slice();
116
117        let mut output =
118            unsafe { Tensor::uninitialized::<f32>(&[self.n, self.oc, self.h_out, self.w])? };
119        let out = unsafe { output.as_slice_mut_unchecked::<f32>() };
120
121        let ocg = self.ocg();
122        match ocg {
123            1 => self.run_simd::<1>(x, kernel, bias, out),
124            2 => self.run_simd::<2>(x, kernel, bias, out),
125            3 => self.run_simd::<3>(x, kernel, bias, out),
126            4 => self.run_simd::<4>(x, kernel, bias, out),
127            5 => self.run_simd::<5>(x, kernel, bias, out),
128            6 => self.run_simd::<6>(x, kernel, bias, out),
129            8 => self.run_simd::<8>(x, kernel, bias, out),
130            _ => self.run_simd_generic(x, kernel, bias, out),
131        }
132
133        Ok(tvec!(output.into_tvalue()))
134    }
135}
136
137impl BlockedConv {
138    /// Const-OCG fast path: `ocg` accumulators of WB lanes held in registers.
139    ///
140    /// The hot loop (full WB-wide blocks) touches `acc` ONLY at compile-time
141    /// constant offsets `[ocl][j]` (ocl<OCG, j<WB, both const) and stores it
142    /// whole — so LLVM's SROA promotes the OCG·WB accumulators to SSA registers
143    /// and keeps them resident across the runtime `(kh, icg)` reduction (this is
144    /// what makes it ~2.4× faster than a runtime-length-access variant, matching
145    /// the standalone microbench). `get_unchecked` keeps the runtime-derived
146    /// input/kernel/output indices bounds-check-free; all are provably in range
147    /// from the shape invariants. The `w % WB` remainder uses a scalar tail.
148    // Index loops are deliberate here: const offsets into `acc` are what let SROA
149    // keep the accumulators register-resident; iterator forms regressed codegen.
150    #[allow(clippy::needless_range_loop)]
151    #[allow(dead_code)]
152    fn run<const OCG: usize>(&self, x: &[f32], kernel: &[f32], bias: &[f32], out: &mut [f32]) {
153        let (icg, w, h_in, h_out, kh) = (self.icg(), self.w, self.h_in, self.h_out, self.kh);
154        let (sh, dh, pb) =
155            (self.stride_h as isize, self.dil_h as isize, self.pad_before_h as isize);
156        let kstride_oc = icg * kh; // weights row stride per output channel
157        let n_full = w / WB; // full WB-wide blocks; remainder handled after
158        for ni in 0..self.n {
159            let x_n = &x[ni * self.c_in * h_in * w..];
160            let out_n = &mut out[ni * self.oc * h_out * w..];
161            for g in 0..self.group {
162                let oc0 = g * OCG;
163                let ic0 = g * icg;
164                for oh in 0..h_out {
165                    // ---- full WB blocks: all-const acc access -> register-resident ----
166                    for blk in 0..n_full {
167                        let wb = blk * WB;
168                        let mut acc = [[0f32; WB]; OCG];
169                        for ocl in 0..OCG {
170                            let b = bias[oc0 + ocl];
171                            for j in 0..WB {
172                                acc[ocl][j] = b;
173                            }
174                        }
175                        for kh_i in 0..kh {
176                            let ih = oh as isize * sh + kh_i as isize * dh - pb;
177                            if ih < 0 || ih >= h_in as isize {
178                                continue;
179                            }
180                            let row0 = ((ic0 * h_in + ih as usize) * w + wb) as isize;
181                            for icl in 0..icg {
182                                let row_base = (row0 + (icl * h_in * w) as isize) as usize;
183                                for ocl in 0..OCG {
184                                    let wv = unsafe {
185                                        *kernel.get_unchecked(
186                                            (oc0 + ocl) * kstride_oc + icl * kh + kh_i,
187                                        )
188                                    };
189                                    let a = &mut acc[ocl];
190                                    for j in 0..WB {
191                                        a[j] += unsafe { *x_n.get_unchecked(row_base + j) } * wv;
192                                    }
193                                }
194                            }
195                        }
196                        for ocl in 0..OCG {
197                            let ob = ((oc0 + ocl) * h_out + oh) * w + wb;
198                            for j in 0..WB {
199                                unsafe { *out_n.get_unchecked_mut(ob + j) = acc[ocl][j] };
200                            }
201                        }
202                    }
203                    // ---- remainder (w % WB != 0): scalar tail accumulated in place ----
204                    let wb = n_full * WB;
205                    if wb < w {
206                        let rem = w - wb;
207                        for ocl in 0..OCG {
208                            let b = bias[oc0 + ocl];
209                            let ob = ((oc0 + ocl) * h_out + oh) * w + wb;
210                            for j in 0..rem {
211                                out_n[ob + j] = b;
212                            }
213                        }
214                        for kh_i in 0..kh {
215                            let ih = oh as isize * sh + kh_i as isize * dh - pb;
216                            if ih < 0 || ih >= h_in as isize {
217                                continue;
218                            }
219                            let ih = ih as usize;
220                            for icl in 0..icg {
221                                let row_base = ((ic0 + icl) * h_in + ih) * w + wb;
222                                for ocl in 0..OCG {
223                                    let wv = kernel[(oc0 + ocl) * kstride_oc + icl * kh + kh_i];
224                                    let ob = ((oc0 + ocl) * h_out + oh) * w + wb;
225                                    for j in 0..rem {
226                                        out_n[ob + j] += x_n[row_base + j] * wv;
227                                    }
228                                }
229                            }
230                        }
231                    }
232                }
233            }
234        }
235    }
236
237    /// Generic fallback for `ocg` outside the const-dispatched set. Correct but
238    /// not register-blocked (heap accumulators). Rarely hit for the eligible class.
239    #[allow(clippy::needless_range_loop)]
240    #[allow(dead_code)]
241    fn run_generic(&self, x: &[f32], kernel: &[f32], bias: &[f32], out: &mut [f32]) {
242        let (icg, ocg, w, h_in, h_out, kh) =
243            (self.icg(), self.ocg(), self.w, self.h_in, self.h_out, self.kh);
244        let (sh, dh, pb) =
245            (self.stride_h as isize, self.dil_h as isize, self.pad_before_h as isize);
246        let kstride_oc = icg * kh;
247        let mut acc = vec![0f32; ocg * w];
248        for ni in 0..self.n {
249            let x_n = &x[ni * self.c_in * h_in * w..];
250            let out_n = &mut out[ni * self.oc * h_out * w..];
251            for g in 0..self.group {
252                let oc0 = g * ocg;
253                let ic0 = g * icg;
254                for oh in 0..h_out {
255                    for ocl in 0..ocg {
256                        let b = bias[oc0 + ocl];
257                        for j in 0..w {
258                            acc[ocl * w + j] = b;
259                        }
260                    }
261                    for kh_i in 0..kh {
262                        let ih = oh as isize * sh + kh_i as isize * dh - pb;
263                        if ih < 0 || ih >= h_in as isize {
264                            continue;
265                        }
266                        let ih = ih as usize;
267                        for icl in 0..icg {
268                            let ic = ic0 + icl;
269                            let row = &x_n[(ic * h_in + ih) * w..(ic * h_in + ih) * w + w];
270                            for ocl in 0..ocg {
271                                let wv = kernel[(oc0 + ocl) * kstride_oc + icl * kh + kh_i];
272                                let a = &mut acc[ocl * w..ocl * w + w];
273                                for j in 0..w {
274                                    a[j] += row[j] * wv;
275                                }
276                            }
277                        }
278                    }
279                    for ocl in 0..ocg {
280                        let ob = ((oc0 + ocl) * h_out + oh) * w;
281                        out_n[ob..ob + w].copy_from_slice(&acc[ocl * w..ocl * w + w]);
282                    }
283                }
284            }
285        }
286    }
287
288    /// SIMD-vectorised version of `run` for targets with `+simd128`.
289    ///
290    /// Each output channel holds 4 `v128` accumulators (16 f32 lanes = WB).
291    /// The inner reduction over `(kh, icg)` uses 4-wide `f32x4` load/madd/store
292    /// per output channel instead of 16 scalar multiplies. When `+relaxed-simd`
293    /// is active, `blocked_madd!` emits `f32x4_relaxed_madd` (FMA); otherwise
294    /// plain `f32x4_mul` + `f32x4_add`.
295    #[cfg(target_feature = "simd128")]
296    #[allow(clippy::needless_range_loop)]
297    fn run_simd<const OCG: usize>(&self, x: &[f32], kernel: &[f32], bias: &[f32], out: &mut [f32]) {
298        use std::arch::wasm32::*;
299
300        let (icg, w, h_in, h_out, kh) = (self.icg(), self.w, self.h_in, self.h_out, self.kh);
301        let (sh, dh, pb) =
302            (self.stride_h as isize, self.dil_h as isize, self.pad_before_h as isize);
303        let kstride_oc = icg * kh;
304        let n_full = w / WB;
305        for ni in 0..self.n {
306            let x_n = &x[ni * self.c_in * h_in * w..];
307            let out_n = &mut out[ni * self.oc * h_out * w..];
308            for g in 0..self.group {
309                let oc0 = g * OCG;
310                let ic0 = g * icg;
311                for oh in 0..h_out {
312                    for blk in 0..n_full {
313                        let wb = blk * WB;
314                        let mut acc: [[v128; 4]; OCG] = {
315                            let z = f32x4_splat(0.0);
316                            [[z; 4]; OCG]
317                        };
318                        for ocl in 0..OCG {
319                            let b = f32x4_splat(bias[oc0 + ocl]);
320                            acc[ocl][0] = b;
321                            acc[ocl][1] = b;
322                            acc[ocl][2] = b;
323                            acc[ocl][3] = b;
324                        }
325                        for kh_i in 0..kh {
326                            let ih = oh as isize * sh + kh_i as isize * dh - pb;
327                            if ih < 0 || ih >= h_in as isize {
328                                continue;
329                            }
330                            let row0 = ((ic0 * h_in + ih as usize) * w + wb) as usize;
331                            for icl in 0..icg {
332                                let row_base = row0 + (icl * h_in * w);
333                                let x_slice = &x_n[row_base..row_base + WB];
334                                for ocl in 0..OCG {
335                                    let wv = f32x4_splat(
336                                        kernel[(oc0 + ocl) * kstride_oc + icl * kh + kh_i],
337                                    );
338                                    unsafe {
339                                        let xp = x_slice.as_ptr() as *const v128;
340                                        let ap = acc[ocl].as_mut_ptr();
341                                        let a0 = v128_load(ap.add(0));
342                                        let a1 = v128_load(ap.add(1));
343                                        let a2 = v128_load(ap.add(2));
344                                        let a3 = v128_load(ap.add(3));
345                                        let x0 = v128_load(xp.add(0));
346                                        let x1 = v128_load(xp.add(1));
347                                        let x2 = v128_load(xp.add(2));
348                                        let x3 = v128_load(xp.add(3));
349                                        v128_store(ap.add(0), blocked_madd!(a0, x0, wv));
350                                        v128_store(ap.add(1), blocked_madd!(a1, x1, wv));
351                                        v128_store(ap.add(2), blocked_madd!(a2, x2, wv));
352                                        v128_store(ap.add(3), blocked_madd!(a3, x3, wv));
353                                    }
354                                }
355                            }
356                        }
357                        for ocl in 0..OCG {
358                            let ob = ((oc0 + ocl) * h_out + oh) * w + wb;
359                            let out_slice = &mut out_n[ob..ob + WB];
360                            unsafe {
361                                let op = out_slice.as_mut_ptr() as *mut v128;
362                                v128_store(op.add(0), acc[ocl][0]);
363                                v128_store(op.add(1), acc[ocl][1]);
364                                v128_store(op.add(2), acc[ocl][2]);
365                                v128_store(op.add(3), acc[ocl][3]);
366                            }
367                        }
368                    }
369                    let wb = n_full * WB;
370                    if wb < w {
371                        let rem = w - wb;
372                        for ocl in 0..OCG {
373                            let b = bias[oc0 + ocl];
374                            let ob = ((oc0 + ocl) * h_out + oh) * w + wb;
375                            for j in 0..rem {
376                                out_n[ob + j] = b;
377                            }
378                        }
379                        for kh_i in 0..kh {
380                            let ih = oh as isize * sh + kh_i as isize * dh - pb;
381                            if ih < 0 || ih >= h_in as isize {
382                                continue;
383                            }
384                            let ih = ih as usize;
385                            for icl in 0..icg {
386                                let row_base = ((ic0 + icl) * h_in + ih) * w + wb;
387                                for ocl in 0..OCG {
388                                    let wv = kernel[(oc0 + ocl) * kstride_oc + icl * kh + kh_i];
389                                    let ob = ((oc0 + ocl) * h_out + oh) * w + wb;
390                                    for j in 0..rem {
391                                        out_n[ob + j] += x_n[row_base + j] * wv;
392                                    }
393                                }
394                            }
395                        }
396                    }
397                }
398            }
399        }
400    }
401
402    /// Fallback when `+simd128` is not active: delegates to the scalar `run`.
403    #[cfg(not(target_feature = "simd128"))]
404    #[inline(always)]
405    fn run_simd<const OCG: usize>(&self, x: &[f32], kernel: &[f32], bias: &[f32], out: &mut [f32]) {
406        self.run::<OCG>(x, kernel, bias, out);
407    }
408
409    /// SIMD-vectorised version of `run_generic`.
410    #[cfg(target_feature = "simd128")]
411    #[allow(clippy::needless_range_loop)]
412    fn run_simd_generic(&self, x: &[f32], kernel: &[f32], bias: &[f32], out: &mut [f32]) {
413        use std::arch::wasm32::*;
414
415        let (icg, ocg, w, h_in, h_out, kh) =
416            (self.icg(), self.ocg(), self.w, self.h_in, self.h_out, self.kh);
417        let (sh, dh, pb) =
418            (self.stride_h as isize, self.dil_h as isize, self.pad_before_h as isize);
419        let kstride_oc = icg * kh;
420        let mut acc = vec![0f32; ocg * w];
421        for ni in 0..self.n {
422            let x_n = &x[ni * self.c_in * h_in * w..];
423            let out_n = &mut out[ni * self.oc * h_out * w..];
424            for g in 0..self.group {
425                let oc0 = g * ocg;
426                let ic0 = g * icg;
427                for oh in 0..h_out {
428                    for ocl in 0..ocg {
429                        let b = bias[oc0 + ocl];
430                        let a = &mut acc[ocl * w..ocl * w + w];
431                        for j in 0..w {
432                            a[j] = b;
433                        }
434                    }
435                    for kh_i in 0..kh {
436                        let ih = oh as isize * sh + kh_i as isize * dh - pb;
437                        if ih < 0 || ih >= h_in as isize {
438                            continue;
439                        }
440                        let ih = ih as usize;
441                        for icl in 0..icg {
442                            let ic = ic0 + icl;
443                            let row = &x_n[(ic * h_in + ih) * w..(ic * h_in + ih) * w + w];
444                            for ocl in 0..ocg {
445                                let wv_scalar = kernel[(oc0 + ocl) * kstride_oc + icl * kh + kh_i];
446                                let a = &mut acc[ocl * w..ocl * w + w];
447                                let n4 = w & !3;
448                                if n4 > 0 {
449                                    unsafe {
450                                        let wv = f32x4_splat(wv_scalar);
451                                        let xp = row.as_ptr() as *const v128;
452                                        let ap = a.as_mut_ptr() as *mut v128;
453                                        for j in 0..n4 / 4 {
454                                            let av = v128_load(ap.add(j));
455                                            let xv = v128_load(xp.add(j));
456                                            v128_store(ap.add(j), blocked_madd!(av, xv, wv));
457                                        }
458                                    }
459                                }
460                                for j in n4..w {
461                                    a[j] += row[j] * wv_scalar;
462                                }
463                            }
464                        }
465                    }
466                    for ocl in 0..ocg {
467                        let ob = ((oc0 + ocl) * h_out + oh) * w;
468                        out_n[ob..ob + w].copy_from_slice(&acc[ocl * w..ocl * w + w]);
469                    }
470                }
471            }
472        }
473    }
474
475    #[cfg(not(target_feature = "simd128"))]
476    #[inline(always)]
477    fn run_simd_generic(&self, x: &[f32], kernel: &[f32], bias: &[f32], out: &mut [f32]) {
478        self.run_generic(x, kernel, bias, out);
479    }
480}
481
482impl TypedOp for BlockedConv {
483    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
484        ensure!(inputs.len() == 3, "BlockedConv expects 3 inputs (X, kernel, bias)");
485        Ok(tvec!(f32::datum_type().fact([self.n, self.oc, self.h_out, self.w])))
486    }
487
488    fn cost(&self, _inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
489        let macs = self.n * self.oc * self.h_out * self.w * self.icg() * self.kh;
490        Ok(tvec!((Cost::FMA(f32::datum_type()), macs.to_dim())))
491    }
492
493    as_op!();
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    /// Independent scalar reference for the eligible conv class (NCHW, kw=1,
501    /// unit stride/dilation on W). `kernel` is `[oc, icg*kh]` (group-major,
502    /// i-major/h-minor); input channel for output `oc` is `(oc/ocg)*icg + icl`.
503    #[allow(clippy::too_many_arguments)]
504    fn reference(op: &BlockedConv, x: &[f32], kernel: &[f32], bias: &[f32]) -> Vec<f32> {
505        let (icg, ocg) = (op.icg(), op.ocg());
506        let (h_in, w, kh) = (op.h_in, op.w, op.kh);
507        let (sh, dh, pb) = (op.stride_h as isize, op.dil_h as isize, op.pad_before_h as isize);
508        let mut out = vec![0f32; op.n * op.oc * op.h_out * w];
509        for ni in 0..op.n {
510            for oc in 0..op.oc {
511                let g = oc / ocg;
512                for oh in 0..op.h_out {
513                    for wi in 0..w {
514                        let mut acc = bias[oc];
515                        for kh_i in 0..kh {
516                            let ih = oh as isize * sh + kh_i as isize * dh - pb;
517                            if ih < 0 || ih >= h_in as isize {
518                                continue;
519                            }
520                            let ih = ih as usize;
521                            for icl in 0..icg {
522                                let ic = g * icg + icl;
523                                let xv = x[((ni * op.c_in + ic) * h_in + ih) * w + wi];
524                                acc += xv * kernel[oc * (icg * kh) + icl * kh + kh_i];
525                            }
526                        }
527                        out[((ni * op.oc + oc) * op.h_out + oh) * w + wi] = acc;
528                    }
529                }
530            }
531        }
532        out
533    }
534
535    fn run_case(c_in: usize, oc: usize, group: usize, kh: usize, h_in: usize, w: usize, pb: usize) {
536        let icg = c_in / group;
537        let h_out = h_in + pb - (kh - 1); // stride=dil=1, pad_after=0
538        let op = BlockedConv {
539            n: 1,
540            c_in,
541            h_in,
542            w,
543            oc,
544            group,
545            kh,
546            stride_h: 1,
547            dil_h: 1,
548            pad_before_h: pb,
549            h_out,
550        };
551        let x: Vec<f32> = (0..c_in * h_in * w).map(|i| ((i as f32 * 0.137).sin()) * 0.7).collect();
552        let kernel: Vec<f32> =
553            (0..oc * icg * kh).map(|i| ((i as f32 * 0.091).cos()) * 0.3).collect();
554        let bias: Vec<f32> = (0..oc).map(|i| (i as f32 * 0.05) - 0.1).collect();
555
556        let want = reference(&op, &x, &kernel, &bias);
557        let got = op
558            .eval(
559                &EvalContext::out_of_plan(),
560                tvec![
561                    Tensor::from_shape(&[1, c_in, h_in, w], &x).unwrap().into_tvalue(),
562                    Tensor::from_shape(&[oc, icg * kh], &kernel).unwrap().into_tvalue(),
563                    Tensor::from_shape(&[oc], &bias).unwrap().into_tvalue(),
564                ],
565            )
566            .unwrap();
567        let got_view = got[0].to_plain_array_view::<f32>().unwrap();
568        let got = got_view.as_slice().unwrap();
569        assert_eq!(got.len(), want.len());
570        let max_abs = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max);
571        assert!(
572            max_abs < 1e-5,
573            "BlockedConv mismatch (c_in={c_in} oc={oc} g={group} kh={kh} h={h_in} w={w} pb={pb}): max_abs={max_abs}"
574        );
575    }
576
577    #[test]
578    fn blocked_conv_matches_reference() {
579        // df_convp.1-like: group=2, ocg=5, kh=5, causal pad, w multiple of WB.
580        run_case(64, 10, 2, 5, 12, 96, 4);
581        // full block + remainder (w=20 = 16 + 4), ocg=2.
582        run_case(4, 4, 2, 3, 5, 20, 1);
583        // remainder-only (w=5 < WB), ocg=3.
584        run_case(8, 6, 2, 4, 7, 5, 2);
585        // group=1, ocg=3, no padding.
586        run_case(6, 3, 1, 3, 8, 33, 0);
587        // ocg=1 edge.
588        run_case(4, 2, 2, 2, 6, 17, 1);
589        // ocg=7: outside the const-dispatched set, exercises the generic path.
590        run_case(14, 14, 2, 3, 6, 20, 1);
591    }
592}