Skip to main content

cortiq_engine/
ltxups.rs

1//! The LTX-2.5 spatial latent upscaler — the ×2 between the two denoising
2//! stages.
3//!
4//! Structurally simple and computationally not: 3-D convolutions at 1024
5//! channels with ordinary zero padding, GroupNorm(32) and SiLU, four
6//! residual blocks before a pixel-shuffle upsample and four after. It runs
7//! on the *un-normalized* latent — the video VAE's per-channel statistics
8//! are undone going in and reapplied coming out — because the upscaler was
9//! trained in the VAE's own units, not the diffusion model's.
10
11use crate::ltxvae::Vol;
12use crate::pool::Pool;
13use cortiq_core::CmfModel;
14use std::sync::Arc;
15
16fn tensor_f32(model: &Arc<CmfModel>, name: &str) -> Result<(Vec<f32>, Vec<usize>), String> {
17    let e = model
18        .tensor(name)
19        .ok_or_else(|| format!("missing tensor {name}"))?;
20    let mut out = vec![0.0f32; e.n_elems()];
21    cortiq_core::quant::dequant_tensor(e, model.entry_bytes(e), &mut out)?;
22    Ok((out, e.shape.clone()))
23}
24
25fn silu(v: f32) -> f32 {
26    v / (1.0 + (-v).exp())
27}
28
29/// `[out, in, kf, kh, kw]` convolution with zero padding of one on every
30/// axis, as im2col into a single GEMM per position chunk.
31struct Conv {
32    w: Vec<f32>,
33    b: Vec<f32>,
34    c_out: usize,
35    c_in: usize,
36    kf: usize,
37    kh: usize,
38    kw: usize,
39}
40
41impl Conv {
42    fn load(model: &Arc<CmfModel>, name: &str) -> Result<Conv, String> {
43        let (w, s) = tensor_f32(model, &format!("{name}.weight"))?;
44        let (b, _) = tensor_f32(model, &format!("{name}.bias"))?;
45        let (kf, kh, kw) = match s.len() {
46            5 => (s[2], s[3], s[4]),
47            4 => (1, s[2], s[3]),
48            _ => return Err(format!("{name}: rank {}", s.len())),
49        };
50        Ok(Conv {
51            w,
52            b,
53            c_out: s[0],
54            c_in: s[1],
55            kf,
56            kh,
57            kw,
58        })
59    }
60
61    fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
62        let (f, h, w) = (x.f, x.h, x.w);
63        let npos = f * h * w;
64        let k = self.c_in * self.kf * self.kh * self.kw;
65        let mut out = Vol::zeros(self.c_out, f, h, w);
66        const CHUNK: usize = 4096;
67        let mut patches = vec![0f32; CHUNK * k];
68        let mut ys = vec![0f32; CHUNK * self.c_out];
69        let (pf, ph, pw) = (self.kf / 2, self.kh / 2, self.kw / 2);
70        let mut p0 = 0usize;
71        while p0 < npos {
72            let n = CHUNK.min(npos - p0);
73            // the buffer is reused across chunks — a stale row would become
74            // the padding value
75            patches[..n * k].fill(0.0);
76            for i in 0..n {
77                let p = p0 + i;
78                let (pw_, rest) = (p % w, p / w);
79                let (ph_, pf_) = (rest % h, rest / h);
80                for ci in 0..self.c_in {
81                    for a in 0..self.kf {
82                        let sf = pf_ as isize + a as isize - pf as isize;
83                        if sf < 0 || sf >= f as isize {
84                            continue;
85                        }
86                        for bb in 0..self.kh {
87                            let sh = ph_ as isize + bb as isize - ph as isize;
88                            if sh < 0 || sh >= h as isize {
89                                continue;
90                            }
91                            for c in 0..self.kw {
92                                let sw = pw_ as isize + c as isize - pw as isize;
93                                if sw < 0 || sw >= w as isize {
94                                    continue;
95                                }
96                                let v = x.data
97                                    [((ci * f + sf as usize) * h + sh as usize) * w + sw as usize];
98                                patches
99                                    [i * k + ((ci * self.kf + a) * self.kh + bb) * self.kw + c] = v;
100                            }
101                        }
102                    }
103                }
104            }
105            crate::fcd_ops::gemm_nt(
106                &patches[..n * k],
107                &self.w,
108                &mut ys[..n * self.c_out],
109                n,
110                k,
111                self.c_out,
112                pool,
113            );
114            for i in 0..n {
115                let p = p0 + i;
116                for co in 0..self.c_out {
117                    out.data[co * npos + p] = ys[i * self.c_out + co] + self.b[co];
118                }
119            }
120            p0 += n;
121        }
122        out
123    }
124}
125
126/// GroupNorm(32) with affine, in place.
127fn group_norm(x: &mut Vol, w: &[f32], b: &[f32]) {
128    let groups = 32usize;
129    let per = x.c / groups;
130    let npos = x.positions();
131    for g in 0..groups {
132        let (lo, hi) = (g * per * npos, (g + 1) * per * npos);
133        let s = &x.data[lo..hi];
134        let n = s.len() as f64;
135        let mean = s.iter().map(|&v| v as f64).sum::<f64>() / n;
136        let var = s
137            .iter()
138            .map(|&v| (v as f64 - mean) * (v as f64 - mean))
139            .sum::<f64>()
140            / n;
141        let inv = 1.0 / (var + 1e-5).sqrt();
142        for c in 0..per {
143            let ch = g * per + c;
144            let (gw, gb) = (w[ch], b[ch]);
145            for v in x.data[ch * npos..(ch + 1) * npos].iter_mut() {
146                *v = ((*v as f64 - mean) * inv) as f32 * gw + gb;
147            }
148        }
149    }
150}
151
152struct ResBlock {
153    conv1: Conv,
154    n1w: Vec<f32>,
155    n1b: Vec<f32>,
156    conv2: Conv,
157    n2w: Vec<f32>,
158    n2b: Vec<f32>,
159}
160
161impl ResBlock {
162    fn load(model: &Arc<CmfModel>, p: &str) -> Result<ResBlock, String> {
163        Ok(ResBlock {
164            conv1: Conv::load(model, &format!("{p}.conv1"))?,
165            n1w: tensor_f32(model, &format!("{p}.norm1.weight"))?.0,
166            n1b: tensor_f32(model, &format!("{p}.norm1.bias"))?.0,
167            conv2: Conv::load(model, &format!("{p}.conv2"))?,
168            n2w: tensor_f32(model, &format!("{p}.norm2.weight"))?.0,
169            n2b: tensor_f32(model, &format!("{p}.norm2.bias"))?.0,
170        })
171    }
172
173    fn forward(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
174        let mut h = self.conv1.forward(x, pool);
175        group_norm(&mut h, &self.n1w, &self.n1b);
176        for v in h.data.iter_mut() {
177            *v = silu(*v);
178        }
179        let mut h2 = self.conv2.forward(&h, pool);
180        group_norm(&mut h2, &self.n2w, &self.n2b);
181        // the activation comes *after* the residual add, not before
182        for (v, &r) in h2.data.iter_mut().zip(&x.data) {
183            *v = silu(*v + r);
184        }
185        h2
186    }
187}
188
189pub struct LatentUpscaler {
190    initial: Conv,
191    in_w: Vec<f32>,
192    in_b: Vec<f32>,
193    pre: Vec<ResBlock>,
194    up: Conv,
195    post: Vec<ResBlock>,
196    final_conv: Conv,
197    mean: Vec<f32>,
198    std: Vec<f32>,
199}
200
201impl LatentUpscaler {
202    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<LatentUpscaler, String> {
203        let load_blocks = |p: &str| -> Result<Vec<ResBlock>, String> {
204            let mut v = Vec::new();
205            let mut i = 0;
206            while model.tensor(&format!("{p}.{i}.conv1.weight")).is_some() {
207                v.push(ResBlock::load(model, &format!("{p}.{i}"))?);
208                i += 1;
209            }
210            Ok(v)
211        };
212        Ok(LatentUpscaler {
213            initial: Conv::load(model, "ups.initial_conv")?,
214            in_w: tensor_f32(model, "ups.initial_norm.weight")?.0,
215            in_b: tensor_f32(model, "ups.initial_norm.bias")?.0,
216            pre: load_blocks("ups.res_blocks")?,
217            up: Conv::load(model, "ups.upsampler.0")?,
218            post: load_blocks("ups.post_upsample_res_blocks")?,
219            final_conv: Conv::load(model, "ups.final_conv")?,
220            mean: tensor_f32(model, "vvae.per_channel_statistics.mean-of-means")?.0,
221            std: tensor_f32(model, "vvae.per_channel_statistics.std-of-means")?.0,
222        })
223    }
224
225    /// `[128, F, H, W]` → `[128, F, 2H, 2W]`, in the diffusion model's units
226    /// on both sides.
227    pub fn upscale(&self, latent: &Vol, pool: Option<&Pool>) -> Vol {
228        let npos = latent.positions();
229        let mut x = latent.clone();
230        // into the VAE's units
231        for c in 0..x.c {
232            for v in x.data[c * npos..(c + 1) * npos].iter_mut() {
233                *v = *v * self.std[c] + self.mean[c];
234            }
235        }
236        let mut h = self.initial.forward(&x, pool);
237        group_norm(&mut h, &self.in_w, &self.in_b);
238        for v in h.data.iter_mut() {
239            *v = silu(*v);
240        }
241        for b in &self.pre {
242            h = b.forward(&h, pool);
243        }
244        h = self.pixel_shuffle(&self.up.forward(&h, pool));
245        for b in &self.post {
246            h = b.forward(&h, pool);
247        }
248        let mut out = self.final_conv.forward(&h, pool);
249        let npos2 = out.positions();
250        for c in 0..out.c {
251            for v in out.data[c * npos2..(c + 1) * npos2].iter_mut() {
252                *v = (*v - self.mean[c]) / self.std[c];
253            }
254        }
255        out
256    }
257
258    /// `b (c p1 p2) f h w -> b c f (h p1) (w p2)`.
259    fn pixel_shuffle(&self, x: &Vol) -> Vol {
260        let c = x.c / 4;
261        let (h2, w2) = (x.h * 2, x.w * 2);
262        let mut out = Vol::zeros(c, x.f, h2, w2);
263        for ch in 0..c {
264            for p1 in 0..2 {
265                for p2 in 0..2 {
266                    let src = (ch * 2 + p1) * 2 + p2;
267                    for f in 0..x.f {
268                        for y in 0..x.h {
269                            for z in 0..x.w {
270                                let v = x.data[((src * x.f + f) * x.h + y) * x.w + z];
271                                out.data[((ch * x.f + f) * h2 + y * 2 + p1) * w2 + z * 2 + p2] = v;
272                            }
273                        }
274                    }
275                }
276            }
277        }
278        out
279    }
280}