Skip to content

Commit a28f2ba

Browse files
authored
[feat] Add an optional MLX TAEH3 preview decoder (#1794)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
1 parent f82d8be commit a28f2ba

9 files changed

Lines changed: 642 additions & 3 deletions

File tree

docs/inference/mlx_taeh3.md

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Decode H3 previews with TAEH3 on MLX
2+
3+
TAEH3 is an optional tiny video decoder for MiniMax H3. It replaces only
4+
video reconstruction. The denoiser, sampler, resolution, frame count, and
5+
audio decoder stay unchanged. The full H3 VAE remains the default.
6+
7+
TAEH3 produces a different reconstruction. Fine fur, fabric, vegetation,
8+
and surface textures can look softer. Use it for previews or when you accept
9+
that tradeoff. It is not a lossless acceleration of the full VAE.
10+
11+
## Generate a video
12+
13+
Use your existing MLX FastH3 environment and converted checkpoint:
14+
15+
```bash
16+
python examples/inference/basic/mlx_fasth3.py \
17+
--model-root ~/models/FastH3-Preview-v0.2 \
18+
--mlx-checkpoint ~/models/FastH3-MLX/int6 \
19+
--prompt 'A red panda beside a mountain lake at sunrise.' \
20+
--height 480 --width 832 --num-frames 124 --steps 4 --seed 2027 \
21+
--video-decode-backend taeh3 --vae-dtype fp32 \
22+
--output-path video_samples/taeh3_preview.mp4
23+
```
24+
25+
The first run downloads a 22.7 MB safetensors checkpoint from an immutable
26+
upstream revision and verifies its SHA-256 digest. No remote Python code runs.
27+
The cache is `~/.cache/fastvideo/taehv/taeh3.safetensors`.
28+
Use `--taeh3-checkpoint /path/to/taeh3.safetensors` for offline use or a custom
29+
trained checkpoint. Custom files must match the decoder architecture; they
30+
are not required to match the upstream digest.
31+
32+
| Option | Default | Behavior |
33+
| --- | --- | --- |
34+
| `--video-decode-backend` | `h3-vae` | Select `taeh3` for approximate decoding. |
35+
| `--taeh3-checkpoint` | Unset | Use the pinned upstream checkpoint from cache. |
36+
| `--taeh3-chunk-size` | `5` | Latent frames per execution chunk. Smaller chunks reduce feature memory. |
37+
| `--vae-dtype` | `fp32` | Decoder computation dtype. FP16 and BF16 are separate numerical tradeoffs. |
38+
39+
`--tiled-video-decode` controls the full VAE only. TAEH3 uses the whole spatial
40+
canvas and bounded temporal chunks. Its memory blocks carry state across
41+
chunks. The pipeline reports `video_decode_backend`, decode timing, and MLX
42+
peak memory alongside the existing generation metrics.
43+
44+
The mode composes with `--fast-spatial` and temporal `--fast`. Those options
45+
change the denoising workload and have additional quality costs. A TAEH3-only
46+
measurement does not establish the quality or speed of a combined mode.
47+
48+
## Latent contract
49+
50+
The native decoder reads normalized diffusion latents in NCTHW layout through
51+
`decode_latents_taeh3_mlx`. Do not apply the full VAE's mean, standard deviation,
52+
or pixel denormalization. Its 24 latent channels reconstruct RGB at 16 times
53+
the latent spatial dimensions.
54+
55+
H3 uses latent lengths `5*k-3`, such as 2, 7, and 37. TAEH3 removes three raw
56+
frames from each group of 20 decoder outputs. Thus 37 latent frames produce
57+
124 RGB frames. The port validates that contract before returning output.
58+
59+
## Provenance and validation
60+
61+
Architecture and weights come from Ollin Boer Bohan's MIT-licensed
62+
[TAEHV H3 implementation](https://github.com/madebyollin/taehv/commit/62f7591f59dfbb4c3c02b7a621d180a9eeaba26c).
63+
The [Aryan fork](https://github.com/aryan5v/taehv/tree/aryan/first-class-taeh3)
64+
adds an explicit `TAEH3` API and checkpoint-loading tests. The fork is not an
65+
official MiniMax release and does not contain newly trained H3 weights.
66+
67+
Run the numerical tests against a local TAEHV checkout containing the released
68+
weights:
69+
70+
```bash
71+
TAEH3_REFERENCE_DIR=/path/to/taehv \
72+
python -m pytest fastvideo/tests/mlx/test_mlx_taeh3.py -q
73+
```
74+
75+
Tests compare MLX FP32 with upstream sequential FP32 and parallel FP64 at
76+
`atol=1e-5, rtol=1e-5`. The initial parallel CPU FP32 comparison failed that
77+
strict gate, reaching about `4e-5` maximum error on a 37-latent small fixture.
78+
CPU convolution rounding changes with its batch size. The FP64 reference
79+
and sequential FP32 checks distinguish this from a temporal chunking error.
80+
The original failed comparison is not reported as a pass.
81+
82+
Passing these tests means the MLX port agrees with the tiny decoder within
83+
the specified tolerance. It does not mean TAEH3 matches the full H3 VAE.
84+
85+
## Compare decoders without another denoising run
86+
87+
Save the normalized packed `video_rows` returned by `pipeline.denoise` as
88+
`np.savez("latents.npz", video=video_rows)`. Then run:
89+
90+
```bash
91+
python examples/inference/basic/mlx_h3_decode_benchmark.py \
92+
--latents latents.npz \
93+
--model-root ~/models/FastH3-Preview-v0.2 \
94+
--mlx-checkpoint ~/models/FastH3-MLX/int6 \
95+
--height 480 --width 832 --num-frames 124 \
96+
--output-dir outputs/taeh3_comparison
97+
```
98+
99+
Use a fresh output directory. The benchmark writes the first decoded frame
100+
arrays and a JSON report with the input digest, MLX version, device, per-trial
101+
latency, MLX peak active memory, lifetime process peak RSS, and swap snapshots.
102+
Decoder loading is included; first-time checkpoint downloading is excluded.
103+
Only run one MLX workload at a time. `--repeats` reverses the decoder order on
104+
alternate trials. Do not treat two memory counters as additive or infer zero
105+
page-outs from unchanged swap snapshots.
106+
107+
## Measured decoder results
108+
109+
On an Apple M4 Max with 36 GB unified memory, MLX 0.32.2, FP32 decoding,
110+
and five-latent execution chunks:
111+
112+
| Workload | Full H3 VAE | TAEH3 |
113+
| --- | --- | --- |
114+
| Saved 37-frame latents to 124 RGB frames, 832x480 | 107.90 s | 1.44 s |
115+
| MLX peak active memory for that decode | 11.03 GiB | 3.62 GiB |
116+
117+
These are one matched pair using the same seed-2027 production latent file,
118+
including decoder loading. Both swap snapshots stayed unchanged. The decoded
119+
images differ: PSNR against the full VAE was 29.86 dB, and inspected frames
120+
showed softer fine detail. The approximately 75x ratio applies only to this
121+
decoder comparison, not the entire generation pipeline.
122+
123+
Eight additional TAEH3-only decodes measured 0.96 s on first use and a 0.98 s median across seven warm trials, ranging from 0.96 to 0.99 s. Decoder construction and weight loading were included in each trial. The full VAE was not repeated eight times.
124+
125+
### Native resolution with temporal fast
126+
127+
A separate uncached run with `--fast --video-decode-backend taeh3`, without
128+
spatial fast, completed in **205.47 s wall time**. It kept the native 832x480
129+
canvas, denoised 73 source frames, and used RIFE to produce 124 output frames
130+
with full-duration audio. Seed 2027, four steps, dense attention, INT6.
131+
132+
Prompt encoding took 16.66 s, denoising 181.14 s, TAEH3 decoding 0.64 s, RIFE
133+
5.23 s, audio decoding 0.73 s, and muxing 0.40 s. Peak denoise MLX allocation
134+
was 17.87 GiB. System swap use rose from 1231.19 to 2753.75 MiB.
135+
The output has 124 H.264 frames at 832x480 and stereo AAC at 32 kHz.
136+
137+
This is one combined-mode measurement. It preserves spatial resolution but
138+
still combines frame interpolation with approximate decoding. Frame samples
139+
retain more fine detail than the spatial fast experiment below. Motion and
140+
speech need human review; do not infer native dense generation parity.
141+
142+
### Spatial fast experiment, not the preferred quality path
143+
144+
A separate uncached generation with `--fast-spatial --video-decode-backend
145+
taeh3` produced a 124-frame, 832x480 MP4 with stereo audio in **95.75 s wall
146+
time**. Seed 2028, four denoising calls, INT6 weights, dense attention, and no
147+
temporal fast mode were used. The internal canvas was 416x256, then cropped
148+
and upscaled to the requested output size.
149+
150+
That run spent 16.66 s encoding the prompt, 77.05 s denoising, 0.34 s decoding
151+
video, 0.30 s upscaling, 0.73 s decoding audio, and 0.40 s muxing. Peak denoise
152+
MLX allocation was 16.85 GiB. System swap use rose from 1041.94 to 1247.19 MiB;
153+
this measurement does not attribute that increase to a particular process.
154+
It is one end-to-end result, not a repeated benchmark or native-resolution
155+
quality comparison. The reduced canvas visibly loses detail, especially in
156+
the opening frames. Speech intelligibility and motion quality need human
157+
review before treating this combination as a final-output preset.

examples/inference/basic/mlx_fasth3.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ def parse_args() -> argparse.Namespace:
9898
parser.add_argument("--rife-weights-dir", type=Path, default=None,
9999
help="optional local mlx-community/RIFE-4.25 snapshot")
100100
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
101+
parser.add_argument("--video-decode-backend", choices=("h3-vae", "taeh3"), default="h3-vae",
102+
help="full H3 VAE or approximate TAEH3 preview decoder; audio is unchanged")
103+
parser.add_argument("--taeh3-checkpoint", type=Path, default=None,
104+
help="local TAEH3 safetensors; otherwise download hash-verified upstream weights")
105+
parser.add_argument("--taeh3-chunk-size", type=int, default=5,
106+
help="latent frames per TAEH3 chunk; causal memory persists across chunks")
101107
parser.add_argument("--prompt-cache-dir", type=Path, default=None,
102108
help="directory for reusable prompt embedding caches")
103109
parser.add_argument(
@@ -169,6 +175,9 @@ def main() -> None:
169175
model_root=args.model_root,
170176
mlx_dit_checkpoint=args.mlx_checkpoint,
171177
vae_dtype=args.vae_dtype,
178+
video_decode_backend=args.video_decode_backend,
179+
taeh3_checkpoint=args.taeh3_checkpoint,
180+
taeh3_chunk_size=args.taeh3_chunk_size,
172181
prompt_cache_dir=args.prompt_cache_dir,
173182
)
174183
result = pipeline.generate(
@@ -201,6 +210,7 @@ def main() -> None:
201210
"timings_s": {k: round(v, 2) for k, v in result.timings.items()},
202211
"peak_memory_gib": {k: round(v, 2) for k, v in result.peak_memory_gib.items()},
203212
"vsa": result.vsa,
213+
"video_decode_backend": result.video_decode_backend,
204214
"audio_samples": int(result.waveform.shape[-1]),
205215
}, indent=2))
206216

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Compare H3 decoders on saved normalized video rows without rerunning denoise.
3+
4+
The input NPZ must contain a ``video`` array of packed diffusion rows. Geometry
5+
and the DiT manifest must match the generation that produced those rows.
6+
"""
7+
from __future__ import annotations
8+
9+
import argparse
10+
import hashlib
11+
import json
12+
import platform
13+
import resource
14+
import subprocess
15+
import time
16+
from pathlib import Path
17+
18+
import numpy as np
19+
20+
21+
def main() -> None:
22+
parser = argparse.ArgumentParser(description=__doc__)
23+
parser.add_argument("--latents", type=Path, required=True)
24+
parser.add_argument("--model-root", type=Path, required=True)
25+
parser.add_argument("--mlx-checkpoint", type=Path, required=True)
26+
parser.add_argument("--output-dir", type=Path, required=True)
27+
parser.add_argument("--height", type=int, default=480)
28+
parser.add_argument("--width", type=int, default=832)
29+
parser.add_argument("--num-frames", type=int, default=124)
30+
parser.add_argument("--backends", nargs="+", choices=("h3-vae", "taeh3"), default=["h3-vae", "taeh3"])
31+
parser.add_argument("--taeh3-checkpoint", type=Path)
32+
parser.add_argument("--taeh3-chunk-size", type=int, default=5)
33+
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
34+
parser.add_argument("--repeats", type=int, default=1)
35+
args = parser.parse_args()
36+
if args.repeats < 1:
37+
parser.error("--repeats must be positive")
38+
if args.output_dir.exists():
39+
parser.error("--output-dir must be a new directory to preserve previous results")
40+
41+
import mlx.core as mx
42+
from fastvideo.mlx_runtime.minimax_h3_pipeline import MiniMaxH3MLXPipeline, _cleanup_mlx
43+
from fastvideo.mlx_runtime.minimax_h3_taeh3 import ensure_taeh3_checkpoint
44+
45+
with np.load(args.latents) as archive:
46+
rows = archive["video"]
47+
if not np.isfinite(rows).all():
48+
raise ValueError("The saved video rows contain non-finite values")
49+
args.output_dir.mkdir(parents=True)
50+
checksum = hashlib.sha256()
51+
with args.latents.open("rb") as handle:
52+
for chunk in iter(lambda: handle.read(1 << 20), b""):
53+
checksum.update(chunk)
54+
checkpoint = ensure_taeh3_checkpoint(args.taeh3_checkpoint) if "taeh3" in args.backends else None
55+
report = {
56+
"mlx": mx.__version__,
57+
"platform": platform.platform(),
58+
"device": mx.device_info(),
59+
"latents": str(args.latents.resolve()),
60+
"latents_sha256": checksum.hexdigest(),
61+
"geometry": [args.height, args.width, args.num_frames],
62+
"dtype": args.vae_dtype,
63+
"chunk_size": args.taeh3_chunk_size,
64+
"checkpoint_download_excluded": True,
65+
"decoder_loading_included": True,
66+
"trials": [],
67+
}
68+
for repeat in range(args.repeats):
69+
# Reverse each paired trial's order to expose warmup/order effects.
70+
order = args.backends if repeat % 2 == 0 else args.backends[::-1]
71+
for backend in order:
72+
pipeline = MiniMaxH3MLXPipeline(model_root=args.model_root,
73+
mlx_dit_checkpoint=args.mlx_checkpoint,
74+
video_decode_backend=backend,
75+
taeh3_checkpoint=checkpoint if backend == "taeh3" else None,
76+
taeh3_chunk_size=args.taeh3_chunk_size,
77+
vae_dtype=args.vae_dtype)
78+
_cleanup_mlx()
79+
mx.reset_peak_memory()
80+
before = subprocess.check_output(["sysctl", "-n", "vm.swapusage"], text=True).strip()
81+
started = time.perf_counter()
82+
frames = pipeline.decode_video(rows, height=args.height, width=args.width, num_frames=args.num_frames)
83+
elapsed = time.perf_counter() - started
84+
trial = {
85+
"repeat": repeat,
86+
"backend": backend,
87+
"decode_s": elapsed,
88+
"shape": list(frames.shape),
89+
"peak_active_gib": mx.get_peak_memory() / 2**30,
90+
"process_lifetime_rss_peak_gib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 2**30,
91+
"swap_before": before,
92+
"swap_after": subprocess.check_output(["sysctl", "-n", "vm.swapusage"], text=True).strip(),
93+
}
94+
if repeat == 0:
95+
np.save(args.output_dir / f"{backend}_frames.npy", frames)
96+
report["trials"].append(trial)
97+
(args.output_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n")
98+
print(json.dumps(trial), flush=True)
99+
del frames, pipeline
100+
_cleanup_mlx()
101+
102+
103+
if __name__ == "__main__":
104+
main()

fastvideo/mlx_runtime/minimax_h3_pipeline.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class GenerationResult:
7171
timings: dict[str, float] = field(default_factory=dict)
7272
peak_memory_gib: dict[str, float] = field(default_factory=dict)
7373
vsa: dict[str, Any] = field(default_factory=dict)
74+
video_decode_backend: str = "h3-vae"
7475

7576

7677
@dataclass(frozen=True)
@@ -305,6 +306,9 @@ def __init__(
305306
conditioner_dir: str | Path | None = None,
306307
tokenizer_dir: str | Path | None = None,
307308
metal_wired_limit_gib: float | None = None,
309+
video_decode_backend: str = "h3-vae",
310+
taeh3_checkpoint: str | Path | None = None,
311+
taeh3_chunk_size: int = 5,
308312
) -> None:
309313
import mlx.core as mx
310314

@@ -322,6 +326,15 @@ def __init__(
322326
self.model_root = Path(model_root)
323327
self.dit_checkpoint = Path(mlx_dit_checkpoint)
324328
self.vae_dtype = vae_dtype
329+
if video_decode_backend not in ("h3-vae", "taeh3"):
330+
raise ValueError(f"Unknown H3 video decoder: {video_decode_backend}")
331+
if taeh3_chunk_size < 1:
332+
raise ValueError("taeh3_chunk_size must be positive.")
333+
if taeh3_checkpoint is not None and video_decode_backend != "taeh3":
334+
raise ValueError("taeh3_checkpoint requires video_decode_backend='taeh3'.")
335+
self.video_decode_backend = video_decode_backend
336+
self.taeh3_checkpoint = taeh3_checkpoint
337+
self.taeh3_chunk_size = taeh3_chunk_size
325338
self.prompt_cache_dir = Path(prompt_cache_dir) if prompt_cache_dir else None
326339
self.conditioner_dir = Path(conditioner_dir) if conditioner_dir else self.model_root / "text_encoder"
327340
self.tokenizer_dir = Path(tokenizer_dir) if tokenizer_dir else self.model_root / "tokenizer"
@@ -344,7 +357,7 @@ def _validate_inputs_before_loading(self) -> bool:
344357
missing.append(str(self.dit_checkpoint))
345358
vae_dir = self.model_root / "vae"
346359
audio_dir = self.model_root / "audio_vae"
347-
if not (vae_dir.exists() and any(vae_dir.glob("*.safetensors"))):
360+
if self.video_decode_backend == "h3-vae" and not (vae_dir.exists() and any(vae_dir.glob("*.safetensors"))):
348361
missing.append(str(vae_dir))
349362
if not (audio_dir.exists() and any(audio_dir.glob("*.safetensors"))):
350363
missing.append(str(audio_dir))
@@ -562,6 +575,22 @@ def decode_video(self,
562575
from fastvideo.mlx_runtime.minimax_h3_video_vae import mlx_h3_video_vae_from_dir
563576

564577
geometry = self.resolve_geometry(height, width, num_frames, enforce_duration=False)
578+
if self.video_decode_backend == "taeh3":
579+
from fastvideo.mlx_runtime.minimax_h3_taeh3 import decode_latents_taeh3_mlx
580+
581+
if self._dit_in_channels != 24:
582+
raise ValueError("TAEH3 requires a 24-channel H3 checkpoint.")
583+
latents = unpatchify_video_tokens(video_rows, geometry["latent_frame_count"], geometry["latent_height"],
584+
geometry["latent_width"], self._dit_in_channels, self._dit_patch_size)
585+
pixels = decode_latents_taeh3_mlx(latents,
586+
checkpoint_path=self.taeh3_checkpoint,
587+
dtype=self.vae_dtype,
588+
chunk_size=self.taeh3_chunk_size)
589+
frames = (pixels[0] * 255.0).astype(np.uint8)
590+
if frames.shape != (geometry["num_frames"], height, width, 3):
591+
raise RuntimeError(f"TAEH3 produced unexpected frame shape: {frames.shape}")
592+
_cleanup_mlx()
593+
return frames
565594
vae = mlx_h3_video_vae_from_dir(self.model_root / "vae", include_encoder=False, storage_dtype=self.vae_dtype)
566595
expected_height = height // vae.spatial_compression_ratio
567596
expected_width = width // vae.spatial_compression_ratio
@@ -775,6 +804,14 @@ def generate(
775804
spatial_plan,
776805
)
777806

807+
if self.video_decode_backend == "taeh3":
808+
from fastvideo.mlx_runtime.minimax_h3_taeh3 import ensure_taeh3_checkpoint
809+
810+
started = time.perf_counter()
811+
ensure_taeh3_checkpoint(self.taeh3_checkpoint)
812+
timings["decoder_prepare_s"] = time.perf_counter() - started
813+
logger.warning("TAEH3 is an approximate preview decoder; reconstruction differs from the full H3 VAE.")
814+
778815
_reset_peak_memory()
779816
started = time.perf_counter()
780817
text_rows, token_tags = self.encode_prompt(prompt)
@@ -872,8 +909,8 @@ def generate(
872909
video_path = self.mux(frames, waveform, output_path)
873910
timings["mux_s"] = time.perf_counter() - started
874911
timings["generate_s"] = sum(
875-
timings.get(key, 0.0) for key in ("condition_s", "denoise_s", "video_decode_s", "rife_s",
876-
"spatial_upsample_s", "audio_decode_s", "mux_s"))
912+
timings.get(key, 0.0) for key in ("decoder_prepare_s", "condition_s", "denoise_s", "video_decode_s",
913+
"rife_s", "spatial_upsample_s", "audio_decode_s", "mux_s"))
877914

878915
result = GenerationResult(
879916
video_path=str(video_path),
@@ -882,6 +919,7 @@ def generate(
882919
sample_rate=32000,
883920
timings=timings,
884921
peak_memory_gib=peaks,
922+
video_decode_backend=self.video_decode_backend,
885923
vsa=getattr(self, "last_vsa_stats", None) or {
886924
"enabled": vsa_config.enabled,
887925
"checkpoint_vsa_capable": mlx_h3_checkpoint_vsa_capable(self.dit_checkpoint),

0 commit comments

Comments
 (0)