-
Notifications
You must be signed in to change notification settings - Fork 443
Adds fastvideo serve support for FastMetal (Wan on Mac/MLX) — all three sizes: 1.3B, 14B, and 5B. #1802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Ishxn20
wants to merge
3
commits into
hao-ai-lab:main
Choose a base branch
from
Ishxn20:feat/wan-mlx-serving
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Adds fastvideo serve support for FastMetal (Wan on Mac/MLX) — all three sizes: 1.3B, 14B, and 5B. #1802
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Native MLX FastMetal 14B server. Run from the repository root after | ||
| # downloading FastVideo/FastMetal-14B-QAD (it ships a pre-packed MLX DiT, | ||
| # so model_root and mlx_checkpoint are the same directory). | ||
| # python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_14b.yaml | ||
| runtime: mlx | ||
| generator: | ||
| model_path: FastVideo/FastMetal-14B-QAD | ||
| model_root: ./FastMetal-14B-QAD | ||
| mlx_checkpoint: ./FastMetal-14B-QAD | ||
| server: | ||
| host: 127.0.0.1 | ||
| port: 8000 | ||
| served_model_name: fastwan21-14b-mlx | ||
| output_dir: outputs/mlx_wan21_14b | ||
| default_request: | ||
| sampling: | ||
| height: 480 | ||
| width: 832 | ||
| num_frames: 81 | ||
| fps: 16 | ||
| num_inference_steps: 3 | ||
| guidance_scale: 1.0 | ||
| seed: 1024 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Native MLX FastMetal 1.3B server. Run from the repository root after | ||
| # downloading FastVideo/FastMetal-1.3B-QAD (it ships a pre-packed MLX DiT, | ||
| # so model_root and mlx_checkpoint are the same directory). | ||
| # python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan21_1_3b.yaml | ||
| runtime: mlx | ||
| generator: | ||
| model_path: FastVideo/FastMetal-1.3B-QAD | ||
| model_root: ./FastMetal-1.3B-QAD | ||
| mlx_checkpoint: ./FastMetal-1.3B-QAD | ||
| server: | ||
| host: 127.0.0.1 | ||
| port: 8000 | ||
| served_model_name: fastwan21-1.3b-mlx | ||
| output_dir: outputs/mlx_wan21_1_3b | ||
| default_request: | ||
| sampling: | ||
| height: 480 | ||
| width: 832 | ||
| num_frames: 81 | ||
| fps: 16 | ||
| num_inference_steps: 3 | ||
| guidance_scale: 1.0 | ||
| seed: 1024 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Native MLX FastMetal 5B (Wan2.2-TI2V) server. Run from the repository root | ||
| # after downloading FastVideo/FastMetal-5B-QAD (it ships a pre-packed MLX DiT, | ||
| # so model_root and mlx_checkpoint are the same directory). | ||
| # python -m fastvideo.entrypoints.openai.mlx_wan_server --config examples/serving/mlx_wan22_5b.yaml | ||
| runtime: mlx | ||
| generator: | ||
| model_path: FastVideo/FastMetal-5B-QAD | ||
| model_root: ./FastMetal-5B-QAD | ||
| mlx_checkpoint: ./FastMetal-5B-QAD | ||
| server: | ||
| host: 127.0.0.1 | ||
| port: 8000 | ||
| served_model_name: fastwan22-5b-mlx | ||
| output_dir: outputs/mlx_wan22_5b | ||
| default_request: | ||
| sampling: | ||
| height: 704 | ||
| width: 1280 | ||
| num_frames: 81 | ||
| fps: 24 | ||
| num_inference_steps: 3 | ||
| guidance_scale: 1.0 | ||
| seed: 1234 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """Serve native FastMetal (Wan2.1) MLX through the shared video-job API and playground.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| from concurrent.futures import ThreadPoolExecutor | ||
| from pathlib import Path | ||
| import platform | ||
| import shutil | ||
| import time | ||
| from types import SimpleNamespace | ||
| from typing import Any, Literal | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field | ||
| import uvicorn | ||
| import yaml | ||
|
|
||
| from fastvideo.api.compat import explicit_request_updates, normalize_generation_request | ||
| from fastvideo.api.schema import GenerationRequest | ||
| from fastvideo.entrypoints.openai.api_server import create_app | ||
| from fastvideo.entrypoints.openai.protocol import VideoGenerationRequest | ||
|
|
||
| # The DMD-distilled step ladder the validated recipes use (fixed count, same | ||
| # reason H3 MLX serving pins num_inference_steps to its own ladder size). | ||
| _DMD_STEP_COUNT = 3 | ||
|
|
||
| # Maps a served model id to the pipeline class that generates it: 1.3B/14B | ||
| # share Wan2.1's architecture (MLXWanPipeline), 5B is Wan2.2-TI2V instead | ||
| # (MLXWan22Pipeline, 48-channel latents, a different DiT and sampler). | ||
| _PIPELINE_CLASS_NAMES = { | ||
| "FastVideo/FastMetal-1.3B-QAD": "MLXWanPipeline", | ||
| "FastVideo/FastMetal-14B-QAD": "MLXWanPipeline", | ||
| "FastVideo/FastMetal-5B-QAD": "MLXWan22Pipeline", | ||
| } | ||
|
|
||
|
|
||
| class MLXWanGeneratorConfig(BaseModel): | ||
| """Where the two FastMetal checkpoint halves live on disk.""" | ||
| model_config = ConfigDict(extra="forbid") | ||
| model_path: Literal["FastVideo/FastMetal-1.3B-QAD", "FastVideo/FastMetal-14B-QAD", "FastVideo/FastMetal-5B-QAD"] | ||
| model_root: str | ||
| mlx_checkpoint: str | ||
|
|
||
|
|
||
| class MLXWanServerConfig(BaseModel): | ||
| """Host/port/output shape for an MLX serve YAML's ``server:`` block. | ||
|
|
||
| Generic across MLX-served models -- if a second MLX server config needs | ||
| the same shape, promote this (and its H3 counterpart) to one shared | ||
| module instead of a third copy. | ||
| """ | ||
| model_config = ConfigDict(extra="forbid") | ||
| host: str = "127.0.0.1" | ||
| port: int = Field(default=8000, ge=1, le=65535) | ||
| output_dir: str = "outputs/mlx_wan" | ||
| served_model_name: str = Field(default="fastwan", min_length=1) | ||
|
|
||
|
|
||
| class MLXWanServeConfig(BaseModel): | ||
| """Top-level ``mlx_wan_*.yaml`` shape read by --config.""" | ||
| model_config = ConfigDict(extra="forbid") | ||
| runtime: Literal["mlx"] | ||
| generator: MLXWanGeneratorConfig | ||
| server: MLXWanServerConfig = Field(default_factory=MLXWanServerConfig) | ||
| default_request: dict[str, Any] | ||
|
|
||
|
|
||
| def validate_wan_video_request(request: VideoGenerationRequest) -> None: | ||
| """Reject unsupported inputs before fetching media or creating a job.""" | ||
| allowed = { | ||
| "model", | ||
| "prompt", | ||
| "seed", | ||
| "size", | ||
| "width", | ||
| "height", | ||
| "fps", | ||
| "num_frames", | ||
| "seconds", | ||
| "video_params", | ||
| "task", | ||
| "guidance_scale", | ||
| "num_inference_steps", | ||
| } | ||
| unsupported = request.model_fields_set - allowed | ||
| if unsupported: | ||
| raise ValueError("Wan MLX serving does not support: " + ", ".join(sorted(unsupported))) | ||
| if request.task not in (None, "t2v"): | ||
| raise ValueError("Wan MLX serving supports task=t2v only.") | ||
|
Comment on lines
+89
to
+90
|
||
| if request.guidance_scale not in (None, 1.0): | ||
| raise ValueError("FastMetal MLX is DMD-distilled and requires guidance_scale=1.") | ||
| if request.num_inference_steps not in (None, _DMD_STEP_COUNT): | ||
| raise ValueError(f"Wan MLX serving uses a fixed {_DMD_STEP_COUNT}-step DMD ladder; " | ||
| f"num_inference_steps must be {_DMD_STEP_COUNT}.") | ||
| if request.seed is not None and not 0 <= request.seed <= 2**32 - 1: | ||
| raise ValueError("Wan MLX seed must be between 0 and 4294967295.") | ||
|
|
||
|
|
||
| class MLXWanGenerator: | ||
| """Keep one FastMetal pipeline on one MLX thread across requests.""" | ||
|
|
||
| def __init__(self, config: MLXWanGeneratorConfig) -> None: | ||
| self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="wan-mlx") | ||
| try: | ||
| self._pipeline = self._worker.submit(self._load, config).result() | ||
| except BaseException: | ||
| self._worker.shutdown(wait=True) | ||
| raise | ||
|
|
||
| @staticmethod | ||
| def _load(config: MLXWanGeneratorConfig): | ||
| """Load the pipeline; must run on the MLX worker thread.""" | ||
| if platform.system() != "Darwin" or platform.machine() != "arm64": | ||
| raise RuntimeError("Wan MLX serving requires an Apple Silicon Mac.") | ||
| if shutil.which("ffmpeg") is None: | ||
| raise RuntimeError("Install ffmpeg before starting the Wan MLX server.") | ||
| import fastvideo.mlx_runtime.wan_pipeline as wan_pipeline_module | ||
|
|
||
| pipeline_cls = getattr(wan_pipeline_module, _PIPELINE_CLASS_NAMES[config.model_path]) | ||
| return pipeline_cls( | ||
| model_root=Path(config.model_root).expanduser(), | ||
| mlx_checkpoint=Path(config.mlx_checkpoint).expanduser(), | ||
| ) | ||
|
|
||
| def generate(self, request: GenerationRequest) -> dict[str, Any]: | ||
| """Run one generation on the MLX worker thread; block until it finishes.""" | ||
| return self._worker.submit(self._generate, request).result() | ||
|
|
||
| def _generate(self, request: GenerationRequest) -> dict[str, Any]: | ||
| """The actual pipeline call; must run on the MLX worker thread.""" | ||
| started = time.perf_counter() | ||
| result = self._pipeline.generate( | ||
| request.prompt, | ||
| output_path=request.output.output_path, | ||
| width=request.sampling.width, | ||
| height=request.sampling.height, | ||
| num_frames=request.sampling.num_frames, | ||
| seed=request.sampling.seed, | ||
| fps=request.sampling.fps, | ||
| ) | ||
| return {"video_path": str(result.video_path), "generation_time": time.perf_counter() - started} | ||
|
|
||
| def shutdown(self) -> None: | ||
| """Release the pipeline and stop the MLX worker thread.""" | ||
|
|
||
| def release(): | ||
| self._pipeline = None | ||
| from fastvideo.mlx_runtime.memory import cleanup_mlx | ||
|
|
||
| cleanup_mlx() | ||
|
|
||
| try: | ||
| self._worker.submit(release).result() | ||
| finally: | ||
| self._worker.shutdown(wait=True) | ||
|
|
||
|
|
||
| def load_config(path: str) -> MLXWanServeConfig: | ||
| """Parse a Wan MLX serve YAML into its typed config.""" | ||
| with open(path, encoding="utf-8") as source: | ||
| return MLXWanServeConfig.model_validate(yaml.safe_load(source)) | ||
|
|
||
|
|
||
| def create_mlx_wan_app(config: MLXWanServeConfig): | ||
| """Build the FastAPI app for a validated Wan MLX serve config.""" | ||
| request = normalize_generation_request(config.default_request) | ||
| explicit = explicit_request_updates(request) | ||
| supported = {"width", "height", "num_frames", "fps", "seed", "num_inference_steps", "guidance_scale"} | ||
| if set(explicit) - supported: | ||
| raise ValueError("Wan MLX default_request contains unsupported fields: " + | ||
| ", ".join(sorted(set(explicit) - supported))) | ||
| required = {"width", "height", "num_frames", "fps"} | ||
| if required - set(explicit): | ||
| raise ValueError("Wan MLX default_request must set: " + ", ".join(sorted(required - set(explicit)))) | ||
| validate_wan_video_request(VideoGenerationRequest(prompt="validate config", **explicit)) | ||
| # Transport admission uses the registered Wan family, not CUDA engine options. | ||
| args = SimpleNamespace(model_path=config.generator.model_path, | ||
| lora_path=None, | ||
| lora_nickname="default", | ||
| lora_strength=1.0, | ||
| override_pipeline_cls_name=None) | ||
| from fastvideo.entrypoints.openai.request_adapter import build_generation_request | ||
|
|
||
| build_generation_request("config-check", | ||
| VideoGenerationRequest(prompt="validate config"), | ||
| args, | ||
| served_model_name=config.server.served_model_name, | ||
| output_dir=config.server.output_dir, | ||
| default_request=request) | ||
| return create_app( | ||
| args, | ||
| config.server.output_dir, | ||
| request, | ||
| config.server.served_model_name, | ||
| generator_factory=lambda: MLXWanGenerator(config.generator), | ||
| video_request_validator=validate_wan_video_request, | ||
| runtime="mlx", | ||
| ) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| """CLI entrypoint: python -m fastvideo.entrypoints.openai.mlx_wan_server --config ...""" | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--config", | ||
| required=True, | ||
| help="Wan MLX serving YAML; paths are relative to the working directory") | ||
| args = parser.parse_args() | ||
| config = load_config(args.config) | ||
| uvicorn.run(create_mlx_wan_app(config), host=config.server.host, port=config.server.port) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Align or reject
secondsbefore admitting the job. The shared adapter turns an explicitsecondsintonum_frames = seconds * fps; with both shipped defaults (16 and 24 fps), that is always 0 mod 4, whileplan_refine_resolutionsrequires Wan frames to be 1 mod 4. A normal OpenAI-style request therefore gets queued and fails only inside generation. Please validate the merged request shape synchronously and either map duration to the nearest valid frame grid (for exampleseconds * fps + 1) or do not advertisesecondsfor this runtime.