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(path: &Path) -> Result<HashMap<String, (Vec<usize>, Vec<f32>)>, String> {
95    crate::ltxlora::read_safetensors(path)
96}
97
98/// A latent volume, channel-major: `[c][t][h][w]`.
99#[derive(Clone)]
100pub struct Vol {
101    pub c: usize,
102    pub t: usize,
103    pub h: usize,
104    pub w: usize,
105    pub data: Vec<f32>,
106}
107
108impl Vol {
109    fn zeros(c: usize, t: usize, h: usize, w: usize) -> Vol {
110        Vol {
111            c,
112            t,
113            h,
114            w,
115            data: vec![0f32; c * t * h * w],
116        }
117    }
118    #[inline]
119    fn at(&self, c: usize, t: usize, h: usize, w: usize) -> f32 {
120        self.data[((c * self.t + t) * self.h + h) * self.w + w]
121    }
122}
123
124/// A dense 3-D convolution with a 3×3×3 or 1×1×1 kernel and "same" padding.
125struct Conv3 {
126    w: Vec<f32>, // [c_out, c_in, kt, kh, kw]
127    b: Vec<f32>,
128    c_out: usize,
129    c_in: usize,
130    k: usize, // 1 or 3, cubic
131}
132
133impl Conv3 {
134    /// `dst = conv(x)`. Implemented as im2col + `gemm_nt` one temporal
135    /// slice at a time: the patch matrix for a whole volume at 512
136    /// channels is gigabytes, and for one slice it is a hundred megabytes.
137    fn apply(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
138        let (t, h, w) = (x.t, x.h, x.w);
139        let mut out = Vol::zeros(self.c_out, t, h, w);
140        let k = self.k;
141        let pad = k / 2;
142        let patch = self.c_in * k * k * k;
143        let n = h * w;
144        let mut col = vec![0f32; n * patch];
145        let mut acc = vec![0f32; n * self.c_out];
146        for ti in 0..t {
147            // im2col for this slice: row p is the neighbourhood of voxel p.
148            col.iter_mut().for_each(|v| *v = 0.0);
149            for hi in 0..h {
150                for wi in 0..w {
151                    let row = (hi * w + wi) * patch;
152                    for ci in 0..self.c_in {
153                        for kt in 0..k {
154                            let tt = ti as isize + kt as isize - pad as isize;
155                            if tt < 0 || tt >= t as isize {
156                                continue;
157                            }
158                            for kh in 0..k {
159                                let hh = hi as isize + kh as isize - pad as isize;
160                                if hh < 0 || hh >= h as isize {
161                                    continue;
162                                }
163                                for kw in 0..k {
164                                    let ww = wi as isize + kw as isize - pad as isize;
165                                    if ww < 0 || ww >= w as isize {
166                                        continue;
167                                    }
168                                    let idx = ((ci * k + kt) * k + kh) * k + kw;
169                                    col[row + idx] =
170                                        x.at(ci, tt as usize, hh as usize, ww as usize);
171                                }
172                            }
173                        }
174                    }
175                }
176            }
177            crate::gpu::cpu_scope(|| {
178                crate::fcd_ops::gemm_nt(&col, &self.w, &mut acc, n, patch, self.c_out, pool);
179            });
180            for p in 0..n {
181                let (hi, wi) = (p / w, p % w);
182                for co in 0..self.c_out {
183                    out.data[((co * t + ti) * h + hi) * w + wi] =
184                        acc[p * self.c_out + co] + self.b[co];
185                }
186            }
187        }
188        out
189    }
190}
191
192/// The temporal depthwise convolution: one channel, one `[k,1,1]` kernel.
193struct DepthwiseT {
194    w: Vec<f32>, // [c, 1, k, 1, 1]
195    b: Vec<f32>,
196    k: usize,
197}
198
199impl DepthwiseT {
200    fn apply(&self, x: &Vol) -> Vol {
201        let mut out = Vol::zeros(x.c, x.t, x.h, x.w);
202        let pad = self.k / 2;
203        for c in 0..x.c {
204            for ti in 0..x.t {
205                for hi in 0..x.h {
206                    for wi in 0..x.w {
207                        let mut acc = self.b[c];
208                        for kt in 0..self.k {
209                            let tt = ti as isize + kt as isize - pad as isize;
210                            if tt < 0 || tt >= x.t as isize {
211                                continue;
212                            }
213                            acc += self.w[c * self.k + kt] * x.at(c, tt as usize, hi, wi);
214                        }
215                        out.data[((c * x.t + ti) * x.h + hi) * x.w + wi] = acc;
216                    }
217                }
218            }
219        }
220        out
221    }
222}
223
224/// `GroupNorm(32)` over the channel axis, per (t, h, w) volume.
225struct GroupNorm {
226    w: Vec<f32>,
227    b: Vec<f32>,
228    groups: usize,
229}
230
231impl GroupNorm {
232    fn apply(&self, x: &Vol) -> Vol {
233        let mut out = x.clone();
234        let per = x.c / self.groups;
235        let n = x.t * x.h * x.w;
236        for g in 0..self.groups {
237            let (lo, hi) = (g * per, (g + 1) * per);
238            let mut sum = 0f64;
239            let mut sq = 0f64;
240            for c in lo..hi {
241                for i in 0..n {
242                    let v = x.data[c * n + i] as f64;
243                    sum += v;
244                    sq += v * v;
245                }
246            }
247            let cnt = (per * n) as f64;
248            let mean = sum / cnt;
249            let var = (sq / cnt - mean * mean).max(0.0);
250            let inv = 1.0 / (var + 1e-5).sqrt();
251            for c in lo..hi {
252                for i in 0..n {
253                    let v = (x.data[c * n + i] as f64 - mean) * inv;
254                    out.data[c * n + i] = v as f32 * self.w[c] + self.b[c];
255                }
256            }
257        }
258        out
259    }
260}
261
262fn silu_in_place(v: &mut [f32]) {
263    for x in v.iter_mut() {
264        *x /= 1.0 + (-*x).exp();
265    }
266}
267
268struct ResBlock {
269    in_norm: GroupNorm,
270    in_conv: Conv3,
271    emb: (Vec<f32>, Vec<f32>), // [2C, E] and [2C]
272    out_norm: GroupNorm,
273    out_conv: Conv3,
274    skip: Option<Conv3>,
275}
276
277impl ResBlock {
278    fn apply(&self, x: &Vol, emb: &[f32], pool: Option<&Pool>) -> Vol {
279        let mut h = self.in_norm.apply(x);
280        silu_in_place(&mut h.data);
281        let h = self.in_conv.apply(&h, pool);
282
283        // emb_layers: SiLU then Linear, giving [scale | shift] over channels.
284        let e_in = emb.len();
285        let two_c = self.emb.1.len();
286        let mut es = emb.to_vec();
287        silu_in_place(&mut es);
288        let mut mod_v = vec![0f32; two_c];
289        for (o, m) in mod_v.iter_mut().enumerate() {
290            let row = &self.emb.0[o * e_in..(o + 1) * e_in];
291            *m = self.emb.1[o] + row.iter().zip(&es).map(|(a, b)| a * b).sum::<f32>();
292        }
293
294        let mut h2 = self.out_norm.apply(&h);
295        let n = h2.t * h2.h * h2.w;
296        let c = h2.c;
297        for ci in 0..c {
298            let (s, sh) = (mod_v[ci], mod_v[c + ci]);
299            for i in 0..n {
300                h2.data[ci * n + i] = h2.data[ci * n + i] * (1.0 + s) + sh;
301            }
302        }
303        silu_in_place(&mut h2.data);
304        let h2 = self.out_conv.apply(&h2, pool);
305
306        let mut out = match &self.skip {
307            Some(cv) => cv.apply(x, pool),
308            None => x.clone(),
309        };
310        for (o, v) in out.data.iter_mut().zip(&h2.data) {
311            *o += *v;
312        }
313        out
314    }
315}
316
317struct TemporalConv {
318    norm: GroupNorm,
319    dw: DepthwiseT,
320    pw: Conv3, // 1×1×1
321}
322
323impl TemporalConv {
324    fn apply(&self, x: &Vol, pool: Option<&Pool>) -> Vol {
325        let mut h = self.norm.apply(x);
326        silu_in_place(&mut h.data);
327        let h = self.dw.apply(&h);
328        let h = self.pw.apply(&h, pool);
329        let mut out = x.clone();
330        for (o, v) in out.data.iter_mut().zip(&h.data) {
331            *o += *v;
332        }
333        out
334    }
335}
336
337enum Block {
338    Res(ResBlock),
339    Temporal(TemporalConv),
340}
341
342pub struct LatentUpscaler {
343    conv_in: Conv3,
344    embed: ((Vec<f32>, Vec<f32>), (Vec<f32>, Vec<f32>)),
345    in_blocks: Vec<Block>,
346    out_blocks: Vec<Block>,
347    norm_out: GroupNorm,
348    conv_out: Conv3,
349    pub channels: usize,
350}
351
352type St = HashMap<String, (Vec<usize>, Vec<f32>)>;
353
354fn take(st: &St, name: &str) -> Result<(Vec<usize>, Vec<f32>), String> {
355    st.get(name)
356        .cloned()
357        .ok_or_else(|| format!("upscaler: missing {name}"))
358}
359
360fn conv(st: &St, prefix: &str) -> Result<Conv3, String> {
361    let (shape, w) = take(st, &format!("{prefix}.weight"))?;
362    let (_, b) = take(st, &format!("{prefix}.bias"))?;
363    if shape.len() != 5 || shape[2] != shape[3] || shape[3] != shape[4] {
364        return Err(format!(
365            "{prefix}: expected a cubic 3-D kernel, got {shape:?}"
366        ));
367    }
368    Ok(Conv3 {
369        w,
370        b,
371        c_out: shape[0],
372        c_in: shape[1],
373        k: shape[2],
374    })
375}
376
377fn gnorm(st: &St, prefix: &str) -> Result<GroupNorm, String> {
378    let (_, w) = take(st, &format!("{prefix}.weight"))?;
379    let (_, b) = take(st, &format!("{prefix}.bias"))?;
380    Ok(GroupNorm { w, b, groups: 32 })
381}
382
383impl LatentUpscaler {
384    /// Read the published `.safetensors` — the net is a third-party model
385    /// with its own licence, so it is loaded beside the container rather
386    /// than packed into it, the way an adapter is.
387    pub fn load(path: &Path) -> Result<LatentUpscaler, String> {
388        let st = crate::ltxlora::read_safetensors(path)?;
389        let conv_in = conv(&st, "conv_in")?;
390        let channels = conv_in.c_out;
391        let e0 = (take(&st, "embed.0.weight")?.1, take(&st, "embed.0.bias")?.1);
392        let e2 = (take(&st, "embed.2.weight")?.1, take(&st, "embed.2.bias")?.1);
393
394        let load_blocks = |side: &str| -> Result<Vec<Block>, String> {
395            let mut out = Vec::new();
396            for i in 0.. {
397                let p = format!("{side}.{i}");
398                if st.contains_key(&format!("{p}.dwconv.weight")) {
399                    let (shape, w) = take(&st, &format!("{p}.dwconv.weight"))?;
400                    let (_, b) = take(&st, &format!("{p}.dwconv.bias"))?;
401                    out.push(Block::Temporal(TemporalConv {
402                        norm: gnorm(&st, &format!("{p}.norm"))?,
403                        dw: DepthwiseT { w, b, k: shape[2] },
404                        pw: conv(&st, &format!("{p}.pwconv"))?,
405                    }));
406                } else if st.contains_key(&format!("{p}.in_layers.0.weight")) {
407                    let (eshape, ew) = take(&st, &format!("{p}.emb_layers.1.weight"))?;
408                    let (_, eb) = take(&st, &format!("{p}.emb_layers.1.bias"))?;
409                    let _ = eshape;
410                    out.push(Block::Res(ResBlock {
411                        in_norm: gnorm(&st, &format!("{p}.in_layers.0"))?,
412                        in_conv: conv(&st, &format!("{p}.in_layers.2"))?,
413                        emb: (ew, eb),
414                        out_norm: gnorm(&st, &format!("{p}.out_norm"))?,
415                        out_conv: conv(&st, &format!("{p}.out_layers.2"))?,
416                        skip: match st.contains_key(&format!("{p}.skip.weight")) {
417                            true => Some(conv(&st, &format!("{p}.skip"))?),
418                            false => None,
419                        },
420                    }));
421                } else {
422                    break;
423                }
424            }
425            if out.is_empty() {
426                return Err(format!("upscaler: no {side} found"));
427            }
428            Ok(out)
429        };
430
431        Ok(LatentUpscaler {
432            conv_in,
433            embed: (e0, e2),
434            in_blocks: load_blocks("in_blocks")?,
435            out_blocks: load_blocks("out_blocks")?,
436            norm_out: gnorm(&st, "norm_out")?,
437            conv_out: conv(&st, "conv_out")?,
438            channels,
439        })
440    }
441
442    /// `embed(scale − 1)`: Linear, SiLU, Linear.
443    fn embedding(&self, scale: f32) -> Vec<f32> {
444        let ((w0, b0), (w2, b2)) = &self.embed;
445        let mut h: Vec<f32> = b0
446            .iter()
447            .zip(w0)
448            .map(|(b, w)| b + w * (scale - 1.0))
449            .collect();
450        silu_in_place(&mut h);
451        let e = b2.len();
452        let mut out = vec![0f32; e];
453        for (o, v) in out.iter_mut().enumerate() {
454            let row = &w2[o * h.len()..(o + 1) * h.len()];
455            *v = b2[o] + row.iter().zip(&h).map(|(a, b)| a * b).sum::<f32>();
456        }
457        out
458    }
459
460    /// Trilinear resize, `align_corners=false` — torch's own convention, and
461    /// the temporal axis is left alone by every caller here.
462    fn resize(x: &Vol, t: usize, h: usize, w: usize) -> Vol {
463        let mut out = Vol::zeros(x.c, t, h, w);
464        let map = |o: usize, n_out: usize, n_in: usize| -> (usize, usize, f32) {
465            if n_out == n_in {
466                return (o, o, 0.0);
467            }
468            let s = ((o as f32 + 0.5) * n_in as f32 / n_out as f32 - 0.5).max(0.0);
469            let i0 = s.floor() as usize;
470            let i1 = (i0 + 1).min(n_in - 1);
471            (i0, i1, s - i0 as f32)
472        };
473        for c in 0..x.c {
474            for ti in 0..t {
475                let (t0, t1, ft) = map(ti, t, x.t);
476                for hi in 0..h {
477                    let (h0, h1, fh) = map(hi, h, x.h);
478                    for wi in 0..w {
479                        let (w0, w1, fw) = map(wi, w, x.w);
480                        let p = |tt: usize, hh: usize, ww: usize| x.at(c, tt, hh, ww);
481                        let lerp = |a: f32, b: f32, f: f32| a + (b - a) * f;
482                        let v00 = lerp(p(t0, h0, w0), p(t0, h0, w1), fw);
483                        let v01 = lerp(p(t0, h1, w0), p(t0, h1, w1), fw);
484                        let v10 = lerp(p(t1, h0, w0), p(t1, h0, w1), fw);
485                        let v11 = lerp(p(t1, h1, w0), p(t1, h1, w1), fw);
486                        let v0 = lerp(v00, v01, fh);
487                        let v1 = lerp(v10, v11, fh);
488                        out.data[((c * t + ti) * h + hi) * w + wi] = lerp(v0, v1, ft);
489                    }
490                }
491            }
492        }
493        out
494    }
495
496    /// Upscale a *raw* latent (the sampler's own scale) to `(h_out, w_out)`.
497    /// Normalization by the VAE's channel statistics happens here, because
498    /// the network was trained on the normalized latent and every caller
499    /// holds the raw one.
500    pub fn upscale(&self, z: &Vol, h_out: usize, w_out: usize, pool: Option<&Pool>) -> Vol {
501        assert_eq!(
502            z.c,
503            LATENT_MEAN.len(),
504            "upscaler expects a 24-channel latent"
505        );
506        let n = z.t * z.h * z.w;
507        let mut x = z.clone();
508        for c in 0..z.c {
509            for i in 0..n {
510                x.data[c * n + i] = (x.data[c * n + i] - LATENT_MEAN[c]) / LATENT_STD[c];
511            }
512        }
513        // The node feeds the SPATIAL ratio; temporal length never changes.
514        let scale = h_out as f32 / z.h as f32;
515        let emb = self.embedding(scale);
516
517        let mut v = self.conv_in.apply(&x, pool);
518        for b in &self.in_blocks {
519            v = match b {
520                Block::Res(r) => r.apply(&v, &emb, pool),
521                Block::Temporal(t) => t.apply(&v, pool),
522            };
523        }
524        v = Self::resize(&v, v.t, h_out, w_out);
525        for b in &self.out_blocks {
526            v = match b {
527                Block::Res(r) => r.apply(&v, &emb, pool),
528                Block::Temporal(t) => t.apply(&v, pool),
529            };
530        }
531        let mut v = self.norm_out.apply(&v);
532        silu_in_place(&mut v.data);
533        let mut out = self.conv_out.apply(&v, pool);
534        let n = out.t * out.h * out.w;
535        for c in 0..out.c {
536            for i in 0..n {
537                out.data[c * n + i] = out.data[c * n + i] * LATENT_STD[c] + LATENT_MEAN[c];
538            }
539        }
540        out
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    /// `align_corners=false` maps output centres onto input centres — the
549    /// one convention difference that would show as a half-pixel shift over
550    /// a whole clip.
551    #[test]
552    fn resize_matches_torch_convention() {
553        let x = Vol {
554            c: 1,
555            t: 1,
556            h: 1,
557            w: 2,
558            data: vec![0.0, 1.0],
559        };
560        let up = LatentUpscaler::resize(&x, 1, 1, 4);
561        // torch: F.interpolate([0,1], size=4, mode='linear', align_corners=False)
562        // → [0, 0.25, 0.75, 1]
563        let want = [0.0f32, 0.25, 0.75, 1.0];
564        for (g, w) in up.data.iter().zip(&want) {
565            assert!((g - w).abs() < 1e-6, "{:?} vs {want:?}", up.data);
566        }
567    }
568
569    /// A resize to the same size is the identity, not a blur.
570    #[test]
571    fn resize_identity() {
572        let x = Vol {
573            c: 1,
574            t: 2,
575            h: 2,
576            w: 2,
577            data: (0..8).map(|i| i as f32).collect(),
578        };
579        let up = LatentUpscaler::resize(&x, 2, 2, 2);
580        assert_eq!(up.data, x.data);
581    }
582}