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    /// Process-unique id of this loaded decoder (`VaeChainArgs::key`).
509    uid: u64,
510}
511
512// ── Device-chain view of the decoder (Z-Image WP0, additive) ─────────
513
514/// One conv of the decoder as the device chain sees it: weight
515/// `[oc, ic, k, k]` row-major f32, bias `[oc]`, stride 1, pad k/2.
516#[derive(Clone, Copy)]
517pub struct VaeConvRef<'a> {
518    pub w: &'a [f32],
519    pub b: &'a [f32],
520    pub oc: usize,
521    pub ic: usize,
522    pub k: usize,
523}
524
525/// GroupNorm (eps 1e-6, affine) weight/bias `[c]` over `groups` groups.
526#[derive(Clone, Copy)]
527pub struct VaeNormRef<'a> {
528    pub w: &'a [f32],
529    pub b: &'a [f32],
530    pub groups: usize,
531}
532
533/// One resnet: `x + conv2(silu(norm2(conv1(silu(norm1(x))))))`, with the
534/// skip through the 1×1 `shortcut` when in/out channels differ.
535#[derive(Clone, Copy)]
536pub struct VaeResnetRef<'a> {
537    pub norm1: VaeNormRef<'a>,
538    pub conv1: VaeConvRef<'a>,
539    pub norm2: VaeNormRef<'a>,
540    pub conv2: VaeConvRef<'a>,
541    pub shortcut: Option<VaeConvRef<'a>>,
542}
543
544/// The mid-block single-head spatial attention: GroupNorm, then token-major
545/// q/k/v Linear `[c, c]` (row-major out×in) + bias, softmax(q·kᵀ/√c)·v,
546/// `out` Linear + bias, residual add onto the block input.
547#[derive(Clone, Copy)]
548pub struct VaeAttnRef<'a> {
549    pub norm: VaeNormRef<'a>,
550    pub q: (&'a [f32], &'a [f32]),
551    pub k: (&'a [f32], &'a [f32]),
552    pub v: (&'a [f32], &'a [f32]),
553    pub out: (&'a [f32], &'a [f32]),
554    pub c: usize,
555}
556
557/// One up block: its resnets in order, then (if present) nearest-2×
558/// upsample followed by `upsample` conv.
559pub struct VaeUpRef<'a> {
560    pub resnets: Vec<VaeResnetRef<'a>>,
561    pub upsample: Option<VaeConvRef<'a>>,
562}
563
564/// The whole decoder as borrowed slices, in execution order:
565/// `conv_in → mid_res1 → mid_attn → mid_res2 → ups[0..] → norm_out+SiLU →
566/// conv_out`. Consumed by `gpu::vae_decode_chain`; `key` is stable for the
567/// life of the `VaeDecoder`, so a backend caches its uploaded weights by it
568/// instead of fingerprinting every conv on every call.
569pub struct VaeChainArgs<'a> {
570    pub key: u64,
571    pub conv_in: VaeConvRef<'a>,
572    pub mid_res1: VaeResnetRef<'a>,
573    pub mid_attn: VaeAttnRef<'a>,
574    pub mid_res2: VaeResnetRef<'a>,
575    pub ups: Vec<VaeUpRef<'a>>,
576    pub norm_out: VaeNormRef<'a>,
577    pub conv_out: VaeConvRef<'a>,
578    pub latent_channels: usize,
579    pub scaling_factor: f32,
580    pub shift_factor: f32,
581}
582
583impl Conv2d {
584    fn chain_ref(&self) -> VaeConvRef<'_> {
585        VaeConvRef {
586            w: &self.w,
587            b: &self.b,
588            oc: self.oc,
589            ic: self.ic,
590            k: self.k,
591        }
592    }
593}
594
595impl GroupNorm {
596    fn chain_ref(&self) -> VaeNormRef<'_> {
597        VaeNormRef {
598            w: &self.w,
599            b: &self.b,
600            groups: self.g,
601        }
602    }
603}
604
605impl ResnetBlock {
606    fn chain_ref(&self) -> VaeResnetRef<'_> {
607        VaeResnetRef {
608            norm1: self.norm1.chain_ref(),
609            conv1: self.conv1.chain_ref(),
610            norm2: self.norm2.chain_ref(),
611            conv2: self.conv2.chain_ref(),
612            shortcut: self.shortcut.as_ref().map(Conv2d::chain_ref),
613        }
614    }
615}
616
617static VAE_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
618
619impl VaeDecoder {
620    /// The borrowed device-chain view (see `VaeChainArgs`).
621    pub fn chain_args(&self) -> VaeChainArgs<'_> {
622        let a = &self.mid_attn;
623        VaeChainArgs {
624            key: self.uid,
625            conv_in: self.conv_in.chain_ref(),
626            mid_res1: self.mid_res1.chain_ref(),
627            mid_attn: VaeAttnRef {
628                norm: a.norm.chain_ref(),
629                q: (&a.q.0, &a.q.1),
630                k: (&a.k.0, &a.k.1),
631                v: (&a.v.0, &a.v.1),
632                out: (&a.out.0, &a.out.1),
633                c: a.c,
634            },
635            mid_res2: self.mid_res2.chain_ref(),
636            ups: self
637                .ups
638                .iter()
639                .map(|u| VaeUpRef {
640                    resnets: u.resnets.iter().map(ResnetBlock::chain_ref).collect(),
641                    upsample: u.upsample.as_ref().map(Conv2d::chain_ref),
642                })
643                .collect(),
644            norm_out: self.norm_out.chain_ref(),
645            conv_out: self.conv_out.chain_ref(),
646            latent_channels: self.latent_channels,
647            scaling_factor: self.scaling_factor,
648            shift_factor: self.shift_factor,
649        }
650    }
651
652    /// `decode` with the resident device chain first: de-normalise on the
653    /// host, try `gpu::vae_decode_chain`, and fall back to `decode` (the
654    /// unchanged Lumina path) when the backend declines. Same contract as
655    /// `decode`: model-scale latents in, RGB `[3, 8h, 8w]` in ≈[-1, 1] out.
656    pub fn decode_fast(&self, z: &[f32], h: usize, w: usize) -> Vec<f32> {
657        if crate::gpu::enabled_here() {
658            let zin: Vec<f32> = z
659                .iter()
660                .map(|&v| v / self.scaling_factor + self.shift_factor)
661                .collect();
662            let mut out = vec![0f32; 3 * 64 * h * w];
663            if crate::gpu::vae_decode_chain(&self.chain_args(), &zin, h, w, &mut out) {
664                return out;
665            }
666        }
667        self.decode(z, h, w)
668    }
669}
670
671impl VaeDecoder {
672    /// Load from a diffusers `vae/` directory (config.json +
673    /// diffusion_pytorch_model.safetensors).
674    pub fn load_dir(dir: &Path) -> Result<Self, String> {
675        let cfg: serde_json::Value = serde_json::from_slice(
676            &std::fs::read(dir.join("config.json")).map_err(|e| format!("config.json: {e}"))?,
677        )
678        .map_err(|e| format!("config.json: {e}"))?;
679        let t = read_safetensors(&dir.join("diffusion_pytorch_model.safetensors"))?;
680        Self::from_tensors(t, &cfg)
681    }
682
683    /// Load from a packaged imagegen .cmf (`vae.*` tensors, stored
684    /// f16/f32, + `vae.config_json`).
685    pub fn from_cmf(model: &cortiq_core::CmfModel) -> Result<Self, String> {
686        let cfg: serde_json::Value = serde_json::from_slice(
687            model
688                .tensor_bytes("vae.config_json")
689                .map_err(|e| e.to_string())?,
690        )
691        .map_err(|e| format!("vae.config_json: {e}"))?;
692        let mut t = std::collections::HashMap::new();
693        for entry in model
694            .tensors
695            .iter()
696            .filter(|e| e.name.starts_with("vae.") && e.name != "vae.config_json")
697        {
698            let data = crate::dit::cmf_f32(model, &entry.name)?;
699            t.insert(
700                entry.name["vae.".len()..].to_string(),
701                StTensor {
702                    shape: entry.shape.clone(),
703                    data,
704                },
705            );
706        }
707        Self::from_tensors(t, &cfg)
708    }
709
710    fn from_tensors(
711        t: std::collections::HashMap<String, StTensor>,
712        cfg: &serde_json::Value,
713    ) -> Result<Self, String> {
714        let groups = cfg["norm_num_groups"].as_u64().unwrap_or(32) as usize;
715        let get = |n: &str| -> Result<&StTensor, String> {
716            t.get(n).ok_or_else(|| format!("missing tensor {n}"))
717        };
718        let conv = |n: &str| -> Result<Conv2d, String> {
719            Ok(Conv2d::from(
720                get(&format!("{n}.weight"))?,
721                get(&format!("{n}.bias"))?,
722            ))
723        };
724        let gnorm = |n: &str| -> Result<GroupNorm, String> {
725            Ok(GroupNorm::from(
726                get(&format!("{n}.weight"))?,
727                get(&format!("{n}.bias"))?,
728                groups,
729            ))
730        };
731        let resnet = |n: &str| -> Result<ResnetBlock, String> {
732            Ok(ResnetBlock {
733                norm1: gnorm(&format!("{n}.norm1"))?,
734                conv1: conv(&format!("{n}.conv1"))?,
735                norm2: gnorm(&format!("{n}.norm2"))?,
736                conv2: conv(&format!("{n}.conv2"))?,
737                shortcut: if t.contains_key(&format!("{n}.conv_shortcut.weight")) {
738                    Some(conv(&format!("{n}.conv_shortcut"))?)
739                } else {
740                    None
741                },
742            })
743        };
744        // Attention projections are [c, c] linears (diffusers stores
745        // to_q/... as Linear weights).
746        let lin = |n: &str| -> Result<(Vec<f32>, Vec<f32>), String> {
747            Ok((
748                get(&format!("{n}.weight"))?.data.clone(),
749                get(&format!("{n}.bias"))?.data.clone(),
750            ))
751        };
752        let attn_c = get("decoder.mid_block.attentions.0.to_q.weight")?.shape[0];
753        let mid_attn = AttnBlock {
754            norm: gnorm("decoder.mid_block.attentions.0.group_norm")?,
755            q: lin("decoder.mid_block.attentions.0.to_q")?,
756            k: lin("decoder.mid_block.attentions.0.to_k")?,
757            v: lin("decoder.mid_block.attentions.0.to_v")?,
758            out: lin("decoder.mid_block.attentions.0.to_out.0")?,
759            c: attn_c,
760        };
761        let mut ups = Vec::new();
762        for b in 0.. {
763            if !t.contains_key(&format!("decoder.up_blocks.{b}.resnets.0.conv1.weight")) {
764                break;
765            }
766            let mut resnets = Vec::new();
767            for r in 0.. {
768                let n = format!("decoder.up_blocks.{b}.resnets.{r}");
769                if !t.contains_key(&format!("{n}.conv1.weight")) {
770                    break;
771                }
772                resnets.push(resnet(&n)?);
773            }
774            let upsample =
775                if t.contains_key(&format!("decoder.up_blocks.{b}.upsamplers.0.conv.weight")) {
776                    Some(conv(&format!("decoder.up_blocks.{b}.upsamplers.0.conv"))?)
777                } else {
778                    None
779                };
780            ups.push(UpBlock { resnets, upsample });
781        }
782        Ok(Self {
783            conv_in: conv("decoder.conv_in")?,
784            mid_res1: resnet("decoder.mid_block.resnets.0")?,
785            mid_attn,
786            mid_res2: resnet("decoder.mid_block.resnets.1")?,
787            ups,
788            norm_out: gnorm("decoder.conv_norm_out")?,
789            conv_out: conv("decoder.conv_out")?,
790            latent_channels: cfg["latent_channels"].as_u64().unwrap_or(16) as usize,
791            scaling_factor: cfg["scaling_factor"].as_f64().unwrap_or(1.0) as f32,
792            shift_factor: cfg["shift_factor"].as_f64().unwrap_or(0.0) as f32,
793            uid: VAE_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
794        })
795    }
796
797    /// Decode latents `[latent_channels, h, w]` (model scale, i.e. as
798    /// produced by the diffusion loop) into RGB `[3, 8h, 8w]` in [-1, 1].
799    pub fn decode(&self, z: &[f32], h: usize, w: usize) -> Vec<f32> {
800        let prof = std::env::var("CMF_VAE_PROF").is_ok();
801        macro_rules! stage {
802            ($name:expr, $e:expr) => {{
803                let t = std::time::Instant::now();
804                let r = $e;
805                if prof {
806                    eprintln!("vae {}: {:.2}s", $name, t.elapsed().as_secs_f64());
807                }
808                r
809            }};
810        }
811        let z: Vec<f32> = z
812            .iter()
813            .map(|&v| v / self.scaling_factor + self.shift_factor)
814            .collect();
815        let mut x = stage!("conv_in", self.conv_in.apply(&z, h, w));
816        x = stage!("mid_res1", self.mid_res1.apply(&x, h, w));
817        x = stage!("mid_attn", self.mid_attn.apply(&x, h, w));
818        x = stage!("mid_res2", self.mid_res2.apply(&x, h, w));
819        let (mut h, mut w) = (h, w);
820        for (ui, up) in self.ups.iter().enumerate() {
821            for (ri, r) in up.resnets.iter().enumerate() {
822                x = stage!(format!("up{ui}.res{ri} ({h}x{w})"), r.apply(&x, h, w));
823            }
824            if let Some(upc) = &up.upsample {
825                let c = upc.ic;
826                let (h2, w2) = (h * 2, w * 2);
827                x = stage!(format!("up{ui}.conv ({h2}x{w2})"), {
828                    // Fused device path: only the small pre-upsample
829                    // image is uploaded.
830                    let mut fused = None;
831                    if h2 * w2 * upc.ic * upc.oc >= 1 << 26 && crate::gpu::enabled_here() {
832                        let mut o = vec![0f32; upc.oc * h2 * w2];
833                        if crate::gpu::vae_upsample_conv(
834                            &upc.w, &upc.b, &x, upc.ic, upc.oc, h, w, upc.k, &mut o,
835                        ) {
836                            fused = Some(o);
837                        }
838                    }
839                    match fused {
840                        Some(o) => o,
841                        None => {
842                            let xu = upsample2x(&x, c, h, w);
843                            upc.apply(&xu, h2, w2)
844                        }
845                    }
846                });
847                h = h2;
848                w = w2;
849            }
850        }
851        self.norm_out.apply(&mut x, h, w);
852        silu(&mut x);
853        stage!("conv_out", self.conv_out.apply(&x, h, w))
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    /// conv2d against a hand-computed 3×3 case with padding.
862    #[test]
863    fn conv2d_matches_hand_reference() {
864        // 1 in-channel 3×3 image, 1 out-channel 3×3 kernel of ones,
865        // bias 0.5: each output = sum of the 3×3 neighbourhood + 0.5.
866        let c = Conv2d {
867            w: vec![1.0; 9],
868            b: vec![0.5],
869            oc: 1,
870            ic: 1,
871            k: 3,
872        };
873        let x = vec![1., 2., 3., 4., 5., 6., 7., 8., 9.];
874        let y = c.apply(&x, 3, 3);
875        // centre: 1+2+..+9 + 0.5 = 45.5; corner (0,0): 1+2+4+5 + 0.5 = 12.5.
876        assert_eq!(y[4], 45.5);
877        assert_eq!(y[0], 12.5);
878        assert_eq!(y[8], 5. + 6. + 8. + 9. + 0.5);
879    }
880
881    /// GroupNorm: two groups of one channel each — plain per-channel
882    /// standardization times affine.
883    #[test]
884    fn group_norm_standardizes() {
885        let gn = GroupNorm {
886            g: 2,
887            w: vec![2.0, 1.0],
888            b: vec![0.0, 3.0],
889        };
890        let mut x = vec![1., 3., 5., 7., 10., 10., 10., 10.];
891        gn.apply(&mut x, 2, 2);
892        // ch0: mean 4, std sqrt(5) → (1-4)/√5*2 …
893        let s = 5f64.sqrt();
894        assert!((x[0] as f64 - (-3.0 / s * 2.0)).abs() < 1e-5);
895        // ch1: constant → normalized 0 → bias 3.
896        assert!((x[4] - 3.0).abs() < 1e-4);
897    }
898
899    /// Upsample doubles both dimensions with nearest fill.
900    #[test]
901    fn upsample_nearest() {
902        let x = vec![1., 2., 3., 4.];
903        let y = upsample2x(&x, 1, 2, 2);
904        assert_eq!(
905            y,
906            vec![
907                1., 1., 2., 2., 1., 1., 2., 2., 3., 3., 4., 4., 3., 3., 4., 4.,
908            ]
909        );
910    }
911}