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]. Banded im2col + GEMM: bands of
125    /// output rows are lowered to a [rows·w, ic·k²] patch matrix and hit
126    /// `fcd_ops::gemm_nt` (Accelerate/AMX on macOS, the portable blocked
127    /// kernel elsewhere) — the band cap keeps the patch matrix ≤ ~128 MB
128    /// at any image size.
129    pub fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
130        debug_assert_eq!(x.len(), self.ic * h * w);
131        let pad = self.k / 2;
132        let ick2 = self.ic * self.k * self.k;
133        let mut out = vec![0f32; self.oc * h * w];
134        let band = (128 << 20) / (ick2 * w * 4).max(1);
135        let band = band.clamp(1, h);
136        let mut cols = vec![0f32; band * w * ick2];
137        let mut yt = vec![0f32; band * w * self.oc];
138        let mut y0 = 0usize;
139        while y0 < h {
140            let rows = band.min(h - y0);
141            let hw_band = rows * w;
142            // im2col: row p of `cols` = the receptive field of output
143            // position p (zero-padded at the borders).
144            for (dy, colrow) in cols[..hw_band * ick2].chunks_mut(w * ick2).enumerate() {
145                let y = y0 + dy;
146                for (xx, patch) in colrow.chunks_mut(ick2).enumerate() {
147                    let mut i = 0;
148                    for c in 0..self.ic {
149                        let img = &x[c * h * w..(c + 1) * h * w];
150                        for ky in 0..self.k {
151                            let sy = y as isize + ky as isize - pad as isize;
152                            for kx in 0..self.k {
153                                let sx = xx as isize + kx as isize - pad as isize;
154                                patch[i] =
155                                    if sy >= 0 && sy < h as isize && sx >= 0 && sx < w as isize {
156                                        img[sy as usize * w + sx as usize]
157                                    } else {
158                                        0.0
159                                    };
160                                i += 1;
161                            }
162                        }
163                    }
164                }
165            }
166            crate::fcd_ops::gemm_nt(
167                &cols[..hw_band * ick2],
168                &self.w,
169                &mut yt[..hw_band * self.oc],
170                hw_band,
171                ick2,
172                self.oc,
173                None,
174            );
175            // [hw, oc] → NCHW [oc, hw] + bias.
176            for o in 0..self.oc {
177                let b = self.b[o];
178                let dst = &mut out[o * h * w + y0 * w..][..hw_band];
179                for (p, d) in dst.iter_mut().enumerate() {
180                    *d = yt[p * self.oc + o] + b;
181                }
182            }
183            y0 += rows;
184        }
185        out
186    }
187}
188
189/// GroupNorm over channel groups (eps 1e-6, affine), NCHW in place.
190pub struct GroupNorm {
191    pub g: usize,
192    pub w: Vec<f32>,
193    pub b: Vec<f32>,
194}
195
196impl GroupNorm {
197    fn from(w: &StTensor, b: &StTensor, groups: usize) -> Self {
198        Self {
199            g: groups,
200            w: w.data.clone(),
201            b: b.data.clone(),
202        }
203    }
204
205    pub fn apply(&self, x: &mut [f32], h: usize, w: usize) {
206        let c = self.w.len();
207        let per = c / self.g;
208        let hw = h * w;
209        // One thread per group (32 groups saturate the cores; the pass
210        // is memory-bound, f64 accumulation kept for parity).
211        std::thread::scope(|s| {
212            for (gi, span) in x.chunks_mut(per * hw).enumerate() {
213                let (wref, bref) = (&self.w, &self.b);
214                s.spawn(move || {
215                    let n = span.len() as f64;
216                    let mean = span.iter().map(|&v| v as f64).sum::<f64>() / n;
217                    let var = span.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / n;
218                    let inv = 1.0 / (var + 1e-6).sqrt();
219                    for (ci, ch) in span.chunks_mut(hw).enumerate() {
220                        let cc = gi * per + ci;
221                        let (sw, sb) = (wref[cc], bref[cc]);
222                        for v in ch.iter_mut() {
223                            *v = ((*v as f64 - mean) * inv) as f32 * sw + sb;
224                        }
225                    }
226                });
227            }
228        });
229    }
230}
231
232fn silu(x: &mut [f32]) {
233    let nt = std::thread::available_parallelism()
234        .map(|n| n.get())
235        .unwrap_or(4);
236    let chunk = x.len().div_ceil(nt).max(1 << 14);
237    std::thread::scope(|s| {
238        for part in x.chunks_mut(chunk) {
239            s.spawn(move || {
240                for v in part.iter_mut() {
241                    *v /= 1.0 + (-*v).exp();
242                }
243            });
244        }
245    });
246}
247
248/// Nearest-neighbour ×2 upsample, NCHW.
249fn upsample2x(x: &[f32], c: usize, h: usize, w: usize) -> Vec<f32> {
250    let mut out = vec![0f32; c * 4 * h * w];
251    for ci in 0..c {
252        let src = &x[ci * h * w..(ci + 1) * h * w];
253        let dst = &mut out[ci * 4 * h * w..(ci + 1) * 4 * h * w];
254        for y in 0..2 * h {
255            for xx in 0..2 * w {
256                dst[y * 2 * w + xx] = src[(y / 2) * w + xx / 2];
257            }
258        }
259    }
260    out
261}
262
263struct ResnetBlock {
264    norm1: GroupNorm,
265    conv1: Conv2d,
266    norm2: GroupNorm,
267    conv2: Conv2d,
268    shortcut: Option<Conv2d>,
269}
270
271impl ResnetBlock {
272    fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
273        let mut t = x.to_vec();
274        self.norm1.apply(&mut t, h, w);
275        silu(&mut t);
276        let mut t = self.conv1.apply(&t, h, w);
277        self.norm2.apply(&mut t, h, w);
278        silu(&mut t);
279        let t = self.conv2.apply(&t, h, w);
280        let skip = match &self.shortcut {
281            Some(sc) => sc.apply(x, h, w),
282            None => x.to_vec(),
283        };
284        skip.iter().zip(&t).map(|(a, b)| a + b).collect()
285    }
286}
287
288/// Single-head self-attention over the spatial grid (mid-block).
289struct AttnBlock {
290    norm: GroupNorm,
291    q: (Vec<f32>, Vec<f32>), // [c, c] weight (row-major out×in), bias
292    k: (Vec<f32>, Vec<f32>),
293    v: (Vec<f32>, Vec<f32>),
294    out: (Vec<f32>, Vec<f32>),
295    c: usize,
296}
297
298impl AttnBlock {
299    /// Token-major dense projection: `x` [hw, c] → [hw, c] via one
300    /// `gemm_nt` (weight rows are output channels), bias fused after.
301    fn proj(w: &[f32], b: &[f32], x: &[f32], hw: usize, c: usize) -> Vec<f32> {
302        let mut y = vec![0f32; hw * c];
303        crate::fcd_ops::gemm_nt(x, w, &mut y, hw, c, c, None);
304        for row in y.chunks_mut(c) {
305            for (v, bb) in row.iter_mut().zip(b) {
306                *v += bb;
307            }
308        }
309        y
310    }
311
312    fn apply(&self, x: &[f32], h: usize, w: usize) -> Vec<f32> {
313        let (c, hw) = (self.c, h * w);
314        let mut n = x.to_vec();
315        self.norm.apply(&mut n, h, w);
316        // Channel-major → token-major once; everything below is GEMM.
317        let mut nt = vec![0f32; hw * c];
318        for ci in 0..c {
319            for p in 0..hw {
320                nt[p * c + ci] = n[ci * hw + p];
321            }
322        }
323        let mut q = Self::proj(&self.q.0, &self.q.1, &nt, hw, c);
324        let k = Self::proj(&self.k.0, &self.k.1, &nt, hw, c);
325        let v = Self::proj(&self.v.0, &self.v.1, &nt, hw, c);
326        let scale = 1.0 / (c as f32).sqrt();
327        for qv in q.iter_mut() {
328            *qv *= scale;
329        }
330        // scores[i, j] = q_i · k_j; row softmax; out = attn · v.
331        let mut scores = vec![0f32; hw * hw];
332        crate::fcd_ops::gemm_nt(&q, &k, &mut scores, hw, c, hw, None);
333        for row in scores.chunks_mut(hw) {
334            let mx = row.iter().cloned().fold(f32::MIN, f32::max);
335            let mut den = 0f32;
336            for r in row.iter_mut() {
337                *r = (*r - mx).exp();
338                den += *r;
339            }
340            let inv = 1.0 / den;
341            for r in row.iter_mut() {
342                *r *= inv;
343            }
344        }
345        // gemm_nt wants the right operand transposed: v as [c, hw].
346        let mut vt = vec![0f32; c * hw];
347        for p in 0..hw {
348            for ci in 0..c {
349                vt[ci * hw + p] = v[p * c + ci];
350            }
351        }
352        let mut ot = vec![0f32; hw * c];
353        crate::fcd_ops::gemm_nt(&scores, &vt, &mut ot, hw, hw, c, None);
354        let o = Self::proj(&self.out.0, &self.out.1, &ot, hw, c);
355        // Token-major → channel-major + residual.
356        let mut y = x.to_vec();
357        for p in 0..hw {
358            for ci in 0..c {
359                y[ci * hw + p] += o[p * c + ci];
360            }
361        }
362        y
363    }
364}
365
366struct UpBlock {
367    resnets: Vec<ResnetBlock>,
368    upsample: Option<Conv2d>,
369}
370
371/// The full decoder: `conv_in → mid(res, attn, res) → up-blocks →
372/// GroupNorm + SiLU → conv_out`, plus the diffusers latent
373/// de-normalization `z/scaling_factor + shift_factor`.
374pub struct VaeDecoder {
375    conv_in: Conv2d,
376    mid_res1: ResnetBlock,
377    mid_attn: AttnBlock,
378    mid_res2: ResnetBlock,
379    ups: Vec<UpBlock>,
380    norm_out: GroupNorm,
381    conv_out: Conv2d,
382    pub latent_channels: usize,
383    pub scaling_factor: f32,
384    pub shift_factor: f32,
385}
386
387impl VaeDecoder {
388    /// Load from a diffusers `vae/` directory (config.json +
389    /// diffusion_pytorch_model.safetensors).
390    pub fn load_dir(dir: &Path) -> Result<Self, String> {
391        let cfg: serde_json::Value = serde_json::from_slice(
392            &std::fs::read(dir.join("config.json")).map_err(|e| format!("config.json: {e}"))?,
393        )
394        .map_err(|e| format!("config.json: {e}"))?;
395        let t = read_safetensors(&dir.join("diffusion_pytorch_model.safetensors"))?;
396        Self::from_tensors(t, &cfg)
397    }
398
399    /// Load from a packaged imagegen .cmf (`vae.*` tensors, stored
400    /// f16/f32, + `vae.config_json`).
401    pub fn from_cmf(model: &cortiq_core::CmfModel) -> Result<Self, String> {
402        let cfg: serde_json::Value = serde_json::from_slice(
403            model
404                .tensor_bytes("vae.config_json")
405                .map_err(|e| e.to_string())?,
406        )
407        .map_err(|e| format!("vae.config_json: {e}"))?;
408        let mut t = std::collections::HashMap::new();
409        for entry in model
410            .tensors
411            .iter()
412            .filter(|e| e.name.starts_with("vae.") && e.name != "vae.config_json")
413        {
414            let data = crate::dit::cmf_f32(model, &entry.name)?;
415            t.insert(
416                entry.name["vae.".len()..].to_string(),
417                StTensor {
418                    shape: entry.shape.clone(),
419                    data,
420                },
421            );
422        }
423        Self::from_tensors(t, &cfg)
424    }
425
426    fn from_tensors(
427        t: std::collections::HashMap<String, StTensor>,
428        cfg: &serde_json::Value,
429    ) -> Result<Self, String> {
430        let groups = cfg["norm_num_groups"].as_u64().unwrap_or(32) as usize;
431        let get = |n: &str| -> Result<&StTensor, String> {
432            t.get(n).ok_or_else(|| format!("missing tensor {n}"))
433        };
434        let conv = |n: &str| -> Result<Conv2d, String> {
435            Ok(Conv2d::from(
436                get(&format!("{n}.weight"))?,
437                get(&format!("{n}.bias"))?,
438            ))
439        };
440        let gnorm = |n: &str| -> Result<GroupNorm, String> {
441            Ok(GroupNorm::from(
442                get(&format!("{n}.weight"))?,
443                get(&format!("{n}.bias"))?,
444                groups,
445            ))
446        };
447        let resnet = |n: &str| -> Result<ResnetBlock, String> {
448            Ok(ResnetBlock {
449                norm1: gnorm(&format!("{n}.norm1"))?,
450                conv1: conv(&format!("{n}.conv1"))?,
451                norm2: gnorm(&format!("{n}.norm2"))?,
452                conv2: conv(&format!("{n}.conv2"))?,
453                shortcut: if t.contains_key(&format!("{n}.conv_shortcut.weight")) {
454                    Some(conv(&format!("{n}.conv_shortcut"))?)
455                } else {
456                    None
457                },
458            })
459        };
460        // Attention projections are [c, c] linears (diffusers stores
461        // to_q/... as Linear weights).
462        let lin = |n: &str| -> Result<(Vec<f32>, Vec<f32>), String> {
463            Ok((
464                get(&format!("{n}.weight"))?.data.clone(),
465                get(&format!("{n}.bias"))?.data.clone(),
466            ))
467        };
468        let attn_c = get("decoder.mid_block.attentions.0.to_q.weight")?.shape[0];
469        let mid_attn = AttnBlock {
470            norm: gnorm("decoder.mid_block.attentions.0.group_norm")?,
471            q: lin("decoder.mid_block.attentions.0.to_q")?,
472            k: lin("decoder.mid_block.attentions.0.to_k")?,
473            v: lin("decoder.mid_block.attentions.0.to_v")?,
474            out: lin("decoder.mid_block.attentions.0.to_out.0")?,
475            c: attn_c,
476        };
477        let mut ups = Vec::new();
478        for b in 0.. {
479            if !t.contains_key(&format!("decoder.up_blocks.{b}.resnets.0.conv1.weight")) {
480                break;
481            }
482            let mut resnets = Vec::new();
483            for r in 0.. {
484                let n = format!("decoder.up_blocks.{b}.resnets.{r}");
485                if !t.contains_key(&format!("{n}.conv1.weight")) {
486                    break;
487                }
488                resnets.push(resnet(&n)?);
489            }
490            let upsample =
491                if t.contains_key(&format!("decoder.up_blocks.{b}.upsamplers.0.conv.weight")) {
492                    Some(conv(&format!("decoder.up_blocks.{b}.upsamplers.0.conv"))?)
493                } else {
494                    None
495                };
496            ups.push(UpBlock { resnets, upsample });
497        }
498        Ok(Self {
499            conv_in: conv("decoder.conv_in")?,
500            mid_res1: resnet("decoder.mid_block.resnets.0")?,
501            mid_attn,
502            mid_res2: resnet("decoder.mid_block.resnets.1")?,
503            ups,
504            norm_out: gnorm("decoder.conv_norm_out")?,
505            conv_out: conv("decoder.conv_out")?,
506            latent_channels: cfg["latent_channels"].as_u64().unwrap_or(16) as usize,
507            scaling_factor: cfg["scaling_factor"].as_f64().unwrap_or(1.0) as f32,
508            shift_factor: cfg["shift_factor"].as_f64().unwrap_or(0.0) as f32,
509        })
510    }
511
512    /// Decode latents `[latent_channels, h, w]` (model scale, i.e. as
513    /// produced by the diffusion loop) into RGB `[3, 8h, 8w]` in [-1, 1].
514    pub fn decode(&self, z: &[f32], h: usize, w: usize) -> Vec<f32> {
515        let prof = std::env::var("CMF_VAE_PROF").is_ok();
516        macro_rules! stage {
517            ($name:expr, $e:expr) => {{
518                let t = std::time::Instant::now();
519                let r = $e;
520                if prof {
521                    eprintln!("vae {}: {:.2}s", $name, t.elapsed().as_secs_f64());
522                }
523                r
524            }};
525        }
526        let z: Vec<f32> = z
527            .iter()
528            .map(|&v| v / self.scaling_factor + self.shift_factor)
529            .collect();
530        let mut x = stage!("conv_in", self.conv_in.apply(&z, h, w));
531        x = stage!("mid_res1", self.mid_res1.apply(&x, h, w));
532        x = stage!("mid_attn", self.mid_attn.apply(&x, h, w));
533        x = stage!("mid_res2", self.mid_res2.apply(&x, h, w));
534        let (mut h, mut w) = (h, w);
535        for (ui, up) in self.ups.iter().enumerate() {
536            for (ri, r) in up.resnets.iter().enumerate() {
537                x = stage!(format!("up{ui}.res{ri} ({h}x{w})"), r.apply(&x, h, w));
538            }
539            if let Some(upc) = &up.upsample {
540                let c = upc.ic;
541                x = upsample2x(&x, c, h, w);
542                h *= 2;
543                w *= 2;
544                x = stage!(format!("up{ui}.conv ({h}x{w})"), upc.apply(&x, h, w));
545            }
546        }
547        self.norm_out.apply(&mut x, h, w);
548        silu(&mut x);
549        stage!("conv_out", self.conv_out.apply(&x, h, w))
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    /// conv2d against a hand-computed 3×3 case with padding.
558    #[test]
559    fn conv2d_matches_hand_reference() {
560        // 1 in-channel 3×3 image, 1 out-channel 3×3 kernel of ones,
561        // bias 0.5: each output = sum of the 3×3 neighbourhood + 0.5.
562        let c = Conv2d {
563            w: vec![1.0; 9],
564            b: vec![0.5],
565            oc: 1,
566            ic: 1,
567            k: 3,
568        };
569        let x = vec![1., 2., 3., 4., 5., 6., 7., 8., 9.];
570        let y = c.apply(&x, 3, 3);
571        // centre: 1+2+..+9 + 0.5 = 45.5; corner (0,0): 1+2+4+5 + 0.5 = 12.5.
572        assert_eq!(y[4], 45.5);
573        assert_eq!(y[0], 12.5);
574        assert_eq!(y[8], 5. + 6. + 8. + 9. + 0.5);
575    }
576
577    /// GroupNorm: two groups of one channel each — plain per-channel
578    /// standardization times affine.
579    #[test]
580    fn group_norm_standardizes() {
581        let gn = GroupNorm {
582            g: 2,
583            w: vec![2.0, 1.0],
584            b: vec![0.0, 3.0],
585        };
586        let mut x = vec![1., 3., 5., 7., 10., 10., 10., 10.];
587        gn.apply(&mut x, 2, 2);
588        // ch0: mean 4, std sqrt(5) → (1-4)/√5*2 …
589        let s = 5f64.sqrt();
590        assert!((x[0] as f64 - (-3.0 / s * 2.0)).abs() < 1e-5);
591        // ch1: constant → normalized 0 → bias 3.
592        assert!((x[4] - 3.0).abs() < 1e-4);
593    }
594
595    /// Upsample doubles both dimensions with nearest fill.
596    #[test]
597    fn upsample_nearest() {
598        let x = vec![1., 2., 3., 4.];
599        let y = upsample2x(&x, 1, 2, 2);
600        assert_eq!(
601            y,
602            vec![
603                1., 1., 2., 2., 1., 1., 2., 2., 3., 3., 4., 4., 3., 3., 4., 4.,
604            ]
605        );
606    }
607}