Skip to content

Commit 61bf7c4

Browse files
committed
[feat] Add FastMetal 14B and 5B MLX serving, harden test coverage
1 parent 84e31b8 commit 61bf7c4

6 files changed

Lines changed: 541 additions & 30 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Native MLX FastMetal 14B server. Run from the repository root after
2+
# downloading FastVideo/FastMetal-14B-QAD (it ships a pre-packed MLX DiT,
3+
# so model_root and mlx_checkpoint are the same directory).
4+
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_14b.yaml
5+
runtime: mlx
6+
generator:
7+
model_path: FastVideo/FastMetal-14B-QAD
8+
model_root: ./FastMetal-14B-QAD
9+
mlx_checkpoint: ./FastMetal-14B-QAD
10+
server:
11+
host: 127.0.0.1
12+
port: 8000
13+
served_model_name: fastwan21-14b-mlx
14+
output_dir: outputs/mlx_wan21_14b
15+
default_request:
16+
sampling:
17+
height: 480
18+
width: 832
19+
num_frames: 81
20+
fps: 16
21+
num_inference_steps: 3
22+
guidance_scale: 1.0
23+
seed: 1024

examples/serving/mlx_wan22_5b.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Native MLX FastMetal 5B (Wan2.2-TI2V) server. Run from the repository root
2+
# after downloading FastVideo/FastMetal-5B-QAD (it ships a pre-packed MLX DiT,
3+
# so model_root and mlx_checkpoint are the same directory).
4+
# python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan22_5b.yaml
5+
runtime: mlx
6+
generator:
7+
model_path: FastVideo/FastMetal-5B-QAD
8+
model_root: ./FastMetal-5B-QAD
9+
mlx_checkpoint: ./FastMetal-5B-QAD
10+
server:
11+
host: 127.0.0.1
12+
port: 8000
13+
served_model_name: fastwan22-5b-mlx
14+
output_dir: outputs/mlx_wan22_5b
15+
default_request:
16+
sampling:
17+
height: 704
18+
width: 1280
19+
num_frames: 81
20+
fps: 24
21+
num_inference_steps: 3
22+
guidance_scale: 1.0
23+
seed: 1234

fastvideo/entrypoints/openai/mlx_wan_server.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,24 @@
2121
from fastvideo.entrypoints.openai.api_server import create_app
2222
from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest
2323

24-
MODEL: Literal["FastVideo/FastMetal-1.3B-QAD"] = "FastVideo/FastMetal-1.3B-QAD"
25-
# The DMD-distilled step ladder the validated 1.3B recipe uses (fixed count,
26-
# same reason H3 MLX serving pins num_inference_steps to its own ladder size).
24+
# The DMD-distilled step ladder the validated recipes use (fixed count, same
25+
# reason H3 MLX serving pins num_inference_steps to its own ladder size).
2726
_DMD_STEP_COUNT = 3
2827

28+
# Maps a served model id to the pipeline class that generates it: 1.3B/14B
29+
# share Wan2.1's architecture (MLXWanPipeline), 5B is Wan2.2-TI2V instead
30+
# (MLXWan22Pipeline, 48-channel latents, a different DiT and sampler).
31+
_PIPELINE_CLASS_NAMES = {
32+
"FastVideo/FastMetal-1.3B-QAD": "MLXWanPipeline",
33+
"FastVideo/FastMetal-14B-QAD": "MLXWanPipeline",
34+
"FastVideo/FastMetal-5B-QAD": "MLXWan22Pipeline",
35+
}
36+
2937

3038
class MLXWanGeneratorConfig(BaseModel):
3139
"""Where the two FastMetal checkpoint halves live on disk."""
3240
model_config = ConfigDict(extra="forbid")
33-
model_path: Literal["FastVideo/FastMetal-1.3B-QAD"] = MODEL
41+
model_path: Literal["FastVideo/FastMetal-1.3B-QAD", "FastVideo/FastMetal-14B-QAD", "FastVideo/FastMetal-5B-QAD"]
3442
model_root: str
3543
mlx_checkpoint: str
3644

@@ -107,9 +115,10 @@ def _load(config: MLXWanGeneratorConfig):
107115
raise RuntimeError("Wan MLX serving requires an Apple Silicon Mac.")
108116
if shutil.which("ffmpeg") is None:
109117
raise RuntimeError("Install ffmpeg before starting the Wan MLX server.")
110-
from fastvideo.mlx_runtime.wan_pipeline import MLXWanPipeline
118+
import fastvideo.mlx_runtime.wan_pipeline as wan_pipeline_module
111119

112-
return MLXWanPipeline(
120+
pipeline_cls = getattr(wan_pipeline_module, _PIPELINE_CLASS_NAMES[config.model_path])
121+
return pipeline_cls(
113122
model_root=Path(config.model_root).expanduser(),
114123
mlx_checkpoint=Path(config.mlx_checkpoint).expanduser(),
115124
)
@@ -166,7 +175,7 @@ def create_mlx_wan_app(config: MLXWanServeConfig):
166175
raise ValueError("Wan MLX default_request must set: " + ", ".join(sorted(required - set(explicit))))
167176
validate_wan_video_request(VideoGenerationRequest(prompt="validate config", **explicit))
168177
# Transport admission uses the registered Wan family, not CUDA engine options.
169-
args = SimpleNamespace(model_path=MODEL,
178+
args = SimpleNamespace(model_path=config.generator.model_path,
170179
lora_path=None,
171180
lora_nickname="default",
172181
lora_strength=1.0,

fastvideo/mlx_runtime/wan_pipeline.py

Lines changed: 178 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
# SPDX-License-Identifier: Apache-2.0
2-
"""Text-to-video generation for Wan2.1/FastMetal through the native MLX runtime.
3-
4-
Scoped to the validated cookbook path only: text-to-video, DMD-distilled
5-
denoising, a packed MLX DiT checkpoint, and TAEHV decode. Refine, fast-spatial,
6-
RIFE fast mode, and prompt enrichment stay in the CLI script
7-
(examples/inference/basic/mlx_wan_prompt_to_video.py) -- this module holds
8-
only what a resident server needs to call repeatedly.
9-
10-
Every step below reuses the same helpers the CLI script already runs (prompt
11-
encoding, checkpoint loading, DMD scheduling, VAE decode) so this pipeline and
12-
the script cannot silently drift into two different implementations of the
13-
same math.
2+
"""Text-to-video generation for Wan2.1 and Wan2.2-TI2V (FastMetal) through the
3+
native MLX runtime.
4+
5+
Scoped to each family's validated cookbook path only: text-to-video,
6+
DMD-distilled denoising, a packed MLX DiT checkpoint, and TAEHV decode.
7+
Refine, fast-spatial, RIFE fast mode, and prompt enrichment stay in the CLI
8+
scripts (examples/inference/basic/mlx_wan_prompt_to_video.py and
9+
mlx_wan22_generate.py) -- this module holds only what a resident server needs
10+
to call repeatedly.
11+
12+
Every step below reuses the same helpers the CLI scripts already run (prompt
13+
encoding, checkpoint loading, DMD scheduling, VAE decode) so these pipelines
14+
and the scripts cannot silently drift into different implementations of the
15+
same math. MLXWanPipeline (Wan2.1: 1.3B/14B) and MLXWan22Pipeline (Wan2.2:
16+
5B) share the UMT5 prompt encoder and rotary-embedding builder below, since
17+
that piece is identical across both families; everything DiT/VAE-shaped is
18+
not, because the two are genuinely different architectures.
1419
"""
1520
from __future__ import annotations
1621

1722
from dataclasses import dataclass, field
23+
import json
1824
from pathlib import Path
1925
import time
2026
from typing import Any
@@ -31,11 +37,16 @@
3137

3238
logger = init_logger(__name__)
3339

34-
# Wan2.1's VAE compresses 4x temporally and 8x spatially; this is fixed for
35-
# every Wan2.1/FastMetal checkpoint this pipeline supports (see the guard in
36-
# MLXWanPipeline.__init__ that rejects Wan2.2-TI2V's 48-channel checkpoints).
40+
# Wan2.1's VAE compresses 4x temporally and 8x spatially; Wan2.2-TI2V's
41+
# compresses 4x temporally and 16x spatially. The two families are not
42+
# interchangeable -- MLXWanPipeline/MLXWan22Pipeline each guard against being
43+
# pointed at the other's checkpoint (see _packed_dit_channels below).
3744
_WAN21_TEMPORAL_COMPRESSION = 4
3845
_WAN21_SPATIAL_COMPRESSION = 8
46+
_WAN21_CHANNELS = 16
47+
_WAN22_TEMPORAL_COMPRESSION = 4
48+
_WAN22_SPATIAL_COMPRESSION = 16
49+
_WAN22_CHANNELS = 48
3950
_DEFAULT_DMD_STEPS = (1000, 757, 522)
4051

4152

@@ -133,6 +144,24 @@ def _make_wan_rotary_embeddings(config: dict[str, Any], *, latent_frames: int, l
133144
return mx.array(freqs_cos.numpy()).astype(mx.float32), mx.array(freqs_sin.numpy()).astype(mx.float32)
134145

135146

147+
def _packed_dit_channels(mlx_checkpoint: Path) -> int | None:
148+
"""Read in_channels from a packed mlx_dit.json, or None if unreadable.
149+
150+
A best-effort check: an unpacked/diffusers-style or missing checkpoint is
151+
left for generate() to fail on when it actually loads the weights.
152+
"""
153+
manifest_path = mlx_checkpoint / "mlx_dit.json"
154+
if not manifest_path.is_file():
155+
return None
156+
try:
157+
manifest = json.loads(manifest_path.read_text())
158+
except (json.JSONDecodeError, OSError):
159+
return None
160+
config = manifest.get("config", manifest)
161+
channels = config.get("in_channels")
162+
return int(channels) if channels is not None else None
163+
164+
136165
class MLXWanPipeline:
137166
"""Text-to-video generation through the native MLX runtime (Wan2.1/FastMetal)."""
138167

@@ -145,6 +174,11 @@ def __init__(self, *, model_root: str | Path, mlx_checkpoint: str | Path) -> Non
145174
raise ValueError(str(error)) from error
146175
if not (self.model_root / "tokenizer").exists() or not (self.model_root / "text_encoder").exists():
147176
raise FileNotFoundError(f"Missing tokenizer/ or text_encoder/ under {self.model_root}.")
177+
channels = _packed_dit_channels(self.mlx_checkpoint)
178+
if channels == _WAN22_CHANNELS:
179+
raise ValueError(f"{self.mlx_checkpoint} is a {channels}-channel Wan2.2-TI2V checkpoint "
180+
"(e.g. FastMetal-5B-QAD); MLXWanPipeline only supports Wan2.1's "
181+
f"{_WAN21_CHANNELS}-channel checkpoints (1.3B/14B). Use MLXWan22Pipeline instead.")
148182

149183
def generate(
150184
self,
@@ -263,3 +297,132 @@ def generate(
263297
k: v
264298
for k, v in timings.items() if k.endswith("_gib")
265299
})
300+
301+
302+
class MLXWan22Pipeline:
303+
"""Text-to-video generation through the native MLX runtime (Wan2.2-TI2V/FastMetal-5B)."""
304+
305+
def __init__(self, *, model_root: str | Path, mlx_checkpoint: str | Path) -> None:
306+
self.model_root = Path(model_root)
307+
self.mlx_checkpoint = Path(mlx_checkpoint)
308+
try:
309+
raise_if_unsupported_mlx_checkpoint(self.mlx_checkpoint)
310+
except UnsupportedMLXCheckpointError as error:
311+
raise ValueError(str(error)) from error
312+
if not (self.model_root / "tokenizer").exists() or not (self.model_root / "text_encoder").exists():
313+
raise FileNotFoundError(f"Missing tokenizer/ or text_encoder/ under {self.model_root}.")
314+
channels = _packed_dit_channels(self.mlx_checkpoint)
315+
if channels is not None and channels != _WAN22_CHANNELS:
316+
raise ValueError(f"{self.mlx_checkpoint} is a {channels}-channel checkpoint; MLXWan22Pipeline only "
317+
f"supports Wan2.2-TI2V's {_WAN22_CHANNELS}-channel checkpoints (FastMetal-5B-QAD). "
318+
"Use MLXWanPipeline for 1.3B/14B.")
319+
320+
def generate(
321+
self,
322+
prompt: str,
323+
*,
324+
output_path: str | Path,
325+
# Defaults match the validated FastMetal-5B-QAD cookbook recipe, not
326+
# mlx_wan22_generate.py's own argparse defaults (448x832x121), which
327+
# were never the evidence-backed shape for this checkpoint.
328+
height: int = 704,
329+
width: int = 1280,
330+
num_frames: int = 81,
331+
seed: int = 1234,
332+
dmd_denoising_steps: tuple[int, ...] = _DEFAULT_DMD_STEPS,
333+
flow_shift: float = 5.0,
334+
fps: int = 24,
335+
max_sequence_length: int = 512,
336+
) -> GenerationResult:
337+
import mlx.core as mx
338+
import torch
339+
340+
from fastvideo.mlx_runtime.wan22 import mlx_wan22_dit_from_mlx_checkpoint
341+
from fastvideo.mlx_runtime.wan22_sample import sample_wan22_dmd
342+
from fastvideo.mlx_runtime.wan_vae import decode_latents_to_video
343+
344+
timings: dict[str, float] = {}
345+
mx.random.seed(seed)
346+
347+
started = time.perf_counter()
348+
prompt_embeds = _encode_wan_prompt(model_root=self.model_root,
349+
prompt=prompt,
350+
max_sequence_length=max_sequence_length)
351+
timings["encode_s"] = time.perf_counter() - started
352+
353+
plan = plan_refine_resolutions(
354+
height=height,
355+
width=width,
356+
num_frames=num_frames,
357+
vae_spatial_compression=_WAN22_SPATIAL_COMPRESSION,
358+
vae_temporal_compression=_WAN22_TEMPORAL_COMPRESSION,
359+
enabled=False,
360+
)
361+
362+
started = time.perf_counter()
363+
mx.clear_cache()
364+
mx.reset_peak_memory()
365+
dit = mlx_wan22_dit_from_mlx_checkpoint(self.mlx_checkpoint, compile=True)
366+
timings["load_s"] = time.perf_counter() - started
367+
timings["load_peak_gib"] = _peak_memory_gib()
368+
369+
latents_seed = torch.Generator(device="cpu").manual_seed(seed)
370+
latents_torch = torch.randn(
371+
(1, int(
372+
dit.config["in_channels"]), plan.latent_frames, plan.stage1_latent_height, plan.stage1_latent_width),
373+
generator=latents_seed,
374+
dtype=torch.float32,
375+
)
376+
noise = mx.array(latents_torch.numpy()).astype(mx.float16)
377+
encoder_hidden_states = mx.array(prompt_embeds.numpy()).astype(mx.float16)
378+
freqs_cis = _make_wan_rotary_embeddings(
379+
dit.config,
380+
latent_frames=plan.latent_frames,
381+
latent_height=plan.stage1_latent_height,
382+
latent_width=plan.stage1_latent_width,
383+
)
384+
385+
started = time.perf_counter()
386+
mx.reset_peak_memory()
387+
# sample_wan22_dmd's own re-noise seed defaults to 0 in the CLI script
388+
# (--renoise-seed), independent of --seed; matched here rather than
389+
# exposed as a second knob nobody overrides in the validated recipe.
390+
latents = sample_wan22_dmd(
391+
dit,
392+
encoder_hidden_states,
393+
noise,
394+
freqs_cis,
395+
dmd_denoising_steps=list(dmd_denoising_steps),
396+
flow_shift=flow_shift,
397+
warp_denoising_step=True,
398+
seed=0,
399+
)
400+
timings["denoise_s"] = time.perf_counter() - started
401+
timings["denoise_peak_gib"] = _peak_memory_gib()
402+
403+
latents_np = np.array(latents.astype(mx.float32))
404+
# Free the DiT before decode, matching the CLI script's phase-memory
405+
# policy -- the 5B DiT and the decoder are not held resident together.
406+
del dit, latents, encoder_hidden_states, freqs_cis, noise
407+
cleanup_mlx()
408+
409+
started = time.perf_counter()
410+
output_path = Path(output_path)
411+
decode_latents_to_video(
412+
latents_np,
413+
output_path,
414+
fps=fps,
415+
backend="taehv",
416+
z_dim=latents_np.shape[1],
417+
taehv_checkpoint=None,
418+
torch_device="auto",
419+
)
420+
timings["decode_s"] = time.perf_counter() - started
421+
cleanup_torch_mps()
422+
423+
return GenerationResult(video_path=str(output_path),
424+
timings=timings,
425+
peak_memory_gib={
426+
k: v
427+
for k, v in timings.items() if k.endswith("_gib")
428+
})

0 commit comments

Comments
 (0)