Skip to main content

cortiq_engine/
mmh3ups.rs

1//! The MiniMax-H3 latent upscaler: a 3-D conv net that resizes the DiT's
2//! own latent instead of decoding, scaling pixels and encoding again.
3//!
4//! Why it exists. A 5 B-parameter video VAE round trip is the expensive way
5//! to change resolution, and interpolating the latent directly is the cheap
6//! way that ghosts. This net — 345 M parameters, published by LBH-123-AI —
7//! learns the map: twelve residual blocks at the source size, a trilinear
8//! resize in the middle, twelve more at the target size, with a scalar
9//! "how much am I scaling" embedding modulating every block.
10//!
11//! ```text
12//! z ── (z − mean)/std ── conv_in ── [Res × 12, Temporal × 6] ──┐
13//!                                                              trilinear
14//!   z' ── ·std + mean ── conv_out ── silu ── norm ── [same again]
15//! ```
16//!
17//! Three details that are easy to get wrong and are gated by
18//! `examples/mmh3_ups_parity.rs` against a torch reference:
19//!
20//! * **The block list is a flat `ModuleList`.** `temporal_every = 2` means a
21//!   `TemporalConv` follows blocks 0, 2, 4 …, so the sequence is Res, Temp,
22//!   Res, Res, Temp, Res, … and the state dict indexes THAT, not the
23//!   residual blocks. We dispatch on which keys an index carries rather
24//!   than assuming a pattern.
25//! * **The modulation is `out_norm(h)·(1 + scale) + shift`,** applied to the
26//!   *second* norm, after the first convolution — not the adaLN order the
27//!   DiT next door uses.
28//! * **The scale embedding takes `scale − 1`**, so an identity resize feeds
29//!   zero, and the network was trained with that offset.
30//!
31//! The latent statistics are the release's own 24-channel mean/std; they are
32//! constants of the VAE, not of this net, and they are applied outside it.
33
34use crate::pool::Pool;
35use std::collections::HashMap;
36use std::path::Path;
37
38/// Per-channel latent statistics of the H3 video VAE — the values the
39/// upscaler was trained against, from the release's training code.
40pub const LATENT_MEAN: [f32; 24] = [
41    0.858_090_34,
42    -0.960_659_15,
43    1.066_164,
44    -0.509_032_55,
45    -0.272_758_19,
46    -1.367_541_4,
47    -0.255_325_5,
48    -0.269_075_54,
49    -0.537_684_08,
50    -0.046_409_73,
51    0.665_737_03,
52    0.196_901_28,
53    -0.546_060_8,
54    -0.403_534_2,
55    -0.236_830_25,
56    0.259_284_53,
57    -0.301_339_45,
58    0.211_341_99,
59    -1.120_684_9,
60    0.358_193_34,
61    -0.042_251_44,
62    0.260_483,
63    0.228_640_93,
64    0.705_603_2,
65];
66pub const LATENT_STD: [f32; 24] = [
67    1.222_377_4,
68    1.276_726_4,
69    1.683_177_5,
70    1.754_945_5,
71    1.563_621_6,
72    2.194_143_5,
73    0.965_313_8,
74    1.056_988_6,
75    0.841_948_9,
76    0.772_995_3,
77    1.895_593_8,
78    0.946_841_8,
79    0.799_680_95,
80    0.449_889,
81    0.719_739_97,
82    0.693_629_3,
83    2.961_095_1,
84    2.769_419_9,
85    3.049_618_5,
86    2.108_805_4,
87    3.276_226_3,
88    3.162_735_7,
89    2.281_681_3,
90    2.612_784_4,
91];
92
93/// Read a `.safetensors` of f32 tensors — the parity oracle's own format.
94pub fn read_oracle(
95    path: &Path,
96) -> Result<HashMap<String, (Vec<usize>, Vec<f32>)>, String> {
97    crate::ltxlora::read_safetensors(path)
98}
99
100/// A latent volume, channel-major: `[c][t][h][w]`.
101#[derive(Clone)]
102pub struct Vol {
103    pub c: usize,
104    pub t: usize,
105    pub h: usize,
106    pub w: usize,
107    pub data: Vec<f32>,
108}
109
110impl Vol {
111    fn zeros(c: usize, t: usize, h: usize, w: usize) -> Vol {
112        Vol { c, t, h, w, data: vec![0f32; c * t * h * w] }
113    }
114    #[inline]
115    fn at(&self, c: usize, t: usize, h: usize, w: usize) -> f32 {
116        self.data[((c * self.t + t) * self.h + h) * self.w + w]
117    }
118}
119
120/// A dense 3-D convolution with a 3×3×3 or 1×1×1 kernel and "same" padding.
121struct Conv3 {
122    w: Vec<f32>, // [c_out, c_in, kt, kh, kw]
123    b: Vec<f32>,
124    c_out: usize,
125    c_in: usize,
126    k: usize, // 1 or 3, cubic
127}
128
129impl Conv3 {
130    /// `dst = conv(x)`. Implemented as im2col + `gemm_nt` one temporal
131    /// slice at a time: the patch matrix for a whole volume at 512
132    /// channels is gigabytes, and for one slice it is a hundred megabytes.
133    fn apply(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
134        let (t, h, w) = (x.t, x.h, x.w);
135        let mut out = Vol::zeros(self.c_out, t, h, w);
136        let k = self.k;
137        let pad = k / 2;
138        let patch = self.c_in * k * k * k;
139        let n = h * w;
140        let mut col = vec![0f32; n * patch];
141        let mut acc = vec![0f32; n * self.c_out];
142        for ti in 0..t {
143            // im2col for this slice: row p is the neighbourhood of voxel p.
144            col.iter_mut().for_each(|v| *v = 0.0);
145            for hi in 0..h {
146                for wi in 0..w {
147                    let row = (hi * w + wi) * patch;
148                    for ci in 0..self.c_in {
149                        for kt in 0..k {
150                            let tt = ti as isize + kt as isize - pad as isize;
151                            if tt < 0 || tt >= t as isize {
152                                continue;
153                            }
154                            for kh in 0..k {
155                                let hh = hi as isize + kh as isize - pad as isize;
156                                if hh < 0 || hh >= h as isize {
157                                    continue;
158                                }
159                                for kw in 0..k {
160                                    let ww = wi as isize + kw as isize - pad as isize;
161                                    if ww < 0 || ww >= w as isize {
162                                        continue;
163                                    }
164                                    let idx = ((ci * k + kt) * k + kh) * k + kw;
165                                    col[row + idx] =
166                                        x.at(ci, tt as usize, hh as usize, ww as usize);
167                                }
168                            }
169                        }
170                    }
171                }
172            }
173            crate::gpu::cpu_scope(|| {
174                crate::fcd_ops::gemm_nt(&col, &self.w, &mut acc, n, patch, self.c_out, pool);
175            });
176            for p in 0..n {
177                let (hi, wi) = (p / w, p % w);
178                for co in 0..self.c_out {
179                    out.data[((co * t + ti) * h + hi) * w + wi] = acc[p * self.c_out + co] + self.b[co];
180                }
181            }
182        }
183        out
184    }
185}
186
187/// The temporal depthwise convolution: one channel, one `[k,1,1]` kernel.
188struct DepthwiseT {
189    w: Vec<f32>, // [c, 1, k, 1, 1]
190    b: Vec<f32>,
191    k: usize,
192}
193
194impl DepthwiseT {
195    fn apply(&self, x: &Vol) -> Vol {
196        let mut out = Vol::zeros(x.c, x.t, x.h, x.w);
197        let pad = self.k / 2;
198        for c in 0..x.c {
199            for ti in 0..x.t {
200                for hi in 0..x.h {
201                    for wi in 0..x.w {
202                        let mut acc = self.b[c];
203                        for kt in 0..self.k {
204                            let tt = ti as isize + kt as isize - pad as isize;
205                            if tt < 0 || tt >= x.t as isize {
206                                continue;
207                            }
208                            acc += self.w[c * self.k + kt] * x.at(c, tt as usize, hi, wi);
209                        }
210                        out.data[((c * x.t + ti) * x.h + hi) * x.w + wi] = acc;
211                    }
212                }
213            }
214        }
215        out
216    }
217}
218
219/// `GroupNorm(32)` over the channel axis, per (t, h, w) volume.
220struct GroupNorm {
221    w: Vec<f32>,
222    b: Vec<f32>,
223    groups: usize,
224}
225
226impl GroupNorm {
227    fn apply(&self, x: &Vol) -> Vol {
228        let mut out = x.clone();
229        let per = x.c / self.groups;
230        let n = x.t * x.h * x.w;
231        for g in 0..self.groups {
232            let (lo, hi) = (g * per, (g + 1) * per);
233            let mut sum = 0f64;
234            let mut sq = 0f64;
235            for c in lo..hi {
236                for i in 0..n {
237                    let v = x.data[c * n + i] as f64;
238                    sum += v;
239                    sq += v * v;
240                }
241            }
242            let cnt = (per * n) as f64;
243            let mean = sum / cnt;
244            let var = (sq / cnt - mean * mean).max(0.0);
245            let inv = 1.0 / (var + 1e-5).sqrt();
246            for c in lo..hi {
247                for i in 0..n {
248                    let v = (x.data[c * n + i] as f64 - mean) * inv;
249                    out.data[c * n + i] = v as f32 * self.w[c] + self.b[c];
250                }
251            }
252        }
253        out
254    }
255}
256
257fn silu_in_place(v: &mut [f32]) {
258    for x in v.iter_mut() {
259        *x /= 1.0 + (-*x).exp();
260    }
261}
262
263struct ResBlock {
264    in_norm: GroupNorm,
265    in_conv: Conv3,
266    emb: (Vec<f32>, Vec<f32>), // [2C, E] and [2C]
267    out_norm: GroupNorm,
268    out_conv: Conv3,
269    skip: Option<Conv3>,
270}
271
272impl ResBlock {
273    fn apply(&self, x: &Vol, emb: &[f32], pool: Option<&Pool>) -> Vol {
274        let mut h = self.in_norm.apply(x);
275        silu_in_place(&mut h.data);
276        let h = self.in_conv.apply(&h, pool);
277
278        // emb_layers: SiLU then Linear, giving [scale | shift] over channels.
279        let e_in = emb.len();
280        let two_c = self.emb.1.len();
281        let mut es = emb.to_vec();
282        silu_in_place(&mut es);
283        let mut mod_v = vec![0f32; two_c];
284        for (o, m) in mod_v.iter_mut().enumerate() {
285            let row = &self.emb.0[o * e_in..(o + 1) * e_in];
286            *m = self.emb.1[o] + row.iter().zip(&es).map(|(a, b)| a * b).sum::<f32>();
287        }
288
289        let mut h2 = self.out_norm.apply(&h);
290        let n = h2.t * h2.h * h2.w;
291        let c = h2.c;
292        for ci in 0..c {
293            let (s, sh) = (mod_v[ci], mod_v[c + ci]);
294            for i in 0..n {
295                h2.data[ci * n + i] = h2.data[ci * n + i] * (1.0 + s) + sh;
296            }
297        }
298        silu_in_place(&mut h2.data);
299        let h2 = self.out_conv.apply(&h2, pool);
300
301        let mut out = match &self.skip {
302            Some(cv) => cv.apply(x, pool),
303            None => x.clone(),
304        };
305        for (o, v) in out.data.iter_mut().zip(&h2.data) {
306            *o += *v;
307        }
308        out
309    }
310}
311
312struct TemporalConv {
313    norm: GroupNorm,
314    dw: DepthwiseT,
315    pw: Conv3, // 1×1×1
316}
317
318impl TemporalConv {
319    fn apply(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
320        let mut h = self.norm.apply(x);
321        silu_in_place(&mut h.data);
322        let h = self.dw.apply(&h);
323        let h = self.pw.apply(&h, pool);
324        let mut out = x.clone();
325        for (o, v) in out.data.iter_mut().zip(&h.data) {
326            *o += *v;
327        }
328        out
329    }
330}
331
332enum Block {
333    Res(ResBlock),
334    Temporal(TemporalConv),
335}
336
337pub struct LatentUpscaler {
338    conv_in: Conv3,
339    embed: ((Vec<f32>, Vec<f32>), (Vec<f32>, Vec<f32>)),
340    in_blocks: Vec<Block>,
341    out_blocks: Vec<Block>,
342    norm_out: GroupNorm,
343    conv_out: Conv3,
344    pub channels: usize,
345}
346
347type St = HashMap<String, (Vec<usize>, Vec<f32>)>;
348
349fn take(st: &St, name: &str) -> Result<(Vec<usize>, Vec<f32>), String> {
350    st.get(name)
351        .cloned()
352        .ok_or_else(|| format!("upscaler: missing {name}"))
353}
354
355fn conv(st: &St, prefix: &str) -> Result<Conv3, String> {
356    let (shape, w) = take(st, &format!("{prefix}.weight"))?;
357    let (_, b) = take(st, &format!("{prefix}.bias"))?;
358    if shape.len() != 5 || shape[2] != shape[3] || shape[3] != shape[4] {
359        return Err(format!("{prefix}: expected a cubic 3-D kernel, got {shape:?}"));
360    }
361    Ok(Conv3 { w, b, c_out: shape[0], c_in: shape[1], k: shape[2] })
362}
363
364fn gnorm(st: &St, prefix: &str) -> Result<GroupNorm, String> {
365    let (_, w) = take(st, &format!("{prefix}.weight"))?;
366    let (_, b) = take(st, &format!("{prefix}.bias"))?;
367    Ok(GroupNorm { w, b, groups: 32 })
368}
369
370impl LatentUpscaler {
371    /// Read the published `.safetensors` — the net is a third-party model
372    /// with its own licence, so it is loaded beside the container rather
373    /// than packed into it, the way an adapter is.
374    pub fn load(path: &Path) -> Result<LatentUpscaler, String> {
375        let st = crate::ltxlora::read_safetensors(path)?;
376        let conv_in = conv(&st, "conv_in")?;
377        let channels = conv_in.c_out;
378        let e0 = (take(&st, "embed.0.weight")?.1, take(&st, "embed.0.bias")?.1);
379        let e2 = (take(&st, "embed.2.weight")?.1, take(&st, "embed.2.bias")?.1);
380
381        let load_blocks = |side: &str| -> Result<Vec<Block>, String> {
382            let mut out = Vec::new();
383            for i in 0.. {
384                let p = format!("{side}.{i}");
385                if st.contains_key(&format!("{p}.dwconv.weight")) {
386                    let (shape, w) = take(&st, &format!("{p}.dwconv.weight"))?;
387                    let (_, b) = take(&st, &format!("{p}.dwconv.bias"))?;
388                    out.push(Block::Temporal(TemporalConv {
389                        norm: gnorm(&st, &format!("{p}.norm"))?,
390                        dw: DepthwiseT { w, b, k: shape[2] },
391                        pw: conv(&st, &format!("{p}.pwconv"))?,
392                    }));
393                } else if st.contains_key(&format!("{p}.in_layers.0.weight")) {
394                    let (eshape, ew) = take(&st, &format!("{p}.emb_layers.1.weight"))?;
395                    let (_, eb) = take(&st, &format!("{p}.emb_layers.1.bias"))?;
396                    let _ = eshape;
397                    out.push(Block::Res(ResBlock {
398                        in_norm: gnorm(&st, &format!("{p}.in_layers.0"))?,
399                        in_conv: conv(&st, &format!("{p}.in_layers.2"))?,
400                        emb: (ew, eb),
401                        out_norm: gnorm(&st, &format!("{p}.out_norm"))?,
402                        out_conv: conv(&st, &format!("{p}.out_layers.2"))?,
403                        skip: match st.contains_key(&format!("{p}.skip.weight")) {
404                            true => Some(conv(&st, &format!("{p}.skip"))?),
405                            false => None,
406                        },
407                    }));
408                } else {
409                    break;
410                }
411            }
412            if out.is_empty() {
413                return Err(format!("upscaler: no {side} found"));
414            }
415            Ok(out)
416        };
417
418        Ok(LatentUpscaler {
419            conv_in,
420            embed: (e0, e2),
421            in_blocks: load_blocks("in_blocks")?,
422            out_blocks: load_blocks("out_blocks")?,
423            norm_out: gnorm(&st, "norm_out")?,
424            conv_out: conv(&st, "conv_out")?,
425            channels,
426        })
427    }
428
429    /// `embed(scale − 1)`: Linear, SiLU, Linear.
430    fn embedding(&self, scale: f32) -> Vec<f32> {
431        let ((w0, b0), (w2, b2)) = &self.embed;
432        let mut h: Vec<f32> = b0.iter().zip(w0).map(|(b, w)| b + w * (scale - 1.0)).collect();
433        silu_in_place(&mut h);
434        let e = b2.len();
435        let mut out = vec![0f32; e];
436        for (o, v) in out.iter_mut().enumerate() {
437            let row = &w2[o * h.len()..(o + 1) * h.len()];
438            *v = b2[o] + row.iter().zip(&h).map(|(a, b)| a * b).sum::<f32>();
439        }
440        out
441    }
442
443    /// Trilinear resize, `align_corners=false` — torch's own convention, and
444    /// the temporal axis is left alone by every caller here.
445    fn resize(x: &Vol, t: usize, h: usize, w: usize) -> Vol {
446        let mut out = Vol::zeros(x.c, t, h, w);
447        let map = |o: usize, n_out: usize, n_in: usize| -> (usize, usize, f32) {
448            if n_out == n_in {
449                return (o, o, 0.0);
450            }
451            let s = ((o as f32 + 0.5) * n_in as f32 / n_out as f32 - 0.5).max(0.0);
452            let i0 = s.floor() as usize;
453            let i1 = (i0 + 1).min(n_in - 1);
454            (i0, i1, s - i0 as f32)
455        };
456        for c in 0..x.c {
457            for ti in 0..t {
458                let (t0, t1, ft) = map(ti, t, x.t);
459                for hi in 0..h {
460                    let (h0, h1, fh) = map(hi, h, x.h);
461                    for wi in 0..w {
462                        let (w0, w1, fw) = map(wi, w, x.w);
463                        let p = |tt: usize, hh: usize, ww: usize| x.at(c, tt, hh, ww);
464                        let lerp = |a: f32, b: f32, f: f32| a + (b - a) * f;
465                        let v00 = lerp(p(t0, h0, w0), p(t0, h0, w1), fw);
466                        let v01 = lerp(p(t0, h1, w0), p(t0, h1, w1), fw);
467                        let v10 = lerp(p(t1, h0, w0), p(t1, h0, w1), fw);
468                        let v11 = lerp(p(t1, h1, w0), p(t1, h1, w1), fw);
469                        let v0 = lerp(v00, v01, fh);
470                        let v1 = lerp(v10, v11, fh);
471                        out.data[((c * t + ti) * h + hi) * w + wi] = lerp(v0, v1, ft);
472                    }
473                }
474            }
475        }
476        out
477    }
478
479    /// Upscale a *raw* latent (the sampler's own scale) to `(h_out, w_out)`.
480    /// Normalization by the VAE's channel statistics happens here, because
481    /// the network was trained on the normalized latent and every caller
482    /// holds the raw one.
483    pub fn upscale(&self, z: &Vol, h_out: usize, w_out: usize, pool: Option<&Pool>) -> Vol {
484        assert_eq!(z.c, LATENT_MEAN.len(), "upscaler expects a 24-channel latent");
485        let n = z.t * z.h * z.w;
486        let mut x = z.clone();
487        for c in 0..z.c {
488            for i in 0..n {
489                x.data[c * n + i] = (x.data[c * n + i] - LATENT_MEAN[c]) / LATENT_STD[c];
490            }
491        }
492        // The node feeds the SPATIAL ratio; temporal length never changes.
493        let scale = h_out as f32 / z.h as f32;
494        let emb = self.embedding(scale);
495
496        let mut v = self.conv_in.apply(&x, pool);
497        for b in &self.in_blocks {
498            v = match b {
499                Block::Res(r) => r.apply(&v, &emb, pool),
500                Block::Temporal(t) => t.apply(&v, pool),
501            };
502        }
503        v = Self::resize(&v, v.t, h_out, w_out);
504        for b in &self.out_blocks {
505            v = match b {
506                Block::Res(r) => r.apply(&v, &emb, pool),
507                Block::Temporal(t) => t.apply(&v, pool),
508            };
509        }
510        let mut v = self.norm_out.apply(&v);
511        silu_in_place(&mut v.data);
512        let mut out = self.conv_out.apply(&v, pool);
513        let n = out.t * out.h * out.w;
514        for c in 0..out.c {
515            for i in 0..n {
516                out.data[c * n + i] = out.data[c * n + i] * LATENT_STD[c] + LATENT_MEAN[c];
517            }
518        }
519        out
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    /// `align_corners=false` maps output centres onto input centres — the
528    /// one convention difference that would show as a half-pixel shift over
529    /// a whole clip.
530    #[test]
531    fn resize_matches_torch_convention() {
532        let x = Vol { c: 1, t: 1, h: 1, w: 2, data: vec![0.0, 1.0] };
533        let up = LatentUpscaler::resize(&x, 1, 1, 4);
534        // torch: F.interpolate([0,1], size=4, mode='linear', align_corners=False)
535        // → [0, 0.25, 0.75, 1]
536        let want = [0.0f32, 0.25, 0.75, 1.0];
537        for (g, w) in up.data.iter().zip(&want) {
538            assert!((g - w).abs() < 1e-6, "{:?} vs {want:?}", up.data);
539        }
540    }
541
542    /// A resize to the same size is the identity, not a blur.
543    #[test]
544    fn resize_identity() {
545        let x = Vol { c: 1, t: 2, h: 2, w: 2, data: (0..8).map(|i| i as f32).collect() };
546        let up = LatentUpscaler::resize(&x, 2, 2, 2);
547        assert_eq!(up.data, x.data);
548    }
549}