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///
104/// NEXT TARGET, measured (`CMF_VAE_PROF=1`, 512×512, one RTX 5090):
105/// the decoder is 5.9–7.0 s of a 21 s image, and the three resnets at
106/// the 512×512 level are 2.73 s of it — 39% — with up2's another 1.4 s
107/// and `mid_attn` 0.69 s. All of that is this convolution, and on the
108/// device it runs `vae_conv`: ONE SCALAR KERNEL, no matrix units. At
109/// 512×512×128×128 a 3×3 conv is 77 GFLOP and takes ~0.35 s, which is
110/// about 220 GFLOP/s on a card that does two orders more in f16.
111///
112/// The fix is the one that took the audio vocoder 8.2 s → 4.3 s:
113/// im2col onto `gemm_nt_f32`, which is already public in the backend
114/// and runs on tensor cores. `out[oc, hw] = W · col`, and W is already
115/// `[oc, ic·k·k]` in the order this kernel reads it, so no repacking.
116///
117/// THE TRAP, and the reason this is a design note and not a patch: the
118/// column matrix does not fit. At 512×512 with ic=128, k=3 it is
119/// 1152 × 262144 floats — 1.2 GB, and that is one conv of six at that
120/// level. It has to be tiled over the pixel axis (≈32k pixels a tile
121/// keeps the buffer near 150 MB), with the tiles chained inside one
122/// submission so the panel never comes home between them. Size the
123/// tile from `CMF_GPU_VRAM_MB`, not from a constant.
124
125/// (pad = k/2). Parallel over output channels.
126pub struct Conv2d {
127    pub w: Vec<f32>, // [oc, ic, k, k]
128    pub b: Vec<f32>, // [oc]
129    pub oc: usize,
130    pub ic: usize,
131    pub k: usize,
132}
133
134impl Conv2d {
135    fn from(t: &StTensor, bias: &StTensor) -> Self {
136        let (oc, ic, k) = (t.shape[0], t.shape[1], t.shape[2]);
137        Self {
138            w: t.data.clone(),
139            b: bias.data.clone(),
140            oc,
141            ic,
142            k,
143        }
144    }
145
146    /// `x`: [ic, h, w] → [oc, h, w]. GPU first (implicit-GEMM Metal
147    /// kernel — no im2col matrix at all), gated to shapes where the
148    /// transfer is amortized; otherwise banded im2col + GEMM: bands of
149    /// output rows are lowered to a [rows·w, ic·k²] patch matrix and hit
150    /// `fcd_ops::gemm_nt` (Accelerate/AMX on macOS, the portable blocked
151    /// kernel elsewhere) — the band cap keeps the patch matrix ≤ ~128 MB
152    /// at any image size.
153    pub fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
154        debug_assert_eq!(x.len(), self.ic * h * w);
155        // The im2col matrix the CPU path materializes is h·w·ic·k²·4
156        // bytes — at 512×512 that is ≥2 GB per conv and IS the VAE
157        // wall. Threshold: only ship to the GPU when the implicit
158        // GEMM has real work (small early convs stay on the CPU).
159        if h * w * self.ic * self.oc >= 1 << 26 && crate::gpu::enabled_here() {
160            let mut out = vec![0f32; self.oc * h * w];
161            // MEASURED AND REJECTED, so opt-in (`CMF_VAE_CONV_COOP=1`):
162            // im2col on the card plus an NT GEMM per pixel tile loses to
163            // the scalar kernel below — VAE decode 7.5 s against 6.3,
164            // back to back on one binary. The arithmetic says why: the
165            // column matrix is 1.2 GB per conv at 512×512 and every byte
166            // of it is written and read again, while the scalar kernel
167            // reads the input in place and spends none of that band.
168            // im2col pays off where a GEMM is many times cheaper than
169            // the gather; here they are the same order.
170            //
171            // (An earlier "6.1 → 5.5 s" for this path was measured
172            // against a stale binary — the pod build had failed and the
173            // old one ran. Same-binary, back-to-back, or it means
174            // nothing.)
175            if std::env::var("CMF_VAE_CONV_COOP").as_deref() == Ok("1")
176                && crate::gpu::vae_conv2d_coop(
177                    &self.w,
178                    Some(&self.b[..]),
179                    x,
180                    self.ic,
181                    self.oc,
182                    h,
183                    w,
184                    self.k,
185                    &mut out,
186                )
187            {
188                return out;
189            }
190            if crate::gpu::vae_conv2d(
191                &self.w, &self.b, x, self.ic, self.oc, h, w, self.k, &mut out,
192            ) {
193                return out;
194            }
195        }
196        let pad = self.k / 2;
197        let ick2 = self.ic * self.k * self.k;
198        let mut out = vec![0f32; self.oc * h * w];
199        let band = (128 << 20) / (ick2 * w * 4).max(1);
200        let band = band.clamp(1, h);
201        let mut cols = vec![0f32; band * w * ick2];
202        let mut yt = vec![0f32; band * w * self.oc];
203        let mut y0 = 0usize;
204        while y0 < h {
205            let rows = band.min(h - y0);
206            let hw_band = rows * w;
207            // im2col: row p of `cols` = the receptive field of output
208            // position p (zero-padded at the borders).
209            for (dy, colrow) in cols[..hw_band * ick2].chunks_mut(w * ick2).enumerate() {
210                let y = y0 + dy;
211                for (xx, patch) in colrow.chunks_mut(ick2).enumerate() {
212                    let mut i = 0;
213                    for c in 0..self.ic {
214                        let img = &x[c * h * w..(c + 1) * h * w];
215                        for ky in 0..self.k {
216                            let sy = y as isize + ky as isize - pad as isize;
217                            for kx in 0..self.k {
218                                let sx = xx as isize + kx as isize - pad as isize;
219                                patch[i] =
220                                    if sy >= 0 && sy < h as isize && sx >= 0 && sx < w as isize {
221                                        img[sy as usize * w + sx as usize]
222                                    } else {
223                                        0.0
224                                    };
225                                i += 1;
226                            }
227                        }
228                    }
229                }
230            }
231            crate::fcd_ops::gemm_nt(
232                &cols[..hw_band * ick2],
233                &self.w,
234                &mut yt[..hw_band * self.oc],
235                hw_band,
236                ick2,
237                self.oc,
238                None,
239            );
240            // [hw, oc] → NCHW [oc, hw] + bias.
241            for o in 0..self.oc {
242                let b = self.b[o];
243                let dst = &mut out[o * h * w + y0 * w..][..hw_band];
244                for (p, d) in dst.iter_mut().enumerate() {
245                    *d = yt[p * self.oc + o] + b;
246                }
247            }
248            y0 += rows;
249        }
250        out
251    }
252}
253
254/// GroupNorm over channel groups (eps 1e-6, affine), NCHW in place.
255pub struct GroupNorm {
256    pub g: usize,
257    pub w: Vec<f32>,
258    pub b: Vec<f32>,
259}
260
261impl GroupNorm {
262    fn from(w: &StTensor, b: &StTensor, groups: usize) -> Self {
263        Self {
264            g: groups,
265            w: w.data.clone(),
266            b: b.data.clone(),
267        }
268    }
269
270    pub fn apply(&self, x: &mut [f32], h: usize, w: usize) {
271        let c = self.w.len();
272        let per = c / self.g;
273        let hw = h * w;
274        // One thread per group (32 groups saturate the cores; the pass
275        // is memory-bound, f64 accumulation kept for parity).
276        std::thread::scope(|s| {
277            for (gi, span) in x.chunks_mut(per * hw).enumerate() {
278                let (wref, bref) = (&self.w, &self.b);
279                s.spawn(move || {
280                    let n = span.len() as f64;
281                    let mean = span.iter().map(|&v| v as f64).sum::<f64>() / n;
282                    let var = span.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / n;
283                    let inv = 1.0 / (var + 1e-6).sqrt();
284                    for (ci, ch) in span.chunks_mut(hw).enumerate() {
285                        let cc = gi * per + ci;
286                        let (sw, sb) = (wref[cc], bref[cc]);
287                        for v in ch.iter_mut() {
288                            *v = ((*v as f64 - mean) * inv) as f32 * sw + sb;
289                        }
290                    }
291                });
292            }
293        });
294    }
295}
296
297fn silu(x: &mut [f32]) {
298    let nt = std::thread::available_parallelism()
299        .map(|n| n.get())
300        .unwrap_or(4);
301    let chunk = x.len().div_ceil(nt).max(1 << 14);
302    std::thread::scope(|s| {
303        for part in x.chunks_mut(chunk) {
304            s.spawn(move || {
305                for v in part.iter_mut() {
306                    *v /= 1.0 + (-*v).exp();
307                }
308            });
309        }
310    });
311}
312
313/// Nearest-neighbour ×2 upsample, NCHW.
314fn upsample2x(x: &[f32], c: usize, h: usize, w: usize) -> Vec<f32> {
315    let mut out = vec![0f32; c * 4 * h * w];
316    for ci in 0..c {
317        let src = &x[ci * h * w..(ci + 1) * h * w];
318        let dst = &mut out[ci * 4 * h * w..(ci + 1) * 4 * h * w];
319        for y in 0..2 * h {
320            for xx in 0..2 * w {
321                dst[y * 2 * w + xx] = src[(y / 2) * w + xx / 2];
322            }
323        }
324    }
325    out
326}
327
328struct ResnetBlock {
329    norm1: GroupNorm,
330    conv1: Conv2d,
331    norm2: GroupNorm,
332    conv2: Conv2d,
333    shortcut: Option<Conv2d>,
334}
335
336impl ResnetBlock {
337    fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
338        let (ic, oc) = (self.conv1.ic, self.conv1.oc);
339        // Whole block on the device when the convs have real work —
340        // one upload/download instead of 2–3 per conv, and the
341        // norm/silu glue never touches the CPU.
342        if h * w * ic * oc >= 1 << 26 && crate::gpu::enabled_here() {
343            let mut out = vec![0f32; oc * h * w];
344            let args = crate::gpu::VaeResnetArgs {
345                groups: self.norm1.g,
346                ic,
347                oc,
348                h,
349                w,
350                n1w: &self.norm1.w,
351                n1b: &self.norm1.b,
352                c1w: &self.conv1.w,
353                c1b: &self.conv1.b,
354                c1k: self.conv1.k,
355                n2w: &self.norm2.w,
356                n2b: &self.norm2.b,
357                c2w: &self.conv2.w,
358                c2b: &self.conv2.b,
359                c2k: self.conv2.k,
360                shortcut: self
361                    .shortcut
362                    .as_ref()
363                    .map(|s| (s.w.as_slice(), s.b.as_slice(), s.k)),
364            };
365            // The fused block keeps the tensor on the card across both
366            // convs, but its convs are the SCALAR kernel. When the
367            // matrix units are available the two convs are worth more
368            // than the round trip the fused block saves — measured, and
369            // `CMF_VAE_RESNET=fused` puts it back.
370            if std::env::var("CMF_VAE_RESNET").as_deref() != Ok("split")
371                && crate::gpu::vae_resnet(&args, x, &mut out)
372            {
373                return out;
374            }
375        }
376        let mut t = x.to_vec();
377        self.norm1.apply(&mut t, h, w);
378        silu(&mut t);
379        let mut t = self.conv1.apply(&t, h, w);
380        self.norm2.apply(&mut t, h, w);
381        silu(&mut t);
382        let t = self.conv2.apply(&t, h, w);
383        let skip = match &self.shortcut {
384            Some(sc) => sc.apply(x, h, w),
385            None => x.to_vec(),
386        };
387        skip.iter().zip(&t).map(|(a, b)| a + b).collect()
388    }
389}
390
391/// Single-head self-attention over the spatial grid (mid-block).
392struct AttnBlock {
393    norm: GroupNorm,
394    q: (Vec<f32>, Vec<f32>), // [c, c] weight (row-major out×in), bias
395    k: (Vec<f32>, Vec<f32>),
396    v: (Vec<f32>, Vec<f32>),
397    out: (Vec<f32>, Vec<f32>),
398    c: usize,
399}
400
401impl AttnBlock {
402    /// Token-major dense projection: `x` [hw, c] → [hw, c] via one
403    /// `gemm_nt` (weight rows are output channels), bias fused after.
404    fn proj(w: &[f32], b: &[f32], x: &[f32], hw: usize, c: usize) -> Vec<f32> {
405        let mut y = vec![0f32; hw * c];
406        crate::fcd_ops::gemm_nt(x, w, &mut y, hw, c, c, None);
407        for row in y.chunks_mut(c) {
408            for (v, bb) in row.iter_mut().zip(b) {
409                *v += bb;
410            }
411        }
412        y
413    }
414
415    fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
416        let (c, hw) = (self.c, h * w);
417        let mut n = x.to_vec();
418        self.norm.apply(&mut n, h, w);
419        // Channel-major → token-major once; everything below is GEMM.
420        let mut nt = vec![0f32; hw * c];
421        for ci in 0..c {
422            for p in 0..hw {
423                nt[p * c + ci] = n[ci * hw + p];
424            }
425        }
426        let mut q = Self::proj(&self.q.0, &self.q.1, &nt, hw, c);
427        let k = Self::proj(&self.k.0, &self.k.1, &nt, hw, c);
428        let v = Self::proj(&self.v.0, &self.v.1, &nt, hw, c);
429        let scale = 1.0 / (c as f32).sqrt();
430        // One head over the spatial grid, so q/k/v are ALREADY the
431        // head-major planes the device chain wants — `nh = 1` and the
432        // hidden size is the head dim. It keeps the hw×hw score plane
433        // on the card; at 64×64 that plane is 67 MB the host was
434        // materializing and walking twice. `CMF_VAE_ATTN=cpu` reverts.
435        if std::env::var("CMF_VAE_ATTN").as_deref() != Ok("cpu")
436            && crate::gpu::enabled_here()
437            && hw >= 256
438        {
439            let mut got = vec![0f32; hw * c];
440            if crate::gpu::dit_attention(&q, &k, &v, 1, 1, hw, c, scale, &mut got) {
441                return Self::finish(self, x, &got, h, w);
442            }
443        }
444        for qv in q.iter_mut() {
445            *qv *= scale;
446        }
447        // scores[i, j] = q_i · k_j; row softmax; out = attn · v.
448        let mut scores = vec![0f32; hw * hw];
449        crate::fcd_ops::gemm_nt(&q, &k, &mut scores, hw, c, hw, None);
450        for row in scores.chunks_mut(hw) {
451            let mx = row.iter().cloned().fold(f32::MIN, f32::max);
452            let mut den = 0f32;
453            for r in row.iter_mut() {
454                *r = (*r - mx).exp();
455                den += *r;
456            }
457            let inv = 1.0 / den;
458            for r in row.iter_mut() {
459                *r *= inv;
460            }
461        }
462        // gemm_nt wants the right operand transposed: v as [c, hw].
463        let mut vt = vec![0f32; c * hw];
464        for p in 0..hw {
465            for ci in 0..c {
466                vt[ci * hw + p] = v[p * c + ci];
467            }
468        }
469        let mut ot = vec![0f32; hw * c];
470        crate::fcd_ops::gemm_nt(&scores, &vt, &mut ot, hw, hw, c, None);
471        Self::finish(self, x, &ot, h, w)
472    }
473
474    /// Output projection, token-major → channel-major, residual. Shared
475    /// by the device arm and the host loop so the two cannot drift.
476    fn finish(&self, x: &[f32], ot: &[f32], h: usize, w: usize) -> Vec<f32> {
477        let (c, hw) = (self.c, h * w);
478        let o = Self::proj(&self.out.0, &self.out.1, ot, hw, c);
479        let mut y = x.to_vec();
480        for p in 0..hw {
481            for ci in 0..c {
482                y[ci * hw + p] += o[p * c + ci];
483            }
484        }
485        y
486    }
487}
488
489struct UpBlock {
490    resnets: Vec<ResnetBlock>,
491    upsample: Option<Conv2d>,
492}
493
494/// The full decoder: `conv_in → mid(res, attn, res) → up-blocks →
495/// GroupNorm + SiLU → conv_out`, plus the diffusers latent
496/// de-normalization `z/scaling_factor + shift_factor`.
497pub struct VaeDecoder {
498    conv_in: Conv2d,
499    mid_res1: ResnetBlock,
500    mid_attn: AttnBlock,
501    mid_res2: ResnetBlock,
502    ups: Vec<UpBlock>,
503    norm_out: GroupNorm,
504    conv_out: Conv2d,
505    pub latent_channels: usize,
506    pub scaling_factor: f32,
507    pub shift_factor: f32,
508}
509
510impl VaeDecoder {
511    /// Load from a diffusers `vae/` directory (config.json +
512    /// diffusion_pytorch_model.safetensors).
513    pub fn load_dir(dir: &Path) -> Result<Self, String> {
514        let cfg: serde_json::Value = serde_json::from_slice(
515            &std::fs::read(dir.join("config.json")).map_err(|e| format!("config.json: {e}"))?,
516        )
517        .map_err(|e| format!("config.json: {e}"))?;
518        let t = read_safetensors(&dir.join("diffusion_pytorch_model.safetensors"))?;
519        Self::from_tensors(t, &cfg)
520    }
521
522    /// Load from a packaged imagegen .cmf (`vae.*` tensors, stored
523    /// f16/f32, + `vae.config_json`).
524    pub fn from_cmf(model: &cortiq_core::CmfModel) -> Result<Self, String> {
525        let cfg: serde_json::Value = serde_json::from_slice(
526            model
527                .tensor_bytes("vae.config_json")
528                .map_err(|e| e.to_string())?,
529        )
530        .map_err(|e| format!("vae.config_json: {e}"))?;
531        let mut t = std::collections::HashMap::new();
532        for entry in model
533            .tensors
534            .iter()
535            .filter(|e| e.name.starts_with("vae.") && e.name != "vae.config_json")
536        {
537            let data = crate::dit::cmf_f32(model, &entry.name)?;
538            t.insert(
539                entry.name["vae.".len()..].to_string(),
540                StTensor {
541                    shape: entry.shape.clone(),
542                    data,
543                },
544            );
545        }
546        Self::from_tensors(t, &cfg)
547    }
548
549    fn from_tensors(
550        t: std::collections::HashMap<String, StTensor>,
551        cfg: &serde_json::Value,
552    ) -> Result<Self, String> {
553        let groups = cfg["norm_num_groups"].as_u64().unwrap_or(32) as usize;
554        let get = |n: &str| -> Result<&StTensor, String> {
555            t.get(n).ok_or_else(|| format!("missing tensor {n}"))
556        };
557        let conv = |n: &str| -> Result<Conv2d, String> {
558            Ok(Conv2d::from(
559                get(&format!("{n}.weight"))?,
560                get(&format!("{n}.bias"))?,
561            ))
562        };
563        let gnorm = |n: &str| -> Result<GroupNorm, String> {
564            Ok(GroupNorm::from(
565                get(&format!("{n}.weight"))?,
566                get(&format!("{n}.bias"))?,
567                groups,
568            ))
569        };
570        let resnet = |n: &str| -> Result<ResnetBlock, String> {
571            Ok(ResnetBlock {
572                norm1: gnorm(&format!("{n}.norm1"))?,
573                conv1: conv(&format!("{n}.conv1"))?,
574                norm2: gnorm(&format!("{n}.norm2"))?,
575                conv2: conv(&format!("{n}.conv2"))?,
576                shortcut: if t.contains_key(&format!("{n}.conv_shortcut.weight")) {
577                    Some(conv(&format!("{n}.conv_shortcut"))?)
578                } else {
579                    None
580                },
581            })
582        };
583        // Attention projections are [c, c] linears (diffusers stores
584        // to_q/... as Linear weights).
585        let lin = |n: &str| -> Result<(Vec<f32>, Vec<f32>), String> {
586            Ok((
587                get(&format!("{n}.weight"))?.data.clone(),
588                get(&format!("{n}.bias"))?.data.clone(),
589            ))
590        };
591        let attn_c = get("decoder.mid_block.attentions.0.to_q.weight")?.shape[0];
592        let mid_attn = AttnBlock {
593            norm: gnorm("decoder.mid_block.attentions.0.group_norm")?,
594            q: lin("decoder.mid_block.attentions.0.to_q")?,
595            k: lin("decoder.mid_block.attentions.0.to_k")?,
596            v: lin("decoder.mid_block.attentions.0.to_v")?,
597            out: lin("decoder.mid_block.attentions.0.to_out.0")?,
598            c: attn_c,
599        };
600        let mut ups = Vec::new();
601        for b in 0.. {
602            if !t.contains_key(&format!("decoder.up_blocks.{b}.resnets.0.conv1.weight")) {
603                break;
604            }
605            let mut resnets = Vec::new();
606            for r in 0.. {
607                let n = format!("decoder.up_blocks.{b}.resnets.{r}");
608                if !t.contains_key(&format!("{n}.conv1.weight")) {
609                    break;
610                }
611                resnets.push(resnet(&n)?);
612            }
613            let upsample =
614                if t.contains_key(&format!("decoder.up_blocks.{b}.upsamplers.0.conv.weight")) {
615                    Some(conv(&format!("decoder.up_blocks.{b}.upsamplers.0.conv"))?)
616                } else {
617                    None
618                };
619            ups.push(UpBlock { resnets, upsample });
620        }
621        Ok(Self {
622            conv_in: conv("decoder.conv_in")?,
623            mid_res1: resnet("decoder.mid_block.resnets.0")?,
624            mid_attn,
625            mid_res2: resnet("decoder.mid_block.resnets.1")?,
626            ups,
627            norm_out: gnorm("decoder.conv_norm_out")?,
628            conv_out: conv("decoder.conv_out")?,
629            latent_channels: cfg["latent_channels"].as_u64().unwrap_or(16) as usize,
630            scaling_factor: cfg["scaling_factor"].as_f64().unwrap_or(1.0) as f32,
631            shift_factor: cfg["shift_factor"].as_f64().unwrap_or(0.0) as f32,
632        })
633    }
634
635    /// Decode latents `[latent_channels, h, w]` (model scale, i.e. as
636    /// produced by the diffusion loop) into RGB `[3, 8h, 8w]` in [-1, 1].
637    pub fn decode(&self, z: &[f32], h: usize, w: usize) -> Vec<f32> {
638        let prof = std::env::var("CMF_VAE_PROF").is_ok();
639        macro_rules! stage {
640            ($name:expr, $e:expr) => {{
641                let t = std::time::Instant::now();
642                let r = $e;
643                if prof {
644                    eprintln!("vae {}: {:.2}s", $name, t.elapsed().as_secs_f64());
645                }
646                r
647            }};
648        }
649        let z: Vec<f32> = z
650            .iter()
651            .map(|&v| v / self.scaling_factor + self.shift_factor)
652            .collect();
653        let mut x = stage!("conv_in", self.conv_in.apply(&z, h, w));
654        x = stage!("mid_res1", self.mid_res1.apply(&x, h, w));
655        x = stage!("mid_attn", self.mid_attn.apply(&x, h, w));
656        x = stage!("mid_res2", self.mid_res2.apply(&x, h, w));
657        let (mut h, mut w) = (h, w);
658        for (ui, up) in self.ups.iter().enumerate() {
659            for (ri, r) in up.resnets.iter().enumerate() {
660                x = stage!(format!("up{ui}.res{ri} ({h}x{w})"), r.apply(&x, h, w));
661            }
662            if let Some(upc) = &up.upsample {
663                let c = upc.ic;
664                let (h2, w2) = (h * 2, w * 2);
665                x = stage!(format!("up{ui}.conv ({h2}x{w2})"), {
666                    // Fused device path: only the small pre-upsample
667                    // image is uploaded.
668                    let mut fused = None;
669                    if h2 * w2 * upc.ic * upc.oc >= 1 << 26 && crate::gpu::enabled_here() {
670                        let mut o = vec![0f32; upc.oc * h2 * w2];
671                        if crate::gpu::vae_upsample_conv(
672                            &upc.w, &upc.b, &x, upc.ic, upc.oc, h, w, upc.k, &mut o,
673                        ) {
674                            fused = Some(o);
675                        }
676                    }
677                    match fused {
678                        Some(o) => o,
679                        None => {
680                            let xu = upsample2x(&x, c, h, w);
681                            upc.apply(&xu, h2, w2)
682                        }
683                    }
684                });
685                h = h2;
686                w = w2;
687            }
688        }
689        self.norm_out.apply(&mut x, h, w);
690        silu(&mut x);
691        stage!("conv_out", self.conv_out.apply(&x, h, w))
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    /// conv2d against a hand-computed 3×3 case with padding.
700    #[test]
701    fn conv2d_matches_hand_reference() {
702        // 1 in-channel 3×3 image, 1 out-channel 3×3 kernel of ones,
703        // bias 0.5: each output = sum of the 3×3 neighbourhood + 0.5.
704        let c = Conv2d {
705            w: vec![1.0; 9],
706            b: vec![0.5],
707            oc: 1,
708            ic: 1,
709            k: 3,
710        };
711        let x = vec![1., 2., 3., 4., 5., 6., 7., 8., 9.];
712        let y = c.apply(&x, 3, 3);
713        // centre: 1+2+..+9 + 0.5 = 45.5; corner (0,0): 1+2+4+5 + 0.5 = 12.5.
714        assert_eq!(y[4], 45.5);
715        assert_eq!(y[0], 12.5);
716        assert_eq!(y[8], 5. + 6. + 8. + 9. + 0.5);
717    }
718
719    /// GroupNorm: two groups of one channel each — plain per-channel
720    /// standardization times affine.
721    #[test]
722    fn group_norm_standardizes() {
723        let gn = GroupNorm {
724            g: 2,
725            w: vec![2.0, 1.0],
726            b: vec![0.0, 3.0],
727        };
728        let mut x = vec![1., 3., 5., 7., 10., 10., 10., 10.];
729        gn.apply(&mut x, 2, 2);
730        // ch0: mean 4, std sqrt(5) → (1-4)/√5*2 …
731        let s = 5f64.sqrt();
732        assert!((x[0] as f64 - (-3.0 / s * 2.0)).abs() < 1e-5);
733        // ch1: constant → normalized 0 → bias 3.
734        assert!((x[4] - 3.0).abs() < 1e-4);
735    }
736
737    /// Upsample doubles both dimensions with nearest fill.
738    #[test]
739    fn upsample_nearest() {
740        let x = vec![1., 2., 3., 4.];
741        let y = upsample2x(&x, 1, 2, 2);
742        assert_eq!(
743            y,
744            vec![
745                1., 1., 2., 2., 1., 1., 2., 2., 3., 3., 4., 4., 3., 3., 4., 4.,
746            ]
747        );
748    }
749}