Skip to main content

cortiq_engine/
vae.rs

1//! FLUX-class VAE decoder (diffusers `AutoencoderKL`, the Lumina-Image
2//! 2.0 latent decoder): latents `[16, h, w]` → RGB `[3, 8h, 8w]`.
3//!
4//! First increment of the image-generation runtime (docs/GENERATIVE.ru.md):
5//! a plain-Rust NCHW decoder — conv2d, GroupNorm(32), SiLU, spatial
6//! self-attention, nearest ×2 upsampling — loaded straight from a
7//! diffusers `vae/` directory (config.json + safetensors). The CMF
8//! packaging comes with the Lumina converter; parity is pinned by a
9//! numpy reference (`python/vae_ref.py` + `tests/vae_parity.rs`).
10//!
11//! Convs run parallel over output channels via scoped threads — naive
12//! kernels, good enough to validate the pipeline end-to-end; the im2col
13//! + GEMM path rides later on the existing matmul kernels.
14
15use std::path::Path;
16
17// ── Minimal safetensors reader (f32/f16/bf16 → f32) ──────────────────
18
19/// One tensor from a .safetensors file, dequantized to f32.
20pub struct StTensor {
21    pub shape: Vec<usize>,
22    pub data: Vec<f32>,
23}
24
25/// Stream every tensor of one .safetensors file through `f` as f32,
26/// one at a time — a 9 GB shard costs one raw blob plus the single
27/// tensor in flight, not a second full-file f32 copy.
28pub fn read_safetensors_each(
29    path: &Path,
30    f: &mut dyn FnMut(&str, Vec<usize>, Vec<f32>) -> Result<(), String>,
31) -> Result<(), String> {
32    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
33    if bytes.len() < 8 {
34        return Err("safetensors: truncated header".into());
35    }
36    let hlen = u64::from_le_bytes(bytes[..8].try_into().unwrap()) as usize;
37    let header: serde_json::Value = serde_json::from_slice(&bytes[8..8 + hlen])
38        .map_err(|e| format!("safetensors header: {e}"))?;
39    let base = 8 + hlen;
40    let obj = header
41        .as_object()
42        .ok_or("safetensors: header not an object")?;
43    for (name, meta) in obj {
44        if name == "__metadata__" {
45            continue;
46        }
47        let dtype = meta["dtype"].as_str().ok_or("dtype")?;
48        let shape: Vec<usize> = meta["shape"]
49            .as_array()
50            .ok_or("shape")?
51            .iter()
52            .map(|v| v.as_u64().unwrap_or(0) as usize)
53            .collect();
54        let offs = meta["data_offsets"].as_array().ok_or("offsets")?;
55        let (s, e) = (
56            offs[0].as_u64().unwrap_or(0) as usize + base,
57            offs[1].as_u64().unwrap_or(0) as usize + base,
58        );
59        let raw = bytes.get(s..e).ok_or("safetensors: span out of file")?;
60        let n: usize = shape.iter().product::<usize>().max(1);
61        let mut data = Vec::with_capacity(n);
62        match dtype {
63            "F32" => {
64                for c in raw.chunks_exact(4) {
65                    data.push(f32::from_le_bytes(c.try_into().unwrap()));
66                }
67            }
68            "F16" => {
69                for c in raw.chunks_exact(2) {
70                    data.push(cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
71                        c.try_into().unwrap(),
72                    )));
73                }
74            }
75            "BF16" => {
76                for c in raw.chunks_exact(2) {
77                    let b = u16::from_le_bytes(c.try_into().unwrap());
78                    data.push(f32::from_bits((b as u32) << 16));
79                }
80            }
81            other => return Err(format!("safetensors: unsupported dtype {other}")),
82        }
83        f(name, shape, data)?;
84    }
85    Ok(())
86}
87
88/// All tensors of one .safetensors file, keyed by name.
89pub fn read_safetensors(
90    path: &Path,
91) -> Result<std::collections::HashMap<String, StTensor>, String> {
92    let mut out = std::collections::HashMap::new();
93    read_safetensors_each(path, &mut |name, shape, data| {
94        out.insert(name.to_string(), StTensor { shape, data });
95        Ok(())
96    })?;
97    Ok(out)
98}
99
100// ── NCHW ops ─────────────────────────────────────────────────────────
101
102/// 2-D convolution, stride 1, square kernel, symmetric padding
103/// (pad = k/2). Parallel over output channels.
104pub struct Conv2d {
105    pub w: Vec<f32>, // [oc, ic, k, k]
106    pub b: Vec<f32>, // [oc]
107    pub oc: usize,
108    pub ic: usize,
109    pub k: usize,
110}
111
112impl Conv2d {
113    fn from(t: &StTensor, bias: &StTensor) -> Self {
114        let (oc, ic, k) = (t.shape[0], t.shape[1], t.shape[2]);
115        Self {
116            w: t.data.clone(),
117            b: bias.data.clone(),
118            oc,
119            ic,
120            k,
121        }
122    }
123
124    /// `x`: [ic, h, w] → [oc, h, w]. GPU first (implicit-GEMM Metal
125    /// kernel — no im2col matrix at all), gated to shapes where the
126    /// transfer is amortized; otherwise banded im2col + GEMM: bands of
127    /// output rows are lowered to a [rows·w, ic·k²] patch matrix and hit
128    /// `fcd_ops::gemm_nt` (Accelerate/AMX on macOS, the portable blocked
129    /// kernel elsewhere) — the band cap keeps the patch matrix ≤ ~128 MB
130    /// at any image size.
131    pub fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
132        debug_assert_eq!(x.len(), self.ic * h * w);
133        // The im2col matrix the CPU path materializes is h·w·ic·k²·4
134        // bytes — at 512×512 that is ≥2 GB per conv and IS the VAE
135        // wall. Threshold: only ship to the GPU when the implicit
136        // GEMM has real work (small early convs stay on the CPU).
137        if h * w * self.ic * self.oc >= 1 << 26 && crate::gpu::enabled_here() {
138            let mut out = vec![0f32; self.oc * h * w];
139            if crate::gpu::vae_conv2d(
140                &self.w, &self.b, x, self.ic, self.oc, h, w, self.k, &mut out,
141            ) {
142                return out;
143            }
144        }
145        let pad = self.k / 2;
146        let ick2 = self.ic * self.k * self.k;
147        let mut out = vec![0f32; self.oc * h * w];
148        let band = (128 << 20) / (ick2 * w * 4).max(1);
149        let band = band.clamp(1, h);
150        let mut cols = vec![0f32; band * w * ick2];
151        let mut yt = vec![0f32; band * w * self.oc];
152        let mut y0 = 0usize;
153        while y0 < h {
154            let rows = band.min(h - y0);
155            let hw_band = rows * w;
156            // im2col: row p of `cols` = the receptive field of output
157            // position p (zero-padded at the borders).
158            for (dy, colrow) in cols[..hw_band * ick2].chunks_mut(w * ick2).enumerate() {
159                let y = y0 + dy;
160                for (xx, patch) in colrow.chunks_mut(ick2).enumerate() {
161                    let mut i = 0;
162                    for c in 0..self.ic {
163                        let img = &x[c * h * w..(c + 1) * h * w];
164                        for ky in 0..self.k {
165                            let sy = y as isize + ky as isize - pad as isize;
166                            for kx in 0..self.k {
167                                let sx = xx as isize + kx as isize - pad as isize;
168                                patch[i] =
169                                    if sy >= 0 && sy < h as isize && sx >= 0 && sx < w as isize {
170                                        img[sy as usize * w + sx as usize]
171                                    } else {
172                                        0.0
173                                    };
174                                i += 1;
175                            }
176                        }
177                    }
178                }
179            }
180            crate::fcd_ops::gemm_nt(
181                &cols[..hw_band * ick2],
182                &self.w,
183                &mut yt[..hw_band * self.oc],
184                hw_band,
185                ick2,
186                self.oc,
187                None,
188            );
189            // [hw, oc] → NCHW [oc, hw] + bias.
190            for o in 0..self.oc {
191                let b = self.b[o];
192                let dst = &mut out[o * h * w + y0 * w..][..hw_band];
193                for (p, d) in dst.iter_mut().enumerate() {
194                    *d = yt[p * self.oc + o] + b;
195                }
196            }
197            y0 += rows;
198        }
199        out
200    }
201}
202
203/// GroupNorm over channel groups (eps 1e-6, affine), NCHW in place.
204pub struct GroupNorm {
205    pub g: usize,
206    pub w: Vec<f32>,
207    pub b: Vec<f32>,
208}
209
210impl GroupNorm {
211    fn from(w: &StTensor, b: &StTensor, groups: usize) -> Self {
212        Self {
213            g: groups,
214            w: w.data.clone(),
215            b: b.data.clone(),
216        }
217    }
218
219    pub fn apply(&self, x: &mut [f32], h: usize, w: usize) {
220        let c = self.w.len();
221        let per = c / self.g;
222        let hw = h * w;
223        // One thread per group (32 groups saturate the cores; the pass
224        // is memory-bound, f64 accumulation kept for parity).
225        std::thread::scope(|s| {
226            for (gi, span) in x.chunks_mut(per * hw).enumerate() {
227                let (wref, bref) = (&self.w, &self.b);
228                s.spawn(move || {
229                    let n = span.len() as f64;
230                    let mean = span.iter().map(|&v| v as f64).sum::<f64>() / n;
231                    let var = span.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / n;
232                    let inv = 1.0 / (var + 1e-6).sqrt();
233                    for (ci, ch) in span.chunks_mut(hw).enumerate() {
234                        let cc = gi * per + ci;
235                        let (sw, sb) = (wref[cc], bref[cc]);
236                        for v in ch.iter_mut() {
237                            *v = ((*v as f64 - mean) * inv) as f32 * sw + sb;
238                        }
239                    }
240                });
241            }
242        });
243    }
244}
245
246fn silu(x: &mut [f32]) {
247    let nt = std::thread::available_parallelism()
248        .map(|n| n.get())
249        .unwrap_or(4);
250    let chunk = x.len().div_ceil(nt).max(1 << 14);
251    std::thread::scope(|s| {
252        for part in x.chunks_mut(chunk) {
253            s.spawn(move || {
254                for v in part.iter_mut() {
255                    *v /= 1.0 + (-*v).exp();
256                }
257            });
258        }
259    });
260}
261
262/// Nearest-neighbour ×2 upsample, NCHW.
263fn upsample2x(x: &[f32], c: usize, h: usize, w: usize) -> Vec<f32> {
264    let mut out = vec![0f32; c * 4 * h * w];
265    for ci in 0..c {
266        let src = &x[ci * h * w..(ci + 1) * h * w];
267        let dst = &mut out[ci * 4 * h * w..(ci + 1) * 4 * h * w];
268        for y in 0..2 * h {
269            for xx in 0..2 * w {
270                dst[y * 2 * w + xx] = src[(y / 2) * w + xx / 2];
271            }
272        }
273    }
274    out
275}
276
277struct ResnetBlock {
278    norm1: GroupNorm,
279    conv1: Conv2d,
280    norm2: GroupNorm,
281    conv2: Conv2d,
282    shortcut: Option<Conv2d>,
283}
284
285impl ResnetBlock {
286    fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
287        let (ic, oc) = (self.conv1.ic, self.conv1.oc);
288        // Whole block on the device when the convs have real work —
289        // one upload/download instead of 2–3 per conv, and the
290        // norm/silu glue never touches the CPU.
291        if h * w * ic * oc >= 1 << 26 && crate::gpu::enabled_here() {
292            let mut out = vec![0f32; oc * h * w];
293            let args = crate::gpu::VaeResnetArgs {
294                groups: self.norm1.g,
295                ic,
296                oc,
297                h,
298                w,
299                n1w: &self.norm1.w,
300                n1b: &self.norm1.b,
301                c1w: &self.conv1.w,
302                c1b: &self.conv1.b,
303                c1k: self.conv1.k,
304                n2w: &self.norm2.w,
305                n2b: &self.norm2.b,
306                c2w: &self.conv2.w,
307                c2b: &self.conv2.b,
308                c2k: self.conv2.k,
309                shortcut: self
310                    .shortcut
311                    .as_ref()
312                    .map(|s| (s.w.as_slice(), s.b.as_slice(), s.k)),
313            };
314            if crate::gpu::vae_resnet(&args, x, &mut out) {
315                return out;
316            }
317        }
318        let mut t = x.to_vec();
319        self.norm1.apply(&mut t, h, w);
320        silu(&mut t);
321        let mut t = self.conv1.apply(&t, h, w);
322        self.norm2.apply(&mut t, h, w);
323        silu(&mut t);
324        let t = self.conv2.apply(&t, h, w);
325        let skip = match &self.shortcut {
326            Some(sc) => sc.apply(x, h, w),
327            None => x.to_vec(),
328        };
329        skip.iter().zip(&t).map(|(a, b)| a + b).collect()
330    }
331}
332
333/// Single-head self-attention over the spatial grid (mid-block).
334struct AttnBlock {
335    norm: GroupNorm,
336    q: (Vec<f32>, Vec<f32>), // [c, c] weight (row-major out×in), bias
337    k: (Vec<f32>, Vec<f32>),
338    v: (Vec<f32>, Vec<f32>),
339    out: (Vec<f32>, Vec<f32>),
340    c: usize,
341}
342
343impl AttnBlock {
344    /// Token-major dense projection: `x` [hw, c] → [hw, c] via one
345    /// `gemm_nt` (weight rows are output channels), bias fused after.
346    fn proj(w: &[f32], b: &[f32], x: &[f32], hw: usize, c: usize) -> Vec<f32> {
347        let mut y = vec![0f32; hw * c];
348        crate::fcd_ops::gemm_nt(x, w, &mut y, hw, c, c, None);
349        for row in y.chunks_mut(c) {
350            for (v, bb) in row.iter_mut().zip(b) {
351                *v += bb;
352            }
353        }
354        y
355    }
356
357    fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
358        let (c, hw) = (self.c, h * w);
359        let mut n = x.to_vec();
360        self.norm.apply(&mut n, h, w);
361        // Channel-major → token-major once; everything below is GEMM.
362        let mut nt = vec![0f32; hw * c];
363        for ci in 0..c {
364            for p in 0..hw {
365                nt[p * c + ci] = n[ci * hw + p];
366            }
367        }
368        let mut q = Self::proj(&self.q.0, &self.q.1, &nt, hw, c);
369        let k = Self::proj(&self.k.0, &self.k.1, &nt, hw, c);
370        let v = Self::proj(&self.v.0, &self.v.1, &nt, hw, c);
371        let scale = 1.0 / (c as f32).sqrt();
372        for qv in q.iter_mut() {
373            *qv *= scale;
374        }
375        // scores[i, j] = q_i · k_j; row softmax; out = attn · v.
376        let mut scores = vec![0f32; hw * hw];
377        crate::fcd_ops::gemm_nt(&q, &k, &mut scores, hw, c, hw, None);
378        for row in scores.chunks_mut(hw) {
379            let mx = row.iter().cloned().fold(f32::MIN, f32::max);
380            let mut den = 0f32;
381            for r in row.iter_mut() {
382                *r = (*r - mx).exp();
383                den += *r;
384            }
385            let inv = 1.0 / den;
386            for r in row.iter_mut() {
387                *r *= inv;
388            }
389        }
390        // gemm_nt wants the right operand transposed: v as [c, hw].
391        let mut vt = vec![0f32; c * hw];
392        for p in 0..hw {
393            for ci in 0..c {
394                vt[ci * hw + p] = v[p * c + ci];
395            }
396        }
397        let mut ot = vec![0f32; hw * c];
398        crate::fcd_ops::gemm_nt(&scores, &vt, &mut ot, hw, hw, c, None);
399        let o = Self::proj(&self.out.0, &self.out.1, &ot, hw, c);
400        // Token-major → channel-major + residual.
401        let mut y = x.to_vec();
402        for p in 0..hw {
403            for ci in 0..c {
404                y[ci * hw + p] += o[p * c + ci];
405            }
406        }
407        y
408    }
409}
410
411struct UpBlock {
412    resnets: Vec<ResnetBlock>,
413    upsample: Option<Conv2d>,
414}
415
416/// The full decoder: `conv_in → mid(res, attn, res) → up-blocks →
417/// GroupNorm + SiLU → conv_out`, plus the diffusers latent
418/// de-normalization `z/scaling_factor + shift_factor`.
419pub struct VaeDecoder {
420    conv_in: Conv2d,
421    mid_res1: ResnetBlock,
422    mid_attn: AttnBlock,
423    mid_res2: ResnetBlock,
424    ups: Vec<UpBlock>,
425    norm_out: GroupNorm,
426    conv_out: Conv2d,
427    pub latent_channels: usize,
428    pub scaling_factor: f32,
429    pub shift_factor: f32,
430}
431
432impl VaeDecoder {
433    /// Load from a diffusers `vae/` directory (config.json +
434    /// diffusion_pytorch_model.safetensors).
435    pub fn load_dir(dir: &Path) -> Result<Self, String> {
436        let cfg: serde_json::Value = serde_json::from_slice(
437            &std::fs::read(dir.join("config.json")).map_err(|e| format!("config.json: {e}"))?,
438        )
439        .map_err(|e| format!("config.json: {e}"))?;
440        let t = read_safetensors(&dir.join("diffusion_pytorch_model.safetensors"))?;
441        Self::from_tensors(t, &cfg)
442    }
443
444    /// Load from a packaged imagegen .cmf (`vae.*` tensors, stored
445    /// f16/f32, + `vae.config_json`).
446    pub fn from_cmf(model: &cortiq_core::CmfModel) -> Result<Self, String> {
447        let cfg: serde_json::Value = serde_json::from_slice(
448            model
449                .tensor_bytes("vae.config_json")
450                .map_err(|e| e.to_string())?,
451        )
452        .map_err(|e| format!("vae.config_json: {e}"))?;
453        let mut t = std::collections::HashMap::new();
454        for entry in model
455            .tensors
456            .iter()
457            .filter(|e| e.name.starts_with("vae.") && e.name != "vae.config_json")
458        {
459            let data = crate::dit::cmf_f32(model, &entry.name)?;
460            t.insert(
461                entry.name["vae.".len()..].to_string(),
462                StTensor {
463                    shape: entry.shape.clone(),
464                    data,
465                },
466            );
467        }
468        Self::from_tensors(t, &cfg)
469    }
470
471    fn from_tensors(
472        t: std::collections::HashMap<String, StTensor>,
473        cfg: &serde_json::Value,
474    ) -> Result<Self, String> {
475        let groups = cfg["norm_num_groups"].as_u64().unwrap_or(32) as usize;
476        let get = |n: &str| -> Result<&StTensor, String> {
477            t.get(n).ok_or_else(|| format!("missing tensor {n}"))
478        };
479        let conv = |n: &str| -> Result<Conv2d, String> {
480            Ok(Conv2d::from(
481                get(&format!("{n}.weight"))?,
482                get(&format!("{n}.bias"))?,
483            ))
484        };
485        let gnorm = |n: &str| -> Result<GroupNorm, String> {
486            Ok(GroupNorm::from(
487                get(&format!("{n}.weight"))?,
488                get(&format!("{n}.bias"))?,
489                groups,
490            ))
491        };
492        let resnet = |n: &str| -> Result<ResnetBlock, String> {
493            Ok(ResnetBlock {
494                norm1: gnorm(&format!("{n}.norm1"))?,
495                conv1: conv(&format!("{n}.conv1"))?,
496                norm2: gnorm(&format!("{n}.norm2"))?,
497                conv2: conv(&format!("{n}.conv2"))?,
498                shortcut: if t.contains_key(&format!("{n}.conv_shortcut.weight")) {
499                    Some(conv(&format!("{n}.conv_shortcut"))?)
500                } else {
501                    None
502                },
503            })
504        };
505        // Attention projections are [c, c] linears (diffusers stores
506        // to_q/... as Linear weights).
507        let lin = |n: &str| -> Result<(Vec<f32>, Vec<f32>), String> {
508            Ok((
509                get(&format!("{n}.weight"))?.data.clone(),
510                get(&format!("{n}.bias"))?.data.clone(),
511            ))
512        };
513        let attn_c = get("decoder.mid_block.attentions.0.to_q.weight")?.shape[0];
514        let mid_attn = AttnBlock {
515            norm: gnorm("decoder.mid_block.attentions.0.group_norm")?,
516            q: lin("decoder.mid_block.attentions.0.to_q")?,
517            k: lin("decoder.mid_block.attentions.0.to_k")?,
518            v: lin("decoder.mid_block.attentions.0.to_v")?,
519            out: lin("decoder.mid_block.attentions.0.to_out.0")?,
520            c: attn_c,
521        };
522        let mut ups = Vec::new();
523        for b in 0.. {
524            if !t.contains_key(&format!("decoder.up_blocks.{b}.resnets.0.conv1.weight")) {
525                break;
526            }
527            let mut resnets = Vec::new();
528            for r in 0.. {
529                let n = format!("decoder.up_blocks.{b}.resnets.{r}");
530                if !t.contains_key(&format!("{n}.conv1.weight")) {
531                    break;
532                }
533                resnets.push(resnet(&n)?);
534            }
535            let upsample =
536                if t.contains_key(&format!("decoder.up_blocks.{b}.upsamplers.0.conv.weight")) {
537                    Some(conv(&format!("decoder.up_blocks.{b}.upsamplers.0.conv"))?)
538                } else {
539                    None
540                };
541            ups.push(UpBlock { resnets, upsample });
542        }
543        Ok(Self {
544            conv_in: conv("decoder.conv_in")?,
545            mid_res1: resnet("decoder.mid_block.resnets.0")?,
546            mid_attn,
547            mid_res2: resnet("decoder.mid_block.resnets.1")?,
548            ups,
549            norm_out: gnorm("decoder.conv_norm_out")?,
550            conv_out: conv("decoder.conv_out")?,
551            latent_channels: cfg["latent_channels"].as_u64().unwrap_or(16) as usize,
552            scaling_factor: cfg["scaling_factor"].as_f64().unwrap_or(1.0) as f32,
553            shift_factor: cfg["shift_factor"].as_f64().unwrap_or(0.0) as f32,
554        })
555    }
556
557    /// Decode latents `[latent_channels, h, w]` (model scale, i.e. as
558    /// produced by the diffusion loop) into RGB `[3, 8h, 8w]` in [-1, 1].
559    pub fn decode(&self, z: &[f32], h: usize, w: usize) -> Vec<f32> {
560        let prof = std::env::var("CMF_VAE_PROF").is_ok();
561        macro_rules! stage {
562            ($name:expr, $e:expr) => {{
563                let t = std::time::Instant::now();
564                let r = $e;
565                if prof {
566                    eprintln!("vae {}: {:.2}s", $name, t.elapsed().as_secs_f64());
567                }
568                r
569            }};
570        }
571        let z: Vec<f32> = z
572            .iter()
573            .map(|&v| v / self.scaling_factor + self.shift_factor)
574            .collect();
575        let mut x = stage!("conv_in", self.conv_in.apply(&z, h, w));
576        x = stage!("mid_res1", self.mid_res1.apply(&x, h, w));
577        x = stage!("mid_attn", self.mid_attn.apply(&x, h, w));
578        x = stage!("mid_res2", self.mid_res2.apply(&x, h, w));
579        let (mut h, mut w) = (h, w);
580        for (ui, up) in self.ups.iter().enumerate() {
581            for (ri, r) in up.resnets.iter().enumerate() {
582                x = stage!(format!("up{ui}.res{ri} ({h}x{w})"), r.apply(&x, h, w));
583            }
584            if let Some(upc) = &up.upsample {
585                let c = upc.ic;
586                let (h2, w2) = (h * 2, w * 2);
587                x = stage!(format!("up{ui}.conv ({h2}x{w2})"), {
588                    // Fused device path: only the small pre-upsample
589                    // image is uploaded.
590                    let mut fused = None;
591                    if h2 * w2 * upc.ic * upc.oc >= 1 << 26 && crate::gpu::enabled_here() {
592                        let mut o = vec![0f32; upc.oc * h2 * w2];
593                        if crate::gpu::vae_upsample_conv(
594                            &upc.w, &upc.b, &x, upc.ic, upc.oc, h, w, upc.k, &mut o,
595                        ) {
596                            fused = Some(o);
597                        }
598                    }
599                    match fused {
600                        Some(o) => o,
601                        None => {
602                            let xu = upsample2x(&x, c, h, w);
603                            upc.apply(&xu, h2, w2)
604                        }
605                    }
606                });
607                h = h2;
608                w = w2;
609            }
610        }
611        self.norm_out.apply(&mut x, h, w);
612        silu(&mut x);
613        stage!("conv_out", self.conv_out.apply(&x, h, w))
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    /// conv2d against a hand-computed 3×3 case with padding.
622    #[test]
623    fn conv2d_matches_hand_reference() {
624        // 1 in-channel 3×3 image, 1 out-channel 3×3 kernel of ones,
625        // bias 0.5: each output = sum of the 3×3 neighbourhood + 0.5.
626        let c = Conv2d {
627            w: vec![1.0; 9],
628            b: vec![0.5],
629            oc: 1,
630            ic: 1,
631            k: 3,
632        };
633        let x = vec![1., 2., 3., 4., 5., 6., 7., 8., 9.];
634        let y = c.apply(&x, 3, 3);
635        // centre: 1+2+..+9 + 0.5 = 45.5; corner (0,0): 1+2+4+5 + 0.5 = 12.5.
636        assert_eq!(y[4], 45.5);
637        assert_eq!(y[0], 12.5);
638        assert_eq!(y[8], 5. + 6. + 8. + 9. + 0.5);
639    }
640
641    /// GroupNorm: two groups of one channel each — plain per-channel
642    /// standardization times affine.
643    #[test]
644    fn group_norm_standardizes() {
645        let gn = GroupNorm {
646            g: 2,
647            w: vec![2.0, 1.0],
648            b: vec![0.0, 3.0],
649        };
650        let mut x = vec![1., 3., 5., 7., 10., 10., 10., 10.];
651        gn.apply(&mut x, 2, 2);
652        // ch0: mean 4, std sqrt(5) → (1-4)/√5*2 …
653        let s = 5f64.sqrt();
654        assert!((x[0] as f64 - (-3.0 / s * 2.0)).abs() < 1e-5);
655        // ch1: constant → normalized 0 → bias 3.
656        assert!((x[4] - 3.0).abs() < 1e-4);
657    }
658
659    /// Upsample doubles both dimensions with nearest fill.
660    #[test]
661    fn upsample_nearest() {
662        let x = vec![1., 2., 3., 4.];
663        let y = upsample2x(&x, 1, 2, 2);
664        assert_eq!(
665            y,
666            vec![
667                1., 1., 2., 2., 1., 1., 2., 2., 3., 3., 4., 4., 3., 3., 4., 4.,
668            ]
669        );
670    }
671}