Skip to main content

cortiq_engine/
ltxvae.rs

1//! LTX-2.5 convolutional video VAE decoder — the `vvae.*` half of an
2//! `ltx-2.5-av` container, in Rust with no ML framework underneath.
3//!
4//! Latents are `[128, F, H, W]`; the decoder returns `[3, 8·(F−1)+1, 32·H,
5//! 32·W]` in `[-1, 1]`. The stack mirrors the encoder, and the checkpoint
6//! stores its blocks in the encoder's order, so `decoder_blocks` from the
7//! config is walked **backwards**:
8//!
9//! ```text
10//! un_normalize → conv_in(128→1024)
11//!   res×2 @1024 │ up(2,2,2) 1024→512 │ res×2 @512 │ up(2,2,2) 512→512
12//!   res×4 @512  │ up(2,1,1) 512→256  │ res×6 @256 │ up(1,2,2) 256→128
13//!   res×4 @128
14//! → PixelNorm → SiLU → conv_out(128→48) → unpatchify(4) → [3, …]
15//! ```
16//!
17//! Every convolution is 3×3×3 with zero spatial padding and **replicated
18//! frames** at both ends of the time axis (`causal_decoder: false`), and
19//! every normalization is `PixelNorm` — RMS over channels at each
20//! location, no learned parameters, which is why the checkpoint carries no
21//! norm weights. A residual path that changes width normalizes with a
22//! 1-group GroupNorm and a 1×1×1 projection; at equal width both are
23//! identity, and this decoder is equal-width everywhere.
24//!
25//! Convolutions run as im2col + GEMM over the worker pool, in position
26//! chunks so the patch buffer stays bounded regardless of resolution.
27
28use crate::pool::Pool;
29use cortiq_core::CmfModel;
30use std::sync::Arc;
31
32/// Positions per im2col chunk (patch buffer ≈ CHUNK · Cin · 27 floats).
33const CHUNK: usize = 8192;
34
35/// A 3×3×3 convolution with zero spatial padding and replicate padding in
36/// time. Weights are `[Cout, Cin, 3, 3, 3]` flattened to `[Cout, Cin·27]`.
37pub struct Conv3d {
38    w: Vec<f32>,
39    b: Vec<f32>,
40    c_out: usize,
41    c_in: usize,
42}
43
44/// One `[C, F, H, W]` volume.
45#[derive(Clone)]
46pub struct Vol {
47    pub c: usize,
48    pub f: usize,
49    pub h: usize,
50    pub w: usize,
51    pub data: Vec<f32>,
52}
53
54impl Vol {
55    pub fn zeros(c: usize, f: usize, h: usize, w: usize) -> Vol {
56        Vol { c, f, h, w, data: vec![0.0; c * f * h * w] }
57    }
58    #[inline]
59    pub fn at(&self, c: usize, f: usize, h: usize, w: usize) -> f32 {
60        self.data[((c * self.f + f) * self.h + h) * self.w + w]
61    }
62    pub fn positions(&self) -> usize {
63        self.f * self.h * self.w
64    }
65}
66
67/// Dequantize one tensor of the container to f32 (the VAE ships f16, so
68/// this is a widening read, not a decode).
69fn tensor_f32(model: &Arc<CmfModel>, name: &str) -> Result<(Vec<f32>, Vec<usize>), String> {
70    let e = model
71        .tensor(name)
72        .ok_or_else(|| format!("missing tensor {name}"))?;
73    let mut out = vec![0.0f32; e.n_elems()];
74    cortiq_core::quant::dequant_tensor(e, model.entry_bytes(e), &mut out)?;
75    Ok((out, e.shape.clone()))
76}
77
78impl Conv3d {
79    fn load(model: &Arc<CmfModel>, prefix: &str, _pool: Option<&Pool>) -> Result<Conv3d, String> {
80        let (w, shape) = tensor_f32(model, &format!("{prefix}.conv.weight"))?;
81        let (c_out, c_in) = (shape[0], shape[1]);
82        let b = match model.tensor(&format!("{prefix}.conv.bias")) {
83            Some(_) => tensor_f32(model, &format!("{prefix}.conv.bias"))?.0,
84            None => vec![0.0; c_out],
85        };
86        Ok(Conv3d { w, b, c_out, c_in })
87    }
88
89    /// `[Cin, F, H, W]` → `[Cout, F, H, W]`.
90    pub fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
91        assert_eq!(x.c, self.c_in, "conv3d channels");
92        let (f, h, w) = (x.f, x.h, x.w);
93        let mut out = Vol::zeros(self.c_out, f, h, w);
94        let k = self.c_in * 27;
95        let npos = f * h * w;
96        let mut patches = vec![0.0f32; CHUNK.min(npos) * k];
97        let mut ybuf = vec![0.0f32; CHUNK.min(npos) * self.c_out];
98        let mut p0 = 0usize;
99        while p0 < npos {
100            let n = CHUNK.min(npos - p0);
101            // im2col: one row per output position, Cin·27 columns. The
102            // spatial border taps are LEFT OUT below (zero padding), so the
103            // buffer must start clean — a stale row from the previous chunk
104            // would silently become the padding value.
105            patches[..n * k].fill(0.0);
106            for i in 0..n {
107                let p = p0 + i;
108                let (pw, rest) = (p % w, p / w);
109                let (ph, pf) = (rest % h, rest / h);
110                let row = &mut patches[i * k..(i + 1) * k];
111                for ci in 0..self.c_in {
112                    for kf in 0..3usize {
113                        // replicate padding in time (non-causal decoder)
114                        let sf = (pf + kf).saturating_sub(1).min(f - 1);
115                        for kh in 0..3usize {
116                            let sh = ph + kh;
117                            if sh == 0 || sh > h {
118                                continue; // zero padding
119                            }
120                            let sh = sh - 1;
121                            for kw in 0..3usize {
122                                let sw = pw + kw;
123                                if sw == 0 || sw > w {
124                                    continue;
125                                }
126                                let sw = sw - 1;
127                                row[(ci * 3 + kf) * 9 + kh * 3 + kw] = x.at(ci, sf, sh, sw);
128                            }
129                        }
130                    }
131                }
132            }
133            // y[n, Cout] = patches[n, k] · wᵀ
134            crate::fcd_ops::gemm_nt(&patches[..n * k], &self.w, &mut ybuf[..n * self.c_out], n, k, self.c_out, pool);
135            for i in 0..n {
136                let p = p0 + i;
137                for co in 0..self.c_out {
138                    out.data[co * npos + p] = ybuf[i * self.c_out + co] + self.b[co];
139                }
140            }
141            p0 += n;
142        }
143        out
144    }
145}
146
147#[inline]
148fn silu(x: f32) -> f32 {
149    x / (1.0 + (-x).exp())
150}
151
152/// RMS over the channel axis at every (f, h, w) — no learned parameters.
153fn pixel_norm(x: &mut Vol, eps: f32) {
154    let npos = x.positions();
155    for p in 0..npos {
156        let mut s = 0.0f32;
157        for c in 0..x.c {
158            let v = x.data[c * npos + p];
159            s += v * v;
160        }
161        let inv = 1.0 / (s / x.c as f32 + eps).sqrt();
162        for c in 0..x.c {
163            x.data[c * npos + p] *= inv;
164        }
165    }
166}
167
168fn silu_inplace(x: &mut Vol) {
169    for v in x.data.iter_mut() {
170        *v = silu(*v);
171    }
172}
173
174/// PixelNorm → SiLU → conv1 → PixelNorm → SiLU → conv2, plus the residual.
175pub struct ResBlock {
176    conv1: Conv3d,
177    conv2: Conv3d,
178}
179
180impl ResBlock {
181    fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
182        let mut h = x.clone();
183        pixel_norm(&mut h, 1e-8);
184        silu_inplace(&mut h);
185        let mut h = self.conv1.forward(&h, pool);
186        pixel_norm(&mut h, 1e-8);
187        silu_inplace(&mut h);
188        let h = self.conv2.forward(&h, pool);
189        let mut out = x.clone();
190        for (o, v) in out.data.iter_mut().zip(&h.data) {
191            *o += v;
192        }
193        out
194    }
195}
196
197/// conv → depth-to-space over `(p1, p2, p3)` → drop the first frame when
198/// the time stride is 2. Channels are laid out `(c p1 p2 p3)`.
199pub struct DepthToSpaceUp {
200    conv: Conv3d,
201    stride: (usize, usize, usize),
202}
203
204impl DepthToSpaceUp {
205    fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
206        let y = self.conv.forward(x, pool);
207        let (p1, p2, p3) = self.stride;
208        let cout = y.c / (p1 * p2 * p3);
209        let (f2, h2, w2) = (y.f * p1, y.h * p2, y.w * p3);
210        let mut out = Vol::zeros(cout, f2, h2, w2);
211        for c in 0..cout {
212            for a in 0..p1 {
213                for b in 0..p2 {
214                    for d in 0..p3 {
215                        let src_c = ((c * p1 + a) * p2 + b) * p3 + d;
216                        for f in 0..y.f {
217                            for hh in 0..y.h {
218                                for ww in 0..y.w {
219                                    let v = y.at(src_c, f, hh, ww);
220                                    let (of, oh, ow) = (f * p1 + a, hh * p2 + b, ww * p3 + d);
221                                    out.data[((c * f2 + of) * h2 + oh) * w2 + ow] = v;
222                                }
223                            }
224                        }
225                    }
226                }
227            }
228        }
229        if p1 == 2 {
230            // the temporal upsample emits one frame too many at the head
231            let f3 = f2 - 1;
232            let mut trimmed = Vol::zeros(cout, f3, h2, w2);
233            for c in 0..cout {
234                for f in 0..f3 {
235                    let src = ((c * f2 + f + 1) * h2) * w2;
236                    let dst = ((c * f3 + f) * h2) * w2;
237                    trimmed.data[dst..dst + h2 * w2].copy_from_slice(&out.data[src..src + h2 * w2]);
238                }
239            }
240            return trimmed;
241        }
242        out
243    }
244}
245
246enum Block {
247    Res(Vec<ResBlock>),
248    Up(DepthToSpaceUp),
249}
250
251/// The whole decoder, read from a packed `ltx-2.5-av` container.
252pub struct ConvVaeDecoder {
253    conv_in: Conv3d,
254    blocks: Vec<Block>,
255    conv_out: Conv3d,
256    mean: Vec<f32>,
257    std: Vec<f32>,
258    patch: usize,
259}
260
261impl ConvVaeDecoder {
262    /// Build from `vvae.*` tensors. The block schedule comes from
263    /// `ltx.config_json`'s `vae.decoder_blocks`, walked in reverse.
264    pub fn from_cmf(model: &Arc<CmfModel>, pool: Option<&Pool>) -> Result<ConvVaeDecoder, String> {
265        // the pipeline config (from the DiT pass) or the VAE file's own
266        // Take whichever config actually describes the VAE: a container
267        // packed from the transformer first carries `ltx.config_json`, whose
268        // `transformer` section says nothing about decoder blocks.
269        let cfg: serde_json::Value = ["vvae.config_json", "ltx.config_json"]
270            .iter()
271            .filter_map(|n| model.tensor(n).map(|e| model.entry_bytes(e)))
272            .filter_map(|b| serde_json::from_slice::<serde_json::Value>(b).ok())
273            .find(|c| c.get("vae").and_then(|v| v.get("decoder_blocks")).is_some())
274            .ok_or("no config in this container carries vae.decoder_blocks")?;
275        let vae = &cfg["vae"];
276        let patch = vae["patch_size"].as_u64().unwrap_or(4) as usize;
277        let blocks_cfg = vae["decoder_blocks"]
278            .as_array()
279            .ok_or("vae.decoder_blocks missing")?;
280        let conv_in = Conv3d::load(model, "vvae.decoder.conv_in", pool)?;
281        let conv_out = Conv3d::load(model, "vvae.decoder.conv_out", pool)?;
282        let mut blocks = Vec::new();
283        // the checkpoint numbers up_blocks in decode order; the config lists
284        // them in the encoder's order
285        for (i, entry) in blocks_cfg.iter().rev().enumerate() {
286            let name = entry[0].as_str().unwrap_or("");
287            let params = &entry[1];
288            let prefix = format!("vvae.decoder.up_blocks.{i}");
289            match name {
290                "res_x" => {
291                    let n = params["num_layers"].as_u64().unwrap_or(1) as usize;
292                    let mut res = Vec::new();
293                    for j in 0..n {
294                        res.push(ResBlock {
295                            conv1: Conv3d::load(model, &format!("{prefix}.res_blocks.{j}.conv1"), pool)?,
296                            conv2: Conv3d::load(model, &format!("{prefix}.res_blocks.{j}.conv2"), pool)?,
297                        });
298                    }
299                    blocks.push(Block::Res(res));
300                }
301                "compress_time" | "compress_space" | "compress_all" => {
302                    let stride = match name {
303                        "compress_time" => (2, 1, 1),
304                        "compress_space" => (1, 2, 2),
305                        _ => (2, 2, 2),
306                    };
307                    blocks.push(Block::Up(DepthToSpaceUp {
308                        conv: Conv3d::load(model, &format!("{prefix}.conv"), pool)?,
309                        stride,
310                    }));
311                }
312                other => return Err(format!("unknown decoder block '{other}'")),
313            }
314        }
315        let mean = tensor_f32(model, "vvae.per_channel_statistics.mean-of-means")?.0;
316        let std = tensor_f32(model, "vvae.per_channel_statistics.std-of-means")?.0;
317        Ok(ConvVaeDecoder {
318            conv_in,
319            blocks,
320            conv_out,
321            mean,
322            std,
323            patch,
324        })
325    }
326
327    /// Latent `[128, F, H, W]` → frames `[3, 8·(F−1)+1, 32·H, 32·W]`.
328    pub fn decode(&self, latent: &Vol, pool: Option<&Pool>) -> Vol {
329        self.decode_traced(latent, pool, &mut |_, _| {})
330    }
331
332    /// `decode` with a callback after every stage — the parity gate walks
333    /// the same names the reference's forward hooks produce.
334    pub fn decode_traced(
335        &self,
336        latent: &Vol,
337        pool: Option<&Pool>,
338        trace: &mut dyn FnMut(&str, &Vol),
339    ) -> Vol {
340        let mut x = latent.clone();
341        // un_normalize: x·std + mean, per latent channel
342        let npos = x.positions();
343        for c in 0..x.c {
344            let (s, m) = (self.std[c], self.mean[c]);
345            for p in 0..npos {
346                x.data[c * npos + p] = x.data[c * npos + p] * s + m;
347            }
348        }
349        let mut h = self.conv_in.forward(&x, pool);
350        trace("after_conv_in", &h);
351        for (i, b) in self.blocks.iter().enumerate() {
352            h = match b {
353                Block::Res(res) => {
354                    let mut cur = h;
355                    for r in res {
356                        cur = r.forward(&cur, pool);
357                    }
358                    cur
359                }
360                Block::Up(u) => u.forward(&h, pool),
361            };
362            trace(&format!("after_block_{i}"), &h);
363        }
364        pixel_norm(&mut h, 1e-8);
365        silu_inplace(&mut h);
366        let y = self.conv_out.forward(&h, pool);
367        trace("after_conv_out", &y);
368        let out = unpatchify(&y, self.patch);
369        trace("frames", &out);
370        out
371    }
372
373}
374
375/// `[C·q·r, F, H, W]` → `[C, F, H·q, W·r]` (channels laid out `(c r q)`).
376pub fn unpatchify(x: &Vol, patch: usize) -> Vol {
377    if patch == 1 {
378        return x.clone();
379    }
380    let c = x.c / (patch * patch);
381    let (h2, w2) = (x.h * patch, x.w * patch);
382    let mut out = Vol::zeros(c, x.f, h2, w2);
383    for cc in 0..c {
384        for r in 0..patch {
385            for q in 0..patch {
386                let src_c = (cc * patch + r) * patch + q;
387                for f in 0..x.f {
388                    for hh in 0..x.h {
389                        for ww in 0..x.w {
390                            let v = x.at(src_c, f, hh, ww);
391                            out.data[((cc * x.f + f) * h2 + hh * patch + q) * w2 + ww * patch + r] = v;
392                        }
393                    }
394                }
395            }
396        }
397    }
398    out
399}